diff --git a/src/client-security.ts b/src/client-security.ts index 54700f6..9faf1ed 100644 --- a/src/client-security.ts +++ b/src/client-security.ts @@ -36,7 +36,7 @@ export function createSafeToJSON(obj: any) { * console.log(client); // Safe: keypair is redacted in output */ export function createSafeInspect() { - return function inspect(this: any, depth: number, opts: any) { + return function inspect(this: any, _depth?: number, _opts?: any) { return 'VeriTixClient { ...config, keypair: [REDACTED], server: [REDACTED] }'; }; } diff --git a/src/client.ts b/src/client.ts index 9b5bf99..2a04e4c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -26,8 +26,7 @@ * ``` */ -import { SorobanRpc, Keypair, Contract, xdr } from '@stellar/stellar-sdk'; -import { SorobanRpc, Keypair, StrKey, xdr } from '@stellar/stellar-sdk'; +import { SorobanRpc, Keypair, Contract, StrKey, xdr } from '@stellar/stellar-sdk'; import type { NetworkConfig, @@ -140,8 +139,8 @@ export class VeriTixClient extends EventEmitter { } /** Redacts the keypair when the client is logged via console/util.inspect. */ - [Symbol.for('nodejs.util.inspect.custom')](): string { - return createSafeInspect()(); + [Symbol.for('nodejs.util.inspect.custom')](): (depth: number, opts: object) => string { + return createSafeInspect(); } // ------------------------------------------------------------------------- @@ -180,7 +179,10 @@ export class VeriTixClient extends EventEmitter { static fromEnvironment(env: NodeJS.ProcessEnv = process.env): VeriTixClient { // Guard against browser bundles: a statically-inlined secret key would // end up shipped to every client. Require an explicit client in browsers. - if (typeof window !== 'undefined' || typeof document !== 'undefined') { + if ( + typeof (globalThis as { window?: unknown }).window !== 'undefined' || + typeof (globalThis as { document?: unknown }).document !== 'undefined' + ) { throw new VeriTixError( VeriTixErrorCode.ReadOnlyClient, 'VeriTixClient.fromEnvironment is not available in browser contexts; construct a VeriTixClient explicitly and never inline a secret key', @@ -340,7 +342,8 @@ export class VeriTixClient extends EventEmitter { if (rpcReachable) { try { await this.server.getContractData( - new Contract(this.config.contractId).getAddress().toScVal(), + this.config.contractId, + new Contract(this.config.contractId).address().toScVal(), ); contractFound = true; } catch { diff --git a/src/modules/admin.ts b/src/modules/admin.ts index 962dac7..80d8788 100644 --- a/src/modules/admin.ts +++ b/src/modules/admin.ts @@ -333,6 +333,43 @@ export class AdminModule { return this.writeCall('unpause', []); } + // ------------------------------------------------------------------------- + // Whitelist management + // ------------------------------------------------------------------------- + + /** + * Enables the whitelist feature on the contract. Must be called by admin. + * When enabled, only whitelisted addresses can interact with the contract. + * + * @returns A {@link TransactionResult} on success. + * @throws {VeriTixError} With code `ADMIN_UNAUTHORIZED` if caller is not admin. + * + * @example + * ```ts + * await client.admin.enableWhitelist(); + * ``` + */ + async enableWhitelist(): Promise { + return this.writeCall('enable_whitelist', []); + } + + /** + * Adds an address to the whitelist. Must be called by admin. + * The contract must have whitelist enabled for this to succeed. + * + * @param address - Stellar account address to whitelist. + * @returns A {@link TransactionResult} on success. + * @throws {VeriTixError} With code `ADMIN_UNAUTHORIZED` if caller is not admin. + * + * @example + * ```ts + * await client.admin.whitelistAddress('GABC…'); + * ``` + */ + async whitelistAddress(address: string): Promise { + return this.writeCall('whitelist_address', [addressToScVal(address)]); + } + // ------------------------------------------------------------------------- // Fee management // ------------------------------------------------------------------------- diff --git a/src/modules/dispute.ts b/src/modules/dispute.ts index 472a9d5..8da591d 100644 --- a/src/modules/dispute.ts +++ b/src/modules/dispute.ts @@ -586,32 +586,82 @@ export class DisputeModule { async expireDispute(disputeId: bigint): Promise { if (!this.keypair) { throw new Error('DisputeModule.expireDispute: signing keypair required'); + } + + const dispute = await this.getDispute(disputeId); + if (!dispute) { + throw new VeriTixError(VeriTixErrorCode.DisputeNotFound, 'Dispute not found'); + } + + if (dispute.status !== DisputeStatus.Open) { + throw new VeriTixError( + VeriTixErrorCode.DisputeAlreadyResolved, + 'Dispute already resolved', + ); + } + + const admin = this.keypair.publicKey(); + + const tx = await buildContractCall( + this.server, + new Account(admin, '0'), + this.config.contractId, + 'expire_dispute', + [addressToScVal(admin), bigintToScVal(disputeId, 'u64')], + this.config.networkPassphrase, + ); + + const raw = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(raw)) { + throw parseSorobanError(raw.error); + } + + const returnValue = + SorobanRpc.Api.isSimulationSuccess(raw) && raw.result ? raw.result.retval : undefined; + + const assembled = SorobanRpc.assembleTransaction(tx, raw).build(); + const result = await submitTransaction(this.server, assembled, this.keypair); + + return { + ...result, + returnValue, + }; + } + + /** * Appeals a resolved dispute. Must be called by the original claimant. * - * @param disputeId - The dispute ID to appeal. + * @param disputeId - The dispute ID to appeal. + * @param appealResolver - Stellar account address of the new resolver for the appeal. * @returns A {@link TransactionResult} on success. * @throws {Error} If no signing keypair is available. * @throws {VeriTixError} With code `DISPUTE_NOT_FOUND` if dispute does not exist. * @throws {VeriTixError} With code `DISPUTE_INVALID_STATE` if dispute is still open. + * @throws {VeriTixError} With code `DISPUTE_INVALID_STATE` if appealResolver equals the caller. * * @example * ```ts - * await client.dispute.appealDispute(3n); + * await client.dispute.appealDispute(3n, 'GARB…'); * ``` */ - async appealDispute(disputeId: bigint): Promise { + async appealDispute(disputeId: bigint, appealResolver: string): Promise { if (!this.keypair) { throw new Error('DisputeModule.appealDispute: signing keypair required'); } - const dispute = await this.getDispute(disputeId); - if (!dispute) { + const claimant = this.keypair.publicKey(); + if (appealResolver === claimant) { throw new VeriTixError( - VeriTixErrorCode.DisputeNotFound, - 'Dispute not found', + VeriTixErrorCode.DisputeInvalidState, + 'DisputeModule.appealDispute: appeal resolver cannot be the claimant', ); } + const dispute = await this.getDispute(disputeId); + if (!dispute) { + throw new VeriTixError(VeriTixErrorCode.DisputeNotFound, 'Dispute not found'); + } + if (dispute.status !== DisputeStatus.Open) { throw new VeriTixError( VeriTixErrorCode.DisputeAlreadyResolved, @@ -619,24 +669,6 @@ export class DisputeModule { ); } - const admin = this.keypair.publicKey(); - - const tx = await buildContractCall( - this.server, - new Account(admin, '0'), - this.config.contractId, - 'expire_dispute', - [ - addressToScVal(admin), - if (dispute.status === DisputeStatus.Open) { - throw new VeriTixError( - VeriTixErrorCode.InvalidAmount, - 'DisputeModule.appealDispute: dispute is still open, cannot appeal', - ); - } - - const claimant = this.keypair.publicKey(); - const tx = await buildContractCall( this.server, new Account(claimant, '0'), @@ -645,6 +677,7 @@ export class DisputeModule { [ addressToScVal(claimant), bigintToScVal(disputeId, 'u64'), + addressToScVal(appealResolver), ], this.config.networkPassphrase, ); @@ -655,9 +688,7 @@ export class DisputeModule { } const returnValue = - SorobanRpc.Api.isSimulationSuccess(raw) && raw.result - ? raw.result.retval - : undefined; + SorobanRpc.Api.isSimulationSuccess(raw) && raw.result ? raw.result.retval : undefined; const assembled = SorobanRpc.assembleTransaction(tx, raw).build(); const result = await submitTransaction(this.server, assembled, this.keypair); diff --git a/src/modules/escrow.ts b/src/modules/escrow.ts index 8e35322..8384d04 100644 --- a/src/modules/escrow.ts +++ b/src/modules/escrow.ts @@ -673,6 +673,13 @@ export class EscrowModule { ); } + if (escrow.released || escrow.refunded) { + throw new VeriTixError( + VeriTixErrorCode.EscrowAlreadySettled, + 'Escrow has already been released or refunded', + ); + } + const caller = this.keypair.publicKey(); if (caller !== escrow.depositor) { throw new VeriTixError( diff --git a/src/modules/recurring.ts b/src/modules/recurring.ts index bb49985..197c95c 100644 --- a/src/modules/recurring.ts +++ b/src/modules/recurring.ts @@ -463,7 +463,7 @@ export class RecurringModule { throw new Error('RecurringModule.amendRecurring: at least one of amount or interval must be provided'); } if (!this.keypair) { - throw new Error('RecurringModule.amendRecurring: signing keypair required'); + throw new VeriTixError(VeriTixErrorCode.ReadOnlyClient, 'RecurringModule.amendRecurring: signing keypair required'); } const args = [bigintToScVal(id, 'u64')]; diff --git a/src/modules/splitter.ts b/src/modules/splitter.ts index cabae6e..0a079bb 100644 --- a/src/modules/splitter.ts +++ b/src/modules/splitter.ts @@ -11,6 +11,8 @@ * and the platform, with support for custom BPS configurations. */ import { SorobanRpc, Keypair, Account, xdr } from '@stellar/stellar-sdk'; +import { addressToScVal, bigintToScVal, scValToBigint, scValToNumber, stringToScVal } from '../utils/scval'; +import { buildContractCall, simulateTransaction, submitTransaction } from '../utils/transaction'; import { addressToScVal, scValToBigint, scValToNumber } from '../utils/scval'; import { buildContractCall } from '../utils/transaction'; import { parseSorobanError, VeriTixError, VeriTixErrorCode } from '../utils/errors'; diff --git a/src/types/index.ts b/src/types/index.ts index c2bdd98..12d6102 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -227,6 +227,8 @@ export interface RecurringRecord { interval: number; /** Whether this recurring payment is still active */ active: boolean; + /** Whether this recurring payment is currently paused */ + paused: boolean; /** Ledger sequence number when the most recent charge was executed */ lastChargedLedger: number; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 17af4b6..a2af21e 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -292,8 +292,7 @@ function buildMessage(code: VeriTixErrorCode, rawStr: string): string { [VeriTixErrorCode.ConnectionFailed]: 'Failed to connect to the Soroban RPC endpoint.', [VeriTixErrorCode.BatchTooLarge]: 'Batch request exceeded maximum allowed size.', [VeriTixErrorCode.ReadOnlyClient]: 'This client is read-only. Provide a Keypair to enable write operations.', - [VeriTixErrorCode.WatchTimeout]: 'watchEscrow or watchTransaction timed out before the operation was confirmed.', - [VeriTixErrorCode.WatchTimeout]: 'Watch timed out before the escrow settled.', + [VeriTixErrorCode.WatchTimeout]: 'Watch timed out before the operation was confirmed.', [VeriTixErrorCode.TransactionFailed]: 'Transaction was rejected by the Stellar network.', }; return messages[code]; diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 7745fc7..0196d22 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -191,6 +191,7 @@ export function parseRecurringRecord(val: xdr.ScVal): RecurringRecord { amount: scValToBigint(getField(map, 'amount')), interval: scValToNumber(getField(map, 'interval')), active: scValToBoolean(getField(map, 'active')), + paused: scValToBoolean(getField(map, 'paused')), lastChargedLedger: scValToNumber(getField(map, 'last_charged_ledger')), }; } diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index 5239c17..d0911dd 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -160,11 +160,6 @@ export async function estimateFee( args: xdr.ScVal[], ): Promise { // Use a throwaway source account — simulation does not require a funded account - const sourceAccount = new Account( - 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', - '0', - ); - // Use a throwaway keypair — simulation does not require a funded account const sourceAccount = new Account(DUMMY_PUBLIC_KEY, '0'); const tx = await buildContractCall( diff --git a/tests/admin.test.ts b/tests/admin.test.ts index acee820..995a878 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -1,15 +1,19 @@ /** * @file tests/admin.test.ts - * Unit tests for AdminModule — cancelEvent(), manualRefund(), pause(), unpause(), - * proposeAdmin(), acceptAdmin(). + * Unit tests for AdminModule — proposeAdmin(), acceptAdmin(), getPendingAdmin(), + * pause(), unpause(), setProtocolFee(), dividendDistribute(), cancelEvent(), + * manualRefund(), forceRefundEscrow(). Issues #470 / #471. */ -import { Keypair } from "@stellar/stellar-sdk"; +import { Keypair, xdr } from "@stellar/stellar-sdk"; import { VeriTixClient } from "../src/client"; import { getTestnetConfig } from "../src/utils/network"; import { VeriTixError, VeriTixErrorCode } from "../src/utils/errors"; +import { scValToBigint, scValToNumber, scValToString } from "../src/utils/scval"; const FAKE_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const FAKE_ADMIN = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const FAKE_RECIPIENT = "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"; jest.mock("../src/utils/transaction", () => { const actual = jest.requireActual("../src/utils/transaction"); @@ -38,438 +42,226 @@ function makeAdminClient(keypair?: Keypair) { beforeEach(() => jest.clearAllMocks()); -describe("AdminModule.cancelEvent()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +// --------------------------------------------------------------------------- +// #470 — proposeAdmin / acceptAdmin / getPendingAdmin +// --------------------------------------------------------------------------- +describe("AdminModule.proposeAdmin()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.cancelEvent([1n])) + await expect(client.admin.proposeAdmin(Keypair.random().publicKey())) .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("throws for empty escrowIds array", async () => { - const { client } = makeAdminClient(Keypair.random()); - await expect(client.admin.cancelEvent([])) - .rejects.toThrow("must not be empty"); - }); - - it("returns a BatchSettlementResult with settled count on success", async () => { + it("submits with the correct new_admin arg", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.cancelEvent([1n, 2n, 3n]); - expect(result.settled).toBe(3); - expect(result.failed).toHaveLength(0); - expect(result.txHashes).toHaveLength(1); - expect(result.txHashes[0]).toBe("mockhash"); - }); - - it("calls buildContractCall with 'cancel_event' method", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.cancelEvent([10n]); + const newAdmin = Keypair.random().publicKey(); + await client.admin.proposeAdmin(newAdmin); const buildMock = txUtils.buildContractCall as jest.Mock; expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("cancel_event"); - }); - - it("processes IDs in chunks of 50 and returns combined results", async () => { - const { client } = makeAdminClient(Keypair.random()); - const ids = Array.from({ length: 75 }, (_, i) => BigInt(i + 1)); - const result = await client.admin.cancelEvent(ids); - expect(result.settled).toBe(75); - expect(result.txHashes).toHaveLength(2); - }); - - it("collects failures without aborting remaining chunks", async () => { - const { client } = makeAdminClient(Keypair.random()); - (txUtils.submitTransaction as jest.Mock) - .mockRejectedValueOnce(new Error("network error")) - .mockResolvedValueOnce({ hash: "ok", ledger: 2, successful: true }); - const ids = Array.from({ length: 60 }, (_, i) => BigInt(i + 1)); - const result = await client.admin.cancelEvent(ids); - expect(result.failed).toHaveLength(50); - expect(result.settled).toBe(10); - }); - - it("invokes submitTransaction once per chunk", async () => { - const { client } = makeAdminClient(Keypair.random()); - const ids = Array.from({ length: 100 }, (_, i) => BigInt(i + 1)); - await client.admin.cancelEvent(ids); - expect(txUtils.submitTransaction as jest.Mock).toHaveBeenCalledTimes(2); - }); -}); - -describe("AdminModule.manualRefund()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { - const { client } = makeAdminClient(); - await expect(client.admin.manualRefund(1n, "reason")) - .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); + expect(buildMock.mock.calls[0][3]).toBe("propose_admin"); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(1); + expect(scValToString(args[0])).toBe(newAdmin); }); it("returns a TransactionResult on success", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.whitelistAddress('GABC'); - const result = await client.admin.setProtocolFee(100); + const result = await client.admin.proposeAdmin(Keypair.random().publicKey()); expect(result.hash).toBe("mockhash"); expect(result.successful).toBe(true); }); }); -describe("AdminModule.enableWhitelist()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +describe("AdminModule.acceptAdmin()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.enableWhitelist()) + await expect(client.admin.acceptAdmin()) .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("calls buildContractCall with 'enable_whitelist' method", async () => { + it("submits with method 'accept_admin' and empty args", async () => { const { client } = makeAdminClient(Keypair.random()); - await client.admin.enableWhitelist(); + await client.admin.acceptAdmin(); const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("enable_whitelist"); + expect(buildMock.mock.calls[0][3]).toBe("accept_admin"); + expect(buildMock.mock.calls[0][4]).toEqual([]); }); it("returns a TransactionResult on success", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.enableWhitelist(); - const result = await client.admin.dividendDistribute(2_000_000n); - expect(result.hash).toBe("mockhash"); + const result = await client.admin.acceptAdmin(); expect(result.successful).toBe(true); }); }); -describe("AdminModule.dividendDistribute()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +describe("AdminModule.getPendingAdmin()", () => { + it("throws READ_ONLY_CLIENT when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.dividendDistribute(1_000_000n)) - .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); + await expect(client.admin.getPendingAdmin()) + .rejects.toMatchObject({ code: VeriTixErrorCode.ReadOnlyClient }); }); - it("calls buildContractCall with 'dividend_distribute' method", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.dividendDistribute(500_000n); - const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("dividend_distribute"); -describe("AdminModule.whitelistAddress()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { - const { client } = makeAdminClient(); - await expect(client.admin.whitelistAddress('GABC')) - .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); - }); - - it("calls buildContractCall with 'whitelist_address' method", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.whitelistAddress('GABC'); - const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("whitelist_address"); -describe("AdminModule.forceRefundEscrow()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { - const { client } = makeAdminClient(); - await expect(client.admin.forceRefundEscrow(42n)) - .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); - }); - - it("calls buildContractCall with 'force_refund_escrow' method", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.forceRefundEscrow(42n); - const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("force_refund_escrow"); - }); - - it("returns a TransactionResult on success", async () => { - const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.dividendDistribute(2_000_000n); - const result = await client.admin.whitelistAddress('GABC'); - const result = await client.admin.forceRefundEscrow(10n); - expect(result.hash).toBe("mockhash"); - expect(result.successful).toBe(true); - }); -}); - - it("calls buildContractCall with 'force_refund_escrow' method", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.manualRefund(7n, "test reason"); - const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("force_refund_escrow"); - }); - - it("encodes both escrowId and reason as contract args", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.manualRefund(99n, "refund reason"); - const buildMock = txUtils.buildContractCall as jest.Mock; - const args = buildMock.mock.calls[0][4] as unknown[]; - expect(args).toHaveLength(2); + it("returns null when no proposal is pending", async () => { + const keypair = Keypair.random(); + const { client, mockServer } = makeAdminClient(keypair); + mockServer.simulateTransaction.mockResolvedValue({ + status: "SUCCESS", + result: { retval: xdr.ScVal.scvVoid() }, + }); + await expect(client.admin.getPendingAdmin()).resolves.toBeNull(); }); - it("invokes simulateTransaction once", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.manualRefund(1n, "reason"); - expect(txUtils.simulateTransaction as jest.Mock).toHaveBeenCalledTimes(1); + it("returns the pending admin address string when a proposal exists", async () => { + const keypair = Keypair.random(); + const { client, mockServer } = makeAdminClient(keypair); + mockServer.simulateTransaction.mockResolvedValue({ + status: "SUCCESS", + result: { retval: xdr.ScVal.scvString(FAKE_ADMIN) }, + }); + const pending = await client.admin.getPendingAdmin(); + expect(pending).toBe(FAKE_ADMIN); }); }); -describe("AdminModule.proposeAdmin()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +// --------------------------------------------------------------------------- +// #471 — pause / unpause / setProtocolFee / dividendDistribute +// --------------------------------------------------------------------------- +describe("AdminModule.pause()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.proposeAdmin(Keypair.random().publicKey())) + await expect(client.admin.pause()) .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("calls buildContractCall with 'propose_admin' and the new admin address", async () => { + it("submits a call to method 'pause' with no args", async () => { const { client } = makeAdminClient(Keypair.random()); - const newAdmin = Keypair.random().publicKey(); - await client.admin.proposeAdmin(newAdmin); - expect(txUtils.buildContractCall as jest.Mock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - FAKE_CONTRACT, - "propose_admin", - expect.arrayContaining([expect.objectContaining({ switch: expect.any(Function) })]), - expect.any(String), - ); + await client.admin.pause(); + const buildMock = txUtils.buildContractCall as jest.Mock; + expect(buildMock.mock.calls[0][3]).toBe("pause"); + expect(buildMock.mock.calls[0][4]).toEqual([]); }); it("returns a TransactionResult on success", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.proposeAdmin(Keypair.random().publicKey()); + const result = await client.admin.pause(); expect(result.hash).toBe("mockhash"); expect(result.successful).toBe(true); }); - - it("invokes simulateTransaction once per call", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.proposeAdmin(Keypair.random().publicKey()); - expect(txUtils.simulateTransaction as jest.Mock).toHaveBeenCalledTimes(1); - }); }); -describe("AdminModule.acceptAdmin()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { - const { client } = makeAdminClient(); - await expect(client.admin.acceptAdmin()) - .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); - }); - - it("calls buildContractCall with 'accept_admin' and empty args", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.acceptAdmin(); - expect(txUtils.buildContractCall as jest.Mock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - FAKE_CONTRACT, - "accept_admin", - [], - expect.any(String), - ); - }); - - it("returns a TransactionResult on success", async () => { - const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.acceptAdmin(); - expect(result.successful).toBe(true); - }); -}); - -describe("AdminModule.pause()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +describe("AdminModule.unpause()", () => { + it("throws when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.pause()).rejects.toMatchObject({ + await expect(client.admin.unpause()).rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized, }); }); - it("calls buildContractCall with method 'pause' and no args", async () => { + it("submits a call to method 'unpause' with no args", async () => { const { client } = makeAdminClient(Keypair.random()); - await client.admin.pause(); - expect(txUtils.buildContractCall as jest.Mock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - FAKE_CONTRACT, - "pause", - [], - expect.any(String), - ); + await client.admin.unpause(); + const buildMock = txUtils.buildContractCall as jest.Mock; + expect(buildMock.mock.calls[0][3]).toBe("unpause"); + expect(buildMock.mock.calls[0][4]).toEqual([]); }); +}); - it("calls simulateTransaction once", async () => { +describe("AdminModule.setProtocolFee()", () => { + it("submits a call to method 'set_protocol_fee' with the correct fee_bps arg", async () => { const { client } = makeAdminClient(Keypair.random()); - await client.admin.pause(); - expect(txUtils.simulateTransaction as jest.Mock).toHaveBeenCalledTimes(1); + await client.admin.setProtocolFee(250); + const buildMock = txUtils.buildContractCall as jest.Mock; + expect(buildMock.mock.calls[0][3]).toBe("set_protocol_fee"); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(1); + expect(scValToNumber(args[0])).toBe(250); }); - it("calls submitTransaction and returns TransactionResult", async () => { + it("returns a TransactionResult on success", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.pause(); + const result = await client.admin.setProtocolFee(100); expect(result.hash).toBe("mockhash"); expect(result.successful).toBe(true); }); - - it("propagates CONTRACT_ALREADY_PAUSED error from contract", async () => { - const { client } = makeAdminClient(Keypair.random()); - (txUtils.simulateTransaction as jest.Mock).mockRejectedValueOnce( - new VeriTixError(VeriTixErrorCode.ContractAlreadyPaused, "Contract is already paused"), - ); - await expect(client.admin.pause()).rejects.toMatchObject({ - code: VeriTixErrorCode.ContractAlreadyPaused, - }); - }); }); -describe("AdminModule.unpause()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +describe("AdminModule.dividendDistribute()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.unpause()).rejects.toMatchObject({ - code: VeriTixErrorCode.AdminUnauthorized, - }); - }); - - it("calls buildContractCall with method 'unpause' and no args", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.unpause(); - expect(txUtils.buildContractCall as jest.Mock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - FAKE_CONTRACT, - "unpause", - [], - expect.any(String), - ); + await expect(client.admin.dividendDistribute([FAKE_RECIPIENT], 1_000_000n)) + .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("calls submitTransaction and returns TransactionResult", async () => { + it("throws when totalAmount is not positive", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.unpause(); - expect(result.hash).toBe("mockhash"); - expect(result.successful).toBe(true); + await expect(client.admin.dividendDistribute([FAKE_RECIPIENT], 0n)) + .rejects.toThrow("totalAmount must be greater than zero"); }); - it("propagates CONTRACT_NOT_PAUSED error from contract", async () => { + it("submits a call to method 'dividend_distribute' with the correct total_amount arg", async () => { const { client } = makeAdminClient(Keypair.random()); - (txUtils.simulateTransaction as jest.Mock).mockRejectedValueOnce( - new VeriTixError(VeriTixErrorCode.ContractNotPaused, "Contract is not paused"), - ); - await expect(client.admin.unpause()).rejects.toMatchObject({ - code: VeriTixErrorCode.ContractNotPaused, - }); - }); - - it("invokes submitTransaction with the admin keypair", async () => { - const keypair = Keypair.random(); - const { client } = makeAdminClient(keypair); - await client.admin.unpause(); - expect(txUtils.submitTransaction as jest.Mock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - keypair, - ); + await client.admin.dividendDistribute([FAKE_RECIPIENT], 10_000_000n); + const buildMock = txUtils.buildContractCall as jest.Mock; + expect(buildMock.mock.calls[0][3]).toBe("dividend_distribute"); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(2); + expect(scValToBigint(args[1])).toBe(10_000_000n); }); }); // --------------------------------------------------------------------------- -// #267 — AdminModule.dividendDistribute and forceRefundEscrow +// Pre-existing admin tests kept for regression // --------------------------------------------------------------------------- - -describe("AdminModule.dividendDistribute()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided", async () => { +describe("AdminModule.cancelEvent()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect( - client.admin.dividendDistribute(["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"], 1_000_000n), - ).rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); + await expect(client.admin.cancelEvent([1n])) + .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("throws when totalAmount is 0n", async () => { + it("throws for an empty escrowIds array", async () => { const { client } = makeAdminClient(Keypair.random()); - await expect( - client.admin.dividendDistribute(["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"], 0n), - ).rejects.toThrow("totalAmount must be greater than zero"); + await expect(client.admin.cancelEvent([])).rejects.toThrow("must not be empty"); }); - it("throws when totalAmount is negative", async () => { + it("returns a BatchSettlementResult with settled count on success", async () => { const { client } = makeAdminClient(Keypair.random()); - await expect( - client.admin.dividendDistribute(["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"], -1n), - ).rejects.toThrow("totalAmount must be greater than zero"); + const result = await client.admin.cancelEvent([1n, 2n, 3n]); + expect(result.settled).toBe(3); + expect(result.failed).toHaveLength(0); + expect(result.txHashes).toHaveLength(1); + expect(result.txHashes[0]).toBe("mockhash"); }); +}); - it("calls buildContractCall with 'dividend_distribute' method on success", async () => { - const { client } = makeAdminClient(Keypair.random()); - const recipients = [ - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", - Keypair.random().publicKey(), - ]; - await client.admin.dividendDistribute(recipients, 10_000_000n); - const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); - expect(buildMock.mock.calls[0][3]).toBe("dividend_distribute"); +describe("AdminModule.manualRefund()", () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { + const { client } = makeAdminClient(); + await expect(client.admin.manualRefund(1n, "reason")) + .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("returns a TransactionResult on success", async () => { + it("submits a call to method 'force_refund_escrow' with escrowId and reason", async () => { const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.dividendDistribute( - ["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"], - 5_000_000n, - ); - expect(result.hash).toBe("mockhash"); - expect(result.successful).toBe(true); - }); - - it("requires admin keypair to call", async () => { - const { client } = makeAdminClient(); - const result = client.admin.dividendDistribute( - ["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"], - 1_000n, - ); - await expect(result).rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); + await client.admin.manualRefund(99n, "refund reason"); + const buildMock = txUtils.buildContractCall as jest.Mock; + expect(buildMock.mock.calls[0][3]).toBe("force_refund_escrow"); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(2); }); }); describe("AdminModule.forceRefundEscrow()", () => { - it("throws ADMIN_UNAUTHORIZED when no keypair provided (non-admin)", async () => { + it("throws ADMIN_UNAUTHORIZED when no keypair is provided", async () => { const { client } = makeAdminClient(); - await expect(client.admin.forceRefundEscrow(1n)).rejects.toMatchObject({ - code: VeriTixErrorCode.AdminUnauthorized, - }); + await expect(client.admin.forceRefundEscrow(1n)) + .rejects.toMatchObject({ code: VeriTixErrorCode.AdminUnauthorized }); }); - it("calls buildContractCall with 'force_refund_escrow' method", async () => { + it("submits a call to method 'force_refund_escrow'", async () => { const { client } = makeAdminClient(Keypair.random()); await client.admin.forceRefundEscrow(42n); const buildMock = txUtils.buildContractCall as jest.Mock; - expect(buildMock).toHaveBeenCalled(); expect(buildMock.mock.calls[0][3]).toBe("force_refund_escrow"); }); - - it("returns a TransactionResult on success", async () => { - const { client } = makeAdminClient(Keypair.random()); - const result = await client.admin.forceRefundEscrow(42n); - expect(result.hash).toBe("mockhash"); - expect(result.successful).toBe(true); - }); - - it("propagates ESCROW_ALREADY_SETTLED error from contract", async () => { - const { client } = makeAdminClient(Keypair.random()); - (txUtils.simulateTransaction as jest.Mock).mockRejectedValueOnce( - new VeriTixError(VeriTixErrorCode.EscrowAlreadySettled, "Escrow already settled"), - ); - await expect(client.admin.forceRefundEscrow(1n)).rejects.toMatchObject({ - code: VeriTixErrorCode.EscrowAlreadySettled, - }); - }); - - it("propagates error when escrow has not yet expired", async () => { - const { client } = makeAdminClient(Keypair.random()); - (txUtils.simulateTransaction as jest.Mock).mockRejectedValueOnce( - new Error("escrow has not yet expired"), - ); - await expect(client.admin.forceRefundEscrow(99n)).rejects.toThrow( - "escrow has not yet expired", - ); - }); - - it("invokes simulateTransaction once", async () => { - const { client } = makeAdminClient(Keypair.random()); - await client.admin.forceRefundEscrow(5n); - expect(txUtils.simulateTransaction as jest.Mock).toHaveBeenCalledTimes(1); - }); }); diff --git a/tests/recurring.test.ts b/tests/recurring.test.ts index c4aeaba..d677f63 100644 --- a/tests/recurring.test.ts +++ b/tests/recurring.test.ts @@ -1,12 +1,16 @@ /** * @file tests/recurring.test.ts - * Unit tests for RecurringModule.executeAllDue() — issues #119 / #141. + * Unit tests for RecurringModule — executeAllDue(), amendRecurring(), transferPayer(). + * Issues #119 / #141 / #263 / #468 / #469. */ import { VeriTixClient } from '../src/client'; import { getTestnetConfig } from '../src/utils/network'; import { RecurringModule } from '../src/modules/recurring'; -import { Keypair } from '@stellar/stellar-sdk'; -import { VeriTixErrorCode } from '../src/utils/errors'; +import { Keypair, xdr } from '@stellar/stellar-sdk'; +import { VeriTixError, VeriTixErrorCode } from '../src/utils/errors'; +import { scValToBigint, scValToNumber } from '../src/utils/scval'; +import * as transactionUtils from '../src/utils/transaction'; +import type { RecurringRecord } from '../src/types/index'; const FAKE_CONTRACT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; const FAKE_PAYER = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; @@ -21,65 +25,58 @@ describe('RecurringModule', () => { jest.restoreAllMocks(); }); - describe('pauseRecurring()', () => { - it('throws ReadOnlyClient when no keypair', async () => { - await expect(recurring.pauseRecurring(1n)).rejects.toThrow('signing keypair required'); + // --------------------------------------------------------------------- + // #468 — executeAllDue() categorisation stress testing + // --------------------------------------------------------------------- + describe('executeAllDue()', () => { + it('returns all empty arrays when the payer has no recurring payments', async () => { + const result = await recurring.executeAllDue(FAKE_PAYER); + expect(result).toEqual({ executed: [], skipped: [], failed: [] }); }); - it('returns tx result on success', async () => { - const kp = Keypair.random(); - const c = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT), kp); - const mockServer = { simulateTransaction: jest.fn(), sendTransaction: jest.fn(), getTransaction: jest.fn() }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).server = mockServer; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).connected = true; - - const fakeTx = { sign: jest.fn().mockReturnValue([]) }; - mockServer.simulateTransaction.mockResolvedValue({ - status: 'SUCCESS', - result: { retval: undefined }, - }); - mockServer.sendTransaction.mockResolvedValue({ hash: 'txhash', status: 'PENDING' }); - mockServer.getTransaction.mockResolvedValue({ status: 'SUCCESS', successful: true, ledger: 42 }); - - const result = await c.recurring.pauseRecurring(5n); - expect(result.successful).toBe(true); - expect(result.hash).toBe('txhash'); + it('returns all empty arrays when payer string is empty', async () => { + const result = await recurring.executeAllDue(''); + expect(result).toEqual({ executed: [], skipped: [], failed: [] }); }); - }); - describe('resumeRecurring()', () => { - it('throws ReadOnlyClient when no keypair', async () => { - await expect(recurring.resumeRecurring(1n)).rejects.toThrow('signing keypair required'); + it('categorises 100 IDs: 50 due executed, 50 not-due skipped, none failed', async () => { + const ids = Array.from({ length: 100 }, (_, i) => BigInt(i + 1)); + jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue(ids); + jest + .spyOn(recurring as any, 'isExecutable') + .mockImplementation(async (id: unknown) => (id as bigint) <= 50n); + jest.spyOn(recurring, 'execute').mockResolvedValue({ hash: 'h', ledger: 1, successful: true }); + + const result = await recurring.executeAllDue(FAKE_PAYER); + expect(result.executed).toHaveLength(50); + expect(result.skipped).toHaveLength(50); + expect(result.failed).toHaveLength(0); + expect(result.executed).toEqual(Array.from({ length: 50 }, (_, i) => BigInt(i + 1))); + expect(result.skipped).toEqual(Array.from({ length: 50 }, (_, i) => BigInt(i + 51))); }); - it('returns tx result on success', async () => { - const kp = Keypair.random(); - const c = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT), kp); - const mockServer = { simulateTransaction: jest.fn(), sendTransaction: jest.fn(), getTransaction: jest.fn() }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).server = mockServer; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).connected = true; - - mockServer.simulateTransaction.mockResolvedValue({ - status: 'SUCCESS', - result: { retval: undefined }, - }); - mockServer.sendTransaction.mockResolvedValue({ hash: 'txhash2', status: 'PENDING' }); - mockServer.getTransaction.mockResolvedValue({ status: 'SUCCESS', successful: true, ledger: 43 }); + it('adds IDs that throw RecurringIntervalNotElapsed to skipped, not failed', async () => { + jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([7n]); + jest.spyOn(recurring as any, 'isExecutable').mockResolvedValue(true); + jest.spyOn(recurring, 'execute').mockRejectedValue( + new VeriTixError(VeriTixErrorCode.RecurringIntervalNotElapsed, 'interval not elapsed'), + ); - const result = await c.recurring.resumeRecurring(5n); - expect(result.successful).toBe(true); - expect(result.hash).toBe('txhash2'); + const result = await recurring.executeAllDue(FAKE_PAYER); + expect(result.skipped).toEqual([7n]); + expect(result.failed).toEqual([]); + expect(result.executed).toEqual([]); }); - }); - describe('executeAllDue()', () => { - it('returns empty arrays when no recurring payments exist', async () => { + it('adds IDs that throw a network error to failed, not skipped', async () => { + jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([8n]); + jest.spyOn(recurring as any, 'isExecutable').mockResolvedValue(true); + jest.spyOn(recurring, 'execute').mockRejectedValue(new Error('network error')); + const result = await recurring.executeAllDue(FAKE_PAYER); - expect(result).toEqual({ executed: [], skipped: [], failed: [] }); + expect(result.failed).toEqual([8n]); + expect(result.skipped).toEqual([]); + expect(result.executed).toEqual([]); }); it('skips inactive IDs', async () => { @@ -109,19 +106,6 @@ describe('RecurringModule', () => { expect(result.failed).toEqual([]); }); - it('adds to failed when execute() throws', async () => { - jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([3n]); - jest.spyOn(recurring as any, 'getRecurring').mockResolvedValue({ - id: 3n, payer: FAKE_PAYER, payee: 'GXYZ', amount: 100n, - interval: 100, active: true, lastChargedLedger: 0, - }); - jest.spyOn(recurring, 'execute').mockRejectedValue(new Error('interval not elapsed')); - - const result = await recurring.executeAllDue(FAKE_PAYER); - expect(result.failed).toEqual([3n]); - expect(result.executed).toEqual([]); - }); - it('handles mixed executed/skipped/failed in one call', async () => { jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([1n, 2n, 3n]); jest.spyOn(recurring as any, 'getRecurring').mockImplementation(async (id: unknown) => ({ @@ -138,86 +122,118 @@ describe('RecurringModule', () => { expect(result.executed).toEqual([2n]); expect(result.failed).toEqual([3n]); }); + }); - // --- Tests mocking isExecutable directly (issue #141) --- + // --------------------------------------------------------------------- + // #469 — amendRecurring() + // --------------------------------------------------------------------- + describe('amendRecurring()', () => { + it('throws when neither amount nor interval is provided', async () => { + const keypair = Keypair.random(); + const c = makeRecurringClient(keypair); + await expect(c.client.recurring.amendRecurring(1n, {})).rejects.toThrow( + 'at least one of amount or interval must be provided', + ); + }); - it('all payments due → all executed, failed: []', async () => { - jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([10n, 11n]); - jest.spyOn(recurring as any, 'isExecutable').mockResolvedValue(true); - jest.spyOn(recurring, 'execute').mockResolvedValue({ hash: 'h', ledger: 1, successful: true }); + it('throws ReadOnlyClient when no keypair is supplied', async () => { + const c = makeRecurringClient(); + await expect( + c.client.recurring.amendRecurring(1n, { amount: 2_000_000n }), + ).rejects.toMatchObject({ code: VeriTixErrorCode.ReadOnlyClient }); + }); - const result = await recurring.executeAllDue(FAKE_PAYER); - expect(result.executed).toEqual([10n, 11n]); - expect(result.skipped).toEqual([]); - expect(result.failed).toEqual([]); + it('submits only the amount arg when only newAmount is provided', async () => { + const c = makeRecurringClient(Keypair.random()); + const buildMock = jest + .spyOn(transactionUtils, 'buildContractCall') + .mockRejectedValue(new Error('stop')); + await expect( + c.client.recurring.amendRecurring(1n, { amount: 2_000_000n }), + ).rejects.toThrow('stop'); + + expect(buildMock).toHaveBeenCalled(); + expect(buildMock.mock.calls[0][3]).toBe('amend_recurring'); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(2); + expect(scValToBigint(args[0])).toBe(1n); + expect(scValToBigint(args[1])).toBe(2_000_000n); }); - it('some not due (isExecutable === false) → correctly skipped', async () => { - jest.spyOn(recurring as any, 'getRecurringByPayer').mockResolvedValue([20n, 21n]); - jest.spyOn(recurring as any, 'isExecutable').mockImplementation(async (id: unknown) => - (id as bigint) === 20n, - ); - jest.spyOn(recurring, 'execute').mockResolvedValue({ hash: 'h', ledger: 1, successful: true }); + it('submits only the interval arg when only newInterval is provided', async () => { + const c = makeRecurringClient(Keypair.random()); + const buildMock = jest + .spyOn(transactionUtils, 'buildContractCall') + .mockRejectedValue(new Error('stop')); + await expect(c.client.recurring.amendRecurring(1n, { interval: 200 })).rejects.toThrow('stop'); + + expect(buildMock).toHaveBeenCalled(); + expect(buildMock.mock.calls[0][3]).toBe('amend_recurring'); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(2); + expect(scValToBigint(args[0])).toBe(1n); + expect(scValToNumber(args[1])).toBe(200); + }); - const result = await recurring.executeAllDue(FAKE_PAYER); - expect(result.executed).toEqual([20n]); - expect(result.skipped).toEqual([21n]); - expect(result.failed).toEqual([]); + it('submits amount and interval args when both fields are provided', async () => { + const c = makeRecurringClient(Keypair.random()); + const buildMock = jest + .spyOn(transactionUtils, 'buildContractCall') + .mockRejectedValue(new Error('stop')); + await expect( + c.client.recurring.amendRecurring(1n, { amount: 3_000_000n, interval: 300 }), + ).rejects.toThrow('stop'); + + expect(buildMock).toHaveBeenCalled(); + expect(buildMock.mock.calls[0][3]).toBe('amend_recurring'); + const args = buildMock.mock.calls[0][4] as xdr.ScVal[]; + expect(args).toHaveLength(3); + expect(scValToBigint(args[0])).toBe(1n); + expect(scValToBigint(args[1])).toBe(3_000_000n); + expect(scValToNumber(args[2])).toBe(300); }); }); + // --------------------------------------------------------------------- + // transferPayer() + // --------------------------------------------------------------------- describe('transferPayer()', () => { - it('throws ReadOnlyClient when no keypair', async () => { - await expect(recurring.transferPayer(1n, 'GNEW')).rejects.toThrow('signing keypair required'); - describe('amendRecurring()', () => { - it('throws ReadOnlyClient when no keypair', async () => { - await expect(recurring.amendRecurring(1n, 100n, 100)).rejects.toThrow('signing keypair required'); + it('throws when no keypair is supplied', async () => { + const c = makeRecurringClient(); + await expect( + c.client.recurring.transferPayer(1n, 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'), + ).rejects.toThrow('signing keypair required'); }); - it('returns tx result on success', async () => { - const kp = Keypair.random(); - const c = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT), kp); - const mockServer = { simulateTransaction: jest.fn(), sendTransaction: jest.fn(), getTransaction: jest.fn() }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).server = mockServer; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (c as any).connected = true; - - mockServer.simulateTransaction.mockResolvedValue({ - status: 'SUCCESS', - result: { retval: undefined }, - }); - mockServer.sendTransaction.mockResolvedValue({ hash: 'transfer-hash', status: 'PENDING' }); - mockServer.getTransaction.mockResolvedValue({ status: 'SUCCESS', successful: true, ledger: 55 }); - - const result = await c.recurring.transferPayer(3n, 'GNEWPAYER'); - expect(result.successful).toBe(true); - expect(result.hash).toBe('transfer-hash'); - mockServer.sendTransaction.mockResolvedValue({ hash: 'amend-hash', status: 'PENDING' }); - mockServer.getTransaction.mockResolvedValue({ status: 'SUCCESS', successful: true, ledger: 50 }); - - const result = await c.recurring.amendRecurring(2n, 500n, 3600); - expect(result.successful).toBe(true); - expect(result.hash).toBe('amend-hash'); + it('throws when new payer is the same as the current payer', async () => { + const keypair = Keypair.random(); + const c = makeRecurringClient(keypair); + await expect(c.client.recurring.transferPayer(1n, keypair.publicKey())).rejects.toThrow( + 'new payer must differ from the current payer', + ); + }); + + it('throws when the recurring payment is inactive', async () => { + const c = makeRecurringClient(Keypair.random()); + jest + .spyOn(c.client.recurring, 'getRecurring') + .mockResolvedValue(makeRecurringRecord({ active: false })); + + await expect( + c.client.recurring.transferPayer(1n, 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'), + ).rejects.toThrow('recurring payment is inactive'); }); }); }); // --------------------------------------------------------------------------- -// #263 — RecurringModule.amendRecurring and transferPayer +// Helpers // --------------------------------------------------------------------------- -import { Keypair } from '@stellar/stellar-sdk'; -import * as transactionUtils from '../src/utils/transaction'; -import type { RecurringRecord } from '../src/types/index'; - function makeRecurringClient(keypair?: Keypair) { const client = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT), keypair); const mockServer = { - simulateTransaction: jest.fn().mockResolvedValue({ - status: 'SUCCESS', - result: { retval: undefined }, - }), + simulateTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS', result: { retval: undefined } }), sendTransaction: jest.fn(), getTransaction: jest.fn(), getLatestLedger: jest.fn().mockResolvedValue({ sequence: 100 }), @@ -235,127 +251,8 @@ function makeRecurringRecord(overrides: Partial = {}): Recurrin amount: 1_000_000n, interval: 100, active: true, + paused: false, lastChargedLedger: 0, ...overrides, }; } - -describe('RecurringModule.amendRecurring', () => { - beforeEach(() => jest.restoreAllMocks()); - - it('throws when neither amount nor interval is provided', async () => { - const { client } = makeRecurringClient(Keypair.random()); - await expect(client.recurring.amendRecurring(1n, {})).rejects.toThrow( - 'at least one of amount or interval must be provided', - ); - }); - - it('throws when no keypair is supplied', async () => { - const { client } = makeRecurringClient(); - await expect( - client.recurring.amendRecurring(1n, { amount: 2_000_000n }), - ).rejects.toThrow('signing keypair required'); - }); - - it('succeeds when only amount is updated', async () => { - const keypair = Keypair.random(); - const { client } = makeRecurringClient(keypair); - jest.spyOn(transactionUtils, 'buildContractCall').mockResolvedValue({} as never); - jest.spyOn(transactionUtils, 'submitTransaction').mockResolvedValue({ - hash: 'amend-hash', - ledger: 10, - successful: true, - }); - - const result = await client.recurring.amendRecurring(1n, { amount: 2_000_000n }); - expect(result.hash).toBe('amend-hash'); - expect(result.successful).toBe(true); - const buildMock = transactionUtils.buildContractCall as jest.Mock; - expect(buildMock.mock.calls[0][3]).toBe('amend_recurring'); - }); - - it('succeeds when only interval is updated', async () => { - const keypair = Keypair.random(); - const { client } = makeRecurringClient(keypair); - jest.spyOn(transactionUtils, 'buildContractCall').mockResolvedValue({} as never); - jest.spyOn(transactionUtils, 'submitTransaction').mockResolvedValue({ - hash: 'amend-interval-hash', - ledger: 11, - successful: true, - }); - - const result = await client.recurring.amendRecurring(1n, { interval: 200 }); - expect(result.hash).toBe('amend-interval-hash'); - const buildMock = transactionUtils.buildContractCall as jest.Mock; - expect(buildMock.mock.calls[0][3]).toBe('amend_recurring'); - }); - - it('succeeds when both amount and interval are updated', async () => { - const keypair = Keypair.random(); - const { client } = makeRecurringClient(keypair); - jest.spyOn(transactionUtils, 'buildContractCall').mockResolvedValue({} as never); - jest.spyOn(transactionUtils, 'submitTransaction').mockResolvedValue({ - hash: 'amend-both-hash', - ledger: 12, - successful: true, - }); - - const result = await client.recurring.amendRecurring(1n, { - amount: 3_000_000n, - interval: 300, - }); - expect(result.hash).toBe('amend-both-hash'); - }); -}); - -describe('RecurringModule.transferPayer', () => { - beforeEach(() => jest.restoreAllMocks()); - - it('throws when no keypair is supplied', async () => { - const { client } = makeRecurringClient(); - await expect( - client.recurring.transferPayer(1n, 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'), - ).rejects.toThrow('signing keypair required'); - }); - - it('throws when new payer is the same as the current payer', async () => { - const keypair = Keypair.random(); - const { client } = makeRecurringClient(keypair); - await expect( - client.recurring.transferPayer(1n, keypair.publicKey()), - ).rejects.toThrow('new payer must differ from the current payer'); - }); - - it('throws when the recurring payment is inactive', async () => { - const keypair = Keypair.random(); - const { client } = makeRecurringClient(keypair); - jest - .spyOn(client.recurring, 'getRecurring') - .mockResolvedValue(makeRecurringRecord({ active: false })); - - await expect( - client.recurring.transferPayer(1n, 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'), - ).rejects.toThrow('recurring payment is inactive'); - }); - - it('succeeds when both payer auth is present and payment is active', async () => { - const keypair = Keypair.random(); - const newPayerAddr = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; - const { client } = makeRecurringClient(keypair); - jest - .spyOn(client.recurring, 'getRecurring') - .mockResolvedValue(makeRecurringRecord({ active: true })); - jest.spyOn(transactionUtils, 'buildContractCall').mockResolvedValue({} as never); - jest.spyOn(transactionUtils, 'submitTransaction').mockResolvedValue({ - hash: 'transfer-payer-hash', - ledger: 20, - successful: true, - }); - - const result = await client.recurring.transferPayer(1n, newPayerAddr); - expect(result.hash).toBe('transfer-payer-hash'); - expect(result.successful).toBe(true); - const buildMock = transactionUtils.buildContractCall as jest.Mock; - expect(buildMock.mock.calls[0][3]).toBe('transfer_payer'); - }); -});