Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/provider-platform",
"version": "0.7.1",
"version": "0.7.2",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
17 changes: 17 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions src/core/service/bundle/bundle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export type BundleDTO = {
fee: string;
createdAt: string;
updatedAt: string | null;
failureDetail: Record<string, unknown> | null;
};

/**
Expand All @@ -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,
};
}

Expand Down
35 changes: 35 additions & 0 deletions src/core/service/executor/executor.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<InsufficientFeesDetail> {
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
*/
Expand Down
67 changes: 67 additions & 0 deletions src/core/service/executor/executor.process.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading