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
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ new StellarSplitClient(config: StellarSplitClientConfig)

| Class | Description |
|-------|-------------|
| `MultiTenantClient` | Manage a pool of `StellarSplitClient` instances keyed by tenant ID, with `getClient`, `evict`, and `evictAll` |
| `MultiTenantClient` | Manage a pool of `StellarSplitClient` instances keyed by tenant ID, with `getClient`, `evict`, `evictAll`, `stats`, and O(1) LRU/TTL/health-check eviction. Construct with a tenant→config factory plus `{ maxClients, ttlMs, healthCheckIntervalMs }`, or with the options only and pass a config to `getClient(tenantId, config)` |

### Profiling

Expand Down Expand Up @@ -177,14 +177,68 @@ Prune stale or over-broad ledger keys from Soroban transactions before submissio

See [docs/FOOTPRINT_OPTIMIZER.md](./docs/FOOTPRINT_OPTIMIZER.md).

### Payment Aggregator (multi-invoice allocation)

Allocate a single budget across many invoices. Choose `"equal"`, `"proportional"`
(weighted by how far each invoice still is from its target) or `"custom"` weights
that sum to 100. Every allocation is capped at the invoice's remaining amount, so
overpayment is impossible.

```typescript
import { aggregatePayments, createInvoiceRemainingFetcher } from "@stellar-split/sdk";

const allocations = await aggregatePayments(parseAmount("100"), [1n, 2n, 3n], "proportional", {
// Any object exposing getInvoice(id) works — a StellarSplitClient is ideal.
fetchRemaining: createInvoiceRemainingFetcher(client),
});

for (const { invoiceId, amount, percentOfBudget } of allocations) {
console.log(`Invoice ${invoiceId}: ${formatAmount(amount)} (${percentOfBudget}%)`);
}
```

| Function | Description |
|----------|-------------|
| `aggregatePayments(budget, invoiceIds, strategy, options?)` | Compute the optimal allocation; returns one `PaymentAllocation` per invoice (`{ invoiceId, amount, percentOfBudget }`) |
| `remainingForInvoice(invoice)` | Remaining (still unfunded) amount of an invoice, clamped at zero |
| `createInvoiceRemainingFetcher(source)` | Build a remaining-amount fetcher from any `getInvoice(id)` source |
| `registerInvoiceRemainingFetcher(fetcher)` | Set a process-wide fallback fetcher so `aggregatePayments` can be called without options |

Behavior notes: an empty invoice list returns `[]`; duplicate invoice IDs and a
negative budget throw `ValidationError`; `"custom"` weights must sum to 100;
when the invoices collectively need less than the budget, the surplus stays
unallocated and the percentages sum to less than 100.

### Deadline Helpers

`bigint`-based deadline helpers (Unix seconds), matching the on-chain `u64`
representation. The legacy `number`-returning `deadlineFromDays` remains
available from the `@stellar-split/sdk/utils` entry point.

| Function | Returns | Description |
|----------|---------|-------------|
| `deadlineFromDays(days)` | `bigint` | Unix timestamp `days` days from now (rounded up to the next whole second) |
| `deadlineFromDate(date)` | `bigint` | Convert a `Date` to a Unix timestamp in seconds |
| `isDeadlineValid(deadline)` | `boolean` | `true` when the deadline is at least 1 hour in the future |
| `timeUntilDeadline(deadline)` | `DeadlineRemaining` | `{ days, hours, minutes, seconds, expired }`, all zeros once expired |
| `formatDeadline(deadline, locale?)` | `string` | Human-readable date string, localized (rendered in UTC) |

```typescript
import { deadlineFromDays, isDeadlineValid, timeUntilDeadline } from "@stellar-split/sdk";

const deadline = deadlineFromDays(7);
isDeadlineValid(deadline); // true
timeUntilDeadline(deadline); // { days: 7, hours: 0, minutes: 0, seconds: 0, expired: false }
```

### Utilities

