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
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Build output
node_modules/
dist
dist/

# Generated by scripts/bundle-size-guard.ts (npm run bundle:check)
scripts/bundle-size-report.json

Expand All @@ -17,6 +18,7 @@ coverage/
.vitest/
.nyc_output/
test-results/
junit.xml

# Test snapshots (all formats)
**/__snapshots__/
Expand All @@ -28,6 +30,9 @@ testSnapshot/
test-snapshot/
test-snapshots/

# Vitest cache
.vitest-cache/

# Profiler / speedscope output
*.profile.json
*.speedscope.json
Expand All @@ -46,11 +51,14 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-error.log*

# Temp / scratch files
/tmp/
*.tmp
*.temp

# Scratch / ad-hoc task notes (not part of the project)
task1.md
task2.md
task3.md
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/invoiceBatchProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Partial-failure handling tests for InvoiceBatchProcessor (#691).
*
* These tests verify that:
* 1. A batch where one invoice throws continues processing remaining invoices.
* 2. The result object includes `succeeded` and `failed` arrays with correct contents.
* 3. A batch where all invoices fail returns an empty `succeeded` array.
*/

import { describe, it, expect, vi } from "vitest";
import { InvoiceBatchProcessor } from "../invoiceBatchProcessor.js";
import type { InvoicePaymentSubmitter } from "../invoiceBatchProcessor.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Drain an async iterator into an array. */
async function drain<T>(iter: AsyncIterableIterator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of iter) results.push(item);
return results;
}

// ---------------------------------------------------------------------------
// Tests – partial-failure handling
// ---------------------------------------------------------------------------

describe("InvoiceBatchProcessor – partial-failure handling", () => {
// ── Criterion 1: one invoice throws, rest continue ───────────────────────

it("continues processing remaining invoices when one invoice throws", async () => {
const submitPayment = vi
.fn()
.mockImplementation(async ({ invoiceId }: { invoiceId: string }) => {
if (invoiceId === "inv2") {
throw new Error("contract call failed");
}
return { txHash: `tx-${invoiceId}` };
});

const processor = new InvoiceBatchProcessor(
{ submitPayment } as InvoicePaymentSubmitter,
);

// Process three invoices: inv1 and inv3 succeed, inv2 fails
const results = await drain(
processor.process(["inv1", "inv2", "inv3"], {
payer: "GPAYER",
amounts: { inv1: 1n, inv2: 1n, inv3: 1n },
maxConcurrent: 1, // serial so order is predictable
}),
);

// All three invoices must have been attempted
expect(results).toHaveLength(3);
expect(new Set(results.map((r) => r.invoiceId))).toEqual(
new Set(["inv1", "inv2", "inv3"]),
);

// inv1 and inv3 must succeed
expect(results.find((r) => r.invoiceId === "inv1")!.status).toBe("success");
expect(results.find((r) => r.invoiceId === "inv3")!.status).toBe("success");

// inv2 must fail with the error message preserved
const failed = results.find((r) => r.invoiceId === "inv2")!;
expect(failed.status).toBe("failed");
expect(failed.error).toContain("contract call failed");
});

// ── Criterion 2: succeeded and failed arrays have correct contents ────────

it("processAll() returns succeeded and failed arrays with correct contents", async () => {
const submitPayment = vi
.fn()
.mockImplementation(async ({ invoiceId }: { invoiceId: string }) => {
if (invoiceId === "bad1" || invoiceId === "bad2") {
throw new Error(`payment rejected: ${invoiceId}`);
}
return { txHash: `tx-${invoiceId}` };
});

const processor = new InvoiceBatchProcessor(
{ submitPayment } as InvoicePaymentSubmitter,
);

const { succeeded, failed } = await processor.processAll(
["good1", "bad1", "good2", "bad2", "good3"],
{
payer: "GPAYER",
amounts: {
good1: 10n,
bad1: 10n,
good2: 10n,
bad2: 10n,
good3: 10n,
},
maxConcurrent: 1,
},
);

// Three invoices succeed
expect(succeeded).toHaveLength(3);
expect(new Set(succeeded.map((r) => r.invoiceId))).toEqual(
new Set(["good1", "good2", "good3"]),
);
expect(succeeded.every((r) => r.status === "success")).toBe(true);
expect(succeeded.every((r) => typeof r.txHash === "string")).toBe(true);

// Two invoices fail
expect(failed).toHaveLength(2);
expect(new Set(failed.map((r) => r.invoiceId))).toEqual(
new Set(["bad1", "bad2"]),
);
expect(failed.every((r) => r.status === "failed")).toBe(true);
expect(failed.every((r) => typeof r.error === "string")).toBe(true);
});

// ── Criterion 3: all invoices fail → empty succeeded array ───────────────

it("returns an empty succeeded array when all invoices in the batch fail", async () => {
const submitPayment = vi
.fn()
.mockRejectedValue(new Error("network error"));

const processor = new InvoiceBatchProcessor(
{ submitPayment } as InvoicePaymentSubmitter,
);

const { succeeded, failed } = await processor.processAll(
["inv1", "inv2", "inv3"],
{
payer: "GPAYER",
amounts: { inv1: 1n, inv2: 1n, inv3: 1n },
maxConcurrent: 1,
},
);

expect(succeeded).toHaveLength(0);
expect(failed).toHaveLength(3);
expect(failed.every((r) => r.status === "failed")).toBe(true);
expect(failed.every((r) => r.error === "network error")).toBe(true);
});
});
100 changes: 94 additions & 6 deletions src/feeSurgeDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
*
* Extends {@link src/feeEstimator.ts} and {@link src/fee.ts} with surge-aware
* behaviour.
*
* ## Moving-average baseline (#690)
*
* Instead of comparing the observed fee against a hard-coded static baseline,
* the detector maintains a **sliding window** of the last N fee samples
* (configurable via `windowSize`, default 20). The moving average of the
* window becomes the dynamic baseline used for surge detection.
*
* When the window is not yet full (i.e. fewer than `windowSize` samples have
* been collected), the static baseline (`DEFAULT_BASE_FEE = 100n stroops`) is
* used as a fallback so the detector is immediately useful on first run.
*/

import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";
Expand All @@ -30,7 +41,7 @@ export interface FeeSurgeConfig {

/**
* Congestion threshold multiplier. When the observed fee exceeds
* `baseFee * surgeMultiplier`, the network is considered congested.
* `baseline * surgeMultiplier`, the network is considered congested.
* Defaults to `2`.
*/
surgeMultiplier?: number;
Expand All @@ -51,6 +62,15 @@ export interface FeeSurgeConfig {
* a safety ceiling. Defaults to 10_000_000 (10 XLM).
*/
maxFeeStroops?: number;

/**
* Number of recent fee samples kept in the sliding window used to compute
* the moving-average baseline. When the window has fewer than `windowSize`
* samples, the static `DEFAULT_BASE_FEE` is used as a fallback.
*
* Defaults to `20`.
*/
windowSize?: number;
}

/** Congestion level derived from fee statistics. */
Expand All @@ -77,14 +97,55 @@ export interface FeeRecommendation {
}

// ---------------------------------------------------------------------------
// Implementation
// Moving-average window state (module-level for the free-standing function)
// ---------------------------------------------------------------------------

const DEFAULT_BASE_FEE = 100n; // 100 stroops
const DEFAULT_WINDOW_SIZE = 20;

/** Circular buffer of recent fee samples (in stroops, as numbers for averaging). */
const _feeSamples: number[] = [];
let _windowSize = DEFAULT_WINDOW_SIZE;

let cachedRecommendation: FeeRecommendation | null = null;
let cacheExpiry = 0;

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Add a new fee sample to the sliding window. Evicts the oldest sample when
* the window is full.
*
* @internal
*/
function addFeeSample(fee: bigint, windowSize: number): void {
// Update window size if it changed between calls
_windowSize = windowSize;
_feeSamples.push(Number(fee));
if (_feeSamples.length > _windowSize) {
_feeSamples.shift();
}
}

/**
* Compute the moving-average baseline from the current window.
*
* Returns `null` when the window is not yet full (so callers can fall back to
* the static baseline).
*
* @internal
*/
function movingAverageBaseline(windowSize: number): bigint | null {
if (_feeSamples.length < windowSize) {
// Window not yet full — use static fallback
return null;
}
const sum = _feeSamples.reduce((acc, v) => acc + v, 0);
return BigInt(Math.ceil(sum / _feeSamples.length));
}

/**
* Fetch the current fee statistics from Horizon and produce a surge-aware
* fee recommendation.
Expand Down Expand Up @@ -112,13 +173,21 @@ export async function detectFeeSurge(
const surgeMultiplier = config?.surgeMultiplier ?? 2;
const surgeFeeMultiplier = config?.surgeFeeMultiplier ?? 1.5;
const maxFee = BigInt(config?.maxFeeStroops ?? 10_000_000);
const baseFee = DEFAULT_BASE_FEE;
const windowSize = config?.windowSize ?? DEFAULT_WINDOW_SIZE;

try {
const server = new Horizon.Server(horizonUrl);
const feeStats = await server.feeStats();

const observedFee = feePercentileToBigInt(feeStats, percentile);

// ── Moving-average baseline ────────────────────────────────────────────
// Add the observed fee to the sliding window and derive the baseline.
// Falls back to the static DEFAULT_BASE_FEE until the window is full.
addFeeSample(observedFee, windowSize);
const maBaseline = movingAverageBaseline(windowSize);
const baseFee = maBaseline ?? DEFAULT_BASE_FEE;

const surgeActive = observedFee > baseFee * BigInt(Math.ceil(surgeMultiplier));

let congestion: CongestionLevel;
Expand Down Expand Up @@ -166,9 +235,9 @@ export async function detectFeeSurge(
} catch {
// On failure, return a safe default (base fee with low congestion).
return {
fee: baseFee,
baseFee,
observedFee: baseFee,
fee: DEFAULT_BASE_FEE,
baseFee: DEFAULT_BASE_FEE,
observedFee: DEFAULT_BASE_FEE,
congestion: "low",
surgeActive: false,
multiplier: 1.0,
Expand All @@ -186,6 +255,25 @@ export function clearFeeSurgeCache(): void {
cacheExpiry = 0;
}

/**
* Reset the moving-average window (clears all accumulated samples).
*
* Useful in tests or when resetting detector state entirely.
*/
export function resetFeeSurgeWindow(): void {
_feeSamples.length = 0;
_windowSize = DEFAULT_WINDOW_SIZE;
}

/**
* Return a read-only snapshot of the current fee sample window.
*
* Intended for debugging and unit testing.
*/
export function getFeeSampleWindow(): readonly number[] {
return [..._feeSamples];
}

/**
* Extract a fee percentile from the Horizon fee stats response as a bigint
* (in stroops).
Expand Down
Loading