| title | Liquidity on Stellar: the SDEX and Liquidity Pools |
|---|
import { CodeExample } from "@site/src/components/CodeExample"; import YouTube from "@site/src/components/YouTube";
Financial marketes thrive because of liquidity. It's the grease in the weels, to sto speak, of a global payments system. Stellar is one of a very few platforms that accounts for liquidity at scale with no centralized parti.
The founders of the network, particularly Jed McCaleb, understoof firsthand the quandaries of of centralized order books. IN 2012, Jed wrote: "One thing you can do with it is exchange bitcoins for fiat without the need of a centralized market such as mtgox. Which will be very useful for bitcoin" when disucssing preliminary ideas for Stellar.
This prescinet perspective led to the creation of a distributed exchange, as it was originally named. The core concept, released in the first version of the protocol, still operates today. However, for phonetical reasons, we will hereafter refer to it as the Stellar Decentralized Exchange or SDEX.
When you convert one currency to another in traditional Financial markets, your trade happens because of limit orders. These orders specify how much someone will pay for a certain amount of any asset. Thus, when you need to convert you dollars to yen, e.g., their order to sell yen for dollars countreracts and "fulfills" your trade.
The more orders avaliable to transact against, the more currency you can convert at any time without moving hte market. Stellar stores these open orders as offer objects directly on chain. XYZ_ONE_SENT_HERE_WITH_HREF_ON_TRADING--myaybe speedex psot
The network and by extension its validators match orders based on the protocol rules of stellar core. SENTENCE_HERE_ON_SAME_PRIC_ORBETT SENTENCE_BEST_EXEC (#ref-more-later-on-per-ledger-match-matching)
Let's pretend you just got paid in Mexican Pesos, but you'd prefer to hold you savings in the U.S. Dollar. After querying the quexhcange rate, we can send a new offer to the network swapping Pesos for dollars at our desired rate. XYZ_CLERVERY_SENT_HERE_ON_SEGWAY_TO_EX--prob somethign along hte lines of using a taker offer to garuntee fill price
new order with your accutn info setup priv, pubkeys
ou create asset objects and current PRICE STATIC VARs
make sell offer, submit to network, print result fillMultiple exchanges will only increase volatility since each market will be thinner. The only thing that will decrease volatility is liquidity. &mdashJed McCaleb
Since any account can send an order to the SDEX, its order book is uniquely egalitarian in that all market participants follow the same rules, treatment, and pricing. You receive the same exchange rate whetehr you are a major European bank or a rural Kenyan farmer.. Ubdeed, the throughout enabled by SCP means you can specify exactly how much you pay for conversions.
Now that we know how to submit an orde to the SDEX , let's walk though reading the current order book. This entails collecting all the buyers for a specific currency pair and comparing thisdemand to all the selers of that pair. We'll stick with our pesos-dollars example, adnd ignore potnetial [passive offers](#prob this page idk) for the time being:
read through horizonRelevantly, this exmaple code querys the Horizon instance of the [SDF](THERE NEEDSNT TO BE A DOCS PAGE ON WHAT THE STELLAR DVELOPMENT FOUNDATION IS THIS IS EXTREMELY IMPORTANT FRAMING <<>>)'s testnet. Howver, in production, you would replace this server with the validating entity ou mst trust. This is because you want to align your view of the network with the entrypoint which you send any order to.
Along those lines, it's also worth noting again the difference in functinal control levels regauding teh SDEX. While you can read and submit transactions through public Horizon interfaces, validators themselves form transactions sets and reach consenssus in accordance with the underlying protocol rules governing core SCP. This means that your trade to convery currencies always exectued in equal preference ot other pending orders as long as the Horizon entrypoint operator includes your operation in their transaction setRE_SCP##) per their [reputation](TODO_MAYBE_EXPND_THIS<EED_A_SEC_ON_THE_innate_trust_facotgin_btn_sts,,,could become GH Issue).
markets good xyz some industries,
But these days the exchanges are all for-profit businesses, and they all seem to be beholden to their best customers... who have the least to do with the purpose for which markets actually exist in the first place. —Erik Townsend
Since all oreders for a currency pari fall into the same SDEX order book, you cna know that you're getting the best exchange rate between two explicit assets. Accordingly, you can analyze the past valuation of a currency by reading its exchanged trades feed. We'll continue our exmaple and set up a recent trading price query:
Time and sales conif data example, stored localWhen perfoming this anaylsis on live [mainnet](SRC_HRED_DOCS_MAONTEST,TESTNET agian i think) data, you will in all ikelihood see a combination of trading take place on top of the SDEX. The first, mst basic option, is when one new order crosses the price set by an outstanding order. The two instantly cross once the new order gets accepted, and the transaction generatesa taker contraID rather than an offerID for the "buyer" of othe existing liquditiy.
The other two optiosn are exchanges made using path paymnets or liquidity tpools. Both path payments and liqudiity pool s are consatntly interacting with the SDEX. Indeed, every new path payment and liquifity pool operation can only happen because of exiting order book offers or new user swap requests. <<prob_move_todo>>
Sdex done such as nd such many trades dynamic range of eassets and more
creating new asets dirfrom issuer
#reading-prices: shoudl go over querying the libve order book and also oracles as applicable; miplicate NBBO subtly
:::note
This section is scoped specifically to liquidity regarding the Stellar Decentralized Exchange ("SDEX") and Automated Market Makers ("AMMs") built into the Stellar protocol. It does not include information regarding smart contracts. Rather, it explains the network's native layer-1 atomic swap infrastructure.
:::
The Stellar network takes a crowdsourced approach to liquidity. Namely, Stellar's DEX gives all users equal access to a global decentralized order book. Since launching with the genesis of stellar-core in 2014, the SDEX has processed over 4.2 billion trades worth upwards of $23,560,000,000.
Users can trade assets across the Stellar network using priced orders, path payments, and native AMMs. These freatures leverage Stellar’s decentralized exchange, which has existed since the launch of the network, and liquidity pools, introduced in Protocol 18.
In this section, we will talk about the SDEX and liquidity pools. To learn about how these work together to execute transactions, see our [Path Payments Encyclopedia Entry].
;;;should start with outlining the generalized payments case for the DEX in cross-border payments; exprapolate to generlaized tokens settlemetn
mv path payment here
The Stellar network acts as a decentralized distributed exchange that allows users to trade and convert assets with the Manage Buy Offer and Manage Sell Offer operations. The Stellar ledger stores both the balances held by user accounts and orders that user accounts make to buy or sell assets.
Stellar uses order books to operate its decentralized exchange.
An order book is a record of outstanding orders on a network, and each record sits between two assets (wheat and sheep, for example). The order book for this asset pair records every account wanting to sell wheat for sheep and every account wanting to sell sheep for wheat. In traditional finance, buying is expressed as a “bid” order, and selling is expressed as an “ask” order (ask orders are also called offers).
A couple of notes on order books on Stellar:
- The term “offers” usually refers specifically to ask orders. In Stellar, however, all orders are stored as selling—i.e., the system automatically converts bids to asks. Because of this, the terms “offer” and “order” are used interchangeably in the Stellar ecosystem.1
- Order books contain all orders that are acceptable to parties on either side to make a trade.
- Some assets will have a small or nonexistent order book between them. In these cases, Stellar facilitates path payments, which we’ll discuss later.
To view an order book chart, see the Order Book Wikipedia Page. In addition, there are also plenty of video tutorials and articles out there that can help you understand how order books work in greater detail.
An account can create orders to buy or sell assets using the Manage Buy Offer, Manage Sell Offer, or Passive Order operations. The account must hold the asset it wants to exchange, and it must trust the issuer of the asset it is trying to buy.
Orders in Stellar behave like limit orders in traditional markets. When an account initiates an order, it is checked against the existing orderbook for that asset pair. If the submitted order is a marketable order (for a marketable buy limit order, the limit price is at or above the ask price; for a marketable sell limit order, the limit price is at or below the bid price), it is filled at the existing order price for the available quantity at that price. If the order is not marketable (i.e., does not cross an existing order), the order is saved on the orderbook until it is either consumed by another order, consumed by a path payment, or canceled by the account that created the order.
Each order constitutes a selling obligation for the selling asset and buying obligation for the buying asset. These obligations are stored in the account (for lumens) or trustline (for other assets) owned by the account creating the order. Any operation that would cause an account to be unable to satisfy its obligations — such as sending away too much balance — will fail. This guarantees that any order in the orderbook can be executed entirely.
Orders are executed on a price-time priority, meaning orders will be executed based first on price; for orders placed at the same price, the order that was entered earlier is given priority and is executed before the newer one.
Each order in Stellar is quoted with an associated price and is represented as a ratio of the two assets in the order, one being the “quote asset” and the other being the “base asset”. This is to ensure there is no loss of precision when representing the price of the order (as opposed to storing the fraction as a floating-point number).
Prices are specified as a {numerator, denominator} pair with both components of the fraction represented as 32-bit signed integers. The numerator is considered the base asset, and the denominator is considered the quote asset. When expressing a price of “Asset A in terms of Asset B”, the amount of B is the denominator (and therefore the quote asset), and A is the numerator (and therefore the base asset). As a good rule of thumb, it’s generally correct to be thinking about the base asset that is being bought/sold (in terms of the quote asset).
When creating a buy order in Stellar via the Manage Buy Offer operation, the price is specified as 1 unit of the base currency (the asset being bought), in terms of the quote asset (the asset that is being sold). For example, if you’re buying 100 XLM in exchange for 20 USD, you would specify the price as {20, 100}, which would be the equivalent of 5 XLM for 1 USD (or $.20 per XLM).
When creating a sell order in Stellar via the Manage Sell Offer operation, the price is specified as 1 unit of base currency (the asset being sold), in terms of the quote asset (the asset that is being bought). For example, if you’re selling 100 XLM in exchange for 40 USD, you would specify the price as {40, 100}, which would be the equivalent of 2.5 XLM for 1 USD (or $.40 per XLM).
Passive orders allow markets to have zero spread. If you want to exchange USD from anchor A for USD from anchor B at a 1:1 price, you can create two passive orders so the two orders don’t fill each other.
A passive order is an order that does not execute against a marketable counter order with the same price. It will only fill if the prices are not equal. For example, if the best order to buy BTC for XLM has a price of 100XLM/BTC, and you make a passive offer to sell BTC at 100XLM/BTC, your passive offer does not take that existing offer. If you instead make a passive offer to sell BTC at 99XLM/BTC it would cross the existing offer and fill at 100XLM/BTC.
An account can place a passive sell order via the Create Passive Sell Offer operation.
The order price you set is independent of the fee you pay for submitting that order in a transaction. Fees are always paid in XLM, and you specify them as a separate parameter when submitting the order to the network.
To learn more about transaction fees, see our section on Fees section.
Liquidity pools enable automated market making on the Stellar network. Liquidity refers to how easily and cost-effectively one asset can be converted to another.
Instead of relying on the buy and sell orders of decentralized exchanges, AMMs keep assets in an ecosystem liquid 24/7 using liquidity pools.
AMMs provide liquidity using a mathematical equation. AMMs hold two different assets in a liquidity pool, and the quantities of those assets (or reserves) are inputs for that equation (Asset A * Asset B = k). If an AMM holds more of the reserve assets, the asset prices move less in response to a trade.
AMMs are willing to make some trades and unwilling to make others. For example, if 1 EUR = 1.17 USD, then the AMM might be willing to sell 1 EUR for 1.18 USD and unwilling to sell 1 EUR for 1.16 USD. To determine what trades are acceptable, the AMM enforces an invariant. There are many possible invariants, and Stellar enforces a constant product invariant and so is known as a constant product market maker. This means that AMMs on Stellar must never allow the product of the reserves to decrease.
For example, suppose the current reserves in the liquidity pool are 1000 EUR and 1170 USD which implies a product of 1,170,000. Selling 1 EUR for 1.18 USD would be acceptable because that would leave reserves of 999 EUR and 1171.18 USD, which implies a product of 1,170,008.82. But selling 1 EUR for 1.16 USD would not be acceptable because that would leave reserves of 999 EUR and 1171.16 USD, which implies a product of 1,169,988.84.
AMMs decide exchange rates based on the ratio of reserves in the liquidity pool. If this ratio is different than the true exchange rate, arbitrageurs will come in and trade with the AMM at a favorable price. This arbitrage trade moves the ratio of the reserves back toward the true exchange rate.
AMMs charge fees on every trade, which is a fixed percentage of the amount bought by the AMM. For example, if an automated market maker sells 100 EUR for 118 USD then the fee is charged on the USD. The fee is 0.30%. If you actually wanted to make this trade, you would need to pay about 118.355 USD for 100 EUR. The automated market maker factors the fees into the constant product invariant, so in reality, the product of the reserves grows after every trade.
Any eligible participant can deposit assets into a liquidity pool, and in return, receive pool shares representing their ownership of that asset. If there are 150 total pool shares and one user owns 30, they are entitled to withdraw 20% of the liquidity pool asset at any time.
Pool shares are similar to other assets on Stellar but they cannot be transferred. You can only increase the number of pool shares you hold by depositing into a liquidity pool with the LiquidityPoolDespositOp and decrease the number of pool shares you hold by withdrawing from a liquidity pool with LiquidityPoolWithdrawOp.
A pool share has two representations. The full representation is used with ChangeTrustOp, and the hashed representation is used in all other cases. When constructing the asset representation of a pool share, the assets must be in lexicographical order. For example, A-B is in the correct order but B-A is not. This results in a canonical representation of a pool share.
AMMs charge a fee on all trades and the participants in the liquidity pool receive a share of the fee proportional to their share of the assets in the liquidity pool. Participants collect these fees when they withdraw their assets from the pool. The communitee agreed on the current fixed rate of 0.30%, the fee used in UniswapV2. These charges are completely separate from the network fees.
Users need to establish trustlines to three different assets to participate in a liquidity pool: both the reserve assets (unless one of them is XLM) and the pool share itself.
An account needs a trustline for every pool share it wants to own. It is not possible to deposit into a liquidity pool without a trustline for the corresponding pool share. Pool share trustlines differ from trustlines for other assets in a few ways:
- A pool share trustline cannot be created unless the account already has trustlines that are authorized or authorized to maintain liabilities for the assets in the liquidity pool. See below for more information about how authorization impacts pool share trustlines.
- A pool share trustline requires 2 base reserves instead of 1. For example, an account (2 base reserves) with a trustline for asset A (1 base reserve), a trustline for asset B (1 base reserve), and a trustline for the A-B pool share (2 base reserves) would have a reserve requirement of 6 base reserves.
Pool share trustlines cannot be authorized or de-authorized independently. Instead, the authorization of a pool share trustline is derived from the trustlines for the assets in the liquidity pool. This design is necessary because a liquidity pool may contain assets from two different issuers, and both issuers should have a say in whether the pool share trustline is authorized.
There are a few possibilities with regard to authorization. The behavior of the A-B pool share trustline is determined according to the following table:
| SCENARIO | BEHAVIOR |
|---|---|
| Trustlines for A and B are fully authorized | No restrictions on deposit and withdrawal |
| Trustline for A is fully authorized but trustline for B is authorized to maintain liabilities | Trustlines for A and B are authorized to maintain liabilities |
| Trustline for B is fully authorized but trustline for A is authorized to maintain liabilities | Trustlines for A and B are authorized to maintain liabilities |
| Trustlines for A and B are authorized to maintain liabilities | Trustlines for A and B are authorized to maintain liabilities |
| Trustline for A is not authorized or doesn’t exist | Pool share trustline does not exist |
| Trustline for B is not authorized or doesn’t exist | Pool share trustline does not exist |
If the issuer of A or B revokes authorization, then the account will automatically withdraw from every liquidity pool containing that asset and those pool share trustlines will be deleted. We say that these pool shares have been redeemed. For example, if the account participates in the A-B, A-C, and B-C liquidity pools and the issuer of A revokes authorization then the account will redeem from A-B and A-C but not B-C. For each redeemed pool share trustline, a Claimable Balance will be created for each asset contained in the pool if there is a balance being withdrawn and the redeemer is not the issuer of that asset. The claimant of the Claimable Balance will be the owner of the deleted pool share trustline, and the sponsor of the Claimable Balance will be the sponsor of the deleted pool share trustline. The BalanceID of each Claimable Balance is the SHA-256 hash of the revokeID.
There are two operations that facilitate participation in a liquidity pool: LiquidityPoolDeposit and LiquidityPoolWithdraw. Use LiquidityPoolDeposit to start providing liquidity to the market. Use LiquidityPoolWithdraw to stop providing liquidity to the market.
However, users don’t need to participate in the pool to take advantage of what it’s offering: an easy way to exchange two assets. For that, just use PathPaymentStrictReceive or PathPaymentStrictSend. If your application is already using path payments, then you don’t need to change anything for users to take advantage of the prices available in liquidity pools.
both the traditional order book (with limit orders) and the AMMs work together seamlessly. When a trade is initiated, instead of having to choose whether to go through the order book or an AMM, the system would automatically check both sources of liquidity and execute the trade using whichever offers the best rate.
Here we will cover basic liquidity pool participation and querying.
For all of the following examples, we’ll be working with three funded Testnet accounts. If you’d like to follow along, generate some keypairs and fund them via the friendbot.
The following code sets up the accounts and defines some helper functions. These should be familiar if you’ve played around with other examples like clawbacks.2
const sdk = require("stellar-sdk");
const BigNumber = require("bignumber.js");
let server = new sdk.Server("https://horizon-testnet.stellar.org");
/// Helps simplify creating & signing a transaction.
function buildTx(source, signer, ...ops) {
let tx = new sdk.TransactionBuilder(source, {
fee: sdk.BASE_FEE,
networkPassphrase: sdk.Networks.TESTNET,
});
ops.forEach((op) => tx.addOperation(op));
tx = tx.setTimeout(30).build();
tx.sign(signer);
return tx;
}
/// Returns the given asset pair in "protocol order."
function orderAssets(A, B) {
return sdk.Asset.compare(A, B) <= 0 ? [A, B] : [B, A];
}
/// Returns all of the accounts we'll be using.
function getAccounts() {
return Promise.all(kps.map((kp) => server.loadAccount(kp.publicKey())));
}
const kps = [
"SBGCD73TK2PTW2DQNWUYZSTCTHHVJPL4GZF3GVZMCDL6GYETYNAYOADN",
"SAAQFHI2FMSIC6OFPWZ3PDIIX3OF64RS3EB52VLYYZBX6GYB54TW3Q4U",
"SCJWYFTBDMDPAABHVJZE3DRMBRTEH4AIC5YUM54QGW57NUBM2XX6433P",
].map((s) => sdk.Keypair.fromSecret(s));
// kp0 issues the assets
const kp0 = kps[0];
const [A, B] = orderAssets(
...[new sdk.Asset("A", kp0.publicKey()), new sdk.Asset("B", kp0.publicKey())],
);
/// Establishes trustlines and funds `recipientKps` for all `assets`.
function distributeAssets(issuerKp, recipientKps, ...assets) {
return server.loadAccount(issuerKp.publicKey()).then((issuer) => {
const ops = recipientKps
.map((recipientKp) =>
assets.map((asset) => [
sdk.Operation.changeTrust({
source: recipientKp.publicKey(),
limit: "100000",
asset: asset,
}),
sdk.Operation.payment({
source: issuerKp.publicKey(),
destination: recipientKp.publicKey(),
amount: "100000",
asset: asset,
}),
]),
)
.flat(2);
let tx = buildTx(issuer, issuerKp, ...ops);
tx.sign(...recipientKps);
return server.submitTransaction(tx);
});
}
function preamble() {
return distributeAssets(kp0, [kps[1], kps[2]], A, B);
}from decimal import Decimal
from typing import List, Any, Dict
from stellar_sdk import *
server = Server("https://horizon-testnet.stellar.org")
# Preamble
def new_tx_builder(source: str) -> TransactionBuilder:
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE
base_fee = 100
source_account = server.load_account(source)
builder = TransactionBuilder(
source_account=source_account, network_passphrase=network_passphrase, base_fee=base_fee
).set_timeout(30)
return builder
# Returns the given asset pair in "protocol order."
def order_asset(a: Asset, b: Asset) -> List[Asset]:
return [a, b] if LiquidityPoolAsset.is_valid_lexicographic_order(a, b) else [b, a]
secrets = [
"SBGCD73TK2PTW2DQNWUYZSTCTHHVJPL4GZF3GVZMCDL6GYETYNAYOADN",
"SAAQFHI2FMSIC6OFPWZ3PDIIX3OF64RS3EB52VLYYZBX6GYB54TW3Q4U",
"SCJWYFTBDMDPAABHVJZE3DRMBRTEH4AIC5YUM54QGW57NUBM2XX6433P",
]
kps = [Keypair.from_secret(secret=secret) for secret in secrets]
# kp0 issues the assets
kp0 = kps[0]
asset_a, asset_b = order_asset(Asset("A", kp0.public_key), Asset("B", kp0.public_key))
def distribute_assets(
issuer_kp: Keypair, recipient_kp: Keypair, assets: List[Asset]
) -> Dict[str, Any]:
builder = new_tx_builder(issuer_kp.public_key)
for asset in assets:
builder.append_change_trust_op(
asset=asset, limit="100000", source=recipient_kp.public_key
).append_payment_op(
destination=recipient_kp.public_key,
asset=asset,
amount="100000",
source=issuer_kp.public_key,
)
tx = builder.build()
tx.sign(issuer_kp)
tx.sign(recipient_kp)
resp = server.submit_transaction(tx)
return resp
def preamble() -> None:
resp1 = distribute_assets(kp0, kps[1], [asset_a, asset_b])
resp2 = distribute_assets(kp0, kps[2], [asset_a, asset_b])
# ...Here, we use distributeAssets() to establish trustlines and set up initial balances of two custom assets (A and B, issued by kp0) for two accounts (kp2 and kp3). For someone to participate in the pool, they must establish trustlines to each of the asset issuers and to the pool share asset (explained below).
:::danger TODO
Case when buying asset an asset, amount aquired > existing trustline amount.
:::
Note the orderAssets() helper here. Operations related to liquidity pools refer to the asset pair arbitrarily as A and B; however, they must be “ordered” such that A < B. This ordering is defined by the protocol, but its details should not be relevant (if you’re curious, it’s essentially lexicographically ordered by asset type, code, then issuer). We can use the comparison methods built into the SDKs (like Asset.compare) to ensure we pass them in the right order and avoid errors.
First, let's create a liquidity pool for the asset pair defined in the preamble. This involves establishing a trustline to the pool itself:
pool_share_asset = LiquidityPoolAsset(asset_a=asset_a, asset_b=asset_b)
def establish_pool_trustline(source: Keypair, pool_asset: LiquidityPoolAsset) -> Dict[str, Any]:
tx = (
new_tx_builder(source.public_key)
.append_change_trust_op(asset=pool_asset, limit="100000")
.build()
)
tx.sign(source)
return server.submit_transaction(tx)const poolShareAsset = new sdk.LiquidityPoolAsset(
A,
B,
sdk.LiquidityPoolFeeV18,
);
function establishPoolTrustline(account, keypair, poolAsset) {
return server.submitTransaction(
buildTx(
account,
keypair,
sdk.Operation.changeTrust({
asset: poolAsset,
limit: "100000",
}),
),
);
}This lets the participants hold pool shares, which means now they can perform deposits and withdrawals.
To work with a liquidity pool, you need to know its ID beforehand. It’s a deterministic value, and only a single liquidity pool can exist for a particular asset pair, so you can calculate it locally from the pool parameters.
pool_id = pool_share_asset.liquidity_pool_id
def add_liquidity(
source: Keypair,
pool_id: str,
max_reserve_a: Decimal,
max_reserve_b: Decimal,
) -> dict[str, Any]:
exact_price = max_reserve_a / max_reserve_b
min_price = exact_price - exact_price * Decimal("0.1")
max_price = exact_price + exact_price * Decimal("0.1")
tx = (
new_tx_builder(source.public_key)
.append_liquidity_pool_deposit_op(
liquidity_pool_id=pool_id,
max_amount_a=f"{max_reserve_a:.7f}",
max_amount_b=f"{max_reserve_b:.7f}",
min_price=min_price,
max_price=max_price,
)
.build()
)
tx.sign(source)
return server.submit_transaction(tx)const poolId = sdk
.getLiquidityPoolId(
"constant_product",
poolShareAsset.getLiquidityPoolParameters(),
)
.toString("hex");
function addLiquidity(source, signer, poolId, maxReserveA, maxReserveB) {
const exactPrice = maxReserveA / maxReserveB;
const minPrice = exactPrice - exactPrice * 0.1;
const maxPrice = exactPrice + exactPrice * 0.1;
return server.submitTransaction(
buildTx(
source,
signer,
sdk.Operation.liquidityPoolDeposit({
liquidityPoolId: poolId,
maxAmountA: maxReserveA,
maxAmountB: maxReserveB,
minPrice: minPrice.toFixed(7),
maxPrice: maxPrice.toFixed(7),
}),
),
);
}When depositing assets into a liquidity pool, you need to define your acceptable price bounds. In the above function, we allow for a +/-10% margin of error from the “spot price”. This margin is by no means a recommendation and is chosen just for demonstration.
Notice that we also specify the maximum amount of each reserve we’re willing to deposit. This, alongside the minimum and maximum prices, helps define boundaries for the deposit, since there can always be a change in the exchange rate between submitting the operation and it getting accepted by the network.
:::danger TODO
Price improvement Only for initial placement Bias towards makers when best ex
:::
While the network automatically calulates the AMM price product, this does not show up in the order book itself per se. Rather, your order will execute strictly at the best avaliable limit offer or AMM rate. While limit orders specify volume, AMM prices actually vary in real time based on the pool size, which we can find:
This proposal only introduces a constant product liquidity pool.
The invariant for such a liquidity pool is (X + x - Fx) (Y - y) >= XY
X and Y are the initial reserves of the liquidity pool
F is the fee charged by the liquidity pool
x is the amount received by the liquidity pool
y is the amount disbursed by the liquidity pool
import requests
def fetch_amm_pool_data(asset_1, asset_2):
"""
Fetches AMM pool data from the Stellar Horizon API for a given asset pair.
:param asset_1: The first asset (e.g., XLM).
:param asset_2: The second asset (e.g., USD stablecoin).
:return: Reserves of asset_1 and asset_2 in the AMM pool.
"""
url = f"https://horizon.stellar.org/liquidity_pools?reserves={asset_1},{asset_2}"
response = requests.get(url)
if response.status_code != 200:
raise Exception("Error fetching data from Horizon API")
data = response.json()
# Assuming we want the first AMM pool found
pool_data = data['_embedded']['records'][0]
# Extract reserves
reserve_xlm = float(pool_data['reserves'][0]['amount']) # XLM reserves
reserve_usd = float(pool_data['reserves'][1]['amount']) # USD reserves
return reserve_xlm, reserve_usd
def amm_price_xlm_usd(reserve_xlm, reserve_usd, trade_usd, xlm_price_usd):
"""
Calculate the price impact of a trade worth $100 of XLM using an AMM's constant product formula.
:param reserve_xlm: Reserve of XLM in the pool.
:param reserve_usd: Reserve of the other token (e.g., USD stablecoin) in the pool.
:param trade_usd: The amount of USD equivalent to be traded.
:param xlm_price_usd: Current price of XLM in USD (e.g., 0.12 for $0.12/XLM).
:return: The price for the $100 trade in XLM.
"""
# Convert the trade amount in USD to XLM based on the current market price
dx = trade_usd / xlm_price_usd # Amount of XLM to trade
# Constant product invariant (x * y = k)
k = reserve_xlm * reserve_usd
# New XLM reserve after the trade
new_reserve_xlm = reserve_xlm + dx
# Calculate the new reserve of USD after the trade
new_reserve_usd = k / new_reserve_xlm
# Amount of USD received (dy)
dy = reserve_usd - new_reserve_usd
# Price of the trade in terms of USD received per XLM traded
price = dy / dx
return price
# Example: Fetch reserves and calculate price impact for $100 worth of XLM
if __name__ == "__main__":
asset_1 = "XLM" # Asset 1 (XLM)
asset_2 = "USD" # Asset 2 (USD stablecoin)
# Fetch AMM pool reserves from Stellar Horizon API
reserve_xlm, reserve_usd = fetch_amm_pool_data(asset_1, asset_2)
# Define trade and market parameters
trade_usd = 100 # Amount of USD equivalent to trade
xlm_price_usd = 0.12 # Current price of XLM in USD
# Calculate price impact for $100 worth of XLM
price_impact = amm_price_xlm_usd(reserve_xlm, reserve_usd, trade_usd, xlm_price_usd)
print(f"Reserves: {reserve_xlm} XLM, {reserve_usd} USD")
print(f"Price for trading $100 worth of XLM is: {price_impact} USD per XLM")If you own shares of a particular pool, you can withdraw reserves from it. The operation structure mirrors the deposit closely:
def remove_liquidity(
source: Keypair, pool_id: str, shares_amount: Decimal
) -> dict[str, Any]:
pool_info = server.liquidity_pools().liquidity_pool(pool_id).call()
total_shares = Decimal(pool_info["total_shares"])
min_reserve_a = (
shares_amount
/ total_shares
* Decimal(pool_info["reserves"][0]["amount"])
* Decimal("0.95")
) #
min_reserve_b = (
shares_amount
/ total_shares
* Decimal(pool_info["reserves"][1]["amount"])
* Decimal("0.95")
)
tx = (
new_tx_builder(source.public_key)
.append_liquidity_pool_withdraw_op(
liquidity_pool_id=pool_id,
amount=f"{shares_amount:.7f}",
min_amount_a=f"{min_reserve_a:.7f}",
min_amount_b=f"{min_reserve_b:.7f}",
)
.build()
)
tx.sign(source)
return server.submit_transaction(tx)function removeLiquidity(source, signer, poolId, sharesAmount) {
return server
.liquidityPools()
.liquidityPoolId(poolId)
.call()
.then((poolInfo) => {
let totalShares = poolInfo.total_shares;
let minReserveA =
(sharesAmount / totalShares) * poolInfo.reserves[0].amount * 0.95;
let minReserveB =
(sharesAmount / totalShares) * poolInfo.reserves[1].amount * 0.95;
return server.submitTransaction(
buildTx(
source,
signer,
sdk.Operation.liquidityPoolWithdraw({
liquidityPoolId: poolId,
amount: sharesAmount,
minAmountA: minReserveA.toFixed(7),
minAmountB: minReserveB.toFixed(7),
}),
),
);
});
}Notice here that we specify the minimum amount. Much like with a strict-receive path payment, we’re specifying that we’re not willing to receive less than this amount of each asset from the pool. This effectively defines a minimum withdrawal price.
Finally, we can combine these pieces together to simulate some participation in a liquidity pool. We’ll have everyone deposit increasing amounts into the pool, then one participant withdraws their shares. Between each step, we’ll retrieve the spot price.
def main():
deposit_a = Decimal(1000)
deposit_b = Decimal(3000) # maintain a 1:3 ratio
establish_pool_trustline(kps[1], pool_share_asset)
add_liquidity(kps[1], pool_id, deposit_a, deposit_b)
get_spot_price()
deposit_a = Decimal(2000)
deposit_b = Decimal(6000) # maintain a 1:3 ratio
establish_pool_trustline(kps[2], pool_share_asset)
add_liquidity(kps[2], pool_id, deposit_a, deposit_b)
get_spot_price()
# kp1 takes all his/her shares out
balance = 0
for b in server.accounts().account_id(kps[1].public_key).call()["balances"]:
if (
b["asset_type"] == "liquidity_pool_shares"
and b["liquidity_pool_id"] == pool_id
):
balance = Decimal(b["balance"])
break
if not balance:
raise
remove_liquidity(kps[1], pool_id, balance)
get_spot_price()
def get_spot_price():
resp = server.liquidity_pools().liquidity_pool(pool_id).call()
amount_a = resp["reserves"][0]["amount"]
amount_b = resp["reserves"][1]["amount"]
spot_price = Decimal(amount_a) / Decimal(amount_b)
print(f"Price: {amount_a}/{amount_b} = {spot_price:.7f}")
if __name__ == '__main__':
preamble()
main()function main() {
return getAccounts()
.then((accounts) => {
return Promise.all(
kps.map((kp, i) => {
const acc = accounts[i];
const depositA = ((i + 1) * 1000).toString();
const depositB = ((i + 1) * 3000).toString(); // maintain a 1:3 ratio
return establishPoolTrustline(acc, kp, poolShareAsset)
.then(() => addLiquidity(acc, kp, poolId, depositA, depositB))
.then(() => getSpotPrice());
}),
).then(() => accounts);
})
.then((accounts) => {
// kp1 takes all his/her shares out
return server
.accounts()
.accountId(kps[1].publicKey())
.call()
.then(({ balances }) => {
let balance = 0;
balances.every((bal) => {
if (
bal.asset_type === "liquidity_pool_shares" &&
bal.liquidity_pool_id === poolId
) {
balance = bal.balance;
return false;
}
return true;
});
return balance;
})
.then((balance) =>
removeLiquidity(accounts[1], kps[1], poolId, balance),
);
})
.then(() => getSpotPrice());
}
function getSpotPrice() {
return server
.liquidityPools()
.liquidityPoolId(poolId)
.call()
.then((pool) => {
const [a, b] = pool.reserves.map((r) => r.amount);
const spotPrice = new BigNumber(a).div(b);
console.log(`Price: ${a}/${b} = ${spotPrice.toFormat(2)}`);
});
}
preamble().then(main);You can access the transactions, operations, and effects related to a liquidity pool if you want to track its activity. Let’s see how we can track the latest deposits in a pool (suppose poolId is defined as before):
def watch_liquidity_pool_activity():
for op in (
server.operations()
.for_liquidity_pool(liquidity_pool_id=pool_id)
.cursor("now")
.stream()
):
if op["type"] == "liquidity_pool_deposit":
print("Reserves deposited:")
for r in op["reserves_deposited"]:
print(f" {r['amount']} of {r['asset']}")
print(f" for pool shares: {op['shares_received']}")
# ...server
.operations()
.forLiquidityPool(poolId)
.call()
.then((ops) => {
ops.records
.filter((op) => op.type == "liquidity_pool_deposit")
.forEach((op) => {
console.log("Reserves deposited:");
op.reserves_deposited.forEach((r) =>
console.log(` ${r.amount} of ${r.asset}`),
);
console.log(" for pool shares: ", op.shares_received);
});
});You can view trades occuring in the vanilla order book through a traditional time and sales report.
Footnotes
-
When you create a buy offer using the
createBuyOfferoperation, it is internally converted and stored as a sell offer. ↩ -
an asset trust line has its authorization revoked -> forces a redemption of all referenced pool trust lines //todo ↩