Axis-Fi / axis-core

Axis Protocol
https://axis.finance
Other
6 stars 1 forks source link

Soulbound Linear Vesting Derivative Module #28

Closed Oighty closed 7 months ago

Oighty commented 7 months ago

A key initial use case for the LSBBA batch auction is token launches. As part of that use case, we'd like to allow projects to have the tokens they sell vest over a certain amount of time. Previously, we've used Cliff vesting token variants due to the simplicity of implementing them with transferability. However, for token launches, it would not be good to have a whole lot of supply become available at the same time after a token launch. Therefore, we need to introduce Linear vesting. The downside of Linear vesting is that the accounting of which tokens are vested and claimable gets messy if you allow users to transfer them. The simplest version then is a Linear vesting token that is non-transferable, aka "soulbound".

Specification

Parameters

The required parameters to create a vesting token are:

  1. Base token - the token the user can claim once it has vested
  2. Duration - the amount of time the tokens vest over

Math

Since we are using Linear vesting, the receiver of the vesting token should have the claimable amount of their balance increase linearly from the time they receive it. Here is an example to illustrate:

An auction is settled at time $s$ and a user receives $x$ vesting tokens as their payout. The tokens vest linearly over the internal $[s, s + d]$ where $d$ is the vesting duration. The user is able to claim the amount vested at any point in time. If a user claims tokens, we increment the amount claimed $c$. Then, we can calculate the amount claimable by the user at a time $t \in [s, s + d]$ as: $$X(t) = x \frac{t - s}{d} - c$$

As we'll see below, subtracting amount claimed is not necessary if you have some guarantees on the user balance representing only non-claimed tokens.

In the implementation, it will be easier to store created and expiry timestamps, so we can use this alternate form: $$X(t) = x \frac{t - s}{T - s}$$ where $T$ is the expiry timestamp.

Tokenization

At the end of an auction, all users will receive the same type of tokens with the amounts dictated by the amount they paid into the auction. Therefore, it doesn't make sense to create a new token type for each user (e.g. ERC721). Since these tokens are fungible, we can implement these tokens with either a gas-efficient multi-token standard (ERC6909) or use standard fungible token (ERC20). Here, I'll describe the multi-token version, which we can allow to be optionally provided as an ERC20 via wrapping.

Token Data and ID

We use the ERC6909 with TokenSupply extension to create tokens within a single contract that behave like ERC20s from a supply and balance perspective. In addition to the balance data, we need to store the parameters for a token:

For each user, we also need to store the timestamp that they most recently received tokens on.

Each token on a multi-token standard needs a unique ID to use as a reference. In order to allow for easy lookup of tokens, we can use a hash to compute the ID: id = keccak256(abi.encodePacked(baseToken, expiry));

In the past, we've rounded expiry (and in this case created) timestamps to the nearest day to have some consistency, but that isn't strictly required. It would pretty much guarantee separate tokens for each market. We don't strictly need them to be different tokens because we have to handle situations where a user receives multiple mints of a token anyway. I actually prefer them to be the same if the baseToken and expiry line up.

Token Standard Modifications

In order to make the tokens soulbound and have assurances around balance calculations, we need to modify a few of the functions:

Module Functionality

The key user-facing feature is claiming of vested amounts of base token. The claim function should check the amount claimable using the math above, send that amount of base tokens to the user, burn the corresponding amount of vesting tokens, and update the receivedAt timestamp to the current time. Updating the receivedAt timestamp gives us a way to ensure that the vesting math will be accurate on the reduced balance that was burned. This also allows us to mint them additional tokens at the new receivedAt time.

The other main functionality for the module is deploying and minting vesting tokens. deploy is the same as any other vesting token. mint requires us to call claim prior for the tokenId prior to issuing new tokens to a user to avoid issues with vesting balance calculations as mentioned above.

Collateral

Minting a vesting token requires depositing the same amount of baseToken into the Derivative module. The module should expect an approval is made from the calling address (likely the AuctionHouse) and it will pull the tokens into the contract with a transferFrom.

0xJem commented 7 months ago

Can you give an example of how the receivedAt timestamp would be used? The rest of the vesting logic makes sense.

0xJem commented 7 months ago

I guess the assumption is that if the vested tokens have been claimed/redeemable at receivedAt, then it can be used to calculate what should have already vested by that timestamp and deduct it as the "claimed amount", instead of storing the claimed amount.

Oighty commented 7 months ago

Not exactly. I realized what I wrote in the Math and Tokenization is confusing.

If you replace $s$ in the equation with receivedAt and have the mint function claim the current redeemable amount for the user, then you don't have to store the claimed amount. You also don't need to store a global start variable. The receivedAt amount is updated anytime the user receives more tokens (which handles cases of receiving after deployment). Since any redeemable is claimed at that point, the vesting math is still correct because you're just vesting the remaining amount over the remaining duration.