CAP: 0038
Title: Automated Market Makers
Working Group:
Owner: Jonathan Jove <@jonjove>
Authors: Jonathan Jove <@jonjove>, Siddharth Suresh <@sisuresh>
Consulted: OrbitLens <@orbitLens>, Nikhil Saraf <@nikhilsaraf>, Tamir Sen <@tamirms>, Phil Meng <@phil-stellar>, Leigh McCulloch <@leighmcculloch>, Nicolas Barry <@monsieurnicolas>
Status: Final
Created: 2021-03-22
Discussion: https://groups.google.com/g/stellar-dev/c/NLE-nprRPtc/m/GHlmlE7ABwAJ
Protocol version: 18
Automated market makers provide a simple way to provide liquidity and exchange assets.
This proposal was initially authored by Jonathan Jove based on the results of numerous discussions. The working group includes the author of a similar proposal (OrbitLens), people with knowledge of market making (Nikhil Saraf and Phil Meng), and a maintainer of Horizon and its SDKs (Tamir Sen).
Projects such as Uniswap have shown that automated market makers are effective at providing easy-to-access liquidity at scale. The simplicity and non-interactivity of liquidity pools can attract large amounts of capital and enable high volumes of trading. We believe adding automated market makers to Stellar will improve overall liquidity on the network.
This proposal is aligned with several Stellar Network Goals, among them:
- The Stellar Network should run at scale and at low cost to all participants of the network
- The Stellar Network should enable cross-border payments, i.e. payments via exchange of assets, throughout the globe, enabling users to make payments between assets in a manner that is fast, cheap, and highly usable.
- The Stellar Network should make it easy for developers of Stellar projects to create highly usable products.
This proposal introduces automated market makers to the Stellar network.
LiquidityPoolEntry
is introduced as a new type of LedgerEntry
which stores
the state of a liquidity pool. New operations, LiquidityPoolDepositOp
and
LiquidityPoolWithdrawOp
, are introduced to enable adding and removing
liquidity from liquidity pools. Because providing liquidity to a liquidity pool
yields pool shares, TrustLineEntry
is modified to store pool shares and
ChangeTrustOp
is modified to allow interacting with those trust lines. Pool
shares are not transferable. PathPaymentStrictSendOp
and
PathPaymentStrictReceiveOp
are modified to act as an opaque interface to
exchanging assets with the order book and liquidity pools.
This patch of XDR changes is based on the XDR files in commit (a5e7028c04305c7b6f7d08c981e87bb9891b7364
) of stellar-core.
diff --git a/src/xdr/Stellar-ledger-entries.x b/src/xdr/Stellar-ledger-entries.x
index 0e7bc842..885cf2d4 100644
--- a/src/xdr/Stellar-ledger-entries.x
+++ b/src/xdr/Stellar-ledger-entries.x
@@ -14,6 +14,7 @@ typedef string string64<64>;
typedef int64 SequenceNumber;
typedef uint64 TimePoint;
typedef opaque DataValue<64>;
+typedef Hash PoolID; // SHA256(LiquidityPoolParameters)
// 1-4 alphanumeric characters right-padded with 0 bytes
typedef opaque AssetCode4[4];
@@ -25,7 +26,8 @@ enum AssetType
{
ASSET_TYPE_NATIVE = 0,
ASSET_TYPE_CREDIT_ALPHANUM4 = 1,
- ASSET_TYPE_CREDIT_ALPHANUM12 = 2
+ ASSET_TYPE_CREDIT_ALPHANUM12 = 2,
+ ASSET_TYPE_POOL_SHARE = 3
};
union AssetCode switch (AssetType type)
@@ -39,24 +41,28 @@ case ASSET_TYPE_CREDIT_ALPHANUM12:
// add other asset types here in the future
};
+struct AlphaNum4
+{
+ AssetCode4 assetCode;
+ AccountID issuer;
+};
+
+struct AlphaNum12
+{
+ AssetCode12 assetCode;
+ AccountID issuer;
+};
+
union Asset switch (AssetType type)
{
case ASSET_TYPE_NATIVE: // Not credit
void;
case ASSET_TYPE_CREDIT_ALPHANUM4:
- struct
- {
- AssetCode4 assetCode;
- AccountID issuer;
- } alphaNum4;
+ AlphaNum4 alphaNum4;
case ASSET_TYPE_CREDIT_ALPHANUM12:
- struct
- {
- AssetCode12 assetCode;
- AccountID issuer;
- } alphaNum12;
+ AlphaNum12 alphaNum12;
// add other asset types here in the future
};
@@ -90,7 +96,8 @@ enum LedgerEntryType
TRUSTLINE = 1,
OFFER = 2,
DATA = 3,
- CLAIMABLE_BALANCE = 4
+ CLAIMABLE_BALANCE = 4,
+ LIQUIDITY_POOL = 5
};
struct Signer
@@ -214,12 +221,46 @@ const MASK_TRUSTLINE_FLAGS = 1;
const MASK_TRUSTLINE_FLAGS_V13 = 3;
const MASK_TRUSTLINE_FLAGS_V17 = 7;
+enum LiquidityPoolType
+{
+ LIQUIDITY_POOL_CONSTANT_PRODUCT = 0
+};
+
+union TrustLineAsset switch (AssetType type)
+{
+case ASSET_TYPE_NATIVE: // Not credit
+ void;
+
+case ASSET_TYPE_CREDIT_ALPHANUM4:
+ AlphaNum4 alphaNum4;
+
+case ASSET_TYPE_CREDIT_ALPHANUM12:
+ AlphaNum12 alphaNum12;
+
+case ASSET_TYPE_POOL_SHARE:
+ PoolID liquidityPoolID;
+
+ // add other asset types here in the future
+};
+
+struct TrustLineEntryExtensionV2
+{
+ int32 liquidityPoolUseCount;
+
+ union switch (int v)
+ {
+ case 0:
+ void;
+ }
+ ext;
+};
+
struct TrustLineEntry
{
- AccountID accountID; // account this trustline belongs to
- Asset asset; // type of asset (with issuer)
- int64 balance; // how much of this asset the user has.
- // Asset defines the unit for this;
+ AccountID accountID; // account this trustline belongs to
+ TrustLineAsset asset; // type of asset (with issuer)
+ int64 balance; // how much of this asset the user has.
+ // Asset defines the unit for this;
int64 limit; // balance cannot be above this
uint32 flags; // see TrustLineFlags
@@ -238,6 +279,8 @@ struct TrustLineEntry
{
case 0:
void;
+ case 2:
+ TrustLineEntryExtensionV2 v2;
}
ext;
} v1;
@@ -403,6 +446,33 @@ struct ClaimableBalanceEntry
ext;
};
+struct LiquidityPoolConstantProductParameters
+{
+ Asset assetA; // assetA < assetB
+ Asset assetB;
+ int32 fee; // Fee is in basis points, so the actual rate is (fee/100)%
+};
+
+struct LiquidityPoolEntry
+{
+ PoolID liquidityPoolID;
+
+ union switch (LiquidityPoolType type)
+ {
+ 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;
+};
+
struct LedgerEntryExtensionV1
{
SponsorshipDescriptor sponsoringID;
@@ -431,6 +501,8 @@ struct LedgerEntry
DataEntry data;
case CLAIMABLE_BALANCE:
ClaimableBalanceEntry claimableBalance;
+ case LIQUIDITY_POOL:
+ LiquidityPoolEntry liquidityPool;
}
data;
@@ -457,7 +529,7 @@ case TRUSTLINE:
struct
{
AccountID accountID;
- Asset asset;
+ TrustLineAsset asset;
} trustLine;
case OFFER:
@@ -479,6 +551,12 @@ case CLAIMABLE_BALANCE:
{
ClaimableBalanceID balanceID;
} claimableBalance;
+
+case LIQUIDITY_POOL:
+ struct
+ {
+ PoolID liquidityPoolID;
+ } liquidityPool;
};
// list of all envelope types used in the application
@@ -492,6 +570,7 @@ enum EnvelopeType
ENVELOPE_TYPE_AUTH = 3,
ENVELOPE_TYPE_SCPVALUE = 4,
ENVELOPE_TYPE_TX_FEE_BUMP = 5,
- ENVELOPE_TYPE_OP_ID = 6
+ ENVELOPE_TYPE_OP_ID = 6,
+ ENVELOPE_TYPE_POOL_REVOKE_OP_ID = 7
};
}
diff --git a/src/xdr/Stellar-ledger.x b/src/xdr/Stellar-ledger.x
index a21c577a..84b84cbf 100644
--- a/src/xdr/Stellar-ledger.x
+++ b/src/xdr/Stellar-ledger.x
@@ -47,6 +47,27 @@ struct StellarValue
ext;
};
+const MASK_LEDGER_HEADER_FLAGS = 0x7;
+
+enum LedgerHeaderFlags
+{
+ DISABLE_LIQUIDITY_POOL_TRADING_FLAG = 0x1,
+ DISABLE_LIQUIDITY_POOL_DEPOSIT_FLAG = 0x2,
+ DISABLE_LIQUIDITY_POOL_WITHDRAWAL_FLAG = 0x4
+};
+
+struct LedgerHeaderExtensionV1
+{
+ uint32 flags; // LedgerHeaderFlags
+
+ union switch (int v)
+ {
+ case 0:
+ void;
+ }
+ ext;
+};
+
/* The LedgerHeader is the highest level structure representing the
* state of a ledger, cryptographically linked to previous ledgers.
*/
@@ -84,6 +105,8 @@ struct LedgerHeader
{
case 0:
void;
+ case 1:
+ LedgerHeaderExtensionV1 v1;
}
ext;
};
@@ -98,7 +121,8 @@ enum LedgerUpgradeType
LEDGER_UPGRADE_VERSION = 1,
LEDGER_UPGRADE_BASE_FEE = 2,
LEDGER_UPGRADE_MAX_TX_SET_SIZE = 3,
- LEDGER_UPGRADE_BASE_RESERVE = 4
+ LEDGER_UPGRADE_BASE_RESERVE = 4,
+ LEDGER_UPGRADE_FLAGS = 5
};
union LedgerUpgrade switch (LedgerUpgradeType type)
@@ -111,6 +135,8 @@ case LEDGER_UPGRADE_MAX_TX_SET_SIZE:
uint32 newMaxTxSetSize; // update maxTxSetSize
case LEDGER_UPGRADE_BASE_RESERVE:
uint32 newBaseReserve; // update baseReserve
+case LEDGER_UPGRADE_FLAGS:
+ uint32 newFlags; // update flags
};
/* Entries used to define the bucket list */
diff --git a/src/xdr/Stellar-transaction.x b/src/xdr/Stellar-transaction.x
index 75f39eb4..f9d62a69 100644
--- a/src/xdr/Stellar-transaction.x
+++ b/src/xdr/Stellar-transaction.x
@@ -7,6 +7,12 @@
namespace stellar
{
+union LiquidityPoolParameters switch (LiquidityPoolType type)
+{
+case LIQUIDITY_POOL_CONSTANT_PRODUCT:
+ LiquidityPoolConstantProductParameters constantProduct;
+};
+
// Source or destination of a payment operation
union MuxedAccount switch (CryptoKeyType type)
{
@@ -49,7 +55,9 @@ enum OperationType
REVOKE_SPONSORSHIP = 18,
CLAWBACK = 19,
CLAWBACK_CLAIMABLE_BALANCE = 20,
- SET_TRUST_LINE_FLAGS = 21
+ SET_TRUST_LINE_FLAGS = 21,
+ LIQUIDITY_POOL_DEPOSIT = 22,
+ LIQUIDITY_POOL_WITHDRAW = 23
};
/* CreateAccount
@@ -212,6 +220,23 @@ struct SetOptionsOp
Signer* signer;
};
+union ChangeTrustAsset switch (AssetType type)
+{
+case ASSET_TYPE_NATIVE: // Not credit
+ void;
+
+case ASSET_TYPE_CREDIT_ALPHANUM4:
+ AlphaNum4 alphaNum4;
+
+case ASSET_TYPE_CREDIT_ALPHANUM12:
+ AlphaNum12 alphaNum12;
+
+case ASSET_TYPE_POOL_SHARE:
+ LiquidityPoolParameters liquidityPool;
+
+ // add other asset types here in the future
+};
+
/* Creates, updates or deletes a trust line
Threshold: med
@@ -221,7 +246,7 @@ struct SetOptionsOp
*/
struct ChangeTrustOp
{
- Asset line;
+ ChangeTrustAsset line;
// if limit is set to 0, deletes the trust line
int64 limit;
@@ -409,6 +434,37 @@ struct SetTrustLineFlagsOp
uint32 setFlags; // which flags to set
};
+const LIQUIDITY_POOL_FEE_V18 = 30;
+
+/* Deposit assets into a liquidity pool
+
+ Threshold: med
+
+ Result: LiquidityPoolDepositResult
+*/
+struct LiquidityPoolDepositOp
+{
+ PoolID liquidityPoolID;
+ int64 maxAmountA; // maximum amount of first asset to deposit
+ int64 maxAmountB; // maximum amount of second asset to deposit
+ Price minPrice; // minimum depositA/depositB
+ Price maxPrice; // maximum depositA/depositB
+};
+
+/* Withdraw assets from a liquidity pool
+
+ Threshold: med
+
+ Result: LiquidityPoolWithdrawResult
+*/
+struct LiquidityPoolWithdrawOp
+{
+ PoolID liquidityPoolID;
+ int64 amount; // amount of pool shares to withdraw
+ int64 minAmountA; // minimum amount of first asset to withdraw
+ int64 minAmountB; // minimum amount of second asset to withdraw
+};
+
/* An operation is the lowest unit of work that a transaction does */
struct Operation
{
@@ -463,11 +519,15 @@ struct Operation
ClawbackClaimableBalanceOp clawbackClaimableBalanceOp;
case SET_TRUST_LINE_FLAGS:
SetTrustLineFlagsOp setTrustLineFlagsOp;
+ case LIQUIDITY_POOL_DEPOSIT:
+ LiquidityPoolDepositOp liquidityPoolDepositOp;
+ case LIQUIDITY_POOL_WITHDRAW:
+ LiquidityPoolWithdrawOp liquidityPoolWithdrawOp;
}
body;
};
-union OperationID switch (EnvelopeType type)
+union HashIDPreimage switch (EnvelopeType type)
{
case ENVELOPE_TYPE_OP_ID:
struct
@@ -475,7 +535,16 @@ case ENVELOPE_TYPE_OP_ID:
MuxedAccount sourceAccount;
SequenceNumber seqNum;
uint32 opNum;
- } id;
+ } operationID;
+case ENVELOPE_TYPE_POOL_REVOKE_OP_ID:
+ struct
+ {
+ AccountID sourceAccount;
+ SequenceNumber seqNum;
+ uint32 opNum;
+ PoolID liquidityPoolID;
+ Asset asset;
+ } revokeID;
};
enum MemoType
@@ -635,7 +704,33 @@ struct TransactionSignaturePayload
/* Operation Results section */
-/* This result is used when offers are taken during an operation */
+enum ClaimAtomType
+{
+ CLAIM_ATOM_TYPE_V0 = 0,
+ CLAIM_ATOM_TYPE_ORDER_BOOK = 1,
+ CLAIM_ATOM_TYPE_LIQUIDITY_POOL = 2
+};
+
+// ClaimOfferAtomV0 is a ClaimOfferAtom with the AccountID discriminant stripped
+// off, leaving a raw ed25519 public key to identify the source account. This is
+// used for backwards compatibility starting from the protocol 17/18 boundary.
+// If an "old-style" ClaimOfferAtom is parsed with this XDR definition, it will
+// be parsed as a "new-style" ClaimAtom containing a ClaimOfferAtomV0.
+struct ClaimOfferAtomV0
+{
+ // emitted to identify the offer
+ uint256 sellerEd25519; // Account that owns the offer
+ int64 offerID;
+
+ // amount and asset taken from the owner
+ Asset assetSold;
+ int64 amountSold;
+
+ // amount and asset sent to the owner
+ Asset assetBought;
+ int64 amountBought;
+};
+
struct ClaimOfferAtom
{
// emitted to identify the offer
@@ -651,6 +746,32 @@ struct ClaimOfferAtom
int64 amountBought;
};
+struct ClaimLiquidityAtom
+{
+ PoolID liquidityPoolID;
+
+ // amount and asset taken from the pool
+ Asset assetSold;
+ int64 amountSold;
+
+ // amount and asset sent to the pool
+ Asset assetBought;
+ int64 amountBought;
+};
+
+/* This result is used when offers are taken or liquidity is exchanged with a
+ liquidity pool during an operation
+*/
+union ClaimAtom switch (ClaimAtomType type)
+{
+case CLAIM_ATOM_TYPE_V0:
+ ClaimOfferAtomV0 v0;
+case CLAIM_ATOM_TYPE_ORDER_BOOK:
+ ClaimOfferAtom orderBook;
+case CLAIM_ATOM_TYPE_LIQUIDITY_POOL:
+ ClaimLiquidityAtom liquidityPool;
+};
+
/******* CreateAccount Result ********/
enum CreateAccountResultCode
@@ -745,7 +866,7 @@ union PathPaymentStrictReceiveResult switch (
case PATH_PAYMENT_STRICT_RECEIVE_SUCCESS:
struct
{
- ClaimOfferAtom offers<>;
+ ClaimAtom offers<>;
SimplePaymentResult last;
} success;
case PATH_PAYMENT_STRICT_RECEIVE_NO_ISSUER:
@@ -789,7 +910,7 @@ union PathPaymentStrictSendResult switch (PathPaymentStrictSendResultCode code)
case PATH_PAYMENT_STRICT_SEND_SUCCESS:
struct
{
- ClaimOfferAtom offers<>;
+ ClaimAtom offers<>;
SimplePaymentResult last;
} success;
case PATH_PAYMENT_STRICT_SEND_NO_ISSUER:
@@ -837,7 +958,7 @@ enum ManageOfferEffect
struct ManageOfferSuccessResult
{
// offers that got claimed while creating this offer
- ClaimOfferAtom offersClaimed<>;
+ ClaimAtom offersClaimed<>;
union switch (ManageOfferEffect effect)
{
@@ -933,7 +1054,10 @@ enum ChangeTrustResultCode
// cannot create with a limit of 0
CHANGE_TRUST_LOW_RESERVE =
-4, // not enough funds to create a new trust line,
- CHANGE_TRUST_SELF_NOT_ALLOWED = -5 // trusting self is not allowed
+ CHANGE_TRUST_SELF_NOT_ALLOWED = -5, // trusting self is not allowed
+ CHANGE_TRUST_TRUST_LINE_MISSING = -6, // Asset trustline is missing for pool
+ CHANGE_TRUST_CANNOT_DELETE = -7, // Asset trustline is still referenced in a pool
+ CHANGE_TRUST_NOT_AUTH_MAINTAIN_LIABILITIES = -8 // Asset trustline is deauthorized
};
union ChangeTrustResult switch (ChangeTrustResultCode code)
@@ -956,7 +1080,9 @@ enum AllowTrustResultCode
// source account does not require trust
ALLOW_TRUST_TRUST_NOT_REQUIRED = -3,
ALLOW_TRUST_CANT_REVOKE = -4, // source account can't revoke trust,
- ALLOW_TRUST_SELF_NOT_ALLOWED = -5 // trusting self is not allowed
+ ALLOW_TRUST_SELF_NOT_ALLOWED = -5, // trusting self is not allowed
+ ALLOW_TRUST_LOW_RESERVE = -6 // claimable balances can't be created
+ // on revoke due to low reserves
};
union AllowTrustResult switch (AllowTrustResultCode code)
@@ -1152,7 +1278,8 @@ enum RevokeSponsorshipResultCode
REVOKE_SPONSORSHIP_DOES_NOT_EXIST = -1,
REVOKE_SPONSORSHIP_NOT_SPONSOR = -2,
REVOKE_SPONSORSHIP_LOW_RESERVE = -3,
- REVOKE_SPONSORSHIP_ONLY_TRANSFERABLE = -4
+ REVOKE_SPONSORSHIP_ONLY_TRANSFERABLE = -4,
+ REVOKE_SPONSORSHIP_MALFORMED = -5
};
union RevokeSponsorshipResult switch (RevokeSponsorshipResultCode code)
@@ -1218,7 +1345,9 @@ enum SetTrustLineFlagsResultCode
SET_TRUST_LINE_FLAGS_MALFORMED = -1,
SET_TRUST_LINE_FLAGS_NO_TRUST_LINE = -2,
SET_TRUST_LINE_FLAGS_CANT_REVOKE = -3,
- SET_TRUST_LINE_FLAGS_INVALID_STATE = -4
+ SET_TRUST_LINE_FLAGS_INVALID_STATE = -4,
+ SET_TRUST_LINE_FLAGS_LOW_RESERVE = -5 // claimable balances can't be created
+ // on revoke due to low reserves
};
union SetTrustLineFlagsResult switch (SetTrustLineFlagsResultCode code)
@@ -1229,6 +1358,63 @@ default:
void;
};
+/******* LiquidityPoolDeposit Result ********/
+
+enum LiquidityPoolDepositResultCode
+{
+ // codes considered as "success" for the operation
+ LIQUIDITY_POOL_DEPOSIT_SUCCESS = 0,
+
+ // codes considered as "failure" for the operation
+ LIQUIDITY_POOL_DEPOSIT_MALFORMED = -1, // bad input
+ LIQUIDITY_POOL_DEPOSIT_NO_TRUST = -2, // no trust line for one of the
+ // assets
+ LIQUIDITY_POOL_DEPOSIT_NOT_AUTHORIZED = -3, // not authorized for one of the
+ // assets
+ LIQUIDITY_POOL_DEPOSIT_UNDERFUNDED = -4, // not enough balance for one of
+ // the assets
+ LIQUIDITY_POOL_DEPOSIT_LINE_FULL = -5, // pool share trust line doesn't
+ // have sufficient limit
+ LIQUIDITY_POOL_DEPOSIT_BAD_PRICE = -6, // deposit price outside bounds
+ LIQUIDITY_POOL_DEPOSIT_POOL_FULL = -7 // pool reserves are full
+};
+
+union LiquidityPoolDepositResult switch (
+ LiquidityPoolDepositResultCode code)
+{
+case LIQUIDITY_POOL_DEPOSIT_SUCCESS:
+ void;
+default:
+ void;
+};
+
+/******* LiquidityPoolWithdraw Result ********/
+
+enum LiquidityPoolWithdrawResultCode
+{
+ // codes considered as "success" for the operation
+ LIQUIDITY_POOL_WITHDRAW_SUCCESS = 0,
+
+ // codes considered as "failure" for the operation
+ LIQUIDITY_POOL_WITHDRAW_MALFORMED = -1, // bad input
+ LIQUIDITY_POOL_WITHDRAW_NO_TRUST = -2, // no trust line for one of the
+ // assets
+ LIQUIDITY_POOL_WITHDRAW_UNDERFUNDED = -3, // not enough balance of the
+ // pool share
+ LIQUIDITY_POOL_WITHDRAW_LINE_FULL = -4, // would go above limit for one
+ // of the assets
+ LIQUIDITY_POOL_WITHDRAW_UNDER_MINIMUM = -5 // didn't withdraw enough
+};
+
+union LiquidityPoolWithdrawResult switch (
+ LiquidityPoolWithdrawResultCode code)
+{
+case LIQUIDITY_POOL_WITHDRAW_SUCCESS:
+ void;
+default:
+ void;
+};
+
/* High level Operation Result */
enum OperationResultCode
{
@@ -1291,6 +1477,10 @@ case opINNER:
ClawbackClaimableBalanceResult clawbackClaimableBalanceResult;
case SET_TRUST_LINE_FLAGS:
SetTrustLineFlagsResult setTrustLineFlagsResult;
+ case LIQUIDITY_POOL_DEPOSIT:
+ LiquidityPoolDepositResult liquidityPoolDepositResult;
+ case LIQUIDITY_POOL_WITHDRAW:
+ LiquidityPoolWithdrawResult liquidityPoolWithdrawResult;
}
tr;
default:
LiquidityPoolDepositOp
is the only way for an account to deposit funds into a
liquidity pool. The operation specifies a maximum amount to deposit for each
asset in the pool (ordered field-wise lexicographically among assets). The
operation then converts these amounts into a number of pool shares that will be
received. Using that number of pool shares, it calculates amounts of each asset
to deposit with a maximum error (rounded against the depositor) of 1 stroop in
each asset. Finally, it checks that the deposit price is within the bounds
specified by minPrice and maxPrice.
LiquidityPoolDepositOp
will return opNOT_SUPPORTED
during validation if
(ledgerHeader.v1.flags & DISABLE_LIQUIDITY_POOL_DEPOSIT_FLAG) == DISABLE_LIQUIDITY_POOL_DEPOSIT_FLAG
LiquidityPoolDepositOp lpdo
is invalid if
lpdo.maxAmountA <= 0
lpdo.maxAmountB <= 0
lpdo.minPrice.n <= 0
lpdo.minPrice.d <= 0
lpdo.maxPrice.n <= 0
lpdo.maxPrice.d <= 0
lpdo.minPrice.n * lpdo.maxPrice.d > lpdo.minPrice.d * lpdo.maxPrice.n
(this is equivalent tolpdo.minPrice.n / lpdo.minPrice.d > lpdo.maxPrice.n / lpdo.maxPrice.d
)
The process of applying LiquidityPoolDepositOp lpdo
with source sourceAccount
is
tlPool = loadTrustLine(sourceAccount, lpdo.liquidityPoolID)
if !exists(tlPool)
Fail with LIQUIDITY_POOL_DEPOSIT_NO_TRUST
// tlPool exists so lp exists too
lp = loadLiquidityPool(lpdo.liquidityPoolID)
cp = lp.constantProduct()
tlA = loadTrustLine(sourceAccount, cp.assetA)
tlB = loadTrustLine(sourceAccount, cp.assetB)
// tlA and tlB must exist because tlPool exists
if !authorized(tlA) || !authorized(tlB)
Fail with LIQUIDITY_POOL_DEPOSIT_NOT_AUTHORIZED
amountA = 0
amountB = 0
amountPoolShares = 0
if cp.totalPoolShares != 0
reserveA = cp.reserveA
reserveB = cp.reserveB
total = cp.totalPoolShares
sharesA = floor(total * lpdo.maxAmountA / reserveA)
sharesB = floor(total * lpdo.maxAmountB / reserveB)
// Must have sharesA <= INT64_MAX || sharesB <= INT64_MAX
amountPoolShares = min(sharesA, sharesB)
amountA = ceil(amountPoolShares * reservesA / total)
amountB = ceil(amountPoolShares * reservesB / total)
if availableBalance(tlA) < amountA || availableBalance(tlB) < amountB
Fail with LIQUIDITY_POOL_DEPOSIT_UNDERFUNDED
if amountA == 0 || amountB = 0 ||
amountA * minPrice.d < amountB * minPrice.n ||
amountA * maxPrice.d > amountB * maxPrice.n
Fail with LIQUIDITY_POOL_DEPOSIT_BAD_PRICE
if availableLimit(tlPool) < amountPoolShares
Fail with LIQUIDITY_POOL_DEPOSIT_LINE_FULL
else
amountA = lpdo.maxAmountA
amountB = lpdo.maxAmountB
if availableBalance(tlA) < amountA || availableBalance(tlB) < amountB
Fail with LIQUIDITY_POOL_DEPOSIT_UNDERFUNDED
if amountA == 0 || amountB = 0 ||
amountA * minPrice.d < amountB * minPrice.n ||
amountA * maxPrice.d > amountB * maxPrice.n
Fail with LIQUIDITY_POOL_DEPOSIT_BAD_PRICE
amountPoolShares = floor(sqrt(amountA * amountB))
if availableLimit(tlPool) < amountPoolShares
Fail with LIQUIDITY_POOL_DEPOSIT_LINE_FULL
if INT64_MAX - amountA < cp.reserveA ||
INT64_MAX - amountB < cp.reserveB ||
INT64_MAX - amountPoolShares < cp.totalPolShares
Fail with LIQUIDITY_POOL_DEPOSIT_POOL_FULL
tlA.balance -= amountA
lp.constantProduct().reserveA += amountA
tlB.balance -= amountB
lp.constantProduct().reserveB += amountB
tlPool.balance += amountPoolShares
lp.constantProduct().totalPoolShares += amountPoolShares
Succeed with LIQUIDITY_POOL_DEPOSIT_SUCCESS
LiquidityPoolWithdrawOp
is the only way for an account to withdraw funds from
a liquidity pool. The operation specifies an amount of pool shares to withdraw.
Using that number of pool shares, it calculates amounts of each asset to withdraw
with a maximum error (rounded against the depositor) of 1 stroop in each asset.
Finally, it checks that the withdrawn amounts are at least those specified by
minAmountA and minAmountB.
LiquidityPoolWithdrawOp
will return opNOT_SUPPORTED
during validation if
(ledgerHeader.v1.flags & DISABLE_LIQUIDITY_POOL_WITHDRAWAL_FLAG) == DISABLE_LIQUIDITY_POOL_WITHDRAWAL_FLAG
LiquidityPoolWithdrawOp lpwo
is invalid if
lpwo.amount <= 0
lpwo.minAmountA < 0
lpwo.minAmountB < 0
The process of applying LiquidityPoolWithdrawOp lpwo
with source sourceAccount
is
tlPool = loadTrustLine(sourceAccount, lpwo.liquidityPoolID)
if !exists(tlPool)
Fail with LIQUIDITY_POOL_WITHDRAW_NO_TRUST
// tlPool exists so lp exists too
lp = loadLiquidityPool(lpwo.liquidityPoolID)
cp = lp.constantProduct()
reserveA = cp.reserveA
reserveB = cp.reserveB
total = cp.totalPoolShares
amount = lpwo.amount
if availableBalance(tlPool) < amount
Fail with LIQUIDITY_POOL_WITHDRAW_UNDERFUNDED
amountA = floor(amount / totalPoolShares * reserveA)
if amountA < lpwo.minAmountA
Fail with LIQUIDITY_POOL_WITHDRAW_UNDER_MINIMUM
tlA = loadTrustLine(sourceAccount, cp.assetA)
if availableLimit(tlA) < amountA
Fail with LIQUIDITY_POOL_WITHDRAW_LINE_FULL
amountB = floor(amount / totalPoolShares * reserveB)
if amountB < lpwo.minAmountB
Fail with LIQUIDITY_POOL_WITHDRAW_UNDER_MINIMUM
tlB = loadTrustLine(sourceAccount, cp.assetB)
if availableLimit(tlB) < amountB
Fail with LIQUIDITY_POOL_WITHDRAW_LINE_FULL
tlA.balance += amountA
lp.constantProduct().reserveA -= amountA
tlB.balance += amountB
lp.constantProduct().reserveB -= amountB
tlPool.balance -= amount
lp.constantProduct().totalPoolShares -= amount
Succeed with LIQUIDITY_POOL_WITHDRAW_SUCCESS
ChangeTrustOp
is extended to allow the creation, modification, and deletion of
pool share trust lines. If a pool share trust line is the first one created for
the specified parameters, then the corresponding LiquidityPoolEntry
will be
created. Likewise, if a pool share trust line is the last one deleted for the
specified parameters, then the corresponding LiquidityPoolEntry
will be
deleted. To create a pool share trust line, you must have trust lines for each
of the constituent assets and those trust lines must at least be authorized to
maintain liabilities.
The validity conditions for ChangeTrustOp
are unchanged if
line.type() != ASSET_TYPE_POOL_SHARE
. ChangeTrustOp
is additionally invalid
if line.type() == ASSET_TYPE_POOL_SHARE
and
line.liquidityPool().constantProduct().assetA >= line.liquidityPool().constantProduct().assetB
line.liquidityPool().constantProduct().fee != LIQUIDITY_POOL_FEE_V18
The behavior of ChangeTrustOp
is changed for all trust line types
If line.type() != ASSET_TYPE_POOL_SHARE
then
- If the asset trust line is being deleted but
liquidityPoolUseCount != 0
, returnCHANGE_TRUST_CANNOT_DELETE
.
If line.type() == ASSET_TYPE_POOL_SHARE
and
-
If pool share trust line does not exist (and therefore needs to be created)
- For each asset in the pool where the source account is not the issuer
- If the trust line for the asset is missing, return
CHANGE_TRUST_TRUST_LINE_MISSING
. - If the trust line for the asset is not authorized to maintain liabilities,
return
CHANGE_TRUST_NOT_AUTH_MAINTAIN_LIABILITIES
.
- If the trust line for the asset is missing, return
- The pool share trust line
tl
hastl.asset.liquidityPoolID() == SHA256(line.liquidityPool())
- No flags are set on the pool share trust line.
- If no liquidity pool with
liquidityPoolID == SHA256(line.liquidityPool())
exists, then that liquidity pool is created. - The pool share trust line should count as two subentries (and therefore require two base reserves)
poolSharesTrustLineCount
is incremented on the corresponding liquidity pool andliquidityPoolUseCount
is incremented on each asset trust line. Note thatpoolSharesTrustLineCount
is counting the number of pool share trust lines tied to a pool, so this will get incremented even if the source account is the issuer of both assets in the pool.liquidityPoolUseCount
on the other hand counts the number of pools a given asset trust line is used in, so this is irrelevant if the source account is the issuer. The issuer doesn't have a trust line to assets it has issued, so this step is skipped in that case.
- For each asset in the pool where the source account is not the issuer
-
If pool share trust line is being deleted
poolSharesTrustLineCount
is decremented on the corresponding liquidity pool, andliquidityPoolUseCount
is decremented on each asset trust line.- If
poolSharesTrustLineCount
on the corresponding liquidity pool becomes 0, then that liquidity pool is erased.
The authorization revocation behavior of SetTrustLineFlagsOp
and
AllowTrustOp
is extended to force a redeem of pool shares if any of the
referenced asset trust lines get their authorization revoked. For each redeemed
pool share trust line, a claimable balance will be created for every constituent
asset if there is a balance being withdrawn and the claimant is not the issuer.
This means that for a redeemed pool share trust line, there can be zero, one, or
two claimable balances created. These claimable balances will be sponsored by the
sponsor of the pool share trust line, and will be unconditionally claimable by the
owner of the pool share trust line.
The validity conditions for SetTrustLineFlagsOp
and AllowTrustOp
are unchanged.
The process of applying SetTrustLineFlagsOp
and AllowTrustOp
tl = loadTrustLine(trustor, asset)
if isAuthorizedToMaintainLiabilities(tl) && !isAuthorizedToMaintainLiabilities(expectedFlag)
// ... existing code to remove offers when auth is revoked
// gets all pool trust lines that use this asset and sourceAccount
poolTLs = getPoolTrustlines(asset, sourceAccount)
// prefetches all pools for the poolTLs just loaded
loadPools(poolTLs)
foreach (poolTL in poolTLs)
lp = loadLiquidityPool(poolTL.liquidityPoolID)
// redeem lp's pool shares using the logic from LiquidityPoolWithdrawOp
assetsAndAmounts = redeem(poolTL)
cbSponsoringAcc = poolTL.sponsoringID ? poolTL.sponsoringID : trustor
sponsorship = loadSponsorship(cbSponsoringAcc)
cbSponsoringAcc = sponsorship ? sponsorship.sponsoringID : cbSponsoringAcc
// free up reserves from poolTL for the claimable balances below
// Delete poolTL using the logic from ChangeTrustOp
// Note that this might also delete the corresponding LiquidityPoolEntry
delete(poolTL)
foreach (assetAndAmount in assetAndAmounts)
if assetAndAmount.amount == 0
continue
if isIssuer(trustor, assetAndAmount.asset)
continue
tlA = loadTrustLine(trustor, assetsAndAmount.asset)
LedgerEntry le;
le.sponsoringID = cbSponsoringAcc
le.type(CLAIMABLE_BALANCE)
ClaimableBalance& cb = le.claimableBalance()
cb.balanceID.type(CLAIMABLE_BALANCE_ID_TYPE_V0)
HashIDPreimage opID;
opID.type(ENVELOPE_TYPE_POOL_REVOKE_OP_ID)
opID.sourceAccount = txSourceAccount
opID.seqNum = txSourceSeqNum
opID.opNum = opNum
opID.liquidityPoolID = poolTL.liquidityPoolID
opID.asset = assetAndAmount.asset
cb.balanceID.fromPoolRevoke = sha256(opID)
cb.amount = assetAndAmount.amount
cb.asset = assetAndAmount.asset
cb.claimant = trustor
if isClawbackEnabledOnTrustline(tlA)
cb.flags = CLAIMABLE_BALANCE_CLAWBACK_ENABLED_FLAG
// will return false if cbSponsoringAcc does not have the appropriate reserves
res = create(cb)
if res == LOW_RESERVE
Fail with ALLOW_TRUST_LOW_RESERVE or SET_TRUST_LINE_FLAGS_LOW_RESERVE
else if res == TOO_MANY_SPONSORING
Fail with opTOO_MANY_SPONSORING
PathPaymentStrictSendOp
and PathPaymentStrictReceiveOp
are the only ways to
exchange with a liquidity pool. The operation does not allow users to determine
their own routing, rather the operation routes the exchange to the single venue
("venue" in this context means either the order book or the liquidity pool) that
yields the best price. In all respects, the behavior with liquidity pools is
analogous to the behavior without liquidity pools.
As noted, for each step in the path the exchange will be routed to the order book or liquidity pool that yields the best price for the entire exchange. The behavior is changed such that:
- The price of the exchange is computed for the liquidity pool, or it is recorded that the exchange is not possible. There are multiple reasons that the exchange might not be possible, including insufficient liquidity or INT64_MAX overflow of either pool reserve. Note that exceeding limits set by the operation does not qualify as "not possible" in this context.
- The price of the exchange is computed for the order book, or it is recorded that the exchange is not possible. There are multiple reasons that the exchange might not be possible, including insufficient liquidity or a self trade. Note that exceeding limits set by the operation does not qualify as "not possible" in this context.
- If both exchanges are possible, then choose the one which produces the best price. In the event that both prices are equal, choose the liquidity pool.
- If the exchange is not possible on the liquidity pool, then return whatever result was produced when exchanging with the order book.
As an example of the above, consider a path payment strict send with path
A -> B -> C:
- The `A -> B` conversion is performed successfully
- There is insufficient liquidity in the liquidity pool for B and C to
perform the `B -> C` exchange
- The `B -> C` exchange in the order book results in a self trade
- The operation fails with `PATH_PAYMENT_STRICT_SEND_CROSS_SELF`
It is important to recognize that this happens on each step in the path, so "return whatever result was produced when exchanging with the order book" is a local statement not a global statement. It is definitely possible that a path payment will produce a different result then it would have in the absence of liquidity pools. One simple example of this occurs on a one step path when there is no liquidity on the order book, but there is sufficient liquidity on the liquidity pool to perform the exchange at a bad price. In the absence of liquidity pools the result would have been too few offers, but with liquidity pools the result would have been under destination minimum or over source maximum.
Exchanging with a liquidity pool depends on the invariants enforced. This
proposal only introduces a constant product liquidity pool. The invariant for
such a liquidity pool is (X + x - Fx) (Y - y) >= XY
where
X
andY
are the initial reserves of the liquidity poolF
is the fee charged by the liquidity poolx
is the amount received by the liquidity pooly
is the amount disbursed by the liquidity pool
There are two important cases to handle: if we know the amount received, and if
we know the amount disbursed. If we know the amount received x
, then the
invariant can be rearranged to yield
y <= Y - XY / (X + x - Fx)
= (1 - F) Yx / (X + x - Fx)
so the integrality requirement produces
y = floor[(1 - F) Yx / (X + x - Fx)] .
If we know the amount disbursed y
, then the invariant can be rearranged to
yield
x >= (XY / (Y - y) - X) / (1 - F)
= Xy / (Y - y) / (1 - F)
so the integrality requirement produces
x = ceil[Xy / (Y - y) / (1 - F)] .
In this proposal, F = 0.003
which corresponds to 0.3% (this is encoded by
LIQUIDITY_POOL_FEE_V18
).
This proposal adds a new result code REVOKE_SPONSORSHIP_MALFORMED
for
RevokeSponsorshipOp
. This result code will be returned on validation if
RevokeSponsorshipOp.ledgerKey().type() == LIQUIDITY_POOL
. Additionally, all
existing validation failures will now return REVOKE_SPONSORSHIP_MALFORMED
for
consistency.
This proposal also adds a LedgerHeaderExtensionV1
that contains flags for
validators to vote on using a new LedgerUpgradeType
LEDGER_UPGRADE_FLAGS
.
Three different flags can be set (enforced by
MASK_LEDGERHEADER_FLAGS
), which are -
DISABLE_LIQUIDITY_POOL_TRADING_FLAG
: disable trading against liquidity poolsDISABLE_LIQUIDITY_POOL_DEPOSIT_FLAG
: disable depositing into liquidity poolsDISABLE_LIQUIDITY_POOL_WITHDRAWAL_FLAG
: disable withdrawing from liquidity pools
This will allow validators to disable parts of this CAP in the event that unexpected behavior is encountered. These flags can only be set if validators vote for them, and they should only be used in case of emergency. The ability to disable these pool related features is only temporary, and will be removed in the future.
Unused liquidity pools are erased automatically. An unused liquidity pool is
characterized by the property that no account has a trust line for the
corresponding pool shares. The implementation of LiquidityPoolWithdrawOp
must
guarantee that the liquidity pool has no reserves if no account owns shares in
the liquidity pool, in order to avoid destroying assets.
This proposal does not require a reserve for a LiquidityPoolEntry
. This is
justified by the fact that a LiquidityPoolEntry
cannot exist without the
existence of a TrustLineEntry
which can hold the pool share. The
TrustLineEntry
for pool shares require two base reserves. This choice
provides the cleanest user experience for LiquidityPoolDepositOp
because any
account can create the LiquidityPoolEntry
, avoiding a race condition where
accounts cannot predict whether they will need a reserve.
An alternative approach is to treat a LiquidityPoolEntry
like a
ClaimableBalanceEntry
, so it is always sponsored. This would still allow the
account that creates a pool to be merged if it can find another account to
assume the sponsorship.
This proposal uses Claimable Balances to send back an asset when a redemption is forced due to auth revocation. Instead of making the issuer put up the reserve, we would like to have the owner of the asset put up the reserve. This is ideal for a few reasons. First, the claimant of the claimable balance now has an additional incentive to claim the balance, and second, the issuer will not have to worry about potentially putting up many reserves in the case where many pool trust lines need to be redeemed on a revocation.
So how do we make the owner of the asset put up the reserve for the Claimable Balance? We require pool trust lines to require the same number of reserves as the number of Claimable Balances that need to be created (so two). When authorization is revoked, the pool shares in the pool trust line are redeemed, the trust line is deleted (freeing up two reserves), and then two sponsored Claimable Balances are created.
But what if the owner account is already sponsoring at least UINT32_MAX - 1
entries? They won't be able to sponsor those claimable balances and the revocation
would fail. For this reason, we should limit numSponsoring + numSubentries
to
UINT32_MAX
, guaranteeing that an account can always sponsor a claimable
balance for every subentry that gets removed.
Let's say Account A has a pool share trust line using Asset b1 and Asset b2. Account A is the issuer of Asset b1, but not b2. If A has it's authorization for b2 pulled then a redeem is forced in the liquidity pool and a Claimable Balance can be created for b2. But should one be created for b1? Account A would end up claiming the b1 balance, which would just result in the balance getting burned because A is the issuer of b1. We could accomplish the same step without failure by not creating the Claimable Balance in the first place and make this scenario simpler for the issuer.
Here are the possible scenarios when a trust line in a pool has it's authorization revoked. Remember that the pool share trust line is deleted to free up reserves for two claimable balances -
- Account A has a pool share trust line.
- On revoke, claimable balances are sponsored by A. Guaranteed to succeed.
- Account A has a pool share trust line, but the trust line is sponsored by B.
- On revoke, claimable balances are sponsored by B. Guaranteed to succeed.
- Account A has a pool share trust line, the trust line is sponsored by B, but B is in the middle
of a sponsorship sandwich where its entries will be sponsored by C.
- On revoke, claimable balances (if any need to be created) are sponsored by C. This can fail
if C does not have enough available balance to sponsor the claimable balances or if
numSponsoring + numSubentries + numClaimableBalancesToSponsor > UINT32_MAX
. The issuer can just submit the revoke again outside the sandwich for this to succeed. - This still works if C=A because claimable balances aren't subentries.
- On revoke, claimable balances (if any need to be created) are sponsored by C. This can fail
if C does not have enough available balance to sponsor the claimable balances or if
- Account A has a pool share trust line, and is in the middle of a sponsorship sandwich
where its entries will be sponsored by C.
- On revoke, claimable balances (if any need to be created) are sponsored by C. This can fail
if C does not have enough available balance to sponsor the claimable balances or if
numSponsoring + numSubentries + numClaimableBalancesToSponsor > UINT32_MAX
. The issuer can just submit the revoke again outside the sandwich for this to succeed.
- On revoke, claimable balances (if any need to be created) are sponsored by C. This can fail
if C does not have enough available balance to sponsor the claimable balances or if
For the failure cases, you can see that the account that owns/sponsors the pool share trust line is in the middle of a sponsorship sandwich. It is actually up to the issuer if the revoke is done in the middle of a sponsorship sandwich, so the issuer could always just submit the revoke with the sponsorship sandwich to make sure the revoke succeeds.
Some implementations of liquidity pools, such as Uniswap V1, enforced the requirement that one of the constituent assets was fixed. More recent implementations, such as Uniswap V2, have generally removed this constraint.
This proposal allows complete flexibility in the constituent assets of a liquidity pool. We believe that this enables liquidity to be provided in the manner that is most efficient. For example, providing liquidity between two stablecoins with the same underlying asset can be relatively low risk (assuming both are creditable). But if instead liquidity had to be provided against some fixed third asset, then the liquidity provider would be subject to impermanent loss in both liquidity pools.
Fix assets X and Y. Suppose that p > 0
is the value of Y in terms of X. A
portfolio consisting of x
of X and y
of Y has value x + py
in terms of X.
Fix a real-valued differentiable function f
of two real-valued variables such
that for all p > 0
the system of equations
0 = f(x,y)
df/dy = p df/dx
has a unique solution with x,y > 0
and x' + py' >= x + py
for all (x',y')
satisfying x',y' > 0
and f(x',y') = 0
that are sufficiently near to
(x,y)
. Consider a liquidity pool where the reserves x
of X and y
of Y
are constrained to satisfy f(x,y) = 0
. Then the value of the reserves can be
minimized by the method of Lagrange multipliers. Let z
be a Lagrange
multiplier and define the Lagrangian L(x,y,z) = x + py - z f(x,y)
. This
yields the system of equations
0 = dL/dx = 1 - z df/dx
0 = dL/dy = p - z df/dy
0 = dL/dz = -f(x,y) .
z
can be eliminated by combining the first two equations, which produces
df/dy = p df/dx
. Following from the definition of f
, there exist unique
x,y > 0
that satisfy these equations. Furthermore, (x,y)
is a local minima
subject to the constraints.
This result, while abstract, has important consequences. Depositing to the liquidity pool is equivalent to purchasing a fraction of the pool in exchange for an equal fraction of the assets in reserve. As demonstrated above, depositors get the best price when they deposit at the fair price of the pool. But an attacker can temporarily move the price of the pool, thereby capturing a profit while depositors make a loss. We conclude that any liquidity pool governed by the assumptions above must have bounds on the deposit price to prevent a vulnerability.
It is easy to see that the constant product invariant satisfies the above
conditions. Let f(x,y) = xy - k
for some k > 0
. For all p > 0
the system
of equations
0 = xy - k
x = py
has the unique solution y = sqrt(k/p), x = sqrt(kp)
where x,y > 0
. Now note
that for any (x',y')
satisfying x',y' > 0
and f(x',y') = 0
, the AM-GM
inequality implies
x' + py' = x' + kp/x' >= 2 sqrt(kp) = x + py .
The corrolary to the above results "Price Bounds for LiquidityPoolDepositOp" is that an attacker cannot profit at the expense of a withdrawer, because moving the pool from its fair price actually makes the pool shares more valuable.
But the above analysis only applies if you ignore rounding. With rounding, it is always possible that you receive less than you expect. It is even possible that you receive 0 of one asset. Therefore, we must include minimum withdrawal amounts.
In some jurisdictions, every operation on a blockchain is considered a taxable event. This means that the process of withdrawing all funds in order to deposit a new amount could have extremely adverse tax consequences. Allowing arbitrary withdrawal amounts avoids this issue with little extra complexity.
This proposal stores pool shares in trust lines, without allowing pool shares
to be used in any operation except LiquidityPoolWithdrawOp
and ChangeTrust
.
This means that pool shares already have any associated data that could be
necessary to make them transferable or control authorization, but we do not
enable these features in this proposal.
There are good reasons that pool shares should be transferable, most notably that this would facilitate using them as collateral in lending. Stellar has very limited support for lending, so this reason is not sufficient to justify the effort of supporting transferability at this point. This proposal is designed such that transferability could easily be added in a future protocol version.
One of the decisions that will need to be made is what should happen to the authorization state of a pool trustline if one of its corresponding asset trustlines has authorization downgraded. We could either always check the asset trustline's authorization when checking the pool trustline's authorization, or automatically update pool trustlines when the asset trustline changes. We need to make sure a pool trustline cannot be transferred to and redeemed by an account that has a deauthorized trustline to one of the assets in the pool.
ChangeTrustOp
creates trust lines for pool shares with no flags set. Right
now, pool shares aren't transferable so the set of possible interactions is
limited. LiquidityPoolDepositOp
should be able to mint pool shares if the
account is fully authorized for both constituent assets; this is analogous to
needing to be fully authorized to create an offer. LiquidityPoolWithdrawOp
should be able to burn pool shares if the account is authorized to maintain
liabilities for both constituent assets; this is analogous to being able to
remove an offer when authorized to maintain liabilities. The pool share trust
line never exists if both constituent assets are not at least authorized to
maintain liabilities, so LiquidityPoolWithdrawOp
cannot actually fail due to
insufficient authorization.
If pool shares become transferable in future protocol versions, we can derive the actual authorization state of the pool trust line from the asset trust lines.
Pool withdrawals are allowed when asset trust lines have AUTHORIZED_TO_MAINTAIN_LIABILITIES_FLAG set
It would be unfair to lock an account's funds in a Liquidity Pool when they no
longer want to be a part of one. It's currently possible for an account to pull
offers in the AUTHORIZED_TO_MAINTAIN_LIABILITIES_FLAG
state, so it makes sense
to treat pool shares the same. An account in this state can withdraw from a pool,
but will not be able to do anything else with those funds since operations like
payments check authorization.
There are no operations that clawback directly from a pool, but the same
results can be achieved by using SetTrustlineFlagsOp
or AllowTrustOp
. The
issuer can deauthorize an asset trust line, which will redeem the all pool
trust lines using that asset and account back to the owner account if
possible, and if not, into a claimable balance. The issuer can then use
ClawbackOp
or ClawbackClaimableBalanceOp
to clawback the assets.
The current proposal forces a redemption of all referenced pool trust lines when an asset trust line has its authorization revoked. There is an alternative to this solution. We could instead require the issuer to revoke authorization on individual pool trust lines to force a redemption. This approach will require the issuer to look up all pool trust lines for an asset trust line to perform the revoke. It would also add an additional step when regulating assets for the issuer, so we would need to add an opt in flag for liquidity pools so issuers are aware of this.
This approach would be simpler to implement, and we wouldn't need to tie the lifetime of an asset trust line to a pool trust line like in this proposal, but it is not as user-friendly as the current proposal.
This proposal introduces TrustLineEntryExtensionV2.liquidityPoolUseCount
, which
keeps track of the number of pool trust lines an asset trust line is used
in. The extension is used to make sure the trust line is not deleted while
corresponding pool trust lines exist. This is required because without the
asset trust line, there is no way to deauthorize the trust line and force a
redemption of the related pool shares.
This mechanism is not required for the native asset since a trust line is not required to hold the native asset, and the native asset is trustless.
This proposal uses PathPaymentStrictSendOp
and PathPaymentStrictReceiveOp
as
opaque interfaces to exchange on the Stellar network. These operations are
referred to as opaque interfaces because there is no way to specify how you want
the exchange to execute. This approach is favorable because it requires no
changes to clients that depend on exchange.
Because this is an opaque interface, the only thing we should absolutely require is that adding liquidity pools as an execution option should never make the exchange price worse. This is a weak requirement. Specifically it is much weaker than requiring that exchange produces the best price.
The primary reason not to require that exchange produces the best price is ease of implementation. Requiring that exchange always produces the best price automatically implies execution that is interleaved between the order book and any liquidity pools. In other words, the exchange might cross some offers and also exchange with liquidity pools. Compare against the simpler implementation where an exchange will execute against either
- the order book alone (this is exactly what happens in protocol 16)
- one specific liquidity pool alone
depending on which produces the better price. This price is not guaranteed to be the best possible price, but by construction it cannot be worse than executing against the order book alone.
It is important to recognize that this happens on each step in the path, so
no interleaving is a local statement not a global statement. It is definitely
possible that a path payment will exchange with a liquidity pool on one step and
the order book on another step. A particularly surprising case occurs when there
is a path with an internal loop such as A -> B -> C -> A -> B
, in which case
the first A -> B
may use one venue and the second A -> B
may use the other
venue.
There are a variety of objections to this approach, but none of them justifies the additional complexity of interleaved execution. It is claimed that interleaved execution guarantees that there will be no arbitrage opportunities after exchange. This is true when considering only the order book and liquidity pools involving the same assets, but false otherwise. If there are linear combinations of assets which are effectively risk-free, then exchange will still generate arbitrage opportunities even if interleaved execution is used.
A concrete example where this could occur is if there were two highly creditable issuers of USD. Let's call the assets USD1 and USD2. Suppose a large exchange occurs from USD1 to EUR. With interleaved execution, there are no arbitrage opportunities between the order book and liquidity pools for USD1/EUR. But it could still be possible to sell USD1 for EUR, then buy USD2 for EUR at a profit. If there is a USD1/USD2 market then the arbitrageur may be able to settle their position instantly. Otherwise, the arbitrageur may wait until the opposite arbitrage opportunity arises to unwind their position.
There is also the reality that if the price of a liquidity pool has moved enough to generate an arbitrage opportunity with the order book or another liquidity pool, then it has probably moved enough to generate an arbitrage opportunity with some centralized exchange.
This proposal does not change the behavior of CreatePassiveOfferOp
,
ManageBuyOfferOp
, and ManageSellOfferOp
. This is a consequence of not
enforcing best pricing and interleaved execution. The Stellar protocol does not
permit the order book to be crossed, so any order that is modified by these
operations must execute against the order book.
A pleasant side-effect of this is that these operations are to the order book
as LiquidityPoolDepositOp
and LiquidityPoolWithdrawOp
are to the liquidity
pools, in the sense that they are the only way to change liquidity provided to
those venues.
In order to enable PathPaymentStrictSendOp
and PathPaymentStrictReceiveOp
to
emit accurate information about exchanges with liquidity pools, we converted
ClaimOfferAtom
into a union named ClaimAtom
. Even though ClaimAtom
is not
required for CreatePassiveOfferOp
, ManageBuyOfferOp
, and ManageSellOfferOp
,
we still make the analogous change to the corresponding operation results. This
should allow downstream systems to handle both results in the same way.
Other proposals include a minimum time that funds must be deposited in a liquidity pool before they can be withdrawn. The argument is that this will help ensure stability of liquidity, avoiding fluctuations as volume moves between different pairs.
This proposal does not include a minimum deposit time. The primary argument is that liquidity fluctuations are the direct manifestation of liquidity providers trying to deploy their capital in the most profitable way. But this means that capital is being deployed where there is the most demand for liquidity and the least supply of it, as that is where liquidity pools generate the most profit. This is exactly the kind of behavior that we want to encourage on the Stellar network.
There is a second argument for not including a minimum deposit time. A minimum deposit time is friction, as it inhibits people from using their money the way that they want to. Modern payment networks, like Stellar, should be trying to remove friction rather than create it.
This proposal fixes the constant product market maker fee at 0.3%. But we expect future protocol versions to take advantage of the extensibility which has been built into this proposal to support other fees. Such changes should be relatively easy to implement.
This proposal introduces one backwards incompatibility. Clients that depend on
PathPaymentStrictSendOp
and PathPaymentStrictReceiveOp
executing against the
order book will be broken. There are two ways a client could depend on this
- Expecting to receive a certain price
- Expecting to execute certain orders
In both cases, the client must control the state of the order book in order to realize these expectations. But if a client used to control the state of the order book, then they would also control the state of liquidity pools according to this proposal. Therefore, the risk of this backwards incompatibility is minimal.
This proposal should have a minor effect on resource utilization. Converting
PathPaymentStrictSendOp
and PathPaymentStrictReceiveOp
into opaque
interfaces for exchange will slightly increase the constant factors associated
with these operations but will not effect the asymptotic complexity.
This proposal does not introduce any new security concerns.
None yet.
None yet.