Smart Contract
PrizeLinkedAccounts
A.a092c4aab33daeda.PrizeLinkedAccounts
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}