December 19, 2023: 0xBabe's in Toyland 👯♀️🎁
Dissecting the Vest-Split factory contract, soon to deliver gifts for Christmas
The Curve Vest-Split github repository is live, and has been deployed for all the affected pools.
Next question is who will be the white knight who votes to execute the funding of these contracts.
Any reward for such user? Perhaps that’s a job for the Llamas…
Today we’ll walk through the code of these vest-split contracts, because it’s remarkably easy for even beginner users to understand.
The simplicity of the vest-split contract stands in stark contrast with the legwork required to calculate the actual splits. The latter required meticulous precision over multiple protocols, and is the primary reason it took several months to prepare the hack response. Take a look at all the separate snapshots which had to go into the CRV/ETH recovery:
Meanwhile, the vest-split contract simply ingests the aggregated results into a simple repository that deploys the final contracts, sets the distributions, and finalizes each contract.
The actual VestSplitter.vy contracts contain a parsimonious 99 lines of code.
You’ll observe the well-documented contract states up front it can “be used for other purposes” — as we walk through the code we’ll note where it could easily be expanded for more general purposes, though it was not particularly necessary to do so in the Vyper hack recovery use case.
State Variables
The contract’s storage shows off a limited set of variables needed to work with these contracts.
The VestingEscrow
interface allows for the funds used for recovery to be stored at any external address if desired (presuming, of course, the external contract has the requisite functions and approvals). This might be useful if you wanted to do something fancier… say… stake the token to earn rewards while it sat in escrow.
For the sake of these contracts, the “vest” contract is set to itself or not yet set (for the contracts handling $CRV rewards, explained later).
Otherwise, the storage variables include some immutable addresses set upon deployment,
Of course these can’t be changed, but if you screwed it up somehow you could just redeploy (if there’s no money already contained in the contract at least!). You could expand this for general usage if you are adapting this to your purposes, but more complexity of course opens additional security risks, so it makes sense to keep it simple. For other purposes, a time window to claim funds and allow admin recovery of leftovers may be useful, but that’s very different from Curve’s use case.
The “finalized” boolean signifies that all the distribution weights are finalized and allows users to begin making claims.
Other storage variables are used for bookkeeping the state of the claims process. Each user’s share is represented as a fraction, with the fractions
HashMap containing the user’s numerator and the total_fraction
variable containing the denominator.
Admin Functions
The handful of admin functions reserved for the deployer are limited to setting the vesting escrow contract that holds the funds and the amounts that addresses are authorized to claim.
The vest contract address is the only address that can be changed after everything else has been finalized. This is necessary because of a chicken/egg problem here, in that the Curve DAO is voting to disburse funds to the escrow factory (ie here), which in turn deploys a new VotingEscrowSimple contract to gradually release funds. Once this contract is created, the vest address can be set (and cannot be changed). The splits really need to be finalized before a DAO vote, otherwise it would be potentially misleading, hence the circularity.
The distribution weights is where the results from the calculated snapshots are stored. If you’re a poor person like me, you might fret at the gas costs of applying the weights in this manner. That is — its not terribly expensive to the end user making the claim, but writing each user’s weight in batches of up to 200 users at a time is incredibly repetitive and expensive for the admin.
In this case, with gas prices often elevated, writing the weights of $CRV to the CRV/ETH recovery contract alone cost over 1 ether in gas!
If your organization was so gas constrained you needed to do this in a more efficient manner, you would look at instead storing a merkle root, then keeping the distributions weights stored offline and passed with a proof at the time of claim.
The primary disadvantage of this approach being that it’s more difficult to execute the claim from a UI perspective if you must pass a merkle proof. The actual claim function can be done without any arguments, so users can easily claim immediately through Etherscan if the UI is not ready or malfunctioning, a convenience that turns out to cost several thousands of dollars to implement.
Public Functions
Wrapping up the contract, the two public functions accomplish very nearly the same thing so we include them both here.
The logic is so identical that both could be piped through an internal function on a refactor. One key difference to note is that balanceOf()
is read-only, while claim()
actually executes a claim before calculating balances, so claiming your balance may actually give you slightly more than the calculated amount.
Otherwise, the logic is some very simple (and extremely important!) math. Each time the claim function is called, the total_granted
variable is updated with the balance changes between the intervals. For the read-only balanceOf()
function this requires simulating the amount that would be claimed if necessary, and otherwise actually running the claim and updating the stored balance.
Finally, the amount to transfer is calculated as the user’s fraction as stored in the HashMap and divided by total_fraction
. The amount claimed previously is deducted from this, and the new claim total is then stored, executed, and logged.
Notably, the functions are designed to be easy enough that they can be used directly on Etherscan in the absence of a UI. We’d hope users are capable of pasting their address into a balanceOf()
function call by this point, and claim()
is even easier does not require any arguments. There are optional parameters that could be passed, such as claiming vested tokens (defaults to true) or claiming on behalf of another user (defaults to the sender), but simplicity really is the name of the game here.
Tests
Final highlight worth showing off is the test suite, performed entirely in Titanoboa.
In particular, the use of Hypothesis stateful testing is a great example of how to use the advanced feature. In the simplest tests, you just provide the inputs and confirm the outputs. More advanced testing involves sweeping over a range of inputs and testing against edge cases.
Hypothesis stateful testing goes a step further, where you suggest potential viable actions that may be taken against the contract, and then Hypothesis sweeps through combinations of potentially viable actions, looking for a combo that triggers problems.
You can see the various rules are defined within the StatefulVest machine as actions which may be taken on the contract.
Some of the rules involve actions which may theoretically affect the contract’s logic, like mixing up whether you pass extra parameters to the contract or whether some good Samaritan has topped it up.
You could of course go even crazier from here, but for a fairly simple contract there’s little we see here that one might imagine requires a more thorough audit.
Great stuff, give it a readthrough. It’s like peeking behind the wrapping paper to see your Christmas haul in advance!