diff --git a/contracts/finance/VestingWalletFactory.sol b/contracts/finance/VestingWalletFactory.sol new file mode 100644 index 00000000000..e723fe4bafe --- /dev/null +++ b/contracts/finance/VestingWalletFactory.sol @@ -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; + } + } +} \ No newline at end of file diff --git a/scripts/demoFactory.js b/scripts/demoFactory.js new file mode 100644 index 00000000000..1dc71790c58 --- /dev/null +++ b/scripts/demoFactory.js @@ -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); +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); }); diff --git a/scripts/gasCompare.js b/scripts/gasCompare.js new file mode 100644 index 00000000000..f20257fd047 --- /dev/null +++ b/scripts/gasCompare.js @@ -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'); + + 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); }); diff --git a/test/finance/VestingWalletFactory.test.js b/test/finance/VestingWalletFactory.test.js new file mode 100644 index 00000000000..375177c6ec5 --- /dev/null +++ b/test/finance/VestingWalletFactory.test.js @@ -0,0 +1,263 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const time = require('../helpers/time'); + +async function fixture() { + const amount = ethers.parseEther('1000'); + const duration = time.duration.years(4); + const start = (await time.clock.timestamp()) + time.duration.hours(1); + + const [owner, beneficiary, other] = await ethers.getSigners(); + + const factory = await ethers.deployContract('VestingWalletFactory', [owner.address]); + const token = await ethers.deployContract('$ERC20', ['Test Token', 'TT']); + + // Mint tokens to owner; schedules are funded via safeTransferFrom + await token.$_mint(owner.address, amount * 10n); + await token.connect(owner).approve(factory.target, ethers.MaxUint256); + + return { factory, token, owner, beneficiary, other, amount, start, duration }; +} + +describe('VestingWalletFactory', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('createVestingSchedule input validation', function () { + it('rejects zero address beneficiary', async function () { + await expect( + this.factory.createVestingSchedule( + ethers.ZeroAddress, + this.token.target, + this.start, + this.duration, + this.amount, + ), + ).to.be.revertedWith('VestingWalletFactory: beneficiary is zero address'); + }); + + it('rejects zero amount', async function () { + await expect( + this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + 0n, + ), + ).to.be.revertedWith('VestingWalletFactory: amount is zero'); + }); + + it('rejects zero duration', async function () { + await expect( + this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + 0n, + this.amount, + ), + ).to.be.revertedWith('VestingWalletFactory: duration is zero'); + }); + }); + + describe('access control', function () { + it('reverts when called by non-owner', async function () { + await expect( + this.factory.connect(this.other).createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + this.amount, + ), + ) + .to.be.revertedWithCustomError(this.factory, 'OwnableUnauthorizedAccount') + .withArgs(this.other.address); + }); + }); + + describe('vesting math', function () { + beforeEach(async function () { + await this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + this.amount, + ); + this.end = this.start + this.duration; + }); + + it('vestedAmount is 0 before start', async function () { + expect(await this.factory.vestedAmount(0n, this.start - 1n)).to.equal(0n); + }); + + it('vestedAmount is proportional mid-schedule', async function () { + expect(await this.factory.vestedAmount(0n, this.start + this.duration / 2n)).to.equal(this.amount / 2n); + }); + + it('vestedAmount equals totalAllocation at end', async function () { + expect(await this.factory.vestedAmount(0n, this.end)).to.equal(this.amount); + }); + + it('vestedAmount equals totalAllocation after end', async function () { + expect(await this.factory.vestedAmount(0n, this.end + 1n)).to.equal(this.amount); + }); + + it('releasable is 0 before start', async function () { + expect(await this.factory.releasable(0n)).to.equal(0n); + }); + + it('releasable is proportional mid-schedule', async function () { + await time.increaseTo.timestamp(this.start + this.duration / 2n); + expect(await this.factory.releasable(0n)).to.equal(this.amount / 2n); + }); + + it('releasable equals totalAllocation at end', async function () { + await time.increaseTo.timestamp(this.end); + expect(await this.factory.releasable(0n)).to.equal(this.amount); + }); + }); + + describe('release', function () { + beforeEach(async function () { + await this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + this.amount, + ); + this.end = this.start + this.duration; + }); + + it('transfers the full amount to beneficiary at end', async function () { + await time.increaseTo.timestamp(this.end); + const tx = await this.factory.release(0n); + await expect(tx).to.changeTokenBalances( + this.token, + [this.factory, this.beneficiary], + [-this.amount, this.amount], + ); + }); + + it('emits ERC20Released', async function () { + await time.increaseTo.timestamp(this.end); + await expect(this.factory.release(0n)) + .to.emit(this.factory, 'ERC20Released') + .withArgs(0n, this.token.target, this.amount); + }); + + it('second release only sends the remaining delta', async function () { + await time.increaseTo.timestamp(this.start + this.duration / 2n, false); + await this.factory.release(0n); + + await time.increaseTo.timestamp(this.end, false); + const tx = await this.factory.release(0n); + const remainder = this.amount - this.amount / 2n; + await expect(tx).to.changeTokenBalances( + this.token, + [this.factory, this.beneficiary], + [-remainder, remainder], + ); + }); + + it('updates released on the schedule', async function () { + await time.increaseTo.timestamp(this.end); + await this.factory.release(0n); + expect((await this.factory.getSchedule(0n)).released).to.equal(this.amount); + }); + }); + + describe('multiple schedules', function () { + beforeEach(async function () { + const [, , , beneficiary2] = await ethers.getSigners(); + this.beneficiary2 = beneficiary2; + this.amount2 = ethers.parseEther('400'); + + await this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + this.amount, + ); + await this.factory.createVestingSchedule( + this.beneficiary2.address, + this.token.target, + this.start, + this.duration, + this.amount2, + ); + + this.end = this.start + this.duration; + }); + + it('scheduleCount is 2', async function () { + expect(await this.factory.scheduleCount()).to.equal(2n); + }); + + it('each schedule tracks its own totalAllocation', async function () { + expect((await this.factory.getSchedule(0n)).totalAllocation).to.equal(this.amount); + expect((await this.factory.getSchedule(1n)).totalAllocation).to.equal(this.amount2); + }); + + it('releasing one schedule does not affect the other', async function () { + await time.increaseTo.timestamp(this.end); + await this.factory.release(0n); + expect(await this.factory.releasable(1n)).to.equal(this.amount2); + }); + + it('each beneficiary receives only their own allocation', async function () { + await time.increaseTo.timestamp(this.end, false); + const tx1 = await this.factory.release(0n); + const tx2 = await this.factory.release(1n); + await expect(tx1).to.changeTokenBalances(this.token, [this.beneficiary], [this.amount]); + await expect(tx2).to.changeTokenBalances(this.token, [this.beneficiary2], [this.amount2]); + }); + }); + + describe('after createVestingSchedule', function () { + beforeEach(async function () { + this.tx = await this.factory.createVestingSchedule( + this.beneficiary.address, + this.token.target, + this.start, + this.duration, + this.amount, + ); + }); + + it('stores the schedule correctly', async function () { + const s = await this.factory.getSchedule(0n); + expect(s.beneficiary).to.equal(this.beneficiary.address); + expect(s.token).to.equal(this.token.target); + expect(s.start).to.equal(this.start); + expect(s.duration).to.equal(this.duration); + expect(s.totalAllocation).to.equal(this.amount); + expect(s.released).to.equal(0n); + }); + + it('increments scheduleCount', async function () { + expect(await this.factory.scheduleCount()).to.equal(1n); + }); + + it('pulls tokens from the caller into the contract', async function () { + await expect(this.tx).to.changeTokenBalances( + this.token, + [this.owner, this.factory], + [-this.amount, this.amount], + ); + }); + + it('emits VestingScheduleCreated', async function () { + await expect(this.tx) + .to.emit(this.factory, 'VestingScheduleCreated') + .withArgs(0n, this.beneficiary.address, this.token.target, this.start, this.duration, this.amount); + }); + }); +});