From d5a990fc97ff3ef5fe193a4e1e9b97db0263cdcc Mon Sep 17 00:00:00 2001 From: ahmadrabiumustapha Date: Mon, 28 Sep 2026 10:17:57 +0000 Subject: [PATCH] feat: implement issues #870-#873 (Drips Wave) Issue #873 - releasePartial with basis-point validation - Add InvalidBpsError (code: INVALID_BPS) for bps outside 1-10000 - Add OverReleaseError (code: OVER_RELEASE) when total would exceed 10000 bps - Add PartialReleaseResult type { txHash, bps, amountReleased, remaining, totalReleasedBps } - Implement StellarSplitClient.releasePartial(invoiceId, bps) wrapping release_partial Issue #872 - cancelInvoice with pre-payment guard - Add CannotCancelFundedInvoiceError (code: CANNOT_CANCEL_FUNDED_INVOICE) with paidAmount - Implement StellarSplitClient.cancelInvoice(invoiceId) with funded check before any RPC write - Emit invoice:cancelled event on success; add event type to SplitClientEventMap Issue #871 - bumpInvoiceTtl and getTtl - Add InvoiceTerminatedError (code: INVOICE_TERMINATED) for terminal-state invoices - Add TtlInfo type { ttlLedgers, approximateDays, health: TtlHealth } - Add TtlHealth union type ('healthy' | 'warning' | 'critical') - Implement StellarSplitClient.bumpInvoiceTtl(invoiceId) - refuses terminal invoices - Implement StellarSplitClient.getTtl(invoiceId) - returns health-classified TtlInfo Issue #870 - getFundingVelocity with hourly bucket aggregation - Add VelocityBucket type { hour, timestamp, amount } - Implement StellarSplitClient.getFundingVelocity(invoiceId, opts?) - last 168h default - Export computeTrendingScore(buckets) standalone pure function (0-100 score) - Implement StellarSplitClient.isTrending(invoiceId) - score > 70 threshold All new symbols exported from src/index.ts. Tests added in test/drips-wave-issues.test.ts. --- .gitignore | 18 ++ src/client.ts | 342 +++++++++++++++++++++++++ src/errors.ts | 112 ++++++++ src/index.ts | 41 +++ src/types.ts | 61 +++++ test/drips-wave-issues.test.ts | 454 +++++++++++++++++++++++++++++++++ 6 files changed, 1028 insertions(+) create mode 100644 test/drips-wave-issues.test.ts diff --git a/.gitignore b/.gitignore index cb697f22..a33b3a74 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,21 @@ task2.md task3.md task4.md somzilla.md + +# Test output artefacts +*.test-results.json +*.junit.xml +vitest-report.json +test-report.json + +# Heap dumps and profiler output +*.heapsnapshot +*.cpuprofile + +# Yarn / pnpm lock caches +.yarn/cache/ +.pnpm-store/ + +# Local developer overrides +*.local.ts +*.local.js diff --git a/src/client.ts b/src/client.ts index 8b44892f..fcfb9093 100644 --- a/src/client.ts +++ b/src/client.ts @@ -38,6 +38,8 @@ export type SplitClientEventMap = { "endpoint:demoted": { url: string; reason: "consecutive_errors" | "failed_health_check" }; /** A previously quarantined RpcLoadBalancer endpoint passed its health check and rejoined rotation. */ "endpoint:reinstated": { url: string }; + /** Emitted after a successful cancelInvoice() call (#872). */ + "invoice:cancelled": { invoiceId: string }; }; import { signTransaction } from "./wallet.js"; import { telemetry } from "./telemetry.js"; @@ -156,6 +158,9 @@ import type { BridgePaymentParams, BridgePaymentRequest, SignedBridgeProof, + PartialReleaseResult, + TtlInfo, + VelocityBucket, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -204,6 +209,10 @@ import { InvoiceIntegrityError, InvoiceNotCloneableError, InvalidTransactionTypeError, + InvalidBpsError, + OverReleaseError, + CannotCancelFundedInvoiceError, + InvoiceTerminatedError, } from "./errors.js"; import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; import { buildFeeBump } from "./feeBumpBuilder.js"; @@ -9501,6 +9510,339 @@ export class StellarSplitClient extends TypedEventEmitter { return { invoiceId, txHash }; } + + // --------------------------------------------------------------------------- + // Issue #873 — releasePartial: basis-point validated partial release + // --------------------------------------------------------------------------- + + /** + * Release a partial amount of an invoice's held funds expressed as + * basis points (1 bps = 0.01%). Performs client-side validation before + * submitting the `release_partial` contract entry point. + * + * @param invoiceId - The invoice to partially release. + * @param bps - Basis points to release (1–10 000 inclusive). + * @returns PartialReleaseResult with per-call and running totals. + * @throws {InvalidBpsError} When `bps` is outside the valid range. + * @throws {OverReleaseError} When adding `bps` would push `totalReleasedBps` past 10 000. + */ + async releasePartial(invoiceId: string, bps: number): Promise { + // Client-side guard: bps must be 1–10000 + if (!Number.isInteger(bps) || bps < 1 || bps > 10_000) { + throw new InvalidBpsError(bps); + } + + // Fetch current state to check accumulated totalReleasedBps + const invoice = await this._fetchInvoice(invoiceId); + const invoiceRecord = invoice as unknown as Record; + const currentTotalBps: number = + typeof invoiceRecord.totalReleasedBps === "number" + ? (invoiceRecord.totalReleasedBps as number) + : 0; + + if (currentTotalBps + bps > 10_000) { + throw new OverReleaseError(invoiceId, currentTotalBps, bps); + } + + const operation = this.contract.call( + "release_partial", + nativeToScVal(BigInt(invoiceId), { type: "u64" }), + nativeToScVal(bps, { type: "u32" }), + ); + + const startTime = Date.now(); + try { + const result = await this._submitTx(invoice.creator, operation); + + // Parse return value from contract: expected [amountReleased, remaining, newTotalBps] + const native = scValToNative(result.returnValue); + const amountReleased: bigint = toBigInt( + Array.isArray(native) ? native[0] : (native as Record).amount_released ?? 0n, + ); + const remaining: bigint = toBigInt( + Array.isArray(native) ? native[1] : (native as Record).remaining ?? 0n, + ); + const totalReleasedBps: number = + Array.isArray(native) + ? Number(native[2] ?? currentTotalBps + bps) + : Number((native as Record).total_released_bps ?? currentTotalBps + bps); + + telemetry.recordMethod("releasePartial", true, Date.now() - startTime); + this._cache?.invalidate(invoiceId); + + return { + txHash: result.txHash, + bps, + amountReleased, + remaining, + totalReleasedBps, + }; + } catch (error) { + telemetry.recordMethod("releasePartial", false, Date.now() - startTime); + throw error; + } + } + + // --------------------------------------------------------------------------- + // Issue #872 — cancelInvoice: pre-payment guard before cancellation + // --------------------------------------------------------------------------- + + /** + * Cancel an invoice. Guards against cancelling an invoice that has already + * received payments — throws {@link CannotCancelFundedInvoiceError} before + * any RPC write is attempted when `paidAmount > 0`. + * + * Emits an `'invoice:cancelled'` event on success. + * + * @param invoiceId - The invoice to cancel. + * @throws {CannotCancelFundedInvoiceError} When the invoice already has payments. + * @throws {InvoiceNotPendingError} When the invoice is not in Pending state. + */ + async cancelInvoice(invoiceId: string): Promise<{ txHash: string }> { + // Pre-payment guard: fetch current state before any write + const invoice = await this._fetchInvoice(invoiceId); + + // Guard: cannot cancel an invoice that has been funded + const paidAmount: bigint = invoice.funded ?? 0n; + if (paidAmount > 0n) { + throw new CannotCancelFundedInvoiceError(invoiceId, paidAmount); + } + + const operation = this.contract.call( + "cancel_invoice", + nativeToScVal(BigInt(invoiceId), { type: "u64" }), + ); + + const startTime = Date.now(); + try { + const result = await this._submitTx(invoice.creator, operation); + + telemetry.recordMethod("cancelInvoice", true, Date.now() - startTime); + this._cache?.invalidate(invoiceId); + + // Emit invoice:cancelled event for subscribers + this.emit("invoice:cancelled", { invoiceId }); + + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("cancelInvoice", false, Date.now() - startTime); + throw error; + } + } + + // --------------------------------------------------------------------------- + // Issue #871 — bumpInvoiceTtl / getTtl: storage TTL management + // --------------------------------------------------------------------------- + + /** + * Bump the on-chain storage TTL for an invoice so long-lived invoices are + * not evicted before they are released or refunded. + * + * @param invoiceId - The invoice whose storage entry should be extended. + * @throws {InvoiceTerminatedError} When the invoice is in a terminal state + * (Released, Refunded, or Cancelled). + */ + async bumpInvoiceTtl(invoiceId: string): Promise<{ txHash: string }> { + const invoice = await this._fetchInvoice(invoiceId); + + const TERMINAL_STATUSES: Array = [ + "Released", + "Refunded", + "Cancelled", + ]; + if (TERMINAL_STATUSES.includes(invoice.status)) { + throw new InvoiceTerminatedError(invoiceId, invoice.status); + } + + const operation = this.contract.call( + "bump_invoice_ttl", + nativeToScVal(BigInt(invoiceId), { type: "u64" }), + ); + + const startTime = Date.now(); + try { + const result = await this._submitTx(invoice.creator, operation); + telemetry.recordMethod("bumpInvoiceTtl", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("bumpInvoiceTtl", false, Date.now() - startTime); + throw error; + } + } + + /** + * Query the current storage TTL for an invoice and return a structured + * {@link TtlInfo} object with health classification. + * + * Health thresholds (approximate, based on 5-second ledger close time): + * - `'healthy'` — > 30 days + * - `'warning'` — 7–30 days + * - `'critical'` — < 7 days + * + * @param invoiceId - The invoice to query TTL for. + * @returns TtlInfo with ledger count, approximate days, and health status. + */ + async getTtl(invoiceId: string): Promise { + // Ledgers per second ≈ 1/5 (5-second average close time) + const LEDGERS_PER_DAY = Math.round((24 * 60 * 60) / 5); // ~17 280 + + const operation = this.contract.call( + "get_invoice_ttl", + nativeToScVal(BigInt(invoiceId), { type: "u64" }), + ); + + const startTime = Date.now(); + try { + const raw = await this._simulateView(operation); + const ttlLedgers: number = Number( + typeof raw === "bigint" + ? raw + : typeof raw === "number" + ? raw + : (raw as Record).ttl_ledgers ?? 0, + ); + + const approximateDays = ttlLedgers / LEDGERS_PER_DAY; + + let health: TtlInfo["health"]; + if (approximateDays > 30) { + health = "healthy"; + } else if (approximateDays >= 7) { + health = "warning"; + } else { + health = "critical"; + } + + telemetry.recordMethod("getTtl", true, Date.now() - startTime); + return { ttlLedgers, approximateDays, health }; + } catch (error) { + telemetry.recordMethod("getTtl", false, Date.now() - startTime); + throw error; + } + } + + // --------------------------------------------------------------------------- + // Issue #870 — getFundingVelocity / computeTrendingScore / isTrending + // --------------------------------------------------------------------------- + + /** + * Fetch hourly funding velocity buckets for an invoice, suitable for + * charting or trend analysis. + * + * @param invoiceId - The invoice to analyse. + * @param options.fromHour - Start hour offset (default: 168 hours ago). + * @param options.toHour - End hour offset (default: current hour). + * @param options.limit - Maximum number of buckets (default: 168 = 7 days). + * @returns Array of VelocityBucket objects ordered oldest-to-newest. + */ + async getFundingVelocity( + invoiceId: string, + options: { fromHour?: number; toHour?: number; limit?: number } = {}, + ): Promise { + const DEFAULT_HOURS = 168; // 7 days + const nowMs = Date.now(); + const msPerHour = 3_600_000; + const nowHour = Math.floor(nowMs / msPerHour); + + const toHour = options.toHour ?? nowHour; + const fromHour = options.fromHour ?? toHour - DEFAULT_HOURS; + const requestedCount = toHour - fromHour; + const limit = Math.min(options.limit ?? DEFAULT_HOURS, requestedCount > 0 ? requestedCount : DEFAULT_HOURS); + + const operation = this.contract.call( + "get_funding_velocity", + nativeToScVal(BigInt(invoiceId), { type: "u64" }), + nativeToScVal(fromHour, { type: "u64" }), + nativeToScVal(toHour, { type: "u64" }), + nativeToScVal(limit, { type: "u32" }), + ); + + const startTime = Date.now(); + try { + const raw = await this._simulateView(operation); + + // raw may be an array of [hourOffset, amount] tuples or a map/object + const entries: VelocityBucket[] = []; + + if (Array.isArray(raw)) { + for (let i = 0; i < raw.length; i++) { + const item = raw[i] as unknown; + let hour: number; + let amount: bigint; + + if (Array.isArray(item)) { + hour = Number(item[0]); + amount = toBigInt(item[1]); + } else if (item !== null && typeof item === "object") { + const obj = item as Record; + hour = Number(obj.hour ?? obj.hour_offset ?? i); + amount = toBigInt(obj.amount ?? obj.funded ?? 0n); + } else { + hour = fromHour + i; + amount = toBigInt(item); + } + + const timestamp = new Date((fromHour + (hour - fromHour)) * msPerHour); + entries.push({ hour, timestamp, amount }); + } + } + // Empty buckets: return empty array (handled gracefully) + + telemetry.recordMethod("getFundingVelocity", true, Date.now() - startTime); + return entries; + } catch (error) { + telemetry.recordMethod("getFundingVelocity", false, Date.now() - startTime); + throw error; + } + } + + /** + * Whether a given invoice is currently trending (trending score > 70). + * + * @param invoiceId - Invoice to check. + * @returns `true` if the trending score exceeds 70. + */ + async isTrending(invoiceId: string): Promise { + const buckets = await this.getFundingVelocity(invoiceId); + return computeTrendingScore(buckets) > 70; + } +} + +// --------------------------------------------------------------------------- +// Issue #870 — computeTrendingScore: exported standalone pure function +// --------------------------------------------------------------------------- + +/** + * Compute a 0–100 trending score from a series of hourly funding buckets. + * + * The algorithm compares the average volume of the most-recent 24 buckets + * against the average of the remaining (older) buckets. A score of 100 + * means recent volume is 2× or more the historic average; 0 means no + * recent activity at all. + * + * @param buckets - Array of VelocityBucket values, oldest-to-newest. + * @returns A number in the range [0, 100]. + */ +export function computeTrendingScore(buckets: VelocityBucket[]): number { + if (buckets.length === 0) return 0; + + const RECENT_WINDOW = 24; // last 24 hours + const recent = buckets.slice(-RECENT_WINDOW); + const historic = buckets.slice(0, Math.max(0, buckets.length - RECENT_WINDOW)); + + const sum = (arr: VelocityBucket[]) => + arr.reduce((acc, b) => acc + b.amount, 0n); + + const recentAvg = recent.length > 0 ? sum(recent) / BigInt(recent.length) : 0n; + const historicAvg = historic.length > 0 ? sum(historic) / BigInt(historic.length) : 0n; + + if (recentAvg === 0n) return 0; + if (historicAvg === 0n) return 100; + + // Score = clamp(recentAvg / historicAvg * 50, 0, 100) + // At 2× recent vs historic we hit 100. + const ratio = Number(recentAvg * 100n) / Number(historicAvg * 100n); + return Math.min(100, Math.round(ratio * 50)); } /** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */ diff --git a/src/errors.ts b/src/errors.ts index f35bd2a3..b2428ec8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2251,3 +2251,115 @@ export function isWalletConnectionTimeoutError(err: unknown): err is WalletConne return err instanceof WalletConnectionTimeoutError; } + +// --------------------------------------------------------------------------- +// Issue #873 — releasePartial basis-point validation errors +// --------------------------------------------------------------------------- + +/** + * Thrown when a basis-points value passed to releasePartial is outside the + * valid range of 1–10 000. + */ +export class InvalidBpsError extends StellarSplitError { + readonly bps: number; + + constructor(bps: number) { + super( + `Invalid basis-points value: ${bps}. Must be between 1 and 10000 (inclusive).`, + "INVALID_BPS", + { bps }, + ); + this.name = "InvalidBpsError"; + this.bps = bps; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvalidBpsError(err: unknown): err is InvalidBpsError { + return err instanceof InvalidBpsError; +} + +/** + * Thrown when the requested partial release would push totalReleasedBps beyond + * 10 000 (100%). + */ +export class OverReleaseError extends StellarSplitError { + readonly invoiceId: string; + readonly currentTotalBps: number; + readonly requestedBps: number; + + constructor(invoiceId: string, currentTotalBps: number, requestedBps: number) { + super( + `Release of ${requestedBps} bps would exceed 100% (currently at ${currentTotalBps} bps) for invoice ${invoiceId}.`, + "OVER_RELEASE", + { invoiceId, currentTotalBps, requestedBps }, + ); + this.name = "OverReleaseError"; + this.invoiceId = invoiceId; + this.currentTotalBps = currentTotalBps; + this.requestedBps = requestedBps; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isOverReleaseError(err: unknown): err is OverReleaseError { + return err instanceof OverReleaseError; +} + +// --------------------------------------------------------------------------- +// Issue #872 — cancelInvoice pre-payment guard error +// --------------------------------------------------------------------------- + +/** + * Thrown when cancelInvoice is called on an invoice that has already received + * at least one payment (paidAmount > 0). + */ +export class CannotCancelFundedInvoiceError extends StellarSplitError { + readonly invoiceId: string; + readonly paidAmount: bigint; + + constructor(invoiceId: string, paidAmount: bigint) { + super( + `Cannot cancel invoice ${invoiceId}: it has already received ${paidAmount} stroops in payments.`, + "CANNOT_CANCEL_FUNDED_INVOICE", + { invoiceId, paidAmount: paidAmount.toString() }, + ); + this.name = "CannotCancelFundedInvoiceError"; + this.invoiceId = invoiceId; + this.paidAmount = paidAmount; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isCannotCancelFundedInvoiceError(err: unknown): err is CannotCancelFundedInvoiceError { + return err instanceof CannotCancelFundedInvoiceError; +} + +// --------------------------------------------------------------------------- +// Issue #871 — TTL management errors +// --------------------------------------------------------------------------- + +/** + * Thrown when bumpInvoiceTtl is called on an invoice that is in a terminal + * state (Released, Refunded, or Cancelled). + */ +export class InvoiceTerminatedError extends StellarSplitError { + readonly invoiceId: string; + readonly status: string; + + constructor(invoiceId: string, status: string) { + super( + `Cannot bump TTL for invoice ${invoiceId}: it is in terminal state "${status}".`, + "INVOICE_TERMINATED", + { invoiceId, status }, + ); + this.name = "InvoiceTerminatedError"; + this.invoiceId = invoiceId; + this.status = status; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceTerminatedError(err: unknown): err is InvoiceTerminatedError { + return err instanceof InvoiceTerminatedError; +} diff --git a/src/index.ts b/src/index.ts index f9cb56b8..f9ae4102 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1427,3 +1427,44 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// Issue #873 — releasePartial: InvalidBpsError, OverReleaseError, PartialReleaseResult +// --------------------------------------------------------------------------- + +export { + InvalidBpsError, + isInvalidBpsError, + OverReleaseError, + isOverReleaseError, +} from "./errors.js"; + +export type { PartialReleaseResult } from "./types.js"; + +// --------------------------------------------------------------------------- +// Issue #872 — cancelInvoice: CannotCancelFundedInvoiceError +// --------------------------------------------------------------------------- + +export { + CannotCancelFundedInvoiceError, + isCannotCancelFundedInvoiceError, +} from "./errors.js"; + +// --------------------------------------------------------------------------- +// Issue #871 — bumpInvoiceTtl / getTtl: InvoiceTerminatedError, TtlInfo +// --------------------------------------------------------------------------- + +export { + InvoiceTerminatedError, + isInvoiceTerminatedError, +} from "./errors.js"; + +export type { TtlInfo, TtlHealth } from "./types.js"; + +// --------------------------------------------------------------------------- +// Issue #870 — getFundingVelocity: VelocityBucket, computeTrendingScore +// --------------------------------------------------------------------------- + +export type { VelocityBucket } from "./types.js"; + +export { computeTrendingScore } from "./client.js"; diff --git a/src/types.ts b/src/types.ts index b60a6ec7..44223a0e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2150,3 +2150,64 @@ export interface ClaimableBalanceRecord { /** Ledger sequence after which the predicate expires (optional). */ predicateExpiryLedger?: number; } + +// --------------------------------------------------------------------------- +// Issue #873 — releasePartial result type +// --------------------------------------------------------------------------- + +/** + * Result returned by {@link StellarSplitClient.releasePartial}. + */ +export interface PartialReleaseResult { + /** Transaction hash of the release_partial contract call. */ + txHash: string; + /** Basis points released in this call (1–10 000). */ + bps: number; + /** Amount of tokens (in stroops) disbursed in this release. */ + amountReleased: bigint; + /** Amount of tokens (in stroops) still held in the invoice after this release. */ + remaining: bigint; + /** Running total of basis points released across all partial releases. */ + totalReleasedBps: number; +} + +// --------------------------------------------------------------------------- +// Issue #871 — TTL info type +// --------------------------------------------------------------------------- + +/** + * Storage TTL health status for an invoice. + * - `'healthy'` — more than 30 days remaining. + * - `'warning'` — between 7 and 30 days remaining. + * - `'critical'` — fewer than 7 days remaining. + */ +export type TtlHealth = "healthy" | "warning" | "critical"; + +/** + * TTL information returned by {@link StellarSplitClient.getTtl}. + */ +export interface TtlInfo { + /** Remaining ledgers until the invoice's storage entry expires. */ + ttlLedgers: number; + /** Approximate number of days remaining (based on 5-second ledger close time). */ + approximateDays: number; + /** Health classification derived from the approximate days remaining. */ + health: TtlHealth; +} + +// --------------------------------------------------------------------------- +// Issue #870 — Funding velocity types +// --------------------------------------------------------------------------- + +/** + * A single hourly funding bucket returned by + * {@link StellarSplitClient.getFundingVelocity}. + */ +export interface VelocityBucket { + /** Hour offset (0 = oldest bucket in the requested range). */ + hour: number; + /** Wall-clock timestamp for this bucket's start. */ + timestamp: Date; + /** Total amount funded (in stroops) during this hour. */ + amount: bigint; +} diff --git a/test/drips-wave-issues.test.ts b/test/drips-wave-issues.test.ts new file mode 100644 index 00000000..ab122d0c --- /dev/null +++ b/test/drips-wave-issues.test.ts @@ -0,0 +1,454 @@ +/** + * Tests for Drips Wave issues: + * #873 — releasePartial with basis-point validation + * #872 — cancelInvoice with pre-payment guard + * #871 — bumpInvoiceTtl and getTtl + * #870 — getFundingVelocity, computeTrendingScore, isTrending + */ + +import { Keypair, StrKey, xdr } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { + InvalidBpsError, + OverReleaseError, + CannotCancelFundedInvoiceError, + InvoiceTerminatedError, +} from "../src/errors.js"; +import { computeTrendingScore } from "../src/client.js"; +import type { VelocityBucket } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeClient(): StellarSplitClient { + return new StellarSplitClient({ + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + validatePassphrase: false, + }); +} + +function makeInvoice(overrides: Partial<{ + id: string; + creator: string; + funded: bigint; + status: "Pending" | "Released" | "Refunded" | "Cancelled"; + totalReleasedBps: number; +}> = {}) { + return { + id: overrides.id ?? "42", + creator: Keypair.random().publicKey(), + recipients: [], + token: StrKey.encodeContract(Keypair.random().rawPublicKey()), + deadline: Math.floor(Date.now() / 1000) + 86_400, + funded: overrides.funded ?? 0n, + status: (overrides.status ?? "Pending") as "Pending" | "Released" | "Refunded" | "Cancelled", + payments: [], + totalReleasedBps: overrides.totalReleasedBps ?? 0, + }; +} + +function makeFakeScVal(value: unknown): xdr.ScVal { + // Return a placeholder ScVal — the mock bypasses the real decoder anyway. + return { toXDR: () => Buffer.alloc(0) } as unknown as xdr.ScVal; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// =========================================================================== +// Issue #873 — releasePartial +// =========================================================================== + +describe("releasePartial (#873)", () => { + it("releases a valid bps amount and returns a PartialReleaseResult", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "1", funded: 1_000_000n, status: "Pending" }); + + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + vi.spyOn(client as any, "_submitTx").mockResolvedValue({ + txHash: "tx-hash-123", + returnValue: makeFakeScVal([500_000n, 500_000n, 5_000]), + }); + + // Manually handle scValToNative by overriding the method + const nativeToScValModule = await import("@stellar/stellar-sdk"); + vi.spyOn(nativeToScValModule, "scValToNative").mockReturnValue([500_000n, 500_000n, 5000]); + + const result = await client.releasePartial("1", 5_000); + + expect(result.txHash).toBe("tx-hash-123"); + expect(result.bps).toBe(5_000); + expect(result.amountReleased).toBe(500_000n); + expect(result.remaining).toBe(500_000n); + expect(result.totalReleasedBps).toBe(5_000); + }); + + it("throws InvalidBpsError when bps is 0", async () => { + const client = makeClient(); + await expect(client.releasePartial("1", 0)).rejects.toThrow(InvalidBpsError); + }); + + it("throws InvalidBpsError when bps is 10001", async () => { + const client = makeClient(); + await expect(client.releasePartial("1", 10_001)).rejects.toThrow(InvalidBpsError); + }); + + it("throws InvalidBpsError when bps is negative", async () => { + const client = makeClient(); + await expect(client.releasePartial("1", -1)).rejects.toThrow(InvalidBpsError); + }); + + it("throws OverReleaseError when bps would exceed 10000 total", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "2", funded: 1_000_000n, totalReleasedBps: 8_000 }); + + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + // Requesting 3000 more would push total to 11000 (> 10000) + await expect(client.releasePartial("2", 3_000)).rejects.toThrow(OverReleaseError); + }); + + it("OverReleaseError carries correct context", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "3", funded: 1_000_000n, totalReleasedBps: 9_500 }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + let err: OverReleaseError | undefined; + try { + await client.releasePartial("3", 1_000); + } catch (e) { + err = e as OverReleaseError; + } + + expect(err).toBeInstanceOf(OverReleaseError); + expect(err!.invoiceId).toBe("3"); + expect(err!.currentTotalBps).toBe(9_500); + expect(err!.requestedBps).toBe(1_000); + }); + + it("allows exactly 10000 total bps (100%)", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "4", funded: 1_000_000n, totalReleasedBps: 9_000 }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + vi.spyOn(client as any, "_submitTx").mockResolvedValue({ + txHash: "tx-full", + returnValue: makeFakeScVal([1_000_000n, 0n, 10_000]), + }); + + const nativeToScValModule = await import("@stellar/stellar-sdk"); + vi.spyOn(nativeToScValModule, "scValToNative").mockReturnValue([1_000_000n, 0n, 10_000]); + + const result = await client.releasePartial("4", 1_000); + expect(result.totalReleasedBps).toBe(10_000); + }); +}); + +// =========================================================================== +// Issue #872 — cancelInvoice +// =========================================================================== + +describe("cancelInvoice (#872)", () => { + it("cancels a zero-payment invoice successfully", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "10", funded: 0n, status: "Pending" }); + + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + vi.spyOn(client as any, "_submitTx").mockResolvedValue({ + txHash: "cancel-tx-hash", + returnValue: makeFakeScVal(null), + }); + + const result = await client.cancelInvoice("10"); + expect(result.txHash).toBe("cancel-tx-hash"); + }); + + it("throws CannotCancelFundedInvoiceError before any RPC write for a funded invoice", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "11", funded: 500_000n, status: "Pending" }); + + const fetchSpy = vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + const submitSpy = vi.spyOn(client as any, "_submitTx"); + + await expect(client.cancelInvoice("11")).rejects.toThrow(CannotCancelFundedInvoiceError); + + // submitTx must NOT have been called — no RPC write for funded invoices + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it("CannotCancelFundedInvoiceError carries the paidAmount", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "12", funded: 250_000n }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + let err: CannotCancelFundedInvoiceError | undefined; + try { + await client.cancelInvoice("12"); + } catch (e) { + err = e as CannotCancelFundedInvoiceError; + } + + expect(err).toBeInstanceOf(CannotCancelFundedInvoiceError); + expect(err!.paidAmount).toBe(250_000n); + expect(err!.invoiceId).toBe("12"); + expect(err!.message).toContain("250000"); + }); + + it("emits invoice:cancelled event on successful cancellation", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "13", funded: 0n }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + vi.spyOn(client as any, "_submitTx").mockResolvedValue({ + txHash: "cancel-event-tx", + returnValue: makeFakeScVal(null), + }); + + const eventSpy = vi.fn(); + (client as any).on("invoice:cancelled", eventSpy); + + await client.cancelInvoice("13"); + + expect(eventSpy).toHaveBeenCalledOnce(); + }); +}); + +// =========================================================================== +// Issue #871 — bumpInvoiceTtl / getTtl +// =========================================================================== + +describe("bumpInvoiceTtl (#871)", () => { + it("bumps TTL for an active invoice", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "20", status: "Pending" }); + + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + vi.spyOn(client as any, "_submitTx").mockResolvedValue({ + txHash: "bump-ttl-tx", + returnValue: makeFakeScVal(null), + }); + + const result = await client.bumpInvoiceTtl("20"); + expect(result.txHash).toBe("bump-ttl-tx"); + }); + + it("throws InvoiceTerminatedError for a Released invoice", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "21", status: "Released" }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + await expect(client.bumpInvoiceTtl("21")).rejects.toThrow(InvoiceTerminatedError); + }); + + it("throws InvoiceTerminatedError for a Refunded invoice", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "22", status: "Refunded" }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + await expect(client.bumpInvoiceTtl("22")).rejects.toThrow(InvoiceTerminatedError); + }); + + it("throws InvoiceTerminatedError for a Cancelled invoice", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "23", status: "Cancelled" }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + await expect(client.bumpInvoiceTtl("23")).rejects.toThrow(InvoiceTerminatedError); + }); + + it("InvoiceTerminatedError carries correct status", async () => { + const client = makeClient(); + const invoice = makeInvoice({ id: "24", status: "Released" }); + vi.spyOn(client as any, "_fetchInvoice").mockResolvedValue(invoice); + + let err: InvoiceTerminatedError | undefined; + try { + await client.bumpInvoiceTtl("24"); + } catch (e) { + err = e as InvoiceTerminatedError; + } + expect(err!.invoiceId).toBe("24"); + expect(err!.status).toBe("Released"); + }); +}); + +describe("getTtl (#871)", () => { + it("returns healthy TtlInfo for a ledger count over 30 days", async () => { + const client = makeClient(); + const LEDGERS_PER_DAY = Math.round((24 * 60 * 60) / 5); // ~17280 + const ttlLedgers = LEDGERS_PER_DAY * 60; // 60 days + + vi.spyOn(client as any, "_simulateView").mockResolvedValue(ttlLedgers); + + const info = await client.getTtl("30"); + expect(info.ttlLedgers).toBe(ttlLedgers); + expect(info.approximateDays).toBeCloseTo(60, 0); + expect(info.health).toBe("healthy"); + }); + + it("returns warning TtlInfo for a ledger count between 7 and 30 days", async () => { + const client = makeClient(); + const LEDGERS_PER_DAY = Math.round((24 * 60 * 60) / 5); + const ttlLedgers = LEDGERS_PER_DAY * 15; // 15 days + + vi.spyOn(client as any, "_simulateView").mockResolvedValue(ttlLedgers); + + const info = await client.getTtl("31"); + expect(info.health).toBe("warning"); + }); + + it("returns critical TtlInfo for a ledger count under 7 days", async () => { + const client = makeClient(); + const LEDGERS_PER_DAY = Math.round((24 * 60 * 60) / 5); + const ttlLedgers = LEDGERS_PER_DAY * 3; // 3 days + + vi.spyOn(client as any, "_simulateView").mockResolvedValue(ttlLedgers); + + const info = await client.getTtl("32"); + expect(info.health).toBe("critical"); + }); + + it("returns critical TtlInfo when TTL is 0 ledgers", async () => { + const client = makeClient(); + vi.spyOn(client as any, "_simulateView").mockResolvedValue(0); + + const info = await client.getTtl("33"); + expect(info.ttlLedgers).toBe(0); + expect(info.approximateDays).toBe(0); + expect(info.health).toBe("critical"); + }); +}); + +// =========================================================================== +// Issue #870 — getFundingVelocity / computeTrendingScore / isTrending +// =========================================================================== + +describe("getFundingVelocity (#870)", () => { + it("returns a 7-day range of VelocityBuckets by default", async () => { + const client = makeClient(); + const fakeBuckets = Array.from({ length: 168 }, (_, i) => [i, BigInt(i * 1_000)]); + vi.spyOn(client as any, "_simulateView").mockResolvedValue(fakeBuckets); + + const result = await client.getFundingVelocity("50"); + + expect(result).toHaveLength(168); + expect(result[0].amount).toBe(0n); + expect(result[167].amount).toBe(167_000n); + expect(result[0].timestamp).toBeInstanceOf(Date); + }); + + it("respects custom fromHour/toHour/limit options", async () => { + const client = makeClient(); + const now = Math.floor(Date.now() / 3_600_000); + const from = now - 48; + const to = now; + const fakeBuckets = Array.from({ length: 10 }, (_, i) => [from + i, BigInt(i * 500)]); + vi.spyOn(client as any, "_simulateView").mockResolvedValue(fakeBuckets); + + const result = await client.getFundingVelocity("51", { + fromHour: from, + toHour: to, + limit: 10, + }); + + expect(result).toHaveLength(10); + }); + + it("handles empty buckets gracefully", async () => { + const client = makeClient(); + vi.spyOn(client as any, "_simulateView").mockResolvedValue([]); + + const result = await client.getFundingVelocity("52"); + expect(result).toHaveLength(0); + expect(Array.isArray(result)).toBe(true); + }); +}); + +describe("computeTrendingScore (#870)", () => { + it("returns 0 for empty buckets", () => { + expect(computeTrendingScore([])).toBe(0); + }); + + it("returns 100 when there is recent volume but no historic volume", () => { + const buckets: VelocityBucket[] = Array.from({ length: 10 }, (_, i) => ({ + hour: i, + timestamp: new Date(), + amount: BigInt(i < 8 ? 0 : 1_000_000), // only last 2 buckets have volume + })); + // All buckets fit within recent window (<=24), historic = 0 + const recentBuckets: VelocityBucket[] = [ + { hour: 0, timestamp: new Date(), amount: 1_000_000n }, + ]; + expect(computeTrendingScore(recentBuckets)).toBe(100); + }); + + it("returns 0 when recent volume is zero", () => { + const buckets: VelocityBucket[] = Array.from({ length: 30 }, (_, i) => ({ + hour: i, + timestamp: new Date(), + amount: i < 6 ? 1_000_000n : 0n, // last 24 are zero + })); + expect(computeTrendingScore(buckets)).toBe(0); + }); + + it("scores above 70 when recent volume is 2x the historic average", () => { + // Build 48 buckets: first 24 with avg 1000, last 24 with avg 2000 + const buckets: VelocityBucket[] = [ + ...Array.from({ length: 24 }, (_, i) => ({ + hour: i, + timestamp: new Date(), + amount: 1_000n, + })), + ...Array.from({ length: 24 }, (_, i) => ({ + hour: i + 24, + timestamp: new Date(), + amount: 2_000n, + })), + ]; + const score = computeTrendingScore(buckets); + expect(score).toBeGreaterThan(70); + }); +}); + +describe("isTrending (#870)", () => { + it("returns true when trending score is above 70", async () => { + const client = makeClient(); + // Make getFundingVelocity return buckets that yield a score > 70 + const highActivity: VelocityBucket[] = [ + ...Array.from({ length: 24 }, (_, i) => ({ + hour: i, + timestamp: new Date(), + amount: 1_000n, + })), + ...Array.from({ length: 24 }, (_, i) => ({ + hour: i + 24, + timestamp: new Date(), + amount: 2_000n, + })), + ]; + vi.spyOn(client, "getFundingVelocity").mockResolvedValue(highActivity); + + expect(await client.isTrending("60")).toBe(true); + }); + + it("returns false when trending score is 70 or below", async () => { + const client = makeClient(); + // Same activity across all buckets → score ≈ 50 + const flatActivity: VelocityBucket[] = Array.from({ length: 48 }, (_, i) => ({ + hour: i, + timestamp: new Date(), + amount: 1_000n, + })); + vi.spyOn(client, "getFundingVelocity").mockResolvedValue(flatActivity); + + expect(await client.isTrending("61")).toBe(false); + }); + + it("returns false for empty buckets", async () => { + const client = makeClient(); + vi.spyOn(client, "getFundingVelocity").mockResolvedValue([]); + expect(await client.isTrending("62")).toBe(false); + }); +});