Liquidity Pool
The liquidity pool example demonstrates how to write a constant product liquidity pool contract. A liquidity pool is an automated way to add liquidity for a set of tokens that will facilitate asset conversion between them. Users can deposit some amount of each token into the pool, receiving a proportional number of "token shares." The user will then receive a portion of the accrued conversion fees when they ultimately "trade in" their token shares to receive their original tokens back.
Soroban liquidity pools are exclusive to Soroban and cannot interact with built-in Stellar AMM liquidity pools.
Implementing a liquidity pool contract should be done cautiously. User funds are involved, so great care should be taken to ensure safety and transparency. The example here should not be considered a ready-to-go contract. Please use it as a reference only.
The Stellar network already has liquidity pool functionality built right in to the core protocol. Learn more here.
Run the Example
First go through the Setup process to get your development environment configured, then clone the v23.0.0 tag of soroban-examples repository:
git clone -b v23.0.0 https://github.com/stellar/soroban-examples
Or, skip the development environment setup and open this example in GitHub Codespaces or Code Anywhere.
To run the tests for the example, navigate to the liquidity_pool directory, and use cargo test.
cd liquidity_pool
cargo test
You should see the output:
running 3 tests
test test::deposit_amount_zero_should_panic - should panic ... ok
test test::swap_reserve_one_nonzero_other_zero - should panic ... ok
test test::test ... ok
Code
#![no_std]
mod test;
use num_integer::Roots;
use soroban_sdk::{contract, contractimpl, contractmeta, contracttype, token, Address, Env};
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
TokenA,
TokenB,
TotalShares,
ReserveA,
ReserveB,
Shares(Address),
}
fn get_token_a(e: &Env) -> Address {
e.storage().instance().get(&DataKey::TokenA).unwrap()
}
fn get_token_b(e: &Env) -> Address {
e.storage().instance().get(&DataKey::TokenB).unwrap()
}
fn get_total_shares(e: &Env) -> i128 {
e.storage().instance().get(&DataKey::TotalShares).unwrap()
}
fn get_reserve_a(e: &Env) -> i128 {
e.storage().instance().get(&DataKey::ReserveA).unwrap()
}
fn get_reserve_b(e: &Env) -> i128 {
e.storage().instance().get(&DataKey::ReserveB).unwrap()
}
fn get_balance(e: &Env, contract: Address) -> i128 {
token::Client::new(e, &contract).balance(&e.current_contract_address())
}
fn get_balance_a(e: &Env) -> i128 {
get_balance(e, get_token_a(e))
}
fn get_balance_b(e: &Env) -> i128 {
get_balance(e, get_token_b(e))
}
fn get_shares(e: &Env, user: &Address) -> i128 {
e.storage()
.persistent()
.get(&DataKey::Shares(user.clone()))
.unwrap_or(0)
}
fn put_shares(e: &Env, user: &Address, amount: i128) {
e.storage()
.persistent()
.set(&DataKey::Shares(user.clone()), &amount);
}
fn put_token_a(e: &Env, contract: Address) {
e.storage().instance().set(&DataKey::TokenA, &contract);
}
fn put_token_b(e: &Env, contract: Address) {
e.storage().instance().set(&DataKey::TokenB, &contract);
}
fn put_total_shares(e: &Env, amount: i128) {
e.storage().instance().set(&DataKey::TotalShares, &amount)
}
fn put_reserve_a(e: &Env, amount: i128) {
e.storage().instance().set(&DataKey::ReserveA, &amount)
}
fn put_reserve_b(e: &Env, amount: i128) {
e.storage().instance().set(&DataKey::ReserveB, &amount)
}
fn burn_shares(e: &Env, from: &Address, amount: i128) {
let current_shares = get_shares(e, from);
if current_shares < amount {
panic!("insufficient shares");
}
let total = get_total_shares(e);
put_shares(e, from, current_shares - amount);
put_total_shares(e, total - amount);
}
fn mint_shares(e: &Env, to: &Address, amount: i128) {
let current_shares = get_shares(e, to);
let total = get_total_shares(e);
put_shares(e, to, current_shares + amount);
put_total_shares(e, total + amount);
}
fn transfer(e: &Env, token: Address, to: Address, amount: i128) {
token::Client::new(e, &token).transfer(&e.current_contract_address(), &to, &amount);
}
fn transfer_a(e: &Env, to: Address, amount: i128) {
transfer(e, get_token_a(e), to, amount);
}
fn transfer_b(e: &Env, to: Address, amount: i128) {
transfer(e, get_token_b(e), to, amount);
}
fn get_deposit_amounts(
desired_a: i128,
min_a: i128,
desired_b: i128,
min_b: i128,
reserve_a: i128,
reserve_b: i128,
) -> (i128, i128) {
if reserve_a == 0 && reserve_b == 0 {
return (desired_a, desired_b);
}
let amount_b = desired_a * reserve_b / reserve_a;
if amount_b <= desired_b {
if amount_b < min_b {
panic!("amount_b less than min")
}
(desired_a, amount_b)
} else {
let amount_a = desired_b * reserve_a / reserve_b;
if amount_a > desired_a || amount_a < min_a {
panic!("amount_a invalid")
}
(amount_a, desired_b)
}
}
// Metadata that is added on to the WASM custom section
contractmeta!(
key = "Description",
val = "Constant product AMM with a .3% swap fee"
);
#[contract]
struct LiquidityPool;
#[contractimpl]
impl LiquidityPool {
pub fn __constructor(e: Env, token_a: Address, token_b: Address) {
if token_a >= token_b {
panic!("token_a must be less than token_b");
}
put_token_a(&e, token_a);
put_token_b(&e, token_b);
put_total_shares(&e, 0);
put_reserve_a(&e, 0);
put_reserve_b(&e, 0);
}
pub fn balance_shares(e: Env, user: Address) -> i128 {
get_shares(&e, &user)
}
pub fn deposit(
e: Env,
to: Address,
desired_a: i128,
min_a: i128,
desired_b: i128,
min_b: i128,
) {
// Depositor needs to authorize the deposit
to.require_auth();
let (reserve_a, reserve_b) = (get_reserve_a(&e), get_reserve_b(&e));
// Calculate deposit amounts
let (amount_a, amount_b) =
get_deposit_amounts(desired_a, min_a, desired_b, min_b, reserve_a, reserve_b);
if amount_a <= 0 || amount_b <= 0 {
// If one of the amounts can be zero, we can get into a situation
// where one of the reserves is 0, which leads to a divide by zero.
panic!("both amounts must be strictly positive");
}
let token_a_client = token::Client::new(&e, &get_token_a(&e));
let token_b_client = token::Client::new(&e, &get_token_b(&e));
token_a_client.transfer(&to, &e.current_contract_address(), &amount_a);
token_b_client.transfer(&to, &e.current_contract_address(), &amount_b);
// Now calculate how many new pool shares to mint
let (balance_a, balance_b) = (get_balance_a(&e), get_balance_b(&e));
let total_shares = get_total_shares(&e);
let zero = 0;
let new_total_shares = if reserve_a > zero && reserve_b > zero {
let shares_a = (balance_a * total_shares) / reserve_a;
let shares_b = (balance_b * total_shares) / reserve_b;
shares_a.min(shares_b)
} else {
(balance_a * balance_b).sqrt()
};
mint_shares(&e, &to, new_total_shares - total_shares);
put_reserve_a(&e, balance_a);
put_reserve_b(&e, balance_b);
}
// If "buy_a" is true, the swap will buy token_a and sell token_b. This is flipped if "buy_a" is false.
// "out" is the amount being bought, with in_max being a safety to make sure you receive at least that amount.
// swap will transfer the selling token "to" to this contract, and then the contract will transfer the buying token to "to".
pub fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) {
to.require_auth();
let (reserve_a, reserve_b) = (get_reserve_a(&e), get_reserve_b(&e));
let (reserve_sell, reserve_buy) = if buy_a {
(reserve_b, reserve_a)
} else {
(reserve_a, reserve_b)
};
if reserve_buy < out {
panic!("not enough token to buy");
}
// First calculate how much needs to be sold to buy amount out from the pool
let n = reserve_sell * out * 1000;
let d = (reserve_buy - out) * 997;
let sell_amount = (n / d) + 1;
if sell_amount > in_max {
panic!("in amount is over max")
}
// Transfer the amount being sold to the contract
let sell_token = if buy_a {
get_token_b(&e)
} else {
get_token_a(&e)
};
let sell_token_client = token::Client::new(&e, &sell_token);
sell_token_client.transfer(&to, &e.current_contract_address(), &sell_amount);
let (balance_a, balance_b) = (get_balance_a(&e), get_balance_b(&e));
// residue_numerator and residue_denominator are the amount that the invariant considers after
// deducting the fee, scaled up by 1000 to avoid fractions
let residue_numerator = 997;
let residue_denominator = 1000;
let zero = 0;
let new_invariant_factor = |balance: i128, reserve: i128, out: i128| {
let delta = balance - reserve - out;
let adj_delta = if delta > zero {
residue_numerator * delta
} else {
residue_denominator * delta
};
residue_denominator * reserve + adj_delta
};
let (out_a, out_b) = if buy_a { (out, 0) } else { (0, out) };
let new_inv_a = new_invariant_factor(balance_a, reserve_a, out_a);
let new_inv_b = new_invariant_factor(balance_b, reserve_b, out_b);
let old_inv_a = residue_denominator * reserve_a;
let old_inv_b = residue_denominator * reserve_b;
if new_inv_a * new_inv_b < old_inv_a * old_inv_b {
panic!("constant product invariant does not hold");
}
if buy_a {
transfer_a(&e, to, out_a);
} else {
transfer_b(&e, to, out_b);
}
let new_reserve_a = balance_a - out_a;
let new_reserve_b = balance_b - out_b;
if new_reserve_a <= 0 || new_reserve_b <= 0 {
panic!("new reserves must be strictly positive");
}
put_reserve_a(&e, new_reserve_a);
put_reserve_b(&e, new_reserve_b);
}
// transfers share_amount of pool share tokens to this contract, burns all pools share tokens in this contracts, and sends the
// corresponding amount of token_a and token_b to "to".
// Returns amount of both tokens withdrawn
pub fn withdraw(
e: Env,
to: Address,
share_amount: i128,
min_a: i128,
min_b: i128,
) -> (i128, i128) {
to.require_auth();
let current_shares = get_shares(&e, &to);
if current_shares < share_amount {
panic!("insufficient shares");
}
let (balance_a, balance_b) = (get_balance_a(&e), get_balance_b(&e));
let total_shares = get_total_shares(&e);
// Calculate withdrawal amounts
let out_a = (balance_a * share_amount) / total_shares;
let out_b = (balance_b * share_amount) / total_shares;
if out_a < min_a || out_b < min_b {
panic!("min not satisfied");
}
burn_shares(&e, &to, share_amount);
transfer_a(&e, to.clone(), out_a);
transfer_b(&e, to, out_b);
put_reserve_a(&e, balance_a - out_a);
put_reserve_b(&e, balance_b - out_b);
(out_a, out_b)
}
pub fn get_rsrvs(e: Env) -> (i128, i128) {
(get_reserve_a(&e), get_reserve_b(&e))
}
}
Ref: https://github.com/stellar/soroban-examples/tree/v23.0.0/liquidity_pool
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 from swap 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.
Bear in mind that which token is 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.
pub fn __constructor(e: Env, token_a: Address, token_b: Address) {
if token_a >= token_b {
panic!("token_a must be less than token_b");
}
put_token_a(&e, token_a);
put_token_b(&e, token_b);
put_total_shares(&e, 0);
put_reserve_a(&e, 0);
put_reserve_b(&e, 0);
}
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 the token_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):
pub fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) {
// Get the current balances of both tokens in the liquidity pool
let (reserve_sell, reserve_buy) = (get_reserve_a(&e), get_reserve_b(&e));
// Calculate how much needs to be
let n = reserve_sell * out * 1000;
let d = (reserve_buy - out) * 997;
let sell_amount = (n / d) + 1;
}
We have much more in-depth information about how this kind of liquidity pool works is available in Stellar Quest: Series 3, Quest 5. This is a really useful, interactive way to learn more about how the built-in Stellar liquidity pools work. Much of the knowledge you might gain from there will easily translate to this example contract.
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.
fn burn_shares(e: &Env, from: &Address, amount: i128) {
let current_shares = get_shares(e, from);
if current_shares < amount {
panic!("insufficient shares");
}
let total = get_total_shares(e);
put_shares(e, from, current_shares - amount);
put_total_shares(e, total - amount);
}
fn mint_shares(e: &Env, to: &Address, amount: i128) {
let current_shares = get_shares(e, to);
let total = get_total_shares(e);
put_shares(e, to, current_shares + amount);
put_total_shares(e, total + amount);
}
How is that number of shares calculated, you ask? Excellent question! If it's the very first deposit (see above), it's just the square root of the product of the quantities of 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.
fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128) {
let zero = 0;
let new_total_shares = if reserve_a > zero && reserve_b > zero {
// Note balance_a and balance_b at this point in the function include
// the tokens the user is currently depositing, whereas reserve_a and
// reserve_b do not yet.
let shares_a = (balance_a * total_shares) / reserve_a;
let shares_b = (balance_b * total_shares) / reserve_b;
shares_a.min(shares_b)
} else {
(balance_a * balance_b).sqrt()
};
}
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 as token_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!?
fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128) {
// Depositor needs to authorize the deposit
to.require_auth();
let token_a_client = token::Client::new(&e, &get_token_a(&e));
let token_b_client = token::Client::new(&e, &get_token_b(&e));
token_a_client.transfer(&to, &e.current_contract_address(), &amount_a);
token_b_client.transfer(&to, &e.current_contract_address(), &amount_b);
mint_shares(&e, to, new_total_shares - total_shares);
}
In contrast, when a user withdraws their deposited tokens, it's a bit more involved, and the following procedure happens.
- 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).
fn withdraw(e: Env, to: Address, share_amount: i128, min_a: i128, min_b: i128) -> (i128, i128) {
to.require_auth();
// First calculate the specified pool shares are available to the user
let current_shares = get_shares(&e, &to);
if current_shares < share_amount {
panic!("insufficient shares");
}
// ... balances of pool shares and underlying assets are retrieved
// Now calculate the withdraw amounts
let out_a = (balance_a * balance_shares) / total_shares;
let out_b = (balance_b * balance_shares) / total_shares;
burn_shares(&e, balance_shares);
transfer_a(&e, to.clone(), out_a);
transfer_b(&e, to, out_b);
}
You'll notice that by holding the balance of 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 the liquidity_pool/src/test.rs file to follow along.
#![cfg(test)]
extern crate std;
use crate::LiquidityPoolClient;
use soroban_sdk::{
symbol_short,
testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation},
token, Address, Env, IntoVal,
};
fn create_token_contract<'a>(
e: &Env,
admin: &Address,
) -> (token::Client<'a>, token::StellarAssetClient<'a>) {
let sac = e.register_stellar_asset_contract_v2(admin.clone());
(
token::Client::new(e, &sac.address()),
token::StellarAssetClient::new(e, &sac.address()),
)
}
fn create_liqpool_contract<'a>(
e: &Env,
token_a: &Address,
token_b: &Address,
) -> LiquidityPoolClient<'a> {
LiquidityPoolClient::new(e, &e.register(crate::LiquidityPool {}, (token_a, token_b)))
}
#[test]
fn test() {
let e = Env::default();
e.mock_all_auths();
let admin1 = Address::generate(&e);
let admin2 = Address::generate(&e);
let (token1, token1_admin) = create_token_contract(&e, &admin1);
let (token2, token2_admin) = create_token_contract(&e, &admin2);
let user1 = Address::generate(&e);
let liqpool = create_liqpool_contract(&e, &token1.address, &token2.address);
token1_admin.mint(&user1, &1000);
assert_eq!(token1.balance(&user1), 1000);
token2_admin.mint(&user1, &1000);
assert_eq!(token2.balance(&user1), 1000);
liqpool.deposit(&user1, &100, &100, &100, &100);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
liqpool.address.clone(),
symbol_short!("deposit"),
(&user1, 100_i128, 100_i128, 100_i128, 100_i128).into_val(&e)
)),
sub_invocations: std::vec![
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token1.address.clone(),
symbol_short!("transfer"),
(&user1, &liqpool.address, 100_i128).into_val(&e)
)),
sub_invocations: std::vec![]
},
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token2.address.clone(),
symbol_short!("transfer"),
(&user1, &liqpool.address, 100_i128).into_val(&e)
)),
sub_invocations: std::vec![]
}
]
}
)]
);
assert_eq!(liqpool.balance_shares(&user1), 100);
assert_eq!(token1.balance(&user1), 900);
assert_eq!(token1.balance(&liqpool.address), 100);
assert_eq!(token2.balance(&user1), 900);
assert_eq!(token2.balance(&liqpool.address), 100);
liqpool.swap(&user1, &false, &49, &100);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
liqpool.address.clone(),
symbol_short!("swap"),
(&user1, false, 49_i128, 100_i128).into_val(&e)
)),
sub_invocations: std::vec![AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token1.address.clone(),
symbol_short!("transfer"),
(&user1, &liqpool.address, 97_i128).into_val(&e)
)),
sub_invocations: std::vec![]
}]
}
)]
);
assert_eq!(token1.balance(&user1), 803);
assert_eq!(token1.balance(&liqpool.address), 197);
assert_eq!(token2.balance(&user1), 949);
assert_eq!(token2.balance(&liqpool.address), 51);
e.cost_estimate().budget().reset_unlimited();
liqpool.withdraw(&user1, &100, &197, &51);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
liqpool.address.clone(),
symbol_short!("withdraw"),
(&user1, 100_i128, 197_i128, 51_i128).into_val(&e)
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token1.balance(&user1), 1000);
assert_eq!(token2.balance(&user1), 1000);
assert_eq!(liqpool.balance_shares(&user1), 0);
assert_eq!(token1.balance(&liqpool.address), 0);
assert_eq!(token2.balance(&liqpool.address), 0);
}
#[test]
#[should_panic]
fn deposit_amount_zero_should_panic() {
let e = Env::default();
e.mock_all_auths();
// Create contracts
let admin1 = Address::generate(&e);
let admin2 = Address::generate(&e);
let (token1, token1_admin) = create_token_contract(&e, &admin1);
let (token2, token2_admin) = create_token_contract(&e, &admin2);
let liqpool = create_liqpool_contract(&e, &token1.address, &token2.address);
// Create a user
let user1 = Address::generate(&e);
token1_admin.mint(&user1, &1000);
assert_eq!(token1.balance(&user1), 1000);
token2_admin.mint(&user1, &1000);
assert_eq!(token2.balance(&user1), 1000);
liqpool.deposit(&user1, &1, &0, &0, &0);
}
#[test]
#[should_panic]
fn swap_reserve_one_nonzero_other_zero() {
let e = Env::default();
e.mock_all_auths();
// Create contracts
let admin1 = Address::generate(&e);
let admin2 = Address::generate(&e);
let (token1, token1_admin) = create_token_contract(&e, &admin1);
let (token2, token2_admin) = create_token_contract(&e, &admin2);
let liqpool = create_liqpool_contract(&e, &token1.address, &token2.address);
// Create a user
let user1 = Address::generate(&e);
token1_admin.mint(&user1, &1000);
assert_eq!(token1.balance(&user1), 1000);
token2_admin.mint(&user1, &1000);
assert_eq!(token2.balance(&user1), 1000);
// Try to get to a situation where the reserves are 1 and 0.
// It shouldn't be possible.
token2.transfer(&user1, &liqpool.address, &1);
liqpool.swap(&user1, &false, &1, &1);
}
In any test, the first thing that is always required is an Env, which is the Soroban environment that the contract will run in.
let e = Env::default();
We mock authentication checks in the tests, which allows the tests to proceed as if all users/addresses/contracts/etc. had successfully authenticated.
e.mock_all_auths();
We have abstracted into a couple functions the tasks of creating token contracts and deploying a liquidity pool contract. Each are then used within the test.
fn create_token_contract<'a>(
e: &Env,
admin: &Address,
) -> (token::Client<'a>, token::StellarAssetClient<'a>) {
let sac = e.register_stellar_asset_contract_v2(admin.clone());
(
token::Client::new(e, &sac.address()),
token::StellarAssetClient::new(e, &sac.address()),
)
}
fn create_liqpool_contract<'a>(
e: &Env,
token_a: &Address,
token_b: &Address,
) -> LiquidityPoolClient<'a> {
LiquidityPoolClient::new(e, &e.register(crate::LiquidityPool {}, (token_a, token_b)))
}
All public functions within an 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 the stellar contract build command.
stellar contract build
A .wasm file should be outputted in the target directory:
target/wasm32v1-none/release/soroban_liquidity_pool_contract.wasm
Run the Contract
If you have stellar-cli installed, you can invoke contract functions using it.
- macOS/Linux
- Windows (PowerShell)
stellar contract invoke \
--wasm target/wasm32v1-none/release/soroban_liquidity_pool_contract.wasm \
--id 1 \
-- \
deposit \
--to GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR \
--desired_a 100 \
--min_a 98 \
--desired_be 200 \
--min_b 196
stellar contract invoke `
--wasm target/wasm32v1-none/release/soroban_liquidity_pool_contract.wasm `
--id 1 `
-- `
deposit `
--to GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR `
--desired_a 100 `
--min_a 98 `
--desired_be 200 `
--min_b 196