Skip to content

Commit

Permalink
Merge pull request #76 from zama-ai/feat/VestingWallet.sol
Browse files Browse the repository at this point in the history
feat: add ConfidentialVestingWallet/ConfidentialVestingWalletCliff
  • Loading branch information
PacificYield authored Dec 23, 2024
2 parents a91c46e + 73be915 commit 500d185
Show file tree
Hide file tree
Showing 8 changed files with 606 additions and 0 deletions.
126 changes: 126 additions & 0 deletions contracts/finance/ConfidentialVestingWallet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: BSD-3-Clause
pragma solidity ^0.8.24;

import "fhevm/lib/TFHE.sol";

import { IConfidentialERC20 } from "../token/ERC20/IConfidentialERC20.sol";

/**
* @title ConfidentialVestingWallet
* @notice This contract offers a simple vesting wallet for ConfidentialERC20 tokens.
* This is based on the VestingWallet.sol contract written by OpenZeppelin.
* see: openzeppelin/openzeppelin-contracts/blob/master/contracts/finance/VestingWallet.sol
* @dev Default implementation is a linear vesting curve.
* To use with the native asset, it is necessary to wrap the native asset to a ConfidentialERC20-like token.
*/
abstract contract ConfidentialVestingWallet {
/// @notice Emitted when tokens are released to the beneficiary address.
/// @param token Address of the token being released.
event ConfidentialERC20Released(address indexed token);

/// @notice Beneficiary address.
address public immutable BENEFICIARY;

/// @notice Duration (in seconds).
uint128 public immutable DURATION;

/// @notice End timestamp.
uint128 public immutable END_TIMESTAMP;

/// @notice Start timestamp.
uint128 public immutable START_TIMESTAMP;

/// @notice Constant for zero using TFHE.
/// @dev Since it is expensive to compute 0, it is stored instead.
/* solhint-disable var-name-mixedcase*/
euint64 internal immutable _EUINT64_ZERO;

/// @notice Total encrypted amount released (to the beneficiary).
mapping(address token => euint64 amountReleased) internal _amountReleased;

/**
* @param beneficiary_ Beneficiary address.
* @param startTimestamp_ Start timestamp.
* @param duration_ Duration (in seconds).
*/
constructor(address beneficiary_, uint128 startTimestamp_, uint128 duration_) {
START_TIMESTAMP = startTimestamp_;
DURATION = duration_;
END_TIMESTAMP = startTimestamp_ + duration_;
BENEFICIARY = beneficiary_;

/// @dev Store this constant variable in the storage.
_EUINT64_ZERO = TFHE.asEuint64(0);

TFHE.allow(_EUINT64_ZERO, beneficiary_);
TFHE.allowThis(_EUINT64_ZERO);
}

/**
* @notice Release the tokens that have already vested.
* @dev Anyone can call this function but the beneficiary receives the tokens.
*/
function release(address token) public virtual {
euint64 amount = _releasable(token);
euint64 amountReleased = TFHE.add(_amountReleased[token], amount);
_amountReleased[token] = amountReleased;

TFHE.allow(amountReleased, BENEFICIARY);
TFHE.allowThis(amountReleased);
TFHE.allowTransient(amount, token);
IConfidentialERC20(token).transfer(BENEFICIARY, amount);

emit ConfidentialERC20Released(token);
}

/**
* @notice Return the encrypted amount of total tokens released.
* @dev It is only reencryptable by the owner.
* @return amountReleased Total amount of tokens released.
*/
function released(address token) public view virtual returns (euint64 amountReleased) {
return _amountReleased[token];
}

/**
* @notice Calculate the amount of tokens that can be released.
* @return releasableAmount Releasable amount.
*/
function _releasable(address token) internal virtual returns (euint64 releasableAmount) {
return TFHE.sub(_vestedAmount(token, uint128(block.timestamp)), released(token));
}

/**
* @notice Calculate the amount of tokens that has already vested.
* @param timestamp Current timestamp.
* @return vestedAmount Vested amount.
*/
function _vestedAmount(address token, uint128 timestamp) internal virtual returns (euint64 vestedAmount) {
return
_vestingSchedule(TFHE.add(IConfidentialERC20(token).balanceOf(address(this)), released(token)), timestamp);
}

/**
* @notice Return the vested amount based on a linear vesting schedule.
* @dev It must be overriden for non-linear schedules.
* @param totalAllocation Total allocation that is vested.
* @param timestamp Current timestamp.
* @return vestedAmount Vested amount.
*/
function _vestingSchedule(
euint64 totalAllocation,
uint128 timestamp
) internal virtual returns (euint64 vestedAmount) {
if (timestamp < START_TIMESTAMP) {
return _EUINT64_ZERO;
} else if (timestamp >= END_TIMESTAMP) {
return totalAllocation;
} else {
/// @dev It casts to euint128 to prevent overflow with the multiplication.
return
TFHE.asEuint64(
TFHE.div(TFHE.mul(TFHE.asEuint128(totalAllocation), timestamp - START_TIMESTAMP), DURATION)
);
}
}
}
51 changes: 51 additions & 0 deletions contracts/finance/ConfidentialVestingWalletCliff.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: BSD-3-Clause
pragma solidity ^0.8.24;

