diff --git a/package-lock.json b/package-lock.json index bf8e9ae..665f7be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1106,7 +1106,6 @@ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -2040,7 +2039,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2392,8 +2390,7 @@ "version": "6.2.4", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/ieee754": { "version": "1.2.1", @@ -2839,7 +2836,6 @@ "version": "4.0.4", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2927,7 +2923,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3521,7 +3516,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/autoRecovery.ts b/src/autoRecovery.ts index 2ca0a2b..9b61d44 100644 --- a/src/autoRecovery.ts +++ b/src/autoRecovery.ts @@ -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 { if (this.intervalId !== null) { return; diff --git a/src/client.ts b/src/client.ts index 2cf5a84..3a3f65f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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"; @@ -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. */ @@ -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. @@ -989,6 +997,77 @@ export class StellarSplitClient { }); } + private _nftGateCache = new Map(); + + /** + * 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> { + const chain: Array<{ id: string; status: InvoiceStatus; forwardTo?: string }> = []; + const visited = new Set(); + 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. */ diff --git a/src/errors.ts b/src/errors.ts index 4b7aab2..ebdfd67 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/src/pdfReceipt.ts b/src/pdfReceipt.ts index 3036728..ac068bc 100644 --- a/src/pdfReceipt.ts +++ b/src/pdfReceipt.ts @@ -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(" "); @@ -158,7 +158,7 @@ class PdfBuilder { objContent.copy(pdf, offset); offset += objContent.length; } else { - const objContent = Buffer.from(`<<${this._dictToString(objects[i] as Record)}>>\nendobj\n`, "utf8"); + const objContent = Buffer.from(`<<${this._dictToString(objects[i] as any)}>>\nendobj\n`, "utf8"); objContent.copy(pdf, offset); offset += objContent.length; } @@ -185,7 +185,7 @@ class PdfBuilder { return new Uint8Array(pdf.slice(0, offset)); } - private _writeObject(obj: Record, isStream = false): number { + private _writeObject(obj: any, isStream = false): number { this.objectCount++; this.objects.set(this.objectCount, Buffer.from("")); return this.objectCount; diff --git a/src/templateMigration.ts b/src/templateMigration.ts index 3c2ff8f..6d93e27 100644 --- a/src/templateMigration.ts +++ b/src/templateMigration.ts @@ -41,11 +41,11 @@ export function diffTemplate( added.push({ field: key, from: undefined, - to: (schema as Record)[key], + to: (schema as unknown as Record)[key], }); } else { - const existingVal = (existing as Record)[key]; - const schemaVal = (schema as Record)[key]; + const existingVal = (existing as unknown as Record)[key]; + const schemaVal = (schema as unknown as Record)[key]; if ( isObject(existingVal) && isObject(schemaVal) && @@ -66,7 +66,7 @@ export function diffTemplate( if (!schemaKeys.has(key)) { removed.push({ field: key, - from: (existing as Record)[key], + from: (existing as unknown as Record)[key], to: undefined, }); } @@ -84,7 +84,7 @@ export function migrateTemplate( for (const key of schemaKeys) { if (migrated[key] === undefined) { - migrated[key] = (schema as Record)[key]; + migrated[key] = (schema as unknown as Record)[key]; } } diff --git a/src/types.ts b/src/types.ts index cbf9d54..a1c9751 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/utils.ts b/src/utils.ts index 287b2c8..357c1a8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; @@ -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) + }; +}