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
2 changes: 2 additions & 0 deletions src/cache/OptimisticCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export class OptimisticCache<T = unknown> {
/** Per-invoice FIFO queue of pending (uncommitted, unrolled-back) predictions. */
private readonly pending = new Map<string, OptimisticEntry<T>[]>();
private readonly rollbackHandlers = new Set<(event: RollbackEvent<T>) => void>();
private readonly eventHandlers = new Set<(event: CacheEvent<T>) => void>();
private readonly expiries = new Map<string, number>();
private readonly versionCounters = new Map<string, number>();
/** Freshness bookkeeping for values written through read()/set(). */
private readonly freshness = new Map<string, FreshnessEntry<T>>();
Expand Down
41 changes: 36 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export type SplitClientEventMap = {
dispute: import("./contractEvents.js").DisputeEvent;
/** Emitted when a tier is unlocked on an invoice. */
tier_unlocked: import("./contractEvents.js").TierUnlockedEvent;
"queue:drained": undefined;
"queue:operation:failed": { operation: import("./offlineQueue.js").QueuedOperation; error: unknown };
"queue:conflict:resolved": { operation: import("./offlineQueue.js").QueuedOperation; strategy: string; remoteState?: unknown };
};
import { signTransaction } from "./wallet.js";
import { telemetry } from "./telemetry.js";
Expand Down Expand Up @@ -924,6 +927,9 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
this._rpcClient = config.container?.getRPCClient() ?? null;
this._adapter =
config.container?.getWalletAdapter() ?? config.adapter ?? null;
if (config.offlineQueue) {
this._offlineQueue = new OfflineQueue(this, config.offlineQueue);
}

// Per-method timeout manager (Issue #1)
/**
Expand Down Expand Up @@ -1307,6 +1313,11 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
if (this._offlineQueue) this._offlineQueue.clear();
}

async syncOfflineQueue(conflictStrategy?: import("./types.js").ConflictStrategy) {
if (!this._offlineQueue) return null;
return this._offlineQueue.sync(conflictStrategy);
}

async switchTo(network: "mainnet" | "testnet" | "futurenet"): Promise<void> {
const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js");
return NetworkSwitcher.switchTo(network, this);
Expand Down Expand Up @@ -3379,6 +3390,18 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
params as unknown as Record<string, unknown>,
);
}
if (this._offlineQueue?.config?.enabled) {
try {
const health = await this.checkRPCHealth();
if (health.status === "down") {
this._offlineQueue.enqueue("pay", [params]);
return { txHash: "queued" };
}
} catch (e) {
this._offlineQueue.enqueue("pay", [params]);
return { txHash: "queued" };
}
}
const startTime = Date.now();
params = this._pluginRegistry.runBeforeCall("pay", params);

Expand Down Expand Up @@ -10709,15 +10732,23 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
* @throws {Error} If the method fails.
*/
async connectWallet(adapter: WalletAdapter): Promise<string> {
const address = await adapter.connect();
const address =
typeof (adapter as any).connect === "function"
? await (adapter as any).connect()
: await adapter.getAddress();
this._adapter = adapter;

// Listen for account changes and update internal reference
adapter.onAccountChange((newAddress: string) => {
this.emit("wallet:accountChanged", newAddress);
});
if (typeof (adapter as any).onAccountChange === "function") {
(adapter as any).onAccountChange((newAddress: string) => {
this.emit("wallet:accountChanged", newAddress);
});
}

this.emit("wallet:connected", { walletName: adapter.name, address });
this.emit("wallet:connected", {
walletName: (adapter as any).name ?? "Wallet",
address,
});
return address;
}

Expand Down
1 change: 1 addition & 0 deletions src/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const KNOWN_CONFIG_KEYS = new Set([
"dexContractId",
"circuitBreaker",
"optimisticCache",
"offlineQueue",
]);

export class UnknownConfigKeyError extends StellarSplitError {
Expand Down
89 changes: 82 additions & 7 deletions src/offlineQueue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StellarSplitClient } from "./client.js";
import type { ConflictStrategy } from "./types.js";

export interface QueuedOperation {
id: string;
Expand All @@ -11,13 +12,37 @@ export interface OfflineQueueConfig {
enabled: boolean;
maxQueueSize: number;
persistToStorage: boolean;
conflictStrategy?: ConflictStrategy;
}

export interface SyncResult {
synced: number;
failed: number;
conflicts: number;
operations: QueuedOperation[];
}

function serializeQueue(queue: QueuedOperation[]): string {
return JSON.stringify(queue, (_key, value) =>
typeof value === "bigint" ? { __type: "bigint", value: value.toString() } : value
);
}

function deserializeQueue(raw: string): QueuedOperation[] {
return JSON.parse(raw, (_key, value) => {
if (value && typeof value === "object" && value.__type === "bigint") {
return BigInt(value.value);
}
return value;
});
}

export class OfflineQueue {
private queue: QueuedOperation[] = [];
private config: OfflineQueueConfig;
public readonly config: OfflineQueueConfig;
private client: StellarSplitClient;
private readonly storageKey = "stellar_split_offline_queue";
private isDraining = false;

constructor(client: StellarSplitClient, config: OfflineQueueConfig) {
this.client = client;
Expand All @@ -35,7 +60,10 @@ export class OfflineQueue {
}

const operation: QueuedOperation = {
id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2),
id:
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).substring(2),
method,
args,
timestamp: Date.now(),
Expand All @@ -59,44 +87,91 @@ export class OfflineQueue {
}
}

public recoverState(): QueuedOperation[] {
this.loadFromStorage();
return this.getQueue();
}

public async drain(): Promise<void> {
if (this.queue.length === 0) return;
await this.sync();
}

public async sync(conflictStrategy?: ConflictStrategy): Promise<SyncResult> {
if (this.isDraining || this.queue.length === 0) {
return { synced: 0, failed: 0, conflicts: 0, operations: [] };
}

this.isDraining = true;
const strategy = conflictStrategy ?? this.config.conflictStrategy ?? "remote-wins";
const operationsToDrain = [...this.queue];
this.clear();

const result: SyncResult = {
synced: 0,
failed: 0,
conflicts: 0,
operations: operationsToDrain,
};

for (const op of operationsToDrain) {
try {
if (op.method === "pay" && op.args.length > 0 && typeof this.client.getInvoice === "function") {
const invoiceId = op.args[0]?.invoiceId;
if (invoiceId) {
try {
const remoteInvoice = await this.client.getInvoice(invoiceId);
if (remoteInvoice && remoteInvoice.status !== "pending") {
result.conflicts++;
if (strategy === "remote-wins") {
this.client.emit("queue:conflict:resolved", {
operation: op,
strategy,
remoteState: remoteInvoice,
});
continue;
}
}
} catch {
// Proceed if remote fetch fails
}
}
}

// @ts-ignore
await this.client[op.method](...op.args);
result.synced++;
} catch (error) {
result.failed++;
this.client.emit("queue:operation:failed", { operation: op, error });
}
}

this.client.emit("queue:drained", undefined);
this.isDraining = false;
return result;
}

private loadFromStorage(): void {
public loadFromStorage(): void {
if (typeof localStorage !== "undefined") {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
this.queue = JSON.parse(stored);
this.queue = deserializeQueue(stored);
}
} catch (e) {
console.warn("Failed to load offline queue from storage", e);
}
}
}

private saveToStorage(): void {
public saveToStorage(): void {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.queue));
localStorage.setItem(this.storageKey, serializeQueue(this.queue));
} catch (e) {
console.warn("Failed to save offline queue to storage", e);
}
}
}
}

4 changes: 2 additions & 2 deletions src/retryPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export function isRetryable(error: unknown): boolean {
if (error instanceof WalletNotConnectedError) return false;

if (error instanceof RpcError) {
return error.statusCode === 429 || error.statusCode === 503;
return error.statusCode === 429 || error.statusCode === 502 || error.statusCode === 503 || error.statusCode === 504;
}

if (!(error instanceof Error)) return false;

const msg = error.message.toLowerCase();
return (
error.name === "AbortError" ||
/timeout|timed out|network|failed to fetch|connection|connect|econnreset|econnrefused|eai_again|enotfound|429|503/.test(
/timeout|timed out|network|failed to fetch|connection|connect|econnreset|econnrefused|eai_again|enotfound|429|502|503|504/.test(
msg
)
);
Expand Down
Loading