Skip to content

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 test

Specific 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 -- --nocapture

Watch Mode (for development)

bash
# In one terminal: start validator
solana-test-validator

# In another: run tests with --skip-local-validator
anchor test --skip-local-validator

Test 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

  1. Test Edge Cases: Zero values, max values, boundary conditions
  2. Test Failure Paths: Ensure errors are thrown correctly
  3. Test Idempotency: Same input → same output
  4. Test Atomicity: Transactions either fully succeed or fully fail
  5. Clean State: Each test starts with fresh accounts
  6. Descriptive Names: it('should reject stale oracle', ...)
  7. Arrange-Act-Assert: Clear test structure

Next Steps

Released under the ISC License.