Skip to content

ebtc-protocol/ebtc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

eBTC

eBTC is a collateralized crypto asset soft pegged to the price of Bitcoin and built on the Ethereum network. It is backed exclusively by Staked Ether (stETH) and powered by immutable smart contracts with minimized counterparty reliance. It’s designed to be the most decentralized synthetic BTC in DeFi and offers the ability for anyone in the world to borrow BTC at no cost.

After locking up stETH as collateral in a smart contract and creating an individual position called a "CDP", the user can get instant liquidity by minting eBTC. Each CDP is required to be collateralized at a fixed minimum ratio determined by the protocol.

The redemption and liquidation mechanisms help ensure that stability is maintained through economically-driven user interactions and arbitrage, rather than through active governance or monetary interventions.

eBTC Audit Reports

eBTC System Summary

eBTC Overview

eBTC is a collateralized crypto asset soft pegged to the price of Bitcoin and built on the Ethereum network. It is backed exclusively by Lido's stETH and powered by immutable smart contracts with minimized counterparty reliance. It’s designed to be the most decentralized synthetic BTC in DeFi and offers the ability for anyone in the world to borrow BTC at no cost.

A CDP is the unit of accounting used to track a specific borrowed debt amount, the respective collateral that backs it as well as the ratio between the value of these two assets, known as the Individual Collateral Ratio (ICR). Each CDP is tied and owned by a single Ethereum account.

CDP owners have the freedom to make adjustments to their CDPs at any time by increasing their collateral, withdrawing some collateral, borrowing more debt, or repaying a part or the full outstanding debt. Any modification to the CDP triggers a corresponding adjustment to the ICR.

The eBTC Token is designed with economic properties that aim to maintain price parity with BTC. These properties include:

  1. The system is designed to always be over-collateralized - the dollar value of the locked stETH exceeds the dollar value of the issued eBTC.

  2. eBTC token are fully redeemable - users can always swap $x worth of eBTC for $x worth of stETH (minus fees), directly with the system.

After opening a CDP with some stETH, users may issue ("borrow") tokens such that the collateralization ratio of their CDP remains above 110%. A user with $1000 worth of stETH in a CDP can issue up to $909.09 worth of eBTC.

The tokens are freely exchangeable - anyone with an Ethereum address can send or receive eBTC tokens, whether they have an open CDP or not. The tokens are burned upon repayment of a CDP's debt.

The eBTC system regularly updates the stETH:BTC price via a decentralized data feed. When a CDP falls below a minimum collateralization ratio (MCR) of 110%, it is considered under-collateralized, and is vulnerable to liquidation.

Liquidations

eBTC implements an open and incentivized liquidation mechanism, where any user can liquidate a CDP that does not have enough collateral. As a reward for their service, the liquidator receives a percentage of the CDP's collateral, ranging from 3% to 10%. Additionally, the liquidator also receives a "Gas Stipend" of 0.2 stETH, which is previously deposited by the borrower as insurance against liquidation costs. See this for details.

Anyone may call the public liquidate() function, which will allow the liquidation of under-collateralized CDPs. Alternatively they can call batchLiquidateCdps() with a custom list of CDP addresses to attempt to liquidate.

eBTC Token Redemption

Any eBTC holder (whether or not they have an active CDP) may redeem their eBTC directly with the system. Their eBTC is exchanged for stETH, at face value: redeeming x eBTC tokens returns $x worth of stETH (minus a redemption fee).

When eBTC is redeemed for stETH, the system cancels the eBTC with debt from CDPs, and the stETH is drawn from their collateral.

In order to fulfill the redemption request, CDPs are redeemed from in ascending order of their collateralization ratio.

A redemption sequence of n steps will fully redeem from up to n-1 CDPs, and, and partially redeems from up to 1 CDP, which is always the last CDP in the redemption sequence.

Redemptions are blocked when TCR < 110% (there is no need to restrict ICR < TCR). At that TCR redemptions would likely be unprofitable, as eBTC is probably trading below the price of 1 BTC if the system has crashed that badly, but it could be a way for an attacker with a lot of eBTC to lower the TCR even further.

Note that redemptions are disabled during the first 14 days of operation since deployment of the eBTC protocol to protect the monetary system in its infancy.

Partial redemption

Most redemption transactions will include a partial redemption, since the amount redeemed is unlikely to perfectly match the total debt of a series of CDPs.

The partially redeemed CDP is re-inserted into the sorted list of CDPs, and remains active, with reduced collateral and debt.

Full redemption

If we assume the fixed liquidation incentive is 200 units, A CDP is defined as “fully redeemed from” when the redemption has caused its debt to absorb (debt-200) eBTC. Then, its 200 eBTC Liquidation Reserve is cancelled with its remaining 200 debt: the Liquidation Reserve is burned from the gas address, and the 200 debt is zero’d.

Before closing, we must handle the CDP’s collateral surplus: that is, the excess stETH collateral remaining after redemption, due to its initial over-collateralization.

This collateral surplus is sent to the CollSurplusPool, and the borrower can reclaim it later. The CDP is then fully closed.

Redemptions create a price floor

Economically, the redemption mechanism creates a hard price floor for eBTC, ensuring that the market price stays at or near to 1 Bitcoin.

Recovery Mode

Recovery Mode kicks in when the total collateralization ratio (TCR) of the system falls below 125%.

During Recovery Mode, liquidation conditions are relaxed, and the system blocks borrower transactions that would further decrease the TCR. New eBTC may only be issued by adjusting existing CDPs in a way that improves their ICR, or by opening a new CDP with an ICR of >=125%. In general, if an existing CDP's adjustment reduces its ICR, the transaction is only executed if the resulting TCR is above 125%

Recovery Mode is structured to incentivize borrowers to behave in ways that promptly raise the TCR back above 125%.

Economically, Recovery Mode is designed to encourage collateral top-ups and debt repayments, and also itself acts as a self-negating deterrent: the possibility of it occurring actually guides the system away from ever reaching it.

Project Structure

Directories

  • packages/contracts/ - The backend development folder, contains the Hardhat and Foundry projects, contracts, and tests
  • packages/contracts/contracts/ - The core back end smart contracts written in Solidity
  • packages/contracts/test/ - JS test suite for the system. Tests run in Mocha/Chai
  • packages/contracts/foundry_test/ - Foundry test suite for the system
  • packages/contracts/tests/ - Python test suite for the system. Tests run in Brownie
  • packages/contracts/utils/ - external Hardhat and node scripts - deployment helpers, gas calculators, etc

Backend development is done in the Hardhat framework, and allows eBTC to be deployed on the Hardhat EVM network for fast compilation and test execution.

External Contract Architecture

Fees generated through the core protocol are managed at an external FeeRecipient contract. This contract is fully managed by BadgerDAO but with the option to switch it out for a new mechanic.

FeeRecipient.sol - All fees generated by the core system are recieved at this address, with events emitted when a fee is processed for acounting purposes. These fees include redemptions and the staking yield split.

Core System Architecture

The core eBTC system consists of several smart contracts.

All application logic and data is contained in these contracts - there is no need for a separate database or back end logic running on a web server. In effect, the Ethereum network is itself the eBTC back end. As such, all balances and contract data are public.

The two main contracts - BorrowerOperations.sol and CdpManager.sol - hold the user-facing public functions, and contain most of the internal system logic. Together they control CDP state updates and movements of stETH and eBTC tokens around the system.

PriceFeed and Oracle

eBTC functions that require the most current stETH:BTC price data fetch the price dynamically, as needed, via the core PriceFeed.sol contract using the Chainlink stETH:BTC reference contract as its primary and can use another oracle source as a secondary. PriceFeed is stateful, i.e. it records the last good price that may come from either of the two sources based on the contract's current state.

The fallback logic distinguishes 3 different failure modes for Chainlink and 2 failure modes for the backup:

  • Frozen (for both oracles): last price update more than 4 hours ago
  • Broken (for both oracles): response call reverted, invalid timeStamp that is either 0 or in the future, or reported price is non-positive (Chainlink) or zero (Backup). Chainlink is considered broken if either the response for the latest round or the response for the round before the latest fails one of these conditions.
  • PriceChangeAboveMax (Chainlink only): higher than 50% deviation between two consecutive price updates

There is also a return condition bothOraclesLiveAndUnbrokenAndSimilarPrice which is a function returning true if both oracles are live and not broken, and the percentual difference between the two reported prices is below 5%.

The current PriceFeed.sol contract has an external fetchPrice() function that is called by core eBTC functions which require a current stETH:BTC price. fetchPrice() calls each oracle's proxy, asserts on the responses, and converts returned prices to 18 digits.

PriceFeed Logic

The PriceFeed contract fetches the current price and previous price from Chainlink and changes its state (called Status) based on certain conditions.

Initial PriceFeed state: chainlinkWorking. The initial system state that is maintained as long as Chainlink is working properly, i.e. neither broken nor frozen nor exceeding the maximum price change threshold between two consecutive rounds. PriceFeed then obeys the logic found in this table:

https://docs.google.com/spreadsheets/d/18fdtTUoqgmsK3Mb6LBO-6na0oK-Y9LWBqnPCJRp5Hsg/edit?usp=sharing

Testnet PriceFeed and PriceFeed tests

The PriceFeedTestnet.sol is a mock PriceFeed for testnet and general back end testing purposes, with no oracle connection. It contains a manual price setter, setPrice(), and a getter, getPrice(), which returns the latest stored price.

PriceFeed limitations and known issues

The purpose of the PriceFeed is to be at least as good as an immutable PriceFeed that relies purely on Chainlink, while also having some resilience in case of Chainlink failure / timeout, and chance of recovery.

The PriceFeed logic consists of automatic on-chain decision-making for obtaining fallback price data from the backup, and if possible, for returning to Chainlink if/when it recovers.

The PriceFeed logic is complex, and although we would prefer simplicity, it does allow the system a chance of switching to an accurate price source in case of a Chainlink failure or timeout, and also the possibility of returning to an honest Chainlink price after it has failed and recovered.

We believe the benefit of the fallback logic is worth the complexity. If we had no fallback logic and Chainlink were to be hacked or permanently fail, eBTC would become unusable without a backup.

Governance is also capable of setting a new backup oracle feed, as long as it conforms to the interface.

Chainlink Decimals: the PriceFeed checks for and uses the latest decimals value reported by the Chainlink aggregator in order to calculate the Chainlink price at 18-digit precision, as needed by eBTC. PriceFeed does not assume a value for decimals and can handle the case where Chainlink change their decimal value.

However, the check chainlinkIsBroken uses both the current response from the latest round and the response previous round. Since decimals is not attached to round data, eBTC has no way of knowing whether decimals has changed between the current round and the previous round, so we assume it is the same. eBTC assumes the current return value of decimals() applies to both current round i and previous round i-1.

This means that a decimal change that coincides with a eBTC price fetch could cause eBTC to assert that the Chainlink price has deviated too much, and fall back to the backup. There is nothing we can do about this. We hope/expect Chainlink to never change their decimals() return value (currently 8), and if a hack/technical error causes Chainlink's decimals to change, eBTC may fall back to the backup.

To summarize the Chainlink decimals issue:

  • eBTC can handle the case where Chainlink decimals changes across two consecutive rounds i and i-1 which are not used in the same eBTC price fetch
  • If eBTC fetches the price at round i, it will not know if Chainlink decimals changed across round i-1 to round i, and the consequent price scaling distortion may cause eBTC to fall back to the backup.
  • eBTC will always calculate the correct current price at 18-digit precision assuming the current return value of decimals() is correct (i.e. is the value used by the nodes).

