-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #76 from zama-ai/feat/VestingWallet.sol
feat: add ConfidentialVestingWallet/ConfidentialVestingWalletCliff
- Loading branch information
Showing
8 changed files
with
606 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
16
contracts/test/finance/TestConfidentialVestingWalletCliff.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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_) { | ||
// | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}); | ||
}); |
Oops, something went wrong.