diff --git a/contracts/finance/ConfidentialVestingWallet.sol b/contracts/finance/ConfidentialVestingWallet.sol index 8261c2c..1947f80 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. @@ -36,9 +34,8 @@ abstract contract ConfidentialVestingWallet { /// @notice Constant for zero using TFHE. /// @dev Since it is expensive to compute 0, it is stored instead. - /// However, is not possible to define it as constant due to TFHE constraints. /* solhint-disable var-name-mixedcase*/ - euint64 internal _EUINT64_ZERO; + euint64 internal immutable _EUINT64_ZERO; /// @notice Total encrypted amount released (to the beneficiary). euint64 internal _amountReleased; @@ -56,7 +53,7 @@ abstract contract ConfidentialVestingWallet { END_TIMESTAMP = startTimestamp_ + duration_; BENEFICIARY = beneficiary_; - /// @dev Store this constant-like variable in the storage. + /// @dev Store this constant variable in the storage. _EUINT64_ZERO = TFHE.asEuint64(0); _amountReleased = _EUINT64_ZERO; @@ -70,12 +67,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); + }); +});