Keeping a sorted list of CDPs ordered by ICR

eBTC relies on a particular data structure: a sorted doubly-linked list of CDPs that remains ordered by individual collateralization ratio (ICR), i.e. the amount of collateral value divided by the amount of debt value.

This ordered list is critical for gas-efficient redemption sequences and for the liquidateCdps sequence, both of which target CDPs in ascending order of ICR.

The sorted doubly-linked list is found in SortedCdps.sol.

Nodes map to active CDPs in the system - the ID property is the address of a CDP owner. The list accepts positional hints for efficient O(1) insertion - please see the hints section for more details.

ICRs are computed dynamically at runtime, and not stored on the node. This is because ICRs of active CDPs change dynamically, when:

  • The stETH:BTC price varies, altering the value of the collateral of every CDP
  • A liquidation that redistributes collateral and debt to active CDPs occurs

The list relies on the fact that a collateral and debt redistribution due to a liquidation preserves the ordering of all active CDPs (though it does decrease the ICR of each active CDP above the MCR).

The fact that ordering is maintained as redistributions occur, is not immediately obvious: please see the mathematical proof which shows that this holds in eBTC.

A node inserted based on current ICR will maintain the correct position, relative to its peers, as liquidation gains accumulate, as long as its raw collateral and debt have not changed.

Nodes also remain sorted as the stETH:BTC price varies, since price fluctuations change the collateral value of each CDP by the same proportion.

Thus, nodes need only be re-inserted to the sorted list upon a CDP operation - when the owner adds or removes collateral or debt to their position.

Expected User Behaviors

Generally, borrowers call functions that trigger CDP operations on their own CDP.

Anyone may call the public liquidation functions, and attempt to liquidate one or several CDPs.

eBTC token holders may also redeem their tokens, and swap an amount of tokens 1-for-1 in value (minus fees) with stETH.

Contract Ownership and Function Permissions

Several public and external functions have modifiers such as requireCallerIsCdpManager, requireCallerIsActivePool, etc - ensuring they can only be called by the respective permitted contract.

Functions subject to minimal governance use the isAuthorized() modifier inherited from AuthNoOwner.sol. The authority contract is the Governor. See solmate auth paradigm which this functionality is lightly modified from.

Deployment to a Development Blockchain

The script in mainnetDeployment/eBTCDeployScript.js deploy all contracts, and connects all contracts to their dependency contracts, by setting the necessary deployed addresses.

The project is deployed on the Sepolia testnet.

System Quantities - Units and Representation

Integer representations of decimals

Several ratios and the stETH:BTC price are integer representations of decimals, to 18 digits of precision. For example:

uint representation of decimal Number
1100000000000000000 1.1
200000000000000000000 200
1000000000000000000 1
5432100000000000000 5.4321
34560000000 0.00000003456
370000000000000000000 370
1 1e-18

etc.

Public Data

Core Public User-Facing Functions

Borrower (CDP) Operations - BorrowerOperations.sol

  • openCdp
  • openCdpFor
  • addColl
  • withdrawColl
  • withdrawDebt
  • repayDebt
  • adjustCdp
  • adjustCdpWithColl
  • closeCdp
  • claimCollateral
  • setPositionManagerApproval
  • revokePositionManagerApproval
  • renouncePositionManagerApproval
  • permitPositionManagerApproval

CdpManager Functions - CdpManager.sol

  • liquidate
  • partiallyLiquidate
  • batchLiquidateCdps
  • redeemCollateral

Hint Helper Functions - HintHelpers.sol

function getApproxHint(uint _CR, uint _numTrials, uint _inputRandomSeed): helper function, returns a positional hint for the sorted list. Used for transactions that must efficiently re-insert a CDP to the sorted list.

getRedemptionHints(uint _EBTCamount, uint _price, uint _maxIterations): helper function specifically for redemptions. Returns three hints:

  • firstRedemptionHint is a positional hint for the first redeemable CDP (i.e. CDP with the lowest ICR >= MCR).
  • partialRedemptionHintNICR is the final nominal ICR of the last CDP after being hit by partial redemption, or zero in case of no partial redemption (see Hints for redeemCollateral).
  • truncatedEBTCamount is the maximum amount that can be redeemed out of the the provided _EBTCamount. This can be lower than _EBTCamount when redeeming the full amount would leave the last CDP of the redemption sequence with less debt than the minimum allowed value.

The number of CDPs to consider for redemption can be capped by passing a non-zero value as _maxIterations, while passing zero will leave it uncapped.

eBTC token EBTCToken.sol

Standard ERC20 and EIP2612 (permit() ) functionality.

Note: permit() can be front-run, as it does not require that the permitted spender be the msg.sender.

This allows flexibility, as it means that anyone can submit a Permit signed by A that allows B to spend a portion of A's tokens.

The end result is the same for the signer A and spender B, but does mean that a permit transaction could be front-run and revert - which may hamper the execution flow of a contract that is intended to handle the submission of a Permit on-chain.

For more details please see the original proposal EIP-2612: https://eips.ethereum.org/EIPS/eip-2612

Supplying Hints to CDP operations

CDPs in eBTC are recorded in a sorted doubly linked list, sorted by their NICR, from high to low. NICR stands for the nominal collateral ratio that is simply the amount of collateral (in stETH) multiplied by 100e18 and divided by the amount of debt (in eBTC), without taking the stETH:BTC price into account. Given that all CDPs are equally affected by stETH price changes, they do not need to be sorted by their real ICR.

All CDP operations that change the collateralization ratio need to either insert or reinsert the CDP to the SortedCdps list. To reduce the computational complexity (and gas cost) of the insertion to the linked list, two ‘hints’ may be provided.

