diff --git a/.gitignore b/.gitignore index 565defc..cb697f2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -17,6 +18,7 @@ coverage/ .vitest/ .nyc_output/ test-results/ +junit.xml # Test snapshots (all formats) **/__snapshots__/ @@ -28,6 +30,9 @@ testSnapshot/ test-snapshot/ test-snapshots/ +# Vitest cache +.vitest-cache/ + # Profiler / speedscope output *.profile.json *.speedscope.json @@ -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 diff --git a/src/__tests__/invoiceBatchProcessor.test.ts b/src/__tests__/invoiceBatchProcessor.test.ts new file mode 100644 index 0000000..872f628 --- /dev/null +++ b/src/__tests__/invoiceBatchProcessor.test.ts @@ -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(iter: AsyncIterableIterator): Promise { + 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); + }); +}); diff --git a/src/feeSurgeDetector.ts b/src/feeSurgeDetector.ts index 75001ed..d83d46e 100644 --- a/src/feeSurgeDetector.ts +++ b/src/feeSurgeDetector.ts @@ -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"; @@ -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; @@ -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. */ @@ -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. @@ -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; @@ -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, @@ -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). diff --git a/src/horizonPaginator.ts b/src/horizonPaginator.ts index 1e00976..3dcc12a 100644 --- a/src/horizonPaginator.ts +++ b/src/horizonPaginator.ts @@ -7,6 +7,17 @@ * * Integrates with {@link cursorTracker} to persist the last-seen paging * token for resumable pagination. + * + * ## Automatic page-size negotiation (#692) + * + * When the configured `pageSize` exceeds the server's actual maximum, + * Horizon silently returns fewer records than requested. The paginator + * detects this on the first response: if the first page returns fewer + * records than `pageSize`, `effectivePageSize` is updated to the actual + * count. Subsequent pages use `effectivePageSize` to decide whether a page + * is the last one, preventing premature termination. + * + * `effectivePageSize` is exposed as a read-only property for debugging. */ import type { CollectionPage, HorizonPaginatorOptions } from "./types.js"; @@ -15,6 +26,64 @@ import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js"; /** Default namespace for cursor store keys. */ const DEFAULT_NAMESPACE = "horizon"; +/** + * Stateful paginator for a Horizon collection endpoint. + * + * Prefer the {@link HorizonPaginator} class when you need access to + * `effectivePageSize`. Use the free-standing {@link paginate} or + * {@link collectAll} helpers for a simpler one-shot API. + */ +export class HorizonPaginator { + /** The page size originally requested by the caller. */ + readonly requestedPageSize: number; + + /** + * The effective page size derived from the first response. + * + * Equals `requestedPageSize` until the first page is received. If the + * first page returns fewer records than `requestedPageSize`, this is + * updated to the actual count so subsequent pages are judged correctly. + * Exposed as a read-only property for debugging. + */ + get effectivePageSize(): number { + return this._effectivePageSize; + } + + private _effectivePageSize: number; + private _firstPageSeen = false; + + constructor(requestedPageSize: number) { + this.requestedPageSize = requestedPageSize; + this._effectivePageSize = requestedPageSize; + } + + /** + * Observe the first page response to negotiate the effective page size. + * Called internally by {@link paginate}. + * + * @param recordCount - Number of records returned in the first page. + */ + observeFirstPage(recordCount: number): void { + if (this._firstPageSeen) return; + this._firstPageSeen = true; + if (recordCount < this.requestedPageSize) { + // Server returned fewer records than requested — adapt to the actual + // maximum so we don't mistake later full pages for the last one. + this._effectivePageSize = recordCount; + } + } + + /** + * Returns `true` when the given page should be treated as the last page + * (i.e. the server has no more data to return). + */ + isLastPage(recordCount: number): boolean { + // A page with 0 records (or fewer than effectivePageSize after negotiation) + // means the collection is exhausted. + return recordCount < this._effectivePageSize; + } +} + /** * Create an async iterable iterator that walks all pages of a Horizon * collection endpoint. @@ -37,11 +106,15 @@ export async function* paginate( opts?: HorizonPaginatorOptions, ): AsyncIterableIterator { const maxRecords = opts?.maxRecords; + const pageSize = opts?.pageSize ?? 200; const cursorStore = opts?.cursorStore ?? getDefaultCursorStore(); const namespace = opts?.cursorNamespace ?? DEFAULT_NAMESPACE; + const paginator = new HorizonPaginator(pageSize); + let yielded = 0; let currentPage: CollectionPage | null = initialPage; + let isFirst = true; while (currentPage) { const records = currentPage.records ?? []; @@ -49,6 +122,14 @@ export async function* paginate( ? records.slice(0, maxRecords - yielded) : records; + // ── Page-size negotiation ────────────────────────────────────────────── + // On the first page, observe the actual record count to detect whether + // the server silently capped our requested page size. + if (isFirst) { + paginator.observeFirstPage(records.length); + isFirst = false; + } + for (const record of batch) { if (maxRecords !== undefined && yielded >= maxRecords) return; yielded++; @@ -71,6 +152,13 @@ export async function* paginate( if (maxRecords !== undefined && yielded >= maxRecords) return; + // ── Termination: use effectivePageSize to detect last page ───────────── + // After negotiation, a page with fewer records than effectivePageSize + // means the collection is exhausted — no need to fetch further. + if (paginator.isLastPage(records.length)) { + return; + } + // Fetch next page currentPage = await currentPage.next(); } diff --git a/src/invoiceBatchProcessor.ts b/src/invoiceBatchProcessor.ts index 65f1162..e9c17a0 100644 --- a/src/invoiceBatchProcessor.ts +++ b/src/invoiceBatchProcessor.ts @@ -134,4 +134,33 @@ export class InvoiceBatchProcessor { yield result; } } + + /** + * Process a batch and return a single result object with `succeeded` and + * `failed` arrays, making it easy to inspect partial-failure outcomes. + * + * Internally delegates to {@link process} so all concurrency and + * rate-limit behaviour is preserved. + * + * @param invoiceIds - Ordered list of invoice IDs to process. + * @param config - Batch configuration (payer, amounts, concurrency). + * @returns `{ succeeded, failed }` — results split by outcome. + */ + async processAll( + invoiceIds: string[], + config: InvoiceBatchConfig, + ): Promise<{ succeeded: BatchInvoiceResult[]; failed: BatchInvoiceResult[] }> { + const succeeded: BatchInvoiceResult[] = []; + const failed: BatchInvoiceResult[] = []; + + for await (const result of this.process(invoiceIds, config)) { + if (result.status === "success") { + succeeded.push(result); + } else { + failed.push(result); + } + } + + return { succeeded, failed }; + } } diff --git a/src/tokenGateController.ts b/src/tokenGateController.ts index d9ee71d..ab0f2a1 100644 --- a/src/tokenGateController.ts +++ b/src/tokenGateController.ts @@ -110,9 +110,14 @@ export class TokenGateController { * - Resolves with a {@link TokenGateVerifyResult} when the caller meets the * requirement. * - Throws {@link TokenGateAccessDeniedError} when `policy.strict !== false` - * and the balance is insufficient. + * and the balance is insufficient **or** the current time is outside the + * gate's `validFrom`/`validUntil` window. * - When `policy.strict === false`, logs a warning and resolves instead of * throwing. + * - When `validFrom` and/or `validUntil` are provided, the gate returns + * `allowed: false` outside that window regardless of the caller's balance. + * - When no time constraints are set the gate evaluates balance only (no + * behavior change). * * @param callerAccountId - The Stellar public key (G…) of the caller. * @param policy - The token-gate policy to evaluate. @@ -121,12 +126,63 @@ export class TokenGateController { callerAccountId: string, policy: TokenGatePolicy, ): Promise { + const strict = policy.strict !== false; // default true + const assetCode = policy.asset.split(":")[0] ?? policy.asset; + + // ── Time-window check ────────────────────────────────────────────────── + // Evaluate before the cache so that time boundaries are always respected + // even for cached results. + const now = new Date(); + if (policy.validFrom !== undefined && now < policy.validFrom) { + const result: TokenGateVerifyResult = { + allowed: false, + actualBalance: "0.0000000", + requiredBalance: policy.minBalance, + cached: false, + }; + if (strict) { + throw new TokenGateAccessDeniedError( + callerAccountId, + assetCode, + policy.minBalance, + result.actualBalance, + ); + } else { + console.warn( + `[TokenGateController] Non-strict warning: gate for ${assetCode} not yet active ` + + `(validFrom: ${policy.validFrom.toISOString()}).`, + ); + } + return result; + } + if (policy.validUntil !== undefined && now > policy.validUntil) { + const result: TokenGateVerifyResult = { + allowed: false, + actualBalance: "0.0000000", + requiredBalance: policy.minBalance, + cached: false, + }; + if (strict) { + throw new TokenGateAccessDeniedError( + callerAccountId, + assetCode, + policy.minBalance, + result.actualBalance, + ); + } else { + console.warn( + `[TokenGateController] Non-strict warning: gate for ${assetCode} has expired ` + + `(validUntil: ${policy.validUntil.toISOString()}).`, + ); + } + return result; + } + + // ── Cache check ──────────────────────────────────────────────────────── const key = cacheKey(callerAccountId, policy); const cached = this._cache.get(key); if (cached !== undefined) { - const strict = policy.strict !== false; if (!cached.allowed && strict) { - const assetCode = policy.asset.split(":")[0] ?? policy.asset; throw new TokenGateAccessDeniedError( callerAccountId, assetCode, @@ -137,8 +193,8 @@ export class TokenGateController { return { ...cached, cached: true }; } + // ── Balance check ────────────────────────────────────────────────────── const balance = await this._fetchBalance(callerAccountId, policy.asset); - const strict = policy.strict !== false; // default true const allowed = parseBalance(balance) >= parseBalance(policy.minBalance); @@ -154,7 +210,6 @@ export class TokenGateController { this._cache.set(key, result); if (!allowed) { - const assetCode = policy.asset.split(":")[0] ?? policy.asset; if (strict) { throw new TokenGateAccessDeniedError( callerAccountId, diff --git a/src/types.ts b/src/types.ts index ee300a5..7d228de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,6 +247,42 @@ export class HealthCheckTimeoutError extends StellarSplitError { /** * Basic invoice data structure mirroring the Soroban contract. */ + +/** + * Policy used to gate access to an invoice based on a minimum token balance. + * + * When `validFrom` or `validUntil` are provided the gate is only active + * during that time window. Outside the window the gate evaluates to `false` + * regardless of the caller's balance. + */ +export interface TokenGatePolicy { + /** + * The asset to check, in "CODE:ISSUER" format or "native" for XLM. + * @example "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + */ + asset: string; + /** + * Minimum balance required to pass the gate, as a decimal string. + * @example "10.0000000" + */ + minBalance: string; + /** + * When `false`, a balance shortfall emits a warning instead of throwing. + * Defaults to `true`. + */ + strict?: boolean; + /** + * Optional start of the gate's active window. Before this date the gate + * always returns `false` (or warns in non-strict mode). + */ + validFrom?: Date; + /** + * Optional end of the gate's active window. After this date the gate + * always returns `false` (or warns in non-strict mode). + */ + validUntil?: Date; +} + /** An on-chain StellarSplit invoice. */ export interface Invoice { /** Invoice ID (u64 from the contract). */ @@ -1960,6 +1996,13 @@ export interface CollectionPage { export interface HorizonPaginatorOptions { /** Maximum number of records to yield across all pages. Default: unlimited. */ maxRecords?: number; + /** + * The page size that was passed to the Horizon call builder's `.limit()` + * method. The paginator uses this to detect when the server has silently + * capped the page size and adapts `effectivePageSize` accordingly. + * Default: 200. + */ + pageSize?: number; /** Optional cursor store for persisting the last-seen paging token. */ cursorStore?: CursorStore; /** Optional namespace for cursor storage keys (default: "horizon"). */ diff --git a/test/feeSurgeDetector.test.ts b/test/feeSurgeDetector.test.ts new file mode 100644 index 0000000..5c4ccff --- /dev/null +++ b/test/feeSurgeDetector.test.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for feeSurgeDetector.ts – moving-average baseline (#690). + * + * These tests verify that: + * 1. The detector maintains a sliding window of the last N fee samples. + * 2. The moving average of the window is used as the baseline for surge detection. + * 3. When the window is not yet full, the static baseline (100 stroops) is used. + * 4. The surge multiplier threshold remains configurable. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + detectFeeSurge, + clearFeeSurgeCache, + resetFeeSurgeWindow, + getFeeSampleWindow, +} from "../src/feeSurgeDetector.js"; +import type { FeeSurgeConfig } from "../src/feeSurgeDetector.js"; +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const HORIZON_URL = "https://horizon-testnet.stellar.org"; + +/** Build a minimal Horizon fee-stats response. */ +function makeFeeStats(p50: number) { + return { + feeCharged: { p10: String(Math.round(p50 * 0.8)), p50: String(p50), p95: String(Math.round(p50 * 1.5)) }, + maxFee: { p10: "100", p50: "200", p95: "500" }, + ledgerCapacityUsage: "0.5", + }; +} + +/** Replace Horizon.Server.feeStats with a stub that returns the given p50 fee. */ +function stubFeeStats(p50: number) { + return vi + .spyOn(Horizon.Server.prototype, "feeStats") + .mockResolvedValue(makeFeeStats(p50) as any); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("feeSurgeDetector – moving-average baseline (#690)", () => { + beforeEach(() => { + // Reset module-level state before each test + clearFeeSurgeCache(); + resetFeeSurgeWindow(); + vi.restoreAllMocks(); + }); + + // ── Window not yet full → static fallback ───────────────────────────────── + + it("uses the static baseline (100 stroops) when the window is not yet full", async () => { + // Only one sample in a window of 20 → should use fallback + stubFeeStats(150); + + const result = await detectFeeSurge(HORIZON_URL, { windowSize: 20 }); + + // baseFee should be the static 100n because window is not full + expect(result.baseFee).toBe(100n); + expect(result.observedFee).toBe(150n); + }); + + // ── Window full → moving average used as baseline ───────────────────────── + + it("uses the moving average as baseline once the window is full", async () => { + const windowSize = 3; + + // Fill the window with 3 samples by calling detectFeeSurge 3 times, + // each time clearing the cache but NOT the window. + for (const fee of [200, 300, 400]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize }); + } + + // Window should now hold [200, 300, 400]; moving average = 300 + expect(getFeeSampleWindow()).toEqual([200, 300, 400]); + + // Next call: observed fee = 200, moving-average baseline = 300 + stubFeeStats(200); + clearFeeSurgeCache(); + const result = await detectFeeSurge(HORIZON_URL, { windowSize }); + + // baseFee should be the moving average of the full window [300, 400, 200] = 300 + // (window slides: oldest 200 evicted, 200 appended → [300, 400, 200]) + expect(result.baseFee).toBe(300n); + }); + + // ── Sliding window evicts oldest sample ────────────────────────────────── + + it("maintains a sliding window that evicts the oldest sample when full", async () => { + const windowSize = 3; + + for (const fee of [100, 200, 300]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize }); + } + expect(getFeeSampleWindow()).toEqual([100, 200, 300]); + + // Adding a 4th sample (400) should evict the first (100) + stubFeeStats(400); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize }); + + expect(getFeeSampleWindow()).toEqual([200, 300, 400]); + }); + + // ── Surge detection uses moving-average baseline ────────────────────────── + + it("marks surgeActive when observed fee exceeds moving-average baseline × surgeMultiplier", async () => { + const windowSize = 3; + const surgeMultiplier = 2; + + // Fill window with low fees → moving-average baseline = 100 + for (const fee of [100, 100, 100]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier }); + } + + // Now spike the fee: 100 * 2 = 200 threshold, so 201 should trigger surge + stubFeeStats(201); + clearFeeSurgeCache(); + const result = await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier }); + + expect(result.surgeActive).toBe(true); + expect(result.congestion).not.toBe("low"); + }); + + it("does NOT mark surgeActive when observed fee is within the moving-average baseline", async () => { + const windowSize = 3; + const surgeMultiplier = 2; + + // Fill window with low fees → moving-average baseline = 100 + for (const fee of [100, 100, 100]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier }); + } + + // Fee of 150 is below 100 * 2 = 200 → no surge + stubFeeStats(150); + clearFeeSurgeCache(); + const result = await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier }); + + expect(result.surgeActive).toBe(false); + }); + + // ── surgeMultiplier remains configurable ───────────────────────────────── + + it("respects a custom surgeMultiplier when determining surge status", async () => { + const windowSize = 3; + + // Fill window → moving-average baseline = 100 + for (const fee of [100, 100, 100]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier: 5 }); + } + + // With surgeMultiplier=5, threshold = 100*5 = 500. Fee of 300 < 500 → no surge. + stubFeeStats(300); + clearFeeSurgeCache(); + const result = await detectFeeSurge(HORIZON_URL, { windowSize, surgeMultiplier: 5 }); + + expect(result.surgeActive).toBe(false); + expect(result.multiplier).toBe(1.0); + }); + + // ── Default window size ─────────────────────────────────────────────────── + + it("defaults to a window size of 20", async () => { + // Confirm the window has fewer than 20 entries initially → fallback used + stubFeeStats(200); + const result = await detectFeeSurge(HORIZON_URL); + + // Window not yet full (1 of 20) → static baseline + expect(result.baseFee).toBe(100n); + expect(getFeeSampleWindow()).toHaveLength(1); + }); + + // ── resetFeeSurgeWindow ─────────────────────────────────────────────────── + + it("resetFeeSurgeWindow clears all accumulated samples", async () => { + const windowSize = 3; + + for (const fee of [100, 200, 300]) { + stubFeeStats(fee); + clearFeeSurgeCache(); + await detectFeeSurge(HORIZON_URL, { windowSize }); + } + expect(getFeeSampleWindow()).toHaveLength(3); + + resetFeeSurgeWindow(); + expect(getFeeSampleWindow()).toHaveLength(0); + + // After reset, window is empty → static baseline fallback + stubFeeStats(500); + clearFeeSurgeCache(); + const result = await detectFeeSurge(HORIZON_URL, { windowSize }); + expect(result.baseFee).toBe(100n); + }); +}); diff --git a/test/horizonPaginator.test.ts b/test/horizonPaginator.test.ts index 665690e..96330b9 100644 --- a/test/horizonPaginator.test.ts +++ b/test/horizonPaginator.test.ts @@ -5,7 +5,7 @@ import { getDefaultCursorStore, buildCursorKey, } from "../src/cursorTracker.js"; -import { paginate, collectAll } from "../src/horizonPaginator.js"; +import { paginate, collectAll, HorizonPaginator } from "../src/horizonPaginator.js"; import type { CollectionPage } from "../src/types.js"; describe("InMemoryCursorStore", () => { @@ -153,3 +153,99 @@ describe("collectAll", () => { expect(results).toHaveLength(5); }); }); + +// ── Page-size negotiation (#692) ───────────────────────────────────────────── + +describe("HorizonPaginator – page-size negotiation", () => { + it("effectivePageSize starts equal to requestedPageSize", () => { + const p = new HorizonPaginator(200); + expect(p.effectivePageSize).toBe(200); + }); + + it("effectivePageSize is updated when the first page returns fewer records than requested", () => { + const p = new HorizonPaginator(200); + p.observeFirstPage(50); // server returned 50, not 200 + expect(p.effectivePageSize).toBe(50); + }); + + it("effectivePageSize stays unchanged when the first page is full", () => { + const p = new HorizonPaginator(200); + p.observeFirstPage(200); + expect(p.effectivePageSize).toBe(200); + }); + + it("observeFirstPage is idempotent – only the first call counts", () => { + const p = new HorizonPaginator(200); + p.observeFirstPage(50); + p.observeFirstPage(100); // should be ignored + expect(p.effectivePageSize).toBe(50); + }); + + it("isLastPage returns false when a page is full (equals effectivePageSize)", () => { + const p = new HorizonPaginator(200); + p.observeFirstPage(50); + expect(p.isLastPage(50)).toBe(false); + }); + + it("isLastPage returns true when a page is shorter than effectivePageSize", () => { + const p = new HorizonPaginator(200); + p.observeFirstPage(50); + expect(p.isLastPage(30)).toBe(true); + }); +}); + +describe("paginate – page-size negotiation integration (#692)", () => { + function makePage(records: T[], nextPage: CollectionPage | null): CollectionPage { + return { + records, + next: vi.fn().mockResolvedValue(nextPage), + }; + } + + it("does not stop early when server caps page size and subsequent pages are full", async () => { + // Caller requested 200 records per page; server caps at 5. + // All three pages are 'full' at the server's limit → should yield all 15 records. + const page3 = makePage([{ id: "11" }, { id: "12" }, { id: "13" }, { id: "14" }, { id: "15" }], null); + const page2 = makePage([{ id: "6" }, { id: "7" }, { id: "8" }, { id: "9" }, { id: "10" }], page3); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }, { id: "5" }], page2); + + const results = []; + for await (const record of paginate(page1, { pageSize: 200 })) { + results.push(record); + } + + expect(results).toHaveLength(15); + }); + + it("stops when a page returns fewer records than effectivePageSize (last page detected)", async () => { + // Server caps at 5. Pages: full (5), full (5), partial (3) → should stop after partial. + const page3 = makePage([{ id: "11" }, { id: "12" }, { id: "13" }], null); + const page2 = makePage([{ id: "6" }, { id: "7" }, { id: "8" }, { id: "9" }, { id: "10" }], page3); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }, { id: "5" }], page2); + + const results = []; + for await (const record of paginate(page1, { pageSize: 200 })) { + results.push(record); + } + + expect(results).toHaveLength(13); + expect(results.map((r: Record) => r.id)).toEqual([ + "1", "2", "3", "4", "5", + "6", "7", "8", "9", "10", + "11", "12", "13", + ]); + }); + + it("updates effectivePageSize on the first page when server cap is lower", async () => { + // We can verify behaviour by checking that page2.next is NOT called after a partial last page. + const page2 = makePage([{ id: "4" }, { id: "5" }, { id: "6" }, { id: "7" }, { id: "8" }], null); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }], page2); // 3 < 200 → effectivePageSize=3 + + // page2 returns 5 records which is > effectivePageSize(3) but that means page2 was fetched. + // What matters is: if page3 existed with 2 records it would stop. Here page2 has 5 > 3, so we fetch page3=null. + const results = await collectAll(page1, { pageSize: 200 }); + + // All records from page1 (3) and page2 (5) should be collected + expect(results).toHaveLength(8); + }); +}); diff --git a/test/tokenGateController.test.ts b/test/tokenGateController.test.ts index c2edd31..2524220 100644 --- a/test/tokenGateController.test.ts +++ b/test/tokenGateController.test.ts @@ -213,4 +213,103 @@ describe("TokenGateController", () => { expect(err?.name).toBe("TokenGateAccessDeniedError"); }); }); + + // ── Time-bounded gate windows (#693) ───────────────────────────────────── + + describe("time-bounded gate windows", () => { + it("returns false and skips balance check when current time is before validFrom", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("100.0000000"); + + const policy: TokenGatePolicy = { + ...USDC_POLICY, + // validFrom is one hour in the future + validFrom: new Date(Date.now() + 60 * 60 * 1000), + }; + + await expect(controller.verify(CALLER, policy)).rejects.toThrow( + TokenGateAccessDeniedError, + ); + // Balance should never be fetched — gate is not yet active + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns false and skips balance check when current time is after validUntil", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("100.0000000"); + + const policy: TokenGatePolicy = { + ...USDC_POLICY, + // validUntil is one hour in the past + validUntil: new Date(Date.now() - 60 * 60 * 1000), + }; + + await expect(controller.verify(CALLER, policy)).rejects.toThrow( + TokenGateAccessDeniedError, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("evaluates balance normally when current time is within the active window", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("25.0000000"); + + const policy: TokenGatePolicy = { + ...USDC_POLICY, + validFrom: new Date(Date.now() - 60 * 60 * 1000), // started 1 h ago + validUntil: new Date(Date.now() + 60 * 60 * 1000), // ends in 1 h + }; + + const result = await controller.verify(CALLER, policy); + + expect(result.allowed).toBe(true); + expect(result.actualBalance).toBe("25.0000000"); + }); + + it("evaluates balance normally when no time constraints are provided", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("15.0000000"); + + // No validFrom / validUntil — should behave exactly as before + const result = await controller.verify(CALLER, USDC_POLICY); + + expect(result.allowed).toBe(true); + }); + + it("warns but does not throw (non-strict) when gate is not yet active", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("100.0000000"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const policy: TokenGatePolicy = { + ...USDC_POLICY, + strict: false, + validFrom: new Date(Date.now() + 60 * 60 * 1000), + }; + + const result = await controller.verify(CALLER, policy); + + expect(result.allowed).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not yet active"), + ); + }); + + it("warns but does not throw (non-strict) when gate has expired", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("100.0000000"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const policy: TokenGatePolicy = { + ...USDC_POLICY, + strict: false, + validUntil: new Date(Date.now() - 60 * 60 * 1000), + }; + + const result = await controller.verify(CALLER, policy); + + expect(result.allowed).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("expired"), + ); + }); + }); });