Catalist tokens integration guide
This document is intended for developers looking to integrate Catalist's bACE or wbACE as a token into their dApp, with a focus on money markets, DEXes and blockchain bridges.
Catalist
Catalist is a family of liquid staking protocols across multiple blockchains, with headquarters on Aceereum. Liquid refers to the ability of a user’s stake to become liquid. Upon the user's deposit Catalist issues bToken, which represents the deposited tokens along with all the rewards & penalties accrued through the deposit's staking. Unlike the staked funds, this bToken is liquid — it can be freely transferred between parties, making it usable across different DeFi applications while still receiving daily staked rewards. It is paramount to preserve this property when integrating bTokens into any DeFi protocol.
This guide refers to Catalist on Aceereum (hereinafter referred to as Catalist).
Catalist tokens
bTokens: bACE and wbACE
For ace staked in Catalist, the Catalist protocol gives users bACE that is equal to the amount staked.
For easier DeFi integrations, bACE
has a non-rebasable value-accruing counterpart called 'wrapped bACE'
(or just wbACE
).
unbACE
A non-fungible token (NFT) is used to represent a withdrawal request position in the protocol-level withdrawals queue when a bToken holder decides to redeem it for ace via the protocol.
Unlike the other Catalist's tokens (bACE
, wbACE
), unbACE is non-fungible,
and implements the ERC-721 token standard.
bACE vs. wbACE
There are two versions of Catalist's bTokens, namely bACE and wbACE. Both are fungible tokens, but they reflect the accrued staking rewards in different ways. bACE implements rebasing mechanics which means the bACE balance increases periodically. On the contrary, wbACE balance is constant, while the token increases in value eventually (denominated in bACE). At any moment, any amount of bACE can be converted to wbACE via a trustless wrapper and vice versa, thus tokens effectively share liquidity.
bACE
What is bACE
bACE is a rebaseable ERC-20 token that represents ace staked with Catalist. Unlike staked ace, it is liquid and can be transferred, traded, or used in DeFi applications. The total supply of bACE reflects the amount of ace deposited into protocol combined with staking rewards, minus potential validator penalties. bACE tokens are minted upon ace deposit at 1:1 ratio. Since withdrawals from the Beacon chain have been introduced, it is also possible to redeem ace by burning bACE at the same 1:1 ratio (in rare cases it won't preserve 1:1 ratio though).
Please note, Catalist has implemented staking rate limits aimed at reducing the post-Merge staking surge's impact on the staking queue & Catalist’s socialized rewards distribution model. Read more about it here.
bACE is a rebasable ERC-20 token. Normally, the bACE token balances get recalculated daily when the Catalist oracle reports the Beacon Chain ace balance update. The bACE balance update happens automatically on all the addresses holding bACE at the moment of rebase. The rebase mechanics have been implemented via shares (see shares).
Note on ERC-20 compliance
bACE does not strictly comply with ERC-20. The only exception is that it does not emit Transfer()
on rebase as ERC-20 standard requires.
Accounting oracle
Normally, bACE rebases happen daily when the Catalist oracle reports the Beacon chain ace balance update. The rebase can be positive or negative, depending on the validators' performance. In case Catalist's validators get slashed or penalized, the bACE balances can decrease according to penalty sizes. However, daily rebases have never been negative by the time of writing.
Oracle corner cases
- In case oracle daemons do not report Beacon chain balance update or do not reach quorum, the oracle does not submit the daily report, and the daily rebase doesn't occur until the quorum is reached.
- Oracle report might be delayed, but it will include values actual for the reporting refSlot. So, even if reported 2 hours late, it will include only rebase values for the original period.
- In case the quorum hasn't been reached, the oracle can skip the daily report. The report will happen as soon as the quorum for one of the next periods will be reached, and it will include the incremental balance update for all periods since the last successful oracle report.
- Oracle daemons only report the finalized epochs. In case of no finality on the Beacon chain, the daemons won't submit their reports, and the daily rebase won't occur.
- In case sanity checks on max APR or total staked amount drop fail, the oracle report cannot be finalized, and the rebase cannot happen.
bACE internals: share mechanics
Daily rebases result in bACE token balances changing. This mechanism is implemented via shares.
The share
is a basic unit representing the bACE holder's share in the total amount of ace controlled by the protocol. When a new deposit happens, the new shares get minted to reflect what share of the protocol-controlled ace has been added to the pool. When the Beacon chain oracle report comes in, the price of 1 share in bACE is being recalculated. Shares aren't normalized, so the contract also stores the sum of all shares to calculate each account's token balance.
Shares balance by bACE balance can be calculated by this formula:
shares[account] = (balanceOf(account) * totalShares) / totalPooledAceer;
1-2 wei corner case
bACE balance calculation includes integer division, and there is a common case when the whole bACE balance can't be transferred from the account while leaving the last 1-2 wei on the sender's account. The same thing can actually happen at any transfer or deposit transaction. In the future, when the bACE/share rate will be greater, the error can become a bit bigger. To avoid it, one can use transferShares
to be precise.
Example:
- User A transfers 1 bACE to User B.
- Under the hood, bACE balance gets converted to shares, integer division happens and rounding down applies.
- The corresponding amount of shares gets transferred from User A to User B.
- Shares balance gets converted to bACE balance for User B.
- In many cases, the actually transferred amount is 1-2 wei less than expected.
Bookkeeping shares
Although user-friendly, bACE rebases add a whole level of complexity to integrating bACE into other dApps and protocols. When integrating bACE as a token into any dApp, it's highly recommended to store and operate shares rather than bACE public balances directly, because bACE balances change both upon transfers, mints/burns, and rebases, while shares balances can only change upon transfers and mints/burns.
To figure out the shares balance, getSharesByPooledAce(uint256)
function can be used. It returns the value not affected by future rebases and it can be converted back into bACE by calling getPooledAceByShares
function.
Any operation on bACE can be performed on shares directly, with no difference between share and bACE.
The preferred way of operating bACE should be:
- get bACE token balance;
- convert bACE balance into shares balance and use it as a primary balance unit in your dApp;
- when any operation on the balance should be done, do it on the shares balance;
- when users interact with bACE, convert the shares balance back to bACE token balance.
Please note that 10% APR on shares balance and 10% APR on bACE token balance will ultimately result in different output values over time, because shares balance is stable, while bACE token balance changes eventually.
If using the rebasable bACE token is not an option for your integration, it is recommended to use wbACE instead of bACE. See how it works here.
Transfer shares function for bACE
Normally, we transfer bACE using ERC-20 transfer
and transferFrom
functions which accept as an input the amount of bACE, not the amount of the underlying shares.
Sometimes we'd better operate with shares directly to avoid possible rounding issues. Rounding issues usually could appear after a token rebase.
This feature is aimed to provide an additional level of precision when operating with bACE.
Also, V2 upgrade introduced a transferSharesFrom
to completely match ERC-20 set of transfer methods.
Fees
Catalist collects a percentage of the staking rewards as a protocol fee. The exact fee size is defined by the DAO and can be changed in the future via DAO voting. To collect the fee, the protocol mints new bACE token shares and assigns them to the fee recipients. Currently, the fee collected by Catalist protocol is 10% of staking rewards with half of it going to the node operators and the other half going to the protocol treasury.
Since the total amount of Catalist pooled ace tends to increase, the combined value of all holders' shares denominated in bACE increases respectively. Thus, the rewards effectively spread between each token holder proportionally to their share in the protocol TVL. So Catalist mints new shares to the fee recipient so that the total cost of the newly-minted shares exactly corresponds to the fee taken (calculated in basis points):
shares2mint * newShareCost = (_totalRewards * feeBasis) / 10000
newShareCost = newTotalPooledAceer / (prevTotalShares + shares2mint)
which follows:
_totalRewards * feeBasis * prevTotalShares
shares2mint = --------------------------------------------------------------
(newTotalPooledAceer * 10000) - (feeBasis * _totalRewards)
How to get APR?
Please refer to this page for the correct Catalist V2 APR calculation.
It is worth noting that with withdrawals enabled, the APR calculation method for Catalist has changed significantly. When Catalist V2 protocol finalizes withdrawal requests, the Catalist contract excludes funds from TVL and assigns to burn underlying locked requests’ bACE shares in return. In other words, withdrawal finalization decreases both TVL and total shares. The old V1 formula isn’t suitable anymore because it catches TVL changes, but skips total shares changes.
Do bACE rewards compound?
Yes, bACE rewards do compound.
All rewards that are withdrawn from the Beacon chain or received as MEV or EL priority fees (that aren't used to fulfill withdrawal requests) are finally restaked to set up new validators and receive more rewards at the end. So, we can say that bACE becomes fully auto-compounding after V2 release.
wbACE
Due to the rebasing nature of bACE, the bACE balance on the holder's address is not constant, it changes daily as oracle report comes in. Although rebasable tokens are becoming a common thing in DeFi recently, many dApps do not support rebasing. For example, a swap DApp forked from Maker, Uniswap, and SushiSwap are not designed for rebasable tokens. Listing bACE on these apps can result in holders not receiving their daily staking rewards which effectively defeats the benefits of liquid staking. To integrate with such dApps, there's another form of Catalist bTokens called wbACE (wrapped staked ace).
What is wbACE
wbACE is an ERC20 token that represents the account's share of the bACE total supply (bACE token wrapper with static balances). For wbACE, 1 wei in shares equals to 1 wei in balance. The wbACE balance can only be changed upon transfers, minting, and burning. wbACE balance does not rebase, wbACE's price denominated in bACE changes instead.
At any given time, anyone holding wbACE can convert any amount of it to bACE at a fixed rate, and vice versa. The rate is the same for everyone at any given moment. Normally, the rate gets updated once a day, when bACE undergoes a rebase. The current rate can be obtained by calling wbACE.bAcePerToken()
Wrap & Unwrap
When wrapping bACE to wbACE, the desired amount of bACE is locked on the WbACE contract balance, and the wbACE is minted according to the share bookkeeping formula.
When unwrapping, wbACE gets burnt and the corresponding amount of bACE gets unlocked.
Thus, the amount of bACE unlocked when unwrapping is different from what has been initially wrapped (given a rebase happened between wrapping and unwrapping bACE).
wbACE shortcut
Note, that the WbACE contract includes a shortcut to convert ace to wbACE under the hood, which allows you to effectively skip the wrapping step and stake ace for wbACE directly. Keep in mind that when using the shortcut, the staking rate limits still apply.
Rewards accounting
Since wbACE represents the holder's share in the total amount of Catalist-controlled ace, rebases don't affect wbACE balances but change the wbACE price denominated in bACE.
Basic example:
- User wraps 1 bACE and gets 0.9803 wbACE (1 bACE = 0.9803 wbACE)
- A rebase happens, the wbACE price goes up by 5%
- User unwraps 0.9803 wbACE and gets 1.0499 bACE (1 bACE = 0.9337 wbACE)
ERC20Permit
wbACE and bACE tokens implement the ERC20 Permit extension allowing approvals to be made via signatures, as defined in EIP-2612.
The permit
method allows users to modify the allowance using a signed message, instead of through msg.sender
.
By not relying on approve
method, you can build interfaces that will approve and use wbACE in one tx.
Staking rate limits
In order to handle the staking surge in case of some unforeseen market conditions, the Catalist protocol implemented staking rate limits aimed at reducing the surge's impact on the staking queue & Catalist’s socialized rewards distribution model.
There is a sliding window limit that is parametrized with _maxStakingLimit
and _stakeLimitIncreasePerBlock
. This means it is only possible to submit this much ace to the Catalist staking contracts within a 24-hours timeframe. Currently, the daily staking limit is set at 150,000 ace.
You can picture this as a health globe from Diablo 2 with a maximum of _maxStakingLimit
and regenerating with a constant speed per block.
When you deposit ace to the protocol, the level of health is reduced by its amount and the current limit becomes smaller and smaller.
When it hits the ground, the transaction gets reverted.
To avoid that, you should check if getCurrentStakeLimit() >= amountToStake
, and if it's not you can go with an alternative route.
The staking rate limits are denominated in ace, thus, it makes no difference if the stake is being deposited for bACE or using the wbACE shortcut, the limits apply in both cases.
Withdrawals (unbACE)
V2 introduced the possibility to withdraw ACE from Catalist. Withdrawals flow is organized as a FIFO queue that accepts the requests with bACE attached and these requests are finalized with oracle reports as soon as ace to fulfill the request is available.
So to obtain ace from the protocol, you'll need to proceed with the following steps:
- request the withdrawal, locking your bace in the queue and receiving an NFT, that represents your position in the queue
- wait, until the request is finalized by the oracle report and becomes claimable
- claim your ace, burning the NFT
Request size should be at least 100 wei (in bACE) and at most 1000 bACE. Larger amounts should be withdrawn in multiple requests, which can be batched via in-protocol API. Once requested, withdrawal cannot be canceled. The withdrawal NFT can be transferred to a different address, and the new owner will be able to claim the requested withdrawal once finalized.
The amount of claimable ACE is determined once the withdrawal request is finalized. The rate bACE/ACE of the request finalization can't get higher than it's been at the moment of request creation. The user will be able to claim:
- normally – the ACE amount corresponding to the bACE amount at the moment of the request's placement
OR
- discounted - lowered ACE amount corresponding to the oracle-reported share rate in case the protocol had undergone significant losses (slashings and penalties)
The second option is unlikely, and we haven't ever seen the conditions for it on mainnet so far.
The end-user contract to deal with the withdrawals is WithdrawalQueueERC721.sol
, which implements the ERC721 standard. NFT represents the position in the withdrawal queue and may be claimed after the finalization of the request.
Let's follow these steps in detail:
Request withdrawal and mint NFT
You have several options for requesting withdrawals, they require you to have bACE or wbACE on your address:
bACE
- Call
requestWithdrawalsWithPermit(uint256[] _amounts, address _owner, PermitInput _permit)
and get the ids of created positions, wheremsg.sender
will be used to transfer tokens from and the_owner
will be the address that can claim or transfer NFT (defaults tomsg.sender
if it’s not provided) - Alternatively, sending bACE on behalf of
WithdrawalQueueERC721.sol
contract can be approved in a separate upfront transaction (bACE.approve(withdrawalQueueERC721.address, allowance)
), and therequestWithdrawals(uint256[] _amounts, address _owner)
method called afterwards
wbACE
- Call
requestWithdrawalsWbACEWithPermit(uint256[] _amounts, address _owner, PermitInput _permit)
and get the ids of created positions, wheremsg.sender
will be used to transfer tokens from, and the_owner
will be the address that can claim or transfer NFT (defaults tomsg.sender
if it’s not provide) - Alternatively, sending wbACE on behalf of
WithdrawalQueueERC721.sol
contract can be approved in a separate upfront transaction (wbACE.approve(withdrawalQueueERC721.address, allowance)
), and therequestWithdrawalsWbACE(uint256[] _amounts, address _owner)
method called afterwards
PermitInput
structure is defined as follows:
struct PermitInput {
uint256 value;
uint256 deadline;
uint8 v;
bytes32 r;
bytes32 s;
}
After request, ERC721 NFT is minted to _owner
address and can be transferred to the other owner who will have all the rights to claim the withdrawal.
Additionally, this NFT implements the ERC4906 standard and it's recommended to rely on
event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);
to update the NFT metadata if you're integrating it somewhere where it should be displayed correctly.
:::note
Withdrawal transactions made with requestWithdrawalsWithPermit
or requestWithdrawalsWbACEWithPermit
might fail due to being front-run by stealing the user-provided signature to execute token.permit
method. It does not impose any fund loss risks nor blocks the capability to withdraw, but it affects the UX.
Any other viable approach for mitigation might be used as well. As one more example, deploy a wrapper smart contract that tries requestWithdrawalsWithPermit/requestWithdrawalsWithPermitWbACE
and if catches the revert error, continues with requestWithdrawals/requestWithdrawalsWbACE
, checking the allowance is enough.
:::
Checking the state of withdrawal
- You can check all the withdrawal requests for the owner by calling
getWithdrawalRequests(address _owner)
which returns an array of NFT ids. - To check the state of the particular NFTs you can call
getWithdrawalStatus(uint256[] _requestIds)
which returns an array of [WithdrawalRequestStatus
]
struct WithdrawalRequestStatus {
/// @notice bACE token amount that was locked on withdrawal queue for this request
uint256 amountOfBACE;
/// @notice amount of bACE shares locked on withdrawal queue for this request
uint256 amountOfShares;
/// @notice address that can claim or transfer this request
address owner;
/// @notice timestamp of when the request was created, in seconds
uint256 timestamp;
/// @notice true, if request is finalized
bool isFinalized;
/// @notice true, if request is claimed. Request is claimable if (isFinalized && !isClaimed)
bool isClaimed;
}
NOTE: Since bACE is an essential token if the user requests a withdrawal using wbACE directly, the amount will be nominated in bACE on request creation.
You can call getClaimableAceer(uint256[] _requestIds, uint256[] _hints)
to get the exact amount of eth that is reserved for the requests, where _hints
can be found by calling findCheckpointHints(__requestIds, 1, getLastCheckpointIndex())
. It will return a non-zero value only if the request is claimable (isFinalized && !isClaimed
)
Claiming
To claim ace you need to call:
claimWithdrawal(uint256 _requestId)
with the NFT Id on behalf of the NFT ownerclaimWithdrawals(uint256[] _requestIDs, uint256[] _hints)
if you want to claim multiple withdrawals in batches or optimize on hint search- hints =
findCheckpointHints(uint256[] calldata _requestIDs, 1, lastCheckpoint)
- lastCheckpoint =
getLastCheckpointIndex()