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
8 changes: 1 addition & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/autoRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class AutoRecoveryMonitor {
* @param client - StellarSplitClient instance with server and loadBalancer
*/
async start(
client: StellarSplitClient & { server: SorobanRpc.Server; loadBalancer: LoadBalancer }
client: any
): Promise<void> {
if (this.intervalId !== null) {
return;
Expand Down
83 changes: 81 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,20 @@ import type {
RolloverResult,
} from "./types.js";
import type { DIContainer, IRPCClient, ICacheStore, IWalletAdapter } from "./container.js";
import { InvoiceNotFoundError } from "./types.js";
import {
CoCreatorApprovalNotRequiredError,
ForwardChainTooDeepError,
InvoiceFrozenError,
InvoiceNotFoundError,
InvoiceNotPendingError,
parseSorobanError,
} from "./errors.js";
import { replayEvents } from "./events.js";
import { subscribeToInvoice as _subscribeToInvoice } from "./stream.js";
import { ConnectionPool } from "./connectionPool.js";
import { snapshotInvoice as _snapshotInvoice } from "./snapshot.js";
import type { InvoiceSnapshot } from "./snapshot.js";
import { SimpleCache } from "./cache.js";
import { parseSorobanError } from "./errors.js";
import { validateOrThrow } from "./configValidator.js";
import { extendStorageTtl, buildInvoiceDataLedgerKey } from "./ttlExtension.js";
import type { TtlExtensionOptions, TtlExtensionResult } from "./ttlExtension.js";
Expand Down Expand Up @@ -203,6 +209,7 @@ export interface StellarSplitClientConfig {
/** Flush interval in milliseconds. Default: 60_000. */
flushIntervalMs?: number;
};
/**
* Optional idempotency configuration for write methods.
* When provided, duplicate submissions are detected and short-circuited.
*/
Expand All @@ -212,6 +219,7 @@ export interface StellarSplitClientConfig {
* 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 @@ -989,6 +997,77 @@ export class StellarSplitClient {
});
}

private _nftGateCache = new Map<string, { timestamp: number; result: { gated: boolean; hasNft: boolean; contractAddress: string | null } }>();

/**
* Checks the NFT gate status for a given creator address.
*/
async checkNftGate(creatorAddress: string): Promise<{ gated: boolean; hasNft: boolean; contractAddress: string | null }> {
const now = Date.now();
const cached = this._nftGateCache.get(creatorAddress);
if (cached && now - cached.timestamp < 30000) {
return cached.result;
}

try {
const operation = this.contract.call(
"check_nft_gate",
nativeToScVal(creatorAddress, { type: "address" })
);

const raw = await this._simulateView(operation) as any;
let result = { gated: false, hasNft: false, contractAddress: null };

if (raw && typeof raw === "object") {
result = {
gated: Boolean(raw.gated),
hasNft: Boolean(raw.hasNft || raw.has_nft),
contractAddress: (raw.contractAddress || raw.contract_address) ?? null
};
}

this._nftGateCache.set(creatorAddress, { timestamp: now, result });
return result;
} catch (error) {
// If the method doesn't exist or fails, assume no gate
const result = { gated: false, hasNft: false, contractAddress: null };
this._nftGateCache.set(creatorAddress, { timestamp: now, result });
return result;
}
}

/**
* Resolves the forward chain for an invoice.
*/
async getForwardChain(invoiceId: string): Promise<Array<{ id: string; status: InvoiceStatus; forwardTo?: string }>> {
const chain: Array<{ id: string; status: InvoiceStatus; forwardTo?: string }> = [];
const visited = new Set<string>();
let currentId: string | undefined = invoiceId;
let depth = 0;

while (currentId) {
if (depth >= 10) {
throw new ForwardChainTooDeepError(`Max chain depth of 10 exceeded starting from invoice ${invoiceId}`);
}
if (visited.has(currentId)) {
throw new Error(`Circular forward chain detected at invoice ${currentId}`);
}
visited.add(currentId);
depth++;

const invoice = await this.getInvoice(currentId);
chain.push({
id: invoice.id,
status: invoice.status,
forwardTo: invoice.forward_invoice_id,
});

currentId = invoice.forward_invoice_id;
}

return chain;
}

/**
* Gracefully shutdown the SDK client, flush pending operations, and close internal resources.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export class CoCreatorApprovalNotRequiredError extends StellarSplitError {
}
}

/** Thrown when resolving a forward chain exceeds the maximum depth limit. */
export class ForwardChainTooDeepError extends StellarSplitError {
constructor(message: string, raw?: string) {
super(message, raw ?? message);
this.name = "ForwardChainTooDeepError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ---------------------------------------------------------------------------
// Error message patterns from the Soroban contract
// ---------------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions src/pdfReceipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class PdfBuilder {
const pages = this._writeObject({
Type: "/Pages",
Kids: "[3 0 R]",
Count: 1,
Count: "1",
});

const content = this.textContent.join(" ");
Expand Down Expand Up @@ -158,7 +158,7 @@ class PdfBuilder {
objContent.copy(pdf, offset);
offset += objContent.length;
} else {
const objContent = Buffer.from(`<<${this._dictToString(objects[i] as Record<string, string>)}>>\nendobj\n`, "utf8");
const objContent = Buffer.from(`<<${this._dictToString(objects[i] as any)}>>\nendobj\n`, "utf8");
objContent.copy(pdf, offset);
offset += objContent.length;
}
Expand All @@ -185,7 +185,7 @@ class PdfBuilder {
return new Uint8Array(pdf.slice(0, offset));
}

private _writeObject(obj: Record<string, string>, isStream = false): number {
private _writeObject(obj: any, isStream = false): number {
this.objectCount++;
this.objects.set(this.objectCount, Buffer.from(""));
return this.objectCount;
Expand Down
10 changes: 5 additions & 5 deletions src/templateMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ export function diffTemplate(
added.push({
field: key,
from: undefined,
to: (schema as Record<string, unknown>)[key],
to: (schema as unknown as Record<string, unknown>)[key],
});
} else {
const existingVal = (existing as Record<string, unknown>)[key];
const schemaVal = (schema as Record<string, unknown>)[key];
const existingVal = (existing as unknown as Record<string, unknown>)[key];
const schemaVal = (schema as unknown as Record<string, unknown>)[key];
if (
isObject(existingVal) &&
isObject(schemaVal) &&
Expand All @@ -66,7 +66,7 @@ export function diffTemplate(
if (!schemaKeys.has(key)) {
removed.push({
field: key,
from: (existing as Record<string, unknown>)[key],
from: (existing as unknown as Record<string, unknown>)[key],
to: undefined,
});
}
Expand All @@ -84,7 +84,7 @@ export function migrateTemplate(

for (const key of schemaKeys) {
if (migrated[key] === undefined) {
migrated[key] = (schema as Record<string, unknown>)[key];
migrated[key] = (schema as unknown as Record<string, unknown>)[key];
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ export interface Invoice {
parentInvoiceId?: string;
/** Depth in the clone chain (0 = root, 1 = cloned from root, etc.). */
cloneDepth?: number;
/** The address of the NFT contract used for gating, if any. */
nft_gate?: string;
/** ID of the next invoice in the forward chain, if any. */
forward_invoice_id?: string;
/** Unix timestamp after which penalties apply. */
penalty_deadline?: number;
/** Configured penalty tiers for late payments. */
penalty_tiers?: { days_late: number; penalty_bps: number }[];
/** List of caller addresses permitted to interact, or null if open. */
allowed_callers?: string[] | null;
}

export interface InvoiceLifecycleHooks {
Expand Down
52 changes: 52 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Utility helpers for StellarSplit SDK.
*/
import { Invoice } from "./types";

/** Number of decimal places used by Stellar token amounts (stroops). */
const STROOPS_PER_UNIT = 10_000_000n;
Expand Down Expand Up @@ -57,3 +58,54 @@ export function truncateAddress(address: string, chars = 4): string {
if (address.length <= chars * 2 + 3) return address;
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}

/**
* Validates if a caller is in the invoice's allowed callers list.
*/
export function validateCallerAllowlist(
invoice: Invoice,
callerAddress: string
): { allowed: boolean; reason?: string } {
if (!invoice.allowed_callers) {
return { allowed: true };
}
if (invoice.allowed_callers.includes(callerAddress)) {
return { allowed: true };
}
return { allowed: false, reason: "caller not in allowlist" };
}

/**
* Computes the penalty amount owed for a late payment.
*/
export function calculatePenalty(
invoice: Invoice,
paymentTimestamp: number
): { penaltyBps: number; penaltyAmount: bigint; tier: number | null } {
if (!invoice.penalty_deadline || paymentTimestamp <= invoice.penalty_deadline) {
return { penaltyBps: 0, penaltyAmount: 0n, tier: null };
}

if (!invoice.penalty_tiers || invoice.penalty_tiers.length === 0) {
return { penaltyBps: 0, penaltyAmount: 0n, tier: null };
}

const daysLate = Math.ceil((paymentTimestamp - invoice.penalty_deadline) / 86400);

// Sort tiers by days_late descending to find the highest applicable tier
const sortedTiers = [...invoice.penalty_tiers].sort((a, b) => b.days_late - a.days_late);
const applicableTier = sortedTiers.find(tier => daysLate >= tier.days_late);

if (!applicableTier) {
return { penaltyBps: 0, penaltyAmount: 0n, tier: null };
}

const totalAmount = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n);
const penaltyAmount = (totalAmount * BigInt(applicableTier.penalty_bps)) / 10000n;

return {
penaltyBps: applicableTier.penalty_bps,
penaltyAmount,
tier: invoice.penalty_tiers.indexOf(applicableTier)
};
}