Bancor3: Trading Code Guide

Aw Kai Shin
8 min readJun 3, 2022

--

This guide focuses on trades being performed against Bancor3 liquidity pools.

Bancor3 Trading UI

Trades against the pool can begin once the BancorDAO enables trading for a particular pool following sufficient ERC20 and BNT deposits. To be precise, token swaps are processed by virtual liquidity pools as all trades are routed via the BNT omnipool (further details here). Given that the liquidity pools are virtual, their relative BNT balances can be adjusted without having to actually move the BNT in the vault. Consequently, Bancor3 trades are effectively a deposit of source tokens to the master vault and withdrawal of target tokens from said vault. The protocol handles the calculation of the relevant amounts based on the relative valuation of the tokens and the trading fees incurred.

For more info on executing trades: Bancor Trading Guide

For more info on fees incurred: What fees do I have to pay when I trade?

States

Read

//BancorNetwork.sol
IERC20 private immutable _bnt;

_bnt refers to the BNT contract. It is used in this case to check if the token being traded is BNT as BNT forms the core of the omnipool.

//PoolCollection.sol
mapping(Token => Pool) internal _poolData;

_poolData maps the tokens to their pools. It is used here to check the trading parameters.

//PoolCollection.sol
uint256 private constant EMA_AVERAGE_RATE_WEIGHT = 4;

EMA_AVERAGE_RATE_WEIGHT is used to provide a weightage ratio for the setting of the new average rate based on the current average rate.

//PoolCollection.sol
uint256 private constant EMA_SPOT_RATE_WEIGHT = 1;

EMA_SPOT_RATE_WEIGHT is used to provide a weightage ratio for the setting of the new average rate based on the spot rate.

//NetworkSettings.sol
uint32 private _networkFeePPM;

_networkFeePPM defines the network fees for a trade and can be configured by the BancorDAO.

//Constants.sol
uint32 constant PPM_RESOLUTION = 1_000_000;

PPM_RESOLUTION configures the resolution for the parts per million measurement unit. It is used here to calculate the trading fees.

//TokenLibrary.sol
address public constant NATIVE_TOKEN_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

NATIVE_TOKEN_ADDRESS is the address that represents the native token reserve. In this case, the native token is ETH and hence the Ethereum address above is a burner address.

Write

//BancorNetwork.sol
mapping(Token => IPoolCollection) private _collectionByPool;

_collectionByPool maps pools to their respective pool collections. The Token object contains much of the pool data which will be changed due to the trade.

//BNTPool.sol
mapping(Token => uint256) private _currentPoolFunding;

_currentPoolFunding maps the pools with their respective BNT funding. The funding is increased as part of a trade as trading fees are converted to BNT and accrued to the pool.

//BancorNetwork.sol
uint256 internal _pendingNetworkFeeAmount;

_pendingNetworkFeeAmount is the pending network fee amount to be burned by the vortex. It is incremented with the network fee from every trade.

Trading

Source

There are 2 entry points to initiate a trade, tradeBySourceAmount() and tradeByTargetAmount() which is defined in BancorNetwork.sol. For the sake of completeness, "source” refers to the token being traded from while “target” refers to the token being traded to. For example, if I’m trading ETH for BNT, the source token will be ETH and the target token is BNT.

tradeBySourceAmount() allows specifying the source amount that the user wants to trade out of and receive the maximum target amount based on the current pool rate. Alternatively, tradeByTargetAmount() allows specifying the target amount that the user wants to receive at the end of the trade with the protocol deducting the required source amount accordingly.

In both cases, the trade parameters are first verified via _verifyTradeParams() prior to calling the _trade() function with their respective trade parameters.

Code
Code
Code

_trade() generates the bytes32 context Id for the trade and triggers the relevant functions depending on whether the trade involves BNT as a source/target token or is a trade between 2 non-BNT tokens. Recall that BNT forms the core of the omnipool design and hence swaps are performed with the maximum of two hops. _trade() is able to handle for 2 hops by utilizing _tradeBaseTokens() which performs the hops separately using the same _tradeBNT() as the single hop trade.

Code

