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
76 changes: 44 additions & 32 deletions contracts/IDOSNodeStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ pragma solidity ^0.8.27;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Arrays.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
import "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
Expand Down Expand Up @@ -104,11 +102,11 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
function stake(address user, address node, uint256 amount)
external nonReentrant whenNotPaused
{
require(node != address(0), ZeroAddressNode());
require(allowlistedNodes.contains(node), NodeIsNotAllowed(node));
require(!slashedNodes.contains(node), NodeIsSlashed(node));
require(amount > 0, AmountNotPositive(amount));
require(block.timestamp >= startTime, NotStarted());
if (node == address(0)) revert ZeroAddressNode();
if (!allowlistedNodes.contains(node)) revert NodeIsNotAllowed(node);
if (slashedNodes.contains(node)) revert NodeIsSlashed(node);
if (amount == 0) revert AmountNotPositive(amount);
if (block.timestamp < startTime) revert NotStarted();

if (user == address(0)) user = msg.sender;

Expand Down Expand Up @@ -136,14 +134,14 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
function unstake(address node, uint256 amount)
external nonReentrant whenNotPaused
{
require(node != address(0), ZeroAddressNode());
require(!slashedNodes.contains(node), NodeIsSlashed(node));
require(amount > 0, AmountNotPositive(amount));
require(block.timestamp >= startTime, NotStarted());
if (node == address(0)) revert ZeroAddressNode();
if (slashedNodes.contains(node)) revert NodeIsSlashed(node);
if (amount == 0) revert AmountNotPositive(amount);
if (block.timestamp < startTime) revert NotStarted();

uint256 currentStake = stakeByNodeByUser[msg.sender][node];

require(amount <= currentStake, AmountExceedsStake(amount, currentStake));
if (amount > currentStake) revert AmountExceedsStake(amount, currentStake);

stakeByNodeByUser[msg.sender][node] -= amount;
unstakeByUserByEpoch[currentEpoch()][msg.sender] += amount;
Expand Down Expand Up @@ -175,14 +173,15 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
external nonReentrant whenNotPaused
returns (uint256 withdrawableAmount)
{
withdrawableAmount;
for (uint i; i < unstakesByUser[msg.sender].length; i++)
for (uint i; i < unstakesByUser[msg.sender].length;) {
if (unstakesByUser[msg.sender][i].timestamp < uint48(block.timestamp) - UNSTAKE_DELAY) {
withdrawableAmount += unstakesByUser[msg.sender][i].amount;
delete unstakesByUser[msg.sender][i];
}
unchecked { ++i; }
}

require(withdrawableAmount > 0, NoWithdrawableStake());
if (withdrawableAmount == 0) revert NoWithdrawableStake();

idosToken.safeTransfer(msg.sender, withdrawableAmount);

Expand All @@ -192,9 +191,9 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
function slash(address node)
external onlyOwner nonReentrant whenNotPaused
{
require(node != address(0), ZeroAddressNode());
require(stakeByNode.contains(node), NodeIsUnknown(node));
require(!slashedNodes.contains(node), NodeIsSlashed(node));
if (node == address(0)) revert ZeroAddressNode();
if (!stakeByNode.contains(node)) revert NodeIsUnknown(node);
if (slashedNodes.contains(node)) revert NodeIsSlashed(node);

slashedNodes.add(node);
slashesByEpoch[currentEpoch()].add(node);
Expand All @@ -208,11 +207,13 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
NodeStake[] memory slashedStakes = getSlashedNodeStakes();

uint256 amount;
for (uint i; i < slashedStakes.length; i++)
for (uint i; i < slashedStakes.length;) {
amount += slashedStakes[i].stake;
unchecked { ++i; }
}

amount -= slashedStakeWithdrawn;
require(amount > 0, NoWithdrawableSlashedStakes());
if (amount == 0) revert NoWithdrawableSlashedStakes();

slashedStakeWithdrawn += amount;

Expand All @@ -231,7 +232,8 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
userStakeAcc = checkpoint.userStakeAcc;
totalStakeAcc = checkpoint.totalStakeAcc;

for (uint48 i = checkpoint.epoch; i < currentEpoch(); i++) {
for (uint48 i = checkpoint.epoch; i < currentEpoch();) {
// Get the reward for this specific epoch by looking up what was active at that time
uint256 epochReward = epochRewardHistory.upperLookup(i);

userStakeAcc += stakeByUserByEpoch[i][user];
Expand All @@ -241,13 +243,16 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
totalStakeAcc -= unstakedByEpoch[i];

address[] memory slashedNodesThisEpoch = slashesByEpoch[i].values();
for (uint j; j < slashedNodesThisEpoch.length; j++) {
for (uint j; j < slashedNodesThisEpoch.length;) {
userStakeAcc -= stakeByNodeByUser[user][slashedNodesThisEpoch[j]];
totalStakeAcc -= stakeByNode.get(slashedNodesThisEpoch[j]);
unchecked { ++j; }
}

if (totalStakeAcc == 0) continue;
rewardAcc += (userStakeAcc * epochReward) / totalStakeAcc;
if (totalStakeAcc > 0) {
rewardAcc += (userStakeAcc * epochReward) / totalStakeAcc;
}
unchecked { ++i; }
}

(, uint256 withdrawnAlready) = rewardWithdrawalsByUser.tryGet(user);
Expand Down Expand Up @@ -279,7 +284,7 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
/// @param newReward The new reward value
function setEpochReward(uint256 newReward) external onlyOwner {
uint256 prevReward = epochRewardHistory.latest();
require(newReward != prevReward, EpochRewardDidntChange());
if (newReward == prevReward) revert EpochRewardDidntChange();

epochRewardHistory.push(currentEpoch(), newReward);
emit EpochRewardChanged(currentEpoch(), prevReward, newReward);
Expand All @@ -291,7 +296,7 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
{
withdrawableAmount = createEpochCheckpoint(msg.sender);

require(withdrawableAmount > 0, NoWithdrawableRewards());
if (withdrawableAmount == 0) revert NoWithdrawableRewards();

(,uint256 prev) = rewardWithdrawalsByUser.tryGet(msg.sender);
rewardWithdrawalsByUser.set(msg.sender, prev + withdrawableAmount);
Expand All @@ -314,8 +319,10 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
{
(, uint256 totalStake) = stakeByUser.tryGet(user);

for (uint i; i < slashedNodes.length(); i++)
for (uint i; i < slashedNodes.length();) {
slashedStake += stakeByNodeByUser[user][slashedNodes.at(i)];
unchecked { ++i; }
}

activeStake = totalStake - slashedStake;
}
Expand All @@ -327,10 +334,13 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
unslashedNodeStakes = new NodeStake[](stakeByNode.length() - slashedNodes.length());

uint returnIndex;
for (uint j; j < stakeByNode.length() && returnIndex < unslashedNodeStakes.length; j++) {
for (uint j; j < stakeByNode.length() && returnIndex < unslashedNodeStakes.length;) {
(address node, uint256 stake_) = stakeByNode.at(j);
if (slashedNodes.contains(node)) continue;
unslashedNodeStakes[returnIndex++] = NodeStake(node, stake_);
if (!slashedNodes.contains(node)) {
unslashedNodeStakes[returnIndex] = NodeStake(node, stake_);
unchecked { ++returnIndex; }
}
unchecked { ++j; }
}
}

Expand All @@ -340,15 +350,17 @@ contract IDOSNodeStaking is ReentrancyGuard, Pausable, Ownable {
{
nodeStakes = new NodeStake[](slashedNodes.length());

for (uint i; i < slashedNodes.length(); i++)
for (uint i; i < slashedNodes.length();) {
nodeStakes[i] = NodeStake(slashedNodes.at(i), stakeByNode.get(slashedNodes.at(i)));
unchecked { ++i; }
}
}

function currentEpoch()
public view
returns (uint48 epoch)
{
require(block.timestamp >= startTime, NotStarted());
if (block.timestamp < startTime) revert NotStarted();

epoch = uint48((uint48(block.timestamp) - startTime) / EPOCH_LENGTH);
}
Expand Down
2 changes: 1 addition & 1 deletion contracts/IDOSVesting.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;
pragma solidity ^0.8.27;

import {VestingWallet} from "@openzeppelin/contracts/finance/VestingWallet.sol";
import {VestingWalletCliff} from "@openzeppelin/contracts/finance/VestingWalletCliff.sol";
Expand Down
159 changes: 159 additions & 0 deletions test/GasBenchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { expect } from "chai";
import type { Signer } from "ethers";
import { network } from "hardhat";
import type { IDOSNodeStaking, IDOSToken } from "../types/ethers-contracts/index.js";
import { Duration, evmTimestamp } from "./utils/time.js";

const { ethers, networkHelpers } = await network.connect();

type SignerWithAddress = Signer & { address: string };

describe("Gas Benchmark", () => {
let idosToken: IDOSToken;
let idosStaking: IDOSNodeStaking;
let owner: SignerWithAddress;
let user1: SignerWithAddress;
let user2: SignerWithAddress;
let node1: SignerWithAddress;
let node2: SignerWithAddress;

const setup = async () => {
const accounts = await ethers.getSigners();
[owner, user1, user2, node1, node2] = accounts as SignerWithAddress[];

idosToken = await ethers.deployContract("IDOSToken", [owner]) as unknown as IDOSToken;
idosStaking = await ethers.deployContract("IDOSNodeStaking", [
await idosToken.getAddress(),
owner,
evmTimestamp(2026, 11),
100
]) as unknown as IDOSNodeStaking;

await idosToken.transfer(idosStaking, 10000);
await idosToken.transfer(user1, 10000);
await idosToken.transfer(user2, 10000);

const idosStakingAddress = await idosStaking.getAddress();
await idosToken.connect(user1).approve(idosStakingAddress, 10000);
await idosToken.connect(user2).approve(idosStakingAddress, 10000);

await networkHelpers.time.increaseTo(evmTimestamp(2026, 11));
await idosStaking.allowNode(node1);
await idosStaking.allowNode(node2);

return { idosToken, idosStaking, owner, user1, user2, node1, node2 };
};

before(async () => {
({ idosToken, idosStaking, owner, user1, user2, node1, node2 } = await networkHelpers.loadFixture(setup));
});

describe("Gas Cost Measurements", () => {
it("Should measure stake() gas cost", async () => {
const tx = await idosStaking.connect(user1).stake(ethers.ZeroAddress, node1.address, 100);
const receipt = await tx.wait();

console.log(`\n ⛽ stake() gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(350000);
});

it("Should measure unstake() gas cost", async () => {
await idosStaking.connect(user1).stake(ethers.ZeroAddress, node1.address, 100);

const tx = await idosStaking.connect(user1).unstake(node1.address, 50);
const receipt = await tx.wait();

console.log(` ⛽ unstake() gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(200000);
});

it("Should measure withdrawReward() gas cost (1 epoch)", async () => {
await idosStaking.connect(user2).stake(ethers.ZeroAddress, node2.address, 100);
await networkHelpers.time.increase(Duration.days(1));

const tx = await idosStaking.connect(user2).withdrawReward();
const receipt = await tx.wait();

console.log(` ⛽ withdrawReward(1 epoch) gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(260000);
});

it("Should measure withdrawReward() gas cost (10 epochs)", async () => {
await idosStaking.connect(user1).stake(ethers.ZeroAddress, node1.address, 100);
await networkHelpers.time.increase(Duration.days(10));

const tx = await idosStaking.connect(user1).withdrawReward();
const receipt = await tx.wait();

console.log(` ⛽ withdrawReward(10 epochs) gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(310000);
});

it("Should measure withdrawReward() gas cost (30 epochs)", async () => {
await idosStaking.connect(user2).stake(ethers.ZeroAddress, node2.address, 500);
await networkHelpers.time.increase(Duration.days(30));

const tx = await idosStaking.connect(user2).withdrawReward();
const receipt = await tx.wait();

console.log(` ⛽ withdrawReward(30 epochs) gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(550000);
});

it("Should measure withdrawUnstaked() gas cost (single unstake)", async () => {
await idosStaking.connect(user1).stake(ethers.ZeroAddress, node1.address, 200);
await idosStaking.connect(user1).unstake(node1.address, 100);
await networkHelpers.time.increase(Duration.days(14));

const tx = await idosStaking.connect(user1).withdrawUnstaked();
const receipt = await tx.wait();

console.log(` ⛽ withdrawUnstaked(1 unstake) gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(100000);
});

it("Should measure withdrawUnstaked() gas cost (5 unstakes)", async () => {
await idosStaking.connect(user2).stake(ethers.ZeroAddress, node2.address, 1000);

for (let i = 0; i < 5; i++) {
await idosStaking.connect(user2).unstake(node2.address, 50);
await networkHelpers.time.increase(Duration.days(1));
}

await networkHelpers.time.increase(Duration.days(14));

const tx = await idosStaking.connect(user2).withdrawUnstaked();
const receipt = await tx.wait();

console.log(` ⛽ withdrawUnstaked(5 unstakes) gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(150000);
});

it("Should measure slash() gas cost", async () => {
await idosStaking.allowNode(user1.address);
await idosStaking.connect(user2).stake(ethers.ZeroAddress, user1.address, 100);

const tx = await idosStaking.slash(user1.address);
const receipt = await tx.wait();

console.log(` ⛽ slash() gas: ${receipt?.gasUsed.toString()}`);
expect(receipt?.gasUsed).to.be.lt(180000);
});
});

describe("Optimization Impact Analysis", () => {
it("Should confirm unchecked loop optimization reduces gas", async () => {
await idosStaking.connect(user1).stake(ethers.ZeroAddress, node1.address, 500);
await networkHelpers.time.increase(Duration.days(20));

const tx = await idosStaking.connect(user1).withdrawReward();
const receipt = await tx.wait();

const gasPerEpoch = Number(receipt?.gasUsed) / 20;
console.log(`\n 📊 Gas per epoch (with unchecked): ${gasPerEpoch.toFixed(0)}`);
console.log(` 💰 Est. savings vs checked: ~${(gasPerEpoch * 0.1).toFixed(0)} gas/epoch`);

expect(gasPerEpoch).to.be.lt(20000);
});
});
});
Loading