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.
Contracts will be created based on Yearn Vesting Escrow
TRP escrow contracts allow transparent on-chain distribution and vesting of the token rewards for the Lido DAO contributors.
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.
We suggest using the factory pattern from the original implementation by Yearn. Hence each vesting will be a separate smart contract (EIP-1167: Minimal Proxy Contract) deployed using a factory
Escrow Factory will have an owner
that will have permission to:
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:
Escrow will fetch the manager from the factory (dev or entity multisig) that will have permission to:
Escrow will have a recipient (contributor) that will have permission to:
Escrow will have permissionless methods to view all escrow params plus:
Also, Escrow, Factory, and VotingAdapter will have permissionless methods to:
Vesting escrow will have is_fully_revokable
flag that will enable an additional revoke_all
method to revoke all escrow tokens by owner.
VestingEscrowFactory.deploy_vesting_contract
providing all paramsNew 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)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
.
We propose the following interface for VestingEscrowFactory
.
The code below presumes the Vyper v0.3.7 syntax.
@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
, andvoting_adapter
to be used in escrow deployments.
Set self.owner = owner
target
is a zero address.token
is a zero address.owner
is a zero address.voting_adapter: address
- address of the VotingAdapter used in the vestingsowner: address
- factory and vestings ownermanager: address
- vestings managerdeploy_vesting_contract
@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
vesting_duration == 0
.cliff_length > vesting_duration
.ERC20(self.token).transferFrom(msg.sender, escrow, amount, default_return_value=True)
return False.VestingEscrowCreated(msg.sender, escrow)
.escrow
- address of the deployed escrow.recover_erc20
@external
def recover_erc20(token: address, amount: uint256):
Collect ERC20 tokens from the contract to the owner
ERC20(token).transfer(self.owner, amount, default_return_value=True)
return False.ERC20Recovered(token, amount)
.recover_ether
@external
def recover_ether():
Collect Ether from the contract to the owner
ETHRecovered(amount)
.update_voting_adapter
@external
def update_voting_adapter(voting_adapter: address):
Set self.voting_adapter
to voting_adapter
msg.sender != self.owner
.VotingAdapterUpgraded(voting_adapter)
.change_owner
@external
def change_owner(owner: address):
Set self.owner
to owner
msg.sender != self.owner
.owner == empty(address)
.OwnerChanged(owner)
.change_manager
@external
def change_manager(manager: address):
Set self.manager
to manager
msg.sender != self.owner
.ManagerChanged(manager)
.target
@external
@view
def target() -> address:
TARGET
token
@external
@view
def token() -> address:
TOKEN
VestingEscrowCreated
event VestingEscrowCreated:
creator: indexed(address)
escrow: address
Emitted when new vesting escrow is deployed
See: deploy_vesting_contract
ERC20Recovered
event ERC20Recovered:
token: address
amount: uint256
Emitted when ERC20 tokens are recovered from escrow to the owner
See: recover_erc20
.
ETHRecovered
event ETHRecovered:
amount: uint256
Emitted when Ether is recovered from escrow to the owner
See: recover_ether
.
VotingAdapterUpgraded
event VotingAdapterUpgraded:
voting_adapter: address
Emitted when VotingAdapter
address is updated
See: update_voting_adapter
.
OwnerChanged
event OwnerChanged:
owner: address
Emitted when self.owner
is updated
See: change_owner
.
ManagerChanged
event ManagerChanged:
manager: address
Emitted when self.manager
is updated
See: update_voting_adapter
.
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
)The contract fetches owner
and manager
from the parent factory.
recipient
is immutable.
We propose the following interface for VestingEscrow
.
The code below presumes the Vyper v0.3.7 syntax.
@external
def __init__():
The only purpose of the __init__
method is not to allow calling the initialize
function on the source contract.
recipient: address
- address that can claim tokens from escrowtoken: ERC20
- address of the vested tokenstart_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 secondsfactory: IVestingEscrowFactory
- address of the parent factorytotal_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 nottotal_claimed: uint256
- total amount of the claimed tokensdisabled_at: uint256
- effective vesting end time (UTC time in UNIX seconds). Can differ from end_time in case of the revoke_xxx methods callinitialized: bool
- flag indicating that escrow was initializedis_fully_revoked: bool
- flag indicating that escrow was fully revoked and there are no more tokensinitialize
@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 initialize
Function is required to set the initial state of the deployed contract and transfer funds
self.initialized
. Escrow can be initialized only once.self.token.transferFrom(msg.sender, self, amount, default_return_value=True)
return FalseVestingEscrowInitialized( factory, recipient, token, amount, start_time, end_time, cliff_length, is_fully_revokable)
.True
.unclaimed
@external
@view
def unclaimed() -> uint256:
locked
@external
@view
def locked() -> uint256:
claim
@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.
msg.sender != self.recipient
.self.token.transfer(beneficiary, claimable, default_return_value=True)
return False.Claim(beneficiary, claimable)
.revoke_unvested
@external
def revoke_unvested():
Disable further flow of tokens and revoke the unvested part to the owner
msg.sender != self.factory.owner()
or msg.sender != self.factory.manager()
self.token.transfer(self._owner(), revokable, default_return_value=True)
return Flase.UnvestedTokensRevoked(msg.sender, revokable)
.revoke_all
@external
def revoke_all():
Disable further flow of tokens and revoke all tokens to the owner
self.is_fully_revokable != True
.msg.sender != self.factory.owner()
.self.token.transfer(self._owner(), revokable, default_return_value=True)
return Flase.VestingFullyRevoked(msg.sender, revokable)
.recover_erc20
@external
def recover_erc20(token: address, amount: uint256):
Collect ERC20 tokens from the contract to the recipient
ERC20(token).transfer(self.recipient, recoverable, default_return_value=True)
return False.ERC20Recovered(token, amount)
.recover_ether
@external
def recover_ether():
Collect all Ether from the contract to the recipient
ETHRecovered(amount)
.aragon_vote
@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
msg.sender != self.recipient
.snapshot_set_delegate
@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
msg.sender != self.recipient
.delegate
@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
msg.sender != self.recipient
.VestingEscrowInitialized
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
.
Claim
event Claim:
beneficiary: indexed(address)
claimed: uint256
Emitted when vested tokens are claimed
See: claim
.
UnvestedTokensRevoked
event UnvestedTokensRevoked:
recoverer: indexed(address)
revoked: uint256
Emitted when vested vesting is disabled and unvested tokens are revoked to the owner
See: revoke_unvested
.
VestingFullyRevoked
event VestingFullyRevoked:
recoverer: indexed(address)
revoked: uint256
Emitted when vested vesting is disabled and all tokens are revoked to the owner
See: revoke_all
.
ERC20Recovered
event ERC20Recovered:
token: address
amount: uint256
Emitted when ERC20 tokens are recovered from escrow to the recipient
See: recover_erc20
.
ETHRecovered
event ETHRecovered:
amount: uint256
Emitted when Ether is recovered from escrow to the recipient
See: recover_ether
.
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
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
)@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
owner
is a zero address.owner: address
- votingAdapter owneraragon_vote
@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
encode_aragon_vote_calldata
@external
@view
def encode_aragon_vote_calldata(voteId: uint256, supports: bool) -> Bytes[1000]:
aragon_vote
callsnapshot_set_delegate
@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
encode_snapshot_set_delegate_calldata
@external
@view
def encode_snapshot_set_delegate_calldata(delegate: address) -> Bytes[1000]:
snapshot_set_delegate
calldelegate
@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
encode_delegate_calldata
@external
@view
def encode_delegate_calldata(delegate: address) -> Bytes[1000]:
delegate
callvoting_contract_addr
@external
@view
def voting_contract_addr() -> address:
VOTING_CONTRACT_ADDR
snapshot_delegate_contract_addr
@external
@view
def snapshot_delegate_contract_addr() -> address:
SNAPSHOT_DELEGATE_CONTRACT_ADDR
delegation_contract_addr
@external
@view
def delegation_contract_addr() -> address:
DELEGATION_CONTRACT_ADDR
change_owner
@external
def change_owner(owner: address):
Set self.owner
to owner
msg.sender != self.owner
.owner == empty(address)
.OwnerChanged(owner)
.recover_erc20
@external
def recover_erc20(token: address, amount: uint256):
Collect ERC20 tokens from the contract to the owner
ERC20(token).transfer(self.owner, amount, default_return_value=True)
return False.ERC20Recovered(token, amount)
.recover_ether
@external
def recover_ether():
Collect Ether from the contract to the owner
ETHRecovered(amount)
.ERC20Recovered
event ERC20Recovered:
token: address
amount: uint256
Emitted when ERC20 tokens are recovered from escrow to the owner
See: recover_erc20
.
ETHRecovered
event ETHRecovered:
amount: uint256
Emitted when Ether is recovered from escrow to the owner
See: recover_ether
.
OwnerChanged
event OwnerChanged:
owner: address
Emitted when self.owner
is updated
See: change_owner
.
All contracts are non-upgradable.
VotingAdapter
address can be updated using update_voting_adapter
call
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
None
delegateCall
with an "upgradable" contractEscrow 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.
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.
The reference implementations of the proposed VestingEscrowFactory
, VestingEscrow
, VestingEscrowFullyRevocable
, and VotingAdapter
contracts are available on the Lido GitHub.