Continuing from our previous post https://dev.to/syedghufranhassan/introducing-kodesherpa-build-defi-smart-contracts-with-ease-55oo I presented how kode sherpa ease dev's life by generating smart contract based on the prompts with the protocol level questions asked as prompts which generated smart contract based on all the possible scenarios. Now we will get to know that how we can generate the unit test of our smart contract.
Based on this, you can see that on right hand side, there is option for generate unit tests.
This will then generate tests and based on the contract, will generate the score accordingly.
The test tells that 9 out of 45 tests are failing so in the next post we will then analyze the below test generated by Kode Sherpa and then will modify the test and contract accordingly.
import hre from "hardhat";
import { expect } from "chai";
describe("FixedAprSingleTokenStakingPullPayments", function () {
// Ethers (Hardhat 3 network connect)
let ethers: any;
// Signers
let owner: any;
let alice: any;
let bob: any;
let carol: any;
let addrs: any[];
// Contracts
let staking: any;
let stakingToken: any;
// Common values (initialized after ethers is available)
let zeroAddress: any;
let aprBpsExpected: any;
let rateDenominatorExpected: any;
let yearExpected: any;
let stakeAmount: any;
let stakeAmount2: any;
let mintAmount: any;
before(async function () {
({ ethers } = await hre.network.connect());
zeroAddress = ethers.ZeroAddress;
aprBpsExpected = 500n;
rateDenominatorExpected = 10_000n;
yearExpected = 365n * 24n * 60n * 60n;
stakeAmount = ethers.parseUnits("1000", 18);
stakeAmount2 = ethers.parseUnits("250", 18);
mintAmount = ethers.parseUnits("1000000", 18);
});
async function increaseTime(seconds: any): Promise<void> {
await ethers.provider.send("evm_increaseTime", [seconds]);
await ethers.provider.send("evm_mine", []);
}
async function latestTimestamp(): Promise<any> {
const block = await ethers.provider.getBlock("latest");
return BigInt(block.timestamp);
}
async function deployFresh(): Promise<void> {
const MockERC20Factory = await ethers.getContractFactory("MockERC20");
stakingToken = await MockERC20Factory.deploy();
await stakingToken.waitForDeployment();
const StakingFactory = await ethers.getContractFactory("FixedAprSingleTokenStakingPullPayments");
staking = await StakingFactory.deploy(await stakingToken.getAddress());
await staking.waitForDeployment();
// Fund users
await stakingToken.mint(await owner.getAddress(), mintAmount);
await stakingToken.mint(await alice.getAddress(), mintAmount);
await stakingToken.mint(await bob.getAddress(), mintAmount);
await stakingToken.mint(await carol.getAddress(), mintAmount);
}
beforeEach(async function () {
[owner, alice, bob, carol, ...addrs] = await ethers.getSigners();
await deployFresh();
});
// ---------------------------------------------------------------------------
// Mandatory extraction (as comments, exact names/signatures)
// ---------------------------------------------------------------------------
// CONTRACT NAME: FixedAprSingleTokenStakingPullPayments
// PUBLIC STATE VARIABLES:
// - stakingToken() public view returns (address) [IERC20 public immutable stakingToken]
// - RATE_DENOMINATOR() public view returns (uint256) [uint256 public constant RATE_DENOMINATOR]
// - YEAR() public view returns (uint256) [uint256 public constant YEAR]
// - aprBps() public view returns (uint256) [uint256 public immutable aprBps]
// - stakedBalance(address) public view returns (uint256) [mapping(address => uint256) public stakedBalance]
// - owedBalance(address) public view returns (uint256) [mapping(address => uint256) public owedBalance]
// - lastAccrualTime(address) public view returns (uint256) [mapping(address => uint256) public lastAccrualTime]
// - totalStaked() public view returns (uint256) [uint256 public totalStaked]
// PUBLIC/EXTERNAL FUNCTIONS:
// - stake(uint256 amount) external
// - unstake(uint256 amount) external
// - withdraw() external
// - pendingRewards(address user) external view returns (uint256)
// - withdrawable(address user) external view returns (uint256)
// MAPPINGS:
// - stakedBalance (mapping(address => uint256))
// - owedBalance (mapping(address => uint256))
// - lastAccrualTime (mapping(address => uint256))
// STRUCTS: none
// EVENTS:
// - Staked(address indexed user, uint256 amount)
// - Unstaked(address indexed user, uint256 amount, uint256 creditedToOwed)
// - RewardsAccrued(address indexed user, uint256 reward, uint256 fromTime, uint256 toTime)
// - Withdrawn(address indexed user, uint256 amount)
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
describe("Constructor", function () {
it("sets stakingToken immutable correctly", async function () {
expect(await staking.stakingToken()).to.equal(await stakingToken.getAddress());
});
it("sets aprBps immutable to 500", async function () {
expect(await staking.aprBps()).to.equal(aprBpsExpected);
});
it("exposes constants RATE_DENOMINATOR and YEAR", async function () {
expect(await staking.RATE_DENOMINATOR()).to.equal(rateDenominatorExpected);
expect(await staking.YEAR()).to.equal(yearExpected);
});
it("initializes totalStaked to 0", async function () {
expect(await staking.totalStaked()).to.equal(0n);
});
it("reverts if staking token is zero address", async function () {
const StakingFactory = await ethers.getContractFactory("FixedAprSingleTokenStakingPullPayments");
await expect(StakingFactory.deploy(zeroAddress)).to.be.revertedWith("staking token is zero");
});
});
// ---------------------------------------------------------------------------
// stake(uint256)
// ---------------------------------------------------------------------------
describe("stake(uint256)", function () {
it("reverts when amount is 0", async function () {
await expect(staking.connect(alice).stake(0)).to.be.revertedWith("amount=0");
});
it("transfers tokens from user to contract and updates stakedBalance and totalStaked", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
const contractAddr = await staking.getAddress();
const aliceBefore = await stakingToken.balanceOf(await alice.getAddress());
const contractBefore = await stakingToken.balanceOf(contractAddr);
await expect(staking.connect(alice).stake(stakeAmount))
.to.emit(staking, "Staked")
.withArgs(await alice.getAddress(), stakeAmount);
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(stakeAmount);
expect(await staking.totalStaked()).to.equal(stakeAmount);
const aliceAfter = await stakingToken.balanceOf(await alice.getAddress());
const contractAfter = await stakingToken.balanceOf(contractAddr);
expect(aliceAfter).to.equal(aliceBefore - stakeAmount);
expect(contractAfter).to.equal(contractBefore + stakeAmount);
});
it("sets lastAccrualTime on first interaction (stake) and does not credit owedBalance immediately", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
const lastBefore = await staking.lastAccrualTime(await alice.getAddress());
expect(lastBefore).to.equal(0n);
const tsBefore = await latestTimestamp();
await staking.connect(alice).stake(stakeAmount);
const tsAfter = await latestTimestamp();
const lastAfter = await staking.lastAccrualTime(await alice.getAddress());
expect(lastAfter).to.be.gte(tsBefore);
expect(lastAfter).to.be.lte(tsAfter);
expect(await staking.owedBalance(await alice.getAddress())).to.equal(0n);
});
it("accrues rewards into owedBalance on subsequent stake and emits RewardsAccrued when reward > 0", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount + stakeAmount2);
// First stake initializes checkpoint
await staking.connect(alice).stake(stakeAmount);
const last1 = await staking.lastAccrualTime(await alice.getAddress());
expect(last1).to.not.equal(0n);
// Wait some time to generate rewards
await increaseTime(30 * 24 * 60 * 60); // 30 days
// Second stake should accrue rewards for the first principal
const tx = await staking.connect(alice).stake(stakeAmount2);
const receipt = await tx.wait();
// Verify state updates
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(stakeAmount + stakeAmount2);
expect(await staking.totalStaked()).to.equal(stakeAmount + stakeAmount2);
// Rewards should be credited to owedBalance (likely > 0 for 30 days at 5% APR on 1000 tokens)
const owed = await staking.owedBalance(await alice.getAddress());
expect(owed).to.be.gt(0n);
// Check RewardsAccrued event exists in receipt logs (don’t assume exact reward)
const parsed = receipt.logs
.map((l: any) => {
try {
return staking.interface.parseLog(l);
} catch {
return null;
}
})
.filter((x: any) => x && x.name === "RewardsAccrued");
expect(parsed.length).to.be.greaterThan(0);
const ev = parsed[0];
expect(ev.args.user).to.equal(await alice.getAddress());
expect(BigInt(ev.args.reward)).to.be.gt(0n);
expect(BigInt(ev.args.toTime)).to.be.gte(BigInt(ev.args.fromTime));
});
it("reverts if allowance is insufficient", async function () {
// Approve less than stakeAmount
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount - 1n);
await expect(staking.connect(alice).stake(stakeAmount)).to.revert(ethers);
});
it("reverts if balance is insufficient even with allowance", async function () {
const richAmount = ethers.parseUnits("999999999", 18);
await stakingToken.connect(alice).approve(await staking.getAddress(), richAmount);
await expect(staking.connect(alice).stake(richAmount)).to.revert(ethers);
});
it("supports multiple users staking and totalStaked equals sum", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await stakingToken.connect(bob).approve(await staking.getAddress(), stakeAmount2);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(bob).stake(stakeAmount2);
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(stakeAmount);
expect(await staking.stakedBalance(await bob.getAddress())).to.equal(stakeAmount2);
expect(await staking.totalStaked()).to.equal(stakeAmount + stakeAmount2);
});
});
// ---------------------------------------------------------------------------
// unstake(uint256)
// ---------------------------------------------------------------------------
describe("unstake(uint256)", function () {
beforeEach(async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
});
it("reverts when amount is 0", async function () {
await expect(staking.connect(alice).unstake(0)).to.be.revertedWith("amount=0");
});
it("reverts when user has insufficient staked", async function () {
await expect(staking.connect(alice).unstake(stakeAmount + 1n)).to.be.revertedWith("insufficient staked");
});
it("moves principal from stakedBalance to owedBalance and reduces totalStaked; emits Unstaked", async function () {
const unstakeAmt = stakeAmount2;
const owedBefore = await staking.owedBalance(await alice.getAddress());
const stakedBefore = await staking.stakedBalance(await alice.getAddress());
const totalBefore = await staking.totalStaked();
await expect(staking.connect(alice).unstake(unstakeAmt))
.to.emit(staking, "Unstaked")
.withArgs(await alice.getAddress(), unstakeAmt, unstakeAmt);
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(stakedBefore - unstakeAmt);
expect(await staking.totalStaked()).to.equal(totalBefore - unstakeAmt);
expect(await staking.owedBalance(await alice.getAddress())).to.equal(owedBefore + unstakeAmt);
});
it("does not transfer tokens to user on unstake (pull-payment pattern)", async function () {
const userAddr = await alice.getAddress();
const contractAddr = await staking.getAddress();
const aliceBalBefore = await stakingToken.balanceOf(userAddr);
const contractBalBefore = await stakingToken.balanceOf(contractAddr);
await staking.connect(alice).unstake(stakeAmount2);
const aliceBalAfter = await stakingToken.balanceOf(userAddr);
const contractBalAfter = await stakingToken.balanceOf(contractAddr);
expect(aliceBalAfter).to.equal(aliceBalBefore);
expect(contractBalAfter).to.equal(contractBalBefore);
});
it("accrues rewards before unstake and credits them to owedBalance (owed increases by > principal unstaked over time)", async function () {
await increaseTime(60 * 24 * 60 * 60); // 60 days
const owedBefore = await staking.owedBalance(await alice.getAddress());
await staking.connect(alice).unstake(stakeAmount2);
const owedAfter = await staking.owedBalance(await alice.getAddress());
// owedAfter should include principal unstaked + some rewards (not exact)
expect(owedAfter).to.be.gt(owedBefore + stakeAmount2);
});
it("can unstake full amount leaving stakedBalance 0 and totalStaked 0", async function () {
await staking.connect(alice).unstake(stakeAmount);
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(0n);
expect(await staking.totalStaked()).to.equal(0n);
expect(await staking.owedBalance(await alice.getAddress())).to.equal(stakeAmount);
});
it("updates lastAccrualTime on unstake (checkpoint moves forward)", async function () {
const lastBefore = await staking.lastAccrualTime(await alice.getAddress());
await increaseTime(7 * 24 * 60 * 60);
await staking.connect(alice).unstake(stakeAmount2);
const lastAfter = await staking.lastAccrualTime(await alice.getAddress());
expect(lastAfter).to.be.gt(lastBefore);
});
});
// ---------------------------------------------------------------------------
// withdraw()
// ---------------------------------------------------------------------------
describe("withdraw()", function () {
it("reverts when nothing owed (never staked)", async function () {
await expect(staking.connect(alice).withdraw()).to.be.revertedWith("nothing owed");
});
it("reverts when user has staked but has no owedBalance and no rewards accrued yet (immediate withdraw)", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
// Immediately withdraw: _accrue sees last != 0 and elapsed may be 0 => no reward; owed still 0
await expect(staking.connect(alice).withdraw()).to.be.revertedWith("nothing owed");
});
it("allows withdrawing unstaked principal; sets owedBalance to 0; emits Withdrawn", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
const owed = await staking.owedBalance(await alice.getAddress());
expect(owed).to.equal(stakeAmount2);
const aliceBalBefore = await stakingToken.balanceOf(await alice.getAddress());
await expect(staking.connect(alice).withdraw())
.to.emit(staking, "Withdrawn")
.withArgs(await alice.getAddress(), owed);
const aliceBalAfter = await stakingToken.balanceOf(await alice.getAddress());
expect(aliceBalAfter).to.equal(aliceBalBefore + owed);
expect(await staking.owedBalance(await alice.getAddress())).to.equal(0n);
});
it("allows withdrawing accrued rewards without unstaking (after time passes)", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(90 * 24 * 60 * 60); // 90 days
// withdraw() accrues then transfers owedBalance (rewards only, since no unstake)
const owedBefore = await staking.owedBalance(await alice.getAddress());
expect(owedBefore).to.equal(0n);
const aliceBalBefore = await stakingToken.balanceOf(await alice.getAddress());
const tx = await staking.connect(alice).withdraw();
const receipt = await tx.wait();
// Find Withdrawn amount from event
const parsed = receipt.logs
.map((l: any) => {
try {
return staking.interface.parseLog(l);
} catch {
return null;
}
})
.filter((x: any) => x && x.name === "Withdrawn");
expect(parsed.length).to.equal(1);
const withdrawnAmount = BigInt(parsed[0].args.amount);
expect(withdrawnAmount).to.be.gt(0n);
const aliceBalAfter = await stakingToken.balanceOf(await alice.getAddress());
expect(aliceBalAfter).to.equal(aliceBalBefore + withdrawnAmount);
expect(await staking.owedBalance(await alice.getAddress())).to.equal(0n);
});
it("emits RewardsAccrued during withdraw when rewards > 0", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(30 * 24 * 60 * 60);
const tx = await staking.connect(alice).withdraw();
const receipt = await tx.wait();
const accrued = receipt.logs
.map((l: any) => {
try {
return staking.interface.parseLog(l);
} catch {
return null;
}
})
.filter((x: any) => x && x.name === "RewardsAccrued");
expect(accrued.length).to.be.greaterThan(0);
expect(accrued[0].args.user).to.equal(await alice.getAddress());
expect(BigInt(accrued[0].args.reward)).to.be.gt(0n);
});
it("reverts if contract does not have enough tokens to pay owed (simulate by draining contract)", async function () {
// Stake from Alice so contract holds principal
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
// Unstake some principal to create owed
await staking.connect(alice).unstake(stakeAmount2);
// Drain contract tokens by transferring away from contract using owner? Can't directly.
// Instead: have owner stake then withdraw? Not possible without owed.
// Practical simulation: move contract balance out by using the token itself if it allows arbitrary transferFrom.
// MockERC20 typically is standard ERC20; contract cannot approve. So we simulate shortage by accruing rewards
// larger than contract balance: mint nothing extra and withdraw after long time.
// We can reduce contract balance by having owner stake and then unstake+withdraw principal to remove tokens,
// leaving only Alice's owed but insufficient for rewards. However owner can't withdraw without owed.
// We'll do: owner stakes big, waits, unstakes all, withdraws (takes tokens out), leaving contract low.
const big = ethers.parseUnits("500000", 18);
await stakingToken.connect(owner).approve(await staking.getAddress(), big);
await staking.connect(owner).stake(big);
// Let both accrue a lot so rewards are non-trivial
await increaseTime(365 * 24 * 60 * 60); // 1 year
// Owner unstakes all and withdraws (pulls out principal+rewards), draining contract
await staking.connect(owner).unstake(big);
await staking.connect(owner).withdraw();
// Now Alice tries to withdraw; may revert if contract balance insufficient
await increaseTime(365 * 24 * 60 * 60); // another year to increase owed via rewards
await expect(staking.connect(alice).withdraw()).to.revert(ethers);
});
it("gas usage sanity: withdraw should not exceed a reasonable bound in a simple case", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
const tx = await staking.connect(alice).withdraw();
const receipt = await tx.wait();
expect(Number(receipt.gasUsed)).to.be.lessThan(250000);
});
});
// ---------------------------------------------------------------------------
// pendingRewards(address) view
// ---------------------------------------------------------------------------
describe("pendingRewards(address)", function () {
it("returns 0 when principal is 0 (never staked)", async function () {
expect(await staking.pendingRewards(await alice.getAddress())).to.equal(0n);
});
it("returns 0 when lastAccrualTime is 0 even if stakedBalance would be > 0 (not possible externally, but validate current behavior)", async function () {
// Normal flow: stake sets lastAccrualTime. So for a fresh user it's 0 and stakedBalance is 0.
expect(await staking.lastAccrualTime(await alice.getAddress())).to.equal(0n);
expect(await staking.pendingRewards(await alice.getAddress())).to.equal(0n);
});
it("returns 0 immediately after first stake because lastAccrualTime was just initialized (or elapsed=0)", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
const pending = await staking.pendingRewards(await alice.getAddress());
expect(pending).to.equal(0n);
});
it("increases over time for a staker after at least one checkpoint exists", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount + stakeAmount2);
// First stake initializes lastAccrualTime
await staking.connect(alice).stake(stakeAmount);
// Second interaction creates a checkpoint with elapsed > 0, moving lastAccrualTime forward
await increaseTime(10 * 24 * 60 * 60);
await staking.connect(alice).stake(stakeAmount2);
// Now pendingRewards should grow with time
await increaseTime(5 * 24 * 60 * 60);
const p1 = await staking.pendingRewards(await alice.getAddress());
await increaseTime(5 * 24 * 60 * 60);
const p2 = await staking.pendingRewards(await alice.getAddress());
expect(p1).to.be.gt(0n);
expect(p2).to.be.gt(p1);
});
it("returns 0 when elapsed is 0 (same block) after a checkpoint update", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount + stakeAmount2);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(2 * 24 * 60 * 60);
await staking.connect(alice).stake(stakeAmount2);
// Immediately query in same block (no time increase)
const pending = await staking.pendingRewards(await alice.getAddress());
expect(pending).to.equal(0n);
});
});
// ---------------------------------------------------------------------------
// withdrawable(address) view
// ---------------------------------------------------------------------------
describe("withdrawable(address)", function () {
it("returns 0 for a fresh user", async function () {
expect(await staking.withdrawable(await alice.getAddress())).to.equal(0n);
});
it("returns principal moved to owedBalance after unstake", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
expect(await staking.withdrawable(await alice.getAddress())).to.equal(stakeAmount2);
});
it("does not include pending rewards until a state-changing action accrues them", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(60 * 24 * 60 * 60);
const pending = await staking.pendingRewards(await alice.getAddress());
expect(pending).to.be.gt(0n);
// withdrawable should still be 0 because rewards not yet credited
expect(await staking.withdrawable(await alice.getAddress())).to.equal(0n);
});
it("increases after an action that triggers _accrue (e.g., unstake) without withdrawing", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(120 * 24 * 60 * 60);
const withdrawableBefore = await staking.withdrawable(await alice.getAddress());
expect(withdrawableBefore).to.equal(0n);
await staking.connect(alice).unstake(stakeAmount2);
const withdrawableAfter = await staking.withdrawable(await alice.getAddress());
// Should be > principal unstaked due to rewards accrued
expect(withdrawableAfter).to.be.gt(stakeAmount2);
});
it("resets to 0 after withdraw", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
expect(await staking.withdrawable(await alice.getAddress())).to.equal(stakeAmount2);
await staking.connect(alice).withdraw();
expect(await staking.withdrawable(await alice.getAddress())).to.equal(0n);
});
});
// ---------------------------------------------------------------------------
// Events (direct checks beyond function-level .emit)
// ---------------------------------------------------------------------------
describe("Events", function () {
it("emits Staked with correct args", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await expect(staking.connect(alice).stake(stakeAmount))
.to.emit(staking, "Staked")
.withArgs(await alice.getAddress(), stakeAmount);
});
it("emits Unstaked with creditedToOwed equal to amount", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await expect(staking.connect(alice).unstake(stakeAmount2))
.to.emit(staking, "Unstaked")
.withArgs(await alice.getAddress(), stakeAmount2, stakeAmount2);
});
it("emits Withdrawn with amount equal to owedBalance at time of withdrawal", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
const owed = await staking.owedBalance(await alice.getAddress());
await expect(staking.connect(alice).withdraw())
.to.emit(staking, "Withdrawn")
.withArgs(await alice.getAddress(), owed);
});
});
// ---------------------------------------------------------------------------
// Security: ReentrancyGuard
// ---------------------------------------------------------------------------
describe("Security / ReentrancyGuard", function () {
it("prevents reentrancy on stake via nonReentrant (cannot be directly exploited here)", async function () {
// We can't easily craft a reentrant ERC20 callback with a plain ERC20.
// But we can at least assert the function exists and behaves normally under standard token.
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(stakeAmount);
});
it("prevents reentrancy on withdraw in presence of malicious token (not available with MockERC20) - sanity check", async function () {
// Same limitation as above; ensure withdraw is nonReentrant by exercising it successfully.
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(alice).unstake(stakeAmount2);
await staking.connect(alice).withdraw();
expect(await staking.owedBalance(await alice.getAddress())).to.equal(0n);
});
});
// ---------------------------------------------------------------------------
// Integration / multi-user / state transitions
// ---------------------------------------------------------------------------
describe("Integration scenarios", function () {
it("multi-user: rewards accrue independently and do not affect each other's balances", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await stakingToken.connect(bob).approve(await staking.getAddress(), stakeAmount2);
await staking.connect(alice).stake(stakeAmount);
await staking.connect(bob).stake(stakeAmount2);
await increaseTime(45 * 24 * 60 * 60);
// Trigger accrual for Alice only via unstake
await staking.connect(alice).unstake(stakeAmount2);
const aliceOwed = await staking.owedBalance(await alice.getAddress());
const bobOwed = await staking.owedBalance(await bob.getAddress());
expect(aliceOwed).to.be.gt(stakeAmount2); // principal + some rewards
expect(bobOwed).to.equal(0n); // bob hasn't interacted since stake; rewards not credited
});
it("state transitions: stake -> time -> unstake -> time -> withdraw withdraws principal+rewards and leaves staked intact for remaining principal", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(30 * 24 * 60 * 60);
await staking.connect(alice).unstake(stakeAmount2);
const remaining = stakeAmount - stakeAmount2;
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(remaining);
await increaseTime(30 * 24 * 60 * 60);
const aliceBalBefore = await stakingToken.balanceOf(await alice.getAddress());
const tx = await staking.connect(alice).withdraw();
const receipt = await tx.wait();
const parsed = receipt.logs
.map((l: any) => {
try {
return staking.interface.parseLog(l);
} catch {
return null;
}
})
.filter((x: any) => x && x.name === "Withdrawn");
expect(parsed.length).to.equal(1);
const withdrawnAmount = BigInt(parsed[0].args.amount);
expect(withdrawnAmount).to.be.gt(stakeAmount2); // includes some rewards
const aliceBalAfter = await stakingToken.balanceOf(await alice.getAddress());
expect(aliceBalAfter).to.equal(aliceBalBefore + withdrawnAmount);
// Remaining principal still staked and earning
expect(await staking.stakedBalance(await alice.getAddress())).to.equal(remaining);
expect(await staking.totalStaked()).to.equal(remaining);
});
it("checkpoint behavior: if principal becomes 0, later interactions advance lastAccrualTime but do not accrue rewards", async function () {
await stakingToken.connect(alice).approve(await staking.getAddress(), stakeAmount);
await staking.connect(alice).stake(stakeAmount);
await increaseTime(10 * 24 * 60 * 60);
await staking.connect(alice).unstake(stakeAmount); // principal now 0
const lastAfterUnstake = await staking.lastAccrualTime(await alice.getAddress());
expect(lastAfterUnstake).to.not.equal(0n);
// Wait and then withdraw (will call _accrue with principal==0 => just advances checkpoint, no new rewards)
await increaseTime(10 * 24 * 60 * 60);
const owedBefore = await staking.owedBalance(await alice.getAddress());
const tx = await staking.connect(alice).withdraw();
await tx.wait();
// owed should be 0 after withdraw
expect(await staking.owedBalance(await alice.getAddress())).to.equal(0n);
// lastAccrualTime should have advanced on withdraw even though principal is 0
const lastAfterWithdraw = await staking.lastAccrualTime(await alice.getAddress());
expect(lastAfterWithdraw).to.be.gt(lastAfterUnstake);
// Rewards should not have increased owed beyond what was already owed before withdraw
// (withdraw transfers owedBefore; we can't directly see amount now, but we can sanity check no revert)
expect(owedBefore).to.be.gte(stakeAmount);
});
});
});


Top comments (0)