Run the Example
First go through the Setup process to get your development environment configured, then clone thev23.0.0 tag of soroban-examples repository:
liquidity_pool directory, and use cargo test.
Code
liquidity_pool/src/lib.rs
How it Works
Every asset created on Stellar starts with zero liquidity. The same is true of tokens created on Soroban (unless a Stellar asset with existing liquidity token has its Stellar Asset Contract (SAC) deployed for use in Soroban). In simple terms, “liquidity” means how much of an asset in a market is available to be bough or sold. In the “old days,” you could generate liquidity in a market by creating buy/sell orders on an order book. Liquidity pools automate this process by substituting the orders with math. Depositors into the liquidity pool earn fees fromswap transactions. No orders required!
Open the liquidity_pool/src/lib.rs file or see the code above to follow along.
Initialize the Contract
When this contract is deployed, the__constructor function will automatically and atomically be invoked, so the following arguments must be passed in:
token_a: The contractAddressfor an already deployed (or wrapped) token that will be held in reserve by the liquidity pool.token_b: The contractAddressfor an already deployed (or wrapped) token that will be held in reserve by the liquidity pool.
token_a and which is token_b is not an arbitrary distinction. In line with the Built-in Stellar liquidity pools, this contract can only make a single liquidity pool for a given set of tokens. So, the token addresses must be provided in lexicographical order at the time of initialization.
liquidity_pool/src/lib.rs
A “Constant Product” Liquidity Pool
The type of liquidity pool this example contract implements is called a “constant product” liquidity pool. While this isn’t the only type of liquidity pool out there, it is the most common variety. These liquidity pools are designed to keep the total value of each asset in relative equilibrium. The “product” in the constant product (also called an “invariant”) will change every time the liquidity pool is interacted with (deposit, withdraw, or token swaps). However, the invariant must only increase with every interaction. During a swap, what must be kept in mind is that for every withdrawal from thetoken_a side, you must “refill” the token_b side with a sufficient amount to keep the liquidity pool’s price balanced. The math is predictable, but it is not linear. The more you take from one side, the more you must give on the opposite site exponentially.
Inside the swap function, the math is done like this (this is a simplified version, however):
liquidity_pool/src/lib.rs
Interacting with Token Contracts in Another Contract
This liquidity pool contract will operate with a total of three different Soroban tokens:- Pool Shares: This example uses a very simple share token given to asset depositors in exchange for their deposit. These tokens are “traded in” by the user when they withdraw some amount of their original deposit (plus any earned swap fees). In this simplified system, shares are just added/subtracted whenever a user deposits or withdraws the underlying assets. No distinct token contract will be used for these shares.
token_aandtoken_b: Will be the two “reserve tokens” that users will deposit into the pool. These could be “wrapped” tokens from pre-existing Stellar assets, or they could be Soroban-native tokens. This contract doesn’t really care, as long as the functions it needs from the common Token Interface are available in the token contract.
Minting and Burning LP Shares
We are minting and burning LP shares within the logic of the main contract, instead of utilizing a distinct token contract. There are some “helper” functions created to facilitate this functionality. These functions are used when a user takes any kind of deposit or withdraw action.liquidity_pool/src/lib.rs
token_a and token_b deposited. Very simple.
However, if there have already been deposits into the liquidity pool, and the user is just adding more tokens into the pool, there’s a bit more math. However, the main point is that each depositor receives the same ratio of POOL tokens for their deposit as every other depositor.
liquidity_pool/src/lib.rs
Token Transfers to/from the LP Contract
As we’ve already discussed, the liquidity pool contract will make use of the Token Interface available in the token contracts that were supplied astoken_a and token_b arguments at the time of initialization. Throughout the rest of the contract, the liquidity pool will make use of that interface to make transfers of those tokens to/from itself.
What’s happening is that as a user deposits tokens into the pool, and the contract invokes the transfer function to move the tokens from the to address (the depositor) to be held by the contract address. POOL tokens are then minted to depositor (see previous section). Pretty simple, right!?
liquidity_pool/src/lib.rs
- The number of shares being “redeemed” by the user are checked against the actual amount of shares the user holds.
- The withdraw amounts for the reserve tokens are calculated based on the amount of share tokens being redeemed.
- The share tokens are burned now the withdraw amounts have been calculated, and they are no longer needed.
- The respective amounts of
token_aandtoken_bare transferred from the contract address into thetoaddress (the depositor).
liquidity_pool/src/lib.rs
token_a and token_b on the liquidity pool contract itself it makes, it very easy for us to perform any of the Token Interface actions inside the contract. As a bonus, any outside observer could query the balances of token_a or token_b held by the contract to verify the reserves are actually in line with the values the contract reports when its own get_rsvs function is invoked.
Tests
Open theliquidity_pool/src/test.rs file to follow along.
liquidity_pool/src/test.rs
Env, which is the Soroban environment that the contract will run in.
liquidity_pool/src/test.rs
liquidity_pool/src/test.rs
liquidity_pool/src/test.rs
impl block that is annotated with the #[contractimpl] attribute have a corresponding function generated in a generated client type. The client type will be named the same as the contract type with Client appended. For example, in our contract the contract type is LiquidityPool, and the client is named LiquidityPoolClient.
These tests examine the “typical” use-case of a liquidity pool, ensuring that the balances, returns, etc. are appropriate at various points during the test.
- First, the test sets everything up with an
Env, two admin addresses, two reserve tokens, a randomly generated address to act as the user of the liquidity pool, the liquidity pool itself, a pool token shares contract, and mints the reserve assets to the user address. - The user then deposits some of each asset into the liquidity pool. At this time, the following checks are done:
- appropriate authorizations for deposits and transfers exist,
- balances are checked for each token (
token_a,token_b, andPOOL) from both the user’s perspective and theliqpoolcontract’s perspective
- The user performs a swap, buying
token_bin exchange fortoken_a. The same checks as the previous step are made now, excepting the balances ofPOOL, since a swap has no effect onPOOLtokens. - The user then withdraws all of the deposits it made, trading all of its
POOLtokens in the process. The same checks are made here as were made in thedepositstep.
Build the Contract
To build the contract, use thestellar contract build command.
.wasm file should be outputted in the target directory:
Run the Contract
If you havestellar-cli installed, you can deploy the contract and invoke its functions.
Deploy
- macOS/Linux
- Windows (PowerShell)
Invoke
- macOS/Linux
- Windows (PowerShell)