Fees and rewards
4. Fees and rewards
Reward flow:
Trader swaps on V4 pool
│
│ ETH
▼
V4 hook captures ETH fee ← 4% buy / 6% sell
│
▼
Token contract accumulates ← until ≥ 0.01 ETH
│
▼
Uniswap V2 router ← ETH → $ASTEROID
│
▼
DividendTracker ← O(1) credit to all holders
│
▼
Holder calls claim()
Each step does one thing. None involve selling $PAM. That's the only thing that matters long-term.
Where the fee comes from
Every swap on the V4 pool pays a fee, always in ETH. Four possible swap shapes, all four charged:
| Shape | User action | Fee path | Rate |
|---|---|---|---|
| Buy exact-in | "N ETH, get as much $PAM as possible" | Hook, 4% of N ETH in beforeSwap | 4% |
| Sell exact-in | "N $PAM, get as much ETH as possible" | Hook, 6% of ETH output in afterSwap | 6% |
| Buy exact-out | "exactly N $PAM, pay whatever ETH" | Hook, 4% of ETH input in afterSwap | 4% |
| Sell exact-out | "exactly N ETH, pay whatever $PAM" | Hook, 6% of ETH output in beforeSwap | 6% |
Some DEX aggregators use exact-output to hit exact-value orders. Covering only exact-input would let them bypass the fee. All four shapes charge. No back doors.
The hook's predicate: fee is always in ETH; beforeSwap vs afterSwap depends on whether ETH is the specified or unspecified currency. Math is symmetric across all four, verified end-to-end per case.
Why the fee is ETH, not $PAM
This is the single choice that makes everything else work.
Fee in $PAM (2021 style) would require the contract to:
- Accumulate
$PAMfrom trades. - Sell that pile on the Uniswap pool (the only pool for
$PAM). - Use the resulting ETH to buy
$ASTEROIDelsewhere.
Step 2 is structural sell pressure on $PAM. Every distribution cycle dumps tokens on the same pool users are holding. Drags price down faster than organic demand can lift it.
Fee in ETH (which only V4 hooks allow) skips step 2. The fee enters as ETH, converts to $ASTEROID on a different pool. The $PAM pool is never sold into by the contract.
The ETH → $ASTEROID sub-swap
When accumulated ETH crosses 0.01 ETH, the next incoming swap triggers _swapAndDistribute:
- Chunk cap. Process at most 0.1 ETH per trigger. Larger balances flow through in chunks. 0.1 ETH is small enough that sandwiching isn't worth the gas.
- Slippage floor. Ask V2 router for expected output (
getAmountsOut), apply 5% floor:minOut = expected × 0.95. Returns below floor revert. - FoT-aware swap. Call
swapExactETHForTokensSupportingFeeOnTransferTokens.$ASTEROIDhas no transfer fee today (hardcoded zero, no setter), but the variant is safer against hypothetical future changes. - Balance delta. Check
$ASTEROIDbalance before and after; use the difference as the authoritative received amount. - Forward. Transfer to the tracker, call
distributeDividends(amount).
If any step fails, the error is caught, ETH stays in the contract, next swap retries. The V4 pool keeps working regardless.
Distribution math
The tracker uses MagnifiedDividends. One number, magnifiedPerShare, updated every distribution. Every user's earned amount derives from that one number plus their balance and a correction.
Update on distribute:
magnifiedPerShare += (amount × MAGNITUDE) ÷ trackedSupply
MAGNITUDE = 2^128 prevents rounding loss.
User's withdrawable:
accumulated = (userBalance × magnifiedPerShare + correction) ÷ MAGNITUDE
withdrawable = accumulated - alreadyWithdrawn
correction adjusts every time a user's balance changes (buy, sell, transfer). Invisible to the user, handled in the _update hook.
Properties:
- Proportional. Hold 5% of tracked supply, earn 5% of every future distribution.
- No retroactive earnings. Buy after a distribution, no retroactive rewards.
- Exclusion-safe. Excluded addresses (token contract, v4 pool infrastructure,
0xdead) don't dilute real holders. The team timelock is not excluded. It earns proportionally like any holder, same math. - Constant-time. Distribution and claim cost the same gas at 10 holders or 100,000.
When $PAM changes hands
On buy/sell/transfer, the token contract calls tracker.setBalance(from, ...) and tracker.setBalance(to, ...). Keeps the tracker in sync with ERC-20 state and adjusts correction so accumulated stays accurate. A transfer from Alice to Bob: Alice stops earning on the transferred amount, Bob starts.
Every setBalance emits BalanceUpdated, inspectable on Etherscan.
The claim function
function claim() external {
uint256 amount = withdrawableDividendOf(msg.sender);
if (amount == 0) revert NothingToClaim();
withdrawnDividends[msg.sender] += amount;
asteroidToken.transfer(msg.sender, amount);
emit DividendClaimed(msg.sender, amount);
}
Check, Effect, Interact. Standard reentrancy-safe ordering.
claimFor(address other) exists too: anyone can claim on anyone's behalf. $ASTEROID still goes to other. Lets someone sponsor gas for inactive holders.
Gasless balance check
withdrawableDividendOf(address) is a view. Zero gas. The dashboard polls it to show live claimable balance. Check a thousand times a second if you want.


