From 2e5f3f6369e537ecef648757de07d44ac80a6197 Mon Sep 17 00:00:00 2001 From: pntech20 Date: Sat, 23 May 2026 11:28:48 +0700 Subject: [PATCH] test: cover vault withdrawal flow --- src/contracts/vault.ts | 21 +++++- tests/cache.test.ts | 13 ++-- tests/vault-withdraw.test.ts | 137 +++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/vault-withdraw.test.ts diff --git a/src/contracts/vault.ts b/src/contracts/vault.ts index 1015fe4..6c737db 100644 --- a/src/contracts/vault.ts +++ b/src/contracts/vault.ts @@ -38,12 +38,13 @@ export class ProtoxVault { async deposit(amount: number | bigint): Promise { if (!this.wallet) throw new Error("Wallet not connected"); const userAddress = await this.wallet.getAddress(); + const normalizedAmount = this.normalizeAmount(amount, 'Deposit'); const transaction = await this.buildContractCall( 'deposit', [ new Address(userAddress).toScVal(), - nativeToScVal(BigInt(amount), { type: 'i128' }) + nativeToScVal(normalizedAmount, { type: 'i128' }) ] ); @@ -56,12 +57,13 @@ export class ProtoxVault { async withdraw(amount: number | bigint): Promise { if (!this.wallet) throw new Error("Wallet not connected"); const userAddress = await this.wallet.getAddress(); + const normalizedAmount = this.normalizeAmount(amount, 'Withdrawal'); const transaction = await this.buildContractCall( 'withdraw', [ new Address(userAddress).toScVal(), - nativeToScVal(BigInt(amount), { type: 'i128' }) + nativeToScVal(normalizedAmount, { type: 'i128' }) ] ); @@ -137,6 +139,21 @@ export class ProtoxVault { return simulation.result.retval; } + private normalizeAmount(amount: number | bigint, operationName: string): bigint { + if (typeof amount === 'number') { + if (!Number.isFinite(amount) || !Number.isInteger(amount)) { + throw new Error(`${operationName} amount must be an integer.`); + } + } + + const normalizedAmount = BigInt(amount); + if (normalizedAmount <= 0n) { + throw new Error(`${operationName} amount must be greater than zero.`); + } + + return normalizedAmount; + } + // TODO: Implement claim_rewards function // TODO: Add events subscription for vault activities // TODO: Add more robust error handling for contract-specific reverts diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 6eedf10..8c242ce 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, beforeEach, jest } from '@jest/globals'; import { StellarClient, ProtoxVault, NETWORKS } from '../src'; -import { SorobanRpc } from '@stellar/stellar-sdk'; // We need to mock the SorobanRpc.Server's simulateTransaction method specifically -const mockSimulate = jest.fn().mockResolvedValue({ +const mockSimulate = jest.fn() as jest.MockedFunction<() => Promise>; +mockSimulate.mockResolvedValue({ result: { retval: (jest.requireActual('@stellar/stellar-sdk') as any).nativeToScVal(100n, { type: 'i128' }) }, }); @@ -14,7 +14,9 @@ jest.mock('@stellar/stellar-sdk', () => { SorobanRpc: { ...actual.SorobanRpc, Server: jest.fn().mockImplementation(() => ({ - getAccount: jest.fn().mockResolvedValue(new actual.Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '1')), + getAccount: (jest.fn() as any).mockImplementation(async () => ( + new actual.Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '1') + )), simulateTransaction: mockSimulate, })), }, @@ -24,13 +26,14 @@ jest.mock('@stellar/stellar-sdk', () => { describe('CacheManager Tests', () => { let client: StellarClient; let vault: ProtoxVault; + const contractAddress = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; const userAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; beforeEach(() => { jest.clearAllMocks(); // Enable cache with a short TTL for testing client = new StellarClient(NETWORKS.TESTNET, { enabled: true, ttl: 5000 }); - vault = new ProtoxVault('CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7', client); + vault = new ProtoxVault(contractAddress, client); }); test('Should hit cache on second call', async () => { @@ -63,7 +66,7 @@ describe('CacheManager Tests', () => { test('Should not use cache when disabled in config', async () => { const disabledClient = new StellarClient(NETWORKS.TESTNET, { enabled: false }); - const disabledVault = new ProtoxVault('CA...', disabledClient); + const disabledVault = new ProtoxVault(contractAddress, disabledClient); await disabledVault.getBalance(userAddress); await disabledVault.getBalance(userAddress); diff --git a/tests/vault-withdraw.test.ts b/tests/vault-withdraw.test.ts new file mode 100644 index 0000000..75b6639 --- /dev/null +++ b/tests/vault-withdraw.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test, beforeEach, jest } from "@jest/globals"; +import { Keypair } from "@stellar/stellar-sdk"; +import { StellarClient } from "../src/client/stellarClient"; +import { ProtoxVault } from "../src/contracts/vault"; +import { NETWORKS } from "../src/utils/networkConfig"; +import { PrivateKeyWallet, WalletConnector } from "../src/wallet/walletConnector"; + +jest.mock("@stellar/stellar-sdk", () => { + const actual = jest.requireActual("@stellar/stellar-sdk") as any; + return { + ...actual, + Transaction: jest.fn().mockImplementation(() => ({ + toXDR: () => "mockXDR", + networkPassphrase: "Test SDF Network ; September 2015", + sign: jest.fn(), + })), + Contract: jest.fn().mockImplementation((address: unknown) => ({ + address: () => ({ toString: () => address }), + call: jest.fn().mockReturnValue({ type: "operation" }), + })), + SorobanRpc: { + ...actual.SorobanRpc, + assembleTransaction: jest.fn().mockReturnValue({ + build: jest.fn().mockReturnValue({ + toXDR: () => "mockXDR", + networkPassphrase: "Test SDF Network ; September 2015", + sign: jest.fn(), + }), + }), + }, + }; +}); + +const CONTRACT_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7"; +const USER_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +const mockTx: any = { + toXDR: () => "mockXDR", + networkPassphrase: "Test SDF Network ; September 2015", + sign: jest.fn(), +}; + +const mockBuilder: any = { + addOperation: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue(mockTx), +}; + +const successfulWithdrawResponse = { + status: "SUCCESS", + latestLedger: 10, + latestLedgerCloseTime: 20, +} as any; + +describe("Vault Withdraw Method", () => { + let client: StellarClient; + let vault: ProtoxVault; + let wallet: WalletConnector; + + beforeEach(() => { + jest.clearAllMocks(); + mockBuilder.addOperation.mockReturnThis(); + mockBuilder.build.mockReturnValue(mockTx); + + client = new StellarClient(NETWORKS.TESTNET); + + jest.spyOn(client, "buildTransaction").mockResolvedValue(mockBuilder); + jest.spyOn(client, "simulateTransaction").mockResolvedValue({ + result: { retval: {} as any }, + minResourceFee: "200", + transactionData: {} as any, + events: [], + latestLedger: 1, + } as any); + jest.spyOn(client, "submitTransaction").mockResolvedValue(successfulWithdrawResponse); + + const signer = new PrivateKeyWallet(Keypair.random().secret()); + jest.spyOn(signer, "getPublicKey").mockResolvedValue(USER_ADDRESS); + + wallet = new WalletConnector(signer); + jest.spyOn(wallet, "sign").mockResolvedValue(mockTx); + + vault = new ProtoxVault(CONTRACT_ADDRESS, client, wallet); + }); + + test("withdraw submits a transaction and returns the network response", async () => { + const result = await vault.withdraw(500n); + + expect(result).toBe(successfulWithdrawResponse); + expect(client.submitTransaction).toHaveBeenCalledTimes(1); + }); + + test("withdraw builds the transaction from the connected wallet address", async () => { + await vault.withdraw(250n); + + expect(client.buildTransaction).toHaveBeenCalledWith(USER_ADDRESS); + expect(mockBuilder.addOperation).toHaveBeenCalledTimes(1); + }); + + test("withdraw accepts numeric integer amounts", async () => { + await vault.withdraw(125); + + expect(client.submitTransaction).toHaveBeenCalledTimes(1); + }); + + test("withdraw rejects when wallet is not connected", async () => { + const noWalletVault = new ProtoxVault(CONTRACT_ADDRESS, client); + + await expect(noWalletVault.withdraw(100n)).rejects.toThrow("Wallet not connected"); + }); + + test.each([ + [0n, "Withdrawal amount must be greater than zero."], + [-1n, "Withdrawal amount must be greater than zero."], + [1.5, "Withdrawal amount must be an integer."], + [Number.NaN, "Withdrawal amount must be an integer."], + ])("withdraw rejects invalid amount %p", async (amount, message) => { + await expect(vault.withdraw(amount as number | bigint)).rejects.toThrow(message); + expect(client.submitTransaction).not.toHaveBeenCalled(); + }); + + test("withdraw surfaces insufficient balance errors from submission", async () => { + jest + .spyOn(client, "submitTransaction") + .mockRejectedValue(new Error("ContractError: insufficient balance")); + + await expect(vault.withdraw(1000n)).rejects.toThrow("insufficient balance"); + }); + + test("withdraw rejects when simulation fails before signing", async () => { + jest.spyOn(client, "simulateTransaction").mockResolvedValue({ + error: "simulation error", + } as any); + + await expect(vault.withdraw(500n)).rejects.toThrow("Simulation failed"); + expect(wallet.sign).not.toHaveBeenCalled(); + }); +});