Units and the earning ledger
An accumulator, a checkpoint, and a deliberate asymmetry that always favours the vault.
A unit is one share, one troy ounce, one wSOL, one bottle. UNIT_ONE is 1e9, so
units are counted at nine decimals regardless of what the unit is, because
things like bottles are routinely fractional.
Rewards reach holders through an accumulator and a checkpoint rather than a transfer hook, for one reason: a transfer-hook mint is not accepted everywhere a pool might live. Four numbers carry it.
Market.acc_units_per_tokenu128Cumulative units-e9 per token base unit, scaled by ACC_ONE.
ACC_ONE is 1e15 rather than the more usual 1e18, and the arithmetic is
the reason. The widest realistic product is units_e9 * ACC_ONE, so a market
crediting a billion units at once reaches 1e33 and leaves five orders of
magnitude of headroom in u128. At 1e18 the same market sits at 1e36 and a fat
deposit could overflow. Precision is unaffected: with the largest plausible
earning supply of 1e15 base units, an accumulator step of one unit-billionth
still lands on 1 rather than 0.
Market.units_outstandingu128Equals the sum of every position's units_owed exactly. That is what
makes coverage a real number rather than an estimate, and every path that
moves one moves the other in the same instruction.
Position.acc_snapshotu128Where that holder last read the accumulator. open_position sets it to the
market's current value, so a position opened today cannot sweep up everything
distributed before it existed.
Market.pending_units_undistributedu128Units that arrived with no one earning, plus the truncation dust from every accumulator step, plus whatever the clamp holds back from a holder who sold. Rolled into the next distribution rather than written off.
The asymmetry
let earning = position.earning_balance.min(live_balance) as u128;
let delta = market.acc_units_per_token - position.acc_snapshot;
if earning > 0 && delta > 0 {
let pending = (earning * delta) / ACC_ONE;
position.units_owed += pending;
market.units_outstanding += pending;
}
position.acc_snapshot = market.acc_units_per_token;
earning_balanceonly rises when the owner callssync_position, so buying more earns nothing until you sync.- Every accrual clamps
earningagainst the live token account, so selling stops earning immediately, with no keeper and no crank.
Both directions favour the vault, deliberately. What the clamp holds back is not
redistributed on the spot; it lands in pending_units_undistributed and lifts
the next accumulator step, so nothing leaks out of the ledger.
Look at what sync_position's accounts do not contain: no pairable, no
price, no vault. Accrual is a pure ledger operation. The price only enters when
fees are credited (Denominated only) and when a claim is paid.
Fees that arrive before any holder exists
This is not an edge case. It is the first thing that happens to every market, because the fee route gets proven before anybody opens a position.
let to_distribute = units_new + m.pending_units_undistributed;
if m.total_earning_balance > 0 && to_distribute > 0 {
let delta = (to_distribute * ACC_ONE) / m.total_earning_balance as u128;
m.acc_units_per_token += delta;
let distributed = (delta * m.total_earning_balance as u128) / ACC_ONE;
m.pending_units_undistributed = to_distribute - distributed;
} else {
m.pending_units_undistributed = to_distribute;
}
With no earning balance there is no denominator, so there is nothing to divide by. Crediting nobody would be a silent loss; reverting would be a crank that fails forever on a healthy market. So the units are parked, and the next credit picks them up.
The worked example, from the devnet run
Two peard_amm pools are live on devnet, one quoted in wSOL
(So11111111111111111111111111111111111111112) and one in EURC
(HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr). Two real swaps
have been executed against the wSOL pool.
The first fee event, end to end:
pool accrues fees_quote = 500,000
claim_fees -> reward vault 500,000 base units arrive
sweep_fees -> credit_fees protocol_cut 50,000 (10%)
holder_cut 450,000
units_new 450,000 (Native, 9dp quote)
total_earning_balance = 0
pendingUnitsUndistributed = 450,000
unitsOwed = 0
unitsOwed at zero is the correct answer, not a stall: nobody held a position
yet. In Native mode against a 9-decimal quote, native_units_from_amount is
amount * 1e9 / 1e9, so 450,000 base units of wSOL is 450,000 units-e9, which
is 0.00045 wSOL. The units and the lamports are the same number here by
coincidence of decimals, not by construction.
Then a position was opened and synced, and a second swap and sweep ran:
accUnitsPerToken 8,987,113
unitsOutstanding 899,999
pendingUnitsUndistributed 1
Every one of those follows from the formula. 450,000 fresh units plus the
450,000 that were pending is 900,000 to distribute. The delta is
900,000 * 1e15 / total_earning_balance, which lands on 8,987,113 against an
earning balance a shade over 1e14 base units, being the creator's retained 10%
of a 1e15 supply plus the base bought back out of the pool. Multiplying back
gives 899,999 distributed, and 900,000 - 899,999 leaves 1 unit-e9 of
truncation dust sitting in pending, waiting for the next credit.
That is one billionth of one wSOL. It is not lost, it is not swept to the protocol, and it will be part of the divisor next time.
The EURC pool has been created and has never been swapped against. Its fee route is structurally identical to the wSOL one, which is an argument rather than evidence.
The credit path
deposit_fees and sweep_fees both funnel into one helper, credit_fees, so
the two can never drift.
let protocol_cut = ((amount * protocol_fee_bps) / BPS);
let holder_cut = amount - protocol_cut;
m.protocol_fees_usdc += protocol_cut;
let units_new = match m.quote_mode {
QuoteMode::Native => m.native_units_from_amount(holder_cut),
QuoteMode::Denominated => units_from_usdc(holder_cut, price),
};
The protocol cut comes off the gross, before any units exist. It stays inside
the same vault as a tracked field and is withdrawable to Global.fee_receiver
by anybody, since the destination is fixed and there is no discretion to guard.
The cap on protocol_fee_bps is 2,000.
Claiming
claim(units) with units == 0 claims everything owed. It accrues first, then:
let face = match market.quote_mode {
QuoteMode::Native => market.native_amount_from_units(units),
QuoteMode::Denominated => usdc_from_units(units, price),
};
let payout = ((face as u128 * market.coverage(price)) / S_ONE) as u64;
require!(payout <= market.holder_usdc(), Err::InsufficientVault);
The units are burned in full even when coverage is below 1.00. That is the mechanism, not an oversight: burning the full units against a partial payout is what lifts coverage back up for everyone still holding.
The vault invariant, relaxed on purpose
The old invariant was vault_usdc == balance. Sweeping relaxes it to
vault_usdc <= balance, which is strictly better: money that arrives by an
unwitnessed route now reaches holders instead of sitting stranded. See
The venue seam.
peard