A hint is the address of a CDP with a position in the sorted list close to the correct insert position.

All CDP operations take two ‘hint’ arguments: a _lowerHint referring to the nextId and an _upperHint referring to the prevId of the two adjacent nodes in the linked list that are (or would become) the neighbors of the given CDP. Taking both direct neighbors as hints has the advantage of being much more resilient to situations where a neighbor gets moved or removed before the caller's transaction is processed: the transaction would only fail if both neighboring CDPs are affected during the pendency of the transaction.

The better the ‘hint’ is, the shorter the list traversal, and the cheaper the gas cost of the function call. SortedList::findInsertPosition(uint256 _NICR, address _prevId, address _nextId) that is called by the CDP operation firsts check if prevId is still existant and valid (larger NICR than the provided _NICR) and then descends the list starting from prevId. If the check fails, the function further checks if nextId is still existant and valid (smaller NICR than the provided _NICR) and then ascends list starting from nextId.

The HintHelpers::getApproxHint(...) function can be used to generate a useful hint pointing to a CDP relatively close to the target position, which can then be passed as an argument to the desired CDP operation or to SortedCdps::findInsertPosition(...) to get its two direct neighbors as ‘exact‘ hints (based on the current state of the system).

getApproxHint(uint _CR, uint _numTrials, uint _inputRandomSeed) randomly selects numTrials amount of CDPs, and returns the one with the closest position in the list to where a CDP with a nominal collateralization ratio of _CR should be inserted. It can be shown mathematically that for numTrials = k * sqrt(n), the function's gas cost is with very high probability worst case O(sqrt(n)) if k >= 10. For scalability reasons (Infura is able to serve up to ~4900 trials), the function also takes a random seed _inputRandomSeed to make sure that calls with different seeds may lead to a different results, allowing for better approximations through multiple consecutive runs.

CDP operation without a hint

  1. User performs CDP operation in their browser
  2. Call the CDP operation with _lowerHint = _upperHint = userAddress

Gas cost will be worst case O(n), where n is the size of the SortedCdps list.

CDP operation with hints

  1. User performs CDP operation in their browser
  2. The front end computes a new collateralization ratio locally, based on the change in collateral and/or debt.
  3. Call HintHelpers::getApproxHint(...), passing it the computed nominal collateralization ratio. Returns an address close to the correct insert position
  4. Call SortedCdps::findInsertPosition(uint256 _NICR, address _prevId, address _nextId), passing it the same approximate hint via both _prevId and _nextId and the new nominal collateralization ratio via _NICR.
  5. Pass the ‘exact‘ hint in the form of the two direct neighbors, i.e. _nextId as _lowerHint and _prevId as _upperHint, to the CDP operation function call. (Note that the hint may become slightly inexact due to pending transactions that are processed first, though this is gracefully handled by the system that can ascend or descend the list as needed to find the right position.)

Gas cost of steps 2-4 will be free, and step 5 will be O(1).

Hints allow cheaper CDP operations for the user, at the expense of a slightly longer time to completion, due to the need to await the result of the two read calls in steps 1 and 2 - which may be sent as JSON-RPC requests to Infura, unless the Frontend Operator is running a full Ethereum node.

Example Borrower Operations with Hints

Opening a CDP

  const toWei = web3.utils.toWei
  const toBN = web3.utils.toBN

  const EBTCAmount = toBN(toWei('2500')) // borrower wants to withdraw 2500 eBTC
  const ETHColl = toBN(toWei('5')) // borrower wants to lock 5 stETH collateral

  // Call deployed CdpManager contract to read the liquidation reserve and latest borrowing fee
  const liquidationReserve = await cdpManager.EBTC_GAS_COMPENSATION()
  const expectedFee = await cdpManager.getBorrowingFeeWithDecay(EBTCAmount)
  
  // Total debt of the new CDP = eBTC amount drawn, plus fee, plus the liquidation reserve
  const expectedDebt = EBTCAmount.add(expectedFee).add(liquidationReserve)

  // Get the nominal NICR of the new CDP
  const _1e20 = toBN(toWei('100'))
  let NICR = ETHColl.mul(_1e20).div(expectedDebt)

  // Get an approximate address hint from the deployed HintHelper contract. Use (15 * number of CDPs) trials 
  // to get an approx. hint that is close to the right position.
  let numCdps = await sortedCdps.getSize()
  let numTrials = numCdps.mul(toBN('15'))
  let { 0: approxHint } = await hintHelpers.getApproxHint(NICR, numTrials, 42)  // random seed of 42

  // Use the approximate hint to get the exact upper and lower hints from the deployed SortedCdps contract
  let { 0: upperHint, 1: lowerHint } = await sortedCdps.findInsertPosition(NICR, approxHint, approxHint)

  // Finally, call openCdp with the exact upperHint and lowerHint
  const maxFee = '5'.concat('0'.repeat(16)) // Slippage protection: 5%
  await borrowerOperations.openCdp(maxFee, EBTCAmount, upperHint, lowerHint, { value: ETHColl })

Adjusting a CDP

  const collIncrease = toBN(toWei('1'))  // borrower wants to add 1 stETH
  const EBTCRepayment = toBN(toWei('230')) // borrower wants to repay 230 eBTC

  // Get CDP's current debt and coll
  const {0: debt, 1: coll} = await cdpManager.getSyncedDebtAndCollShares(borrower)
  
  const newDebt = debt.sub(EBTCRepayment)
  const newColl = coll.add(collIncrease)

  NICR = newColl.mul(_1e20).div(newDebt)

  // Get an approximate address hint from the deployed HintHelper contract. Use (15 * number of CDPs) trials 
  // to get an approx. hint that is close to the right position.
  numCdps = await sortedCdps.getSize()
  numTrials = numCdps.mul(toBN('15'))
  ({0: approxHint} = await hintHelpers.getApproxHint(NICR, numTrials, 42))

  // Use the approximate hint to get the exact upper and lower hints from the deployed SortedCdps contract
  ({ 0: upperHint, 1: lowerHint } = await sortedCdps.findInsertPosition(NICR, approxHint, approxHint))

  // Call adjustCdp with the exact upperHint and lowerHint
  await borrowerOperations.adjustCdp(maxFee, 0, EBTCRepayment, false, upperHint, lowerHint, {value: collIncrease})

