Dissecting BarnBridge's SMART Exposure

Automate position management between ERC-20 assets.

SMART Exposure is BarnBridge’s second product. SMART Exposure allows users to automate position management between their ERC-20 assets. This product is for investors who want to passively maintain their asset pairs’ target allocations. There is a pool for each supported asset pair and in each pool there is a tranche for each target ratio. For instance, there are 2 pools currently, namely WETH/USDC and WETH/WBTC. Each pool has the tranches 50/50, 25/75 and 75/25. It can support up to 5 tranches.

A user’s position is rebalanced by keeper bots through Sushiswap’s flash swap if his position’s ratio deviates from the target ratio more than the threshold or if the time since the last rebalance exceeds the defined frequency.

When depositing ERC-20 assets into a tranche, a user receives exposure tokens (eTokens) in return. eTokens are also ERC-20 tokens, so they can be freely traded or even be utilized as collaterals by other protocols.

eToken’s name follows the format

bb_ET_TOKEN0RATIO0/TOKEN1RATIO1

  • bb = BarnBridge

  • ET = exposure token

  • TOKEN0/TOKEN1 = token symbols such as WETH, USDC and WBTC

  • RATIO0/RATIO1 = target ratios in percentage

Using SMART exposure can be cheaper than rebalancing your position by yourself because

  1. You save on gas fees

  2. The rebalance uses the price from Chainlink, so theoretically it should be cheaper

The 30,000 foot view

BarnBridge’s tech team wrote a very good specification with an architecture diagram. EPool is the core engine that holds different tranches of an asset pair. It mints and burns EToken created by the ETokenFactory, which represents exposure to a tranche. Users interact with the pool through EPoolPeriphery, although they can also interace with EPool directly. When a rebalance happens, the pool fetches the market price from Chainlink (AggregatorV3Interface) and then swaps using Sushiswap (IUniswapRouterV2).

EPool

EPool as the core engine holds all the data it needs to function. It holds a number of tranches (max 5).

struct Tranche {
    IEToken eToken; /* Exposure token interface */
    uint256 sFactorE; /* eToken's scaling factor */
    uint256 reserveA; /* eToken's token A reserve */
    uint256 reserveB; /* eToken's token B reserve */
    uint256 targetRatio; /* token A ÷ token B target ratio */
}

Each ERC-20 token in the pool has its own decimals and they can be different (WETH 18, WBTC 8, USDC 6). This is why we need a scaling factor for both reserve tokens and also the eToken in order to calculate target ratio and price correctly.

uint256 public immutable override sFactorA;
uint256 public immutable override sFactorB;

Rebalancing can only happen if the pool’s reserve ratio deviates from the target ratio by more than its threshold. It uses rebalanceMinRDiv as the threshold. rebalanceMinRDiv is in percentage scaled by 1e18.

uint256 public override rebalanceMinRDiv;

Rebalancing can only happen at particular intervals of time. The pool holds the interval as well as its last rebalance timestamp.

uint256 public override rebalanceInterval;
uint256 public override lastRebalance;

Moving forward, we will look at the ETH/USDC 75/25 tranche, assuming ETH to be token A and USDC to be token B.

Issuing eTokens

Issuing eTokens can be done via EPoolPeriphery or Epool directly. We will look at EPoolPeriphery#issueForMaxTokenA. This function allows a user to mint a certain amount of bb_ET_ETH75/USDC25 for a max amount of ETH (maxInputAmountA).

First, the function makes sure the eToken is approved as a tranche and transfers maxInputAmountA ETH to the periphery contract.

require(ePools[address(ePool)], "EPoolPeriphery: unapproved EPool");
(IERC20 tokenA, IERC20 tokenB) = (ePool.tokenA(), ePool.tokenB());
tokenA.safeTransferFrom(msg.sender, address(this), maxInputAmountA);

Then it uses EPoolLibrary#tokenATokenBForEToken to calculate the amount of ETH and USDC required to mint the number of eTokens required by the user. Let’s say the user deposits 100 ETH. There are 2 scenarios to consider.

Case 1: When the pool is empty

