diff --git a/src/test/basics/VaultSharePricing_test.cpp b/src/test/basics/VaultSharePricing_test.cpp index eb6b4d2a60..b2c8784666 100644 --- a/src/test/basics/VaultSharePricing_test.cpp +++ b/src/test/basics/VaultSharePricing_test.cpp @@ -48,7 +48,7 @@ public: DepositResult deposit(STAmount const& assets) { - Number const depositAmount = static_cast(assets); + Number const depositAmount = assets; Number shares; Number actualAssets; @@ -82,13 +82,11 @@ public: STAmount redeem(STAmount const& shares) { - Number const shareAmount = static_cast(shares); + Number const shareAmount = shares; // assets = shares * withdrawalNAV / sharesTotal - Number const withdrawalNAV = - assetsTotal_ - interestUnrealized_ - lossUnrealized_; - Number const assetsOut = - (shareAmount * withdrawalNAV) / sharesTotal_; + Number const withdrawalNAV = assetsTotal_ - interestUnrealized_ - lossUnrealized_; + Number const assetsOut = (shareAmount * withdrawalNAV) / sharesTotal_; assetsTotal_ -= assetsOut; assetsAvailable_ -= assetsOut; @@ -100,15 +98,13 @@ public: WithdrawResult withdraw(STAmount const& assetsRequested) { - Number const requestedAmount = static_cast(assetsRequested); + Number const requestedAmount = assetsRequested; - Number const withdrawalNAV = - assetsTotal_ - interestUnrealized_ - lossUnrealized_; + Number const withdrawalNAV = assetsTotal_ - interestUnrealized_ - lossUnrealized_; // shares = round_nearest(requested * sharesTotal / withdrawalNAV) - Number const rawShares = - (requestedAmount * sharesTotal_) / withdrawalNAV; - // Round to nearest integer via int64_t cast + Number const rawShares = (requestedAmount * sharesTotal_) / withdrawalNAV; + // Number::operator int64_t() rounds to nearest (not standard C++ truncation) Number const shares{static_cast(rawShares)}; // Recalculate actual assets out @@ -136,6 +132,13 @@ public: lossUnrealized_ = iota; } + void + addRealizedInterest(Number amount) + { + assetsTotal_ += amount; + assetsAvailable_ += amount; + } + STAmount assetsTotal() const { @@ -175,13 +178,6 @@ public: class VaultSharePricing_test : public beast::unit_test::suite { - // Helper to get the numeric Number value from an STAmount - static Number - num(STAmount const& a) - { - return static_cast(a); - } - public: void testInitialDepositIOU() @@ -194,10 +190,10 @@ public: auto const [shares, assets] = vault.deposit(STAmount{usd, 100}); // 100 * 10^6 = 100,000,000 shares - BEAST_EXPECT(num(shares) == Number(100'000'000)); - BEAST_EXPECT(num(assets) == Number(100)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(100)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(100'000'000)); + BEAST_EXPECT(Number(shares) == Number(100'000'000)); + BEAST_EXPECT(Number(assets) == Number(100)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(100)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(100'000'000)); } void @@ -208,12 +204,11 @@ public: Vault vault{xrpIssue()}; // 10 XRP = 10,000,000 drops; scale=0 so shares = drops 1:1 - auto const [shares, assets] = - vault.deposit(STAmount{xrpIssue(), 10'000'000}); + auto const [shares, assets] = vault.deposit(STAmount{xrpIssue(), 10'000'000}); - BEAST_EXPECT(num(shares) == Number(10'000'000)); - BEAST_EXPECT(num(assets) == Number(10'000'000)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(10'000'000)); + BEAST_EXPECT(Number(shares) == Number(10'000'000)); + BEAST_EXPECT(Number(assets) == Number(10'000'000)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(10'000'000)); } void @@ -227,9 +222,9 @@ public: auto const [shares, assets] = vault.deposit(STAmount{mptAsset, 500}); // scale=0, so 500 assets → 500 shares - BEAST_EXPECT(num(shares) == Number(500)); - BEAST_EXPECT(num(assets) == Number(500)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(500)); + BEAST_EXPECT(Number(shares) == Number(500)); + BEAST_EXPECT(Number(assets) == Number(500)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(500)); } void @@ -246,9 +241,9 @@ public: // Second deposit: 50 assets at same price → 50M shares auto const [shares, assets] = vault.deposit(STAmount{usd, 50}); - BEAST_EXPECT(num(shares) == Number(50'000'000)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(150)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(150'000'000)); + BEAST_EXPECT(Number(shares) == Number(50'000'000)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(150)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(150'000'000)); } void @@ -262,12 +257,11 @@ public: vault.deposit(STAmount{usd, 100}); // Redeem half the shares - auto const assetsOut = - vault.redeem(STAmount{vault.shareAsset(), 50'000'000}); + auto const assetsOut = vault.redeem(STAmount{vault.shareAsset(), 50'000'000}); - BEAST_EXPECT(num(assetsOut) == Number(50)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(50)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(50'000'000)); + BEAST_EXPECT(Number(assetsOut) == Number(50)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(50)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(50'000'000)); } void @@ -281,12 +275,12 @@ public: vault.deposit(STAmount{usd, 100}); // Withdraw 50 assets - auto const [shares, assets] = - vault.withdraw(STAmount{usd, 50}); + auto const [shares, assets] = vault.withdraw(STAmount{usd, 50}); - BEAST_EXPECT(num(shares) == Number(50'000'000)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(50)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(50'000'000)); + BEAST_EXPECT(Number(shares) == Number(50'000'000)); + BEAST_EXPECT(Number(assets) == Number(50)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(50)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(50'000'000)); } void @@ -299,7 +293,7 @@ public: // Seed vault: 1000 assets, 1000 shares vault.deposit(STAmount{usd, 1000}); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(1000)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1000)); // Protocol adds unrealized interest (omega = 50) vault.setInterestUnrealized(Number(50)); @@ -308,9 +302,9 @@ public: // Deposit 95 assets: shares = floor(95 * 1000 / 950) = floor(100) = 100 auto const [shares, assets] = vault.deposit(STAmount{usd, 95}); - BEAST_EXPECT(num(shares) == Number(100)); + BEAST_EXPECT(Number(shares) == Number(100)); // Recalculated assets: 100 * 950 / 1000 = 95 - BEAST_EXPECT(num(assets) == Number(95)); + BEAST_EXPECT(Number(assets) == Number(95)); } void @@ -327,10 +321,9 @@ public: // Withdrawal NAV = 1000 - 50 - 100 = 850 // Redeem 100 shares: assets = 100 * 850 / 1000 = 85 - auto const assetsOut = - vault.redeem(STAmount{vault.shareAsset(), 100}); + auto const assetsOut = vault.redeem(STAmount{vault.shareAsset(), 100}); - BEAST_EXPECT(num(assetsOut) == Number(85)); + BEAST_EXPECT(Number(assetsOut) == Number(85)); } void @@ -351,15 +344,19 @@ public: // So 1 share costs 0.95 assets to deposit // Depositing 0.95 assets: shares = floor(0.95 * 1000 / 950) = floor(1) = 1 { - auto const [shares, assets] = - vault.deposit(STAmount{usd, 95, -2}); // 0.95 - BEAST_EXPECT(num(shares) == Number(1)); + auto const [shares, assets] = vault.deposit(STAmount{usd, 95, -2}); // 0.95 + BEAST_EXPECT(Number(shares) == Number(1)); } - // Withdrawal share price = (assetsTotal - omega - iota) / sharesTotal - // ≈ 850 / 1001 ≈ 0.849... - // Redeem 1 share: assets ≈ 0.849... - // (accounting for the extra share and assets from the deposit above) + // After deposit: assetsTotal = 1000.95, sharesTotal = 1001 + // withdrawalNAV = assetsTotal - omega - iota = 1000.95 - 50 - 100 = 850.95 + // Redeem 1 share: assetsOut = 1 * 850.95 / 1001 + { + Number const expected = Number(85095, -2) / Number(1001); + STAmount const expectedAmt(usd, expected); + auto const redeemAssets = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(redeemAssets == expectedAmt); + } } void @@ -368,14 +365,6 @@ public: testcase("Deposit rounding - shares floor"); Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; - Vault vault{usd, 0}; - - // Seed: 3 assets, 3 shares - vault.deposit(STAmount{usd, 3}); - - // Deposit 1 asset: shares = floor(1 * 3 / 3) = 1 - // Now try a case that produces fractional shares: - // Seed a vault with 10 assets, 7 shares (custom ratio) Vault vault2{usd, 0}; vault2.deposit(STAmount{usd, 10}); // 10 assets, 10 shares @@ -385,9 +374,9 @@ public: // depositNAV = 10 - 3 = 7 // Deposit 1: shares = floor(1 * 10 / 7) = floor(1.4285...) = 1 auto const [shares, assets] = vault2.deposit(STAmount{usd, 1}); - BEAST_EXPECT(num(shares) == Number(1)); + BEAST_EXPECT(Number(shares) == Number(1)); // Recalculated assets: 1 * 7 / 10 = 0.7 - BEAST_EXPECT(num(assets) == Number(7, -1)); + BEAST_EXPECT(Number(assets) == Number(7, -1)); } void @@ -403,7 +392,16 @@ public: // withdrawalNAV = 10 - 3 = 7 // Withdraw 1 asset: shares = round(1 * 10 / 7) = round(1.4285...) = 1 auto const [shares, assets] = vault.withdraw(STAmount{usd, 1}); - BEAST_EXPECT(num(shares) == Number(1)); + BEAST_EXPECT(Number(shares) == Number(1)); + + // Round up case: rawShares = 1.1*10/7 ≈ 1.571 → nearest = 2 + Vault vault2{usd, 0}; + vault2.deposit(STAmount{usd, 10}); + vault2.setInterestUnrealized(Number(3)); + auto const [shares2, assets2] = vault2.withdraw(STAmount{usd, 11, -1}); // 1.1 + BEAST_EXPECT(Number(shares2) == Number(2)); + // assetsOut = 2*7/10 = 1.4 + BEAST_EXPECT(Number(assets2) == Number(14, -1)); } void @@ -415,12 +413,12 @@ public: Vault vault{usd}; vault.deposit(STAmount{usd, 100}); - auto const assetsOut = - vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); + auto const assetsOut = vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); - BEAST_EXPECT(num(assetsOut) == Number(100)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(0)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(0)); + BEAST_EXPECT(Number(assetsOut) == Number(100)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(0)); + BEAST_EXPECT(Number(vault.assetsAvailable()) == Number(0)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(0)); } void @@ -436,12 +434,11 @@ public: // Deposit NAV = 1000 - 0 = 1000 (loss doesn't affect deposit) auto const [depShares, depAssets] = vault.deposit(STAmount{usd, 100}); - BEAST_EXPECT(num(depShares) == Number(100)); + BEAST_EXPECT(Number(depShares) == Number(100)); // Withdrawal NAV = 1100 - 0 - 200 = 900 (total is now 1100) // Redeem 100 shares: assets = 100 * 900 / 1100 ≈ 81.818... - auto const redeemAssets = - vault.redeem(STAmount{vault.shareAsset(), 100}); + auto const redeemAssets = vault.redeem(STAmount{vault.shareAsset(), 100}); // 100 * 900 / 1100 = 81.8181... // Compare as STAmount to account for IOU normalization Number const expected = (Number(100) * Number(900)) / Number(1100); @@ -455,35 +452,53 @@ public: testcase("Multiple depositors with yield (IOU)"); Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; - Vault vault{usd}; + Vault vault{usd, 0}; // scale=0 for clean integer math - // User A deposits 100 + // User A deposits 100 → 100 shares auto const [sharesA, assetsA] = vault.deposit(STAmount{usd, 100}); - BEAST_EXPECT(num(sharesA) == Number(100'000'000)); + BEAST_EXPECT(Number(sharesA) == Number(100)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(100)); - // Protocol earns 10 in realized interest (assetsTotal goes up) - // Simulate by: a protocol repays 10, increasing assetsTotal - // We don't have a direct setter for assetsTotal, but we can - // approximate this scenario through the pricing mechanism. - // Instead, let's verify through deposit pricing. + // Protocol realizes 10 interest: assetsTotal becomes 110 + vault.addRealizedInterest(Number(10)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(110)); - // User B deposits 100 at the same rate (no yield yet) + // User B deposits 100: depositNAV=110, shares=floor(100*100/110)=90 + // actualAssets = 90*110/100 = 99 auto const [sharesB, assetsB] = vault.deposit(STAmount{usd, 100}); - BEAST_EXPECT(num(sharesB) == Number(100'000'000)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(200)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(200'000'000)); + BEAST_EXPECT(Number(sharesB) == Number(90)); + BEAST_EXPECT(Number(assetsB) == Number(99)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(209)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(190)); - // Both redeem: should get back 100 each - auto const outA = - vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); - BEAST_EXPECT(num(outA) == Number(100)); + // A redeems 100 shares: assetsOut = 100 * 209 / 190 = 110 + auto const outA = vault.redeem(STAmount{vault.shareAsset(), 100}); + BEAST_EXPECT(Number(outA) == Number(110)); - auto const outB = - vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); - BEAST_EXPECT(num(outB) == Number(100)); + // B redeems 90 shares: assetsOut = 90 * 99 / 90 = 99 + auto const outB = vault.redeem(STAmount{vault.shareAsset(), 90}); + BEAST_EXPECT(Number(outB) == Number(99)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(0)); - BEAST_EXPECT(num(vault.sharesTotal()) == Number(0)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(0)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(0)); + } + + void + testPrecisionLoss() + { + testcase("Precision loss - zero shares condition"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + // Initial deposit: 1 asset → 1 share + vault.deposit(STAmount{usd, 1}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1)); + + // Deposit 0.0001 assets: shares = floor(0.0001 * 1 / 1) = 0 + // This is the zero-shares condition (tecPRECISION_LOSS in real implementation) + auto const [shares, assets] = vault.deposit(STAmount{usd, 1, -4}); + BEAST_EXPECT(Number(shares) == Number(0)); } void @@ -494,21 +509,19 @@ public: Vault vault{xrpIssue()}; // Deposit 10 XRP = 10M drops - auto const [shares1, assets1] = - vault.deposit(STAmount{xrpIssue(), 10'000'000}); - BEAST_EXPECT(num(shares1) == Number(10'000'000)); + auto const [shares1, assets1] = vault.deposit(STAmount{xrpIssue(), 10'000'000}); + BEAST_EXPECT(Number(shares1) == Number(10'000'000)); // Withdraw 5M drops - auto const [wShares, wAssets] = - vault.withdraw(STAmount{xrpIssue(), 5'000'000}); - BEAST_EXPECT(num(wShares) == Number(5'000'000)); - BEAST_EXPECT(num(wAssets) == Number(5'000'000)); + auto const [wShares, wAssets] = vault.withdraw(STAmount{xrpIssue(), 5'000'000}); + BEAST_EXPECT(Number(wShares) == Number(5'000'000)); + BEAST_EXPECT(Number(wAssets) == Number(5'000'000)); // Redeem remaining 5M shares - auto const rAssets = - vault.redeem(STAmount{vault.shareAsset(), 5'000'000}); - BEAST_EXPECT(num(rAssets) == Number(5'000'000)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(0)); + auto const rAssets = vault.redeem(STAmount{vault.shareAsset(), 5'000'000}); + BEAST_EXPECT(Number(rAssets) == Number(5'000'000)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(0)); + BEAST_EXPECT(Number(vault.assetsAvailable()) == Number(0)); } void @@ -520,16 +533,261 @@ public: Vault vault{mptAsset}; auto const [shares1, assets1] = vault.deposit(STAmount{mptAsset, 1000}); - BEAST_EXPECT(num(shares1) == Number(1000)); + BEAST_EXPECT(Number(shares1) == Number(1000)); - auto const [wShares, wAssets] = - vault.withdraw(STAmount{mptAsset, 400}); - BEAST_EXPECT(num(wShares) == Number(400)); + auto const [wShares, wAssets] = vault.withdraw(STAmount{mptAsset, 400}); + BEAST_EXPECT(Number(wShares) == Number(400)); - auto const rAssets = - vault.redeem(STAmount{vault.shareAsset(), 600}); - BEAST_EXPECT(num(rAssets) == Number(600)); - BEAST_EXPECT(num(vault.assetsTotal()) == Number(0)); + auto const rAssets = vault.redeem(STAmount{vault.shareAsset(), 600}); + BEAST_EXPECT(Number(rAssets) == Number(600)); + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(0)); + } + + void + testTinyDepositIntoLargeVault() + { + testcase("Tiny deposit into large vault (IOU)"); + + // Large vault with 1 billion assets at scale=6 + // sharesTotal = 1e9 * 1e6 = 1e15 + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; // scale=6 + + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000)}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1, 15)); + + // Tiny deposit: 0.000001 (1e-6) into a 1e9 vault + // shares = floor(1e-6 * 1e15 / 1e9) = floor(1e0) = 1 + auto const [shares, assets] = + vault.deposit(STAmount{usd, 1, -6}); + + BEAST_EXPECT(Number(shares) == Number(1)); + // Recalculated assets: 1 * 1e9 / 1e15 = 1e-6 + BEAST_EXPECT(Number(assets) == Number(1, -6)); + + // Redeem the 1 share back + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(Number(out) == Number(1, -6)); + } + + void + testTinyDepositIntoLargeVaultXRP() + { + testcase("Tiny deposit into large vault (XRP)"); + + // 100 million XRP = 1e14 drops, scale=0 so shares = 1e14 + Vault vault{xrpIssue()}; + + vault.deposit(STAmount{xrpIssue(), UINT64_C(100'000'000'000'000)}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1, 14)); + + // Deposit 1 drop into a 1e14-drop vault + // shares = floor(1 * 1e14 / 1e14) = 1 + auto const [shares, assets] = + vault.deposit(STAmount{xrpIssue(), 1}); + + BEAST_EXPECT(Number(shares) == Number(1)); + BEAST_EXPECT(Number(assets) == Number(1)); + + // Redeem the 1 share + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(Number(out) == Number(1)); + } + + void + testLargeDepositIntoTinyVault() + { + testcase("Large deposit into tiny vault (IOU)"); + + // Tiny vault: 0.001 assets, scale=6 → shares = 0.001 * 1e6 = 1000 + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + vault.deposit(STAmount{usd, 1, -3}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1000)); + + // Massive deposit: 1 billion assets + // shares = floor(1e9 * 1000 / 0.001) = floor(1e15) = 1e15 + auto const [shares, assets] = + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000)}); + + BEAST_EXPECT(Number(shares) == Number(1, 15)); + BEAST_EXPECT(Number(assets) == Number(1, 9)); + + // Vault now: assetsTotal ≈ 1e9, sharesTotal = 1e15 + 1000 + // Redeem original 1000 shares + // assetsOut = 1000 * assetsTotal / sharesTotal + // The original depositor should get back roughly 0.001 + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1000}); + // 1000 * (1e9 + 0.001) / (1e15 + 1000) ≈ 0.001 + // Precision loss means the exact value depends on Number rounding + Number const expected = + (Number(1000) * Number(vault.assetsTotal())) / + (Number(vault.sharesTotal()) + Number(out)); + STAmount const expectedAmt(usd, expected); + BEAST_EXPECT(out == expectedAmt); + } + + void + testLargeDepositIntoTinyVaultXRP() + { + testcase("Large deposit into tiny vault (XRP)"); + + // Tiny vault: 1 drop, scale=0 → 1 share + Vault vault{xrpIssue()}; + + vault.deposit(STAmount{xrpIssue(), 1}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1)); + + // Massive deposit: 1e14 drops (100M XRP) + // shares = floor(1e14 * 1 / 1) = 1e14 + auto const [shares, assets] = + vault.deposit(STAmount{xrpIssue(), UINT64_C(100'000'000'000'000)}); + + BEAST_EXPECT(Number(shares) == Number(1, 14)); + BEAST_EXPECT(Number(assets) == Number(1, 14)); + + // Redeem original 1 share: assetsOut = 1 * (1e14+1) / (1e14+1) = 1 + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(Number(out) == Number(1)); + } + + void + testHighPrecisionWithInterest() + { + testcase("High precision deposit/redeem with unrealized interest"); + + // Large vault with interest: tests that tiny depositors aren't + // short-changed or over-paid when NAV diverges from 1:1 + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; // scale=0 + + // Seed: 1e12 assets → 1e12 shares + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000'000)}); + + // Large unrealized interest: 50% of total + vault.setInterestUnrealized(Number(5, 11)); + // depositNAV = 1e12 - 5e11 = 5e11 + + // Tiny deposit: 1 asset + // shares = floor(1 * 1e12 / 5e11) = floor(2) = 2 + auto const [shares, assets] = vault.deposit(STAmount{usd, 1}); + BEAST_EXPECT(Number(shares) == Number(2)); + // Recalculated assets: 2 * 5e11 / 1e12 = 1 + BEAST_EXPECT(Number(assets) == Number(1)); + + // Redeem those 2 shares + // withdrawalNAV = (1e12 + 1) - 5e11 - 0 = 5e11 + 1 + // assetsOut = 2 * (5e11 + 1) / (1e12 + 2) + auto const out = vault.redeem(STAmount{vault.shareAsset(), 2}); + // Should get back ≈ 1 (within rounding) + Number const expectedNum = + (Number(2) * (Number(1, 12) + Number(1) - Number(5, 11))) / + (Number(1, 12) + Number(2)); + STAmount const expectedAmt(usd, expectedNum); + BEAST_EXPECT(out == expectedAmt); + } + + void + testHighPrecisionWithLoss() + { + testcase("High precision redeem with unrealized loss"); + + // Large vault with loss: tests that a tiny share redemption + // correctly accounts for the discounted withdrawalNAV + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000'000)}); + + // 20% unrealized loss + vault.setLossUnrealized(Number(2, 11)); + // withdrawalNAV = 1e12 - 0 - 2e11 = 8e11 + + // Redeem 1 share out of 1e12 + // assetsOut = 1 * 8e11 / 1e12 = 0.8 + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + Number const expectedNum = Number(8, 11) / Number(1, 12); + STAmount const expectedAmt(usd, expectedNum); + BEAST_EXPECT(out == expectedAmt); + } + + void + testManyTinyDepositsLargeVault() + { + testcase("Many tiny deposits into large vault then full redeem"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; // scale=0 + + // Seed with 1e9 assets + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000)}); + + Number totalTinyShares{0}; + Number totalTinyAssets{0}; + + // 100 tiny deposits of 1 asset each + for (int i = 0; i < 100; ++i) + { + auto const [s, a] = vault.deposit(STAmount{usd, 1}); + totalTinyShares += Number(s); + totalTinyAssets += Number(a); + } + + // Each deposit: shares = floor(1 * sharesTotal / assetsTotal) + // First tiny: floor(1 * 1e9 / 1e9) = 1 share per deposit + BEAST_EXPECT(totalTinyShares == Number(100)); + BEAST_EXPECT(totalTinyAssets == Number(100)); + + // Redeem all 100 tiny shares + auto const out = vault.redeem(STAmount{vault.shareAsset(), 100}); + // assetsOut = 100 * (1e9 + 100) / (1e9 + 100) = 100 + BEAST_EXPECT(Number(out) == Number(100)); + } + + void + testExtremeLiquidityRatioIOU() + { + testcase("Extreme liquidity ratio - 1e15 to 1 (IOU)"); + + // Vault with maximum practical IOU assets at scale=0 + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + // 1e15 assets → 1e15 shares + vault.deposit(STAmount{usd, UINT64_C(1'000'000'000'000'000)}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1, 15)); + + // Deposit 1 asset: shares = floor(1 * 1e15 / 1e15) = 1 + auto const [shares, assets] = vault.deposit(STAmount{usd, 1}); + BEAST_EXPECT(Number(shares) == Number(1)); + BEAST_EXPECT(Number(assets) == Number(1)); + + // Redeem that 1 share + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(Number(out) == Number(1)); + + // Vault should be back to exactly 1e15 + BEAST_EXPECT(Number(vault.assetsTotal()) == Number(1, 15)); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1, 15)); + } + + void + testExtremeLiquidityRatioMPT() + { + testcase("Extreme liquidity ratio - 1e15 to 1 (MPT)"); + + MPTIssue const mptAsset{makeMptID(300, AccountID(0x5678))}; + Vault vault{mptAsset}; + + vault.deposit(STAmount{mptAsset, UINT64_C(1'000'000'000'000'000)}); + BEAST_EXPECT(Number(vault.sharesTotal()) == Number(1, 15)); + + auto const [shares, assets] = vault.deposit(STAmount{mptAsset, 1}); + BEAST_EXPECT(Number(shares) == Number(1)); + + auto const out = vault.redeem(STAmount{vault.shareAsset(), 1}); + BEAST_EXPECT(Number(out) == Number(1)); } void @@ -549,8 +807,18 @@ public: testRedeemAll(); testLossOnlyVault(); testMultipleDepositorsIOU(); + testPrecisionLoss(); testXRPFullCycle(); testMPTFullCycle(); + testTinyDepositIntoLargeVault(); + testTinyDepositIntoLargeVaultXRP(); + testLargeDepositIntoTinyVault(); + testLargeDepositIntoTinyVaultXRP(); + testHighPrecisionWithInterest(); + testHighPrecisionWithLoss(); + testManyTinyDepositsLargeVault(); + testExtremeLiquidityRatioIOU(); + testExtremeLiquidityRatioMPT(); } };