Hints for redeemCollateral

CdpManager::redeemCollateral as a special case requires additional hints:

  • _firstRedemptionHint hints at the position of the first CDP that will be redeemed from,
  • _lowerPartialRedemptionHint hints at the nextId neighbor of the last redeemed CDP upon reinsertion, if it's partially redeemed,
  • _upperPartialRedemptionHint hints at the prevId neighbor of the last redeemed CDP upon reinsertion, if it's partially redeemed,
  • _partialRedemptionHintNICR ensures that the transaction won't run out of gas if neither _lowerPartialRedemptionHint nor _upperPartialRedemptionHint are valid anymore.

redeemCollateral will only redeem from CDPs that have an ICR >= MCR. In other words, if there are CDPs at the bottom of the SortedCdps list that are below the minimum collateralization ratio (which can happen after an stETH:BTC price drop), they will be skipped. To make this more gas-efficient, the position of the first redeemable CDP should be passed as _firstRedemptionHint.

First redemption hint

The first redemption hint is the address of the CDP from which to start the redemption sequence - i.e the address of the first CDP in the system with ICR >= 110%.

If when the transaction is confirmed the address is in fact not valid - the system will start from the lowest ICR CDP in the system, and step upwards until it finds the first CDP with ICR >= 110% to redeem from. In this case, since the number of CDPs below 110% will be limited due to ongoing liquidations, there's a good chance that the redemption transaction still succeed.

Partial redemption hints

All CDPs that are fully redeemed from in a redemption sequence are left with zero debt, and are closed. The remaining collateral (the difference between the orginal collateral and the amount used for the redemption) will be claimable by the owner.

It’s likely that the last CDP in the redemption sequence would be partially redeemed from - i.e. only some of its debt cancelled with eBTC. In this case, it should be reinserted somewhere between top and bottom of the list. The _lowerPartialRedemptionHint and _upperPartialRedemptionHint hints passed to redeemCollateral describe the future neighbors the expected reinsert position.

However, if between the off-chain hint computation and on-chain execution a different transaction changes the state of a CDP that would otherwise be hit by the redemption sequence, then the off-chain hint computation could end up totally inaccurate. This could lead to the whole redemption sequence reverting due to out-of-gas error.

To mitigate this, another hint needs to be provided: _partialRedemptionHintNICR, the expected nominal ICR of the final partially-redeemed-from CDP. The on-chain redemption function checks whether, after redemption, the nominal ICR of this CDP would equal the nominal ICR hint.

If not, the redemption sequence doesn’t perform the final partial redemption, and terminates early. This ensures that the transaction doesn’t revert, and most of the requested eBTC redemption can be fulfilled.

Example Redemption with hints

 // Get the redemptions hints from the deployed HintHelpers contract
  const redemptionhint = await hintHelpers.getRedemptionHints(EBTCAmount, price, 50)

  const { 0: firstRedemptionHint, 1: partialRedemptionNewICR, 2: truncatedEBTCAmount } = redemptionhint

  // Get the approximate partial redemption hint
  const { hintAddress: approxPartialRedemptionHint } = await contracts.hintHelpers.getApproxHint(partialRedemptionNewICR, numTrials, 42)
  
  /* Use the approximate partial redemption hint to get the exact partial redemption hint from the 
  * deployed SortedCdps contract
  */
  const exactPartialRedemptionHint = (await sortedCdps.findInsertPosition(partialRedemptionNewICR,
    approxPartialRedemptionHint,
    approxPartialRedemptionHint))

  /* Finally, perform the on-chain redemption, passing the truncated eBTC amount, the correct hints, and the expected
  * ICR of the final partially redeemed CDP in the sequence. 
  */
  await cdpManager.redeemCollateral(truncatedEBTCAmount,
    firstRedemptionHint,
    exactPartialRedemptionHint[0],
    exactPartialRedemptionHint[1],
    partialRedemptionNewICR,
    0, maxFee,
    { from: redeemer },
  )

Gas compensation

In eBTC, we want to maximize liquidation throughput, and ensure that undercollateralized CDPs are liquidated promptly by “liquidators” at all times, regardless of the degree of collateralization of the CDP.

However, gas costs in Ethereum are substantial. If the gas costs of our public liquidation functions are too high, this may discourage liquidators from calling them, and leave the system holding too many undercollateralized CDPs for too long.

The protocol thus directly compensates liquidators for their gas costs, to incentivize prompt liquidations in both normal and extreme periods of high gas prices. Liquidators should be confident that they will at least break even by making liquidation transactions.

Liquidation incentives are paid in stETH. When a borrower first issues debt, they must provide an additional 0.2 stETH (Gas Stipend) that is reserved as a Liquidation Reserve. A liquidation transaction thus draws stETH from the CDP(s) it liquidates, and sends both the reserved Gas Stipend and the compensation in stETH to the caller, and liquidates the remainder.

When a liquidation transaction liquidates multiple CDPs, each CDP contributes its Gas Stipend and stETH towards the total compensation for the transaction.

Gas compensation per liquidated CDP is given by the formula:

  • Full liquidation Gas compensation = max(1.03, min(ICR, 1.1)) + Gas Stipend
  • Partial liquidation Gas compensation = max(1.03, min(ICR, 1.1))

