Skip to main content

Liquidity on Stellar: SDEX and Liquidity Pools

note

This section is scoped specifically to liquidity regarding the AMM and SDEX built into the Stellar protocol and does not include information regarding smart contracts.

Users can trade and convert assets on the Stellar network with the use of path payments through Stellar’s decentralized exchange and liquidity pools.

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.

SDEX

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.

Order books

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

Orders

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.

Price and operations

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

Manage Buy Offer

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

Manage Sell Offer

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 Order

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.

Fees

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

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.

Automated Market Makers (AMMs)

Instead of relying on the buy and sell orders of decentralized exchanges, AMMs keep assets in an ecosystem liquid 24/7 using liquidity pools.

Automated market makers 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.

AMM pricing

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 30 bps, which is equal to 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.

Liquidity pool participation

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 fee rate on Stellar is 30 bps, which is equal to 0.30%. These fees are completely separate from the network fees.

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

Authorization

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:

SCENARIOBEHAVIOR
Trustlines for A and B are fully authorizedNo restrictions on deposit and withdrawal
Trustline for A is fully authorized but trustline for B is authorized to maintain liabilitiesTrustlines for A and B are authorized to maintain liabilities
Trustline for B is fully authorized but trustline for A is authorized to maintain liabilitiesTrustlines for A and B are authorized to maintain liabilities
Trustlines for A and B are authorized to maintain liabilitiesTrustlines for A and B are authorized to maintain liabilities
Trustline for A is not authorized or doesn’t existPool share trustline does not exist
Trustline for B is not authorized or doesn’t existPool 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.

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.

Examples

Here we will cover basic liquidity pool participation and querying.

Preamble

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.

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);
}

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

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.

Participation: Creation

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

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.

Participation: 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.

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.

Participation: Withdrawals

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

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.

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.

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

Watching Liquidity Pool 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):

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);
});
});