Smart Contract

PrizeLinkedAccounts

A.a092c4aab33daeda.PrizeLinkedAccounts

Valid From

143,518,272

Deployed

2w ago
Feb 10, 2026, 10:27:36 PM UTC

Dependents

19 imports
1/*
2PrizeLinkedAccounts - Prize-Linked Accounts Protocol
3
4Deposit tokens into a pool to earn rewards and prizes.  Aggregated deposits are deposited into a yield generating
5source and the yield is distributed to the depositors based on a configurable distribution strategy.
6
7Architecture:
8- ERC4626-style shares with virtual offset protection (inflation attack resistant)
9- TWAB (time-weighted average balance) using normalized weights for fair prize weighting
10- On-chain randomness via Flow's RandomConsumer
11- Modular yield sources via DeFi Actions interface
12- Configurable distribution strategies (rewards/prize/protocolFee split)
13- Pluggable winner selection (weighted single, multi-winner, fixed tiers)
14- Resource-based position ownership via PoolPositionCollection
15- Emergency mode with auto-recovery and health monitoring
16- NFT prize support with pending claims
17- Direct funding for external sponsors
18- Bonus prize weights for promotions
19- Winner tracking integration for leaderboards
20
21Prize Fairness:
22- Uses normalized TWAB (average shares over time) for prize weighting
23- Share-based TWAB is stable against price fluctuations (yield/loss)
24- Rewards commitment: longer deposits = higher time-weighted average
25- Early depositors get more shares per dollar, increasing prize weight
26- Supports unlimited TVL and any round duration within UFix64 limits
27
28Core Components:
29- ShareTracker: Manages share-based accounting
30- PrizeDistributor: Prize pool, NFT prizes, and draw execution
31- Pool: Deposits, withdrawals, yield processing, and prize draws
32- PoolPositionCollection: User's resource for interacting with pools
33- Admin: Pool configuration and emergency operations
34*/
35
36import FungibleToken from 0xf233dcee88fe0abe
37import NonFungibleToken from 0x1d7e57aa55817448
38import RandomConsumer from 0x45caec600164c9e6
39import DeFiActions from 0x92195d814edf9cb0
40import DeFiActionsUtils from 0x92195d814edf9cb0
41import Xorshift128plus from 0x45caec600164c9e6
42
43access(all) contract PrizeLinkedAccounts {    
44    /// Entitlement for configuration operations (non-destructive admin actions).
45    /// Examples: updating draw intervals, processing rewards, managing bonus weights.
46    access(all) entitlement ConfigOps
47    
48    /// Entitlement for critical operations (potentially destructive admin actions).
49    /// Examples: creating pools, enabling emergency mode, starting/completing draws.
50    access(all) entitlement CriticalOps
51    
52    /// Entitlement reserved exclusively for account owner operations.
53    /// SECURITY: Never issue capabilities with this entitlement - it protects
54    /// protocol fee recipient configuration which could redirect funds if compromised.
55    access(all) entitlement OwnerOnly
56    
57    /// Entitlement for user position operations (deposit, withdraw, claim).
58    /// Users must have this entitlement to interact with their pool positions.
59    access(all) entitlement PositionOps
60    
61    // ============================================================
62    // CONSTANTS
63    // ============================================================
64    
65    /// Virtual offset constant for ERC4626 inflation attack protection (shares).
66    /// Creates "dead" shares that prevent share price manipulation by early depositors.
67    /// Using 0.0001 to minimize user dilution (~0.0001%) while maintaining security.
68    /// See: https://blog.openzeppelin.com/a-]novel-defense-against-erc4626-inflation-attacks
69    access(all) let VIRTUAL_SHARES: UFix64
70    
71    /// Virtual offset constant for ERC4626 inflation attack protection (assets).
72    /// Works in tandem with VIRTUAL_SHARES to ensure share price starts near 1.0.
73    access(all) let VIRTUAL_ASSETS: UFix64
74    
75    /// Minimum yield amount required to trigger distribution.
76    /// Amounts below this threshold remain in the yield source and accumulate
77    /// until the next sync when they may exceed the threshold.
78    /// 
79    /// Set to 100x minimum UFix64 (0.000001) to ensure:
80    /// - All percentage buckets receive non-zero allocations (even 1% buckets)
81    /// - No precision loss from UFix64 rounding during distribution
82    /// - Negligible economic impact (~$0.000002 at $2/FLOW)
83    access(all) let MINIMUM_DISTRIBUTION_THRESHOLD: UFix64
84
85    /// Maximum value for UFix64 type (≈ 184.467 billion).
86    /// Used for percentage calculations in weight warnings.
87    access(all) let UFIX64_MAX: UFix64
88    
89    /// Warning threshold for normalized weight values (90% of UFix64 max).
90    /// If totalWeight exceeds this during batch processing, emit a warning event.
91    /// With normalized TWAB this should never be reached in practice, but provides safety.
92    access(all) let WEIGHT_WARNING_THRESHOLD: UFix64
93
94    /// Maximum total value locked (TVL) per pool (80% of UFix64 max ≈ 147 billion).
95    /// Deposits that would exceed this limit are rejected to prevent UFix64 overflow.
96    /// This provides a safety margin for yield accrual and weight calculations.
97    access(all) let SAFE_MAX_TVL: UFix64
98
99    /// Maximum bonus weight that can be assigned to a single user for promotional purposes.
100    access(all) let MAX_BONUS_WEIGHT_PER_USER: UFix64
101
102    // ============================================================
103    // STORAGE PATHS
104    // ============================================================
105    
106    /// Storage path where users store their PoolPositionCollection resource.
107    access(all) let PoolPositionCollectionStoragePath: StoragePath
108    
109    /// Public path for PoolPositionCollection capability (read-only access).
110    access(all) let PoolPositionCollectionPublicPath: PublicPath
111    
112    /// Storage path where users store their SponsorPositionCollection resource.
113    /// Sponsors earn rewards yield but are NOT eligible to win prizes.
114    access(all) let SponsorPositionCollectionStoragePath: StoragePath
115    
116    /// Public path for SponsorPositionCollection capability (read-only access).
117    access(all) let SponsorPositionCollectionPublicPath: PublicPath
118
119    /// Storage path for the Admin resource.
120    access(all) let AdminStoragePath: StoragePath
121    
122    // ============================================================
123    // EVENTS - Core Operations
124    // ============================================================
125    
126    /// Emitted when a new prize pool is created.
127    /// @param poolID - Unique identifier for the pool
128    /// @param assetType - Type identifier of the fungible token (e.g., "A.xxx.FlowToken.Vault")
129    /// @param strategy - Name of the distribution strategy in use
130    access(all) event PoolCreated(poolID: UInt64, assetType: String, strategy: String)
131    
132    /// Emitted when a user deposits funds into a pool.
133    /// @param poolID - Pool receiving the deposit
134    /// @param receiverID - UUID of the user's PoolPositionCollection
135    /// @param amount - Amount deposited
136    /// @param shares - Number of shares minted
137    /// @param ownerAddress - Current owner address of the PoolPositionCollection (nil if resource transferred/not stored)
138    access(all) event Deposited(poolID: UInt64, receiverID: UInt64, amount: UFix64, shares: UFix64, ownerAddress: Address?)
139    
140    /// Emitted when a sponsor deposits funds (prize-ineligible).
141    /// Sponsors earn rewards yield but cannot win prizes.
142    /// @param poolID - Pool receiving the deposit
143    /// @param receiverID - UUID of the sponsor's SponsorPositionCollection
144    /// @param amount - Amount deposited
145    /// @param shares - Number of shares minted
146    /// @param ownerAddress - Current owner address of the SponsorPositionCollection (nil if resource transferred/not stored)
147    access(all) event SponsorDeposited(poolID: UInt64, receiverID: UInt64, amount: UFix64, shares: UFix64, ownerAddress: Address?)
148    
149    /// Emitted when a user withdraws funds from a pool.
150    /// @param poolID - Pool being withdrawn from
151    /// @param receiverID - UUID of the user's PoolPositionCollection
152    /// @param requestedAmount - Amount the user requested to withdraw
153    /// @param actualAmount - Amount actually withdrawn (may be less if yield source has insufficient liquidity)
154    /// @param ownerAddress - Current owner address of the PoolPositionCollection (nil if resource transferred/not stored)
155    access(all) event Withdrawn(poolID: UInt64, receiverID: UInt64, requestedAmount: UFix64, actualAmount: UFix64, ownerAddress: Address?)
156
157    /// Emitted when a deposit to the yield source results in less value than nominal.
158    /// This occurs due to ERC4626 share conversion slippage, fees, or buffering.
159    /// @param poolID - Pool where deposit occurred
160    /// @param nominalAmount - Amount sent to yield source
161    /// @param actualReceived - Amount actually credited by yield source
162    /// @param slippage - Difference (nominalAmount - actualReceived)
163    access(all) event DepositSlippage(poolID: UInt64, nominalAmount: UFix64, actualReceived: UFix64, slippage: UFix64)
164
165    // ============================================================
166    // EVENTS - Reward Processing
167    // ============================================================
168    
169    /// Emitted when yield rewards are processed and distributed.
170    /// @param poolID - Pool processing rewards
171    /// @param totalAmount - Total yield amount processed
172    /// @param rewardsAmount - Portion allocated to rewards (auto-compounds)
173    /// @param prizeAmount - Portion allocated to prize pool
174    access(all) event RewardsProcessed(poolID: UInt64, totalAmount: UFix64, rewardsAmount: UFix64, prizeAmount: UFix64)
175    
176    /// Emitted when rewards yield is accrued to the share price.
177    /// @param poolID - Pool accruing yield
178    /// @param amount - Amount of yield accrued (increases share price for all depositors)
179    access(all) event RewardsYieldAccrued(poolID: UInt64, amount: UFix64)
180    
181    /// Emitted when a deficit is applied across allocations.
182    /// @param poolID - Pool experiencing the deficit
183    /// @param totalDeficit - Total deficit amount detected
184    /// @param absorbedByProtocolFee - Amount absorbed by pending protocol fee
185    /// @param absorbedByPrize - Amount absorbed by pending prize yield
186    /// @param absorbedByRewards - Amount absorbed by rewards (decreases share price)
187    access(all) event DeficitApplied(poolID: UInt64, totalDeficit: UFix64, absorbedByProtocolFee: UFix64, absorbedByPrize: UFix64, absorbedByRewards: UFix64)
188
189    /// Emitted when a deficit cannot be fully reconciled (pool insolvency).
190    /// This means protocol fee, prize, and rewards were all exhausted but deficit remains.
191    /// @param poolID - Pool experiencing insolvency
192    /// @param unreconciledAmount - Deficit amount that could not be absorbed
193    access(all) event InsolvencyDetected(poolID: UInt64, unreconciledAmount: UFix64)
194    
195    /// Emitted when rounding dust from rewards distribution is sent to protocol fee.
196    /// This occurs due to virtual shares absorbing a tiny fraction of yield.
197    /// @param poolID - Pool generating dust
198    /// @param amount - Dust amount routed to protocol fee
199    access(all) event RewardsRoundingDustToProtocolFee(poolID: UInt64, amount: UFix64)
200    
201    // ============================================================
202    // EVENTS - Prize/Draw
203    // ============================================================
204    
205    /// Emitted when prizes are awarded to winners.
206    /// @param poolID - Pool awarding prizes
207    /// @param winners - Array of winner receiverIDs
208    /// @param winnerAddresses - Array of winner addresses (nil = resource transferred/destroyed, parallel to winners)
209    /// @param amounts - Array of prize amounts (parallel to winners)
210    /// @param round - Draw round number
211    access(all) event PrizesAwarded(poolID: UInt64, winners: [UInt64], winnerAddresses: [Address?], amounts: [UFix64], round: UInt64)
212    
213    /// Emitted when the prize pool receives funding.
214    /// @param poolID - Pool receiving funds
215    /// @param amount - Amount added to prize pool
216    /// @param source - Source of funding (e.g., "yield_pending", "direct")
217    access(all) event PrizePoolFunded(poolID: UInt64, amount: UFix64, source: String)
218    
219    /// Emitted when batch draw processing begins.
220    /// @param poolID - ID of the pool
221    /// @param endedRoundID - Round that ended and is being processed
222    /// @param newRoundID - New round that started
223    /// @param totalReceivers - Number of receivers to process in batches
224    access(all) event DrawBatchStarted(poolID: UInt64, endedRoundID: UInt64, newRoundID: UInt64, totalReceivers: Int)
225    
226    /// Emitted when a batch of receivers is processed.
227    /// @param poolID - ID of the pool
228    /// @param processed - Number processed in this batch
229    /// @param remaining - Number still to process
230    access(all) event DrawBatchProcessed(poolID: UInt64, processed: Int, remaining: Int)
231    
232    /// Emitted when batch processing completes and randomness is requested.
233    /// @param poolID - ID of the pool
234    /// @param totalWeight - Total prize weight captured
235    /// @param prizeAmount - Prize pool amount
236    /// @param commitBlock - Block where randomness was committed
237    access(all) event DrawRandomnessRequested(poolID: UInt64, totalWeight: UFix64, prizeAmount: UFix64, commitBlock: UInt64)
238
239    /// Emitted when the pool enters intermission (after completeDraw, before startNextRound).
240    /// Intermission is a normal state where deposits/withdrawals continue but no draw can occur.
241    /// @param poolID - ID of the pool
242    /// @param completedRoundID - ID of the round that just completed
243    /// @param prizePoolBalance - Current balance in the prize pool
244    access(all) event IntermissionStarted(poolID: UInt64, completedRoundID: UInt64, prizePoolBalance: UFix64)
245
246    /// Emitted when the pool exits intermission and a new round begins.
247    /// @param poolID - ID of the pool
248    /// @param newRoundID - ID of the newly started round
249    /// @param roundDuration - Duration of the new round in seconds
250    access(all) event IntermissionEnded(poolID: UInt64, newRoundID: UInt64, roundDuration: UFix64)
251
252    // ============================================================
253    // EVENTS - Admin Configuration Changes
254    // ============================================================
255    
256    /// Emitted when the distribution strategy is updated.
257    /// @param poolID - ID of the pool being configured
258    /// @param oldStrategy - Name of the previous distribution strategy
259    /// @param newStrategy - Name of the new distribution strategy
260    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
261    access(all) event DistributionStrategyUpdated(poolID: UInt64, oldStrategy: String, newStrategy: String, adminUUID: UInt64)
262    
263    /// Emitted when the winner selection strategy is updated.
264    /// @param poolID - ID of the pool being configured
265    /// @param oldDistribution - Name of the previous prize distribution
266    /// @param newDistribution - Name of the new prize distribution
267    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
268    access(all) event PrizeDistributionUpdated(poolID: UInt64, oldDistribution: String, newDistribution: String, adminUUID: UInt64)
269    
270    /// Emitted when the draw interval for FUTURE rounds is changed.
271    /// This affects rounds created after the next startDraw().
272    /// Does NOT affect the current round's timing or eligibility.
273    /// @param poolID - ID of the pool being configured
274    /// @param oldInterval - Previous draw interval in seconds
275    /// @param newInterval - New draw interval in seconds
276    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
277    access(all) event FutureRoundsIntervalUpdated(poolID: UInt64, oldInterval: UFix64, newInterval: UFix64, adminUUID: UInt64)
278
279    /// Emitted when the current round's target end time is updated.
280    /// Admin can extend or shorten the current round by adjusting target end time.
281    /// Can only be changed before startDraw() is called on the round.
282    /// @param poolID - ID of the pool being configured
283    /// @param roundID - ID of the round being modified
284    /// @param oldTarget - Previous target end time
285    /// @param newTarget - New target end time
286    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
287    access(all) event RoundTargetEndTimeUpdated(poolID: UInt64, roundID: UInt64, oldTarget: UFix64, newTarget: UFix64, adminUUID: UInt64)
288
289    /// Emitted when the minimum deposit requirement is changed.
290    /// @param poolID - ID of the pool being configured
291    /// @param oldMinimum - Previous minimum deposit amount
292    /// @param newMinimum - New minimum deposit amount
293    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
294    access(all) event MinimumDepositUpdated(poolID: UInt64, oldMinimum: UFix64, newMinimum: UFix64, adminUUID: UInt64)
295    
296    /// Emitted when an admin creates a new pool.
297    /// @param poolID - ID assigned to the newly created pool
298    /// @param assetType - Type identifier of the fungible token the pool accepts
299    /// @param strategy - Name of the initial distribution strategy
300    /// @param adminUUID - UUID of the Admin resource that created the pool (audit trail)
301    access(all) event PoolCreatedByAdmin(poolID: UInt64, assetType: String, strategy: String, adminUUID: UInt64)
302    
303    /// Emitted when admin performs storage cleanup on a pool.
304    /// @param poolID - ID of the pool being cleaned
305    /// @param ghostReceiversCleaned - Number of 0-share receivers unregistered
306    /// @param userSharesCleaned - Number of 0-value userShares entries removed
307    /// @param pendingNFTClaimsCleaned - Number of empty pendingNFTClaims arrays removed
308    /// @param adminUUID - UUID of the Admin resource performing cleanup (audit trail)
309    access(all) event PoolStorageCleanedUp(
310        poolID: UInt64,
311        ghostReceiversCleaned: Int,
312        userSharesCleaned: Int,
313        pendingNFTClaimsCleaned: Int,
314        nextIndex: Int,
315        totalReceivers: Int,
316        adminUUID: UInt64
317    )
318    
319    // ============================================================
320    // EVENTS - Pool State Changes
321    // ============================================================
322    
323    /// Emitted when a pool is paused (all operations disabled).
324    /// @param poolID - Pool being paused
325    /// @param adminUUID - UUID of the Admin resource performing the pause (audit trail)
326    /// @param reason - Human-readable explanation for the pause
327    access(all) event PoolPaused(poolID: UInt64, adminUUID: UInt64, reason: String)
328    
329    /// Emitted when a pool is unpaused (returns to normal operation).
330    /// @param poolID - Pool being unpaused
331    /// @param adminUUID - UUID of the Admin resource performing the unpause (audit trail)
332    access(all) event PoolUnpaused(poolID: UInt64, adminUUID: UInt64)
333    
334    /// Emitted when the protocol fee receives funding
335    /// @param poolID - Pool whose protocol fee received funds
336    /// @param amount - Amount of tokens funded
337    /// @param source - Source of funding (e.g., "rounding_dust", "fees")
338    access(all) event ProtocolFeeFunded(poolID: UInt64, amount: UFix64, source: String)
339    
340    /// Emitted when the protocol fee recipient address is changed.
341    /// SECURITY: This is a sensitive operation - recipient receives protocol fees.
342    /// @param poolID - Pool being configured
343    /// @param newRecipient - New protocol fee recipient address (nil to disable forwarding)
344    /// @param adminUUID - UUID of the Admin resource performing the update (audit trail)
345    access(all) event ProtocolFeeRecipientUpdated(poolID: UInt64, newRecipient: Address?, adminUUID: UInt64)
346    
347    /// Emitted when protocol fee is auto-forwarded to the configured recipient.
348    /// @param poolID - Pool forwarding protocol fee
349    /// @param amount - Amount forwarded
350    /// @param recipient - Address receiving the funds
351    access(all) event ProtocolFeeForwarded(poolID: UInt64, amount: UFix64, recipient: Address)
352    
353    // ============================================================
354    // EVENTS - Bonus Weight Management
355    // ============================================================
356    
357    /// Emitted when a user's bonus prize weight is set (replaces existing).
358    /// Bonus weights increase prize odds for promotional purposes.
359    /// @param poolID - Pool where bonus is being set
360    /// @param receiverID - UUID of the user's PoolPositionCollection receiving the bonus
361    /// @param bonusWeight - New bonus weight value (replaces any existing bonus)
362    /// @param reason - Human-readable explanation for the bonus (e.g., "referral", "promotion")
363    /// @param adminUUID - UUID of the Admin resource setting the bonus (audit trail)
364    /// @param timestamp - Block timestamp when the bonus was set
365    /// @param ownerAddress - Address of the user receiving the bonus (for dashboard tracking)
366    access(all) event BonusPrizeWeightSet(poolID: UInt64, receiverID: UInt64, bonusWeight: UFix64, reason: String, adminUUID: UInt64, timestamp: UFix64, ownerAddress: Address?)
367    
368    /// Emitted when additional bonus weight is added to a user's existing bonus.
369    /// @param poolID - Pool where bonus is being added
370    /// @param receiverID - UUID of the user's PoolPositionCollection receiving additional bonus
371    /// @param additionalWeight - Amount of weight being added
372    /// @param newTotalBonus - User's new total bonus weight after addition
373    /// @param reason - Human-readable explanation for the bonus addition
374    /// @param adminUUID - UUID of the Admin resource adding the bonus (audit trail)
375    /// @param timestamp - Block timestamp when the bonus was added
376    /// @param ownerAddress - Address of the user receiving the bonus (for dashboard tracking)
377    access(all) event BonusPrizeWeightAdded(poolID: UInt64, receiverID: UInt64, additionalWeight: UFix64, newTotalBonus: UFix64, reason: String, adminUUID: UInt64, timestamp: UFix64, ownerAddress: Address?)
378    
379    /// Emitted when a user's bonus prize weight is completely removed.
380    /// @param poolID - Pool where bonus is being removed
381    /// @param receiverID - UUID of the user's PoolPositionCollection losing the bonus
382    /// @param previousBonus - Bonus weight that was removed
383    /// @param adminUUID - UUID of the Admin resource removing the bonus (audit trail)
384    /// @param timestamp - Block timestamp when the bonus was removed
385    /// @param ownerAddress - Address of the user losing the bonus (for dashboard tracking)
386    access(all) event BonusPrizeWeightRemoved(poolID: UInt64, receiverID: UInt64, previousBonus: UFix64, adminUUID: UInt64, timestamp: UFix64, ownerAddress: Address?)
387    
388    // ============================================================
389    // EVENTS - NFT Prize Management
390    // ============================================================
391    
392    /// Emitted when an NFT is deposited as a potential prize.
393    /// @param poolID - Pool receiving the NFT prize
394    /// @param nftID - UUID of the deposited NFT
395    /// @param nftType - Type identifier of the NFT (e.g., "A.xxx.ExampleNFT.NFT")
396    /// @param adminUUID - UUID of the Admin resource depositing the NFT (audit trail)
397    access(all) event NFTPrizeDeposited(poolID: UInt64, nftID: UInt64, nftType: String, adminUUID: UInt64)
398    
399    /// Emitted when an NFT prize is awarded to a winner.
400    /// @param poolID - Pool awarding the NFT
401    /// @param receiverID - UUID of the winner's PoolPositionCollection
402    /// @param nftID - UUID of the awarded NFT
403    /// @param nftType - Type identifier of the NFT
404    /// @param round - Draw round number when the NFT was awarded
405    /// @param ownerAddress - Address of the winner (for dashboard tracking)
406    access(all) event NFTPrizeAwarded(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String, round: UInt64, ownerAddress: Address?)
407    
408    /// Emitted when an NFT is stored in pending claims for a winner.
409    /// NFTs are stored rather than directly transferred since we don't have winner's collection reference.
410    /// @param poolID - Pool storing the pending NFT
411    /// @param receiverID - UUID of the winner's PoolPositionCollection
412    /// @param nftID - UUID of the stored NFT
413    /// @param nftType - Type identifier of the NFT
414    /// @param reason - Explanation for why NFT is pending (e.g., "prize_win")
415    /// @param ownerAddress - Address of the winner (for dashboard tracking)
416    access(all) event NFTPrizeStored(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String, reason: String, ownerAddress: Address?)
417    
418    /// Emitted when a winner claims their pending NFT prize.
419    /// @param poolID - Pool from which NFT is claimed
420    /// @param receiverID - UUID of the claimant's PoolPositionCollection
421    /// @param nftID - UUID of the claimed NFT
422    /// @param nftType - Type identifier of the NFT
423    /// @param ownerAddress - Address of the claimant (for dashboard tracking)
424    access(all) event NFTPrizeClaimed(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String, ownerAddress: Address?)
425    
426    /// Emitted when an admin withdraws an NFT prize (before it's awarded).
427    /// @param poolID - Pool from which NFT is withdrawn
428    /// @param nftID - UUID of the withdrawn NFT
429    /// @param nftType - Type identifier of the NFT
430    /// @param adminUUID - UUID of the Admin resource withdrawing the NFT (audit trail)
431    access(all) event NFTPrizeWithdrawn(poolID: UInt64, nftID: UInt64, nftType: String, adminUUID: UInt64)
432    
433    // ============================================================
434    // EVENTS - Health & Monitoring
435    // ============================================================
436    
437    /// Emitted when totalWeight during batch processing exceeds the warning threshold.
438    /// This is a proactive alert that the system is approaching capacity limits.
439    /// Provides early warning for operational monitoring.
440    /// @param poolID - Pool with high weight
441    /// @param totalWeight - Current total weight value
442    /// @param warningThreshold - Threshold that was exceeded
443    /// @param percentOfMax - Approximate percentage of UFix64 max
444    access(all) event WeightWarningThresholdExceeded(poolID: UInt64, totalWeight: UFix64, warningThreshold: UFix64, percentOfMax: UFix64)
445    
446    // ============================================================
447    // EVENTS - Emergency Mode
448    // ============================================================
449    
450    /// Emitted when emergency mode is enabled by an admin.
451    /// Emergency mode: only withdrawals allowed, no deposits or draws.
452    /// @param poolID - Pool entering emergency mode
453    /// @param reason - Human-readable explanation for enabling emergency mode
454    /// @param adminUUID - UUID of the Admin resource enabling emergency mode (audit trail)
455    /// @param timestamp - Block timestamp when emergency mode was enabled
456    access(all) event PoolEmergencyEnabled(poolID: UInt64, reason: String, adminUUID: UInt64, timestamp: UFix64)
457    
458    /// Emitted when emergency mode is disabled by an admin.
459    /// @param poolID - Pool exiting emergency mode
460    /// @param adminUUID - UUID of the Admin resource disabling emergency mode (audit trail)
461    /// @param timestamp - Block timestamp when emergency mode was disabled
462    access(all) event PoolEmergencyDisabled(poolID: UInt64, adminUUID: UInt64, timestamp: UFix64)
463    
464    /// Emitted when partial mode is enabled (limited deposits, no draws).
465    /// @param poolID - Pool entering partial mode
466    /// @param reason - Human-readable explanation for enabling partial mode
467    /// @param adminUUID - UUID of the Admin resource enabling partial mode (audit trail)
468    /// @param timestamp - Block timestamp when partial mode was enabled
469    access(all) event PoolPartialModeEnabled(poolID: UInt64, reason: String, adminUUID: UInt64, timestamp: UFix64)
470    
471    /// Emitted when emergency mode is auto-triggered due to health checks.
472    /// Auto-triggers: low yield source health, consecutive withdrawal failures.
473    /// @param poolID - Pool auto-entering emergency mode
474    /// @param reason - Explanation for auto-trigger (e.g., "low_health_score", "withdrawal_failures")
475    /// @param healthScore - Current health score that triggered emergency mode (0.0-1.0)
476    /// @param timestamp - Block timestamp when auto-triggered
477    access(all) event EmergencyModeAutoTriggered(poolID: UInt64, reason: String, healthScore: UFix64, timestamp: UFix64)
478    
479    /// Emitted when the pool auto-recovers from emergency mode.
480    /// Recovery occurs when yield source health returns to normal.
481    /// @param poolID - Pool auto-recovering from emergency mode
482    /// @param reason - Explanation for recovery (e.g., "health_restored")
483    /// @param healthScore - Current health score (nil if not applicable)
484    /// @param duration - How long the pool was in emergency mode in seconds (nil if not tracked)
485    /// @param timestamp - Block timestamp when auto-recovered
486    access(all) event EmergencyModeAutoRecovered(poolID: UInt64, reason: String, healthScore: UFix64?, duration: UFix64?, timestamp: UFix64)
487    
488    /// Emitted when emergency configuration is updated.
489    /// @param poolID - Pool whose emergency config was updated
490    /// @param adminUUID - UUID of the Admin resource updating the config (audit trail)
491    access(all) event EmergencyConfigUpdated(poolID: UInt64, adminUUID: UInt64)
492    
493    /// Emitted when a withdrawal fails (usually due to yield source liquidity issues).
494    /// Multiple consecutive failures may trigger emergency mode.
495    /// @param poolID - Pool where withdrawal failed
496    /// @param receiverID - UUID of the user's PoolPositionCollection attempting withdrawal
497    /// @param amount - Amount the user attempted to withdraw
498    /// @param consecutiveFailures - Running count of consecutive withdrawal failures
499    /// @param yieldAvailable - Amount currently available in yield source
500    /// @param ownerAddress - Address of the user experiencing the failure (for dashboard tracking)
501    access(all) event WithdrawalFailure(poolID: UInt64, receiverID: UInt64, amount: UFix64, consecutiveFailures: Int, yieldAvailable: UFix64, ownerAddress: Address?)
502    
503    // ============================================================
504    // EVENTS - Direct Funding
505    // ============================================================
506    
507    /// Emitted when an admin directly funds a pool component.
508    /// Used for external sponsorships or manual prize pool funding.
509    /// @param poolID - Pool receiving the direct funding
510    /// @param destination - Numeric destination code: 0=Rewards, 1=Prize (see PoolFundingDestination enum)
511    /// @param destinationName - Human-readable destination name (e.g., "Rewards", "Prize")
512    /// @param amount - Amount of tokens being funded
513    /// @param adminUUID - UUID of the Admin resource performing the funding (audit trail)
514    /// @param purpose - Human-readable explanation for the funding (e.g., "weekly_sponsorship")
515    /// @param metadata - Additional key-value metadata for the funding event
516    access(all) event DirectFundingReceived(poolID: UInt64, destination: UInt8, destinationName: String, amount: UFix64, adminUUID: UInt64, purpose: String, metadata: {String: String})
517    
518    // ============================================================
519    // CONTRACT STATE
520    // ============================================================
521    
522    /// Mapping of pool IDs to Pool resources.
523    access(self) var pools: @{UInt64: Pool}
524    
525    /// Auto-incrementing counter for generating unique pool IDs.
526    access(self) var nextPoolID: UInt64
527    
528    // ============================================================
529    // ENUMS
530    // ============================================================
531    
532    /// Represents the operational state of a pool.
533    /// Determines which operations are allowed.
534    access(all) enum PoolEmergencyState: UInt8 {
535        /// Normal operation - all functions available
536        access(all) case Normal
537        /// Completely paused - no operations allowed
538        access(all) case Paused
539        /// Emergency mode - only withdrawals allowed, no deposits or draws
540        access(all) case EmergencyMode
541        /// Partial mode - limited deposits (up to configured limit), no draws
542        access(all) case PartialMode
543    }
544    
545    /// Specifies the destination for direct funding operations.
546    /// Used by admin's fundPoolDirect() function.
547    access(all) enum PoolFundingDestination: UInt8 {
548        /// Fund the share tracker (increases share price for all users)
549        access(all) case Rewards
550        /// Fund the prize pool (available for next draw)
551        access(all) case Prize
552    }
553    
554    // ============================================================
555    // STRUCTS
556    // ============================================================
557    
558    /// Configuration parameters for emergency mode behavior.
559    /// Controls auto-triggering, auto-recovery, and partial mode limits.
560    access(all) struct EmergencyConfig {
561        /// Maximum time (seconds) to stay in emergency mode before auto-recovery.
562        /// nil = no time limit, manual intervention required.
563        access(all) let maxEmergencyDuration: UFix64?
564        
565        /// Whether the pool should attempt to auto-recover from emergency mode
566        /// when health metrics improve.
567        access(all) let autoRecoveryEnabled: Bool
568        
569        /// Minimum yield source health score (0.0-1.0) below which emergency triggers.
570        /// Health is calculated from balance ratio and withdrawal success rate.
571        access(all) let minYieldSourceHealth: UFix64
572        
573        /// Number of consecutive withdrawal failures that triggers emergency mode.
574        access(all) let maxWithdrawFailures: Int
575        
576        /// Maximum deposit amount allowed during partial mode.
577        /// nil = deposits disabled in partial mode.
578        access(all) let partialModeDepositLimit: UFix64?
579        
580        /// Minimum ratio of yield source balance to userPoolBalance (0.8-1.0).
581        /// Below this ratio, health score is reduced.
582        access(all) let minBalanceThreshold: UFix64
583        
584        /// Minimum health score required for time-based auto-recovery.
585        /// Prevents recovery when yield source is still critically unhealthy.
586        access(all) let minRecoveryHealth: UFix64
587        
588        /// Creates an EmergencyConfig with validated parameters.
589        /// @param maxEmergencyDuration - Max seconds in emergency (nil = unlimited)
590        /// @param autoRecoveryEnabled - Enable auto-recovery on health improvement
591        /// @param minYieldSourceHealth - Health threshold for emergency trigger (0.0-1.0)
592        /// @param maxWithdrawFailures - Failure count before emergency (must be >= 1)
593        /// @param partialModeDepositLimit - Deposit limit in partial mode (nil = disabled)
594        /// @param minBalanceThreshold - Balance ratio for health calc (0.8-1.0)
595        /// @param minRecoveryHealth - Min health for time-based recovery (0.0-1.0)
596        init(
597            maxEmergencyDuration: UFix64?,
598            autoRecoveryEnabled: Bool,
599            minYieldSourceHealth: UFix64,
600            maxWithdrawFailures: Int,
601            partialModeDepositLimit: UFix64?,
602            minBalanceThreshold: UFix64,
603            minRecoveryHealth: UFix64?
604        ) {
605            pre {
606                minYieldSourceHealth >= 0.0 && minYieldSourceHealth <= 1.0: "minYieldSourceHealth must be between 0.0 and 1.0 but got \(minYieldSourceHealth)"
607                maxWithdrawFailures > 0: "maxWithdrawFailures must be at least 1 but got \(maxWithdrawFailures)"
608                minBalanceThreshold >= 0.8 && minBalanceThreshold <= 1.0: "minBalanceThreshold must be between 0.8 and 1.0 but got \(minBalanceThreshold)"
609                (minRecoveryHealth ?? 0.5) >= 0.0 && (minRecoveryHealth ?? 0.5) <= 1.0: "minRecoveryHealth must be between 0.0 and 1.0 but got \(minRecoveryHealth ?? 0.5)"
610            }
611            self.maxEmergencyDuration = maxEmergencyDuration
612            self.autoRecoveryEnabled = autoRecoveryEnabled
613            self.minYieldSourceHealth = minYieldSourceHealth
614            self.maxWithdrawFailures = maxWithdrawFailures
615            self.partialModeDepositLimit = partialModeDepositLimit
616            self.minBalanceThreshold = minBalanceThreshold
617            self.minRecoveryHealth = minRecoveryHealth ?? 0.5
618        }
619    }
620    
621    /// Creates a default EmergencyConfig with sensible production defaults.
622    /// - 24 hour max emergency duration
623    /// - Auto-recovery enabled
624    /// - 50% min yield source health
625    /// - 3 consecutive failures triggers emergency
626    /// - 100 token partial mode deposit limit
627    /// - 95% min balance threshold
628    /// @return A pre-configured EmergencyConfig
629    access(all) fun createDefaultEmergencyConfig(): EmergencyConfig {
630        return EmergencyConfig(
631            maxEmergencyDuration: 86400.0,          // 24 hours
632            autoRecoveryEnabled: true,
633            minYieldSourceHealth: 0.5,              // 50%
634            maxWithdrawFailures: 3,
635            partialModeDepositLimit: 100.0,
636            minBalanceThreshold: 0.95,              // 95%
637            minRecoveryHealth: 0.5                  // 50%
638        )
639    }
640    
641    // ============================================================
642    // ADMIN RESOURCE
643    // ============================================================
644    
645    /// Admin resource providing privileged access to pool management operations.
646    /// 
647    /// The Admin resource uses entitlements to provide fine-grained access control:
648    /// - ConfigOps: Non-destructive configuration changes (draw intervals, bonuses, rewards)
649    /// - CriticalOps: Potentially impactful changes (strategies, emergency mode, draws)
650    /// - OwnerOnly: Highly sensitive operations (protocol fee recipient - NEVER issue capabilities)
651    /// 
652    /// SECURITY NOTES:
653    /// - Store in a secure account
654    /// - Issue capabilities with minimal required entitlements
655    /// - Admin UUID is logged in events for audit trail
656    access(all) resource Admin {
657        /// Extensible metadata storage for future use.
658        access(self) var metadata: {String: {String: AnyStruct}}
659        
660        init() {
661            self.metadata = {}
662        }
663
664        /// Updates the yield distribution strategy for a pool.
665        /// @param poolID - ID of the pool to update
666        /// @param newStrategy - The new distribution strategy to use
667        access(CriticalOps) fun updatePoolDistributionStrategy(
668            poolID: UInt64,
669            newStrategy: {DistributionStrategy}
670        ) {
671            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
672            
673            let oldStrategyName = poolRef.getDistributionStrategyName()
674            poolRef.setDistributionStrategy(strategy: newStrategy)
675            let newStrategyName = newStrategy.getStrategyName()
676            
677            emit DistributionStrategyUpdated(
678                poolID: poolID,
679                oldStrategy: oldStrategyName,
680                newStrategy: newStrategyName,
681                adminUUID: self.uuid
682            )
683        }
684        
685        /// Updates the prize distribution for prize draws.
686        /// @param poolID - ID of the pool to update
687        /// @param newDistribution - The new prize distribution (e.g., single winner, percentage split)
688        access(CriticalOps) fun updatePoolPrizeDistribution(
689            poolID: UInt64,
690            newDistribution: {PrizeDistribution}
691        ) {
692            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
693            
694            let oldDistributionName = poolRef.getPrizeDistributionName()
695            poolRef.setPrizeDistribution(distribution: newDistribution)
696            let newDistributionName = newDistribution.getDistributionName()
697            
698            emit PrizeDistributionUpdated(
699                poolID: poolID,
700                oldDistribution: oldDistributionName,
701                newDistribution: newDistributionName,
702                adminUUID: self.uuid
703            )
704        }
705        /// Updates the draw interval for FUTURE rounds only.
706        /// 
707        /// This function ONLY affects future rounds created after the next startDraw().
708        /// The current active round is NOT affected (neither eligibility nor draw timing).
709        /// 
710        /// Use this when you want to change the interval starting from the NEXT round
711        /// without affecting the current round at all.
712        /// 
713        /// @param poolID - ID of the pool to update
714        /// @param newInterval - New draw interval in seconds (must be >= 1.0)
715        access(ConfigOps) fun updatePoolDrawIntervalForFutureRounds(
716            poolID: UInt64,
717            newInterval: UFix64
718        ) {
719            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
720
721            let oldInterval = poolRef.getConfig().drawIntervalSeconds
722            poolRef.setDrawIntervalSecondsForFutureOnly(interval: newInterval)
723
724            emit FutureRoundsIntervalUpdated(
725                poolID: poolID,
726                oldInterval: oldInterval,
727                newInterval: newInterval,
728                adminUUID: self.uuid
729            )
730        }
731
732        /// Updates the current round's target end time.
733        ///
734        /// Use this to extend or shorten the current round. Can only be called
735        /// before startDraw() is called on this round.
736        ///
737        /// This does NOT retroactively change existing users' TWAB. The TWAB math
738        /// uses fixed-scale accumulation and normalizes by actual elapsed time
739        /// at finalization, so extending/shortening the round is fair to all users.
740        ///
741        /// @param poolID - ID of the pool to update
742        /// @param newTargetEndTime - New target end time (must be after round start time)
743        access(ConfigOps) fun updateCurrentRoundTargetEndTime(
744            poolID: UInt64,
745            newTargetEndTime: UFix64
746        ) {
747            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
748
749            let oldTarget = poolRef.getCurrentRoundTargetEndTime()
750            let roundID = poolRef.getActiveRoundID()
751
752            poolRef.setCurrentRoundTargetEndTime(newTarget: newTargetEndTime)
753
754            emit RoundTargetEndTimeUpdated(
755                poolID: poolID,
756                roundID: roundID,
757                oldTarget: oldTarget,
758                newTarget: newTargetEndTime,
759                adminUUID: self.uuid
760            )
761        }
762
763        /// Updates the minimum deposit amount for the pool.
764        /// @param poolID - ID of the pool to update
765        /// @param newMinimum - New minimum deposit (>= 0.0)
766        access(ConfigOps) fun updatePoolMinimumDeposit(
767            poolID: UInt64,
768            newMinimum: UFix64
769        ) { 
770            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
771            
772            let oldMinimum = poolRef.getConfig().minimumDeposit
773            poolRef.setMinimumDeposit(minimum: newMinimum)
774            
775            emit MinimumDepositUpdated(
776                poolID: poolID,
777                oldMinimum: oldMinimum,
778                newMinimum: newMinimum,
779                adminUUID: self.uuid
780            )
781        }
782        
783        /// Enables emergency mode for a pool.
784        /// In emergency mode, only withdrawals are allowed - no deposits or draws.
785        /// Use when yield source is compromised or protocol-level issues detected.
786        /// @param poolID - ID of the pool to put in emergency mode
787        /// @param reason - Human-readable reason for emergency (logged in event)
788        access(CriticalOps) fun enableEmergencyMode(poolID: UInt64, reason: String) {
789            pre {
790                reason.length > 0: "Reason cannot be empty. Pool ID: \(poolID)"
791            }
792            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
793            poolRef.setEmergencyMode(reason: reason)
794            emit PoolEmergencyEnabled(poolID: poolID, reason: reason, adminUUID: self.uuid, timestamp: getCurrentBlock().timestamp)
795        }
796        
797        /// Disables emergency mode and returns pool to normal operation.
798        /// Clears consecutive failure counter and enables all operations.
799        /// @param poolID - ID of the pool to restore
800        access(CriticalOps) fun disableEmergencyMode(poolID: UInt64) {
801            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
802            poolRef.clearEmergencyMode()
803            emit PoolEmergencyDisabled(poolID: poolID, adminUUID: self.uuid, timestamp: getCurrentBlock().timestamp)
804        }
805        
806        /// Enables partial mode for a pool.
807        /// Partial mode: limited deposits (up to configured limit), no draws.
808        /// Use for graceful degradation when full operation isn't safe.
809        /// @param poolID - ID of the pool
810        /// @param reason - Human-readable reason for partial mode
811        access(CriticalOps) fun setEmergencyPartialMode(poolID: UInt64, reason: String) {
812            pre {
813                reason.length > 0: "Reason cannot be empty. Pool ID: \(poolID)"
814            }
815            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
816            poolRef.setPartialMode(reason: reason)
817            emit PoolPartialModeEnabled(poolID: poolID, reason: reason, adminUUID: self.uuid, timestamp: getCurrentBlock().timestamp)
818        }
819        
820        /// Updates the emergency configuration for a pool.
821        /// Controls auto-triggering thresholds and recovery behavior.
822        /// @param poolID - ID of the pool to configure
823        /// @param newConfig - New emergency configuration
824        access(CriticalOps) fun updateEmergencyConfig(poolID: UInt64, newConfig: EmergencyConfig) {
825            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
826            poolRef.setEmergencyConfig(config: newConfig)
827            emit EmergencyConfigUpdated(poolID: poolID, adminUUID: self.uuid)
828        }
829        
830        /// Directly funds a pool component with external tokens.
831        /// Use for sponsorships, promotional prize pools, or yield subsidies.
832        /// @param poolID - ID of the pool to fund
833        /// @param destination - Where to route funds (Rewards or Prize)
834        /// @param from - Vault containing funds to deposit
835        /// @param purpose - Human-readable description of funding purpose
836        /// @param metadata - Optional key-value pairs for additional context
837        access(CriticalOps) fun fundPoolDirect(
838            poolID: UInt64,
839            destination: PoolFundingDestination,
840            from: @{FungibleToken.Vault},
841            purpose: String,
842            metadata: {String: String}?
843        ) {
844            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
845            let amount = from.balance
846            poolRef.fundDirectInternal(destination: destination, from: <- from, adminUUID: self.uuid, purpose: purpose, metadata: metadata ?? {})
847            
848            emit DirectFundingReceived(
849                poolID: poolID,
850                destination: destination.rawValue,
851                destinationName: self.getDestinationName(destination),
852                amount: amount,
853                adminUUID: self.uuid,
854                purpose: purpose,
855                metadata: metadata ?? {}
856            )
857        }
858        
859        /// Internal helper to convert funding destination enum to human-readable string.
860        access(self) fun getDestinationName(_ destination: PoolFundingDestination): String {
861            switch destination {
862                case PoolFundingDestination.Rewards: return "Rewards"
863                case PoolFundingDestination.Prize: return "Prize"
864                default: return "Unknown"
865            }
866        }
867        
868        /// Creates a new prize-linked accounts pool.
869        /// @param config - Pool configuration (asset type, yield connector, strategies, etc.)
870        /// @param emergencyConfig - Optional emergency configuration (uses defaults if nil)
871        /// @return The ID of the newly created pool
872        access(CriticalOps) fun createPool(
873            config: PoolConfig,
874            emergencyConfig: EmergencyConfig?
875        ): UInt64 {
876            // Use provided config or fall back to sensible defaults
877            let finalEmergencyConfig = emergencyConfig 
878                ?? PrizeLinkedAccounts.createDefaultEmergencyConfig()
879            
880            let poolID = PrizeLinkedAccounts.createPool(
881                config: config,
882                emergencyConfig: finalEmergencyConfig
883            )
884            
885            emit PoolCreatedByAdmin(
886                poolID: poolID,
887                assetType: config.assetType.identifier,
888                strategy: config.distributionStrategy.getStrategyName(),
889                adminUUID: self.uuid
890            )
891            
892            return poolID
893        }
894        
895        /// Manually triggers reward processing for a pool.
896        /// Normally called automatically during deposits, but can be called explicitly
897        /// to materialize pending yield before a draw.
898        /// @param poolID - ID of the pool to process rewards for
899        access(ConfigOps) fun processPoolRewards(poolID: UInt64) {
900            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
901            
902            poolRef.syncWithYieldSource()
903        }
904        
905        /// Directly sets the pool's operational state.
906        /// Provides unified interface for all state transitions.
907        /// @param poolID - ID of the pool
908        /// @param state - Target state (Normal, Paused, EmergencyMode, PartialMode)
909        /// @param reason - Optional reason for non-Normal states
910        access(CriticalOps) fun setPoolState(poolID: UInt64, state: PoolEmergencyState, reason: String?) {
911            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
912            
913            poolRef.setState(state: state, reason: reason)
914            
915            // Emit appropriate event based on new state
916            switch state {
917                case PoolEmergencyState.Normal:
918                    emit PoolUnpaused(poolID: poolID, adminUUID: self.uuid)
919                case PoolEmergencyState.Paused:
920                    emit PoolPaused(poolID: poolID, adminUUID: self.uuid, reason: reason ?? "Manual pause")
921                case PoolEmergencyState.EmergencyMode:
922                    emit PoolEmergencyEnabled(poolID: poolID, reason: reason ?? "Emergency", adminUUID: self.uuid, timestamp: getCurrentBlock().timestamp)
923                case PoolEmergencyState.PartialMode:
924                    emit PoolPartialModeEnabled(poolID: poolID, reason: reason ?? "Partial mode", adminUUID: self.uuid, timestamp: getCurrentBlock().timestamp)
925            }
926        }
927        
928        /// Set the protocol fee recipient for automatic forwarding.
929        /// Once set, protocol fee is auto-forwarded when round ends.
930        /// Pass nil to disable auto-forwarding (funds stored in unclaimedProtocolFeeVault).
931        ///
932        /// IMPORTANT: The recipient MUST accept the same vault type as the pool's asset.
933        /// For example, if the pool uses FLOW tokens, the recipient must be a FLOW receiver.
934        /// A type mismatch will cause startDraw() to fail. If this happens,
935        /// clear the recipient (set to nil) and retry the draw - funds will go to
936        /// unclaimedProtocolFeeVault for manual withdrawal.
937        ///
938        /// SECURITY: Requires OwnerOnly entitlement - NEVER issue capabilities with this.
939        /// Only the account owner (via direct storage borrow with auth) can call this.
940        /// For multi-sig protection, store Admin in a multi-sig account.
941        ///
942        /// @param poolID - ID of the pool to configure
943        /// @param recipientCap - Capability to receive protocol fee, or nil to disable
944        access(OwnerOnly) fun setPoolProtocolFeeRecipient(
945            poolID: UInt64,
946            recipientCap: Capability<&{FungibleToken.Receiver}>?
947        ) {
948            pre {
949                // Validate capability is usable if provided
950                recipientCap?.check() ?? true: "Protocol fee recipient capability is invalid or cannot be borrowed. Pool ID: \(poolID), Recipient address: \(recipientCap?.address?.toString() ?? "nil")"
951            }
952            
953            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
954            
955            poolRef.setProtocolFeeRecipient(cap: recipientCap)
956            
957            emit ProtocolFeeRecipientUpdated(
958                poolID: poolID,
959                newRecipient: recipientCap?.address,
960                adminUUID: self.uuid
961            )
962        }
963        
964        /// Sets or replaces a user's bonus prize weight.
965        /// Bonus weight is added to their TWAB-based weight during draw selection.
966        /// Use for promotional campaigns or loyalty rewards.
967        /// @param poolID - ID of the pool
968        /// @param receiverID - UUID of the user's PoolPositionCollection
969        /// @param bonusWeight - Weight to assign (replaces any existing bonus)
970        /// @param reason - Human-readable reason for the bonus (audit trail)
971        access(ConfigOps) fun setBonusPrizeWeight(
972            poolID: UInt64,
973            receiverID: UInt64,
974            bonusWeight: UFix64,
975            reason: String
976        ) {
977            pre {
978                reason.length > 0: "Reason cannot be empty. Pool ID: \(poolID), Receiver ID: \(receiverID)"
979            }
980            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
981            
982            poolRef.setBonusWeight(receiverID: receiverID, bonusWeight: bonusWeight, reason: reason, adminUUID: self.uuid)
983        }
984        
985        /// Adds additional bonus weight to a user's existing bonus.
986        /// Cumulative with any previous bonus weight assigned.
987        /// @param poolID - ID of the pool
988        /// @param receiverID - UUID of the user's PoolPositionCollection
989        /// @param additionalWeight - Weight to add (must be > 0)
990        /// @param reason - Human-readable reason for the addition
991        access(ConfigOps) fun addBonusPrizeWeight(
992            poolID: UInt64,
993            receiverID: UInt64,
994            additionalWeight: UFix64,
995            reason: String
996        ) {
997            pre {
998                additionalWeight > 0.0: "Additional weight must be positive (greater than 0). Pool ID: \(poolID), Receiver ID: \(receiverID), Received weight: \(additionalWeight)"
999                reason.length > 0: "Reason cannot be empty. Pool ID: \(poolID), Receiver ID: \(receiverID)"
1000            }
1001            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1002            
1003            poolRef.addBonusWeight(receiverID: receiverID, additionalWeight: additionalWeight, reason: reason, adminUUID: self.uuid)
1004        }
1005        
1006        /// Removes all bonus prize weight from a user.
1007        /// User returns to pure TWAB-based prize odds.
1008        /// @param poolID - ID of the pool
1009        /// @param receiverID - UUID of the user's PoolPositionCollection
1010        access(ConfigOps) fun removeBonusPrizeWeight(
1011            poolID: UInt64,
1012            receiverID: UInt64
1013        ) {
1014            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1015            
1016            poolRef.removeBonusWeight(receiverID: receiverID, adminUUID: self.uuid)
1017        }
1018        
1019        /// Deposits an NFT to be awarded as a prize in future draws.
1020        /// NFTs are stored in the prize distributor and assigned via winner selection strategy.
1021        /// @param poolID - ID of the pool to receive the NFT
1022        /// @param nft - The NFT resource to deposit
1023        access(ConfigOps) fun depositNFTPrize(
1024            poolID: UInt64,
1025            nft: @{NonFungibleToken.NFT}
1026        ) {
1027            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1028            
1029            let nftID = nft.uuid
1030            let nftType = nft.getType().identifier
1031            
1032            poolRef.depositNFTPrize(nft: <- nft)
1033            
1034            emit NFTPrizeDeposited(
1035                poolID: poolID,
1036                nftID: nftID,
1037                nftType: nftType,
1038                adminUUID: self.uuid
1039            )
1040        }
1041        
1042        /// Withdraws an NFT prize that hasn't been awarded yet.
1043        /// Use to recover NFTs or update prize pool contents.
1044        /// @param poolID - ID of the pool
1045        /// @param nftID - UUID of the NFT to withdraw
1046        /// @return The withdrawn NFT resource
1047        access(ConfigOps) fun withdrawNFTPrize(
1048            poolID: UInt64,
1049            nftID: UInt64
1050        ): @{NonFungibleToken.NFT} {
1051            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1052            
1053            let nft <- poolRef.withdrawNFTPrize(nftID: nftID)
1054            let nftType = nft.getType().identifier
1055            
1056            emit NFTPrizeWithdrawn(
1057                poolID: poolID,
1058                nftID: nftID,
1059                nftType: nftType,
1060                adminUUID: self.uuid
1061            )
1062            
1063            return <- nft
1064        }
1065        
1066        // ============================================================
1067        // BATCH DRAW FUNCTIONS
1068        // ============================================================
1069        // BATCHED DRAW OPERATIONS
1070        // ============================================================
1071        // Breaks draw into multiple transactions to avoid gas limits for large pools.
1072        // Flow: startPoolDraw() → processPoolDrawBatch() (repeat) → completePoolDraw()
1073        // Note: Randomness is requested during startPoolDraw() and fulfilled during completePoolDraw().
1074
1075        /// Starts a prize draw for a pool (Phase 1 of 3).
1076        /// 
1077        /// This instantly transitions rounds and initializes batch processing.
1078        /// Users can continue depositing/withdrawing immediately.
1079        /// 
1080        /// @param poolID - ID of the pool to start draw for
1081        access(CriticalOps) fun startPoolDraw(poolID: UInt64) {
1082            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1083            poolRef.startDraw()
1084        }
1085        
1086        /// Processes a batch of receivers for weight capture (Phase 2 of 3).
1087        /// 
1088        /// Call repeatedly until return value is 0 (or isDrawBatchComplete()).
1089        /// Each call processes up to `limit` receivers.
1090        /// 
1091        /// @param poolID - ID of the pool
1092        /// @param limit - Maximum receivers to process this batch
1093        /// @return Number of receivers remaining to process
1094        access(CriticalOps) fun processPoolDrawBatch(poolID: UInt64, limit: Int): Int {
1095            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1096            return poolRef.processDrawBatch(limit: limit)
1097        }
1098        
1099        /// Completes a prize draw for a pool (Phase 3 of 3).
1100        /// 
1101        /// Fulfills randomness request, selects winners, and distributes prizes.
1102        /// Prizes are auto-compounded into winners' deposits.
1103        /// 
1104        /// PREREQUISITES:
1105        /// - startPoolDraw() must have been called
1106        /// - processPoolDrawBatch() must have been called until complete
1107        /// - At least 1 block must have passed since startPoolDraw()
1108        /// 
1109        /// @param poolID - ID of the pool to complete draw for
1110        access(CriticalOps) fun completePoolDraw(poolID: UInt64) {
1111            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1112            poolRef.completeDraw()
1113        }
1114        
1115        /// Starts the next round for a pool, exiting intermission (Phase 5 - optional).
1116        /// 
1117        /// After completeDraw(), the pool enters intermission. Call this to begin
1118        /// the next round with fresh TWAB tracking.
1119        /// 
1120        /// @param poolID - ID of the pool to start next round for
1121        access(ConfigOps) fun startNextRound(poolID: UInt64) {
1122            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1123            poolRef.startNextRound()
1124        }
1125
1126        /// Withdraws unclaimed protocol fee from a pool.
1127        /// 
1128        /// Protocol fee accumulates in the unclaimed vault when no protocol fee recipient
1129        /// is configured at draw time. This function allows admin to withdraw those funds.
1130        /// 
1131        /// @param poolID - ID of the pool to withdraw from
1132        /// @param amount - Amount to withdraw (will be capped at available balance)
1133        /// @param recipient - Capability to receive the withdrawn funds
1134        /// @return Actual amount withdrawn (may be less than requested if insufficient balance)
1135        access(CriticalOps) fun withdrawUnclaimedProtocolFee(
1136            poolID: UInt64,
1137            amount: UFix64,
1138            recipient: Capability<&{FungibleToken.Receiver}>
1139        ): UFix64 {
1140            pre {
1141                recipient.check(): "Recipient capability is invalid"
1142                amount > 0.0: "Amount must be greater than 0"
1143            }
1144            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1145            let withdrawn <- poolRef.withdrawUnclaimedProtocolFee(amount: amount)
1146            let actualAmount = withdrawn.balance
1147            
1148            if actualAmount > 0.0 {
1149                recipient.borrow()!.deposit(from: <- withdrawn)
1150                emit ProtocolFeeForwarded(
1151                    poolID: poolID,
1152                    amount: actualAmount,
1153                    recipient: recipient.address
1154                )
1155            } else {
1156                destroy withdrawn
1157            }
1158            
1159            return actualAmount
1160        }
1161
1162        /// Cleans up stale dictionary entries and ghost receivers to manage storage growth.
1163        /// 
1164        /// This function should be called periodically (e.g., after each draw or weekly) to:
1165        /// 1. Remove "ghost" receivers (0-share users still in registeredReceiverList)
1166        /// 2. Clean up userShares entries with 0.0 value
1167        /// 3. Remove empty pendingNFTClaims arrays
1168        /// 
1169        /// Uses cursor-based batching to avoid gas limits - call multiple times with
1170        /// increasing startIndex until nextIndex >= totalReceivers in the result.
1171        /// 
1172        /// @param poolID - ID of the pool to clean up
1173        /// @param startIndex - Index in registeredReceiverList to start iterating from (0 for first call)
1174        /// @param limit - Max receivers to process per call (for gas management)
1175        /// @return CleanupResult with counts and nextIndex for continuation
1176        access(ConfigOps) fun cleanupPoolStaleEntries(
1177            poolID: UInt64,
1178            startIndex: Int,
1179            limit: Int
1180        ): {String: Int} {
1181            pre {
1182                startIndex >= 0: "Start index must be non-negative"
1183                limit > 0: "Limit must be positive"
1184            }
1185            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
1186            let result = poolRef.cleanupStaleEntries(startIndex: startIndex, limit: limit)
1187            
1188            emit PoolStorageCleanedUp(
1189                poolID: poolID,
1190                ghostReceiversCleaned: result["ghostReceivers"] ?? 0,
1191                userSharesCleaned: result["userShares"] ?? 0,
1192                pendingNFTClaimsCleaned: result["pendingNFTClaims"] ?? 0,
1193                nextIndex: result["nextIndex"] ?? 0,
1194                totalReceivers: result["totalReceivers"] ?? 0,
1195                adminUUID: self.uuid
1196            )
1197            
1198            return result
1199        }
1200
1201    }
1202    
1203    
1204    // ============================================================
1205    // DISTRIBUTION STRATEGY - Yield Allocation
1206    // ============================================================
1207    
1208    /// Represents the result of a yield distribution calculation.
1209    /// Contains the amounts to allocate to each component.
1210    access(all) struct DistributionPlan {
1211        /// Amount allocated to rewards (increases share price for all depositors)
1212        access(all) let rewardsAmount: UFix64
1213        /// Amount allocated to prize pool (awarded to winners)
1214        access(all) let prizeAmount: UFix64
1215        /// Amount allocated to protocol fee (protocol fees)
1216        access(all) let protocolFeeAmount: UFix64
1217        
1218        /// Creates a new DistributionPlan.
1219        /// @param rewards - Amount for rewards distribution
1220        /// @param prize - Amount for prize pool
1221        /// @param protocolFee - Amount for protocol
1222        init(rewards: UFix64, prize: UFix64, protocolFee: UFix64) {
1223            self.rewardsAmount = rewards
1224            self.prizeAmount = prize
1225            self.protocolFeeAmount = protocolFee
1226        }
1227    }
1228    
1229    /// Strategy Pattern interface for yield distribution algorithms.
1230    /// 
1231    /// Implementations determine how yield is split between rewards, prize, and protocol fee.
1232    /// This enables pools to use different distribution models and swap them at runtime.
1233    /// 
1234    /// IMPLEMENTATION NOTES:
1235    /// - Validation (e.g., percentages summing to 1.0) should be in concrete init()
1236    /// - calculateDistribution() must handle totalAmount = 0.0 gracefully
1237    /// - Strategy instances are stored by value (structs), so they're immutable after creation
1238    access(all) struct interface DistributionStrategy {
1239        /// Calculates how to split the given yield amount.
1240        /// @param totalAmount - Total yield to distribute
1241        /// @return DistributionPlan with amounts for each component
1242        access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan
1243        
1244        /// Returns a human-readable description of this strategy.
1245        /// Used for display in UI and event logging.
1246        access(all) view fun getStrategyName(): String
1247    }
1248    
1249    /// Fixed percentage distribution strategy.
1250    /// Splits yield according to pre-configured percentages that must sum to 1.0.
1251    /// 
1252    /// Example: FixedPercentageStrategy(rewards: 0.4, prize: 0.4, protocolFee: 0.2)
1253    /// - 40% of yield goes to rewards (increases share price)
1254    /// - 40% goes to prize pool
1255    /// - 20% goes to protocol fee
1256    access(all) struct FixedPercentageStrategy: DistributionStrategy {
1257        /// Percentage of yield allocated to rewards (0.0 to 1.0)
1258        access(all) let rewardsPercent: UFix64
1259        /// Percentage of yield allocated to prize (0.0 to 1.0)
1260        access(all) let prizePercent: UFix64
1261        /// Percentage of yield allocated to protocol fee (0.0 to 1.0)
1262        access(all) let protocolFeePercent: UFix64
1263        
1264        /// Creates a FixedPercentageStrategy.
1265        /// IMPORTANT: Percentages must sum to exactly 1.0 (strict equality).
1266        /// Use values like 0.4, 0.4, 0.2 - not repeating decimals like 0.33333333.
1267        /// If using thirds, use 0.33, 0.33, 0.34 to sum exactly to 1.0.
1268        /// @param rewards - Rewards percentage (0.0-1.0)
1269        /// @param prize - Prize percentage (0.0-1.0)
1270        /// @param protocolFee - Protocol fee percentage (0.0-1.0)
1271        init(rewards: UFix64, prize: UFix64, protocolFee: UFix64) {
1272            pre {
1273                rewards + prize + protocolFee == 1.0:
1274                    "FixedPercentageStrategy: Percentages must sum to exactly 1.0, but got \(rewards) + \(prize) + \(protocolFee) = \(rewards + prize + protocolFee)"
1275            }
1276            self.rewardsPercent = rewards
1277            self.prizePercent = prize
1278            self.protocolFeePercent = protocolFee
1279        }
1280        
1281        /// Calculates distribution by multiplying total by each percentage.
1282        /// Protocol receives the remainder to ensure sum == totalAmount (handles UFix64 rounding).
1283        /// @param totalAmount - Total yield to distribute
1284        /// @return DistributionPlan with proportional amounts
1285        access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan {
1286            let rewards = totalAmount * self.rewardsPercent
1287            let prize = totalAmount * self.prizePercent
1288            // Protocol gets the remainder to ensure sum == totalAmount
1289            let protocolFee = totalAmount - rewards - prize
1290            
1291            return DistributionPlan(
1292                rewards: rewards,
1293                prize: prize,
1294                protocolFee: protocolFee
1295            )
1296        }
1297        
1298        /// Returns strategy description with configured percentages.
1299        access(all) view fun getStrategyName(): String {
1300            return "Fixed: \(self.rewardsPercent) rewards, \(self.prizePercent) prize, \(self.protocolFeePercent) protocol"
1301        }
1302    }
1303    
1304    // ============================================================
1305    // ROUND RESOURCE - Per-Round TWAB Tracking (Normalized)
1306    // ============================================================
1307    
1308    /// Represents a single prize round with NORMALIZED TWAB tracking.
1309    ///
1310    /// TWAB uses a fixed TWAB_SCALE (1 year) for overflow protection, then normalizes
1311    /// by actual duration at finalization to get "average shares".
1312    /// Weight accumulation continues until startDraw() is called (actualEndTime).
1313    ///
1314    /// Round Lifecycle: Created → Active → Frozen → Processing → Destroyed
1315    
1316    access(all) resource Round {
1317        /// Unique identifier for this round (increments each draw).
1318        access(all) let roundID: UInt64
1319
1320        /// Timestamp when this round started.
1321        access(all) let startTime: UFix64
1322
1323        /// Target end time for this round. Admin can adjust before startDraw().
1324        access(self) var targetEndTime: UFix64
1325
1326        /// Fixed scale for TWAB accumulation (1 year in seconds = 31,536,000).
1327        /// Using a fixed scale prevents overflow with large TVL and long durations.
1328        /// Final normalization happens at finalizeTWAB() using actual elapsed time.
1329        access(all) let TWAB_SCALE: UFix64
1330
1331        /// Actual end time when round was finalized (set by startDraw).
1332        /// nil means round is still active.
1333        access(self) var actualEndTime: UFix64?
1334
1335        /// Accumulated SCALED weight from round start to lastUpdateTime.
1336        /// Key: receiverID, Value: accumulated scaled weight.
1337        /// Scaling: shares × (elapsed / TWAB_SCALE) instead of shares × elapsed
1338        /// Final normalization by actual duration happens in finalizeTWAB().
1339        access(self) var userScaledTWAB: {UInt64: UFix64}
1340
1341        /// Timestamp of last TWAB update for each user.
1342        /// Key: receiverID, Value: timestamp of last update.
1343        access(self) var userLastUpdateTime: {UInt64: UFix64}
1344
1345
1346        /// User's shares at last update (for calculating pending accumulation).
1347        /// Key: receiverID, Value: shares balance at last update.
1348        access(self) var userSharesAtLastUpdate: {UInt64: UFix64}
1349
1350        /// Creates a new Round.
1351        /// @param roundID - Unique round identifier
1352        /// @param startTime - When the round starts
1353        /// @param targetEndTime - Minimum time before startDraw() can be called
1354        init(roundID: UInt64, startTime: UFix64, targetEndTime: UFix64) {
1355            pre {
1356                targetEndTime > startTime: "Target end time must be after start time. Start: \(startTime), Target: \(targetEndTime)"
1357            }
1358            self.roundID = roundID
1359            self.startTime = startTime
1360            self.targetEndTime = targetEndTime
1361            self.TWAB_SCALE = 31_536_000.0  // 1 year in seconds
1362            self.actualEndTime = nil
1363            self.userScaledTWAB = {}
1364            self.userLastUpdateTime = {}
1365            self.userSharesAtLastUpdate = {}
1366        }
1367        
1368        /// Records a share change and accumulates TWAB up to current time.
1369        ///
1370        /// Flow:
1371        /// 1. Accumulate pending scaled share-time for old balance
1372        /// 2. Update shares snapshot and timestamp for future accumulation
1373        ///
1374        /// If the round has ended (actualEndTime is set), the timestamp is capped
1375        /// at actualEndTime. This ensures deposits during draw processing get fair
1376        /// weight - new shares added after round end contribute zero weight.
1377        ///
1378        /// @param receiverID - User's receiver ID
1379        /// @param oldShares - Shares BEFORE the operation
1380        /// @param newShares - Shares AFTER the operation
1381        /// @param atTime - Current timestamp (will be capped at actualEndTime if round ended)
1382        access(contract) fun recordShareChange(
1383            receiverID: UInt64,
1384            oldShares: UFix64,
1385            newShares: UFix64,
1386            atTime: UFix64
1387        ) {
1388            // Cap time at actualEndTime if round has ended
1389            // This ensures deposits during draw processing get weight only up to round end
1390            let effectiveTime = self.actualEndTime != nil && atTime > self.actualEndTime!
1391                ? self.actualEndTime!
1392                : atTime
1393
1394            // First, accumulate any pending scaled TWAB for old balance
1395            self.accumulatePendingTWAB(receiverID: receiverID, upToTime: effectiveTime, withShares: oldShares)
1396
1397            // Update shares snapshot for future accumulation
1398            self.userSharesAtLastUpdate[receiverID] = newShares
1399            self.userLastUpdateTime[receiverID] = effectiveTime
1400        }
1401
1402        /// Accumulates SCALED pending weight from lastUpdateTime to upToTime.
1403        ///
1404        /// Formula: scaledPending = shares × (elapsed / TWAB_SCALE)
1405        ///
1406        /// Using a fixed TWAB_SCALE (1 year) ensures overflow protection regardless
1407        /// of round duration or TVL. Final normalization to "average shares" happens
1408        /// in finalizeTWAB() using the actual elapsed time.
1409        ///
1410        /// @param receiverID - User's receiver ID
1411        /// @param upToTime - Time to accumulate up to
1412        /// @param withShares - Shares to use for accumulation
1413        access(self) fun accumulatePendingTWAB(
1414            receiverID: UInt64,
1415            upToTime: UFix64,
1416            withShares: UFix64
1417        ) {
1418            let lastUpdate = self.userLastUpdateTime[receiverID] ?? self.startTime
1419
1420            // Only accumulate if time has passed
1421            if upToTime > lastUpdate {
1422                let elapsed = upToTime - lastUpdate
1423                let scaledPending = withShares * (elapsed / self.TWAB_SCALE)
1424                let current = self.userScaledTWAB[receiverID] ?? 0.0
1425                self.userScaledTWAB[receiverID] = current + scaledPending
1426                self.userLastUpdateTime[receiverID] = upToTime
1427            }
1428        }
1429        
1430        /// Calculates the finalized TWAB for a user at the actual round end.
1431        /// Called during processDrawBatch() to get each user's final weight.
1432        ///
1433        /// Returns "average shares" (normalized by actual duration).
1434        /// For a user who held X shares for the entire round, returns X.
1435        /// For a user who held X shares for half the round, returns X/2.
1436        ///
1437        /// @param receiverID - User's receiver ID
1438        /// @param currentShares - User's current share balance (for lazy users)
1439        /// @param roundEndTime - The actual end time of the round (set by startDraw)
1440        /// @return Normalized weight for this user (≈ average shares held)
1441        access(all) view fun finalizeTWAB(
1442            receiverID: UInt64,
1443            currentShares: UFix64,
1444            roundEndTime: UFix64
1445        ): UFix64 {
1446            // Get accumulated scaled weight so far
1447            let accumulated = self.userScaledTWAB[receiverID] ?? 0.0
1448            let lastUpdate = self.userLastUpdateTime[receiverID] ?? self.startTime
1449            let shares = self.userSharesAtLastUpdate[receiverID] ?? currentShares
1450
1451            var scaledPending: UFix64 = 0.0
1452            if roundEndTime > lastUpdate {
1453                let elapsed = roundEndTime - lastUpdate
1454                scaledPending = shares * (elapsed / self.TWAB_SCALE)
1455            }
1456
1457            let totalScaled = accumulated + scaledPending
1458            let actualDuration = roundEndTime - self.startTime
1459            if actualDuration == 0.0 {
1460                return 0.0
1461            }
1462
1463            let normalizedWeight = totalScaled * (self.TWAB_SCALE / actualDuration)
1464
1465            // SAFETY: cap weight to shares
1466            if normalizedWeight > shares {
1467                return shares
1468            }
1469            return normalizedWeight
1470        }
1471        
1472        /// Returns the current TWAB for a user (for view functions).
1473        /// Calculates accumulated + pending up to current time, normalized by elapsed time.
1474        ///
1475        /// Returns "average shares" (normalized by elapsed time).
1476        ///
1477        /// @param receiverID - User's receiver ID
1478        /// @param currentShares - User's current share balance
1479        /// @param atTime - Time to calculate TWAB up to
1480        /// @return Current normalized weight (≈ average shares held so far)
1481        access(all) view fun getCurrentTWAB(
1482            receiverID: UInt64,
1483            currentShares: UFix64,
1484            atTime: UFix64
1485        ): UFix64 {
1486            let accumulated = self.userScaledTWAB[receiverID] ?? 0.0
1487            let lastUpdate = self.userLastUpdateTime[receiverID] ?? self.startTime
1488            let shares = self.userSharesAtLastUpdate[receiverID] ?? currentShares
1489
1490            var scaledPending: UFix64 = 0.0
1491            if atTime > lastUpdate {
1492                let elapsed = atTime - lastUpdate
1493                scaledPending = shares * (elapsed / self.TWAB_SCALE)
1494            }
1495
1496            let totalScaled = accumulated + scaledPending
1497            let elapsedFromStart = atTime - self.startTime
1498            if elapsedFromStart == 0.0 {
1499                return 0.0
1500            }
1501
1502            let normalizedWeight = totalScaled * (self.TWAB_SCALE / elapsedFromStart)
1503            if normalizedWeight > shares {
1504                return shares
1505            }
1506            return normalizedWeight
1507        }
1508
1509        /// Sets the actual end time when the round is finalized.
1510        /// Called by startDraw() to mark the round for draw processing.
1511        ///
1512        /// @param endTime - The actual end time
1513        access(contract) fun setActualEndTime(_ endTime: UFix64) {
1514            self.actualEndTime = endTime
1515        }
1516
1517        /// Returns the actual end time if set, nil otherwise.
1518        access(all) view fun getActualEndTime(): UFix64? {
1519            return self.actualEndTime
1520        }
1521
1522        /// Updates the target end time for this round.
1523        /// Can only be called before startDraw() finalizes the round.
1524        ///
1525        /// SAFETY: When shortening, the new target must be >= current block timestamp.
1526        /// This prevents a bug where already-accumulated time could exceed the new
1527        /// target duration, causing weight > shares (violating the TWAB invariant).
1528        ///
1529        /// @param newTarget - New target end time (must be after start time and >= now if shortening)
1530        access(contract) fun setTargetEndTime(newTarget: UFix64) {
1531            pre {
1532                self.actualEndTime == nil: "Cannot change target after startDraw()"
1533                newTarget > self.startTime: "Target must be after start time. Start: \(self.startTime), NewTarget: \(newTarget)"
1534            }
1535            // SAFETY CHECK: When shortening, new target must be >= current time
1536            // This ensures no user has accumulated time beyond the new target
1537            let now = getCurrentBlock().timestamp
1538            if newTarget < self.targetEndTime {
1539                // Shortening - must be >= current time to prevent weight > shares bug
1540                assert(
1541                    newTarget >= now,
1542                    message: "Cannot shorten target to before current time. Now: \(now), NewTarget: \(newTarget)"
1543                )
1544            }
1545            self.targetEndTime = newTarget
1546        }
1547
1548        /// Returns whether this round has reached its target end time.
1549        /// Used for "can we start a draw" check.
1550        /// OPTIMIZATION: Uses pre-computed configuredEndTime.
1551        access(all) view fun hasEnded(): Bool {
1552            return getCurrentBlock().timestamp >= self.targetEndTime
1553        }
1554
1555        /// Returns the target end time for this round.
1556        access(all) view fun getTargetEndTime(): UFix64 {
1557            return self.targetEndTime
1558        }
1559
1560        /// Returns the target end time (same as getTargetEndTime for backward compatibility).
1561        /// Used for gap period detection.
1562        /// OPTIMIZATION: Returns pre-computed value.
1563        access(all) view fun getConfiguredEndTime(): UFix64 {
1564            return self.targetEndTime
1565        }
1566
1567
1568        /// Returns the round ID.
1569        access(all) view fun getRoundID(): UInt64 {
1570            return self.roundID
1571        }
1572
1573
1574        /// Returns the round start time.
1575        access(all) view fun getStartTime(): UFix64 {
1576            return self.startTime
1577        }
1578
1579        /// Returns the round duration (computed as targetEndTime - startTime).
1580        /// Note: This is the target duration, not actual duration.
1581        access(all) view fun getDuration(): UFix64 {
1582            return self.targetEndTime - self.startTime
1583        }
1584
1585        /// Returns the target end time (same as getTargetEndTime for backward compatibility).
1586        access(all) view fun getEndTime(): UFix64 {
1587            return self.targetEndTime
1588        }
1589
1590
1591        /// Returns whether a user has been initialized in this round.
1592        access(all) view fun isUserInitialized(receiverID: UInt64): Bool {
1593            return self.userLastUpdateTime[receiverID] != nil
1594        }
1595    }
1596    
1597    // ============================================================
1598    // SHARE TRACKER RESOURCE
1599    // ============================================================
1600    
1601    /// ERC4626-style share accounting ledger with virtual offset protection against inflation attacks.
1602    /// 
1603    /// This resource manages share-based accounting for user deposits:
1604    /// - Tracks user shares and converts between shares <-> assets
1605    /// - Accrues yield by increasing share price (not individual balances)
1606    /// 
1607    /// KEY CONCEPTS:
1608    /// 
1609    /// Share-Based Accounting (ERC4626):
1610    /// - Users receive shares proportional to their deposit
1611    /// - Yield increases totalAssets, which increases share price
1612    /// - All depositors benefit proportionally without individual updates
1613    /// - Virtual offsets prevent first-depositor inflation attacks
1614    /// 
1615    /// 
1616    /// INVARIANTS:
1617    /// - totalAssets should approximately equal Pool.userPoolBalance
1618    /// - sum(userShares) == totalShares
1619    /// - share price may increase (yield) or decrease (loss socialization)
1620    access(all) resource ShareTracker {
1621        /// Total shares outstanding across all users.
1622        access(self) var totalShares: UFix64
1623        
1624        /// Total asset value held (principal + accrued yield).
1625        /// This determines share price: price = (totalAssets + VIRTUAL) / (totalShares + VIRTUAL)
1626        access(self) var totalAssets: UFix64
1627        
1628        /// Mapping of receiverID to their share balance.
1629        access(self) let userShares: {UInt64: UFix64}
1630        
1631        /// Cumulative yield distributed since pool creation (for statistics).
1632        access(all) var totalDistributed: UFix64
1633        
1634        /// Type of fungible token vault this tracker handles.
1635        access(self) let vaultType: Type
1636        
1637        /// Initializes a new ShareTracker.
1638        /// @param vaultType - Type of fungible token to track
1639        init(vaultType: Type) {
1640            self.totalShares = 0.0
1641            self.totalAssets = 0.0
1642            self.userShares = {}
1643            self.totalDistributed = 0.0
1644            self.vaultType = vaultType
1645        }
1646        
1647        /// Pure calculation that returns the effective rewards amount after subtracting
1648        /// virtual-share dust.
1649        ///
1650        /// @param amount - Yield amount to preview
1651        /// @return Effective rewards after dust excluded
1652        access(all) view fun previewAccrueYield(amount: UFix64): UFix64 {
1653            if amount == 0.0 || self.totalShares == 0.0 {
1654                return 0.0
1655            }
1656            let effectiveShares = self.totalShares + PrizeLinkedAccounts.VIRTUAL_SHARES
1657            let dustAmount = amount * PrizeLinkedAccounts.VIRTUAL_SHARES / effectiveShares
1658            return amount - dustAmount
1659        }
1660
1661        /// Accrues yield to the rewards pool by increasing totalAssets.
1662        /// This effectively increases share price for all depositors.
1663        /// Delegates dust calculation to previewAccrueYield.
1664        ///
1665        /// @param amount - Yield amount to accrue
1666        /// @return Actual amount accrued to users (after dust excluded)
1667        access(contract) fun accrueYield(amount: UFix64): UFix64 {
1668            let actualRewards = self.previewAccrueYield(amount: amount)
1669            if actualRewards == 0.0 {
1670                return 0.0
1671            }
1672            self.totalAssets = self.totalAssets + actualRewards
1673            self.totalDistributed = self.totalDistributed + actualRewards
1674            return actualRewards
1675        }
1676        
1677        /// Decreases total assets to reflect a loss in the yield source.
1678        /// This effectively decreases share price for all depositors proportionally.
1679        /// 
1680        /// Unlike accrueYield, this does NOT apply virtual share dust calculation
1681        /// because losses should be fully socialized across all depositors.
1682        /// 
1683        /// @param amount - Loss amount to socialize
1684        /// @return Actual amount decreased (capped at totalAssets to prevent underflow)
1685        access(contract) fun decreaseTotalAssets(amount: UFix64): UFix64 {
1686            if amount == 0.0 || self.totalAssets == 0.0 {
1687                return 0.0
1688            }
1689            
1690            // Cap at totalAssets to prevent underflow
1691            let actualDecrease = amount > self.totalAssets ? self.totalAssets : amount
1692            
1693            self.totalAssets = self.totalAssets - actualDecrease
1694            return actualDecrease
1695        }
1696        
1697        /// Records a deposit by minting shares proportional to the deposit amount.
1698        /// @param receiverID - User's receiver ID
1699        /// @param amount - Amount being deposited
1700        /// @return The number of shares minted
1701        access(contract) fun deposit(receiverID: UInt64, amount: UFix64): UFix64 {
1702            if amount == 0.0 {
1703                return 0.0
1704            }
1705            
1706            // Mint shares proportional to deposit at current share price
1707            let sharesToMint = self.convertToShares(amount)
1708            let currentShares = self.userShares[receiverID] ?? 0.0
1709            self.userShares[receiverID] = currentShares + sharesToMint
1710            self.totalShares = self.totalShares + sharesToMint
1711            self.totalAssets = self.totalAssets + amount
1712            
1713            return sharesToMint
1714        }
1715        
1716        /// Records a withdrawal by burning shares proportional to the withdrawal amount.
1717        /// @param receiverID - User's receiver ID
1718        /// @param amount - Amount to withdraw
1719        /// @param dustThreshold - If remaining balance would be below this, burn all shares (prevents dust)
1720        /// @return The actual amount withdrawn
1721        access(contract) fun withdraw(receiverID: UInt64, amount: UFix64, dustThreshold: UFix64): UFix64 {
1722            if amount == 0.0 {
1723                return 0.0
1724            }
1725
1726            // Validate user has sufficient shares
1727            let userShareBalance = self.userShares[receiverID] ?? 0.0
1728            assert(
1729                userShareBalance > 0.0,
1730                message: "ShareTracker.withdraw: No shares to withdraw for receiver \(receiverID)"
1731            )
1732            assert(
1733                self.totalShares > 0.0 && self.totalAssets > 0.0,
1734                message: "ShareTracker.withdraw: Invalid tracker state - totalShares: \(self.totalShares), totalAssets: \(self.totalAssets)"
1735            )
1736
1737            // Validate user has sufficient balance
1738            let currentAssetValue = self.convertToAssets(userShareBalance)
1739            assert(
1740                amount <= currentAssetValue,
1741                message: "ShareTracker.withdraw: Insufficient balance - requested \(amount) but receiver \(receiverID) only has \(currentAssetValue)"
1742            )
1743
1744            // Calculate shares to burn at current share price
1745            let calculatedSharesToBurn = self.convertToShares(amount)
1746
1747            // DUST PREVENTION: Determine if we should burn all shares instead of calculated amount.
1748            // This happens when:
1749            // 1. Withdrawing full balance (amount >= currentAssetValue)
1750            // 2. Rounding would cause underflow (calculatedSharesToBurn > userShareBalance)
1751            // 3. Remaining balance would be below dust threshold (new!)
1752            let remainingValueAfterBurn = currentAssetValue - amount
1753            let wouldLeaveDust = remainingValueAfterBurn < dustThreshold && remainingValueAfterBurn > 0.0
1754
1755            let burnAllShares = amount >= currentAssetValue
1756                || calculatedSharesToBurn > userShareBalance
1757                || wouldLeaveDust
1758
1759            let sharesToBurn = burnAllShares ? userShareBalance : calculatedSharesToBurn
1760
1761            self.userShares[receiverID] = userShareBalance - sharesToBurn
1762            self.totalShares = self.totalShares - sharesToBurn
1763            self.totalAssets = self.totalAssets - amount
1764
1765            return amount
1766        }
1767        
1768        /// Returns the current share price using ERC4626-style virtual offsets.
1769        /// Virtual shares/assets prevent inflation attacks and ensure share price starts near 1.0.
1770        /// 
1771        /// Formula: sharePrice = (totalAssets + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES)
1772        /// 
1773        /// This protects against the "inflation attack" where the first depositor can
1774        /// manipulate share price by donating assets before others deposit.
1775        /// @return Current share price (assets per share)
1776        access(all) view fun getSharePrice(): UFix64 {
1777            let effectiveShares = self.totalShares + PrizeLinkedAccounts.VIRTUAL_SHARES
1778            let effectiveAssets = self.totalAssets + PrizeLinkedAccounts.VIRTUAL_ASSETS
1779            return effectiveAssets / effectiveShares
1780        }
1781        
1782        /// Converts an asset amount to shares at current share price.
1783        /// @param assets - Asset amount to convert
1784        /// @return Equivalent share amount
1785        access(all) view fun convertToShares(_ assets: UFix64): UFix64 {
1786            return assets / self.getSharePrice()
1787        }
1788        
1789        /// Converts a share amount to assets at current share price.
1790        /// @param shares - Share amount to convert
1791        /// @return Equivalent asset amount
1792        access(all) view fun convertToAssets(_ shares: UFix64): UFix64 {
1793            return shares * self.getSharePrice()
1794        }
1795        
1796        /// Returns the total asset value of a user's shares.
1797        /// @param receiverID - User's receiver ID
1798        /// @return User's total withdrawable balance
1799        access(all) view fun getUserAssetValue(receiverID: UInt64): UFix64 {
1800            let userShareBalance = self.userShares[receiverID] ?? 0.0
1801            return self.convertToAssets(userShareBalance)
1802        }
1803        
1804        /// Returns cumulative yield distributed since pool creation.
1805        access(all) view fun getTotalDistributed(): UFix64 {
1806            return self.totalDistributed
1807        }
1808        
1809        /// Returns total shares outstanding.
1810        access(all) view fun getTotalShares(): UFix64 {
1811            return self.totalShares
1812        }
1813        
1814        /// Returns total assets under management.
1815        access(all) view fun getTotalAssets(): UFix64 {
1816            return self.totalAssets
1817        }
1818        
1819        /// Returns a user's share balance.
1820        /// @param receiverID - User's receiver ID
1821        access(all) view fun getUserShares(receiverID: UInt64): UFix64 {
1822            return self.userShares[receiverID] ?? 0.0
1823        }
1824
1825        /// Cleans up zero-share entries using forEachKey (avoids .keys memory copy).
1826        /// @param limit - Maximum entries to process (for gas management)
1827        /// @return Number of entries cleaned
1828        access(contract) fun cleanupZeroShareEntries(limit: Int): Int {
1829            // Collect keys to check (can't access self inside closure)
1830            var keysToCheck: [UInt64] = []
1831            var count = 0
1832            
1833            self.userShares.forEachKey(fun (key: UInt64): Bool {
1834                if count >= limit {
1835                    return false  // Early exit for gas management
1836                }
1837                keysToCheck.append(key)
1838                count = count + 1
1839                return true
1840            })
1841            
1842            // Check and remove zero entries
1843            var cleaned = 0
1844            for key in keysToCheck {
1845                if self.userShares[key] == 0.0 {
1846                    let _ = self.userShares.remove(key: key)
1847                    cleaned = cleaned + 1
1848                }
1849            }
1850            
1851            return cleaned
1852        }
1853    }
1854    
1855    // ============================================================
1856    // LOTTERY DISTRIBUTOR RESOURCE
1857    // ============================================================
1858    
1859    /// Manages the prize pool and NFT prizes.
1860    /// 
1861    /// This resource handles:
1862    /// - Fungible token prize pool (accumulated from yield distribution)
1863    /// - Available NFT prizes (deposited by admin, awaiting draw)
1864    /// - Pending NFT claims (awarded to winners, awaiting user pickup)
1865    /// - Draw round tracking
1866    /// 
1867    /// PRIZE FLOW:
1868    /// 1. Yield processed → prize portion added to prizeVault
1869    /// 2. NFTs deposited by admin → stored in nftPrizes
1870    /// 3. Draw completes → prizes withdrawn and awarded
1871    /// 4. NFT prizes → stored in pendingNFTClaims for winner
1872    /// 5. Winner claims → NFT transferred to their collection
1873    access(all) resource PrizeDistributor {
1874        /// Vault holding fungible token prizes.
1875        /// Balance is the available prize pool for the next draw.
1876        access(self) var prizeVault: @{FungibleToken.Vault}
1877        
1878        /// NFTs available as prizes, keyed by NFT UUID.
1879        /// Admin deposits NFTs here; winner selection strategy assigns them.
1880        access(self) var nftPrizes: @{UInt64: {NonFungibleToken.NFT}}
1881        
1882        /// NFTs awarded to winners but not yet claimed.
1883        /// Keyed by receiverID → array of NFTs.
1884        /// Winners must explicitly claim via claimPendingNFT().
1885        access(self) var pendingNFTClaims: @{UInt64: [{NonFungibleToken.NFT}]}
1886        
1887        /// Current draw round number (increments each completed draw).
1888        access(self) var _prizeRound: UInt64
1889        
1890        /// Cumulative prizes distributed since pool creation (for statistics).
1891        access(all) var totalPrizesDistributed: UFix64
1892        
1893        /// Returns the current draw round number.
1894        access(all) view fun getPrizeRound(): UInt64 {
1895            return self._prizeRound
1896        }
1897        
1898        /// Updates the draw round number.
1899        /// Called when a draw completes successfully.
1900        /// @param round - New round number
1901        access(contract) fun setPrizeRound(round: UInt64) {
1902            self._prizeRound = round
1903        }
1904        
1905        /// Initializes a new PrizeDistributor with an empty prize vault.
1906        /// @param vaultType - Type of fungible token for prizes
1907        init(vaultType: Type) {
1908            self.prizeVault <- DeFiActionsUtils.getEmptyVault(vaultType)
1909            self.nftPrizes <- {}
1910            self.pendingNFTClaims <- {}
1911            self._prizeRound = 0
1912            self.totalPrizesDistributed = 0.0
1913        }
1914        
1915        /// Adds funds to the prize pool.
1916        /// Called during yield processing when prize portion is allocated.
1917        /// @param vault - Vault containing funds to add
1918        access(contract) fun fundPrizePool(vault: @{FungibleToken.Vault}) {
1919            self.prizeVault.deposit(from: <- vault)
1920        }
1921        
1922        /// Returns the current balance of the prize pool.
1923        access(all) view fun getPrizePoolBalance(): UFix64 {
1924            return self.prizeVault.balance
1925        }
1926        
1927        /// Withdraws prize funds for distribution to winners.
1928        /// 
1929        /// Attempts to withdraw from yield source first (if provided), then
1930        /// falls back to the prize vault. This allows prizes to stay earning
1931        /// yield until they're actually distributed.
1932        /// 
1933        /// @param amount - Amount to withdraw
1934        /// @param yieldSource - Optional yield source to withdraw from first
1935        /// @return Vault containing the withdrawn prize
1936        access(contract) fun withdrawPrize(amount: UFix64, yieldSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}?): @{FungibleToken.Vault} {
1937            // Track cumulative prizes distributed
1938            self.totalPrizesDistributed = self.totalPrizesDistributed + amount
1939            
1940            var result <- DeFiActionsUtils.getEmptyVault(self.prizeVault.getType())
1941            
1942            // Try yield source first if provided
1943            if let source = yieldSource {
1944                let available = source.minimumAvailable()
1945                if available >= amount {
1946                    // Yield source can cover entire amount
1947                    result.deposit(from: <- source.withdrawAvailable(maxAmount: amount))
1948                    return <- result
1949                } else if available > 0.0 {
1950                    // Partial from yield source
1951                    result.deposit(from: <- source.withdrawAvailable(maxAmount: available))
1952                }
1953            }
1954            
1955            // Cover remaining from prize vault
1956            if result.balance < amount {
1957                let remaining = amount - result.balance
1958                assert(self.prizeVault.balance >= remaining, message: "Insufficient prize pool")
1959                result.deposit(from: <- self.prizeVault.withdraw(amount: remaining))
1960            }
1961            
1962            return <- result
1963        }
1964        
1965        /// Deposits an NFT to be available as a prize.
1966        /// @param nft - NFT resource to deposit
1967        access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
1968            let nftID = nft.uuid
1969            // Use force-move to ensure no duplicate IDs
1970            self.nftPrizes[nftID] <-! nft
1971        }
1972        
1973        /// Withdraws an available NFT prize (before it's awarded).
1974        /// Used by admin to recover NFTs or update prize pool.
1975        /// @param nftID - UUID of the NFT to withdraw
1976        /// @return The withdrawn NFT resource
1977        access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
1978            if let nft <- self.nftPrizes.remove(key: nftID) {
1979                return <- nft
1980            }
1981            panic("NFT not found in prize vault: \(nftID)")
1982        }
1983        
1984        /// Stores an NFT for a winner to claim later.
1985        /// Used when awarding NFT prizes - we can't directly transfer to winner's
1986        /// collection without their active participation.
1987        /// @param receiverID - Winner's receiver ID
1988        /// @param nft - NFT to store for claiming
1989        access(contract) fun storePendingNFT(receiverID: UInt64, nft: @{NonFungibleToken.NFT}) {
1990            let nftID = nft.uuid
1991            
1992            // Initialize array if first NFT for this receiver
1993            if self.pendingNFTClaims[receiverID] == nil {
1994                self.pendingNFTClaims[receiverID] <-! []
1995            }
1996            
1997            // Append NFT to receiver's pending claims
1998            if let arrayRef = &self.pendingNFTClaims[receiverID] as auth(Mutate) &[{NonFungibleToken.NFT}]? {
1999                arrayRef.append(<- nft)
2000            } else {
2001                // This shouldn't happen, but handle gracefully
2002                destroy nft
2003                panic("Failed to store NFT in pending claims. NFTID: \(nftID), receiverID: \(receiverID)")
2004            }
2005        }
2006        
2007        /// Returns the number of pending NFT claims for a receiver.
2008        /// @param receiverID - Receiver ID to check
2009        access(all) view fun getPendingNFTCount(receiverID: UInt64): Int {
2010            return self.pendingNFTClaims[receiverID]?.length ?? 0
2011        }
2012        
2013        /// Returns the UUIDs of all pending NFT claims for a receiver.
2014        /// @param receiverID - Receiver ID to check
2015        /// @return Array of NFT UUIDs
2016        access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
2017            if let nfts = &self.pendingNFTClaims[receiverID] as &[{NonFungibleToken.NFT}]? {
2018                var ids: [UInt64] = []
2019                for nft in nfts {
2020                    ids.append(nft.uuid)
2021                }
2022                return ids
2023            }
2024            return []
2025        }
2026        
2027        /// Returns UUIDs of all NFTs available as prizes (not yet awarded).
2028        access(all) view fun getAvailableNFTPrizeIDs(): [UInt64] {
2029            return self.nftPrizes.keys
2030        }
2031        
2032        /// Borrows a reference to an available NFT prize (read-only).
2033        /// @param nftID - UUID of the NFT
2034        /// @return Reference to the NFT, or nil if not found
2035        access(all) view fun borrowNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
2036            return &self.nftPrizes[nftID]
2037        }
2038
2039        /// Claims a pending NFT and returns it to the caller.
2040        /// Called when a winner picks up their NFT prize.
2041        /// @param receiverID - Winner's receiver ID
2042        /// @param nftIndex - Index in the pending claims array (0-based)
2043        /// @return The claimed NFT resource
2044        access(contract) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
2045            pre {
2046                self.pendingNFTClaims[receiverID] != nil: "No pending NFTs for this receiver"
2047                nftIndex >= 0: "NFT index cannot be negative"
2048                nftIndex < (self.pendingNFTClaims[receiverID]?.length ?? 0): "Invalid NFT index"
2049            }
2050            if let nftsRef = &self.pendingNFTClaims[receiverID] as auth(Remove) &[{NonFungibleToken.NFT}]? {
2051                return <- nftsRef.remove(at: nftIndex)
2052            }
2053            panic("Failed to access pending NFT claims. receiverID: \(receiverID), nftIndex: \(nftIndex)")
2054        }
2055
2056        /// Cleans up empty pendingNFTClaims entries using forEachKey (avoids .keys memory copy).
2057        /// @param limit - Maximum entries to process (for gas management)
2058        /// @return Number of entries cleaned
2059        access(contract) fun cleanupEmptyNFTClaimEntries(limit: Int): Int {
2060            // Collect keys to check (can't access self inside closure)
2061            var keysToCheck: [UInt64] = []
2062            var count = 0
2063            
2064            self.pendingNFTClaims.forEachKey(fun (key: UInt64): Bool {
2065                if count >= limit {
2066                    return false  // Early exit for gas management
2067                }
2068                keysToCheck.append(key)
2069                count = count + 1
2070                return true
2071            })
2072            
2073            // Check and remove empty entries
2074            var cleaned = 0
2075            for key in keysToCheck {
2076                if self.pendingNFTClaims[key]?.length == 0 {
2077                    if let emptyArray <- self.pendingNFTClaims.remove(key: key) {
2078                        destroy emptyArray
2079                    }
2080                    cleaned = cleaned + 1
2081                }
2082            }
2083            
2084            return cleaned
2085        }
2086    }
2087    
2088    // ============================================================
2089    // PRIZE DRAW RECEIPT RESOURCE
2090    // ============================================================
2091    
2092    /// Represents a pending prize draw that is waiting for randomness.
2093    /// 
2094    /// This receipt is created during startDraw() and consumed during completeDraw().
2095    /// It holds:
2096    /// - The prize amount committed for this draw
2097    /// - The randomness request (to be fulfilled by Flow's RandomConsumer)
2098    /// 
2099    /// NOTE: User weights are stored in BatchSelectionData resource
2100    /// to enable zero-copy reference passing.
2101    /// 
2102    /// SECURITY: The selection data is built during batch processing phase,
2103    /// so late deposits/withdrawals cannot affect prize odds for this draw.
2104    access(all) resource PrizeDrawReceipt {
2105        /// Total prize amount committed for this draw.
2106        access(all) let prizeAmount: UFix64
2107        
2108        /// Pending randomness request from Flow's RandomConsumer.
2109        /// Set to nil after fulfillment in completeDraw().
2110        access(self) var request: @RandomConsumer.Request?
2111        
2112        /// Creates a new PrizeDrawReceipt.
2113        /// @param prizeAmount - Prize pool for this draw
2114        /// @param request - RandomConsumer request resource
2115        init(prizeAmount: UFix64, request: @RandomConsumer.Request) {
2116            self.prizeAmount = prizeAmount
2117            self.request <- request
2118        }
2119        
2120        /// Returns the block height where randomness was requested.
2121        /// Used to verify enough blocks have passed for secure randomness.
2122        access(all) view fun getRequestBlock(): UInt64? {
2123            return self.request?.block
2124        }
2125        
2126        /// Extracts and returns the randomness request.
2127        /// Called once during completeDraw() to fulfill the request.
2128        /// Panics if called twice (request is consumed).
2129        /// @return The RandomConsumer.Request resource
2130        access(contract) fun popRequest(): @RandomConsumer.Request {
2131            let request <- self.request <- nil
2132            if let r <- request {
2133                return <- r
2134            }
2135            panic("No request to pop")
2136        }
2137    }
2138    
2139    // ============================================================
2140    // WINNER SELECTION TYPES
2141    // ============================================================
2142    
2143    /// Result of a winner selection operation.
2144    /// Contains parallel arrays of winners, their prize amounts, and NFT assignments.
2145    access(all) struct WinnerSelectionResult {
2146        /// Array of winner receiverIDs.
2147        access(all) let winners: [UInt64]
2148        
2149        /// Array of prize amounts (parallel to winners array).
2150        access(all) let amounts: [UFix64]
2151        
2152        /// Array of NFT ID arrays (parallel to winners array).
2153        /// Each winner can receive multiple NFTs.
2154        access(all) let nftIDs: [[UInt64]]
2155        
2156        /// Creates a WinnerSelectionResult.
2157        /// All arrays must have the same length.
2158        /// @param winners - Array of winner receiverIDs
2159        /// @param amounts - Array of prize amounts per winner
2160        /// @param nftIDs - Array of NFT ID arrays per winner
2161        init(winners: [UInt64], amounts: [UFix64], nftIDs: [[UInt64]]) {
2162            pre {
2163                winners.length == amounts.length: "Winners and amounts must have same length"
2164                winners.length == nftIDs.length: "Winners and nftIDs must have same length"
2165            }
2166            self.winners = winners
2167            self.amounts = amounts
2168            self.nftIDs = nftIDs
2169        }
2170    }
2171    
2172    /// Configures how prizes are distributed among winners.
2173    /// 
2174    /// Implementations define:
2175    /// - How many winners to select
2176    /// - Prize amounts or percentages per winner position
2177    /// - NFT assignments per winner position
2178    /// 
2179    /// The actual winner SELECTION is handled by BatchSelectionData.
2180    /// This separation keeps distribution logic clean and testable.
2181    access(all) struct interface PrizeDistribution {
2182        
2183        /// Returns the number of winners this distribution needs.
2184        access(all) view fun getWinnerCount(): Int
2185        
2186        /// Distributes prizes among the selected winners.
2187        /// 
2188        /// @param winners - Array of winner receiverIDs (from BatchSelectionData.selectWinners)
2189        /// @param totalPrizeAmount - Total prize pool available
2190        /// @return WinnerSelectionResult with amounts and NFTs assigned to each winner
2191        access(all) fun distributePrizes(
2192            winners: [UInt64],
2193            totalPrizeAmount: UFix64
2194        ): WinnerSelectionResult
2195        
2196        /// Returns a human-readable description of this distribution.
2197        access(all) view fun getDistributionName(): String
2198    }
2199    
2200    /// Single winner prize distribution.
2201    /// 
2202    /// The simplest distribution: one winner takes the entire prize pool.
2203    /// Winner selection is handled by BatchSelectionData.
2204    access(all) struct SingleWinnerPrize: PrizeDistribution {
2205        /// NFT IDs to award to the winner (all go to single winner).
2206        access(all) let nftIDs: [UInt64]
2207        
2208        /// Creates a SingleWinnerPrize distribution.
2209        /// @param nftIDs - Array of NFT UUIDs to award to winner
2210        init(nftIDs: [UInt64]) {
2211            self.nftIDs = nftIDs
2212        }
2213        
2214        access(all) view fun getWinnerCount(): Int {
2215            return 1
2216        }
2217        
2218        /// Distributes the entire prize to the single winner.
2219        access(all) fun distributePrizes(
2220            winners: [UInt64],
2221            totalPrizeAmount: UFix64
2222        ): WinnerSelectionResult {
2223            if winners.length == 0 {
2224                return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
2225            }
2226            return WinnerSelectionResult(
2227                winners: [winners[0]],
2228                amounts: [totalPrizeAmount],
2229                nftIDs: [self.nftIDs]
2230            )
2231        }
2232        
2233        access(all) view fun getDistributionName(): String {
2234            return "Single Winner (100%)"
2235        }
2236    }
2237    
2238    /// Percentage-based prize distribution across multiple winners.
2239    /// 
2240    /// Distributes prizes by percentage splits. Winner selection is handled by BatchSelectionData.
2241    /// 
2242    /// Example: 3 winners with splits [0.5, 0.3, 0.2]
2243    /// - 1st place: 50% of prize pool
2244    /// - 2nd place: 30% of prize pool
2245    /// - 3rd place: 20% of prize pool
2246    access(all) struct PercentageSplit: PrizeDistribution {
2247        /// Prize split percentages for each winner position.
2248        /// Must sum to 1.0.
2249        access(all) let prizeSplits: [UFix64]
2250        
2251        /// NFT IDs assigned to each winner position.
2252        /// nftIDsPerWinner[i] = array of NFTs for winner at position i.
2253        access(all) let nftIDsPerWinner: [[UInt64]]
2254        
2255        /// Creates a PercentageSplit distribution.
2256        /// @param prizeSplits - Array of percentages summing to 1.0
2257        /// @param nftIDs - Array of NFT UUIDs to distribute (one per winner)
2258        init(prizeSplits: [UFix64], nftIDs: [UInt64]) {
2259            pre {
2260                prizeSplits.length > 0: "Must have at least one split"
2261            }
2262            
2263            // Validate prize splits sum to 1.0 and each is in [0, 1]
2264            var total: UFix64 = 0.0
2265            var splitIndex = 0
2266            for split in prizeSplits {
2267                assert(split >= 0.0 && split <= 1.0, message: "Each split must be between 0 and 1. split: \(split), index: \(splitIndex)")
2268                total = total + split
2269                splitIndex = splitIndex + 1
2270            }
2271            
2272            assert(total == 1.0, message: "Prize splits must sum to 1.0. actual total: \(total)")
2273            
2274            self.prizeSplits = prizeSplits
2275            
2276            // Distribute NFTs: one per winner, in order
2277            var nftArray: [[UInt64]] = []
2278            var nftIndex = 0
2279            for winnerIdx in InclusiveRange(0, prizeSplits.length - 1) {
2280                if nftIndex < nftIDs.length {
2281                    nftArray.append([nftIDs[nftIndex]])
2282                    nftIndex = nftIndex + 1
2283                } else {
2284                    nftArray.append([])
2285                }
2286            }
2287            self.nftIDsPerWinner = nftArray
2288        }
2289        
2290        access(all) view fun getWinnerCount(): Int {
2291            return self.prizeSplits.length
2292        }
2293        
2294        /// Distributes prizes by percentage to the winners.
2295        access(all) fun distributePrizes(
2296            winners: [UInt64],
2297            totalPrizeAmount: UFix64
2298        ): WinnerSelectionResult {
2299            if winners.length == 0 {
2300                return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
2301            }
2302            
2303            // Calculate prize amounts with last winner getting remainder
2304            var prizeAmounts: [UFix64] = []
2305            var nftIDsArray: [[UInt64]] = []
2306            var calculatedSum: UFix64 = 0.0
2307            
2308            for idx in InclusiveRange(0, winners.length - 1) {
2309                // Last winner gets remainder to avoid rounding errors
2310                if idx < winners.length - 1 {
2311                    let split = idx < self.prizeSplits.length ? self.prizeSplits[idx] : 0.0
2312                    let amount = totalPrizeAmount * split
2313                    prizeAmounts.append(amount)
2314                    calculatedSum = calculatedSum + amount
2315                }
2316                
2317                // Assign NFTs
2318                if idx < self.nftIDsPerWinner.length {
2319                    nftIDsArray.append(self.nftIDsPerWinner[idx])
2320                } else {
2321                    nftIDsArray.append([])
2322                }
2323            }
2324            
2325            // Last winner gets remainder
2326            prizeAmounts.append(totalPrizeAmount - calculatedSum)
2327            
2328            return WinnerSelectionResult(
2329                winners: winners,
2330                amounts: prizeAmounts,
2331                nftIDs: nftIDsArray
2332            )
2333        }
2334        
2335        access(all) view fun getDistributionName(): String {
2336            var name = "Split ("
2337            for idx in InclusiveRange(0, self.prizeSplits.length - 1) {
2338                if idx > 0 {
2339                    name = name.concat("/")
2340                }
2341                name = name.concat("\(self.prizeSplits[idx] * 100.0)%")
2342            }
2343            return name.concat(")")
2344        }
2345    }
2346    
2347    /// Defines a prize tier with fixed amount and winner count.
2348    /// Used by FixedAmountTiers for structured prize distribution.
2349    /// 
2350    /// Example tiers:
2351    /// - Grand Prize: 1 winner, 100 tokens, NFT included
2352    /// - First Prize: 3 winners, 50 tokens each
2353    /// - Consolation: 10 winners, 10 tokens each
2354    access(all) struct PrizeTier {
2355        /// Fixed prize amount for each winner in this tier.
2356        access(all) let prizeAmount: UFix64
2357        
2358        /// Number of winners to select for this tier.
2359        access(all) let winnerCount: Int
2360        
2361        /// Human-readable tier name (e.g., "Grand Prize", "Runner Up").
2362        access(all) let name: String
2363        
2364        /// NFT IDs to distribute in this tier (one per winner, in order).
2365        access(all) let nftIDs: [UInt64]
2366        
2367        /// Creates a PrizeTier.
2368        /// @param amount - Prize amount per winner (must be > 0)
2369        /// @param count - Number of winners (must be > 0)
2370        /// @param name - Tier name for display
2371        /// @param nftIDs - NFTs to award (length must be <= count)
2372        init(amount: UFix64, count: Int, name: String, nftIDs: [UInt64]) {
2373            pre {
2374                amount > 0.0: "Prize amount must be positive"
2375                count > 0: "Winner count must be positive"
2376                nftIDs.length <= count: "Cannot have more NFTs than winners in tier"
2377            }
2378            self.prizeAmount = amount
2379            self.winnerCount = count
2380            self.name = name
2381            self.nftIDs = nftIDs
2382        }
2383    }
2384    
2385    /// Fixed amount tier-based prize distribution.
2386    /// 
2387    /// Distributes prizes according to pre-defined tiers, each with a fixed
2388    /// prize amount and winner count. Unlike PercentageSplit which uses
2389    /// percentages, this uses absolute amounts.
2390    /// 
2391    /// Example configuration:
2392    /// - Tier 1: 1 winner gets 100 tokens + rare NFT
2393    /// - Tier 2: 3 winners get 50 tokens each
2394    /// - Tier 3: 10 winners get 10 tokens each
2395    /// 
2396    /// Winner selection is handled by BatchSelectionData.
2397    access(all) struct FixedAmountTiers: PrizeDistribution {
2398        /// Ordered array of prize tiers (processed in order).
2399        access(all) let tiers: [PrizeTier]
2400        
2401        /// Creates a FixedAmountTiers distribution.
2402        /// @param tiers - Array of prize tiers (must have at least one)
2403        init(tiers: [PrizeTier]) {
2404            pre {
2405                tiers.length > 0: "Must have at least one prize tier"
2406            }
2407            self.tiers = tiers
2408        }
2409        
2410        access(all) view fun getWinnerCount(): Int {
2411            var total = 0
2412            for tier in self.tiers {
2413                total = total + tier.winnerCount
2414            }
2415            return total
2416        }
2417        
2418        /// Distributes fixed amounts to winners according to tiers.
2419        /// Winners are assigned to tiers in order.
2420        access(all) fun distributePrizes(
2421            winners: [UInt64],
2422            totalPrizeAmount: UFix64
2423        ): WinnerSelectionResult {
2424            if winners.length == 0 {
2425                return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
2426            }
2427            
2428            // Calculate total prize amount needed
2429            var totalNeeded: UFix64 = 0.0
2430            for tier in self.tiers {
2431                totalNeeded = totalNeeded + (tier.prizeAmount * UFix64(tier.winnerCount))
2432            }
2433            
2434            // Insufficient prize pool - return empty
2435            if totalPrizeAmount < totalNeeded {
2436                return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
2437            }
2438            
2439            var allWinners: [UInt64] = []
2440            var allPrizes: [UFix64] = []
2441            var allNFTIDs: [[UInt64]] = []
2442            var winnerIdx = 0
2443            
2444            // Process each tier in order
2445            for tier in self.tiers {
2446                var tierWinnerCount = 0
2447                
2448                while tierWinnerCount < tier.winnerCount && winnerIdx < winners.length {
2449                    allWinners.append(winners[winnerIdx])
2450                    allPrizes.append(tier.prizeAmount)
2451                    
2452                    // Assign NFT if available for this position
2453                    if tierWinnerCount < tier.nftIDs.length {
2454                        allNFTIDs.append([tier.nftIDs[tierWinnerCount]])
2455                    } else {
2456                        allNFTIDs.append([])
2457                    }
2458                    
2459                    tierWinnerCount = tierWinnerCount + 1
2460                    winnerIdx = winnerIdx + 1
2461                }
2462            }
2463            
2464            return WinnerSelectionResult(
2465                winners: allWinners,
2466                amounts: allPrizes,
2467                nftIDs: allNFTIDs
2468            )
2469        }
2470        
2471        access(all) view fun getDistributionName(): String {
2472            var name = "Fixed Tiers ("
2473            for idx in InclusiveRange(0, self.tiers.length - 1) {
2474                if idx > 0 {
2475                    name = name.concat(", ")
2476                }
2477                let tier = self.tiers[idx]
2478                name = name.concat("\(tier.winnerCount)x \(tier.prizeAmount)")
2479            }
2480            return name.concat(")")
2481        }
2482    }
2483    
2484    // ============================================================
2485    // POOL CONFIGURATION
2486    // ============================================================
2487    
2488    /// Configuration parameters for a prize-linked accounts pool.
2489    /// 
2490    /// Contains all settings needed to operate a pool:
2491    /// - Asset type and yield source
2492    /// - Distribution and winner selection strategies
2493    /// - Operational parameters (minimum deposit, draw interval)
2494    /// - Optional integrations (winner tracker)
2495    /// 
2496    /// Most parameters can be updated by admin after pool creation.
2497    access(all) struct PoolConfig {
2498        /// Type of fungible token this pool accepts (e.g., FlowToken.Vault type).
2499        /// Immutable after pool creation.
2500        access(all) let assetType: Type
2501        
2502        /// Minimum amount required for deposits (prevents dust deposits).
2503        /// Can be updated by admin. Set to 0 to allow any amount.
2504        access(all) var minimumDeposit: UFix64
2505        
2506        /// Minimum time (seconds) between prize draws.
2507        /// Determines epoch length and TWAB accumulation period.
2508        access(all) var drawIntervalSeconds: UFix64
2509        
2510        /// Yield source connection (implements both deposit and withdraw).
2511        /// Handles depositing funds to earn yield and withdrawing for prizes/redemptions.
2512        /// Immutable after pool creation.
2513        access(contract) let yieldConnector: {DeFiActions.Sink, DeFiActions.Source}
2514        
2515        /// Strategy for distributing yield between rewards, prize, and protocol fee.
2516        /// Can be updated by admin with CriticalOps entitlement.
2517        access(contract) var distributionStrategy: {DistributionStrategy}
2518        
2519        /// Configuration for how prizes are distributed among winners.
2520        /// Can be updated by admin with CriticalOps entitlement.
2521        access(contract) var prizeDistribution: {PrizeDistribution}
2522
2523        /// Creates a new PoolConfig.
2524        /// @param assetType - Type of fungible token vault
2525        /// @param yieldConnector - DeFi connector for yield generation
2526        /// @param minimumDeposit - Minimum deposit amount (>= 0)
2527        /// @param drawIntervalSeconds - Seconds between draws (>= 1)
2528        /// @param distributionStrategy - Yield distribution strategy
2529        /// @param prizeDistribution - Prize distribution configuration
2530        init(
2531            assetType: Type,
2532            yieldConnector: {DeFiActions.Sink, DeFiActions.Source},
2533            minimumDeposit: UFix64,
2534            drawIntervalSeconds: UFix64,
2535            distributionStrategy: {DistributionStrategy},
2536            prizeDistribution: {PrizeDistribution}
2537        ) {
2538            self.assetType = assetType
2539            self.yieldConnector = yieldConnector
2540            self.minimumDeposit = minimumDeposit
2541            self.drawIntervalSeconds = drawIntervalSeconds
2542            self.distributionStrategy = distributionStrategy
2543            self.prizeDistribution = prizeDistribution
2544        }
2545        
2546        /// Updates the distribution strategy.
2547        /// @param strategy - New distribution strategy
2548        access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
2549            self.distributionStrategy = strategy
2550        }
2551        
2552        /// Updates the prize distribution configuration.
2553        /// @param distribution - New prize distribution
2554        access(contract) fun setPrizeDistribution(distribution: {PrizeDistribution}) {
2555            self.prizeDistribution = distribution
2556        }
2557        
2558        /// Updates the draw interval.
2559        /// @param interval - New interval in seconds (must be >= 1)
2560        access(contract) fun setDrawIntervalSeconds(interval: UFix64) {
2561            pre {
2562                interval >= 1.0: "Draw interval must be at least 1 seconds"
2563            }
2564            self.drawIntervalSeconds = interval
2565        }
2566        
2567        /// Updates the minimum deposit amount.
2568        /// @param minimum - New minimum (must be >= 0)
2569        access(contract) fun setMinimumDeposit(minimum: UFix64) {
2570            pre {
2571                minimum >= 0.0: "Minimum deposit cannot be negative"
2572            }
2573            self.minimumDeposit = minimum
2574        }
2575        
2576        /// Returns the distribution strategy name for display.
2577        access(all) view fun getDistributionStrategyName(): String {
2578            return self.distributionStrategy.getStrategyName()
2579        }
2580        
2581        /// Returns the prize distribution name for display.
2582        access(all) view fun getPrizeDistributionName(): String {
2583            return self.prizeDistribution.getDistributionName()
2584        }
2585        
2586        /// Calculates yield distribution for a given amount.
2587        /// Delegates to the configured distribution strategy.
2588        /// @param totalAmount - Amount to distribute
2589        /// @return DistributionPlan with calculated amounts
2590        access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan {
2591            return self.distributionStrategy.calculateDistribution(totalAmount: totalAmount)
2592        }
2593    }
2594    
2595    // ============================================================
2596    // BATCH DRAW STATE (FUTURE SCALABILITY)
2597    // ============================================================
2598    
2599    /// State tracking for multi-transaction batch draws.
2600    /// 
2601    /// When user count grows large, processing all TWAB calculations in a single
2602    /// transaction may exceed gas limits. This struct enables breaking the draw
2603    /// into multiple transactions:
2604    /// 
2605    /// Resource holding prize selection data - implemented as a resource to enable zero-copy reference passing.
2606    /// 
2607    /// Lifecycle:
2608    /// 1. Created at startDraw() with empty arrays
2609    /// 2. Built incrementally by processDrawBatch()
2610    /// 3. Reference passed to selectWinners() in completeDraw()
2611    /// 4. Destroyed after completeDraw()
2612    /// 
2613    access(all) resource BatchSelectionData {
2614        /// Receivers with weight > 0, in processing order
2615        access(contract) var receiverIDs: [UInt64]
2616        
2617        /// Parallel array: cumulative weight sums for binary search
2618        /// cumulativeWeights[i] = sum of weights for receivers 0..i
2619        access(contract) var cumulativeWeights: [UFix64]
2620        
2621        /// Total weight (cached, equals last element of cumulativeWeights)
2622        access(contract) var totalWeight: UFix64
2623        
2624        /// Current cursor position in registeredReceiverList
2625        access(contract) var cursor: Int
2626        
2627        /// Snapshot of receiver count at startDraw time.
2628        /// Used to determine batch completion - only process users who existed at draw start.
2629        /// New deposits during batch processing don't extend the batch.
2630        access(contract) let snapshotReceiverCount: Int
2631        
2632        init(snapshotCount: Int) {
2633            self.receiverIDs = []
2634            self.cumulativeWeights = []
2635            self.totalWeight = 0.0
2636            self.cursor = 0
2637            self.snapshotReceiverCount = snapshotCount
2638            self.RANDOM_SCALING_FACTOR = 1_000_000_000
2639            self.RANDOM_SCALING_DIVISOR = 1_000_000_000.0
2640        }
2641        
2642        // ============================================================
2643        // BATCH BUILDING METHODS (called by processDrawBatch)
2644        // ============================================================
2645        
2646        /// Adds a receiver with their weight. Builds cumulative sum on the fly.
2647        /// Only adds if weight > 0.
2648        /// Includes overflow protection to prevent draw DoS at high weight accumulation.
2649        access(contract) fun addEntry(receiverID: UInt64, weight: UFix64) {
2650            if weight > 0.0 {
2651                // Overflow protection: ensure addition won't exceed safe threshold
2652                assert(
2653                    self.totalWeight + weight <= PrizeLinkedAccounts.WEIGHT_WARNING_THRESHOLD,
2654                    message: "Total weight would exceed safe threshold. Current: \(self.totalWeight), adding: \(weight)"
2655                )
2656
2657                self.receiverIDs.append(receiverID)
2658                self.totalWeight = self.totalWeight + weight
2659                self.cumulativeWeights.append(self.totalWeight)
2660            }
2661        }
2662        
2663        /// Sets cursor to specific position.
2664        access(contract) fun setCursor(_ position: Int) {
2665            self.cursor = position
2666        }
2667        
2668        // ============================================================
2669        // READ METHODS (for strategies via reference)
2670        // ============================================================
2671        
2672        access(all) view fun getCursor(): Int {
2673            return self.cursor
2674        }
2675        
2676        access(all) view fun getSnapshotReceiverCount(): Int {
2677            return self.snapshotReceiverCount
2678        }
2679        
2680        access(all) view fun getReceiverCount(): Int {
2681            return self.receiverIDs.length
2682        }
2683        
2684        access(all) view fun getTotalWeight(): UFix64 {
2685            return self.totalWeight
2686        }
2687        
2688        access(all) view fun getReceiverID(at index: Int): UInt64 {
2689            return self.receiverIDs[index]
2690        }
2691        
2692        access(all) view fun getCumulativeWeight(at index: Int): UFix64 {
2693            return self.cumulativeWeights[index]
2694        }
2695        
2696        /// Binary search: finds first index where cumulativeWeights[i] > target.
2697        /// Used for weighted random selection. O(log n) complexity.
2698        access(all) view fun findWinnerIndex(randomValue: UFix64): Int {
2699            if self.receiverIDs.length == 0 {
2700                return 0
2701            }
2702            
2703            var low = 0
2704            var high = self.receiverIDs.length - 1
2705            
2706            while low < high {
2707                let mid = (low + high) / 2
2708                if self.cumulativeWeights[mid] <= randomValue {
2709                    low = mid + 1
2710                } else {
2711                    high = mid
2712                }
2713            }
2714            return low
2715        }
2716        
2717        /// Gets the individual weight for a receiver at a given index.
2718        /// (Not cumulative - the actual weight for that receiver)
2719        access(all) view fun getWeight(at index: Int): UFix64 {
2720            if index == 0 {
2721                return self.cumulativeWeights[0]
2722            }
2723            return self.cumulativeWeights[index] - self.cumulativeWeights[index - 1]
2724        }
2725        
2726        // ============================================================
2727        // WINNER SELECTION METHODS
2728        // ============================================================
2729        
2730        /// Scaling constants for random number conversion.
2731        /// Uses 1 billion for 9 decimal places of precision.
2732        access(self) let RANDOM_SCALING_FACTOR: UInt64
2733        access(self) let RANDOM_SCALING_DIVISOR: UFix64
2734        
2735        /// Selects winners using weighted random selection without replacement.
2736        /// Uses PRNG for deterministic sequence from initial seed.
2737        /// For single winner, pass count=1.
2738        /// 
2739        /// @param count - Number of winners to select
2740        /// @param randomNumber - Initial seed for PRNG
2741        /// @return Array of winner receiverIDs (may be shorter than count if insufficient participants)
2742        access(all) fun selectWinners(count: Int, randomNumber: UInt64): [UInt64] {
2743            let receiverCount = self.receiverIDs.length
2744            if receiverCount == 0 || count == 0 {
2745                return []
2746            }
2747            
2748            let actualCount = count < receiverCount ? count : receiverCount
2749            
2750            // Single participant case
2751            if receiverCount == 1 {
2752                return [self.receiverIDs[0]]
2753            }
2754            
2755            // Zero weight fallback: return first N participants
2756            if self.totalWeight == 0.0 {
2757                var winners: [UInt64] = []
2758                for idx in InclusiveRange(0, actualCount - 1) {
2759                    winners.append(self.receiverIDs[idx])
2760                }
2761                return winners
2762            }
2763            
2764            // Initialize PRNG
2765            let prg = self.createPRNG(seed: randomNumber)
2766
2767            // OPTIMIZED: Binary search with rejection sampling O(k * log n) average
2768            // Uses binary search for fast lookup, re-samples on collision
2769            // More efficient than linear scan O(n * k) when k << n
2770            var winners: [UInt64] = []
2771            var selectedIndices: {Int: Bool} = {}
2772            let maxRetries = receiverCount * 3  // Safety limit to avoid infinite loops
2773
2774            var selected = 0
2775            var retries = 0
2776            while selected < actualCount && retries < maxRetries {
2777                let rng = prg.nextUInt64()
2778                let scaledRandom = UFix64(rng % self.RANDOM_SCALING_FACTOR) / self.RANDOM_SCALING_DIVISOR
2779                let randomValue = scaledRandom * self.totalWeight
2780
2781                // Use binary search to find candidate winner
2782                let candidateIdx = self.findWinnerIndex(randomValue: randomValue)
2783
2784                // Check if already selected (rejection sampling)
2785                if selectedIndices[candidateIdx] != nil {
2786                    retries = retries + 1
2787                    continue
2788                }
2789
2790                // Accept this winner
2791                winners.append(self.receiverIDs[candidateIdx])
2792                selectedIndices[candidateIdx] = true
2793                selected = selected + 1
2794                retries = 0  // Reset retry counter on success
2795            }
2796
2797            // Fallback: if we hit max retries (very unlikely), fill remaining with unselected
2798            if selected < actualCount {
2799                for i in InclusiveRange(0, receiverCount - 1) {
2800                    if selected >= actualCount {
2801                        break
2802                    }
2803                    if selectedIndices[i] == nil {
2804                        winners.append(self.receiverIDs[i])
2805                        selectedIndices[i] = true
2806                        selected = selected + 1
2807                    }
2808                }
2809            }
2810
2811            return winners
2812        }
2813        
2814        /// Creates a PRNG from a seed for deterministic multi-winner selection.
2815        access(self) fun createPRNG(seed: UInt64): Xorshift128plus.PRG {
2816            var randomBytes = seed.toBigEndianBytes()
2817            while randomBytes.length < 16 {
2818                randomBytes.appendAll(seed.toBigEndianBytes())
2819            }
2820            var paddedBytes: [UInt8] = []
2821            for idx in InclusiveRange(0, 15) {
2822                paddedBytes.append(randomBytes[idx % randomBytes.length])
2823            }
2824            return Xorshift128plus.PRG(sourceOfRandomness: paddedBytes, salt: [])
2825        }
2826    }
2827    
2828    // ============================================================
2829    // POOL RESOURCE - Core Prize Rewards Pool
2830    // ============================================================
2831    
2832    /// The main prize-linked accounts pool resource.
2833    /// 
2834    /// Pool is the central coordinator that manages:
2835    /// - User deposits and withdrawals
2836    /// - Yield generation and distribution
2837    /// - Prize draws and prize distribution
2838    /// - Emergency mode and health monitoring
2839    /// 
2840    /// ARCHITECTURE:
2841    /// Pool contains nested resources:
2842    /// - ShareTracker: Share-based accounting
2843    /// - PrizeDistributor: Prize pool and NFT management
2844    /// - RandomConsumer: On-chain randomness for fair draws
2845    /// 
2846    /// LIFECYCLE:
2847    /// 1. Admin creates pool with createPool()
2848    /// 2. Users deposit via PoolPositionCollection.deposit()
2849    /// 3. Yield accrues from connected DeFi source
2850    /// 4. syncWithYieldSource() distributes yield per strategy
2851    /// 5. Admin calls startDraw() → completeDraw() for prize
2852    /// 6. Winners receive auto-compounded prizes
2853    /// 7. Users withdraw via PoolPositionCollection.withdraw()
2854    /// 
2855    /// DESTRUCTION:
2856    /// In Cadence 1.0+, nested resources are automatically destroyed with Pool.
2857    /// Order: pendingDrawReceipt → randomConsumer → shareTracker → prizeDistributor
2858    /// Protocol fee should be forwarded before destruction.
2859    access(all) resource Pool {
2860        // ============================================================
2861        // CONFIGURATION STATE
2862        // ============================================================
2863        
2864        /// Pool configuration (strategies, asset type, parameters).
2865        access(self) var config: PoolConfig
2866        
2867        /// Unique identifier for this pool (assigned at creation).
2868        access(self) var poolID: UInt64
2869        
2870        // ============================================================
2871        // EMERGENCY STATE
2872        // ============================================================
2873        
2874        /// Current operational state of the pool.
2875        access(self) var emergencyState: PoolEmergencyState
2876        
2877        /// Human-readable reason for non-Normal state (for debugging).
2878        access(self) var emergencyReason: String?
2879        
2880        /// Timestamp when emergency/partial mode was activated.
2881        access(self) var emergencyActivatedAt: UFix64?
2882        
2883        /// Configuration for emergency behavior (thresholds, auto-recovery).
2884        access(self) var emergencyConfig: EmergencyConfig
2885        
2886        /// Counter for consecutive withdrawal failures (triggers emergency).
2887        access(self) var consecutiveWithdrawFailures: Int
2888        
2889        /// Sets the pool ID. Called once during pool creation.
2890        /// @param id - The unique pool identifier
2891        access(contract) fun setPoolID(id: UInt64) {
2892            self.poolID = id
2893        }
2894        
2895        // ============================================================
2896        // USER TRACKING STATE
2897        // ============================================================
2898        
2899        /// Mapping of receiverID to their lifetime prize winnings (cumulative).
2900        access(self) let receiverTotalEarnedPrizes: {UInt64: UFix64}
2901        
2902        /// Maps receiverID to their index in registeredReceiverList.
2903        /// Used for O(1) lookup and O(1) unregistration via swap-and-pop.
2904        access(self) var registeredReceivers: {UInt64: Int}
2905        
2906        /// Sequential list of registered receiver IDs.
2907        /// Used for O(n) iteration during batch processing without array allocation.
2908        access(self) var registeredReceiverList: [UInt64]
2909        
2910        /// Mapping of receiverID to bonus prize weight.
2911        /// Bonus weight represents equivalent token deposit for the full round duration.
2912        /// A bonus of 5.0 gives the same prize weight as holding 5 tokens for the entire round.
2913        access(self) let receiverBonusWeights: {UInt64: UFix64}
2914        
2915        /// Tracks which receivers are sponsors (prize-ineligible).
2916        /// Sponsors earn rewards yield but cannot win prizes.
2917        /// Key: receiverID (UUID of SponsorPositionCollection), Value: true if sponsor
2918        access(self) let sponsorReceivers: {UInt64: Bool}
2919        
2920        /// Maps receiverID to their last known owner address.
2921        /// Updated on each deposit/withdraw to track current owner.
2922        /// Address may become stale if resource is transferred without interaction.
2923        /// WARNING: This is not a source of truth.  Used only for event emission  
2924        access(self) let receiverAddresses: {UInt64: Address}
2925        
2926        // ============================================================
2927        // ACCOUNTING STATE
2928        // ============================================================
2929        // 
2930        // KEY RELATIONSHIPS:
2931        // 
2932        // userPoolBalance: Sum of user deposits + auto-compounded prizes
2933        //   - Excludes rewards interest (interest is tracked in share price)
2934        //   - Updated on: deposit (+), prize awarded (+), withdraw (-)
2935        //   - This is the "no-loss guarantee" amount
2936        // 
2937        // ============================================================
2938        // YIELD ALLOCATION TRACKING
2939        // ============================================================
2940        // These three variables partition the yield source balance into buckets.
2941        // Their sum (getTotalAllocatedFunds()) represents the total tracked assets.
2942        // syncWithYieldSource() syncs these variables with the yield source balance.
2943        //
2944        // User portion of yield source balance
2945        //   - Includes deposits + won prizes + accrued rewards yield
2946        //   - Updated on: deposit (+), prize (+), rewards yield (+), withdraw (-)
2947        access(all) var userPoolBalance: UFix64
2948        //
2949        // allocatedPrizeYield: Prize portion awaiting transfer to prize pool
2950        //   - Accumulates as yield is earned
2951        //   - Transferred to prize pool vault at draw time
2952        access(all) var allocatedPrizeYield: UFix64
2953        //
2954        // allocatedProtocolFee: Protocol portion awaiting transfer to recipient
2955        //   - Accumulates as yield is earned (includes rounding dust)
2956        //   - Transferred to recipient or unclaimed vault at draw time
2957        // 
2958        // ============================================================
2959
2960        /// Timestamp of the last completed prize draw.
2961        access(all) var lastDrawTimestamp: UFix64
2962        
2963        // ============================================================
2964        // YIELD ALLOCATION VARIABLES
2965        // Sum of these three = yield source balance (see getTotalAllocatedFunds)
2966        // ============================================================
2967        
2968        /// User allocation: deposits + prizes won + accrued rewards yield.
2969        /// This is the portion of the yield source that belongs to users.
2970        
2971        
2972        /// Prize allocation: yield awaiting transfer to prize pool at draw time.
2973        /// Stays in yield source earning until materialized during draw.
2974        
2975        /// Tracks how much of allocatedPrizeYield came from direct funding (e.g. marketing sponsorship)
2976        /// for the current draw period. Reset to 0.0 when a new round starts.
2977        /// Use this to distinguish organic yield from direct contributions:
2978        ///   organicPrizeYield = allocatedPrizeYield - directPrizeFundingThisDraw
2979        access(all) var directPrizeFundingThisDraw: UFix64
2980        
2981        /// Protocol allocation: yield awaiting transfer to recipient at draw time.
2982        /// Stays in yield source earning until materialized during draw.
2983        access(all) var allocatedProtocolFee: UFix64
2984        
2985        /// Cumulative protocol fee amount forwarded to recipient.
2986        access(all) var totalProtocolFeeForwarded: UFix64
2987        
2988        /// Capability to protocol fee recipient for forwarding at draw time.
2989        /// If nil, protocol fee goes to unclaimedProtocolFeeVault instead.
2990        access(self) var protocolFeeRecipientCap: Capability<&{FungibleToken.Receiver}>?
2991        
2992        /// Holds protocol fee when no recipient is configured.
2993        /// Admin can withdraw from this vault at any time.
2994        access(self) var unclaimedProtocolFeeVault: @{FungibleToken.Vault}
2995        
2996        // ============================================================
2997        // NESTED RESOURCES
2998        // ============================================================
2999        
3000        /// Manages rewards: ERC4626-style share accounting.
3001        access(self) let shareTracker: @ShareTracker
3002        
3003        /// Manages prize: prize pool, NFTs, pending claims.
3004        access(self) let prizeDistributor: @PrizeDistributor
3005        
3006        /// Holds pending draw receipt during two-phase draw process.
3007        /// Set during startDraw(), consumed during completeDraw().
3008        access(self) var pendingDrawReceipt: @PrizeDrawReceipt?
3009        
3010        /// On-chain randomness consumer for fair prize selection.
3011        access(self) let randomConsumer: @RandomConsumer.Consumer
3012        
3013        // ============================================================
3014        // ROUND-BASED TWAB TRACKING
3015        // ============================================================
3016        
3017        /// Current active round for TWAB accumulation.
3018        /// Deposits and withdrawals accumulate TWAB in this round.
3019        /// During draw processing, this round has actualEndTime set and is being finalized.
3020        /// nil indicates the pool is in intermission (between rounds).
3021        /// Destroyed at completeDraw(), recreated at startNextRound().
3022        access(self) var activeRound: @Round?
3023
3024        /// ID of the last completed round (for intermission state queries).
3025        /// Updated when completeDraw() finishes, used by getCurrentRoundID() during intermission.
3026        access(self) var lastCompletedRoundID: UInt64
3027        
3028        // ============================================================
3029        // BATCH PROCESSING STATE (for prize weight capture)
3030        // ============================================================
3031        
3032        /// Selection data being built during batch processing.
3033        /// Created at startDraw(), built by processDrawBatch(),
3034        /// reference passed to selectWinners() in completeDraw(), then destroyed.
3035        /// Using a resource enables zero-copy reference passing for large datasets.
3036        access(self) var pendingSelectionData: @BatchSelectionData?
3037        
3038        /// Creates a new Pool.
3039        /// @param config - Pool configuration
3040        /// @param emergencyConfig - Optional emergency config (uses defaults if nil)
3041        init(
3042            config: PoolConfig, 
3043            emergencyConfig: EmergencyConfig?
3044        ) {
3045            self.config = config
3046            self.poolID = 0  // Set by setPoolID after creation
3047            
3048            // Initialize emergency state as Normal
3049            self.emergencyState = PoolEmergencyState.Normal
3050            self.emergencyReason = nil
3051            self.emergencyActivatedAt = nil
3052            self.emergencyConfig = emergencyConfig ?? PrizeLinkedAccounts.createDefaultEmergencyConfig()
3053            self.consecutiveWithdrawFailures = 0
3054            
3055            // Initialize user tracking
3056            self.receiverTotalEarnedPrizes = {}
3057            self.registeredReceivers = {}
3058            self.registeredReceiverList = []
3059            self.receiverBonusWeights = {}
3060            self.sponsorReceivers = {}
3061            self.receiverAddresses = {}
3062            
3063            // Initialize accounting
3064            self.userPoolBalance = 0.0
3065            self.lastDrawTimestamp = 0.0
3066            self.allocatedPrizeYield = 0.0
3067            self.directPrizeFundingThisDraw = 0.0
3068            self.allocatedProtocolFee = 0.0
3069            self.totalProtocolFeeForwarded = 0.0
3070            self.protocolFeeRecipientCap = nil
3071            
3072            // Create vault for unclaimed protocol fee (when no recipient configured)
3073            self.unclaimedProtocolFeeVault <- DeFiActionsUtils.getEmptyVault(config.assetType)
3074            
3075            // Create nested resources
3076            self.shareTracker <- create ShareTracker(vaultType: config.assetType)
3077            self.prizeDistributor <- create PrizeDistributor(vaultType: config.assetType)
3078            
3079            // Initialize draw state
3080            self.pendingDrawReceipt <- nil
3081            self.randomConsumer <- RandomConsumer.createConsumer()
3082            
3083            // Initialize round-based TWAB tracking
3084            // Create initial round starting now with configured draw interval determining target end time
3085            let now = getCurrentBlock().timestamp
3086            self.activeRound <- create Round(
3087                roundID: 1,
3088                startTime: now,
3089                targetEndTime: now + config.drawIntervalSeconds
3090            )
3091            self.lastCompletedRoundID = 0
3092            
3093            // Initialize selection data (nil = no batch in progress)
3094            self.pendingSelectionData <- nil
3095        }
3096        
3097        // ============================================================
3098        // RECEIVER REGISTRATION
3099        // ============================================================
3100
3101        /// Registers a receiver ID with this pool.
3102        /// Called automatically when a user first deposits.
3103        /// Adds to both the index dictionary and the sequential list.
3104        /// @param receiverID - UUID of the PoolPositionCollection
3105        /// @param ownerAddress - Optional owner address for address resolution
3106        access(contract) fun registerReceiver(receiverID: UInt64, ownerAddress: Address?) {
3107            pre {
3108                self.registeredReceivers[receiverID] == nil: "Receiver already registered"
3109            }
3110            // Store index pointing to the end of the list
3111            let index = self.registeredReceiverList.length
3112            self.registeredReceivers[receiverID] = index
3113            self.registeredReceiverList.append(receiverID)
3114            
3115            // Store address for address resolution if provided
3116            self.updatereceiverAddress(receiverID: receiverID, ownerAddress: ownerAddress)
3117        }
3118        
3119        /// Resolves the last known owner address of a receiver.
3120        /// Returns the address stored during the last deposit/withdraw interaction.
3121        /// Address may be stale if resource was transferred without interaction.
3122        /// @param receiverID - UUID of the PoolPositionCollection
3123        /// @return Last known owner address, or nil if unknown
3124        access(all) view fun getReceiverOwnerAddress(receiverID: UInt64): Address? {
3125            return self.receiverAddresses[receiverID]
3126        }
3127        
3128        /// Updates the stored owner address for a receiver only if it has changed.
3129        /// Saves storage write costs when address remains the same.
3130        /// @param receiverID - UUID of the PoolPositionCollection
3131        /// @param ownerAddress - Optional new owner address to store
3132        access(contract) fun updatereceiverAddress(receiverID: UInt64, ownerAddress: Address?) {
3133            if let addr = ownerAddress {
3134                if self.receiverAddresses[receiverID] != addr {
3135                    self.receiverAddresses[receiverID] = addr
3136                }
3137            }
3138        }
3139        
3140        /// Unregisters a receiver ID from this pool.
3141        /// Called when a user withdraws to 0 shares.
3142        /// Uses swap-and-pop for O(1) removal from the list.
3143        /// @param receiverID - UUID of the PoolPositionCollection
3144        access(contract) fun unregisterReceiver(receiverID: UInt64) {
3145            pre {
3146                self.registeredReceivers[receiverID] != nil: "Receiver not registered"
3147            }
3148            
3149            let index = self.registeredReceivers[receiverID]!
3150            let lastIndex = self.registeredReceiverList.length - 1
3151            
3152            // If not the last element, swap with last
3153            if index != lastIndex {
3154                let lastReceiverID = self.registeredReceiverList[lastIndex]
3155                // Move last element to the removed position
3156                self.registeredReceiverList[index] = lastReceiverID
3157                // Update the moved element's index in the dictionary
3158                self.registeredReceivers[lastReceiverID] = index
3159            }
3160            
3161            // Remove last element (O(1))
3162            let _removedReceiver = self.registeredReceiverList.removeLast()
3163            // Remove from dictionary
3164            let _removedIndex = self.registeredReceivers.remove(key: receiverID)
3165            
3166            // Clean up address mapping
3167            self.receiverAddresses.remove(key: receiverID)
3168        }
3169        
3170        /// Cleans up stale dictionary entries and ghost receivers to manage storage growth.
3171        /// 
3172        /// Handles:
3173        /// 1. Ghost receivers - users with 0 shares still in registeredReceiverList
3174        /// 2. userShares entries with 0.0 value
3175        /// 3. Empty pendingNFTClaims arrays
3176        /// 
3177        /// IMPORTANT: Cannot be called during active draw processing (would corrupt indices).
3178        /// Should be called periodically (e.g., after draws, weekly) by admin.
3179        /// Uses limit to avoid gas limits - call multiple times if needed.
3180        /// 
3181        /// GAS OPTIMIZATION:
3182        /// - Uses forEachKey instead of .keys (avoids O(n) memory copy)
3183        /// - All cleanups have limits for gas management
3184        /// 
3185        /// @param limit - Max entries to process per cleanup type
3186        /// @return Dictionary with cleanup counts
3187        /// Cleans up stale entries with cursor-based iteration.
3188        /// 
3189        /// @param startIndex - Index to start iterating from in registeredReceiverList
3190        /// @param limit - Max receivers to process in this call
3191        /// @return Dictionary with cleanup counts and nextIndex for continuation
3192        access(contract) fun cleanupStaleEntries(startIndex: Int, limit: Int): {String: Int} {
3193            pre {
3194                self.pendingSelectionData == nil: "Cannot cleanup during active draw - would corrupt batch indices"
3195            }
3196            
3197            var ghostReceiversCleaned = 0
3198            var userSharesCleaned = 0
3199            var pendingNFTClaimsCleaned = 0
3200            
3201            let totalReceivers = self.registeredReceiverList.length
3202            
3203            // 1. Clean ghost receivers (0-share users still in registeredReceiverList)
3204            // Use cursor-based iteration with swap-and-pop awareness
3205            var i = startIndex < totalReceivers ? startIndex : totalReceivers
3206            var processed = 0
3207            
3208            while i < self.registeredReceiverList.length && processed < limit {
3209                let receiverID = self.registeredReceiverList[i]
3210                let shares = self.shareTracker.getUserShares(receiverID: receiverID)
3211                
3212                if shares == 0.0 {
3213                    // This receiver is a ghost - unregister them
3214                    self.unregisterReceiver(receiverID: receiverID)
3215                    ghostReceiversCleaned = ghostReceiversCleaned + 1
3216                    // Don't increment i - swap-and-pop moved a new element here
3217                } else {
3218                    i = i + 1
3219                }
3220                processed = processed + 1
3221            }
3222            
3223            // 2. Clean userShares with 0 values (forEachKey avoids .keys memory copy)
3224            userSharesCleaned = self.shareTracker.cleanupZeroShareEntries(limit: limit)
3225            
3226            // 3. Clean empty pendingNFTClaims arrays (forEachKey avoids .keys memory copy)
3227            pendingNFTClaimsCleaned = self.prizeDistributor.cleanupEmptyNFTClaimEntries(limit: limit)
3228            
3229            return {
3230                "ghostReceivers": ghostReceiversCleaned,
3231                "userShares": userSharesCleaned,
3232                "pendingNFTClaims": pendingNFTClaimsCleaned,
3233                "nextIndex": i,
3234                "totalReceivers": self.registeredReceiverList.length
3235            }
3236        }
3237
3238        // ============================================================
3239        // EMERGENCY STATE MANAGEMENT
3240        // ============================================================
3241        
3242        /// Returns the current emergency state.
3243        access(all) view fun getEmergencyState(): PoolEmergencyState {
3244            return self.emergencyState
3245        }
3246        
3247        /// Returns the emergency configuration.
3248        access(all) view fun getEmergencyConfig(): EmergencyConfig {
3249            return self.emergencyConfig
3250        }
3251        
3252        /// Sets the pool state with optional reason.
3253        /// Handles state transition logic (reset counters on Normal).
3254        /// @param state - Target state
3255        /// @param reason - Optional reason for non-Normal states
3256        access(contract) fun setState(state: PoolEmergencyState, reason: String?) {
3257            self.emergencyState = state
3258            if state == PoolEmergencyState.Normal {
3259                // Clear emergency tracking when returning to normal
3260                self.emergencyReason = nil
3261                self.emergencyActivatedAt = nil
3262                self.consecutiveWithdrawFailures = 0
3263            } else {
3264                self.emergencyReason = reason
3265                self.emergencyActivatedAt = getCurrentBlock().timestamp
3266            }
3267        }
3268        
3269        /// Enables emergency mode (withdrawals only).
3270        /// @param reason - Human-readable reason
3271        access(contract) fun setEmergencyMode(reason: String) {
3272            self.emergencyState = PoolEmergencyState.EmergencyMode
3273            self.emergencyReason = reason
3274            self.emergencyActivatedAt = getCurrentBlock().timestamp
3275        }
3276        
3277        /// Enables partial mode (limited deposits, no draws).
3278        /// @param reason - Human-readable reason
3279        access(contract) fun setPartialMode(reason: String) {
3280            self.emergencyState = PoolEmergencyState.PartialMode
3281            self.emergencyReason = reason
3282            self.emergencyActivatedAt = getCurrentBlock().timestamp
3283        }
3284        
3285        /// Clears emergency mode and returns to Normal.
3286        /// Resets failure counters.
3287        access(contract) fun clearEmergencyMode() {
3288            self.emergencyState = PoolEmergencyState.Normal
3289            self.emergencyReason = nil
3290            self.emergencyActivatedAt = nil
3291            self.consecutiveWithdrawFailures = 0
3292        }
3293        
3294        /// Updates the emergency configuration.
3295        /// @param config - New emergency configuration
3296        access(contract) fun setEmergencyConfig(config: EmergencyConfig) {
3297            self.emergencyConfig = config
3298        }
3299        
3300        /// Returns true if pool is in emergency mode.
3301        access(all) view fun isEmergencyMode(): Bool {
3302            return self.emergencyState == PoolEmergencyState.EmergencyMode
3303        }
3304        
3305        /// Returns true if pool is in partial mode.
3306        access(all) view fun isPartialMode(): Bool {
3307            return self.emergencyState == PoolEmergencyState.PartialMode
3308        }
3309        
3310        /// Returns detailed emergency information if not in Normal state.
3311        /// Useful for debugging and monitoring.
3312        /// @return Dictionary with emergency details, or nil if Normal
3313        access(all) fun getEmergencyInfo(): {String: AnyStruct}? {
3314            if self.emergencyState != PoolEmergencyState.Normal {
3315                let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
3316                let health = self.checkYieldSourceHealth()
3317                return {
3318                    "state": self.emergencyState.rawValue,
3319                    "reason": self.emergencyReason ?? "Unknown",
3320                    "activatedAt": self.emergencyActivatedAt ?? 0.0,
3321                    "durationSeconds": duration,
3322                    "yieldSourceHealth": health,
3323                    "canAutoRecover": self.emergencyConfig.autoRecoveryEnabled,
3324                    "maxDuration": self.emergencyConfig.maxEmergencyDuration ?? 0.0  // 0.0 = no max
3325                }
3326            }
3327            return nil
3328        }
3329        
3330        // ============================================================
3331        // HEALTH MONITORING
3332        // ============================================================
3333        
3334        /// Calculates a health score for the yield source (0.0 to 1.0).
3335        /// 
3336        /// Components:
3337        /// - Balance health (0.5): Is balance >= expected threshold?
3338        /// - Withdrawal success (0.5): Based on consecutive failure count
3339        /// 
3340        /// @return Health score from 0.0 (unhealthy) to 1.0 (fully healthy)
3341        access(contract) fun checkYieldSourceHealth(): UFix64 {
3342            let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
3343            let balance = yieldSource.minimumAvailable()
3344            let threshold = self.getEmergencyConfig().minBalanceThreshold
3345            
3346            // Check if balance meets threshold (50% of health score)
3347            let balanceHealthy = balance >= self.userPoolBalance * threshold
3348            
3349            // Calculate withdrawal success rate (50% of health score)
3350            let withdrawSuccessRate = self.consecutiveWithdrawFailures == 0 ? 1.0 : 
3351                (1.0 / UFix64(self.consecutiveWithdrawFailures + 1))
3352            
3353            // Combine scores
3354            var health: UFix64 = 0.0
3355            if balanceHealthy { health = health + 0.5 }
3356            health = health + (withdrawSuccessRate * 0.5)
3357            return health
3358        }
3359        
3360        /// Checks if emergency mode should be auto-triggered.
3361        /// Called during withdrawals to detect yield source issues.
3362        /// @return true if emergency mode was triggered
3363        access(contract) fun checkAndAutoTriggerEmergency(): Bool {
3364            // Only trigger from Normal state
3365            if self.emergencyState != PoolEmergencyState.Normal {
3366                return false
3367            }
3368            
3369            let health = self.checkYieldSourceHealth()
3370            
3371            // Trigger on low health
3372            if health < self.emergencyConfig.minYieldSourceHealth {
3373                self.setEmergencyMode(reason: "Auto-triggered: Yield source health below threshold (\(health))")
3374                emit EmergencyModeAutoTriggered(
3375                    poolID: self.poolID,
3376                    reason: "Low yield source health",
3377                    healthScore: health,
3378                    timestamp: getCurrentBlock().timestamp
3379                )
3380                return true
3381            }
3382            
3383            // Trigger on consecutive withdrawal failures
3384            if self.consecutiveWithdrawFailures >= self.emergencyConfig.maxWithdrawFailures {
3385                self.setEmergencyMode(reason: "Auto-triggered: Multiple consecutive withdrawal failures")
3386                emit EmergencyModeAutoTriggered(
3387                    poolID: self.poolID,
3388                    reason: "Withdrawal failures",
3389                    healthScore: health,
3390                    timestamp: getCurrentBlock().timestamp)
3391                return true
3392            }
3393            
3394            return false
3395        }
3396        
3397        /// Checks if pool should auto-recover from emergency mode.
3398        /// Called during withdrawals to detect improved conditions.
3399        /// @return true if recovery occurred
3400        access(contract) fun checkAndAutoRecover(): Bool {
3401            // Only recover from EmergencyMode
3402            if self.emergencyState != PoolEmergencyState.EmergencyMode {
3403                return false
3404            }
3405            
3406            // Must have auto-recovery enabled
3407            if !self.emergencyConfig.autoRecoveryEnabled {
3408                return false
3409            }
3410            
3411            let health = self.checkYieldSourceHealth()
3412            
3413            // Health-based recovery: yield source is fully healthy
3414            if health >= 0.9 {
3415                self.clearEmergencyMode()
3416                emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Yield source recovered", healthScore: health, duration: nil, timestamp: getCurrentBlock().timestamp)
3417                return true
3418            }
3419            
3420            // Time-based recovery: only if health is above minimum threshold
3421            let minRecoveryHealth = self.emergencyConfig.minRecoveryHealth
3422            if let maxDuration = self.emergencyConfig.maxEmergencyDuration {
3423                let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
3424                if duration > maxDuration && health >= minRecoveryHealth {
3425                    self.clearEmergencyMode()
3426                    emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Max duration exceeded", healthScore: health, duration: duration, timestamp: getCurrentBlock().timestamp)
3427                    return true
3428                }
3429            }
3430            
3431            return false
3432        }
3433        
3434        // ============================================================
3435        // DIRECT FUNDING (Admin Only)
3436        // ============================================================
3437        
3438        /// Internal implementation for admin direct funding.
3439        /// Routes funds to specified destination (Rewards or Prize).
3440        /// 
3441        /// For Rewards: Deposits to yield source and accrues yield to share price.
3442        /// For Prize: Adds directly to prize pool.
3443        /// 
3444        /// @param destination - Where to route funds (Rewards or Prize)
3445        /// @param from - Vault containing funds to deposit
3446        /// @param adminUUID - Admin resource UUID for audit trail
3447        /// @param purpose - Description of funding purpose
3448        /// @param metadata - Additional metadata key-value pairs
3449        access(contract) fun fundDirectInternal(
3450            destination: PoolFundingDestination,
3451            from: @{FungibleToken.Vault},
3452            adminUUID: UInt64,
3453            purpose: String,
3454            metadata: {String: String}
3455        ) {
3456            pre {
3457                self.emergencyState == PoolEmergencyState.Normal: "Direct funding only in normal state. Current state: \(self.emergencyState.rawValue)"
3458                from.getType() == self.config.assetType: "Invalid vault type. Expected: \(self.config.assetType.identifier), got: \(from.getType().identifier)"
3459            }
3460            
3461            switch destination {
3462                case PoolFundingDestination.Prize:
3463                    // Deposit to yield source, track as prize allocation
3464                    let nominalAmount = from.balance
3465                    let actualReceived = self.depositToYieldSourceFull(<- from)
3466                    self.allocatedPrizeYield = self.allocatedPrizeYield + actualReceived
3467                    self.directPrizeFundingThisDraw = self.directPrizeFundingThisDraw + actualReceived
3468                    emit PrizePoolFunded(poolID: self.poolID, amount: actualReceived, source: "direct_funding")
3469
3470                case PoolFundingDestination.Rewards:
3471                    // Rewards funding requires depositors to receive the yield
3472                    assert(
3473                        self.shareTracker.getTotalShares() > 0.0,
3474                        message: "Cannot fund rewards with no depositors - funds would be orphaned. Amount: \(from.balance), totalShares: \(self.shareTracker.getTotalShares())"
3475                    )
3476
3477                    let nominalAmount = from.balance
3478
3479                    // Deposit to yield source and measure actual received
3480                    let actualReceived = self.depositToYieldSourceFull(<- from)
3481
3482                    // Accrue yield to share price based on ACTUAL received (minus dust to virtual shares)
3483                    let actualRewards = self.shareTracker.accrueYield(amount: actualReceived)
3484                    let dustAmount = actualReceived - actualRewards
3485                    self.userPoolBalance = self.userPoolBalance + actualRewards
3486                    emit RewardsYieldAccrued(poolID: self.poolID, amount: actualRewards)
3487
3488                    // Route dust to pending protocol
3489                    if dustAmount > 0.0 {
3490                        emit RewardsRoundingDustToProtocolFee(poolID: self.poolID, amount: dustAmount)
3491                        self.allocatedProtocolFee = self.allocatedProtocolFee + dustAmount
3492                    }
3493
3494                default:
3495                    panic("Unsupported funding destination. Destination rawValue: \(destination.rawValue)")
3496            }
3497        }
3498        
3499        // ============================================================
3500        // CORE USER OPERATIONS
3501        // ============================================================
3502        
3503        /// Deposits funds for a receiver.
3504        /// 
3505        /// Called internally by PoolPositionCollection.deposit().
3506        /// 
3507        /// FLOW:
3508        /// 1. Validate state (not paused/emergency) and amount (>= minimum)
3509        /// 2. Process any pending yield rewards
3510        /// 3. Mint shares proportional to deposit
3511        /// 4. Update TWAB in active round (or mark as gap interactor if round ended)
3512        /// 5. Update accounting (userPoolBalance)
3513        /// 6. Deposit to yield source
3514        /// 
3515        /// TWAB HANDLING:
3516        /// - If in active round: recordShareChange() accumulates TWAB up to now
3517        /// - If in gap period (round ended, startDraw not called): finalize in ended round
3518        ///   with actualEndTime, new round will use lazy fallback
3519        /// - If pending draw exists: finalize user in that round with actual end time
3520        /// 
3521        /// @param from - Vault containing funds to deposit (consumed)
3522        /// @param receiverID - UUID of the depositor's PoolPositionCollection
3523        /// @param ownerAddress - Optional owner address for address resolution
3524        /// @param maxSlippageBps - Maximum acceptable slippage in basis points (100 = 1%, 10000 = 100% = no protection)
3525        access(contract) fun deposit(from: @{FungibleToken.Vault}, receiverID: UInt64, ownerAddress: Address?, maxSlippageBps: UInt64) {
3526            pre {
3527                from.balance > 0.0: "Deposit amount must be positive. Amount: \(from.balance)"
3528                from.getType() == self.config.assetType: "Invalid vault type. Expected: \(self.config.assetType.identifier), got: \(from.getType().identifier)"
3529                self.shareTracker.getTotalAssets() + from.balance <= PrizeLinkedAccounts.SAFE_MAX_TVL: "Deposit would exceed pool TVL capacity"
3530                maxSlippageBps <= 10000: "maxSlippageBps cannot exceed 10000 (100%)"
3531            }
3532
3533            // Auto-register if not registered (handles re-deposits after full withdrawal)
3534            if self.registeredReceivers[receiverID] == nil {
3535                self.registerReceiver(receiverID: receiverID, ownerAddress: ownerAddress)
3536            } else {
3537                // Update address if provided (tracks current owner)
3538                self.updatereceiverAddress(receiverID: receiverID, ownerAddress: ownerAddress)
3539            }
3540
3541            // Enforce state-specific deposit rules (validate on nominal amount before slippage)
3542            let nominalAmount = from.balance
3543            switch self.emergencyState {
3544                case PoolEmergencyState.Normal:
3545                    // Normal: enforce minimum deposit
3546                    assert(nominalAmount >= self.config.minimumDeposit, message: "Below minimum deposit. Required: \(self.config.minimumDeposit), got: \(nominalAmount)")
3547                case PoolEmergencyState.PartialMode:
3548                    // Partial: enforce deposit limit
3549                    let depositLimit = self.emergencyConfig.partialModeDepositLimit ?? 0.0
3550                    assert(depositLimit > 0.0, message: "Partial mode deposit limit not configured. ReceiverID: \(receiverID)")
3551                    assert(nominalAmount <= depositLimit, message: "Deposit exceeds partial mode limit. Limit: \(depositLimit), got: \(nominalAmount)")
3552                case PoolEmergencyState.EmergencyMode:
3553                    // Emergency: no deposits allowed
3554                    panic("Deposits disabled in emergency mode. Withdrawals only. ReceiverID: \(receiverID), amount: \(nominalAmount)")
3555                case PoolEmergencyState.Paused:
3556                    // Paused: nothing allowed
3557                    panic("Pool is paused. No operations allowed. ReceiverID: \(receiverID), amount: \(nominalAmount)")
3558            }
3559
3560            // Process pending yield/deficit before deposit to ensure fair share price
3561            if self.needsSync() {
3562                self.syncWithYieldSource()
3563            }
3564
3565            let now = getCurrentBlock().timestamp
3566
3567            // 1. Deposit to yield source (zero-check is centralized in depositToYieldSourceFull)
3568            let actualReceived = self.depositToYieldSourceFull(<- from)
3569
3570            // 2. Enforce slippage protection (compare asset amounts, not shares)
3571            // minAcceptable = nominalAmount * (10000 - maxSlippageBps) / 10000
3572            let minAcceptable = nominalAmount * UFix64(UInt64(10000) - maxSlippageBps) / 10000.0
3573            assert(
3574                actualReceived >= minAcceptable,
3575                message: "Slippage exceeded: sent \(nominalAmount), received \(actualReceived), max slippage \(maxSlippageBps) bps"
3576            )
3577
3578            // 3. Get current shares BEFORE minting for TWAB calculation
3579            let oldShares = self.shareTracker.getUserShares(receiverID: receiverID)
3580
3581            // 4. Mint shares based on ACTUAL received (not nominal)
3582            let newSharesMinted = self.shareTracker.deposit(receiverID: receiverID, amount: actualReceived)
3583
3584            let newShares = oldShares + newSharesMinted
3585
3586            // 5. Update TWAB in the active round (if exists)
3587            if let round = &self.activeRound as &Round? {
3588                round.recordShareChange(
3589                    receiverID: receiverID,
3590                    oldShares: oldShares,
3591                    newShares: newShares,
3592                    atTime: now
3593                )
3594            }
3595
3596            // 6. Update pool total with ACTUAL amount received
3597            self.userPoolBalance = self.userPoolBalance + actualReceived
3598
3599            emit Deposited(poolID: self.poolID, receiverID: receiverID, amount: actualReceived, shares: newSharesMinted, ownerAddress: ownerAddress)
3600        }
3601        
3602        /// Deposits funds as a sponsor (prize-ineligible).
3603        /// 
3604        /// Called internally by SponsorPositionCollection.deposit().
3605        /// 
3606        /// Sponsors earn rewards yield through share price appreciation but
3607        /// are NOT eligible to win prizes. This is useful for:
3608        /// - Protocol treasuries seeding initial liquidity
3609        /// - Foundations incentivizing participation without competing
3610        /// - Users who want yield but don't want prize exposure
3611        /// 
3612        /// DIFFERENCES FROM REGULAR DEPOSIT:
3613        /// - Not added to registeredReceiverList (no prize eligibility)
3614        /// - No TWAB tracking (no prize weight needed)
3615        /// - Tracked in sponsorReceivers mapping instead
3616        /// 
3617        /// @param from - Vault containing funds to deposit (consumed)
3618        /// @param receiverID - UUID of the sponsor's SponsorPositionCollection
3619        /// @param ownerAddress - Owner address of the SponsorPositionCollection (passed directly since sponsors don't use capabilities)
3620        /// @param maxSlippageBps - Maximum acceptable slippage in basis points (100 = 1%, 10000 = 100% = no protection)
3621        access(contract) fun sponsorDeposit(from: @{FungibleToken.Vault}, receiverID: UInt64, ownerAddress: Address?, maxSlippageBps: UInt64) {
3622            pre {
3623                from.balance > 0.0: "Deposit amount must be positive. Amount: \(from.balance)"
3624                from.getType() == self.config.assetType: "Invalid vault type. Expected: \(self.config.assetType.identifier), got: \(from.getType().identifier)"
3625                self.shareTracker.getTotalAssets() + from.balance <= PrizeLinkedAccounts.SAFE_MAX_TVL: "Deposit would exceed pool TVL capacity"
3626                maxSlippageBps <= 10000: "maxSlippageBps cannot exceed 10000 (100%)"
3627            }
3628
3629            // Enforce state-specific deposit rules (validate on nominal amount before slippage)
3630            let nominalAmount = from.balance
3631            switch self.emergencyState {
3632                case PoolEmergencyState.Normal:
3633                    // Normal: enforce minimum deposit
3634                    assert(nominalAmount >= self.config.minimumDeposit, message: "Below minimum deposit. Required: \(self.config.minimumDeposit), got: \(nominalAmount)")
3635                case PoolEmergencyState.PartialMode:
3636                    // Partial: enforce deposit limit
3637                    let depositLimit = self.emergencyConfig.partialModeDepositLimit ?? 0.0
3638                    assert(depositLimit > 0.0, message: "Partial mode deposit limit not configured. ReceiverID: \(receiverID)")
3639                    assert(nominalAmount <= depositLimit, message: "Deposit exceeds partial mode limit. Limit: \(depositLimit), got: \(nominalAmount)")
3640                case PoolEmergencyState.EmergencyMode:
3641                    // Emergency: no deposits allowed
3642                    panic("Deposits disabled in emergency mode. Withdrawals only. ReceiverID: \(receiverID), amount: \(nominalAmount)")
3643                case PoolEmergencyState.Paused:
3644                    // Paused: nothing allowed
3645                    panic("Pool is paused. No operations allowed. ReceiverID: \(receiverID), amount: \(nominalAmount)")
3646            }
3647
3648            // Process pending yield/deficit before deposit to ensure fair share price
3649            if self.needsSync() {
3650                self.syncWithYieldSource()
3651            }
3652
3653            // 1. Deposit to yield source FIRST (zero-check is centralized in depositToYieldSourceFull)
3654            let actualReceived = self.depositToYieldSourceFull(<- from)
3655
3656            // 2. Enforce slippage protection (compare asset amounts, not shares)
3657            let minAcceptable = nominalAmount * UFix64(UInt64(10000) - maxSlippageBps) / 10000.0
3658            assert(
3659                actualReceived >= minAcceptable,
3660                message: "Slippage exceeded: sent \(nominalAmount), received \(actualReceived), max slippage \(maxSlippageBps) bps"
3661            )
3662
3663            // 3. Mint shares based on ACTUAL received (not nominal)
3664            let newSharesMinted = self.shareTracker.deposit(receiverID: receiverID, amount: actualReceived)
3665
3666            // Mark as sponsor (prize-ineligible)
3667            self.sponsorReceivers[receiverID] = true
3668            
3669            // Update pool total
3670            self.userPoolBalance = self.userPoolBalance + actualReceived
3671
3672            // NOTE: No registeredReceiverList registration - sponsors are NOT prize-eligible
3673            // NOTE: No TWAB/Round tracking - no prize weight needed
3674
3675            emit SponsorDeposited(poolID: self.poolID, receiverID: receiverID, amount: actualReceived, shares: newSharesMinted, ownerAddress: ownerAddress)
3676        }
3677        
3678        /// Withdraws funds for a receiver.
3679        /// 
3680        /// Called internally by PoolPositionCollection.withdraw().
3681        /// 
3682        /// FLOW:
3683        /// 1. Validate state (not paused) and balance (sufficient)
3684        /// 2. Attempt auto-recovery if in emergency mode
3685        /// 3. Process pending yield (if in normal mode)
3686        /// 4. Withdraw from yield source
3687        /// 5. Burn shares proportional to withdrawal
3688        /// 6. Update TWAB in active round (or mark as gap interactor if round ended)
3689        /// 7. Update accounting (userPoolBalance)
3690        /// 
3691        /// TWAB HANDLING:
3692        /// - If in active round: recordShareChange() accumulates TWAB up to now
3693        /// - If in gap period: finalize in ended round with actual end time
3694        /// - If pending draw exists: finalize user in that round with actual end time
3695        /// 
3696        /// DUST PREVENTION & FULL WITHDRAWAL HANDLING:
3697        /// When remaining balance after withdrawal would be below minimumDeposit, this is
3698        /// treated as a "full withdrawal" - all shares are burned and the user receives
3699        /// whatever is available from the yield source. This prevents dust accumulation
3700        /// and handles rounding errors gracefully. Users don't need to calculate exact
3701        /// amounts; requesting their approximate full balance will cleanly exit the pool.
3702        ///
3703        /// LIQUIDITY FAILURE BEHAVIOR:
3704        /// If the yield source has insufficient liquidity for a non-full withdrawal,
3705        /// this function returns an EMPTY vault (0 tokens). This:
3706        /// - Simplifies accounting (no unexpected partial share burns)
3707        /// - Enables clear failure detection via WithdrawalFailure events
3708        /// - Triggers emergency mode after consecutive failures
3709        /// For partial withdrawals, users should check available liquidity first via
3710        /// getYieldSourceBalance().
3711        /// 
3712        /// @param amount - Amount to withdraw (must be > 0)
3713        /// @param receiverID - UUID of the withdrawer's PoolPositionCollection
3714        /// @param ownerAddress - Optional owner address for tracking current owner
3715        /// @return Vault containing withdrawn funds (may be empty on failure)
3716        access(contract) fun withdraw(amount: UFix64, receiverID: UInt64, ownerAddress: Address?): @{FungibleToken.Vault} {
3717            pre {
3718                amount > 0.0: "Withdraw amount must be greater than 0"
3719                self.registeredReceivers[receiverID] != nil || self.sponsorReceivers[receiverID] == true: "Receiver not registered. ReceiverID: \(receiverID)"
3720            }
3721            
3722            // Update stored address if provided (tracks current owner)
3723            self.updatereceiverAddress(receiverID: receiverID, ownerAddress: ownerAddress)
3724            
3725            // Paused pool: nothing allowed
3726            assert(self.emergencyState != PoolEmergencyState.Paused, message: "Pool is paused - no operations allowed. ReceiverID: \(receiverID), amount: \(amount)")
3727            
3728            // In emergency mode, check if we can auto-recover
3729            if self.emergencyState == PoolEmergencyState.EmergencyMode {
3730                let _ = self.checkAndAutoRecover()
3731            }
3732            
3733            // Process pending yield/deficit before withdrawal (if in normal mode)
3734            if self.emergencyState == PoolEmergencyState.Normal && self.needsSync() {
3735                self.syncWithYieldSource()
3736            }
3737            
3738            // Validate user has sufficient balance
3739            let totalBalance = self.shareTracker.getUserAssetValue(receiverID: receiverID)
3740            assert(totalBalance >= amount, message: "Insufficient balance. You have \(totalBalance) but trying to withdraw \(amount)")
3741
3742            // DUST PREVENTION: If remaining balance after withdrawal would be below dust threshold,
3743            // treat this as a full withdrawal to prevent dust from being left behind.
3744            // This also handles rounding errors where user's calculated balance differs slightly
3745            // from actual yield source availability.
3746            // Using 1/10 of minimumDeposit as threshold to only catch true rounding dust,
3747            // not meaningful partial balances.
3748            let dustThreshold = self.config.minimumDeposit / 10.0
3749            let remainingBalance = totalBalance - amount
3750            let isFullWithdrawal = remainingBalance < dustThreshold
3751
3752            // For full withdrawals, request the full balance (may be adjusted by yield source availability)
3753            var withdrawAmount = isFullWithdrawal ? totalBalance : amount
3754
3755            // Check if yield source has sufficient liquidity
3756            let yieldAvailable = self.config.yieldConnector.minimumAvailable()
3757
3758            // For full withdrawals with minor rounding mismatch, use available amount
3759            if isFullWithdrawal && yieldAvailable < withdrawAmount && yieldAvailable > 0.0 {
3760                withdrawAmount = yieldAvailable
3761            }
3762
3763            // Handle insufficient liquidity in yield source
3764            if yieldAvailable < withdrawAmount {
3765                // Track failure (only increment in Normal mode to avoid double-counting)
3766                let newFailureCount = self.consecutiveWithdrawFailures
3767                    + (self.emergencyState == PoolEmergencyState.Normal ? 1 : 0)
3768                
3769                emit WithdrawalFailure(
3770                    poolID: self.poolID, 
3771                    receiverID: receiverID, 
3772                    amount: amount,
3773                    consecutiveFailures: newFailureCount, 
3774                    yieldAvailable: yieldAvailable,
3775                    ownerAddress: ownerAddress
3776                )
3777                
3778                // Update failure count and check for emergency trigger
3779                if self.emergencyState == PoolEmergencyState.Normal {
3780                    self.consecutiveWithdrawFailures = newFailureCount
3781                    let _ = self.checkAndAutoTriggerEmergency()
3782                }
3783                
3784                // Return empty vault - withdrawal failed
3785                emit Withdrawn(poolID: self.poolID, receiverID: receiverID, requestedAmount: amount, actualAmount: 0.0, ownerAddress: ownerAddress)
3786                return <- DeFiActionsUtils.getEmptyVault(self.config.assetType)
3787            }
3788            
3789            // Attempt withdrawal from yield source
3790            let withdrawn <- self.config.yieldConnector.withdrawAvailable(maxAmount: withdrawAmount)
3791            let actualWithdrawn = withdrawn.balance
3792            
3793            // Handle zero withdrawal (yield source returned nothing despite claiming availability)
3794            if actualWithdrawn == 0.0 {
3795                let newFailureCount = self.consecutiveWithdrawFailures
3796                    + (self.emergencyState == PoolEmergencyState.Normal ? 1 : 0)
3797                
3798                emit WithdrawalFailure(
3799                    poolID: self.poolID, 
3800                    receiverID: receiverID, 
3801                    amount: amount,
3802                    consecutiveFailures: newFailureCount, 
3803                    yieldAvailable: yieldAvailable,
3804                    ownerAddress: ownerAddress
3805                )
3806                
3807                if self.emergencyState == PoolEmergencyState.Normal {
3808                    self.consecutiveWithdrawFailures = newFailureCount
3809                    let _ = self.checkAndAutoTriggerEmergency()
3810                }
3811                
3812                emit Withdrawn(poolID: self.poolID, receiverID: receiverID, requestedAmount: amount, actualAmount: 0.0, ownerAddress: ownerAddress)
3813                return <- withdrawn
3814            }
3815            
3816            // Successful withdrawal - reset failure counter
3817            if self.emergencyState == PoolEmergencyState.Normal {
3818                self.consecutiveWithdrawFailures = 0
3819            }
3820            
3821            let now = getCurrentBlock().timestamp
3822            
3823            // Get current shares BEFORE the withdrawal for TWAB calculation
3824            let oldShares = self.shareTracker.getUserShares(receiverID: receiverID)
3825            
3826            // Burn shares proportional to withdrawal
3827            let actualBurned = self.shareTracker.withdraw(
3828                receiverID: receiverID,
3829                amount: actualWithdrawn,
3830                dustThreshold: dustThreshold
3831            )
3832            
3833            // Get new shares AFTER the withdrawal
3834            let newShares = self.shareTracker.getUserShares(receiverID: receiverID)
3835            
3836            // Update TWAB in the active round (if exists)
3837            if let round = &self.activeRound as &Round? {
3838                round.recordShareChange(
3839                    receiverID: receiverID,
3840                    oldShares: oldShares,
3841                    newShares: newShares,
3842                    atTime: now 
3843                )
3844            }
3845
3846            // Update pool total
3847            self.userPoolBalance = self.userPoolBalance - actualWithdrawn
3848            
3849            // If user has withdrawn to 0 shares, unregister them
3850            // BUT NOT if a draw is in progress - unregistering during batch processing
3851            // would corrupt indices (swap-and-pop). Ghost users with 0 shares get 0 weight.
3852            // They can be cleaned up via admin cleanupStaleEntries() after the draw.
3853            if newShares == 0.0 && self.pendingSelectionData == nil {
3854                // Handle sponsors vs regular receivers differently
3855                if self.sponsorReceivers[receiverID] == true {
3856                    // Clean up sponsor mapping
3857                    let _ = self.sponsorReceivers.remove(key: receiverID)
3858                } else {
3859                    // Unregister regular receiver from prize draws
3860                    self.unregisterReceiver(receiverID: receiverID)
3861                }
3862            }
3863            
3864            emit Withdrawn(poolID: self.poolID, receiverID: receiverID, requestedAmount: amount, actualAmount: actualWithdrawn, ownerAddress: ownerAddress)
3865            return <- withdrawn
3866        }
3867        
3868        // ============================================================
3869        // YIELD SOURCE SYNCHRONIZATION
3870        // ============================================================
3871        
3872        /// Syncs internal accounting with the yield source balance.
3873        /// 
3874        /// Compares actual yield source balance to internal allocations and
3875        /// adjusts accounting to match reality. Handles both appreciation (excess)
3876        /// and depreciation (deficit).
3877        /// 
3878        /// ALLOCATED FUNDS (see getTotalAllocatedFunds()):
3879        /// userPoolBalance + allocatedPrizeYield + allocatedProtocolFee
3880        /// This sum must always equal the yield source balance after sync.
3881        /// 
3882        /// EXCESS (yieldBalance > allocatedFunds):
3883        /// 1. Calculate excess amount
3884        /// 2. Apply distribution strategy (rewards/prize/protocolFee split)
3885        /// 3. Accrue rewards yield to share price (increases userPoolBalance)
3886        /// 4. Add prize yield to allocatedPrizeYield
3887        /// 5. Add protocol fee to allocatedProtocolFee
3888        /// 
3889        /// DEFICIT (yieldBalance < allocatedFunds):
3890        /// 1. Calculate deficit amount
3891        /// 2. Distribute proportionally across all allocations
3892        /// 3. Reduce allocatedProtocolFee first (protocol absorbs loss first)
3893        /// 4. Reduce allocatedPrizeYield second
3894        /// 5. Reduce rewards (share price) last - protecting user principal
3895        /// 
3896        /// Called automatically during deposits and withdrawals.
3897        /// Can also be called manually by admin.
3898        access(contract) fun syncWithYieldSource() {
3899            let yieldBalance = self.config.yieldConnector.minimumAvailable()
3900            let allocatedFunds = self.getTotalAllocatedFunds()
3901            
3902            // Calculate absolute difference
3903            let difference: UFix64 = yieldBalance > allocatedFunds 
3904                ? yieldBalance - allocatedFunds 
3905                : allocatedFunds - yieldBalance
3906            
3907            // Skip sync for amounts below threshold to avoid precision loss.
3908            // Small discrepancies accumulate in the yield source until they exceed threshold.
3909            if difference < PrizeLinkedAccounts.MINIMUM_DISTRIBUTION_THRESHOLD {
3910                return
3911            }
3912            
3913            // === EXCESS: Apply gains ===
3914            if yieldBalance > allocatedFunds {
3915                self.applyExcess(amount: difference)
3916                return
3917            }
3918            
3919            // === DEFICIT: Apply shortfall ===
3920            if yieldBalance < allocatedFunds {
3921                self.applyDeficit(amount: difference)
3922                return
3923            }
3924            
3925            // === BALANCED: Nothing to do ===
3926        }
3927        
3928        /// Deposits a vault's full balance to the yield source.
3929        /// Asserts the entire amount was accepted; reverts if any funds are left over.
3930        /// Destroys the vault after successful deposit.
3931        /// Returns the actual amount received by the yield source (may differ from nominal due to slippage).
3932        ///
3933        /// @param vault - The vault to deposit (will be destroyed)
3934        /// @return actualReceived - The actual amount credited to the yield source
3935        access(self) fun depositToYieldSourceFull(_ vault: @{FungibleToken.Vault}): UFix64 {
3936            let nominalAmount = vault.balance
3937            let beforeYieldBalance = self.config.yieldConnector.minimumAvailable()
3938
3939            self.config.yieldConnector.depositCapacity(from: &vault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
3940            assert(vault.balance == 0.0, message: "Yield sink could not accept full deposit. Nominal: \(nominalAmount), leftover: \(vault.balance)")
3941            destroy vault
3942
3943            let afterYieldBalance = self.config.yieldConnector.minimumAvailable()
3944            let actualReceived = afterYieldBalance - beforeYieldBalance
3945
3946            // Centralized zero-check: every caller is protected against a buggy/paused
3947            // yield source that silently swallows tokens without crediting anything
3948            assert(actualReceived > 0.0, message: "Yield source credited zero for deposit. Nominal: \(nominalAmount), received: 0")
3949
3950            // Emit slippage event if there's a difference
3951            if actualReceived < nominalAmount {
3952                emit DepositSlippage(
3953                    poolID: self.poolID,
3954                    nominalAmount: nominalAmount,
3955                    actualReceived: actualReceived,
3956                    slippage: nominalAmount - actualReceived
3957                )
3958            }
3959
3960            return actualReceived
3961        }
3962
3963        /// Applies excess funds (appreciation) according to the distribution strategy.
3964        ///
3965        /// All portions stay in the yield source and are tracked via pending variables.
3966        /// Actual transfers happen at draw time (prize yield → prize pool, protocol → recipient/vault).
3967        ///
3968        /// @param amount - Total excess amount to distribute
3969        access(self) fun applyExcess(amount: UFix64) {
3970            if amount == 0.0 {
3971                return
3972            }
3973            
3974            // Note: Threshold check is done in syncWithYieldSource() before calling this function
3975            
3976            // Apply distribution strategy
3977            let plan = self.config.distributionStrategy.calculateDistribution(totalAmount: amount)
3978            
3979            var rewardsDust: UFix64 = 0.0
3980            
3981            // Process rewards portion - increases share price for all users
3982            if plan.rewardsAmount > 0.0 {
3983                // Accrue returns actual amount after virtual share dust
3984                let actualRewards = self.shareTracker.accrueYield(amount: plan.rewardsAmount)
3985                rewardsDust = plan.rewardsAmount - actualRewards
3986                self.userPoolBalance = self.userPoolBalance + actualRewards
3987                emit RewardsYieldAccrued(poolID: self.poolID, amount: actualRewards)
3988                
3989                if rewardsDust > 0.0 {
3990                    emit RewardsRoundingDustToProtocolFee(poolID: self.poolID, amount: rewardsDust)
3991                }
3992            }
3993            
3994            // Process prize portion - stays in yield source until draw
3995            if plan.prizeAmount > 0.0 {
3996                self.allocatedPrizeYield = self.allocatedPrizeYield + plan.prizeAmount
3997                emit PrizePoolFunded(
3998                    poolID: self.poolID,
3999                    amount: plan.prizeAmount,
4000                    source: "yield_pending"
4001                )
4002            }
4003            
4004            // Process protocol portion + rewards dust - stays in yield source until draw
4005            let totalProtocolAmount = plan.protocolFeeAmount + rewardsDust
4006            if totalProtocolAmount > 0.0 {
4007                self.allocatedProtocolFee = self.allocatedProtocolFee + totalProtocolAmount
4008                emit ProtocolFeeFunded(
4009                    poolID: self.poolID,
4010                    amount: totalProtocolAmount,
4011                    source: "yield_pending"
4012                )
4013            }
4014            
4015            emit RewardsProcessed(
4016                poolID: self.poolID,
4017                totalAmount: amount,
4018                rewardsAmount: plan.rewardsAmount - rewardsDust,
4019                prizeAmount: plan.prizeAmount
4020            )
4021        }
4022        
4023        /// Pure calculation of how much of a deficit would cascade through to the
4024        /// rewards pool (reducing share price). Mirrors the deficit waterfall:
4025        /// protocol fee absorbed first, then prize pool, then rewards.
4026        /// Used by getProjectedUserBalance for read-only deficit preview.
4027        ///
4028        /// @param deficitAmount - Total deficit to preview
4029        /// @return Amount that would hit rewards (share price)
4030        access(self) view fun previewDeficitImpactOnRewards(deficitAmount: UFix64): UFix64 {
4031            var remaining = deficitAmount
4032            let absorbedByProtocol = remaining < self.allocatedProtocolFee
4033                ? remaining : self.allocatedProtocolFee
4034            remaining = remaining - absorbedByProtocol
4035            let absorbedByPrize = remaining < self.allocatedPrizeYield
4036                ? remaining : self.allocatedPrizeYield
4037            remaining = remaining - absorbedByPrize
4038            return remaining
4039        }
4040
4041        /// Applies a deficit (depreciation) from the yield source across the pool.
4042        ///
4043        /// Uses a deterministic waterfall that protects user funds (rewards) by
4044        /// exhausting protocol fee (protocol, prize) first. This is INDEPENDENT
4045        /// of the distribution strategy to ensure consistent loss handling even after
4046        /// strategy changes.
4047        ///
4048        /// WATERFALL ORDER (protect user principal):
4049        /// 1. Protocol absorbs first (drain completely if needed)
4050        /// 2. Prize absorbs second (drain completely if needed)
4051        /// 3. Rewards absorbs last (share price decrease affects all users)
4052        ///
4053        /// If all three are exhausted but deficit remains, an InsolvencyDetected
4054        /// event is emitted to alert administrators.
4055        ///
4056        /// @param amount - Total deficit to absorb
4057        access(self) fun applyDeficit(amount: UFix64) {
4058            if amount == 0.0 {
4059                return
4060            }
4061
4062            var remainingDeficit = amount
4063
4064            // === STEP 1: Protocol absorbs first (protocol fund) ===
4065            var absorbedByProtocolFee: UFix64 = 0.0
4066            if remainingDeficit > 0.0 && self.allocatedProtocolFee > 0.0 {
4067                absorbedByProtocolFee = remainingDeficit > self.allocatedProtocolFee
4068                    ? self.allocatedProtocolFee
4069                    : remainingDeficit
4070                self.allocatedProtocolFee = self.allocatedProtocolFee - absorbedByProtocolFee
4071                remainingDeficit = remainingDeficit - absorbedByProtocolFee
4072            }
4073
4074            // === STEP 2: Prize absorbs second (protocol fund) ===
4075            var absorbedByPrize: UFix64 = 0.0
4076            if remainingDeficit > 0.0 && self.allocatedPrizeYield > 0.0 {
4077                absorbedByPrize = remainingDeficit > self.allocatedPrizeYield
4078                    ? self.allocatedPrizeYield
4079                    : remainingDeficit
4080                self.allocatedPrizeYield = self.allocatedPrizeYield - absorbedByPrize
4081                remainingDeficit = remainingDeficit - absorbedByPrize
4082            }
4083
4084            // === STEP 3: Rewards absorbs last (user funds) ===
4085            var absorbedByRewards: UFix64 = 0.0
4086            if remainingDeficit > 0.0 {
4087                absorbedByRewards = self.shareTracker.decreaseTotalAssets(amount: remainingDeficit)
4088                self.userPoolBalance = self.userPoolBalance - absorbedByRewards
4089                remainingDeficit = remainingDeficit - absorbedByRewards
4090            }
4091
4092            // === Check for insolvency ===
4093            if remainingDeficit > 0.0 {
4094                emit InsolvencyDetected(
4095                    poolID: self.poolID,
4096                    unreconciledAmount: remainingDeficit
4097                )
4098            }
4099
4100            emit DeficitApplied(
4101                poolID: self.poolID,
4102                totalDeficit: amount,
4103                absorbedByProtocolFee: absorbedByProtocolFee,
4104                absorbedByPrize: absorbedByPrize,
4105                absorbedByRewards: absorbedByRewards
4106            )
4107        }
4108        
4109        // ============================================================
4110        // LOTTERY DRAW OPERATIONS
4111        // ============================================================
4112        
4113        /// Starts a prize draw (Phase 1 of 3 - Batched Draw Process).
4114        /// 
4115        /// FLOW:
4116        /// 1. Validate state (Normal, no active draw, round has ended)
4117        /// 2. Set actualEndTime on activeRound (marks it for finalization)
4118        /// 3. Initialize batch capture state with receiver snapshot
4119        /// 4. Materialize yield and request randomness
4120        /// 5. Emit DrawBatchStarted and randomness events
4121        ///
4122        /// NEXT STEPS:
4123        /// - Call processDrawBatch() repeatedly to capture TWAB weights
4124        /// 
4125        access(contract) fun startDraw() {
4126            pre {
4127                self.emergencyState == PoolEmergencyState.Normal: "Draws disabled - pool state: \(self.emergencyState.rawValue)"
4128                self.pendingDrawReceipt == nil: "Draw already in progress"
4129                self.activeRound != nil: "Pool is in intermission - call startNextRound first"
4130            }
4131
4132            // Validate round has ended (this replaces the old draw interval check)
4133            assert(self.canDrawNow(), message: "Round has not ended yet")
4134
4135            // Final health check before draw
4136            if self.checkAndAutoTriggerEmergency() {
4137                panic("Emergency mode auto-triggered - cannot start draw")
4138            }
4139
4140            // Sync with yield source to capture any pending yield before materializing prizes.
4141            // This ensures yield added directly to the yield source (not via deposits) is
4142            // properly accounted for in allocatedPrizeYield before the draw.
4143            if self.needsSync() {
4144                self.syncWithYieldSource()
4145            }
4146
4147            let now = getCurrentBlock().timestamp
4148
4149            // Get the current round's info
4150            // Reference is safe - we validated activeRound != nil in precondition
4151            let activeRoundRef = (&self.activeRound as &Round?)!
4152            let endedRoundID = activeRoundRef.getRoundID()
4153
4154            // Set the actual end time on the round being finalized
4155            // This is the moment we're finalizing - used for TWAB calculations
4156            activeRoundRef.setActualEndTime(now)
4157
4158            // Create selection data resource for batch processing
4159            // Snapshot the current receiver count - only these users will be processed
4160            // New deposits during batch processing won't extend the batch (prevents DoS)
4161            self.pendingSelectionData <-! create BatchSelectionData(
4162                snapshotCount: self.registeredReceiverList.length
4163            )
4164
4165            // Update last draw timestamp (draw initiated, even though batch processing pending)
4166            self.lastDrawTimestamp = now
4167            // Materialize pending protocol fee from yield source
4168            if self.allocatedProtocolFee > 0.0 {
4169                let protocolVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: self.allocatedProtocolFee)
4170                let actualWithdrawn = protocolVault.balance
4171                self.allocatedProtocolFee = self.allocatedProtocolFee - actualWithdrawn
4172                
4173                // Forward to recipient if configured, otherwise store in unclaimed vault
4174                if let cap = self.protocolFeeRecipientCap {
4175                    if let recipientRef = cap.borrow() {
4176                        let forwardedAmount = protocolVault.balance
4177                        recipientRef.deposit(from: <- protocolVault)
4178                        self.totalProtocolFeeForwarded = self.totalProtocolFeeForwarded + forwardedAmount
4179                        emit ProtocolFeeForwarded(
4180                            poolID: self.poolID,
4181                            amount: forwardedAmount,
4182                            recipient: cap.address
4183                        )
4184                    } else {
4185                        // Recipient capability invalid - store in unclaimed vault
4186                        self.unclaimedProtocolFeeVault.deposit(from: <- protocolVault)
4187                    }
4188                } else {
4189                    // No recipient configured - store in unclaimed vault for admin withdrawal
4190                    self.unclaimedProtocolFeeVault.deposit(from: <- protocolVault)
4191                }
4192            }
4193            
4194            // Prize amount is the allocated yield
4195            let prizeAmount = self.allocatedPrizeYield
4196            assert(prizeAmount > 0.0, message: "No prize pool funds. allocatedPrizeYield: \(self.allocatedPrizeYield)")
4197            
4198            // Request randomness now - will be fulfilled in completeDraw() after batch processing
4199            let randomRequest <- self.randomConsumer.requestRandomness()
4200            let receipt <- create PrizeDrawReceipt(
4201                prizeAmount: prizeAmount,
4202                request: <- randomRequest
4203            )
4204            
4205            let commitBlock = receipt.getRequestBlock() ?? 0
4206            self.pendingDrawReceipt <-! receipt
4207
4208            // Emit draw started event (weights will be captured via batch processing)
4209            emit DrawBatchStarted(
4210                poolID: self.poolID,
4211                endedRoundID: endedRoundID,
4212                newRoundID: 0,  // No new round created yet - pool enters intermission
4213                totalReceivers: self.registeredReceiverList.length
4214            )
4215            
4216            // Emit randomness committed event
4217            emit DrawRandomnessRequested(
4218                poolID: self.poolID,
4219                totalWeight: 0.0,  // Not known yet - weights captured during batch processing
4220                prizeAmount: prizeAmount,
4221                commitBlock: commitBlock
4222            )
4223        }
4224        
4225        /// Processes a batch of receivers for weight capture (Phase 2 of 3).
4226        /// 
4227        /// Call this repeatedly until isDrawBatchComplete() returns true.
4228        /// Iterates directly over registeredReceiverList using selection data cursor.
4229        /// 
4230        /// FLOW:
4231        /// 1. Get current shares for each receiver in batch
4232        /// 2. Calculate TWAB from activeRound (which has actualEndTime set)
4233        /// 3. Add bonus weights (scaled by round duration)
4234        /// 4. Build cumulative weight sums in pendingSelectionData (for binary search)
4235        /// 
4236        /// @param limit - Maximum receivers to process this batch
4237        /// @return Number of receivers remaining to process
4238        access(contract) fun processDrawBatch(limit: Int): Int {
4239            pre {
4240                limit >= 0: "Batch limit cannot be negative"
4241                self.activeRound != nil: "No draw in progress"
4242                self.pendingDrawReceipt != nil: "No draw receipt - call startDraw first"
4243                self.pendingSelectionData != nil: "No selection data"
4244                !self.isBatchComplete(): "Batch processing already complete"
4245            }
4246
4247            // Get reference to selection data
4248            let selectionDataRef = (&self.pendingSelectionData as &BatchSelectionData?)!
4249            let selectionData = selectionDataRef
4250
4251            let startCursor = selectionData.getCursor()
4252
4253            // Get the active round reference
4254            let activeRoundRef = (&self.activeRound as &Round?)!
4255            
4256            // Get the actual end time set by startDraw() - this is when we finalized the round
4257            // This must exist since startDraw() sets it - if nil, the state is corrupted
4258            let roundEndTime = activeRoundRef.getActualEndTime() 
4259                ?? panic("Corrupted state: actualEndTime not set. startDraw() must be called first.")
4260            
4261            // Use snapshot count - only process users who existed at startDraw time
4262            let snapshotCount = selectionData.getSnapshotReceiverCount()
4263            let endIndex = startCursor + limit > snapshotCount 
4264                ? snapshotCount 
4265                : startCursor + limit
4266            
4267            // Process batch directly from registeredReceiverList
4268            var i = startCursor
4269            while i < endIndex {
4270                let receiverID = self.registeredReceiverList[i]
4271                
4272                // Get current shares
4273                let shares = self.shareTracker.getUserShares(receiverID: receiverID)
4274                
4275                // Finalize TWAB using actual round end time
4276                // Returns NORMALIZED weight (≈ average shares)
4277                let twabStake = activeRoundRef.finalizeTWAB(
4278                    receiverID: receiverID, 
4279                    currentShares: shares,
4280                    roundEndTime: roundEndTime
4281                )
4282                
4283                let bonusWeight = self.getBonusWeight(receiverID: receiverID)
4284                
4285                let totalWeight = twabStake + bonusWeight
4286                
4287                // Add entry directly to resource - builds cumulative sum on the fly
4288                // Only adds if weight > 0
4289                selectionData.addEntry(receiverID: receiverID, weight: totalWeight)
4290                
4291                i = i + 1
4292            }
4293            
4294            // Update cursor directly in resource
4295            let processed = endIndex - startCursor
4296            selectionData.setCursor(endIndex)
4297            
4298            // Calculate remaining based on snapshot count (not current list length)
4299            let remaining = snapshotCount - endIndex
4300            
4301            // Runtime guardrail: emit warning if totalWeight exceeds threshold
4302            // With normalized TWAB this should never happen, but provides extra safety
4303            let currentTotalWeight = selectionData.getTotalWeight()
4304            if currentTotalWeight > PrizeLinkedAccounts.WEIGHT_WARNING_THRESHOLD {
4305                let percentOfMax = currentTotalWeight / PrizeLinkedAccounts.UFIX64_MAX * 100.0
4306                emit WeightWarningThresholdExceeded(
4307                    poolID: self.poolID,
4308                    totalWeight: currentTotalWeight,
4309                    warningThreshold: PrizeLinkedAccounts.WEIGHT_WARNING_THRESHOLD,
4310                    percentOfMax: percentOfMax
4311                )
4312            }
4313            
4314            emit DrawBatchProcessed(
4315                poolID: self.poolID,
4316                processed: processed,
4317                remaining: remaining
4318            )
4319            
4320            return remaining
4321        }
4322        
4323        /// Completes a prize draw (Phase 3 of 3).
4324        /// 
4325        /// PREREQUISITES:
4326        /// - startDraw() must have been called (randomness requested, yield materialized)
4327        /// - processDrawBatch() must have been called until batch is complete
4328        /// - At least 1 block must have passed since startDraw() for randomness fulfillment
4329        /// 
4330        /// FLOW:
4331        /// 1. Consume PrizeDrawReceipt (created by startDraw)
4332        /// 2. Fulfill randomness request (secure on-chain random from previous block)
4333        /// 3. Apply winner selection strategy with captured weights
4334        /// 4. For each winner:
4335        ///    a. Withdraw prize from prize pool
4336        ///    b. Auto-compound prize into winner's deposit (mints shares + updates TWAB)
4337        ///    c. Re-deposit prize to yield source (continues earning)
4338        ///    d. Award any NFT prizes (stored for claiming)
4339        /// 5. Record winners in tracker (if configured)
4340        /// 6. Emit PrizesAwarded event
4341        /// 7. Destroy activeRound (cleanup, pool enters intermission)
4342        /// 
4343        /// TWAB: Prize deposits accumulate TWAB in the active round,
4344        /// giving winners credit for their new shares going forward.
4345        /// 
4346        /// IMPORTANT: Prizes are AUTO-COMPOUNDED into deposits, not transferred.
4347        /// Winners can withdraw their increased balance at any time.
4348        access(contract) fun completeDraw() {
4349            pre {
4350                self.pendingDrawReceipt != nil: "No draw in progress - call startDraw first"
4351                self.pendingSelectionData != nil: "No selection data"
4352                self.isBatchComplete(): "Batch processing not complete - call processDrawBatch until complete"
4353            }
4354            
4355            // Extract and consume the pending receipt
4356            let receipt <- self.pendingDrawReceipt <- nil
4357            let unwrappedReceipt <- receipt!
4358            let totalPrizeAmount = unwrappedReceipt.prizeAmount
4359            
4360            // Fulfill randomness request (must be different block from request)
4361            let request <- unwrappedReceipt.popRequest()
4362            let randomNumber = self.randomConsumer.fulfillRandomRequest(<- request)
4363            destroy unwrappedReceipt
4364            
4365            // Get reference to selection data for zero-copy winner selection
4366            let selectionDataRef = (&self.pendingSelectionData as &BatchSelectionData?)!
4367            
4368            // Step 1: Select winners using BatchSelectionData (handles weighted random)
4369            let winnerCount = self.config.prizeDistribution.getWinnerCount()
4370            let winners = selectionDataRef.selectWinners(
4371                count: winnerCount,
4372                randomNumber: randomNumber
4373            )
4374            
4375            // Step 2: Distribute prizes using PrizeDistribution (handles amounts/NFTs)
4376            let selectionResult = self.config.prizeDistribution.distributePrizes(
4377                winners: winners,
4378                totalPrizeAmount: totalPrizeAmount
4379            )
4380            
4381            // Consume and destroy selection data (done with it)
4382            let usedSelectionData <- self.pendingSelectionData <- nil
4383            destroy usedSelectionData
4384            
4385            // Extract distribution results (winners are already selected above)
4386            let distributedWinners = selectionResult.winners
4387            let prizeAmounts = selectionResult.amounts
4388            let nftIDsPerWinner = selectionResult.nftIDs
4389            
4390            // Handle case of no winners (e.g., no eligible participants)
4391            if distributedWinners.length == 0 {
4392                emit PrizesAwarded(
4393                    poolID: self.poolID,
4394                    winners: [],
4395                    winnerAddresses: [],
4396                    amounts: [],
4397                    round: self.prizeDistributor.getPrizeRound()
4398                )
4399                // Still need to clean up the active round
4400                // Store the completed round ID before destroying for intermission state queries
4401                let usedRound <- self.activeRound <- nil
4402                let completedRoundID = usedRound?.getRoundID() ?? 0
4403                self.lastCompletedRoundID = completedRoundID
4404                destroy usedRound
4405                
4406                // Pool is now in intermission - emit event
4407                emit IntermissionStarted(
4408                    poolID: self.poolID,
4409                    completedRoundID: completedRoundID,
4410                    prizePoolBalance: self.allocatedPrizeYield
4411                )
4412                return
4413            }
4414            
4415            // Validate parallel arrays are consistent
4416            assert(distributedWinners.length == prizeAmounts.length, message: "Winners and prize amounts must match")
4417            assert(distributedWinners.length == nftIDsPerWinner.length, message: "Winners and NFT IDs must match")
4418            
4419            // Increment draw round
4420            let currentRound = self.prizeDistributor.getPrizeRound() + 1
4421            self.prizeDistributor.setPrizeRound(round: currentRound)
4422            var totalAwarded: UFix64 = 0.0
4423            
4424            // Process each winner
4425            for i in InclusiveRange(0, distributedWinners.length - 1) {
4426                let winnerID = distributedWinners[i]
4427                let prizeAmount = prizeAmounts[i]
4428                let nftIDsForWinner = nftIDsPerWinner[i]
4429
4430                // Prize funds are already in yield source (allocatedPrizeYield)
4431                // Just reallocate accounting - NO token movement, NO slippage
4432                assert(
4433                    prizeAmount <= self.allocatedPrizeYield,
4434                    message: "Insufficient prize yield. Requested: \(prizeAmount), available: \(self.allocatedPrizeYield)"
4435                )
4436
4437                // Move allocation from prize → user
4438                self.allocatedPrizeYield = self.allocatedPrizeYield - prizeAmount
4439
4440                // Get current shares BEFORE the prize deposit for TWAB calculation
4441                let oldShares = self.shareTracker.getUserShares(receiverID: winnerID)
4442
4443                // AUTO-COMPOUND: Mint shares for winner (funds already in yield source)
4444                let newSharesMinted = self.shareTracker.deposit(receiverID: winnerID, amount: prizeAmount)
4445                let newShares = oldShares + newSharesMinted
4446
4447                // Update TWAB in active round if one exists (prize deposits accumulate TWAB like regular deposits)
4448                // Note: Pool is in intermission during completeDraw (activeRound == nil)
4449                // Prize amounts are added to shares but no TWAB is recorded until next round starts
4450                if let round = &self.activeRound as &Round? {
4451                    let now = getCurrentBlock().timestamp
4452                    round.recordShareChange(
4453                        receiverID: winnerID,
4454                        oldShares: oldShares,
4455                        newShares: newShares,
4456                        atTime: now
4457                    )
4458                }
4459                
4460                // Update pool total
4461                self.userPoolBalance = self.userPoolBalance + prizeAmount
4462
4463                // Track lifetime prize winnings
4464                let totalPrizes = self.receiverTotalEarnedPrizes[winnerID] ?? 0.0
4465                self.receiverTotalEarnedPrizes[winnerID] = totalPrizes + prizeAmount
4466                
4467                // Process NFT prizes for this winner
4468                for nftID in nftIDsForWinner {
4469                    // Verify NFT is still available - O(1) dictionary lookup
4470                    // (might have been withdrawn by admin during draw)
4471                    if self.prizeDistributor.borrowNFTPrize(nftID: nftID) == nil {
4472                        continue
4473                    }
4474                    
4475                    // Move NFT to pending claims for winner to pick up
4476                    let nft <- self.prizeDistributor.withdrawNFTPrize(nftID: nftID)
4477                    let nftType = nft.getType().identifier
4478                    self.prizeDistributor.storePendingNFT(receiverID: winnerID, nft: <- nft)
4479                    
4480                    emit NFTPrizeStored(
4481                        poolID: self.poolID,
4482                        receiverID: winnerID,
4483                        nftID: nftID,
4484                        nftType: nftType,
4485                        reason: "Prize win - round \(currentRound)",
4486                        ownerAddress: self.getReceiverOwnerAddress(receiverID: winnerID)
4487                    )
4488                    
4489                    emit NFTPrizeAwarded(
4490                        poolID: self.poolID,
4491                        receiverID: winnerID,
4492                        nftID: nftID,
4493                        nftType: nftType,
4494                        round: currentRound,
4495                        ownerAddress: self.getReceiverOwnerAddress(receiverID: winnerID)
4496                    )
4497                }
4498                
4499                totalAwarded = totalAwarded + prizeAmount
4500            }
4501            
4502            // Build winner addresses array from capabilities
4503            let winnerAddresses: [Address?] = []
4504            for winnerID in distributedWinners {
4505                winnerAddresses.append(self.getReceiverOwnerAddress(receiverID: winnerID))
4506            }
4507            
4508            emit PrizesAwarded(
4509                poolID: self.poolID,
4510                winners: distributedWinners,
4511                winnerAddresses: winnerAddresses,
4512                amounts: prizeAmounts,
4513                round: currentRound
4514            )
4515            
4516            // Destroy the active round - its TWAB data has been used
4517            // Store the completed round ID before destroying for intermission state queries
4518            let usedRound <- self.activeRound <- nil
4519            let completedRoundID = usedRound?.getRoundID() ?? 0
4520            self.lastCompletedRoundID = completedRoundID
4521            destroy usedRound
4522            
4523            // Pool is now in intermission - emit event
4524            emit IntermissionStarted(
4525                poolID: self.poolID,
4526                completedRoundID: completedRoundID,
4527                prizePoolBalance: self.allocatedPrizeYield
4528            )
4529        }
4530
4531        /// Starts a new round, exiting intermission (Phase 5 - optional, for explicit round control).
4532        /// 
4533        /// Creates a new active round with the configured draw interval duration.
4534        /// This must be called after completeDraw() to begin the next round.
4535        /// 
4536        /// PRECONDITIONS:
4537        /// - Pool must be in intermission (activeRound == nil)
4538        /// - No pending draw receipt (randomness request completed)
4539        ///
4540        /// EMITS: IntermissionEnded
4541        access(contract) fun startNextRound() {
4542            pre {
4543                self.activeRound == nil: "Pool is not in intermission"
4544                self.pendingDrawReceipt == nil: "Pending randomness request not completed"
4545            }
4546
4547            // Reset per-draw direct funding tracker for the new round
4548            self.directPrizeFundingThisDraw = 0.0
4549
4550            let now = getCurrentBlock().timestamp
4551            let newRoundID = self.lastCompletedRoundID + 1
4552            let duration = self.config.drawIntervalSeconds
4553
4554            self.activeRound <-! create Round(
4555                roundID: newRoundID,
4556                startTime: now,
4557                targetEndTime: now + duration
4558            )
4559
4560            emit IntermissionEnded(
4561                poolID: self.poolID,
4562                newRoundID: newRoundID,
4563                roundDuration: duration
4564            )
4565        }
4566        
4567        // ============================================================
4568        // STRATEGY AND CONFIGURATION SETTERS
4569        // ============================================================
4570        
4571        /// Returns the name of the current distribution strategy.
4572        access(all) view fun getDistributionStrategyName(): String {
4573            return self.config.distributionStrategy.getStrategyName()
4574        }
4575        
4576        /// Updates the distribution strategy. Called by Admin.
4577        /// @param strategy - New distribution strategy
4578        access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
4579            self.config.setDistributionStrategy(strategy: strategy)
4580        }
4581        
4582        /// Returns the name of the current prize distribution.
4583        access(all) view fun getPrizeDistributionName(): String {
4584            return self.config.prizeDistribution.getDistributionName()
4585        }
4586        
4587        /// Updates the prize distribution. Called by Admin.
4588        /// @param distribution - New prize distribution
4589        access(contract) fun setPrizeDistribution(distribution: {PrizeDistribution}) {
4590            self.config.setPrizeDistribution(distribution: distribution)
4591        }
4592        
4593        /// Updates the draw interval for FUTURE rounds only.
4594        /// Does not affect the current active round at all.
4595        /// 
4596        /// @param interval - New interval in seconds
4597        access(contract) fun setDrawIntervalSecondsForFutureOnly(interval: UFix64) {
4598            // Only update pool config - current round is not affected
4599            self.config.setDrawIntervalSeconds(interval: interval)
4600        }
4601        
4602        /// Returns the current active round's duration.
4603        /// Returns 0.0 if in intermission (no active round).
4604        access(all) view fun getActiveRoundDuration(): UFix64 {
4605            return self.activeRound?.getDuration() ?? 0.0
4606        }
4607
4608        /// Returns the current active round's ID.
4609        /// Returns 0 if in intermission (no active round).
4610        access(all) view fun getActiveRoundID(): UInt64 {
4611            return self.activeRound?.getRoundID() ?? 0
4612        }
4613
4614        /// Returns the current active round's target end time.
4615        /// Returns 0.0 if in intermission (no active round).
4616        access(all) view fun getCurrentRoundTargetEndTime(): UFix64 {
4617            return self.activeRound?.getTargetEndTime() ?? 0.0
4618        }
4619
4620        /// Updates the current round's target end time.
4621        /// Can only be called before startDraw() is called on this round.
4622        /// @param newTarget - New target end time (must be after round start time)
4623        access(contract) fun setCurrentRoundTargetEndTime(newTarget: UFix64) {
4624            pre {
4625                self.activeRound != nil: "No active round - pool is in intermission"
4626            }
4627            let roundRef = (&self.activeRound as &Round?)!
4628            roundRef.setTargetEndTime(newTarget: newTarget)
4629        }
4630
4631        /// Updates the minimum deposit amount.
4632        /// @param minimum - New minimum deposit
4633        access(contract) fun setMinimumDeposit(minimum: UFix64) {
4634            self.config.setMinimumDeposit(minimum: minimum)
4635        }
4636        
4637        // ============================================================
4638        // BONUS WEIGHT MANAGEMENT
4639        // ============================================================
4640        
4641        /// Sets or replaces a user's bonus prize weight.
4642        /// @param receiverID - User's receiver ID
4643        /// @param bonusWeight - Weight to assign (replaces existing)
4644        /// @param reason - Reason for bonus
4645        /// @param adminUUID - Admin performing the action
4646        access(contract) fun setBonusWeight(receiverID: UInt64, bonusWeight: UFix64, reason: String, adminUUID: UInt64) {
4647            pre {
4648                bonusWeight <= PrizeLinkedAccounts.MAX_BONUS_WEIGHT_PER_USER:
4649                    "Bonus weight exceeds maximum. Max: \(PrizeLinkedAccounts.MAX_BONUS_WEIGHT_PER_USER), got: \(bonusWeight)"
4650            }
4651            let timestamp = getCurrentBlock().timestamp
4652            self.receiverBonusWeights[receiverID] = bonusWeight
4653            
4654            emit BonusPrizeWeightSet(
4655                poolID: self.poolID,
4656                receiverID: receiverID,
4657                bonusWeight: bonusWeight,
4658                reason: reason,
4659                adminUUID: adminUUID,
4660                timestamp: timestamp,
4661                ownerAddress: self.getReceiverOwnerAddress(receiverID: receiverID)
4662            )
4663        }
4664        
4665        /// Adds weight to a user's existing bonus.
4666        /// @param receiverID - User's receiver ID
4667        /// @param additionalWeight - Weight to add
4668        /// @param reason - Reason for addition (emitted in event)
4669        /// @param adminUUID - Admin performing the action
4670        access(contract) fun addBonusWeight(receiverID: UInt64, additionalWeight: UFix64, reason: String, adminUUID: UInt64) {
4671            let timestamp = getCurrentBlock().timestamp
4672            let currentBonus = self.receiverBonusWeights[receiverID] ?? 0.0
4673            let newTotalBonus = currentBonus + additionalWeight
4674
4675            // Validate total bonus won't exceed cap
4676            assert(
4677                newTotalBonus <= PrizeLinkedAccounts.MAX_BONUS_WEIGHT_PER_USER,
4678                message: "Total bonus would exceed maximum. Max: ".concat(PrizeLinkedAccounts.MAX_BONUS_WEIGHT_PER_USER.toString()).concat(", would be: ").concat(newTotalBonus.toString())
4679            )
4680
4681            self.receiverBonusWeights[receiverID] = newTotalBonus
4682            
4683            emit BonusPrizeWeightAdded(
4684                poolID: self.poolID,
4685                receiverID: receiverID,
4686                additionalWeight: additionalWeight,
4687                newTotalBonus: newTotalBonus,
4688                reason: reason,
4689                adminUUID: adminUUID,
4690                timestamp: timestamp,
4691                ownerAddress: self.getReceiverOwnerAddress(receiverID: receiverID)
4692            )
4693        }
4694        
4695        /// Removes all bonus weight from a user.
4696        /// @param receiverID - User's receiver ID
4697        /// @param adminUUID - Admin performing the action
4698        access(contract) fun removeBonusWeight(receiverID: UInt64, adminUUID: UInt64) {
4699            let timestamp = getCurrentBlock().timestamp
4700            let previousBonus = self.receiverBonusWeights[receiverID] ?? 0.0
4701            
4702            let _ = self.receiverBonusWeights.remove(key: receiverID)
4703            
4704            emit BonusPrizeWeightRemoved(
4705                poolID: self.poolID,
4706                receiverID: receiverID,
4707                previousBonus: previousBonus,
4708                adminUUID: adminUUID,
4709                timestamp: timestamp,
4710                ownerAddress: self.getReceiverOwnerAddress(receiverID: receiverID)
4711            )
4712        }
4713        
4714        /// Returns a user's current bonus weight (equivalent token deposit for full round).
4715        /// @param receiverID - User's receiver ID
4716        access(all) view fun getBonusWeight(receiverID: UInt64): UFix64 {
4717            return self.receiverBonusWeights[receiverID] ?? 0.0
4718        }
4719        
4720        /// Returns list of all receiver IDs with bonus weights.
4721        access(all) view fun getAllBonusWeightReceivers(): [UInt64] {
4722            return self.receiverBonusWeights.keys
4723        }
4724        
4725        // ============================================================
4726        // NFT PRIZE MANAGEMENT
4727        // ============================================================
4728        
4729        /// Deposits an NFT as a prize. Called by Admin.
4730        /// @param nft - NFT to deposit
4731        access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
4732            self.prizeDistributor.depositNFTPrize(nft: <- nft)
4733        }
4734        
4735        /// Withdraws an available NFT prize. Called by Admin.
4736        /// @param nftID - UUID of NFT to withdraw
4737        /// @return The withdrawn NFT
4738        access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
4739            return <- self.prizeDistributor.withdrawNFTPrize(nftID: nftID)
4740        }
4741        
4742        /// Returns UUIDs of all available NFT prizes.
4743        access(all) view fun getAvailableNFTPrizeIDs(): [UInt64] {
4744            return self.prizeDistributor.getAvailableNFTPrizeIDs()
4745        }
4746        
4747        /// Borrows a reference to an available NFT prize.
4748        /// @param nftID - UUID of NFT
4749        /// @return Reference to NFT, or nil if not found
4750        access(all) view fun borrowAvailableNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
4751            return self.prizeDistributor.borrowNFTPrize(nftID: nftID)
4752        }
4753        
4754        /// Returns count of pending NFT claims for a user.
4755        /// @param receiverID - User's receiver ID
4756        access(all) view fun getPendingNFTCount(receiverID: UInt64): Int {
4757            return self.prizeDistributor.getPendingNFTCount(receiverID: receiverID)
4758        }
4759        
4760        /// Returns UUIDs of pending NFT claims for a user.
4761        /// @param receiverID - User's receiver ID
4762        access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
4763            return self.prizeDistributor.getPendingNFTIDs(receiverID: receiverID)
4764        }
4765        
4766        /// Claims a pending NFT prize for a user.
4767        /// Called by PoolPositionCollection.
4768        /// @param receiverID - User's receiver ID
4769        /// @param nftIndex - Index in pending claims array
4770        /// @return The claimed NFT
4771        access(contract) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
4772            let nft <- self.prizeDistributor.claimPendingNFT(receiverID: receiverID, nftIndex: nftIndex)
4773            let nftType = nft.getType().identifier
4774            
4775            emit NFTPrizeClaimed(
4776                poolID: self.poolID,
4777                receiverID: receiverID,
4778                nftID: nft.uuid,
4779                nftType: nftType,
4780                ownerAddress: self.getReceiverOwnerAddress(receiverID: receiverID)
4781            )
4782            
4783            return <- nft
4784        }
4785        
4786        // ============================================================
4787        // DRAW TIMING
4788        // ============================================================
4789
4790        /// Returns whether the current round has ended and a draw can start.
4791        /// Returns false if in intermission (no active round) - must call startNextRound first.
4792        access(all) view fun canDrawNow(): Bool {
4793            return self.activeRound?.hasEnded() ?? false
4794        }
4795        
4796        /// Returns total withdrawable balance for a receiver.
4797        /// This is the receiver's shares × current share price.
4798        access(all) view fun getReceiverTotalBalance(receiverID: UInt64): UFix64 {
4799            return self.shareTracker.getUserAssetValue(receiverID: receiverID)
4800        }
4801        
4802        /// Returns lifetime total prizes earned by this receiver.
4803        /// This is a cumulative counter that increases when prizes are won.
4804        access(all) view fun getReceiverTotalEarnedPrizes(receiverID: UInt64): UFix64 {
4805            return self.receiverTotalEarnedPrizes[receiverID] ?? 0.0
4806        }
4807        
4808        access(all) view fun getUserRewardsShares(receiverID: UInt64): UFix64 {
4809            return self.shareTracker.getUserShares(receiverID: receiverID)
4810        }
4811        
4812        access(all) view fun getTotalRewardsShares(): UFix64 {
4813            return self.shareTracker.getTotalShares()
4814        }
4815        
4816        access(all) view fun getTotalRewardsAssets(): UFix64 {
4817            return self.shareTracker.getTotalAssets()
4818        }
4819        
4820        access(all) view fun getRewardsSharePrice(): UFix64 {
4821            return self.shareTracker.getSharePrice()
4822        }
4823        
4824        /// Returns the user's current TWAB for the active round.
4825        /// @param receiverID - User's receiver ID
4826        /// @return Current TWAB (accumulated + pending up to now)
4827        access(all) view fun getUserTimeWeightedShares(receiverID: UInt64): UFix64 {
4828            if let round = &self.activeRound as &Round? {
4829                let shares = self.shareTracker.getUserShares(receiverID: receiverID)
4830                let now = getCurrentBlock().timestamp
4831                return round.getCurrentTWAB(receiverID: receiverID, currentShares: shares, atTime: now)
4832            }
4833            return 0.0
4834        }
4835
4836        /// Returns a user's projected balance accounting for unsync'd yield or deficit.
4837        /// Calculates what the balance would be if syncWithYieldSource() were called now.
4838        /// This is a read-only preview — no state is mutated.
4839        ///
4840        /// @param receiverID - User's receiver ID
4841        /// @return Projected asset value of user's shares
4842        access(all) fun getProjectedUserBalance(receiverID: UInt64): UFix64 {
4843            let userShares = self.shareTracker.getUserShares(receiverID: receiverID)
4844            if userShares == 0.0 {
4845                return 0.0
4846            }
4847
4848            // Compare yield source balance to what we've already accounted for
4849            let yieldBalance = self.config.yieldConnector.minimumAvailable()
4850            let allocatedFunds = self.getTotalAllocatedFunds()
4851            let difference: UFix64 = yieldBalance > allocatedFunds
4852                ? yieldBalance - allocatedFunds
4853                : allocatedFunds - yieldBalance
4854
4855            // If difference is below dust threshold, just return current balance
4856            if difference < PrizeLinkedAccounts.MINIMUM_DISTRIBUTION_THRESHOLD {
4857                return self.shareTracker.getUserAssetValue(receiverID: receiverID)
4858            }
4859
4860            var projectedTotalAssets = self.shareTracker.getTotalAssets()
4861            let totalShares = self.shareTracker.getTotalShares()
4862
4863            if yieldBalance > allocatedFunds {
4864                // Excess yield — preview the distribution split
4865                let plan = self.config.distributionStrategy.calculateDistribution(
4866                    totalAmount: difference
4867                )
4868                // Only the rewards portion increases share price
4869                let projectedRewards = self.shareTracker.previewAccrueYield(
4870                    amount: plan.rewardsAmount
4871                )
4872                projectedTotalAssets = projectedTotalAssets + projectedRewards
4873            } else {
4874                // Deficit — preview the waterfall impact on rewards
4875                let deficitToRewards = self.previewDeficitImpactOnRewards(
4876                    deficitAmount: difference
4877                )
4878                projectedTotalAssets = projectedTotalAssets > deficitToRewards
4879                    ? projectedTotalAssets - deficitToRewards
4880                    : 0.0
4881            }
4882
4883            // Compute projected share price with virtual offset
4884            let effectiveShares = totalShares + PrizeLinkedAccounts.VIRTUAL_SHARES
4885            let effectiveAssets = projectedTotalAssets + PrizeLinkedAccounts.VIRTUAL_ASSETS
4886            let projectedSharePrice = effectiveAssets / effectiveShares
4887
4888            return userShares * projectedSharePrice
4889        }
4890
4891        /// Returns the current round ID.
4892        /// During intermission, returns the ID of the last completed round.
4893        access(all) view fun getCurrentRoundID(): UInt64 {
4894            if let round = &self.activeRound as &Round? {
4895                return round.getRoundID()
4896            }
4897            // In intermission - return last completed round ID
4898            return self.lastCompletedRoundID
4899        }
4900        
4901        /// Returns the current round start time.
4902        /// Returns 0.0 if in intermission (no active round).
4903        access(all) view fun getRoundStartTime(): UFix64 {
4904            return self.activeRound?.getStartTime() ?? 0.0
4905        }
4906
4907        /// Returns the current round end time.
4908        /// Returns 0.0 if in intermission (no active round).
4909        access(all) view fun getRoundEndTime(): UFix64 {
4910            return self.activeRound?.getEndTime() ?? 0.0
4911        }
4912
4913        /// Returns the current round duration.
4914        /// Returns 0.0 if in intermission (no active round).
4915        access(all) view fun getRoundDuration(): UFix64 {
4916            return self.activeRound?.getDuration() ?? 0.0
4917        }
4918
4919        /// Returns elapsed time since round started.
4920        /// Returns 0.0 if in intermission (no active round).
4921        access(all) view fun getRoundElapsedTime(): UFix64 {
4922            if let round = &self.activeRound as &Round? {
4923                let startTime = round.getStartTime()
4924                let now = getCurrentBlock().timestamp
4925                if now > startTime {
4926                    return now - startTime
4927                }
4928            }
4929            return 0.0
4930        }
4931
4932        /// Returns whether the active round has ended (gap period).
4933        /// Returns true if in intermission (no active round).
4934        access(all) view fun isRoundEnded(): Bool {
4935            return self.activeRound?.hasEnded() ?? true
4936        }
4937
4938        // ============================================================
4939        // POOL STATE MACHINE
4940        // ============================================================
4941        //
4942        // State 1: ROUND_ACTIVE    - Round in progress, timer hasn't expired
4943        // State 2: AWAITING_DRAW   - Round ended, waiting for admin to start draw
4944        // State 3: DRAW_PROCESSING - Draw ceremony in progress (phases 1-3)
4945        // State 4: INTERMISSION    - Draw complete, waiting for next round to start
4946        //
4947        // Transition: ROUND_ACTIVE → AWAITING_DRAW → DRAW_PROCESSING → INTERMISSION → ROUND_ACTIVE
4948        // ============================================================
4949
4950        /// STATE 1: Returns whether a round is actively in progress (timer hasn't expired).
4951        /// Use this to show countdown timers and "round in progress" UI.
4952        access(all) view fun isRoundActive(): Bool {
4953            if let round = &self.activeRound as &Round? {
4954                return !round.hasEnded()
4955            }
4956            return false
4957        }
4958
4959        /// STATE 2: Returns whether the round has ended and is waiting for draw to start.
4960        /// This is the window where an admin needs to call startDraw().
4961        /// Use this to show "Draw available" or "Waiting for draw" UI.
4962        access(all) view fun isAwaitingDraw(): Bool {
4963            if let round = &self.activeRound as &Round? {
4964                return round.hasEnded() && self.pendingDrawReceipt == nil
4965            }
4966            return false
4967        }
4968
4969        /// STATE 3: Returns whether a draw is being processed.
4970        /// This covers all draw phases: batch processing and winner selection.
4971        /// Use this to show "Draw in progress" with batch progress UI.
4972        access(all) view fun isDrawInProgress(): Bool {
4973            return self.pendingDrawReceipt != nil
4974        }
4975
4976        /// STATE 4: Returns whether the pool is in true intermission.
4977        /// Intermission = draw completed, no active round, waiting for startNextRound().
4978        access(all) view fun isInIntermission(): Bool {
4979            return self.activeRound == nil && self.pendingDrawReceipt == nil
4980        }
4981        
4982        /// Returns whether batch processing is complete (cursor has reached snapshot count).
4983        /// Uses snapshotReceiverCount from startDraw() - only processes users who existed then.        /// New deposits during batch processing don't extend the batch (prevents DoS).
4984        /// Returns true if no batch in progress (nil state = complete/not started).
4985        access(all) view fun isBatchComplete(): Bool {
4986            if let selectionDataRef = &self.pendingSelectionData as &BatchSelectionData? {
4987                return selectionDataRef.getCursor() >= selectionDataRef.getSnapshotReceiverCount()
4988            }
4989            return true  // No batch in progress = considered complete
4990        }
4991        
4992        /// Returns whether batch processing is in progress (after startDraw, before batch complete).
4993        access(all) view fun isDrawBatchInProgress(): Bool {
4994            return self.pendingDrawReceipt != nil && self.pendingSelectionData != nil && !self.isBatchComplete()
4995        }
4996        
4997        /// Returns whether batch processing is complete and ready for completeDraw.
4998        /// Batch processing must finish before completeDraw can be called.
4999        access(all) view fun isDrawBatchComplete(): Bool {
5000            return self.pendingSelectionData != nil && self.isBatchComplete() && self.pendingDrawReceipt != nil
5001        }
5002        
5003        /// Returns whether the draw is ready to complete.
5004        /// Requires: randomness requested (in startDraw) AND batch processing complete.
5005        access(all) view fun isReadyForDrawCompletion(): Bool {
5006            return self.pendingDrawReceipt != nil && self.isBatchComplete()
5007        }
5008        
5009        /// Returns batch processing progress information.
5010        /// Returns nil if no batch processing is in progress.
5011        access(all) view fun getDrawBatchProgress(): {String: AnyStruct}? {
5012            if let selectionDataRef = &self.pendingSelectionData as &BatchSelectionData? {
5013                let total = self.registeredReceiverList.length
5014                let processed = selectionDataRef.getCursor()
5015                let percentComplete: UFix64 = total > 0 
5016                    ? UFix64(processed) / UFix64(total) * 100.0 
5017                    : 100.0
5018                
5019                return {
5020                    "cursor": processed,
5021                    "total": total,
5022                    "remaining": total - processed,
5023                    "percentComplete": percentComplete,
5024                    "isComplete": self.isBatchComplete(),
5025                    "eligibleCount": selectionDataRef.getReceiverCount(),
5026                    "totalWeight": selectionDataRef.getTotalWeight()
5027                }
5028            }
5029            return nil
5030        }
5031        
5032        /// Preview how many shares would be minted for a deposit amount (ERC-4626 style)
5033        access(all) view fun previewDeposit(amount: UFix64): UFix64 {
5034            return self.shareTracker.convertToShares(amount)
5035        }
5036        
5037        /// Preview how many assets a number of shares is worth (ERC-4626 style)
5038        access(all) view fun previewRedeem(shares: UFix64): UFix64 {
5039            return self.shareTracker.convertToAssets(shares)
5040        }
5041        
5042        access(all) view fun getUserRewardsValue(receiverID: UInt64): UFix64 {
5043            return self.shareTracker.getUserAssetValue(receiverID: receiverID)
5044        }
5045        
5046        access(all) view fun isReceiverRegistered(receiverID: UInt64): Bool {
5047            return self.registeredReceivers[receiverID] != nil
5048        }
5049        
5050        access(all) view fun getRegisteredReceiverIDs(): [UInt64] {
5051            return self.registeredReceiverList
5052        }
5053        
5054        access(all) view fun getRegisteredReceiverCount(): Int {
5055            return self.registeredReceiverList.length
5056        }
5057        
5058        /// Returns whether a receiver is a sponsor (prize-ineligible).
5059        /// @param receiverID - UUID of the receiver to check
5060        /// @return true if the receiver is a sponsor, false otherwise
5061        access(all) view fun isSponsor(receiverID: UInt64): Bool {
5062            return self.sponsorReceivers[receiverID] ?? false
5063        }
5064        
5065        /// Returns the total number of sponsors in this pool.
5066        access(all) view fun getSponsorCount(): Int {
5067            return self.sponsorReceivers.keys.length
5068        }
5069        
5070        access(all) view fun getConfig(): PoolConfig {
5071            return self.config
5072        }
5073        
5074        access(all) view fun getTotalRewardsDistributed(): UFix64 {
5075            return self.shareTracker.getTotalDistributed()
5076        }
5077        
5078        access(all) fun getAvailableYieldRewards(): UFix64 {
5079            let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
5080            let available = yieldSource.minimumAvailable()
5081            // Exclude already-allocated funds (same logic as syncWithYieldSource)
5082            let allocatedFunds = self.getTotalAllocatedFunds()
5083            if available > allocatedFunds {
5084                return available - allocatedFunds
5085            }
5086            return 0.0
5087        }
5088
5089        /// Returns the current balance in the yield source (real-time query).
5090        /// This may differ from getTotalAllocatedFunds() if:
5091        /// - Yield has accrued since last sync (excess)
5092        /// - Yield source has lost value (deficit)
5093        /// Use this to see the actual assets before they're synced to the pool.
5094        access(all) fun getYieldSourceBalance(): UFix64 {
5095            let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
5096            return yieldSource.minimumAvailable()
5097        }
5098
5099        /// Returns true if internal accounting differs from yield source balance.
5100        /// Handles both excess (gains) and deficit (losses).
5101        /// This is used to determine if syncWithYieldSource() needs to be called.
5102        access(all) fun needsSync(): Bool {
5103            let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
5104            let yieldBalance = yieldSource.minimumAvailable()
5105            return yieldBalance != self.getTotalAllocatedFunds()
5106        }
5107        
5108        // ============================================================
5109        // YIELD ALLOCATION GETTERS
5110        // ============================================================
5111        
5112        /// Returns total funds allocated across all buckets (rewards + prize + protocolFee).
5113        /// This sum should always equal the yield source balance after sync.
5114        access(all) view fun getTotalAllocatedFunds(): UFix64 {
5115            return self.userPoolBalance + self.allocatedPrizeYield + self.allocatedProtocolFee
5116        }
5117        
5118        /// Returns the allocated rewards amount (user portion of yield source).
5119        access(all) view fun getUserPoolBalance(): UFix64 {
5120            return self.userPoolBalance
5121        }
5122        
5123        /// Returns the allocated prize yield (awaiting transfer to prize pool).
5124        access(all) view fun getAllocatedPrizeYield(): UFix64 {
5125            return self.allocatedPrizeYield
5126        }
5127
5128        /// Returns the amount of direct funding (e.g. marketing sponsorship) added to the
5129        /// prize pool during the current draw period. Reset when a new round starts.
5130        /// Organic prize yield = getAllocatedPrizeYield() - getDirectPrizeFundingThisDraw()
5131        access(all) view fun getDirectPrizeFundingThisDraw(): UFix64 {
5132            return self.directPrizeFundingThisDraw
5133        }
5134
5135        /// Returns the allocated protocol fee (awaiting transfer to recipient).
5136        access(all) view fun getAllocatedProtocolFee(): UFix64 {
5137            return self.allocatedProtocolFee
5138        }
5139        
5140        /// Returns total prize pool balance including pending yield.
5141        access(all) view fun getPrizePoolBalance(): UFix64 {
5142            return self.prizeDistributor.getPrizePoolBalance() + self.allocatedPrizeYield
5143        }
5144
5145        access(all) view fun getUnclaimedProtocolBalance(): UFix64 {
5146            return self.unclaimedProtocolFeeVault.balance
5147        }
5148
5149        access(all) view fun getProtocolRecipient(): Address? {
5150            return self.protocolFeeRecipientCap?.address
5151        }
5152        
5153        access(all) view fun hasProtocolRecipient(): Bool {
5154            if let cap = self.protocolFeeRecipientCap {
5155                return cap.check()
5156            }
5157            return false
5158        }
5159        
5160        access(all) view fun getTotalProtocolFeeForwarded(): UFix64 {
5161            return self.totalProtocolFeeForwarded
5162        }
5163        
5164        /// Set protocol fee recipient for forwarding at draw time.
5165        /// Validates that the receiver accepts the pool's asset type to prevent draw failures.
5166        access(contract) fun setProtocolFeeRecipient(cap: Capability<&{FungibleToken.Receiver}>?) {
5167            // Validate vault type compatibility if capability is provided
5168            if let recipientCap = cap {
5169                if let receiverRef = recipientCap.borrow() {
5170                    // Check if receiver accepts this pool's vault type
5171                    assert(
5172                        receiverRef.isSupportedVaultType(type: self.config.assetType),
5173                        message: "Protocol fee recipient does not accept this pool's asset type. Pool asset type: \(self.config.assetType.identifier), recipient address: \(recipientCap.address)"
5174                    )
5175                }
5176            }
5177            self.protocolFeeRecipientCap = cap
5178        }
5179
5180        /// Withdraws funds from the unclaimed protocol fee vault.
5181        /// Called by Admin.withdrawUnclaimedProtocolFee.
5182        /// @param amount - Maximum amount to withdraw
5183        /// @return Vault containing withdrawn funds (may be less than requested)
5184        access(contract) fun withdrawUnclaimedProtocolFee(amount: UFix64): @{FungibleToken.Vault} {
5185            let available = self.unclaimedProtocolFeeVault.balance
5186            let withdrawAmount = amount > available ? available : amount
5187            return <- self.unclaimedProtocolFeeVault.withdraw(amount: withdrawAmount)
5188        }
5189
5190        // ============================================================
5191        // ENTRY VIEW FUNCTIONS - Human-readable UI helpers
5192        // ============================================================
5193        // "Entries" represent the user's EARNED prize weight so far this round.
5194        //
5195        // Entries grow passively over time as the user holds their deposit:
5196        // - Every deposit starts at 0 entries
5197        // - Entries accumulate linearly: shares × (timeHeld / roundDuration)
5198        // - At draw time, entries equal the finalized TWAB weight
5199        //
5200        // With NORMALIZED TWAB, entries ≈ average shares scaled by round progress:
5201        // - 10 shares held for full round → 10 entries at draw time
5202        // - 10 shares deposited halfway → grows from 0 to ~5 entries by draw
5203        // - At next round: same 10 shares → starts at 0, earns toward 10
5204        //
5205        // Withdrawing reduces future entry accumulation (fewer shares earning).
5206        // Actual prize weight is finalized during processDrawBatch.
5207        // ============================================================
5208
5209        /// Returns the user's earned entries so far this round, in asset (token) units.
5210        /// Entries grow passively over time based on deposit value and hold duration.
5211        ///
5212        /// Formula: convertToAssets(getCurrentTWAB(now) × (elapsed / roundDuration))
5213        /// The TWAB tracks shares internally, but entries are converted to asset value
5214        /// so that depositing 100 tokens → entries grow toward ~100 regardless of share price.
5215        ///
5216        /// At draw time (elapsed == roundDuration), this equals the asset value of
5217        /// the finalized TWAB weight.
5218        /// During gap periods (now > roundEndTime), caps at the round end value.
5219        /// During intermission, returns the user's asset value (full potential
5220        /// for the next round).
5221        ///
5222        /// Examples (10-day round, 100 tokens deposited at start):
5223        /// - Day 0: 0 entries (just deposited)
5224        /// - Day 3: ~30 entries
5225        /// - Day 7: ~70 entries
5226        /// - Day 10 (draw): ~100 entries
5227        access(all) view fun getUserEntries(receiverID: UInt64): UFix64 {
5228            // During intermission, return asset value (their full potential for next round)
5229            if self.activeRound == nil {
5230                return self.shareTracker.getUserAssetValue(receiverID: receiverID)
5231            }
5232
5233            if let round = &self.activeRound as &Round? {
5234                let roundDuration = round.getDuration()
5235                if roundDuration == 0.0 {
5236                    return 0.0
5237                }
5238
5239                let roundEndTime = round.getEndTime()
5240                let now = getCurrentBlock().timestamp
5241                let shares = self.shareTracker.getUserShares(receiverID: receiverID)
5242
5243                // Cap at round end time so entries don't exceed final value during gap periods
5244                let effectiveTime = now < roundEndTime ? now : roundEndTime
5245
5246                // Get current TWAB normalized by elapsed time (= average shares held so far)
5247                let currentWeight = round.getCurrentTWAB(
5248                    receiverID: receiverID,
5249                    currentShares: shares,
5250                    atTime: effectiveTime
5251                )
5252
5253                // Scale from "average shares over elapsed" to "earned entries over round"
5254                // This converts getCurrentTWAB's normalization-by-elapsed to normalization-by-roundDuration
5255                let elapsedFromStart = effectiveTime - round.getStartTime()
5256                if elapsedFromStart == 0.0 {
5257                    return 0.0
5258                }
5259
5260                let shareBasedEntries = currentWeight * (elapsedFromStart / roundDuration)
5261                // Convert from share units to asset (token) units so entries map to deposit value
5262                return self.shareTracker.convertToAssets(shareBasedEntries)
5263            }
5264            return 0.0
5265        }
5266        
5267        /// Returns how far through the current round we are (0.0 to 1.0+).
5268        /// - 0.0 = round just started (or in intermission)
5269        /// - 0.5 = halfway through round
5270        /// - 1.0 = round complete, ready for next draw
5271        access(all) view fun getDrawProgressPercent(): UFix64 {
5272            if let round = &self.activeRound as &Round? {
5273                let roundDuration = round.getDuration()
5274                if roundDuration == 0.0 {
5275                    return 0.0
5276                }
5277                let elapsed = self.getRoundElapsedTime()
5278                return elapsed / roundDuration
5279            }
5280            return 0.0
5281        }
5282
5283        /// Returns time remaining until round ends (in seconds).
5284        /// Returns 0.0 if round has ended, in intermission, or draw can happen now.
5285        access(all) view fun getTimeUntilNextDraw(): UFix64 {
5286            if let round = &self.activeRound as &Round? {
5287                let endTime = round.getEndTime()
5288                let now = getCurrentBlock().timestamp
5289                if now >= endTime {
5290                    return 0.0
5291                }
5292                return endTime - now
5293            }
5294            return 0.0
5295        }
5296
5297    }
5298    
5299    // ============================================================
5300    // POOL BALANCE STRUCT
5301    // ============================================================
5302    
5303    /// Represents a user's balance in a pool.
5304    /// Note: Deposit history is tracked off-chain via events.
5305    access(all) struct PoolBalance {
5306        /// Total withdrawable balance (shares × sharePrice).
5307        /// This is what the user can actually withdraw right now.
5308        access(all) let totalBalance: UFix64
5309        
5310        /// Lifetime total of prizes won (cumulative counter).
5311        /// This never decreases - useful for leaderboards and statistics.
5312        access(all) let totalEarnedPrizes: UFix64
5313        
5314        /// Creates a PoolBalance summary.
5315        /// @param totalBalance - Current withdrawable balance
5316        /// @param totalEarnedPrizes - Lifetime prize winnings
5317        init(totalBalance: UFix64, totalEarnedPrizes: UFix64) {
5318            self.totalBalance = totalBalance
5319            self.totalEarnedPrizes = totalEarnedPrizes
5320        }
5321    }
5322    
5323    // ============================================================
5324    // POOL POSITION COLLECTION RESOURCE
5325    // ============================================================
5326    
5327    /// User's position collection for interacting with prize-linked accounts pools.
5328    /// 
5329    /// This resource represents a user's account in the prize rewards protocol.
5330    /// It can hold positions across multiple pools simultaneously.
5331    /// 
5332    /// ⚠️ CRITICAL SECURITY WARNING:
5333    /// This resource's UUID serves as the account key for ALL deposits.
5334    /// - All funds, shares, prizes, and NFTs are keyed to this resource's UUID
5335    /// - If this resource is destroyed or lost, funds become INACCESSIBLE
5336    /// - There is NO built-in recovery mechanism without admin intervention
5337    /// - Users should treat this resource like a wallet private key
5338    /// 
5339    /// USAGE:
5340    /// 1. Create and store: account.storage.save(<- createPoolPositionCollection(), to: path)
5341    /// 2. Deposit: collection.deposit(poolID: 0, from: <- vault)
5342    /// 3. Withdraw: let vault <- collection.withdraw(poolID: 0, amount: 10.0)
5343    /// 4. Claim NFTs: let nft <- collection.claimPendingNFT(poolID: 0, nftIndex: 0)
5344    access(all) resource PoolPositionCollection {
5345        /// Tracks which pools this collection is registered with.
5346        /// Registration happens automatically on first deposit.
5347        access(self) let registeredPools: {UInt64: Bool}
5348        
5349        init() {
5350            self.registeredPools = {}
5351        }
5352        
5353        /// Internal: Registers this collection with a pool.
5354        /// Called automatically on first deposit to that pool.
5355        /// @param poolID - ID of pool to register with
5356        access(self) fun registerWithPool(poolID: UInt64) {
5357            pre {
5358                self.registeredPools[poolID] == nil: "Already registered"
5359                self.owner != nil: "Collection must be stored in an account"
5360            }
5361            
5362            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5363            
5364            // Register our UUID with the owner address for address resolution
5365            poolRef.registerReceiver(receiverID: self.uuid, ownerAddress: self.owner!.address)
5366            self.registeredPools[poolID] = true
5367        }
5368        
5369        /// Returns list of pool IDs this collection is registered with.
5370        access(all) view fun getRegisteredPoolIDs(): [UInt64] {
5371            return self.registeredPools.keys
5372        }
5373        
5374        /// Checks if this collection is registered with a specific pool.
5375        /// @param poolID - Pool ID to check
5376        access(all) view fun isRegisteredWithPool(poolID: UInt64): Bool {
5377            return self.registeredPools[poolID] == true
5378        }
5379        
5380        /// Deposits funds into a pool.
5381        ///
5382        /// Automatically registers with the pool on first deposit.
5383        /// Requires PositionOps entitlement (user must have authorized capability).
5384        ///
5385        /// @param poolID - ID of pool to deposit into
5386        /// @param from - Vault containing funds to deposit (consumed)
5387        /// @param maxSlippageBps - Maximum acceptable slippage in basis points (100 = 1%, 10000 = no protection)
5388        access(PositionOps) fun deposit(poolID: UInt64, from: @{FungibleToken.Vault}, maxSlippageBps: UInt64) {
5389            // Auto-register on first deposit
5390            if self.registeredPools[poolID] == nil {
5391                self.registerWithPool(poolID: poolID)
5392            }
5393
5394            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5395
5396            // Delegate to pool's deposit function with owner address for tracking
5397            poolRef.deposit(from: <- from, receiverID: self.uuid, ownerAddress: self.owner?.address, maxSlippageBps: maxSlippageBps)
5398        }
5399        
5400        /// Withdraws funds from a pool.
5401        /// 
5402        /// Can withdraw up to total balance (deposits + earned interest).
5403        /// May return empty vault if yield source has liquidity issues.
5404        /// 
5405        /// @param poolID - ID of pool to withdraw from
5406        /// @param amount - Amount to withdraw (must be > 0)
5407        /// @return Vault containing withdrawn funds
5408        access(PositionOps) fun withdraw(poolID: UInt64, amount: UFix64): @{FungibleToken.Vault} {
5409            pre {
5410                amount > 0.0: "Withdraw amount must be greater than 0"
5411                self.registeredPools[poolID] == true: "Not registered with pool"
5412            }
5413            
5414            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5415            
5416            // Pass owner address for tracking current owner
5417            return <- poolRef.withdraw(amount: amount, receiverID: self.uuid, ownerAddress: self.owner?.address)
5418        }
5419        
5420        /// Claims a pending NFT prize.
5421        /// 
5422        /// NFT prizes won in draws are stored in pending claims until picked up.
5423        /// Use getPendingNFTIDs() to see available NFTs.
5424        /// 
5425        /// @param poolID - ID of pool where NFT was won
5426        /// @param nftIndex - Index in pending claims array (0-based)
5427        /// @return The claimed NFT resource
5428        access(PositionOps) fun claimPendingNFT(poolID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
5429            pre {
5430                self.registeredPools[poolID] == true: "Not registered with pool"
5431            }
5432            
5433            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5434            
5435            return <- poolRef.claimPendingNFT(receiverID: self.uuid, nftIndex: nftIndex)
5436        }
5437        
5438        /// Returns count of pending NFT claims for this user in a pool.
5439        /// @param poolID - Pool ID to check
5440        /// @return Number of NFTs awaiting claim
5441        access(all) view fun getPendingNFTCount(poolID: UInt64): Int {
5442            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5443                return poolRef.getPendingNFTCount(receiverID: self.uuid)
5444            }
5445            return 0
5446        }
5447        
5448        /// Returns UUIDs of all pending NFT claims for this user in a pool.
5449        /// @param poolID - Pool ID to check
5450        /// @return Array of NFT UUIDs
5451        access(all) fun getPendingNFTIDs(poolID: UInt64): [UInt64] {
5452            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5453                return poolRef.getPendingNFTIDs(receiverID: self.uuid)
5454            }
5455            return []
5456        }
5457        
5458        /// Returns this collection's receiver ID (its UUID).
5459        /// This is the key used to identify the user in all pools.
5460        access(all) view fun getReceiverID(): UInt64 {
5461            return self.uuid
5462        }
5463        
5464        /// Returns a complete balance breakdown for this user in a pool.
5465        /// @param poolID - Pool ID to check
5466        /// @return PoolBalance struct with balance and lifetime prizes
5467        access(all) fun getPoolBalance(poolID: UInt64): PoolBalance {
5468            // Return zero balance if not registered
5469            if self.registeredPools[poolID] == nil {
5470                return PoolBalance(totalBalance: 0.0, totalEarnedPrizes: 0.0)
5471            }
5472
5473            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5474                return PoolBalance(
5475                    totalBalance: poolRef.getReceiverTotalBalance(receiverID: self.uuid),
5476                    totalEarnedPrizes: poolRef.getReceiverTotalEarnedPrizes(receiverID: self.uuid)
5477                )
5478            }
5479            return PoolBalance(totalBalance: 0.0, totalEarnedPrizes: 0.0)
5480        }
5481        
5482        /// Returns the user's projected entry count for the current draw.
5483        /// Entries represent prize weight - higher entries = better odds.
5484        /// 
5485        /// Entry calculation:
5486        /// - Projects TWAB forward to draw end time
5487        /// - Normalizes by draw interval for human-readable number
5488        /// 
5489        /// Example: $10 deposited at start of 7-day draw = ~10 entries
5490        /// Example: $10 deposited halfway through = ~5 entries (prorated)
5491        /// 
5492        /// @param poolID - Pool ID to check
5493        /// @return Projected entry count
5494        access(all) view fun getPoolEntries(poolID: UInt64): UFix64 {
5495            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5496                return poolRef.getUserEntries(receiverID: self.uuid)
5497            }
5498            return 0.0
5499        }
5500    }
5501    
5502    // ============================================================
5503    // SPONSOR POSITION COLLECTION RESOURCE
5504    // ============================================================
5505    
5506    /// Sponsor's position collection for prize-ineligible deposits.
5507    /// 
5508    /// This resource allows users to make deposits that earn rewards yield
5509    /// but are NOT eligible to win prizes. Useful for:
5510    /// - Protocol treasuries seeding initial liquidity
5511    /// - Foundations incentivizing participation without competing
5512    /// - Users who want yield but don't want prize exposure
5513    /// 
5514    /// A single account can have BOTH a PoolPositionCollection (prize-eligible)
5515    /// AND a SponsorPositionCollection (prize-ineligible) simultaneously.
5516    /// Each has its own UUID, enabling independent positions.
5517    /// 
5518    /// ⚠️ CRITICAL SECURITY WARNING:
5519    /// This resource's UUID serves as the account key for ALL sponsor deposits.
5520    /// - All funds and shares are keyed to this resource's UUID
5521    /// - If this resource is destroyed or lost, funds become INACCESSIBLE
5522    /// - Users should treat this resource like a wallet private key
5523    /// 
5524    /// USAGE:
5525    /// 1. Create and store: account.storage.save(<- createSponsorPositionCollection(), to: path)
5526    /// 2. Deposit: collection.deposit(poolID: 0, from: <- vault)
5527    /// 3. Withdraw: let vault <- collection.withdraw(poolID: 0, amount: 10.0)
5528    access(all) resource SponsorPositionCollection {
5529        /// Tracks which pools this collection is registered with.
5530        access(self) let registeredPools: {UInt64: Bool}
5531        
5532        init() {
5533            self.registeredPools = {}
5534        }
5535        
5536        /// Returns this collection's receiver ID (UUID).
5537        /// Used internally to identify this sponsor's position in pools.
5538        access(all) view fun getReceiverID(): UInt64 {
5539            return self.uuid
5540        }
5541        
5542        /// Returns list of pool IDs this collection is registered with.
5543        access(all) view fun getRegisteredPoolIDs(): [UInt64] {
5544            return self.registeredPools.keys
5545        }
5546        
5547        /// Checks if this collection is registered with a specific pool.
5548        /// @param poolID - Pool ID to check
5549        access(all) view fun isRegisteredWithPool(poolID: UInt64): Bool {
5550            return self.registeredPools[poolID] == true
5551        }
5552        
5553        /// Deposits funds as a sponsor (prize-ineligible).
5554        ///
5555        /// Sponsors earn rewards yield but cannot win prizes.
5556        /// Requires PositionOps entitlement.
5557        ///
5558        /// @param poolID - ID of pool to deposit into
5559        /// @param from - Vault containing funds to deposit (consumed)
5560        /// @param maxSlippageBps - Maximum acceptable slippage in basis points (100 = 1%, 10000 = no protection)
5561        access(PositionOps) fun deposit(poolID: UInt64, from: @{FungibleToken.Vault}, maxSlippageBps: UInt64) {
5562            // Track registration locally
5563            if self.registeredPools[poolID] == nil {
5564                self.registeredPools[poolID] = true
5565            }
5566
5567            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5568            // Pass owner address directly (sponsors don't use capability-based lookup)
5569            poolRef.sponsorDeposit(from: <- from, receiverID: self.uuid, ownerAddress: self.owner?.address, maxSlippageBps: maxSlippageBps)
5570        }
5571        
5572        /// Withdraws funds from a pool.
5573        /// 
5574        /// Can withdraw up to total balance (deposits + earned interest).
5575        /// May return empty vault if yield source has liquidity issues.
5576        /// 
5577        /// @param poolID - ID of pool to withdraw from
5578        /// @param amount - Amount to withdraw (must be > 0)
5579        /// @return Vault containing withdrawn funds
5580        access(PositionOps) fun withdraw(poolID: UInt64, amount: UFix64): @{FungibleToken.Vault} {
5581            pre {
5582                amount > 0.0: "Withdraw amount must be greater than 0"
5583                self.registeredPools[poolID] == true: "Not registered with pool"
5584            }
5585            
5586            let poolRef = PrizeLinkedAccounts.getPoolInternal(poolID)
5587            // Pass owner address directly (sponsors don't use capability-based lookup)
5588            return <- poolRef.withdraw(amount: amount, receiverID: self.uuid, ownerAddress: self.owner?.address)
5589        }
5590        
5591        /// Returns the sponsor's share balance in a pool.
5592        /// @param poolID - Pool ID to check
5593        access(all) view fun getPoolShares(poolID: UInt64): UFix64 {
5594            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5595                return poolRef.getUserRewardsShares(receiverID: self.uuid)
5596            }
5597            return 0.0
5598        }
5599        
5600        /// Returns the sponsor's asset balance in a pool (shares converted to assets).
5601        /// @param poolID - Pool ID to check
5602        access(all) view fun getPoolAssetBalance(poolID: UInt64): UFix64 {
5603            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5604                return poolRef.getReceiverTotalBalance(receiverID: self.uuid)
5605            }
5606            return 0.0
5607        }
5608        
5609        /// Returns 0.0 - sponsors have no prize entries by design.
5610        /// @param poolID - Pool ID to check
5611        /// @return Always 0.0 (sponsors are prize-ineligible)
5612        access(all) view fun getPoolEntries(poolID: UInt64): UFix64 {
5613            return 0.0  // Sponsors are never prize-eligible
5614        }
5615        
5616        /// Returns a complete balance breakdown for this sponsor in a pool.
5617        /// Note: totalEarnedPrizes will always be 0 since sponsors cannot win.
5618        /// @param poolID - Pool ID to check
5619        /// @return PoolBalance struct with balance components
5620        access(all) fun getPoolBalance(poolID: UInt64): PoolBalance {
5621            // Return zero balance if not registered
5622            if self.registeredPools[poolID] == nil {
5623                return PoolBalance(totalBalance: 0.0, totalEarnedPrizes: 0.0)
5624            }
5625            
5626            if let poolRef = PrizeLinkedAccounts.borrowPool(poolID: poolID) {
5627                return PoolBalance(
5628                    totalBalance: poolRef.getReceiverTotalBalance(receiverID: self.uuid),
5629                    totalEarnedPrizes: 0.0  // Sponsors cannot win prizes
5630                )
5631            }
5632            return PoolBalance(totalBalance: 0.0, totalEarnedPrizes: 0.0)
5633        }
5634    }
5635    
5636    // ============================================================
5637    // CONTRACT-LEVEL FUNCTIONS
5638    // ============================================================
5639    
5640    /// Internal: Creates a new Pool resource.
5641    /// Called by Admin.createPool() - not directly accessible.
5642    /// @param config - Pool configuration
5643    /// @param emergencyConfig - Optional emergency configuration
5644    /// @return The new pool's ID
5645    access(contract) fun createPool(
5646        config: PoolConfig,
5647        emergencyConfig: EmergencyConfig?
5648    ): UInt64 {
5649        let pool <- create Pool(
5650            config: config, 
5651            emergencyConfig: emergencyConfig
5652        )
5653        
5654        // Assign next available ID
5655        let poolID = self.nextPoolID
5656        self.nextPoolID = self.nextPoolID + 1
5657        
5658        // Set pool ID and store
5659        pool.setPoolID(id: poolID)
5660        emit PoolCreated(
5661            poolID: poolID,
5662            assetType: config.assetType.identifier,
5663            strategy: config.distributionStrategy.getStrategyName()
5664        )
5665        
5666        self.pools[poolID] <-! pool
5667        return poolID
5668    }
5669    
5670    /// Returns a read-only reference to a pool.
5671    /// Safe for public use - no mutation allowed.
5672    /// @param poolID - ID of pool to borrow
5673    /// @return Reference to pool, or nil if not found
5674    access(all) view fun borrowPool(poolID: UInt64): &Pool? {
5675        return &self.pools[poolID]
5676    }
5677    
5678    /// Internal: Returns an authorized reference to a pool.
5679    /// Only used by Admin operations - not publicly accessible.
5680    /// @param poolID - ID of pool to borrow
5681    /// @return Authorized reference, or nil if not found
5682    access(contract) fun borrowPoolInternal(_ poolID: UInt64): auth(CriticalOps, ConfigOps) &Pool? {
5683        return &self.pools[poolID]
5684    }
5685
5686    /// Internal: Returns an authorized reference or panics.
5687    /// Reduces boilerplate for functions that require pool to exist.
5688    /// @param poolID - ID of pool to get
5689    /// @return Authorized reference (panics if not found)
5690    access(contract) fun getPoolInternal(_ poolID: UInt64): auth(CriticalOps, ConfigOps) &Pool {
5691        return (&self.pools[poolID] as auth(CriticalOps, ConfigOps) &Pool?)
5692            ?? panic("Cannot get Pool: Pool with ID \(poolID) does not exist")
5693    }
5694    
5695    /// Returns all pool IDs currently in the contract.
5696    access(all) view fun getAllPoolIDs(): [UInt64] {
5697        return self.pools.keys
5698    }
5699
5700    /// Returns a user's projected balance accounting for unsync'd yield or deficit.
5701    /// Convenience wrapper that borrows the pool and delegates to Pool.getProjectedUserBalance.
5702    ///
5703    /// @param poolID - Pool to query
5704    /// @param receiverID - User's receiver ID
5705    /// @return Projected balance, or 0.0 if pool not found
5706    access(all) fun getProjectedUserBalance(poolID: UInt64, receiverID: UInt64): UFix64 {
5707        if let poolRef = self.borrowPool(poolID: poolID) {
5708            return poolRef.getProjectedUserBalance(receiverID: receiverID)
5709        }
5710        return 0.0
5711    }
5712    
5713    /// Creates a new PoolPositionCollection for a user.
5714    /// 
5715    /// Users must create and store this resource to interact with pools.
5716    /// Typical usage:
5717    /// ```
5718    /// let collection <- PrizeLinkedAccounts.createPoolPositionCollection()
5719    /// account.storage.save(<- collection, to: PrizeLinkedAccounts.PoolPositionCollectionStoragePath)
5720    /// ```
5721    /// 
5722    /// @return New PoolPositionCollection resource
5723    access(all) fun createPoolPositionCollection(): @PoolPositionCollection {
5724        return <- create PoolPositionCollection()
5725    }
5726    
5727    /// Creates a new SponsorPositionCollection for a user.
5728    /// 
5729    /// Sponsors can deposit funds that earn rewards yield but are
5730    /// NOT eligible to win prizes. A single account can have
5731    /// both a PoolPositionCollection and SponsorPositionCollection.
5732    /// 
5733    /// Typical usage:
5734    /// ```
5735    /// let collection <- PrizeLinkedAccounts.createSponsorPositionCollection()
5736    /// account.storage.save(<- collection, to: PrizeLinkedAccounts.SponsorPositionCollectionStoragePath)
5737    /// ```
5738    /// 
5739    /// @return New SponsorPositionCollection resource
5740    access(all) fun createSponsorPositionCollection(): @SponsorPositionCollection {
5741        return <- create SponsorPositionCollection()
5742    }
5743
5744    /// Starts a prize draw for a pool (Phase 1 of 3). PERMISSIONLESS.
5745    ///
5746    /// Anyone can call this when the round has ended and no draw is in progress.
5747    /// This ensures draws cannot stall if admin is unavailable.
5748    ///
5749    /// Preconditions (enforced internally):
5750    /// - Pool must be in Normal state (not emergency/paused)
5751    /// - No pending draw in progress
5752    /// - Round must have ended (canDrawNow() returns true)
5753    ///
5754    /// @param poolID - ID of the pool to start draw for
5755    access(all) fun startDraw(poolID: UInt64) {
5756        let poolRef = self.getPoolInternal(poolID)
5757        poolRef.startDraw()
5758    }
5759
5760    /// Processes a batch of receivers for weight capture (Phase 2 of 3). PERMISSIONLESS.
5761    ///
5762    /// Anyone can call this to advance batch processing.
5763    /// Call repeatedly until return value is 0 (or isDrawBatchComplete()).
5764    ///
5765    /// Preconditions (enforced internally):
5766    /// - Draw must be in progress
5767    /// - Batch processing not complete
5768    ///
5769    /// @param poolID - ID of the pool
5770    /// @param limit - Maximum receivers to process this batch
5771    /// @return Number of receivers remaining to process
5772    access(all) fun processDrawBatch(poolID: UInt64, limit: Int): Int {
5773        let poolRef = self.getPoolInternal(poolID)
5774        return poolRef.processDrawBatch(limit: limit)
5775    }
5776
5777    /// Completes a prize draw for a pool (Phase 3 of 3). PERMISSIONLESS.
5778    ///
5779    /// Anyone can call this after batch processing is complete and at least 1 block
5780    /// has passed since startDraw() (for randomness fulfillment).
5781    /// Fulfills randomness request, selects winners, and distributes prizes.
5782    /// Prizes are auto-compounded into winners' deposits.
5783    ///
5784    /// Preconditions (enforced internally):
5785    /// - startDraw() must have been called (randomness already requested)
5786    /// - Batch processing must be complete
5787    /// - Must be in a different block than startDraw()
5788    ///
5789    /// @param poolID - ID of the pool to complete draw for
5790    access(all) fun completeDraw(poolID: UInt64) {
5791        let poolRef = self.getPoolInternal(poolID)
5792        poolRef.completeDraw()
5793    }
5794
5795    // ============================================================
5796    // ENTRY QUERY FUNCTIONS - Contract-level convenience accessors
5797    // ============================================================
5798    // These functions provide easy access to entry information for UI/scripts.
5799    // "Entries" represent the user's prize weight for the current draw:
5800    // - entries = currentTWAB / elapsedTime
5801    // - $10 deposited at start of draw = 10 entries
5802    // - $10 deposited halfway through = ~5 entries (prorated based on elapsed time)
5803    // ============================================================
5804    
5805    /// Returns a user's projected entry count for the current draw.
5806    /// Convenience wrapper for scripts that have receiverID but not collection.
5807    /// @param poolID - Pool ID to check
5808    /// @param receiverID - User's receiver ID (PoolPositionCollection UUID)
5809    /// @return Projected entry count
5810    access(all) view fun getUserEntries(poolID: UInt64, receiverID: UInt64): UFix64 {
5811        if let poolRef = self.borrowPool(poolID: poolID) {
5812            return poolRef.getUserEntries(receiverID: receiverID)
5813        }
5814        return 0.0
5815    }
5816    
5817    /// Returns the draw progress as a percentage (0.0 to 1.0+).
5818    /// Values > 1.0 indicate draw is overdue.
5819    /// @param poolID - Pool ID to check
5820    /// @return Draw progress percentage
5821    access(all) view fun getDrawProgressPercent(poolID: UInt64): UFix64 {
5822        if let poolRef = self.borrowPool(poolID: poolID) {
5823            return poolRef.getDrawProgressPercent()
5824        }
5825        return 0.0
5826    }
5827    
5828    /// Returns time remaining until next draw (in seconds).
5829    /// Returns 0.0 if draw can happen now.
5830    /// @param poolID - Pool ID to check
5831    /// @return Seconds until next draw is available
5832    access(all) view fun getTimeUntilNextDraw(poolID: UInt64): UFix64 {
5833        if let poolRef = self.borrowPool(poolID: poolID) {
5834            return poolRef.getTimeUntilNextDraw()
5835        }
5836        return 0.0
5837    }
5838
5839    // ============================================================
5840    // POOL STATE MACHINE (Contract-level convenience functions)
5841    // ============================================================
5842
5843    /// STATE 1: Returns whether a round is actively in progress (timer hasn't expired).
5844    access(all) view fun isRoundActive(poolID: UInt64): Bool {
5845        if let poolRef = self.borrowPool(poolID: poolID) {
5846            return poolRef.isRoundActive()
5847        }
5848        return false
5849    }
5850
5851    /// STATE 2: Returns whether the round has ended and is waiting for draw to start.
5852    access(all) view fun isAwaitingDraw(poolID: UInt64): Bool {
5853        if let poolRef = self.borrowPool(poolID: poolID) {
5854            return poolRef.isAwaitingDraw()
5855        }
5856        return false
5857    }
5858
5859    /// STATE 3: Returns whether a draw is being processed.
5860    access(all) view fun isDrawInProgress(poolID: UInt64): Bool {
5861        if let poolRef = self.borrowPool(poolID: poolID) {
5862            return poolRef.isDrawInProgress()
5863        }
5864        return false
5865    }
5866
5867    /// STATE 4: Returns whether the pool is in intermission (between rounds).
5868    access(all) view fun isInIntermission(poolID: UInt64): Bool {
5869        if let poolRef = self.borrowPool(poolID: poolID) {
5870            return poolRef.isInIntermission()
5871        }
5872        return false
5873    }
5874
5875    // ============================================================
5876    // CONTRACT INITIALIZATION
5877    // ============================================================
5878    
5879    /// Contract initializer - called once when contract is deployed.
5880    /// Sets up constants, storage paths, and creates Admin resource.
5881    init() {
5882        // Virtual offset constants for ERC4626 inflation attack protection.
5883        // Using 0.0001 to minimize dilution (~0.0001%) while providing security
5884        self.VIRTUAL_SHARES = 0.0001
5885        self.VIRTUAL_ASSETS = 0.0001
5886        
5887        // Minimum yield distribution threshold (100x minimum UFix64).
5888        // Prevents precision loss when distributing tiny amounts across percentage buckets.
5889        self.MINIMUM_DISTRIBUTION_THRESHOLD = 0.000001
5890
5891        // Warning threshold for normalized weights (90% of UFix64 max ≈ 166 billion)
5892        // UFix64 max value for percentage calculations
5893        self.UFIX64_MAX = 184467440737.0
5894        
5895        // Warning threshold for normalized weights (90% of UFIX64_MAX ≈ 166 billion)
5896        // With normalized TWAB, weights are ~average shares, so this is very generous
5897        self.WEIGHT_WARNING_THRESHOLD = 166000000000.0
5898
5899        // Maximum TVL per pool (80% of UFIX64_MAX ≈ 147 billion)
5900        // Provides safety margin for yield accrual and prevents overflow
5901        self.SAFE_MAX_TVL = 147500000000.0
5902
5903        // Maximum bonus weight per user (10% of SAFE_MAX_TVL ≈ 14.75 billion)
5904        // Prevents overflow when combined with TWAB during draw processing
5905        self.MAX_BONUS_WEIGHT_PER_USER = 14750000000.0
5906
5907        // Storage paths for user collections
5908        self.PoolPositionCollectionStoragePath = /storage/PrizeLinkedAccountsCollection
5909        self.PoolPositionCollectionPublicPath = /public/PrizeLinkedAccountsCollection
5910        
5911        // Storage paths for sponsor collections (prize-ineligible)
5912        self.SponsorPositionCollectionStoragePath = /storage/PrizeLinkedAccountsSponsorCollection
5913        self.SponsorPositionCollectionPublicPath = /public/PrizeLinkedAccountsSponsorCollection
5914        
5915        // Storage path for admin resource
5916        self.AdminStoragePath = /storage/PrizeLinkedAccountsAdmin
5917        
5918        // Initialize pool storage
5919        self.pools <- {}
5920        self.nextPoolID = 0
5921        
5922        let admin <- create Admin()
5923        self.account.storage.save(<-admin, to: self.AdminStoragePath)
5924    }
5925}