diff --git a/CHANGES.md b/CHANGES.md index 7eded054..ab1a977b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -49,6 +49,16 @@ The raw-response work, all five levels landing together. The problem: a consumer The entries below are grouped by what changed, not by the order the levels were built in. The short version of what will not compile is in the table above. +* **AMM deposit and withdrawal arithmetic, taken from rippled rather than from the formula that circulates** (#133). The SDK offered no way to work out what an `AMMDeposit` would credit before submitting it, so consumers reached for the widely quoted `T·(√(1 + b·(1 − f/2)/B) − 1)`. That formula is the right one with the fee applied loosely, and it is exact wherever there is no fee - which is what makes it hard to catch. At a 1% fee it credits **0.41244·T** where the node credits **0.41213·T**: out by 0.08%, always in the direction that promises more tokens than arrive. + * `Xrpl.Sugar.AmmMath` is static and needs no client: `LPTokensForSingleAssetDeposit`, `LPTokensForSingleAssetWithdraw`, `LPTokensForProportionalDeposit`, `AssetsForProportionalDeposit`, `AssetsForProportionalWithdraw`, plus `TradingFeeFraction` and `DiscountedTradingFee` with the `TradingFeeScale` (100 000) and `AuctionSlotFeeDiscount` (10) constants behind them + * the single-asset pair are equations 3 and 7 from rippled's `AMMHelpers.cpp` - `lpTokensOut` and `lpTokensIn` - transcribed rather than derived. The two are not symmetric, and the asymmetry is easy to get backwards: `lpTokensIn` multiplies by the fee where `lpTokensOut` multiplies by `1 − fee`. Swapping them still satisfies the round-trip inequality, which is too loose to notice, so the pair is pinned by the zero-fee identity instead: with no fee the two equations must invert each other exactly, and with the multipliers swapped they miss by a wide margin + * **the auction slot is the trap that makes correct formulas look wrong.** Its holder trades at `DiscountedFee`, a tenth of the pool's fee, and `AMMCreate` hands the slot to whoever created the pool - so the account most likely to be estimating is the one the pool's fee is wrong for. Estimating at the pool's fee in the integration test was out by 0.23%, three times the error of the approximation this replaces, with every equation right. Read the fee from `amm_info`'s auction slot when the account holds it + * **the swap, and the inverse of each equation.** `SwapAssetIn`/`SwapAssetOut` are rippled's own, equation (2) in `AMMHelpers.h`: a payment routed through a pool takes the fee off the input before the curve sees it, which is not the same number as taking it off the output. `SingleAssetDepositForLPTokens` and `SingleAssetWithdrawForLPTokens` are equations 4 and 8 - what an `AMMDeposit` carrying `LPTokenOut` will cost, and what an `AMMWithdraw` carrying `LPTokenIn` returns. Each pair composes to the identity, which is what pins the two derived through a quadratic + * units are the caller's and nothing converts between them, which is now said out loud because it bites: `amm_info` reports the XRP side of a pool in **drops**, so a balance read from it and an amount a caller thinks of in XRP are a million apart, and mixing them reads as a broken formula rather than a unit mistake + * a fee in the wrong units is refused rather than answered. `TradingFee` is in units of 1/100 000 and rippled caps it at 1000 (`kTradingFeeThreshold`, now `AmmMath.TradingFeeThreshold`); reaching for basis points or whole per cent is out by a factor of ten or a hundred, and the arithmetic notices nothing - at 5000 every intermediate value stays finite and a plausible wrong number comes back. The bound is on the fee itself, so it holds even for an amount of zero + * what comes back is a bound rather than the exact credit, and the direction is documented. Under `fixAMMv1_3` rippled rounds the final multiplication against the caller both ways - `lpTokensOut` downward, `lpTokensIn` upward - so a deposit is credited this much or a shade less and a withdrawal costs this much or a shade more. It lands in the last of `STAmount`'s 15 significant digits + * `decimal` throughout, and a square root written for it. `Math.Sqrt` carries 15 significant digits against `decimal`'s 28, and the root is the one step where the formulas need the precision + * checked against a node, not only against the source: five integration tests put deposits, withdrawals and a payment routed through the pool on the standalone stand and compare the estimate with what the node actually moved. Relative errors from 9.7e-17 (the swap) to 6.2e-14 - the precision the node reports balances at. That is the measurement; what the tests enforce is 1e-9, because the figures compared are differences of two reported balances and pinning them to the last digit would buy brittleness rather than coverage. The swap test also covers the one case the others cannot: an account that does **not** hold the auction slot, and therefore trades at the pool's own fee. Unit tests can only prove a formula was copied faithfully - a faithful copy of the wrong equation passes all of them * **`nft_info` and `nft_history`, the two Clio commands NFT work needs** (#132). Neither had a model, and neither has a substitute on a rippled node. * **an owner cannot be read out of `nft_sell_offers`**, which is the natural guess. Selling a token does not remove offers for it from the ledger, so offers made by a previous owner keep being returned long after they can no longer be accepted, and the new owner has usually made none - which is exactly the state a token is in right after being bought. Taking the owner from the first offer shows the wrong account * field names were taken from Clio's own handlers rather than from documentation, and one of them differs: Clio emits `nft_serial`, and its own source notes that the docs call it `nft_sequence`. A test pins the name that arrives on the wire diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs new file mode 100644 index 00000000..549c6037 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs @@ -0,0 +1,451 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; +using Xrpl.Sugar; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// credits what the node credits - issue #133. +/// +/// +/// +/// The unit tests prove the formulas were transcribed correctly from rippled's +/// AMMHelpers.cpp. They cannot prove they are the right formulas: a faithful copy of the +/// wrong equation passes every one of them. Only a node settles that, so this deposits into a real +/// pool and compares the estimate with what was actually credited. +/// +/// +/// The comparison is tight on purpose. The approximation this class replaces is out by 0.08%, so a +/// loose tolerance would pass with the wrong formula and prove nothing; the bound here is five +/// orders of magnitude tighter than that error. +/// +/// +/// It is not tighter still, and the gap is deliberate. What these tests compare is the difference +/// of two reported LP token balances, so the last of STAmount's 15 significant digits is +/// lost to cancellation before the comparison happens; the measured agreement is around 1e-15, and +/// asserting anywhere near that would buy brittleness rather than coverage. At 1e-9 there are six +/// orders of margin over what is measured and five over the error being guarded against. +/// +/// +/// These tests also demonstrate the trap the report names first, because the first version of them +/// fell into it: AMMCreate hands the auction slot to the pool's creator, so the account +/// depositing here trades at DiscountedFee - a tenth of the pool's fee - and the node +/// credits it accordingly. Estimating at the pool's fee was out by 0.23%, three times the error of +/// the approximation this class exists to replace, with correct formulas throughout. Each test +/// asserts both halves: the right fee matches, and the pool's fee visibly does not. +/// +/// +[TestClass] +public class TestIAmmMathAgainstTheNode : TestIAMMBase +{ + private static IXrplClient client; + protected override IXrplClient GetClient() => client; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestMethod] + public async Task TestIASingleAssetDepositIsCreditedAsEstimated() + { + await CreatePool(); + + // Read immediately before the calculation, not once at the start: an estimate run against a + // stale pool drifts in a way that looks exactly like an error in the arithmetic. That is one + // of the two things the report names as making a correct formula appear wrong. + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint poolFee = before.Amm.TradingFee; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal Deposit = 500m; + + decimal estimated = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance, + Deposit, + lpBefore, + effectiveFee); + decimal atPoolFee = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance, + Deposit, + lpBefore, + poolFee); + + AMMDeposit deposit = new AMMDeposit + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = Deposit.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + Flags = AMMDepositFlags.tfSingleAsset, + }; + + ITransactionRequest autofilled = await client.Autofill(deposit); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMDeposit single asset"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal credited = after.Amm.LPTokenBalance.ValueAsNumber - lpBefore; + + decimal relativeError = Math.Abs(credited - estimated) / credited; + + Console.WriteLine( + $"pool {poolBalance}, pool fee {poolFee}, effective fee {effectiveFee}, deposit {Deposit}: " + + $"estimated {estimated}, credited {credited}, relative error {relativeError}"); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {credited} actually credited - a relative error of " + + $"{relativeError}, against a bound of 1e-9. The approximation this class exists to " + + $"replace is out by 8e-4, so anything near that means the wrong equation was used."); + + Assert.AreNotEqual( + poolFee, + effectiveFee, + "This account holds the auction slot, which is what makes the second assertion mean something."); + Assert.IsTrue( + Math.Abs(atPoolFee - credited) / credited > 0.001m, + $"Estimating at the pool's fee instead of the slot holder's must be visibly wrong, and is: " + + $"{atPoolFee} against {credited}."); + } + + /// + /// And the withdrawal side, which the unit tests can only pin through an identity. + /// + [TestMethod] + public async Task TestIASingleAssetWithdrawCostsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint poolFee = before.Amm.TradingFee; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal Withdraw = 100m; + + decimal estimated = AmmMath.LPTokensForSingleAssetWithdraw( + poolBalance, + Withdraw, + lpBefore, + effectiveFee); + + AMMWithdraw withdraw = new AMMWithdraw + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = Withdraw.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + Flags = AMMWithdrawFlags.tfSingleAsset, + }; + + ITransactionRequest autofilled = await client.Autofill(withdraw); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMWithdraw single asset"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal spent = lpBefore - after.Amm.LPTokenBalance.ValueAsNumber; + + decimal relativeError = Math.Abs(spent - estimated) / spent; + + Console.WriteLine( + $"pool {poolBalance}, pool fee {poolFee}, effective fee {effectiveFee}, withdraw {Withdraw}: " + + $"estimated {estimated}, spent {spent}, relative error {relativeError}"); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {spent} actually spent - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// Equation 4: asking the node for an exact number of LP tokens costs what it says it costs. + /// + /// + /// The other direction of the deposit, and the one the unit tests can only reach through an + /// identity. tfOneAssetLPToken names the tokens wanted and lets the node work out the + /// asset, with Amount as a ceiling rather than the figure - so what is compared here is + /// what the node decided to take. + /// + [TestMethod] + public async Task TestIADepositForExactTokensCostsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal WantTokens = 50m; + + decimal estimated = AmmMath.SingleAssetDepositForLPTokens( + poolBalance, + WantTokens, + lpBefore, + effectiveFee); + + AMMDeposit deposit = new AMMDeposit + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + + // A ceiling, deliberately far above the estimate: if the node were to spend all of it + // the comparison below would fail loudly instead of being satisfied by construction. + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "500", + }, + LPTokenOut = LpTokens(before, WantTokens), + Flags = AMMDepositFlags.tfOneAssetLPToken, + }; + + ITransactionRequest autofilled = await client.Autofill(deposit); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMDeposit one asset for LP tokens"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal taken = after.Amm.Amount.ValueAsNumber - poolBalance; + decimal credited = after.Amm.LPTokenBalance.ValueAsNumber - lpBefore; + + decimal relativeError = Math.Abs(taken - estimated) / taken; + + Console.WriteLine( + $"pool {poolBalance}, effective fee {effectiveFee}, wanted {WantTokens} tokens: " + + $"estimated {estimated}, taken {taken}, credited {credited}, relative error {relativeError}"); + + Assert.AreEqual( + WantTokens, + credited, + "tfOneAssetLPToken credits exactly what was asked for; if it did not, the comparison below is measuring something else."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated a cost of {estimated} against {taken} actually taken - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// Equation 8: redeeming an exact number of LP tokens returns what it says it returns. + /// + /// + /// Amount is a floor here rather than a ceiling, so it is set low enough not to bind + /// and the node's own figure is what gets compared. + /// + [TestMethod] + public async Task TestIAWithdrawForExactTokensReturnsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal RedeemTokens = 50m; + + decimal estimated = AmmMath.SingleAssetWithdrawForLPTokens( + poolBalance, + RedeemTokens, + lpBefore, + effectiveFee); + + AMMWithdraw withdraw = new AMMWithdraw + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "0.000001", + }, + LPTokenIn = LpTokens(before, RedeemTokens), + Flags = AMMWithdrawFlags.tfOneAssetLPToken, + }; + + ITransactionRequest autofilled = await client.Autofill(withdraw); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMWithdraw one asset for LP tokens"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal received = poolBalance - after.Amm.Amount.ValueAsNumber; + decimal spent = lpBefore - after.Amm.LPTokenBalance.ValueAsNumber; + + decimal relativeError = Math.Abs(received - estimated) / received; + + Console.WriteLine( + $"pool {poolBalance}, effective fee {effectiveFee}, redeemed {RedeemTokens} tokens: " + + $"estimated {estimated}, received {received}, spent {spent}, relative error {relativeError}"); + + Assert.AreEqual(RedeemTokens, spent, "The node should burn exactly the tokens it was given."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {received} actually returned - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// The swap: a payment routed through the pool pays out what + /// says it will. + /// + /// + /// + /// Nobody swaps by sending an AMMDeposit; a swap reaches the pool as a payment, which + /// is why this one goes through Payment rather than an AMM transaction. With + /// tfPartialPayment and a destination amount the pool cannot possibly cover, the whole + /// of SendMax goes in and whatever the curve gives comes out - which is exactly the + /// quantity the formula computes. + /// + /// + /// The account swapping here is a second holder, not the one that created the pool, so this + /// is the one case in this class that trades at the pool's own fee rather than at the auction + /// slot's discount. The other tests cover the discounted path; between them both branches of + /// are exercised against a node. + /// + /// + /// The arithmetic below is in drops, because that is the unit amm_info reports the XRP + /// side of a pool in. converts nothing, so mixing drops with XRP in one + /// call produces a number that looks like a broken formula rather than a unit mistake - the + /// first draft of this test did exactly that. + /// + /// + [TestMethod] + public async Task TestIASwapThroughThePoolPaysOutAsEstimated() + { + await CreatePool(); + XrplWallet swapper = await SetupSecondHolder(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolToken = before.Amm.Amount.ValueAsNumber; + decimal poolXrpDrops = before.Amm.Amount2.ValueAsNumber; + uint effectiveFee = FeeFor(before, swapper.ClassicAddress); + + Assert.AreEqual( + before.Amm.TradingFee, + effectiveFee, + "The swapper does not hold the auction slot, so this must be the pool's own fee."); + + const decimal SendXrp = 1m; + const decimal SendDrops = SendXrp * 1_000_000m; + + // The pool takes XRP and gives the token back. + decimal estimated = AmmMath.SwapAssetIn(poolXrpDrops, poolToken, SendDrops, effectiveFee); + + Payment payment = new Payment + { + Account = swapper.ClassicAddress, + Destination = walletHolder.ClassicAddress, + + // Far more than one XRP can buy, so SendMax is what binds and the whole of it is spent. + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "1000", + }, + SendMax = new Currency { ValueAsXrp = SendXrp }, + DeliverMin = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "0.000001", + }, + Flags = PaymentFlags.tfPartialPayment, + }; + + ITransactionRequest autofilled = await client.Autofill(payment); + TransactionSummary result = await client.SubmitAndWait(autofilled, swapper, true); + AssertSuccess(result, "Payment routed through the AMM"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal spentDrops = after.Amm.Amount2.ValueAsNumber - poolXrpDrops; + decimal received = poolToken - after.Amm.Amount.ValueAsNumber; + + decimal relativeError = Math.Abs(received - estimated) / received; + + Console.WriteLine( + $"pool {poolToken} token / {poolXrpDrops} drops, fee {effectiveFee}, sent {spentDrops} drops: " + + $"estimated {estimated}, paid out {received}, relative error {relativeError}"); + + Assert.AreEqual( + SendDrops, + spentDrops, + "The whole of SendMax should have entered the pool; if it did not, this is measuring a smaller swap than it estimated."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} out of the pool against {received} actually paid - a relative " + + $"error of {relativeError}, against a bound of 1e-9."); + } + + /// + /// The pool's LP token, as an amount this many of them. + /// + /// + /// The currency code is a hash of the two assets and the issuer is the AMM's own account, so + /// both are read off amm_info rather than constructed. + /// + private static Currency LpTokens(AMMInfoResponse info, decimal count) => new Currency + { + CurrencyCode = info.Amm.LPTokenBalance.CurrencyCode, + Issuer = info.Amm.LPTokenBalance.Issuer, + Value = count.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + /// + /// The fee this account actually trades at, which is not the pool's fee if it holds the slot. + /// + /// + /// What a consumer has to do too, and the reason + /// exists. The node's own discounted_fee is used rather than computed, and checked + /// against what the SDK would have computed - if those ever disagree, the SDK's constant is + /// wrong and this says so. + /// + private static uint FeeFor(AMMInfoResponse info, string account) + { + AuctionSlot slot = info.Amm.AuctionSlot; + if (slot is null || !string.Equals(slot.Account, account, StringComparison.Ordinal)) + { + return info.Amm.TradingFee; + } + + Assert.AreEqual( + slot.DiscountedFee, + AmmMath.DiscountedTradingFee(info.Amm.TradingFee), + "The SDK's idea of the auction slot discount must be the node's."); + + return slot.DiscountedFee; + } +} diff --git a/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs b/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs new file mode 100644 index 00000000..866c4c1f --- /dev/null +++ b/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs @@ -0,0 +1,454 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; + +using Xrpl.Sugar; + +namespace XrplTests.Xrpl.Sugar; + +/// +/// AMM deposit and withdrawal arithmetic, checked against what rippled computes - issue #133. +/// +/// +/// The formulas are equations 3 and 7 from rippled's AMMHelpers.cpp, and the point of taking +/// them from there rather than from the widely quoted approximation is measurable: see +/// . +/// +[TestClass] +public class TestUAmmMath +{ + /// + /// The figure from the report, and the reason this class exists. + /// + /// + /// A deposit the size of the pool at a 1% fee. rippled credits 0.41213·T; the formula + /// that circulates for this, T·(√(1 + b·(1 − f/2)/B) − 1), says 0.41244·T. + /// + [TestMethod] + public void TestUASingleAssetDepositMatchesTheNodesFigure() + { + decimal tokens = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance: 1_000_000m, + deposit: 1_000_000m, + lpTokenBalance: 1_000_000m, + tradingFee: 1000); + + decimal asFractionOfT = tokens / 1_000_000m; + + Assert.AreEqual( + 0.41213m, + Math.Round(asFractionOfT, 5), + $"Equation 3 gives 0.41213·T for this pool; got {asFractionOfT}"); + } + + /// + /// The approximation this replaces, shown to be wrong rather than asserted to be. + /// + /// + /// Worth its own test because the two agree closely enough that a spot check does not tell them + /// apart - 0.08% here - and because the error is always in the same direction: the + /// approximation credits more tokens than the node will. + /// + [TestMethod] + public void TestUTheCirculatingApproximationIsWrongOnceThereIsAFee() + { + const decimal Pool = 1_000_000m; + const uint Fee = 1000; // one per cent + + decimal exact = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, Fee) / Pool; + + // T * (sqrt(1 + b*(1 - f/2)/B) - 1), with b = B + decimal f = AmmMath.TradingFeeFraction(Fee); + decimal approximate = AmmMath.Sqrt(1m + (1m - f / 2m)) - 1m; + + Assert.AreEqual(0.41213m, Math.Round(exact, 5)); + Assert.AreEqual(0.41244m, Math.Round(approximate, 5)); + Assert.IsTrue( + approximate > exact, + "The approximation overstates the credit, which is the direction that disappoints a caller."); + } + + /// + /// Without a fee the two agree exactly, which is where the approximation came from. + /// + /// + /// This is what makes the difference easy to miss: the approximation is not a rough model of + /// the wrong thing, it is the right formula with the fee handled loosely, so it is exact + /// wherever there is no fee to handle. + /// + [TestMethod] + public void TestUWithoutAFeeTheTwoAgree() + { + const decimal Pool = 1_000_000m; + + decimal exact = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, tradingFee: 0) / Pool; + decimal approximate = AmmMath.Sqrt(2m) - 1m; + + Assert.AreEqual(Math.Round(approximate, 20), Math.Round(exact, 20)); + } + + /// + /// The auction slot holder trades at a tenth of the pool's fee, and is credited accordingly. + /// + /// + /// One of the two things the report names as making an otherwise correct estimate miss: the + /// node computes the slot holder's deposits and withdrawals at DiscountedFee, so an + /// estimate at the pool's fee is wrong for exactly the account most likely to be doing the + /// estimating. + /// + [TestMethod] + public void TestUTheAuctionSlotHolderIsCreditedAtTheDiscountedFee() + { + const decimal Pool = 1_000_000m; + + Assert.AreEqual(100u, AmmMath.DiscountedTradingFee(1000), "A tenth of the pool's fee."); + + decimal atPoolFee = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, 1000); + decimal atSlotFee = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, AmmMath.DiscountedTradingFee(1000)); + + Assert.IsTrue( + atSlotFee > atPoolFee, + "A smaller fee means more tokens for the same deposit; estimating at the pool's fee shortchanges the slot holder."); + } + + /// + /// A withdrawal costs at least what the same deposit earned, and more once there is a fee. + /// + /// + /// Equations 3 and 7 are separate formulas, and a transcription error in either would most + /// likely show up as this invariant breaking - putting an asset in and taking the same amount + /// straight back out cannot be free, or the pool could be drained by doing it repeatedly. + /// + [TestMethod] + public void TestUARoundTripCostsTheFee() + { + const decimal Pool = 1_000_000m; + const decimal Amount = 100_000m; + const uint Fee = 1000; + + decimal earned = AmmMath.LPTokensForSingleAssetDeposit(Pool, Amount, Pool, Fee); + decimal spent = AmmMath.LPTokensForSingleAssetWithdraw(Pool + Amount, Amount, Pool + earned, Fee); + + Assert.IsTrue( + spent > earned, + $"Depositing and withdrawing the same amount must cost the fee, but earned {earned} and spent {spent}."); + } + + /// + /// Without a fee the round trip is exactly neutral, which is what pins equation 7. + /// + /// + /// + /// Deposit b into a pool of B, then take the same b straight back out of + /// the pool it has become. With no fee to pay, that must return precisely the tokens it earned + /// - the pool ends where it started, so the LP must too. + /// + /// + /// This is here because a mutation survived without it. Equation 7 multiplies by the fee where + /// equation 3 multiplies by 1 − fee - rippled's lpTokensIn calls getFee + /// where lpTokensOut calls feeMult - and swapping the two passed every other test + /// here, including the round-trip inequality, which is too loose to notice. The identity below + /// is not: with the wrong multiplier the two sides stop matching by a wide margin. + /// + /// + [TestMethod] + public void TestUWithoutAFeeTheRoundTripIsExactlyNeutral() + { + const decimal Pool = 1_000_000m; + const decimal Amount = 250_000m; + const decimal Tokens = 1_000_000m; + + decimal earned = AmmMath.LPTokensForSingleAssetDeposit(Pool, Amount, Tokens, tradingFee: 0); + decimal spent = AmmMath.LPTokensForSingleAssetWithdraw( + Pool + Amount, + Amount, + Tokens + earned, + tradingFee: 0); + + Assert.AreEqual( + Math.Round(earned, 18), + Math.Round(spent, 18), + $"With no fee the two equations must invert each other exactly; earned {earned}, spent {spent}."); + } + + [TestMethod] + public void TestUAProportionalDepositIsLimitedByWhicheverAssetRunsOutFirst() + { + decimal tokens = AmmMath.LPTokensForProportionalDeposit( + poolBalance1: 1_000m, + poolBalance2: 4_000m, + deposit1: 100m, // a tenth of the pool + deposit2: 200m, // a twentieth, and therefore the limit + lpTokenBalance: 2_000m); + + Assert.AreEqual(100m, tokens, "frac is the smaller of the two ratios: 200/4000 = 0.05."); + + (decimal asset1, decimal asset2) = AmmMath.AssetsForProportionalDeposit(1_000m, 4_000m, 100m, 200m); + + Assert.AreEqual(50m, asset1, "Only half of what was offered on the first asset is taken."); + Assert.AreEqual(200m, asset2, "All of the limiting one."); + } + + [TestMethod] + public void TestUAProportionalWithdrawReturnsBothSidesAtTheSameFraction() + { + (decimal asset1, decimal asset2) = AmmMath.AssetsForProportionalWithdraw( + poolBalance1: 1_000m, + poolBalance2: 4_000m, + lpTokens: 500m, + lpTokenBalance: 2_000m); + + Assert.AreEqual(250m, asset1); + Assert.AreEqual(1_000m, asset2); + } + + /// + /// The square root keeps more digits than a double one would. + /// + /// + /// The reason the class does its own: carries 15 significant digits and + /// the rest of the arithmetic carries 28, so using it would throw away precision at the one step + /// where the formulas need it most. + /// + [TestMethod] + public void TestUTheSquareRootIsExactToDecimalPrecision() + { + decimal root = AmmMath.Sqrt(2m); + + Assert.IsTrue( + Math.Abs(root * root - 2m) < 0.0000000000000000000000001m, + $"√2 squared came back as {root * root}"); + + double viaDouble = Math.Sqrt(2.0); + Assert.IsTrue( + Math.Abs(root - (decimal)viaDouble) > 0m, + "If this matched the double result exactly there would be no point computing it separately."); + } + + [TestMethod] + public void TestUImpossibleInputsAreRefusedRatherThanReturningNonsense() + { + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(0m, 10m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(100m, -1m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetWithdraw(100m, 101m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.AssetsForProportionalWithdraw(100m, 100m, 101m, 100m)); + } + + /// + /// A fee in the wrong units is refused rather than quietly answered. + /// + /// + /// + /// TradingFee is in units of 1/100 000, and rippled caps it at 1000 - one per cent - in + /// kTradingFeeThreshold. A caller who reaches for basis points or for whole per cent is + /// out by a factor of ten or a hundred, and nothing in the arithmetic notices: at 5000 every + /// intermediate value stays finite and a plausible, wrong number comes back. Only at 100 000 + /// does 1 - fee reach zero and the division fail, and a DivideByZeroException is + /// not what a caller should have to diagnose from. + /// + /// + /// The bound is on the fee itself, so it holds even for an amount of zero - otherwise whether + /// bad input is reported would depend on how much was being deposited. + /// + /// + [TestMethod] + public void TestUAFeeInTheWrongUnitsIsRefused() + { + Assert.AreEqual(1000u, AmmMath.TradingFeeThreshold); + + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetWithdraw(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.TradingFeeFraction(100_000)); + Assert.ThrowsExactly( + () => AmmMath.DiscountedTradingFee(1001)); + + // Not conditional on there being an amount to compute. + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(1_000m, 0m, 1_000m, tradingFee: 5000)); + + // And the cap itself is allowed - an off-by-one here would refuse the highest legal pool. + AmmMath.LPTokensForSingleAssetDeposit(1_000m, 100m, 1_000m, AmmMath.TradingFeeThreshold); + } + + /// + /// Equations 3 and 4 invert each other, which is the only cheap way to pin equation 4. + /// + /// + /// rippled derives equation 4 by solving equation 3 for the deposit, so the two must compose + /// to the identity for any input. That derivation runs through a quadratic, and a sign or a + /// factor lost anywhere in it breaks this while leaving a plausible-looking number behind. + /// + [TestMethod] + public void TestUTheDepositEquationsInvertEachOther() + { + const decimal Pool = 1_000_000m; + const decimal Tokens = 1_000_000m; + + foreach (uint fee in new uint[] { 0, 1, 500, 1000 }) + { + foreach (decimal deposit in new[] { 1m, 1_000m, 250_000m, 1_000_000m }) + { + decimal tokens = AmmMath.LPTokensForSingleAssetDeposit(Pool, deposit, Tokens, fee); + decimal back = AmmMath.SingleAssetDepositForLPTokens(Pool, tokens, Tokens, fee); + + Assert.AreEqual( + Math.Round(deposit, 12), + Math.Round(back, 12), + $"Depositing {deposit} at a fee of {fee} earns {tokens}, which should cost {deposit} to buy back - got {back}."); + } + } + } + + /// + /// And equations 7 and 8, the withdrawal pair. + /// + [TestMethod] + public void TestUTheWithdrawEquationsInvertEachOther() + { + const decimal Pool = 1_000_000m; + const decimal Tokens = 1_000_000m; + + foreach (uint fee in new uint[] { 0, 1, 500, 1000 }) + { + foreach (decimal withdraw in new[] { 1m, 1_000m, 250_000m, 900_000m }) + { + decimal tokens = AmmMath.LPTokensForSingleAssetWithdraw(Pool, withdraw, Tokens, fee); + decimal back = AmmMath.SingleAssetWithdrawForLPTokens(Pool, tokens, Tokens, fee); + + Assert.AreEqual( + Math.Round(withdraw, 12), + Math.Round(back, 12), + $"Withdrawing {withdraw} at a fee of {fee} costs {tokens}, which should return {withdraw} - got {back}."); + } + } + } + + /// + /// Redeeming every token empties the pool, whatever the fee. + /// + /// + /// The input where equation 8's denominator comes closest to zero, and the answer is still + /// exact: at t1 = 1 the fraction is (fee - 1)/(fee - 1). Worth its own test + /// because a formula that is merely close would show it here first. + /// + [TestMethod] + public void TestURedeemingEveryTokenTakesTheWholePool() + { + Assert.AreEqual(1_000m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 500m, 500m, 1000)); + Assert.AreEqual(1_000m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 500m, 500m, 0)); + } + + /// + /// The swap matches the closed form, and the fee is taken before the curve rather than after. + /// + /// + /// Two different things could be called "a 1% fee on a swap of 100": one takes 1 off the input + /// and puts 99 through the curve, the other puts 100 through and takes 1% off the output. + /// rippled does the first, and the two do not agree. + /// + [TestMethod] + public void TestUASwapTakesTheFeeOffTheInputBeforeTheCurve() + { + decimal withoutFee = AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, 0); + Assert.AreEqual(1_000m * 100m / 1_100m, withoutFee, "out = poolOut * in / (poolIn + in)"); + + decimal withFee = AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, 1000); + Assert.AreEqual(1_000m * 99m / 1_099m, withFee, "99 goes through the curve, not 100."); + + decimal feeOnOutput = withoutFee * 0.99m; + Assert.AreNotEqual( + Math.Round(feeOnOutput, 12), + Math.Round(withFee, 12), + "Taking the fee off the output instead gives a different number, and is the natural mistake."); + } + + /// + /// Without a fee the swap leaves the constant product where it found it. + /// + [TestMethod] + public void TestUAFreeSwapPreservesTheInvariant() + { + const decimal In = 1_000m; + const decimal Out = 4_000m; + + decimal received = AmmMath.SwapAssetIn(In, Out, 250m, tradingFee: 0); + + Assert.AreEqual( + Math.Round(In * Out, 8), + Math.Round((In + 250m) * (Out - received), 8), + "k must be unchanged when nothing is charged for the trade."); + } + + /// + /// The two halves of the swap invert each other exactly. + /// + [TestMethod] + public void TestUTheSwapInvertsExactly() + { + foreach (uint fee in new uint[] { 0, 500, 1000 }) + { + decimal received = AmmMath.SwapAssetIn(1_000m, 4_000m, 250m, fee); + decimal cost = AmmMath.SwapAssetOut(1_000m, 4_000m, received, fee); + + Assert.AreEqual( + Math.Round(250m, 12), + Math.Round(cost, 12), + $"At a fee of {fee}, buying back what 250 bought should cost 250 - got {cost}."); + } + } + + /// + /// A constant-product pool cannot be swapped empty, and says so instead of dividing by zero. + /// + [TestMethod] + public void TestUAPoolCannotBeSwappedEmpty() + { + decimal nearly = AmmMath.SwapAssetOut(1_000m, 4_000m, 3_999m, 0); + decimal nearer = AmmMath.SwapAssetOut(1_000m, 4_000m, 3_999.9m, 0); + Assert.IsTrue(nearer > nearly * 9m, $"The cost should climb steeply: {nearly} then {nearer}."); + + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 4_000m, 4_000m, 0)); + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 4_000m, 4_001m, 0)); + } + + /// + /// The fee bound covers everything that charges a fee, not only what it was written for. + /// + [TestMethod] + public void TestUTheFeeBoundCoversTheSwapAndTheInverses() + { + Assert.ThrowsExactly( + () => AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 1_000m, 100m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SingleAssetDepositForLPTokens(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 100m, 1_000m, tradingFee: 5000)); + } + + [TestMethod] + public void TestUSwappingAndRedeemingNothingGivesNothing() + { + Assert.AreEqual(0m, AmmMath.SwapAssetIn(1_000m, 1_000m, 0m, 1000)); + Assert.AreEqual(0m, AmmMath.SwapAssetOut(1_000m, 1_000m, 0m, 1000)); + Assert.AreEqual(0m, AmmMath.SingleAssetDepositForLPTokens(1_000m, 0m, 1_000m, 1000)); + Assert.AreEqual(0m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 0m, 1_000m, 1000)); + } + + [TestMethod] + public void TestUDepositingNothingEarnsNothing() + { + Assert.AreEqual(0m, AmmMath.LPTokensForSingleAssetDeposit(100m, 0m, 100m, 1000)); + Assert.AreEqual(0m, AmmMath.LPTokensForSingleAssetWithdraw(100m, 0m, 100m, 1000)); + } +} diff --git a/Xrpl/Sugar/AmmMath.cs b/Xrpl/Sugar/AmmMath.cs new file mode 100644 index 00000000..203274ca --- /dev/null +++ b/Xrpl/Sugar/AmmMath.cs @@ -0,0 +1,584 @@ +using System; + +namespace Xrpl.Sugar +{ + /// + /// What an AMM pool will do before you ask it to - what a deposit is worth in LP tokens, what a + /// withdrawal costs, what a swap pays out, and each of those read backwards - computed the way + /// rippled computes it. + /// + /// + /// + /// The formulas are equations 3 and 7 from rippled's AMMHelpers.cpp, not the ones that + /// circulate as the "AMM single-sided deposit formula". The circulating one, + /// T·(√(1 + b·(1 − f/2)/B) − 1), is close and always wrong in the same direction: a + /// deposit the size of the pool at a 1% fee gives 0.41244·T against rippled's + /// 0.41213·T, an error of 0.08%. reproduces + /// the node's figure. + /// + /// + /// Two things decide whether an estimate matches what the node actually credits, and neither is + /// in the formulas: + /// + /// + /// Whose fee. The holder of the pool's auction slot trades at + /// DiscountedFee - a tenth of the pool's trading fee - and the node computes their + /// deposits and withdrawals at that rate too. Estimating at the pool's fee is wrong for them; + /// see . + /// How fresh the pool state is. amm_info has to be read + /// immediately before the calculation. Run against a snapshot taken when a screen opened, the + /// drift looks exactly like an error in the arithmetic. + /// + /// + /// What comes back is a bound rather than the exact credit, and the direction is known. Under + /// fixAMMv1_3 rippled rounds the final multiplication against the caller in both + /// directions - lpTokensOut downward ("minimize tokens out"), lpTokensIn upward + /// ("maximize tokens in") - so a deposit is credited this much or a shade less, and a + /// withdrawal costs this much or a shade more. The difference lands in the last of + /// STAmount's 15 significant digits, which is below what differencing two reported LP + /// token balances can resolve. + /// + /// + /// Units are the caller's and nothing here converts between them. That matters most for XRP: + /// amm_info reports the XRP side of a pool in drops, so a balance read from it and an + /// amount the caller is thinking of in XRP are a million apart. Mixing the two in one call + /// returns a number that reads as a broken formula rather than as a unit mistake. + /// + /// + /// Everything is computed in rather than : 28 + /// significant digits against 15. That is also why the square root here is Newton's method - + /// would throw away the precision the rest of the calculation keeps. + /// + /// + public static class AmmMath + { + /// + /// What a TradingFee of 1 is worth as a fraction: 1/100 000, so 1000 is one per cent. + /// + /// + /// rippled's kAuctionSlotFeeScaleFactor. The field is in units of 1/10 of a basis + /// point, which is easy to be out by a factor of ten on. + /// + public const uint TradingFeeScale = 100_000; + + /// + /// How much cheaper the auction slot holder's fee is than the pool's. + /// + /// rippled's kAuctionSlotDiscountedFeeFraction. + public const uint AuctionSlotFeeDiscount = 10; + + /// + /// The largest TradingFee a pool can have: 1000, one per cent. + /// + /// + /// rippled's kTradingFeeThreshold, and the reason a fee is checked against it here. + /// The field is in units of 1/10 of a basis point, so a caller who reaches for basis points + /// or for whole per cent is out by a factor of ten or a hundred - and the arithmetic below + /// would carry on and return a plausible number rather than say so. + /// + public const uint TradingFeeThreshold = 1000; + + /// + /// The trading fee as a fraction of 1. + /// + /// The pool's TradingFee. rippled caps it at 1000 - one per cent - in kTradingFeeThreshold. + /// exceeds . + public static decimal TradingFeeFraction(uint tradingFee) + { + RequireValidTradingFee(tradingFee); + return (decimal)tradingFee / TradingFeeScale; + } + + /// + /// The fee the auction slot holder trades at. + /// + /// + /// Use this in place of the pool's fee when the account holding the slot is the one + /// depositing or withdrawing - the node does, and an estimate at the pool's fee will not + /// match what it credits. + /// + /// The pool's TradingFee. + /// exceeds . + public static uint DiscountedTradingFee(uint tradingFee) + { + RequireValidTradingFee(tradingFee); + return tradingFee / AuctionSlotFeeDiscount; + } + + /// + /// LP tokens credited for depositing one asset only. + /// + /// + /// + /// Equation 3: with f1 = 1 − fee, f2 = (1 − fee/2)/f1 and r = b/B, + /// + /// + /// c = √(f2² + r/f1) − f2 + /// t = T · (r − c) / (1 + c) + /// + /// + /// The node rounds the last multiplication down, so it credits this or a shade less. + /// + /// + /// The plus under the root is deliberate and is what rippled computes. The comment above + /// that equation in AMMHelpers.cpp writes it as √(f2² − b/(B·f1)), but the + /// code uses +, and so does the derivation of equation 4 immediately below it. With + /// a minus the radicand goes negative for ordinary inputs, which settles it. + /// + /// + /// The pool's balance of the asset being deposited, before the deposit. + /// How much of it is being deposited. + /// The pool's LP token balance, before the deposit. + /// The fee this depositor trades at - see . + /// A balance is not positive, the deposit is negative, or the fee exceeds . + public static decimal LPTokensForSingleAssetDeposit( + decimal poolBalance, + decimal deposit, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(deposit, nameof(deposit)); + RequireValidTradingFee(tradingFee); + + if (deposit == 0m) + { + return 0m; + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal f1 = 1m - fee; + decimal f2 = (1m - fee / 2m) / f1; + decimal r = deposit / poolBalance; + decimal c = Sqrt(f2 * f2 + r / f1) - f2; + + return lpTokenBalance * (r - c) / (1m + c); + } + + /// + /// LP tokens spent to withdraw one asset only. + /// + /// + /// Equation 7: with fr = b/B and c = fr·fee + 2 − fee, + /// + /// t = T · (c − √(c² − 4·fr)) / 2 + /// + /// Note that this one uses the fee itself where + /// uses 1 − fee; rippled's lpTokensIn calls getFee rather than + /// feeMult, and the difference is easy to lose when transcribing. + /// The node rounds the last multiplication up here rather than down - both directions go + /// against the caller - so a withdrawal costs this or a shade more. + /// + /// The pool's balance of the asset being withdrawn, before the withdrawal. + /// How much of it is being withdrawn. + /// The pool's LP token balance, before the withdrawal. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative, it exceeds the pool, or the fee exceeds . + public static decimal LPTokensForSingleAssetWithdraw( + decimal poolBalance, + decimal withdraw, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(withdraw, nameof(withdraw)); + RequireValidTradingFee(tradingFee); + + if (withdraw == 0m) + { + return 0m; + } + + if (withdraw > poolBalance) + { + throw new ArgumentOutOfRangeException( + nameof(withdraw), + $"Cannot withdraw {withdraw} from a pool holding {poolBalance}."); + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal fr = withdraw / poolBalance; + decimal c = fr * fee + 2m - fee; + + return lpTokenBalance * (c - Sqrt(c * c - 4m * fr)) / 2m; + } + + /// + /// How much of one asset must be deposited to be credited exactly this many LP tokens. + /// + /// + /// + /// Equation 4, which is rippled solving equation 3 for b. With f1 and + /// f2 as in , t1 = t/T and + /// t2 = 1 + t1: + /// + /// + /// d = f2 - t1/t2 + /// a = 1/t2², b = 2·d/t2 - 1/f1, c = d² - f2² + /// deposit = B · (-b + √(b² - 4ac)) / 2a + /// + /// + /// The root is always real: swept across every fee up to the cap and token ratios from + /// 1e-6 to 1000, the discriminant never falls below 1, so the quadratic cannot hand + /// a negative and rippled's solveQuadraticEq does not guard it + /// either. + /// + /// + /// This is what an AMMDeposit carrying LPTokenOut will actually take from + /// the account, and the direction of the node's rounding reverses here: it maximizes the + /// deposit, so it takes this much or a shade more. That is consistent rather than + /// contrary - every one of these roundings favours the pool. + /// + /// + /// The pool's balance of the asset being deposited. + /// The LP tokens wanted. + /// The pool's LP token balance, before the deposit. + /// The fee this depositor trades at - see . + /// A balance is not positive, the token amount is negative, or the fee exceeds . + public static decimal SingleAssetDepositForLPTokens( + decimal poolBalance, + decimal lpTokens, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + RequireValidTradingFee(tradingFee); + + if (lpTokens == 0m) + { + return 0m; + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal f1 = 1m - fee; + decimal f2 = (1m - fee / 2m) / f1; + decimal t1 = lpTokens / lpTokenBalance; + decimal t2 = 1m + t1; + decimal d = f2 - t1 / t2; + decimal a = 1m / (t2 * t2); + decimal b = 2m * d / t2 - 1m / f1; + decimal c = d * d - f2 * f2; + + return poolBalance * SolveQuadratic(a, b, c); + } + + /// + /// How much of one asset comes out for redeeming exactly this many LP tokens. + /// + /// + /// + /// Equation 8, rippled solving equation 7 for b. With t1 = t/T: + /// + /// + /// withdraw = B · (t1² - t1·(2 - fee)) / (t1·fee - 1) + /// + /// + /// Both halves of that fraction are negative for any real input, which is why the result + /// is not. What an AMMWithdraw carrying LPTokenIn pays out; the node + /// minimizes the withdrawal, so it pays this or a shade less. + /// + /// + /// The pool's balance of the asset being withdrawn. + /// The LP tokens being redeemed. + /// The pool's LP token balance, before the withdrawal. + /// The fee this account trades at - see . + /// A balance is not positive, the token amount is negative or exceeds the pool's, or the fee exceeds . + public static decimal SingleAssetWithdrawForLPTokens( + decimal poolBalance, + decimal lpTokens, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + RequireValidTradingFee(tradingFee); + + if (lpTokens == 0m) + { + return 0m; + } + + if (lpTokens > lpTokenBalance) + { + throw new ArgumentOutOfRangeException( + nameof(lpTokens), + $"Cannot redeem {lpTokens} tokens against a balance of {lpTokenBalance}."); + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal t1 = lpTokens / lpTokenBalance; + + return poolBalance * (t1 * t1 - t1 * (2m - fee)) / (t1 * fee - 1m); + } + + /// + /// LP tokens credited for depositing both assets at the pool's own ratio. + /// + /// + /// A deposit in proportion does not move the price, so no fee applies: the node credits + /// T · frac and takes A · frac and B · frac, where frac is the + /// smaller of the two ratios offered - whichever asset runs out first decides how much of + /// the other is used. Use to find out how much of + /// each will actually be taken. + /// + /// A balance is not positive, or an amount is negative. + public static decimal LPTokensForProportionalDeposit( + decimal poolBalance1, + decimal poolBalance2, + decimal deposit1, + decimal deposit2, + decimal lpTokenBalance) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(deposit1, nameof(deposit1)); + RequireNotNegative(deposit2, nameof(deposit2)); + + decimal frac = Math.Min(deposit1 / poolBalance1, deposit2 / poolBalance2); + return lpTokenBalance * frac; + } + + /// + /// How much of each asset a proportional deposit will actually take. + /// + /// + /// The leftover of the more plentiful asset stays where it is; the node deposits both sides + /// at the same fraction of the pool. + /// + public static (decimal Asset1, decimal Asset2) AssetsForProportionalDeposit( + decimal poolBalance1, + decimal poolBalance2, + decimal deposit1, + decimal deposit2) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequireNotNegative(deposit1, nameof(deposit1)); + RequireNotNegative(deposit2, nameof(deposit2)); + + decimal frac = Math.Min(deposit1 / poolBalance1, deposit2 / poolBalance2); + return (poolBalance1 * frac, poolBalance2 * frac); + } + + /// + /// What redeeming LP tokens returns when both assets are taken out at the pool's ratio. + /// + /// + /// Equations 1 and 2: a = (t/T)·A and b = (t/T)·B. No fee, for the same + /// reason as a proportional deposit - the price does not move. + /// + /// A balance is not positive, the token amount is negative, or it exceeds the pool's. + public static (decimal Asset1, decimal Asset2) AssetsForProportionalWithdraw( + decimal poolBalance1, + decimal poolBalance2, + decimal lpTokens, + decimal lpTokenBalance) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + + if (lpTokens > lpTokenBalance) + { + throw new ArgumentOutOfRangeException( + nameof(lpTokens), + $"Cannot redeem {lpTokens} tokens against a balance of {lpTokenBalance}."); + } + + decimal frac = lpTokens / lpTokenBalance; + return (poolBalance1 * frac, poolBalance2 * frac); + } + + /// + /// What comes out of the pool for swapping this much of the other asset in. + /// + /// + /// + /// rippled's swapAssetIn, and what a payment routed through an AMM pays the taker. + /// The node writes it as + /// + /// + /// out = poolOut - (poolIn · poolOut) / (poolIn + in·(1 - fee)) + /// + /// + /// which is the form used here, rearranged to poolOut·x/(poolIn + x) with + /// x = in·(1 - fee). The two are the same expression; the difference is that the + /// node's form subtracts two nearly equal numbers for a small swap and loses digits to + /// the cancellation, while this one has nothing to cancel. + /// + /// + /// The fee comes off the input before the curve sees it, so the whole of + /// still enters the pool - the fee stays there for the + /// liquidity providers rather than being taken away. + /// + /// + /// The pool's balance of the asset being swapped in. + /// The pool's balance of the asset being swapped out. + /// How much is being swapped in, fee included. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative, or the fee exceeds . + public static decimal SwapAssetIn( + decimal poolIn, + decimal poolOut, + decimal assetIn, + uint tradingFee) + { + RequirePositive(poolIn, nameof(poolIn)); + RequirePositive(poolOut, nameof(poolOut)); + RequireNotNegative(assetIn, nameof(assetIn)); + RequireValidTradingFee(tradingFee); + + if (assetIn == 0m) + { + return 0m; + } + + decimal effectiveIn = assetIn * (1m - TradingFeeFraction(tradingFee)); + + return poolOut * effectiveIn / (poolIn + effectiveIn); + } + + /// + /// What must be swapped in to take exactly this much of the other asset out. + /// + /// + /// + /// rippled's swapAssetOut, the inverse of : + /// + /// + /// in = ((poolIn · poolOut) / (poolOut - out) - poolIn) / (1 - fee) + /// + /// + /// rearranged here to poolIn·out / ((poolOut - out)·(1 - fee)) for the same reason + /// as above. The cost climbs without bound as approaches the + /// pool's balance, which is the constant product refusing to be emptied; asking for the + /// whole of it, or more, is rejected rather than answered with a division by zero. + /// + /// + /// The pool's balance of the asset being swapped in. + /// The pool's balance of the asset being swapped out. + /// How much is wanted out. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative or is not less than the pool, or the fee exceeds . + public static decimal SwapAssetOut( + decimal poolIn, + decimal poolOut, + decimal assetOut, + uint tradingFee) + { + RequirePositive(poolIn, nameof(poolIn)); + RequirePositive(poolOut, nameof(poolOut)); + RequireNotNegative(assetOut, nameof(assetOut)); + RequireValidTradingFee(tradingFee); + + if (assetOut == 0m) + { + return 0m; + } + + if (assetOut >= poolOut) + { + throw new ArgumentOutOfRangeException( + nameof(assetOut), + $"A constant-product pool cannot be emptied: {assetOut} was asked of a balance " + + $"of {poolOut}, and the cost of the last unit is unbounded."); + } + + return poolIn * assetOut / ((poolOut - assetOut) * (1m - TradingFeeFraction(tradingFee))); + } + + /// + /// Square root in , by Newton's method. + /// + /// + /// works in , whose 15 significant digits would + /// discard the precision the rest of this class keeps. The iteration is seeded from the + /// double result, which is already close, so it converges in a handful of steps; it stops + /// when the estimate settles or begins alternating between two neighbouring values, which + /// is how a decimal iteration ends when it can get no closer. + /// + /// is negative. + internal static decimal Sqrt(decimal value) + { + if (value < 0m) + { + throw new ArgumentOutOfRangeException(nameof(value), "Cannot take the square root of a negative number."); + } + + if (value == 0m) + { + return 0m; + } + + decimal guess; + try + { + guess = (decimal)Math.Sqrt((double)value); + } + catch (OverflowException) + { + guess = value; + } + + if (guess <= 0m) + { + guess = value > 1m ? value / 2m : 1m; + } + + decimal previous = 0m; + for (int step = 0; step < 100; step++) + { + decimal next = (guess + value / guess) / 2m; + if (next == guess || next == previous) + { + return next; + } + + previous = guess; + guess = next; + } + + return guess; + } + + /// + /// The larger root, which is the one rippled's solveQuadraticEq takes. + /// + private static decimal SolveQuadratic(decimal a, decimal b, decimal c) + => (-b + Sqrt(b * b - 4m * a * c)) / (2m * a); + + private static void RequireValidTradingFee(uint tradingFee) + { + if (tradingFee > TradingFeeThreshold) + { + throw new ArgumentOutOfRangeException( + nameof(tradingFee), + $"A trading fee is in units of 1/{TradingFeeScale}, so it cannot exceed " + + $"{TradingFeeThreshold} - one per cent - but was {tradingFee}."); + } + } + + private static void RequirePositive(decimal value, string name) + { + if (value <= 0m) + { + throw new ArgumentOutOfRangeException(name, $"{name} must be positive, but was {value}."); + } + } + + private static void RequireNotNegative(decimal value, string name) + { + if (value < 0m) + { + throw new ArgumentOutOfRangeException(name, $"{name} must not be negative, but was {value}."); + } + } + } +}