Bancor3: Trading Code Guide
This guide focuses on trades being performed against Bancor3 liquidity pools.
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
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.
_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.
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
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.
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.
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 theEMA_AVERAGE_RATE_WEIGHT
andEMA_SPOT_RATE_WEIGHT
which is specified inPoolCollection.sol
.
- 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 theresult
object. The fees are then processed via_processNetworkFee()
which processes the fees to the BNT equivalent and updates the respective intermediate balances.
- 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
- Introduction to Bancor3: Redefining DeFi Playbooks Once Again
- Key Concepts in Bancor3: Key Concepts Driving The Next DeFi Wave
- Bancor3 Tokenomics: A New Token Model
- Bancor3 Governance Tokenomics: The Voting Premium
Smart Contract Guides
- Token Whitelisting: Code Guide
- Pool Creation: Code Guide
- Depositing ERC20 Tokens: Code Guide
- Depositing BNT Tokens: Code Guide
- Trading: Technical Documentation
- Withdrawal Request: Code Guide
- BNT Withdrawal Claim: Code Guide
- ERC20 Withdrawal Claim: Code Guide
Other Links
- Personal Smart Contract Gitbook: https://kai27.gitbook.io/bancor/
- Glossary of Terms (WIP): https://kai27.gitbook.io/bancor/glossary
Official Links
- Bancor Home: https://home.bancor.network/
- Bancor Support: https://support.bancor.network/hc/en-us
- Bancor App: https://app.bancor.network/earn
- Bancor Official Documentation: https://docs.bancor.network/about-bancor-network/bancor-v3