For example, if I’m trading from ETH to WBTC based on the source ETH amount, the protocol will first trade ETH for BNT and subsequently BNT to WBTC. On the other hand, trading by target WBTC amount will cause the protocol to trade BNT for WBTC first and subsequently ETH to the corresponding BNT amount. This intermediate hop for non-BNT trades are virtualized and invisible to the end user. It is important to note that _trade() updates the pool virtual balances and the staked balances prior to transferring source tokens from provider to master vault and target tokens from master vault to provider.

_trade

Code (minimized for legibility)

For trades which involve BNT, the _tradeBNT() function requires a fromBNT boolean to be passed in. Depending on the whether BNT forms the source or target token, this boolean will be passed in as true or false respectively. _tradeBNT() then uses this boolean in order to define the source and target for the trade. _tradeBNT() also checks the bySourceAmount boolean to apply the token amounts specified to the relevant side of the trade. Based on the above, _tradeBNT() invokes the relevant functions tradeBySourceAmount() or tradeByTargetAmount() defined in PoolCollection.sol on the pool object.

Code

Both tradeBySourceAmount() and tradeByTargetAmount() first checks the initial trading parameters via _initTrade() which processes and validates the trading parameters. Of note, the parameters passed into _initTrade() differs based upon which function was called. Consequently, the result object that is returned by _initTrade() already contains the validated source and target tokens, trading fees, as well as the correct application of the token amounts passed in.

Code
Code
Code

With the trading parameters successfully processed and stored in the result object, the protocol finally performs the trade via _performTrade().

_performTrade() does 3 things:

  • Updates the average rates via _updateAverageRates() which checks for the last updated block number and updates the current rate if the current block number differs from the last updated. The average rate changes are weighted 4:1 between the previous average rate and the current spot rate. This ratio is configured using the EMA_AVERAGE_RATE_WEIGHT and EMA_SPOT_RATE_WEIGHT which is specified in PoolCollection.sol.
Code
  • Processes the trade via _processTrade() which first calculates the trade amount and fees for the trade using _tradeAmountAndFeeBySourceAmount() or _tradeAmountAndFeeByTargetAmount(). Do note that in both cases, the trading fee is always taken from the target token. Of note, the trading and staked balances are also synchronized by updating the result object. The fees are then processed via _processNetworkFee() which processes the fees to the BNT equivalent and updates the respective intermediate balances.
Code
  • Updates the liquidity data of the pool.

Upon returning to the _tradeBNT() function in BancorNetwork.sol, the BNT pool staked balance and pool funding is then updated with the collected fees if the target token is BNT. At this juncture, the actual source and target tokens can finally be transferred between the trader and the vault based on the amounts processed above.

_trade() deposits the source tokens form the trade into the master vault using _depositToMasterVault(). _depositToMasterVault differentiates the token transfer method based on whether the token is ETH or an ERC20 token. This check for ETH is done using isNative() which checks the token contract against the NATIVE_TOKEN_ADDRESS of 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE. For ETH deposits, the msg.value amounts are checked and then transferred to the master vault contract with any additional amount being refunded to the caller. In order to avoid reversion due to gas limits form the regular transfer, the sendValue() function from OpenZeppelin Address.sol is used here.

Upon successful deposit, the target tokens are then withdrawn from the master vault and transferred to the beneficiary via withdrawFunds() which emits a FundsWithdrawn() event upon completion. Lastly, the pending network fee amount to be burned by the vortex is incremented by the network fee amount. Depending on whether the trade was initiated with tradeBySourceAmount() or tradeByTargetAmount(), the total amount of the target or source tokens for the trade are returned respectively.

Thanks for staying till the end. Would love to hear your thought/comments so do drop a comment. I’m active on twitter @AwKaiShin if you would like to receive more digestible tidbits of crypto-related info or visit my personal website if you would like my services :)

Bancor3 Deep Dive Series

Analysis

Smart Contract Guides

Other Links

Official Links

--

--

Aw Kai Shin

Web3, Crypto & Blockchain: Building a More Equitable Web | Technical Writer @FactorDAO | www.awkaishin.com