From d8a01d28ab469612f652241f73ac913f174c7a03 Mon Sep 17 00:00:00 2001 From: Cephas Date: Sat, 26 Sep 2026 22:47:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20issues=20#878=20#879=20#880?= =?UTF-8?q?=20#881=20=E2=80=94=20rounds,=20milestones,=20referrals,=20erro?= =?UTF-8?q?r=20taxonomy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue #881 — Typed error taxonomy - Added RoundNotEndedError, WrongMilestoneError, NothingToClaimError - Added InvoiceExpiredError, PayerNotWhitelistedError, ContributionCapExceededError - Added PaymentCooldownActiveError (with retryAfter: Date field) - Added ContractFrozenError, InvoiceFullyFundedError - Added isRetryable(error) utility — true for transient RPC errors, false for contract logic errors - All new errors extend StellarSplitError with typed code, context and raw fields - Exported all new classes and guards from src/index.ts ## Issue #878 — closeRound and getRoundInfo - Added closeRound(invoiceId): Promise wrapping close_round entry point - Added getRoundInfo(invoiceId): Promise returning current round state - RoundCloseResult includes totalRaised, hardCap, overflow, refunds: PayerRefund[] - PayerRefund includes payer, amountKept, amountRefunded - Throws RoundNotEndedError when round_end timestamp has not passed - Tests: close after round end, close before end throws, getRoundInfo success/not-found ## Issue #879 — Milestone management - Added completeMilestone(invoiceId, index): Promise - Added getActiveMilestone(invoiceId): Promise - Added getMilestones(invoiceId): Promise - MilestoneResult includes index, amountReleased, nextMilestoneIndex?, txHash - Milestone includes index, description, targetAmount, fundedAmount, status - Throws WrongMilestoneError when index is not the active milestone - Tests: complete active milestone, wrong index throws, get all states ## Issue #880 — Referral rewards - Added claimReferralRewards(): Promise - Added getReferralBalance(address?): Promise (defaults to connected wallet) - Added payWithReferral(invoiceId, amount, referrer): Promise - Validates referrer as a valid Stellar G-address (throws ValidationError if invalid) - Throws NothingToClaimError when referral balance is zero - Tests: pay with referrer, balance check, claim, claim zero throws, invalid address throws --- src/client.ts | 502 +++++++++++++++++++++++++++++++++++++ src/errors.ts | 237 +++++++++++++++++ src/index.ts | 37 +++ src/types.ts | 95 +++++++ test/closeRound.test.ts | 171 +++++++++++++ test/errorTaxonomy.test.ts | 393 +++++++++++++++++++++++++++++ test/milestones.test.ts | 239 ++++++++++++++++++ test/referral.test.ts | 289 +++++++++++++++++++++ 8 files changed, 1963 insertions(+) create mode 100644 test/closeRound.test.ts create mode 100644 test/errorTaxonomy.test.ts create mode 100644 test/milestones.test.ts create mode 100644 test/referral.test.ts diff --git a/src/client.ts b/src/client.ts index 8b44892..23df2c2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -78,6 +78,7 @@ import { } from "./compression.js"; import type { CompressionConfig } from "./compression.js"; import { calculateFee } from "./fee.js"; +import { isValidStellarAddress as isValidAddress } from "./utils.js"; import { resolveToken } from "./token.js"; import { generatePaymentReceipt } from "./receipt.js"; import type { PaymentReceipt } from "./receipt.js"; @@ -156,6 +157,14 @@ import type { BridgePaymentParams, BridgePaymentRequest, SignedBridgeProof, + RoundCloseResult, + RoundInfo, + Milestone, + MilestoneStatus, + MilestoneResult, + ReferralClaimResult, + ReferralPayResult, + PayerRefund, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -204,6 +213,9 @@ import { InvoiceIntegrityError, InvoiceNotCloneableError, InvalidTransactionTypeError, + RoundNotEndedError, + WrongMilestoneError, + NothingToClaimError, } from "./errors.js"; import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; import { buildFeeBump } from "./feeBumpBuilder.js"; @@ -9501,6 +9513,496 @@ export class StellarSplitClient extends TypedEventEmitter { return { invoiceId, txHash }; } + + // --------------------------------------------------------------------------- + // Issue #878 — Fundraising Round methods + // --------------------------------------------------------------------------- + + /** + * Closes a fundraising round for the given invoice, computing pro-rata + * refunds for any over-subscribed payers. + * + * @throws {RoundNotEndedError} if the round_end timestamp has not yet passed. + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async closeRound(invoiceId: string): Promise { + const method = "close_round"; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("RoundNotEnded") || raw?.includes("round_not_ended")) { + // Parse round end from context if available + throw new RoundNotEndedError(invoiceId, 0, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await signTransaction(prepared.toXDR(), { + network: this.config.networkPassphrase, + networkPassphrase: this.config.networkPassphrase, + accountToSign: this.config.publicKey, + } as Parameters[1]); + const result = await this.server.sendTransaction( + TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) + ); + if (result.status === "ERROR") { + throw new TransactionFailedError("close_round transaction failed", result.hash); + } + + // Parse the result — the contract returns a map of payer -> amounts + // We decode optimistically; real XDR parsing is contract-specific + const native = sim.result ? scValToNative(sim.result.retval) : null; + const refunds: PayerRefund[] = []; + let totalRaised = 0n; + let hardCap = 0n; + let overflow = 0n; + + if (native && typeof native === "object") { + totalRaised = BigInt((native as Record).total_raised ?? 0); + hardCap = BigInt((native as Record).hard_cap ?? 0); + overflow = BigInt((native as Record).overflow ?? 0); + if (Array.isArray((native as Record).refunds)) { + for (const r of (native as Record).refunds as Record[]) { + refunds.push({ + payer: String(r.payer), + amountKept: BigInt(r.amount_kept ?? 0), + amountRefunded: BigInt(r.amount_refunded ?? 0), + }); + } + } + } + + return { totalRaised, hardCap, overflow, refunds, txHash: result.hash }; + } catch (err) { + if ( + err instanceof RoundNotEndedError || + err instanceof InvoiceNotFoundError || + err instanceof SimulationFailedError || + err instanceof TransactionFailedError + ) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + /** + * Fetches the current state of the fundraising round for the given invoice. + * + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async getRoundInfo(invoiceId: string): Promise { + const method = "get_round_info"; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("NotFound") || raw?.includes("not_found")) { + throw new InvoiceNotFoundError(invoiceId, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const native = sim.result ? scValToNative(sim.result.retval) : {}; + return { + invoiceId, + totalRaised: BigInt((native as Record)?.total_raised ?? 0), + hardCap: BigInt((native as Record)?.hard_cap ?? 0), + roundEnd: Number((native as Record)?.round_end ?? 0), + closed: Boolean((native as Record)?.closed ?? false), + }; + } catch (err) { + if (err instanceof InvoiceNotFoundError || err instanceof SimulationFailedError) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + // --------------------------------------------------------------------------- + // Issue #879 — Milestone methods + // --------------------------------------------------------------------------- + + /** + * Marks the specified milestone as completed and releases funds to recipients. + * + * @throws {WrongMilestoneError} if `index` is not the currently active milestone. + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async completeMilestone(invoiceId: string, index: number): Promise { + const method = "complete_milestone"; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }), + nativeToScVal(index, { type: "u32" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("WrongMilestone") || raw?.includes("wrong_milestone")) { + throw new WrongMilestoneError(invoiceId, index, -1, raw); + } + if (raw?.includes("NotFound") || raw?.includes("not_found")) { + throw new InvoiceNotFoundError(invoiceId, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await signTransaction(prepared.toXDR(), { + network: this.config.networkPassphrase, + networkPassphrase: this.config.networkPassphrase, + accountToSign: this.config.publicKey, + } as Parameters[1]); + const result = await this.server.sendTransaction( + TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) + ); + if (result.status === "ERROR") { + throw new TransactionFailedError("complete_milestone transaction failed", result.hash); + } + + const native = sim.result ? scValToNative(sim.result.retval) : {}; + const n = native as Record; + return { + index, + amountReleased: BigInt(n?.amount_released ?? 0), + nextMilestoneIndex: n?.next_milestone_index != null ? Number(n.next_milestone_index) : undefined, + txHash: result.hash, + }; + } catch (err) { + if ( + err instanceof WrongMilestoneError || + err instanceof InvoiceNotFoundError || + err instanceof SimulationFailedError || + err instanceof TransactionFailedError + ) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + /** + * Returns the currently active milestone for the given invoice. + * + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async getActiveMilestone(invoiceId: string): Promise { + const method = "get_active_milestone"; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("NotFound") || raw?.includes("not_found")) { + throw new InvoiceNotFoundError(invoiceId, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const native = sim.result ? scValToNative(sim.result.retval) : null; + if (!native) return null; + const n = native as Record; + return { + index: Number(n.index ?? 0), + description: String(n.description ?? ""), + targetAmount: BigInt(n.target_amount ?? 0), + fundedAmount: BigInt(n.funded_amount ?? 0), + status: (n.status as MilestoneStatus) ?? "active", + }; + } catch (err) { + if (err instanceof InvoiceNotFoundError || err instanceof SimulationFailedError) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + /** + * Returns all milestones for the given invoice. + * + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async getMilestones(invoiceId: string): Promise { + const method = "get_milestones"; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("NotFound") || raw?.includes("not_found")) { + throw new InvoiceNotFoundError(invoiceId, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const native = sim.result ? scValToNative(sim.result.retval) : []; + if (!Array.isArray(native)) return []; + return (native as Record[]).map((n) => ({ + index: Number(n.index ?? 0), + description: String(n.description ?? ""), + targetAmount: BigInt(n.target_amount ?? 0), + fundedAmount: BigInt(n.funded_amount ?? 0), + status: (n.status as MilestoneStatus) ?? "locked", + })); + } catch (err) { + if (err instanceof InvoiceNotFoundError || err instanceof SimulationFailedError) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + // --------------------------------------------------------------------------- + // Issue #880 — Referral methods + // --------------------------------------------------------------------------- + + /** + * Claims any accumulated referral rewards for the connected wallet. + * + * @throws {NothingToClaimError} if the referral balance is zero. + */ + async claimReferralRewards(): Promise { + const method = "claim_referral_rewards"; + const address = this.config.publicKey ?? ""; + try { + const balance = await this.getReferralBalance(address); + if (balance === 0n) { + throw new NothingToClaimError(address); + } + + const account = await this.server.getAccount(address); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(address, { type: "address" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await signTransaction(prepared.toXDR(), { + network: this.config.networkPassphrase, + networkPassphrase: this.config.networkPassphrase, + accountToSign: this.config.publicKey, + } as Parameters[1]); + const result = await this.server.sendTransaction( + TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) + ); + if (result.status === "ERROR") { + throw new TransactionFailedError("claim_referral_rewards transaction failed", result.hash); + } + + const native = sim.result ? scValToNative(sim.result.retval) : 0n; + const amountClaimed = BigInt(typeof native === "bigint" ? native : (native ?? 0)); + return { amountClaimed, txHash: result.hash }; + } catch (err) { + if ( + err instanceof NothingToClaimError || + err instanceof SimulationFailedError || + err instanceof TransactionFailedError + ) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + /** + * Returns the current referral reward balance for the given address (or the + * connected wallet if no address is specified). + */ + async getReferralBalance(address?: string): Promise { + const method = "get_referral_balance"; + const target = address ?? this.config.publicKey ?? ""; + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(target, { type: "address" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const native = sim.result ? scValToNative(sim.result.retval) : 0n; + return BigInt(typeof native === "bigint" ? native : (native ?? 0)); + } catch (err) { + if (err instanceof SimulationFailedError) throw err; + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } + + /** + * Pays toward an invoice while crediting a referrer address. + * + * @param invoiceId - The invoice to pay toward. + * @param amount - Amount to pay in stroops. + * @param referrer - Stellar G-address of the referrer to credit. + * + * @throws {ValidationError} if the referrer address is not a valid G-address. + * @throws {InvoiceNotFoundError} if the invoice does not exist. + */ + async payWithReferral( + invoiceId: string, + amount: bigint, + referrer: string + ): Promise { + const method = "pay_with_referral"; + + if (!isValidAddress(referrer)) { + throw new ValidationError(`Invalid referrer address: ${referrer}`, { referrer }); + } + + try { + const account = await this.server.getAccount(this.config.publicKey ?? ""); + const contract = new Contract(this.config.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation( + contract.call( + method, + nativeToScVal(invoiceId, { type: "string" }), + nativeToScVal(amount, { type: "i128" }), + nativeToScVal(referrer, { type: "address" }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + const raw = (sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error; + if (raw?.includes("NotFound") || raw?.includes("not_found")) { + throw new InvoiceNotFoundError(invoiceId, raw); + } + throw new SimulationFailedError(raw ?? "Simulation failed", method, raw); + } + + const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await signTransaction(prepared.toXDR(), { + network: this.config.networkPassphrase, + networkPassphrase: this.config.networkPassphrase, + accountToSign: this.config.publicKey, + } as Parameters[1]); + const result = await this.server.sendTransaction( + TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) + ); + if (result.status === "ERROR") { + throw new TransactionFailedError("pay_with_referral transaction failed", result.hash); + } + + return { txHash: result.hash, referrer }; + } catch (err) { + if ( + err instanceof ValidationError || + err instanceof InvoiceNotFoundError || + err instanceof SimulationFailedError || + err instanceof TransactionFailedError + ) { + throw err; + } + throw parseSorobanError(err instanceof Error ? err.message : String(err)); + } + } } /** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */ diff --git a/src/errors.ts b/src/errors.ts index f35bd2a..d20c926 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2251,3 +2251,240 @@ export function isWalletConnectionTimeoutError(err: unknown): err is WalletConne return err instanceof WalletConnectionTimeoutError; } + +// --------------------------------------------------------------------------- +// Issues #878 / #879 / #880 / #881 — Extended error taxonomy +// --------------------------------------------------------------------------- + +/** Thrown when closeRound is called before the round end timestamp has passed. */ +export class RoundNotEndedError extends StellarSplitError { + readonly invoiceId: string; + readonly roundEnd: number; + + constructor(invoiceId: string, roundEnd: number, raw?: string) { + super( + `Round for invoice ${invoiceId} has not ended yet (ends at ${new Date(roundEnd * 1000).toISOString()})`, + "ROUND_NOT_ENDED", + { invoiceId, roundEnd }, + raw + ); + this.name = "RoundNotEndedError"; + this.invoiceId = invoiceId; + this.roundEnd = roundEnd; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isRoundNotEndedError(err: unknown): err is RoundNotEndedError { + return err instanceof RoundNotEndedError; +} + +/** Thrown when completeMilestone is called for an index that is not active. */ +export class WrongMilestoneError extends StellarSplitError { + readonly invoiceId: string; + readonly requestedIndex: number; + readonly activeIndex: number; + + constructor(invoiceId: string, requestedIndex: number, activeIndex: number, raw?: string) { + super( + `Milestone ${requestedIndex} is not active for invoice ${invoiceId} (active: ${activeIndex})`, + "WRONG_MILESTONE", + { invoiceId, requestedIndex, activeIndex }, + raw + ); + this.name = "WrongMilestoneError"; + this.invoiceId = invoiceId; + this.requestedIndex = requestedIndex; + this.activeIndex = activeIndex; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isWrongMilestoneError(err: unknown): err is WrongMilestoneError { + return err instanceof WrongMilestoneError; +} + +/** Thrown when claimReferralRewards is called with a zero balance. */ +export class NothingToClaimError extends StellarSplitError { + readonly address: string; + + constructor(address: string, raw?: string) { + super( + `No referral rewards to claim for address ${address}`, + "NOTHING_TO_CLAIM", + { address }, + raw + ); + this.name = "NothingToClaimError"; + this.address = address; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isNothingToClaimError(err: unknown): err is NothingToClaimError { + return err instanceof NothingToClaimError; +} + +/** Thrown when an invoice is expired (round_end / deadline already passed). */ +export class InvoiceExpiredError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string, raw?: string) { + super(`Invoice ${invoiceId} has expired`, "INVOICE_EXPIRED", { invoiceId }, raw); + this.name = "InvoiceExpiredError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceExpiredError(err: unknown): err is InvoiceExpiredError { + return err instanceof InvoiceExpiredError; +} + +/** Thrown when payer is not on the whitelist for a restricted invoice. */ +export class PayerNotWhitelistedError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + + constructor(invoiceId: string, payer: string, raw?: string) { + super( + `Payer ${payer} is not whitelisted for invoice ${invoiceId}`, + "PAYER_NOT_WHITELISTED", + { invoiceId, payer }, + raw + ); + this.name = "PayerNotWhitelistedError"; + this.invoiceId = invoiceId; + this.payer = payer; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPayerNotWhitelistedError(err: unknown): err is PayerNotWhitelistedError { + return err instanceof PayerNotWhitelistedError; +} + +/** Thrown when a payer's contribution exceeds their per-payer cap. */ +export class ContributionCapExceededError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + readonly cap: bigint; + readonly attempted: bigint; + + constructor(invoiceId: string, payer: string, cap: bigint, attempted: bigint, raw?: string) { + super( + `Contribution cap exceeded for payer ${payer} on invoice ${invoiceId}: cap ${cap}, attempted ${attempted}`, + "CONTRIBUTION_CAP_EXCEEDED", + { invoiceId, payer, cap: cap.toString(), attempted: attempted.toString() }, + raw + ); + this.name = "ContributionCapExceededError"; + this.invoiceId = invoiceId; + this.payer = payer; + this.cap = cap; + this.attempted = attempted; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isContributionCapExceededError(err: unknown): err is ContributionCapExceededError { + return err instanceof ContributionCapExceededError; +} + +/** + * Thrown when a payment is attempted during an active cooldown window. + * Includes retryAfter so callers know when they may try again. + */ +export class PaymentCooldownActiveError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + /** The earliest time after which the payer may retry. */ + readonly retryAfter: Date; + + constructor(invoiceId: string, payer: string, retryAfterTimestamp: number, raw?: string) { + const retryAfter = new Date(retryAfterTimestamp * 1000); + super( + `Payment cooldown active for payer ${payer} on invoice ${invoiceId}. Retry after ${retryAfter.toISOString()}`, + "PAYMENT_COOLDOWN_ACTIVE", + { invoiceId, payer, retryAfterTimestamp }, + raw + ); + this.name = "PaymentCooldownActiveError"; + this.invoiceId = invoiceId; + this.payer = payer; + this.retryAfter = retryAfter; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPaymentCooldownActiveError(err: unknown): err is PaymentCooldownActiveError { + return err instanceof PaymentCooldownActiveError; +} + +/** Thrown when the contract is administratively frozen for all operations. */ +export class ContractFrozenError extends StellarSplitError { + constructor(raw?: string) { + super("Contract is frozen and not accepting operations", "CONTRACT_FROZEN", undefined, raw); + this.name = "ContractFrozenError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isContractFrozenError(err: unknown): err is ContractFrozenError { + return err instanceof ContractFrozenError; +} + +/** Thrown when an invoice has been fully funded and cannot receive more payments. */ +export class InvoiceFullyFundedError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string, raw?: string) { + super(`Invoice ${invoiceId} is already fully funded`, "INVOICE_FULLY_FUNDED", { invoiceId }, raw); + this.name = "InvoiceFullyFundedError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceFullyFundedError(err: unknown): err is InvoiceFullyFundedError { + return err instanceof InvoiceFullyFundedError; +} + +/** + * Returns true for errors that represent transient, retryable conditions + * (e.g. RPC timeouts, cooldown windows) and false for permanent contract- + * logic failures (e.g. wrong milestone, invoice not found). + * + * @example + * ```ts + * try { + * await client.pay(...); + * } catch (err) { + * if (isRetryable(err)) { + * // schedule a retry + * } else { + * throw err; // surface to user + * } + * } + * ``` + */ +export function isRetryable(error: unknown): boolean { + if (error instanceof PaymentCooldownActiveError) return true; + if (error instanceof RpcError) return true; + if (error instanceof TransactionNotConfirmedError) return true; + if (error instanceof CircuitOpenError) return true; + // Permanent contract-logic errors + if (error instanceof RoundNotEndedError) return false; + if (error instanceof WrongMilestoneError) return false; + if (error instanceof NothingToClaimError) return false; + if (error instanceof InvoiceNotFoundError) return false; + if (error instanceof InvoiceExpiredError) return false; + if (error instanceof PayerNotWhitelistedError) return false; + if (error instanceof ContributionCapExceededError) return false; + if (error instanceof ContractFrozenError) return false; + if (error instanceof InvoiceFullyFundedError) return false; + if (error instanceof InvoiceFrozenError) return false; + if (error instanceof UnauthorizedError) return false; + // Default: non-retryable for unknown errors + return false; +} diff --git a/src/index.ts b/src/index.ts index f9cb56b..23ca15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1427,3 +1427,40 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// Issues #878 / #879 / #880 / #881 — Extended error taxonomy & new features +// --------------------------------------------------------------------------- + +export { + RoundNotEndedError, + isRoundNotEndedError, + WrongMilestoneError, + isWrongMilestoneError, + NothingToClaimError, + isNothingToClaimError, + InvoiceExpiredError, + isInvoiceExpiredError, + PayerNotWhitelistedError, + isPayerNotWhitelistedError, + ContributionCapExceededError, + isContributionCapExceededError, + PaymentCooldownActiveError, + isPaymentCooldownActiveError, + ContractFrozenError, + isContractFrozenError, + InvoiceFullyFundedError, + isInvoiceFullyFundedError, + isRetryable, +} from "./errors.js"; + +export type { + PayerRefund, + RoundCloseResult, + RoundInfo, + MilestoneStatus, + Milestone, + MilestoneResult, + ReferralClaimResult, + ReferralPayResult, +} from "./types.js"; diff --git a/src/types.ts b/src/types.ts index b60a6ec..90b42ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2150,3 +2150,98 @@ export interface ClaimableBalanceRecord { /** Ledger sequence after which the predicate expires (optional). */ predicateExpiryLedger?: number; } + +// --------------------------------------------------------------------------- +// Issues #878 — Round types +// --------------------------------------------------------------------------- + +/** A single payer's refund breakdown after a round is closed. */ +export interface PayerRefund { + /** Stellar address of the payer. */ + payer: string; + /** Amount (in stroops) that was kept / credited toward the invoice. */ + amountKept: bigint; + /** Amount (in stroops) refunded to this payer due to over-subscription. */ + amountRefunded: bigint; +} + +/** Result returned by closeRound. */ +export interface RoundCloseResult { + /** Total amount raised (in stroops) before overflow trimming. */ + totalRaised: bigint; + /** Maximum amount the round was capped at (in stroops). */ + hardCap: bigint; + /** Overflow amount distributed back as refunds (in stroops). */ + overflow: bigint; + /** Per-payer refund breakdown. */ + refunds: PayerRefund[]; + /** Transaction hash of the close_round call. */ + txHash: string; +} + +/** Current state of a fundraising round. */ +export interface RoundInfo { + /** Invoice identifier. */ + invoiceId: string; + /** Total raised so far (in stroops). */ + totalRaised: bigint; + /** Round hard cap (in stroops). */ + hardCap: bigint; + /** Unix timestamp when the round ends. */ + roundEnd: number; + /** Whether the round has ended and been closed. */ + closed: boolean; +} + +// --------------------------------------------------------------------------- +// Issues #879 — Milestone types +// --------------------------------------------------------------------------- + +/** Status of a milestone. */ +export type MilestoneStatus = 'locked' | 'active' | 'completed'; + +/** A single milestone in an invoice's milestone schedule. */ +export interface Milestone { + /** Zero-based milestone index. */ + index: number; + /** Human-readable description of what this milestone represents. */ + description: string; + /** Target amount required to complete this milestone (in stroops). */ + targetAmount: bigint; + /** Amount currently funded toward this milestone (in stroops). */ + fundedAmount: bigint; + /** Current lifecycle status of this milestone. */ + status: MilestoneStatus; +} + +/** Result returned by completeMilestone. */ +export interface MilestoneResult { + /** Index of the milestone that was completed. */ + index: number; + /** Amount released to recipients upon completion (in stroops). */ + amountReleased: bigint; + /** Index of the next milestone, if any. */ + nextMilestoneIndex?: number; + /** Transaction hash. */ + txHash: string; +} + +// --------------------------------------------------------------------------- +// Issues #880 — Referral types +// --------------------------------------------------------------------------- + +/** Result returned by claimReferralRewards. */ +export interface ReferralClaimResult { + /** Amount of referral rewards claimed (in stroops). */ + amountClaimed: bigint; + /** Transaction hash. */ + txHash: string; +} + +/** Result returned by payWithReferral. */ +export interface ReferralPayResult { + /** Transaction hash. */ + txHash: string; + /** Referrer address credited for this payment. */ + referrer: string; +} diff --git a/test/closeRound.test.ts b/test/closeRound.test.ts new file mode 100644 index 0000000..d1daf75 --- /dev/null +++ b/test/closeRound.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for issue #878 — Fundraising Round: closeRound and getRoundInfo + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Keypair, StrKey, nativeToScVal } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { + RoundNotEndedError, + InvoiceNotFoundError, + SimulationFailedError, +} from "../src/errors.js"; +import type { RoundCloseResult, RoundInfo } from "../src/types.js"; + +function makeClient() { + return new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); +} + +function makeSimSuccess(retval: unknown) { + return { + result: { retval: nativeToScVal(retval as bigint) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }; +} + +describe("closeRound (#878)", () => { + it("returns a RoundCloseResult on success", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { + retval: nativeToScVal({ + total_raised: 100_000_000n, + hard_cap: 200_000_000n, + overflow: 0n, + refunds: [], + } as unknown as bigint), + }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + sendTransaction: vi + .fn() + .mockResolvedValue({ status: "SUCCESS", hash: "close-round-tx" }), + }); + + // Mock assembleTransaction + signTransaction + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + vi.spyOn(SorobanRpc, "assembleTransaction" as keyof typeof SorobanRpc).mockReturnValue({ + build: () => ({ toXDR: () => "signed-xdr" }), + } as any); + + const walletMod = await import("../src/wallet.js"); + vi.spyOn(walletMod, "signTransaction").mockResolvedValue("signed-xdr"); + + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + vi.spyOn(TransactionBuilder, "fromXDR").mockReturnValue({} as any); + + const result: RoundCloseResult = await client.closeRound("inv-123"); + expect(result).toHaveProperty("txHash"); + expect(result).toHaveProperty("refunds"); + expect(Array.isArray(result.refunds)).toBe(true); + }); + + it("throws RoundNotEndedError when simulation returns round_not_ended", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "round_not_ended: round ends at 9999999999", + }), + }); + + await expect(client.closeRound("inv-456")).rejects.toBeInstanceOf(RoundNotEndedError); + }); + + it("throws RoundNotEndedError when simulation returns RoundNotEnded", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "RoundNotEnded", + }), + }); + + await expect(client.closeRound("inv-789")).rejects.toBeInstanceOf(RoundNotEndedError); + }); + + it("throws SimulationFailedError for unrecognised simulation errors", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "some_other_error", + }), + }); + + await expect(client.closeRound("inv-000")).rejects.toBeInstanceOf(SimulationFailedError); + }); +}); + +describe("getRoundInfo (#878)", () => { + it("returns a RoundInfo object on success", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { + retval: nativeToScVal({ + total_raised: 50_000_000n, + hard_cap: 100_000_000n, + round_end: 9999999999, + closed: false, + } as unknown as bigint), + }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + const result: RoundInfo = await client.getRoundInfo("inv-abc"); + expect(result.invoiceId).toBe("inv-abc"); + }); + + it("throws InvoiceNotFoundError when simulation returns not_found", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "not_found: invoice inv-missing does not exist", + }), + }); + + await expect(client.getRoundInfo("inv-missing")).rejects.toBeInstanceOf(InvoiceNotFoundError); + }); +}); diff --git a/test/errorTaxonomy.test.ts b/test/errorTaxonomy.test.ts new file mode 100644 index 0000000..b3d69d2 --- /dev/null +++ b/test/errorTaxonomy.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for issue #881 — Typed error taxonomy + * + * Verifies that every new error class is correctly typed, has the right code, + * and that isRetryable returns the correct boolean for each. + */ +import { describe, it, expect } from "vitest"; +import { + StellarSplitError, + RoundNotEndedError, + isRoundNotEndedError, + WrongMilestoneError, + isWrongMilestoneError, + NothingToClaimError, + isNothingToClaimError, + InvoiceExpiredError, + isInvoiceExpiredError, + PayerNotWhitelistedError, + isPayerNotWhitelistedError, + ContributionCapExceededError, + isContributionCapExceededError, + PaymentCooldownActiveError, + isPaymentCooldownActiveError, + ContractFrozenError, + isContractFrozenError, + InvoiceFullyFundedError, + isInvoiceFullyFundedError, + isRetryable, + // Existing errors that isRetryable must also handle + RpcError, + TransactionNotConfirmedError, + CircuitOpenError, + InvoiceNotFoundError, + InvoiceFrozenError, + UnauthorizedError, +} from "../src/errors.js"; + +// --------------------------------------------------------------------------- +// RoundNotEndedError +// --------------------------------------------------------------------------- + +describe("RoundNotEndedError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new RoundNotEndedError("inv-1", 1_800_000_000); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(RoundNotEndedError); + expect(err).toBeInstanceOf(Error); + }); + + it("carries the correct code", () => { + const err = new RoundNotEndedError("inv-1", 1_800_000_000); + expect(err.code).toBe("ROUND_NOT_ENDED"); + }); + + it("carries invoiceId and roundEnd", () => { + const err = new RoundNotEndedError("inv-42", 9_999_999_999); + expect(err.invoiceId).toBe("inv-42"); + expect(err.roundEnd).toBe(9_999_999_999); + }); + + it("isRoundNotEndedError guard works", () => { + expect(isRoundNotEndedError(new RoundNotEndedError("x", 0))).toBe(true); + expect(isRoundNotEndedError(new Error("plain"))).toBe(false); + expect(isRoundNotEndedError(null)).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new RoundNotEndedError("inv-1", 0))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// WrongMilestoneError +// --------------------------------------------------------------------------- + +describe("WrongMilestoneError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new WrongMilestoneError("inv-1", 2, 1); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(WrongMilestoneError); + }); + + it("carries the correct code", () => { + const err = new WrongMilestoneError("inv-1", 2, 1); + expect(err.code).toBe("WRONG_MILESTONE"); + }); + + it("carries invoiceId, requestedIndex and activeIndex", () => { + const err = new WrongMilestoneError("inv-99", 3, 1); + expect(err.invoiceId).toBe("inv-99"); + expect(err.requestedIndex).toBe(3); + expect(err.activeIndex).toBe(1); + }); + + it("isWrongMilestoneError guard works", () => { + expect(isWrongMilestoneError(new WrongMilestoneError("x", 0, 1))).toBe(true); + expect(isWrongMilestoneError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new WrongMilestoneError("inv-1", 0, 1))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// NothingToClaimError +// --------------------------------------------------------------------------- + +describe("NothingToClaimError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new NothingToClaimError("GABC"); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(NothingToClaimError); + }); + + it("carries the correct code", () => { + const err = new NothingToClaimError("GABC"); + expect(err.code).toBe("NOTHING_TO_CLAIM"); + }); + + it("carries the address", () => { + const err = new NothingToClaimError("GABC123"); + expect(err.address).toBe("GABC123"); + }); + + it("isNothingToClaimError guard works", () => { + expect(isNothingToClaimError(new NothingToClaimError("addr"))).toBe(true); + expect(isNothingToClaimError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new NothingToClaimError("GABC"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// InvoiceExpiredError +// --------------------------------------------------------------------------- + +describe("InvoiceExpiredError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new InvoiceExpiredError("inv-exp"); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(InvoiceExpiredError); + }); + + it("carries the correct code", () => { + const err = new InvoiceExpiredError("inv-exp"); + expect(err.code).toBe("INVOICE_EXPIRED"); + }); + + it("carries the invoiceId", () => { + const err = new InvoiceExpiredError("inv-abc"); + expect(err.invoiceId).toBe("inv-abc"); + }); + + it("isInvoiceExpiredError guard works", () => { + expect(isInvoiceExpiredError(new InvoiceExpiredError("x"))).toBe(true); + expect(isInvoiceExpiredError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new InvoiceExpiredError("inv-1"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// PayerNotWhitelistedError +// --------------------------------------------------------------------------- + +describe("PayerNotWhitelistedError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new PayerNotWhitelistedError("inv-1", "GABC"); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(PayerNotWhitelistedError); + }); + + it("carries the correct code", () => { + const err = new PayerNotWhitelistedError("inv-1", "GABC"); + expect(err.code).toBe("PAYER_NOT_WHITELISTED"); + }); + + it("carries invoiceId and payer", () => { + const err = new PayerNotWhitelistedError("inv-99", "GDEF"); + expect(err.invoiceId).toBe("inv-99"); + expect(err.payer).toBe("GDEF"); + }); + + it("isPayerNotWhitelistedError guard works", () => { + expect(isPayerNotWhitelistedError(new PayerNotWhitelistedError("x", "y"))).toBe(true); + expect(isPayerNotWhitelistedError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new PayerNotWhitelistedError("inv-1", "GABC"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ContributionCapExceededError +// --------------------------------------------------------------------------- + +describe("ContributionCapExceededError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new ContributionCapExceededError("inv-1", "GABC", 100n, 200n); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(ContributionCapExceededError); + }); + + it("carries the correct code", () => { + const err = new ContributionCapExceededError("inv-1", "GABC", 100n, 200n); + expect(err.code).toBe("CONTRIBUTION_CAP_EXCEEDED"); + }); + + it("carries invoiceId, payer, cap and attempted as bigints", () => { + const err = new ContributionCapExceededError("inv-99", "GDEF", 500n, 600n); + expect(err.invoiceId).toBe("inv-99"); + expect(err.payer).toBe("GDEF"); + expect(err.cap).toBe(500n); + expect(err.attempted).toBe(600n); + }); + + it("isContributionCapExceededError guard works", () => { + expect( + isContributionCapExceededError(new ContributionCapExceededError("x", "y", 0n, 1n)) + ).toBe(true); + expect(isContributionCapExceededError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new ContributionCapExceededError("inv-1", "GABC", 100n, 200n))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// PaymentCooldownActiveError +// --------------------------------------------------------------------------- + +describe("PaymentCooldownActiveError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new PaymentCooldownActiveError("inv-1", "GABC", 1_800_000_000); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(PaymentCooldownActiveError); + }); + + it("carries the correct code", () => { + const err = new PaymentCooldownActiveError("inv-1", "GABC", 1_800_000_000); + expect(err.code).toBe("PAYMENT_COOLDOWN_ACTIVE"); + }); + + it("carries invoiceId, payer and retryAfter as a Date", () => { + const ts = 1_800_000_000; + const err = new PaymentCooldownActiveError("inv-99", "GDEF", ts); + expect(err.invoiceId).toBe("inv-99"); + expect(err.payer).toBe("GDEF"); + expect(err.retryAfter).toBeInstanceOf(Date); + expect(err.retryAfter.getTime()).toBe(ts * 1000); + }); + + it("isPaymentCooldownActiveError guard works", () => { + expect(isPaymentCooldownActiveError(new PaymentCooldownActiveError("x", "y", 0))).toBe(true); + expect(isPaymentCooldownActiveError(new Error("plain"))).toBe(false); + }); + + it("IS retryable (transient condition)", () => { + expect(isRetryable(new PaymentCooldownActiveError("inv-1", "GABC", 0))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ContractFrozenError +// --------------------------------------------------------------------------- + +describe("ContractFrozenError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new ContractFrozenError(); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(ContractFrozenError); + }); + + it("carries the correct code", () => { + const err = new ContractFrozenError(); + expect(err.code).toBe("CONTRACT_FROZEN"); + }); + + it("isContractFrozenError guard works", () => { + expect(isContractFrozenError(new ContractFrozenError())).toBe(true); + expect(isContractFrozenError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new ContractFrozenError())).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// InvoiceFullyFundedError +// --------------------------------------------------------------------------- + +describe("InvoiceFullyFundedError (#881)", () => { + it("is an instance of StellarSplitError", () => { + const err = new InvoiceFullyFundedError("inv-full"); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(InvoiceFullyFundedError); + }); + + it("carries the correct code", () => { + const err = new InvoiceFullyFundedError("inv-full"); + expect(err.code).toBe("INVOICE_FULLY_FUNDED"); + }); + + it("carries the invoiceId", () => { + const err = new InvoiceFullyFundedError("inv-abc"); + expect(err.invoiceId).toBe("inv-abc"); + }); + + it("isInvoiceFullyFundedError guard works", () => { + expect(isInvoiceFullyFundedError(new InvoiceFullyFundedError("x"))).toBe(true); + expect(isInvoiceFullyFundedError(new Error("plain"))).toBe(false); + }); + + it("is NOT retryable", () => { + expect(isRetryable(new InvoiceFullyFundedError("inv-1"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isRetryable — existing error types +// --------------------------------------------------------------------------- + +describe("isRetryable — existing error classes (#881)", () => { + it("RpcError IS retryable", () => { + expect(isRetryable(new RpcError("Network error", 503))).toBe(true); + }); + + it("TransactionNotConfirmedError IS retryable", () => { + expect(isRetryable(new TransactionNotConfirmedError("PENDING"))).toBe(true); + }); + + it("CircuitOpenError IS retryable", () => { + expect(isRetryable(new CircuitOpenError())).toBe(true); + }); + + it("InvoiceNotFoundError is NOT retryable", () => { + expect(isRetryable(new InvoiceNotFoundError("inv-1"))).toBe(false); + }); + + it("InvoiceFrozenError is NOT retryable", () => { + expect(isRetryable(new InvoiceFrozenError("inv-1"))).toBe(false); + }); + + it("UnauthorizedError is NOT retryable", () => { + expect(isRetryable(new UnauthorizedError())).toBe(false); + }); + + it("plain Error is NOT retryable", () => { + expect(isRetryable(new Error("oops"))).toBe(false); + }); + + it("non-error values are NOT retryable", () => { + expect(isRetryable(null)).toBe(false); + expect(isRetryable(undefined)).toBe(false); + expect(isRetryable("string")).toBe(false); + expect(isRetryable(42)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// All new error classes extend StellarSplitError (base requirement #881) +// --------------------------------------------------------------------------- + +describe("All new errors extend StellarSplitError (#881)", () => { + const errors: StellarSplitError[] = [ + new RoundNotEndedError("inv-1", 0), + new WrongMilestoneError("inv-1", 0, 1), + new NothingToClaimError("GABC"), + new InvoiceExpiredError("inv-1"), + new PayerNotWhitelistedError("inv-1", "GABC"), + new ContributionCapExceededError("inv-1", "GABC", 100n, 200n), + new PaymentCooldownActiveError("inv-1", "GABC", 0), + new ContractFrozenError(), + new InvoiceFullyFundedError("inv-1"), + ]; + + for (const err of errors) { + it(`${err.name} is instanceof StellarSplitError`, () => { + expect(err).toBeInstanceOf(StellarSplitError); + expect(err).toBeInstanceOf(Error); + expect(typeof err.code).toBe("string"); + expect(err.code.length).toBeGreaterThan(0); + }); + } +}); diff --git a/test/milestones.test.ts b/test/milestones.test.ts new file mode 100644 index 0000000..b918221 --- /dev/null +++ b/test/milestones.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for issue #879 — Milestone management: completeMilestone, getActiveMilestone, getMilestones + */ +import { describe, it, expect, vi } from "vitest"; +import { Keypair, StrKey, nativeToScVal } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { + WrongMilestoneError, + InvoiceNotFoundError, + SimulationFailedError, +} from "../src/errors.js"; +import type { MilestoneResult, Milestone } from "../src/types.js"; + +function makeClient() { + return new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); +} + +describe("completeMilestone (#879)", () => { + it("returns MilestoneResult on success", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { + retval: nativeToScVal({ + amount_released: 500_000_000n, + next_milestone_index: 1, + } as unknown as bigint), + }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + sendTransaction: vi + .fn() + .mockResolvedValue({ status: "SUCCESS", hash: "milestone-tx" }), + }); + + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + vi.spyOn(SorobanRpc, "assembleTransaction" as keyof typeof SorobanRpc).mockReturnValue({ + build: () => ({ toXDR: () => "signed-xdr" }), + } as any); + + const walletMod = await import("../src/wallet.js"); + vi.spyOn(walletMod, "signTransaction").mockResolvedValue("signed-xdr"); + + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + vi.spyOn(TransactionBuilder, "fromXDR").mockReturnValue({} as any); + + const result: MilestoneResult = await client.completeMilestone("inv-123", 0); + expect(result.index).toBe(0); + expect(result).toHaveProperty("txHash"); + expect(result).toHaveProperty("amountReleased"); + }); + + it("throws WrongMilestoneError when simulation returns wrong_milestone", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "wrong_milestone: expected 1 but got 0", + }), + }); + + await expect(client.completeMilestone("inv-123", 0)).rejects.toBeInstanceOf(WrongMilestoneError); + }); + + it("throws WrongMilestoneError when simulation returns WrongMilestone", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "WrongMilestone", + }), + }); + + await expect(client.completeMilestone("inv-123", 2)).rejects.toBeInstanceOf(WrongMilestoneError); + }); + + it("throws InvoiceNotFoundError when simulation returns not_found", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "not_found: invoice does not exist", + }), + }); + + await expect(client.completeMilestone("inv-missing", 0)).rejects.toBeInstanceOf( + InvoiceNotFoundError + ); + }); + + it("throws SimulationFailedError for unknown simulation errors", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "unexpected_contract_error", + }), + }); + + await expect(client.completeMilestone("inv-123", 0)).rejects.toBeInstanceOf( + SimulationFailedError + ); + }); + + it("WrongMilestoneError carries requestedIndex and invoiceId", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "WrongMilestone", + }), + }); + + try { + await client.completeMilestone("inv-err", 3); + } catch (err) { + expect(err).toBeInstanceOf(WrongMilestoneError); + const typed = err as WrongMilestoneError; + expect(typed.invoiceId).toBe("inv-err"); + expect(typed.requestedIndex).toBe(3); + } + }); +}); + +describe("getActiveMilestone (#879)", () => { + it("returns null when contract returns no value", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: null }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + const result = await client.getActiveMilestone("inv-abc"); + expect(result).toBeNull(); + }); + + it("throws InvoiceNotFoundError when not_found in simulation error", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "not_found: no such invoice", + }), + }); + + await expect(client.getActiveMilestone("inv-gone")).rejects.toBeInstanceOf( + InvoiceNotFoundError + ); + }); +}); + +describe("getMilestones (#879)", () => { + it("returns an empty array when no milestones present", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: null }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + const result: Milestone[] = await client.getMilestones("inv-abc"); + expect(Array.isArray(result)).toBe(true); + }); + + it("throws InvoiceNotFoundError for not_found simulation error", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "not_found: invoice gone", + }), + }); + + await expect(client.getMilestones("inv-gone")).rejects.toBeInstanceOf(InvoiceNotFoundError); + }); +}); diff --git a/test/referral.test.ts b/test/referral.test.ts new file mode 100644 index 0000000..372e8f2 --- /dev/null +++ b/test/referral.test.ts @@ -0,0 +1,289 @@ +/** + * Tests for issue #880 — Referral rewards: claimReferralRewards, getReferralBalance, payWithReferral + */ +import { describe, it, expect, vi } from "vitest"; +import { Keypair, StrKey, nativeToScVal } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { + NothingToClaimError, + ValidationError, + InvoiceNotFoundError, + SimulationFailedError, +} from "../src/errors.js"; +import type { ReferralClaimResult, ReferralPayResult } from "../src/types.js"; + +/** Valid Stellar G-address used as a referrer in tests. */ +const REFERRER = Keypair.random().publicKey(); + +function makeClient() { + return new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); +} + +// --------------------------------------------------------------------------- +// getReferralBalance +// --------------------------------------------------------------------------- + +describe("getReferralBalance (#880)", () => { + it("returns the balance as a bigint", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(250_000_000n) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + const balance = await client.getReferralBalance(REFERRER); + expect(typeof balance).toBe("bigint"); + }); + + it("returns 0n when contract returns falsy retval", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: null }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + const balance = await client.getReferralBalance(REFERRER); + expect(balance).toBe(0n); + }); + + it("defaults to connected wallet when no address provided", async () => { + const client = makeClient(); + + const mockSimulate = vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(100n) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: mockSimulate, + }); + + // Should not throw when no address is passed + await expect(client.getReferralBalance()).resolves.not.toThrow(); + expect(mockSimulate).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// claimReferralRewards +// --------------------------------------------------------------------------- + +describe("claimReferralRewards (#880)", () => { + it("throws NothingToClaimError when balance is zero", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(0n) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + await expect(client.claimReferralRewards()).rejects.toBeInstanceOf(NothingToClaimError); + }); + + it("returns ReferralClaimResult with amountClaimed and txHash on success", async () => { + const client = makeClient(); + + let callCount = 0; + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockImplementation(() => { + callCount++; + // First call is getReferralBalance check; return non-zero balance + // Second call is the actual claim + return Promise.resolve({ + result: { retval: nativeToScVal(500_000_000n) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }); + }), + sendTransaction: vi.fn().mockResolvedValue({ status: "SUCCESS", hash: "claim-tx-hash" }), + }); + + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + vi.spyOn(SorobanRpc, "assembleTransaction" as keyof typeof SorobanRpc).mockReturnValue({ + build: () => ({ toXDR: () => "signed-xdr" }), + } as any); + + const walletMod = await import("../src/wallet.js"); + vi.spyOn(walletMod, "signTransaction").mockResolvedValue("signed-xdr"); + + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + vi.spyOn(TransactionBuilder, "fromXDR").mockReturnValue({} as any); + + const result: ReferralClaimResult = await client.claimReferralRewards(); + expect(result).toHaveProperty("amountClaimed"); + expect(result).toHaveProperty("txHash", "claim-tx-hash"); + expect(typeof result.amountClaimed).toBe("bigint"); + }); + + it("NothingToClaimError carries the address", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(0n) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + }); + + try { + await client.claimReferralRewards(); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(NothingToClaimError); + expect((err as NothingToClaimError).code).toBe("NOTHING_TO_CLAIM"); + } + }); +}); + +// --------------------------------------------------------------------------- +// payWithReferral +// --------------------------------------------------------------------------- + +describe("payWithReferral (#880)", () => { + it("throws ValidationError for an invalid referrer address", async () => { + const client = makeClient(); + + await expect( + client.payWithReferral("inv-123", 100_000_000n, "NOT_A_VALID_ADDRESS") + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("returns ReferralPayResult with txHash and referrer on success", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(null as unknown as bigint) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }), + sendTransaction: vi.fn().mockResolvedValue({ status: "SUCCESS", hash: "pay-ref-tx" }), + }); + + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + vi.spyOn(SorobanRpc, "assembleTransaction" as keyof typeof SorobanRpc).mockReturnValue({ + build: () => ({ toXDR: () => "signed-xdr" }), + } as any); + + const walletMod = await import("../src/wallet.js"); + vi.spyOn(walletMod, "signTransaction").mockResolvedValue("signed-xdr"); + + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + vi.spyOn(TransactionBuilder, "fromXDR").mockReturnValue({} as any); + + const result: ReferralPayResult = await client.payWithReferral( + "inv-123", + 100_000_000n, + REFERRER + ); + expect(result.txHash).toBe("pay-ref-tx"); + expect(result.referrer).toBe(REFERRER); + }); + + it("passes the referrer address to the contract call", async () => { + const client = makeClient(); + + const mockSimulate = vi.fn().mockResolvedValue({ + result: { retval: nativeToScVal(null as unknown as bigint) }, + transactionData: { build: () => ({ toXDR: () => "" }) }, + minResourceFee: "100", + }); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: mockSimulate, + sendTransaction: vi.fn().mockResolvedValue({ status: "SUCCESS", hash: "tx-hash" }), + }); + + const { rpc: SorobanRpc } = await import("@stellar/stellar-sdk"); + vi.spyOn(SorobanRpc, "assembleTransaction" as keyof typeof SorobanRpc).mockReturnValue({ + build: () => ({ toXDR: () => "signed-xdr" }), + } as any); + + const walletMod = await import("../src/wallet.js"); + vi.spyOn(walletMod, "signTransaction").mockResolvedValue("signed-xdr"); + + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + vi.spyOn(TransactionBuilder, "fromXDR").mockReturnValue({} as any); + + const result = await client.payWithReferral("inv-abc", 50_000_000n, REFERRER); + // The referrer must be echoed back in the result + expect(result.referrer).toBe(REFERRER); + expect(mockSimulate).toHaveBeenCalledTimes(1); + }); + + it("throws InvoiceNotFoundError when simulation returns not_found", async () => { + const client = makeClient(); + + vi.spyOn(client as any, "server", "get").mockReturnValue({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => "GABC", + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + error: "not_found: invoice does not exist", + }), + }); + + await expect( + client.payWithReferral("inv-gone", 100n, REFERRER) + ).rejects.toBeInstanceOf(InvoiceNotFoundError); + }); +});