Skip to main content

Liquidity on Stellar: the DEX & Liquidity Pools

Stellar uses two protocols to help users trade:

  1. Unified orderbook for priced offers and
  2. Native AMMs through liquidity pools.

Collectively, these connected systems create the SDEX, a decentralized market for everyone.

The SDEX works for all assets on the network from the moment they're created . Its accessable to any account , and its liquidity can be seen by all as peers trade balances. You can iuse the exchange to pay someone in their preferred currency, rack prices, or even go public.

note

You can deploy any kind of smart contract, including a liquidity pool (example). However, these isolated silos can fragment the core network's layer-1 pathfinding. This page focuses on infrastructure that optimizes prermissionless, equitible, and free markets.

integrated? concentrated? universal?

KO'd:

  • operation complexity
  • offer aggregation
  • atomic-swap

NO'd:

  • accessibility
  • transparency
  • inclusion

NO steps:

  • Introduce buy offer example
  • Introduce sell offer example
  • Include full example of doing price lookups
  • Emphasize obviating price oracles, proprietary contracts, or off-chain systems

The Price-Time Orderbook

Markets are an intangible concept born when people want to exchange different goods. If one resource is scarce, it should take more of other assets to voluntarily trade it away. We record these excanmge rates through an An orderbook of outstanding orders to trade two assets

THe last agreement of prices becomes the c urrent valuer if using an accessible, transparent, and efficient market with minimal transaction costs. Trade breaks down from puire;y free marlkets trough legacy financial gatekeepers like The exchanges with high listing fees, brokers with strict customer requirements, or even the sotrage costs of physcial commodity deliveries. Stellar has none of these.

This barter back and forth happens billions of times a day in the limit order books of financial networks, and you can see an exmaple of it visualized in the Wikipedia page. They form the backbone of currency, securities, and (yes) fruit markets. By acting together in an unknowing unison, the masses of individuals bidding and offering their fair rates create the going price.

New Orders

This price is more than a number, which might present a gain or loss from your cost. It's a tool for social organization that lets society organize efforts autonomously. And you can influence it (maybe even a lot!) with your own trades of value into network assets.

The spread is the difference between the most someone will pay for something and the least someone will sell it for. Consider that I have apples and you have bananas. If I offer to give you one banana for five apples, you could counter with three apples each.

A priced order only expires when you remove it from the ledger, offering supporting liquidity in down markets or rational selling in exuberant ones. More offers between pairs add volume to the market depth, promoting stable conversion rates. You can rely on stable trades because offers are funded from wallet assets on a debit basis—creating common, distributed, peer-to-peer trading interests.

The ledger's innovation lies in where these offers get stored, matched, and disclosed. To submit directly to network nodes, you can use the Manage Buy Offer operation, specifying how much A\mathcal{A} you want to purchase with B\mathcal{B} at z\mathcal{z} price. The protocol calculates the cost of the trade in terms of amount * price and locks up the selling asset you offer in exchange as collateral, until the trade is (partially) executed or cancelled.

BuyTradeOffer(
selling = APPLES,
buying = BANANAS,
amount = "100",
price = "4" // 🍎 per 🍌
)

Alternatively, you can specify exactly how much of an asset you want to sell at a specific price using the Manage Sell Offer operation. Anyone viewing this public Horizon interface to the ledger will see your orders as validators match first based on price. For orders placed at the same price, the order that was received earlier is given priority and is executed before the newer one.

The orderbook for this asset pair records every account wanting to sell Pples for bananas and every account wanting to sell bannasa forapples. UIt uses a specific ledger record to record offer objjkects with associated metadata like trade amount, precise price, and offer ID. Develoeprs are persently hard at work migrating this infrtreraface to communtiy RPC nodes, which aare efficiently resource-aware; and archiving searche rs can use the Hubble interface queries.

Terminoplogy
  • The term “offers” usually refers specifically to selling through "ask" orders. However, in the protocol all orders are stored as selling—i.e., the system automatically converts buying "bid" orders 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.

Order Execution

An account can create orders to buy or sell assets using the ManageBuyOffer, Manage Sell Offer, or Create Passive Sell Offer 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 behave like limit orders in traditional markets. When an account initiates an order, it is checked against the existing order book 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 order book 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 order book can be executed entirely.

Purchase Assets (Manage Buy Offer)

When creating a buy order 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).

Manage Sell Offer

When creating a sell order 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).

Market Data

interoperability points here

The SDEX facilitates seamless interoperability between different assets by combining orders in a unified order book. This design ensures that liquidity is concentrated, reducing spreads and improving execution for all participants.

No matter how an offer gets there, you can view your trading effects on the network. Let's go through an example of how to interact with the order book between two assets. First, we'll set up some basic primitives used throughout the page:

from stellar_sdk import Keypair, Server, TransactionBuilder, Network, Asset

server = Server("https://horizon-testnet.stellar.org")

# Account setup
privateKey = "SAXBT6KO6NJ6SJXHBO6EBC7I5ZB7DZFYNPQOLXZJOKQ2LSGY5FU7ZJZB"
publicKey = "GBRPYHIL2CI3R5N4A7WMBETDZQ24DXFQGNCJWHXPFRGFWZHJZZBDTWR2"

# Asset setup
astroDollar = Asset("AstroDollar", "GDRM3MK6KMHSYIT4E2AG2S2LWTDBJNYXE4H72C7YTTRWOWX5ZBECFWO7")
astroPeso = Asset("AstroPeso", "GBHNGLLIE3KWGKCHIKMHJ5HVZHYIK7WTBE4QF5PLAKL4CJGSEU7HZIW5")

def newTxBuilder(publicKey):
return TransactionBuilder(
source_account = server.load_account(publicKey),
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE,
base_fee = server.fetch_base_fee()
).set_timeout(360)

This setup gives us a simple keypair, transaction constructor, and trading assets. You cancel swap the server URL or asset codes to follow along with live exchange markets. Next, we'll read the current market price between AstroDollars and AstroPesos:

orderbook = server.orderbook(
selling = astroPeso,
buying = astroDollar
).call()

print("Bids:")
for bid in orderbook['bids']:
print(f"Price: {bid['price']}, Amount: {bid['amount']}")

print("\nAsks:")
for ask in orderbook['asks']:
print(f"Price: {ask['price']}, Amount: {ask['amount']}")

The more orders available to transact against, the more currency you can convert at any time without moving the market. Stellar stores these open orders as offer objects directly on chain. This transparency allows anyone to analyze the order book and understand the trading activity for any asset pair.

The network and by extension its validators match orders based on the protocol rules of Stellar Core.

?

Orders are filled at the same price or better than specified, ensuring fair execution. This means you receive the best possible price available in the market at that moment.

Let's pretend you just got paid in Mexican Pesos, but you'd prefer to hold your savings in U.S. Dollars. After querying the exchange rate, we can send a new offer to the network, swapping Pesos for Dollars at our desired rate. By submitting a sell offer that matches the current market price, we can ensure our Pesos are converted to Dollars promptly.

Best Execution

The order book only matches offers at the price specified or better, when available. For instance, say there are four buyers offering 10 bananas per apple. You can sell an apple for 7 bananas, and the offer automatically exchanges for the higher 10 bananas.

In traditional markets, this is precisely how market orders allow instant trades. They simply execute against the best available prices in the common order book. And since we can see every offer, the DEX also gives us the handy ability to know if there's enough orders at the prevailing price.

When you submit an order, the protocol will also check whether an AMM (below) offers a better rate than priced orders. If so, your trade executes against the pool at the better conversion rate without priced DEX liquidity. To better understand the market, let's uncover the spread between the best AstroDollar offers, calculating a midpoint trading price.

def getMidpointPrice(orderbook);
try:
highestBid = float(orderbook["bids"][0]["price"])
lowestAsk = float(orderbook["asks"][0]["price"])
midpointPrice = (highestBid + lowestAsk) / 2
return round(midpointPrice, 7)
except IndexError:
print("Missing existing buy or sell offers.")
return null
note

We have now found the price of AstroDollar as the response item's base asset. We can invert price_r from the Horizon response to find the counter (AstroPeso) exchange rate. You can also stream server updates to keep a view of the orderbook maerket up to date.

hmm i dont like this todo

i probbably ndont even need this wnetiole entire note

View Trades

A more complete understanding of market liquitiy might incldue volume analysis. To affirm the fairness of this example price, we can also check up to 25 recent trade rates:

def getAverageRecentPrice():
trades = server.trades().for_asset_pair(
selling = astroPeso,
buying = astroDollar
).limit(25).call()

recentPrices = [
float(trade["price"])
for trade in trades["_embedded"]["records"]
]
if recentPrices:
return sum(recentPrices) / len(recentPrices)
else:
return None

def sanityCheck(midpointPrice, averageRecentPrice):
differencePercent = abs(midpointPrice - averageRecentPrice) / midpointPrice
return differencePercent <= 0.05 # arbitrary maximum slippage of 5%
Scanning

This example examines orders using the default paging token limit. For more insight into the depth of a market, you can increase the request limit and even continue paging through results. For very liquid assets, there can be thousands of active offers awaiting exchange.

Limit-Order Example

We can confidently convert our AstroPesos now that we know the going rate paid for them:

offerPrice = round( getMidpointPrice(orderbook), 7 )

transaction = (
newTxBuilder(publicKey)
.append_manage_sell_offer_op(
selling = astroPeso,
buying = astroDollar,
amount = "1000",
price = str(getMidpointPrice(orderbook))
)
.build()
)

keypair = Keypair.from_secret(privateKey)
transaction.sign(keypair)
response = server.submit_transaction(transaction)

ocne hte network receives your order, the protocol ueries against avalibe liquidity. If our trades mathes iwth exists orders, our TransactionResult immediately claims offers, like the example below. Remainingn asetsd create a new OfferID from the account.

{ ...

"offers_claimed": [
{
"order_book": {
"seller_id": "GDAVYIICLHJIQACEC3FQFQAZDMIR4IRUJMURN446DD6SLILZS2FPUSDC",
"offer_id": 1634174581, // existing account trades fully
"asset_sold": {
"credit_alphanum4": {
"asset_code": "AstroPeso",
"issuer": "GBHNGLLIE3KWGKCHIKMHJ5HVZHYIK7WTBE4QF5PLAKL4CJGSEU7HZIW5"
}
},
"amount_sold": 4863901365,
"asset_bought": {
"credit_alphanum4": {
"asset_code": "AstroDollar",
"issuer": "GDRM3MK6KMHSYIT4E2AG2S2LWTDBJNYXE4H72C7YTTRWOWX5ZBECFWO7"
}
},
"amount_bought": 438292475
}
}, ...
]

... }

The first, most basic option is when a new order crosses the price set by an outstanding order. The two instantly cross once the new order gets accepted, and the transaction generates a taker contraID rather than an offerID for the "buyer" of the existing liquidity. This counter ID is a design scheme choice to return an offer ID to orders immediately executed by taking liquidity away from existing order books.

In actual queries, you can ignore the arbitrarily large simulated ID in deference to the OfferEntry owned by a selling account making the market with standing liquidity. This standing ledger entry will update over time until all offers are filled or deleted. Each offer can be thought of as a trade with a cumulative price which we can easily find with its ID.

Both path payments and liquidity pools are constantly interacting with the DEX to check for (partial) offer execution. Comparatively, path payments immediately execute in full or fail to send. Indeed, every new path payment and liquidity pool operation can only occur because of existing order book offers or new user swap requests.

Claiming

If our trade does take from existing offers in full, we receive a ClaimAtom result with the offers or AMMs used. If it went through an AMM pool, we receive a different report based on asset identifiers, shown below. This flexibility allowed best execution for our offer.

{ ...

"offers": [
{
"liquidity_pool": {
"liquidity_pool_id": "63268ced073f689a5b0b45aa5dc515190acc5f4c0b15d15894c4bb78403e517f",
"asset_sold": {
"credit_alphanum4": {
"asset_code": "AstroPeso",
"issuer": "GBHNGLLIE3KWGKCHIKMHJ5HVZHYIK7WTBE4QF5PLAKL4CJGSEU7HZIW5"
}
},
"amount_sold": 4863901,
"asset_bought": {
"credit_alphanum4": {
"asset_code": "AstroDollar",
"issuer": "AstroDollar"
}
},
"amount_bought": 438292
}
}, ...
],

... }

Now that we know how to submit an order to the SDEX, let's walk through reading the current order book. This entails collecting all the buyers for a specific currency pair and comparing this demand to all the sellers of that pair. We'll stick with our pesos-dollars example and crossing/implementing potential passive offers.

Since all orders for a currency pair fall into the same SDEX order book, you can 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 example and set up a recent trading price query:

response = server.trades().for_asset_pair(astroPeso, astroDollar).call()

for trade in response['_embedded']['records']:
price = int(trade['price_r']['n']) / int(trade['price_r']['d'])
print(f"Trade ID: {trade['id']}, Price: {price}, Amount: {trade['base_amount']}")

Automated Market Makers

Market makers are businesses that traditionally help establish liquidity on exchanges. They are historically a party willing to buy or sell an asset at any time. They maintain an “inventory” of said asset (to sell) alongside a stockpile of cash (to buy).

A market maker hopes to get a buyer real soon when they purchase an asset, or a seller if they run out. They profit off the difference between what they buy an asset at and what they can sell it for—called the “spread.” The more volatile an asset, the less competition there might be between buyers and sellers to narrow the spread.

In other words, it becomes more expensive to trade an asset the less liquid it becomes, as you will need to “cross the spread” more often to fill sizable orders in reasonable time. AMMs democratize the process of market-making by encoding the maximum spread into a pool of capital, creating liquidity. When users want to trade, their order can execute against the AMM in place of explicit SDEX order book offers, should no bids or asks exist inside the spread.

info

Liquidity refers to how easily and cost-effectively one asset can be converted to another. Market-making businesses accept the inherent risk that an asset will move in one direction or another before a closing trade can cancel out any positions (or lack thereof), and they expect that the “fees” they can extract from the spread will exceed any trade losses over time. These small costs accrue in AMM pools, and gains become shared by users depositing their capital.

Passive Orders

Creation only There are plenty of MM bots deployed on the DDEX. Outside the scope of this2

Note that regular offers made later than your passive sell offer can act on and take your passive sell offer, even if the regular offer is of the same price as your passive sell offer.

Passive sell offers allow market makers to have zero spread. If you want to trade EUR for USD at 1:1 price and USD for EUR also at 1:1, you can create two passive sell offers so the two offers don’t immediately act on each other.

Once the passive sell offer is created, you can manage it like any other offer using the manage offer operation — see ManageBuyOffer for more details.

PY src check

Passive orders allow markets to have zero spread. They come in handy if you're making a market to exchange USD from [anchor](./anchors.mdx) A for USD from [anchor](./anchors.mdx) B at a 1:1 price. A passive bid and ask let you create two sides of a book that don’t fill each other.

Our example so far used assets with presumably different values, but we might use this order as an asset issuer to peg alike anchored assets.

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 100 XLM/BTC, and you make a passive offer to sell BTC at 100 XLM/BTC,

your passive offer does not take that existing offer.

If you instead make a passive offer to sell BTC at 99 XLM/BTC it would cross the existing offer and fill at 100 XLM/BTC.

An account can place a passive sell order via the Create Passive Sell Offer operation.

Setup here as comp between someone who might be associated with an issuer or have an interest in market

They can manually use a bot to update prices (link kelp archive and yUSD bot if repo public — dir)

Example of passive sell setup as a means of setting to not cross self, otherwise natural trade intent over

Parallel automation as using smart contracts on other networks. Explain here the liquidity singularity need

The one page transition to native setup with 18 ref to difference between stable curves, protocol interoperability bonds

The rest of this example will presume you are an individual seeking to invest on a native pool

Liquidity Pools

Stabled curve transition to industry-standard V2 pricing

Authorization Setup \

\ Operations

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. ✅

Deterministic Pricing

Instead of relying on the buy and sell orders of the SDEX, AMMs keep assets liquid 24/7 using pooled capital and 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\mathcal{A} * Asset B\mathcal{B} = k). If an AMM holds more of the reserve assets, the asset prices move less in response to a trade.

When you submit an..

AMM Calculations

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 a trading function. The protocol supports arbitrary functions, although it presently only adopts a constant-product market maker. This means that AMMs presently never allow the product of the reserves to decrease, although the protocol is configured to use other bonding curves.

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 trading function, so the product of the reserves grows after every trade.

Viewing Activity

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']}")
# ...

AMM Participation

A pool of deposits from accounts allows trades to execute against the predefined market-making algorithm.

Any eligible participant can deposit assets into an AMM and receive pool shares representing their AMM ownership in return. 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, but they cannot be transferred yet. You can only increase the number of pool shares you hold by depositing into a liquidity pool with the LiquidityPoolDepositOp and decrease the number of pool shares you hold by withdrawing from a liquidity pool with LiquidityPoolWithdrawOp. Accordingly, pool shares cannot be sent in payments, sold using offers, or within claimable balances.

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\mathcal{A}B\mathcal{B} is in the correct order but B\mathcal{B}A\mathcal{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 community agreed on the current fixed rate of 0.30%, the fee used in Uniswap V2. These charges are completely separate from the network fees.

AMM Trustlines

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:

  1. 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.
  2. A pool share trustline requires 2 base reserves instead of 1. For example, an account (2 base reserves) with a trustline for asset A\mathcal{A} (1 base reserve), a trustline for asset B\mathcal{B} (1 base reserve), and a trustline for the A\mathcal{A}B\mathcal{B} pool share (2 base reserves) would have a reserve requirement of 6 base reserves.

Liquidity-Pool Example

Here we will cover basic AMM 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.3

from decimal import Decimal
from stellar_sdk import *

server = Server("https://horizon-testnet.stellar.org")

secrets = [
"SBGCD73TK2PTW2DQNWUYZSTCTHHVJPL4GZF3GVZMCDL6GYETYNAYOADN",
"SAAQFHI2FMSIC6OFPWZ3PDIIX3OF64RS3EB52VLYYZBX6GYB54TW3Q4U",
"SCJWYFTBDMDPAABHVJZE3DRMBRTEH4AIC5YUM54QGW57NUBM2XX6433P",
]
keypairs = [Keypair.from_secret(secret = secrets) for secrets in secretsList]

# Returns the given asset pair in "protocol order"
def orderAsset(a, b):
return [a, b] if LiquidityPoolAsset.is_valid_lexicographic_order(a, b) else [b, a]

# kp0 issues the assets
kp0 = keypairs[0]
assetA, assetB = orderAsset(
Asset("A", kp0.public_key),
Asset("B", kp0.public_key)
)

def distributeAssets(issuerKp, recipientKp, assets):
builder = newTxBuilder(issuerKp.public_key)
for asset in assets:
builder.append_change_trust_op(
asset = asset,
source = recipientKp.public_key
).append_payment_op(
destination = recipientKp.public_key,
asset = asset,
amount = "100000",
source = issuerKp.public_key
)

tx = builder.build()
tx.sign(issuerKp)
tx.sign(recipientKp)
return server.submit_transaction(tx)

def preamble():
resp1 = distributeAssets(kp0, keypairs[1], [assetA, assetB])
resp2 = distributeAssets(kp0, keypairs[2], [assetA, assetB])
# ...

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).

TODO

Case when buying an asset, amount acquired > existing trustline amount. Implicates reserves art.

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.

Participant Creation

First, let's create an AMM for the asset pair defined in the preamble. This involves establishing a trustline to the pool itself.

poolAsset = LiquidityPoolAsset(
asset_a = assetA,
asset_b = assetB
)

def establishPoolTrustline(source, poolAsset):
tx = (
newTxBuilder(source.public_key)
.appendChangeTrustOp(asset = poolAsset)
.build()
)
tx.sign(source)
return server.submitTransaction(tx)

This lets participants hold pool shares, which means they can now perform AMM deposits and withdrawals.

Participant Deposits

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.

poolId = poolAsset.liquidity_pool_id

def addLiquidity(source, maxReserveA, maxReserveB):
exactPrice = maxReserveA / maxReserveB
minPrice = exactPrice - (exactPrice * Decimal("0.1"))
maxPrice = exactPrice + (exactPrice * Decimal("0.1"))

transaction = (
newTxBuilder(source.public_key)
.append_liquidity_pool_deposit_op(
liquidity_pool_id = poolId,
max_amount_a = f"{maxReserveA:.7f}",
max_amount_b = f"{maxReserveB:.7f}",
min_price = f"{minPrice:.7f}",
max_price = f"{maxPrice:.7f}",
)
.build()
)

transaction.sign(source)
return server.submit_transaction(transaction)

If the pool is empty, then this operation deposits maxAmountA of A and maxAmountB of B into the pool. If the pool is not empty, then this operation deposits at most maxAmountA of A and maxAmountB of B into the pool. The actual amounts deposited are determined using the current reserves of the pool. You can use these parameters to control a percentage of slippage.

Withdrawing reduces the number of pool shares in exchange for reserves from a liquidity pool. Parameters to this operation depend on the ordering of assets in the liquidity pool (see the liquidity pool glossary entry for information about how to determine the ordering of assets). “A” refers to the first asset in the liquidity pool, and “B” refers to the second asset in the liquidity pool. Withdrawing reduces the number of pool shares in exchange for reserves from a liquidity pool.

The minAmountA and minAmountB parameters can be used to control a percentage of slippage from the "spot price" on the pool.


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.

Calculating Price

While the network automatically calculates 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 available 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:

Also let's see "The pool’s state (reserves, fees, shares, parameters) is stored directly on the Stellar ledger."

!unavoidable Horizon reference

case LIQUIDITY_POOL_CONSTANT_PRODUCT:
struct
{
LiquidityPoolConstantProductParameters params;

int64 reserveA; // amount of A in the pool
int64 reserveB; // amount of B in the pool
int64 totalPoolShares; // total number of pool shares issued
int64 poolSharesTrustLineCount; // number of trust lines for the
// associated pool shares
} constantProduct;
}
body;
};
# 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):
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):
"""
TODO remove this and make CamelCase
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
# ALSO remoOVE@!@ todo
if __name__ == "__main__":
asset_1 = "XLM" # Asset 1 (XLM)
asset_2 = "USD" # Asset 2 (USD stablecoin)


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")

def getSpotPrice():
resp = server.liquidity_pools().liquidity_pool(poolId).call()
amountA = resp["reserves"][0]["amount"]
amountB = resp["reserves"][1]["amount"]
spotPrice = Decimal(amountA) / Decimal(amountB)
print(f"Price: {amountA}/{amountB} = {spotPrice:.7f}") # Max network precision

Participant Withdrawals

If you own shares of a particular pool, you can withdraw reserves from it. The operation structure mirrors the deposit closely:

def removeLiquidity(source, poolId, sharesAmount):
poolInfo = server.liquidity_pools().liquidity_pool(poolId).call()
totalShares = Decimal(poolInfo["total_shares"])
minReserveA = (
sharesAmount
/ totalShares
* Decimal(poolInfo["reserves"][0]["amount"])
* Decimal("0.95") # 95% safety factor
)
minReserveB = (
sharesAmount
/ totalShares
* Decimal(poolInfo["reserves"][1]["amount"])
* Decimal("0.95")
)
tx = (
newTxBuilder(source.public_key)
.appendLiquidityPoolWithdrawOp(
liquidityPoolId=poolId,
amount=f"{sharesAmount:.7f}",
minAmountA=f"{minReserveA:.7f}",
minAmountB=f"{minReserveB:.7f}",
)
.build()
)
tx.sign(source)
return server.submit_transaction(tx)

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.

Putting it all together

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 like earlier to see our effects.

# Step 1: kp1 adds liquidity
establishPoolTrustline(keypairs[1], poolAsset)
addLiquidity(
keypairs[1],
poolId,
Decimal(1000),
Decimal(3000)
) # Divides into 1:3 ratio
getSpotPrice()

# Step 2: kp2 adds liquidity
establishPoolTrustline(keypairs[2], poolAsset)
addLiquidity(
keypairs[2],
poolId,
Decimal(2000),
Decimal(6000)
) # Larger deposit this time
getSpotPrice()

# Step 3: kp1 removes all liquidity
accountDetails = server.accounts().account_id(keypairs[1].public_key).call()
for bals in accountDetails["balances"]:
if (
bals["asset_type"] == "liquidity_pool_shares" and
bals["liquidity_pool_id"] == poolId
):
balance = Decimal(bals["balance"])
break
if not balance:
raise Exception("No liquidity pool shares found for kp1")
removeLiquidity(keypairs[1], poolId, balance)
getSpotPrice()

TODO spell out the changes as the example changes spot price

  • Passive sell offers (with examples)

Trade Operating Principles

At no point in these examples did assets leave your custody. And, since no other entities control (intermediate) funds, conversions happen with equal preference as offer operations enter transaction sets via consensus reputation. This new breakthrough introduces a few worthwhile design implications.

Regulated compliance

Issuers of regulated assets may need to manage or otherwise oversee trading of their assets. Stellar's trustline management tools let asset issuers seamlessly handle various trade authorization scenarios. The trading behavior of an account holding regulated asset A\mathcal{A} depends on the status of its trustlines:

Trustline for A\mathcal{A}Response
Fully authorizedNo restrictions on transactions
Authorized to maintain liabilitiesOffers to trade A\mathcal{A} remain outstanding (and can be cancelled), but no new offers can be created
Not authorized or doesn’t existNew offer operation fails. Existing offers are cancelled at the time of deauthrization from the issuer (mainginting an existing frozen accoiunt balance)

In addition to controlling asset use in the orderbook, ssuers can slo enforce authorization and compliance controls on their assets deposited into AMMs (below with details on initial trustline configuration). These optional trustline flags configured before holding an asset ensure smooth compliance by revoking authorization to prevent an account under inestigation from further (automated) trading. The behavior of an A\mathcal{A}B\mathcal{B} AMM trustline depends on a few authorization possibilities:

PermissionsResponse
Trustlines for A\mathcal{A} and B\mathcal{B} are fully authorizedNo restrictions on deposit and withdrawal
Trustline for A\mathcal{A} is fully authorized but trustline for B\mathcal{B} is authorized to maintain liabilitiesTrustlines for A\mathcal{A} and B\mathcal{B} are authorized to maintain liabilities
Trustline for B\mathcal{B} is fully authorized but trustline for A\mathcal{A} is authorized to maintain liabilitiesTrustlines for A\mathcal{A} and B\mathcal{B} are authorized to maintain liabilities
Trustlines for A\mathcal{A} and B\mathcal{B} are authorized to maintain liabilitiesTrustlines for A\mathcal{A} and B\mathcal{B} are authorized to maintain liabilities
Trustline for A\mathcal{A} is not authorized or doesn’t existPool share trustline cannot exist
Trustline for B\mathcal{B} is not authorized or doesn’t existPool share trustline cannot exist

An AMM trustline which is authorization to maintain liabilities will only allow an account to withdraw existing deposits (with earned pool fees). The account's trustlines for the AMM's assets determine the authorization of an AMM trustline. The resulting pool-share trustline cannot be authorized or de-authorized independently partly because there exists no "issuer" of the AMM.

This design is necessary because an AMM may contain assets from two different issuers, and both issuers should have a say in whether the pool-share trustline is authorized. The first person to deposit into an AMM with two assets creates a unique identifier [detailed below](#pool-id TODO), based only on the hash of constituent assets. That enjoining process also needs to conform with trust permissions.

If the issuer of A\mathcal{A} or B\mathcal{B} fully revokes authorization after deposit, 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 AMM shares have been "redeemed." This action by the issuer also cancels any outstanding limit orders, as described in the first table.

For example, consider an issuer of A\mathcal{A} revokes authorization for an account participating in the A\mathcal{A}B\mathcal{B}, A\mathcal{A}C\mathcal{C}, and B\mathcal{B}C\mathcal{C} AMMs. The account will redeem from A\mathcal{A}B\mathcal{B} and A\mathcal{A}C\mathcal{C}, but it will not redeem from B\mathcal{B}C\mathcal{C}. Thus issuers only have authorization control of their assets.

The ledger creates a claimable balance for each AMM asset in all redeemed pool-share trustlines, so long as there is a balance being withdrawn and the redeemer is not the issuer of that asset. In the latter case, assets are simply burned through return to the issuer. The unconditional claimant of the claimable balance is the owner of the deleted pool-share trustline, but this account may not claim the regulated asset until duly authorized by the issuer.

The sponsor of the claimable balance is the sponsor of the deleted pool-share trustline. This balances out since the pool-share trustline requires two base reserves for both AMM assets. The BalanceID of each returned claimable balance is the SHA-256 hash of the revokeID.

Minting Tokens

The issuer of an asset has a special relationship with the SDEX because they create new tokens by decree. Consider an issuing account which wants to create Banana tokens. If this asset has never been issued before, the account can create a new "NANA" sell offer with the base asset token issuer as itself.

If NANAs already exist in the market, then this trade will generate more NANAs as other accounts purchase the tokens, in exchange for the counter-asset specified by the offer. Thus, the issuer strictly increases the circulating supply by minting new assets, keeping the payment received from a sale to the market. Issuers can do this at any time without a locked account, and their buy offers will similarly burn tokens they originate.

Additionally, issuers can mint or burn their own tokens through AMMs. Since they cannot hold their own asset, issuing accounts can deposit into AMMs with some or none of their own external assets. If there is a NANA and "apple" AMM with the issuer creating both tokens, then they can deposit as much as they want into the pool to mint the coins.

Upon withdrawal in this example, the tokens get returned to the issuing account and burned like before. Alternatively, the issuer might consider depositing into the NANA and AstroDollar AMM. Here, the issuer needs only to have an amount of AstroDollar, and they can issue as many NANAs as needed to achieve the conversion ratio for a new AMM deposit, as detailed below.

When withdrawing from the AMM, the issuer will burn returned NANAs while keeping all earned (or lost) AstroDollars. Some projects use this feature of AMMs to conduct a project launch with their own seed capital as the initial half of deposited funds. Contrarily, traditional markets prefer selling new issuances through limit offers which can represent fiat currencies, commodities, or any other assets in exchange for a user's valued token.

Burning \

\ Price and Operations

Each order is quoted with an associated strign 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 (like bananas), and the denominator is considered the quote asset (like dollars). When expressing a price of "Asset A\mathcal{A} in terms of Asset B\mathcal{B}," the amount of B\mathcal{B} is the denominator (and therefore the quote asset), and A\mathcal{A} is the numerator (and therefore the base asset).

TODO

Break out to burn

Numerator and denominator are stored as signed 32-bit integers, but since one bit is for the sign, only 31 bits are available for the value.

The order price you set is independent of the fee you pay for submitting that order in a transaction. Fees are always paid in lumens, 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.

Footnotes

  1. When you create a buy offer using the createBuyOffer operation, it is internally converted and stored as a sell offer.

  2. hrefs are https://github.com/stellar-deprecated/kelp and https://github.com/JFWooten4/trading-algos/blob/main/mm-yUSDC-USDC.py

  3. The protocol handles compliance edge cases if an account has trustlines revoked for one or more of the assets in an AMM. Once this happens, the account is forced to redeem all AMM pool shares. Since they by definition are not authorized to hold at least one asset, it gets returned to them as an unconditional claimable balance, available once the issuer authorizes the account again.