import "fhevm/lib/TFHE.sol";

import { ConfidentialVestingWallet } from "./ConfidentialVestingWallet.sol";

/**
* @title ConfidentialVestingWalletCliff
* @notice This contract offers a simple vesting wallet with a cliff for ConfidentialERC20 tokens.
* This is based on the VestingWalletCliff.sol contract written by OpenZeppelin.
* see: openzeppelin/openzeppelin-contracts/blob/master/contracts/finance/VestingWalletCliff.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 ConfidentialVestingWalletCliff is ConfidentialVestingWallet {
/// @notice Returned if the cliff duration is greater than the vesting duration.
error InvalidCliffDuration(uint128 cliffSeconds, uint128 durationSeconds);

/// @notice Cliff timestamp.
uint128 public immutable CLIFF;

/**
* @param beneficiary_ Beneficiary address.
* @param startTimestamp_ Start timestamp.
* @param duration_ Duration (in seconds).
* @param cliffSeconds_ Cliff (in seconds).
*/
constructor(
address beneficiary_,
uint128 startTimestamp_,
uint128 duration_,
uint128 cliffSeconds_
) ConfidentialVestingWallet(beneficiary_, startTimestamp_, duration_) {
if (cliffSeconds_ > duration_) {
revert InvalidCliffDuration(cliffSeconds_, duration_);
}

CLIFF = startTimestamp_ + cliffSeconds_;
}

/**
* @notice Return the vested amount based on a linear vesting schedule with a cliff.
* @param totalAllocation Total allocation that is vested.
* @param timestamp Current timestamp.
* @return vestedAmount Vested amount.
*/
function _vestingSchedule(euint64 totalAllocation, uint128 timestamp) internal virtual override returns (euint64) {
return timestamp < CLIFF ? _EUINT64_ZERO : super._vestingSchedule(totalAllocation, timestamp);
}
}
15 changes: 15 additions & 0 deletions contracts/test/finance/TestConfidentialVestingWallet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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_,
uint64 startTimestamp_,
uint64 duration_
) ConfidentialVestingWallet(beneficiary_, startTimestamp_, duration_) {
//
}
}
16 changes: 16 additions & 0 deletions contracts/test/finance/TestConfidentialVestingWalletCliff.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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_,
uint64 startTimestamp_,
uint64 duration_,
uint64 cliff_
) ConfidentialVestingWalletCliff(beneficiary_, startTimestamp_, duration_, cliff_) {
//
}
}
30 changes: 30 additions & 0 deletions test/finance/ConfidentialVestingWallet.fixture.ts
Original file line number Diff line number Diff line change
@@ -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,
startTimestamp: bigint,
duration: bigint,
): Promise<TestConfidentialVestingWallet> {
const contractFactory = await ethers.getContractFactory("TestConfidentialVestingWallet");
const contract = await contractFactory.connect(account).deploy(beneficiaryAddress, startTimestamp, duration);
await contract.waitForDeployment();
return contract;
}

export async function reencryptReleased(
account: Signer,
instance: FhevmInstance,
tokenAddress: string,
vestingWallet: ConfidentialVestingWallet,
vestingWalletAddress: string,
): Promise<bigint> {
const releasedHandled = await vestingWallet.released(tokenAddress);
const releasedAmount = await reencryptEuint64(account, instance, releasedHandled, vestingWalletAddress);
return releasedAmount;
}
154 changes: 154 additions & 0 deletions test/finance/ConfidentialVestingWallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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.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.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);
});

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(this.confidentialERC20Address);
await expect(tx)
.to.emit(this.confidentialVestingWallet, "ConfidentialERC20Released")
.withArgs(this.confidentialERC20Address);

// It should be equal to 0 because the vesting has not started.
expect(
await reencryptReleased(
this.beneficiary,
this.instance,
this.confidentialERC20Address,
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(this.confidentialERC20Address);
await tx.wait();

// It should be equal to 1/4 of the amount vested.
expect(
await reencryptReleased(
this.beneficiary,
this.instance,
this.confidentialERC20Address,
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(this.confidentialERC20Address);
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.confidentialERC20Address,
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(this.confidentialERC20Address);
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.confidentialERC20Address,
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));
});
});
Loading

0 comments on commit 500d185

Please sign in to comment.