Closed Oighty closed 7 months ago
Can you give an example of how the receivedAt
timestamp would be used? The rest of the vesting logic makes sense.
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.
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.
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:
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
andexpiry
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:
address baseToken
uint48 expiry
(calculated as created + duration or passed in as a timestamp)uint8 decimals
(pulled from the baseToken on creation and stored to reference locally)For each user, we also need to store the timestamp that they most recently received tokens on.
uint48 receivedAt
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:
approve
,transfer
,transferFrom
- these can all be modified to revert with a message that the tokens are non-transferableModule Functionality
The key user-facing feature is
claim
ing of vested amounts of base token. Theclaim
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 thereceivedAt
timestamp to the current time. Updating thereceivedAt
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 newreceivedAt
time.The other main functionality for the module is
deploy
ing andmint
ing vesting tokens.deploy
is the same as any other vesting token.mint
requires us to callclaim
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 atransferFrom
.