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
70 changes: 70 additions & 0 deletions src/auditLogger.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { truncateAddress } from "./utils.js";
import { decodeXDR } from "./xdrDecoder.js";
import type { XDRType, DecodedXDR } from "./types.js";

export interface AuditEntry {
timestamp: number;
method: string;
params: Record<string, unknown>;
success: boolean;
durationMs: number;
/** Optional decoded XDR payload attached to this audit entry. */
decodedXdr?: DecodedXDR;
}

const STELLAR_ADDRESS_RE = /^G[A-Z0-9]{55}$/;

/** Detect if a string value looks like base64-encoded XDR. */
const XDR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;

/** Heuristic minimum length for XDR base64 strings (at least ~40 chars for a minimal tx). */
const MIN_XDR_LENGTH = 40;

export class AuditLogger {
private readonly sink: (entry: AuditEntry) => void;

Expand All @@ -31,4 +41,64 @@ export class AuditLogger {
])
);
}

/**
* Log an entry with automatic XDR decoding.
*
* When `xdrPayload` is provided, it is decoded and attached as `decodedXdr`.
* This produces structured JSON safe for log aggregation, audit trails,
* and developer UIs — no external XDR converters needed.
*
* @param entry - Base audit entry.
* @param xdrPayload - Base64-encoded XDR to decode (e.g. a transaction envelope).
* @param xdrType - The expected XDR type.
*/
logWithXdr(
entry: AuditEntry,
xdrPayload: string,
xdrType: XDRType,
): void {
try {
if (
xdrPayload.length >= MIN_XDR_LENGTH &&
XDR_BASE64_RE.test(xdrPayload)
) {
entry.decodedXdr = decodeXDR(xdrPayload, xdrType);
}
} catch {
// Decoding best-effort; never fail an audit log write.
}
this.log(entry);
}

/**
* Auto-detect and decode XDR payloads embedded in audit params.
*
* Scans `entry.params` for keys matching known XDR field names
* ("xdr", "txXdr", "envelopeXdr", "resultXdr", "metaXdr")
* and attempts to decode them, attaching the result to `entry.decodedXdr`.
*/
logAndDecodeXdr(entry: AuditEntry): void {
const xdrKeys: Array<{ key: string; type: XDRType }> = [
{ key: "xdr", type: "TransactionEnvelope" },
{ key: "txXdr", type: "TransactionEnvelope" },
{ key: "envelopeXdr", type: "TransactionEnvelope" },
{ key: "resultXdr", type: "TransactionResult" },
{ key: "metaXdr", type: "TransactionMeta" },
];

for (const { key, type } of xdrKeys) {
const value = entry.params[key];
if (typeof value === "string" && value.length >= MIN_XDR_LENGTH) {
try {
entry.decodedXdr = decodeXDR(value, type);
break; // Decode the first match only
} catch {
// continue to next key
}
}
}

this.log(entry);
}
}
121 changes: 121 additions & 0 deletions src/cursorTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* SSE / stream cursor position tracker.
*
* Persists the last-processed paging token for each named stream so
* long-running SDK processes can resume from the last processed ledger
* after restarts without duplicating events.
*/

import type { InvoiceSnapshot } from "./snapshot.js";

/** In-memory cursor store. Production uses should inject a persistent backend. */
const cursorMap = new Map<string, string>();

/** Optional persistent store that survives process restarts. */
let persistentStore: CursorPersistence | null = null;

// ---------------------------------------------------------------------------
// Persistence adapter
// ---------------------------------------------------------------------------

/**
* Backend for persisting cursor positions across restarts.
* Implementations may write to localStorage, IndexedDB, a file, etc.
*/
export interface CursorPersistence {
/** Read a cursor value by key. Returns undefined when not found. */
get(key: string): string | undefined;
/** Write a cursor value by key. */
set(key: string, value: string): void;
/** Remove a cursor by key. */
delete(key: string): void;
}

/**
* Inject a persistent cursor store. Call once at app startup to enable
* cursor survival across process restarts.
*
* @example
* ```ts
* import { configureCursorStore } from "@stellar-split/sdk";
*
* // localStorage (browser)
* configureCursorStore({
* get: (k) => localStorage.getItem(`cursor:${k}`) ?? undefined,
* set: (k, v) => localStorage.setItem(`cursor:${k}`, v),
* delete: (k) => localStorage.removeItem(`cursor:${k}`),
* });
* ```
*/
export function configureCursorStore(store: CursorPersistence): void {
persistentStore = store;
}

