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:

  1. User A transfers 1 bACE to User B.
  2. Under the hood, bACE balance gets converted to shares, integer division happens and rounding down applies.
  3. The corresponding amount of shares gets transferred from User A to User B.
  4. Shares balance gets converted to bACE balance for User B.
  5. 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:

  1. get bACE token balance;
  2. convert bACE balance into shares balance and use it as a primary balance unit in your dApp;
  3. when any operation on the balance should be done, do it on the shares balance;
  4. 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:

  1. User wraps 1 bACE and gets 0.9803 wbACE (1 bACE = 0.9803 wbACE)
  2. A rebase happens, the wbACE price goes up by 5%
  3. 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, where msg.sender will be used to transfer tokens from and the _owner will be the address that can claim or transfer NFT (defaults to msg.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 the requestWithdrawals(uint256[] _amounts, address _owner) method called afterwards

wbACE

  • Call requestWithdrawalsWbACEWithPermit(uint256[] _amounts, address _owner, PermitInput _permit) and get the ids of created positions, where msg.sender will be used to transfer tokens from, and the _owner will be the address that can claim or transfer NFT (defaults to msg.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 the requestWithdrawalsWbACE(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 owner
  • claimWithdrawals(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()