Explorar o código

Add VestingWalletWithCliff (#4870)

Co-authored-by: Ernesto García <ernestognw@gmail.com>
Hadrien Croubois hai 1 ano
pai
achega
ae1bafcb48

+ 5 - 0
.changeset/wise-bobcats-speak.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`VestingWalletCliff`: Add an extension of the `VestingWallet` contract with an added cliff.

+ 51 - 0
contracts/finance/VestingWalletCliff.sol

@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {SafeCast} from "../utils/math/SafeCast.sol";
+import {VestingWallet} from "./VestingWallet.sol";
+
+/**
+ * @dev Extension of {VestingWallet} that adds a cliff to the vesting schedule.
+ */
+abstract contract VestingWalletCliff is VestingWallet {
+    using SafeCast for *;
+
+    uint64 private immutable _cliff;
+
+    /// @dev The specified cliff duration is larger than the vesting duration.
+    error InvalidCliffDuration(uint64 cliffSeconds, uint64 durationSeconds);
+
+    /**
+     * @dev Sets the sender as the initial owner, the beneficiary as the pending owner, the start timestamp, the
+     * vesting duration and the duration of the cliff of the vesting wallet.
+     */
+    constructor(uint64 cliffSeconds) {
+        if (cliffSeconds > duration()) {
+            revert InvalidCliffDuration(cliffSeconds, duration().toUint64());
+        }
+        _cliff = start().toUint64() + cliffSeconds;
+    }
+
+    /**
+     * @dev Getter for the cliff timestamp.
+     */
+    function cliff() public view virtual returns (uint256) {
+        return _cliff;
+    }
+
+    /**
+     * @dev Virtual implementation of the vesting formula. This returns the amount vested, as a function of time, for
+     * an asset given its total historical allocation. Returns 0 if the {cliff} timestamp is not met.
+     *
+     * IMPORTANT: The cliff not only makes the schedule return 0, but it also ignores every possible side
+     * effect from calling the inherited implementation (i.e. `super._vestingSchedule`). Carefully consider
+     * this caveat if the overridden implementation of this function has any (e.g. writing to memory or reverting).
+     */
+    function _vestingSchedule(
+        uint256 totalAllocation,
+        uint64 timestamp
+    ) internal view virtual override returns (uint256) {
+        return timestamp < cliff() ? 0 : super._vestingSchedule(totalAllocation, timestamp);
+    }
+}

+ 1 - 1
hardhat.config.js

@@ -102,7 +102,7 @@ module.exports = {
   exposed: {
     imports: true,
     initializers: true,
-    exclude: ['vendor/**/*'],
+    exclude: ['vendor/**/*', '**/*WithInit.sol'],
   },
   gasReporter: {
     enabled: argv.gas,

+ 107 - 0
test/finance/VestingWalletCliff.test.js

@@ -0,0 +1,107 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { min } = require('../helpers/math');
+const time = require('../helpers/time');
+
+const { shouldBehaveLikeVesting } = require('./VestingWallet.behavior');
+
+async function fixture() {
+  const amount = ethers.parseEther('100');
+  const duration = time.duration.years(4);
+  const start = (await time.clock.timestamp()) + time.duration.hours(1);
+  const cliffDuration = time.duration.years(1);
+  const cliff = start + cliffDuration;
+
+  const [sender, beneficiary] = await ethers.getSigners();
+  const mock = await ethers.deployContract('$VestingWalletCliff', [beneficiary, start, duration, cliffDuration]);
+
+  const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']);
+  await token.$_mint(mock, amount);
+  await sender.sendTransaction({ to: mock, value: amount });
+
+  const pausableToken = await ethers.deployContract('$ERC20Pausable', ['Name', 'Symbol']);
+  const beneficiaryMock = await ethers.deployContract('EtherReceiverMock');
+
+  const env = {
+    eth: {
+      checkRelease: async (tx, amount) => {
+        await expect(tx).to.emit(mock, 'EtherReleased').withArgs(amount);
+        await expect(tx).to.changeEtherBalances([mock, beneficiary], [-amount, amount]);
+      },
+      setupFailure: async () => {
+        await beneficiaryMock.setAcceptEther(false);
+        await mock.connect(beneficiary).transferOwnership(beneficiaryMock);
+        return { args: [], error: [mock, 'FailedInnerCall'] };
+      },
+      releasedEvent: 'EtherReleased',
+      argsVerify: [],
+      args: [],
+    },
+    token: {
+      checkRelease: async (tx, amount) => {
+        await expect(tx).to.emit(token, 'Transfer').withArgs(mock, beneficiary, amount);
+        await expect(tx).to.changeTokenBalances(token, [mock, beneficiary], [-amount, amount]);
+      },
+      setupFailure: async () => {
+        await pausableToken.$_pause();
+        return {
+          args: [ethers.Typed.address(pausableToken)],
+          error: [pausableToken, 'EnforcedPause'],
+        };
+      },
+      releasedEvent: 'ERC20Released',
+      argsVerify: [token],
+      args: [ethers.Typed.address(token)],
+    },
+  };
+
+  const schedule = Array(64)
+    .fill()
+    .map((_, i) => (BigInt(i) * duration) / 60n + start);
+
+  const vestingFn = timestamp => min(amount, timestamp < cliff ? 0n : (amount * (timestamp - start)) / duration);
+
+  return { mock, duration, start, beneficiary, cliff, schedule, vestingFn, env };
+}
+
+describe('VestingWalletCliff', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  it('rejects a larger cliff than vesting duration', async function () {
+    await expect(
+      ethers.deployContract('$VestingWalletCliff', [this.beneficiary, this.start, this.duration, this.duration + 1n]),
+    )
+      .revertedWithCustomError(this.mock, 'InvalidCliffDuration')
+      .withArgs(this.duration + 1n, this.duration);
+  });
+
+  it('check vesting contract', async function () {
+    expect(await this.mock.owner()).to.equal(this.beneficiary);
+    expect(await this.mock.start()).to.equal(this.start);
+    expect(await this.mock.duration()).to.equal(this.duration);
+    expect(await this.mock.end()).to.equal(this.start + this.duration);
+    expect(await this.mock.cliff()).to.equal(this.cliff);
+  });
+
+  describe('vesting schedule', function () {
+    describe('Eth vesting', function () {
+      beforeEach(async function () {
+        Object.assign(this, this.env.eth);
+      });
+
+      shouldBehaveLikeVesting();
+    });
+
+    describe('ERC20 vesting', function () {
+      beforeEach(async function () {
+        Object.assign(this, this.env.token);
+      });
+
+      shouldBehaveLikeVesting();
+    });
+  });
+});