Skip to content

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.

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 UNIT and burns a Tide-LP NFT. The burn path calls the lottery with the counterparty set to address(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
}

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 budget

The 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.

Excluded addressWhyScope
ex1 — the sellerCan’t win back your own forfeitThis draw only
ex2 — the counterparty (transfer recipient, or address(0) on a burn)Prevents a sender → receiver self-dealThis 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.

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 emits PrizeRedistributed(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.

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 activated
mapping(address => uint64) public prizeAwardedAt; // when it was awarded

activatePrize() 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.

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.

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 valueMeaning
amountTIDE awaiting activation (0 if no prize)
expiresAtprizeAwardedAt + prizeActivationWindow — the activation deadline
expiredtrue once the window has passed (call expirePrize, not activatePrize)
StepWhat happensEvent
1. Sell / transfer before vesting completesSeller’s still-locked TIDE is forfeited
2. Draw a winnerRandom eligible holder picked (seller + counterparty excluded)PrizeAwarded(winner, amount, forfeitedBy)
2b. No eligible winnerForfeit spread pro-rata over all live sharesPrizeRedistributed(amount)
3a. Winner activates in windowPrize re-vests over vestingDurationPrizeActivated(winner, amount)
3b. Window lapses, anyone expires itPrize routed to the Treasury for burningPrizeExpired(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: