peard
The market side

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_tokenu128

Cumulative 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_outstandingu128

Equals 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_snapshotu128

Where 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_undistributedu128

Units 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_balance only rises when the owner calls sync_position, so buying more earns nothing until you sync.
  • Every accrual clamps earning against 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.