--- tags: TRP, spec --- # Lido TRP smart-contracts spec > **Disclaimer** > In this doc, we describe only tech specs for the vesting escrow smart contracts that might be used for Lido TRP. > This doc has nothing to do with Lido TRP [policy](https://research.lido.fi/t/lidodao-token-rewards-plan-trp/3364). > Contracts will be created based on [Yearn Vesting Escrow](https://github.com/banteg/yearn-vesting-escrow) ## Simple summary TRP escrow contracts allow transparent on-chain distribution and vesting of the token rewards for the Lido DAO contributors. ## Motivation Lido DAO wants to keep all operations transparent, reliable, and comfortable for contributors. One of the types of compensation for contributors will be Lido governance tokens (LDO) assigned to each contributor with predefined cliff and vesting periods. All operational work on maintaining vesting, withdrawals, handling edge cases (ex., contributor terminates cooperation with Lido DAO), etc., should be performed on-chain with the smart contracts. Here and after, we describe the technical specs of the vesting escrow contracts for Lido DAO TRP. ## Mechanics We suggest using the factory pattern from the original implementation by [Yearn](https://github.com/banteg/yearn-vesting-escrow). Hence each vesting will be a separate smart contract ([EIP-1167: Minimal Proxy Contract](https://eips.ethereum.org/EIPS/eip-1167)) deployed using a [factory](https://github.com/lidofinance/lido-vesting-escrow/blob/main/contracts/VestingEscrowFactory.vy) Escrow Factory will have an `owner` that will have permission to: - Update the "VotingAdapter" address - Update the "manager" address (some committee) - Update "owner" address (most likely Lido DAO) The vesting escrow will maintain the controlled tokens' vesting (with a cliff). Vesting escrow will allow Aragon voting participation with locked tokens, delegation of the locked tokens voting power on Snapshot voting, and a stub method for further voting power delegation using upgradable middleware. Vesting escrow and factory will be **non-upgradable**. ``` // tokens available for claim // | _/-------- // | _/ // | _/ // | _/ // | _/ // | _/ // | _/ // | / // | .| // | . | // | . | // | . | // +==============X========X==============X=============----> time // vesting start cliff end vesting end ``` Escrow will fetch the owner from the factory (most likely Lido DAO Agent) that will have permission to: - Terminate vesting and withdraw all unvested tokens (when contributor leaves DAO) - Terminate vesting and withdraw all tokens (if the contributor has chosen fully revokable escrow) Escrow will fetch the manager from the factory (dev or entity multisig) that will have permission to: - Terminate vesting and withdraw all unvested tokens to the owner (when contributor leaves DAO) Escrow will have a recipient (contributor) that will have permission to: - Claim any amount of tokens up to the amount of currently vested tokens to the arbitrary address - Participate in Aragon voting using all vesting tokens available on the contract's balance - Delegate Snapshot voting power of all vesting tokens available on the contract's balance - Delegate voting power of all available tokens once voting with delegation is implemented Escrow will have permissionless methods to view all escrow params plus: - Amount of tokens available for the claim at the current block and not claimed yet - Amount of tokens remaining locked at the current block Also, Escrow, Factory, and VotingAdapter will have permissionless methods to: - Recover ERC20 tokens accidentally sent to the contract (to the recipient for Escrow and the owner for others) except for the thokens under vesting - Recover Ether accidentally sent to the contract (to the recipient for Escrow and the owner for others) Vesting escrow will have `is_fully_revokable` flag that will enable an additional `revoke_all` method to revoke all escrow tokens by owner. ### Deploy schema 1. TRP committee multisig holds a sufficient amount of the LDO to fund Escrow 2. TRP committee multisig approves the amount of the tokens required for escrow funding 3. TRP committee multisig deploy new VestingEscrow by calling `VestingEscrowFactory.deploy_vesting_contract` providing all params ## VestingEscrowFactory ### Events New escrow deployment emits an event containing all necessary data to reproduce the changes by external indexers: - `VestingEscrowCreated` (once new escrow is deployed) - `ERC20Recovered` (once ERC20 tokens are recovered to `owner`) - `ETHRecovered` (once ETH is recovered to `owner`) - `VotingAdapterUpgraded` (once `VotingAdapter` address is updated) - `OwnerChanged` (once the `owner` address is updated) - `ManagerChanged` (once the `manager` address is updated) ### Permissions `VestingEscrowFactory` is permissionless. Anyone can deploy new vesting escrow. Except for permissioned methods: - `update_voting_adapter` - `change_owner` - `change_manager` that can be called only by `owner`. ### Specification We propose the following interface for `VestingEscrowFactory`. The code below presumes the Vyper v0.3.7 syntax. #### Constructor ```vyper @external def __init__( target: address, token: address, owner: address, manager: address, voting_adapter: address, ): ``` Stores internally `target` as a sample of the escrow to deploy new instances from. Also stores `token`, `owner`, `manager`, and`voting_adapter` to be used in escrow deployments. Set `self.owner = owner` - Reverts if `target` is a zero address. - Reverts if `token` is a zero address. - Reverts if `owner` is a zero address. #### Public variables - `voting_adapter: address` - address of the VotingAdapter used in the vestings - `owner: address` - factory and vestings owner - `manager: address` - vestings manager #### Function: `deploy_vesting_contract` ```vyper @external def deploy_vesting_contract( amount: uint256, recipient: address, vesting_duration: uint256, vesting_start: uint256 = block.timestamp, cliff_length: uint256 = 0, is_fully_revokable: bool = False ) -> address: ``` Deploy and fund a new instance of the `VestingEscrow` for the given `recipient`. Set all params for the deployed escrow - Reverts if `vesting_duration == 0`. - Reverts if `cliff_length > vesting_duration`. - Reverts if `ERC20(self.token).transferFrom(msg.sender, escrow, amount, default_return_value=True)` return False. - Emits `VestingEscrowCreated(msg.sender, escrow)`. - Returns `escrow` - address of the deployed escrow. #### Function: `recover_erc20` ```vyper @external def recover_erc20(token: address, amount: uint256): ``` Collect ERC20 tokens from the contract to the owner - Reverts if `ERC20(token).transfer(self.owner, amount, default_return_value=True)` return False. - Emits `ERC20Recovered(token, amount)`. #### Function: `recover_ether` ```vyper @external def recover_ether(): ``` Collect Ether from the contract to the owner - Reverts if Ether transfer fails - Emits `ETHRecovered(amount)`. #### Function: `update_voting_adapter` ```vyper @external def update_voting_adapter(voting_adapter: address): ``` Set `self.voting_adapter` to `voting_adapter` - Reverts if `msg.sender != self.owner`. - Emits `VotingAdapterUpgraded(voting_adapter)`. #### Function: `change_owner` ```vyper @external def change_owner(owner: address): ``` Set `self.owner` to `owner` - Reverts if `msg.sender != self.owner`. - Reverts if `owner == empty(address)`. - Emits `OwnerChanged(owner)`. #### Function: `change_manager` ```vyper @external def change_manager(manager: address): ``` Set `self.manager` to `manager` - Reverts if `msg.sender != self.owner`. - Emits `ManagerChanged(manager)`. #### Function: `target` ```vyper @external @view def target() -> address: ``` - Returns immutable `TARGET` #### Function: `token` ```vyper @external @view def token() -> address: ``` - Returns immutable `TOKEN` #### Event: `VestingEscrowCreated` ```vyper event VestingEscrowCreated: creator: indexed(address) escrow: address ``` Emitted when new vesting escrow is deployed See: `deploy_vesting_contract` #### Event: `ERC20Recovered` ```vyper event ERC20Recovered: token: address amount: uint256 ``` Emitted when ERC20 tokens are recovered from escrow to the owner See: `recover_erc20`. #### Event: `ETHRecovered` ```vyper event ETHRecovered: amount: uint256 ``` Emitted when Ether is recovered from escrow to the owner See: `recover_ether`. #### Event: `VotingAdapterUpgraded` ```vyper event VotingAdapterUpgraded: voting_adapter: address ``` Emitted when `VotingAdapter` address is updated See: `update_voting_adapter`. #### Event: `OwnerChanged` ```vyper event OwnerChanged: owner: address ``` Emitted when `self.owner` is updated See: `change_owner`. #### Event: `ManagerChanged` ```vyper event ManagerChanged: manager: address ``` Emitted when `self.manager` is updated See: `update_voting_adapter`. ## VestingEscrow ### Events All storage modification functions emit at least a single event containing all necessary data to reproduce the changes by external indexers: - `VestingEscrowInitialized` (once the escrow is funded) - `Claim` (once vested tokens are claimed by the `recipient`) - `UnvestedTokensRevoked` (once vesting is terminated and unvested tokens are revoked to the owner) - `VestingFullyRevoked` (once vesting is terminated and all tokens are revoked to the owner) - `ERC20Recovered` (once ERC20 tokens are recovered to `recipient`) - `ETHRecovered` (once ETH is recovered to `recipient`) ### Permissions The contract fetches `owner` and `manager` from the parent factory. `recipient` is immutable. ### Specification We propose the following interface for `VestingEscrow`. The code below presumes the Vyper v0.3.7 syntax. #### Constructor ```vyper @external def __init__(): ``` The only purpose of the `__init__` method is not to allow calling the `initialize` function on the source contract. #### Public variables - `recipient: address` - address that can claim tokens from escrow - `token: ERC20` - address of the vested token - `start_time: uint256` - vesting start time (UTC time in UNIX seconds) - `end_time: uint256` - vesting end time (UTC time in UNIX seconds) - `cliff_length: uint256` - cliff length in seconds - `factory: IVestingEscrowFactory` - address of the parent factory - `total_locked: uint256` - total amount of the tokens to be vested (does not change after claims) - `is_fully_revokable: bool` - flag showing if the escrow is fully revocable or not - `total_claimed: uint256` - total amount of the claimed tokens - `disabled_at: uint256` - effective vesting end time (UTC time in UNIX seconds). Can differ from end_time in case of the revoke_xxx methods call - `initialized: bool` - flag indicating that escrow was initialized - `is_fully_revoked: bool` - flag indicating that escrow was fully revoked and there are no more tokens #### Function: `initialize` ```vyper @external @nonreentrant("lock") def initialize( token: address, amount: uint256, recipient: address, start_time: uint256, end_time: uint256, cliff_length: uint256, is_fully_revokable: bool, factory: address, ) -> bool: ``` Since the call of the `deploy_vesting_contract` on a `VestingEscrowFactory` is not deploying a copy of the contract but [EIP-1167: Minimal Proxy Contract](https://eips.ethereum.org/EIPS/eip-1167) `initialize` Function is required to set the initial state of the deployed contract and transfer funds - Reverts if `self.initialized`. Escrow can be initialized only once. - Reverts if `self.token.transferFrom(msg.sender, self, amount, default_return_value=True)` return False - Emits `VestingEscrowInitialized( factory, recipient, token, amount, start_time, end_time, cliff_length, is_fully_revokable)`. - Returns `True`. #### Function: `unclaimed` ```vyper @external @view def unclaimed() -> uint256: ``` - Returns the current amount of the tokens available for the claim. #### Function: `locked` ```vyper @external @view def locked() -> uint256: ``` - Returns the current amount of the tokens locked. #### Function: `claim` ```vyper @external def claim( beneficiary: address = msg.sender, amount: uint256 = max_value(uint256) ) -> uint256: ``` Claim tokens to the `beneficiary` address. If the requested amount is larger than `unclaimed`, then the `unclaimed` amount will be claimed. - Reverts if `msg.sender != self.recipient`. - Reverts if `self.token.transfer(beneficiary, claimable, default_return_value=True)` return False. - Emits `Claim(beneficiary, claimable)`. - Returns actual claimed amount. #### Function: `revoke_unvested` ```vyper @external def revoke_unvested(): ``` Disable further flow of tokens and revoke the unvested part to the owner - Reverts if `msg.sender != self.factory.owner()` or `msg.sender != self.factory.manager()` - Reverts if `self.token.transfer(self._owner(), revokable, default_return_value=True)` return Flase. - Emits `UnvestedTokensRevoked(msg.sender, revokable)`. #### Function: `revoke_all` ```vyper @external def revoke_all(): ``` Disable further flow of tokens and revoke all tokens to the owner - Reverts if `self.is_fully_revokable != True`. - Reverts if `msg.sender != self.factory.owner()`. - Reverts if `self.token.transfer(self._owner(), revokable, default_return_value=True)` return Flase. - Emits `VestingFullyRevoked(msg.sender, revokable) `. #### Function: `recover_erc20` ```vyper @external def recover_erc20(token: address, amount: uint256): ``` Collect ERC20 tokens from the contract to the `recipient` - Reverts if `ERC20(token).transfer(self.recipient, recoverable, default_return_value=True)` return False. - Emits `ERC20Recovered(token, amount)`. #### Function: `recover_ether` ```vyper @external def recover_ether(): ``` Collect all Ether from the contract to the `recipient` - Reverts if Ether transfer fails - Emits `ETHRecovered(amount)`. #### Function: `aragon_vote` ```vyper @external def aragon_vote(abi_encoded_params: Bytes[1000]): ``` Participate in the Aragon vote using all available tokens on the contract's balance. Uses `delegateCall` to VotingAdapter. `VotingAdapter` address is fetched from `self.factory`. `abi_encoded_params` can be compiled using `VotingAdapter.encode_aragon_vote_calldata` - Reverts if `msg.sender != self.recipient`. #### Function: `snapshot_set_delegate` ```vyper @external def snapshot_set_delegate(abi_encoded_params: Bytes[1000]): ``` Delegate Snapshot voting power of all available tokens on the contract's balance to `delegate`. Uses `delegateCall` to VotingAdapter. `VotingAdapter` address is fetched from `self.factory`. `abi_encoded_params` can be compiled using `VotingAdapter.encode_snapshot_set_delegate_calldata` - Reverts if `msg.sender != self.recipient`. #### Function: `delegate` ```vyper @external def delegate(abi_encoded_params: Bytes[1000]): ``` Delegate voting power of all available tokens on the contract's balance to `delegate`. Uses `delegateCall` to VotingAdapter. `VotingAdapter` address is fetched from `self.factory`. `abi_encoded_params` can be compiled using `VotingAdapter.encode_delegate_calldata` - Reverts if `msg.sender != self.recipient`. #### Event: `VestingEscrowInitialized` ```vyper event VestingEscrowInitialized: factory: indexed(address) recipient: indexed(address) token: indexed(address) amount: uint256 start_time: uint256 end_time: uint256 cliff_length: uint256 is_fully_revokable: bool ``` Emitted when vesting escrow is initialized See: `initialize`. #### Event: `Claim` ```vyper event Claim: beneficiary: indexed(address) claimed: uint256 ``` Emitted when vested tokens are claimed See: `claim`. #### Event: `UnvestedTokensRevoked` ```vyper event UnvestedTokensRevoked: recoverer: indexed(address) revoked: uint256 ``` Emitted when vested vesting is disabled and unvested tokens are revoked to the owner See: `revoke_unvested`. #### Event: `VestingFullyRevoked` ```vyper event VestingFullyRevoked: recoverer: indexed(address) revoked: uint256 ``` Emitted when vested vesting is disabled and all tokens are revoked to the owner See: `revoke_all`. #### Event: `ERC20Recovered` ```vyper event ERC20Recovered: token: address amount: uint256 ``` Emitted when ERC20 tokens are recovered from escrow to the recipient See: `recover_erc20`. #### Event: `ETHRecovered` ```vyper event ETHRecovered: amount: uint256 ``` Emitted when Ether is recovered from escrow to the recipient See: `recover_ether`. ## VotingAdapter `VotingAdapter` is a permissionless middleware that offers an immutable voting methods interface. Suppose the implementation of the Aragon voting or Snapshot delegation (contract is immutable, but part of the delegation is done off-chain, hence can be changed) has changed, or Voting with delegation will be implemented. In that case, Lido can deploy new `VotingAdapter` and Lido DAO Agent can call `update_voting_adapter` on `VastingEscrowFactory` to make all deployed vestings use it. `VotingAdapter` has `owner` to which tokens and Ether can be recovered ### Events All storage modification functions emit at least a single event containing all necessary data to reproduce the changes by external indexers: - `OwnerChanged` (once `owner` address is changed) - `ERC20Recovered` (once ERC20 tokens are recovered to `recipient`) - `ETHRecovered` (once ETH is recovered to `recipient`) #### Constructor ```vyper @external def __init__( voting_addr: address, snapshot_delegate_addr: address, delegation_addr: address, owner: address, ): ``` Sets immutable ``` VOTING_CONTRACT_ADDR = voting_addr SNAPSHOT_DELEGATE_CONTRACT_ADDR = snapshot_delegate_addr DELEGATION_CONTRACT_ADDR = delegation_addr ``` and mutable `owner` - Reverts if `owner` is a zero address. #### Public variables - `owner: address` - votingAdapter owner #### Function: `aragon_vote` ```vyper @external def aragon_vote(abi_encoded_params: Bytes[1000]): ``` Participate in the Aragon vote using all available tokens on the contract's balance. It makes sense only for delegateCalls, so the caller's balance will be used. Uses `VOTING_CONTRACT_ADDR` as the voting contract address. `abi_encoded_params` can be compiled using `encode_aragon_vote_calldata` #### Function: `encode_aragon_vote_calldata` ```vyper @external @view def encode_aragon_vote_calldata(voteId: uint256, supports: bool) -> Bytes[1000]: ``` - Returns abi encoded params for the `aragon_vote` call #### Function: `snapshot_set_delegate` ```vyper @external def snapshot_set_delegate(abi_encoded_params: Bytes[1000]): ``` Delegate Snapshot voting power of all available tokens. Makes sense only for delegateCalls so that the balance of the caller will be used. Uses `SNAPSHOT_DELEGATE_CONTRACT_ADDR` as the voting contract address. `abi_encoded_params` can be compiled using `encode_snapshot_set_delegate_calldata` #### Function: `encode_snapshot_set_delegate_calldata` ```vyper @external @view def encode_snapshot_set_delegate_calldata(delegate: address) -> Bytes[1000]: ``` - Returns abi encoded params for the `snapshot_set_delegate` call #### Function: `delegate` ```vyper @external def delegate(abi_encoded_params: Bytes[1000]): ``` Stub for the future implementation of the Voting with Delegation. `abi_encoded_params` can be compiled using `encode_delegate_calldata` - Always reverts #### Function: `encode_delegate_calldata` ```vyper @external @view def encode_delegate_calldata(delegate: address) -> Bytes[1000]: ``` - Returns abi encoded params for the `delegate` call #### Function: `voting_contract_addr` ```vyper @external @view def voting_contract_addr() -> address: ``` - Returns immutable `VOTING_CONTRACT_ADDR` #### Function: `snapshot_delegate_contract_addr` ```vyper @external @view def snapshot_delegate_contract_addr() -> address: ``` - Returns immutable `SNAPSHOT_DELEGATE_CONTRACT_ADDR` #### Function: `delegation_contract_addr` ```vyper @external @view def delegation_contract_addr() -> address: ``` - Returns immutable `DELEGATION_CONTRACT_ADDR` #### Function: `change_owner` ```vyper @external def change_owner(owner: address): ``` Set `self.owner` to `owner` - Reverts if `msg.sender != self.owner`. - Reverts if `owner == empty(address)`. - Emits `OwnerChanged(owner)`. #### Function: `recover_erc20` ```vyper @external def recover_erc20(token: address, amount: uint256): ``` Collect ERC20 tokens from the contract to the owner - Reverts if `ERC20(token).transfer(self.owner, amount, default_return_value=True)` return False. - Emits `ERC20Recovered(token, amount)`. #### Function: `recover_ether` ```vyper @external def recover_ether(): ``` Collect Ether from the contract to the owner - Reverts if Ether transfer fails - Emits `ETHRecovered(amount)`. #### Event: `ERC20Recovered` ```vyper event ERC20Recovered: token: address amount: uint256 ``` Emitted when ERC20 tokens are recovered from escrow to the owner See: `recover_erc20`. #### Event: `ETHRecovered` ```vyper event ETHRecovered: amount: uint256 ``` Emitted when Ether is recovered from escrow to the owner See: `recover_ether`. #### Event: `OwnerChanged` ```vyper event OwnerChanged: owner: address ``` Emitted when `self.owner` is updated See: `change_owner`. ## Security Considerations ### Upgradability and mutability All contracts are non-upgradable. `VotingAdapter` address can be updated using `update_voting_adapter` call ### Storage modification is restricted `VestingEscrowFactory` The contract `owner` is eligible update `owner`, `manager` and `voting_adapter`. `VestingEscrow` Once deployed only `total_claimed` and `disabled_at` values can be changed over corresponding methods `VotingAdapter` The contract `owner` is eligible to change `owner` ### Sanity caps and limits None ### Usage of `delegateCall` with an "upgradable" contract Escrow methods `vote`, `set_delegate`, and `delegate` use `delegateCall` to `VotingAdapter` that can be upgraded using `update_voting_adapter` call. It is possible that Lido DAO Agent will perform an update of the `VotingAdapter` in the way that the call of `vote`, `set_delegate`, or `delegate` will result in stealing funds from escrow. We consider this a minor risk due to `msg.sender == self.recipient` restriction in the `vote`, `set_delegate`, and `delegate` escrow methods. The recipient should check the current proxy implementation before calling these methods. ### `revoke_unvested` can be called by `manager` The risk here is that the `manager` can call the `revoke_unvested` method on the escrow while the contributor still works with DAO. This risk can be minimized by assigning a `manager` role to multisig. If the method was called anyway (by mistake or a malicious actor), funds would be transferred to the `owner` (Lido DAO Agent) and can be restored as a new Escrow. ### `revoke_unvested` and `revoke_all` allow revoking funds by malicious `owner` The risk here is that the `owner` can call the `revoke_unvested` (or `revoke_all` for `VestingEscrowFullyRevocable`) method at any time and revoke vesting funds at any time. In case of malicious `owner` funds might be stolen. This risk can be minimized by assigning the `owner` role to Lido DAO Agent that can only execute actions over Aragon votes. ### LDO token implementation allows burning user's tokens The risk here is that Lido DAO can `burn` LDO tokens for any token holder by `destroyTokens` call without interactions with the escrow. This is a general risk. We acknowledge that it exists not only for vesting escrow, but for any LDO holder. ## Reference implementation The reference implementations of the proposed `VestingEscrowFactory`, `VestingEscrow`, `VestingEscrowFullyRevocable`, and `VotingAdapter` contracts are available on the [Lido GitHub](https://github.com/lidofinance/lido-vesting-escrow/blob/main/contracts). ## Links - [Yearn Vesting Escrow](https://github.com/banteg/yearn-vesting-escrow) - [Lido Token Compensation Plan (TRP) vesting contract discussion](https://hackmd.io/bDylyQVeR1eeRfjHwl0JUA)