/**
* Reset the cursor tracker (useful for testing).
*/
export function _resetCursorTrackerForTesting(): void {
cursorMap.clear();
persistentStore = null;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Retrieve the last persisted paging token for `streamId`.
* Returns `undefined` when no cursor has been saved.
*
* @param streamId - Unique identifier for the event stream (e.g. invoice ID or a named stream key).
*/
export function getCursor(streamId: string): string | undefined {
// Check persistent store first
if (persistentStore) {
const persisted = persistentStore.get(streamId);
if (persisted !== undefined) {
cursorMap.set(streamId, persisted);
return persisted;
}
}
return cursorMap.get(streamId);
}

/**
* Persist the latest paging token for `streamId`.
*
* @param streamId - Unique identifier for the event stream.
* @param token - The latest processed paging token (ledger sequence or cursor).
*/
export function setCursor(streamId: string, token: string): void {
cursorMap.set(streamId, token);
if (persistentStore) {
persistentStore.set(streamId, token);
}
}

/**
* Remove a stored cursor position for `streamId`.
*
* @param streamId - Unique identifier for the event stream.
*/
export function removeCursor(streamId: string): void {
cursorMap.delete(streamId);
if (persistentStore) {
persistentStore.delete(streamId);
}
}

/**
* Persist the cursor from a snapshot — convenience helper that stores
* the ledger sequence recorded in an invoice snapshot as the cursor
* for the stream identified by `streamId`.
*/
export function setCursorFromSnapshot(
streamId: string,
snapshot: InvoiceSnapshot,
): void {
// We use the capturedAt timestamp as a pseudo-cursor; real usage
// would pass the ledger sequence from the subscription context.
setCursor(streamId, String(snapshot.capturedAt));
}
76 changes: 76 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,3 +1459,79 @@ export class PassphraseMismatchError extends StellarSplitError {
export function isIPFSConfigError(err: unknown): err is IPFSConfigError {
return err instanceof IPFSConfigError;
}

// ---------------------------------------------------------------------------
// Sponsorship Reserve Verifier Error
// ---------------------------------------------------------------------------

/**
* Thrown when the sponsoring account does not have enough XLM reserve
* to cover the new ledger entries it will sponsor.
*/
export class InsufficientSponsorReserveError extends StellarSplitError {
readonly sponsorAddress: string;
readonly availableStroops: bigint;
readonly requiredStroops: bigint;
readonly newEntries: number;

constructor(
sponsorAddress: string,
available: bigint,
required: bigint,
newEntries: number,
raw?: string,
) {
super(
`Sponsor ${sponsorAddress} has insufficient XLM reserve: ` +
`${available} stroops available, ${required} stroops required ` +
`(${newEntries} new sponsored entries)`,
"INSUFFICIENT_SPONSOR_RESERVE",
{ sponsorAddress, available: available.toString(), required: required.toString(), newEntries },
raw,
);
this.name = "InsufficientSponsorReserveError";
this.sponsorAddress = sponsorAddress;
this.availableStroops = available;
this.requiredStroops = required;
this.newEntries = newEntries;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isInsufficientSponsorReserveError(
err: unknown,
): err is InsufficientSponsorReserveError {
return err instanceof InsufficientSponsorReserveError;
}

// ---------------------------------------------------------------------------
// Payment Expired Error (Invoice Expiry Guard)
// ---------------------------------------------------------------------------

/**
* Thrown when a payment is submitted after the invoice has expired.
*/
export class PaymentExpiredError extends StellarSplitError {
readonly invoiceId: string;
readonly expiresAt: number;
readonly now: number;

constructor(invoiceId: string, expiresAt: number, raw?: string) {
const now = Math.floor(Date.now() / 1000);
super(
`Payment rejected: invoice ${invoiceId} expired at ${expiresAt} (now: ${now})`,
"PAYMENT_EXPIRED",
{ invoiceId, expiresAt, now },
raw,
);
this.name = "PaymentExpiredError";
this.invoiceId = invoiceId;
this.expiresAt = expiresAt;
this.now = now;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isPaymentExpiredError(err: unknown): err is PaymentExpiredError {
return err instanceof PaymentExpiredError;
}
43 changes: 41 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export {
isIPFSConfigError,
ShutdownInProgressError,
isShutdownInProgressError,
InsufficientSponsorReserveError,
isInsufficientSponsorReserveError,
PaymentExpiredError,
isPaymentExpiredError,
} from "./errors.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -333,6 +337,16 @@ export type {
AdminFreezeResult,
AdminUnfreezeResult,
TransitionRecord,
SponsorshipConfig,
SponsorReserveCheckResult,
InvoiceRecord,
XDRType,
DecodedXDR,
DecodedTransactionEnvelope,
DecodedTransactionResult,
DecodedTransactionMeta,
DecodedLedgerEntry,
DecodedOperation,
} from "./types.js";
export { InvalidTransitionError } from "./types.js";

Expand Down Expand Up @@ -360,11 +374,35 @@ export type { RpcClient } from "./rpcClient.js";
export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js";
export type { VersionInfo } from "./types.js";

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

export { getSuggestion } from "./errorSuggestions.js";

// ---------------------------------------------------------------------------
// XDR Decoder — structured logging of Stellar XDR
// ---------------------------------------------------------------------------

export { decodeXDR } from "./xdrDecoder.js";

// ---------------------------------------------------------------------------
// SSE Cursor Tracker — persistent cursor for stream resumption
// ---------------------------------------------------------------------------

export {
configureCursorStore,
getCursor,
setCursor,
removeCursor,
setCursorFromSnapshot,
_resetCursorTrackerForTesting,
} from "./cursorTracker.js";
export type { CursorPersistence } from "./cursorTracker.js";

// ---------------------------------------------------------------------------
// Stream + SSE subscription helpers
// ---------------------------------------------------------------------------

// Real-time invoice event subscription (Issue #417)
export { createInvoiceSubscription } from "./subscription.js";
export type {
Expand Down Expand Up @@ -635,6 +673,7 @@ export {
buildSponsoredOnboarding,
MissingSponsorAccountError,
InsufficientReserveError,
checkSponsorshipReserve,
} from "./sponsorship.js";

export {
Expand Down
Loading