diff --git a/src/client.ts b/src/client.ts index 21d9281..5a6ac72 100644 --- a/src/client.ts +++ b/src/client.ts @@ -103,6 +103,10 @@ import { computePrediction } from "./predictor.js"; import type { CompletionPrediction } from "./predictor.js"; import { PriorityQueue } from "./priorityQueue.js"; import type { RequestPriority } from "./priorityQueue.js"; +import { IdempotencyManager } from "./idempotency.js"; +import type { IdempotencyConfig } from "./idempotency.js"; +import { validateInvoicePayload } from "./payloadGuard.js"; +import type { PayloadGuardConfig } from "./payloadGuard.js"; import { HorizonFallbackReader } from "./horizonFallback.js"; import type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; import { FallbackChain } from "./fallbackChain.js"; @@ -182,6 +186,15 @@ export interface StellarSplitClientConfig { */ sponsorAccount?: string; /** + * Optional idempotency configuration for write methods. + * When provided, duplicate submissions are detected and short-circuited. + */ + idempotency?: IdempotencyConfig; + /** + * Optional payload guard configuration for createInvoice. + * When provided, invoice payloads are checked before submission. + */ + payloadGuard?: PayloadGuardConfig; * Optional list of plugins to register at construction time. * Each plugin's `install()` is called during the constructor, and * `onInit()` is invoked once all subsystems are ready. @@ -235,6 +248,7 @@ export class StellarSplitClient { private _hooks: InvoiceLifecycleHooks = {}; private _retryEngine: RetryEngine | null = null; private _horizonReader: HorizonFallbackReader | null = null; + private _idempotency: IdempotencyManager | null = null; private get server(): SorobanRpc.Server { return this._rpcClient ?? this._standby?.server ?? this._mainServer; @@ -357,6 +371,10 @@ export class StellarSplitClient { this._horizonReader = new HorizonFallbackReader(config.horizonUrl); } + if (config.idempotency) { + this._idempotency = new IdempotencyManager(config.idempotency); + } + initHealthDashboard(this.server, this._dedup); // Register and initialize config-level plugins @@ -486,6 +504,10 @@ export class StellarSplitClient { ): Promise<{ invoiceId: string; txHash: string }> { const startTime = Date.now(); try { + if (this.config.payloadGuard) { + validateInvoicePayload(params, this.config.payloadGuard); + } + const recipientAddresses = params.recipients.map((r) => nativeToScVal(r.address, { type: "address" }) ); @@ -1939,12 +1961,36 @@ export class StellarSplitClient { priority: RequestPriority = "normal" ): Promise<{ txHash: string; returnValue: xdr.ScVal }> { return this._queue.enqueue(priority, async () => { + if (this._idempotency) { + const opXdr = operation.toXDR().toString("base64"); + const key = this._idempotency.generateKey(sourceAddress, opXdr); + const existing = this._idempotency.getResult(key); + if (existing) { + return { + txHash: existing.txHash, + returnValue: xdr.ScVal.scvVoid(), + }; + } + } + try { - return await this._doSubmitTx(sourceAddress, operation); + const result = await this._doSubmitTx(sourceAddress, operation); + if (this._idempotency) { + const opXdr = operation.toXDR().toString("base64"); + const key = this._idempotency.generateKey(sourceAddress, opXdr); + this._idempotency.tryClaim(key, { txHash: result.txHash }); + } + return result; } catch (error) { if (this._standby) { this._standby.failover(); - return await this._doSubmitTx(sourceAddress, operation); + const result = await this._doSubmitTx(sourceAddress, operation); + if (this._idempotency) { + const opXdr = operation.toXDR().toString("base64"); + const key = this._idempotency.generateKey(sourceAddress, opXdr); + this._idempotency.tryClaim(key, { txHash: result.txHash }); + } + return result; } throw error; } diff --git a/src/forecast.ts b/src/forecast.ts new file mode 100644 index 0000000..0828f96 --- /dev/null +++ b/src/forecast.ts @@ -0,0 +1,134 @@ +import type { Invoice, Payment } from "./types.js"; +import { computePrediction } from "./predictor.js"; +import type { CompletionPrediction } from "./predictor.js"; + +export interface HistoricalInvoiceSample { + invoiceId: string; + total: bigint; + funded: bigint; + payments: Payment[]; + creator: string; + status: string; +} + +export interface ForecastConfig { + /** Amount range tolerance (percentage) for considering similar invoices. Default: 0.5 */ + amountRangeTolerance?: number; + /** Minimum number of historical samples needed for a historical forecast. Default: 3 */ + minHistoricalSamples?: number; +} + +export interface PaymentForecast { + currentPrediction: CompletionPrediction; + historicalPrediction: CompletionPrediction | null; + historicalSampleSize: number; + blendedEstimate: number | null; + blendedConfidence: number; +} + +function amountDifference(a: bigint, b: bigint): number { + const max = a > b ? a : b; + if (max === 0n) return 0; + const diff = a > b ? a - b : b - a; + return Number((diff * 100n) / max) / 100; +} + +function isSimilarAmount( + invoiceTotal: bigint, + historicalTotal: bigint, + tolerance: number +): boolean { + return amountDifference(invoiceTotal, historicalTotal) <= tolerance; +} + +export function computePaymentForecast( + invoice: Invoice, + historicalInvoices: Invoice[], + config?: ForecastConfig +): PaymentForecast { + const tolerance = config?.amountRangeTolerance ?? 0.5; + const minSamples = config?.minHistoricalSamples ?? 3; + + const currentPrediction = computePrediction( + invoice.payments, + invoice.recipients.reduce((sum, r) => sum + r.amount, 0n), + invoice.funded + ); + + const sameCreator = historicalInvoices.filter( + (h) => h.creator === invoice.creator && h.id !== invoice.id + ); + + const similarAmount = sameCreator.filter((h) => { + const hTotal = h.recipients.reduce((sum, r) => sum + r.amount, 0n); + const invTotal = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); + return isSimilarAmount(invTotal, hTotal, tolerance); + }); + + const historicalSamples: HistoricalInvoiceSample[] = similarAmount + .filter((h) => h.status === "Released" && h.payments.length >= 2) + .map((h) => ({ + invoiceId: h.id, + total: h.recipients.reduce((sum, r) => sum + r.amount, 0n), + funded: h.funded, + payments: h.payments, + creator: h.creator, + status: h.status, + })); + + let historicalPrediction: CompletionPrediction | null = null; + + if (historicalSamples.length >= minSamples) { + const allPayments = historicalSamples.flatMap((s) => s.payments); + const maxTotal = historicalSamples.reduce( + (max, s) => (s.total > max ? s.total : max), + 0n + ); + const totalFunded = historicalSamples.reduce( + (sum, s) => sum + s.funded, + 0n + ); + + historicalPrediction = computePrediction( + allPayments, + maxTotal, + totalFunded + ); + } + + let blendedEstimate: number | null = null; + let blendedConfidence = currentPrediction.confidence; + + if (currentPrediction.estimatedDate !== null) { + blendedEstimate = currentPrediction.estimatedDate; + blendedConfidence = currentPrediction.confidence; + } + + if ( + historicalPrediction?.estimatedDate !== null && + historicalPrediction !== null + ) { + const histConfidence = Math.min(historicalSamples.length / 20, 1); + const totalConf = currentPrediction.confidence + histConfidence; + + if (totalConf > 0) { + const currentWeight = currentPrediction.confidence / totalConf; + const histWeight = histConfidence / totalConf; + + blendedEstimate = Math.round( + (currentPrediction.estimatedDate ?? historicalPrediction.estimatedDate!) * + currentWeight + + historicalPrediction.estimatedDate! * histWeight + ); + blendedConfidence = Math.min(currentPrediction.confidence + histConfidence, 1); + } + } + + return { + currentPrediction, + historicalPrediction, + historicalSampleSize: historicalSamples.length, + blendedEstimate, + blendedConfidence, + }; +} diff --git a/src/idempotency.ts b/src/idempotency.ts new file mode 100644 index 0000000..c3324a8 --- /dev/null +++ b/src/idempotency.ts @@ -0,0 +1,83 @@ +import { createHash } from "crypto"; + +export interface IdempotencyConfig { + /** Duration (ms) to remember completed keys. Default: 300_000 (5 min). */ + ttlMs?: number; + /** Max entries in the key store before evicting oldest. Default: 10_000. */ + maxEntries?: number; +} + +interface IdempotencyEntry { + result: { txHash: string; returnValue?: string }; + expiresAt: number; +} + +export class IdempotencyManager { + private readonly store = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + + constructor(config?: IdempotencyConfig) { + this.ttlMs = config?.ttlMs ?? 300_000; + this.maxEntries = config?.maxEntries ?? 10_000; + } + + generateKey( + sourceAddress: string, + operationXdr: string + ): string { + const raw = `${sourceAddress}:${operationXdr}`; + return createHash("sha256").update(raw).digest("hex"); + } + + tryClaim( + key: string, + result: { txHash: string; returnValue?: string } + ): { duplicate: boolean; existing?: { txHash: string } } { + this.sweep(); + + const existing = this.store.get(key); + if (existing) { + return { + duplicate: true, + existing: { txHash: existing.result.txHash }, + }; + } + + if (this.store.size >= this.maxEntries) { + const oldest = this.store.keys().next(); + if (oldest.value) this.store.delete(oldest.value); + } + + this.store.set(key, { + result, + expiresAt: Date.now() + this.ttlMs, + }); + + return { duplicate: false }; + } + + isDuplicate(key: string): boolean { + this.sweep(); + return this.store.has(key); + } + + getResult(key: string): { txHash: string } | null { + this.sweep(); + const entry = this.store.get(key); + return entry ? { txHash: entry.result.txHash } : null; + } + + clear(): void { + this.store.clear(); + } + + private sweep(): void { + const now = Date.now(); + for (const [key, entry] of this.store) { + if (now > entry.expiresAt) { + this.store.delete(key); + } + } + } +} diff --git a/src/index.ts b/src/index.ts index da3378d..9854e0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -291,3 +291,28 @@ export type { ClaimableRefundResult, ClaimableRefundEntry, } from "./claimableBalanceFallback.js"; + +export { IdempotencyManager } from "./idempotency.js"; +export type { IdempotencyConfig } from "./idempotency.js"; + +export { + validateInvoicePayload, + PayloadSizeError, +} from "./payloadGuard.js"; +export type { + PayloadGuardConfig, + PayloadViolation, +} from "./payloadGuard.js"; + +export { computeCreatorReputation } from "./reputation.js"; +export type { + CreatorReputationScore, + ReputationConfig, +} from "./reputation.js"; + +export { computePaymentForecast } from "./forecast.js"; +export type { + PaymentForecast, + ForecastConfig, + HistoricalInvoiceSample, +} from "./forecast.js"; diff --git a/src/payloadGuard.ts b/src/payloadGuard.ts new file mode 100644 index 0000000..36f9817 --- /dev/null +++ b/src/payloadGuard.ts @@ -0,0 +1,95 @@ +import type { CreateInvoiceParams, Recipient } from "./types.js"; + +export const DEFAULT_MAX_INVOICE_SIZE_BYTES = 8_192; +export const DEFAULT_MAX_RECIPIENTS = 50; +export const DEFAULT_MAX_MEMO_LENGTH = 512; + +export interface PayloadGuardConfig { + maxInvoiceSizeBytes?: number; + maxRecipients?: number; + maxMemoLength?: number; +} + +export interface PayloadViolation { + field: string; + issue: string; + actual: number; + limit: number; +} + +export class PayloadSizeError extends Error { + readonly violations: PayloadViolation[]; + + constructor(violations: PayloadViolation[]) { + const messages = violations.map( + (v) => `${v.field}: ${v.issue} (${v.actual} > ${v.limit})` + ); + super(`Invoice payload too large:\n${messages.join("\n")}`); + this.name = "PayloadSizeError"; + this.violations = violations; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function validateInvoicePayload( + params: CreateInvoiceParams, + config?: PayloadGuardConfig +): void { + const maxSize = config?.maxInvoiceSizeBytes ?? DEFAULT_MAX_INVOICE_SIZE_BYTES; + const maxRecipients = config?.maxRecipients ?? DEFAULT_MAX_RECIPIENTS; + const maxMemoLength = config?.maxMemoLength ?? DEFAULT_MAX_MEMO_LENGTH; + + const violations: PayloadViolation[] = []; + + if (params.recipients.length > maxRecipients) { + violations.push({ + field: "recipients", + issue: "Too many recipients", + actual: params.recipients.length, + limit: maxRecipients, + }); + } + + for (const r of params.recipients) { + if (r.address.length > 56) { + violations.push({ + field: `recipient[].address`, + issue: "Address exceeds Stellar length", + actual: r.address.length, + limit: 56, + }); + } + } + + const serialized = serializePayload(params); + const sizeBytes = new TextEncoder().encode(serialized).length; + + if (sizeBytes > maxSize) { + violations.push({ + field: "payload", + issue: "Total serialized size exceeds limit", + actual: sizeBytes, + limit: maxSize, + }); + } + + if (params.memo !== undefined && params.memo.length > maxMemoLength) { + violations.push({ + field: "memo", + issue: "Memo too long", + actual: params.memo.length, + limit: maxMemoLength, + }); + } + + if (violations.length > 0) { + throw new PayloadSizeError(violations); + } +} + +function serializePayload(params: CreateInvoiceParams): string { + const recipientsStr = params.recipients + .map((r) => `${r.address}:${r.amount.toString()}`) + .join(","); + return `${params.creator}|${params.token}|${params.deadline}|${recipientsStr}|${params.memo ?? ""}`; +} diff --git a/src/reputation.ts b/src/reputation.ts new file mode 100644 index 0000000..5cc6d50 --- /dev/null +++ b/src/reputation.ts @@ -0,0 +1,113 @@ +import type { Invoice, InvoiceStatus } from "./types.js"; + +export interface CreatorReputationScore { + creator: string; + completionRate: number; + averageFundingTimeSeconds: number | null; + disputeRate: number; + totalInvoices: number; + completedInvoices: number; + disputedInvoices: number; + overallScore: number; +} + +export interface ReputationConfig { + /** Weight for completion rate in overall score (0-1). Default: 0.4 */ + completionWeight?: number; + /** Weight for average funding time in overall score (0-1). Default: 0.3 */ + fundingTimeWeight?: number; + /** Weight for low dispute rate in overall score (0-1). Default: 0.3 */ + disputeWeight?: number; + /** Maximum funding time (seconds) considered for best score. Default: 7 days. */ + maxFundingTimeSeconds?: number; +} + +const DEFAULT_CONFIG: Required = { + completionWeight: 0.4, + fundingTimeWeight: 0.3, + disputeWeight: 0.3, + maxFundingTimeSeconds: 604_800, +}; + +function terminatedOk(status: InvoiceStatus): boolean { + return status === "Released"; +} + +function isDisputed(status: InvoiceStatus): boolean { + return status === "Refunded"; +} + +export function computeCreatorReputation( + invoices: Invoice[], + config?: ReputationConfig +): CreatorReputationScore { + const { + completionWeight, + fundingTimeWeight, + disputeWeight, + maxFundingTimeSeconds, + } = { ...DEFAULT_CONFIG, ...config }; + + const totalInvoices = invoices.length; + + if (totalInvoices === 0) { + return { + creator: "", + completionRate: 0, + averageFundingTimeSeconds: null, + disputeRate: 0, + totalInvoices: 0, + completedInvoices: 0, + disputedInvoices: 0, + overallScore: 0, + }; + } + + const creator = invoices[0]!.creator; + + const completedInvoices = invoices.filter((inv) => terminatedOk(inv.status)).length; + const disputedInvoices = invoices.filter((inv) => isDisputed(inv.status)).length; + + const completionRate = totalInvoices > 0 ? completedInvoices / totalInvoices : 0; + const disputeRate = totalInvoices > 0 ? disputedInvoices / totalInvoices : 0; + + const fundingTimes: number[] = []; + for (const inv of invoices) { + if (terminatedOk(inv.status) && inv.payments.length > 0) { + const created = inv.deadline - 14 * 86400; + const firstPayment = inv.payments.reduce( + (earliest, p) => (p.timestamp && p.timestamp < earliest ? p.timestamp : earliest), + Infinity + ); + if (firstPayment !== Infinity) { + fundingTimes.push(firstPayment - created); + } + } + } + + const averageFundingTimeSeconds = + fundingTimes.length > 0 + ? fundingTimes.reduce((a, b) => a + b, 0) / fundingTimes.length + : null; + + const fundingTimeScore = + averageFundingTimeSeconds !== null + ? Math.max(0, 1 - averageFundingTimeSeconds / maxFundingTimeSeconds) + : 0; + + const overallScore = + completionRate * completionWeight + + fundingTimeScore * fundingTimeWeight + + (1 - disputeRate) * disputeWeight; + + return { + creator, + completionRate, + averageFundingTimeSeconds, + disputeRate, + totalInvoices, + completedInvoices, + disputedInvoices, + overallScore: Math.round(overallScore * 100) / 100, + }; +} diff --git a/test/forecast.test.ts b/test/forecast.test.ts new file mode 100644 index 0000000..c3adab2 --- /dev/null +++ b/test/forecast.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { computePaymentForecast } from "../src/forecast.js"; +import type { Invoice, Payment } from "../src/types.js"; + +function makePayment( + payer: string, + amount: bigint, + timestamp: number +): Payment { + return { payer, amount, timestamp }; +} + +function makeInvoice( + id: string, + creator: string, + total: bigint, + funded: bigint, + payments: Payment[], + status: string = "Pending" +): Invoice { + return { + id, + creator, + recipients: [{ address: "GPAYEE", amount: total }], + token: "USDC", + deadline: 1_800_000_000, + funded, + status: status as "Pending" | "Released" | "Refunded" | "Cancelled", + payments, + }; +} + +describe("computePaymentForecast", () => { + it("returns currentPrediction with no historical data", () => { + const invoice = makeInvoice("inv-1", "GCREATOR", 1000n, 500n, [ + makePayment("GPAYER", 500n, 1_800_100_000), + ]); + + const forecast = computePaymentForecast(invoice, []); + expect(forecast.currentPrediction).toBeDefined(); + expect(forecast.historicalPrediction).toBeNull(); + expect(forecast.historicalSampleSize).toBe(0); + }); + + it("returns historicalPrediction when enough similar invoices exist", () => { + const invoice = makeInvoice("inv-new", "GCREATOR", 1000n, 0n, []); + + const historical = [ + makeInvoice("h1", "GCREATOR", 1100n, 1000n, [ + makePayment("GPAYER", 500n, 1_800_100_000), + makePayment("GPAYER", 500n, 1_800_200_000), + ], "Released"), + makeInvoice("h2", "GCREATOR", 900n, 900n, [ + makePayment("GPAYER", 300n, 1_800_300_000), + makePayment("GPAYER", 600n, 1_800_400_000), + ], "Released"), + makeInvoice("h3", "GCREATOR", 1050n, 1050n, [ + makePayment("GPAYER", 1000n, 1_800_100_000), + makePayment("GPAYER", 50n, 1_800_500_000), + ], "Released"), + ]; + + const forecast = computePaymentForecast(invoice, historical); + expect(forecast.historicalPrediction).not.toBeNull(); + expect(forecast.historicalSampleSize).toBe(3); + }); + + it("filters by same creator", () => { + const invoice = makeInvoice("inv-new", "GCREATOR", 1000n, 0n, []); + + const historical = [ + makeInvoice("h1", "OTHER", 1100n, 1000n, [ + makePayment("GPAYER", 500n, 1_800_100_000), + makePayment("GPAYER", 500n, 1_800_200_000), + ], "Released"), + ]; + + const forecast = computePaymentForecast(invoice, historical); + expect(forecast.historicalSampleSize).toBe(0); + expect(forecast.historicalPrediction).toBeNull(); + }); + + it("filters invoices outside amount tolerance", () => { + const invoice = makeInvoice("inv-new", "GCREATOR", 1000n, 0n, []); + + const historical = [ + makeInvoice("h1", "GCREATOR", 100_000n, 100_000n, [ + makePayment("GPAYER", 50_000n, 1_800_100_000), + makePayment("GPAYER", 50_000n, 1_800_200_000), + ], "Released"), + ]; + + const forecast = computePaymentForecast(invoice, historical, { + amountRangeTolerance: 0.1, + }); + expect(forecast.historicalSampleSize).toBe(0); + expect(forecast.historicalPrediction).toBeNull(); + }); + + it("produces blended estimate when both predictions exist", () => { + const invoice = makeInvoice("inv-new", "GCREATOR", 1000n, 200n, [ + makePayment("GPAYER", 200n, 1_800_100_000), + ]); + + const similarCompleted = Array.from({ length: 5 }, (_, i) => + makeInvoice(`h${i}`, "GCREATOR", 1000n, 1000n, [ + makePayment("GPAYER", 500n, 1_800_100_000 + i * 100_000), + makePayment("GPAYER", 500n, 1_800_200_000 + i * 100_000), + ], "Released") + ); + + const forecast = computePaymentForecast(invoice, similarCompleted); + expect(forecast.blendedEstimate).not.toBeNull(); + expect(forecast.blendedConfidence).toBeGreaterThan(0); + }); + + it("returns blendedConfidence of 0 when only current prediction with low confidence", () => { + const invoice = makeInvoice("inv-new", "GCREATOR", 1000n, 0n, []); + + const forecast = computePaymentForecast(invoice, []); + expect(forecast.currentPrediction.confidence).toBe(0); + expect(forecast.blendedEstimate).toBeNull(); + }); +}); diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..8a344c0 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { IdempotencyManager } from "../src/idempotency.js"; + +describe("IdempotencyManager", () => { + let manager: IdempotencyManager; + + beforeEach(() => { + manager = new IdempotencyManager({ ttlMs: 300_000 }); + }); + + it("generates deterministic keys for the same input", () => { + const key1 = manager.generateKey("GABC", "op-xdr-1"); + const key2 = manager.generateKey("GABC", "op-xdr-1"); + expect(key1).toBe(key2); + }); + + it("generates different keys for different source addresses", () => { + const key1 = manager.generateKey("GABC", "op-xdr-1"); + const key2 = manager.generateKey("GDEF", "op-xdr-1"); + expect(key1).not.toBe(key2); + }); + + it("generates different keys for different operations", () => { + const key1 = manager.generateKey("GABC", "op-xdr-1"); + const key2 = manager.generateKey("GABC", "op-xdr-2"); + expect(key1).not.toBe(key2); + }); + + it("returns duplicate=true for a previously claimed key", () => { + const key = manager.generateKey("GABC", "op-xdr-1"); + const first = manager.tryClaim(key, { txHash: "hash-1" }); + expect(first.duplicate).toBe(false); + + const second = manager.tryClaim(key, { txHash: "hash-2" }); + expect(second.duplicate).toBe(true); + expect(second.existing?.txHash).toBe("hash-1"); + }); + + it("returns the stored result for duplicate keys", () => { + const key = manager.generateKey("GABC", "op-xdr-1"); + manager.tryClaim(key, { txHash: "hash-1" }); + + const result = manager.getResult(key); + expect(result).not.toBeNull(); + expect(result!.txHash).toBe("hash-1"); + }); + + it("returns null for unknown keys", () => { + const result = manager.getResult("unknown-key"); + expect(result).toBeNull(); + }); + + it("isDuplicate returns true for claimed keys", () => { + const key = manager.generateKey("GABC", "op-xdr-1"); + expect(manager.isDuplicate(key)).toBe(false); + manager.tryClaim(key, { txHash: "hash-1" }); + expect(manager.isDuplicate(key)).toBe(true); + }); + + it("evicts expired entries", () => { + const shortManager = new IdempotencyManager({ ttlMs: -1 }); + const key = shortManager.generateKey("GABC", "op-xdr-1"); + shortManager.tryClaim(key, { txHash: "hash-1" }); + + const result = shortManager.getResult(key); + expect(result).toBeNull(); + }); + + it("evicts oldest entry when at max capacity", () => { + const smallManager = new IdempotencyManager({ ttlMs: 300_000, maxEntries: 2 }); + + const key1 = smallManager.generateKey("G1", "op-1"); + const key2 = smallManager.generateKey("G2", "op-2"); + smallManager.tryClaim(key1, { txHash: "hash-1" }); + smallManager.tryClaim(key2, { txHash: "hash-2" }); + + const key3 = smallManager.generateKey("G3", "op-3"); + smallManager.tryClaim(key3, { txHash: "hash-3" }); + + expect(smallManager.getResult(key1)).toBeNull(); + expect(smallManager.getResult(key2)).not.toBeNull(); + expect(smallManager.getResult(key3)).not.toBeNull(); + }); + + it("clear removes all entries", () => { + const key = manager.generateKey("GABC", "op-xdr-1"); + manager.tryClaim(key, { txHash: "hash-1" }); + manager.clear(); + expect(manager.getResult(key)).toBeNull(); + }); +}); diff --git a/test/payloadGuard.test.ts b/test/payloadGuard.test.ts new file mode 100644 index 0000000..71121ee --- /dev/null +++ b/test/payloadGuard.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { + validateInvoicePayload, + PayloadSizeError, +} from "../src/payloadGuard.js"; +import type { CreateInvoiceParams } from "../src/types.js"; + +function makeValidParams(overrides: Partial = {}): CreateInvoiceParams { + return { + creator: "GABCDEF123", + recipients: [{ address: "GXYZ123", amount: 100n }], + token: "USDC", + deadline: 1_800_000_000, + ...overrides, + }; +} + +describe("PayloadGuard", () => { + it("passes for a valid small payload", () => { + expect(() => validateInvoicePayload(makeValidParams())).not.toThrow(); + }); + + it("throws PayloadSizeError when recipients exceed maxRecipients", () => { + const recipients = Array.from({ length: 51 }, (_, i) => ({ + address: `G${String(i).padStart(10, "0")}`, + amount: 100n, + })); + const params = makeValidParams({ recipients }); + + expect(() => validateInvoicePayload(params, { maxRecipients: 50 })).toThrow(PayloadSizeError); + }); + + it("throws PayloadSizeError when memo is too long", () => { + const params = makeValidParams({ memo: "x".repeat(600) }); + + expect(() => validateInvoicePayload(params, { maxMemoLength: 512 })).toThrow(PayloadSizeError); + }); + + it("throws PayloadSizeError when serialized size exceeds limit", () => { + const recipients = Array.from({ length: 100 }, (_, i) => ({ + address: `GA${String(i).padStart(50, "0")}`, + amount: 100n, + })); + const params = makeValidParams({ recipients }); + + expect(() => validateInvoicePayload(params, { maxInvoiceSizeBytes: 2048 })).toThrow(PayloadSizeError); + }); + + it("reports all violations in the error", () => { + const recipients = Array.from({ length: 100 }, (_, i) => ({ + address: `GA${String(i).padStart(50, "0")}`, + amount: 100n, + })); + const params = makeValidParams({ recipients, memo: "x".repeat(600) }); + + try { + validateInvoicePayload(params, { maxRecipients: 10, maxMemoLength: 10, maxInvoiceSizeBytes: 512 }); + } catch (error) { + expect(error).toBeInstanceOf(PayloadSizeError); + const psError = error as PayloadSizeError; + expect(psError.violations.length).toBeGreaterThanOrEqual(2); + } + }); + + it("detects oversize recipient addresses", () => { + const params = makeValidParams({ + recipients: [{ address: "G".repeat(100), amount: 100n }], + }); + + expect(() => validateInvoicePayload(params)).toThrow(PayloadSizeError); + }); + + it("uses defaults when config is not provided", () => { + expect(() => validateInvoicePayload(makeValidParams())).not.toThrow(); + }); + + it("uses custom config when provided", () => { + const params = makeValidParams({ + recipients: [ + { address: "GA", amount: 100n }, + { address: "GB", amount: 200n }, + ], + }); + + expect(() => + validateInvoicePayload(params, { maxRecipients: 1 }) + ).toThrow(PayloadSizeError); + }); +}); diff --git a/test/reputation.test.ts b/test/reputation.test.ts new file mode 100644 index 0000000..c1622cd --- /dev/null +++ b/test/reputation.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { computeCreatorReputation } from "../src/reputation.js"; +import type { Invoice, InvoiceStatus } from "../src/types.js"; + +function makeInvoice( + id: string, + status: InvoiceStatus, + overrides: Partial = {} +): Invoice { + return { + id, + creator: "GCREATOR", + recipients: [{ address: "GPAYEE", amount: 1000n }], + token: "USDC", + deadline: 1_800_000_000, + funded: status === "Released" ? 1000n : 0n, + status, + payments: [], + ...overrides, + }; +} + +describe("computeCreatorReputation", () => { + it("returns zero score for empty invoices array", () => { + const score = computeCreatorReputation([]); + expect(score.totalInvoices).toBe(0); + expect(score.overallScore).toBe(0); + }); + + it("calculates completion rate correctly", () => { + const invoices = [ + makeInvoice("1", "Released"), + makeInvoice("2", "Released"), + makeInvoice("3", "Pending"), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.totalInvoices).toBe(3); + expect(score.completedInvoices).toBe(2); + expect(score.completionRate).toBe(2 / 3); + }); + + it("calculates dispute rate correctly", () => { + const invoices = [ + makeInvoice("1", "Released"), + makeInvoice("2", "Refunded"), + makeInvoice("3", "Pending"), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.disputedInvoices).toBe(1); + expect(score.disputeRate).toBe(1 / 3); + }); + + it("computes average funding time from payment timestamps", () => { + const invoices = [ + makeInvoice("1", "Released", { + payments: [ + { payer: "GPAYER", amount: 1000n, timestamp: 1_800_100_000 }, + ], + }), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.averageFundingTimeSeconds).not.toBeNull(); + }); + + it("sets averageFundingTimeSeconds to null when no completed invoices have payments", () => { + const invoices = [ + makeInvoice("1", "Released", { payments: [] }), + makeInvoice("2", "Pending"), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.averageFundingTimeSeconds).toBeNull(); + }); + + it("produces overallScore between 0 and 1", () => { + const invoices = [ + makeInvoice("1", "Released"), + makeInvoice("2", "Released"), + makeInvoice("3", "Pending"), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.overallScore).toBeGreaterThanOrEqual(0); + expect(score.overallScore).toBeLessThanOrEqual(1); + }); + + it("uses custom weights when provided", () => { + const invoices = [ + makeInvoice("1", "Released"), + ]; + + const defaultScore = computeCreatorReputation(invoices); + const customScore = computeCreatorReputation(invoices, { + completionWeight: 1, + fundingTimeWeight: 0, + disputeWeight: 0, + }); + + expect(customScore.overallScore).toBe(1); + }); + + it("uses creator from first invoice", () => { + const invoices = [ + makeInvoice("1", "Released"), + makeInvoice("2", "Pending"), + ]; + + const score = computeCreatorReputation(invoices); + expect(score.creator).toBe("GCREATOR"); + }); +});