| Function | Description |
|----------|-------------|
| `formatAmount(stroops)` | Format stroops as USDC string (7 decimals) |
| `parseAmount(value)` | Parse USDC string to stroops |
| `isValidAddress(address)` | Validate a Stellar G... address |
| `deadlineFromDays(days)` | Unix timestamp N days from now |
| `deadlineFromDays(days)` | Unix timestamp N days from now (`number`; the root export returns a `bigint` — see [Deadline Helpers](#deadline-helpers)) |
| `isExpired(deadline)` | Check if a deadline has passed |
| `truncateAddress(address)` | Truncate for display: "GABC...XYZ" |

Expand Down
171 changes: 171 additions & 0 deletions src/deadline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Deadline helpers for StellarSplit invoices.
*
* Complements {@link DeadlineEngine} (countdowns, business hours and expiry
* callbacks) with small, pure helpers for *computing*, *validating* and
* *formatting* invoice deadlines as `bigint` Unix timestamps in seconds — the
* same `u64` representation the Soroban contract stores.
*
* Note: `@stellar-split/sdk/utils` still exposes a legacy
* `deadlineFromDays(days): number` helper. The helpers in this module are the
* `bigint`-based API re-exported from the package root.
*
* @example
* import { deadlineFromDays, isDeadlineValid, timeUntilDeadline } from "@stellar-split/sdk";
*
* const deadline = deadlineFromDays(7); // 7 days from now, as bigint
* isDeadlineValid(deadline); // true (more than 1 hour away)
* timeUntilDeadline(deadline); // { days: 7, hours: 0, … }
*/

import { ValidationError } from "./errors.js";

/** Number of seconds in one day. */
const SECONDS_PER_DAY = 86_400;
/** Number of seconds in one hour. */
const SECONDS_PER_HOUR = 3_600;
/** Minimum lead time (in seconds) for a deadline to be accepted. */
const MIN_VALID_LEAD_SECONDS = 3_600;

/** Remaining time until a deadline, broken down into calendar units. */
export interface DeadlineRemaining {
/** Whole days remaining (0 once expired). */
days: number;
/** Whole hours remaining after `days` (0-23). */
hours: number;
/** Whole minutes remaining after `hours` (0-59). */
minutes: number;
/** Whole seconds remaining after `minutes` (0-59). */
seconds: number;
/** `true` when the deadline is now or in the past. */
expired: boolean;
}

/** Current wall-clock time as a whole Unix second. */
function nowSeconds(): bigint {
return BigInt(Math.floor(Date.now() / 1000));
}

/**
* Compute a Unix timestamp `n` days from now.
*
* Any positive `n`, however small, yields a timestamp strictly in the future
* because the duration is rounded **up** to the next whole second.
*
* @param n - Number of days from now. May be fractional or negative.
* @returns A Unix timestamp in seconds, as a `bigint`.
* @throws {ValidationError} If `n` is not a finite number.
*
* @example
* deadlineFromDays(7); // now (seconds) + 604_800n
* deadlineFromDays(0); // now, rounded up to the current second
*/
export function deadlineFromDays(n: number): bigint {
if (!Number.isFinite(n)) {
throw new ValidationError("deadlineFromDays requires a finite number of days", {
days: n,
});
}

return nowSeconds() + BigInt(Math.ceil(n * SECONDS_PER_DAY));
}

/**
* Convert a `Date` to a Unix timestamp in seconds.
*
* Sub-second precision is truncated, matching the on-chain second resolution.
*
* @param date - The date to convert.
* @returns A Unix timestamp in seconds, as a `bigint`.
* @throws {ValidationError} If `date` is invalid (for example `new Date("nope")`).
*
* @example
* deadlineFromDate(new Date(0)); // 0n
*/
export function deadlineFromDate(date: Date): bigint {
const ms = date.getTime();

if (!Number.isFinite(ms)) {
throw new ValidationError("deadlineFromDate requires a valid Date", {
date: date.toString(),
});
}

return BigInt(Math.floor(ms / 1000));
}

/**
* Report whether `deadline` is at least one hour in the future.
*
* @param deadline - Unix timestamp in seconds.
* @returns `true` when `deadline - now >= 1 hour`.
*
* @example
* isDeadlineValid(deadlineFromDays(1)); // true
* isDeadlineValid(BigInt(Math.floor(Date.now() / 1000))); // false
*/
export function isDeadlineValid(deadline: bigint): boolean {
return deadline - nowSeconds() >= BigInt(MIN_VALID_LEAD_SECONDS);
}

/**
* Break the remaining time until `deadline` into calendar units.
*
* Expired deadlines return all-zero units with `expired: true` rather than
* negative values, so the result can be rendered directly.
*
* @param deadline - Unix timestamp in seconds.
* @returns A {@link DeadlineRemaining} breakdown.
*
* @example
* timeUntilDeadline(deadlineFromDays(1)); // { days: 1, hours: 0, minutes: 0, seconds: 0, expired: false }
* timeUntilDeadline(0n); // { days: 0, …, expired: true }
*/
export function timeUntilDeadline(deadline: bigint): DeadlineRemaining {
const diff = deadline - nowSeconds();

if (diff <= 0n) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
}

return {
days: Number(diff / BigInt(SECONDS_PER_DAY)),
hours: Number((diff % BigInt(SECONDS_PER_DAY)) / BigInt(SECONDS_PER_HOUR)),
minutes: Number((diff % BigInt(SECONDS_PER_HOUR)) / 60n),
seconds: Number(diff % 60n),
expired: false,
};
}

/**
* Format a deadline as a human-readable, locale-aware date string.
*
* Output is always rendered in UTC so the same deadline formats identically on
* every machine and CI runner; only the language/format of the string changes
* with `locale`.
*
* @param deadline - Unix timestamp in seconds.
* @param locale - Optional BCP-47 locale tag (for example `"en-US"`, `"de-DE"`).
* Defaults to the runtime locale.
* @returns A localized date/time string, for example `"Sep 25, 2026, 12:00 PM"`.
* @throws {ValidationError} If `deadline` cannot be represented as a `Date`.
*
* @example
* formatDeadline(deadlineFromDays(7)); // e.g. "Oct 2, 2026, 11:00 AM"
* formatDeadline(deadlineFromDays(7), "de-DE"); // e.g. "02.10.2026, 11:00"
*/
export function formatDeadline(deadline: bigint, locale?: string): string {
const ms = Number(deadline) * 1000;

if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) {
throw new ValidationError("formatDeadline received a deadline outside the supported Date range", {
deadline: deadline.toString(),
});
}

return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(ms));
}
33 changes: 33 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,39 @@ export type {
HistoricalInvoiceSample,
} from "./forecast.js";

