diff --git a/.env.example b/.env.example index bc7774a..6da3873 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ DATABASE_URL=postgresql://admin:devpass@localhost:5432/provider_platform_db NETWORK=testnet NETWORK_FEE=1000000000 # stroops # STELLAR_RPC_URL= # override per-network default (see README) +# BASE_RESERVE_STROOPS=5000000 # stroops; min-balance unit for fee-payer reserves check. Override on protocol upgrade. TRANSACTION_EXPIRATION_OFFSET=1000 # ledger sequences; ~83min on testnet # SERVICE diff --git a/deno.json b/deno.json index 62abb3f..1e36d3f 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/provider-platform", - "version": "0.7.1", + "version": "0.7.2", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/config/env.ts b/src/config/env.ts index 28a28b4..6ad3362 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,6 +21,23 @@ export const SESSION_TTL = Number(requireEnv("SESSION_TTL")); export const { NETWORK_CONFIG, NETWORK } = selectNetwork(requireEnv("NETWORK")); export const NETWORK_FEE = requireBaseFee("NETWORK_FEE"); +// Stellar account minimum reserve unit, in stroops. The on-chain minimum +// balance for an account is `(2 + numSubEntries) * BASE_RESERVE_STROOPS`. +// Soroban RPC does not expose base_reserve (it is a stellar-core protocol +// constant, not a ConfigSettingEntry), so we read it from env. Override per +// network if a protocol upgrade changes it. +const _rawBaseReserve = loadOptionalEnv("BASE_RESERVE_STROOPS") ?? "5000000"; +const _parsedBaseReserve = Number(_rawBaseReserve); +if ( + !Number.isFinite(_parsedBaseReserve) || + !Number.isInteger(_parsedBaseReserve) || _parsedBaseReserve < 0 +) { + throw new Error( + `BASE_RESERVE_STROOPS must be a non-negative integer, got: "${_rawBaseReserve}"`, + ); +} +export const BASE_RESERVE_STROOPS = BigInt(_parsedBaseReserve); + export const NETWORK_RPC_SERVER = new Server( NETWORK_CONFIG.rpcUrl as string, { allowHttp: NETWORK_CONFIG.allowHttp }, diff --git a/src/core/service/bundle/bundle.service.ts b/src/core/service/bundle/bundle.service.ts index d8de150..46f33f1 100644 --- a/src/core/service/bundle/bundle.service.ts +++ b/src/core/service/bundle/bundle.service.ts @@ -218,6 +218,7 @@ export type BundleDTO = { fee: string; createdAt: string; updatedAt: string | null; + failureDetail: Record | null; }; /** @@ -235,6 +236,7 @@ export function toBundleDTO(bundle: OperationsBundle): BundleDTO { fee: bundle.fee.toString(), createdAt: bundle.createdAt.toISOString(), updatedAt: bundle.updatedAt ? bundle.updatedAt.toISOString() : null, + failureDetail: bundle.failureDetail ?? null, }; } diff --git a/src/core/service/executor/executor.errors.ts b/src/core/service/executor/executor.errors.ts index 7371177..98f536d 100644 --- a/src/core/service/executor/executor.errors.ts +++ b/src/core/service/executor/executor.errors.ts @@ -5,8 +5,20 @@ export enum EXECUTOR_ERROR_CODES { TRANSACTION_SUBMIT_FAILED = "EXC_002", INSUFFICIENT_UTXOS = "EXC_003", SLOT_EMPTY = "EXC_004", + INSUFFICIENT_FEES = "EXC_005", } +/** + * Structured detail attached to InsufficientFees and persisted on the bundle + * record as `failure_detail`. All XLM amounts are stroop strings (int64). + */ +export type InsufficientFeesDetail = { + feePayerPubkey: string; + availableXlm: string; + requiredXlm: string; + shortfallXlm: string; +}; + const source = "@service/executor"; /** @@ -58,6 +70,29 @@ export class INSUFFICIENT_UTXOS } } +/** + * Pre-flight terminal failure: the fee-paying account cannot cover the + * simulated tx fee after subtracting Stellar minimum reserves. Thrown by the + * pre-flight check before any signing or submission attempt. The submit + * orchestration catches this specifically and moves the bundle straight to + * BundleStatus.FAILED (no retry counter, no mempool retention). + */ +export class InsufficientFees extends PlatformError { + readonly detail: InsufficientFeesDetail; + + constructor(detail: InsufficientFeesDetail) { + super({ + source, + code: EXECUTOR_ERROR_CODES.INSUFFICIENT_FEES, + message: "Insufficient fees on fee-payer account", + details: + `Fee payer ${detail.feePayerPubkey} has ${detail.availableXlm} stroops available after reserves; required ${detail.requiredXlm} (shortfall ${detail.shortfallXlm}).`, + meta: detail, + }); + this.detail = detail; + } +} + /** * Error thrown when trying to execute an empty slot */ diff --git a/src/core/service/executor/executor.process.ts b/src/core/service/executor/executor.process.ts index 17c618d..9b7c868 100644 --- a/src/core/service/executor/executor.process.ts +++ b/src/core/service/executor/executor.process.ts @@ -1,13 +1,21 @@ import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; +import { + BundleStatus, +} from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { TransactionStatus } from "@/persistence/drizzle/entity/transaction.entity.ts"; import { getMempool } from "@/core/mempool/index.ts"; import { + BASE_RESERVE_STROOPS, MEMPOOL_EXECUTOR_INTERVAL_MS, MEMPOOL_MAX_RETRY_ATTEMPTS, + NETWORK_CONFIG, + NETWORK_FEE, NETWORK_RPC_SERVER, TRANSACTION_EXPIRATION_OFFSET, } from "@/config/env.ts"; +import { InsufficientFees } from "@/core/service/executor/executor.errors.ts"; +import { runPreflightOpexFeeCheck } from "@/core/service/executor/preflight-opex-balance.ts"; import { resolveChannelContext } from "@/core/service/executor/channel-resolver.ts"; import { ChannelInvokeMethods } from "@moonlight/moonlight-sdk"; import type { SIM_ERRORS } from "@colibri/core"; @@ -379,6 +387,22 @@ export class Executor { // Get transaction expiration const expiration = await getTransactionExpiration(); + // Pre-flight OpEx fee check. Throws InsufficientFees if the PP root + // account cannot cover (inclusion + Soroban resource fee) after + // subtracting Stellar minimum reserves. The submit-orchestration + // catch block routes InsufficientFees to a terminal-FAILED bypass + // (no retry counter, no mempool retention). + await runPreflightOpexFeeCheck( + { txBuilder, feePayerPubkey: ppPublicKey }, + { + rpcServer: NETWORK_RPC_SERVER, + networkPassphrase: NETWORK_CONFIG.networkPassphrase as string, + baseInclusionFeeStroops: BigInt(NETWORK_FEE), + baseReserveStroops: BASE_RESERVE_STROOPS, + log: this.log, + }, + ); + // Submit transaction to network const transactionHash = await submitTransactionToNetwork( txBuilder, @@ -410,6 +434,49 @@ export class Executor { }, }), { log: this.log }); } catch (error) { + // Typed-error fast-path: pre-flight detected an under-funded fee + // payer. Terminal-fail every bundle in the slot with the structured + // detail; DO NOT increment retry counters; DO NOT re-enqueue. + if (error instanceof InsufficientFees) { + span.addEvent("preflight_insufficient_fees_terminal", { + bundleIds, + }); + this.log.error(error, "pre-flight InsufficientFees — terminal-fail"); + for (const bundleId of bundleIds) { + try { + await operationsBundleRepository.update(bundleId, { + status: BundleStatus.FAILED, + lastFailureReason: error.message, + failureDetail: { ...error.detail }, + updatedAt: new Date(), + }); + } catch (updateError) { + span.addEvent("insufficient_fees_persist_failed", { + "bundle.id": bundleId, + }); + this.log.error( + updateError, + "failed to mark bundle FAILED on InsufficientFees", + ); + } + } + const failedChannelContractId = slot?.getBundles()[0] + ?.channelContractId ?? null; + if (failedChannelContractId) { + await emitForBundles(bundleIds, (scope) => ({ + kind: "executor.execution_failed", + ts: Date.now(), + scope, + payload: { + bundleIds, + channelContractId: failedChannelContractId, + reason: error.message, + }, + }), { log: this.log }); + } + return; + } + const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/core/service/executor/preflight-opex-balance.ts b/src/core/service/executor/preflight-opex-balance.ts new file mode 100644 index 0000000..3212da1 --- /dev/null +++ b/src/core/service/executor/preflight-opex-balance.ts @@ -0,0 +1,257 @@ +/** + * Pre-flight OpEx fee check. + * + * Runs before bundle submission: simulates the Soroban tx the executor is + * about to submit, then checks the PP root account (the Stellar fee payer) + * has enough XLM to cover (base inclusion fee + min Soroban resource fee) + * after subtracting the minimum reserve. If not, throws + * `InsufficientFees` with structured detail — the submit-orchestration + * layer catches that specifically and moves the bundle to terminal-FAILED + * without entering the retry loop. + * + * Why this lives outside `submitTransactionToNetwork`: the check needs the + * un-signed transaction to simulate (Soroban sim does not require auth + * entries), so it is naturally a step that runs after build but before + * sign+submit. + */ +import type { MoonlightTransactionBuilder } from "@moonlight/moonlight-sdk"; +import { + Account as StellarAccount, + Keypair, + Operation, + TransactionBuilder, + xdr, +} from "stellar-sdk"; +import { Api as RpcApi, type Server } from "stellar-sdk/rpc"; +import { withSpan } from "@/core/tracing.ts"; +import { + InsufficientFees, + type InsufficientFeesDetail, +} from "@/core/service/executor/executor.errors.ts"; +import type { Logger } from "@/utils/logger/index.ts"; + +export interface PreflightOpexDeps { + rpcServer: Pick; + networkPassphrase: string; + /** Base inclusion fee, in stroops, applied to the outer transaction. */ + baseInclusionFeeStroops: bigint; + /** `(2 + numSubEntries) * BASE_RESERVE_STROOPS` reserve unit, in stroops. */ + baseReserveStroops: bigint; + log: Logger; +} + +/** Output of the math, useful for tests and for the failure detail payload. */ +export type PreflightResult = { + feePayerPubkey: string; + availableXlmStroops: bigint; + requiredXlmStroops: bigint; + /** Negative when sufficient; positive when shortfall. */ + shortfallStroops: bigint; +}; + +/** + * Compute `available = balance - (2 + numSubEntries) * baseReserve` + * and `required = baseInclusionFee + minResourceFee`, returning the + * shortfall (positive when under-funded). + * + * Pure function — broken out so the math is unit-testable without RPC mocks. + */ +export function computePreflightResult(args: { + feePayerPubkey: string; + balanceStroops: bigint; + numSubEntries: bigint; + baseReserveStroops: bigint; + baseInclusionFeeStroops: bigint; + minResourceFeeStroops: bigint; +}): PreflightResult { + const reserveStroops = (BigInt(2) + args.numSubEntries) * + args.baseReserveStroops; + const availableXlmStroops = args.balanceStroops - reserveStroops; + const requiredXlmStroops = args.baseInclusionFeeStroops + + args.minResourceFeeStroops; + const shortfallStroops = requiredXlmStroops - availableXlmStroops; + return { + feePayerPubkey: args.feePayerPubkey, + availableXlmStroops, + requiredXlmStroops, + shortfallStroops, + }; +} + +/** Encode a PreflightResult into the persisted/serialised detail shape. */ +export function toInsufficientFeesDetail( + result: PreflightResult, +): InsufficientFeesDetail { + return { + feePayerPubkey: result.feePayerPubkey, + availableXlm: result.availableXlmStroops.toString(), + requiredXlm: result.requiredXlmStroops.toString(), + shortfallXlm: result.shortfallStroops.toString(), + }; +} + +function readAccountEntry( + entries: ReadonlyArray<{ val: xdr.LedgerEntryData }>, +): { balanceStroops: bigint; numSubEntries: bigint } | null { + for (const e of entries) { + if (e.val.switch().name !== "account") continue; + const acct = e.val.account(); + return { + balanceStroops: BigInt(acct.balance().toString()), + numSubEntries: BigInt(acct.numSubEntries()), + }; + } + return null; +} + +/** + * Fetches the fee-payer's XLM balance and subentry count via Soroban RPC's + * `getLedgerEntries`. Returns `null` if the account is missing entirely + * (not yet funded), in which case the caller treats it as zero balance. + */ +export async function fetchFeePayerAccountState( + feePayerPubkey: string, + rpcServer: Pick, +): Promise<{ balanceStroops: bigint; numSubEntries: bigint } | null> { + const accountKey = xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(feePayerPubkey).xdrAccountId(), + }), + ); + const result = await rpcServer.getLedgerEntries(accountKey); + if (!result.entries || result.entries.length === 0) return null; + return readAccountEntry(result.entries); +} + +/** + * Simulates the channel-invoke transaction the executor is about to submit, + * extracting `minResourceFee` (the Soroban resource portion). The simulation + * is run with an un-signed contract-call operation; Soroban does not require + * auth entries for fee estimation. + */ +export async function simulateBundleResourceFee(args: { + txBuilder: MoonlightTransactionBuilder; + feePayerPubkey: string; + feePayerSequence: bigint; + networkPassphrase: string; + baseInclusionFeeStroops: bigint; + rpcServer: Pick; +}): Promise { + const sourceAccount = new StellarAccount( + args.feePayerPubkey, + args.feePayerSequence.toString(), + ); + + const invokeOp = Operation.invokeContractFunction({ + contract: args.txBuilder.getChannelId(), + function: "transact", + args: [args.txBuilder.buildXDR()], + auth: [], + }); + + const tx = new TransactionBuilder(sourceAccount, { + fee: args.baseInclusionFeeStroops.toString(), + networkPassphrase: args.networkPassphrase, + }) + .addOperation(invokeOp) + .setTimeout(30) + .build(); + + const sim = await args.rpcServer.simulateTransaction(tx); + if (RpcApi.isSimulationError(sim)) { + throw new Error(`simulateTransaction returned error: ${sim.error}`); + } + if (!("minResourceFee" in sim) || !sim.minResourceFee) { + throw new Error("simulateTransaction did not return minResourceFee"); + } + return BigInt(sim.minResourceFee); +} + +/** + * End-to-end pre-flight check. Throws `InsufficientFees` if the fee payer + * cannot cover (inclusion + Soroban-resource) fee after reserves. + */ +export async function runPreflightOpexFeeCheck( + args: { + txBuilder: MoonlightTransactionBuilder; + feePayerPubkey: string; + }, + deps: PreflightOpexDeps, +): Promise { + return await withSpan("Executor.preflightOpexFeeCheck", async (span) => { + const log = deps.log.scope("preflightOpexFeeCheck"); + span.setAttribute("fee_payer.pubkey", args.feePayerPubkey); + log.event("fetching fee-payer account state"); + + const accountState = await fetchFeePayerAccountState( + args.feePayerPubkey, + deps.rpcServer, + ); + + const balanceStroops = accountState?.balanceStroops ?? BigInt(0); + const numSubEntries = accountState?.numSubEntries ?? BigInt(0); + span.setAttribute("fee_payer.balance_stroops", balanceStroops.toString()); + span.setAttribute("fee_payer.num_sub_entries", numSubEntries.toString()); + + log.event("simulating transaction for resource-fee estimate"); + const sourceAcct = await deps.rpcServer.getLedgerEntries( + xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(args.feePayerPubkey).xdrAccountId(), + }), + ), + ); + // Sequence is irrelevant for sim correctness; use 0 if the account is + // missing or its sequence cannot be read. simulateTransaction does not + // execute the tx — it returns resource estimates only. + let feePayerSequence = BigInt(0); + if (sourceAcct.entries && sourceAcct.entries[0]) { + const accountEntry = sourceAcct.entries[0].val.account(); + feePayerSequence = BigInt(accountEntry.seqNum().toString()); + } + + const minResourceFeeStroops = await simulateBundleResourceFee({ + txBuilder: args.txBuilder, + feePayerPubkey: args.feePayerPubkey, + feePayerSequence, + networkPassphrase: deps.networkPassphrase, + baseInclusionFeeStroops: deps.baseInclusionFeeStroops, + rpcServer: deps.rpcServer, + }); + span.setAttribute( + "fee_payer.min_resource_fee_stroops", + minResourceFeeStroops.toString(), + ); + + const result = computePreflightResult({ + feePayerPubkey: args.feePayerPubkey, + balanceStroops, + numSubEntries, + baseReserveStroops: deps.baseReserveStroops, + baseInclusionFeeStroops: deps.baseInclusionFeeStroops, + minResourceFeeStroops, + }); + span.setAttribute( + "fee_payer.required_stroops", + result.requiredXlmStroops.toString(), + ); + span.setAttribute( + "fee_payer.available_stroops", + result.availableXlmStroops.toString(), + ); + span.setAttribute( + "fee_payer.shortfall_stroops", + result.shortfallStroops.toString(), + ); + + if (result.shortfallStroops > BigInt(0)) { + span.addEvent("preflight_insufficient_fees"); + log.event("pre-flight check failed: insufficient fees"); + throw new InsufficientFees(toInsufficientFeesDetail(result)); + } + + span.addEvent("preflight_ok"); + log.event("pre-flight check passed"); + return result; + }); +} diff --git a/src/http/v1/bundle/get.ts b/src/http/v1/bundle/get.ts index 8703e81..f5e7634 100644 --- a/src/http/v1/bundle/get.ts +++ b/src/http/v1/bundle/get.ts @@ -17,6 +17,10 @@ export const responseSchema = z.object({ fee: z.string(), createdAt: z.string(), updatedAt: z.string().nullable(), + // Structured-reason payload for terminal failures (e.g. InsufficientFees + // carries { feePayerPubkey, availableXlm, requiredXlm, shortfallXlm }). + // null on success states and for legacy free-form failures. + failureDetail: z.record(z.string(), z.unknown()).nullable(), }); export type BundleGetProcessOutput = { diff --git a/src/persistence/drizzle/entity/operations-bundle.entity.ts b/src/persistence/drizzle/entity/operations-bundle.entity.ts index 11d2638..6ce0c08 100644 --- a/src/persistence/drizzle/entity/operations-bundle.entity.ts +++ b/src/persistence/drizzle/entity/operations-bundle.entity.ts @@ -37,6 +37,7 @@ export const operationsBundle = pgTable("operations_bundles", { fee: bigint("fee", { mode: "bigint" }).notNull(), retryCount: integer("retry_count").notNull().default(0), lastFailureReason: text("last_failure_reason"), + failureDetail: jsonb("failure_detail").$type>(), ppPublicKey: text("pp_public_key"), ...createBaseColumns(), }, (table) => [ diff --git a/src/persistence/drizzle/migration/0018_add_failure_detail_to_bundles.sql b/src/persistence/drizzle/migration/0018_add_failure_detail_to_bundles.sql new file mode 100644 index 0000000..16fbfec --- /dev/null +++ b/src/persistence/drizzle/migration/0018_add_failure_detail_to_bundles.sql @@ -0,0 +1 @@ +ALTER TABLE "operations_bundles" ADD COLUMN IF NOT EXISTS "failure_detail" jsonb; diff --git a/src/persistence/drizzle/migration/meta/_journal.json b/src/persistence/drizzle/migration/meta/_journal.json index 1e64efe..a23ab4b 100644 --- a/src/persistence/drizzle/migration/meta/_journal.json +++ b/src/persistence/drizzle/migration/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1780300000000, "tag": "0017_pp_aware_entity_status_and_kyc_url", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1780531200000, + "tag": "0018_add_failure_detail_to_bundles", + "breakpoints": true } ] } diff --git a/tests/integration/http/waitlist.test.ts b/tests/integration/http/waitlist.test.ts index ca9778a..c2117ab 100644 --- a/tests/integration/http/waitlist.test.ts +++ b/tests/integration/http/waitlist.test.ts @@ -5,18 +5,20 @@ import { assertEquals } from "@std/assert"; import { ensureInitialized, getTestDb, resetDb } from "../../test_helpers.ts"; import { waitlistRequest } from "@/persistence/drizzle/entity/index.ts"; import { WaitlistRequestRepository } from "@/persistence/drizzle/repository/waitlist-request.repository.ts"; +import { newNoop } from "@/utils/logger/index.ts"; const WAITLIST_PATH = "http://localhost/api/v1/waitlist"; // We import the route module *after* PGlite is wired up (tests/deno.json // remaps @/persistence/drizzle/config.ts to our pglite_db.ts). -const { default: waitlistRouter, setWaitlistRepoForTests } = await import( +const { buildWaitlistRouter, setWaitlistRepoForTests } = await import( "@/http/v1/waitlist/routes.ts" ); function createTestApp(): Application { const app = new Application(); const router = new Router(); + const waitlistRouter = buildWaitlistRouter({ log: newNoop() }); router.use( "/api/v1", waitlistRouter.routes(), diff --git a/tests/integration/service/executor-preflight-opex-fees.test.ts b/tests/integration/service/executor-preflight-opex-fees.test.ts new file mode 100644 index 0000000..e7a6862 --- /dev/null +++ b/tests/integration/service/executor-preflight-opex-fees.test.ts @@ -0,0 +1,325 @@ +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + ensureInitialized, + getBundleRepo, + resetDb, + seedBundle, + testBundleId, +} from "../../test_helpers.ts"; +import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; +import { + InsufficientFees, + type InsufficientFeesDetail, +} from "@/core/service/executor/executor.errors.ts"; +import { runPreflightOpexFeeCheck } from "@/core/service/executor/preflight-opex-balance.ts"; +import { toBundleDTO } from "@/core/service/bundle/bundle.service.ts"; +import { responseSchema as bundleGetResponseSchema } from "@/http/v1/bundle/get.ts"; +import { Keypair, Networks, StrKey, xdr } from "stellar-sdk"; +import { Buffer } from "buffer"; +import type { MoonlightTransactionBuilder } from "@moonlight/moonlight-sdk"; + +const STUB_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 0x01)); + +const STUB_LOG = { + scope: () => STUB_LOG, + info: () => {}, + debug: () => {}, + event: () => {}, + error: () => {}, +} as unknown as Parameters[1]["log"]; + +// --------------------------------------------------------------------------- +// Helpers — RPC + txBuilder doubles +// --------------------------------------------------------------------------- + +/** Returns a stub Soroban-RPC server that yields a fixed account balance, + * subentry count, and `minResourceFee` from simulateTransaction. */ +function makeStubRpc(opts: { + balanceStroops: bigint; + numSubEntries: bigint; + minResourceFee: string; + /** When true, getLedgerEntries returns no entries (account not funded). */ + accountMissing?: boolean; +}) { + const accountEntry = xdr.LedgerEntryData.account( + new xdr.AccountEntry({ + accountId: Keypair.random().xdrAccountId(), + balance: xdr.Int64.fromString(opts.balanceStroops.toString()), + seqNum: xdr.SequenceNumber.fromString("1"), + numSubEntries: Number(opts.numSubEntries), + inflationDest: null, + flags: 0, + homeDomain: "", + thresholds: Buffer.from([1, 0, 0, 0]), + signers: [], + ext: new xdr.AccountEntryExt(0), + }), + ); + + return { + getLedgerEntries: (_key: xdr.LedgerKey) => { + if (opts.accountMissing) { + return Promise.resolve({ latestLedger: 1, entries: [] }); + } + return Promise.resolve({ + latestLedger: 1, + entries: [ + { + lastModifiedLedgerSeq: 1, + key: _key, + val: accountEntry, + }, + ], + }); + }, + simulateTransaction: (_tx: unknown) => + Promise.resolve({ + minResourceFee: opts.minResourceFee, + transactionData: "", + events: [], + results: [], + cost: { cpuInsns: "0", memBytes: "0" }, + latestLedger: 1, + }), + } as unknown as Parameters[1]["rpcServer"]; +} + +/** A minimal `MoonlightTransactionBuilder` shim covering only the two + * methods `runPreflightOpexFeeCheck` calls. */ +function makeStubTxBuilder( + channelContractId: string, +): MoonlightTransactionBuilder { + return { + getChannelId: () => channelContractId, + buildXDR: () => xdr.ScVal.scvMap([]), + } as unknown as MoonlightTransactionBuilder; +} + +const FEE_PAYER_PUBKEY = Keypair.random().publicKey(); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +Deno.test( + "preflight — under-funded fee payer: throws InsufficientFees with all four structured fields", + async () => { + await ensureInitialized(); + + const rpc = makeStubRpc({ + balanceStroops: BigInt(10_000_300), // 1.0000300 XLM + numSubEntries: BigInt(2), // reserves = (2+2) * 5M = 20M + minResourceFee: "1000", + }); + const txBuilder = makeStubTxBuilder( + STUB_CONTRACT_ID, + ); + + const err = await assertRejects( + () => + runPreflightOpexFeeCheck( + { txBuilder, feePayerPubkey: FEE_PAYER_PUBKEY }, + { + rpcServer: rpc, + networkPassphrase: Networks.TESTNET, + baseInclusionFeeStroops: BigInt(100), + baseReserveStroops: BigInt(5_000_000), + log: STUB_LOG, + }, + ), + InsufficientFees, + ); + + const detail = (err as InsufficientFees).detail; + assertEquals(detail.feePayerPubkey, FEE_PAYER_PUBKEY); + // available = 10_000_300 - 20_000_000 = -9_999_700 + assertEquals(detail.availableXlm, "-9999700"); + // required = 100 + 1000 = 1100 + assertEquals(detail.requiredXlm, "1100"); + // shortfall = 1100 - (-9_999_700) = 10_000_800 + assertEquals(detail.shortfallXlm, "10000800"); + }, +); + +Deno.test( + "preflight — sufficient balance: returns the result without throwing", + async () => { + await ensureInitialized(); + + // balance = 100 XLM, 0 subentries → reserves = 10M, available = 990M + // required = 100 + 1000 = 1100 → shortfall = -989999900 (huge surplus) + const rpc = makeStubRpc({ + balanceStroops: BigInt(1_000_000_000), + numSubEntries: BigInt(0), + minResourceFee: "1000", + }); + const txBuilder = makeStubTxBuilder( + STUB_CONTRACT_ID, + ); + + const result = await runPreflightOpexFeeCheck( + { txBuilder, feePayerPubkey: FEE_PAYER_PUBKEY }, + { + rpcServer: rpc, + networkPassphrase: Networks.TESTNET, + baseInclusionFeeStroops: BigInt(100), + baseReserveStroops: BigInt(5_000_000), + log: STUB_LOG, + }, + ); + assertEquals(result.shortfallStroops < BigInt(0), true); + }, +); + +Deno.test( + "preflight — unfunded fee payer (account missing): treated as zero balance, throws InsufficientFees", + async () => { + await ensureInitialized(); + + const rpc = makeStubRpc({ + balanceStroops: BigInt(0), + numSubEntries: BigInt(0), + minResourceFee: "1000", + accountMissing: true, + }); + const txBuilder = makeStubTxBuilder( + STUB_CONTRACT_ID, + ); + + await assertRejects( + () => + runPreflightOpexFeeCheck( + { txBuilder, feePayerPubkey: FEE_PAYER_PUBKEY }, + { + rpcServer: rpc, + networkPassphrase: Networks.TESTNET, + baseInclusionFeeStroops: BigInt(100), + baseReserveStroops: BigInt(5_000_000), + log: STUB_LOG, + }, + ), + InsufficientFees, + ); + }, +); + +Deno.test( + "catch-site terminal-fail — persists FAILED with structured detail and does NOT increment retryCount", + async () => { + await ensureInitialized(); + await resetDb(); + const repo = getBundleRepo(); + const bundleId = testBundleId(); + await seedBundle({ + id: bundleId, + retryCount: 2, // already retried twice; pre-flight must NOT increment + status: BundleStatus.PROCESSING, + }); + + // Construct the typed error as the pre-flight would + const detail: InsufficientFeesDetail = { + feePayerPubkey: FEE_PAYER_PUBKEY, + availableXlm: "-9999700", + requiredXlm: "1100", + shortfallXlm: "10000800", + }; + const error = new InsufficientFees(detail); + + // Mimic the executor catch-site fast-path (executor.process.ts) exactly: + // status=FAILED, persist failureDetail, keep retryCount unchanged, do NOT + // pass through handleExecutionFailure. + await repo.update(bundleId, { + status: BundleStatus.FAILED, + lastFailureReason: error.message, + failureDetail: { ...error.detail }, + updatedAt: new Date(), + }); + + const reloaded = await repo.findById(bundleId); + assertExists(reloaded); + assertEquals(reloaded.status, BundleStatus.FAILED); + assertEquals(reloaded.retryCount, 2, "retryCount must NOT be incremented"); + assertExists(reloaded.failureDetail); + assertEquals( + (reloaded.failureDetail as InsufficientFeesDetail).feePayerPubkey, + FEE_PAYER_PUBKEY, + ); + assertEquals( + (reloaded.failureDetail as InsufficientFeesDetail).availableXlm, + "-9999700", + ); + assertEquals( + (reloaded.failureDetail as InsufficientFeesDetail).requiredXlm, + "1100", + ); + assertEquals( + (reloaded.failureDetail as InsufficientFeesDetail).shortfallXlm, + "10000800", + ); + }, +); + +Deno.test( + "bundle-status API surfacing — DTO carries failureDetail and parses against responseSchema", + async () => { + await ensureInitialized(); + await resetDb(); + const repo = getBundleRepo(); + const bundleId = testBundleId(); + await seedBundle({ id: bundleId, status: BundleStatus.PROCESSING }); + + const detail: InsufficientFeesDetail = { + feePayerPubkey: FEE_PAYER_PUBKEY, + availableXlm: "0", + requiredXlm: "1100", + shortfallXlm: "1100", + }; + await repo.update(bundleId, { + status: BundleStatus.FAILED, + lastFailureReason: "Insufficient fees on fee-payer account", + failureDetail: { ...detail }, + updatedAt: new Date(), + }); + + const persisted = await repo.findById(bundleId); + assertExists(persisted); + const dto = toBundleDTO(persisted); + + // The new field is on the DTO and the existing API response schema accepts it. + const parsed = bundleGetResponseSchema.parse(dto); + assertExists(parsed.failureDetail); + assertEquals( + (parsed.failureDetail as InsufficientFeesDetail).feePayerPubkey, + FEE_PAYER_PUBKEY, + ); + assertEquals( + (parsed.failureDetail as InsufficientFeesDetail).availableXlm, + "0", + ); + assertEquals( + (parsed.failureDetail as InsufficientFeesDetail).requiredXlm, + "1100", + ); + assertEquals( + (parsed.failureDetail as InsufficientFeesDetail).shortfallXlm, + "1100", + ); + }, +); + +Deno.test( + "bundle-status API surfacing — failureDetail is null for non-failed bundles (back-compat)", + async () => { + await ensureInitialized(); + await resetDb(); + const repo = getBundleRepo(); + const bundleId = testBundleId(); + await seedBundle({ id: bundleId, status: BundleStatus.PENDING }); + const bundle = await repo.findById(bundleId); + assertExists(bundle); + const dto = toBundleDTO(bundle); + assertEquals(dto.failureDetail, null); + const parsed = bundleGetResponseSchema.parse(dto); + assertEquals(parsed.failureDetail, null); + }, +); diff --git a/tests/integration/service/executor-retry.test.ts b/tests/integration/service/executor-retry.test.ts index d1e077a..66792b2 100644 --- a/tests/integration/service/executor-retry.test.ts +++ b/tests/integration/service/executor-retry.test.ts @@ -8,6 +8,7 @@ import { } from "../../test_helpers.ts"; import { handleExecutionFailure } from "@/core/service/executor/executor-failure.helpers.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; +import { newNoop } from "@/utils/logger/index.ts"; const MAX_RETRY = 3; @@ -54,6 +55,7 @@ Deno.test( const retryMeta = await handleExecutionFailure(error, [id], reason, { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); // The bundle should be returned for retry @@ -83,6 +85,7 @@ Deno.test( const retryMeta = await handleExecutionFailure(error, [id], reason, { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); // Should NOT be returned for retry @@ -128,7 +131,11 @@ Deno.test( error, [eligibleId, deadLetterId], reason, - { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY }, + { + operationsBundleRepository: repo, + maxRetryAttempts: MAX_RETRY, + log: newNoop(), + }, ); assertEquals(retryMeta.length, 1); @@ -162,6 +169,7 @@ Deno.test( await handleExecutionFailure(error, [id], reason, { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); const found = await repo.findById(id); @@ -191,6 +199,7 @@ Deno.test( const meta = await handleExecutionFailure(error, [id], reason, { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); assertEquals(meta.length, 1); @@ -215,6 +224,7 @@ Deno.test( const meta = await handleExecutionFailure(error, [missingId], reason, { operationsBundleRepository: repo, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); assertEquals(meta.length, 0); diff --git a/tests/integration/service/verifier-retry.test.ts b/tests/integration/service/verifier-retry.test.ts index 8d49959..ce97c9b 100644 --- a/tests/integration/service/verifier-retry.test.ts +++ b/tests/integration/service/verifier-retry.test.ts @@ -15,6 +15,7 @@ import type { SlotBundle } from "@/core/service/bundle/bundle.types.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { eq } from "drizzle-orm"; import { transaction } from "@/persistence/drizzle/entity/index.ts"; +import { newNoop } from "@/utils/logger/index.ts"; const MAX_RETRY = 3; @@ -121,6 +122,7 @@ Deno.test( reAddedBundles.push(...bundles); }, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }, ); @@ -163,6 +165,7 @@ Deno.test( reAddedBundles.push(...bundles); }, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); // Transaction marked FAILED @@ -213,6 +216,7 @@ Deno.test( reAddedBundles.push(...bundles); }, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }, ); @@ -248,6 +252,7 @@ Deno.test( createSlotBundleFn: mockCreateSlotBundle, reAddBundlesFn: async () => {}, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); const found = await repo.findById(bundleId); @@ -293,6 +298,7 @@ Deno.test( reAddCalled = true; }, maxRetryAttempts: MAX_RETRY, + log: newNoop(), }); assertEquals(reAddCalled, false); diff --git a/tests/unit/preflight-opex-balance.test.ts b/tests/unit/preflight-opex-balance.test.ts new file mode 100644 index 0000000..b63a207 --- /dev/null +++ b/tests/unit/preflight-opex-balance.test.ts @@ -0,0 +1,118 @@ +import { assertEquals, assertExists, assertThrows } from "@std/assert"; +import { + computePreflightResult, + type PreflightResult, + toInsufficientFeesDetail, +} from "@/core/service/executor/preflight-opex-balance.ts"; +import { InsufficientFees } from "@/core/service/executor/executor.errors.ts"; + +const PUBKEY = "GAAAA000000000000000000000000000000000000000000000000000"; +const BASE_RESERVE = BigInt(5_000_000); // 0.5 XLM in stroops +const BASE_INCLUSION = BigInt(100); // tx-level inclusion fee +const MIN_RESOURCE = BigInt(200); // soroban resource fee + +function compute( + balance: bigint, + subentries: bigint, +): PreflightResult { + return computePreflightResult({ + feePayerPubkey: PUBKEY, + balanceStroops: balance, + numSubEntries: subentries, + baseReserveStroops: BASE_RESERVE, + baseInclusionFeeStroops: BASE_INCLUSION, + minResourceFeeStroops: MIN_RESOURCE, + }); +} + +Deno.test("preflight math — sufficient funds: shortfall is negative", () => { + // 2 + 0 subentries × 5M stroops = 10M reserves + // required = 100 + 200 = 300 + // available = 100_000_000 - 10_000_000 = 90_000_000 + // shortfall = 300 - 90_000_000 = -89_999_700 + const r = compute(BigInt(100_000_000), BigInt(0)); + assertEquals(r.feePayerPubkey, PUBKEY); + assertEquals(r.availableXlmStroops, BigInt(90_000_000)); + assertEquals(r.requiredXlmStroops, BigInt(300)); + assertEquals(r.shortfallStroops, BigInt(-89_999_700)); +}); + +Deno.test("preflight math — exact match: shortfall is zero", () => { + // 2 subentries → 4 × 5M = 20M reserves + // available = 20_000_300 - 20_000_000 = 300 + // required = 300 + // shortfall = 0 + const r = compute(BigInt(20_000_300), BigInt(2)); + assertEquals(r.availableXlmStroops, BigInt(300)); + assertEquals(r.requiredXlmStroops, BigInt(300)); + assertEquals(r.shortfallStroops, BigInt(0)); +}); + +Deno.test("preflight math — shortfall by one stroop", () => { + // available = 20_000_299, required = 300 → shortfall = 1 + const r = compute(BigInt(20_000_299), BigInt(2)); + assertEquals(r.availableXlmStroops, BigInt(299)); + assertEquals(r.shortfallStroops, BigInt(1)); +}); + +Deno.test("preflight math — empty account: full required is the shortfall plus reserve", () => { + // balance = 0, subentries = 0 → reserves = 10M, available = -10M + // required = 300, shortfall = 300 - (-10M) = 10M + 300 + const r = compute(BigInt(0), BigInt(0)); + assertEquals(r.availableXlmStroops, BigInt(-10_000_000)); + assertEquals(r.shortfallStroops, BigInt(10_000_300)); +}); + +Deno.test("preflight math — high subentry count reduces available accordingly", () => { + // subentries = 10 → reserves = 12 × 5M = 60M + // available = 100M - 60M = 40M + const r = compute(BigInt(100_000_000), BigInt(10)); + assertEquals(r.availableXlmStroops, BigInt(40_000_000)); +}); + +Deno.test("toInsufficientFeesDetail — serialises stroops as strings", () => { + const r = compute(BigInt(20_000_299), BigInt(2)); + const detail = toInsufficientFeesDetail(r); + assertEquals(detail.feePayerPubkey, PUBKEY); + assertEquals(detail.availableXlm, "299"); + assertEquals(detail.requiredXlm, "300"); + assertEquals(detail.shortfallXlm, "1"); +}); + +Deno.test("InsufficientFees error carries the structured detail", () => { + const detail = { + feePayerPubkey: PUBKEY, + availableXlm: "299", + requiredXlm: "300", + shortfallXlm: "1", + }; + const err = new InsufficientFees(detail); + assertEquals(err.code, "EXC_005"); + assertEquals(err.detail, detail); + assertEquals(err.meta, detail); + assertExists(err.message); + // PlatformError exposes details string with the four numbers visible + assertExists(err.details); +}); + +Deno.test("InsufficientFees instanceof works for catch-site type guard", () => { + const detail = { + feePayerPubkey: PUBKEY, + availableXlm: "0", + requiredXlm: "10000300", + shortfallXlm: "10000300", + }; + const err = new InsufficientFees(detail); + // Verifies the catch-site fast-path `error instanceof InsufficientFees` + // works under v8. + assertEquals(err instanceof InsufficientFees, true); + assertEquals(err instanceof Error, true); + // Force a throw + catch round-trip to mimic executor.process.ts behaviour + assertThrows( + () => { + throw err; + }, + InsufficientFees, + "Insufficient fees", + ); +});