Smart Contract
PrizeVaultModular
A.262cf58c0b9fbcff.PrizeVaultModular
1/*
2PrizeVault - Prize-Linked Savings Protocol
3
4A no-loss lottery system where users deposit tokens to earn both guaranteed savings
5interest and lottery prizes. Rewards are automatically compounded into deposits.
6
7Architecture:
8- Modular yield sources via DeFi Actions interface
9- Configurable distribution strategies (savings/lottery/treasury split)
10- O(1) gas complexity for interest distribution using accumulator pattern
11- Emergency mode with multi-sig support, configurable withdrawal limits, and auto-recovery
12- NFT prize support with pending claims system
13- Direct funding capabilities for external sponsors
14- Batch event emission for scalability (100k+ users)
15
16Core Components:
17- SavingsDistributor: Manages proportional interest distribution with O(1) gas
18- LotteryDistributor: Prize pool management and winner payouts
19- TreasuryDistributor: Protocol reserves, fee collection, and rounding dust
20- FundingPolicy: Rate limits and caps for direct funding
21
22Security Features:
23- Contract-only authorized pool access (prevents unauthorized manipulation)
24- Dust handling to prevent yield source imbalance
25- Emergency mode with configurable policies per pool
26- Scalable compounding with batch event summaries
27*/
28
29import FungibleToken from 0xf233dcee88fe0abe
30import NonFungibleToken from 0x1d7e57aa55817448
31import RandomConsumer from 0x45caec600164c9e6
32import DeFiActions from 0x92195d814edf9cb0
33import DeFiActionsUtils from 0x92195d814edf9cb0
34import PrizeWinnerTracker from 0x262cf58c0b9fbcff
35import Xorshift128plus from 0x262cf58c0b9fbcff
36
37access(all) contract PrizeVaultModular {
38
39 access(all) let PoolPositionCollectionStoragePath: StoragePath
40 access(all) let PoolPositionCollectionPublicPath: PublicPath
41
42 access(all) event PoolCreated(poolID: UInt64, assetType: String, strategy: String)
43 access(all) event Deposited(poolID: UInt64, receiverID: UInt64, amount: UFix64)
44 access(all) event Withdrawn(poolID: UInt64, receiverID: UInt64, amount: UFix64)
45
46 access(all) event RewardsProcessed(poolID: UInt64, totalAmount: UFix64, savingsAmount: UFix64, lotteryAmount: UFix64)
47
48 access(all) event SavingsInterestDistributed(poolID: UInt64, amount: UFix64, interestPerShare: UFix64)
49 access(all) event SavingsInterestCompounded(poolID: UInt64, receiverID: UInt64, amount: UFix64)
50 access(all) event SavingsInterestCompoundedBatch(poolID: UInt64, userCount: Int, totalAmount: UFix64, avgAmount: UFix64)
51 access(all) event SavingsRoundingDustToTreasury(poolID: UInt64, amount: UFix64)
52
53 access(all) event PrizeDrawCommitted(poolID: UInt64, prizeAmount: UFix64, commitBlock: UInt64)
54 access(all) event PrizesAwarded(poolID: UInt64, winners: [UInt64], amounts: [UFix64], round: UInt64)
55 access(all) event LotteryPrizePoolFunded(poolID: UInt64, amount: UFix64, source: String)
56
57 access(all) event DistributionStrategyUpdated(poolID: UInt64, oldStrategy: String, newStrategy: String, updatedBy: Address)
58 access(all) event WinnerSelectionStrategyUpdated(poolID: UInt64, oldStrategy: String, newStrategy: String, updatedBy: Address)
59 access(all) event WinnerTrackerUpdated(poolID: UInt64, hasOldTracker: Bool, hasNewTracker: Bool, updatedBy: Address)
60 access(all) event DrawIntervalUpdated(poolID: UInt64, oldInterval: UFix64, newInterval: UFix64, updatedBy: Address)
61 access(all) event MinimumDepositUpdated(poolID: UInt64, oldMinimum: UFix64, newMinimum: UFix64, updatedBy: Address)
62 access(all) event PoolCreatedByAdmin(poolID: UInt64, assetType: String, strategy: String, createdBy: Address)
63
64 access(all) event PoolPaused(poolID: UInt64, pausedBy: Address, reason: String)
65 access(all) event PoolUnpaused(poolID: UInt64, unpausedBy: Address)
66 access(all) event TreasuryFunded(poolID: UInt64, amount: UFix64, source: String)
67 access(all) event TreasuryWithdrawn(poolID: UInt64, withdrawnBy: Address, amount: UFix64, purpose: String, remainingBalance: UFix64)
68
69 access(all) event BonusLotteryWeightSet(poolID: UInt64, receiverID: UInt64, bonusWeight: UFix64, reason: String, setBy: Address, timestamp: UFix64)
70 access(all) event BonusLotteryWeightAdded(poolID: UInt64, receiverID: UInt64, additionalWeight: UFix64, newTotalBonus: UFix64, reason: String, addedBy: Address, timestamp: UFix64)
71 access(all) event BonusLotteryWeightRemoved(poolID: UInt64, receiverID: UInt64, previousBonus: UFix64, removedBy: Address, timestamp: UFix64)
72
73 access(all) event NFTPrizeDeposited(poolID: UInt64, nftID: UInt64, nftType: String, depositedBy: Address)
74 access(all) event NFTPrizeAwarded(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String, round: UInt64)
75 access(all) event NFTPrizeStored(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String, reason: String)
76 access(all) event NFTPrizeClaimed(poolID: UInt64, receiverID: UInt64, nftID: UInt64, nftType: String)
77 access(all) event NFTPrizeWithdrawn(poolID: UInt64, nftID: UInt64, nftType: String, withdrawnBy: Address)
78
79 access(all) event PoolEmergencyEnabled(poolID: UInt64, reason: String, enabledBy: Address, timestamp: UFix64)
80 access(all) event PoolEmergencyDisabled(poolID: UInt64, disabledBy: Address, timestamp: UFix64)
81 access(all) event PoolPartialModeEnabled(poolID: UInt64, reason: String, setBy: Address, timestamp: UFix64)
82 access(all) event EmergencyModeAutoTriggered(poolID: UInt64, reason: String, healthScore: UFix64, timestamp: UFix64)
83 access(all) event EmergencyModeAutoRecovered(poolID: UInt64, reason: String, healthScore: UFix64?, duration: UFix64?, timestamp: UFix64)
84 access(all) event EmergencyConfigUpdated(poolID: UInt64, updatedBy: Address)
85 access(all) event WithdrawalFailure(poolID: UInt64, receiverID: UInt64, amount: UFix64, consecutiveFailures: Int, yieldAvailable: UFix64)
86
87 access(all) event DirectFundingReceived(poolID: UInt64, destination: UInt8, destinationName: String, amount: UFix64, sponsor: Address, purpose: String, metadata: {String: String})
88
89 access(self) var pools: @{UInt64: Pool}
90 access(self) var nextPoolID: UInt64
91
92 access(all) enum PoolEmergencyState: UInt8 {
93 access(all) case Normal
94 access(all) case Paused
95 access(all) case EmergencyMode
96 access(all) case PartialMode
97 }
98
99 access(all) enum PoolFundingDestination: UInt8 {
100 access(all) case Savings
101 access(all) case Lottery
102 access(all) case Treasury
103 }
104
105 access(all) struct BonusWeightRecord {
106 access(all) let bonusWeight: UFix64
107 access(all) let reason: String
108 access(all) let addedAt: UFix64
109 access(all) let addedBy: Address
110
111 access(contract) init(bonusWeight: UFix64, reason: String, addedBy: Address) {
112 self.bonusWeight = bonusWeight
113 self.reason = reason
114 self.addedAt = getCurrentBlock().timestamp
115 self.addedBy = addedBy
116 }
117 }
118
119 /// Emergency configuration for pool safety mechanisms
120 /// Authorization and multi-sig are handled at the Flow account level via Admin resource
121 access(all) struct EmergencyConfig {
122 access(all) let maxEmergencyDuration: UFix64? // Max time pool can stay in emergency mode
123 access(all) let autoRecoveryEnabled: Bool // Auto-recover to normal after duration
124 access(all) let minYieldSourceHealth: UFix64 // Health threshold (0.0-1.0)
125 access(all) let maxWithdrawFailures: Int // Max consecutive withdraw failures before emergency
126 access(all) let partialModeDepositLimit: UFix64? // Max deposit allowed in partial mode
127 access(all) let minBalanceThreshold: UFix64 // Min balance as % of totalStaked (0.8-1.0, default 0.95)
128
129 init(
130 maxEmergencyDuration: UFix64?,
131 autoRecoveryEnabled: Bool,
132 minYieldSourceHealth: UFix64,
133 maxWithdrawFailures: Int,
134 partialModeDepositLimit: UFix64?,
135 minBalanceThreshold: UFix64
136 ) {
137 pre {
138 minYieldSourceHealth >= 0.0 && minYieldSourceHealth <= 1.0: "Health must be between 0.0 and 1.0"
139 maxWithdrawFailures > 0: "Must allow at least 1 withdrawal failure"
140 minBalanceThreshold >= 0.8 && minBalanceThreshold <= 1.0: "Balance threshold must be between 0.8 and 1.0"
141 }
142 self.maxEmergencyDuration = maxEmergencyDuration
143 self.autoRecoveryEnabled = autoRecoveryEnabled
144 self.minYieldSourceHealth = minYieldSourceHealth
145 self.maxWithdrawFailures = maxWithdrawFailures
146 self.partialModeDepositLimit = partialModeDepositLimit
147 self.minBalanceThreshold = minBalanceThreshold
148 }
149 }
150
151 access(all) struct FundingPolicy {
152 access(all) let maxDirectLottery: UFix64?
153 access(all) let maxDirectTreasury: UFix64?
154 access(all) let maxDirectSavings: UFix64?
155 access(all) var totalDirectLottery: UFix64
156 access(all) var totalDirectTreasury: UFix64
157 access(all) var totalDirectSavings: UFix64
158
159 init(maxDirectLottery: UFix64?, maxDirectTreasury: UFix64?, maxDirectSavings: UFix64?) {
160 self.maxDirectLottery = maxDirectLottery
161 self.maxDirectTreasury = maxDirectTreasury
162 self.maxDirectSavings = maxDirectSavings
163 self.totalDirectLottery = 0.0
164 self.totalDirectTreasury = 0.0
165 self.totalDirectSavings = 0.0
166 }
167
168 access(contract) fun recordDirectFunding(destination: PoolFundingDestination, amount: UFix64) {
169 switch destination {
170 case PoolFundingDestination.Lottery:
171 self.totalDirectLottery = self.totalDirectLottery + amount
172 if self.maxDirectLottery != nil {
173 assert(self.totalDirectLottery <= self.maxDirectLottery!, message: "Direct lottery funding limit exceeded")
174 }
175 case PoolFundingDestination.Treasury:
176 self.totalDirectTreasury = self.totalDirectTreasury + amount
177 if self.maxDirectTreasury != nil {
178 assert(self.totalDirectTreasury <= self.maxDirectTreasury!, message: "Direct treasury funding limit exceeded")
179 }
180 case PoolFundingDestination.Savings:
181 self.totalDirectSavings = self.totalDirectSavings + amount
182 if self.maxDirectSavings != nil {
183 assert(self.totalDirectSavings <= self.maxDirectSavings!, message: "Direct savings funding limit exceeded")
184 }
185 }
186 }
187 }
188
189 /// Creates default emergency config with reasonable safety limits
190 /// Authorization is handled by Admin resource ownership (Flow account level)
191 access(all) fun createDefaultEmergencyConfig(): EmergencyConfig {
192 return EmergencyConfig(
193 maxEmergencyDuration: 86400.0, // 24 hours
194 autoRecoveryEnabled: true,
195 minYieldSourceHealth: 0.5, // 50% health threshold
196 maxWithdrawFailures: 3,
197 partialModeDepositLimit: 100.0,
198 minBalanceThreshold: 0.95 // 95% of totalStaked
199 )
200 }
201
202 access(all) fun createDefaultFundingPolicy(): FundingPolicy {
203 return FundingPolicy(
204 maxDirectLottery: nil,
205 maxDirectTreasury: nil,
206 maxDirectSavings: nil
207 )
208 }
209
210 access(all) resource Admin {
211 access(contract) init() {}
212
213 access(all) fun updatePoolDistributionStrategy(
214 poolID: UInt64,
215 newStrategy: {DistributionStrategy},
216 updatedBy: Address
217 ) {
218 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
219 ?? panic("Pool does not exist")
220
221 let oldStrategyName = poolRef.getDistributionStrategyName()
222 poolRef.setDistributionStrategy(strategy: newStrategy)
223 let newStrategyName = newStrategy.getStrategyName()
224
225 emit DistributionStrategyUpdated(
226 poolID: poolID,
227 oldStrategy: oldStrategyName,
228 newStrategy: newStrategyName,
229 updatedBy: updatedBy
230 )
231 }
232
233 access(all) fun updatePoolWinnerSelectionStrategy(
234 poolID: UInt64,
235 newStrategy: {WinnerSelectionStrategy},
236 updatedBy: Address
237 ) {
238 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
239 ?? panic("Pool does not exist")
240
241 let oldStrategyName = poolRef.getWinnerSelectionStrategyName()
242 poolRef.setWinnerSelectionStrategy(strategy: newStrategy)
243 let newStrategyName = newStrategy.getStrategyName()
244
245 emit WinnerSelectionStrategyUpdated(
246 poolID: poolID,
247 oldStrategy: oldStrategyName,
248 newStrategy: newStrategyName,
249 updatedBy: updatedBy
250 )
251 }
252
253 access(all) fun updatePoolWinnerTracker(
254 poolID: UInt64,
255 newTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?,
256 updatedBy: Address
257 ) {
258 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
259 ?? panic("Pool does not exist")
260
261 let hasOldTracker = poolRef.hasWinnerTracker()
262 poolRef.setWinnerTrackerCap(cap: newTrackerCap)
263 let hasNewTracker = newTrackerCap != nil
264
265 emit WinnerTrackerUpdated(
266 poolID: poolID,
267 hasOldTracker: hasOldTracker,
268 hasNewTracker: hasNewTracker,
269 updatedBy: updatedBy
270 )
271 }
272
273 access(all) fun updatePoolDrawInterval(
274 poolID: UInt64,
275 newInterval: UFix64,
276 updatedBy: Address
277 ) {
278 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
279 ?? panic("Pool does not exist")
280
281 let oldInterval = poolRef.getConfig().drawIntervalSeconds
282 poolRef.setDrawIntervalSeconds(interval: newInterval)
283
284 emit DrawIntervalUpdated(
285 poolID: poolID,
286 oldInterval: oldInterval,
287 newInterval: newInterval,
288 updatedBy: updatedBy
289 )
290 }
291
292 access(all) fun updatePoolMinimumDeposit(
293 poolID: UInt64,
294 newMinimum: UFix64,
295 updatedBy: Address
296 ) {
297 pre {
298 newMinimum >= 0.0: "Minimum deposit cannot be negative"
299 }
300
301 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
302 ?? panic("Pool does not exist")
303
304 let oldMinimum = poolRef.getConfig().minimumDeposit
305 poolRef.setMinimumDeposit(minimum: newMinimum)
306
307 emit MinimumDepositUpdated(
308 poolID: poolID,
309 oldMinimum: oldMinimum,
310 newMinimum: newMinimum,
311 updatedBy: updatedBy
312 )
313 }
314
315 /// Enable emergency mode for a pool
316 /// Authorization is enforced by Admin resource ownership (Flow account multi-sig)
317 access(all) fun enableEmergencyMode(poolID: UInt64, reason: String, enabledBy: Address) {
318 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID) ?? panic("Pool does not exist")
319 poolRef.setEmergencyMode(reason: reason)
320 emit PoolEmergencyEnabled(poolID: poolID, reason: reason, enabledBy: enabledBy, timestamp: getCurrentBlock().timestamp)
321 }
322
323 /// Disable emergency mode for a pool
324 /// Authorization is enforced by Admin resource ownership (Flow account multi-sig)
325 access(all) fun disableEmergencyMode(poolID: UInt64, disabledBy: Address) {
326 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID) ?? panic("Pool does not exist")
327 poolRef.clearEmergencyMode()
328 emit PoolEmergencyDisabled(poolID: poolID, disabledBy: disabledBy, timestamp: getCurrentBlock().timestamp)
329 }
330
331 /// Set pool to partial emergency mode (deposits limited, withdrawals work)
332 /// Authorization is enforced by Admin resource ownership (Flow account multi-sig)
333 access(all) fun setEmergencyPartialMode(poolID: UInt64, reason: String, setBy: Address) {
334 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID) ?? panic("Pool does not exist")
335 poolRef.setPartialMode(reason: reason)
336 emit PoolPartialModeEnabled(poolID: poolID, reason: reason, setBy: setBy, timestamp: getCurrentBlock().timestamp)
337 }
338
339 access(all) fun updateEmergencyConfig(poolID: UInt64, newConfig: EmergencyConfig, updatedBy: Address) {
340 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID) ?? panic("Pool does not exist")
341 poolRef.setEmergencyConfig(config: newConfig)
342 emit EmergencyConfigUpdated(poolID: poolID, updatedBy: updatedBy)
343 }
344
345 access(all) fun fundPoolDirect(
346 poolID: UInt64,
347 destination: PoolFundingDestination,
348 from: @{FungibleToken.Vault},
349 sponsor: Address,
350 purpose: String,
351 metadata: {String: String}?
352 ) {
353 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID) ?? panic("Pool does not exist")
354 let amount = from.balance
355 poolRef.fundDirectInternal(destination: destination, from: <- from, sponsor: sponsor, purpose: purpose, metadata: metadata ?? {})
356
357 emit DirectFundingReceived(
358 poolID: poolID,
359 destination: destination.rawValue,
360 destinationName: self.getDestinationName(destination),
361 amount: amount,
362 sponsor: sponsor,
363 purpose: purpose,
364 metadata: metadata ?? {}
365 )
366 }
367
368 access(self) fun getDestinationName(_ destination: PoolFundingDestination): String {
369 switch destination {
370 case PoolFundingDestination.Savings: return "Savings"
371 case PoolFundingDestination.Lottery: return "Lottery"
372 case PoolFundingDestination.Treasury: return "Treasury"
373 default: return "Unknown"
374 }
375 }
376
377 // Yield connector immutable per pool for security - create new pool for different yield protocol
378 access(all) fun createPool(
379 config: PoolConfig,
380 emergencyConfig: EmergencyConfig?,
381 fundingPolicy: FundingPolicy?,
382 createdBy: Address
383 ): UInt64 {
384 // Use provided configs or create defaults
385 let finalEmergencyConfig = emergencyConfig
386 ?? PrizeVaultModular.createDefaultEmergencyConfig()
387 let finalFundingPolicy = fundingPolicy
388 ?? PrizeVaultModular.createDefaultFundingPolicy()
389
390 let poolID = PrizeVaultModular.createPool(
391 config: config,
392 emergencyConfig: finalEmergencyConfig,
393 fundingPolicy: finalFundingPolicy
394 )
395
396 emit PoolCreatedByAdmin(
397 poolID: poolID,
398 assetType: config.assetType.identifier,
399 strategy: config.distributionStrategy.getStrategyName(),
400 createdBy: createdBy
401 )
402
403 return poolID
404 }
405
406 access(all) fun processPoolRewards(poolID: UInt64) {
407 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
408 ?? panic("Pool does not exist")
409
410 poolRef.processRewards()
411 }
412
413 access(all) fun setPoolState(poolID: UInt64, state: PoolEmergencyState, reason: String?, setBy: Address) {
414 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
415 ?? panic("Pool does not exist")
416
417 poolRef.setState(state: state, reason: reason)
418
419 switch state {
420 case PoolEmergencyState.Normal:
421 emit PoolUnpaused(poolID: poolID, unpausedBy: setBy)
422 case PoolEmergencyState.Paused:
423 emit PoolPaused(poolID: poolID, pausedBy: setBy, reason: reason ?? "Manual pause")
424 case PoolEmergencyState.EmergencyMode:
425 emit PoolEmergencyEnabled(poolID: poolID, reason: reason ?? "Emergency", enabledBy: setBy, timestamp: getCurrentBlock().timestamp)
426 case PoolEmergencyState.PartialMode:
427 emit PoolPartialModeEnabled(poolID: poolID, reason: reason ?? "Partial mode", setBy: setBy, timestamp: getCurrentBlock().timestamp)
428 }
429 }
430
431 // All treasury withdrawals recorded on-chain for transparency
432 access(all) fun withdrawPoolTreasury(
433 poolID: UInt64,
434 amount: UFix64,
435 purpose: String,
436 withdrawnBy: Address
437 ): @{FungibleToken.Vault} {
438 pre {
439 purpose.length > 0: "Purpose must be specified for treasury withdrawal"
440 }
441
442 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
443 ?? panic("Pool does not exist")
444
445 let treasuryVault <- poolRef.withdrawTreasury(
446 amount: amount,
447 withdrawnBy: withdrawnBy,
448 purpose: purpose
449 )
450
451 emit TreasuryWithdrawn(
452 poolID: poolID,
453 withdrawnBy: withdrawnBy,
454 amount: amount,
455 purpose: purpose,
456 remainingBalance: poolRef.getTreasuryBalance()
457 )
458
459 return <- treasuryVault
460 }
461
462 // Bonus lottery weight management
463 access(all) fun setBonusLotteryWeight(
464 poolID: UInt64,
465 receiverID: UInt64,
466 bonusWeight: UFix64,
467 reason: String,
468 setBy: Address
469 ) {
470 pre {
471 bonusWeight >= 0.0: "Bonus weight cannot be negative"
472 }
473 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
474 ?? panic("Pool does not exist")
475
476 poolRef.setBonusWeight(receiverID: receiverID, bonusWeight: bonusWeight, reason: reason, setBy: setBy)
477 }
478
479 access(all) fun addBonusLotteryWeight(
480 poolID: UInt64,
481 receiverID: UInt64,
482 additionalWeight: UFix64,
483 reason: String,
484 addedBy: Address
485 ) {
486 pre {
487 additionalWeight > 0.0: "Additional weight must be positive"
488 }
489 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
490 ?? panic("Pool does not exist")
491
492 poolRef.addBonusWeight(receiverID: receiverID, additionalWeight: additionalWeight, reason: reason, addedBy: addedBy)
493 }
494
495 access(all) fun removeBonusLotteryWeight(
496 poolID: UInt64,
497 receiverID: UInt64,
498 removedBy: Address
499 ) {
500 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
501 ?? panic("Pool does not exist")
502
503 poolRef.removeBonusWeight(receiverID: receiverID, removedBy: removedBy)
504 }
505
506 // NFT Prize Management
507 access(all) fun depositNFTPrize(
508 poolID: UInt64,
509 nft: @{NonFungibleToken.NFT},
510 depositedBy: Address
511 ) {
512 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
513 ?? panic("Pool does not exist")
514
515 let nftID = nft.uuid
516 let nftType = nft.getType().identifier
517
518 poolRef.depositNFTPrize(nft: <- nft)
519
520 emit NFTPrizeDeposited(
521 poolID: poolID,
522 nftID: nftID,
523 nftType: nftType,
524 depositedBy: depositedBy
525 )
526 }
527
528 access(all) fun withdrawNFTPrize(
529 poolID: UInt64,
530 nftID: UInt64,
531 withdrawnBy: Address
532 ): @{NonFungibleToken.NFT} {
533 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
534 ?? panic("Pool does not exist")
535
536 let nft <- poolRef.withdrawNFTPrize(nftID: nftID)
537 let nftType = nft.getType().identifier
538
539 emit NFTPrizeWithdrawn(
540 poolID: poolID,
541 nftID: nftID,
542 nftType: nftType,
543 withdrawnBy: withdrawnBy
544 )
545
546 return <- nft
547 }
548 }
549
550 access(all) let AdminStoragePath: StoragePath
551 access(all) let AdminPublicPath: PublicPath
552
553 access(all) struct DistributionPlan {
554 access(all) let savingsAmount: UFix64
555 access(all) let lotteryAmount: UFix64
556 access(all) let treasuryAmount: UFix64
557
558 init(savings: UFix64, lottery: UFix64, treasury: UFix64) {
559 self.savingsAmount = savings
560 self.lotteryAmount = lottery
561 self.treasuryAmount = treasury
562 }
563 }
564
565 access(all) struct interface DistributionStrategy {
566 access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan
567 access(all) fun getStrategyName(): String
568 }
569
570 access(all) struct FixedPercentageStrategy: DistributionStrategy {
571 access(all) let savingsPercent: UFix64
572 access(all) let lotteryPercent: UFix64
573 access(all) let treasuryPercent: UFix64
574
575 init(savings: UFix64, lottery: UFix64, treasury: UFix64) {
576 pre {
577 savings + lottery + treasury == 1.0: "Percentages must sum to 1.0"
578 savings >= 0.0 && lottery >= 0.0 && treasury >= 0.0: "Must be non-negative"
579 }
580 self.savingsPercent = savings
581 self.lotteryPercent = lottery
582 self.treasuryPercent = treasury
583 }
584
585 access(all) fun calculateDistribution(totalAmount: UFix64): DistributionPlan {
586 return DistributionPlan(
587 savings: totalAmount * self.savingsPercent,
588 lottery: totalAmount * self.lotteryPercent,
589 treasury: totalAmount * self.treasuryPercent
590 )
591 }
592
593 access(all) fun getStrategyName(): String {
594 return "Fixed: "
595 .concat(self.savingsPercent.toString())
596 .concat(" savings, ")
597 .concat(self.lotteryPercent.toString())
598 .concat(" lottery")
599 }
600 }
601
602 // Savings Distributor - O(1) time-weighted distribution
603 access(all) resource SavingsDistributor {
604 access(self) let PRECISION: UFix64
605 access(self) var accumulatedInterestPerShare: UFix64
606 access(self) let userClaimedAmount: {UInt64: UFix64}
607 access(self) var interestVault: @{FungibleToken.Vault}
608 access(all) var totalDistributed: UFix64
609
610 init(vaultType: Type) {
611 // PRECISION = 100 enables billion-scale distributions (up to ~1.84B FLOW per tx)
612 // Provides 2 decimal places of precision (0.01 FLOW minimum)
613 // Max safe amount = UFix64.max / PRECISION ≈ 1.84 billion FLOW
614 self.PRECISION = 100.0
615 self.accumulatedInterestPerShare = 0.0
616 self.userClaimedAmount = {}
617 self.interestVault <- DeFiActionsUtils.getEmptyVault(vaultType)
618 self.totalDistributed = 0.0
619 }
620
621 /// Distribute interest and reinvest into yield source to continue generating yield
622 access(contract) fun distributeInterestAndReinvest(
623 vault: @{FungibleToken.Vault},
624 totalDeposited: UFix64,
625 yieldSink: &{DeFiActions.Sink}
626 ): UFix64 {
627 let amount = vault.balance
628
629 if amount == 0.0 || totalDeposited == 0.0 {
630 // Even if zero, reinvest to keep vault clean
631 yieldSink.depositCapacity(from: &vault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
632 destroy vault
633 return 0.0
634 }
635
636 // Overflow protection: Check multiplication safety
637 let maxSafeAmount = UFix64.max / self.PRECISION
638 assert(amount <= maxSafeAmount, message: "Reward amount too large - would cause overflow")
639
640 let interestPerShare = (amount * self.PRECISION) / totalDeposited
641
642 self.accumulatedInterestPerShare = self.accumulatedInterestPerShare + interestPerShare
643
644 // Reinvest into yield source instead of storing in interestVault
645 yieldSink.depositCapacity(from: &vault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
646 destroy vault
647 self.totalDistributed = self.totalDistributed + amount
648
649 return interestPerShare
650 }
651
652 access(contract) fun initializeReceiver(receiverID: UInt64, deposit: UFix64) {
653 // Initialize or re-initialize to prevent claiming past interest
654 self.userClaimedAmount[receiverID] = (deposit * self.accumulatedInterestPerShare) / self.PRECISION
655 }
656
657 access(all) fun calculatePendingInterest(receiverID: UInt64, deposit: UFix64): UFix64 {
658 if deposit == 0.0 {
659 return 0.0
660 }
661
662 let totalEarned = (deposit * self.accumulatedInterestPerShare) / self.PRECISION
663 let alreadyClaimed = self.userClaimedAmount[receiverID] ?? 0.0
664
665 return totalEarned > alreadyClaimed ? totalEarned - alreadyClaimed : 0.0
666 }
667
668 access(contract) fun claimInterest(receiverID: UInt64, deposit: UFix64): UFix64 {
669 let pending = self.calculatePendingInterest(receiverID: receiverID, deposit: deposit)
670
671 if pending > 0.0 {
672 let totalEarned = (deposit * self.accumulatedInterestPerShare) / self.PRECISION
673 self.userClaimedAmount[receiverID] = totalEarned
674 }
675
676 return pending
677 }
678
679 /// Withdraw interest, using yield source if interestVault is insufficient (for reinvested savings)
680 access(contract) fun withdrawInterestWithYieldSource(
681 amount: UFix64,
682 yieldSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}
683 ): @{FungibleToken.Vault} {
684 if amount == 0.0 {
685 let vaultType = self.interestVault.getType()
686 return <- DeFiActionsUtils.getEmptyVault(vaultType)
687 }
688
689 var remaining = amount
690 let vaultType = self.interestVault.getType()
691 var resultVault <- DeFiActionsUtils.getEmptyVault(vaultType)
692
693 // First, try to withdraw from interestVault (for any non-reinvested interest)
694 if self.interestVault.balance > 0.0 {
695 let fromVault = remaining < self.interestVault.balance ? remaining : self.interestVault.balance
696 resultVault.deposit(from: <- self.interestVault.withdraw(amount: fromVault))
697 remaining = remaining - fromVault
698 }
699
700 // If still need more, withdraw from yield source (reinvested savings)
701 if remaining > 0.0 {
702 let availableFromYield = yieldSource.minimumAvailable()
703 let fromYield = remaining < availableFromYield ? remaining : availableFromYield
704 if fromYield > 0.0 {
705 resultVault.deposit(from: <- yieldSource.withdrawAvailable(maxAmount: fromYield))
706 remaining = remaining - fromYield
707 }
708 }
709
710 assert(remaining == 0.0, message: "Insufficient interest available (vault + yield source)")
711 return <- resultVault
712 }
713
714 access(contract) fun updateAfterBalanceChange(receiverID: UInt64, newDeposit: UFix64) {
715 self.userClaimedAmount[receiverID] = (newDeposit * self.accumulatedInterestPerShare) / self.PRECISION
716 }
717
718 access(all) fun getInterestVaultBalance(): UFix64 {
719 return self.interestVault.balance
720 }
721
722 access(all) fun getTotalDistributed(): UFix64 {
723 return self.totalDistributed
724 }
725 }
726
727 // Lottery Distributor
728 access(all) resource LotteryDistributor {
729 access(self) var prizeVault: @{FungibleToken.Vault}
730 access(self) var nftPrizeVault: @{UInt64: {NonFungibleToken.NFT}}
731 access(self) var pendingNFTClaims: @{UInt64: [{NonFungibleToken.NFT}]}
732 access(self) var _prizeRound: UInt64
733 access(all) var totalPrizesDistributed: UFix64
734
735 access(all) fun getPrizeRound(): UInt64 {
736 return self._prizeRound
737 }
738
739 access(contract) fun setPrizeRound(round: UInt64) {
740 self._prizeRound = round
741 }
742
743 init(vaultType: Type) {
744 self.prizeVault <- DeFiActionsUtils.getEmptyVault(vaultType)
745 self.nftPrizeVault <- {}
746 self.pendingNFTClaims <- {}
747 self._prizeRound = 0
748 self.totalPrizesDistributed = 0.0
749 }
750
751 access(contract) fun fundPrizePool(vault: @{FungibleToken.Vault}) {
752 self.prizeVault.deposit(from: <- vault)
753 }
754
755 access(all) fun getPrizePoolBalance(): UFix64 {
756 return self.prizeVault.balance
757 }
758
759 access(contract) fun awardPrize(receiverID: UInt64, amount: UFix64, yieldSource: auth(FungibleToken.Withdraw) &{DeFiActions.Source}?): @{FungibleToken.Vault} {
760 self.totalPrizesDistributed = self.totalPrizesDistributed + amount
761
762 var result <- DeFiActionsUtils.getEmptyVault(self.prizeVault.getType())
763
764 // If yield source is provided (lottery is reinvested), try to withdraw from it first
765 if yieldSource != nil {
766 let available = yieldSource!.minimumAvailable()
767 if available >= amount {
768 result.deposit(from: <- yieldSource!.withdrawAvailable(maxAmount: amount))
769 return <- result
770 } else if available > 0.0 {
771 // Partial withdrawal from yield source
772 result.deposit(from: <- yieldSource!.withdrawAvailable(maxAmount: available))
773 }
774 }
775
776 // Withdraw remainder from internal vault
777 if result.balance < amount {
778 let remaining = amount - result.balance
779 assert(self.prizeVault.balance >= remaining, message: "Insufficient prize pool")
780 result.deposit(from: <- self.prizeVault.withdraw(amount: remaining))
781 }
782
783 return <- result
784 }
785
786 // NFT Prize Management
787 access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
788 let nftID = nft.uuid
789 self.nftPrizeVault[nftID] <-! nft
790 }
791
792 access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
793 let nft <- self.nftPrizeVault.remove(key: nftID)
794 if nft == nil {
795 panic("NFT not found in prize vault")
796 }
797 return <- nft!
798 }
799
800 access(contract) fun storePendingNFT(receiverID: UInt64, nft: @{NonFungibleToken.NFT}) {
801 if self.pendingNFTClaims[receiverID] == nil {
802 self.pendingNFTClaims[receiverID] <-! []
803 }
804 // Get mutable reference and append
805 let arrayRef = &self.pendingNFTClaims[receiverID] as auth(Mutate) &[{NonFungibleToken.NFT}]?
806 if arrayRef != nil {
807 arrayRef!.append(<- nft)
808 } else {
809 destroy nft
810 panic("Failed to store NFT in pending claims")
811 }
812 }
813
814 access(all) fun getPendingNFTCount(receiverID: UInt64): Int {
815 return self.pendingNFTClaims[receiverID]?.length ?? 0
816 }
817
818 access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
819 let nfts = &self.pendingNFTClaims[receiverID] as &[{NonFungibleToken.NFT}]?
820 if nfts == nil {
821 return []
822 }
823
824 let ids: [UInt64] = []
825 for nft in nfts! {
826 ids.append(nft.uuid)
827 }
828 return ids
829 }
830
831 access(all) fun getAvailableNFTPrizeIDs(): [UInt64] {
832 return self.nftPrizeVault.keys
833 }
834
835 access(all) fun borrowNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
836 return &self.nftPrizeVault[nftID]
837 }
838
839 access(contract) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
840 pre {
841 self.pendingNFTClaims[receiverID] != nil: "No pending NFTs for this receiver"
842 nftIndex < self.pendingNFTClaims[receiverID]?.length!: "Invalid NFT index"
843 }
844 return <- self.pendingNFTClaims[receiverID]?.remove(at: nftIndex)!
845 }
846 }
847
848 // Treasury Distributor
849 access(all) resource TreasuryDistributor {
850 access(self) var treasuryVault: @{FungibleToken.Vault}
851 access(all) var totalCollected: UFix64
852 access(all) var totalWithdrawn: UFix64
853 access(self) var withdrawalHistory: [{String: AnyStruct}]
854
855 init(vaultType: Type) {
856 self.treasuryVault <- DeFiActionsUtils.getEmptyVault(vaultType)
857 self.totalCollected = 0.0
858 self.totalWithdrawn = 0.0
859 self.withdrawalHistory = []
860 }
861
862 access(contract) fun deposit(vault: @{FungibleToken.Vault}) {
863 let amount = vault.balance
864 self.totalCollected = self.totalCollected + amount
865 self.treasuryVault.deposit(from: <- vault)
866 }
867
868 access(all) fun getBalance(): UFix64 {
869 return self.treasuryVault.balance
870 }
871
872 access(all) fun getTotalCollected(): UFix64 {
873 return self.totalCollected
874 }
875
876 access(all) fun getTotalWithdrawn(): UFix64 {
877 return self.totalWithdrawn
878 }
879
880 access(all) fun getWithdrawalHistory(): [{String: AnyStruct}] {
881 return self.withdrawalHistory
882 }
883
884 access(contract) fun withdraw(amount: UFix64, withdrawnBy: Address, purpose: String): @{FungibleToken.Vault} {
885 pre {
886 self.treasuryVault.balance >= amount: "Insufficient treasury balance"
887 amount > 0.0: "Withdrawal amount must be positive"
888 purpose.length > 0: "Purpose must be specified"
889 }
890
891 self.totalWithdrawn = self.totalWithdrawn + amount
892
893 self.withdrawalHistory.append({
894 "address": withdrawnBy,
895 "amount": amount,
896 "timestamp": getCurrentBlock().timestamp,
897 "purpose": purpose
898 })
899
900 return <- self.treasuryVault.withdraw(amount: amount)
901 }
902 }
903
904 access(all) resource PrizeDrawReceipt {
905 access(all) let prizeAmount: UFix64
906 access(self) var request: @RandomConsumer.Request?
907 access(all) let timeWeightedStakes: {UInt64: UFix64} // Snapshot of stakes at draw start
908
909 init(prizeAmount: UFix64, request: @RandomConsumer.Request, timeWeightedStakes: {UInt64: UFix64}) {
910 self.prizeAmount = prizeAmount
911 self.request <- request
912 self.timeWeightedStakes = timeWeightedStakes
913 }
914
915 access(all) view fun getRequestBlock(): UInt64? {
916 return self.request?.block
917 }
918
919 access(contract) fun popRequest(): @RandomConsumer.Request {
920 let request <- self.request <- nil
921 return <- request!
922 }
923
924 access(all) fun getTimeWeightedStakes(): {UInt64: UFix64} {
925 return self.timeWeightedStakes
926 }
927 }
928
929 access(all) struct WinnerSelectionResult {
930 access(all) let winners: [UInt64]
931 access(all) let amounts: [UFix64]
932 access(all) let nftIDs: [[UInt64]]
933
934 init(winners: [UInt64], amounts: [UFix64], nftIDs: [[UInt64]]) {
935 pre {
936 winners.length == amounts.length: "Winners and amounts must have same length"
937 winners.length == nftIDs.length: "Winners and nftIDs must have same length"
938 }
939 self.winners = winners
940 self.amounts = amounts
941 self.nftIDs = nftIDs
942 }
943 }
944
945 /// Winner Selection Strategy Interface
946 /// Note: receiverDeposits contains time-weighted lottery stakes (deposit + pending interest)
947 /// This ensures users who have been in the pool longer have proportionally higher lottery chances
948 access(all) struct interface WinnerSelectionStrategy {
949 access(all) fun selectWinners(
950 randomNumber: UInt64,
951 receiverDeposits: {UInt64: UFix64}, // Time-weighted stakes: deposit + pending interest
952 totalPrizeAmount: UFix64
953 ): WinnerSelectionResult
954 access(all) fun getStrategyName(): String
955 }
956
957 /// Weighted random selection - single winner
958 /// Selection is weighted by time-weighted stake (deposit + pending interest)
959 /// Users with larger deposits and/or longer time in pool have higher winning probability
960 access(all) struct WeightedSingleWinner: WinnerSelectionStrategy {
961 access(all) let nftIDs: [UInt64]
962
963 init(nftIDs: [UInt64]) {
964 self.nftIDs = nftIDs
965 }
966
967 access(all) fun selectWinners(
968 randomNumber: UInt64,
969 receiverDeposits: {UInt64: UFix64},
970 totalPrizeAmount: UFix64
971 ): WinnerSelectionResult {
972 let receiverIDs = receiverDeposits.keys
973
974 if receiverIDs.length == 0 {
975 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
976 }
977
978 if receiverIDs.length == 1 {
979 return WinnerSelectionResult(
980 winners: [receiverIDs[0]],
981 amounts: [totalPrizeAmount],
982 nftIDs: [self.nftIDs]
983 )
984 }
985
986 var cumulativeSum: [UFix64] = []
987 var runningTotal: UFix64 = 0.0
988
989 for receiverID in receiverIDs {
990 runningTotal = runningTotal + receiverDeposits[receiverID]!
991 cumulativeSum.append(runningTotal)
992 }
993
994 // Safety check: prevent overflow if runningTotal is 0
995 if runningTotal == 0.0 {
996 return WinnerSelectionResult(
997 winners: [receiverIDs[0]],
998 amounts: [totalPrizeAmount],
999 nftIDs: [self.nftIDs]
1000 )
1001 }
1002
1003 // Scale random number to [0, runningTotal) without overflow
1004 // Use modulo with safe upper bound (1 billion) for precision
1005 let scaledRandom = UFix64(randomNumber % 1_000_000_000) / 1_000_000_000.0
1006 let randomValue = scaledRandom * runningTotal
1007
1008 var winnerIndex = 0
1009 for i, cumSum in cumulativeSum {
1010 if randomValue < cumSum {
1011 winnerIndex = i
1012 break
1013 }
1014 }
1015
1016 return WinnerSelectionResult(
1017 winners: [receiverIDs[winnerIndex]],
1018 amounts: [totalPrizeAmount],
1019 nftIDs: [self.nftIDs]
1020 )
1021 }
1022
1023 access(all) fun getStrategyName(): String {
1024 return "Weighted Single Winner"
1025 }
1026 }
1027
1028 /// Multi-winner with split prizes
1029 /// Selection is weighted by time-weighted stake (deposit + pending interest)
1030 /// Users with larger deposits and/or longer time in pool have higher winning probability
1031 access(all) struct MultiWinnerSplit: WinnerSelectionStrategy {
1032 access(all) let winnerCount: Int
1033 access(all) let prizeSplits: [UFix64] // e.g., [0.6, 0.3, 0.1] for 60%, 30%, 10%
1034 access(all) let nftIDsPerWinner: [[UInt64]] // NFT IDs for each winner position, e.g., [[123], [456, 789], []]
1035
1036 init(winnerCount: Int, prizeSplits: [UFix64], nftIDsPerWinner: [UInt64]) {
1037 pre {
1038 winnerCount > 0: "Must have at least one winner"
1039 prizeSplits.length == winnerCount: "Prize splits must match winner count"
1040 }
1041
1042 // Validate all splits
1043 var total: UFix64 = 0.0
1044 for split in prizeSplits {
1045 assert(split >= 0.0 && split <= 1.0, message: "Each split must be between 0 and 1")
1046 total = total + split
1047 }
1048
1049 assert(total == 1.0, message: "Prize splits must sum to 1.0")
1050
1051 self.winnerCount = winnerCount
1052 self.prizeSplits = prizeSplits
1053
1054 // Distribute NFT IDs across winners
1055 // If nftIDsPerWinner is provided, assign them to winners in order
1056 // Otherwise, create empty arrays for each winner
1057 var nftArray: [[UInt64]] = []
1058 var nftIndex = 0
1059 var winnerIdx = 0
1060 while winnerIdx < winnerCount {
1061 if nftIndex < nftIDsPerWinner.length {
1062 // Assign one NFT per winner in order
1063 nftArray.append([nftIDsPerWinner[nftIndex]])
1064 nftIndex = nftIndex + 1
1065 } else {
1066 // No more NFTs, assign empty array
1067 nftArray.append([])
1068 }
1069 winnerIdx = winnerIdx + 1
1070 }
1071 self.nftIDsPerWinner = nftArray
1072 }
1073
1074 access(all) fun selectWinners(
1075 randomNumber: UInt64,
1076 receiverDeposits: {UInt64: UFix64},
1077 totalPrizeAmount: UFix64
1078 ): WinnerSelectionResult {
1079 let receiverIDs = receiverDeposits.keys
1080 let depositorCount = receiverIDs.length
1081
1082 if depositorCount == 0 {
1083 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1084 }
1085
1086 assert(self.winnerCount <= depositorCount, message: "More winners than depositors")
1087
1088 if depositorCount == 1 {
1089 // If only one depositor, they get all NFTs assigned to first winner position
1090 let nftIDsForFirst: [UInt64] = self.nftIDsPerWinner.length > 0 ? self.nftIDsPerWinner[0] : []
1091 return WinnerSelectionResult(
1092 winners: [receiverIDs[0]],
1093 amounts: [totalPrizeAmount],
1094 nftIDs: [nftIDsForFirst]
1095 )
1096 }
1097
1098 var cumulativeSum: [UFix64] = []
1099 var runningTotal: UFix64 = 0.0
1100 var depositsList: [UFix64] = []
1101
1102 for receiverID in receiverIDs {
1103 let deposit = receiverDeposits[receiverID]!
1104 depositsList.append(deposit)
1105 runningTotal = runningTotal + deposit
1106 cumulativeSum.append(runningTotal)
1107 }
1108
1109 var selectedWinners: [UInt64] = []
1110 var selectedIndices: {Int: Bool} = {}
1111 var remainingDeposits = depositsList
1112 var remainingIDs = receiverIDs
1113 var remainingCumSum = cumulativeSum
1114 var remainingTotal = runningTotal
1115
1116 // Initialize Xorshift128plus PRG with the random beacon value
1117 // Use empty salt as the randomNumber already comes from a secure source (Flow's random beacon)
1118 // Pad to 16 bytes (PRG requires at least 16 bytes)
1119 var randomBytes = randomNumber.toBigEndianBytes()
1120 // Pad by duplicating the bytes to reach 16 bytes
1121 while randomBytes.length < 16 {
1122 randomBytes.appendAll(randomNumber.toBigEndianBytes())
1123 }
1124 // Take only first 16 bytes if we have more
1125 var paddedBytes: [UInt8] = []
1126 var padIdx = 0
1127 while padIdx < 16 {
1128 paddedBytes.append(randomBytes[padIdx % randomBytes.length])
1129 padIdx = padIdx + 1
1130 }
1131
1132 let prg = Xorshift128plus.PRG(
1133 sourceOfRandomness: paddedBytes,
1134 salt: []
1135 )
1136
1137 var winnerIndex = 0
1138 while winnerIndex < self.winnerCount && remainingIDs.length > 0 && remainingTotal > 0.0 {
1139 let rng = prg.nextUInt64()
1140 // Scale RNG to [0, remainingTotal) without overflow
1141 // Use modulo with safe upper bound (1 billion) for precision
1142 let scaledRandom = UFix64(rng % 1_000_000_000) / 1_000_000_000.0
1143 let randomValue = scaledRandom * remainingTotal
1144
1145 var selectedIdx = 0
1146 for i, cumSum in remainingCumSum {
1147 if randomValue < cumSum {
1148 selectedIdx = i
1149 break
1150 }
1151 }
1152
1153 selectedWinners.append(remainingIDs[selectedIdx])
1154 selectedIndices[selectedIdx] = true
1155 var newRemainingIDs: [UInt64] = []
1156 var newRemainingDeposits: [UFix64] = []
1157 var newCumSum: [UFix64] = []
1158 var newRunningTotal: UFix64 = 0.0
1159
1160 var idx = 0
1161 while idx < remainingIDs.length {
1162 if idx != selectedIdx {
1163 newRemainingIDs.append(remainingIDs[idx])
1164 newRemainingDeposits.append(remainingDeposits[idx])
1165 newRunningTotal = newRunningTotal + remainingDeposits[idx]
1166 newCumSum.append(newRunningTotal)
1167 }
1168 idx = idx + 1
1169 }
1170
1171 remainingIDs = newRemainingIDs
1172 remainingDeposits = newRemainingDeposits
1173 remainingCumSum = newCumSum
1174 remainingTotal = newRunningTotal
1175 winnerIndex = winnerIndex + 1
1176 }
1177
1178 var prizeAmounts: [UFix64] = []
1179 var calculatedSum: UFix64 = 0.0
1180 var idx = 0
1181
1182 while idx < selectedWinners.length - 1 {
1183 let split = self.prizeSplits[idx]
1184 let amount = totalPrizeAmount * split
1185 prizeAmounts.append(amount)
1186 calculatedSum = calculatedSum + amount
1187 idx = idx + 1
1188 }
1189
1190 // Last prize gets remainder to ensure exact sum
1191 let lastPrize = totalPrizeAmount - calculatedSum
1192 prizeAmounts.append(lastPrize)
1193
1194 var finalSum: UFix64 = 0.0
1195 for amount in prizeAmounts {
1196 finalSum = finalSum + amount
1197 }
1198 assert(finalSum == totalPrizeAmount, message: "Prize amounts must sum to total prize pool")
1199
1200 let expectedLast = totalPrizeAmount * self.prizeSplits[selectedWinners.length - 1]
1201 let deviation = lastPrize > expectedLast ? lastPrize - expectedLast : expectedLast - lastPrize
1202 let maxDeviation = totalPrizeAmount * 0.01 // 1% tolerance
1203 assert(deviation <= maxDeviation, message: "Last prize deviation too large - check splits")
1204
1205 // Assign NFT IDs to winners based on their position
1206 // First winner gets first NFT set, second winner gets second NFT set, etc.
1207 var nftIDsArray: [[UInt64]] = []
1208 var idx2 = 0
1209 while idx2 < selectedWinners.length {
1210 if idx2 < self.nftIDsPerWinner.length {
1211 nftIDsArray.append(self.nftIDsPerWinner[idx2])
1212 } else {
1213 nftIDsArray.append([])
1214 }
1215 idx2 = idx2 + 1
1216 }
1217
1218 return WinnerSelectionResult(
1219 winners: selectedWinners,
1220 amounts: prizeAmounts,
1221 nftIDs: nftIDsArray
1222 )
1223 }
1224
1225 access(all) fun getStrategyName(): String {
1226 var name = "Multi-Winner (".concat(self.winnerCount.toString()).concat(" winners): ")
1227 var idx = 0
1228 while idx < self.prizeSplits.length {
1229 if idx > 0 {
1230 name = name.concat(", ")
1231 }
1232 name = name.concat((self.prizeSplits[idx] * 100.0).toString()).concat("%")
1233 idx = idx + 1
1234 }
1235 return name
1236 }
1237 }
1238
1239 /// Prize tier for fixed-amount lottery
1240 access(all) struct PrizeTier {
1241 access(all) let prizeAmount: UFix64
1242 access(all) let winnerCount: Int
1243 access(all) let name: String
1244 access(all) let nftIDs: [UInt64]
1245
1246 init(amount: UFix64, count: Int, name: String, nftIDs: [UInt64]) {
1247 pre {
1248 amount > 0.0: "Prize amount must be positive"
1249 count > 0: "Winner count must be positive"
1250 nftIDs.length <= count: "Cannot have more NFTs than winners in tier"
1251 }
1252 self.prizeAmount = amount
1253 self.winnerCount = count
1254 self.name = name
1255 self.nftIDs = nftIDs
1256 }
1257 }
1258
1259 /// Fixed prize tiers - only draws when sufficient funds accumulated
1260 /// Best for marketing: "Win $10,000!" instead of variable percentages
1261 /// Selection is weighted by time-weighted stake (deposit + pending interest)
1262 access(all) struct FixedPrizeTiers: WinnerSelectionStrategy {
1263 access(all) let tiers: [PrizeTier]
1264
1265 init(tiers: [PrizeTier]) {
1266 pre {
1267 tiers.length > 0: "Must have at least one prize tier"
1268 }
1269 self.tiers = tiers
1270 }
1271
1272 access(all) fun selectWinners(
1273 randomNumber: UInt64,
1274 receiverDeposits: {UInt64: UFix64},
1275 totalPrizeAmount: UFix64
1276 ): WinnerSelectionResult {
1277 let receiverIDs = receiverDeposits.keys
1278 let depositorCount = receiverIDs.length
1279
1280 if depositorCount == 0 {
1281 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1282 }
1283
1284 // Calculate total needed for all tiers
1285 var totalNeeded: UFix64 = 0.0
1286 var totalWinnersNeeded = 0
1287 for tier in self.tiers {
1288 totalNeeded = totalNeeded + (tier.prizeAmount * UFix64(tier.winnerCount))
1289 totalWinnersNeeded = totalWinnersNeeded + tier.winnerCount
1290 }
1291
1292 // Not enough funds - skip draw and let prize pool accumulate
1293 if totalPrizeAmount < totalNeeded {
1294 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1295 }
1296
1297 // Not enough depositors for all prizes
1298 if totalWinnersNeeded > depositorCount {
1299 return WinnerSelectionResult(winners: [], amounts: [], nftIDs: [])
1300 }
1301
1302 // Build cumulative distribution for weighted selection
1303 var cumulativeSum: [UFix64] = []
1304 var runningTotal: UFix64 = 0.0
1305
1306 for receiverID in receiverIDs {
1307 let deposit = receiverDeposits[receiverID]!
1308 runningTotal = runningTotal + deposit
1309 cumulativeSum.append(runningTotal)
1310 }
1311
1312 // Initialize PRG - pad to 16 bytes (PRG requires at least 16 bytes)
1313 var randomBytes = randomNumber.toBigEndianBytes()
1314 // Pad by duplicating the bytes to reach 16 bytes
1315 while randomBytes.length < 16 {
1316 randomBytes.appendAll(randomNumber.toBigEndianBytes())
1317 }
1318 // Take only first 16 bytes if we have more
1319 var paddedBytes: [UInt8] = []
1320 var padIdx2 = 0
1321 while padIdx2 < 16 {
1322 paddedBytes.append(randomBytes[padIdx2 % randomBytes.length])
1323 padIdx2 = padIdx2 + 1
1324 }
1325
1326 let prg = Xorshift128plus.PRG(
1327 sourceOfRandomness: paddedBytes,
1328 salt: []
1329 )
1330
1331 var allWinners: [UInt64] = []
1332 var allPrizes: [UFix64] = []
1333 var allNFTIDs: [[UInt64]] = []
1334 var usedIndices: {Int: Bool} = {}
1335 var remainingIDs = receiverIDs
1336 var remainingCumSum = cumulativeSum
1337 var remainingTotal = runningTotal
1338
1339 // Select winners for each tier
1340 for tier in self.tiers {
1341 var tierWinnerCount = 0
1342
1343 while tierWinnerCount < tier.winnerCount && remainingIDs.length > 0 && remainingTotal > 0.0 {
1344 let rng = prg.nextUInt64()
1345 // Scale RNG to [0, remainingTotal) without overflow
1346 // Use modulo with safe upper bound (1 billion) for precision
1347 let scaledRandom = UFix64(rng % 1_000_000_000) / 1_000_000_000.0
1348 let randomValue = scaledRandom * remainingTotal
1349
1350 var selectedIdx = 0
1351 for i, cumSum in remainingCumSum {
1352 if randomValue <= cumSum {
1353 selectedIdx = i
1354 break
1355 }
1356 }
1357
1358 let winnerID = remainingIDs[selectedIdx]
1359 allWinners.append(winnerID)
1360 allPrizes.append(tier.prizeAmount)
1361
1362 // Assign NFT to this winner if tier has NFTs
1363 if tierWinnerCount < tier.nftIDs.length {
1364 allNFTIDs.append([tier.nftIDs[tierWinnerCount]])
1365 } else {
1366 allNFTIDs.append([])
1367 }
1368
1369 // Remove winner from remaining pool
1370 var newRemainingIDs: [UInt64] = []
1371 var newRemainingCumSum: [UFix64] = []
1372 var newRunningTotal: UFix64 = 0.0
1373 var oldIdx = 0
1374
1375 while oldIdx < remainingIDs.length {
1376 if oldIdx != selectedIdx {
1377 newRemainingIDs.append(remainingIDs[oldIdx])
1378 let deposit = receiverDeposits[remainingIDs[oldIdx]]!
1379 newRunningTotal = newRunningTotal + deposit
1380 newRemainingCumSum.append(newRunningTotal)
1381 }
1382 oldIdx = oldIdx + 1
1383 }
1384
1385 remainingIDs = newRemainingIDs
1386 remainingCumSum = newRemainingCumSum
1387 remainingTotal = newRunningTotal
1388 tierWinnerCount = tierWinnerCount + 1
1389 }
1390 }
1391
1392 return WinnerSelectionResult(
1393 winners: allWinners,
1394 amounts: allPrizes,
1395 nftIDs: allNFTIDs
1396 )
1397 }
1398
1399 access(all) fun getStrategyName(): String {
1400 var name = "Fixed Prizes ("
1401 var idx = 0
1402 while idx < self.tiers.length {
1403 if idx > 0 {
1404 name = name.concat(", ")
1405 }
1406 let tier = self.tiers[idx]
1407 name = name.concat(tier.winnerCount.toString())
1408 .concat("x ")
1409 .concat(tier.prizeAmount.toString())
1410 idx = idx + 1
1411 }
1412 return name.concat(")")
1413 }
1414 }
1415
1416 // Pool Configuration
1417 access(all) struct PoolConfig {
1418 access(all) let assetType: Type
1419 access(all) let yieldConnector: {DeFiActions.Sink, DeFiActions.Source}
1420 access(all) var minimumDeposit: UFix64
1421 access(all) var drawIntervalSeconds: UFix64
1422 access(all) var distributionStrategy: {DistributionStrategy}
1423 access(all) var winnerSelectionStrategy: {WinnerSelectionStrategy}
1424 access(all) var winnerTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?
1425
1426 init(
1427 assetType: Type,
1428 yieldConnector: {DeFiActions.Sink, DeFiActions.Source},
1429 minimumDeposit: UFix64,
1430 drawIntervalSeconds: UFix64,
1431 distributionStrategy: {DistributionStrategy},
1432 winnerSelectionStrategy: {WinnerSelectionStrategy},
1433 winnerTrackerCap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?
1434 ) {
1435 self.assetType = assetType
1436 self.yieldConnector = yieldConnector
1437 self.minimumDeposit = minimumDeposit
1438 self.drawIntervalSeconds = drawIntervalSeconds
1439 self.distributionStrategy = distributionStrategy
1440 self.winnerSelectionStrategy = winnerSelectionStrategy
1441 self.winnerTrackerCap = winnerTrackerCap
1442 }
1443
1444 access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
1445 self.distributionStrategy = strategy
1446 }
1447
1448 access(contract) fun setWinnerSelectionStrategy(strategy: {WinnerSelectionStrategy}) {
1449 self.winnerSelectionStrategy = strategy
1450 }
1451
1452 access(contract) fun setWinnerTrackerCap(cap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?) {
1453 self.winnerTrackerCap = cap
1454 }
1455
1456 access(contract) fun setDrawIntervalSeconds(interval: UFix64) {
1457 pre {
1458 interval >= 1.0: "Draw interval must be at least 1 hour (1 seconds)"
1459 }
1460 self.drawIntervalSeconds = interval
1461 }
1462
1463 access(contract) fun setMinimumDeposit(minimum: UFix64) {
1464 pre {
1465 minimum >= 0.0: "Minimum deposit cannot be negative"
1466 }
1467 self.minimumDeposit = minimum
1468 }
1469 }
1470
1471 // Pool Resource
1472 access(all) resource Pool {
1473 access(self) var config: PoolConfig
1474 access(self) var poolID: UInt64
1475
1476 access(self) var emergencyState: PoolEmergencyState
1477 access(self) var emergencyReason: String?
1478 access(self) var emergencyActivatedAt: UFix64?
1479 access(self) var emergencyConfig: EmergencyConfig
1480 access(self) var consecutiveWithdrawFailures: Int
1481
1482 access(self) var fundingPolicy: FundingPolicy
1483
1484 access(contract) fun setPoolID(id: UInt64) {
1485 self.poolID = id
1486 }
1487
1488 // User tracking
1489 access(self) let receiverDeposits: {UInt64: UFix64}
1490 access(self) let receiverTotalEarnedSavings: {UInt64: UFix64}
1491 access(self) let receiverTotalEarnedPrizes: {UInt64: UFix64}
1492 access(self) let receiverPrizes: {UInt64: UFix64}
1493 access(self) let registeredReceivers: {UInt64: Bool}
1494 access(self) let receiverBonusWeights: {UInt64: BonusWeightRecord}
1495 access(all) var totalDeposited: UFix64
1496 access(all) var totalStaked: UFix64
1497 access(all) var lotteryStaked: UFix64
1498 access(all) var lastDrawTimestamp: UFix64
1499 access(self) let savingsDistributor: @SavingsDistributor
1500 access(self) let lotteryDistributor: @LotteryDistributor
1501 access(self) let treasuryDistributor: @TreasuryDistributor
1502
1503 // Liquid vault and draw state
1504 access(self) var liquidVault: @{FungibleToken.Vault}
1505 access(self) var pendingDrawReceipt: @PrizeDrawReceipt?
1506 access(self) let randomConsumer: @RandomConsumer.Consumer
1507
1508 init(
1509 config: PoolConfig,
1510 initialVault: @{FungibleToken.Vault},
1511 emergencyConfig: EmergencyConfig?,
1512 fundingPolicy: FundingPolicy?
1513 ) {
1514 pre {
1515 initialVault.getType() == config.assetType: "Vault type mismatch"
1516 initialVault.balance == 0.0: "Initial vault must be empty"
1517 }
1518
1519 self.config = config
1520 self.poolID = 0
1521
1522 self.emergencyState = PoolEmergencyState.Normal
1523 self.emergencyReason = nil
1524 self.emergencyActivatedAt = nil
1525 self.emergencyConfig = emergencyConfig ?? PrizeVaultModular.createDefaultEmergencyConfig()
1526 self.consecutiveWithdrawFailures = 0
1527
1528 self.fundingPolicy = fundingPolicy ?? PrizeVaultModular.createDefaultFundingPolicy()
1529
1530 self.receiverDeposits = {}
1531 self.receiverTotalEarnedSavings = {}
1532 self.receiverTotalEarnedPrizes = {}
1533 self.receiverPrizes = {}
1534 self.registeredReceivers = {}
1535 self.receiverBonusWeights = {}
1536 self.totalDeposited = 0.0
1537 self.totalStaked = 0.0
1538 self.lotteryStaked = 0.0
1539 self.lastDrawTimestamp = 0.0
1540
1541 self.savingsDistributor <- create SavingsDistributor(vaultType: config.assetType)
1542 self.lotteryDistributor <- create LotteryDistributor(vaultType: config.assetType)
1543 self.treasuryDistributor <- create TreasuryDistributor(vaultType: config.assetType)
1544
1545 self.liquidVault <- initialVault
1546 self.pendingDrawReceipt <- nil
1547 self.randomConsumer <- RandomConsumer.createConsumer()
1548 }
1549
1550 access(all) fun registerReceiver(receiverID: UInt64) {
1551 pre {
1552 self.registeredReceivers[receiverID] == nil: "Receiver already registered"
1553 }
1554 self.registeredReceivers[receiverID] = true
1555 }
1556
1557
1558 access(all) view fun getEmergencyState(): PoolEmergencyState {
1559 return self.emergencyState
1560 }
1561
1562 access(all) view fun getEmergencyConfig(): EmergencyConfig {
1563 return self.emergencyConfig
1564 }
1565
1566 access(contract) fun setState(state: PoolEmergencyState, reason: String?) {
1567 self.emergencyState = state
1568 if state != PoolEmergencyState.Normal {
1569 self.emergencyReason = reason
1570 self.emergencyActivatedAt = getCurrentBlock().timestamp
1571 } else {
1572 self.emergencyReason = nil
1573 self.emergencyActivatedAt = nil
1574 self.consecutiveWithdrawFailures = 0
1575 }
1576 }
1577
1578 access(contract) fun setEmergencyMode(reason: String) {
1579 self.emergencyState = PoolEmergencyState.EmergencyMode
1580 self.emergencyReason = reason
1581 self.emergencyActivatedAt = getCurrentBlock().timestamp
1582 }
1583
1584 access(contract) fun setPartialMode(reason: String) {
1585 self.emergencyState = PoolEmergencyState.PartialMode
1586 self.emergencyReason = reason
1587 self.emergencyActivatedAt = getCurrentBlock().timestamp
1588 }
1589
1590 access(contract) fun clearEmergencyMode() {
1591 self.emergencyState = PoolEmergencyState.Normal
1592 self.emergencyReason = nil
1593 self.emergencyActivatedAt = nil
1594 self.consecutiveWithdrawFailures = 0
1595 }
1596
1597 access(contract) fun setEmergencyConfig(config: EmergencyConfig) {
1598 self.emergencyConfig = config
1599 }
1600
1601 access(all) view fun isEmergencyMode(): Bool {
1602 return self.emergencyState == PoolEmergencyState.EmergencyMode
1603 }
1604
1605 access(all) view fun isPartialMode(): Bool {
1606 return self.emergencyState == PoolEmergencyState.PartialMode
1607 }
1608
1609 access(all) fun getEmergencyInfo(): {String: AnyStruct}? {
1610 if self.emergencyState != PoolEmergencyState.Normal {
1611 let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
1612 let health = self.checkYieldSourceHealth()
1613 return {
1614 "state": self.emergencyState.rawValue,
1615 "reason": self.emergencyReason ?? "Unknown",
1616 "activatedAt": self.emergencyActivatedAt ?? 0.0,
1617 "durationSeconds": duration,
1618 "yieldSourceHealth": health,
1619 "canAutoRecover": self.emergencyConfig.autoRecoveryEnabled,
1620 "maxDuration": self.emergencyConfig.maxEmergencyDuration
1621 }
1622 }
1623 return nil
1624 }
1625
1626 access(contract) fun checkYieldSourceHealth(): UFix64 {
1627 let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
1628 let balance = yieldSource.minimumAvailable()
1629 let threshold = self.getEmergencyConfig().minBalanceThreshold
1630 let balanceHealthy = balance >= self.totalStaked * threshold
1631 let withdrawSuccessRate = self.consecutiveWithdrawFailures == 0 ? 1.0 :
1632 (1.0 / UFix64(self.consecutiveWithdrawFailures + 1))
1633
1634 var health: UFix64 = 0.0
1635 if balanceHealthy { health = health + 0.5 }
1636 health = health + (withdrawSuccessRate * 0.5)
1637 return health
1638 }
1639
1640 access(contract) fun checkAndAutoTriggerEmergency(): Bool {
1641 if self.emergencyState != PoolEmergencyState.Normal {
1642 return false
1643 }
1644
1645 let health = self.checkYieldSourceHealth()
1646 if health < self.emergencyConfig.minYieldSourceHealth {
1647 self.setEmergencyMode(reason: "Auto-triggered: Yield source health below threshold (".concat(health.toString()).concat(")"))
1648 emit EmergencyModeAutoTriggered(poolID: self.poolID, reason: "Low yield source health", healthScore: health, timestamp: getCurrentBlock().timestamp)
1649 return true
1650 }
1651
1652 if self.consecutiveWithdrawFailures >= self.emergencyConfig.maxWithdrawFailures {
1653 self.setEmergencyMode(reason: "Auto-triggered: Multiple consecutive withdrawal failures")
1654 emit EmergencyModeAutoTriggered(poolID: self.poolID, reason: "Withdrawal failures", healthScore: health, timestamp: getCurrentBlock().timestamp)
1655 return true
1656 }
1657
1658 return false
1659 }
1660
1661 access(contract) fun checkAndAutoRecover(): Bool {
1662 if self.emergencyState != PoolEmergencyState.EmergencyMode {
1663 return false
1664 }
1665
1666 if !self.emergencyConfig.autoRecoveryEnabled {
1667 return false
1668 }
1669
1670 if let maxDuration = self.emergencyConfig.maxEmergencyDuration {
1671 let duration = getCurrentBlock().timestamp - (self.emergencyActivatedAt ?? 0.0)
1672 if duration > maxDuration {
1673 self.clearEmergencyMode()
1674 emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Max duration exceeded", healthScore: nil, duration: duration, timestamp: getCurrentBlock().timestamp)
1675 return true
1676 }
1677 }
1678
1679 let health = self.checkYieldSourceHealth()
1680 if health >= 0.9 {
1681 self.clearEmergencyMode()
1682 emit EmergencyModeAutoRecovered(poolID: self.poolID, reason: "Yield source recovered", healthScore: health, duration: nil, timestamp: getCurrentBlock().timestamp)
1683 return true
1684 }
1685
1686 return false
1687 }
1688
1689
1690 /// Distribute savings interest to all users
1691 /// This is the core savings distribution logic used by both direct funding and process rewards
1692 ///
1693 /// Flow:
1694 /// 1. Snapshot totalDeposited BEFORE compounding (prevents dilution)
1695 /// 2. Compound any existing pending interest
1696 /// 3. Distribute new interest based on snapshot
1697 /// 4. Compound the newly distributed interest
1698 /// 5. Send any rounding dust to treasury
1699 access(contract) fun distributeSavingsInterest(vault: @{FungibleToken.Vault}) {
1700 let amount = vault.balance
1701
1702 let totalDepositedSnapshot = self.totalDeposited
1703
1704 self.compoundAllPendingSavings()
1705
1706 // Distribute new interest based on snapshot
1707 let interestPerShare = self.savingsDistributor.distributeInterestAndReinvest(
1708 vault: <- vault,
1709 totalDeposited: totalDepositedSnapshot,
1710 yieldSink: &self.config.yieldConnector as &{DeFiActions.Sink}
1711 )
1712
1713 // Compound the newly distributed interest (updates totalDeposited and totalStaked)
1714 let totalCompounded = self.compoundAllPendingSavings()
1715
1716 // Handle rounding dust: send to treasury to prevent yield source imbalance
1717 if totalCompounded < amount {
1718 let dust = amount - totalCompounded
1719 let dustVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: dust)
1720 self.treasuryDistributor.deposit(vault: <- dustVault)
1721
1722 emit SavingsRoundingDustToTreasury(poolID: self.poolID, amount: dust)
1723 }
1724
1725 emit SavingsInterestDistributed(poolID: self.poolID, amount: amount, interestPerShare: interestPerShare)
1726 }
1727
1728 access(contract) fun compoundAllPendingSavings(): UFix64 {
1729 let receiverIDs = self.getRegisteredReceiverIDs()
1730 var totalCompounded: UFix64 = 0.0
1731 var usersCompounded: Int = 0
1732
1733 for receiverID in receiverIDs {
1734 let currentDeposit = self.receiverDeposits[receiverID] ?? 0.0
1735 if currentDeposit > 0.0 {
1736 let pending = self.savingsDistributor.claimInterest(receiverID: receiverID, deposit: currentDeposit)
1737
1738 if pending > 0.0 {
1739 let newDeposit = currentDeposit + pending
1740 self.receiverDeposits[receiverID] = newDeposit
1741 self.totalDeposited = self.totalDeposited + pending
1742 totalCompounded = totalCompounded + pending
1743 self.totalStaked = self.totalStaked + pending
1744 self.savingsDistributor.updateAfterBalanceChange(receiverID: receiverID, newDeposit: newDeposit)
1745
1746 let currentSavings = self.receiverTotalEarnedSavings[receiverID] ?? 0.0
1747 self.receiverTotalEarnedSavings[receiverID] = currentSavings + pending
1748
1749 usersCompounded = usersCompounded + 1
1750 }
1751 }
1752 }
1753
1754 if usersCompounded > 0 {
1755 let avgAmount = totalCompounded / UFix64(usersCompounded)
1756 emit SavingsInterestCompoundedBatch(
1757 poolID: self.poolID,
1758 userCount: usersCompounded,
1759 totalAmount: totalCompounded,
1760 avgAmount: avgAmount
1761 )
1762 }
1763
1764 return totalCompounded
1765 }
1766
1767 access(contract) fun fundDirectInternal(
1768 destination: PoolFundingDestination,
1769 from: @{FungibleToken.Vault},
1770 sponsor: Address,
1771 purpose: String,
1772 metadata: {String: String}
1773 ) {
1774 pre {
1775 self.emergencyState == PoolEmergencyState.Normal: "Direct funding only in normal state"
1776 from.getType() == self.config.assetType: "Invalid vault type"
1777 }
1778
1779 let amount = from.balance
1780 self.fundingPolicy.recordDirectFunding(destination: destination, amount: amount)
1781
1782 switch destination {
1783 case PoolFundingDestination.Lottery:
1784 self.lotteryDistributor.fundPrizePool(vault: <- from)
1785 case PoolFundingDestination.Treasury:
1786 self.treasuryDistributor.deposit(vault: <- from)
1787 case PoolFundingDestination.Savings:
1788 self.distributeSavingsInterest(vault: <- from)
1789 default:
1790 panic("Unsupported funding destination")
1791 }
1792 }
1793
1794 access(all) fun getFundingStats(): {String: UFix64} {
1795 return {
1796 "totalDirectLottery": self.fundingPolicy.totalDirectLottery,
1797 "totalDirectTreasury": self.fundingPolicy.totalDirectTreasury,
1798 "totalDirectSavings": self.fundingPolicy.totalDirectSavings,
1799 "maxDirectLottery": self.fundingPolicy.maxDirectLottery ?? 0.0,
1800 "maxDirectTreasury": self.fundingPolicy.maxDirectTreasury ?? 0.0,
1801 "maxDirectSavings": self.fundingPolicy.maxDirectSavings ?? 0.0
1802 }
1803 }
1804
1805 access(all) fun deposit(from: @{FungibleToken.Vault}, receiverID: UInt64) {
1806 pre {
1807 from.balance > 0.0: "Deposit amount must be positive"
1808 from.getType() == self.config.assetType: "Invalid vault type"
1809 self.registeredReceivers[receiverID] == true: "Receiver not registered"
1810 }
1811
1812 switch self.emergencyState {
1813 case PoolEmergencyState.Normal:
1814 assert(from.balance >= self.config.minimumDeposit, message: "Below minimum deposit of ".concat(self.config.minimumDeposit.toString()))
1815 case PoolEmergencyState.PartialMode:
1816 let depositLimit = self.emergencyConfig.partialModeDepositLimit ?? 0.0
1817 assert(depositLimit > 0.0, message: "Partial mode deposit limit not configured")
1818 assert(from.balance <= depositLimit, message: "Deposit exceeds partial mode limit of ".concat(depositLimit.toString()))
1819 case PoolEmergencyState.EmergencyMode:
1820 panic("Deposits disabled in emergency mode. Withdrawals only.")
1821 case PoolEmergencyState.Paused:
1822 panic("Pool is paused. No operations allowed.")
1823 }
1824
1825 let amount = from.balance
1826 let isFirstDeposit = (self.receiverDeposits[receiverID] ?? 0.0) == 0.0
1827 var pendingCompounded: UFix64 = 0.0
1828
1829 if !isFirstDeposit {
1830 let currentDeposit = self.receiverDeposits[receiverID]!
1831 let pending = self.savingsDistributor.claimInterest(receiverID: receiverID, deposit: currentDeposit)
1832 if pending > 0.0 {
1833 self.receiverDeposits[receiverID] = currentDeposit + pending
1834 pendingCompounded = pending
1835 self.totalDeposited = self.totalDeposited + pending
1836 self.totalStaked = self.totalStaked + pending
1837
1838 let currentSavings = self.receiverTotalEarnedSavings[receiverID] ?? 0.0
1839 self.receiverTotalEarnedSavings[receiverID] = currentSavings + pending
1840 emit SavingsInterestCompounded(poolID: self.poolID, receiverID: receiverID, amount: pending)
1841 }
1842 }
1843
1844 let newDeposit = (self.receiverDeposits[receiverID] ?? 0.0) + amount
1845 self.receiverDeposits[receiverID] = newDeposit
1846 self.totalDeposited = self.totalDeposited + amount
1847 self.totalStaked = self.totalStaked + amount
1848
1849 if isFirstDeposit {
1850 self.savingsDistributor.initializeReceiver(receiverID: receiverID, deposit: amount)
1851 } else {
1852 self.savingsDistributor.updateAfterBalanceChange(receiverID: receiverID, newDeposit: newDeposit)
1853 }
1854 self.config.yieldConnector.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
1855 destroy from
1856 emit Deposited(poolID: self.poolID, receiverID: receiverID, amount: amount)
1857 }
1858
1859 access(all) fun withdraw(amount: UFix64, receiverID: UInt64): @{FungibleToken.Vault} {
1860 pre {
1861 amount > 0.0: "Withdrawal amount must be positive"
1862 self.registeredReceivers[receiverID] == true: "Receiver not registered"
1863 }
1864
1865 assert(self.emergencyState != PoolEmergencyState.Paused, message: "Pool is paused - no operations allowed")
1866
1867 if self.emergencyState == PoolEmergencyState.EmergencyMode {
1868 self.checkAndAutoRecover()
1869 }
1870
1871 let receiverDeposit = self.receiverDeposits[receiverID] ?? 0.0
1872 assert(receiverDeposit >= amount, message: "Insufficient deposit. You have ".concat(receiverDeposit.toString()).concat(" but trying to withdraw ").concat(amount.toString()))
1873
1874 if self.emergencyState == PoolEmergencyState.EmergencyMode {
1875 log("⚠️ Emergency withdrawal - skipping interest compounding")
1876 } else {
1877 let pending = self.savingsDistributor.claimInterest(receiverID: receiverID, deposit: receiverDeposit)
1878 if pending > 0.0 {
1879 let newDepositWithPending = receiverDeposit + pending
1880 self.receiverDeposits[receiverID] = newDepositWithPending
1881 self.totalDeposited = self.totalDeposited + pending
1882 self.totalStaked = self.totalStaked + pending
1883 self.savingsDistributor.updateAfterBalanceChange(receiverID: receiverID, newDeposit: newDepositWithPending)
1884 let currentSavings = self.receiverTotalEarnedSavings[receiverID] ?? 0.0
1885 self.receiverTotalEarnedSavings[receiverID] = currentSavings + pending
1886 emit SavingsInterestCompounded(poolID: self.poolID, receiverID: receiverID, amount: pending)
1887 }
1888 }
1889
1890 let currentDeposit = self.receiverDeposits[receiverID] ?? 0.0
1891 let newDeposit = currentDeposit - amount
1892 self.receiverDeposits[receiverID] = newDeposit
1893 self.totalDeposited = self.totalDeposited - amount
1894 self.savingsDistributor.updateAfterBalanceChange(receiverID: receiverID, newDeposit: newDeposit)
1895
1896 var withdrawn <- DeFiActionsUtils.getEmptyVault(self.config.assetType)
1897 var withdrawalFailed = false
1898 var amountFromYieldSource: UFix64 = 0.0
1899
1900 if self.emergencyState == PoolEmergencyState.EmergencyMode {
1901 let yieldAvailable = self.config.yieldConnector.minimumAvailable()
1902 if yieldAvailable >= amount {
1903 let yieldVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: amount)
1904 amountFromYieldSource = yieldVault.balance
1905 withdrawn.deposit(from: <- yieldVault)
1906 } else {
1907 log("⚠️ Yield source insufficient in emergency, using liquid vault")
1908 withdrawalFailed = true
1909 }
1910 } else {
1911 let yieldAvailable = self.config.yieldConnector.minimumAvailable()
1912 if yieldAvailable >= amount {
1913 let yieldVault <- self.config.yieldConnector.withdrawAvailable(maxAmount: amount)
1914 amountFromYieldSource = yieldVault.balance
1915 withdrawn.deposit(from: <- yieldVault)
1916 } else {
1917 withdrawalFailed = true
1918 }
1919
1920 if withdrawalFailed {
1921 self.consecutiveWithdrawFailures = self.consecutiveWithdrawFailures + 1
1922 emit WithdrawalFailure(poolID: self.poolID, receiverID: receiverID, amount: amount,
1923 consecutiveFailures: self.consecutiveWithdrawFailures, yieldAvailable: yieldAvailable)
1924 self.checkAndAutoTriggerEmergency()
1925 } else {
1926 self.consecutiveWithdrawFailures = 0
1927 }
1928 }
1929
1930 // Use liquid vault if needed
1931 if withdrawn.balance < amount {
1932 let remaining = amount - withdrawn.balance
1933 withdrawn.deposit(from: <- self.liquidVault.withdraw(amount: remaining))
1934 }
1935
1936 // Only decrease totalStaked by amount withdrawn from yield source (not liquid vault fallback)
1937 self.totalStaked = self.totalStaked - amountFromYieldSource
1938
1939 emit Withdrawn(poolID: self.poolID, receiverID: receiverID, amount: amount)
1940 return <- withdrawn
1941 }
1942
1943 access(contract) fun processRewards() {
1944 let yieldBalance = self.config.yieldConnector.minimumAvailable()
1945 let availableYield = yieldBalance > self.totalStaked ? yieldBalance - self.totalStaked : 0.0
1946
1947 if availableYield == 0.0 {
1948 return
1949 }
1950
1951 let yieldRewards <- self.config.yieldConnector.withdrawAvailable(maxAmount: availableYield)
1952 let totalRewards = yieldRewards.balance
1953 let plan = self.config.distributionStrategy.calculateDistribution(totalAmount: totalRewards)
1954
1955 if plan.savingsAmount > 0.0 {
1956 // Process rewards: collect from yield source, invest and distribute
1957 let savingsVault <- yieldRewards.withdraw(amount: plan.savingsAmount)
1958 self.distributeSavingsInterest(vault: <- savingsVault)
1959 }
1960
1961 if plan.lotteryAmount > 0.0 {
1962 let lotteryVault <- yieldRewards.withdraw(amount: plan.lotteryAmount)
1963 self.lotteryDistributor.fundPrizePool(vault: <- lotteryVault)
1964 emit LotteryPrizePoolFunded(
1965 poolID: self.poolID,
1966 amount: plan.lotteryAmount,
1967 source: "yield"
1968 )
1969 }
1970
1971 if plan.treasuryAmount > 0.0 {
1972 let treasuryVault <- yieldRewards.withdraw(amount: plan.treasuryAmount)
1973 self.treasuryDistributor.deposit(vault: <- treasuryVault)
1974 emit TreasuryFunded(
1975 poolID: self.poolID,
1976 amount: plan.treasuryAmount,
1977 source: "yield"
1978 )
1979 }
1980
1981 destroy yieldRewards
1982
1983 emit RewardsProcessed(
1984 poolID: self.poolID,
1985 totalAmount: totalRewards,
1986 savingsAmount: plan.savingsAmount,
1987 lotteryAmount: plan.lotteryAmount
1988 )
1989 }
1990
1991 access(all) fun startDraw() {
1992 pre {
1993 self.emergencyState == PoolEmergencyState.Normal: "Draws disabled - pool state: ".concat(self.emergencyState.rawValue.toString())
1994 self.pendingDrawReceipt == nil: "Draw already in progress"
1995 }
1996
1997 assert(self.canDrawNow(), message: "Not enough blocks since last draw")
1998
1999 self.checkAndAutoTriggerEmergency()
2000
2001 let timeWeightedStakes: {UInt64: UFix64} = {}
2002 for receiverID in self.receiverDeposits.keys {
2003 let deposit = self.receiverDeposits[receiverID]!
2004 let bonusWeight = self.getBonusWeight(receiverID: receiverID)
2005 let stake = deposit + bonusWeight
2006
2007 if stake > 0.0 {
2008 timeWeightedStakes[receiverID] = stake
2009 }
2010 }
2011
2012 let prizeAmount = self.lotteryDistributor.getPrizePoolBalance()
2013 assert(prizeAmount > 0.0, message: "No prize pool funds")
2014
2015 let randomRequest <- self.randomConsumer.requestRandomness()
2016 let receipt <- create PrizeDrawReceipt(
2017 prizeAmount: prizeAmount,
2018 request: <- randomRequest,
2019 timeWeightedStakes: timeWeightedStakes
2020 )
2021 emit PrizeDrawCommitted(
2022 poolID: self.poolID,
2023 prizeAmount: prizeAmount,
2024 commitBlock: receipt.getRequestBlock()!
2025 )
2026
2027 self.pendingDrawReceipt <-! receipt
2028 self.lastDrawTimestamp = getCurrentBlock().timestamp
2029 }
2030
2031 access(all) fun completeDraw() {
2032 pre {
2033 self.pendingDrawReceipt != nil: "No draw in progress"
2034 }
2035
2036 let receipt <- self.pendingDrawReceipt <- nil
2037 let unwrappedReceipt <- receipt!
2038 let totalPrizeAmount = unwrappedReceipt.prizeAmount
2039
2040 let timeWeightedStakes = unwrappedReceipt.getTimeWeightedStakes()
2041
2042 let request <- unwrappedReceipt.popRequest()
2043 let randomNumber = self.randomConsumer.fulfillRandomRequest(<- request)
2044 destroy unwrappedReceipt
2045
2046 let selectionResult = self.config.winnerSelectionStrategy.selectWinners(
2047 randomNumber: randomNumber,
2048 receiverDeposits: timeWeightedStakes,
2049 totalPrizeAmount: totalPrizeAmount
2050 )
2051
2052 let winners = selectionResult.winners
2053 let prizeAmounts = selectionResult.amounts
2054 let nftIDsPerWinner = selectionResult.nftIDs
2055
2056 if winners.length == 0 {
2057 emit PrizesAwarded(
2058 poolID: self.poolID,
2059 winners: [],
2060 amounts: [],
2061 round: self.lotteryDistributor.getPrizeRound()
2062 )
2063 log("⚠️ Draw completed with no depositors - ".concat(totalPrizeAmount.toString()).concat(" FLOW stays for next draw"))
2064 return
2065 }
2066
2067 assert(winners.length == prizeAmounts.length, message: "Winners and prize amounts must match")
2068 assert(winners.length == nftIDsPerWinner.length, message: "Winners and NFT IDs must match")
2069
2070 let currentRound = self.lotteryDistributor.getPrizeRound() + 1
2071 self.lotteryDistributor.setPrizeRound(round: currentRound)
2072 var totalAwarded: UFix64 = 0.0
2073 var i = 0
2074 while i < winners.length {
2075 let winnerID = winners[i]
2076 let prizeAmount = prizeAmounts[i]
2077 let nftIDsForWinner = nftIDsPerWinner[i]
2078
2079 let prizeVault <- self.lotteryDistributor.awardPrize(
2080 receiverID: winnerID,
2081 amount: prizeAmount,
2082 yieldSource: nil
2083 )
2084
2085 let currentDeposit = self.receiverDeposits[winnerID] ?? 0.0
2086 let pendingSavings = self.savingsDistributor.claimInterest(receiverID: winnerID, deposit: currentDeposit)
2087 var newDeposit = currentDeposit
2088
2089 if pendingSavings > 0.0 {
2090 newDeposit = newDeposit + pendingSavings
2091 self.totalDeposited = self.totalDeposited + pendingSavings
2092 self.totalStaked = self.totalStaked + pendingSavings
2093
2094 let currentSavings = self.receiverTotalEarnedSavings[winnerID] ?? 0.0
2095 self.receiverTotalEarnedSavings[winnerID] = currentSavings + pendingSavings
2096 }
2097
2098 newDeposit = newDeposit + prizeAmount
2099 self.receiverDeposits[winnerID] = newDeposit
2100 self.totalDeposited = self.totalDeposited + prizeAmount
2101 self.totalStaked = self.totalStaked + prizeAmount
2102 self.savingsDistributor.updateAfterBalanceChange(receiverID: winnerID, newDeposit: newDeposit)
2103 self.config.yieldConnector.depositCapacity(from: &prizeVault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
2104 destroy prizeVault
2105
2106 let totalPrizes = self.receiverTotalEarnedPrizes[winnerID] ?? 0.0
2107 self.receiverTotalEarnedPrizes[winnerID] = totalPrizes + prizeAmount
2108
2109 for nftID in nftIDsForWinner {
2110 let availableNFTs = self.lotteryDistributor.getAvailableNFTPrizeIDs()
2111 var nftFound = false
2112 for availableID in availableNFTs {
2113 if availableID == nftID {
2114 nftFound = true
2115 break
2116 }
2117 }
2118
2119 if !nftFound {
2120 continue
2121 }
2122
2123 let nft <- self.lotteryDistributor.withdrawNFTPrize(nftID: nftID)
2124 let nftType = nft.getType().identifier
2125
2126 // Store as pending claim - winner claims via separate transaction
2127 self.lotteryDistributor.storePendingNFT(receiverID: winnerID, nft: <- nft)
2128
2129 emit NFTPrizeStored(
2130 poolID: self.poolID,
2131 receiverID: winnerID,
2132 nftID: nftID,
2133 nftType: nftType,
2134 reason: "Lottery win - round ".concat(currentRound.toString())
2135 )
2136
2137 emit NFTPrizeAwarded(
2138 poolID: self.poolID,
2139 receiverID: winnerID,
2140 nftID: nftID,
2141 nftType: nftType,
2142 round: currentRound
2143 )
2144 }
2145
2146 totalAwarded = totalAwarded + prizeAmount
2147 i = i + 1
2148 }
2149
2150 if let trackerCap = self.config.winnerTrackerCap {
2151 if let trackerRef = trackerCap.borrow() {
2152 var idx = 0
2153 while idx < winners.length {
2154 trackerRef.recordWinner(
2155 poolID: self.poolID,
2156 round: currentRound,
2157 winnerReceiverID: winners[idx],
2158 amount: prizeAmounts[idx],
2159 nftIDs: nftIDsPerWinner[idx]
2160 )
2161 idx = idx + 1
2162 }
2163 }
2164 }
2165
2166 emit PrizesAwarded(
2167 poolID: self.poolID,
2168 winners: winners,
2169 amounts: prizeAmounts,
2170 round: currentRound
2171 )
2172 }
2173
2174 access(all) fun compoundSavingsInterest(receiverID: UInt64): UFix64 {
2175 pre {
2176 self.registeredReceivers[receiverID] == true: "Receiver not registered"
2177 }
2178
2179 let currentDeposit = self.receiverDeposits[receiverID] ?? 0.0
2180 let pending = self.savingsDistributor.claimInterest(receiverID: receiverID, deposit: currentDeposit)
2181
2182 if pending > 0.0 {
2183 // Add pending savings to deposit
2184 let newDeposit = currentDeposit + pending
2185 self.receiverDeposits[receiverID] = newDeposit
2186 self.totalDeposited = self.totalDeposited + pending
2187 self.totalStaked = self.totalStaked + pending
2188
2189 // Update savings distributor to track the new balance
2190 self.savingsDistributor.updateAfterBalanceChange(receiverID: receiverID, newDeposit: newDeposit)
2191
2192 // Track historical savings for user reference
2193 let currentSavings = self.receiverTotalEarnedSavings[receiverID] ?? 0.0
2194 self.receiverTotalEarnedSavings[receiverID] = currentSavings + pending
2195
2196 emit SavingsInterestCompounded(poolID: self.poolID, receiverID: receiverID, amount: pending)
2197 }
2198
2199 return pending
2200 }
2201
2202 // Admin functions for strategy updates
2203 access(contract) fun getDistributionStrategyName(): String {
2204 return self.config.distributionStrategy.getStrategyName()
2205 }
2206
2207 access(contract) fun setDistributionStrategy(strategy: {DistributionStrategy}) {
2208 self.config.setDistributionStrategy(strategy: strategy)
2209 }
2210
2211 access(contract) fun getWinnerSelectionStrategyName(): String {
2212 return self.config.winnerSelectionStrategy.getStrategyName()
2213 }
2214
2215 access(contract) fun setWinnerSelectionStrategy(strategy: {WinnerSelectionStrategy}) {
2216 self.config.setWinnerSelectionStrategy(strategy: strategy)
2217 }
2218
2219 access(all) fun hasWinnerTracker(): Bool {
2220 return self.config.winnerTrackerCap != nil
2221 }
2222
2223 access(contract) fun setWinnerTrackerCap(cap: Capability<&{PrizeWinnerTracker.WinnerTrackerPublic}>?) {
2224 self.config.setWinnerTrackerCap(cap: cap)
2225 }
2226
2227 access(contract) fun setDrawIntervalSeconds(interval: UFix64) {
2228 assert(!self.isDrawInProgress(), message: "Cannot change draw interval during an active draw")
2229 self.config.setDrawIntervalSeconds(interval: interval)
2230 }
2231
2232 access(contract) fun setMinimumDeposit(minimum: UFix64) {
2233 self.config.setMinimumDeposit(minimum: minimum)
2234 }
2235
2236 access(contract) fun setBonusWeight(receiverID: UInt64, bonusWeight: UFix64, reason: String, setBy: Address) {
2237 let timestamp = getCurrentBlock().timestamp
2238 let record = BonusWeightRecord(bonusWeight: bonusWeight, reason: reason, addedBy: setBy)
2239 self.receiverBonusWeights[receiverID] = record
2240
2241 emit BonusLotteryWeightSet(
2242 poolID: self.poolID,
2243 receiverID: receiverID,
2244 bonusWeight: bonusWeight,
2245 reason: reason,
2246 setBy: setBy,
2247 timestamp: timestamp
2248 )
2249 }
2250
2251 access(contract) fun addBonusWeight(receiverID: UInt64, additionalWeight: UFix64, reason: String, addedBy: Address) {
2252 let timestamp = getCurrentBlock().timestamp
2253 let currentBonus = self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2254 let newTotalBonus = currentBonus + additionalWeight
2255
2256 let record = BonusWeightRecord(bonusWeight: newTotalBonus, reason: reason, addedBy: addedBy)
2257 self.receiverBonusWeights[receiverID] = record
2258
2259 emit BonusLotteryWeightAdded(
2260 poolID: self.poolID,
2261 receiverID: receiverID,
2262 additionalWeight: additionalWeight,
2263 newTotalBonus: newTotalBonus,
2264 reason: reason,
2265 addedBy: addedBy,
2266 timestamp: timestamp
2267 )
2268 }
2269
2270 access(contract) fun removeBonusWeight(receiverID: UInt64, removedBy: Address) {
2271 let timestamp = getCurrentBlock().timestamp
2272 let previousBonus = self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2273
2274 let _ = self.receiverBonusWeights.remove(key: receiverID)
2275
2276 emit BonusLotteryWeightRemoved(
2277 poolID: self.poolID,
2278 receiverID: receiverID,
2279 previousBonus: previousBonus,
2280 removedBy: removedBy,
2281 timestamp: timestamp
2282 )
2283 }
2284
2285 access(all) fun getBonusWeight(receiverID: UInt64): UFix64 {
2286 return self.receiverBonusWeights[receiverID]?.bonusWeight ?? 0.0
2287 }
2288
2289 access(all) fun getBonusWeightRecord(receiverID: UInt64): BonusWeightRecord? {
2290 return self.receiverBonusWeights[receiverID]
2291 }
2292
2293 access(all) fun getAllBonusWeightReceivers(): [UInt64] {
2294 return self.receiverBonusWeights.keys
2295 }
2296
2297 // NFT Prize Management (delegated to LotteryDistributor)
2298 access(contract) fun depositNFTPrize(nft: @{NonFungibleToken.NFT}) {
2299 self.lotteryDistributor.depositNFTPrize(nft: <- nft)
2300 }
2301
2302 access(contract) fun withdrawNFTPrize(nftID: UInt64): @{NonFungibleToken.NFT} {
2303 return <- self.lotteryDistributor.withdrawNFTPrize(nftID: nftID)
2304 }
2305
2306 access(all) fun getAvailableNFTPrizeIDs(): [UInt64] {
2307 return self.lotteryDistributor.getAvailableNFTPrizeIDs()
2308 }
2309
2310 access(all) fun borrowAvailableNFTPrize(nftID: UInt64): &{NonFungibleToken.NFT}? {
2311 return self.lotteryDistributor.borrowNFTPrize(nftID: nftID)
2312 }
2313
2314 access(all) fun getPendingNFTCount(receiverID: UInt64): Int {
2315 return self.lotteryDistributor.getPendingNFTCount(receiverID: receiverID)
2316 }
2317
2318 access(all) fun getPendingNFTIDs(receiverID: UInt64): [UInt64] {
2319 return self.lotteryDistributor.getPendingNFTIDs(receiverID: receiverID)
2320 }
2321
2322 // Note: Pending NFTs cannot be borrowed directly due to Cadence limitations
2323 // Use getPendingNFTIDs() to see what's pending, then claim and view in wallet
2324
2325 access(all) fun claimPendingNFT(receiverID: UInt64, nftIndex: Int): @{NonFungibleToken.NFT} {
2326 let nft <- self.lotteryDistributor.claimPendingNFT(receiverID: receiverID, nftIndex: nftIndex)
2327 let nftType = nft.getType().identifier
2328
2329 emit NFTPrizeClaimed(
2330 poolID: self.poolID,
2331 receiverID: receiverID,
2332 nftID: nft.uuid,
2333 nftType: nftType
2334 )
2335
2336 return <- nft
2337 }
2338
2339 access(all) fun canDrawNow(): Bool {
2340 return (getCurrentBlock().timestamp - self.lastDrawTimestamp) >= self.config.drawIntervalSeconds
2341 }
2342
2343 access(all) fun getReceiverDeposit(receiverID: UInt64): UFix64 {
2344 return self.receiverDeposits[receiverID] ?? 0.0
2345 }
2346
2347 access(all) fun getReceiverTotalEarnedSavings(receiverID: UInt64): UFix64 {
2348 return self.receiverTotalEarnedSavings[receiverID] ?? 0.0
2349 }
2350
2351 access(all) fun getReceiverTotalEarnedPrizes(receiverID: UInt64): UFix64 {
2352 return self.receiverTotalEarnedPrizes[receiverID] ?? 0.0
2353 }
2354
2355 access(all) fun getPendingSavingsInterest(receiverID: UInt64): UFix64 {
2356 let deposit = self.receiverDeposits[receiverID] ?? 0.0
2357 return self.savingsDistributor.calculatePendingInterest(receiverID: receiverID, deposit: deposit)
2358 }
2359
2360 access(all) fun isReceiverRegistered(receiverID: UInt64): Bool {
2361 return self.registeredReceivers[receiverID] == true
2362 }
2363
2364 access(all) fun getRegisteredReceiverIDs(): [UInt64] {
2365 return self.registeredReceivers.keys
2366 }
2367
2368 access(all) fun isDrawInProgress(): Bool {
2369 return self.pendingDrawReceipt != nil
2370 }
2371
2372 access(all) fun getConfig(): PoolConfig {
2373 return self.config
2374 }
2375
2376 access(all) fun getLiquidBalance(): UFix64 {
2377 return self.liquidVault.balance
2378 }
2379
2380 access(all) fun getSavingsPoolBalance(): UFix64 {
2381 return self.savingsDistributor.getInterestVaultBalance()
2382 }
2383
2384 access(all) fun getTotalSavingsDistributed(): UFix64 {
2385 return self.savingsDistributor.getTotalDistributed()
2386 }
2387
2388 /// Calculate current amount of savings generating yield in the yield source
2389 /// This is the difference between totalStaked (all funds in yield source) and totalDeposited (user deposits)
2390 access(all) fun getCurrentReinvestedSavings(): UFix64 {
2391 if self.totalStaked > self.totalDeposited {
2392 return self.totalStaked - self.totalDeposited
2393 }
2394 return 0.0
2395 }
2396
2397 /// Get available yield rewards ready to be collected
2398 /// This is the difference between what's in the yield source and what we've tracked as staked
2399 access(all) fun getAvailableYieldRewards(): UFix64 {
2400 let yieldSource = &self.config.yieldConnector as &{DeFiActions.Source}
2401 let available = yieldSource.minimumAvailable()
2402 if available > self.totalStaked {
2403 return available - self.totalStaked
2404 }
2405 return 0.0
2406 }
2407
2408 access(all) fun getLotteryPoolBalance(): UFix64 {
2409 return self.lotteryDistributor.getPrizePoolBalance()
2410 }
2411
2412 access(all) fun getTreasuryBalance(): UFix64 {
2413 return self.treasuryDistributor.getBalance()
2414 }
2415
2416 access(all) fun getTreasuryStats(): {String: UFix64} {
2417 return {
2418 "balance": self.treasuryDistributor.getBalance(),
2419 "totalCollected": self.treasuryDistributor.getTotalCollected(),
2420 "totalWithdrawn": self.treasuryDistributor.getTotalWithdrawn()
2421 }
2422 }
2423
2424 access(all) fun getTreasuryWithdrawalHistory(): [{String: AnyStruct}] {
2425 return self.treasuryDistributor.getWithdrawalHistory()
2426 }
2427
2428 access(contract) fun withdrawTreasury(
2429 amount: UFix64,
2430 withdrawnBy: Address,
2431 purpose: String
2432 ): @{FungibleToken.Vault} {
2433 return <- self.treasuryDistributor.withdraw(
2434 amount: amount,
2435 withdrawnBy: withdrawnBy,
2436 purpose: purpose
2437 )
2438 }
2439
2440 }
2441
2442 // Pool Position Collection
2443
2444 access(all) struct PoolBalance {
2445 access(all) let deposits: UFix64 // Total deposited (includes auto-compounded savings & prizes)
2446 access(all) let totalEarnedSavings: UFix64 // Historical: total savings earned (auto-compounded)
2447 access(all) let totalEarnedPrizes: UFix64 // Historical: total prizes earned (auto-compounded)
2448 access(all) let pendingSavings: UFix64 // Savings not yet compounded (ready to compound)
2449 access(all) let totalBalance: UFix64 // deposits + pendingSavings (total in yield sink)
2450
2451 init(deposits: UFix64, totalEarnedSavings: UFix64, totalEarnedPrizes: UFix64, pendingSavings: UFix64) {
2452 self.deposits = deposits
2453 self.totalEarnedSavings = totalEarnedSavings
2454 self.totalEarnedPrizes = totalEarnedPrizes
2455 self.pendingSavings = pendingSavings
2456 self.totalBalance = deposits + pendingSavings
2457 }
2458 }
2459
2460 access(all) resource interface PoolPositionCollectionPublic {
2461 access(all) fun getRegisteredPoolIDs(): [UInt64]
2462 access(all) fun isRegisteredWithPool(poolID: UInt64): Bool
2463 access(all) fun deposit(poolID: UInt64, from: @{FungibleToken.Vault})
2464 access(all) fun withdraw(poolID: UInt64, amount: UFix64): @{FungibleToken.Vault}
2465 access(all) fun compoundSavingsInterest(poolID: UInt64): UFix64
2466 access(all) fun getPoolBalance(poolID: UInt64): PoolBalance
2467 }
2468
2469 access(all) resource PoolPositionCollection: PoolPositionCollectionPublic {
2470 access(self) let registeredPools: {UInt64: Bool}
2471
2472 init() {
2473 self.registeredPools = {}
2474 }
2475
2476 access(self) fun registerWithPool(poolID: UInt64) {
2477 pre {
2478 self.registeredPools[poolID] == nil: "Already registered"
2479 }
2480
2481 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
2482 ?? panic("Pool does not exist")
2483
2484 poolRef.registerReceiver(receiverID: self.uuid)
2485 self.registeredPools[poolID] = true
2486 }
2487
2488 access(all) fun getRegisteredPoolIDs(): [UInt64] {
2489 return self.registeredPools.keys
2490 }
2491
2492 access(all) fun isRegisteredWithPool(poolID: UInt64): Bool {
2493 return self.registeredPools[poolID] == true
2494 }
2495
2496 access(all) fun deposit(poolID: UInt64, from: @{FungibleToken.Vault}) {
2497 if self.registeredPools[poolID] == nil {
2498 self.registerWithPool(poolID: poolID)
2499 }
2500
2501 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
2502 ?? panic("Cannot borrow pool")
2503
2504 poolRef.deposit(from: <- from, receiverID: self.uuid)
2505 }
2506
2507 access(all) fun withdraw(poolID: UInt64, amount: UFix64): @{FungibleToken.Vault} {
2508 pre {
2509 self.registeredPools[poolID] == true: "Not registered with pool"
2510 }
2511
2512 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
2513 ?? panic("Cannot borrow pool")
2514
2515 return <- poolRef.withdraw(amount: amount, receiverID: self.uuid)
2516 }
2517
2518 access(all) fun compoundSavingsInterest(poolID: UInt64): UFix64 {
2519 pre {
2520 self.registeredPools[poolID] == true: "Not registered with pool"
2521 }
2522
2523 let poolRef = PrizeVaultModular.borrowPoolAuth(poolID: poolID)
2524 ?? panic("Cannot borrow pool")
2525
2526 return poolRef.compoundSavingsInterest(receiverID: self.uuid)
2527 }
2528
2529 access(all) fun getPoolBalance(poolID: UInt64): PoolBalance {
2530 if self.registeredPools[poolID] == nil {
2531 return PoolBalance(deposits: 0.0, totalEarnedSavings: 0.0, totalEarnedPrizes: 0.0, pendingSavings: 0.0)
2532 }
2533
2534 let poolRef = PrizeVaultModular.borrowPool(poolID: poolID)
2535 if poolRef == nil {
2536 return PoolBalance(deposits: 0.0, totalEarnedSavings: 0.0, totalEarnedPrizes: 0.0, pendingSavings: 0.0)
2537 }
2538
2539 return PoolBalance(
2540 deposits: poolRef!.getReceiverDeposit(receiverID: self.uuid),
2541 totalEarnedSavings: poolRef!.getReceiverTotalEarnedSavings(receiverID: self.uuid),
2542 totalEarnedPrizes: poolRef!.getReceiverTotalEarnedPrizes(receiverID: self.uuid),
2543 pendingSavings: poolRef!.getPendingSavingsInterest(receiverID: self.uuid)
2544 )
2545 }
2546 }
2547
2548 // Contract Functions
2549
2550 access(contract) fun createPool(
2551 config: PoolConfig,
2552 emergencyConfig: EmergencyConfig?,
2553 fundingPolicy: FundingPolicy?
2554 ): UInt64 {
2555 let emptyVault <- DeFiActionsUtils.getEmptyVault(config.assetType)
2556 let pool <- create Pool(
2557 config: config,
2558 initialVault: <- emptyVault,
2559 emergencyConfig: emergencyConfig,
2560 fundingPolicy: fundingPolicy
2561 )
2562
2563 let poolID = self.nextPoolID
2564 self.nextPoolID = self.nextPoolID + 1
2565
2566 pool.setPoolID(id: poolID)
2567 emit PoolCreated(
2568 poolID: poolID,
2569 assetType: config.assetType.identifier,
2570 strategy: config.distributionStrategy.getStrategyName()
2571 )
2572
2573 self.pools[poolID] <-! pool
2574 return poolID
2575 }
2576
2577 access(all) view fun borrowPool(poolID: UInt64): &Pool? {
2578 return &self.pools[poolID]
2579 }
2580
2581 /// Returns authorized pool reference - restricted to contract only for security
2582 /// Only Admin resource and internal contract functions can access this
2583 access(contract) fun borrowPoolAuth(poolID: UInt64): &Pool? {
2584 return &self.pools[poolID]
2585 }
2586
2587 access(all) view fun getAllPoolIDs(): [UInt64] {
2588 return self.pools.keys
2589 }
2590
2591 access(all) fun createPoolPositionCollection(): @PoolPositionCollection {
2592 return <- create PoolPositionCollection()
2593 }
2594
2595 init() {
2596 self.PoolPositionCollectionStoragePath = /storage/PrizeVaultModularCollection
2597 self.PoolPositionCollectionPublicPath = /public/PrizeVaultModularCollection
2598
2599 self.AdminStoragePath = /storage/PrizeVaultModularAdmin
2600 self.AdminPublicPath = /public/PrizeVaultModularAdmin
2601
2602 self.pools <- {}
2603 self.nextPoolID = 0
2604
2605 let admin <- create Admin()
2606 self.account.storage.save(<-admin, to: self.AdminStoragePath)
2607 }
2608}