DEX Walkthrough
Introduction
If you are building a project that involves decentralized exchange functionality, integrating DEX contracts can be a crucial step in achieving your goals. These contracts provide the underlying infrastructure necessary to facilitate the exchange of assets on a decentralized platform. However, implementing these contracts can be a complex process, and understanding the various public endpoints and functions can be challenging. In this in-depth walkthrough, we will guide you through the process of integrating DEX contracts into your MultiversX project. We will cover all of the main contracts involved in a typical DEX implementation, and provide detailed explanations of the most commonly used public endpoints. By the end of this tutorial, you should have a solid understanding of how to implement DEX functionality in your own project, and be able to make informed decisions about how to customize and extend the functionality to meet your specific needs.
Prerequisites
The DEX contracts are a bit more advanced than the standard SCs, so basic knowledge about Rust SC development is required. If you are a beginner, an easier starting point (like the Crowdfunding SC or the Staking SC tutorials) is strongly advised. Also, to better grasp the DEX contracts implementation, it is important that you first understand the xExchange economic model.
You can find the xExchange whitepaper here: https://xexchange.com/x-exchange-economics.pdf
DEX repo structure & recommendations
The main DEX contracts are as follow:
- Pair SC
- Router SC
- Farm SC
- Proxy DEX SC
- Farm Staking SC
- Farm Staking Proxy SC
- Simple Lock SC
- Energy Factory SC
- Token Unstake SC
- Fees Collector SC
- Locked Token Wrapper SC
You can find the repository containing all the DEX contracts here: https://github.com/multiversx/mx-exchange-sc
This walkthrough was made based on a synchronous, intrashard contract calls flow as the suggested implementation. While you can still use async calls, that approach would complicate the implementation of any new projects building on top of the DEX contracts to some extent, with more complex gas handling requirements and callbacks logic. In order to have synchronous integration with the DEX contracts, the newly developed SCs need to be deployed on the same shard as the xExchange contracts.
Later on, with the launch of the AsyncV2 functionality, these kinds of contracts will be able to be deployed in other shards as well, as the protocol will support multiple asyncCalls.
You can find an in-depth overview of SC interactions here: https://docs.multiversx.com/developers/developer-reference/sc-contract-calls
Pair SC
This contract allows users to provide liquidity and to swap tokens. Users are incentivized to add liquidity by earning rewards from fees and by being able to enter farms, thus earning even more rewards. This contract is usually deployed by the router smart contract and it (usually) has no dependency, as it is used as a DeFi primitive.
Add liquidity
pub type AddLiquidityResultType<BigUint> =
MultiValue3<EsdtTokenPayment<BigUint>, EsdtTokenPayment<BigUint>, EsdtTokenPayment<BigUint>>;
#[payable("*")]
#[endpoint(addLiquidity)]
fn add_liquidity(
&self,
first_token_amount_min: BigUint,
second_token_amount_min: BigUint,
) -> AddLiquidityResultType<Self::Api>
The process of adding liquidity to a pool is a straightforward one and does not affect the ratio between the two tokens. Let's assume that the reserves of the first and second tokens are denoted by rA and rB respectively, while the desired amounts of those tokens to be added as liquidity are denoted by aA and aB. In order to maintain the ratio of the tokens in the liquidity pool, the following formula must hold true: rA / rB = aA / aB
. Calculating the appropriate values is easy since one of the desired values, aA or aB, can be fixed, and the other one can be derived from the aforementioned formula.
For newly deployed pairs, the first liquidity addition sets the ratio and price of the tokens since there are no tokens in the pool yet, and thus no formula to be followed.
When the add liquidity function is called, it takes an array of two payments that correspond to the amounts the user wants to add to the liquidity pool. The order of the payments is important, as they must correspond to the order of the tokens in the contract. The function also takes in two arguments, first_token_amount_min and second_token_amount_min, which represent the desired slippage, or the minimum amount of tokens that must be returned by the contract.
After all necessary checks and computations are done, the endpoint returns a vector of 3 payments to the user in the following order: liquidity added, the difference between the first token amount sent by the user and the amount that was used, and the difference between the second token amount sent by the user and the amount that was used. A MultiValue of these 3 EsdtTokenPayment is returned as the final result.
Remove liquidity
pub type RemoveLiquidityResultType<BigUint> =
MultiValue2<EsdtTokenPayment<BigUint>, EsdtTokenPayment<BigUint>>;
#[payable("*")]
#[endpoint(removeLiquidity)]
fn remove_liquidity(
&self,
first_token_amount_min: BigUint,
second_token_amount_min: BigUint,
) -> RemoveLiquidityResultType<Self::Api>
The removal of liquidity from a pool is a process that can be thought of as the reverse of adding liquidity. It involves a liquidity provider sending their LP tokens back to the Pair SC and providing the same parameters that were presented in the addLiquidity
endpoint, namely the first_token_amount_min and second_token_amount_min. In exchange, the provider receives back both types of tokens that he initially provided. Typically, for a pool that is relatively stable, the amounts received when removing liquidity will be greater than the amounts provided initially during the addition process, as they will include the accumulated swap fees.
After all the checks and computations are done, the endpoint constructs and sends back to the user a vector of 2 payments with the following amounts: first_token_amount_removed and second_token_amount_removed. In the end, a MultiValue of 2 EsdtTokenPayment is returned.
Swap tokens fixed input
pub type SwapTokensFixedInputResultType<BigUint> = EsdtTokenPayment<BigUint>;
#[payable("*")]
#[endpoint(swapTokensFixedInput)]
fn swap_tokens_fixed_input(
&self,
token_out: TokenIdentifier,
amount_out_min: BigUint,
) -> SwapTokensFixedInputResultType<Self::Api>
This smart contract acts as an AMM based on the constant product formula x * y = k
.
This means that swapping, when ignoring fees, would happen based on the following logic:
Let's assume that:
- rI is the reserve of the input token (the one that the user paid)
- rO is the reserve of the output token (the one that the user desires in exchange of the paid one)
- aI is the amount of the input token
- aO is the amount of the output token
From the two equations, we can safely state that
Where aI would be known, and aO would need to be calculated.
Considering f being the percent of total fee, the formula including fees is the following:
The workflow of the endpoint is as follows: the users sends a payment with the tokens he wants to swap to the contract, along with 2 parameters (token_out and amount_out_min). Based on the token_out parameter, the swapping order is deducted, the variables are checked and then the contract performs the swap operation as described above.
In the end, the user receives back his requested tokens, with one important mention. If one of the pair tokens is an underlying version of a locked token, then the output token is locked before it is sent to the user. Finally, the endpoints returns the EsdtTokenPayment, containing the output token payment.
Swap tokens fixed output
pub type SwapTokensFixedOutputResultType<BigUint> =
MultiValue2<EsdtTokenPayment<BigUint>, EsdtTokenPayment<BigUint>>;
#[payable("*")]
#[endpoint(swapTokensFixedOutput)]
fn swap_tokens_fixed_output(
&self,
token_out: TokenIdentifier,
amount_out: BigUint,
) -> SwapTokensFixedOutputResultType<Self::Api>
The flow is approximately the same as with the SwapFixedInput function, with the main difference that aO is fixed and aI is calculated using the same formulas. One other difference is that besides the desired tokens, the contract also sends back the leftover tokens, in case there are any. The leftover amount in this case is the difference between the amount_in_max and the actual amount that was used to swap in order to get to the desired amount_out. In the end, the endpoint returns a MultiValue of 2 EsdtTokenPayment.
Router SC
The Router SC serves as a convenient tool for efficiently managing and monitoring Pair contracts in a decentralized environment. It enables the deployer to easily keep track of the existing Pair contracts and offers a wide array of settings functions, that makes the management of the liquidity pools much more easier.
Taking into consideration that this tutorial is intended for developers who wish to import more easily the DEX contracts into their own projects, we will concentrate on the only public endpoint that can be particularly beneficial for external projects, the multiPairSwap
endpoint.
Multi pair swap
type SwapOperationType<M> =
MultiValue4<ManagedAddress<M>, ManagedBuffer<M>, TokenIdentifier<M>, BigUint<M>>;
#[payable("*")]
#[endpoint(multiPairSwap)]
fn multi_pair_swap(&self, swap_operations: MultiValueEncoded<SwapOperationType<Self::Api>>)
The multiPairSwap
endpoint allows users to swap two different tokens, that don't have a direct pool, in one transaction. It receives an array (of type MultiValueEncoded) of SwapOperationType (which are basically a MultiValue of 4 different parameters). The 4 parameters are (in this exact order): pair_address, function, token_wanted, amount_wanted. So, for each SwapOperationType, the flow is as follows:
- The endpoint checks if the pair_address is indeed a pair contract, in order to be able to call the swap function.
- It then calls the specified function (which can be of type swap_tokens_fixed_input or swap_tokens_fixed_output), by sending the tokens received as a payment in the endpoint, in return of the token_wanted, with the specified amount_wanted.
- A PaymentsVec is then created, consisting in the desired token (the last token from the swap_operations list), along with all the remaining tokens that were not used during the swap operations.
- In case the entire flow works as intended, the PaymentsVec is then sent back to the user.
Farm SC
This base farm contract has the role of generating and distributing MEX tokens to liquidity providers that choose to lock their LP tokens, thus increasing the ecosystem stability. On the xExchange, a variation of the Farm contract is deployed, namely the Farm with locked rewards contract, which heavily relies on the standard Farm contract, the difference being that the generated rewards are locked (which also involves an additional energy computation step, according to the new xExchange economics model).
Throughout the Farm SC we will come across an optional variable, namely opt_orig_caller. When building an external SC on top of the xExchange farm contract, this argument must always be sent as None (it is used by the other whitelisted xExchange contracts to claim rewards and compute energy for another user, other that the caller - in our case the external xExchange contract). With the new update of the MEX economics model (where SCs are allowed to have energy), the account that now has and uses the Energy can be the external SC itself, which later computes any existing rewards for its users or applies any other custom logic (e.g. like the Energy DAO SC) to further distribute those rewards.
Enter farm
pub type EnterFarmResultType<M> = DoubleMultiPayment<M>;
#[payable("*")]
#[endpoint(enterFarm)]
fn enter_farm_endpoint(
&self,
opt_orig_caller: OptionalValue<ManagedAddress>,
) -> EnterFarmResultType<Self::Api>
This endpoint receives at least one payment:
- The first payment has to be of type farming_token_id, and represents the actual token that is meant to be locked inside the Farm contract.
- The additional payments, if any, will be current farm positions and will be merged with the newly created tokens, in order to consolidate all previous positions with the current one.
This endpoint will give back to the caller a Farm position as a result. This is a MetaESDT that contains, in its attributes, information about the user input tokens and the current state of the contract when the user did enter. This information will be later used when trying to claim rewards or exit farm.
Claim rewards
pub type ClaimRewardsResultType<M> = DoubleMultiPayment<M>;
#[payable("*")]
#[endpoint(claimRewards)]
fn claim_rewards_endpoint(
&self,
opt_orig_caller: OptionalValue<ManagedAddress>,
) -> ClaimRewardsResultType<Self::Api>
When a user makes a call to this endpoint, they must provide their current farm position (or positions). The endpoint will then use this position to compute the rewards that the user has earned. The rewards that are calculated will depend on each specific farm. Some farms may offer both base rewards and boosted rewards, with the latter being calculated only once every 7 epochs. Other farms may offer only base rewards. In the end, the function will return two pieces of information: the updated farm position (which will now include the latest RPS information) and the amount of rewards that the user has earned.
Exit farm
pub type ExitFarmWithPartialPosResultType<M> =
MultiValue3<EsdtTokenPayment<M>, EsdtTokenPayment<M>, EsdtTokenPayment<M>>;
#[payable("*")]
#[endpoint(exitFarm)]
fn exit_farm_endpoint(
&self,
exit_amount: BigUint,
opt_orig_caller: OptionalValue<ManagedAddress>,
) -> ExitFarmWithPartialPosResultType<Self::Api>
The exitFarm
endpoint allows users to exit their position in the farm. It receives the entire farm position as a payment, and an exit_amount as a parameter, which tells the function with how much tokens the user wants to exit the farm. This logic was implemented in order to be able to compute the complete farm position's boosted rewards, without losing any tokens.
The flows is as follows:
- The user calls the endpoint with his entire farm position as a single payment and specifies which is the exit amount
- The endpoint first computes the boosted rewards, if any, and then it exits the farm with the specified amount
- The energy of the user is updated accordingly (or deleted if the user exited the farm with the entire position)
- Lastly, the user receives back the initial farming position (usually the LP tokens), the rewards, if any, and the remaining farm position, in case he did not exit the farm with the entire position
Merge farm tokens
#[payable("*")]
#[endpoint(mergeFarmTokens)]
fn merge_farm_tokens_endpoint(
&self,
opt_orig_caller: OptionalValue<ManagedAddress>,
) -> EsdtTokenPayment<Self::Api>
The mergeFarmTokens
endpoint allows users to send multiple farm positions and combine them into one aggregated position. One important aspect here is that in order to be able to merge the farm tokens, the user must have the energy claim progress up-to-date.
Boosted rewards formula
It's worth noting that while not a specific function, the boosted rewards formula is still an important concept to understand when participating in certain farms. This formula is used to maximize the potential boosted rewards that an account can receive.
The formula takes into account several variables, including the amount of tokens that the user has staked in the farm (user_farm_amount), the total amount of tokens staked in the farm (total_farm_amount), the amount of energy that the user has (user_energy_amount), and the total amount of energy contributed to the farm (total_energy). It is important to mention that the weekly values are used. Additionally, certain boost factors are applied to further fine-tune the calculation of rewards. For example, some factors may overemphasize the importance of the user's energy contribution in the rewards calculation.
By understanding this formula, an account holder can determine how much energy they need to have in order to maximize the rewards for their current farm position. Alternatively, they can determine how much energy they need to obtain in order to to achieve a certain level of boosted rewards. This knowledge can be especially valuable for those projects seeking to optimize their yield farming strategies.
// computed user rewards = total_boosted_rewards *
// (energy_const * user_energy / total_energy + farm_const * user_farm / total_farm) /
// (energy_const + farm_const)
let boosted_rewards_by_energy =
&weekly_reward.amount * &factors.user_rewards_energy_const * energy_amount
/ total_energy;
let boosted_rewards_by_tokens =
&weekly_reward.amount * &factors.user_rewards_farm_const * &self.user_farm_amount
/ &farm_supply_for_week;
let constants_base = &factors.user_rewards_energy_const + &factors.user_rewards_farm_const;
let boosted_reward_amount =
(boosted_rewards_by_energy + boosted_rewards_by_tokens) / constants_base;
// min between base rewards per week and computed rewards
let user_reward = cmp::min(max_rewards, boosted_reward_amount);
if user_reward > 0 {
sc.remaining_boosted_rewards_to_distribute(week)
.update(|amount| *amount -= &user_reward);
user_rewards.push(EsdtTokenPayment::new(
weekly_reward.token_identifier,
0,
user_reward,
));
}
Proxy DEX SC
This smart contract offers users with locked MEX the possibility of interacting with the DEX contracts, for operations like adding liquidity or entering farms, as if they had MEX.
Add liquidity proxy
#[payable("*")]
#[endpoint(addLiquidityProxy)]
fn add_liquidity_proxy(
&self,
pair_address: ManagedAddress,
first_token_amount_min: BigUint,
second_token_amount_min: BigUint,
) -> MultiValueEncoded<EsdtTokenPayment>
The addLiquidityProxy
intermediates liquidity adding in a Pair SC as follows:
- The user must send the tokens in the same order as they are in the Pair contract
- The user must configure the slippage as he would in the Pair contract
The output payments of this endpoint consists not in the original LP token, but instead in a Wrapped LP token, along with any leftover tokens. The reason for wrapping the LP tokens is that if the user receives them directly, he would have had the possibility of removing the liquidity and thus unlocking his locked MEX.
Remove liquidity proxy
#[payable("*")]
#[endpoint(removeLiquidityProxy)]
fn remove_liquidity_proxy(
&self,
pair_address: ManagedAddress,
first_token_amount_min: BigUint,
second_token_amount_min: BigUint,
) -> MultiValueEncoded<EsdtTokenPayment>
The removeLiquidityProxy
endpoint intermediates removing liquidity from a Pair contract as follows: the user sends Wrapped LP tokens and receives the first token and the locked MEX tokens. The address and slippage are configurable as they would be for the Pair SC.
Merge wrapped LP tokens
#[payable("*")]
#[endpoint(mergeWrappedLpTokens)]
fn merge_wrapped_lp_tokens_endpoint(&self) -> EsdtTokenPayment
This function merges two or more positions of Wrapped LP tokens (LP positions obtained using locked MEX instead of MEX and this intermediary contract). The same logic as for mergeWrappedFarmTokens is applied.
Enter farm proxy
#[payable("*")]
#[endpoint(enterFarmProxy)]
fn enter_farm_proxy_endpoint(&self, farm_address: ManagedAddress) -> EsdtTokenPayment
The process of entering a Farm contract is facilitated by the Enter Farm Proxy function. This involves the user sending Wrapped LP tokens and receiving Wrapped Farm tokens. The rationale behind using Wrapped Farm tokens is similar to that of Wrapped LP tokens.
It should be noted that the original LP tokens and Farm tokens are kept in the smart contract, which creates Wrapped Tokens. These original tokens will be used by the smart contract to carry out actions on behalf of the user.
Exit farm proxy
pub type ExitFarmProxyResultType<M> =
MultiValue3<EsdtTokenPayment<M>, EsdtTokenPayment<M>, EsdtTokenPayment<M>>;
#[payable("*")]
#[endpoint(exitFarmProxy)]
fn exit_farm_proxy(
&self,
farm_address: ManagedAddress,
exit_amount: BigUint,
) -> ExitFarmProxyResultType<Self::Api>
The exitFarmProxy
works exactly like its base contract counterpart, except it takes Wrapped Farm tokens as input. This includes the new exit_amount logic, where the user sends his entire position and specifies the actual exit amount as an argument. The output of this endpoint consists of a MultiValue of 3 EsdtTokenPayment, namely the initial_proxy_farming_tokens, the reward_tokens and the remaining_wrapped_tokens, like with the base exitFarm
endpoint.