From 1c7daa183c66510e7555c0d7ca51f9ed2e263cef Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:25:49 +0100 Subject: [PATCH] adds PoC model for vault share price valuation --- src/test/basics/VaultSharePricing_test.cpp | 559 +++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 src/test/basics/VaultSharePricing_test.cpp diff --git a/src/test/basics/VaultSharePricing_test.cpp b/src/test/basics/VaultSharePricing_test.cpp new file mode 100644 index 0000000000..eb6b4d2a60 --- /dev/null +++ b/src/test/basics/VaultSharePricing_test.cpp @@ -0,0 +1,559 @@ +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +// Result types for vault operations +struct DepositResult +{ + STAmount shares; + STAmount assets; +}; + +struct WithdrawResult +{ + STAmount shares; + STAmount assets; +}; + +// Proof-of-concept Vault implementing the XLS-0065 share pricing model. +// Uses STAmount for typed asset/share values and Number for arithmetic. +class Vault +{ + Asset asset_; + MPTIssue shareAsset_; + std::uint8_t scale_; + Number assetsTotal_{0}; + Number assetsAvailable_{0}; + Number sharesTotal_{0}; + Number interestUnrealized_{0}; + Number lossUnrealized_{0}; + + static inline std::uint32_t nextShareSeq_ = 1; + +public: + Vault(Asset asset, std::uint8_t scale = 6) + : asset_(asset) + , shareAsset_(makeMptID(nextShareSeq_++, AccountID(0xFACE))) + , scale_(asset.native() || asset.integral() ? 0 : scale) + { + // XRP and MPT force scale to 0 per spec + // IOU uses caller-provided scale (default 6) + } + + DepositResult + deposit(STAmount const& assets) + { + Number const depositAmount = static_cast(assets); + + Number shares; + Number actualAssets; + + if (assetsTotal_ == 0 && sharesTotal_ == 0) + { + // Initial deposit: shares = assets * 10^scale + shares = Number(depositAmount.mantissa(), depositAmount.exponent() + scale_).truncate(); + actualAssets = depositAmount; + } + else + { + // Subsequent deposit: shares = floor(assets * sharesTotal / depositNAV) + Number const depositNAV = assetsTotal_ - interestUnrealized_; + shares = ((depositAmount * sharesTotal_) / depositNAV).truncate(); + + // Recalculate actual assets taken + actualAssets = (shares * depositNAV) / sharesTotal_; + } + + assetsTotal_ += actualAssets; + assetsAvailable_ += actualAssets; + sharesTotal_ += shares; + + return { + STAmount{shareAsset_, shares}, + STAmount{asset_, actualAssets}, + }; + } + + STAmount + redeem(STAmount const& shares) + { + Number const shareAmount = static_cast(shares); + + // assets = shares * withdrawalNAV / sharesTotal + Number const withdrawalNAV = + assetsTotal_ - interestUnrealized_ - lossUnrealized_; + Number const assetsOut = + (shareAmount * withdrawalNAV) / sharesTotal_; + + assetsTotal_ -= assetsOut; + assetsAvailable_ -= assetsOut; + sharesTotal_ -= shareAmount; + + return STAmount{asset_, assetsOut}; + } + + WithdrawResult + withdraw(STAmount const& assetsRequested) + { + Number const requestedAmount = static_cast(assetsRequested); + + 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 shares{static_cast(rawShares)}; + + // Recalculate actual assets out + Number const assetsOut = (shares * withdrawalNAV) / sharesTotal_; + + assetsTotal_ -= assetsOut; + assetsAvailable_ -= assetsOut; + sharesTotal_ -= shares; + + return { + STAmount{shareAsset_, shares}, + STAmount{asset_, assetsOut}, + }; + } + + void + setInterestUnrealized(Number omega) + { + interestUnrealized_ = omega; + } + + void + setLossUnrealized(Number iota) + { + lossUnrealized_ = iota; + } + + STAmount + assetsTotal() const + { + return STAmount{asset_, assetsTotal_}; + } + + STAmount + assetsAvailable() const + { + return STAmount{asset_, assetsAvailable_}; + } + + STAmount + sharesTotal() const + { + return STAmount{shareAsset_, sharesTotal_}; + } + + MPTIssue const& + shareAsset() const + { + return shareAsset_; + } + + Number + interestUnrealized() const + { + return interestUnrealized_; + } + + Number + lossUnrealized() const + { + return lossUnrealized_; + } +}; + +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() + { + testcase("Initial deposit IOU (scale=6)"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; // scale defaults to 6 + + 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)); + } + + void + testInitialDepositXRP() + { + testcase("Initial deposit XRP (scale=0)"); + + 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}); + + 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)); + } + + void + testInitialDepositMPT() + { + testcase("Initial deposit MPT (scale=0)"); + + MPTIssue const mptAsset{makeMptID(100, AccountID(0x5678))}; + Vault vault{mptAsset}; + + 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)); + } + + void + testSubsequentDeposit() + { + testcase("Subsequent deposit (proportional)"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + // Initial: 100 assets → 100M shares + vault.deposit(STAmount{usd, 100}); + + // 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)); + } + + void + testRedeemBasic() + { + testcase("Redeem basic"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + vault.deposit(STAmount{usd, 100}); + + // Redeem half the shares + 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)); + } + + void + testWithdrawBasic() + { + testcase("Withdraw basic"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + vault.deposit(STAmount{usd, 100}); + + // Withdraw 50 assets + 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)); + } + + void + testAsymmetricDepositWithInterest() + { + testcase("Asymmetric pricing - deposit with unrealized interest"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; // scale=0 for simpler math + + // Seed vault: 1000 assets, 1000 shares + vault.deposit(STAmount{usd, 1000}); + BEAST_EXPECT(num(vault.sharesTotal()) == Number(1000)); + + // Protocol adds unrealized interest (omega = 50) + vault.setInterestUnrealized(Number(50)); + + // Deposit NAV = assetsTotal - omega = 1000 - 50 = 950 + // 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)); + // Recalculated assets: 100 * 950 / 1000 = 95 + BEAST_EXPECT(num(assets) == Number(95)); + } + + void + testAsymmetricWithdrawWithLoss() + { + testcase("Asymmetric pricing - withdraw with unrealized loss"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + vault.deposit(STAmount{usd, 1000}); + vault.setInterestUnrealized(Number(50)); + vault.setLossUnrealized(Number(100)); + + // Withdrawal NAV = 1000 - 50 - 100 = 850 + // Redeem 100 shares: assets = 100 * 850 / 1000 = 85 + auto const assetsOut = + vault.redeem(STAmount{vault.shareAsset(), 100}); + + BEAST_EXPECT(num(assetsOut) == Number(85)); + } + + void + testSpecExample() + { + testcase("Spec example (section 3.1.7.1)"); + + // Vault: assetsTotal=1000, omega=50, iota=100, sharesTotal=1000 + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + vault.deposit(STAmount{usd, 1000}); + vault.setInterestUnrealized(Number(50)); + vault.setLossUnrealized(Number(100)); + + // Deposit share price = (assetsTotal - omega) / sharesTotal + // = (1000 - 50) / 1000 = 0.95 + // 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)); + } + + // 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) + } + + void + testDepositRoundingDown() + { + 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 + // Deposit 3: shares = floor(3 * 10 / 10) = 3 (exact) + // Need non-trivial ratio. Let's add interest to skew the price. + vault2.setInterestUnrealized(Number(3)); + // 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)); + // Recalculated assets: 1 * 7 / 10 = 0.7 + BEAST_EXPECT(num(assets) == Number(7, -1)); + } + + void + testWithdrawRoundingNearest() + { + testcase("Withdraw rounding - shares nearest"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + vault.deposit(STAmount{usd, 10}); + vault.setInterestUnrealized(Number(3)); + // 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)); + } + + void + testRedeemAll() + { + testcase("Redeem all shares - vault empties"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + vault.deposit(STAmount{usd, 100}); + 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)); + } + + void + testLossOnlyVault() + { + testcase("Vault with lossUnrealized only (no interestUnrealized)"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd, 0}; + + vault.deposit(STAmount{usd, 1000}); + vault.setLossUnrealized(Number(200)); + + // 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)); + + // 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}); + // 100 * 900 / 1100 = 81.8181... + // Compare as STAmount to account for IOU normalization + Number const expected = (Number(100) * Number(900)) / Number(1100); + STAmount const expectedAmt(usd, expected); + BEAST_EXPECT(redeemAssets == expectedAmt); + } + + void + testMultipleDepositorsIOU() + { + testcase("Multiple depositors with yield (IOU)"); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + Vault vault{usd}; + + // User A deposits 100 + auto const [sharesA, assetsA] = vault.deposit(STAmount{usd, 100}); + BEAST_EXPECT(num(sharesA) == Number(100'000'000)); + + // 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. + + // User B deposits 100 at the same rate (no yield yet) + 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)); + + // Both redeem: should get back 100 each + auto const outA = + vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); + BEAST_EXPECT(num(outA) == Number(100)); + + auto const outB = + vault.redeem(STAmount{vault.shareAsset(), 100'000'000}); + BEAST_EXPECT(num(outB) == Number(100)); + + BEAST_EXPECT(num(vault.assetsTotal()) == Number(0)); + BEAST_EXPECT(num(vault.sharesTotal()) == Number(0)); + } + + void + testXRPFullCycle() + { + testcase("XRP full cycle: deposit, withdraw, redeem"); + + 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)); + + // 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)); + + // 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)); + } + + void + testMPTFullCycle() + { + testcase("MPT full cycle: deposit, withdraw, redeem"); + + MPTIssue const mptAsset{makeMptID(200, AccountID(0x5678))}; + Vault vault{mptAsset}; + + auto const [shares1, assets1] = vault.deposit(STAmount{mptAsset, 1000}); + BEAST_EXPECT(num(shares1) == Number(1000)); + + auto const [wShares, wAssets] = + vault.withdraw(STAmount{mptAsset, 400}); + BEAST_EXPECT(num(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)); + } + + void + run() override + { + testInitialDepositIOU(); + testInitialDepositXRP(); + testInitialDepositMPT(); + testSubsequentDeposit(); + testRedeemBasic(); + testWithdrawBasic(); + testAsymmetricDepositWithInterest(); + testAsymmetricWithdrawWithLoss(); + testSpecExample(); + testDepositRoundingDown(); + testWithdrawRoundingNearest(); + testRedeemAll(); + testLossOnlyVault(); + testMultipleDepositorsIOU(); + testXRPFullCycle(); + testMPTFullCycle(); + } +}; + +BEAST_DEFINE_TESTSUITE(VaultSharePricing, basics, xrpl); + +} // namespace xrpl