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