The forfeited-vesting lottery
When you claim fees, your gains vest linearly over 72 hours. If you sell or transfer a Tide-LP NFT before that vesting completes, you forfeit the still-locked portion — and instead of disappearing, it is drawn to another holder. This is the mechanic that turns exits into upside for the people who stay: exits enrich those who stay.
It is also the most playful part of the protocol, and the most honest about its trade-offs. The draw uses block.prevrandao, which is validator-influenceable at the margin. TIDE accepts that for a low-value ludic mechanic and says so plainly. See Known limitations.
What triggers a forfeiture
Section titled “What triggers a forfeiture”Any NFT departure forfeits the seller’s unvested TIDE. Two code paths reach the lottery:
- Burn — selling TIDE back into the pool drops you below a whole
UNITand burns a Tide-LP NFT. The burn path calls the lottery with the counterparty set toaddress(0). - Move — transferring a Tide-LP NFT wallet-to-wallet calls the lottery with the recipient as the counterparty.
In both cases the seller’s vesting is settled by _forfeitLocked: the already-vested part is realized into the seller’s claimable (you keep what has vested), and only the still-locked remainder is forfeited.
function _forfeitLocked(address user) private returns (uint256 forfeited) { Vest memory v = vests[user]; if (v.lockedTotal == 0) return 0; uint256 grossVested = _grossVested(v); uint256 newClaimable = uint256(v.claimable) + (grossVested - v.lockedWithdrawn); forfeited = uint256(v.lockedTotal) - grossVested; // the part that is raffled // ... lockedTotal reset to 0}The draw
Section titled “The draw”If forfeited > 0, the hook draws a single winner among current Tide-LP holders via _drawWinner. The RNG seed is a keccak256 over block.prevrandao, block.timestamp, a per-call _drawNonce, the seller’s address, and the number of minted ids:
uint256 rand = uint256( keccak256(abi.encode(block.prevrandao, block.timestamp, ++_drawNonce, ex1, minted)));uint256 probes = minted < LOTTERY_PROBES ? minted : LOTTERY_PROBES;for (uint256 i = 0; i < probes; i++) { uint256 id = (rand + i) % minted + 1; // an id in 1.._nextTokenId address o = _ownerOf(id); if (o != address(0) && o != ex1 && o != ex2) return o; // first eligible owner wins}return address(0); // none found within the probe budgetThe draw probes up to LOTTERY_PROBES = 128 token ids starting from a random offset, returning the first live owner that is neither of the two excluded addresses. The _drawNonce increments on every draw so multiple forfeitures in the same block or transaction do not correlate.
Who is excluded
Section titled “Who is excluded”| Excluded address | Why | Scope |
|---|---|---|
ex1 — the seller | Can’t win back your own forfeit | This draw only |
ex2 — the counterparty (transfer recipient, or address(0) on a burn) | Prevents a sender → receiver self-deal | This draw only |
There is no shared prize pot: each sale only ever redistributes its own forfeited amount, so there is nothing to farm by triggering many small sales.
When there is no eligible winner
Section titled “When there is no eligible winner”If _drawWinner returns address(0) (e.g. every other id is burned, or the probe budget is exhausted on a tiny holder set), the forfeit is not lost:
-
If
totalShares > 0, it is redistributed pro-rata to every live share by bumping the TIDE fee accumulator, exactly as if it were swap fees. This emitsPrizeRedistributed(amount).accFeesPerShareTIDE += forfeited * ACC_SCALE / totalShares; -
If there are no live shares at all, the forfeit is sent to the Treasury, where the only possible action is buyback-burn.
So forfeited TIDE always ends up benefiting holders — as a targeted prize, a pro-rata top-up of everyone’s fee share, or a supply burn.
Claiming a prize — activatePrize
Section titled “Claiming a prize — activatePrize”A winner is credited, not paid. The hook records the prize in trustless storage and the winner must come collect it (a pull payment — the winner pays the gas):
mapping(address => uint256) public pendingPrize; // TIDE waiting to be activatedmapping(address => uint64) public prizeAwardedAt; // when it was awardedactivatePrize() must be called within prizeActivationWindow of the award:
function activatePrize() external nonReentrant { uint256 amount = pendingPrize[msg.sender]; if (amount == 0) revert NoActivatablePrize(); if (block.timestamp > uint256(prizeAwardedAt[msg.sender]) + prizeActivationWindow) { revert NoActivatablePrize(); // window passed; call expirePrize instead } pendingPrize[msg.sender] = 0; prizeAwardedAt[msg.sender] = 0; _depositVesting(msg.sender, amount); // re-vests over vestingDuration, like normal gains emit PrizeActivated(msg.sender, amount);}An activated prize does not pay out instantly — it is deposited into your vesting tranche and re-vests over vestingDuration, the same as any claimed gain. You then withdraw it through the normal vesting flow.
Letting a prize expire — expirePrize
Section titled “Letting a prize expire — expirePrize”If the winner never activates within the window, anyone can permissionlessly expire the stale prize. It is then routed to the Treasury (where the guardian may eventually burn it):
function expirePrize(address winner) external nonReentrant { uint256 amount = pendingPrize[winner]; if (amount == 0) revert NoActivatablePrize(); if (block.timestamp <= uint256(prizeAwardedAt[winner]) + prizeActivationWindow) { revert NoActivatablePrize(); // not expired yet } pendingPrize[winner] = 0; prizeAwardedAt[winner] = 0; _transfer(address(this), address(treasury), amount); emit PrizeExpired(winner, amount);}Both activatePrize and expirePrize revert with NoActivatablePrize() when called out of phase — activatePrize after the window, expirePrize before it — so a prize is always in exactly one of two terminal states: activated by the winner, or expired to the Treasury.
Reading prize state — prizeStatus
Section titled “Reading prize state — prizeStatus”The dapp’s claim dashboard surfaces an “Activate prize” panel (with a live countdown) whenever you have a pending prize. It reads prizeStatus:
function prizeStatus(address user) external view returns (uint256 amount, uint256 expiresAt, bool expired){ amount = pendingPrize[user]; if (amount == 0) return (0, 0, false); expiresAt = uint256(prizeAwardedAt[user]) + prizeActivationWindow; expired = block.timestamp > expiresAt;}| Return value | Meaning |
|---|---|
amount | TIDE awaiting activation (0 if no prize) |
expiresAt | prizeAwardedAt + prizeActivationWindow — the activation deadline |
expired | true once the window has passed (call expirePrize, not activatePrize) |
Lifecycle at a glance
Section titled “Lifecycle at a glance”| Step | What happens | Event |
|---|---|---|
| 1. Sell / transfer before vesting completes | Seller’s still-locked TIDE is forfeited | — |
| 2. Draw a winner | Random eligible holder picked (seller + counterparty excluded) | PrizeAwarded(winner, amount, forfeitedBy) |
| 2b. No eligible winner | Forfeit spread pro-rata over all live shares | PrizeRedistributed(amount) |
| 3a. Winner activates in window | Prize re-vests over vestingDuration | PrizeActivated(winner, amount) |
| 3b. Window lapses, anyone expires it | Prize routed to the Treasury for burning | PrizeExpired(winner, amount) |
See Events & custom errors for the full signatures, and TideHook for the surrounding hook API.
Trustless storage, not trustless randomness
Section titled “Trustless storage, not trustless randomness”Everything in this mechanic is on-chain and rule-bound: the forfeiture amount is computed from your own vesting state, prizes live in public mappings, the activation window is an immutable, and the only two state transitions are activatePrize (by the winner) and expirePrize (by anyone). There is no privileged path that can divert a prize.
The one soft spot is the randomness source: