Testing
Comprehensive testing documentation for the Rate Swap Protocol.
Test Suite Overview
The protocol includes extensive TypeScript tests using ts-mocha and the Anchor testing framework.
Location: rate-swap/tests/
Test Files
- pool_nav.ts: Pool NAV calculations and LP share accounting
- init_market.ts: Market initialization and parameter validation
- liquidity_depth.ts: Liquidity routing and reserve requirements
- liquidation.ts: Position liquidation and health checks
- oracle.ts: Oracle staleness validation and updates
Running Tests
All Tests
bash
cd rate-swap
anchor testSpecific Test File
bash
yarn run ts-mocha -p ./tsconfig.json -t 1000000 "tests/pool_nav.ts"With Verbose Logs
bash
RUST_LOG=debug anchor test -- --nocaptureWatch Mode (for development)
bash
# In one terminal: start validator
solana-test-validator
# In another: run tests with --skip-local-validator
anchor test --skip-local-validatorTest Scenarios
Pool NAV Tests (pool_nav.ts)
Scenario 1: First Deposit (1:1 Share Mint)
typescript
it('first deposit mints 1:1 shares', async () => {
const depositAmount = 1_000_000; // 1M USDC
await depositPool(pool, depositAmount);
const poolData = await program.account.pool.fetch(pool);
expect(poolData.totalShares.toNumber()).to.equal(depositAmount);
});Purpose: Verify first LP gets 1:1 shares
Scenario 2: Subsequent Deposits (NAV-Based Pricing)
typescript
it('subsequent deposits use NAV pricing', async () => {
// First deposit: 1M USDC
await depositPool(pool, 1_000_000);
// Simulate NAV increase (pool profit)
// NAV goes from 1M to 1.1M
// Second deposit: 100k USDC
await depositPool(pool, 100_000);
// Expected shares: floor(100k * 1M shares / 1.1M NAV) = 90,909 shares
const poolData = await program.account.pool.fetch(pool);
expect(poolData.totalShares.toNumber()).to.be.closeTo(1_090_909, 10);
});Purpose: Verify NAV-based share pricing protects existing LPs
Scenario 3: Withdrawal (NAV-Based Assets)
typescript
it('withdrawals use NAV pricing', async () => {
// Setup: 1M USDC, 1M shares, NAV = 1.2M (pool profit)
const sharesToBurn = 100_000;
await withdrawPool(pool, sharesToBurn);
// Expected assets: floor(100k shares * 1.2M NAV / 1M shares) = 120k USDC
const userBalance = await getTokenBalance(userCollateralAta);
expect(userBalance).to.equal(120_000);
});Purpose: Verify withdrawals reflect NAV appreciation
Scenario 4: Floor Division Protection
typescript
it('floor division prevents pool drainage', async () => {
// Setup: NAV = 1M, total_shares = 1M
// Attempt to deposit 1 token (should mint 0 or 1 share)
await depositPool(pool, 1);
// Floor division ensures rounding favors pool
const poolData = await program.account.pool.fetch(pool);
const sharesIncrease = poolData.totalShares.toNumber() - 1_000_000;
expect(sharesIncrease).to.be.lte(1);
});Purpose: Prevent share manipulation attacks
Market Initialization Tests (init_market.ts)
Scenario 1: Valid Market Creation
typescript
it('creates market with valid parameters', async () => {
const params = {
ammSeed: 1,
maturity: seedTime + 86400 * 30, // 30 days
initialRate: toWad(0.05), // 5%
minAbsRate: toWad(0.01), // 1%
maxAbsRate: toWad(0.20), // 20%
};
await initMarket(pool, oracle, params);
const marketData = await program.account.market.fetch(market);
expect(marketData.maturity.toNumber()).to.equal(params.maturity);
});Scenario 2: Invalid Time Bounds
typescript
it('rejects maturity <= seed_time', async () => {
const params = {
maturity: seedTime - 1, // Invalid: before seed
// ... other params
};
try {
await initMarket(pool, oracle, params);
expect.fail('Should have rejected invalid maturity');
} catch (err) {
expect(err.message).to.include('InvalidMarketParams');
}
});Scenario 3: Invalid Rate Bounds
typescript
it('rejects initial_rate outside bounds', async () => {
const params = {
initialRate: toWad(0.25), // 25%
minAbsRate: toWad(0.01), // 1%
maxAbsRate: toWad(0.20), // 20% (initial > max!)
};
try {
await initMarket(pool, oracle, params);
expect.fail('Should have rejected out-of-bounds rate');
} catch (err) {
expect(err.message).to.include('InvalidMarketParams');
}
});Scenario 4: Single-Market Restriction (MVP)
typescript
it('rejects second market per pool', async () => {
// First market succeeds
await initMarket(pool, oracle, { ammSeed: 1, /* ... */ });
// Second market fails (MVP restriction)
try {
await initMarket(pool, oracle, { ammSeed: 2, /* ... */ });
expect.fail('Should enforce single-market per pool');
} catch (err) {
expect(err.message).to.include('SingleMarketRestriction');
}
});Liquidity Depth Tests (liquidity_depth.ts)
Scenario 1: Reserve Calculation
typescript
it('computes reserve correctly', async () => {
const nav = 1_000_000; // 1M USDC
const reserveBps = 2000; // 20%
// Expected reserve: ceil(1M * 2000 / 10000) = 200,000
const reserve = await computeReserve(nav, reserveBps);
expect(reserve).to.equal(200_000);
});Scenario 2: Available Liquidity
typescript
it('computes available liquidity', async () => {
const nav = 1_000_000;
const reserveBps = 1500; // 15%
// Reserve: 150k, Available: 850k
const available = await computeAvailableLiquidity(nav, reserveBps);
expect(available).to.equal(850_000);
});Scenario 3: Reserve >= NAV (Edge Case)
typescript
it('clamps available to zero if reserve >= NAV', async () => {
const nav = 100_000;
const reserveBps = 10000; // 100% reserve
const available = await computeAvailableLiquidity(nav, reserveBps);
expect(available).to.equal(0);
});Scenario 4: Liquidity Refresh
typescript
it('refreshes market liquidity from pool NAV', async () => {
// Initial NAV: 1M
await initPool(/* ... */);
await initMarket(/* ... */);
// Add more liquidity to pool
await depositPool(pool, 500_000); // NAV now 1.5M
// Refresh market
await refreshMarketLiquidity(market);
const marketData = await program.account.market.fetch(market);
// Cached liquidity should reflect new NAV (minus reserve)
expect(marketData.cachedLiquidityWad.toNumber()).to.be.closeTo(
toWad(1_500_000 * 0.8), // 80% of 1.5M (20% reserve)
toWad(1000)
);
});Liquidation Tests (liquidation.ts)
Scenario 1: Healthy Position (No Liquidation)
typescript
it('rejects liquidation of healthy position', async () => {
// Open position with ample margin
await depositMargin(margin, 50_000); // $50k collateral
await swap(margin, market, toWad(100_000)); // $100k notional, 5% IM = $5k
// Position is healthy (50k collateral >> 3k maintenance margin)
try {
await liquidate(margin, market);
expect.fail('Should not liquidate healthy position');
} catch (err) {
expect(err.message).to.include('PositionIsHealthy');
}
});Scenario 2: Unhealthy Position (Liquidation)
typescript
it('liquidates unhealthy position', async () => {
// Open position with minimal margin
await depositMargin(margin, 6_000); // $6k collateral
await swap(margin, market, toWad(100_000)); // $100k notional
// Simulate adverse rate move
await updateOracle(oracle, toWad(-0.05)); // Rates drop 5%
// MTM loss: (current -5% - entry 5%) * 100k * time_factor ≈ -$10k
// Health: $6k collateral - $10k MTM - $3k maintenance = -$7k (negative!)
const liquidatorBalanceBefore = await getBalance(liquidatorCollateralAta);
await liquidate(margin, market, liquidator);
const liquidatorBalanceAfter = await getBalance(liquidatorCollateralAta);
const reward = liquidatorBalanceAfter - liquidatorBalanceBefore;
// Liquidator receives penalty reward
expect(reward).to.be.gt(0);
// Position closed or reduced
const marginData = await program.account.margin.fetch(margin);
expect(marginData.positions[0].notionalWad.toNumber()).to.equal(0);
});Scenario 3: Funding Settlement Before Liquidation
typescript
it('settles funding before checking health', async () => {
// Position with funding exposure
await swap(margin, market, toWad(100_000)); // Pay fixed
// Simulate funding accrual (favorable)
await updateOracle(oracle, toWad(0.10)); // Rates rise (good for us)
// Funding delta should be applied before health check
await liquidate(margin, market);
const marginData = await program.account.margin.fetch(margin);
// Collateral increased by funding
expect(marginData.collateralWad.toNumber()).to.be.gt(initialCollateral);
});Oracle Tests (oracle.ts)
Scenario 1: Fresh Oracle (Accepted)
typescript
it('accepts fresh oracle data', async () => {
const maxStalenessSecs = 300; // 5 minutes
await initOracle(oracle, maxStalenessSecs);
// Update oracle
await updateOracle(oracle, toWad(0.05));
// Immediately use (fresh)
await swap(margin, market, toWad(10_000));
// Should succeed (no StaleOracle error)
});Scenario 2: Stale Oracle (Rejected)
typescript
it('rejects stale oracle data', async () => {
const maxStalenessSecs = 60; // 1 minute
await initOracle(oracle, maxStalenessSecs);
await updateOracle(oracle, toWad(0.05));
// Wait for staleness
await sleep(65_000); // 65 seconds
// Attempt swap with stale oracle
try {
await swap(margin, market, toWad(10_000));
expect.fail('Should reject stale oracle');
} catch (err) {
expect(err.message).to.include('StaleOracle');
}
});Scenario 3: Oracle Update Authority
typescript
it('only oracle authority can update', async () => {
await initOracle(oracle, 300);
// Attacker tries to update
try {
await updateOracle(oracle, toWad(0.99), attacker);
expect.fail('Should reject unauthorized update');
} catch (err) {
expect(err.message).to.include('ConstraintHasOne'); // Authority mismatch
}
});Testing Utilities
Helper Functions
typescript
// Convert to WAD (1e18)
function toWad(value: number): BN {
return new BN(value * 1e18);
}
// Get token account balance
async function getBalance(tokenAccount: PublicKey): Promise<number> {
const account = await getAccount(connection, tokenAccount);
return Number(account.amount);
}
// Sleep (for oracle staleness tests)
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Expect error
async function expectError(fn: () => Promise<any>, errorMsg: string) {
try {
await fn();
expect.fail('Should have thrown error');
} catch (err) {
expect(err.message).to.include(errorMsg);
}
}Test Coverage
Run with coverage (requires additional setup):
bash
# Generate coverage report
# (Solana program coverage requires additional tooling)Best Practices
- Test Edge Cases: Zero values, max values, boundary conditions
- Test Failure Paths: Ensure errors are thrown correctly
- Test Idempotency: Same input → same output
- Test Atomicity: Transactions either fully succeed or fully fail
- Clean State: Each test starts with fresh accounts
- Descriptive Names:
it('should reject stale oracle', ...) - Arrange-Act-Assert: Clear test structure
Next Steps
- Development Guide - Build and deploy
- Smart Contracts - Contract internals
- API Reference - Client API usage