uint256 totalA = amount * sFactorA / tranche.sFactorE;
uint256 amountA = totalA - (totalA * sFactorI / (sFactorI + ratio));
amountB = (((totalA * sFactorI / sFactorA) * rate) / (sFactorI + tranche.targetRatio)) * sFactorB / sFactorI;

The 100 ETH deposited has to be split into 75% ETH (18 decimals) and 25% USDC (6 decimals) to meet the target ratio. Assuming the price of 1 ETH = 2,000 USDC, an ETH/USDC ratio of 3 to 1, and eToken’s decimals as 18, it has 75 ETH (150,000 USDC) and 50,000 USDC.

totalEth = 100e18 x 1e18 ÷ 1e18 
         = 100e18

amountEth = 100e18 - 100e18 x 1e18 ÷ (1e18 + 3e18)
          = 100e18 - 100e18 ÷ 4e18
          = 75e18

amountUsdc = 100e18 x 1e18 ÷ 1e18 x 2000e18 ÷ (1e18 + 3e18) x 1e6 ÷ 1e18
           = 100e18 x 2000e18 ÷ 4e18 x 1e-12
           = 50000e18 x 1e-12
           = 50000e6

Case 2: When the pool is not empty

uint256 eTokenTotalSupply = t.eToken.totalSupply();
if (eTokenTotalSupply == 0) return(0, 0);
uint256 share = amount * t.sFactorE / eTokenTotalSupply;
amountA = share * t.reserveA / t.sFactorE;
amountB = share * t.reserveB / t.sFactorE;

The calculation is simpler when the pool is not empty. The ETH and USDC amount to deposit are the reserves in the pool times the share of eTokens in the pool.

Swapping ETH for USDC

Since the user only provided ETH, the function has to swap ETH for USDC at Sushiswap. The ETH swap amount is the difference between maxInputAmountA and amountA. Its value should be equal to amountB.

require(maxInputAmountA >= amountA, "EPoolPeriphery: insufficient max. input");
uint256 amountAToSwap = maxInputAmountA - amountA;
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
tokenA.approve(address(router), amountAToSwap);
uint256[] memory amountsOut = router.swapTokensForExactTokens(
    amountB, amountAToSwap, path, address(this), deadline
);

Token transfers and reserves update

After getting USDC, the pool updates its reserves, mints eTokens, and transfers the tokens from the periphery.

(t.reserveA, t.reserveB) = (t.reserveA + amountA, t.reserveB + amountB);
t.eToken.mint(msg.sender, amount);
tokenA.safeTransferFrom(msg.sender, address(this), amountA);
tokenB.safeTransferFrom(msg.sender, address(this), amountB);

The function then transfers the eTokens to the user and refunds any ETH leftover.

IERC20(eToken).safeTransfer(msg.sender, amount);
tokenA.safeTransfer(msg.sender, maxInputAmountA - amountA - amountsOut[0]);

Rebalancing a pool

In order to rebalance all tranches in a pool, EPoolPeriphery#rebalanceWithFlashSwap can be called to conduct a flash swap at Sushiswap. A flash swap allows the pool to withdraw Sushiswap’s reserves with no upfront cost as long as the pool can repay the withdrawn amount with the correponding pair tokens or the withdrawn amount along with a small fee. In this code sample, we will assume ETH to have dropped to 1,800 USD.

First, it has to calculate ETH and USDC’s delta as well as its rate of change.

(uint256 deltaA, uint256 deltaB, uint256 rChange, ) = EPoolLibrary.delta(
    ePool.getTranches(), ePool.getRate(), ePool.sFactorA(), ePool.sFactorB()
);

The delta function loops through every tranche in a pool to calculate their individual deltas and rate of change.

for (uint256 i = 0; i < ts.length; i++) {
    totalReserveA += ts[i].reserveA;
    (uint256 _deltaA, uint256 _deltaB, uint256 _rChange) = trancheDelta(
        ts[i], rate, sFactorA, sFactorB
    );
    (totalDeltaA, totalDeltaB) = (_rChange == 0)
        ? (totalDeltaA - int256(_deltaA), totalDeltaB + int256(_deltaB))
        : (totalDeltaA + int256(_deltaA), totalDeltaB - int256(_deltaB));

}

To calculate a tranche’s delta (using EPoolLibrary#trancheDelta), it has to first calculate the tranche’s current ratio. The formula is

((t.reserveA * rate / sFactorA) * sFactorI) / (t.reserveB * sFactorI / sFactorB)

It has 75 ETH and 50,000 USDC in the tranche, so the current ratio is

((75e18 x 1800e18 ÷ 1e18) x 1e18) ÷ (50000e6 x 1e18 ÷ 1e6)

= 135000e18 x 1e18 ÷ (50000 x 1e18)
= 135000e18 ÷ 50000
= 2.7e18

If the current ratio (2.7) is less than the target ratio (3), then rChange is 1. rChange is used to determine whether it needs to add or remove liquidity from one side of tranche.

rChange = (currentRatio(t, rate, sFactorA, sFactorB) < t.targetRatio) ? 1 : 0;

deltaA is equal to abs(tranche.reserveA, (tranche.reserveB ÷ rate x tranche.targetRatio)) ÷ (1 + tranche.targetRatio)

deltaA = (
    Math.abs(t.reserveA, tokenAForTokenB(t.reserveB, t.targetRatio, rate, sFactorA, sFactorB)) * sFactorA
) / (sFactorA + (t.targetRatio * sFactorA / sFactorI));
function tokenAForTokenB(
    uint256 amountB,
    uint256 ratio,
    uint256 rate,
    uint256 sFactorA,
    uint256 sFactorB
) internal pure returns(uint256) {
    return (((amountB * sFactorI / sFactorB) * ratio) / rate) * sFactorA / sFactorI;
}

deltaB is equal to deltaA x rate.

deltaB = ((deltaA * sFactorB / sFactorA) * rate) / sFactorI;

Filling in all the known numbers, we have

// Given USDC, which ETH is required such that USDC / ETH is equal to the ratio
tokenAForTokenB = (((50000e6 x 1e18 ÷ 1e6) x 3e18) ÷ 1800e18) x 1e18 ÷ 1e18 

= 50000e18 x 3e18 ÷ 1800e18
= 150000e36 ÷ 1800e18
= 83.333333333e18

deltaA = Math.abs(75e18, 83.333333333e18) x 1e18 ÷ (1e18 + (3e18 x 1e18 ÷ 1e18))

= 8.3333333e18 x 1e18 ÷ 4e18
= 2.0833333e18

deltaB = ((2.0833333e18 x 1e6 ÷ 1e18) x 1800e18) ÷ 1e18

= 2.0833333e6 x 1800
= 3749.99994e6

That means it needs to add 2.0833333 ETH and remove 3749.99994 USDC to meet the target ratio. To prove that the ratio is correct, let’s try to calculate the ratio with the new reserves.

new ETH = 75 + 2.0833333 = 77.0833333
new ETH value in USDC = 77.0833333 x 1800 = 138749.99994
new USDC = 50000 - 3749.99994 = 46250.00006

new ratio = 138749.99994 ÷ 46250.00006 = 2.99999999481 (≈3)

Since totalDeltaA is > 0 and totalDeltaB is < 0, the final result is

(deltaA, deltaB, rChange) = (uint256(totalDeltaA), uint256(-totalDeltaB), 1);

and the rate of change is

rDiv = (totalReserveA == 0) ? 0 : deltaA * EPoolLibrary.sFactorI / totalReserveA;
     = 2.0833333e18 x 1e18 ÷ 75e18
     = 0.02777777733e18
     = 2.78%

Now that we know how many USDC it needs to swap for USDC, it can go ahead and perform the flash swap.

IUniswapV2Pair pair = IUniswapV2Pair(factory.getPair(address(tokenA), address(tokenB)));
uint256 amountOut0; uint256 amountOut1;
(amountOut0, amountOut1) = (address(tokenA) == pair.token0())
                ? (deltaA, uint256(0)) : (uint256(0), deltaA);

bytes memory data = abi.encode(ePool, fracDelta);
pair.swap(amountOut0, amountOut1, address(this), data);

By providing data bytes as the last argument, Sushiswap knows it is a flash swap and it will send a callback to EPoolPeriphery (uniswapV2Call function) with the same data bytes.

The callback function has to first verify the call is for an approved tranche by decoding the data and checking the ePool address.

(IEPool ePool, uint256 fracDelta) = abi.decode(data, (IEPool, uint256));
require(ePools[address(ePool)], "EPoolPeriphery: unapproved EPool");

Then it calls EPool#rebalance to rebalance the tranche. For a rebalance to be successful, the rate of change has to be greater than the minimum rate of change threshold and the current rebalance has to be at least rebalanceInterval seconds away from the last rebalance. The function accepts an argument fracDelta in case the rebalancer does not want to perform 100% of the rebalance, but we will assume it to be a full rebalance in this article.

We will not go into details on the math of rebalancing tranches as it is the same as the delta calculation previously. Just note that it loops through all the tranches, calculates their corresponding deltaA and deltaB, and then updates their reserves.

for (uint256 i = 0; i < tranchesByIndex.length; i++) {
    Tranche storage t = tranches[tranchesByIndex[i]];
    totalReserveA += t.reserveA;
    (uint256 _deltaA, uint256 _deltaB, uint256 _rChange) = _trancheDelta(t, fracDelta);
    if (_rChange == 0) {
        (t.reserveA, t.reserveB) = (t.reserveA - _deltaA, t.reserveB + _deltaB);
        (totalDeltaA, totalDeltaB) = (totalDeltaA - int256(_deltaA), totalDeltaB + int256(_deltaB));
    } else {
        (t.reserveA, t.reserveB) = (t.reserveA + _deltaA, t.reserveB - _deltaB);
        (totalDeltaA, totalDeltaB) = (totalDeltaA + int256(_deltaA), totalDeltaB - int256(_deltaB));
    }
}

After calculating the necessary info, the function does its rate of change and timestamp check.

(deltaA, deltaB, rChange, rDiv) = _rebalanceTranches(fracDelta);
require(rDiv >= rebalanceMinRDiv, "EPool: minRDiv not met");
require(block.timestamp >= lastRebalance + rebalanceInterval, "EPool: within interval");

Because it is adding ETH and removing USDC, the function transfers ETH from the periphery to the pool and sends USDC from the pool to the periphery.

tokenA.safeTransferFrom(msg.sender, address(this), deltaA);
tokenB.safeTransfer(msg.sender, deltaB);

Back to the callback function, it has to determine the amount of USDC to repay Sushiswap. It does so by calling Sushiswap router’s getAmountsIn function.

address[] memory path = new address[](2); // [0] flash swap repay token, [1] flash lent token
uint256 amountsIn; // flash swap repay amount
uint256 deltaOut;

// add ETH, release USDC to EPool -> flash swap ETH, repay with USDC
path[0] = address(ePool.tokenB()); path[1] = address(ePool.tokenA());
(amountsIn, deltaOut) = (router.getAmountsIn(deltaA, path)[0], deltaB);

deltaOut is the amount of USDC transferred out from the pool, if the amount to repay Sushiswap is greater than deltaOut, it needs to get a subsidy from the Keeper subsidy pool. Otherwise it will transfer the surplus to the Keeper subsidy pool for any deficits in the future. The function checks the slippage between the repay amount and the actual amount transferred out from the pool is not greater than its max flash swap slippage threshold.

if (amountsIn > deltaOut) {
    require(
        amountsIn * EPoolLibrary.sFactorI / deltaOut <= maxFlashSwapSlippage,
        "EPoolPeriphery: excessive slippage"
    );
    keeperSubsidyPool.requestSubsidy(path[0], amountsIn - deltaOut);
} else if (amountsIn < deltaOut) {
    IERC20(path[0]).safeTransfer(address(keeperSubsidyPool), deltaOut - amountsIn);
}

Finally, it repays the flash swap. Failing to repay will revert the whole transaction.

IERC20(path[0]).safeTransfer(msg.sender, amountsIn);

Closing Thoughts

BarnBridge’s SMART Exposure allows users to easily rebalance their portfolios. Rebalancing is automatically taken care for regularly, without users having to do complex calculations and going through multiple transactions to swap just to lose money on slippage. Changing your exposure is also as easy as swapping your exposure token for another exposure token with a different target ratio. They are just ERC-20 tokens in the end. The ability to turn risk exposure into a token simplifies the lives of many investors who rely on a fixed asset ratio as their investment strategy.