Skip to content
Closed
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
95 changes: 95 additions & 0 deletions contracts/finance/VestingWalletFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.6.0) (finance/VestingWallet.sol)

pragma solidity ^0.8.20;

import {IERC20} from "../token/ERC20/IERC20.sol";
import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "../access/Ownable.sol";

contract VestingWalletFactory is Ownable {
struct VestingSchedule {
address beneficiary;
address token;
uint64 start;
uint64 duration;
uint256 totalAllocation;
uint256 released;
}

mapping(uint256 scheduleId => VestingSchedule) private _schedules;
uint256 private _scheduleCount;

constructor(address owner) Ownable(owner) {}

event VestingScheduleCreated(uint256 indexed scheduleId, address indexed beneficiary, address indexed token, uint64 start, uint64 duration, uint256 amount);
event ERC20Released(uint256 indexed scheduleId, address indexed token, uint256 amount);

function createVestingSchedule(
address beneficiary,
address token,
uint64 startTimestamp,
uint64 durationSeconds,
uint256 amount
) external onlyOwner returns (uint256 scheduleId) {
require(beneficiary != address(0), "VestingWalletFactory: beneficiary is zero address");
require(amount > 0, "VestingWalletFactory: amount is zero");
require(durationSeconds > 0, "VestingWalletFactory: duration is zero");

scheduleId = _scheduleCount;

_schedules[scheduleId] = VestingSchedule({
beneficiary: beneficiary,
token: token,
start: startTimestamp,
duration: durationSeconds,
totalAllocation: amount,
released: 0
});

_scheduleCount++;

SafeERC20.safeTransferFrom(IERC20(token), msg.sender, address(this), amount);

emit VestingScheduleCreated(scheduleId, beneficiary, token, startTimestamp, durationSeconds, amount);
}

function vestedAmount(uint256 scheduleId, uint64 timestamp) public view returns (uint256) {
VestingSchedule storage schedule = _schedules[scheduleId];
return _vestingSchedule(schedule.totalAllocation, schedule.start, schedule.duration, timestamp);
}

function releasable(uint256 scheduleId) public view returns (uint256) {
VestingSchedule storage schedule = _schedules[scheduleId];
return vestedAmount(scheduleId, uint64(block.timestamp)) - schedule.released;
}

function getSchedule(uint256 scheduleId) external view returns (VestingSchedule memory) {
return _schedules[scheduleId];
}

function scheduleCount() external view returns (uint256) {
return _scheduleCount;
}

function release(uint256 scheduleId) external {
VestingSchedule storage schedule = _schedules[scheduleId];
uint256 amount = releasable(scheduleId);
schedule.released += amount;
SafeERC20.safeTransfer(IERC20(schedule.token), schedule.beneficiary, amount);
emit ERC20Released(scheduleId, schedule.token, amount);
}

function _vestingSchedule(uint256 totalAllocation, uint64 start, uint64 duration, uint64 timestamp) internal pure virtual returns (uint256) {
uint64 end = start + duration;
if (timestamp < start) {
return 0;
}
else if (timestamp >= end) {
return totalAllocation;
}
else {
return (totalAllocation * (timestamp - start)) / duration;
}
}
}
132 changes: 132 additions & 0 deletions scripts/demoFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Local demo: VestingWalletFactory end-to-end walkthrough.
//
// Usage:
// npx hardhat run scripts/demoFactory.js

const { ethers } = require('hardhat');

const DECIMALS = 18n;
const UNIT = 10n ** DECIMALS;
const fmt = n => (Number(n) / Number(UNIT)).toFixed(2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the actual file and surrounding context
cat scripts/demoFactory.js

Repository: OpenZeppelin/openzeppelin-contracts

Length of output: 5689


🏁 Script executed:

# Verify the precision loss concern by testing Number conversion behavior
node <<'EOF'
// Test the precision loss with the actual values from the demo
const DECIMALS = 18n;
const UNIT = 10n ** DECIMALS;

const values = [
  { name: "totalSupply (3000 tokens)", value: 3000n * UNIT },
  { name: "Alice (1000 tokens)", value: 1000n * UNIT },
  { name: "Bob (500 tokens)", value: 500n * UNIT },
  { name: "Carol (1500 tokens)", value: 1500n * UNIT },
];

const SAFE_INTEGER_LIMIT = 2n ** 53n - 1n;

console.log(`Safe integer limit: ${SAFE_INTEGER_LIMIT}`);
console.log(`That's: ${Number(SAFE_INTEGER_LIMIT).toExponential()}\n`);

values.forEach(({ name, value }) => {
  const exceedsLimit = value > SAFE_INTEGER_LIMIT;
  const current = (Number(value) / Number(UNIT)).toFixed(2);
  const safe = Number("1000"); // example of what ethers.formatUnits would yield
  
  console.log(`${name}:`);
  console.log(`  Value: ${value.toString()} (exceeds safe limit: ${exceedsLimit})`);
  console.log(`  Current approach: ${current}`);
});

console.log(`\nConclusion: Values EXCEED JavaScript's safe integer limit (2^53 - 1)`);
EOF

Repository: OpenZeppelin/openzeppelin-contracts

Length of output: 650


Use ethers.formatUnits() instead of Number(BigInt) for precise token formatting.

The values in this demo exceed JavaScript's safe integer limit (2^53 - 1), making direct BigInt-to-Number conversion unsafe even though the output happens to appear correct. ethers.formatUnits(n, 18) handles this safely and is the idiomatic approach for ethers.js v6.

Proposed fix
-const fmt       = n => (Number(n) / Number(UNIT)).toFixed(2);
+const fmt       = n => Number(ethers.formatUnits(n, 18)).toFixed(2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/demoFactory.js` at line 10, The current formatter fmt uses
Number/BigInt which is unsafe for values > Number.MAX_SAFE_INTEGER; change fmt
to use ethers.formatUnits so large token amounts are accurately formatted (e.g.,
call ethers.formatUnits(n, 18) or ethers.formatUnits(n, DECIMALS) if
DECIMALS/UNIT is defined) and ensure ethers is imported; accept
BigInt/string/BigNumber inputs and pass them to ethers.formatUnits rather than
converting to Number.

const fmtAddr = a => a.slice(0, 6) + '...' + a.slice(-4);

async function printSchedule(factory, token, id, label) {
const s = await factory.getSchedule(id);
const releasable = await factory.releasable(id);
const balance = await token.balanceOf(s.beneficiary);
console.log(` [Schedule ${id}] ${label}`);
console.log(` beneficiary : ${fmtAddr(s.beneficiary)}`);
console.log(` allocation : ${fmt(s.totalAllocation)} tokens`);
console.log(` released : ${fmt(s.released)} tokens`);
console.log(` releasable : ${fmt(releasable)} tokens`);
console.log(` wallet bal : ${fmt(balance)} tokens`);
}

async function main() {
const signers = await ethers.getSigners();
const [owner, alice, bob, carol] = signers;

const block = await ethers.provider.getBlock('latest');
const now = BigInt(block.timestamp);

// ── 1. Deploy token & factory ─────────────────────────────────────────────

console.log('\n══════════════════════════════════════════════════');
console.log(' VestingWalletFactory — Local Demo');
console.log('══════════════════════════════════════════════════\n');

const token = await ethers.deployContract('$ERC20', ['Demo Token', 'DMT']);
const factory = await ethers.deployContract('VestingWalletFactory', [owner.address]);

console.log('Contracts deployed');
console.log(` Token : ${token.target}`);
console.log(` Factory : ${factory.target}`);

// ── 2. Mint tokens to owner and approve factory ───────────────────────────

const totalSupply = 3000n * UNIT;
await token.$_mint(owner.address, totalSupply);
await token.connect(owner).approve(factory.target, ethers.MaxUint256);

console.log(`\nMinted ${fmt(totalSupply)} DMT to owner, factory approved.\n`);

// ── 3. Create three vesting schedules ─────────────────────────────────────

const start = now + 60n; // starts in 1 minute
const oneYear = BigInt(365 * 24 * 3600);
const sixMonth = oneYear / 2n;

// Alice: 1000 tokens over 1 year
await factory.createVestingSchedule(alice.address, token.target, start, oneYear, 1000n * UNIT);
// Bob: 500 tokens over 6 months
await factory.createVestingSchedule(bob.address, token.target, start, sixMonth, 500n * UNIT);
// Carol: 1500 tokens over 1 year
await factory.createVestingSchedule(carol.address, token.target, start, oneYear, 1500n * UNIT);

console.log('Vesting schedules created:');
console.log(` Schedule 0 — Alice : 1000 DMT over 12 months`);
console.log(` Schedule 1 — Bob : 500 DMT over 6 months`);
console.log(` Schedule 2 — Carol : 1500 DMT over 12 months`);

// ── 4. State at t=0 ───────────────────────────────────────────────────────

console.log('\n──────────────────────────────────────────────────');
console.log(' State at T=0 (vesting just started)');
console.log('──────────────────────────────────────────────────');
await printSchedule(factory, token, 0n, 'Alice');
await printSchedule(factory, token, 1n, 'Bob');
await printSchedule(factory, token, 2n, 'Carol');

// ── 5. Advance to 6 months ────────────────────────────────────────────────

await ethers.provider.send('evm_increaseTime', [Number(sixMonth) + 60]);
await ethers.provider.send('evm_mine');

console.log('\n──────────────────────────────────────────────────');
console.log(' State at T=6 months (Bob fully vested)');
console.log('──────────────────────────────────────────────────');
await printSchedule(factory, token, 0n, 'Alice');
await printSchedule(factory, token, 1n, 'Bob');
await printSchedule(factory, token, 2n, 'Carol');

// ── 6. Release Bob's tokens ───────────────────────────────────────────────

console.log('\nReleasing Bob\'s tokens...');
await factory.release(1n);
console.log(' Done.\n');
await printSchedule(factory, token, 1n, 'Bob (after release)');

// ── 7. Advance to 12 months ───────────────────────────────────────────────

await ethers.provider.send('evm_increaseTime', [Number(sixMonth)]);
await ethers.provider.send('evm_mine');

console.log('\n──────────────────────────────────────────────────');
console.log(' State at T=12 months (all fully vested)');
console.log('──────────────────────────────────────────────────');
await printSchedule(factory, token, 0n, 'Alice');
await printSchedule(factory, token, 1n, 'Bob');
await printSchedule(factory, token, 2n, 'Carol');

// ── 8. Release Alice and Carol ────────────────────────────────────────────

console.log('\nReleasing Alice and Carol\'s tokens...');
await factory.release(0n);
await factory.release(2n);
console.log(' Done.\n');

// ── 9. Final state ────────────────────────────────────────────────────────

console.log('──────────────────────────────────────────────────');
console.log(' Final State');
console.log('──────────────────────────────────────────────────');
await printSchedule(factory, token, 0n, 'Alice');
await printSchedule(factory, token, 1n, 'Bob');
await printSchedule(factory, token, 2n, 'Carol');

const factoryBalance = await token.balanceOf(factory.target);
console.log(`\n Factory remaining balance: ${fmt(factoryBalance)} DMT`);
console.log('\n══════════════════════════════════════════════════\n');
}

main().catch(err => { console.error(err); process.exit(1); });
165 changes: 165 additions & 0 deletions scripts/gasCompare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Gas comparison: N separate VestingWallet contracts vs one VestingWalletFactory.
//
// Usage:
// npx hardhat run scripts/gasCompare.js
// N=10 npx hardhat run scripts/gasCompare.js (Linux/macOS)
// $env:N="10"; npx hardhat run scripts/gasCompare.js (PowerShell)

const { ethers } = require('hardhat');

const N = parseInt(process.env.N ?? '5');
const AMOUNT = ethers.parseEther('1000');
const DURATION = BigInt(365 * 24 * 3600); // 1 year in seconds

async function main() {
if (N < 1) throw new Error('N must be at least 1');

Comment on lines +10 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate N strictly to prevent invalid-input runtime failures.

Line 10/Line 15 currently allow NaN or partially parsed values (e.g., N=abc, N=10foo), which can cascade into a BigInt divide-by-zero at Line 103 when arrays are empty. Fail fast with a strict positive-integer check.

Suggested fix
-const N        = parseInt(process.env.N ?? '5');
+const rawN     = process.env.N ?? '5';
+const N        = Number(rawN);
 const AMOUNT   = ethers.parseEther('1000');
 const DURATION = BigInt(365 * 24 * 3600); // 1 year in seconds
 
 async function main() {
-  if (N < 1) throw new Error('N must be at least 1');
+  if (!Number.isInteger(N) || N < 1) {
+    throw new Error(`N must be a positive integer; received "${rawN}"`);
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const N = parseInt(process.env.N ?? '5');
const AMOUNT = ethers.parseEther('1000');
const DURATION = BigInt(365 * 24 * 3600); // 1 year in seconds
async function main() {
if (N < 1) throw new Error('N must be at least 1');
const rawN = process.env.N ?? '5';
const N = Number(rawN);
const AMOUNT = ethers.parseEther('1000');
const DURATION = BigInt(365 * 24 * 3600); // 1 year in seconds
async function main() {
if (!Number.isInteger(N) || N < 1) {
throw new Error(`N must be a positive integer; received "${rawN}"`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/gasCompare.js` around lines 10 - 16, The variable N is currently
parsed loosely and can be NaN or partially parsed (e.g., "10foo"), causing
downstream failures in main (including a BigInt divide-by-zero when arrays are
empty); update the parsing/validation of N so it only accepts a strict positive
integer: validate the raw env string first (e.g., /^\d+$/) or use Number() +
Number.isInteger and ensure > 0, then set N accordingly and throw a clear Error
from main (or before calling main) if validation fails; reference the N constant
and main function when applying this change so the early-fail prevents the later
divide-by-zero.

const signers = await ethers.getSigners();
const [owner, ...rest] = signers;

if (rest.length < N) {
throw new Error(
`N=${N} requires ${N + 1} signers but Hardhat provides ${signers.length}. ` +
`Lower N or add more accounts in hardhat.config.js.`,
);
}

const beneficiaries = rest.slice(0, N);
const block = await ethers.provider.getBlock('latest');
const start = BigInt(block.timestamp) + 3600n;

const token = await ethers.deployContract('$ERC20', ['Gas Token', 'GT']);
console.log(`\nBenchmarking N=${N} beneficiar${N === 1 ? 'y' : 'ies'}...`);

// ── Approach A: one VestingWallet per beneficiary ─────────────────────────

const walletDeployGas = [];
const walletFundGas = [];
const walletReleaseGas = [];
const wallets = [];

for (let i = 0; i < N; i++) {
await token.$_mint(owner.address, AMOUNT);

const wallet = await ethers.deployContract('VestingWallet', [
beneficiaries[i].address,
start,
DURATION,
]);
walletDeployGas.push((await wallet.deploymentTransaction().wait()).gasUsed);

const fundReceipt = await (await token.connect(owner).transfer(wallet.target, AMOUNT)).wait();
walletFundGas.push(fundReceipt.gasUsed);

wallets.push(wallet);
}

// ── Approach B: VestingWalletFactory ─────────────────────────────────────

const factory = await ethers.deployContract('VestingWalletFactory', [owner.address]);
const factoryDeployGas = (await factory.deploymentTransaction().wait()).gasUsed;

await token.$_mint(owner.address, AMOUNT * BigInt(N));
const approveGas = (
await (await token.connect(owner).approve(factory.target, ethers.MaxUint256)).wait()
).gasUsed;

const factoryCreateGas = [];
const factoryReleaseGas = [];

for (let i = 0; i < N; i++) {
const receipt = await (
await factory.createVestingSchedule(
beneficiaries[i].address,
token.target,
start,
DURATION,
AMOUNT,
)
).wait();
factoryCreateGas.push(receipt.gasUsed);
}

// ── Advance time past vesting end ─────────────────────────────────────────

await ethers.provider.send('evm_increaseTime', [Number(DURATION) + 3601]);
await ethers.provider.send('evm_mine');

// ── Release ───────────────────────────────────────────────────────────────

for (const wallet of wallets) {
const receipt = await (await wallet['release(address)'](token.target)).wait();
walletReleaseGas.push(receipt.gasUsed);
}

for (let i = 0; i < N; i++) {
const receipt = await (await factory.release(BigInt(i))).wait();
factoryReleaseGas.push(receipt.gasUsed);
}

// ── Aggregate ─────────────────────────────────────────────────────────────

const sum = arr => arr.reduce((a, b) => a + b, 0n);
const avg = arr => sum(arr) / BigInt(arr.length);

const factoryOneTime = factoryDeployGas + approveGas;
const walletSetupTotal = sum(walletDeployGas) + sum(walletFundGas);
const factorySetupTotal = factoryOneTime + sum(factoryCreateGas);
const walletTotal = walletSetupTotal + sum(walletReleaseGas);
const factoryTotal = factorySetupTotal + sum(factoryReleaseGas);

// ── Print table ───────────────────────────────────────────────────────────

const LBL = 38;
const W = 14;
const SEP = ' ' + '-'.repeat(LBL + W + W + 2);
const RULE = ' ' + '='.repeat(LBL + W + W + 2);

// Inserts commas and right-pads to W characters
const fmt = n => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',').padStart(W);

const row = (label, walletVal, factoryVal) =>
` ${label.padEnd(LBL)} ${walletVal} ${factoryVal}`;

console.log('');
console.log(RULE);
console.log(` Gas Comparison — N = ${N} beneficiar${N === 1 ? 'y' : 'ies'}`);
console.log(RULE);

console.log('');
console.log(row('SETUP', 'VestingWallet'.padStart(W), 'Factory'.padStart(W)));
console.log(SEP);
console.log(row(' avg deploy / createSchedule', fmt(avg(walletDeployGas)), fmt(avg(factoryCreateGas))));
console.log(row(' avg fund transfer', fmt(avg(walletFundGas)), '(incl. above)'.padStart(W)));
console.log(row(' avg per-beneficiary total', fmt(avg(walletDeployGas) + avg(walletFundGas)), fmt(avg(factoryCreateGas))));
console.log(row(' one-time: deploy + approve', '—'.padStart(W), fmt(factoryOneTime)));
console.log(SEP);
console.log(row(` total setup (N=${N})`, fmt(walletSetupTotal), fmt(factorySetupTotal)));

console.log('');
console.log(row('RELEASE', 'VestingWallet'.padStart(W), 'Factory'.padStart(W)));
console.log(SEP);
console.log(row(' avg per beneficiary', fmt(avg(walletReleaseGas)), fmt(avg(factoryReleaseGas))));
console.log(SEP);
console.log(row(` total release (N=${N})`, fmt(sum(walletReleaseGas)), fmt(sum(factoryReleaseGas))));

console.log('');
console.log(RULE);
console.log(row('GRAND TOTAL (setup + release)', fmt(walletTotal), fmt(factoryTotal)));
console.log(RULE);

const savings = walletTotal - factoryTotal;
if (savings > 0n) {
const pct = (Number(savings * 10000n / walletTotal) / 100).toFixed(1);
const fmtSavings = savings.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log(`\n Factory saves ${fmtSavings} gas (${pct}% cheaper than separate wallets)`);
} else {
const extra = -savings;
const pct = (Number(extra * 10000n / factoryTotal) / 100).toFixed(1);
const fmtExtra = extra.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log(`\n Separate wallets save ${fmtExtra} gas (${pct}% cheaper than factory)`);
}
console.log('');
}

main().catch(err => { console.error(err); process.exit(1); });
Loading
Loading