This means that liquidations are always incentivized within the eBTC ecosystem with a percentage of the collateral that can go from 3% to 10%, plus tha gas stipend when the liquidation results in the closing of the CDP. This also applies to CDPs being liquidated during Recovery Mode, the max incentive is capped at 10%. In the same way, CDPs that are liquidated at or below the 103% ICR mark are also subject to a fixed 3% incentive. In these cases, CDPs will remain with a portion of bad dept remaining and no collateral. Then, and only then, this outstanding debt will be subject to redistribution.

Gas compensation schedule

When a borrower opens a CDP, an additional 0.2 stETH are required and the equivalent amount of shares are sent to the ActivePool for gas compensation. Their accounting is kept separate from the core system collateral.

When a borrower closes their active CDP, this gas compensation is refunded: the amount of shares sent by the user are transferred back from the ActivePool to the user. Note that these shares may represent a larger amount of stETH than before due to the accrued yield or a smaller amount due to negative rebases.

The purpose of the 0.2 stETH Liquidation Reserve is to provide a minimum level of gas compensation, regardless of the CDP's collateral size or the current stETH market price.

Liquidation

When a CDP is liquidated, all of the collateral is transferred to the liquidator. Therefore, the compensation incentive percentage will depend on the ICR at which the ICR is liquidated according to the equations above. For example, a liquidation at 110% ICR will mean a 10% profit for the liquidator plus the Gas Stipend.

As mentioned as well, if liquidated below 103%, the liquidator is guaranteed a 3% incentive. For intance, if the liquidation occurs at 97% ICR, the system will estimate the debt to be repaid based equivalent to that required to yield a 103% ICR. Therefore, the liquidator will be required to pay a debt amount 3% lower in value than the total available collateral and profit from that difference. Undercollateralized liquidations are also incentivized with the Gas Stipend.

Gas compensation and redemptions

If the redemption causes a CDP's full debt to be cancelled, the CDP is then closed: Gas Stipend from the Liquidation Reserve becomes avaiable for the borrower to reclaim along of the CDP's Collateral Surplus.

eBTC Redemption Fees

eBTC generates fee revenue from redemptions. Fees are captured by the feeRecipient contract. Redemptions fees are paid in stETH.

Redemption Fee

The redemption fee is taken as a cut of the total stETH drawn from the system in a redemption. It is based on the current redemption rate.

In the CdpManager, redeemCollateral calculates the stETH fee and it is allocated to the FeeRecipient address in the ActivePool.

Fee Schedule

Redemption fees are based on the baseRate state variable in CdpManager, which is dynamically updated. The baseRate increases with each redemption, and decays according to time passed since the last fee event - i.e. the last redemption of eBTC.

The current fee schedule:

Upon each redemption:

  • baseRate is decayed based on time passed since the last fee event
  • baseRate is incremented by an amount proportional to the fraction of the total eBTC supply that was redeemed
  • The redemption rate is given by min{REDEMPTION_FEE_FLOOR + baseRate * ETHdrawn, DECIMAL_PRECISION}

REDEMPTION_FEE_FLOOR is set to 1%, while DECIMAL_PRECISION is 100%.

Intuition behind fees

The larger the redemption volume, the greater the fee percentage.

The longer the time delay since the last operation, the more the baseRate decreases.

The intent is to throttle large redemptions with higher fees. The baseRate decay over time ensures that the fee for redeemers will “cool down”, while redemptions volumes are low.

Furthermore, the fees cannot become smaller than 1% (Oracle's maximum deviation threshold), which in the case of redemptions protects the redemption facility from being front-run by arbitrageurs that are faster than the price feed.

Fee decay Implementation

Time is measured in units of minutes. The baseRate decay is based on block.timestamp - lastFeeOpTime. If less than a minute has passed since the last fee event, then lastFeeOpTime is not updated. This prevents “base rate griefing”: i.e. it prevents an attacker stopping the baseRate from decaying by making a series of redemptions or issuing eBTC with time intervals of < 1 minute.

The decay parameter is tuned such that the fee changes by a factor of 0.99 per hour, i.e. it loses 1% of its current value per hour. At that rate, after one week, the baseRate decays to 18% of its prior value. The exact decay parameter is subject to change, and will be fine-tuned via economic modelling.

Redistributions and Corrected Stakes

When a liquidation occurs on an undercollateralized Cdp and bad debt remains after paying out the premium, the redistribution mechanism should distribute the remaining collateral and debt of the liquidated CDP, to all active CDPs in the system, in proportion to their collateral.

For two CDPs A and B with collateral A.coll > B.coll, CDP A should earn a bigger share of the liquidated collateral and debt.

However, when it comes to implementation, Ethereum gas costs make it too expensive to loop over all CDPs and write new data to storage for each one. When a CDP receives redistributed debt, the system does not update the CDP's debt value - instead, the debt remains "pending" until the borrower's next operation (or more accurately, next operation directly modifying that CDP).

These “pending debt redistributions" can not be accounted for in future calculations in a scalable way.

However: the ICR of a CDP is always calculated as the ratio of its total collateral to its total debt. So, a Cdp’s ICR calculation does include all its previous accumulated rewards.

This causes a problem: redistributions proportional to initial collateral can break CDP ordering.

Consider the case where new CDP is created after all active CDPs have received a redistribution from a liquidation. This “fresh” CDP has then experienced fewer rewards than the older CDPs, and thus, it receives a disproportionate share of subsequent rewards, relative to its total collateral.

The fresh CDP would earns rewards based on its entire collateral, whereas old CDPs would earn rewards based only on some portion of their collateral - since a part of their collateral is pending, and not included in the Cdp’s coll property.

This can break the ordering of CDPs by ICR - see the proofs section.

Corrected Stake Solution

We use a corrected stake to account for this discrepancy, and ensure that newer CDPs earn the same liquidation rewards per unit of total collateral, as do older CDPs with pending changes. Thus the corrected stake ensures the sorted list remains ordered by ICR, as liquidation events occur over time.

When a CDP is opened, its stake is calculated based on its collateral, and snapshots of the entire system collateral and debt which were taken immediately after the last liquidation.

A Cdp’s stake is given by:

stake = _coll.mul(totalStakesSnapshot).div(totalCollateralSnapshot)

It then earns redistribution rewards based on this corrected stake. A newly opened Cdp’s stake will be less than its raw collateral, if the system contains active CDPs with pending redistribution rewards when it was made.

Whenever a borrower adjusts their Cdp’s collateral, their pending rewards are applied, and a fresh corrected stake is computed.

To convince yourself this corrected stake preserves ordering of active CDPs by ICR, please see the proofs section.

Math Proofs

The eBTC implementation relies on some important system properties and mathematical derivations from Liquity's initial design.

In particular, we have:

  • Proofs that CDP ordering is maintained throughout a series of liquidations and new CDP openings
  • A derivation of a formula and implementation for a highly scalable (O(1) complexity) reward distribution in the Stability Pool, involving compounding and decreasing stakes.

PDFs of these can be found in https://github.com/liquity/dev/blob/main/papers

Definitions

CDP: a collateralized debt position, bound to a single Ethereum address. Also referred to as a “CDP” in similar protocols.

eBTC: The soft-pegged asset that may be issued from a user's collateralized debt position and freely transferred/traded to any Ethereum address. Intended to maintain parity with BTC, and can always be redeemed directly with the system: 1 eBTC is always exchangeable for 1 BTC worth of stETH, minus fees.

Active CDP: an Ethereum address owns an “active Cdp” if there is a node in the SortedCdps list with ID equal to the address, and non-zero collateral is recorded on the CDP struct for that address.

Closed CDP: a CDP that was once active, but now has zero debt and zero collateral recorded on its struct, and there is no node in the SortedCdps list with ID equal to the owning address.

Cached collateral: the amount of stETH collateral recorded on a Cdp’s struct

Cached debt: the amount of eBTC debt recorded on a Cdp’s struct

Synced collateral: the sum of a Cdp’s active collateral plus its pending collateral rewards accumulated from postive stETH rebases

Sycned debt: the sum of a Cdp’s active debt plus its pending debt accumulated from distributions

Individual collateralization ratio (ICR): a CDP's ICR is the ratio of the dollar value of its entire collateral at the current stETH:BTC price, to its entire debt

Nominal collateralization ratio (nominal ICR, NICR): a CDP's nominal ICR is its entire collateral (in stETH) multiplied by 100e18 and divided by its entire debt.

System collateral: the sum of active collateral over all CDPs. Equal to the stETH in the ActivePool allocated to the system from internal accounting values.

System debt: the sum of active debt over all CDPs. Equal to the eBTC in the ActivePool.

Total collateralization ratio (TCR): the ratio of the dollar value of the entire system collateral at the current stETH:BTC price, to the entire system debt

Critical collateralization ratio (CCR): 125%. When the TCR is below the CCR, the system enters Recovery Mode.

Borrower: an externally owned account or contract that locks collateral in a CDP and issues eBTC tokens to their own address. They “borrow” eBTC tokens against their stETH collateral.

Redemption: the act of swapping eBTC tokens with the system, in return for an equivalent value of stETH. Any account with a eBTC token balance may redeem them, whether or not they are a borrower.

When eBTC is redeemed for stETH, the stETH is always withdrawn from the lowest collateral CDPs, in ascending order of their collateralization ratio. A redeemer can not selectively target CDPs with which to swap eBTC for stETH.

Liquidation: the act of force-closing a CDP that is considered undercollateralized in the current system mode, and distributing its collateral and debt.

Liquidation functionality is permissionless and publically available - anyone may liquidate an undercollateralized CDP, or batch liquidate CDPs in ascending order of collateralization ratio.

Gas stipend: A fixed value, in stETH, automatically paid to the caller of a liquidation function that fully liquidates a CDP. Intended to at least cover the gas cost of the transaction. Designed to ensure that liquidators are not dissuaded by potentially high gas costs.

Clone & Install

git clone https://github.com/Badger-Finance/ebtc.git ebtc
cd ebtc
yarn

Top-level scripts

There are a number of scripts in the top-level package.json file to ease development, which you can run with yarn.

Run tests

Hardhat test suite

yarn test

Foundry test suite

forge test

Known Issues (Liquity)

🦉 These issues are lightly modified from the text of the Liquity readme, and may no longer be relevant or may behave differently within the context of eBTC.

Temporary and slightly inaccurate TCR calculation within batchLiquidateCdps in Recovery Mode.

When liquidating a CDP with ICR > 110%, a collateral surplus remains claimable by the borrower. This collateral surplus should be excluded from subsequent TCR calculations, but within the liquidation sequence in batchLiquidateCdps in Recovery Mode, it is not. This results in a slight distortion to the TCR value used at each step of the liquidation sequence going forward. This distortion only persists for the duration the batchLiquidateCdps function call, and the TCR is again calculated correctly after the liquidation sequence ends. In most cases there is no impact at all, and when there is, the effect tends to be minor. The issue is not present at all in Normal Mode.

There is a theoretical and extremely rare case where it incorrectly causes a loss for Stability Depositors instead of a gain. It relies on the stars aligning: the system must be in Recovery Mode, the TCR must be very close to the 125% boundary, a large CDP must be liquidated, and the stETH price must drop by >10% at exactly the right moment. No profitable exploit is possible. For more details, please see this security advisory.

SortedCdps edge cases - top and bottom of the sorted list

When the CDP is at one end of the SortedCdps list and adjusted such that its ICR moves further away from its neighbor, findInsertPosition returns unhelpful positional hints, which if used can cause the adjustCdp transaction to run out of gas. This is due to the fact that one of the returned addresses is in fact the address of the CDP to move - however, at re-insertion, it has already been removed from the list. As such the insertion logic defaults to 0x0 for that hint address, causing the system to search for the CDP starting at the opposite end of the list. A workaround is possible, and this has been corrected in the SDK used by front ends.

Front-running issues

Loss evasion by front-running Stability Pool depositors

Example sequence 1): evade liquidation tx

  • Depositor sees incoming liquidation tx that would cause them a net loss
  • Depositor front-runs with withdrawFromSP() to evade the loss

Example sequence 2): evade price drop

  • Depositor sees incoming price drop tx (or just anticipates one, by reading exchange price data), that would shortly be followed by unprofitable liquidation txs
  • Depositor front-runs with withdrawFromSP() to evade the loss

Stability Pool depositors expect to make profits from liquidations which are likely to happen at a collateral ratio slightly below 110%, but well above 100%. In rare cases (flash crashes, oracle failures), CDPs may be liquidated below 100% though, resulting in a net loss for stability depositors. Depositors thus have an incentive to withdraw their deposits if they anticipate liquidations below 100% (note that the exact threshold of such “unprofitable” liquidations will depend on the current Dollar price of eBTC).

As long the difference between two price feed updates is <10% and price stability is maintained, loss evasion situations should be rare. The percentage changes between two consecutive prices reported by Chainlink’s stETH:BTC oracle has only ever come close to 10% a handful of times in the past few years.

In the current implementation, deposit withdrawals are prohibited if and while there are CDPs with a collateral ratio (ICR) < 110% in the system. This prevents loss evasion by front-running the liquidate transaction as long as there are CDPs that are liquidatable in normal mode.

This solution is only partially effective since it does not prevent stability depositors from monitoring the stETH price feed and front-running oracle price update transactions that would make CDPs liquidatable. Given that we expect loss-evasion opportunities to be very rare, we do not expect that a significant fraction of stability depositors would actually apply front-running strategies, which require sophistication and automation. In the unlikely event that large fraction of the depositors withdraw shortly before the liquidation of CDPs at <100% CR, the redistribution mechanism will still be able to absorb defaults.

Reaping liquidation gains on the fly

Example sequence:

  • User sees incoming profitable liquidation tx
  • User front-runs it and immediately makes a deposit with provideToSP()
  • User earns a profit

Front-runners could deposit funds to the Stability Pool on the fly (instead of keeping their funds in the pool) and make liquidation gains when they see a pending price update or liquidate transaction. They could even borrow the eBTC using a CDP as a flash loan.

Such flash deposit-liquidations would actually be beneficial (in terms of TCR) to system health and prevent redistributions, since the pool can be filled on the spot to liquidate CDPs anytime, if only for the length of 1 transaction.

Front-running and changing the order of CDPs as a DoS attack

Example sequence:* -Attacker sees incoming operation(openLoan(), redeemCollateral(), etc) that would insert a CDP to the sorted list -Attacker front-runs with mass openLoan txs -Incoming operation becomes more costly - more traversals needed for insertion

It’s theoretically possible to increase the number of the CDPs that need to be traversed on-chain. That is, an attacker that sees a pending borrower transaction (or redemption or liquidation transaction) could try to increase the number of traversed CDPs by introducing additional CDPs on the way. However, the number of CDPs that an attacker can inject before the pending transaction gets mined is limited by the amount of spendable gas. Also, the total costs of making the path longer by 1 are significantly higher (gas costs of opening a CDP, plus the 0.5% borrowing fee) than the costs of one extra traversal step (simply reading from storage). The attacker also needs significant capital on-hand, since the minimum debt for a CDP is 2000 eBTC.

In case of a redemption, the “last” CDP affected by the transaction may end up being only partially redeemed from, which means that its ICR will change so that it needs to be reinserted at a different place in the sorted CDP list (note that this is not the case for partial liquidations in recovery mode, which preserve the ICR). A special ICR hint therefore needs to be provided by the transaction sender for that matter, which may become incorrect if another transaction changes the order before the redemption is processed. The protocol gracefully handles this by terminating the redemption sequence at the last fully redeemed CDP (see here).

An attacker trying to DoS redemptions could be bypassed by redeeming an amount that exactly corresponds to the debt of the affected CDP(s).

Finally, this DoS could be avoided if the initial transaction avoids the public gas auction entirely and is sent direct-to-miner, via (for example) Flashbots.

Periphery

Leverage Macro

Leverage Macro is divided into multiple contracts:

  • LeverageMacroBase The base reference contract, that allows to perform an Open, Close or Adjust of a CDP as a callback of a flashloan

  • LeverageMacroDelegatTarget The variant of the Leverage Macro that is meant to be used as a delegatecall target

  • LeverageMacroReference The smart contract version that can be deployed as a contract / proxy which will open a CDP via FLashloan on behalf of it's owner

SimplifiedDiamondLike

A reference implementation of a smart contract wallet that uses configurable callbacks to use leverage macro natively, rather than as a separate support contract. Demonstrates how this can also be achieved by other SC wallets with configurable callbacks such as Gnosis Safe.

A mix of a DSProxy and a Diamond -> execute is heavily inspired by Gnosis Safe -> _fallback is basically a diamon proxy with the extra check for callback being enabled

Allows arbitrary call execution by it's owner, both via call and delegate call -> Arbitrary calls can be performed via execute -> This can be further extended by setting up callbackHandlers

Adds a check to allow callbacks or allow any call to be handled by it's fallback -> Non callback must be explicitly allowed via setAllowAnyCall which ensures that the owner of the proxy is explicitly taking on the extra risks

Allows to specify a different implementation for each function selector -> Thanks to callbackHandler any function sig (beside ones clashing with the basic ones), can be added to the proxy, instead of having a proxy by proxy upgrade pattern