From 33bef15b50ee47313483ca27026c573e57b291b2 Mon Sep 17 00:00:00 2001 From: Benjamin Boudreau Date: Tue, 23 Jun 2026 11:27:49 -0700 Subject: [PATCH] Add projected prize pool balance and share price view functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two read-only view functions for real-time frontend display: - getProjectedPrizePoolBalance(): Projects the prize pool balance by querying the yield source live and applying the distribution strategy to unsync'd yield (or the deficit waterfall for losses). - getProjectedSharePrice(): Projects the share price by computing what syncWithYieldSource() would do to totalAssets (rewards portion of pending yield, or deficit impact on rewards after protocol/prize absorb first). Both are view-only with no state mutation, no new storage, and no entitlements — minimal audit blast radius. Refactored getProjectedUserBalance() to delegate to getProjectedSharePrice(), eliminating duplicated projection logic. Includes query scripts (production + test versions), test helper wrappers, and 11 tests covering excess yield, deficit, deficit absorbed by protocol fee, below-threshold, convergence after sync, and the refactored getProjectedUserBalance. --- cadence/contracts/PrizeLinkedAccounts.cdc | 198 +++++--- .../get_projected_prize_pool_balance.cdc | 35 ++ .../get_projected_share_price.cdc | 35 ++ .../test/get_projected_prize_pool_balance.cdc | 23 + .../test/get_projected_share_price.cdc | 23 + cadence/tests/ProjectedBalance_test.cdc | 3 +- .../ProjectedPrizePoolAndSharePrice_test.cdc | 451 ++++++++++++++++++ cadence/tests/test_helpers.cdc | 14 + 8 files changed, 725 insertions(+), 57 deletions(-) create mode 100644 cadence/scripts/prize-linked-accounts/get_projected_prize_pool_balance.cdc create mode 100644 cadence/scripts/prize-linked-accounts/get_projected_share_price.cdc create mode 100644 cadence/scripts/test/get_projected_prize_pool_balance.cdc create mode 100644 cadence/scripts/test/get_projected_share_price.cdc create mode 100644 cadence/tests/ProjectedPrizePoolAndSharePrice_test.cdc diff --git a/cadence/contracts/PrizeLinkedAccounts.cdc b/cadence/contracts/PrizeLinkedAccounts.cdc index bfd72d1..b43da65 100644 --- a/cadence/contracts/PrizeLinkedAccounts.cdc +++ b/cadence/contracts/PrizeLinkedAccounts.cdc @@ -1227,6 +1227,33 @@ access(all) contract PrizeLinkedAccounts { } } + /// Represents the projected state of all three yield buckets (rewards, prize, protocol) + /// if syncWithYieldSource() were called right now. Used by view functions to provide + /// real-time projections without mutating state. + /// + /// - rewardsAmount: Projected totalAssets in the ShareTracker (drives share price) + /// - prizeAmount: Projected allocatedPrizeYield (pending prize yield in yield source) + /// - protocolFeeAmount: Projected allocatedProtocolFee (pending protocol fee in yield source) + /// - totalShares: Current total shares (unchanged by sync — only share price moves) + access(all) struct ProjectedDistribution { + access(all) let rewardsAmount: UFix64 + access(all) let prizeAmount: UFix64 + access(all) let protocolFeeAmount: UFix64 + access(all) let totalShares: UFix64 + + init( + rewardsAmount: UFix64, + prizeAmount: UFix64, + protocolFeeAmount: UFix64, + totalShares: UFix64 + ) { + self.rewardsAmount = rewardsAmount + self.prizeAmount = prizeAmount + self.protocolFeeAmount = protocolFeeAmount + self.totalShares = totalShares + } + } + /// Strategy Pattern interface for yield distribution algorithms. /// /// Implementations determine how yield is split between rewards, prize, and protocol fee. @@ -4022,22 +4049,73 @@ access(all) contract PrizeLinkedAccounts { ) } - /// Pure calculation of how much of a deficit would cascade through to the - /// rewards pool (reducing share price). Mirrors the deficit waterfall: - /// protocol fee absorbed first, then prize pool, then rewards. - /// Used by getProjectedUserBalance for read-only deficit preview. + /// Computes the projected state of all three yield buckets (rewards, prize, protocol) + /// if syncWithYieldSource() were called right now. This is the single source of truth + /// for all projected view functions — no state is mutated. /// - /// @param deficitAmount - Total deficit to preview - /// @return Amount that would hit rewards (share price) - access(self) view fun previewDeficitImpactOnRewards(deficitAmount: UFix64): UFix64 { - var remaining = deficitAmount - let absorbedByProtocol = remaining < self.allocatedProtocolFee - ? remaining : self.allocatedProtocolFee - remaining = remaining - absorbedByProtocol - let absorbedByPrize = remaining < self.allocatedPrizeYield - ? remaining : self.allocatedPrizeYield - remaining = remaining - absorbedByPrize - return remaining + /// Excess yield: distributes the pending difference via the distribution strategy. + /// Rewards portion is reduced by virtual-share dust (via previewAccrueYield). + /// Protocol portion includes the rewards dust that would be routed there. + /// Deficit: applies the waterfall (protocol fee → prize → rewards) to determine + /// how much each bucket would lose. + /// Below threshold: returns current cached values unchanged. + /// + /// @return ProjectedDistribution with projected totals for each bucket + access(self) fun getProjectedDistribution(): PrizeLinkedAccounts.ProjectedDistribution { + let yieldBalance = self.config.yieldConnector.minimumAvailable() + let allocatedFunds = self.getTotalAllocatedFunds() + let difference: UFix64 = yieldBalance > allocatedFunds + ? yieldBalance - allocatedFunds + : allocatedFunds - yieldBalance + + // Below threshold — no change + if difference < PrizeLinkedAccounts.MINIMUM_DISTRIBUTION_THRESHOLD { + return PrizeLinkedAccounts.ProjectedDistribution( + rewardsAmount: self.shareTracker.getTotalAssets(), + prizeAmount: self.allocatedPrizeYield, + protocolFeeAmount: self.allocatedProtocolFee, + totalShares: self.shareTracker.getTotalShares() + ) + } + + if yieldBalance > allocatedFunds { + // Excess yield — preview the distribution split + let plan = self.config.distributionStrategy.calculateDistribution( + totalAmount: difference + ) + // Rewards portion: previewAccrueYield excludes virtual-share dust. + // The dust would be routed to protocol fee (matching applyExcess). + let projectedRewards = self.shareTracker.previewAccrueYield( + amount: plan.rewardsAmount + ) + let rewardsDust = plan.rewardsAmount - projectedRewards + return PrizeLinkedAccounts.ProjectedDistribution( + rewardsAmount: self.shareTracker.getTotalAssets() + projectedRewards, + prizeAmount: self.allocatedPrizeYield + plan.prizeAmount, + protocolFeeAmount: self.allocatedProtocolFee + plan.protocolFeeAmount + rewardsDust, + totalShares: self.shareTracker.getTotalShares() + ) + } else { + // Deficit — waterfall: protocol fee first, then prize, then rewards + var remaining = difference + let absorbedByProtocol = remaining < self.allocatedProtocolFee + ? remaining : self.allocatedProtocolFee + remaining = remaining - absorbedByProtocol + let absorbedByPrize = remaining < self.allocatedPrizeYield + ? remaining : self.allocatedPrizeYield + remaining = remaining - absorbedByPrize + // remaining is now the amount that would hit rewards (share price) + let currentTotalAssets = self.shareTracker.getTotalAssets() + let projectedRewards = currentTotalAssets > remaining + ? currentTotalAssets - remaining + : 0.0 + return PrizeLinkedAccounts.ProjectedDistribution( + rewardsAmount: projectedRewards, + prizeAmount: self.allocatedPrizeYield - absorbedByPrize, + protocolFeeAmount: self.allocatedProtocolFee - absorbedByProtocol, + totalShares: self.shareTracker.getTotalShares() + ) + } } /// Applies a deficit (depreciation) from the yield source across the pool. @@ -4826,10 +4904,21 @@ access(all) contract PrizeLinkedAccounts { access(all) view fun getTotalRewardsAssets(): UFix64 { return self.shareTracker.getTotalAssets() } - access(all) view fun getRewardsSharePrice(): UFix64 { return self.shareTracker.getSharePrice() } + + /// Returns the projected share price accounting for unsync'd yield or deficit. + /// Calculates what the share price would be if syncWithYieldSource() were called now. + /// This is a read-only preview — no state is mutated. + /// + /// @return Projected share price (assets per share, with virtual offset) + access(all) fun getProjectedSharePrice(): UFix64 { + let projected = self.getProjectedDistribution() + let effectiveShares = projected.totalShares + PrizeLinkedAccounts.VIRTUAL_SHARES + let effectiveAssets = projected.rewardsAmount + PrizeLinkedAccounts.VIRTUAL_ASSETS + return effectiveAssets / effectiveShares + } /// Returns the user's current TWAB for the active round. /// @param receiverID - User's receiver ID @@ -4855,46 +4944,9 @@ access(all) contract PrizeLinkedAccounts { return 0.0 } - // Compare yield source balance to what we've already accounted for - let yieldBalance = self.config.yieldConnector.minimumAvailable() - let allocatedFunds = self.getTotalAllocatedFunds() - let difference: UFix64 = yieldBalance > allocatedFunds - ? yieldBalance - allocatedFunds - : allocatedFunds - yieldBalance - - // If difference is below dust threshold, just return current balance - if difference < PrizeLinkedAccounts.MINIMUM_DISTRIBUTION_THRESHOLD { - return self.shareTracker.getUserAssetValue(receiverID: receiverID) - } - - var projectedTotalAssets = self.shareTracker.getTotalAssets() - let totalShares = self.shareTracker.getTotalShares() - - if yieldBalance > allocatedFunds { - // Excess yield — preview the distribution split - let plan = self.config.distributionStrategy.calculateDistribution( - totalAmount: difference - ) - // Only the rewards portion increases share price - let projectedRewards = self.shareTracker.previewAccrueYield( - amount: plan.rewardsAmount - ) - projectedTotalAssets = projectedTotalAssets + projectedRewards - } else { - // Deficit — preview the waterfall impact on rewards - let deficitToRewards = self.previewDeficitImpactOnRewards( - deficitAmount: difference - ) - projectedTotalAssets = projectedTotalAssets > deficitToRewards - ? projectedTotalAssets - deficitToRewards - : 0.0 - } - - // Compute projected share price with virtual offset - let effectiveShares = totalShares + PrizeLinkedAccounts.VIRTUAL_SHARES - let effectiveAssets = projectedTotalAssets + PrizeLinkedAccounts.VIRTUAL_ASSETS - let projectedSharePrice = effectiveAssets / effectiveShares - + // Delegate to getProjectedSharePrice() which handles both excess and + // deficit projection (threshold check, distribution split, waterfall). + let projectedSharePrice = self.getProjectedSharePrice() return userShares * projectedSharePrice } @@ -5152,6 +5204,16 @@ access(all) contract PrizeLinkedAccounts { return self.prizeDistributor.getPrizePoolBalance() + self.allocatedPrizeYield } + /// Returns the projected prize pool balance accounting for unsync'd yield or deficit. + /// Calculates what the prize pool would be if syncWithYieldSource() were called now. + /// This is a read-only preview — no state is mutated. + /// + /// @return Projected total prize pool balance (prize vault + projected allocated prize yield) + access(all) fun getProjectedPrizePoolBalance(): UFix64 { + let projected = self.getProjectedDistribution() + return self.prizeDistributor.getPrizePoolBalance() + projected.prizeAmount + } + access(all) view fun getUnclaimedProtocolBalance(): UFix64 { return self.unclaimedProtocolFeeVault.balance } @@ -5720,6 +5782,30 @@ access(all) contract PrizeLinkedAccounts { return 0.0 } + /// Returns the projected share price for a pool, accounting for unsync'd yield or deficit. + /// Convenience wrapper that borrows the pool and delegates to Pool.getProjectedSharePrice. + /// + /// @param poolID - Pool to query + /// @return Projected share price, or 1.0 if pool not found + access(all) fun getProjectedSharePrice(poolID: UInt64): UFix64 { + if let poolRef = self.borrowPool(poolID: poolID) { + return poolRef.getProjectedSharePrice() + } + return 1.0 + } + + /// Returns the projected prize pool balance for a pool, accounting for unsync'd yield or deficit. + /// Convenience wrapper that borrows the pool and delegates to Pool.getProjectedPrizePoolBalance. + /// + /// @param poolID - Pool to query + /// @return Projected prize pool balance, or 0.0 if pool not found + access(all) fun getProjectedPrizePoolBalance(poolID: UInt64): UFix64 { + if let poolRef = self.borrowPool(poolID: poolID) { + return poolRef.getProjectedPrizePoolBalance() + } + return 0.0 + } + /// Creates a new PoolPositionCollection for a user. /// /// Users must create and store this resource to interact with pools. diff --git a/cadence/scripts/prize-linked-accounts/get_projected_prize_pool_balance.cdc b/cadence/scripts/prize-linked-accounts/get_projected_prize_pool_balance.cdc new file mode 100644 index 0000000..9f8942b --- /dev/null +++ b/cadence/scripts/prize-linked-accounts/get_projected_prize_pool_balance.cdc @@ -0,0 +1,35 @@ +import PrizeLinkedAccounts from "../../contracts/PrizeLinkedAccounts.cdc" + +/// Projected prize pool balance information structure +access(all) struct ProjectedPrizePoolInfo { + /// Prize pool balance accounting for unsynced yield/deficit in the yield source + access(all) let projectedPrizePoolBalance: UFix64 + /// Prize pool balance based on last sync (cached) + access(all) let syncedPrizePoolBalance: UFix64 + + init( + projectedPrizePoolBalance: UFix64, + syncedPrizePoolBalance: UFix64 + ) { + self.projectedPrizePoolBalance = projectedPrizePoolBalance + self.syncedPrizePoolBalance = syncedPrizePoolBalance + } +} + +/// Get a pool's projected prize pool balance, accounting for unsynced yield or deficit +/// in the yield source. Returns both the projected (live) and synced (cached) prize +/// pool balances so the caller can compare. +/// +/// Parameters: +/// - poolID: The pool ID to query +/// +/// Returns: ProjectedPrizePoolInfo with live and cached prize pool data +access(all) fun main(poolID: UInt64): ProjectedPrizePoolInfo { + let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) + ?? panic("Pool does not exist") + + return ProjectedPrizePoolInfo( + projectedPrizePoolBalance: poolRef.getProjectedPrizePoolBalance(), + syncedPrizePoolBalance: poolRef.getPrizePoolBalance() + ) +} diff --git a/cadence/scripts/prize-linked-accounts/get_projected_share_price.cdc b/cadence/scripts/prize-linked-accounts/get_projected_share_price.cdc new file mode 100644 index 0000000..8432126 --- /dev/null +++ b/cadence/scripts/prize-linked-accounts/get_projected_share_price.cdc @@ -0,0 +1,35 @@ +import PrizeLinkedAccounts from "../../contracts/PrizeLinkedAccounts.cdc" + +/// Projected share price information structure +access(all) struct ProjectedSharePriceInfo { + /// Share price accounting for unsynced yield/deficit in the yield source + access(all) let projectedSharePrice: UFix64 + /// Share price from last sync (cached) + access(all) let syncedSharePrice: UFix64 + + init( + projectedSharePrice: UFix64, + syncedSharePrice: UFix64 + ) { + self.projectedSharePrice = projectedSharePrice + self.syncedSharePrice = syncedSharePrice + } +} + +/// Get a pool's projected share price, accounting for unsynced yield or deficit +/// in the yield source. Returns both the projected (live) and synced (cached) share +/// price so the caller can compare. +/// +/// Parameters: +/// - poolID: The pool ID to query +/// +/// Returns: ProjectedSharePriceInfo with live and cached share price data +access(all) fun main(poolID: UInt64): ProjectedSharePriceInfo { + let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) + ?? panic("Pool does not exist") + + return ProjectedSharePriceInfo( + projectedSharePrice: poolRef.getProjectedSharePrice(), + syncedSharePrice: poolRef.getRewardsSharePrice() + ) +} diff --git a/cadence/scripts/test/get_projected_prize_pool_balance.cdc b/cadence/scripts/test/get_projected_prize_pool_balance.cdc new file mode 100644 index 0000000..db42f9d --- /dev/null +++ b/cadence/scripts/test/get_projected_prize_pool_balance.cdc @@ -0,0 +1,23 @@ +import "PrizeLinkedAccounts" + +/// Get a pool's projected prize pool balance, accounting for unsync'd yield or deficit. +/// Returns both the projected and synced prize pool balance for comparison. +/// +/// Parameters: +/// - poolID: The pool ID to query +/// +/// Returns: Dictionary with: +/// - "projectedPrizePoolBalance": Prize pool if sync happened now +/// - "syncedPrizePoolBalance": Current prize pool (last synced) +access(all) fun main(poolID: UInt64): {String: UFix64} { + let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) + ?? panic("Pool does not exist") + + let projected = poolRef.getProjectedPrizePoolBalance() + let synced = poolRef.getPrizePoolBalance() + + return { + "projectedPrizePoolBalance": projected, + "syncedPrizePoolBalance": synced + } +} diff --git a/cadence/scripts/test/get_projected_share_price.cdc b/cadence/scripts/test/get_projected_share_price.cdc new file mode 100644 index 0000000..57db2c0 --- /dev/null +++ b/cadence/scripts/test/get_projected_share_price.cdc @@ -0,0 +1,23 @@ +import "PrizeLinkedAccounts" + +/// Get a pool's projected share price, accounting for unsync'd yield or deficit. +/// Returns both the projected and synced share price for comparison. +/// +/// Parameters: +/// - poolID: The pool ID to query +/// +/// Returns: Dictionary with: +/// - "projectedSharePrice": Share price if sync happened now +/// - "syncedSharePrice": Current share price (last synced) +access(all) fun main(poolID: UInt64): {String: UFix64} { + let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) + ?? panic("Pool does not exist") + + let projected = poolRef.getProjectedSharePrice() + let synced = poolRef.getRewardsSharePrice() + + return { + "projectedSharePrice": projected, + "syncedSharePrice": synced + } +} diff --git a/cadence/tests/ProjectedBalance_test.cdc b/cadence/tests/ProjectedBalance_test.cdc index 18b26ef..c2d2739 100644 --- a/cadence/tests/ProjectedBalance_test.cdc +++ b/cadence/tests/ProjectedBalance_test.cdc @@ -12,7 +12,8 @@ import "test_helpers.cdc" // // Functions under test: // - ShareTracker.previewAccrueYield() -// - Pool.previewDeficitImpactOnRewards() +// - Pool.getProjectedDistribution() +// - Pool.getProjectedSharePrice() // - Pool.getProjectedUserBalance() // - PrizeLinkedAccounts.getProjectedUserBalance() (contract-level) // ============================================================================ diff --git a/cadence/tests/ProjectedPrizePoolAndSharePrice_test.cdc b/cadence/tests/ProjectedPrizePoolAndSharePrice_test.cdc new file mode 100644 index 0000000..5d5b2d0 --- /dev/null +++ b/cadence/tests/ProjectedPrizePoolAndSharePrice_test.cdc @@ -0,0 +1,451 @@ +import Test +import "test_helpers.cdc" + +// ============================================================================ +// PROJECTED PRIZE POOL & SHARE PRICE TEST SUITE +// ============================================================================ +// +// Tests for the projected prize pool balance and projected share price features: +// view-only preview functions that calculate what these values would be if +// syncWithYieldSource() were called right now. This enables real-time display +// without mutating contract state. +// +// Functions under test: +// - Pool.getProjectedPrizePoolBalance() +// - Pool.getProjectedSharePrice() +// - PrizeLinkedAccounts.getProjectedPrizePoolBalance() (contract-level) +// - PrizeLinkedAccounts.getProjectedSharePrice() (contract-level) +// +// The projected share price is also used internally by getProjectedUserBalance(), +// so these tests transitively validate that refactoring as well. +// ============================================================================ + +// ============================================================================ +// SETUP +// ============================================================================ + +access(all) fun setup() { + deployAllDependencies() +} + +// ============================================================================ +// TEST: Projected Prize Pool Equals Synced When No Unsync'd Yield +// ============================================================================ + +access(all) fun testProjectedPrizePoolEqualsSyncedWhenNoYield() { + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // No unsync'd yield — projected should equal synced + let result = getProjectedPrizePoolBalance(poolID) + let projected = result["projectedPrizePoolBalance"]! + let synced = result["syncedPrizePoolBalance"]! + + Test.assertEqual(projected, synced) +} + +// ============================================================================ +// TEST: Projected Prize Pool Reflects Unsync'd Yield +// ============================================================================ + +access(all) fun testProjectedPrizePoolReflectsUnsyncdYield() { + // 70% rewards, 20% prize, 10% protocol fee + let poolID = createPoolWithDistribution(rewards: 0.7, prize: 0.2, protocolFee: 0.1) + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Simulate yield appreciation WITHOUT syncing + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 10.0, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + + // Projected should be higher than synced (unsync'd yield) + let beforeSync = getProjectedPrizePoolBalance(poolID) + let projectedBefore = beforeSync["projectedPrizePoolBalance"]! + let syncedBefore = beforeSync["syncedPrizePoolBalance"]! + + Test.assert( + projectedBefore > syncedBefore, + message: "Projected prize pool should be > synced with unsync'd yield. Projected: " + .concat(projectedBefore.toString()) + .concat(", Synced: ").concat(syncedBefore.toString()) + ) + + // Now sync and verify they converge + triggerSyncWithYieldSource(poolID: poolID) + + let afterSync = getProjectedPrizePoolBalance(poolID) + let projectedAfter = afterSync["projectedPrizePoolBalance"]! + let syncedAfter = afterSync["syncedPrizePoolBalance"]! + + Test.assertEqual(projectedAfter, syncedAfter) + + // Synced after sync should match projected before sync + Test.assert( + isWithinTolerance(syncedAfter, projectedBefore, 0.00000002), + message: "Synced after sync should match projected before sync. Synced: " + .concat(syncedAfter.toString()) + .concat(", Projected was: ").concat(projectedBefore.toString()) + ) +} + +// ============================================================================ +// TEST: Projected Prize Pool Reflects Deficit +// ============================================================================ + +access(all) fun testProjectedPrizePoolReflectsDeficit() { + // 50% rewards, 20% prize, 30% protocol fee + let poolID = createPoolWithDistribution(rewards: 0.5, prize: 0.2, protocolFee: 0.3) + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // First, add yield and sync to build up prize and protocol allocations + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 20.0, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + triggerSyncWithYieldSource(poolID: poolID) + + // Verify we have prize yield allocated + let rewardsInfo = getPoolRewardsInfo(poolID) + let allocatedPrizeYield = rewardsInfo["allocatedPrizeYield"]! + Test.assert(allocatedPrizeYield > 0.0, message: "Should have prize yield allocation after sync") + + // Now simulate a deficit that fits within the prize allocation + // Prize has 20% of 20 = 4.0, protocol has 30% of 20 = 6.0 + // A deficit of 5.0 would drain protocol (6.0) first, leaving prize untouched + // A deficit of 8.0 would drain protocol (6.0) then prize (2.0 of 4.0) + let deficitAmount = 8.0 + simulateYieldDepreciation(poolIndex: poolIndex, amount: deficitAmount, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + + // Projected should be lower than synced (unsync'd deficit hits prize) + let beforeSync = getProjectedPrizePoolBalance(poolID) + let projectedBefore = beforeSync["projectedPrizePoolBalance"]! + let syncedBefore = beforeSync["syncedPrizePoolBalance"]! + + Test.assert( + projectedBefore < syncedBefore, + message: "Projected prize pool should be < synced with deficit hitting prize. Projected: " + .concat(projectedBefore.toString()) + .concat(", Synced: ").concat(syncedBefore.toString()) + ) + + // Sync and verify convergence + triggerSyncWithYieldSource(poolID: poolID) + + let afterSync = getProjectedPrizePoolBalance(poolID) + let syncedAfter = afterSync["syncedPrizePoolBalance"]! + + Test.assert( + isWithinTolerance(syncedAfter, projectedBefore, 0.00000002), + message: "Synced after sync should match projected before sync. Synced: " + .concat(syncedAfter.toString()) + .concat(", Projected was: ").concat(projectedBefore.toString()) + ) +} + +// ============================================================================ +// TEST: Projected Prize Pool Deficit Absorbed By Protocol Fee Only +// ============================================================================ + +access(all) fun testProjectedPrizePoolDeficitAbsorbedByProtocol() { + // 50% rewards, 20% prize, 30% protocol fee + let poolID = createPoolWithDistribution(rewards: 0.5, prize: 0.2, protocolFee: 0.3) + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Build up allocations + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 20.0, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + triggerSyncWithYieldSource(poolID: poolID) + + let rewardsInfo = getPoolRewardsInfo(poolID) + let allocatedPrizeYield = rewardsInfo["allocatedPrizeYield"]! + let allocatedProtocolFee = rewardsInfo["allocatedProtocolFee"]! + + // Deficit small enough to be fully absorbed by protocol fee + let smallDeficit = allocatedProtocolFee * 0.5 + simulateYieldDepreciation(poolIndex: poolIndex, amount: smallDeficit, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + + // Projected prize pool should equal synced — deficit absorbed by protocol, not prize + let result = getProjectedPrizePoolBalance(poolID) + let projected = result["projectedPrizePoolBalance"]! + let synced = result["syncedPrizePoolBalance"]! + + Test.assert( + isWithinTolerance(projected, synced, 0.00000002), + message: "Small deficit absorbed by protocol fee should not affect prize pool. Projected: " + .concat(projected.toString()) + .concat(", Synced: ").concat(synced.toString()) + ) +} + +// ============================================================================ +// TEST: Projected Share Price Equals Synced When No Unsync'd Yield +// ============================================================================ + +access(all) fun testProjectedSharePriceEqualsSyncedWhenNoYield() { + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // No unsync'd yield — projected should equal synced + let result = getProjectedSharePrice(poolID) + let projected = result["projectedSharePrice"]! + let synced = result["syncedSharePrice"]! + + Test.assertEqual(projected, synced) +} + +// ============================================================================ +// TEST: Projected Share Price Reflects Unsync'd Yield +// ============================================================================ + +access(all) fun testProjectedSharePriceReflectsUnsyncdYield() { + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Simulate yield appreciation WITHOUT syncing + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 10.0, vaultPrefix: "testYieldVaultShort_") + + // Projected should be higher than synced (unsync'd yield increases share price) + let beforeSync = getProjectedSharePrice(poolID) + let projectedBefore = beforeSync["projectedSharePrice"]! + let syncedBefore = beforeSync["syncedSharePrice"]! + + Test.assert( + projectedBefore > syncedBefore, + message: "Projected share price should be > synced with unsync'd yield. Projected: " + .concat(projectedBefore.toString()) + .concat(", Synced: ").concat(syncedBefore.toString()) + ) + + // Now sync and verify they converge + triggerSyncWithYieldSource(poolID: poolID) + + let afterSync = getProjectedSharePrice(poolID) + let projectedAfter = afterSync["projectedSharePrice"]! + let syncedAfter = afterSync["syncedSharePrice"]! + + Test.assertEqual(projectedAfter, syncedAfter) + + // Synced after sync should match projected before sync + Test.assert( + isWithinTolerance(syncedAfter, projectedBefore, 0.00000002), + message: "Synced share price after sync should match projected before sync. Synced: " + .concat(syncedAfter.toString()) + .concat(", Projected was: ").concat(projectedBefore.toString()) + ) +} + +// ============================================================================ +// TEST: Projected Share Price Reflects Deficit +// ============================================================================ + +access(all) fun testProjectedSharePriceReflectsDeficit() { + // 70% rewards, 20% prize, 10% protocol fee + let poolID = createPoolWithDistribution(rewards: 0.7, prize: 0.2, protocolFee: 0.1) + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Simulate depreciation WITHOUT syncing + let poolIndex = Int(poolID) + simulateYieldDepreciation(poolIndex: poolIndex, amount: 5.0, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + + // Projected should be lower than synced (deficit reduces share price) + let beforeSync = getProjectedSharePrice(poolID) + let projectedBefore = beforeSync["projectedSharePrice"]! + let syncedBefore = beforeSync["syncedSharePrice"]! + + Test.assert( + projectedBefore < syncedBefore, + message: "Projected share price should be < synced with deficit. Projected: " + .concat(projectedBefore.toString()) + .concat(", Synced: ").concat(syncedBefore.toString()) + ) + + // Sync and verify convergence + triggerSyncWithYieldSource(poolID: poolID) + + let afterSync = getProjectedSharePrice(poolID) + let syncedAfter = afterSync["syncedSharePrice"]! + + Test.assert( + isWithinTolerance(syncedAfter, projectedBefore, 0.00000002), + message: "Synced share price after sync should match projected before sync. Synced: " + .concat(syncedAfter.toString()) + .concat(", Projected was: ").concat(projectedBefore.toString()) + ) +} + +// ============================================================================ +// TEST: Projected Share Price Deficit Absorbed By Protocol Fee +// ============================================================================ + +access(all) fun testProjectedSharePriceDeficitAbsorbedByProtocol() { + // 50% rewards, 20% prize, 30% protocol fee + let poolID = createPoolWithDistribution(rewards: 0.5, prize: 0.2, protocolFee: 0.3) + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Build up allocations + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 20.0, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + triggerSyncWithYieldSource(poolID: poolID) + + let rewardsInfo = getPoolRewardsInfo(poolID) + let allocatedProtocolFee = rewardsInfo["allocatedProtocolFee"]! + + // Record synced share price after yield sync + let syncedInfo = getProjectedSharePrice(poolID) + let syncedSharePrice = syncedInfo["syncedSharePrice"]! + + // Deficit small enough to be fully absorbed by protocol fee + let smallDeficit = allocatedProtocolFee * 0.5 + simulateYieldDepreciation(poolIndex: poolIndex, amount: smallDeficit, vaultPrefix: VAULT_PREFIX_DISTRIBUTION) + + // Projected share price should equal synced — deficit absorbed by protocol, not rewards + let result = getProjectedSharePrice(poolID) + let projected = result["projectedSharePrice"]! + + Test.assert( + isWithinTolerance(projected, syncedSharePrice, 0.00000002), + message: "Small deficit absorbed by protocol fee should not affect share price. Projected: " + .concat(projected.toString()) + .concat(", Synced: ").concat(syncedSharePrice.toString()) + ) +} + +// ============================================================================ +// TEST: Below-Threshold Difference Returns Synced Values +// ============================================================================ + +access(all) fun testBelowThresholdReturnsSynced() { + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // No yield manipulation — difference is 0 (below threshold) + // Both projected values should exactly equal synced values + let prizeResult = getProjectedPrizePoolBalance(poolID) + Test.assertEqual( + prizeResult["projectedPrizePoolBalance"]!, + prizeResult["syncedPrizePoolBalance"]! + ) + + let priceResult = getProjectedSharePrice(poolID) + Test.assertEqual( + priceResult["projectedSharePrice"]!, + priceResult["syncedSharePrice"]! + ) +} + +// ============================================================================ +// TEST: Projected User Balance Still Works After Refactor +// ============================================================================ + +access(all) fun testProjectedUserBalanceUsesProjectedSharePrice() { + // Verify that getProjectedUserBalance still produces correct results + // after being refactored to delegate to getProjectedSharePrice. + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Add unsync'd yield + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 10.0, vaultPrefix: "testYieldVaultShort_") + + // Get projected balance and projected share price independently + let balanceResult = getProjectedBalance(user.address, poolID) + let projectedBalance = balanceResult["projectedBalance"]! + let actualBalance = balanceResult["actualBalance"]! + let shares = balanceResult["shares"]! + + let priceResult = getProjectedSharePrice(poolID) + let projectedSharePrice = priceResult["projectedSharePrice"]! + + // Projected balance should equal shares * projected share price + Test.assert( + isWithinTolerance(projectedBalance, shares * projectedSharePrice, 0.00000002), + message: "Projected balance should equal shares * projected share price. Balance: " + .concat(projectedBalance.toString()) + .concat(", Shares * Price: ").concat((shares * projectedSharePrice).toString()) + ) + + // Projected should be higher than actual + Test.assert( + projectedBalance > actualBalance, + message: "Projected balance should be > actual with unsync'd yield" + ) + + // Sync and verify convergence + triggerSyncWithYieldSource(poolID: poolID) + + let afterSync = getProjectedBalance(user.address, poolID) + let projectedAfter = afterSync["projectedBalance"]! + let actualAfter = afterSync["actualBalance"]! + + Test.assertEqual(projectedAfter, actualAfter) +} + +// ============================================================================ +// TEST: Projected Prize Pool with Direct Funding +// ============================================================================ + +access(all) fun testProjectedPrizePoolWithDirectFunding() { + let poolID = createTestPoolWithShortInterval() + + let user = Test.createAccount() + setupUserWithFundsAndCollection(user, amount: 110.0) + depositToPool(user, poolID: poolID, amount: 100.0) + + // Directly fund the prize pool (this goes to allocatedPrizeYield, not the vault) + fundPrizePool(poolID, amount: 50.0) + + // Synced should reflect the direct funding immediately + let beforeYield = getProjectedPrizePoolBalance(poolID) + let syncedBefore = beforeYield["syncedPrizePoolBalance"]! + let projectedBefore = beforeYield["projectedPrizePoolBalance"]! + + // Direct funding is already in allocatedPrizeYield, so projected == synced + Test.assertEqual(projectedBefore, syncedBefore) + + // Now add unsync'd yield on top + let poolIndex = Int(poolID) + simulateYieldAppreciation(poolIndex: poolIndex, amount: 10.0, vaultPrefix: "testYieldVaultShort_") + + // Projected should now be higher (direct funding + projected yield portion) + let afterYield = getProjectedPrizePoolBalance(poolID) + let projectedAfter = afterYield["projectedPrizePoolBalance"]! + let syncedAfter = afterYield["syncedPrizePoolBalance"]! + + Test.assert( + projectedAfter > syncedAfter, + message: "Projected should be > synced after unsync'd yield on top of direct funding. Projected: " + .concat(projectedAfter.toString()) + .concat(", Synced: ").concat(syncedAfter.toString()) + ) + + // Synced should still equal the direct funding amount (no yield synced yet) + Test.assertEqual(syncedAfter, syncedBefore) +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 30ddfea..cad1c9c 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -1104,6 +1104,20 @@ fun getProjectedBalance(_ userAddress: Address, _ poolID: UInt64): {String: UFix return scriptResult.returnValue! as! {String: UFix64} } +access(all) +fun getProjectedPrizePoolBalance(_ poolID: UInt64): {String: UFix64} { + let scriptResult = _executeScript("../scripts/test/get_projected_prize_pool_balance.cdc", [poolID]) + Test.expect(scriptResult, Test.beSucceeded()) + return scriptResult.returnValue! as! {String: UFix64} +} + +access(all) +fun getProjectedSharePrice(_ poolID: UInt64): {String: UFix64} { + let scriptResult = _executeScript("../scripts/test/get_projected_share_price.cdc", [poolID]) + Test.expect(scriptResult, Test.beSucceeded()) + return scriptResult.returnValue! as! {String: UFix64} +} + // ============================================================================ // PRECISION TESTING HELPERS // ============================================================================