diff --git a/src/client.ts b/src/client.ts index 2195e9f..551082f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -91,6 +91,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"; @@ -232,6 +233,9 @@ import { InvoiceIntegrityError, InvoiceNotCloneableError, InvalidTransactionTypeError, + RoundNotEndedError, + WrongMilestoneError, + NothingToClaimError, } from "./errors.js"; import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; import { buildFeeBump } from "./feeBumpBuilder.js"; @@ -10883,6 +10887,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/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); + }); +});