Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 142 additions & 56 deletions cadence/contracts/PrizeLinkedAccounts.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
)
}
Original file line number Diff line number Diff line change
@@ -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()
)
}
23 changes: 23 additions & 0 deletions cadence/scripts/test/get_projected_prize_pool_balance.cdc
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions cadence/scripts/test/get_projected_share_price.cdc
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion cadence/tests/ProjectedBalance_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ============================================================================
Expand Down
Loading