// ---------------------------------------------------------------------------
// #852 — Deadline helpers
// ---------------------------------------------------------------------------

export {
deadlineFromDays,
deadlineFromDate,
isDeadlineValid,
timeUntilDeadline,
formatDeadline,
} from "./deadline.js";
export type { DeadlineRemaining } from "./deadline.js";

// ---------------------------------------------------------------------------
// #853 — Payment aggregator (multi-invoice budget allocation)
// ---------------------------------------------------------------------------

export {
aggregatePayments,
createInvoiceRemainingFetcher,
registerInvoiceRemainingFetcher,
remainingForInvoice,
} from "./paymentAllocation.js";
export type {
AggregatePaymentsOptions,
AmountLookup,
InvoiceRemainingFetcher,
InvoiceSource,
PaymentAllocation,
SplitStrategy,
} from "./paymentAllocation.js";


// ---------------------------------------------------------------------------
// Split ratio validator
// ---------------------------------------------------------------------------
Expand Down
41 changes: 37 additions & 4 deletions src/multiTenant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { rpc as SorobanRpc } from "@stellar/stellar-sdk";
import type { StellarSplitClientConfig } from "./client.js";
import { StellarSplitClient } from "./client.js";
import { ValidationError } from "./errors.js";

// ---------------------------------------------------------------------------
// Disposable helpers (unchanged from original)
Expand Down Expand Up @@ -120,7 +121,7 @@ export class MultiTenantClient {
// on every hit so the most-recently-used entry is always at the end.
private readonly pool = new Map<string, PoolEntry>();

private readonly clientFactory: (tenantId: string) => StellarSplitClientConfig;
private readonly clientFactory: ((tenantId: string) => StellarSplitClientConfig) | null;
private readonly maxClients: number;
private readonly ttlMs: number;
private readonly healthCheckIntervalMs: number;
Expand All @@ -134,11 +135,35 @@ export class MultiTenantClient {
// Background health-check timer
private _healthTimer: ReturnType<typeof setInterval> | null = null;

/**
* Create a tenant pool.
*
* Two call styles are supported: pass a factory plus options (the original
* signature), or pass only {@link PoolOptions} and supply each tenant's config
* to `getClient(tenantId, config)`.
*
* @example
* // Options only — config comes from getClient()
* const pool = new MultiTenantClient({ maxClients: 50, ttlMs: 60_000 });
* pool.getClient("tenant-a", tenantConfig);
*
* @example
* // Factory + options
* const pool = new MultiTenantClient((id) => configFor(id), { maxClients: 50 });
*/
constructor(
clientFactory: (tenantId: string) => StellarSplitClientConfig,
clientFactoryOrOptions:
| ((tenantId: string) => StellarSplitClientConfig)
| PoolOptions = {},
options: PoolOptions = {}
) {
this.clientFactory = clientFactory;
if (typeof clientFactoryOrOptions === "function") {
this.clientFactory = clientFactoryOrOptions;
} else {
this.clientFactory = null;
options = clientFactoryOrOptions;
}

this.maxClients = options.maxClients ?? Infinity;
this.ttlMs = options.ttlMs ?? Infinity;
this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 0;
Expand Down Expand Up @@ -196,7 +221,15 @@ export class MultiTenantClient {
this._evict(lruKey, lruEntry);
}

const resolvedConfig = config ?? this.clientFactory(tenantId);
const resolvedConfig = config ?? this.clientFactory?.(tenantId);

if (!resolvedConfig) {
throw new ValidationError(
`No configuration available for tenant "${tenantId}". Pass a config to getClient() or a client factory to the MultiTenantClient constructor.`,
{ tenantId }
);
}

const client = new StellarSplitClient(resolvedConfig);
const rpcUrl = Array.isArray(resolvedConfig.rpcUrl)
? resolvedConfig.rpcUrl[0] ?? ""
Expand Down
Loading