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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" })
);
Expand Down Expand Up @@ -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;
}
Expand Down
134 changes: 134 additions & 0 deletions src/forecast.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
83 changes: 83 additions & 0 deletions src/idempotency.ts
Original file line number Diff line number Diff line change
@@ -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<string, IdempotencyEntry>();
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);
}
}
}
}
25 changes: 25 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading