Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/contracts/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ export class ProtoxVault {
async deposit(amount: number | bigint): Promise<SorobanRpc.Api.GetTransactionResponse> {
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' })
]
);

Expand All @@ -56,12 +57,13 @@ export class ProtoxVault {
async withdraw(amount: number | bigint): Promise<SorobanRpc.Api.GetTransactionResponse> {
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' })
]
);

Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>>;
mockSimulate.mockResolvedValue({
result: { retval: (jest.requireActual('@stellar/stellar-sdk') as any).nativeToScVal(100n, { type: 'i128' }) },
});

Expand All @@ -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,
})),
},
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
137 changes: 137 additions & 0 deletions tests/vault-withdraw.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});