A No-Loss Lottery Protocol on Flow
PrizeLinkedAccounts is a prize-linked savings protocol where users deposit tokens to earn guaranteed savings interest while also having chances to win lottery prizes. Users never lose their principal—all prizes come from yield generated by deposits.
⚠️ Audit Status: This contract is currently undergoing security audit.
- Overview
- Architecture
- Core Contracts
- Key Mechanisms
- Getting Started
- User Flows
- Pool Configuration
- Scripts & Queries
- Events
- Security Considerations
- External Dependencies
- Testing
- Deployment
PrizeLinkedAccounts implements a "no-loss lottery" where:
- Users deposit tokens into savings pools
- Deposits are sent to yield sources (modular DeFi integrations)
- Yield is split between savings interest, lottery prizes, and protocol (configurable)
- Periodic lottery draws award prizes to depositors (weighted by balance × time)
- Users can withdraw their principal + accrued savings interest at any time
- No Loss: Users always retain access to their principal + savings interest
- Fair Odds: Lottery odds are weighted by time-weighted average balance (TWAB)
- Provably Fair: Uses Flow's on-chain randomness (commit-reveal scheme)
- Gas Efficient: ERC4626-style shares enable O(1) interest distribution
- Modular: Pluggable yield sources, distribution strategies, and winner selection
┌─────────────────────────────────────────────────────────────────────────┐
│ PrizeLinkedAccounts │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ PoolPositionCollection│ │ Admin │ │
│ │ (per user) │ │ (protocol owner) │ │
│ └──────────┬──────────┘ └──────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Pool │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ │
│ │ │SavingsDistributor│ │PrizeDistributor│ │ProtocolDistributor│ │ │
│ │ │ (shares/TWAB) │ │ (prizes/NFTs) │ │ (protocol fees) │ │ │
│ │ └────────┬────────┘ └────────┬─────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │ │
│ └───────────┼────────────────────┼──────────────────────┼───────────┘ │
│ │ │ │ │
│ └────────────────────┼──────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Yield Connector (DeFi) │ │
│ │ (modular yield source) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
The main protocol contract containing:
| Component | Description |
|---|---|
| Pool | Core resource managing deposits, withdrawals, yield processing, and prize draws |
| SavingsDistributor | ERC4626-style shares vault with epoch-based TWAB tracking |
| PrizeDistributor | Prize pool management, NFT prizes, and draw execution |
| ProtocolDistributor | Protocol fee collection and withdrawals |
| PoolPositionCollection | User-owned resource for interacting with pools |
| Admin | Privileged operations (strategy updates, emergency controls) |
Interest distribution uses a shares-based model for O(1) gas efficiency:
shares = (depositAmount × totalShares) / totalAssets
userValue = (userShares × totalAssets) / totalShares
When yield is distributed, totalAssets increases while shares remain constant—automatically increasing each user's proportional value.
The implementation includes virtual offset protection against the ERC4626 "inflation attack" (also known as the "donation attack"). This attack allows a malicious first depositor to manipulate the share price when totalShares is small, potentially stealing funds from subsequent depositors.
The protection uses virtual shares and assets:
effectiveShares = totalShares + 1.0
effectiveAssets = totalAssets + 1.0
shares = (assets × effectiveShares) / effectiveAssets
assets = (shares × effectiveAssets) / effectiveShares
This creates "dead" shares/assets that ensure the share price starts near 1:1 and cannot be manipulated through donation attacks, providing defense-in-depth even for future yield connectors that might be permissionless.
Lottery odds are based on share-seconds—shares held multiplied by time held during the epoch:
timeWeightedStake = userShares × (currentTime - lastUpdateTime)
lotteryWeight = cumulativeShareSeconds + currentElapsed
This ensures:
- Larger deposits = better odds
- Longer deposits = better odds
- Late depositors don't get retroactive weight
Lottery draws use Flow's RandomConsumer for provably fair selection:
startDraw(): Commits to a future block's randomness, snapshots TWAB weights- Wait 1 block: Randomness source block is mined
completeDraw(): Reveals random number, selects winner(s), distributes prizes
Configurable splits between rewards/prize/protocol:
| Strategy | Description |
|---|---|
FixedPercentageStrategy |
Static split (e.g., 50% savings, 40% lottery, 10% protocol) |
Custom strategies can implement the DistributionStrategy interface.
Winner selection is a configurable component that determines how prizes are distributed among participants. The following strategies are implemented in the contract:
| Strategy | Description |
|---|---|
WeightedSingleWinner |
One winner takes all, weighted by TWAB |
MultiWinnerSplit |
Multiple winners with configurable prize splits |
FixedPrizeTiers |
Fixed prize amounts per tier (e.g., 1st: 100, 2nd: 50, 3rd: 25) |
Custom strategies can implement the WinnerSelectionStrategy interface.
Automatic health monitoring with configurable thresholds:
| State | Description |
|---|---|
Normal |
All operations enabled |
Paused |
All operations disabled |
EmergencyMode |
Withdrawals only (no deposits/draws) |
PartialMode |
Limited deposits, withdrawals enabled |
Auto-triggers based on yield source health, withdrawal failures, or balance thresholds.
- Flow CLI installed
- Flow emulator or testnet access
# Start emulator
flow emulator
# Deploy to emulator
flow project deploy --network=emulatorUsers must create a PoolPositionCollection before interacting:
flow transactions send cadence/transactions/prize-linked-accounts/setup_collection.cdc \
--network=emulator --signer=user-accountflow transactions send cadence/transactions/prize-linked-accounts/deposit.cdc \
--args UInt64:0 UFix64:100.0 \
--network=emulator --signer=user-accountflow transactions send cadence/transactions/prize-linked-accounts/withdraw.cdc \
--args UInt64:0 UFix64:50.0 \
--network=emulator --signer=user-accountflow scripts execute cadence/scripts/prize-linked-accounts/get_pool_balance.cdc \
--args Address:0xUSER_ADDRESS UInt64:0 \
--network=emulator# Start the draw (commits to randomness)
flow transactions send cadence/transactions/prize-linked-accounts/start_draw.cdc \
--args UInt64:0 \
--network=emulator
# Wait at least 1 block, then complete the draw
flow transactions send cadence/transactions/prize-linked-accounts/complete_draw.cdc \
--args UInt64:0 \
--network=emulator// Borrow user's collection
let collection = signer.storage.borrow<&PrizeLinkedAccounts.PoolPositionCollection>(
from: PrizeLinkedAccounts.PoolPositionCollectionStoragePath
)!
// Withdraw tokens from wallet
let tokens <- flowVault.withdraw(amount: 100.0)
// Deposit into pool (auto-registers on first deposit)
collection.deposit(poolID: 0, from: <-tokens)// Withdraw principal + accrued savings interest
let withdrawn <- collection.withdraw(poolID: 0, amount: 50.0)let balance = collection.getPoolBalance(poolID: 0)
// balance.deposits: original principal
// balance.savingsEarned: accrued interest
// balance.totalBalance: deposits + savingsEarnedPools are created with a PoolConfig:
let config = PrizeLinkedAccounts.PoolConfig(
assetType: Type<@FlowToken.Vault>(),
yieldConnector: connector, // DeFiActions.Connector
minimumDeposit: 1.0, // Minimum deposit amount
drawIntervalSeconds: 86400.0, // 24 hours between draws
distributionStrategy: strategy, // FixedPercentageStrategy
winnerSelectionStrategy: winnerStrategy,
winnerTrackerCap: nil // Optional tracker
)let emergencyConfig = PrizeLinkedAccounts.EmergencyConfig(
maxEmergencyDuration: 86400.0, // Auto-recover after 24h
autoRecoveryEnabled: true,
minYieldSourceHealth: 0.5, // Trigger at 50% health
maxWithdrawFailures: 3, // Trigger after 3 failures
partialModeDepositLimit: 100.0,
minBalanceThreshold: 0.95, // 95% balance required
minRecoveryHealth: 0.5
)All transactions are located in cadence/transactions/prize-linked-accounts/:
| Transaction | Description |
|---|---|
setup_collection.cdc |
Create a PoolPositionCollection (one-time setup) |
deposit.cdc |
Deposit FLOW tokens into a pool |
withdraw.cdc |
Withdraw tokens from a pool (principal + interest) |
| Transaction | Description |
|---|---|
start_draw.cdc |
Start a lottery draw (commits to randomness) |
complete_draw.cdc |
Complete a draw (reveals winner and distributes prizes) |
process_rewards.cdc |
Trigger yield distribution |
| Transaction | Description |
|---|---|
create_pool.cdc |
Create a new PrizeLinkedAccounts pool |
withdraw_protocol.cdc |
Withdraw funds from pool protocol |
update_draw_interval.cdc |
Change time between lottery draws |
enable_emergency_mode.cdc |
Enable emergency mode (withdrawals only) |
disable_emergency_mode.cdc |
Return pool to normal operation |
| Transaction | Description |
|---|---|
setup_test_yield_vault.cdc |
Create a test vault to simulate yield source |
create_test_pool.cdc |
Create a pool using MockYieldConnector |
add_yield_to_pool.cdc |
Add simulated yield to the test vault |
All scripts are located in cadence/scripts/prize-linked-accounts/:
| Script | Description |
|---|---|
get_pool_stats.cdc |
Pool totals, config, draw status, share price |
get_pool_balance.cdc |
User balance in a pool (deposits, prizes, savings) |
get_all_pools.cdc |
List all pool IDs |
get_user_shares.cdc |
Detailed user share info and TWAB stake |
get_draw_status.cdc |
Lottery timing, prize pool, epoch info |
get_protocol_stats.cdc |
Protocol balance and funding stats |
get_emergency_info.cdc |
Emergency state and details |
get_user_pools.cdc |
All pools a user is registered with |
is_registered.cdc |
Check if user is registered with a pool |
preview_deposit.cdc |
Preview shares received for a deposit amount |
Deposited(poolID, receiverID, amount)Withdrawn(poolID, receiverID, amount)
RewardsProcessed(poolID, totalAmount, rewardsAmount, lotteryAmount)SavingsYieldAccrued(poolID, amount)LotteryPrizePoolFunded(poolID, amount, source)
PrizeDrawCommitted(poolID, prizeAmount, commitBlock)PrizesAwarded(poolID, winners, amounts, round)
NFTPrizeDeposited(poolID, nftID, nftType, depositedBy)NFTPrizeAwarded(poolID, receiverID, nftID, nftType, round)NFTPrizeClaimed(poolID, receiverID, nftID, nftType)
PoolEmergencyEnabled(poolID, reason, enabledBy, timestamp)PoolEmergencyDisabled(poolID, disabledBy, timestamp)EmergencyModeAutoTriggered(poolID, reason, healthScore, timestamp)EmergencyModeAutoRecovered(poolID, reason, healthScore, duration, timestamp)
DistributionStrategyUpdated(poolID, oldStrategy, newStrategy, updatedBy)WinnerSelectionStrategyUpdated(poolID, oldStrategy, newStrategy, updatedBy)DrawIntervalUpdated(poolID, oldInterval, newInterval, updatedBy)
| Component | Trust Level | Notes |
|---|---|---|
| Admin | High | Can update strategies, enable emergency mode, withdraw protocol |
| Yield Source | High | Protocol depends on yield source solvency |
| RandomConsumer | Core Flow | Flow protocol-level randomness |
sum(userShareValues) == totalAssets- Users can always withdraw
principal + savingsInterest(when not paused) - Lottery prizes never exceed accumulated lottery yield
totalSharesandtotalAssetsnever underflow
- Share conversions may produce small rounding dust
- Dust is tracked and periodically sent to protocol
- Minimum deposit prevents dust-only positions
| Function | Access |
|---|---|
deposit() / withdraw() |
Any user (own collection) |
startDraw() / completeDraw() |
Anyone (if conditions met) |
processRewards() |
Anyone (permissionless) |
Admin.* |
Admin resource holder only |
Pool.registerReceiver() |
Contract-internal only |
| Contract | Address (Mainnet) | Purpose |
|---|---|---|
FungibleToken |
f233dcee88fe0abe |
Token standard |
NonFungibleToken |
1d7e57aa55817448 |
NFT prize support |
RandomConsumer |
45caec600164c9e6 |
On-chain randomness |
DeFiActions |
92195d814edf9cb0 |
Yield source connectors (BETA) |
FlowTransactionScheduler |
e467b9dd11fa00df |
Automated draws |
The test_prize_savings.py script runs a comprehensive integration test suite against the emulator:
# Start the emulator in one terminal
flow emulator start
# Run the test suite in another terminal
python3 test_prize_savings.pyThis tests the full contract lifecycle including:
- Contract deployment and pool creation
- User collection setup and deposits/withdrawals
- Yield simulation and reward processing
- Lottery draw execution (start → complete)
- Prize winner verification
- NFT prize lifecycle (deposit, award, claim)
- Emergency mode and admin operations
- Multi-user scenarios and lottery fairness
- Protocol recipient setup and forwarding
- Draw timing and state management
- Invalid pool/parameter handling
- Access control verification
- NFT edge cases (admin withdrawal, non-winner claims)
- Precision and boundary cases (UFix64 limits)
flow test cadence/tests/PrizeLinkedAccounts_shares_test.cdc
flow test cadence/tests/PrizeVault_test.cdc| Area | Tests |
|---|---|
| Core Operations | Deposits, withdrawals, yield distribution, lottery draws |
| Shares Math (ERC4626) | Interest distribution, late joiner fairness |
| NFT Prizes | Deposit, award, claim, admin withdrawal, non-winner blocking |
| Access Control | Admin-only operations, user position isolation |
| Edge Cases | Minimum deposits, zero values, invalid pools, concurrent draws |
| Precision | UFix64 limits, 8 decimal precision, large values |
| Multi-User | Multiple depositors, fair prize distribution |
| Protocol | Recipient setup, forwarding verification |
| Draw Timing | Interval enforcement, concurrent prevention, stale recovery |
| Integration | Full lifecycle via test_prize_savings.py |
| Network | Contract Address | Status |
|---|---|---|
| Emulator | f8d6e0586b0a20c7 |
Development |
| Testnet | c24c9fd9b176ea87 |
Testing |
| Mainnet | TBD | Pending Audit |
PrizeLinkedAccountsPrizeVaultScheduler(optional, for automated draws)
MIT
For questions or support, please open an issue on this repository.