Smart Contract
PrizeSavings
A.a092c4aab33daeda.PrizeSavings
1/*
2PrizeSavings - Prize-Linked Savings Protocol
3
4No-loss lottery where users deposit tokens to earn guaranteed savings interest and lottery prizes.
5Rewards auto-compound into deposits via ERC4626-style shares model.
6
7Architecture:
8- ERC4626-style shares with virtual offset protection (inflation attack resistant)
9- TWAB (time-weighted average balance) using balance-seconds for fair lottery weighting
10- On-chain randomness via Flow's RandomConsumer
11- Modular yield sources via DeFi Actions interface
12- Configurable distribution strategies (savings/lottery/treasury 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 lottery weights for promotions
19- Winner tracking integration for leaderboards
20
21Lottery Fairness:
22- Uses balance-seconds (current_balance × time) for lottery weighting
23- Balance = shares × share_price, so yield automatically counts toward lottery odds
24- Users who earn more yield have proportionally higher lottery chances
25- Rewards commitment: longer deposits = more accumulated balance-seconds
26
27Core Components:
28- SavingsDistributor: Shares vault with balance-seconds tracking for lottery weights
29- LotteryDistributor: Prize pool, NFT prizes, and draw execution
30- Pool: Deposits, withdrawals, yield processing, and prize draws
31- PoolPositionCollection: User's resource for interacting with pools
32- Admin: Pool configuration and emergency operations
33*/
34
35import FungibleToken from 0xf233dcee88fe0abe
36import NonFungibleToken from 0x1d7e57aa55817448
37import RandomConsumer from 0xa092c4aab33daeda
38import DeFiActions from 0x92195d814edf9cb0
39import DeFiActionsUtils from 0x92195d814edf9cb0
40import PrizeWinnerTracker from 0xa092c4aab33daeda
41import Xorshift128plus from 0xa092c4aab33daeda
42
43access(all) contract PrizeSavings {
44 // Admin entitlements
45 access(all) entitlement ConfigOps
46 access(all) entitlement CriticalOps
47 access(all) entitlement OwnerOnly
48
49 // User collection entitlement - protects deposit, withdraw, and claim operations
50 access(all) entitlement PositionOps
51
52 /// Virtual offset constants for ERC4626 inflation attack protection.
53 /// These create "dead" shares/assets that prevent share price manipulation.
54 /// Using 0.0001 to minimize user dilution (~0.0001%) while maintaining security.
55 access(all) let VIRTUAL_SHARES: UFix64
56 access(all) let VIRTUAL_ASSETS: UFix64
57
58 access(all) let PoolPositionCollectionStoragePath: StoragePath
59 access(all) let PoolPositionCollectionPublicPath: PublicPath
60
61 access(all) event PoolCreated(poolID: UInt64, assetType: String, strategy: String)
62 access(all) event Deposited(poolID: UInt64, receiverID: UInt64, userAddress: Address, amount: UFix64)
63 access(all) event Withdrawn(poolID: UInt64, receiverID: UInt64, userAddress: Address, requestedAmount: UFix64, actualAmount: UFix64)
64
65 access(all) event RewardsProcessed(poolID: UInt64, totalAmount: UFix64, savingsAmount: UFix64, lotteryAmount: UFix64)
66
67 access(all) event SavingsYieldAccrued(poolID: UInt64, amount: UFix64)
68 access(all) event SavingsInterestCompounded(poolID: UInt64, receiverID: UInt64, userAddress: Address, amount: UFix64)
69 access(all) event SavingsInterestCompoundedBatch(poolID: UInt64, userCount: Int, totalAmount: UFix64, avgAmount: UFix64)
70 access(all) event SavingsRoundingDustToTreasury(poolID: UInt64, amount: UFix64)
71
72 access(all) event PrizeDrawCommitted(poolID: UInt64, prizeAmount: UFix64, commitBlock: UInt64)
73 access(all) event PrizesAwarded(poolID: UInt64, winners: [UInt64], winnerAddresses: [Address], amounts: [UFix64], round: UInt64)
74 access(all) event LotteryPrizePoolFunded(poolID: UInt64, amount: UFix64, source: String)
75 access(all) event NewEpochStarted(poolID: UInt64, epochID: UInt64, startTime: UFix64)
76
77 access(all) event DistributionStrategyUpdated(poolID: UInt64, oldStrategy: String, newStrategy: String, updatedBy: Address)
78 access(all) event WinnerSelectionStrategyUpdated(poolID: UInt64, oldStrategy: String, newStrategy: String, updatedBy: Address)
79 access(all) event WinnerTrackerUpdated(poolID: UInt64, hasOldTracker: Bool, hasNewTracker: Bool, updatedBy: Address)
80 access(all) event DrawIntervalUpdated(poolID: UInt64, oldInterval: UFix64, newInterval: UFix64, updatedBy: Address)
81 access(all) event MinimumDepositUpdated(poolID: UInt64, oldMinimum: UFix64, newMinimum: UFix64, updatedBy: Address)
82 access(all) event PoolCreatedByAdmin(poolID: UInt64, assetType: String, strategy: String, createdBy: Address)
83
84 access(all) event PoolPaused(poolID: UInt64, pausedBy: Address, reason: String)
85 access(all) event PoolUnpaused(poolID: UInt64, unpausedBy: Address)
86 access(all) event TreasuryFunded(poolID: UInt64, amount: UFix64, source: String)
87 access(all) event TreasuryRecipientUpdated(poolID: UInt64, newRecipient: Address?, updatedBy: Address)
88 access(all) event TreasuryForwarded(poolID: UInt64, amount: UFix64, recipient: Address)
89
90 access(all) event BonusLotteryWeightSet(poolID: UInt64, receiverID: UInt64, userAddress: Address, bonusWeight: UFix64, reason: String, setBy: Address, timestamp: UFix64)
91 access(all) event BonusLotteryWeightAdded(poolID: UInt64, receiverID: UInt64, userAddress: Address, additionalWeight: UFix64, newTotalBonus: UFix64, reason: String, addedBy: Address, timestamp: UFix64)
92 access(all) event BonusLotteryWeightRemoved(poolID: UInt64, receiverID: UInt64, userAddress: Address, previousBonus: UFix64, removedBy: Address, timestamp: UFix64)
93
94 access(all) event NFTPrizeDeposited(poolID: UInt64, nftID: UInt64, nftType: String, depositedBy: Address)
95 access(all) event NFTPrizeAwarded(poolID: UInt64, receiverID: UInt64, userAddress: Address, nftID: UInt64, nftType: String, round: UInt64)
96 access(all) event NFTPrizeStored(poolID: UInt64, receiverID: UInt64, userAddress: Address, nftID: UInt64, nftType: String, reason: String)
97 access(all) event NFTPrizeClaimed(poolID: UInt64, receiverID: UInt64, userAddress: Address, nftID: UInt64, nftType: String)
98 access(all) event NFTPrizeWithdrawn(poolID: UInt64, nftID: UInt64, nftType: String, withdrawnBy: Address)
99
100 access(all) event PoolEmergencyEnabled(poolID: UInt64, reason: String, enabledBy: Address, timestamp: UFix64)
101 access(all) event PoolEmergencyDisabled(poolID: UInt64, disabledBy: Address, timestamp: UFix64)
102 access(all) event PoolPartialModeEnabled(poolID: UInt64, reason: String, setBy: Address, timestamp: UFix64)
103 access(all) event EmergencyModeAutoTriggered(poolID: UInt64, reason: String, healthScore: UFix64, timestamp: UFix64)
104 access(all) event EmergencyModeAutoRecovered(poolID: UInt64, reason: String, healthScore: UFix64?, duration: UFix64?, timestamp: UFix64)
105 access(all) event EmergencyConfigUpdated(poolID: UInt64, updatedBy: Address)
106 access(all) event WithdrawalFailure(poolID: UInt64, receiverID: UInt64, userAddress: Address, amount: UFix64, consecutiveFailures: Int, yieldAvailable: UFix64)
107
108 access(all) event DirectFundingReceived(poolID: UInt64, destination: UInt8, destinationName: String, amount: UFix64, sponsor: Address, purpose: String, metadata: {String: String})
109
110 access(self) var pools: @{UInt64: Pool}
111 access(self) var nextPoolID: UInt64
112
113 access(all) enum PoolEmergencyState: UInt8 {
114 access(all) case Normal
115 access(all) case Paused
116 access(all) case EmergencyMode
117 access(all) case PartialMode
118 }
119
120 access(all) enum PoolFundingDestination: UInt8 {
121 access(all) case Savings
122 access(all) case Lottery
123 }
124
125 access(all) struct BonusWeightRecord {
126 access(all) let bonusWeight: UFix64
127 access(all) let reason: String
128 access(all) let addedAt: UFix64
129 access(all) let addedBy: Address
130
131 access(contract) init(bonusWeight: UFix64, reason: String, addedBy: Address) {
132 self.bonusWeight = bonusWeight
133 self.reason = reason
134 self.addedAt = getCurrentBlock().timestamp
135 self.addedBy = addedBy
136 }
137 }
138
139 access(all) struct EmergencyConfig {
140 access(all) let maxEmergencyDuration: UFix64?
141 access(all) let autoRecoveryEnabled: Bool
142 access(all) let minYieldSourceHealth: UFix64
143 access(all) let maxWithdrawFailures: Int
144 access(all) let partialModeDepositLimit: UFix64?
145 access(all) let minBalanceThreshold: UFix64
146 access(all) let minRecoveryHealth: UFix64
147
148 init(
149 maxEmergencyDuration: UFix64?,
150 autoRecoveryEnabled: Bool,
151 minYieldSourceHealth: UFix64,
152 maxWithdrawFailures: Int,
153 partialModeDepositLimit: UFix64?,
154 minBalanceThreshold: UFix64,
155 minRecoveryHealth: UFix64?
156 ) {
157 pre {
158 minYieldSourceHealth >= 0.0 && minYieldSourceHealth <= 1.0: "Health must be between 0.0 and 1.0"
159 maxWithdrawFailures > 0: "Must allow at least 1 withdrawal failure"
160 minBalanceThreshold >= 0.8 && minBalanceThreshold <= 1.0: "Balance threshold must be between 0.8 and 1.0"
161 (minRecoveryHealth ?? 0.5) >= 0.0 && (minRecoveryHealth ?? 0.5) <= 1.0: "minRecoveryHealth must be between 0.0 and 1.0"
162 }
163 self.maxEmergencyDuration = maxEmergencyDuration
164 self.autoRecoveryEnabled = autoRecoveryEnabled
165 self.minYieldSourceHealth = minYieldSourceHealth
166 self.maxWithdrawFailures = maxWithdrawFailures
167 self.partialModeDepositLimit = partialModeDepositLimit
168 self.minBalanceThreshold = minBalanceThreshold
169 self.minRecoveryHealth = minRecoveryHealth ?? 0.5
170 }
171 }
172
173 access(all) struct FundingPolicy {
174 access(all) let maxDirectLottery: UFix64?
175 access(all) let maxDirectSavings: UFix64?
176 access(all) var totalDirectLottery: UFix64
177 access(all) var totalDirectSavings: UFix64
178
179 init(maxDirectLottery: UFix64?, maxDirectSavings: UFix64?) {
180 self.maxDirectLottery = maxDirectLottery
181 self.maxDirectSavings = maxDirectSavings
182 self.totalDirectLottery = 0.0
183 self.totalDirectSavings = 0.0
184 }
185
186 access(contract) fun recordDirectFunding(destination: PoolFundingDestination, amount: UFix64) {
187 switch destination {
188 case PoolFundingDestination.Lottery:
189 self.totalDirectLottery = self.totalDirectLottery + amount
190 if let maxDirectLottery = self.maxDirectLottery {
191 assert(self.totalDirectLottery <= maxDirectLottery, message: "Direct lottery funding limit exceeded")
192 }
193 case PoolFundingDestination.Savings:
194 self.totalDirectSavings = self.totalDirectSavings + amount
195 if let maxDirectSavings = self.maxDirectSavings {
196 assert(self.totalDirectSavings <= maxDirectSavings, message: "Direct savings funding limit exceeded")
197 }
198 }
199 }
200 }
201
202 access(all) fun createDefaultEmergencyConfig(): EmergencyConfig {
203 return EmergencyConfig(
204 maxEmergencyDuration: 86400.0,
205 autoRecoveryEnabled: true,
206 minYieldSourceHealth: 0.5,
207 maxWithdrawFailures: 3,
208 partialModeDepositLimit: 100.0,
209 minBalanceThreshold: 0.95,
210 minRecoveryHealth: 0.5
211 )
212 }
213
214 access(all) fun createDefaultFundingPolicy(): FundingPolicy {
215 return FundingPolicy(
216 maxDirectLottery: nil,
217 maxDirectSavings: nil
218 )
219 }
220
221 access(all) resource Admin {
222 access(self) var metadata: {String: {String: AnyStruct}}
223
224 access(contract) init() {
225 self.metadata = {}
226 }
227
228 access(CriticalOps) fun updatePoolDistributionStrategy(
229 poolID: UInt64,
230 newStrategy: {DistributionStrategy},
231 updatedBy: Address
232 ) {
233 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
234 ?? panic("Pool does not exist")
235
236 let oldStrategyName = poolRef.getDistributionStrategyName()
237 poolRef.setDistributionStrategy(strategy: newStrategy)
238 let newStrategyName = newStrategy.getStrategyName()
239
240 emit DistributionStrategyUpdated(
241 poolID: poolID,
242 oldStrategy: oldStrategyName,
243 newStrategy: newStrategyName,
244 updatedBy: updatedBy
245 )
246 }
247
248 access(CriticalOps) fun updatePoolWinnerSelectionStrategy(
249 poolID: UInt64,
250 newStrategy: {WinnerSelectionStrategy},
251 updatedBy: Address
252 ) {
253 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
254 ?? panic("Pool does not exist")
255
256 let oldStrategyName = poolRef.getWinnerSelectionStrategyName()
257 poolRef.setWinnerSelectionStrategy(strategy: newStrategy)
258 let newStrategyName = newStrategy.getStrategyName()
259
260 emit WinnerSelectionStrategyUpdated(
261 poolID: poolID,
262 oldStrategy: oldStrategyName,
263 newStrategy: newStrategyName,
264 updatedBy: updatedBy
265 )
266 }
267
268 access(ConfigOps) fun updatePoolWinnerTracker(
269 poolID: UInt64,
270 newTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?,
271 updatedBy: Address
272 ) {
273 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
274 ?? panic("Pool does not exist")
275
276 let hasOldTracker = poolRef.hasWinnerTracker()
277 poolRef.setWinnerTrackerCap(cap: newTrackerCap)
278 let hasNewTracker = newTrackerCap != nil
279
280 emit WinnerTrackerUpdated(
281 poolID: poolID,
282 hasOldTracker: hasOldTracker,
283 hasNewTracker: hasNewTracker,
284 updatedBy: updatedBy
285 )
286 }
287
288 access(ConfigOps) fun updatePoolDrawInterval(
289 poolID: UInt64,
290 newInterval: UFix64,
291 updatedBy: Address
292 ) {
293 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
294 ?? panic("Pool does not exist")
295
296 let oldInterval = poolRef.getConfig().drawIntervalSeconds
297 poolRef.setDrawIntervalSeconds(interval: newInterval)
298
299 emit DrawIntervalUpdated(
300 poolID: poolID,
301 oldInterval: oldInterval,
302 newInterval: newInterval,
303 updatedBy: updatedBy
304 )
305 }
306
307 access(ConfigOps) fun updatePoolMinimumDeposit(
308 poolID: UInt64,
309 newMinimum: UFix64,
310 updatedBy: Address
311 ) {
312 pre {
313 newMinimum >= 0.0: "Minimum deposit cannot be negative"
314 }
315
316 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
317 ?? panic("Pool does not exist")
318
319 let oldMinimum = poolRef.getConfig().minimumDeposit
320 poolRef.setMinimumDeposit(minimum: newMinimum)
321
322 emit MinimumDepositUpdated(
323 poolID: poolID,
324 oldMinimum: oldMinimum,
325 newMinimum: newMinimum,
326 updatedBy: updatedBy
327 )
328 }
329
330 access(CriticalOps) fun enableEmergencyMode(poolID: UInt64, reason: String, enabledBy: Address) {
331 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
332 ?? panic("Pool does not exist")
333 poolRef.setEmergencyMode(reason: reason)
334 emit PoolEmergencyEnabled(poolID: poolID, reason: reason, enabledBy: enabledBy, timestamp: getCurrentBlock().timestamp)
335 }
336
337 access(CriticalOps) fun disableEmergencyMode(poolID: UInt64, disabledBy: Address) {
338 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
339 ?? panic("Pool does not exist")
340 poolRef.clearEmergencyMode()
341 emit PoolEmergencyDisabled(poolID: poolID, disabledBy: disabledBy, timestamp: getCurrentBlock().timestamp)
342 }
343
344 access(CriticalOps) fun setEmergencyPartialMode(poolID: UInt64, reason: String, setBy: Address) {
345 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
346 ?? panic("Pool does not exist")
347 poolRef.setPartialMode(reason: reason)
348 emit PoolPartialModeEnabled(poolID: poolID, reason: reason, setBy: setBy, timestamp: getCurrentBlock().timestamp)
349 }
350
351 access(CriticalOps) fun updateEmergencyConfig(poolID: UInt64, newConfig: EmergencyConfig, updatedBy: Address) {
352 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
353 ?? panic("Pool does not exist")
354 poolRef.setEmergencyConfig(config: newConfig)
355 emit EmergencyConfigUpdated(poolID: poolID, updatedBy: updatedBy)
356 }
357
358 access(CriticalOps) fun fundPoolDirect(
359 poolID: UInt64,
360 destination: PoolFundingDestination,
361 from: @{FungibleToken.Vault},
362 sponsor: Address,
363 purpose: String,
364 metadata: {String: String}?
365 ) {
366 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
367 ?? panic("Pool does not exist")
368 let amount = from.balance
369 poolRef.fundDirectInternal(destination: destination, from: <- from, sponsor: sponsor, purpose: purpose, metadata: metadata ?? {})
370
371 emit DirectFundingReceived(
372 poolID: poolID,
373 destination: destination.rawValue,
374 destinationName: self.getDestinationName(destination),
375 amount: amount,
376 sponsor: sponsor,
377 purpose: purpose,
378 metadata: metadata ?? {}
379 )
380 }
381
382 access(self) fun getDestinationName(_ destination: PoolFundingDestination): String {
383 switch destination {
384 case PoolFundingDestination.Savings: return "Savings"
385 case PoolFundingDestination.Lottery: return "Lottery"
386 default: return "Unknown"
387 }
388 }
389
390 access(CriticalOps) fun createPool(
391 config: PoolConfig,
392 emergencyConfig: EmergencyConfig?,
393 fundingPolicy: FundingPolicy?,
394 createdBy: Address
395 ): UInt64 {
396 let finalEmergencyConfig = emergencyConfig
397 ?? PrizeSavings.createDefaultEmergencyConfig()
398 let finalFundingPolicy = fundingPolicy
399 ?? PrizeSavings.createDefaultFundingPolicy()
400
401 let poolID = PrizeSavings.createPool(
402 config: config,
403 emergencyConfig: finalEmergencyConfig,
404 fundingPolicy: finalFundingPolicy
405 )
406
407 emit PoolCreatedByAdmin(
408 poolID: poolID,
409 assetType: config.assetType.identifier,
410 strategy: config.distributionStrategy.getStrategyName(),
411 createdBy: createdBy
412 )
413
414 return poolID
415 }
416
417 access(ConfigOps) fun processPoolRewards(poolID: UInt64) {
418 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
419 ?? panic("Pool does not exist")
420
421 poolRef.processRewards()
422 }
423
424 access(CriticalOps) fun setPoolState(poolID: UInt64, state: PoolEmergencyState, reason: String?, setBy: Address) {
425 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
426 ?? panic("Pool does not exist")
427
428 poolRef.setState(state: state, reason: reason)
429
430 switch state {
431 case PoolEmergencyState.Normal:
432 emit PoolUnpaused(poolID: poolID, unpausedBy: setBy)
433 case PoolEmergencyState.Paused:
434 emit PoolPaused(poolID: poolID, pausedBy: setBy, reason: reason ?? "Manual pause")
435 case PoolEmergencyState.EmergencyMode:
436 emit PoolEmergencyEnabled(poolID: poolID, reason: reason ?? "Emergency", enabledBy: setBy, timestamp: getCurrentBlock().timestamp)
437 case PoolEmergencyState.PartialMode:
438 emit PoolPartialModeEnabled(poolID: poolID, reason: reason ?? "Partial mode", setBy: setBy, timestamp: getCurrentBlock().timestamp)
439 }
440 }
441
442 /// Set the treasury recipient for automatic forwarding.
443 /// Once set, treasury funds are auto-forwarded during processRewards().
444 /// Pass nil to disable auto-forwarding (funds stored in distributor).
445 ///
446 /// SECURITY: Requires OwnerOnly entitlement - NEVER issue capabilities with this.
447 /// Only the account owner (via direct storage borrow with auth) can call this.
448 /// For multi-sig protection, store Admin in a multi-sig account.
449 access(OwnerOnly) fun setPoolTreasuryRecipient(
450 poolID: UInt64,
451 recipientCap: Capability<&{FungibleToken.Receiver}>?,
452 updatedBy: Address
453 ) {
454 pre {
455 recipientCap?.check() ?? true: "Treasury recipient capability must be valid"
456 }
457
458 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
459 ?? panic("Pool does not exist")
460
461 poolRef.setTreasuryRecipient(cap: recipientCap)
462
463 emit TreasuryRecipientUpdated(
464 poolID: poolID,
465 newRecipient: recipientCap?.address,
466 updatedBy: updatedBy
467 )
468 }
469
470 access(ConfigOps) fun setBonusLotteryWeight(
471 poolID: UInt64,
472 receiverID: UInt64,
473 bonusWeight: UFix64,
474 reason: String,
475 setBy: Address
476 ) {
477 pre {
478 bonusWeight >= 0.0: "Bonus weight cannot be negative"
479 }
480 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
481 ?? panic("Pool does not exist")
482
483 poolRef.setBonusWeight(receiverID: receiverID, bonusWeight: bonusWeight, reason: reason, setBy: setBy)
484 }
485
486 access(ConfigOps) fun addBonusLotteryWeight(
487 poolID: UInt64,
488 receiverID: UInt64,
489 additionalWeight: UFix64,
490 reason: String,
491 addedBy: Address
492 ) {
493 pre {
494 additionalWeight > 0.0: "Additional weight must be positive"
495 }
496 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
497 ?? panic("Pool does not exist")
498
499 poolRef.addBonusWeight(receiverID: receiverID, additionalWeight: additionalWeight, reason: reason, addedBy: addedBy)
500 }
501
502 access(ConfigOps) fun removeBonusLotteryWeight(
503 poolID: UInt64,
504 receiverID: UInt64,
505 removedBy: Address
506 ) {
507 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
508 ?? panic("Pool does not exist")
509
510 poolRef.removeBonusWeight(receiverID: receiverID, removedBy: removedBy)
511 }
512
513 access(ConfigOps) fun depositNFTPrize(
514 poolID: UInt64,
515 nft: @{NonFungibleToken.NFT},
516 depositedBy: Address
517 ) {
518 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
519 ?? panic("Pool does not exist")
520
521 let nftID = nft.uuid
522 let nftType = nft.getType().identifier
523
524 poolRef.depositNFTPrize(nft: <- nft)
525
526 emit NFTPrizeDeposited(
527 poolID: poolID,
528 nftID: nftID,
529 nftType: nftType,
530 depositedBy: depositedBy
531 )
532 }
533
534 access(ConfigOps) fun withdrawNFTPrize(
535 poolID: UInt64,
536 nftID: UInt64,
537 withdrawnBy: Address
538 ): @{NonFungibleToken.NFT} {
539 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
540 ?? panic("Pool does not exist")
541
542 let nft <- poolRef.withdrawNFTPrize(nftID: nftID)
543 let nftType = nft.getType().identifier
544
545 emit NFTPrizeWithdrawn(
546 poolID: poolID,
547 nftID: nftID,
548 nftType: nftType,
549 withdrawnBy: withdrawnBy
550 )
551
552 return <- nft
553 }
554
555 // Batch draw functions (for future scalability when user count grows)
556
557 access(CriticalOps) fun startPoolDrawSnapshot(poolID: UInt64) {
558 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
559 ?? panic("Pool does not exist")
560 poolRef.startDrawSnapshot()
561 }
562
563 access(CriticalOps) fun processPoolDrawBatch(poolID: UInt64, limit: Int) {
564 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
565 ?? panic("Pool does not exist")
566 poolRef.captureStakesBatch(limit: limit)
567 }
568
569 access(CriticalOps) fun finalizePoolDraw(poolID: UInt64) {
570 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
571 ?? panic("Pool does not exist")
572 poolRef.finalizeDrawStart()
573 }
574
575 }
576
577 access(all) let AdminStoragePath: StoragePath
578
579 access(all) struct DistributionPlan {
580 access(all) let savingsAmount: UFix64
581 access(all) let lotteryAmount: UFix64
582 access(all) let treasuryAmount: UFix64
583
584 init(savings: UFix64, lottery: UFix64, treasury: UFix64) {
585 self.savingsAmount = savings
586 self.lotteryAmount = lottery
587 self.treasuryAmount = treasury
588 }
589 }
590
591 access(all) struct interface DistributionStrategy {
592 access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan
593 access(all) view fun getStrategyName(): String
594 }
595
596 access(all) struct FixedPercentageStrategy: DistributionStrategy {
597 access(all) let savingsPercent: UFix64
598 access(all) let lotteryPercent: UFix64
599 access(all) let treasuryPercent: UFix64
600
601 /// Note: Percentages must sum to exactly 1.0 (strict equality).
602 /// Use values like 0.4, 0.4, 0.2 - not repeating decimals like 0.33333333.
603 /// If using thirds, use 0.33, 0.33, 0.34 to sum exactly to 1.0.
604 init(savings: UFix64, lottery: UFix64, treasury: UFix64) {
605 pre {
606 savings + lottery + treasury == 1.0: "Percentages must sum to exactly 1.0 (e.g., 0.4 + 0.4 + 0.2)"
607 savings >= 0.0 && lottery >= 0.0 && treasury >= 0.0: "Must be non-negative"
608 }
609 self.savingsPercent = savings
610 self.lotteryPercent = lottery
611 self.treasuryPercent = treasury
612 }
613
614 access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan {
615 return DistributionPlan(
616 savings: totalAmount * self.savingsPercent,
617 lottery: totalAmount * self.lotteryPercent,
618 treasury: totalAmount * self.treasuryPercent
619 )
620 }
621
622 access(all) view fun getStrategyName(): String {
623 return "Fixed: \(self.savingsPercent) savings, \(self.lotteryPercent) lottery, \(self.treasuryPercent) treasury"
624 }
625 }
626
627 /// ERC4626-style shares distributor with virtual offset protection against inflation attacks.
628 /// totalAssets = principal + accrued yield (should equal Pool.totalStaked)
629 access(all) resource SavingsDistributor {
630 access(self) var totalShares: UFix64
631 access(self) var totalAssets: UFix64
632 access(self) let userShares: {UInt64: UFix64}
633 access(all) var totalDistributed: UFix64
634 access(self) let vaultType: Type
635
636 // Time-weighted balance tracking for lottery fairness (balance-seconds)
637 // Uses current balance (shares × share_price) so yield counts toward lottery odds
638 access(self) let userCumulativeBalanceSeconds: {UInt64: UFix64}
639 access(self) let userLastUpdateTime: {UInt64: UFix64}
640 access(self) let userEpochID: {UInt64: UInt64}
641 access(self) var currentEpochID: UInt64
642 access(self) var epochStartTime: UFix64
643
644 init(vaultType: Type) {
645 self.totalShares = 0.0
646 self.totalAssets = 0.0
647 self.userShares = {}
648 self.totalDistributed = 0.0
649 self.vaultType = vaultType
650
651 self.userCumulativeBalanceSeconds = {}
652 self.userLastUpdateTime = {}
653 self.userEpochID = {}
654 self.currentEpochID = 1
655 self.epochStartTime = getCurrentBlock().timestamp
656 }
657
658 /// Accrue yield to the savings pool.
659 /// Returns the actual amount accrued to users (after virtual share dust is excluded).
660 /// The dust portion goes to virtual shares to prevent dilution attacks.
661 access(contract) fun accrueYield(amount: UFix64): UFix64 {
662 if amount == 0.0 || self.totalShares == 0.0 {
663 return 0.0
664 }
665
666 // Calculate how much goes to virtual shares (dust)
667 // dustPercent = VIRTUAL_SHARES / (totalShares + VIRTUAL_SHARES)
668 let effectiveShares = self.totalShares + PrizeSavings.VIRTUAL_SHARES
669 let dustAmount = amount * PrizeSavings.VIRTUAL_SHARES / effectiveShares
670 let actualSavings = amount - dustAmount
671
672 self.totalAssets = self.totalAssets + actualSavings
673 self.totalDistributed = self.totalDistributed + actualSavings
674
675 return actualSavings
676 }
677
678 /// Calculate elapsed balance-seconds since last update.
679 /// Uses current balance (shares × share_price) so yield is included in lottery weight.
680 access(all) view fun getElapsedBalanceSeconds(receiverID: UInt64): UFix64 {
681 let now = getCurrentBlock().timestamp
682 let userEpoch = self.userEpochID[receiverID] ?? 0
683 let currentBalance = self.getUserAssetValue(receiverID: receiverID)
684
685 let effectiveLastUpdate = userEpoch < self.currentEpochID
686 ? self.epochStartTime
687 : (self.userLastUpdateTime[receiverID] ?? self.epochStartTime)
688
689 let elapsed = now - effectiveLastUpdate
690 if elapsed <= 0.0 {
691 return 0.0
692 }
693 return currentBalance * elapsed
694 }
695
696 access(all) view fun getEffectiveAccumulated(receiverID: UInt64): UFix64 {
697 let userEpoch = self.userEpochID[receiverID] ?? 0
698 if userEpoch < self.currentEpochID {
699 return 0.0 // Would be reset on next accumulation
700 }
701 return self.userCumulativeBalanceSeconds[receiverID] ?? 0.0
702 }
703
704 access(contract) fun accumulateTime(receiverID: UInt64) {
705 let userEpoch = self.userEpochID[receiverID] ?? 0
706
707 if userEpoch < self.currentEpochID {
708 self.userCumulativeBalanceSeconds[receiverID] = 0.0
709 self.userEpochID[receiverID] = self.currentEpochID
710
711 let currentBalance = self.getUserAssetValue(receiverID: receiverID)
712 if currentBalance > 0.0 {
713 self.userLastUpdateTime[receiverID] = self.epochStartTime
714 } else {
715 self.userLastUpdateTime[receiverID] = getCurrentBlock().timestamp
716 return
717 }
718 }
719
720 let elapsed = self.getElapsedBalanceSeconds(receiverID: receiverID)
721 if elapsed > 0.0 {
722 let currentAccum = self.userCumulativeBalanceSeconds[receiverID] ?? 0.0
723 self.userCumulativeBalanceSeconds[receiverID] = currentAccum + elapsed
724 }
725 self.userLastUpdateTime[receiverID] = getCurrentBlock().timestamp
726 }
727
728 /// Returns total time-weighted balance (balance-seconds) for lottery weight calculation.
729 /// Includes both accumulated and elapsed balance-seconds.
730 access(all) view fun getTimeWeightedBalance(receiverID: UInt64): UFix64 {
731 return self.getEffectiveAccumulated(receiverID: receiverID)
732 + self.getElapsedBalanceSeconds(receiverID: receiverID)
733 }
734
735 /// Accumulate time and return the time-weighted balance (balance-seconds).
736 /// Used by lottery draw to get final lottery weight.
737 access(contract) fun updateAndGetTimeWeightedBalance(receiverID: UInt64): UFix64 {
738 self.accumulateTime(receiverID: receiverID)
739 return self.userCumulativeBalanceSeconds[receiverID] ?? 0.0
740 }
741
742 access(contract) fun startNewPeriod() {
743 self.currentEpochID = self.currentEpochID + 1
744 self.epochStartTime = getCurrentBlock().timestamp
745 }
746
747 access(all) view fun getCurrentEpochID(): UInt64 {
748 return self.currentEpochID
749 }
750
751 access(all) view fun getEpochStartTime(): UFix64 {
752 return self.epochStartTime
753 }
754
755 access(contract) fun deposit(receiverID: UInt64, amount: UFix64) {
756 if amount == 0.0 {
757 return
758 }
759
760 self.accumulateTime(receiverID: receiverID)
761
762 let sharesToMint = self.convertToShares(amount)
763 let currentShares = self.userShares[receiverID] ?? 0.0
764 self.userShares[receiverID] = currentShares + sharesToMint
765 self.totalShares = self.totalShares + sharesToMint
766 self.totalAssets = self.totalAssets + amount
767 }
768
769 access(contract) fun withdraw(receiverID: UInt64, amount: UFix64): UFix64 {
770 if amount == 0.0 {
771 return 0.0
772 }
773
774 self.accumulateTime(receiverID: receiverID)
775
776 let userShareBalance = self.userShares[receiverID] ?? 0.0
777 assert(userShareBalance > 0.0, message: "No shares to withdraw")
778 assert(self.totalShares > 0.0 && self.totalAssets > 0.0, message: "Invalid distributor state")
779
780 let currentAssetValue = self.convertToAssets(userShareBalance)
781 assert(amount <= currentAssetValue, message: "Insufficient balance")
782
783 let sharesToBurn = self.convertToShares(amount)
784
785 self.userShares[receiverID] = userShareBalance - sharesToBurn
786 self.totalShares = self.totalShares - sharesToBurn
787 self.totalAssets = self.totalAssets - amount
788
789 return amount
790 }
791
792 access(all) view fun convertToShares(_ assets: UFix64): UFix64 {
793 let effectiveShares = self.totalShares + PrizeSavings.VIRTUAL_SHARES
794 let effectiveAssets = self.totalAssets + PrizeSavings.VIRTUAL_ASSETS
795
796 // Calculate share price first to avoid overflow in assets * effectiveShares
797 // share_price = effectiveAssets / effectiveShares (close to 1.0)
798 // shares = assets / share_price
799 let sharePrice = effectiveAssets / effectiveShares
800 return assets / sharePrice
801 }
802
803 access(all) view fun convertToAssets(_ shares: UFix64): UFix64 {
804 let effectiveShares = self.totalShares + PrizeSavings.VIRTUAL_SHARES
805 let effectiveAssets = self.totalAssets + PrizeSavings.VIRTUAL_ASSETS
806
807 // Calculate share price first to avoid overflow in shares * effectiveAssets
808 // share_price = effectiveAssets / effectiveShares (close to 1.0)
809 // Then: assets = shares * share_price
810 let sharePrice = effectiveAssets / effectiveShares
811 return shares * sharePrice
812 }
813
814 access(all) view fun getUserAssetValue(receiverID: UInt64): UFix64 {
815 let userShareBalance = self.userShares[receiverID] ?? 0.0
816 return self.convertToAssets(userShareBalance)
817 }
818
819 access(all) view fun getTotalDistributed(): UFix64 {
820 return self.totalDistributed
821 }
822
823 access(all) view fun getTotalShares(): UFix64 {
824 return self.totalShares
825 }
826
827 access(all) view fun getTotalAssets(): UFix64 {
828 return self.totalAssets
829 }
830
831 access(all) view fun getUserShares(receiverID: UInt64): UFix64 {
832 return self.userShares[receiverID] ?? 0.0
833 }
834
835 access(all) view fun getUserAccumulatedRaw(receiverID: UInt64): UFix64 {
836 return self.userCumulativeBalanceSeconds[receiverID] ?? 0.0
837 }
838
839 access(all) view fun getUserLastUpdateTime(receiverID: UInt64): UFix64 {
840 return self.userLastUpdateTime[receiverID] ?? self.epochStartTime
841 }
842
843 access(all) view fun getUserEpochID(receiverID: UInt64): UInt64 {
844 return self.userEpochID[receiverID] ?? 0
845 }
846
847 /// Calculate projected balance-seconds at a specific time (no state change)
848 access(all) view fun calculateBalanceSecondsAtTime(receiverID: UInt64, targetTime: UFix64): UFix64 {
849 let userEpoch = self.userEpochID[receiverID] ?? 0
850 let balance = self.getUserAssetValue(receiverID: receiverID)
851
852 if userEpoch < self.currentEpochID {
853 if targetTime <= self.epochStartTime { return 0.0 }
854 return balance * (targetTime - self.epochStartTime)
855 }
856
857 let lastUpdate = self.userLastUpdateTime[receiverID] ?? self.epochStartTime
858 let accumulated = self.userCumulativeBalanceSeconds[receiverID] ?? 0.0
859
860 if targetTime <= lastUpdate {
861 let overdraft = lastUpdate - targetTime
862 let overdraftAmount = balance * overdraft
863 return accumulated >= overdraftAmount ? accumulated - overdraftAmount : 0.0
864 }
865
866 return accumulated + (balance * (targetTime - lastUpdate))
867 }
868 }
869
870 access(all) resource LotteryDistributor {
871 access(self) var prizeVault: @{FungibleToken.Vault}
872 access(self) var nftPrizeSavings: @{UInt64: {NonFungibleToken.NFT}}
873 access(self) var pendingNFTClaims: @{UInt64: [{NonFungibleToken.NFT}]}
874 access(self) var _prizeRound: UInt64
875 access(all) var totalPrizesDistributed: UFix64
876
877 access(all) view fun getPrizeRound(): UInt64 {
878 return self._prizeRound
879 }
880
881 access(contract) fun setPrizeRound(round: UInt64) {
882 self._prizeRound = round
883 }
884
885 init(vaultType: Type) {
886 self.prizeVault <- DeFiActionsUtils.getEmptyVault(vaultType)
887 self.nftPrizeSavings <- {}
888 self.pendingNFTClaims <- {}
889 self._prizeRound = 0
890 self.totalPrizesDistributed = 0.0
891 }
892
893 access(contract) fun fundPrizePool(vault: @{FungibleToken.Vault}) {
894 self.prizeVault.deposit(from: <- vault)
895 }
896
897 access(all) view fun getPrizePoolBalance(): UFix64 {
898 return self.prizeVault.balance
899 }
900
901 access(contract) fun awardPrize(receiverID: UInt64, amount: UFix64, yieldSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}?): @{FungibleToken.Vault} {
902 self.totalPrizesDistributed = self.totalPrizesDistributed + amount
903
904 var result <- DeFiActionsUtils.getEmptyVault(self.prizeVault.getType())
905
906 if let source = yieldSource {
907 let available = source.minimumAvailable()
908 if available >= amount {
909 result.deposit(from: <- source.withdrawAvailable(maxAmount: amount))
910 return <- result
911 } else if available > 0.0 {
912 result.deposit(from: <- source.withdrawAvailable(maxAmount: available))
913 }
914 }
915
916 if result.balance < amount {
917 let remaining = amount - result.balance
918 assert(self.prizeVault.balance >= remaining, message: "Insufficient prize pool")
919 result.deposit(from: <- self.prizeVault.withdraw(amount: remaining))
920 }
921
922 return <- result
923 }
924
925 access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
926 let nftID = nft.uuid
927 self.nftPrizeSavings[nftID] <-! nft
928 }
929
930 access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
931 if let nft <- self.nftPrizeSavings.remove(key: nftID) {
932 return <- nft
933 }
934 panic("NFT not found in prize vault")
935 }
936
937 access(contract) fun storePendingNFT(receiverID: UInt64, nft: @{NonFungibleToken.NFT}) {
938 if self.pendingNFTClaims[receiverID] == nil {
939 self.pendingNFTClaims[receiverID] <-! []
940 }
941 if let arrayRef = &self.pendingNFTClaims[receiverID] as auth(Mutate) &[{NonFungibleToken.NFT}]? {
942 arrayRef.append(<- nft)
943 } else {
944 destroy nft
945 panic("Failed to store NFT in pending claims")
946 }
947 }
948
949 access(all) view fun getPendingNFTCount(receiverID: UInt64): Int {
950 return self.pendingNFTClaims[receiverID]?.length ?? 0
951 }
952
953 access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
954 if let nfts = &self.pendingNFTClaims[receiverID] as &[{NonFungibleToken.NFT}]? {
955 var ids: [UInt64] = []
956 for nft in nfts {
957 ids.append(nft.uuid)
958 }
959 return ids
960 }
961 return []
962 }
963
964 access(all) view fun getAvailableNFTPrizeIDs(): [UInt64] {
965 return self.nftPrizeSavings.keys
966 }
967
968 access(all) view fun borrowNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
969 return &self.nftPrizeSavings[nftID]
970 }
971
972 access(contract) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
973 pre {
974 self.pendingNFTClaims[receiverID] != nil: "No pending NFTs for this receiver"
975 nftIndex < (self.pendingNFTClaims[receiverID]?.length ?? 0): "Invalid NFT index"
976 }
977 if let nftsRef = &self.pendingNFTClaims[receiverID] as auth(Remove) &[{NonFungibleToken.NFT}]? {
978 return <- nftsRef.remove(at: nftIndex)
979 }
980 panic("Failed to access pending NFT claims")
981 }
982 }
983
984
985 access(all) resource PrizeDrawReceipt {
986 access(all) let prizeAmount: UFix64
987 access(self) var request: @RandomConsumer.Request?
988 access(all) let timeWeightedStakes: {UInt64: UFix64}
989
990 init(prizeAmount: UFix64, request: @RandomConsumer.Request, timeWeightedStakes: {UInt64: UFix64}) {
991 self.prizeAmount = prizeAmount
992 self.request <- request
993 self.timeWeightedStakes = timeWeightedStakes
994 }
995
996 access(all) view fun getRequestBlock(): UInt64? {
997 return self.request?.block
998 }
999
1000 access(contract) fun popRequest(): @RandomConsumer.Request {
1001 let request <- self.request <- nil
1002 if let r <- request {
1003 return <- r
1004 }
1005 panic("No request to pop")
1006 }
1007
1008 access(all) view fun getTimeWeightedStakes(): {UInt64: UFix64} {
1009 return self.timeWeightedStakes
1010 }
1011 }
1012
1013 access(all) struct WinnerSelectionResult {
1014 access(all) let winners: [UInt64]
1015 access(all) let amounts: [UFix64]
1016 access(all) let nftIDs: [[UInt64]]
1017
1018 init(winners: [UInt64], amounts: [UFix64], nftIDs: [[UInt64]]) {
1019 pre {
1020 winners.length == amounts.length: "Winners and amounts must have same length"
1021 winners.length == nftIDs.length: "Winners and nftIDs must have same length"
1022 }
1023 self.winners = winners
1024 self.amounts = amounts
1025 self.nftIDs = nftIDs
1026 }
1027 }
1028
1029 /// Interface for winner selection strategies in lottery draws.
1030 /// Implementations receive weighted values (not raw deposits) to determine winner probability.
1031 access(all) struct interface WinnerSelectionStrategy {
1032 /// Select winners based on weighted random selection.
1033 /// - Parameter randomNumber: Source of randomness for selection
1034 /// - Parameter receiverWeights: Map of receiverID to their selection weight (balance-seconds + bonuses).
1035 /// NOTE: These are NOT raw deposit balances - they represent lottery ticket weights.
1036 /// - Parameter totalPrizeAmount: Total prize pool to distribute among winners
1037 access(all) fun selectWinners(
1038 randomNumber: UInt64,
1039 receiverWeights: {UInt64: UFix64},
1040 totalPrizeAmount: UFix64
1041 ): WinnerSelectionResult
1042 access(all) view fun getStrategyName(): String
1043 }
1044
1045 access(all) struct WeightedSingleWinner: WinnerSelectionStrategy {
1046 access(all) let RANDOM_SCALING_FACTOR: UInt64
1047 access(all) let RANDOM_SCALING_DIVISOR: UFix64
1048
1049 access(all) let nftIDs: [UInt64]
1050
1051 init(nftIDs: [UInt64]) {
1052 self.RANDOM_SCALING_FACTOR = 1_000_000_000
1053 self.RANDOM_SCALING_DIVISOR = 1_000_000_000.0
1054 self.nftIDs = nftIDs
1055 }
1056
1057 access(all) fun selectWinners(
1058 randomNumber: UInt64,
1059 receiverWeights: {UInt64: UFix64},
1060 totalPrizeAmount: UFix64
1061 ): WinnerSelectionResult {
1062 let receiverIDs = receiverWeights.keys
1063
1064 if receiverIDs.length == 0 {
1065 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1066 }
1067
1068 if receiverIDs.length == 1 {
1069 return WinnerSelectionResult(
1070 winners: [receiverIDs[0]],
1071 amounts: [totalPrizeAmount],
1072 nftIDs: [self.nftIDs]
1073 )
1074 }
1075
1076 var cumulativeSum: [UFix64] = []
1077 var runningTotal: UFix64 = 0.0
1078
1079 for receiverID in receiverIDs {
1080 let weight = receiverWeights[receiverID] ?? 0.0
1081 runningTotal = runningTotal + weight
1082 cumulativeSum.append(runningTotal)
1083 }
1084
1085 if runningTotal == 0.0 {
1086 return WinnerSelectionResult(
1087 winners: [receiverIDs[0]],
1088 amounts: [totalPrizeAmount],
1089 nftIDs: [self.nftIDs]
1090 )
1091 }
1092
1093 let scaledRandom = UFix64(randomNumber % self.RANDOM_SCALING_FACTOR) / self.RANDOM_SCALING_DIVISOR
1094 let randomValue = scaledRandom * runningTotal
1095
1096 var winnerIndex = 0
1097 for i, cumSum in cumulativeSum {
1098 if randomValue < cumSum {
1099 winnerIndex = i
1100 break
1101 }
1102 }
1103
1104 return WinnerSelectionResult(
1105 winners: [receiverIDs[winnerIndex]],
1106 amounts: [totalPrizeAmount],
1107 nftIDs: [self.nftIDs]
1108 )
1109 }
1110
1111 access(all) view fun getStrategyName(): String {
1112 return "Weighted Single Winner"
1113 }
1114 }
1115
1116 access(all) struct MultiWinnerSplit: WinnerSelectionStrategy {
1117 access(all) let RANDOM_SCALING_FACTOR: UInt64
1118 access(all) let RANDOM_SCALING_DIVISOR: UFix64
1119 access(all) let winnerCount: Int
1120 access(all) let prizeSplits: [UFix64]
1121 access(all) let nftIDsPerWinner: [[UInt64]]
1122
1123 init(winnerCount: Int, prizeSplits: [UFix64], nftIDs: [UInt64]) {
1124 pre {
1125 winnerCount > 0: "Must have at least one winner"
1126 prizeSplits.length == winnerCount: "Prize splits must match winner count"
1127 }
1128
1129 var total: UFix64 = 0.0
1130 for split in prizeSplits {
1131 assert(split >= 0.0 && split <= 1.0, message: "Each split must be between 0 and 1")
1132 total = total + split
1133 }
1134
1135 assert(total == 1.0, message: "Prize splits must sum to 1.0")
1136
1137 self.RANDOM_SCALING_FACTOR = 1_000_000_000
1138 self.RANDOM_SCALING_DIVISOR = 1_000_000_000.0
1139 self.winnerCount = winnerCount
1140 self.prizeSplits = prizeSplits
1141
1142 var nftArray: [[UInt64]] = []
1143 var nftIndex = 0
1144 for winnerIdx in InclusiveRange(0, winnerCount - 1) {
1145 if nftIndex < nftIDs.length {
1146 nftArray.append([nftIDs[nftIndex]])
1147 nftIndex = nftIndex + 1
1148 } else {
1149 nftArray.append([])
1150 }
1151 }
1152 self.nftIDsPerWinner = nftArray
1153 }
1154
1155 access(all) fun selectWinners(
1156 randomNumber: UInt64,
1157 receiverWeights: {UInt64: UFix64},
1158 totalPrizeAmount: UFix64
1159 ): WinnerSelectionResult {
1160 let receiverIDs = receiverWeights.keys
1161 let receiverCount = receiverIDs.length
1162
1163 if receiverCount == 0 {
1164 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1165 }
1166
1167 let actualWinnerCount = self.winnerCount < receiverCount ? self.winnerCount : receiverCount
1168
1169 if receiverCount == 1 {
1170 let nftIDsForFirst: [UInt64] = self.nftIDsPerWinner.length > 0 ? self.nftIDsPerWinner[0] : []
1171 return WinnerSelectionResult(
1172 winners: [receiverIDs[0]],
1173 amounts: [totalPrizeAmount],
1174 nftIDs: [nftIDsForFirst]
1175 )
1176 }
1177
1178 var cumulativeSum: [UFix64] = []
1179 var runningTotal: UFix64 = 0.0
1180 var weightsList: [UFix64] = []
1181
1182 for receiverID in receiverIDs {
1183 let weight = receiverWeights[receiverID] ?? 0.0
1184 weightsList.append(weight)
1185 runningTotal = runningTotal + weight
1186 cumulativeSum.append(runningTotal)
1187 }
1188
1189 if runningTotal == 0.0 {
1190 var uniformWinners: [UInt64] = []
1191 var uniformAmounts: [UFix64] = []
1192 var uniformNFTs: [[UInt64]] = []
1193 var calculatedSum: UFix64 = 0.0
1194
1195 for idx in InclusiveRange(0, actualWinnerCount - 1) {
1196 uniformWinners.append(receiverIDs[idx])
1197 if idx < actualWinnerCount - 1 {
1198 let amount = totalPrizeAmount * self.prizeSplits[idx]
1199 uniformAmounts.append(amount)
1200 calculatedSum = calculatedSum + amount
1201 }
1202 if idx < self.nftIDsPerWinner.length {
1203 uniformNFTs.append(self.nftIDsPerWinner[idx])
1204 } else {
1205 uniformNFTs.append([])
1206 }
1207 }
1208 uniformAmounts.append(totalPrizeAmount - calculatedSum)
1209
1210 return WinnerSelectionResult(
1211 winners: uniformWinners,
1212 amounts: uniformAmounts,
1213 nftIDs: uniformNFTs
1214 )
1215 }
1216
1217 var selectedWinners: [UInt64] = []
1218 var remainingWeights = weightsList
1219 var remainingIDs = receiverIDs
1220 var remainingCumSum = cumulativeSum
1221 var remainingTotal = runningTotal
1222
1223 var randomBytes = randomNumber.toBigEndianBytes()
1224 while randomBytes.length < 16 {
1225 randomBytes.appendAll(randomNumber.toBigEndianBytes())
1226 }
1227 var paddedBytes: [UInt8] = []
1228 for padIdx in InclusiveRange(0, 15) {
1229 paddedBytes.append(randomBytes[padIdx % randomBytes.length])
1230 }
1231
1232 let prg = Xorshift128plus.PRG(
1233 sourceOfRandomness: paddedBytes,
1234 salt: []
1235 )
1236
1237 var winnerIndex = 0
1238 while winnerIndex < actualWinnerCount && remainingIDs.length > 0 && remainingTotal > 0.0 {
1239 let rng = prg.nextUInt64()
1240 let scaledRandom = UFix64(rng % self.RANDOM_SCALING_FACTOR) / self.RANDOM_SCALING_DIVISOR
1241 let randomValue = scaledRandom * remainingTotal
1242
1243 var selectedIdx = 0
1244 for i, cumSum in remainingCumSum {
1245 if randomValue < cumSum {
1246 selectedIdx = i
1247 break
1248 }
1249 }
1250
1251 selectedWinners.append(remainingIDs[selectedIdx])
1252 var newRemainingIDs: [UInt64] = []
1253 var newRemainingWeights: [UFix64] = []
1254 var newCumSum: [UFix64] = []
1255 var newRunningTotal: UFix64 = 0.0
1256
1257 for idx in InclusiveRange(0, remainingIDs.length - 1) {
1258 if idx != selectedIdx {
1259 newRemainingIDs.append(remainingIDs[idx])
1260 newRemainingWeights.append(remainingWeights[idx])
1261 newRunningTotal = newRunningTotal + remainingWeights[idx]
1262 newCumSum.append(newRunningTotal)
1263 }
1264 }
1265
1266 remainingIDs = newRemainingIDs
1267 remainingWeights = newRemainingWeights
1268 remainingCumSum = newCumSum
1269 remainingTotal = newRunningTotal
1270 winnerIndex = winnerIndex + 1
1271 }
1272
1273 var prizeAmounts: [UFix64] = []
1274 var calculatedSum: UFix64 = 0.0
1275
1276 if selectedWinners.length > 1 {
1277 for idx in InclusiveRange(0, selectedWinners.length - 2) {
1278 let split = self.prizeSplits[idx]
1279 let amount = totalPrizeAmount * split
1280 prizeAmounts.append(amount)
1281 calculatedSum = calculatedSum + amount
1282 }
1283 }
1284
1285 let lastPrize = totalPrizeAmount - calculatedSum
1286 prizeAmounts.append(lastPrize)
1287
1288 if selectedWinners.length == self.winnerCount {
1289 let expectedLast = totalPrizeAmount * self.prizeSplits[selectedWinners.length - 1]
1290 let deviation = lastPrize > expectedLast ? lastPrize - expectedLast : expectedLast - lastPrize
1291 let maxDeviation = totalPrizeAmount * 0.01
1292 assert(deviation <= maxDeviation, message: "Last prize deviation too large - check splits")
1293 }
1294
1295 var nftIDsArray: [[UInt64]] = []
1296 for idx2 in InclusiveRange(0, selectedWinners.length - 1) {
1297 if idx2 < self.nftIDsPerWinner.length {
1298 nftIDsArray.append(self.nftIDsPerWinner[idx2])
1299 } else {
1300 nftIDsArray.append([])
1301 }
1302 }
1303
1304 return WinnerSelectionResult(
1305 winners: selectedWinners,
1306 amounts: prizeAmounts,
1307 nftIDs: nftIDsArray
1308 )
1309 }
1310
1311 access(all) view fun getStrategyName(): String {
1312 var name = "Multi-Winner (\(self.winnerCount) winners): "
1313 for idx in InclusiveRange(0, self.prizeSplits.length - 1) {
1314 if idx > 0 {
1315 name = name.concat(", ")
1316 }
1317 name = name.concat("\(self.prizeSplits[idx] * 100.0)%")
1318 }
1319 return name
1320 }
1321 }
1322
1323 access(all) struct PrizeTier {
1324 access(all) let prizeAmount: UFix64
1325 access(all) let winnerCount: Int
1326 access(all) let name: String
1327 access(all) let nftIDs: [UInt64]
1328
1329 init(amount: UFix64, count: Int, name: String, nftIDs: [UInt64]) {
1330 pre {
1331 amount > 0.0: "Prize amount must be positive"
1332 count > 0: "Winner count must be positive"
1333 nftIDs.length <= count: "Cannot have more NFTs than winners in tier"
1334 }
1335 self.prizeAmount = amount
1336 self.winnerCount = count
1337 self.name = name
1338 self.nftIDs = nftIDs
1339 }
1340 }
1341
1342 access(all) struct FixedPrizeTiers: WinnerSelectionStrategy {
1343 access(all) let RANDOM_SCALING_FACTOR: UInt64
1344 access(all) let RANDOM_SCALING_DIVISOR: UFix64
1345
1346 access(all) let tiers: [PrizeTier]
1347
1348 init(tiers: [PrizeTier]) {
1349 pre {
1350 tiers.length > 0: "Must have at least one prize tier"
1351 }
1352 self.RANDOM_SCALING_FACTOR = 1_000_000_000
1353 self.RANDOM_SCALING_DIVISOR = 1_000_000_000.0
1354 self.tiers = tiers
1355 }
1356
1357 access(all) fun selectWinners(
1358 randomNumber: UInt64,
1359 receiverWeights: {UInt64: UFix64},
1360 totalPrizeAmount: UFix64
1361 ): WinnerSelectionResult {
1362 let receiverIDs = receiverWeights.keys
1363 let receiverCount = receiverIDs.length
1364
1365 if receiverCount == 0 {
1366 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1367 }
1368
1369 var totalNeeded: UFix64 = 0.0
1370 var totalWinnersNeeded = 0
1371 for tier in self.tiers {
1372 totalNeeded = totalNeeded + (tier.prizeAmount * UFix64(tier.winnerCount))
1373 totalWinnersNeeded = totalWinnersNeeded + tier.winnerCount
1374 }
1375
1376 if totalPrizeAmount < totalNeeded {
1377 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1378 }
1379
1380 if totalWinnersNeeded > receiverCount {
1381 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1382 }
1383
1384 var cumulativeSum: [UFix64] = []
1385 var runningTotal: UFix64 = 0.0
1386
1387 for receiverID in receiverIDs {
1388 let weight = receiverWeights[receiverID] ?? 0.0
1389 runningTotal = runningTotal + weight
1390 cumulativeSum.append(runningTotal)
1391 }
1392
1393 var randomBytes = randomNumber.toBigEndianBytes()
1394 while randomBytes.length < 16 {
1395 randomBytes.appendAll(randomNumber.toBigEndianBytes())
1396 }
1397 var paddedBytes: [UInt8] = []
1398 for padIdx in InclusiveRange(0, 15) {
1399 paddedBytes.append(randomBytes[padIdx % randomBytes.length])
1400 }
1401
1402 let prg = Xorshift128plus.PRG(
1403 sourceOfRandomness: paddedBytes,
1404 salt: []
1405 )
1406
1407 var allWinners: [UInt64] = []
1408 var allPrizes: [UFix64] = []
1409 var allNFTIDs: [[UInt64]] = []
1410 var remainingIDs = receiverIDs
1411 var remainingCumSum = cumulativeSum
1412 var remainingTotal = runningTotal
1413
1414 for tier in self.tiers {
1415 var tierWinnerCount = 0
1416
1417 while tierWinnerCount < tier.winnerCount && remainingIDs.length > 0 && remainingTotal > 0.0 {
1418 let rng = prg.nextUInt64()
1419 let scaledRandom = UFix64(rng % self.RANDOM_SCALING_FACTOR) / self.RANDOM_SCALING_DIVISOR
1420 let randomValue = scaledRandom * remainingTotal
1421
1422 var selectedIdx = 0
1423 for i, cumSum in remainingCumSum {
1424 if randomValue < cumSum {
1425 selectedIdx = i
1426 break
1427 }
1428 }
1429
1430 let winnerID = remainingIDs[selectedIdx]
1431 allWinners.append(winnerID)
1432 allPrizes.append(tier.prizeAmount)
1433
1434 if tierWinnerCount < tier.nftIDs.length {
1435 allNFTIDs.append([tier.nftIDs[tierWinnerCount]])
1436 } else {
1437 allNFTIDs.append([])
1438 }
1439
1440 var newRemainingIDs: [UInt64] = []
1441 var newRemainingCumSum: [UFix64] = []
1442 var newRunningTotal: UFix64 = 0.0
1443
1444 for oldIdx in InclusiveRange(0, remainingIDs.length - 1) {
1445 if oldIdx != selectedIdx {
1446 newRemainingIDs.append(remainingIDs[oldIdx])
1447 let weight = receiverWeights[remainingIDs[oldIdx]] ?? 0.0
1448 newRunningTotal = newRunningTotal + weight
1449 newRemainingCumSum.append(newRunningTotal)
1450 }
1451 }
1452
1453 remainingIDs = newRemainingIDs
1454 remainingCumSum = newRemainingCumSum
1455 remainingTotal = newRunningTotal
1456 tierWinnerCount = tierWinnerCount + 1
1457 }
1458 }
1459
1460 return WinnerSelectionResult(
1461 winners: allWinners,
1462 amounts: allPrizes,
1463 nftIDs: allNFTIDs
1464 )
1465 }
1466
1467 access(all) view fun getStrategyName(): String {
1468 var name = "Fixed Prizes ("
1469 for idx in InclusiveRange(0, self.tiers.length - 1) {
1470 if idx > 0 {
1471 name = name.concat(", ")
1472 }
1473 let tier = self.tiers[idx]
1474 name = name.concat("\(tier.winnerCount)x \(tier.prizeAmount)")
1475 }
1476 return name.concat(")")
1477 }
1478 }
1479
1480 access(all) struct PoolConfig {
1481 access(all) let assetType: Type
1482 access(all) let yieldConnector: {DeFiActions.Sink, DeFiActions.Source}
1483 access(all) var minimumDeposit: UFix64
1484 access(all) var drawIntervalSeconds: UFix64
1485 access(all) var distributionStrategy: {DistributionStrategy}
1486 access(all) var winnerSelectionStrategy: {WinnerSelectionStrategy}
1487 access(all) var winnerTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?
1488
1489 init(
1490 assetType: Type,
1491 yieldConnector: {DeFiActions.Sink, DeFiActions.Source},
1492 minimumDeposit: UFix64,
1493 drawIntervalSeconds: UFix64,
1494 distributionStrategy: {DistributionStrategy},
1495 winnerSelectionStrategy: {WinnerSelectionStrategy},
1496 winnerTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?
1497 ) {
1498 self.assetType = assetType
1499 self.yieldConnector = yieldConnector
1500 self.minimumDeposit = minimumDeposit
1501 self.drawIntervalSeconds = drawIntervalSeconds
1502 self.distributionStrategy = distributionStrategy
1503 self.winnerSelectionStrategy = winnerSelectionStrategy
1504 self.winnerTrackerCap = winnerTrackerCap
1505 }
1506
1507 access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
1508 self.distributionStrategy = strategy
1509 }
1510
1511 access(contract) fun setWinnerSelectionStrategy(strategy: {WinnerSelectionStrategy}) {
1512 self.winnerSelectionStrategy = strategy
1513 }
1514
1515 access(contract) fun setWinnerTrackerCap(cap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?) {
1516 self.winnerTrackerCap = cap
1517 }
1518
1519 access(contract) fun setDrawIntervalSeconds(interval: UFix64) {
1520 pre {
1521 interval >= 1.0: "Draw interval must be at least 1 seconds"
1522 }
1523 self.drawIntervalSeconds = interval
1524 }
1525
1526 access(contract) fun setMinimumDeposit(minimum: UFix64) {
1527 pre {
1528 minimum >= 0.0: "Minimum deposit cannot be negative"
1529 }
1530 self.minimumDeposit = minimum
1531 }
1532 }
1533
1534 /// Batch draw state for breaking startDraw() into multiple transactions.
1535 /// Flow: startDrawSnapshot() → captureStakesBatch() (repeat) → finalizeDrawStart()
1536 access(all) struct BatchDrawState {
1537 access(all) let drawCutoffTime: UFix64
1538 access(all) let previousEpochStartTime: UFix64 // Store epoch start time before startNewPeriod()
1539 access(all) var capturedWeights: {UInt64: UFix64}
1540 access(all) var totalWeight: UFix64
1541 access(all) var processedCount: Int
1542 access(all) let snapshotEpochID: UInt64
1543
1544 init(cutoffTime: UFix64, epochStartTime: UFix64, epochID: UInt64) {
1545 self.drawCutoffTime = cutoffTime
1546 self.previousEpochStartTime = epochStartTime
1547 self.capturedWeights = {}
1548 self.totalWeight = 0.0
1549 self.processedCount = 0
1550 self.snapshotEpochID = epochID
1551 }
1552 }
1553
1554 /// Pool contains nested resources (SavingsDistributor, LotteryDistributor)
1555 /// that hold FungibleToken vaults and NFTs. In Cadence 1.0+, these are automatically
1556 /// destroyed when the Pool is destroyed. The destroy order is:
1557 /// 1. pendingDrawReceipt (RandomConsumer.Request)
1558 /// 2. randomConsumer
1559 /// 3. savingsDistributor (contains user share data)
1560 /// 4. lotteryDistributor (contains prizeVault, nftPrizeSavings, pendingNFTClaims)
1561 /// Treasury is auto-forwarded to configured recipient during processRewards().
1562 access(all) resource Pool {
1563 access(self) var config: PoolConfig
1564 access(self) var poolID: UInt64
1565
1566 access(self) var emergencyState: PoolEmergencyState
1567 access(self) var emergencyReason: String?
1568 access(self) var emergencyActivatedAt: UFix64?
1569 access(self) var emergencyConfig: EmergencyConfig
1570 access(self) var consecutiveWithdrawFailures: Int
1571
1572 access(self) var fundingPolicy: FundingPolicy
1573
1574 access(contract) fun setPoolID(id: UInt64) {
1575 self.poolID = id
1576 }
1577
1578 access(self) let receiverDeposits: {UInt64: UFix64}
1579 access(self) let receiverTotalEarnedPrizes: {UInt64: UFix64}
1580 access(self) let registeredReceivers: {UInt64: Bool}
1581 access(self) let receiverBonusWeights: {UInt64: BonusWeightRecord}
1582 access(self) let receiverAddresses: {UInt64: Address}
1583
1584 /// ACCOUNTING VARIABLES - Key relationships:
1585 ///
1586 /// totalDeposited: Sum of user deposits + auto-compounded lottery prizes (excludes savings interest)
1587 /// Updated on: deposit (+), prize awarded (+), withdraw (-)
1588 /// Note: This is the "no-loss guarantee" amount - users can always withdraw at least this much
1589 ///
1590 /// totalStaked: Amount tracked as being in the yield source
1591 /// Updated on: deposit (+), prize awarded (+), savings yield accrual (+), withdraw from yield source (-)
1592 /// Should equal: yieldSourceBalance (approximately)
1593 ///
1594 /// Invariant: totalStaked >= totalDeposited (difference is reinvested savings interest)
1595 ///
1596 /// Note: SavingsDistributor.totalAssets tracks what users own and should equal totalStaked
1597 access(all) var totalDeposited: UFix64
1598 access(all) var totalStaked: UFix64
1599 access(all) var lastDrawTimestamp: UFix64
1600 access(all) var pendingLotteryYield: UFix64 // Lottery funds still earning in yield source
1601 access(all) var totalTreasuryForwarded: UFix64 // Total treasury auto-forwarded to recipient
1602 access(self) var treasuryRecipientCap: Capability<&{FungibleToken.Receiver}>? // Auto-forward treasury to this address
1603 access(self) let savingsDistributor: @SavingsDistributor
1604 access(self) let lotteryDistributor: @LotteryDistributor
1605
1606 access(self) var pendingDrawReceipt: @PrizeDrawReceipt?
1607 access(self) let randomConsumer: @RandomConsumer.Consumer
1608
1609 /// Batch draw state. When set, deposits/withdrawals are locked.
1610 access(self) var batchDrawState: BatchDrawState?
1611
1612 init(
1613 config: PoolConfig,
1614 emergencyConfig: EmergencyConfig?,
1615 fundingPolicy: FundingPolicy?
1616 ) {
1617 self.config = config
1618 self.poolID = 0
1619
1620 self.emergencyState = PoolEmergencyState.Normal
1621 self.emergencyReason = nil
1622 self.emergencyActivatedAt = nil
1623 self.emergencyConfig = emergencyConfig ?? PrizeSavings.createDefaultEmergencyConfig()
1624 self.consecutiveWithdrawFailures = 0
1625
1626 self.fundingPolicy = fundingPolicy ?? PrizeSavings.createDefaultFundingPolicy()
1627
1628 self.receiverDeposits = {}
1629 self.receiverTotalEarnedPrizes = {}
1630 self.registeredReceivers = {}
1631 self.receiverBonusWeights = {}
1632 self.receiverAddresses = {}
1633 self.totalDeposited = 0.0
1634 self.totalStaked = 0.0
1635 self.lastDrawTimestamp = 0.0
1636 self.pendingLotteryYield = 0.0
1637 self.totalTreasuryForwarded = 0.0
1638 self.treasuryRecipientCap = nil
1639
1640 self.savingsDistributor <- create SavingsDistributor(vaultType: config.assetType)
1641 self.lotteryDistributor <- create LotteryDistributor(vaultType: config.assetType)
1642
1643 self.pendingDrawReceipt <- nil
1644 self.randomConsumer <- RandomConsumer.createConsumer()
1645 self.batchDrawState = nil
1646 }
1647
1648 /// Register a receiver ID with owner address. Only callable within contract (by PoolPositionCollection).
1649 access(contract) fun registerReceiver(receiverID: UInt64, ownerAddress: Address) {
1650 pre {
1651 self.registeredReceivers[receiverID] == nil: "Receiver already registered"
1652 }
1653 self.registeredReceivers[receiverID] = true
1654 self.receiverAddresses[receiverID] = ownerAddress
1655 }
1656
1657 /// Get the owner address for a receiver ID.
1658 access(all) view fun getReceiverAddress(receiverID: UInt64): Address? {
1659 return self.receiverAddresses[receiverID]
1660 }
1661
1662
1663 access(all) view fun getEmergencyState(): PoolEmergencyState {
1664 return self.emergencyState
1665 }
1666
1667 access(all) view fun getEmergencyConfig(): EmergencyConfig {
1668 return self.emergencyConfig
1669 }
1670
1671 access(contract) fun setState(state: PoolEmergencyState, reason: String?) {
1672 self.emergencyState = state
1673 if state == PoolEmergencyState.Normal {
1674 self.emergencyReason = nil
1675 self.emergencyActivatedAt = nil
1676 self.consecutiveWithdrawFailures = 0
1677 } else {
1678 self.emergencyReason = reason
1679 self.emergencyActivatedAt = getCurrentBlock().timestamp
1680 }
1681 }
1682
1683 access(contract) fun setEmergencyMode(reason: String) {
1684 self.emergencyState = PoolEmergencyState.EmergencyMode
1685 self.emergencyReason = reason
1686 self.emergencyActivatedAt = getCurrentBlock().timestamp
1687 }
1688
1689 access(contract) fun setPartialMode(reason: String) {
1690 self.emergencyState = PoolEmergencyState.PartialMode
1691 self.emergencyReason = reason
1692 self.emergencyActivatedAt = getCurrentBlock().timestamp
1693 }
1694
1695 access(contract) fun clearEmergencyMode() {
1696 self.emergencyState = PoolEmergencyState.Normal
1697 self.emergencyReason = nil
1698 self.emergencyActivatedAt = nil
1699 self.consecutiveWithdrawFailures = 0
1700 }
1701
1702 access(contract) fun setEmergencyConfig(config: EmergencyConfig) {
1703 self.emergencyConfig = config
1704 }
1705
1706 access(all) view fun isEmergencyMode(): Bool {
1707 return self.emergencyState == PoolEmergencyState.EmergencyMode
1708 }
1709
1710 access(all) view fun isPartialMode(): Bool {
1711 return self.emergencyState == PoolEmergencyState.PartialMode
1712 }
1713
1714 access(all) fun getEmergencyInfo(): {String: AnyStruct}? {
1715 if self.emergencyState != PoolEmergencyState.Normal {
1716 let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
1717 let health = self.checkYieldSourceHealth()
1718 return {
1719 "state": self.emergencyState.rawValue,
1720 "reason": self.emergencyReason ?? "Unknown",
1721 "activatedAt": self.emergencyActivatedAt ?? 0.0,
1722 "durationSeconds": duration,
1723 "yieldSourceHealth": health,
1724 "canAutoRecover": self.emergencyConfig.autoRecoveryEnabled,
1725 "maxDuration": self.emergencyConfig.maxEmergencyDuration ?? 0.0 // 0.0 = no max
1726 }
1727 }
1728 return nil
1729 }
1730
1731 access(contract) fun checkYieldSourceHealth(): UFix64 {
1732 let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
1733 let balance = yieldSource.minimumAvailable()
1734 let threshold = self.getEmergencyConfig().minBalanceThreshold
1735 let balanceHealthy = balance >= self.totalStaked * threshold
1736 let withdrawSuccessRate = self.consecutiveWithdrawFailures == 0 ? 1.0 :
1737 (1.0 / UFix64(self.consecutiveWithdrawFailures + 1))
1738
1739 var health: UFix64 = 0.0
1740 if balanceHealthy { health = health + 0.5 }
1741 health = health + (withdrawSuccessRate * 0.5)
1742 return health
1743 }
1744
1745 access(contract) fun checkAndAutoTriggerEmergency(): Bool {
1746 if self.emergencyState != PoolEmergencyState.Normal {
1747 return false
1748 }
1749
1750 let health = self.checkYieldSourceHealth()
1751 if health < self.emergencyConfig.minYieldSourceHealth {
1752 self.setEmergencyMode(reason: "Auto-triggered: Yield source health below threshold (\(health))")
1753 emit EmergencyModeAutoTriggered(
1754 poolID: self.poolID,
1755 reason: "Low yield source health",
1756 healthScore: health,
1757 timestamp: getCurrentBlock().timestamp
1758 )
1759 return true
1760 }
1761
1762 if self.consecutiveWithdrawFailures >= self.emergencyConfig.maxWithdrawFailures {
1763 self.setEmergencyMode(reason: "Auto-triggered: Multiple consecutive withdrawal failures")
1764 emit EmergencyModeAutoTriggered(
1765 poolID: self.poolID,
1766 reason: "Withdrawal failures",
1767 healthScore: health,
1768 timestamp: getCurrentBlock().timestamp)
1769 return true
1770 }
1771
1772 return false
1773 }
1774
1775 access(contract) fun checkAndAutoRecover(): Bool {
1776 if self.emergencyState != PoolEmergencyState.EmergencyMode {
1777 return false
1778 }
1779
1780 if !self.emergencyConfig.autoRecoveryEnabled {
1781 return false
1782 }
1783
1784 let health = self.checkYieldSourceHealth()
1785
1786 // Health-based recovery: yield source is healthy
1787 if health >= 0.9 {
1788 self.clearEmergencyMode()
1789 emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Yield source recovered", healthScore: health, duration: nil, timestamp: getCurrentBlock().timestamp)
1790 return true
1791 }
1792
1793 // Time-based recovery: only if health is not critically low
1794 let minRecoveryHealth = self.emergencyConfig.minRecoveryHealth
1795 if let maxDuration = self.emergencyConfig.maxEmergencyDuration {
1796 let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
1797 if duration > maxDuration && health >= minRecoveryHealth {
1798 self.clearEmergencyMode()
1799 emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Max duration exceeded", healthScore: health, duration: duration, timestamp: getCurrentBlock().timestamp)
1800 return true
1801 }
1802 }
1803
1804 return false
1805 }
1806
1807 access(contract) fun fundDirectInternal(
1808 destination: PoolFundingDestination,
1809 from: @{FungibleToken.Vault},
1810 sponsor: Address,
1811 purpose: String,
1812 metadata: {String: String}
1813 ) {
1814 pre {
1815 self.emergencyState == PoolEmergencyState.Normal: "Direct funding only in normal state"
1816 from.getType() == self.config.assetType: "Invalid vault type"
1817 }
1818
1819 let amount = from.balance
1820 var policy = self.fundingPolicy
1821 policy.recordDirectFunding(destination: destination, amount: amount)
1822 self.fundingPolicy = policy
1823
1824 switch destination {
1825 case PoolFundingDestination.Lottery:
1826 self.lotteryDistributor.fundPrizePool(vault: <- from)
1827 case PoolFundingDestination.Savings:
1828 // Prevent orphaned funds: savings distribution requires depositors
1829 assert(
1830 self.savingsDistributor.getTotalShares() > 0.0,
1831 message: "Cannot fund savings with no depositors - funds would be orphaned"
1832 )
1833 self.config.yieldConnector.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
1834 destroy from
1835 let actualSavings = self.savingsDistributor.accrueYield(amount: amount)
1836 let dustAmount = amount - actualSavings
1837 self.totalStaked = self.totalStaked + actualSavings
1838 emit SavingsYieldAccrued(poolID: self.poolID, amount: actualSavings)
1839
1840 // Route dust to treasury if recipient configured
1841 if dustAmount > 0.0 {
1842 emit SavingsRoundingDustToTreasury(poolID: self.poolID, amount: dustAmount)
1843 if let cap = self.treasuryRecipientCap {
1844 if let recipientRef = cap.borrow() {
1845 let dustVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: dustAmount)
1846 if dustVault.balance > 0.0 {
1847 recipientRef.deposit(from: <- dustVault)
1848 self.totalTreasuryForwarded = self.totalTreasuryForwarded + dustAmount
1849 emit TreasuryForwarded(poolID: self.poolID, amount: dustAmount, recipient: cap.address)
1850 } else {
1851 destroy dustVault
1852 }
1853 }
1854 }
1855 }
1856 default:
1857 panic("Unsupported funding destination")
1858 }
1859 }
1860
1861 access(all) view fun getFundingStats(): {String: UFix64} {
1862 return {
1863 "totalDirectLottery": self.fundingPolicy.totalDirectLottery,
1864 "totalDirectSavings": self.fundingPolicy.totalDirectSavings,
1865 "maxDirectLottery": self.fundingPolicy.maxDirectLottery ?? 0.0,
1866 "maxDirectSavings": self.fundingPolicy.maxDirectSavings ?? 0.0
1867 }
1868 }
1869
1870 /// Deposit funds for a receiver. Only callable within contract (by PoolPositionCollection).
1871 access(contract) fun deposit(from: @{FungibleToken.Vault}, receiverID: UInt64) {
1872 pre {
1873 from.balance > 0.0: "Deposit amount must be positive"
1874 from.getType() == self.config.assetType: "Invalid vault type"
1875 self.registeredReceivers[receiverID] != nil: "Receiver not registered"
1876 }
1877
1878 // TODO: Future batch draw support - add check here:
1879 // assert(self.batchDrawState == nil, message: "Deposits locked during batch draw processing")
1880
1881 switch self.emergencyState {
1882 case PoolEmergencyState.Normal:
1883 assert(from.balance >= self.config.minimumDeposit, message: "Below minimum deposit of \(self.config.minimumDeposit)")
1884 case PoolEmergencyState.PartialMode:
1885 let depositLimit = self.emergencyConfig.partialModeDepositLimit ?? 0.0
1886 assert(depositLimit > 0.0, message: "Partial mode deposit limit not configured")
1887 assert(from.balance <= depositLimit, message: "Deposit exceeds partial mode limit of \(depositLimit)")
1888 case PoolEmergencyState.EmergencyMode:
1889 panic("Deposits disabled in emergency mode. Withdrawals only.")
1890 case PoolEmergencyState.Paused:
1891 panic("Pool is paused. No operations allowed.")
1892 }
1893
1894 if self.getAvailableYieldRewards() > 0.0 {
1895 self.processRewards()
1896 }
1897
1898 let amount = from.balance
1899 self.savingsDistributor.deposit(receiverID: receiverID, amount: amount)
1900 let currentPrincipal = self.receiverDeposits[receiverID] ?? 0.0
1901 self.receiverDeposits[receiverID] = currentPrincipal + amount
1902 self.totalDeposited = self.totalDeposited + amount
1903 self.totalStaked = self.totalStaked + amount
1904 self.config.yieldConnector.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
1905 destroy from
1906 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
1907 emit Deposited(poolID: self.poolID, receiverID: receiverID, userAddress: userAddress, amount: amount)
1908 }
1909
1910 /// Withdraw funds for a receiver. Only callable within contract (by PoolPositionCollection).
1911 access(contract) fun withdraw(amount: UFix64, receiverID: UInt64): @{FungibleToken.Vault} {
1912 pre {
1913 amount > 0.0: "Withdrawal amount must be positive"
1914 self.registeredReceivers[receiverID] != nil: "Receiver not registered"
1915 }
1916
1917 // TODO: Future batch draw support - add check here:
1918 // assert(self.batchDrawState == nil, message: "Withdrawals locked during batch draw processing")
1919
1920 assert(self.emergencyState != PoolEmergencyState.Paused, message: "Pool is paused - no operations allowed")
1921
1922 if self.emergencyState == PoolEmergencyState.EmergencyMode {
1923 let _ = self.checkAndAutoRecover()
1924 }
1925
1926 if self.emergencyState == PoolEmergencyState.Normal && self.getAvailableYieldRewards() > 0.0 {
1927 self.processRewards()
1928 }
1929
1930 let totalBalance = self.savingsDistributor.getUserAssetValue(receiverID: receiverID)
1931 assert(totalBalance >= amount, message: "Insufficient balance. You have \(totalBalance) but trying to withdraw \(amount)")
1932
1933 let yieldAvailable = self.config.yieldConnector.minimumAvailable()
1934
1935 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
1936
1937 if yieldAvailable < amount {
1938 let newFailureCount = self.consecutiveWithdrawFailures
1939 + (self.emergencyState == PoolEmergencyState.Normal ? 1 : 0)
1940
1941 emit WithdrawalFailure(
1942 poolID: self.poolID,
1943 receiverID: receiverID,
1944 userAddress: userAddress,
1945 amount: amount,
1946 consecutiveFailures: newFailureCount,
1947 yieldAvailable: yieldAvailable
1948 )
1949
1950 if self.emergencyState == PoolEmergencyState.Normal {
1951 self.consecutiveWithdrawFailures = newFailureCount
1952 let _ = self.checkAndAutoTriggerEmergency()
1953 }
1954
1955 emit Withdrawn(poolID: self.poolID, receiverID: receiverID, userAddress: userAddress, requestedAmount: amount, actualAmount: 0.0)
1956 return <- DeFiActionsUtils.getEmptyVault(self.config.assetType)
1957 }
1958
1959 let withdrawn <- self.config.yieldConnector.withdrawAvailable(maxAmount: amount)
1960 let actualWithdrawn = withdrawn.balance
1961
1962 if actualWithdrawn == 0.0 {
1963 let newFailureCount = self.consecutiveWithdrawFailures
1964 + (self.emergencyState == PoolEmergencyState.Normal ? 1 : 0)
1965
1966 emit WithdrawalFailure(
1967 poolID: self.poolID,
1968 receiverID: receiverID,
1969 userAddress: userAddress,
1970 amount: amount,
1971 consecutiveFailures: newFailureCount,
1972 yieldAvailable: yieldAvailable
1973 )
1974
1975 if self.emergencyState == PoolEmergencyState.Normal {
1976 self.consecutiveWithdrawFailures = newFailureCount
1977 let _ = self.checkAndAutoTriggerEmergency()
1978 }
1979
1980 emit Withdrawn(poolID: self.poolID, receiverID: receiverID, userAddress: userAddress, requestedAmount: amount, actualAmount: 0.0)
1981 return <- withdrawn
1982 }
1983
1984 if self.emergencyState == PoolEmergencyState.Normal {
1985 self.consecutiveWithdrawFailures = 0
1986 }
1987
1988 let _ = self.savingsDistributor.withdraw(receiverID: receiverID, amount: actualWithdrawn)
1989
1990 let currentPrincipal = self.receiverDeposits[receiverID] ?? 0.0
1991 let interestEarned: UFix64 = totalBalance > currentPrincipal ? totalBalance - currentPrincipal : 0.0
1992 let principalWithdrawn: UFix64 = actualWithdrawn > interestEarned ? actualWithdrawn - interestEarned : 0.0
1993
1994 if principalWithdrawn > 0.0 {
1995 self.receiverDeposits[receiverID] = currentPrincipal - principalWithdrawn
1996 self.totalDeposited = self.totalDeposited - principalWithdrawn
1997 }
1998
1999 self.totalStaked = self.totalStaked - actualWithdrawn
2000
2001 emit Withdrawn(poolID: self.poolID, receiverID: receiverID, userAddress: userAddress, requestedAmount: amount, actualAmount: actualWithdrawn)
2002 return <- withdrawn
2003 }
2004
2005 access(contract) fun processRewards() {
2006 let yieldBalance = self.config.yieldConnector.minimumAvailable()
2007 let allocatedFunds = self.totalStaked + self.pendingLotteryYield
2008 let availableYield: UFix64 = yieldBalance > allocatedFunds ? yieldBalance - allocatedFunds : 0.0
2009
2010 if availableYield == 0.0 {
2011 return
2012 }
2013
2014 let plan = self.config.distributionStrategy.calculateDistribution(totalAmount: availableYield)
2015
2016 var savingsDust: UFix64 = 0.0
2017
2018 if plan.savingsAmount > 0.0 {
2019 let actualSavings = self.savingsDistributor.accrueYield(amount: plan.savingsAmount)
2020 savingsDust = plan.savingsAmount - actualSavings
2021 self.totalStaked = self.totalStaked + actualSavings
2022 emit SavingsYieldAccrued(poolID: self.poolID, amount: actualSavings)
2023
2024 if savingsDust > 0.0 {
2025 emit SavingsRoundingDustToTreasury(poolID: self.poolID, amount: savingsDust)
2026 }
2027 }
2028
2029 if plan.lotteryAmount > 0.0 {
2030 self.pendingLotteryYield = self.pendingLotteryYield + plan.lotteryAmount
2031 emit LotteryPrizePoolFunded(
2032 poolID: self.poolID,
2033 amount: plan.lotteryAmount,
2034 source: "yield_pending"
2035 )
2036 }
2037
2038 // Combine planned treasury amount with savings dust
2039 let totalTreasuryAmount = plan.treasuryAmount + savingsDust
2040
2041 if totalTreasuryAmount > 0.00001 {
2042 if let cap = self.treasuryRecipientCap {
2043 if let recipientRef = cap.borrow() {
2044 let treasuryVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: totalTreasuryAmount)
2045 let actualAmount = treasuryVault.balance
2046 if actualAmount > 0.0 {
2047 recipientRef.deposit(from: <- treasuryVault)
2048 self.totalTreasuryForwarded = self.totalTreasuryForwarded + actualAmount
2049 emit TreasuryForwarded(
2050 poolID: self.poolID,
2051 amount: actualAmount,
2052 recipient: cap.address
2053 )
2054 } else {
2055 destroy treasuryVault
2056 }
2057 }
2058 }
2059 }
2060
2061 emit RewardsProcessed(
2062 poolID: self.poolID,
2063 totalAmount: availableYield,
2064 savingsAmount: plan.savingsAmount - savingsDust,
2065 lotteryAmount: plan.lotteryAmount
2066 )
2067 }
2068
2069 /// Start a lottery draw using balance-seconds (TWAB) for fair weighting.
2070 /// Each user's lottery weight = accumulated (balance × time) during the epoch.
2071 access(all) fun startDraw() {
2072 pre {
2073 self.emergencyState == PoolEmergencyState.Normal: "Draws disabled - pool state: \(self.emergencyState.rawValue)"
2074 self.pendingDrawReceipt == nil: "Draw already in progress"
2075 }
2076
2077 assert(self.canDrawNow(), message: "Not enough blocks since last draw")
2078
2079 if self.checkAndAutoTriggerEmergency() {
2080 panic("Emergency mode auto-triggered - cannot start draw")
2081 }
2082
2083 let timeWeightedStakes: {UInt64: UFix64} = {}
2084 for receiverID in self.registeredReceivers.keys {
2085 let twabStake = self.savingsDistributor.updateAndGetTimeWeightedBalance(receiverID: receiverID)
2086
2087 // Scale bonus weights by epoch duration
2088 let bonusWeight = self.getBonusWeight(receiverID: receiverID)
2089 let epochDuration = getCurrentBlock().timestamp - self.savingsDistributor.getEpochStartTime()
2090 let scaledBonus = bonusWeight * epochDuration
2091
2092 let totalStake = twabStake + scaledBonus
2093 if totalStake > 0.0 {
2094 timeWeightedStakes[receiverID] = totalStake
2095 }
2096 }
2097
2098 self.savingsDistributor.startNewPeriod()
2099 emit NewEpochStarted(
2100 poolID: self.poolID,
2101 epochID: self.savingsDistributor.getCurrentEpochID(),
2102 startTime: self.savingsDistributor.getEpochStartTime()
2103 )
2104
2105 // Materialize pending lottery funds from yield source
2106 if self.pendingLotteryYield > 0.0 {
2107 let lotteryVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: self.pendingLotteryYield)
2108 let actualWithdrawn = lotteryVault.balance
2109 self.lotteryDistributor.fundPrizePool(vault: <- lotteryVault)
2110 self.pendingLotteryYield = self.pendingLotteryYield - actualWithdrawn
2111 }
2112
2113 let prizeAmount = self.lotteryDistributor.getPrizePoolBalance()
2114 assert(prizeAmount > 0.0, message: "No prize pool funds")
2115
2116 let randomRequest <- self.randomConsumer.requestRandomness()
2117 let receipt <- create PrizeDrawReceipt(
2118 prizeAmount: prizeAmount,
2119 request: <- randomRequest,
2120 timeWeightedStakes: timeWeightedStakes
2121 )
2122 emit PrizeDrawCommitted(
2123 poolID: self.poolID,
2124 prizeAmount: prizeAmount,
2125 commitBlock: receipt.getRequestBlock() ?? 0
2126 )
2127
2128 self.pendingDrawReceipt <-! receipt
2129 self.lastDrawTimestamp = getCurrentBlock().timestamp
2130 }
2131
2132 // Batch draw: breaks startDraw() into multiple transactions for scalability
2133 // Flow: startDrawSnapshot() → captureStakesBatch() (repeat) → finalizeDrawStart()
2134
2135 /// Step 1: Lock the pool and take time snapshot (not yet implemented)
2136 access(CriticalOps) fun startDrawSnapshot() {
2137 pre {
2138 self.emergencyState == PoolEmergencyState.Normal: "Draws disabled"
2139 self.batchDrawState == nil: "Draw already in progress"
2140 self.pendingDrawReceipt == nil: "Receipt exists"
2141 }
2142 panic("Batch draw not yet implemented")
2143 }
2144
2145 /// Step 2: Calculate stakes for a batch of users (not yet implemented)
2146 access(CriticalOps) fun captureStakesBatch(limit: Int) {
2147 pre {
2148 self.batchDrawState != nil: "No batch draw active"
2149 }
2150 panic("Batch draw not yet implemented")
2151 }
2152
2153 /// Step 3: Request randomness after all batches processed (not yet implemented)
2154 access(CriticalOps) fun finalizeDrawStart() {
2155 pre {
2156 self.batchDrawState != nil: "No batch draw active"
2157 (self.batchDrawState?.processedCount ?? 0) >= self.registeredReceivers.keys.length: "Batch processing not complete"
2158 }
2159 panic("Batch draw not yet implemented")
2160 }
2161
2162 access(all) fun completeDraw() {
2163 pre {
2164 self.pendingDrawReceipt != nil: "No draw in progress"
2165 }
2166
2167 let receipt <- self.pendingDrawReceipt <- nil
2168 let unwrappedReceipt <- receipt!
2169 let totalPrizeAmount = unwrappedReceipt.prizeAmount
2170
2171 let timeWeightedStakes = unwrappedReceipt.getTimeWeightedStakes()
2172
2173 let request <- unwrappedReceipt.popRequest()
2174 let randomNumber = self.randomConsumer.fulfillRandomRequest(<- request)
2175 destroy unwrappedReceipt
2176
2177 let selectionResult = self.config.winnerSelectionStrategy.selectWinners(
2178 randomNumber: randomNumber,
2179 receiverWeights: timeWeightedStakes,
2180 totalPrizeAmount: totalPrizeAmount
2181 )
2182
2183 let winners = selectionResult.winners
2184 let prizeAmounts = selectionResult.amounts
2185 let nftIDsPerWinner = selectionResult.nftIDs
2186
2187 if winners.length == 0 {
2188 emit PrizesAwarded(
2189 poolID: self.poolID,
2190 winners: [],
2191 winnerAddresses: [],
2192 amounts: [],
2193 round: self.lotteryDistributor.getPrizeRound()
2194 )
2195 return
2196 }
2197
2198 assert(winners.length == prizeAmounts.length, message: "Winners and prize amounts must match")
2199 assert(winners.length == nftIDsPerWinner.length, message: "Winners and NFT IDs must match")
2200
2201 let currentRound = self.lotteryDistributor.getPrizeRound() + 1
2202 self.lotteryDistributor.setPrizeRound(round: currentRound)
2203 var totalAwarded: UFix64 = 0.0
2204
2205 for i in InclusiveRange(0, winners.length - 1) {
2206 let winnerID = winners[i]
2207 let prizeAmount = prizeAmounts[i]
2208 let nftIDsForWinner = nftIDsPerWinner[i]
2209
2210 let prizeVault <- self.lotteryDistributor.awardPrize(
2211 receiverID: winnerID,
2212 amount: prizeAmount,
2213 yieldSource: nil
2214 )
2215
2216 self.savingsDistributor.deposit(receiverID: winnerID, amount: prizeAmount)
2217 let currentPrincipal = self.receiverDeposits[winnerID] ?? 0.0
2218 self.receiverDeposits[winnerID] = currentPrincipal + prizeAmount
2219 self.totalDeposited = self.totalDeposited + prizeAmount
2220 self.totalStaked = self.totalStaked + prizeAmount
2221 self.config.yieldConnector.depositCapacity(from: &prizeVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
2222 destroy prizeVault
2223 let totalPrizes = self.receiverTotalEarnedPrizes[winnerID] ?? 0.0
2224 self.receiverTotalEarnedPrizes[winnerID] = totalPrizes + prizeAmount
2225
2226 for nftID in nftIDsForWinner {
2227 let availableNFTs = self.lotteryDistributor.getAvailableNFTPrizeIDs()
2228 var nftFound = false
2229 for availableID in availableNFTs {
2230 if availableID == nftID {
2231 nftFound = true
2232 break
2233 }
2234 }
2235
2236 if !nftFound {
2237 continue
2238 }
2239
2240 let nft <- self.lotteryDistributor.withdrawNFTPrize(nftID: nftID)
2241 let nftType = nft.getType().identifier
2242 self.lotteryDistributor.storePendingNFT(receiverID: winnerID, nft: <- nft)
2243
2244 let winnerAddress = self.receiverAddresses[winnerID] ?? Address(0x0)
2245
2246 emit NFTPrizeStored(
2247 poolID: self.poolID,
2248 receiverID: winnerID,
2249 userAddress: winnerAddress,
2250 nftID: nftID,
2251 nftType: nftType,
2252 reason: "Lottery win - round \(currentRound)"
2253 )
2254
2255 emit NFTPrizeAwarded(
2256 poolID: self.poolID,
2257 receiverID: winnerID,
2258 userAddress: winnerAddress,
2259 nftID: nftID,
2260 nftType: nftType,
2261 round: currentRound
2262 )
2263 }
2264
2265 totalAwarded = totalAwarded + prizeAmount
2266 }
2267
2268 // Build array of winner addresses
2269 var winnerAddresses: [Address] = []
2270 for winnerID in winners {
2271 // Default to 0x0 if address not found (shouldn't happen for valid receivers)
2272 let address = self.receiverAddresses[winnerID] ?? Address(0x0)
2273 winnerAddresses.append(address)
2274 }
2275
2276 if let trackerCap = self.config.winnerTrackerCap {
2277 if let trackerRef = trackerCap.borrow() {
2278 for idx in InclusiveRange(0, winners.length - 1) {
2279 trackerRef.recordWinner(
2280 poolID: self.poolID,
2281 round: currentRound,
2282 winnerReceiverID: winners[idx],
2283 winnerAddress: winnerAddresses[idx],
2284 amount: prizeAmounts[idx],
2285 nftIDs: nftIDsPerWinner[idx]
2286 )
2287 }
2288 }
2289 }
2290
2291 emit PrizesAwarded(
2292 poolID: self.poolID,
2293 winners: winners,
2294 winnerAddresses: winnerAddresses,
2295 amounts: prizeAmounts,
2296 round: currentRound
2297 )
2298 }
2299
2300 access(contract) fun getDistributionStrategyName(): String {
2301 return self.config.distributionStrategy.getStrategyName()
2302 }
2303
2304 access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
2305 self.config.setDistributionStrategy(strategy: strategy)
2306 }
2307
2308 access(contract) fun getWinnerSelectionStrategyName(): String {
2309 return self.config.winnerSelectionStrategy.getStrategyName()
2310 }
2311
2312 access(contract) fun setWinnerSelectionStrategy(strategy: {WinnerSelectionStrategy}) {
2313 self.config.setWinnerSelectionStrategy(strategy: strategy)
2314 }
2315
2316 access(all) fun hasWinnerTracker(): Bool {
2317 return self.config.winnerTrackerCap != nil
2318 }
2319
2320 access(contract) fun setWinnerTrackerCap(cap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?) {
2321 self.config.setWinnerTrackerCap(cap: cap)
2322 }
2323
2324 access(contract) fun setDrawIntervalSeconds(interval: UFix64) {
2325 assert(!self.isDrawInProgress(), message: "Cannot change draw interval during an active draw")
2326 self.config.setDrawIntervalSeconds(interval: interval)
2327 }
2328
2329 access(contract) fun setMinimumDeposit(minimum: UFix64) {
2330 self.config.setMinimumDeposit(minimum: minimum)
2331 }
2332
2333 access(contract) fun setBonusWeight(receiverID: UInt64, bonusWeight: UFix64, reason: String, setBy: Address) {
2334 let timestamp = getCurrentBlock().timestamp
2335 let record = BonusWeightRecord(bonusWeight: bonusWeight, reason: reason, addedBy: setBy)
2336 self.receiverBonusWeights[receiverID] = record
2337
2338 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
2339 emit BonusLotteryWeightSet(
2340 poolID: self.poolID,
2341 receiverID: receiverID,
2342 userAddress: userAddress,
2343 bonusWeight: bonusWeight,
2344 reason: reason,
2345 setBy: setBy,
2346 timestamp: timestamp
2347 )
2348 }
2349
2350 access(contract) fun addBonusWeight(receiverID: UInt64, additionalWeight: UFix64, reason: String, addedBy: Address) {
2351 let timestamp = getCurrentBlock().timestamp
2352 let currentBonus = self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2353 let newTotalBonus = currentBonus + additionalWeight
2354
2355 let record = BonusWeightRecord(bonusWeight: newTotalBonus, reason: reason, addedBy: addedBy)
2356 self.receiverBonusWeights[receiverID] = record
2357
2358 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
2359 emit BonusLotteryWeightAdded(
2360 poolID: self.poolID,
2361 receiverID: receiverID,
2362 userAddress: userAddress,
2363 additionalWeight: additionalWeight,
2364 newTotalBonus: newTotalBonus,
2365 reason: reason,
2366 addedBy: addedBy,
2367 timestamp: timestamp
2368 )
2369 }
2370
2371 access(contract) fun removeBonusWeight(receiverID: UInt64, removedBy: Address) {
2372 let timestamp = getCurrentBlock().timestamp
2373 let previousBonus = self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2374
2375 let _ = self.receiverBonusWeights.remove(key: receiverID)
2376
2377 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
2378 emit BonusLotteryWeightRemoved(
2379 poolID: self.poolID,
2380 receiverID: receiverID,
2381 userAddress: userAddress,
2382 previousBonus: previousBonus,
2383 removedBy: removedBy,
2384 timestamp: timestamp
2385 )
2386 }
2387
2388 access(all) view fun getBonusWeight(receiverID: UInt64): UFix64 {
2389 return self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2390 }
2391
2392 access(all) view fun getBonusWeightRecord(receiverID: UInt64): BonusWeightRecord? {
2393 return self.receiverBonusWeights[receiverID]
2394 }
2395
2396 access(all) view fun getAllBonusWeightReceivers(): [UInt64] {
2397 return self.receiverBonusWeights.keys
2398 }
2399
2400 access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
2401 self.lotteryDistributor.depositNFTPrize(nft: <- nft)
2402 }
2403
2404 access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
2405 return <- self.lotteryDistributor.withdrawNFTPrize(nftID: nftID)
2406 }
2407
2408 access(all) view fun getAvailableNFTPrizeIDs(): [UInt64] {
2409 return self.lotteryDistributor.getAvailableNFTPrizeIDs()
2410 }
2411
2412 access(all) view fun borrowAvailableNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
2413 return self.lotteryDistributor.borrowNFTPrize(nftID: nftID)
2414 }
2415
2416 access(all) view fun getPendingNFTCount(receiverID: UInt64): Int {
2417 return self.lotteryDistributor.getPendingNFTCount(receiverID: receiverID)
2418 }
2419
2420 access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
2421 return self.lotteryDistributor.getPendingNFTIDs(receiverID: receiverID)
2422 }
2423
2424 /// Claim pending NFT prize. Only callable within contract (by PoolPositionCollection).
2425 access(contract) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
2426 let nft <- self.lotteryDistributor.claimPendingNFT(receiverID: receiverID, nftIndex: nftIndex)
2427 let nftType = nft.getType().identifier
2428
2429 let userAddress = self.receiverAddresses[receiverID] ?? Address(0x0)
2430 emit NFTPrizeClaimed(
2431 poolID: self.poolID,
2432 receiverID: receiverID,
2433 userAddress: userAddress,
2434 nftID: nft.uuid,
2435 nftType: nftType
2436 )
2437
2438 return <- nft
2439 }
2440
2441 access(all) view fun canDrawNow(): Bool {
2442 return (getCurrentBlock().timestamp - self.lastDrawTimestamp) >= self.config.drawIntervalSeconds
2443 }
2444
2445 /// Returns the "no-loss guarantee" amount: user deposits + auto-compounded lottery prizes
2446 /// This is the minimum amount users can always withdraw (excludes savings interest)
2447 access(all) view fun getReceiverDeposit(receiverID: UInt64): UFix64 {
2448 return self.receiverDeposits[receiverID] ?? 0.0
2449 }
2450
2451 /// Returns total withdrawable balance (principal + interest)
2452 access(all) view fun getReceiverTotalBalance(receiverID: UInt64): UFix64 {
2453 return self.savingsDistributor.getUserAssetValue(receiverID: receiverID)
2454 }
2455
2456 /// Returns lifetime total lottery prizes earned by this receiver.
2457 /// This is a cumulative counter that increases when prizes are won.
2458 access(all) view fun getReceiverTotalEarnedPrizes(receiverID: UInt64): UFix64 {
2459 return self.receiverTotalEarnedPrizes[receiverID] ?? 0.0
2460 }
2461
2462 /// Returns current pending savings interest (not yet withdrawn).
2463 /// This is NOT lifetime total - it's the current accrued interest that
2464 /// will be included in the next withdrawal.
2465 /// Formula: totalBalance - (deposits + prizes)
2466 /// Note: "principal" here includes both user deposits AND auto-compounded lottery prizes
2467 access(all) view fun getPendingSavingsInterest(receiverID: UInt64): UFix64 {
2468 let principal = self.receiverDeposits[receiverID] ?? 0.0 // deposits + prizes
2469 let totalBalance = self.savingsDistributor.getUserAssetValue(receiverID: receiverID)
2470 return totalBalance > principal ? totalBalance - principal : 0.0
2471 }
2472
2473 access(all) view fun getUserSavingsShares(receiverID: UInt64): UFix64 {
2474 return self.savingsDistributor.getUserShares(receiverID: receiverID)
2475 }
2476
2477 access(all) view fun getTotalSavingsShares(): UFix64 {
2478 return self.savingsDistributor.getTotalShares()
2479 }
2480
2481 access(all) view fun getTotalSavingsAssets(): UFix64 {
2482 return self.savingsDistributor.getTotalAssets()
2483 }
2484
2485 access(all) view fun getSavingsSharePrice(): UFix64 {
2486 let totalShares = self.savingsDistributor.getTotalShares()
2487 let totalAssets = self.savingsDistributor.getTotalAssets()
2488 let effectiveShares = totalShares + PrizeSavings.VIRTUAL_SHARES
2489 let effectiveAssets = totalAssets + PrizeSavings.VIRTUAL_ASSETS
2490 return effectiveAssets / effectiveShares
2491 }
2492
2493 access(all) view fun getUserTimeWeightedBalance(receiverID: UInt64): UFix64 {
2494 return self.savingsDistributor.getTimeWeightedBalance(receiverID: receiverID)
2495 }
2496
2497 access(all) view fun getCurrentEpochID(): UInt64 {
2498 return self.savingsDistributor.getCurrentEpochID()
2499 }
2500
2501 access(all) view fun getEpochStartTime(): UFix64 {
2502 return self.savingsDistributor.getEpochStartTime()
2503 }
2504
2505 access(all) view fun getEpochElapsedTime(): UFix64 {
2506 return getCurrentBlock().timestamp - self.savingsDistributor.getEpochStartTime()
2507 }
2508
2509 /// Batch draw support
2510 access(all) view fun isBatchDrawInProgress(): Bool {
2511 return self.batchDrawState != nil
2512 }
2513
2514 access(all) view fun getBatchDrawProgress(): {String: AnyStruct}? {
2515 if let state = self.batchDrawState {
2516 return {
2517 "cutoffTime": state.drawCutoffTime,
2518 "totalWeight": state.totalWeight,
2519 "processedCount": state.processedCount,
2520 "snapshotEpochID": state.snapshotEpochID
2521 }
2522 }
2523 return nil
2524 }
2525
2526 access(all) view fun getUserProjectedBalanceSeconds(receiverID: UInt64, atTime: UFix64): UFix64 {
2527 return self.savingsDistributor.calculateBalanceSecondsAtTime(receiverID: receiverID, targetTime: atTime)
2528 }
2529
2530 /// Preview how many shares would be minted for a deposit amount (ERC-4626 style)
2531 access(all) view fun previewDeposit(amount: UFix64): UFix64 {
2532 return self.savingsDistributor.convertToShares(amount)
2533 }
2534
2535 /// Preview how many assets a number of shares is worth (ERC-4626 style)
2536 access(all) view fun previewRedeem(shares: UFix64): UFix64 {
2537 return self.savingsDistributor.convertToAssets(shares)
2538 }
2539
2540 access(all) view fun getUserSavingsValue(receiverID: UInt64): UFix64 {
2541 return self.savingsDistributor.getUserAssetValue(receiverID: receiverID)
2542 }
2543
2544 access(all) view fun isReceiverRegistered(receiverID: UInt64): Bool {
2545 return self.registeredReceivers[receiverID] != nil
2546 }
2547
2548 access(all) view fun getRegisteredReceiverIDs(): [UInt64] {
2549 return self.registeredReceivers.keys
2550 }
2551
2552 access(all) view fun isDrawInProgress(): Bool {
2553 return self.pendingDrawReceipt != nil
2554 }
2555
2556 access(all) view fun getConfig(): PoolConfig {
2557 return self.config
2558 }
2559
2560 access(all) view fun getTotalSavingsDistributed(): UFix64 {
2561 return self.savingsDistributor.getTotalDistributed()
2562 }
2563
2564 access(all) view fun getCurrentReinvestedSavings(): UFix64 {
2565 if self.totalStaked > self.totalDeposited {
2566 return self.totalStaked - self.totalDeposited
2567 }
2568 return 0.0
2569 }
2570
2571 access(all) fun getAvailableYieldRewards(): UFix64 {
2572 let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
2573 let available = yieldSource.minimumAvailable()
2574 // Exclude already-allocated funds (same logic as processRewards)
2575 let allocatedFunds = self.totalStaked + self.pendingLotteryYield
2576 if available > allocatedFunds {
2577 return available - allocatedFunds
2578 }
2579 return 0.0
2580 }
2581
2582 access(all) view fun getLotteryPoolBalance(): UFix64 {
2583 return self.lotteryDistributor.getPrizePoolBalance() + self.pendingLotteryYield
2584 }
2585
2586 access(all) view fun getPendingLotteryYield(): UFix64 {
2587 return self.pendingLotteryYield
2588 }
2589
2590 access(all) view fun getTreasuryRecipient(): Address? {
2591 return self.treasuryRecipientCap?.address
2592 }
2593
2594 access(all) view fun hasTreasuryRecipient(): Bool {
2595 if let cap = self.treasuryRecipientCap {
2596 return cap.check()
2597 }
2598 return false
2599 }
2600
2601 access(all) view fun getTotalTreasuryForwarded(): UFix64 {
2602 return self.totalTreasuryForwarded
2603 }
2604
2605 /// Set treasury recipient for auto-forwarding. Only callable by account owner.
2606 access(contract) fun setTreasuryRecipient(cap: Capability<&{FungibleToken.Receiver}>?) {
2607 self.treasuryRecipientCap = cap
2608 }
2609
2610 // ============================================================
2611 // ENTRY VIEW FUNCTIONS - Human-readable UI helpers
2612 // ============================================================
2613 // "Entries" represent the user's lottery weight for the current draw.
2614 // Formula: entries = projectedTWAB / drawInterval
2615 //
2616 // This normalizes balance-seconds to human-readable whole numbers:
2617 // - $10 deposited at start of 7-day draw → 10 entries
2618 // - $10 deposited halfway through → 5 entries (prorated for this draw)
2619 // - At next round: same $10 deposit → 10 entries (full period credit)
2620 //
2621 // The TWAB naturally handles:
2622 // - Multiple deposits at different times (weighted correctly)
2623 // - Partial withdrawals (reduces entry count)
2624 // - Yield compounding (higher balance = more entries over time)
2625 // ============================================================
2626
2627 /// Internal: Returns the user's current accumulated entries (TWAB / drawInterval).
2628 /// This represents their lottery weight accumulated so far, NOT their final weight.
2629 access(self) view fun getCurrentEntries(receiverID: UInt64): UFix64 {
2630 let twab = self.savingsDistributor.getTimeWeightedBalance(receiverID: receiverID)
2631 let drawInterval = self.config.drawIntervalSeconds
2632 if drawInterval == 0.0 {
2633 return 0.0
2634 }
2635 return twab / drawInterval
2636 }
2637
2638 /// Returns the user's entry count for this draw.
2639 /// Projects TWAB forward to epoch end assuming no balance changes.
2640 /// Formula: (currentTWAB + balance × remainingEpochTime) / drawInterval
2641 ///
2642 /// Examples:
2643 /// - $10 deposited at start of draw → 10 entries
2644 /// - $10 deposited halfway through draw → 5 entries
2645 /// - At next round, same $10 → 10 entries (full credit)
2646 access(all) view fun getUserEntries(receiverID: UInt64): UFix64 {
2647 let drawInterval = self.config.drawIntervalSeconds
2648 if drawInterval == 0.0 {
2649 return 0.0
2650 }
2651
2652 let currentTwab = self.savingsDistributor.getTimeWeightedBalance(receiverID: receiverID)
2653 let currentBalance = self.savingsDistributor.getUserAssetValue(receiverID: receiverID)
2654 let remainingEpochTime = self.getTimeRemainingInEpoch()
2655
2656 // Project TWAB forward to epoch end: current + (balance × remaining epoch time)
2657 let projectedTwab = currentTwab + (currentBalance * remainingEpochTime)
2658 return projectedTwab / drawInterval
2659 }
2660
2661 /// Returns time remaining in the current epoch (for TWAB projection).
2662 /// Different from getTimeUntilNextDraw() which is based on draw cooldown.
2663 access(all) view fun getTimeRemainingInEpoch(): UFix64 {
2664 let epochStart = self.savingsDistributor.getEpochStartTime()
2665 let elapsed = getCurrentBlock().timestamp - epochStart
2666 let drawInterval = self.config.drawIntervalSeconds
2667 if elapsed >= drawInterval {
2668 return 0.0
2669 }
2670 return drawInterval - elapsed
2671 }
2672
2673 /// Returns how far through the current draw period we are (0.0 to 1.0+).
2674 /// - 0.0 = draw just started
2675 /// - 0.5 = halfway through draw period
2676 /// - 1.0 = draw period complete, ready for next draw
2677 access(all) view fun getDrawProgressPercent(): UFix64 {
2678 let epochStart = self.savingsDistributor.getEpochStartTime()
2679 let elapsed = getCurrentBlock().timestamp - epochStart
2680 let drawInterval = self.config.drawIntervalSeconds
2681 if drawInterval == 0.0 {
2682 return 0.0
2683 }
2684 return elapsed / drawInterval
2685 }
2686
2687 /// Returns time remaining until next draw is available (in seconds).
2688 /// Returns 0.0 if draw can happen now.
2689 access(all) view fun getTimeUntilNextDraw(): UFix64 {
2690 let elapsed = getCurrentBlock().timestamp - self.lastDrawTimestamp
2691 let drawInterval = self.config.drawIntervalSeconds
2692 if elapsed >= drawInterval {
2693 return 0.0
2694 }
2695 return drawInterval - elapsed
2696 }
2697
2698 }
2699
2700 access(all) struct PoolBalance {
2701 /// User deposits + auto-compounded prizes (the "no-loss guarantee" amount, decreases on withdrawal)
2702 access(all) let deposits: UFix64
2703 /// Lifetime total of lottery prizes won (cumulative, never decreases)
2704 access(all) let totalEarnedPrizes: UFix64
2705 /// Current pending savings interest (will be included in next withdrawal)
2706 access(all) let savingsEarned: UFix64
2707 /// Total withdrawable balance: deposits + savingsEarned
2708 access(all) let totalBalance: UFix64
2709
2710 init(deposits: UFix64, totalEarnedPrizes: UFix64, savingsEarned: UFix64) {
2711 self.deposits = deposits
2712 self.totalEarnedPrizes = totalEarnedPrizes
2713 self.savingsEarned = savingsEarned
2714 self.totalBalance = deposits + savingsEarned
2715 }
2716 }
2717
2718 /// Public interface for PoolPositionCollection - exposes read-only functions
2719 access(all) resource interface PoolPositionCollectionPublic {
2720 access(all) view fun getRegisteredPoolIDs(): [UInt64]
2721 access(all) view fun isRegisteredWithPool(poolID: UInt64): Bool
2722 access(all) view fun getReceiverID(): UInt64
2723 access(all) view fun getPendingNFTCount(poolID: UInt64): Int
2724 access(all) fun getPendingNFTIDs(poolID: UInt64): [UInt64]
2725 access(all) view fun getPendingSavingsInterest(poolID: UInt64): UFix64
2726 access(all) fun getPoolBalance(poolID: UInt64): PoolBalance
2727 access(all) view fun getPoolEntries(poolID: UInt64): UFix64
2728 }
2729
2730 /// PoolPositionCollection represents a user's position across all prize pools.
2731 ///
2732 /// ⚠️ IMPORTANT: This resource's UUID serves as the account key for all deposits.
2733 /// - All funds, shares, prizes, and NFTs are keyed to this resource's UUID
2734 /// - If this resource is destroyed or the account loses access, those funds become INACCESSIBLE
2735 /// - There is NO built-in recovery mechanism without admin intervention
2736 /// - Users should treat this resource like a wallet private key
2737 ///
2738 /// For production deployments:
2739 /// - Implement clear UI warnings about this behavior
2740 /// - Consider enabling emergency admin recovery for extreme cases
2741 /// - Backup account access is critical
2742 access(all) resource PoolPositionCollection: PoolPositionCollectionPublic {
2743 access(self) let registeredPools: {UInt64: Bool}
2744
2745 init() {
2746 self.registeredPools = {}
2747 }
2748
2749 access(self) fun registerWithPool(poolID: UInt64) {
2750 pre {
2751 self.registeredPools[poolID] == nil: "Already registered"
2752 }
2753
2754 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
2755 ?? panic("Pool does not exist")
2756
2757 // Use self.owner!.address - resource is already stored at this point
2758 poolRef.registerReceiver(receiverID: self.uuid, ownerAddress: self.owner!.address)
2759 self.registeredPools[poolID] = true
2760 }
2761
2762 access(all) view fun getRegisteredPoolIDs(): [UInt64] {
2763 return self.registeredPools.keys
2764 }
2765
2766 access(all) view fun isRegisteredWithPool(poolID: UInt64): Bool {
2767 return self.registeredPools[poolID] == true
2768 }
2769
2770 access(PositionOps) fun deposit(poolID: UInt64, from: @{FungibleToken.Vault}) {
2771 if self.registeredPools[poolID] == nil {
2772 self.registerWithPool(poolID: poolID)
2773 }
2774
2775 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
2776 ?? panic("Cannot borrow pool")
2777
2778 poolRef.deposit(from: <- from, receiverID: self.uuid)
2779 }
2780
2781 access(PositionOps) fun withdraw(poolID: UInt64, amount: UFix64): @{FungibleToken.Vault} {
2782 pre {
2783 self.registeredPools[poolID] == true: "Not registered with pool"
2784 }
2785
2786 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
2787 ?? panic("Cannot borrow pool")
2788
2789 return <- poolRef.withdraw(amount: amount, receiverID: self.uuid)
2790 }
2791
2792 access(PositionOps) fun claimPendingNFT(poolID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
2793 pre {
2794 self.registeredPools[poolID] == true: "Not registered with pool"
2795 }
2796
2797 let poolRef = PrizeSavings.borrowPoolInternal(poolID)
2798 ?? panic("Cannot borrow pool")
2799
2800 return <- poolRef.claimPendingNFT(receiverID: self.uuid, nftIndex: nftIndex)
2801 }
2802
2803 access(all) view fun getPendingNFTCount(poolID: UInt64): Int {
2804 if let poolRef = PrizeSavings.borrowPool(poolID: poolID) {
2805 return poolRef.getPendingNFTCount(receiverID: self.uuid)
2806 }
2807 return 0
2808 }
2809
2810 access(all) fun getPendingNFTIDs(poolID: UInt64): [UInt64] {
2811 if let poolRef = PrizeSavings.borrowPool(poolID: poolID) {
2812 return poolRef.getPendingNFTIDs(receiverID: self.uuid)
2813 }
2814 return []
2815 }
2816
2817 access(all) view fun getReceiverID(): UInt64 {
2818 return self.uuid
2819 }
2820
2821 access(all) view fun getPendingSavingsInterest(poolID: UInt64): UFix64 {
2822 if let poolRef = PrizeSavings.borrowPool(poolID: poolID) {
2823 return poolRef.getPendingSavingsInterest(receiverID: self.uuid)
2824 }
2825 return 0.0
2826 }
2827
2828 access(all) fun getPoolBalance(poolID: UInt64): PoolBalance {
2829 if self.registeredPools[poolID] == nil {
2830 return PoolBalance(deposits: 0.0, totalEarnedPrizes: 0.0, savingsEarned: 0.0)
2831 }
2832
2833 if let poolRef = PrizeSavings.borrowPool(poolID: poolID) {
2834 return PoolBalance(
2835 deposits: poolRef.getReceiverDeposit(receiverID: self.uuid),
2836 totalEarnedPrizes: poolRef.getReceiverTotalEarnedPrizes(receiverID: self.uuid),
2837 savingsEarned: poolRef.getPendingSavingsInterest(receiverID: self.uuid)
2838 )
2839 }
2840 return PoolBalance(deposits: 0.0, totalEarnedPrizes: 0.0, savingsEarned: 0.0)
2841 }
2842
2843 /// Returns the user's entry count for the current draw.
2844 /// Entries represent lottery weight - higher entries = better odds.
2845 /// Example: $10 deposited at start = 10 entries, $10 halfway = 5 entries
2846 access(all) view fun getPoolEntries(poolID: UInt64): UFix64 {
2847 if let poolRef = PrizeSavings.borrowPool(poolID: poolID) {
2848 return poolRef.getUserEntries(receiverID: self.uuid)
2849 }
2850 return 0.0
2851 }
2852 }
2853
2854 access(contract) fun createPool(
2855 config: PoolConfig,
2856 emergencyConfig: EmergencyConfig?,
2857 fundingPolicy: FundingPolicy?
2858 ): UInt64 {
2859 let pool <- create Pool(
2860 config: config,
2861 emergencyConfig: emergencyConfig,
2862 fundingPolicy: fundingPolicy
2863 )
2864
2865 let poolID = self.nextPoolID
2866 self.nextPoolID = self.nextPoolID + 1
2867
2868 pool.setPoolID(id: poolID)
2869 emit PoolCreated(
2870 poolID: poolID,
2871 assetType: config.assetType.identifier,
2872 strategy: config.distributionStrategy.getStrategyName()
2873 )
2874
2875 self.pools[poolID] <-! pool
2876 return poolID
2877 }
2878
2879 /// Public view-only reference to a pool (no mutation allowed)
2880 access(all) view fun borrowPool(poolID: UInt64): &Pool? {
2881 return &self.pools[poolID]
2882 }
2883
2884 /// Internal borrow with full authorization for Admin operations
2885 access(contract) fun borrowPoolInternal(_ poolID: UInt64): auth(CriticalOps, ConfigOps) &Pool? {
2886 return &self.pools[poolID]
2887 }
2888
2889 access(all) view fun getAllPoolIDs(): [UInt64] {
2890 return self.pools.keys
2891 }
2892
2893 access(all) fun createPoolPositionCollection(): @PoolPositionCollection {
2894 return <- create PoolPositionCollection()
2895 }
2896
2897 // ============================================================
2898 // ENTRY QUERY FUNCTIONS - Contract-level convenience accessors
2899 // ============================================================
2900 // These functions provide easy access to entry information for UI/scripts.
2901 // "Entries" represent the user's lottery weight for the current draw:
2902 // - entries = projectedTWAB / drawInterval
2903 // - $10 deposited at start of draw = 10 entries
2904 // - $10 deposited halfway through = 5 entries (prorated)
2905 // ============================================================
2906
2907 /// Get a user's entry count for the current draw.
2908 /// Projects TWAB forward to draw time assuming no balance changes.
2909 access(all) view fun getUserEntries(poolID: UInt64, receiverID: UInt64): UFix64 {
2910 if let poolRef = self.borrowPool(poolID: poolID) {
2911 return poolRef.getUserEntries(receiverID: receiverID)
2912 }
2913 return 0.0
2914 }
2915
2916 /// Get the draw progress as a percentage (0.0 to 1.0+).
2917 access(all) view fun getDrawProgressPercent(poolID: UInt64): UFix64 {
2918 if let poolRef = self.borrowPool(poolID: poolID) {
2919 return poolRef.getDrawProgressPercent()
2920 }
2921 return 0.0
2922 }
2923
2924 /// Get time remaining until next draw (in seconds).
2925 access(all) view fun getTimeUntilNextDraw(poolID: UInt64): UFix64 {
2926 if let poolRef = self.borrowPool(poolID: poolID) {
2927 return poolRef.getTimeUntilNextDraw()
2928 }
2929 return 0.0
2930 }
2931
2932 /// Get time remaining in the current epoch (in seconds).
2933 /// Based on epoch start time (for TWAB projection).
2934 access(all) view fun getTimeRemainingInEpoch(poolID: UInt64): UFix64 {
2935 if let poolRef = self.borrowPool(poolID: poolID) {
2936 return poolRef.getTimeRemainingInEpoch()
2937 }
2938 return 0.0
2939 }
2940
2941 init() {
2942 // Virtual offset constants for ERC4626 inflation attack protection.
2943 // Using 0.0001 to minimize dilution (~0.0001%) while still protecting against
2944 self.VIRTUAL_SHARES = 0.0001
2945 self.VIRTUAL_ASSETS = 0.0001
2946
2947 self.PoolPositionCollectionStoragePath = /storage/PrizeSavingsCollection_v2
2948 self.PoolPositionCollectionPublicPath = /public/PrizeSavingsCollection_v2
2949
2950 self.AdminStoragePath = /storage/PrizeSavingsAdmin
2951
2952 self.pools <- {}
2953 self.nextPoolID = 0
2954
2955 let admin <- create Admin()
2956 self.account.storage.save(<-admin, to: self.AdminStoragePath)
2957 }
2958}