title | order |
---|---|
Liquidity Pools |
import { CodeExample } from "components/CodeExample";
As of Protocol 18 and CAP-38, the Stellar network supports liquidity pools, which enables automated market making on the network. If you’re already familiar with AMMs, feel free to skip ahead to the relevant operations or dive straight into the examples.
In order to be able to buy an asset, someone needs to be willing to sell it. The easier it is to quickly and cost-effectively trade large amounts of an asset, the more liquid it is. Colloquially, this is what it means to be able to easily liquidate your assets into cash when you need it.
In a traditional exchange like the NYSE, there are lots of individuals (e.g. retail investors) and companies (e.g. hedge funds) making trades on any given asset. A market maker for a particular asset is anyone regularly offering to buy and sell it at quoted prices; market makers create liquidity because their offers make it easier to quickly trade large amounts of an asset. The market maker is available to trade even when no one else wants to. Typically, market makers are large investment firms that have the capital to buy or sell arbitrary assets in large quantities, but anyone can be a market maker.
Market makers generally don’t provide liquidity out of the goodness of their hearts. Like other businesses, they are seeking to generate a profit. They achieve this by offering to buy and sell at different prices—the difference between these prices is known as the spread. If a market maker successfully buys low and sells high, then they will generate a profit. But nothing is guaranteed, and they may generate a loss if the market moves against them while they hold a position. Making the spread wider makes it less likely that the market maker will lose money, but also discourages people from trading.
An automated market maker provides liquidity but, unlike a conventional market maker, the quoted prices are determined solely by a mathematical equation. An automated market maker holds two different assets in a liquidity pool, and the quantities of those assets—typically called reserves—are inputs to the mathematical equation. Liquidity pools democratize market making by letting any eligible participant deposit assets into the liquidity pool. In return for their deposit they will receive pool shares representing their ownership of the assets in the liquidity pool. If there are 150 total pool shares and they own 30, then they are entitled to withdraw 20% of the assets at any time.
If an automated market maker holds more reserves, then the price moves less in response to a trade. The price always moves against a trade, so traders get better prices when the automated market maker holds more reserves. In the interest of providing competitive prices, automated market makers attract reserves by incentivizing liquidity providers. The automated market maker attempts to capture the spread for the liquidity providers by offering to buy and sell at different prices. In this context, the spread is typically referred to as a fee. The fees cause the reserves to grow. But like traditional market making, nothing is guaranteed—the automated market maker may lose money if the market moves. If the automated market maker does make money, then the reserves will grow and participants will find that they may withdraw more than they deposited.
An automated market maker is willing to make some trades and unwilling to make others. For example, if 1 EUR = 1.17 USD then the automated market maker might be willing to sell 1 EUR for 1.18 USD and unwilling to sell 1 EUR for 1.16 USD. How does an automated market maker know what trades are acceptable and what trades aren’t acceptable? How does it even know what the exchange rate is?
To determine what trades are acceptable, the automated market maker enforces an invariant. There are many possible invariants with different advantages and disadvantages, but automated market makers built on Stellar enforce the constant product invariant and are therefore known as constant product market makers. This is analogous to the invariant enforced by Uniswap. The constant product invariant states that an automated market maker 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.
In the above example, it seems that the automated market maker somehow knows that the exchange rate is 1 EUR = 1.17 USD. In reality, the automated market maker infers this from the reserves in the liquidity pool. Ignoring fees, the exchange rate is the ratio of the reserves. If the ratio of the reserves deviates from the true exchange rate, then an arbitrageur will recognize that they can trade with the automated market maker at a favorable price. Arbitrage trades move the ratio of the reserves towards the market exchange rate.
An automated market maker charges a fee on every trade, and that fee is a fixed percent of the amount bought by the automated market maker. 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%. So if you actually wanted to make this trade, you would need to pay about 118.355 USD for 100 EUR. The automated market maker actually factors the fees into the constant product invariant, so in reality the product of the reserves grows after every trade.
Participation in a liquidity pool is represented by pool shares. Pool shares are very similar to other Stellar assets, but there is one important difference: pool shares are not transferable. The only way to increase the number of pool shares held is to deposit into a liquidity pool (via LiquidityPoolDepositOp
, and the only way to decrease the number of pool shares held is to withdraw from a liquidity pool LiquidityPoolWithdrawOp
. Two specific examples of this are that pool shares cannot be sent in payments and cannot be sold using offers.
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.
Like other Stellar assets, 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 important 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.
Analogous to the spread in a traditional market, an automated market maker charges a fee on all trades. The participants in the liquidity pool receive a share of the fee proportional to their share of the assets in the liquidity pool. The fee rate is fixed at 30 bps, which is equal to 0.30%. These fees are completely separate from network fees.
Pool share trustlines cannot be authorized or deauthorized 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 withdraw |
Trustline for A is fully authorized but trustline for B is authorized to maintain liabilities OR Trustline for B is fully authorized but trustline for A is authorized to maintain liabilities OR 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 OR 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
whenever a user wants to start providing liquidity to the market. Use LiquidityPoolWithdraw
whenever a user wants to stop providing liquidity to the market. It’s really that easy.
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.
For now, we'll cover basic liquidity pool participation and querying.
In the following code samples, proper error checking is omitted for brevity. However, you should always validate your results, as there are many ways that requests can fail. You can refer to the guide on Handling Errors Gracefully for tips on error management strategies.
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. If you use the secrets included in this guide, you may encounter problems with asset issuance or other account balance problems.
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");
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,
withMuxing: true,
});
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));
// kp1 issues the assets
const kp1 = kps[0];
const [ A, B ] = orderAssets(...[
new sdk.Asset("A", kp1.publicKey()),
new sdk.Asset("B", kp1.publicKey()),
]);
/// Establishes trustlines and funds `recipientKp` for all `assets`.
function distributeAssets(issuerKp, recipientKp, ...assets) {
return server
.loadAccount(issuerKp.publicKey())
.then(issuer => {
const ops = 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();
let tx = buildTx(issuer, issuerKp, ...ops);
tx.sign(recipientKp);
return server.submitTransaction(tx);
});
}
function preamble() {
return Promise.all([1, 2].map(i => distributeAssets(kp1, kps[i], A, B)));
}
Here, we use distributeAssets()
to establish trustlines and set up initial balances of two custom assets (A
and B
, issued by kp1
) 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 lexographically 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, lets 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 (refer to the discussion about pool shares earlier for details), 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.
const poolId = sdk.getLiquidityPoolId(
"constant_product",
poolShareAsset.getLiquidityPoolParameters()
).toString("hex");
function addLiquidity(source, signer, poolId, maxReserveA, maxReserveB) {
const exactPrice = reserveA / reserveB;
const minPrice = exactPrice - (exactPrice * 0.10);
const maxPrice = exactPrice + (exactPrice * 0.10);
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.
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, minReserveA, minReserveB) {
return server.submitTransaction(
buildTx(source, signer,
sdk.Operation.liquidityPoolWithdraw({
liquidityPoolId: poolId,
minAmountA: minReserveA,
minAmountB: minReserveB,
})
)
)
}
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.
function main() {
return getAccounts()
.then(accounts =>
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(_ => withdrawLiquidity(accounts[1], kps[1], "500", "2000"))
.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):
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);
});
});