The fee route and the sweeper
The venue seam, which turns out to contain no venue at all.
cd relayer && npm run sweep # claim, then credit, every market
npx ts-node scripts/fake-venue-fee.ts # stand in for a claim, then sweep
peard owns denomination and settlement and owns nothing of the pool. That
principle survives contact with a real venue, and the integration turns out to
contain no venue at all.
The whole hook is one subtraction
sweep_fees is permissionless and credits the difference between what the
reward vault holds and what the market has accounted for.
let unaccounted = actual.saturating_sub(accounted);
require!(unaccounted > 0, Err::NothingToSweep);
A peard_amm claim_fees, a Meteora DBC fee claim, a DAMM v2 fee position, a
treasury top-up and a stray transfer all land in the same token account, and the
program credits whatever turned up without knowing which happened.
saturating_sub rather than checked, because an under-accounted vault is the
normal state and an over-accounted one means the vault was drained behind our
back, in which case sweeping nothing is the right answer rather than aborting
the crank forever.
The old vault 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.
Two facts about DBC make this fit without a CPI
- Its
collect_fee_mode: 0collects in the quote token only, so a claim deposits exactly the asset the pool is quoted in. - Its
fee_claimeris an arbitrary address, so fees can be routed at the pool rather than trusted to a keeper afterwards.
Where a venue's claim needs a signature, the crank bundles that instruction
ahead of sweep_fees in the same transaction. That adapter is the only piece
that differs per venue and it is deliberately outside the program.
The claim pass runs first, and it is not optional
trader
| buy / sell
v
peard_amm Pool ---- fees_quote accrues in the quote vault
|
| claim_fees (permissionless, destination fixed at creation)
v
peard Market's reward vault (an ATA owned by the market PDA)
|
| sweep_fees (permissionless, credits the unaccounted delta)
v
Market.acc_units_per_token rises
|
| sync_position / claim / request_fulfillment (each calls accrue)
v
Position.units_owed rises
Running the sweep without the claim pass is, in the sweeper's own comment, "a crank that reports success every cycle while holders are owed nothing no matter how much the pool trades". The claim runs first because sweeping first only credits last cycle's fees.
The claim pass is keyed by fee_claimer, because that is the only thing tying a
pool to a market. It walks every account the peard_amm program owns and skips
anything that fails to decode as a Pool, since the program owns other account
types.
One seam detail worth knowing
peard_amm checks its fee_destination as the claimer's associated token
account. peard's sweep_fees derives the reward vault as the ATA too. The
two only agree because the crank targets the ATA explicitly:
marketAta(pool.feeClaimer, pool.quoteMint, quoteProgram)
Fees claimed into some other market-owned account would be stranded: creditable by nothing, recoverable by nobody.
The inbox converter
cd relayer && npm run convert:dry # quote every inbox, print the edge
cd relayer && npm run convert # fill what clears minEdgeBps
A Native market's pool pays dollars and its vault holds the asset. The program
does not swap; it offers. convert_inbox posts a standing offer to deliver
the asset at the oracle price less a bounty and take the dollars.
That makes the converter a market maker rather than an arbitrage bot: it fills out of inventory, is paid in dollars, and buys the inventory back on its own account. Jupiter lives on this side of the line, where it can be swapped for another venue without touching a deployed program.
This is the Meteora-path fallback rather than the main path. Quoting a peard_amm curve in the asset itself means fees arrive already denominated in what a Native vault holds, so there is no inbox, no conversion, and no price read on any path.
Both divisions in convert_inbox round up, so every remainder lands on the
vault's side. At the size a fee claim arrives in that is dust; at one base unit
it is the difference between an offer and a faucet.
The protocol cut
credit_fees is the single helper deposit_fees and sweep_fees both funnel
into, 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;
The 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.
protocol_fee_bps is capped at 2,000.
It is also subtracted out of holder_usdc(), so it never inflates coverage. See
Quote modes and coverage.
What the route has actually done
On devnet, with real swaps:
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
After a second swap with a position open, the accumulator landed at 8,987,113,
units_outstanding at 899,999, and pending_units_undistributed at 1. Every
one of those follows from the formula. See
Units and the earning ledger.
scripts/devnet-buy.ts and scripts/devnet-holder.ts are the two scripts that
did it, and both exist specifically to prove the join between programs that
share no CPI.
peard