---
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)