Smart Contract Details

Smart contract variable, storage and function mechanics

The TokenLockupPlans and VotingTokenLockupPlans (and their non-transferable versions) are built on top of the ERC721Enumerate contracts by OpenZeppelin. The backbone of each lockup plan is the combination of an NFT issued to the beneficiary, mapped to a storage variable, a struct, that contains all of the lockup plan details.

Both sets of contracts are unique, however, in how they handle token voting and delegation. The TokenLockupPlans are optimized for snapshot (or offchain) voting, and use a special ERC721Delegate contract that allows plan owners to delegate their NFT to another wallet, which when combined with the read functions allows Snapshot to simply and elegantly calculate all of the tokens delegated to a specific wallet across all NFTs.

In contrast, the VotingTokenLockupPlans do not use the ERC721Delegate contract, but instead have a built in method to physically separate tokens from the main ERC721 escrow contract, and transfer to a special VotingVault contract. This segregation of tokens enables the owners to directly delegate their tokens, assuming those tokens follow the OpenZeppelin ERC20Votes standard. When a plan beneficiary wants to delegate their locked tokens, they will call the smart contract to setupVoting or delegate their tokens, which then creates a unique VotingVault contract, mapped to the NFT, and then allows the beneficiary and owner of the NFT to delegate the tokens held inside of the VotingVault.

For more detailed information and architecture of the voting and delegation system, read more in the Delegation Architecture section.

Lockup Plan Parameters:

PlanId: The NFT TokenId and planId mapped in storage linking the NFT and Lockup Plan Token: The address of the ERC20 Token that is locked in the contract Amount: The total amount of tokens remaining in the lockup plan (this resets on each claim) Start: The date in which the currently locked amount started (this resets on each claim) Cliff: An optional cliff date, this represents a date after the start in which tokens remain locked, but anything from the start to the cliff date unlock in bulk fashion upon the cliff date Rate: The rate at which tokens unlock, per period Period: The time of each period (in seconds), for streaming style this is 1, but for daily it is 86,400 as an example. The period dictates how often the 'rate' of tokens unlock to the beneficiary.

The Plan is a struct mapped in storage by the NFT token Id (also commonly referred to as the planId). You can query the details of a specific Plan using the public getter function created by the 'plans' mapping, which will return the Plan (struct) details for that particular NFT.

struct Plan {
    address token;
    uint256 amount;
    uint256 start;
    uint256 cliff;
    uint256 rate;
    uint256 period;
mapping(uint256 => Plan) public plans;
// getter function created automatically by the public plans mapping
function plans(uint256 planId) external view returns (Plan);

Creating A Lockup Plan

Creating a new lockup plan involves inputting the parameters (except the PlanId which is assigned) into the createPlan function on the smart contract. This function will transfer tokens from the msg.sender to the ERC721 contract, create a struct in storage, and mint the recipient an NFT. The NFT tokenId is mapped in storage to the struct that has all of the lockup plan parameters.

The tokens, having been pulled from the msg.sender address, are kept in escrow inside the ERC721 contract until they are claimed via redemption by the beneficiary.

Below is the create function used when creating a new lockup plan. The contract will only mint a single plan at a time, so to create them in bulk there is a separate batching contract that loops through an array of plans to mint them.

function createPlan(
    address recipient,
    address token,
    uint256 amount,
    uint256 start,
    uint256 cliff,
    uint256 rate,
    uint256 period
  ) external returns (uint256 newPlanId);

Redeeming and claiming tokens from Lockup Plan

A beneficiary will claim unlocked tokens from their lockup plan, using one of three redemption functions. They do this via calling one of three contract calls, which are laid out below in more detail. Calling any of these functions will use an internal library to calculate the amount of tokens that are unlocked and claimable, and then deliver those tokens to the beneficiary. Then the contract will update its storage of the lockup plan to reduce the amount by what was claimed, so the Amount parameter only reflects the remainder, and will update the Start date, so that the start date reflects the block timestamp of the claim. This storage update is the most efficient way to keep the lockup plan clean so that tokens are redeemed only on the predefined schedule, but without waste of storing additional variables that reflect original amounts and dates.

A lockup plan beneficiary with multiple plans may redeem multiple or all of them at the same time, and the contract will only process the plans that have tokens available to be claimed, otherwise it will skip those entirely.

The first way to redeem tokens is using the redeemPlans method, which takes an array of the planIds (NFT TokenIds). The function will use an internal function _redeemPlan to process each plan individually, calculating the balance that is unlocked. If the unlocked balance is greater than 0, then it will withdraw the tokens from the escrow contract (either the ERC721 contract itself or the VotingVault contract if voting was setup) and transfer them to the beneficiary. The function will then update the storage of the remaining amount still to be unlocked, and the timestamp applicable of the last redemption.

function redeemPlans(uint256[] calldata planIds) external;

The redeemAllPlans function works similarly to the redeemPlans function, except that it leverages the ERC721Enumerable iteration functionality to iterate through all of the plans owned by a single wallet address. Each plan is then processed by the same _redeemPlan function. Because the function does not require any inputs, it is very effective for users manually interacting with the contract but who may not know their lockup plan tokenIDs offhand - and especially easy for custodians calling contract functions without an interface.

function redeemAllPlans() external;

The final redemption function is the partialRedeemPlans function, which takes an additional input of the redemptionTime, and the array of planIds to be redeemed. The redemptionTime parameter allows an owner to partially redeem tokens from their plan(s) with a backdate. The function will revert if a timestamp is put into the future. The internal library will calculate the amount of tokens that have unlocked as of the redemptionTime, and then withdraw that balance to the beneficiary.

function partialRedeemPlans(uint256[] calldata planIds, uint256 redemptionTime) external;

Last updated