Skip to content

Latest commit

 

History

History
1442 lines (1275 loc) · 57.1 KB

cap-0038.md

File metadata and controls

1442 lines (1275 loc) · 57.1 KB

Preamble

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

Simple Summary

Automated market makers provide a simple way to provide liquidity and exchange assets.

Working Group

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

Motivation

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.

Goals Alignment

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.

Abstract

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.

Specification

XDR changes

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:

Semantics

LiquidityPoolDepositOp

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 to lpdo.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

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

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, return CHANGE_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.
    • The pool share trust line tl has tl.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 and liquidityPoolUseCount is incremented on each asset trust line. Note that poolSharesTrustLineCount 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.
  • If pool share trust line is being deleted

    • poolSharesTrustLineCount is decremented on the corresponding liquidity pool, and liquidityPoolUseCount is decremented on each asset trust line.
    • If poolSharesTrustLineCount on the corresponding liquidity pool becomes 0, then that liquidity pool is erased.

SetTrustLineFlagsOp and AllowTrustOp

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

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 and Y are the initial reserves of the liquidity pool
  • F is the fee charged by the liquidity pool
  • x is the amount received by the liquidity pool
  • y is the amount disbursed by the liquidity pool

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

RevokeSponsorshipOp

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.

Ledger Header Flags

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 pools
  • DISABLE_LIQUIDITY_POOL_DEPOSIT_FLAG: disable depositing into liquidity pools
  • DISABLE_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.

Design Rationale

Erasing the Liquidity Pool

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.

No LiquidityPoolEntry Reserve

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.

TrustLineEntry with asset of type ASSET_TYPE_POOL_SHARE takes two base reserves

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.

Claimable Balance is not created on authorization revocation if the claimant is the issuer

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.

Authorization revocation of an asset trust line in a pool can fail

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 -

  1. Account A has a pool share trust line.
    • On revoke, claimable balances are sponsored by A. Guaranteed to succeed.
  2. 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.
  3. 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.
  4. 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.

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.

Liquidity Pools Support Arbitrary Asset Pairs

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.

Price Bounds for LiquidityPoolDepositOp

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 .

Price Bounds for LiquidityPoolWithdrawOp

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.

LiquidityPoolWithdrawOp Specifies a Withdrawal Amount

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.

Store Pool Shares in Trust Lines

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.

Pool Shares are not Transferable

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.

Trust Lines for Pool Shares do not have any flags set

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.

Clawback assets from a pool

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.

Alternative authorization revocation solution

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.

Why the asset trust line is required to exist for the lifetime of corresponding pool trust lines

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.

No Interleaved Execution

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.

Residual Arbitrage Opportunities

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.

CreatePassiveOfferOp, ManageBuyOfferOp, and ManageSellOfferOp Unchanged

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.

ClaimAtom

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.

No Minimum Deposit Time

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.

Future Work: Support Fees Other than 0.3%

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.

Protocol Upgrade Transition

Backwards Incompatibilities

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.

Resource Utilization

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.

Security Concerns

This proposal does not introduce any new security concerns.

Test Cases

None yet.

Implementation

None yet.