Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ConfidentialVestingWallet/ConfidentialVestingWalletCliff #76

Merged
merged 5 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading