Cranker
A crank is one pass over a locked coin. FeeKit does not assign the job to a wallet. Whoever signs pays the network fee, and the program pays that signer from the fees it just collected. Two crankers can watch the same coin. The second one finds an empty creator vault, or a vault under the spend threshold, and stops.
One pass
The cranker loads every FeeKit config, or one mint if you pass it. A coin that is not locked, or whose quote is not SOL, is left alone. For each of the others it runs the same four steps. It simulates first. A kit that has nothing to do is skipped. A real failure is printed and the loop moves on.
- 1
Checkpoint. Floor only. If spot is above the stored high, the mark moves up. The line under it moves with the mark and never down.
- 2
Sweep. After graduation. Wrapped SOL sitting in the PumpSwap creator vault is moved into the pump creator vault, where collect can reach it.
- 3
Collect. Pulls the pump creator vault into the kit vault. Pump already split that fee 85% to the vault and 15% to the treasury. Collect then pays the crank 5% of the whole fee, out of the vault's share.
- 4
Execute. Burn and Floor buy the coin and burn the tokens. Graduate does that until the curve completes, then pays the vault out to the creator instead of buying.
if (config.kit === KIT_FLOOR && needsCheckpoint(config, slot)) {
await checkpoint(cranker, config, configPk, curvePk, curve.complete, label);
}
if (curve.complete) {
await sweep(cranker, config, configPk, curvePk, label);
}
await collect(cranker, config, configPk, curvePk, label);
await execute(cranker, config, configPk, curvePk, curve, fees, rentExempt, label);Running it
The default keypair is the Solana CLI id, and the default cluster is mainnet. run loops. once exits after a single pass. status prints each coin, its kit, and the vault balance.
npm run crank -- run --cluster mainnet
npm run crank -- once --mint <mint>
npm run crank -- statusThe loop waits 20 seconds between passes. --interval is that wait in milliseconds, and it cannot be under 1,000. --url replaces the cluster RPC.
The 5%
Pump is locked at 85% to the kit vault and 15% to the FeeKit treasury. The crank's cut is 5% of the whole creator fee, taken from the 85% that just landed in the vault. The kit keeps 80%. The treasury keeps its 15%. The cut is not a refund of the transaction fee. A collect that brings in nothing pays nothing.
/// Pump pays the treasury this share. It stays with FeeKit.
pub const PLATFORM_FEE_BPS: u16 = 1_500;
/// Pump pays the kit vault this share. `collect_fees` forwards the keeper cut from it.
pub const KIT_SHARE_BPS: u16 = 8_500;
/// Crank reward, in bps of the whole creator fee. Taken from the vault's incoming
/// share, which leaves the kit 80% and the platform 15%.
pub const KEEPER_FEE_BPS: u16 = 500;Collect measures what arrived, then transfers the reward from the vault to the signer. The vault is a program-derived address, so the program signs that transfer.
fn keeper_cut(vault_in: u64, treasury_in: u64) -> Result<u64> {
let gross = (vault_in as u128)
.checked_add(treasury_in as u128)
.ok_or(FeeKitError::Overflow)?;
let reward = gross
.checked_mul(KEEPER_FEE_BPS as u128)
.and_then(|value| value.checked_div(BPS_DENOMINATOR as u128))
.ok_or(FeeKitError::Overflow)?;
let reward = u64::try_from(reward).map_err(|_| error!(FeeKitError::Overflow))?;
require!(reward <= vault_in, FeeKitError::Overflow);
Ok(reward)
}What it will spend
The vault is a zero-data system account, so pump can pay SOL into it. There is no arbitrary withdraw. Before a buy, rent for an empty account and 3,000,000 lamports stay behind. That buffer pays the one-time pump volume account on the first buy. What is left has to clear the coin's minimum, which cannot be under 50,000 lamports. An optional cap spends less than the full balance. Zero means no cap.
export function spendableLamports(balance: bigint, rentExempt: bigint): bigint {
const reserve = rentExempt + EXECUTION_RENT_BUFFER;
return balance > reserve ? balance - reserve : 0n;
}
export function plannedSpend(config: LaunchConfig, spendable: bigint, slot: bigint): bigint | null {
if (spendable < config.minExecuteLamports) return null;
if (
config.intervalSlots > 0n &&
config.lastExecutionSlot !== 0n &&
slot < config.lastExecutionSlot + config.intervalSlots
) {
return null;
}
const spend = cap(spendable, config.maxSpendLamports);
if (spend < config.minExecuteLamports || spend > spendable) return null;
return spend;
}Burn, Floor, and Graduate are created with no interval, so the slot wait in that check does not apply to them. The cranker turns the spend into a minimum number of tokens with a constant-product quote, then shaves the coin's slippage plus a 5% fee haircut, and never more than 50%. The program checks the same floor and rejects a minimum that is softer than its own. Bought tokens are burned in the same transaction.
Floor
The on-chain line is a percent under the highest price the curve has printed. Spot at or under that line is allowed to buy. Spot above it returns DrawdownNotMet. The buy is then clamped to the quote that would lift the curve back to the line, so a large vault does not buy past it. If that repair is under the minimum, the crank waits. There is no resting bid. A sell can trade through, and the next crank buys with whatever SOL is spendable.
KIT_FLOOR => {
let high = config.high_water_price_q64.max(fresh_price_q64);
let line = trigger_price(high, config.drawdown_bps).ok_or(FeeKitError::MarkNotReady)?;
require!(fresh_price_q64 <= line, FeeKitError::DrawdownNotMet);
cap_spend(spendable, config.max_spend_lamports)
}let needed = quote_in_to_reach_price(quote_reserve, token_reserve, target)?;
require!(needed > 0, FeeKitError::DrawdownNotMet);
let spend = spend.min(needed);Graduate
While the bonding curve is open, Graduate buys it and burns, with slippage fixed at 5% and no spend cap. execute_swap refuses this kit. Once the curve is complete, the same pass calls release_creator and sends the spendable vault balance to the creator who launched the coin.
if (curve.complete && config.kit === KIT_GRADUATE) {
await submit(builder, `${label} release creator`);
return;
}Draw
Draw keeps the existing sentinel format: a Burn config whose minimum spend is the maximum u64, so buy execution can never clear the threshold. Collect still runs and pays the 5% crank reward. A separately configured Draw executor enumerates non-zero token accounts whose owners are ordinary system wallets. It excludes the bonding curve and program-owned accounts. Each wallet's chance is its eligible token balance divided by the total eligible wallet balance, recalculated from current on-chain balances for every payout. The executor picks from those weighted balances and calls pay_draw. The program enforces the one-hour interval, requires the full collected unpaid amount, verifies that the wallet still holds the mint, and transfers the pot from the vault to that winner. Ordinary public crankers can collect Draw fees but cannot choose or pay its winner.
The transfer and accounting are on-chain. The weighted selection itself is performed by the configured executor and is not an on-chain-verifiable randomness source.
Skipped, not failed
These program errors mean the coin is fine and this pass has no work. The cranker logs them and continues. Anything else is a failed transaction.
export const SKIP_ERRORS = new Set<number>([
ERROR.notGraduated,
ERROR.graduated,
ERROR.thresholdNotMet,
ERROR.intervalNotElapsed,
ERROR.drawdownNotMet,
ERROR.markNotReady,
ERROR.checkpointNotUsed,
ERROR.zeroOutput,
]);