From 7501cdb67f88e64c7fb0e6dfa2bb721e9754bf29 Mon Sep 17 00:00:00 2001 From: PacificYield <173040337+PacificYield@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:23:58 +0100 Subject: [PATCH] test: add unit tests --- .../finance/ConfidentialVestingWallet.sol | 5 +- .../ConfidentialVestingWalletCliff.sol | 15 +- .../finance/TestConfidentialVestingWallet.sol | 16 ++ .../TestConfidentialVestingWalletCliff.sol | 17 ++ .../ConfidentialVestingWallet.fixture.ts | 30 +++ .../finance/ConfidentialVestingWallet.test.ts | 158 ++++++++++++++ .../ConfidentialVestingWalletCliff.fixture.ts | 20 ++ .../ConfidentialVestingWalletCliff.test.ts | 202 ++++++++++++++++++ 8 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 contracts/test/finance/TestConfidentialVestingWallet.sol create mode 100644 contracts/test/finance/TestConfidentialVestingWalletCliff.sol create mode 100644 test/finance/ConfidentialVestingWallet.fixture.ts create mode 100644 test/finance/ConfidentialVestingWallet.test.ts create mode 100644 test/finance/ConfidentialVestingWalletCliff.fixture.ts create mode 100644 test/finance/ConfidentialVestingWalletCliff.test.ts diff --git a/contracts/finance/ConfidentialVestingWallet.sol b/contracts/finance/ConfidentialVestingWallet.sol index 8261c2c..29567a7 100644 --- a/contracts/finance/ConfidentialVestingWallet.sol +++ b/contracts/finance/ConfidentialVestingWallet.sol @@ -5,8 +5,6 @@ import "fhevm/lib/TFHE.sol"; import { IConfidentialERC20 } from "../token/ERC20/IConfidentialERC20.sol"; -import "hardhat/console.sol"; - /** * @title ConfidentialVestingWallet * @notice This contract offers a simple vesting wallet for ConfidentialERC20 tokens. @@ -70,12 +68,11 @@ abstract contract ConfidentialVestingWallet { */ function release() public virtual { euint64 amount = _releasable(); - euint64 amountReleased = TFHE.add(_amountReleased, amount); _amountReleased = amountReleased; + TFHE.allow(amountReleased, BENEFICIARY); TFHE.allowThis(amountReleased); - TFHE.allowTransient(amount, address(CONFIDENTIAL_ERC20)); CONFIDENTIAL_ERC20.transfer(BENEFICIARY, amount); diff --git a/contracts/finance/ConfidentialVestingWalletCliff.sol b/contracts/finance/ConfidentialVestingWalletCliff.sol index fc34fab..ea3aeca 100644 --- a/contracts/finance/ConfidentialVestingWalletCliff.sol +++ b/contracts/finance/ConfidentialVestingWalletCliff.sol @@ -13,12 +13,11 @@ import { ConfidentialVestingWallet } from "./ConfidentialVestingWallet.sol"; * @dev This implementation is a linear vesting curve with a cliff. * To use with the native asset, it is necessary to wrap the native asset to a ConfidentialERC20-like token. */ - -abstract contract VestingWalletCliff is ConfidentialVestingWallet { +abstract contract ConfidentialVestingWalletCliff is ConfidentialVestingWallet { /// @notice Returned if the cliff duration is greater than the vesting duration. error InvalidCliffDuration(uint64 cliffSeconds, uint64 durationSeconds); - /// @notice Cliff duration (in seconds). + /// @notice Cliff timestamp. uint64 public immutable CLIFF; /** @@ -26,20 +25,20 @@ abstract contract VestingWalletCliff is ConfidentialVestingWallet { * @param token_ Confidential token address. * @param startTimestamp_ Start timestamp. * @param duration_ Duration (in seconds). - * @param cliff_ Cliff (in seconds). + * @param cliffSeconds_ Cliff (in seconds). */ constructor( address beneficiary_, address token_, uint64 startTimestamp_, uint64 duration_, - uint64 cliff_ + uint64 cliffSeconds_ ) ConfidentialVestingWallet(beneficiary_, token_, startTimestamp_, duration_) { - if (cliff_ > duration_) { - revert InvalidCliffDuration(cliff_, duration_); + if (cliffSeconds_ > duration_) { + revert InvalidCliffDuration(cliffSeconds_, duration_); } - CLIFF = startTimestamp_ + cliff_; + CLIFF = startTimestamp_ + cliffSeconds_; } /** diff --git a/contracts/test/finance/TestConfidentialVestingWallet.sol b/contracts/test/finance/TestConfidentialVestingWallet.sol new file mode 100644 index 0000000..8b5cc19 --- /dev/null +++ b/contracts/test/finance/TestConfidentialVestingWallet.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.24; + +import { ConfidentialVestingWallet } from "../../finance/ConfidentialVestingWallet.sol"; +import { SepoliaZamaFHEVMConfig } from "fhevm/config/ZamaFHEVMConfig.sol"; + +contract TestConfidentialVestingWallet is SepoliaZamaFHEVMConfig, ConfidentialVestingWallet { + constructor( + address beneficiary_, + address token_, + uint64 startTimestamp_, + uint64 duration_ + ) ConfidentialVestingWallet(beneficiary_, token_, startTimestamp_, duration_) { + // + } +} diff --git a/contracts/test/finance/TestConfidentialVestingWalletCliff.sol b/contracts/test/finance/TestConfidentialVestingWalletCliff.sol new file mode 100644 index 0000000..e4f5091 --- /dev/null +++ b/contracts/test/finance/TestConfidentialVestingWalletCliff.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.24; + +import { ConfidentialVestingWalletCliff } from "../../finance/ConfidentialVestingWalletCliff.sol"; +import { SepoliaZamaFHEVMConfig } from "fhevm/config/ZamaFHEVMConfig.sol"; + +contract TestConfidentialVestingWalletCliff is SepoliaZamaFHEVMConfig, ConfidentialVestingWalletCliff { + constructor( + address beneficiary_, + address token_, + uint64 startTimestamp_, + uint64 duration_, + uint64 cliff_ + ) ConfidentialVestingWalletCliff(beneficiary_, token_, startTimestamp_, duration_, cliff_) { + // + } +} diff --git a/test/finance/ConfidentialVestingWallet.fixture.ts b/test/finance/ConfidentialVestingWallet.fixture.ts new file mode 100644 index 0000000..b1e90d5 --- /dev/null +++ b/test/finance/ConfidentialVestingWallet.fixture.ts @@ -0,0 +1,30 @@ +import { Signer } from "ethers"; +import { FhevmInstance } from "fhevmjs/node"; +import { ethers } from "hardhat"; + +import type { ConfidentialVestingWallet, TestConfidentialVestingWallet } from "../../types"; +import { reencryptEuint64 } from "../reencrypt"; + +export async function deployConfidentialVestingWalletFixture( + account: Signer, + beneficiaryAddress: string, + token: string, + startTimestamp: bigint, + duration: bigint, +): Promise { + const contractFactory = await ethers.getContractFactory("TestConfidentialVestingWallet"); + const contract = await contractFactory.connect(account).deploy(beneficiaryAddress, token, startTimestamp, duration); + await contract.waitForDeployment(); + return contract; +} + +export async function reencryptReleased( + account: Signer, + instance: FhevmInstance, + vestingWallet: ConfidentialVestingWallet, + vestingWalletAddress: string, +): Promise { + const releasedHandled = await vestingWallet.released(); + const releasedAmount = await reencryptEuint64(account, instance, releasedHandled, vestingWalletAddress); + return releasedAmount; +} diff --git a/test/finance/ConfidentialVestingWallet.test.ts b/test/finance/ConfidentialVestingWallet.test.ts new file mode 100644 index 0000000..cf0e517 --- /dev/null +++ b/test/finance/ConfidentialVestingWallet.test.ts @@ -0,0 +1,158 @@ +import { expect } from "chai"; +import { parseUnits } from "ethers"; +import { ethers } from "hardhat"; + +import { deployConfidentialERC20Fixture, reencryptBalance } from "../confidentialERC20/ConfidentialERC20.fixture"; +import { createInstance } from "../instance"; +import { getSigners, initSigners } from "../signers"; +import { deployConfidentialVestingWalletFixture, reencryptReleased } from "./ConfidentialVestingWallet.fixture"; + +describe("ConfidentialVestingWallet", function () { + before(async function () { + await initSigners(); + this.signers = await getSigners(); + this.instance = await createInstance(); + }); + + beforeEach(async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + + this.beneficiary = this.signers.bob; + this.beneficiaryAddress = this.signers.bob.address; + + const contractConfidentialERC20 = await deployConfidentialERC20Fixture( + this.signers.alice, + "Naraggara", + "NARA", + this.signers.alice.address, + ); + this.confidentialERC20Address = await contractConfidentialERC20.getAddress(); + this.confidentialERC20 = contractConfidentialERC20; + this.startTimestamp = BigInt(block!.timestamp + 3600); + this.duration = BigInt(36_000); // 36,000 seconds + + const contractConfidentialVestingWallet = await deployConfidentialVestingWalletFixture( + this.signers.alice, + this.beneficiaryAddress, + this.confidentialERC20Address, + this.startTimestamp, + this.duration, + ); + + this.confidentialVestingWallet = contractConfidentialVestingWallet; + this.confidentialVestingWalletAddress = await contractConfidentialVestingWallet.getAddress(); + }); + + it("post-deployment state", async function () { + expect(await this.confidentialVestingWallet.BENEFICIARY()).to.equal(this.beneficiaryAddress); + expect(await this.confidentialVestingWallet.CONFIDENTIAL_ERC20()).to.equal(this.confidentialERC20); + expect(await this.confidentialVestingWallet.DURATION()).to.equal(this.duration); + expect(await this.confidentialVestingWallet.END_TIMESTAMP()).to.be.eq(this.startTimestamp + this.duration); + expect(await this.confidentialVestingWallet.START_TIMESTAMP()).to.be.eq(this.startTimestamp); + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(0n); + }); + + it("can release", async function () { + // 10M + const amount = parseUnits("10000000", 6); + + let tx = await this.confidentialERC20.connect(this.signers.alice).mint(this.signers.alice, amount); + await tx.wait(); + + const input = this.instance.createEncryptedInput(this.confidentialERC20Address, this.signers.alice.address); + input.add64(amount); + const encryptedTransferAmount = await input.encrypt(); + + tx = await this.confidentialERC20 + .connect(this.signers.alice) + [ + "transfer(address,bytes32,bytes)" + ](this.confidentialVestingWalletAddress, encryptedTransferAmount.handles[0], encryptedTransferAmount.inputProof); + + await tx.wait(); + + let nextTimestamp = this.startTimestamp; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await expect(tx).to.emit(this.confidentialVestingWallet, "ConfidentialERC20Released"); + + // It should be equal to 0 because the vesting has not started. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(0n); + + nextTimestamp = this.startTimestamp + this.duration / BigInt(4); + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/4 of the amount vested. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount) / BigInt(4)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount) / BigInt(4)); + + nextTimestamp = this.startTimestamp + this.duration / BigInt(2); + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/4 of the amount vested since 1/4 was already collected. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount) / BigInt(2)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount) / BigInt(2)); + + nextTimestamp = this.startTimestamp + this.duration; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/2 of the amount vested since 2/4 was already collected. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount)); + }); +}); diff --git a/test/finance/ConfidentialVestingWalletCliff.fixture.ts b/test/finance/ConfidentialVestingWalletCliff.fixture.ts new file mode 100644 index 0000000..b1a76f7 --- /dev/null +++ b/test/finance/ConfidentialVestingWalletCliff.fixture.ts @@ -0,0 +1,20 @@ +import { Signer } from "ethers"; +import { ethers } from "hardhat"; + +import type { TestConfidentialVestingWalletCliff } from "../../types"; + +export async function deployConfidentialVestingWalletCliffFixture( + account: Signer, + beneficiaryAddress: string, + token: string, + startTimestamp: bigint, + duration: bigint, + cliffSeconds: bigint, +): Promise { + const contractFactory = await ethers.getContractFactory("TestConfidentialVestingWalletCliff"); + const contract = await contractFactory + .connect(account) + .deploy(beneficiaryAddress, token, startTimestamp, duration, cliffSeconds); + await contract.waitForDeployment(); + return contract; +} diff --git a/test/finance/ConfidentialVestingWalletCliff.test.ts b/test/finance/ConfidentialVestingWalletCliff.test.ts new file mode 100644 index 0000000..65fe4fb --- /dev/null +++ b/test/finance/ConfidentialVestingWalletCliff.test.ts @@ -0,0 +1,202 @@ +import { expect } from "chai"; +import { parseUnits } from "ethers"; +import { ethers } from "hardhat"; + +import { deployConfidentialERC20Fixture, reencryptBalance } from "../confidentialERC20/ConfidentialERC20.fixture"; +import { createInstance } from "../instance"; +import { getSigners, initSigners } from "../signers"; +import { reencryptReleased } from "./ConfidentialVestingWallet.fixture"; +import { deployConfidentialVestingWalletCliffFixture } from "./ConfidentialVestingWalletCliff.fixture"; + +describe("ConfidentialVestingWalletCliff", function () { + before(async function () { + await initSigners(); + this.signers = await getSigners(); + this.instance = await createInstance(); + }); + + beforeEach(async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + + this.beneficiary = this.signers.bob; + this.beneficiaryAddress = this.signers.bob.address; + + const contractConfidentialERC20 = await deployConfidentialERC20Fixture( + this.signers.alice, + "Naraggara", + "NARA", + this.signers.alice.address, + ); + this.confidentialERC20Address = await contractConfidentialERC20.getAddress(); + this.confidentialERC20 = contractConfidentialERC20; + this.startTimestamp = BigInt(block!.timestamp + 3600); + this.duration = BigInt(36_000); // 36,000 seconds + this.cliffSeconds = this.duration / 4n; + + const contractConfidentialVestingWallet = await deployConfidentialVestingWalletCliffFixture( + this.signers.alice, + this.beneficiaryAddress, + this.confidentialERC20Address, + this.startTimestamp, + this.duration, + this.cliffSeconds, + ); + + this.confidentialVestingWallet = contractConfidentialVestingWallet; + this.confidentialVestingWalletAddress = await contractConfidentialVestingWallet.getAddress(); + }); + + it("post-deployment state", async function () { + expect(await this.confidentialVestingWallet.BENEFICIARY()).to.equal(this.beneficiaryAddress); + expect(await this.confidentialVestingWallet.CONFIDENTIAL_ERC20()).to.equal(this.confidentialERC20); + expect(await this.confidentialVestingWallet.DURATION()).to.equal(this.duration); + expect(await this.confidentialVestingWallet.END_TIMESTAMP()).to.be.eq(this.startTimestamp + this.duration); + expect(await this.confidentialVestingWallet.START_TIMESTAMP()).to.be.eq(this.startTimestamp); + expect(await this.confidentialVestingWallet.START_TIMESTAMP()).to.be.eq(this.startTimestamp); + expect(await this.confidentialVestingWallet.CLIFF()).to.be.eq(this.cliffSeconds + this.startTimestamp); + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(0n); + }); + + it("can release", async function () { + // 10M + const amount = parseUnits("10000000", 6); + + let tx = await this.confidentialERC20.connect(this.signers.alice).mint(this.signers.alice, amount); + await tx.wait(); + + const input = this.instance.createEncryptedInput(this.confidentialERC20Address, this.signers.alice.address); + input.add64(amount); + const encryptedTransferAmount = await input.encrypt(); + + tx = await this.confidentialERC20 + .connect(this.signers.alice) + [ + "transfer(address,bytes32,bytes)" + ](this.confidentialVestingWalletAddress, encryptedTransferAmount.handles[0], encryptedTransferAmount.inputProof); + + await tx.wait(); + + let nextTimestamp = this.startTimestamp; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await expect(tx).to.emit(this.confidentialVestingWallet, "ConfidentialERC20Released"); + + // It should be equal to 0 because the vesting has not started. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(0n); + + // Move to the cliff - 1 second + nextTimestamp = this.startTimestamp + this.cliffSeconds - 1n; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 0 because of the cliff. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(0); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(0); + + // Bump to the end of the cliff + nextTimestamp = this.startTimestamp + this.cliffSeconds; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/4 since the cliff was reached so everything that was pending is releasable at once. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount) / BigInt(4)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount) / BigInt(4)); + + nextTimestamp = this.startTimestamp + this.duration / BigInt(2); + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/4 of the amount vested since 1/4 was already collected. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount) / BigInt(2)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount) / BigInt(2)); + + nextTimestamp = this.startTimestamp + this.duration; + await ethers.provider.send("evm_setNextBlockTimestamp", [nextTimestamp.toString()]); + + tx = await this.confidentialVestingWallet.connect(this.beneficiary).release(); + await tx.wait(); + + // It should be equal to 1/2 of the amount vested since 2/4 was already collected. + expect( + await reencryptReleased( + this.beneficiary, + this.instance, + this.confidentialVestingWallet, + this.confidentialVestingWalletAddress, + ), + ).to.be.eq(BigInt(amount)); + + expect( + await reencryptBalance(this.beneficiary, this.instance, this.confidentialERC20, this.confidentialERC20Address), + ).to.be.eq(BigInt(amount)); + }); + + it("cannot deploy if cliff > duration", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const startTimestamp = BigInt(block!.timestamp + 3600); + const duration = 100n; + const cliff = duration + 1n; + + const contractFactory = await ethers.getContractFactory("TestConfidentialVestingWalletCliff"); + await expect( + contractFactory + .connect(this.signers.alice) + .deploy(this.signers.alice.address, this.confidentialERC20, startTimestamp, duration, cliff), + ) + .to.be.revertedWithCustomError(this.confidentialVestingWallet, "InvalidCliffDuration") + .withArgs(cliff, duration); + }); +});