The contracts were designed with a couple concepts in mind:
It is possible that Tokemak could run multiple, separate, instances of this system on the same chain and the majority of contracts would not be safe to share between the two.
Misconfigurations are a potential source of bugs and this is a highly configurable system. Any validations that can be performed during registration and setup, should.
To assist in enforcing these points, the system revolves around a SystemRegistry contract (src/SystemRegistry.sol
), and most contracts inherit from a SystemComponent (src/SystemComponent.sol
) contract that requires a reference to a SystemRegistry. These allow us to tie the various contracts together and provide a lookup point for the various contracts to talk to one another.
With the exception of the SystemRegistry contract which uses an “onlyOwner” setup for security (which will be granted to a multisig and eventually a Governor contract), all other contracts follow a RBAC security system.
AccessController
src/security/AccessController.sol
This is largely an OZ AccessControlEnumerable contract with the setup functions exposed, however, instead of each contract managing their own permissions, they all reference this one through the SecurityBase
contract.
Given the sensitive nature of this contract, it is one of the contracts that can never be changed or upgraded in the system.
SystemSecurity
src/security/SystemSecurity.sol
This contract allows us to coordinate operations across all Autopools in the system. This coordination falls into two areas:
Pausing
NAV operation coordination
Via the usage of this contract, we are able to pause all Autopool operations in the system. Autopools can still be paused locally or one-by-one, but this gives us a way pause all of them in one go.
Operations in an Autopool can be broken down into ones that can see nav/share go down, and ones that can’t. To ensure proper calculations, operations that SHOULD NOT see a nav/share decrease can never be executed within the context of those that can.
Operations that can see a decrease in nav/share:
Debt reporting - updateDebtReporting()
Rebalances - flashRebalance()
Operations that shouldn’t:
User balance management - deposit() / mint() / redeem() / withdraw()
This restrictions applies cross-Autopool as well. An updateDebtReporting()
call in one Autopool for example, blocks deposit()
in all Autopools during its execution.
Pausable
src/security/Pausable.sol
A near duplicate of the OZ contract by the same name. However, this one incorporates our SystemSecurity contract to support our global-pause behavior. It is used only by our Autopools.
The Stats System is responsible for making all required signals (e.g., staking yield, fee yield, incentive yield, etc) available on-chain. These signals are read by the Strategy to determine if a rebalance is in the best interest of its Autopool. A Calculator
is responsible for storing, augmenting, and cleaning data required to provide one of those signals. Let’s take an example Destination and see how it’s stats are built:
Curve osETH/rETH + Convex Staking
Stat calculators depend on each other to build the full picture of a Destination, with only the top-most calculator being referenced directly by the DestinationVault. In this case, the top-most calculator is the ConvexCalculator
. This calculator is an example of an Incentive calculator. LP tokens staked into Convex automatically earn CRV and CVX. This pool is also configured with SWISE and RPL tokens as extra rewards. And so, some annualized amount of CRV, CVX, SWISE, and RPL will be reported to the Strategy where it will calculate what the yield of those tokens are.
As a dependency, the ConvexCalculator
takes a reference to a calculator responsible for providing stats about the DEX pool itself, a CurveV1PoolNoRebasingStatsCalculator
(V1 calculators work for both Stableswap and Cryptoswap pools). This calculator provides the trading fee yield for the pool.
The last piece(s) are the stats about the LST tokens themselves, osETH and rETH. These calculators, OsethLSTCalculator
and RethLSTCalculator
, were provided to the CurveV1PoolNoRebasingStatsCalculator
as dependencies when it was created. These calculators provide stats such as the base yield and price-to-book value.
With all of these calculators each providing their own data to the overall picture, a Strategy can get an accurate reading of a Destinations return.
Incentive - Responsible for tracking the return of the incentives available when staking in Convex/Aura/Maverick/etc.
DEX - Responsible for tracking the trading fee yield of a given DEX pool
LST - Responsible for tracking the base yield and backing of an LST
A keeper network is used to periodically snapshot new data. Each calculator type defines the frequency and conditions under which a snapshot should be taken. Importantly, each calculator only stores the required information to provide its stats. If it needs to provide stats from another calculator, those are read at the time of the request to ensure that data is consistent.
The purpose of Autopilot is to continuously rebalance Autopool assets into the destinations with the best return/risk profile. However, the best risk/return profile is somewhat subjective, as such, each Autopool has an assigned Strategy
that is responsible for determining if a given rebalance (e.g., swap Pool A LP units for Pool B LP units) meets its requirements. For the LST vaults, there are a number of criteria that are considered in deciding if a rebalance is favorable, including:
Total Slippage - the absolute difference in the value of the in and out LP tokens. Priced using safe price oracles (previously audited).
Increase in expected return metric between the assets being swapped. Expected return is composed of (1) LST base yield, (2) DEX fee return, (3) incentive APR. Expected return metric is calculated as follows:
Swap Loss Offset Period - how long it takes for the incremental expected return to offset the immediate loss (swap cost). The Autopool will utilize an adaptive offset period that determines how strict or loose the offset period is. The LMP rebalance transactions (a.k.a pool/destination swaps) will occur only if the following condition is met:
where,
is the annualized gain in ETH expected to be earned as a result of this rebalance transaction.
are the new and old composite APRs for the destination involved in the swap/rebalance transaction.
is defined as period in days allowed for this rebalance transaction to recoup or recover the slippage loss. Default value is 28. At the minimum value of 7 days, approximately 525 bps of incremental APR will be needed to offset a swap cost of 10 bps. Similarly, a value of 60 days allows ~ 60 bps of incremental APR at 10 bps of swap cost.
is the loss in value in ETH units as a result of this transaction primarily driven by slippage, token swap fees, gas used to execute the rebalance operations.
Swap loss offset period determines how loose or strict the constraint to allowing a pool swap is. A large value relaxes the constraint and will allow many swap transactions to go through. In comparison, a value such as 7 days will require a large hurdle to be met before a destination pool swap transaction is allowed.
If the value of swap loss offset period is fixed at a high value, the Autopool system could exhibit increased turnover and experience increased swapping cost. The following describes the mechanism to adapt the swap loss offset period and tighten or loosen the pool swapping constraint should conditions warrant.
At every pool swap-out transaction, we determine the time elapsed since the last time we added to this pool. A pool swap-out transaction is a non trivial swap out i.e. swaps from WETH reserves (base asset of the Autopool) are not included. Let this time be $t$. Next, we define a constraint violation event as a pool swap out transaction where $t$ ≤ swap_cost_offset_period.
After 5 or greater constraint violation swaps within 10 swaps, the swap loss offset period is decreased by x (tighten step size) days until it reaches the minimum allowed value of 7.
After no swaps transactions occur for the period of y (constraint relax period) days, the swap loss offset period is increased by z (relax step size) day until it reaches the maximum allowed value of 60 days.
This is a test conducted on the NAV (per unit or token) of the Autopool. Swapping costs should be carefully monitored to provide positive value to the Autopool. The 30 followed by 60, 90 day test is meant to allow enough time for the Autopool to earn back the swap costs. Should a lot of swaps have recently occurred (i.e. frequent swaps is not a feature of the system but just happen to occur in clusters some times), looking back in the past far enough should result a positive NAV delta i.e. NAV per token should have increased over time. A variant of this test is a 30-60 NAV test.
The following steps are conducted in this test.
Aligned with some time reference (i.e. 30th of every month), record the NAV of the Autopool.
Calculate the delta with the NAV recorded 30 days in the past.
Calculate the delta with the NAV recorded 60 days in the past
Repeat with NAV recorded 90 days in the past.
If all of the three delta values are negative, pause pool swaps until
the NAV reaches the highest NAV recorded (of the 3 values - 30, 60, 90 days in the past)
90 days have lapsed since the test was conducted
After the pause, set the value of $swap\_cost\_offset\_period$ at the minimum value $swap\_cost\_offset\period{min}$
References:
verifyRebalance is called here by Autopool on rebalance to ensure it is a favorable rebalance.
At least every 24 hours, each Destination configured on the Autopool has its cached price information updated. This has the potential to lower the nav/share of the Autopool right at that moment. When the Autopool sees a profit during debt reporting, that profit is locked up for a period time, say 24 hours. This means any nav/share increase unlocks over that same time period. At the end of a call to debt reporting, AutopoolStrategy.navUpdate()
is called.
The valuation of tokens is core to the operation of this system. All token valuations are performed through the RootPriceOracle
and different approaches for pricing are used depending on the type of token (native or LP) and the intended usage of the price:
These are prices that are calculated/queried from off-chain sources. This includes sources such as Chainlink, Redstone, and Tellor. These are exposed through three calls on the RootPriceOracle
: getPriceInEth()
, getPriceInQuote()
, and one of the return values of getRangePriceLP()
These are mainly used for operations where we aren’t executing with the price at that moment. This can include pricing debt in the Autopools, calculating statistics, etc.
These are prices that are calculated/queried from primarily on-chain sources. These are always in reference to a specific pool that want to know the spot price of the tokens from. To calculate these values, a small amount of token is trial swapped through a pool to determine its execution price and then any fees are factored back in.
For requests that don’t have the requested quote token as one of the tokens in the pools, “safe” prices are used to complete the steps in the conversion.
These prices are exposed through getSpotPriceInEth()
and one of the return values of getRangePriceLP()
.
Spot prices are checked against some tolerance when used within the system to ensure they are safe to use. The spot price of the token must be within some bps of the safe price. These are used during rebalance execution to measure impact on pools we are operating against as well as one of the ends of the getRangePriceLP()
call.
These prices are exclusively used in the Autopools when debt reporting has gone stale. While this should never actually be an occurrence, should a pool enter this state, debt that is stale will be re-valued using this method of pricing. This will take the worst price (depending on the operation that could be the highest or the lowest) between the safe and spot prices of all the tokens that constitute the LP token, and value all the reserves that back that LP back at that single price. This is to ensure that any potential manipulation that could be occurring cannot negatively impact the Autopool.
The system-at-large will only ever interact with the RootPriceOracle. All specific implementations related to the method of pricing an asset are hidden behind here:
The majority of functions on the RootPriceOracle are not view functions even though they are performing “view” type operations. This is due to methods used for pricing. Since we use swaps to calculate prices, and we can’t count on every DEX to have a view operation to perform this, we have to keep the interface as payable.
The following is an example call flow for resolving a complex price. We will assume that the requested price is for the $ABC token in ETH. $ABC token has an $ABC/USD feed through Redstone:
The following is an example call flow for resolving the safe and spot price of an LP token. We will assume this is a Curve pool:
When there is a withdraw from an Autopool, a couple things can happen. If there are enough assets to cover the withdrawal sitting idle in the Autopool, then all of the assets come from there. However, if there is not, then we must pull from the market. Just retrieving the LP positions doesn’t help the user, we must provide them back their funds in the base asset of the Autopool. This is where the SwapRouter comes in.
The SwapRouter contains routes for all the tokens that make up the constituents of any LP token we support, back to the base asset of the Autopool. This is a list that will be kept up to date by the team to ensure users receive the best execution when exiting the pools.
The routes can be simple or complex. We can use the same pools we are in as Destinations or completely different pools should there be better execution.
Simple Example for TokenA → TokenD:
Swap TokenA → TokenD in Balancer TokenA/TokenD Pool
Complex Example for TokenA → TokenD
Swap TokenA → TokenB in Curve TokenA/TokenB Pool
Swap TokenB → TokenC in Uni V3 TokenB/TokenC Pool
Swap TokenC → TokenD in Balancer TokenC/TokenD Pool
The chaining of operations all happen at the SwapRouter level. The only thing an adapter needs to be able to do is to swap between two tokens and validate that a given configuration is supported in the specified pool. They are only callable by the Currently we support:
Balancer Pools
Curve StableSwap Pools
Curve CryptoSwap Pools
UniV3 Pools
Getting information generically about a given Curve pool can be a tricky task given the slight variations between their different types of pools. This is a task we need to perform in many different areas of the app and so this contract was built to assist in that.
Luckily, Curve provides a meta registry themselves that can perform this lookup. However, at the time of writing this, there is a type of pool that is not supported by their registry and that is their Stable-NG type pools. To get around this, the CurveResolver has a fallback method where it attempts to figure out the information based on the successful execution of various functions.
This approach currently works for the types of pools we are interested in supporting. Should new types of pools be introduced in the future, the approaches outlined may need to be re-evaluated.
This contract is an interface to be able push data or events to other chains. It is exposed to the rest of the system as a generic interface but currently relies entirely on Chainlinks CCIP. It is designed to support a couple core features:
Fan-out messaging. Given a sender and message type, send the provided data to one or many destination chains
Retries. The last message of a given sender+type can be re-sent to a destination. This serves a few purposes
Should CCIP ever be down and messages unable to be sent, we should be able to get the data off that we need when it comes back online
Our contract could be out of funds when the send is attempted. We need processes that send the message to continue without reverting, but we want to be able to send that message eventually
Seeding information when standing up the destination chain. Some messages have large delays between them being sent. This can make standing up dependent contracts on the destination difficult and time consuming. Being able to re-send the last message ensures we have the information we need as soon as we can.
Current usage is restricted to just the LST calculators through the LSTCalculatorBase. The stats related to LSTs are specific to their native chain.
accToke gives users the ability to stake their TOKE and earn rewards in WETH. This contract is largely based on https://docs.oeth.com/governance/ogv-staking.
Differences to call out
Rewards can accrue in the contract for a user and don’t actual claiming
Rewards are queued instead of being on an emissions schedule. Rewards will not be added in large amounts to ensure there can’t being meaningful gaming of, and spikes in, accRewardPerShaer
Generally accToke sits on the outside of the platform. It doesn’t have a integration into the Autopilot router. The exception to this is within the AutopoolMainRewarder. We optionally have the ability to configure that if TOKE is the token being rewarded, it can be staked for a period of time into AccToke upon claim.
The Autopilot Router is the main entry for users in the system. This is a multicall-type contract where the exposed functions are designed to be chained together to perform various operations. This can include but is not limited to:
Depositing into an Autopool and staking the tokens into a rewarder:
deposit(autoPool, router, amount, min)
approve(autoPool, rewarder, max)
stakeVaultToken(autoPool, max)
Migrating from one Autopool to another
withdrawVaultToken(autoPool, rewarder, amount, true)
selfPermit(autoPool, amount,….)
redeem(autoPool, router, amount, min)
approve(weth, autoPool2, max)
depositBalance(autoPool2, router, min)
stakeVaultToken(autoPool2, max)
The router also has the ability to swap tokens utilizing one of the AsyncSwappers configured in our system. Whether or not a swap can be utilized depends on the type of operation being performed. If we are able to know the exact number of input tokens upfront then it can be performed. Otherwise, we cannot.
Given the flexibility of the Router it is possible to leave funds in the contract should the order of operations be incorrect. Those funds can then be withdrawn by anyone. This is expected. The Router will be integrated into our front-end and only specific flows will be supported ensuring that all token balances are sent back to the user when appropriate.
Liquidation in Autopilot refers to the process of auto-compounding rewards from our various asset deployments. For efficiency reasons, LiquidationRow attempts to batch as much of the claiming and swapping process as possible. This is broken up into two processes:
Each DestinationVault is required to implement a collectRewards()
function which will claim and transfer any reward tokens back to the liquidator. The exact details of how a claim happen are not of a concern to the liquidator, it is only worried about being told how much of some token it has received. Call flow:
Both the process of claiming and liquidation can be gas intensive given how many DestinationVaults and reward tokens we may need to process in the system. Both of these processes are designed to be able to work on a subset of either the DestinationVaults or the tokens to combat this. The goal is batch as match of the work as possible for efficiency, though. So, during liquidation the balance of tokens across many DestinationVaults are compiled, swapped as a single operation, and the proceeds distributed back to the DestinationVaults according to the share they contributed. Call flow:
This process is one that is permissioned and run by Tokemak. To support the gas and fees required to perform this a fee can optionally be enabled against any liquidated funds.
Destination Vaults act as our proxy to the greater DeFi ecosystem. Their goal is to provide a common interface for us to deposit/withdraw LP tokens and earn yield. Each type of Destination has its own unique concrete DestinationVault implementation. Currently we support:
Balancer Pools
Balancer Pools with LP staked into Aura
Curve Pools with LP staked into Convex
Maverick Boosted Positions
Autopool’s will be the main actors interacting with a Destination Vault. Primarily this will come in the form of deposit and withdrawing LP tokens. The actions taken after a deposit aren’t much of a concern to the Autopool and can vary depending on the specific type of Destination. If this is a just a “Balancer Pool” type of Destination then the LP tokens will stay in the Destination Vault. If this is a Destination that say stakes into Aura, then that operation will happen upon deposit.
Withdrawing can come in two forms: as the LP units, or as the base asset. The LP unit can be withdrawn as part of a rebalance. During a user withdraw where we have to pull from the market, the LP units burned and the resulting tokens will be swapped into the base asset. All base assets in the system will be WETH at this time. Additional validations will be required to support additional base assets.
Depending on the type of Destination Vault, rewards may be due to us for holding or staking the LP units. The implementation of this can be hard coded to return nothing, or to interact with an external protocol. Resulting claimed tokens are sent to the caller of collectRewards()
.
These functions are intentionally left empty at this time.
DestinationVaults have very simple 1271 support. The exact usage of this is unknown at this time. However, much of the current and coming narrative around points and other various off-chain crediting systems present a unique problem for us when it comes to claiming these rewards. It is our hope that protocols will support this interface when it comes to claiming or at least we can prove ownership of transferrability of these rewards via this interface.
Autopools will be the main jumping off point for end-user interactions with the system (though technically this is the Router, these are the tokens the user will get back). Users will deposit and withdraw the base asset from here. Vaults are ERC 4626 + Permit compatible.
An Autopool is actually a pair of contracts. These contracts are:
The pool itself
A Convex-like, block height+rate style, Rewarder
The purpose of an Autopool is to represent a set of destinations that deposited assets may be deployed. It acts almost as an index. If an Autopool points to 5 destinations then your assets may be deployed to one or all of those destinations depending on how much those destinations are currently earning.
An Autopool should track it’s “base asset”. This is the asset that will be deposited and withdrawn from the pool. Any auto-compounding that happens will be in terms of the base asset, as well. However, it is expected that the Autopools associated Rewarder can emit any token(s).
Autopools support being able to recover tokens that are erroneously sent to it. However, we would not want the ability to be able to transfer out any tokens that are core to operation of the Autopool. This would include the base asset and any DestinationVault shares that it currently holds. Tokens that should not be transferred are called “tracked”.
At least every 24 hours, or whenever valuations deviate by a certain threshold, the value of the DestinationVault tokens that are held by the Autopool are re-valued. Deposits and withdraws from the Autopool are based on these cached values. Should this debt reporting result in an increase in value, shares will be minted to the Autopool to offset the increase. This is to ensure there isn’t a sharp increase in the nav/share of Autopool. Auto-compounded rewards are also incorporated during this time.
Two types of fees can be taken by the Autopool and they are taken during Debt Reporting.
Periodic Fee - This is a set fee annualized fee that is taken each Debt Reporting
Streaming Fee - This fee is taken on profit earned by the Autopool. Optionally, this fee can only be taken when profit exceeds previous values (the high watermark).
Should the high watermark be enabled and not be broken for a period of 60 days, it will start to decay until we are able to take fees again
Any locked profit will be burned to offset the dilution incurred by any minted fee shares
An Autopool tracks three valuations for any LP units it holds via a Destination Vault. These are the safe, spot, and mid point price of the LP tokens (see https://docs.tokemak.xyz/developer-docs/contracts-overview/autopool-eth-contracts-overview/autopilot-contracts-and-systems/pricing). These values are used for different purposes in the life cycle of assets in the Autopool:
Higher of safe and spot: Used during deposit to value the assets held by the Autopool
Lower of safe and spot: Used during withdraw to value the assets held by the Autopool
Mid point: Used for general reporting and fee calculations
To value shares for general purposes the standard ERC4626 convertToAssets(uint256)
should be used.
Using different valuations depending on the operation means that to find the maximum amount of assets one would receive during a redeem()
call we need something more than the standard convertToAssets(uint256)
. For this, we have an extension to the standard function:
Calculating the shares you'd receive on a deposit follows very closely to the previous example
Above we went through how the Autopool values the tokens it holds and how to calculate a max return value. However, there is another aspect to a redeem()
call that can affect the assets returned to the user. An Autopool holds a certain percentage of assets in idle to cover small withdraws. However, if an attempted withdraw needs more assets than sit in idle, the Autopool will start the redemption process by going to the market first. This entails removing liquidity from one or more destinations, and swapping out of non-WETH assets. These swaps can result in slippage and fee's taken in the liquidity pool being swapped through. The Autopool will also ensure that any recent price positive movements in the LP tokens being liquidated are captured. All of this is considered slippage that is passed on to the user performing the redeem()
.
To retrieve an accurate estimation of assets that would be returned one can use the previewRedeem()
call. However, it should be noted that this call deviates from the ERC4626 spec in that it is a nonpayable function instead of a view function. No state changes are made as a result of the call, but to provide the estimate state changes are necessary which are later reverted. This should not be called on-chain.
Due to this possible slippage, it is important to note that any call to redeem()
should be checked that an expected amount of assets were received and to revert if not.
Assets can be rebalanced between destinations according the rules of the Autopools configured strategy. Autopools support the ERC3156 flash loan interface and can grant our Solver access to funds to be able convert them into a more desirable Destinations LP token.
GIven that the creation of Autopools may vary widely depending on the type of Autopool being created (future looking), Autopools and their corresponding Factory are a 1:1 relationship. This differs from other factory+register+template relationships in our system: