- https://blog.uniswap.org/uniswap-v2
- https://rskswap.com/audit.html The Pair contract for Soroswap, written in Rust, is inspired by the UniswapV2Pair contract, which is written in Solidity. At the time this comparison was written, SoroswapPair was in its first version (0.0.1) and did not yet implement many of the functions, variables, events, and other features present in the UniswapV2Pair contract.
Events: Included!
The UniswapV2 pair contract has four events: Mint, Burn, Swap and Sync. The corresponding events in the Soroswap pair contract are: deposit, withdraw, swap, and sync.SafeMath: Included!
In Solidity, the SafeMath library is used to validate arithmetic operations and prevent integer overflow and underflow. When such a situation arise, the library throws an exception, which effectively reverts the transaction. In Rust, we can achieve a similar level of protection by enabling the overflow check flag during the compilation process with the following code:checked_add, checked_mul, checked_div, and checked_sub. You can explore these functions and test their functionality in this repository: https://github.com/esteblock/overflow-soroban/
When it comes to preventing overflow in Soroban, we have the two solutions mentioned above: using the compiler flag or the overflow-safe functions. Yet, as we will see in the oracle section, there are cases where overflow is the intended result. Hence, we will bypass the compiler flag option, choosing instead to use overflow-safe functions for our arithmetic operations. Exceptions will be made only in those unique cases where overflow is desirable.
About underflow, it is worth noting that since we are using i128, a signed integer type, underflow will not occur as it would simply result in negative numbers. However, to ensure the integrity of our calculations, we’ve implemented checks where necessary. For instance:
Reserves Function: included!
In UniswapV2, the reserves function returns the reserves of token0 and token1, along with the timestamp of the last block.get_reserves function. Note that the SoroswapPair contract as deployed today returns exactly two values from get_reserves, the reserve of token_0 and the reserve of token_1. No block timestamp is returned. The snippet below is from the early version.
get_block_timestamp_last function, which returned the timestamp of the last block, defaulting to 0 if it did not exist.
Name of Pairs: included!
In UniswapV2, the name and symbol of the token pairs are designated as follows:Mint (Deposit)
In UniswapV2, the mint function is invoked when a user adds liquidity to the pool, resulting in the creation of pool tokens. Before calling the swap function, the seller transfers the asset to the core contract. The contract then measures the received asset quantity by comparing the last recorded balance with its current balance. This approach makes the core contract agnostic to how the trader transfers the asset. Instead of transferFrom, a meta transaction or any other future mechanism for authorizing the transfer of ERC-20s can be used.Comments for Soroswap implementation:
- The equivalent function in Soroswap is named deposit. To avoid confusion with the mint function of the token interface, we have opted to keep deposit as the function name.
- This function in UniswapV2 employs a reentrancy guard. Since reentrancy is not currently possible in Soroban, we have not implemented this guard.
- In UniswapV2, the router contract sends (with approval) tokens from the user to the Pair contract before executing the mint function. This design isn’t necessary in Soroban (read https://stellar.org/developers-blog/sorobans-technical-design-decisions-learnings-from-ethereum) because tokens can be sent using from.require_auth();, which is checked in the token contract itself.
- However, we need to consider tokens that do not implement require_auth. In such cases, we can follow Uniswap’s design and implement a
Routerwith aaddLiquidity_with_transfer_fromand a standardaddLiquiditywithrequire_auth. - At the time of writing, the objective was simply to implement the UniswapV2 Pair and Factory contracts, so the design was:
deposit now takes only the to address and requires the tokens to be sent to the pair before it is called.
- We’ve implemented bool feeOn = _mintFee(_reserve0, _reserve1);.
- As there’s no
totalSupplyin the Soroban token interface, we’ve implemented aget_total_sharesand aput_total_sharesfunction. - UniswapV2Pair compares whether
totalSupply == 0to send the “first” LP withsqrt(x*y)because it mints aMINIMUM_LIQUIDITYto the zero address to permanently lock it forever. This ensures there’s always some level of liquidity available, preventing scenarios where liquidity providers could fully drain a pool.
Swap
This function is invoked when a user swaps tokens. Emits Swap and Sync events.swap function now takes amount_0_out, amount_1_out and to, matching the UniswapV2 shape shown above.
Burn (Withdraw)
This function is invoked when a user withdraws liquidity from the pool. Emits Burn, Transfer and Sync events.withdraw function now takes only the to address and returns the two withdrawn amounts.
Reentrancy Guards: Currently not implemented
In UniswapV2, a reentrancy guard is employed to prevent recursive calls. Here is the corresponding code snippet:- https://github.com/esteblock/reentrancy-soroban
- https://discord.com/channels/897514728459468821/993874836336152576
Protocol Fee Mechanism: Mint Fee Implemented!
UniswapV2 incorporates a protocol fee of 0.05%, which can be toggled on or off. When activated, this fee is routed to an address,feeTo, specified in the factory contract. Initially, feeTo isn’t set, and hence, no fees are collected. There is a designated address, feeToSetter, with the power to invoke the setFeeTo function on the UniswapV2 factory contract, altering the feeTo value. feeToSetter can also change its address via the setFeeToSetter function.
checked_add, checked_sub, checked_mult and checked_div functions to prevent potential overflows.
This functionality has been successfully integrated into the code!
Oracles:
The marginal price of a token pair is calculated by dividing the reserve of one token by the reserve of the other token. Since arbitrageurs will trade against the pair contract to make profits, the marginal price of the pair contract will tend to follow the market price, so maybe we can use the marginal price as an oracle for the market price. However, this is not enough to reliably use this price as an on-chain oracle. An attacker could manipulate the price at an specific moment. If the attacker can get a dApp to check the oracle at the precise instant when the price has been manipulated, then they can cause significant harm to the system. UniswapV1 was vulnerable to this attack, as we can see here. In UniswapV2, the oracle function was modified to prevent this attack, and we will use this oracle function as a reference for our implementation. The solution is to use a cumulative price, which is the sum of the marginal prices over a period of time. The oracle measures and stores the price before the first trade of each block. This price is more difficult to manipulate than the prices in the middle of a block. If the attacker tries to manipulate the price at the start of the block, another arbitrageur can send a transaction to trade back the manipulated price to the real price, so the attacker can’t profit from the manipulation. A miner or an attacker that uses enough gas to fill an entire block can try to manipulate the price at the end of the block, but this will be useless if they mine the following block themselves. The miners can’t know if they will mine the next block, so they can’t profit from this manipulation. So, we know that the price at the start of the block is difficult to manipulate, but we still need to know how to use it as an oracle.A note on arithmetic operations and data types:
The design of oracle functions requires some consideration of arithmetic operations and data types, given that neither Solidity nor Soroban support floating-point numbers or non-integer number data types natively. Both systems employ custom-made fixed-point number data types, conforming to the Q format, which are stored as integers. The Q format is a fixed-point number format that specifies the number of bits used for the integer and fractional parts. Both UniswapV2 and Soroswap utilize the unsigned variant of the Q format, called UQ, only diverging in the number of bits assigned for the integer and fractional components. A UQn.m number is stored as an unsigned integer of n+m bits, where the first n bits are used for the integer part, and the last m bits are used for the fractional part. For illustration, suppose that we have a UQ4.4 format. It means that we are using 4 bits for the integer part and 4 bits for the fractional part. The whole number is stored as an 8-bit unsigned integer. Some examples of UQ4.4 numbers are:- The number 1.5 in UQ4.4 format is represented as 00011000 in binary. The first four bits (0001) represent the integer part 1, and the last four bits (1000) represent the fractional part 0.5.
- The number 3.75 in UQ4.4 format is represented as 00111100 in binary. The first four bits (0011) represent the integer part 3, and the last four bits (1100) represent the fractional part 0.75.
-
Balances need to fit within the uint112 data type to be encoded into UQ112x112 and undergo division operations.
- For Soroswap: Balances will need to fit within an u64 type to be encoded into UQ64X64.
-
Block timestamps are obtained by using the modulo operator to fit them within the uint32 data type. This is done for gas optimization purposes, as described in the whitepaper. Consequently, each set of 224-bit reserves (two reserves as 112-bit) is accompanied by a 32-bit timestamp within a single 256-bit storage slot.
- For Soroswap: We won’t pay much attention for now in gas usage. Can be u32 or u64
-
The block timestamp has the potential to overflow, with the next overflow occurring on 02/07/2106. Oracles are required to account for this and ensure proper functionality by checking prices at least once within each interval of 2^ 32 - 1 seconds (approximately 136 years).
- For Soroswap: Block timestamp can be stored in u64, and will overflow in the year 2554, so we are safe.
-
The variables price0CumulativeLast and price1CumulativeLast are stored using 224 bits each because they hold a sum and multiplications of UQ112X112.\
- For Soroswap: price0CumulativeLast will need to be u128.
-
The price itself will not overflow, but the accumulated price over an interval may exceed the 224-bit limit. To address this, an additional 32 bits are allocated in the storage slots for the accumulated prices of the ratios token A/token B and token B/token A. These extra bits handle any overflow resulting from repeated summations of prices.
- For Soroswap: By default price0CumulativeLast won’t be able to overflow in soroban due to the
overflow-checks = true. Also, there are no bigger integer types in Soroban. See https://soroban.stellar.org/docs/fundamentals-and-concepts/built-in-types
- For Soroswap: By default price0CumulativeLast won’t be able to overflow in soroban due to the
Assuming that the ratio of the reserves in a given pair will be the same as the ratio of the dollar prices of one wei of each token, we can solve for a example pair consisting of a 36 decimal token and a 2 decimal token where the unit value of the 2 decimal token is 100 times that of the 36 decimal token: giving ≈ 8 months until overflow! Authors of oracles that build upon the price accumulator functionality in the core should therefore take care that the their oracles do not introduce spikes or discontinuities in the reported price at the overflow point, if price accumulator overflow is a realistic possibility for the assets involved.What this means for Soroswap?
This means that Soroswap should allow overflow, hence not using overflow-checks = true, but using
checked_fn every time the overflow it is NOT DESIRED (all parts except for price0CumulativeLast)
- The reserves are stored using 112 bits for each token.
Skim
From UniswapV2 Whitepaper:To protect against bespoke token implementations that can update the pair contract’s balance, and to more gracefully handle tokens whose total supply can be greater than , Uniswap v2 has two bail-out functions: sync()and skim(). sync() functions as a recovery mechanism in the case that a token asynchronously deflates the balance of a pair. In this case, trades will receive sub-optimal rates, and if no liquidity provider is willing to rectify the situation, the pair is stuck. sync() exists to set the reserves of the contract to the current balances, providing a somewhat graceful recovery from this situation. skim() functions as a recovery mechanism in case enough tokens are sent to an pair to overflow the two uint112 storage slots for reserves, which could otherwise cause trades to fail. skim() allows a user to withdraw the difference between the current balance of the pair and 2**2112 − 1 to the caller, if that difference is greater than 0.
Safe Transfer: not needed
The_safeTransfer function is specific to Solidity and isn’t necessary to be implemented in Soroban.
Constructor: not needed
In Soroban, theconstructor() and initialize() functions are the same, thus there’s no need to separate them.