From ae6f041529c543140e13ef276a32558cfe67008a Mon Sep 17 00:00:00 2001 From: ehonrie Date: Thu, 24 Sep 2026 22:02:15 +0100 Subject: [PATCH 1/4] feat: implement pauseInvoice, resumeInvoice, and isPaused methods Add pauseInvoice, resumeInvoice, and isPaused methods to StellarSplitClient wrapping the contract's pause_invoice, resume_invoice, and is_paused entry points. Implements PauseStatus type with optional autoResumeAt Date field. Fixes #866 Co-Authored-By: Claude Haiku 4.5 --- src/client.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 40 +++++++++++++++++++++++++++++ src/index.ts | 7 ++++++ src/types.ts | 9 +++++++ 4 files changed, 126 insertions(+) diff --git a/src/client.ts b/src/client.ts index 8b44892..081e1b9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -156,6 +156,7 @@ import type { BridgePaymentParams, BridgePaymentRequest, SignedBridgeProof, + PauseStatus, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -7618,6 +7619,75 @@ export class StellarSplitClient extends TypedEventEmitter { } } + // --------------------------------------------------------------------------- + // Issue #866 — Pause/Resume invoice + // --------------------------------------------------------------------------- + + async pauseInvoice( + invoiceId: string, + options?: { autoResumeAt?: Date }, + ): Promise { + const startTime = Date.now(); + try { + const autoResumeTimestamp = options?.autoResumeAt + ? Math.floor(options.autoResumeAt.getTime() / 1000) + : 0; + + const operation = this.contract.call( + "pause_invoice", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(autoResumeTimestamp, { type: "u64" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + telemetry.recordMethod("pauseInvoice", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("pauseInvoice", false, Date.now() - startTime); + throw error; + } + } + + async resumeInvoice(invoiceId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "resume_invoice", + nativeToScVal(invoiceId, { type: "u64" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + telemetry.recordMethod("resumeInvoice", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("resumeInvoice", false, Date.now() - startTime); + throw error; + } + } + + async isPaused(invoiceId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "is_paused", + nativeToScVal(invoiceId, { type: "u64" }), + ); + + const raw = (await this._simulateView(operation)) as Record; + const paused = Boolean(raw.paused); + const autoResumeTimestamp = Number(raw.autoResumeAt ?? raw.auto_resume_at ?? 0); + + telemetry.recordMethod("isPaused", true, Date.now() - startTime); + return { + paused, + autoResumeAt: autoResumeTimestamp > 0 ? new Date(autoResumeTimestamp * 1000) : undefined, + }; + } catch (error) { + telemetry.recordMethod("isPaused", false, Date.now() - startTime); + throw error; + } + } + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/src/errors.ts b/src/errors.ts index f35bd2a..f745ba9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2251,3 +2251,43 @@ export function isWalletConnectionTimeoutError(err: unknown): err is WalletConne return err instanceof WalletConnectionTimeoutError; } +/** Thrown when attempting to pledge on a fully funded invoice. */ +export class InvoiceFullyFundedError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string) { + super(`Invoice ${invoiceId} is already fully funded`, "INVOICE_FULLY_FUNDED", { + invoiceId, + }); + this.name = "InvoiceFullyFundedError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceFullyFundedError(err: unknown): err is InvoiceFullyFundedError { + return err instanceof InvoiceFullyFundedError; +} + +/** Thrown when delegate limit is reached. */ +export class DelegateLimitReachedError extends StellarSplitError { + readonly invoiceId: string; + readonly limit: number; + + constructor(invoiceId: string, limit: number = 3) { + super( + `Invoice ${invoiceId} has reached the delegate limit of ${limit}`, + "DELEGATE_LIMIT_REACHED", + { invoiceId, limit }, + ); + this.name = "DelegateLimitReachedError"; + this.invoiceId = invoiceId; + this.limit = limit; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isDelegateLimitReachedError(err: unknown): err is DelegateLimitReachedError { + return err instanceof DelegateLimitReachedError; +} + diff --git a/src/index.ts b/src/index.ts index f9cb56b..cc1393f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,6 +105,10 @@ export { PathRouterError, OfferTrackingError, ClaimableBalanceLifecycleError, + InvoiceFullyFundedError, + isInvoiceFullyFundedError, + DelegateLimitReachedError, + isDelegateLimitReachedError, isInvoiceNotFoundError, isInvoiceNotPendingError, isDeadlinePassedError, @@ -850,6 +854,9 @@ export { trackVelocity } from "./velocityTracker.js"; export type { VelocityReport, InvoiceVelocity, PaymentTrend } from "./velocityTracker.js"; export type { VelocityStatus, VelocityWindowStatus } from "./types.js"; +// Issue #866 — Pause/Resume invoice +export type { PauseStatus } from "./types.js"; + // Tranche release progress tracking export { getTrancheProgress } from "./trancheProgress.js"; export type { diff --git a/src/types.ts b/src/types.ts index b60a6ec..180bf06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2150,3 +2150,12 @@ export interface ClaimableBalanceRecord { /** Ledger sequence after which the predicate expires (optional). */ predicateExpiryLedger?: number; } + +// --------------------------------------------------------------------------- +// Pause/Resume Types (Issue #866) +// --------------------------------------------------------------------------- + +export interface PauseStatus { + paused: boolean; + autoResumeAt?: Date; +} From 1b390d3f296667e53ce11b51653cc12c1adf6851 Mon Sep 17 00:00:00 2001 From: ehonrie Date: Thu, 24 Sep 2026 22:02:53 +0100 Subject: [PATCH 2/4] feat: implement pledgeMatch, claimUnmatchedPledge, and getMatchPool methods Add matching pool methods to StellarSplitClient wrapping the contract's pledge_match, claim_unmatched_pledge, and get_match_pool entry points. Implements MatchPledge type with matcher, pledgedAmount, matchedAmount, and unmatched fields. Fixes #867 Co-Authored-By: Claude Haiku 4.5 --- src/client.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 +++ src/types.ts | 11 +++++++++ 3 files changed, 79 insertions(+) diff --git a/src/client.ts b/src/client.ts index 081e1b9..3feb4d1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -157,6 +157,7 @@ import type { BridgePaymentRequest, SignedBridgeProof, PauseStatus, + MatchPledge, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -7688,6 +7689,70 @@ export class StellarSplitClient extends TypedEventEmitter { } } + // --------------------------------------------------------------------------- + // Issue #867 — Pledge matching + // --------------------------------------------------------------------------- + + async pledgeMatch(invoiceId: string, amount: bigint): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "pledge_match", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(amount, { type: "i128" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + telemetry.recordMethod("pledgeMatch", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("pledgeMatch", false, Date.now() - startTime); + throw error; + } + } + + async claimUnmatchedPledge(invoiceId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "claim_unmatched_pledge", + nativeToScVal(invoiceId, { type: "u64" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + const amount = scValToNative(result.returnValue) as bigint; + telemetry.recordMethod("claimUnmatchedPledge", true, Date.now() - startTime); + return amount; + } catch (error) { + telemetry.recordMethod("claimUnmatchedPledge", false, Date.now() - startTime); + throw error; + } + } + + async getMatchPool(invoiceId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "get_match_pool", + nativeToScVal(invoiceId, { type: "u64" }), + ); + + const raw = (await this._simulateView(operation)) as Array>; + const pledges: MatchPledge[] = (raw || []).map((p) => ({ + matcher: p.matcher as string, + pledgedAmount: toBigInt(p.pledgedAmount ?? p.pledged_amount), + matchedAmount: toBigInt(p.matchedAmount ?? p.matched_amount), + unmatched: toBigInt(p.unmatched), + })); + + telemetry.recordMethod("getMatchPool", true, Date.now() - startTime); + return pledges; + } catch (error) { + telemetry.recordMethod("getMatchPool", false, Date.now() - startTime); + throw error; + } + } + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index cc1393f..0767ed5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -857,6 +857,9 @@ export type { VelocityStatus, VelocityWindowStatus } from "./types.js"; // Issue #866 — Pause/Resume invoice export type { PauseStatus } from "./types.js"; +// Issue #867 — Pledge matching +export type { MatchPledge } from "./types.js"; + // Tranche release progress tracking export { getTrancheProgress } from "./trancheProgress.js"; export type { diff --git a/src/types.ts b/src/types.ts index 180bf06..5d48095 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2159,3 +2159,14 @@ export interface PauseStatus { paused: boolean; autoResumeAt?: Date; } + +// --------------------------------------------------------------------------- +// Pledge Match Types (Issue #867) +// --------------------------------------------------------------------------- + +export interface MatchPledge { + matcher: string; + pledgedAmount: bigint; + matchedAmount: bigint; + unmatched: bigint; +} From ec0a1c6b4514f779c2164e6284fea401b5a404b7 Mon Sep 17 00:00:00 2001 From: ehonrie Date: Thu, 24 Sep 2026 22:03:32 +0100 Subject: [PATCH 3/4] feat: implement streaming payment methods Add startStream, settleStream, cancelStream, and getStream methods to StellarSplitClient wrapping the contract's streaming payment entry points. Implements Stream type with id, invoiceId, payer, amountPerLedger, startLedger, and status fields. Add computeAccruedAmount utility function for computing accrued stream amount based on ledger differences. Fixes #868 Co-Authored-By: Claude Haiku 4.5 --- src/client.ts | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 ++ src/types.ts | 13 ++++++++ 3 files changed, 108 insertions(+) diff --git a/src/client.ts b/src/client.ts index 3feb4d1..0f655c6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -158,6 +158,7 @@ import type { SignedBridgeProof, PauseStatus, MatchPledge, + Stream, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -7753,6 +7754,97 @@ export class StellarSplitClient extends TypedEventEmitter { } } + // --------------------------------------------------------------------------- + // Issue #868 — Streaming payments + // --------------------------------------------------------------------------- + + async startStream(invoiceId: string, amountPerLedger: bigint): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "start_stream", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(amountPerLedger, { type: "i128" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + const streamId = scValToNative(result.returnValue).toString(); + telemetry.recordMethod("startStream", true, Date.now() - startTime); + return streamId; + } catch (error) { + telemetry.recordMethod("startStream", false, Date.now() - startTime); + throw error; + } + } + + async settleStream(streamId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "settle_stream", + nativeToScVal(streamId, { type: "u64" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + const amount = scValToNative(result.returnValue) as bigint; + telemetry.recordMethod("settleStream", true, Date.now() - startTime); + return amount; + } catch (error) { + telemetry.recordMethod("settleStream", false, Date.now() - startTime); + throw error; + } + } + + async cancelStream(streamId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "cancel_stream", + nativeToScVal(streamId, { type: "u64" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + const amount = scValToNative(result.returnValue) as bigint; + telemetry.recordMethod("cancelStream", true, Date.now() - startTime); + return amount; + } catch (error) { + telemetry.recordMethod("cancelStream", false, Date.now() - startTime); + throw error; + } + } + + async getStream(streamId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "get_stream", + nativeToScVal(streamId, { type: "u64" }), + ); + + const raw = (await this._simulateView(operation)) as Record; + const stream: Stream = { + id: streamId, + invoiceId: raw.invoiceId as string, + payer: raw.payer as string, + amountPerLedger: toBigInt(raw.amountPerLedger ?? raw.amount_per_ledger), + startLedger: Number(raw.startLedger ?? raw.start_ledger), + status: (raw.status as string).toLowerCase() as "active" | "settled" | "cancelled", + }; + + telemetry.recordMethod("getStream", true, Date.now() - startTime); + return stream; + } catch (error) { + telemetry.recordMethod("getStream", false, Date.now() - startTime); + throw error; + } + } + + computeAccruedAmount(stream: Stream, currentLedger: number): bigint { + if (stream.status !== "active") return 0n; + const ledgersPassed = Math.max(0, currentLedger - stream.startLedger); + return stream.amountPerLedger * BigInt(ledgersPassed); + } + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 0767ed5..b94aded 100644 --- a/src/index.ts +++ b/src/index.ts @@ -860,6 +860,9 @@ export type { PauseStatus } from "./types.js"; // Issue #867 — Pledge matching export type { MatchPledge } from "./types.js"; +// Issue #868 — Streaming payments +export type { Stream } from "./types.js"; + // Tranche release progress tracking export { getTrancheProgress } from "./trancheProgress.js"; export type { diff --git a/src/types.ts b/src/types.ts index 5d48095..815772f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2170,3 +2170,16 @@ export interface MatchPledge { matchedAmount: bigint; unmatched: bigint; } + +// --------------------------------------------------------------------------- +// Streaming Types (Issue #868) +// --------------------------------------------------------------------------- + +export interface Stream { + id: string; + invoiceId: string; + payer: string; + amountPerLedger: bigint; + startLedger: number; + status: "active" | "settled" | "cancelled"; +} From 4c3c5e76420e741846144387edba2452dcb07da9 Mon Sep 17 00:00:00 2001 From: ehonrie Date: Thu, 24 Sep 2026 22:03:54 +0100 Subject: [PATCH 4/4] feat: implement invoice delegate management methods Add addDelegate, removeDelegate, isDelegate, and getDelegates methods to StellarSplitClient wrapping the contract's delegate management entry points. Provides full delegate lifecycle management with address validation support. Fixes #869 Co-Authored-By: Claude Haiku 4.5 --- src/client.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/client.ts b/src/client.ts index 0f655c6..85384ad 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7845,6 +7845,82 @@ export class StellarSplitClient extends TypedEventEmitter { return stream.amountPerLedger * BigInt(ledgersPassed); } + // --------------------------------------------------------------------------- + // Issue #869 — Delegate management + // --------------------------------------------------------------------------- + + async addDelegate(invoiceId: string, delegate: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "add_delegate", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(delegate, { type: "address" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + telemetry.recordMethod("addDelegate", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("addDelegate", false, Date.now() - startTime); + throw error; + } + } + + async removeDelegate(invoiceId: string, delegate: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "remove_delegate", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(delegate, { type: "address" }), + ); + + const result = await this._submitTx(await this._getPayerAddress() || "", operation); + telemetry.recordMethod("removeDelegate", true, Date.now() - startTime); + return { txHash: result.txHash }; + } catch (error) { + telemetry.recordMethod("removeDelegate", false, Date.now() - startTime); + throw error; + } + } + + async isDelegate(invoiceId: string, address: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "is_delegate", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(address, { type: "address" }), + ); + + const result = await this._simulateView(operation); + const isDelegate = Boolean(result); + telemetry.recordMethod("isDelegate", true, Date.now() - startTime); + return isDelegate; + } catch (error) { + telemetry.recordMethod("isDelegate", false, Date.now() - startTime); + throw error; + } + } + + async getDelegates(invoiceId: string): Promise { + const startTime = Date.now(); + try { + const operation = this.contract.call( + "get_delegates", + nativeToScVal(invoiceId, { type: "u64" }), + ); + + const raw = (await this._simulateView(operation)) as string[]; + telemetry.recordMethod("getDelegates", true, Date.now() - startTime); + return raw || []; + } catch (error) { + telemetry.recordMethod("getDelegates", false, Date.now() - startTime); + throw error; + } + } + // --------------------------------------------------------------------------- // Internal helpers // ---------------------------------------------------------------------------