Try   HackMD

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.

Contracts will be created based on 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. 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:

  • 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

@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

  • 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

@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

@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

@external
def recover_ether():

Collect Ether from the contract to the owner

  • Reverts if Ether transfer fails
  • Emits ETHRecovered(amount).

Function: update_voting_adapter

@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

@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

@external
def change_manager(manager: address):

Set self.manager to manager

  • Reverts if msg.sender != self.owner.
  • Emits ManagerChanged(manager).

Function: target

@external
@view
def target() -> address:
  • Returns immutable TARGET

Function: token

@external
@view
def token() -> address:
  • Returns immutable TOKEN

Event: VestingEscrowCreated

event VestingEscrowCreated:
  creator: indexed(address)
  escrow: address

Emitted when new vesting escrow is deployed

See: deploy_vesting_contract

Event: ERC20Recovered

event ERC20Recovered:
  token: address
  amount: uint256

Emitted when ERC20 tokens are recovered from escrow to the owner

See: recover_erc20.

Event: ETHRecovered

event ETHRecovered:
  amount: uint256

Emitted when Ether is recovered from escrow to the owner

See: recover_ether.

Event: VotingAdapterUpgraded

event VotingAdapterUpgraded:
  voting_adapter: address

Emitted when VotingAdapter address is updated

See: update_voting_adapter.

Event: OwnerChanged

event OwnerChanged:
  owner: address

Emitted when self.owner is updated

See: change_owner.

Event: ManagerChanged

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

@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

@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

  • 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

@external
@view
def unclaimed() -> uint256:
  • Returns the current amount of the tokens available for the claim.

Function: locked

@external
@view
def locked() -> uint256:
  • Returns the current amount of the tokens locked.

Function: 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.

  • 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

@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

@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

@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

@external
def recover_ether():

Collect all Ether from the contract to the recipient

  • Reverts if Ether transfer fails
  • Emits ETHRecovered(amount).

Function: 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

  • Reverts if msg.sender != self.recipient.

Function: 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

  • Reverts if msg.sender != self.recipient.

Function: 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

  • Reverts if msg.sender != self.recipient.

Event: 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.

Event: Claim

event Claim:
  beneficiary: indexed(address)
  claimed: uint256

Emitted when vested tokens are claimed

See: claim.

Event: 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.

Event: 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.

Event: ERC20Recovered

event ERC20Recovered:
  token: address
  amount: uint256

Emitted when ERC20 tokens are recovered from escrow to the recipient

See: recover_erc20.

Event: ETHRecovered

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

@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

@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

@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

@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

@external
@view
def encode_snapshot_set_delegate_calldata(delegate: address) -> Bytes[1000]:
  • Returns abi encoded params for the snapshot_set_delegate call

Function: delegate

@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

@external
@view
def encode_delegate_calldata(delegate: address) -> Bytes[1000]:
  • Returns abi encoded params for the delegate call

Function: voting_contract_addr

@external
@view
def voting_contract_addr() -> address:
  • Returns immutable VOTING_CONTRACT_ADDR

Function: snapshot_delegate_contract_addr

@external
@view
def snapshot_delegate_contract_addr() -> address:
  • Returns immutable SNAPSHOT_DELEGATE_CONTRACT_ADDR

Function: delegation_contract_addr

@external
@view
def delegation_contract_addr() -> address:
  • Returns immutable DELEGATION_CONTRACT_ADDR

Function: change_owner

@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

@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

@external
def recover_ether():

Collect Ether from the contract to the owner

  • Reverts if Ether transfer fails
  • Emits ETHRecovered(amount).

Event: ERC20Recovered

event ERC20Recovered:
  token: address
  amount: uint256

Emitted when ERC20 tokens are recovered from escrow to the owner

See: recover_erc20.

Event: ETHRecovered

event ETHRecovered:
  amount: uint256

Emitted when Ether is recovered from escrow to the owner

See: recover_ether.

Event: OwnerChanged

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.