Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,22 @@ export class RefundGraceError extends StellarSplitError {
}
}

/** Thrown when a preflight check fails before the SDK attempts contract calls. */
export class PreflightError extends StellarSplitError {
/** The endpoint URL the failing check targeted. */
readonly url: string;
/** Human-readable reason the check failed. */
readonly reason: string;

constructor(url: string, reason: string) {
super(`Preflight check failed for ${url}: ${reason}`, "PREFLIGHT_ERROR", { url, reason });
this.name = "PreflightError";
this.url = url;
this.reason = reason;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when submitting a WaterfallPlan with an unsatisfied tier and `allowPartial` was not set. */
export class WaterfallInsufficientFundsError extends StellarSplitError {
readonly invoiceId: string;
Expand Down Expand Up @@ -1284,6 +1300,10 @@ export function isRefundGraceError(err: unknown): err is RefundGraceError {
return err instanceof RefundGraceError;
}

export function isPreflightError(err: unknown): err is PreflightError {
return err instanceof PreflightError;
}

export function isChannelReconciliationError(err: unknown): err is ChannelReconciliationError {
return err instanceof ChannelReconciliationError;
}
Expand Down
8 changes: 5 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
Sep41AdapterError,
TrancheProgressError,
RefundGraceError,
PreflightError,
ChannelReconciliationError,
SequenceCacheError,
SequenceNumberTooOldError,
Expand Down Expand Up @@ -156,6 +157,7 @@ export {
isSep41AdapterError,
isTrancheProgressError,
isRefundGraceError,
isPreflightError,
isChannelReconciliationError,
isSequenceCacheError,
isSequenceNumberTooOldError,
Expand Down Expand Up @@ -474,7 +476,7 @@ export { TimeoutManager, withTimeout, EscalationManager, RequestTimeoutError as
export type { TimeoutConfig, EscalationEvent, EscalationCallback } from "./timeout.js";

// Trace IDs (Issue #2)
export { TraceIdManager, globalTraceIdManager } from "./traceId.js";
export { TraceIdManager, globalTraceIdManager, generateTraceId } from "./traceId.js";
export type { TraceIdGenerator } from "./traceId.js";

// Injectable RpcClient (Issue #3)
Expand All @@ -484,8 +486,8 @@ export type { RpcClient } from "./rpcClient.js";
export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js";
export type { VersionInfo } from "./types.js";

export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags } from "./preflightChecker.js";
export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck } from "./preflightChecker.js";
export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags, runPreflight } from "./preflightChecker.js";
export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck, RunPreflightOptions } from "./preflightChecker.js";

export { inspectFlags, hasAnyRestrictiveFlag } from "./accountFlagsInspector.js";
export type { AccountFlagSet } from "./types.js";
Expand Down
59 changes: 59 additions & 0 deletions src/preflightChecker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";
import { inspectFlags } from "./accountFlagsInspector.js";
import type { AccountFlagSet } from "./types.js";
import { withTimeout, RequestTimeoutError } from "./timeout.js";
import { PreflightError } from "./errors.js";

export type PayerReadinessReason =
| "account_not_found"
Expand Down Expand Up @@ -330,3 +332,60 @@ export async function checkTrustlineAuthRequirement(
const authRequired = (account as unknown as { flags: { auth_required: boolean } }).flags.auth_required === true;
return { authRequired };
}

// ---------------------------------------------------------------------------
// RPC Endpoint Reachability Check
// ---------------------------------------------------------------------------

/** Default timeout (ms) for the RPC reachability probe. */
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 3_000;

/** Options for {@link runPreflight}. */
export interface RunPreflightOptions {
/** RPC endpoint URL to probe for reachability. */
rpcUrl: string;
/** Probe timeout in milliseconds. Defaults to 3000. */
timeoutMs?: number;
/**
* `fetch` implementation to use for the probe. Defaults to the global
* `fetch`. Provided mainly for testing and non-standard runtimes.
*/
fetchImpl?: typeof fetch;
}

/**
* Verify the configured RPC endpoint is reachable before the SDK attempts
* contract calls, surfacing network problems with a clear error rather than a
* cryptic downstream timeout.
*
* Performs a lightweight HTTP `HEAD` request against `rpcUrl`. Any HTTP
* response (including 4xx / 405) counts as reachable — this checks
* connectivity, not method support. A connection failure, DNS error, or a
* probe that exceeds `timeoutMs` throws a {@link PreflightError} carrying the
* URL and the underlying reason.
*
* @throws {PreflightError} When the endpoint cannot be reached.
*/
export async function runPreflight(options: RunPreflightOptions): Promise<void> {
const { rpcUrl } = options;
const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;

if (typeof fetchImpl !== "function") {
throw new PreflightError(rpcUrl, "no fetch implementation available in this runtime");
}

try {
await withTimeout(
(signal) => fetchImpl(rpcUrl, { method: "HEAD", signal }),
timeoutMs,
"runPreflight",
);
} catch (error) {
if (error instanceof RequestTimeoutError) {
throw new PreflightError(rpcUrl, `endpoint did not respond within ${timeoutMs}ms`);
}
const reason = error instanceof Error ? error.message : String(error);
throw new PreflightError(rpcUrl, reason);
}
}
59 changes: 54 additions & 5 deletions src/refundGrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,80 @@ export interface CanRefundOptions {
gracePeriodSecs?: number;
useOnChainTime?: boolean;
server?: SorobanRpc.Server;
/**
* Unix timestamp (seconds) the grace period countdown started from.
*
* When omitted, the countdown is measured from `invoice.deadline`. A partial
* refund resets this to the time it was applied (see {@link applyPartialRefund}),
* so subsequent full-refund eligibility is evaluated from that reset point
* rather than the original deadline.
*/
graceStartedAt?: number;
}

/** Result of applying a partial refund: a fresh {@link RefundStatus} plus the reset grace anchor. */
export interface PartialRefundResult extends RefundStatus {
/** Unix timestamp (seconds) the grace period countdown was reset to. */
graceStartedAt: number;
}

// === Time source ===

/** Resolve "now" in seconds, using ledger time when requested and a server is available. */
async function resolveNow(options: CanRefundOptions): Promise<number> {
return options.useOnChainTime && options.server
? getLedgerTime(options.server)
: Math.floor(Date.now() / 1000);
}

/** The point the grace countdown runs from: an explicit reset anchor, else the invoice deadline. */
function graceAnchor(invoice: Invoice, options: CanRefundOptions): number {
return options.graceStartedAt ?? invoice.deadline;
}

// === Refund eligibility ===

export async function canRefund(
invoice: Invoice,
options: CanRefundOptions = {},
): Promise<RefundStatus> {
const gracePeriodSecs = options.gracePeriodSecs ?? 0;
const refundAvailableAt = invoice.deadline + gracePeriodSecs;
const refundAvailableAt = graceAnchor(invoice, options) + gracePeriodSecs;

if (invoice.status !== "Pending") {
return { canRefund: false, refundAvailableAt, gracePeriodSecs };
}

const now = options.useOnChainTime && options.server
? await getLedgerTime(options.server)
: Math.floor(Date.now() / 1000);
const now = await resolveNow(options);

return { canRefund: now >= refundAvailableAt, refundAvailableAt, gracePeriodSecs };
}

/**
* Apply a partial refund: reset the grace period countdown to the current
* timestamp and re-evaluate full-refund eligibility from that reset point.
*
* The grace period *duration* (`gracePeriodSecs`) is unchanged; only the
* instant it is measured from moves forward to now. Callers should persist the
* returned `graceStartedAt` and pass it back via {@link CanRefundOptions} on
* later {@link canRefund} calls.
*/
export async function applyPartialRefund(
invoice: Invoice,
options: CanRefundOptions = {},
): Promise<PartialRefundResult> {
const gracePeriodSecs = options.gracePeriodSecs ?? 0;
const graceStartedAt = await resolveNow(options);
const status = await canRefund(invoice, { ...options, graceStartedAt });

return { ...status, gracePeriodSecs, graceStartedAt };
}

async function getLedgerTime(server: SorobanRpc.Server): Promise<number> {
const ledger = await server.getLatestLedger();
const raw = ledger as { closedAt?: string };
if (!raw.closedAt) {
throw new RefundGraceError("RPC getLatestLedger did not return closedAt; cannot determine ledger time");
}
return Math.floor(new Date(raw.closedAt).getTime() / 1000);
}
}
61 changes: 60 additions & 1 deletion src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
* No PII (addresses, amounts) is collected.
*/

interface TelemetryEvent {
export interface TelemetryEvent {
method: string;
success: boolean;
durationMs: number;
timestamp: number;
/**
* ID of the span this event was emitted within, enabling tracing backends to
* reconstruct parent-child relationships. `undefined` when the event was
* emitted outside of any active span.
*/
parentSpanId?: string;
}

interface TelemetryConfig {
Expand All @@ -20,6 +26,8 @@ class Telemetry {
private events: TelemetryEvent[] = [];
private flushInterval: ReturnType<typeof setInterval> | null = null;
private readonly FLUSH_INTERVAL_MS = 60000;
/** Stack of active span IDs; the top is the current span for new events. */
private spanStack: string[] = [];

/**
* Initialize telemetry with configuration.
Expand All @@ -34,6 +42,56 @@ class Telemetry {
}
}

/**
* Mark a span as active. Events recorded until the matching {@link endSpan}
* (or {@link runInSpan} scope exit) inherit `spanId` as their `parentSpanId`.
*/
startSpan(spanId: string): void {
this.spanStack.push(spanId);
}

/**
* End the most recently started span. When `spanId` is given, entries are
* popped up to and including its first match from the top, tolerating a
* missed `endSpan` call.
*/
endSpan(spanId?: string): void {
if (spanId === undefined) {
this.spanStack.pop();
return;
}
const idx = this.spanStack.lastIndexOf(spanId);
if (idx !== -1) {
this.spanStack.length = idx;
}
}

/**
* Run `fn` with `spanId` active, so any telemetry events it records inherit
* that span as their parent. Works for sync and async `fn`; the span is
* always ended, including on throw or rejection.
*/
runInSpan<T>(spanId: string, fn: () => T): T {
this.startSpan(spanId);
let result: T;
try {
result = fn();
} catch (error) {
this.endSpan(spanId);
throw error;
}
if (result instanceof Promise) {
return result.finally(() => this.endSpan(spanId)) as T;
}
this.endSpan(spanId);
return result;
}

/** The span ID new events currently inherit, or `undefined` outside any span. */
private currentSpanId(): string | undefined {
return this.spanStack.length > 0 ? this.spanStack[this.spanStack.length - 1] : undefined;
}

/**
* Record a method call.
*/
Expand All @@ -47,6 +105,7 @@ class Telemetry {
success,
durationMs,
timestamp: Date.now(),
parentSpanId: this.currentSpanId(),
});
}

Expand Down
29 changes: 24 additions & 5 deletions src/traceId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,37 @@

export type TraceIdGenerator = () => string;

function defaultGenerateTraceId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback for environments without crypto.randomUUID
// === UUID v4 fallback ===

/** RFC 4122 v4 UUID built from Math.random(); used only when Web Crypto is absent. */
function uuidV4FromMathRandom(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

// === Trace ID generation ===

/**
* Generate a UUID v4 trace ID.
*
* Prefers `crypto.randomUUID()` from the Web Crypto API, which is
* collision-resistant and suitable for high-throughput usage. Falls back to a
* `Math.random()`-based UUID v4 in runtimes where `crypto.randomUUID` is
* unavailable (e.g. Node.js < 19). The returned value always conforms to the
* UUID v4 format `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.
*/
export function generateTraceId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return uuidV4FromMathRandom();
}

const defaultGenerateTraceId: TraceIdGenerator = generateTraceId;

export class TraceIdManager {
private _generator: TraceIdGenerator = defaultGenerateTraceId;

Expand Down
Loading