Eschaton

Repository docs

docs/FEES.md

Rendered from the repository at request time. Back to research

ESC trading fees → reward vault 90% + eacc_lock 10%

Owner: fees workstream (fee-router/). Nodes are rewarded from the creator fees of the project's own token, Eschaton (ticker ESC), which does not exist yet: "ESC trading fees pay the nodes; stake e/acc to run one" (INTERFACES changelog, 2026-10-06 14:27 UTC). The adopted split sends 90% to the reward vault and 10% to the ["eacc_lock"] PDA. e/acc's only role is the stake mint: you deposit e/acc to run a node, and e/acc's trading fees do not fund the reward vault (13:40 UTC). e/acc appears below only as Appendix A, a live reference case showing that once a sharing config's admin is revoked, its creator and admin can no longer change the split (pump.fun's override powers remain, §6).

Everything here was checked against Solana mainnet with read-only RPC (getMultipleAccountsInfo, getSignaturesForAddress, getTransaction, getAddressLookupTable and unsigned simulateTransaction with sigVerify: false). Nothing was signed or sent. Snapshot: 2026-10-06 13:50–15:10 UTC. Reproduce with cd fee-router && npm run launch-plan and npm run status. Nothing here is a forecast or a promise of rewards: the token's volume is unknown.

TL;DR

  • ›Design. The project token launches on pump.fun in one atomic transaction: create_v2 (create the coin) → pump-fees create_fee_sharing_config (the coin's creator becomes the sharing-config PDA) → update_fee_shares (shareholders = the ["reward_vault"] PDA at 9,000 bps and the ["eacc_lock"] PDA at 1,000 bps). On a v2 sharing config the one allowed update also sets `admin_revoked = true`, so no separate revoke instruction is needed. After that, neither the creator nor the team can change where the creator fees go; pump.fun's admin powers and our program's upgrade authority remain (§6).
  • ›eacc_lock (10%). A system-owned lamport account with no data, at the ["eacc_lock"] PDA of the eacc-swarm program. Program v2 is the first mainnet deploy (`docs/V2.md`), so eacc_lock ships with buy_and_lock: permissionless, rate-limited and TWAP-price-guarded, it buys e/acc on PumpSwap with the accrued SOL and locks the tokens. It is the only instruction that signs for the PDA. It must be rent-exempt before launch, or a distribution could fail on it: npm run prefund (§2.5).
  • ›Simulated on mainnet, unsigned: the 90/10 launch as one legacy transaction is 1,249 B with a placeholder URI and 1,300 B with a real-length one, over the 1,232 B limit. So it goes out as a v0 transaction with an address lookup table: 652 B with its own table. Against an existing mainnet table it simulates ok at 900 B and 242,642 CU, with real-length metadata and all three steps (§2.3). With minimal metadata the legacy transaction fits (1,211 B) and simulates ok at ≈ 241k–262k CU. The post-state, decoded by the same code status uses, shows the bonding-curve creator = sharing-config PDA, shareholders = [reward_vault 90%, eacc_lock 10%], admin revoked YES.
  • ›Fees move by a permissionless crank (fee-router crank / run): pump distribute_creator_fees_v2 (plus pump-amm transfer_creator_fees_to_pump_v2 after migration). Only a fee payer signs, about 5,000 lamports per crank. No creator key is needed after launch.
  • ›Creator fee rate: 0.30% of volume on the bonding curve. After graduation (≈ 85 SOL raised, ≈ 411 SOL market cap), PumpSwap's market-cap tiers apply: 0.30% below 420 SOL, 0.95% from 420 SOL, then falling step by step to 0.05% at ≥ 98,240 SOL.
  • ›Payout pacing: in production, settlement pays PAYOUT_FRACTION = 1/60 of the vault's available per 24 h epoch, so fees stream out over weeks instead of all at once (§5). Localnet demos keep 1.
  • ›Before launch the eacc-swarm program must be deployed on mainnet under its final program id, with initialize run and eacc_lock prefunded, because the split is locked at launch: once the admin is revoked, the sharing config pays these two fixed addresses (§2.5). Today config/mainnet.json still says programId: "TBD", so fee-router derives both PDAs from the IDL address and labels them *planned*.

1. Routing design

trade on the bonding curve or on PumpSwap (every fee is taken on the SOL side)
  ├─ protocol fee ─────────────► pump.fun fee recipients
  ├─ LP fee (PumpSwap only) ───► the pool (LP token holders)
  └─ creator fee ──────────────► vault of coin_creator = sharing-config PDA  feeSharingConfigPda(mint)
       on the curve:   pump creator vault           creatorVaultPda(sharingConfig)                  (SOL)
       after migration: PumpSwap creator vault ATA  coinCreatorVaultAuthorityPda(sharingConfig)     (WSOL)
                          └─ pump-amm transfer_creator_fees_to_pump_v2 ─► pump creator vault
                                              ▼  pump distribute_creator_fees_v2   (permissionless crank)
       shareholders: ["reward_vault"] PDA of eacc_swarm  90%          ["eacc_lock"] PDA of eacc_swarm  10%
                                              ▼                                   ▼
       eacc_swarm reward vault ─► post_epoch (coordinator)        eacc_lock (system-owned, no data)
         ─► claim (nodes, by points)   INTERFACES §4                 buy_and_lock (permissionless: buys e/acc, locks it)

Why this works:

  • ›Shareholders are plain pubkeys. distribute_creator_fees pays each one a lamport credit and rejects only executable recipients (error 6052). The reward vault is a non-executable, program-owned PDA, and INTERFACES §4 counts SOL that arrives directly toward available. These payouts do not go through fund_rewards, so they show up in the vault balance and available, but not in Config.total_rewards_funded.
  • ›eacc_lock is a plain system account, credited like any wallet. Every credit must leave a recipient at or above its rent-exempt minimum, or the whole distribution fails. A missing or underfunded eacc_lock would therefore block the reward vault's 90% too. Hence the prefund step (§2.5) and the crank's rent guard (§3).
  • ›Rules (pump-fees and SDK): 1–10 shareholders, each > 0 bps, summing to exactly 10,000, no duplicates. fee-token.json and --shares are validated the same way.
  • ›Shares syntax: --shares reward_vault:9000,eacc_lock:1000 is the adopted split. Each entry is reward_vault, eacc_lock or a base58 address. A third-party share must be a SOL wallet, not eacc-swarm's Config.treasury, which is a stake-mint token account.

The plan lives in fee-router/fee-token.json: name Eschaton, symbol ESC, and uri null until the metadata is uploaded. mint stays null until launch, shares is 9,000 / 1,000 bps (also the default when shares is omitted), and creatorFeeBps is null.

2. Launch plan (npm run launch-plan)

Dry run only. It refuses --live and has no send path. It builds the three instructions offline, labels every account from the pump IDLs, measures the transaction size, simulates unsigned on mainnet with a placeholder mint and decodes the post-state.

2.1 Instructions (one atomic transaction; signers: launch wallet + mint keypair)

| # | Program | Instruction | Accounts | What it does |
|---|---|---|---|---|
| 1 | pump `6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P` | `create_v2` | 16 | Token-2022 mint + bonding curve; creator = launch wallet; signed by the wallet and the mint keypair |
| 2 | pump-fees `pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ` | `create_fee_sharing_config` | 13 | creates `feeSharingConfigPda(mint)` (admin = launch wallet, shareholders = [launch wallet 100%]) and migrates the bonding-curve creator to the PDA (`MigrateBondingCurveCreator`) |
| 3 | pump-fees | `update_fee_shares` | 18 + 1 remaining (the current shareholder) | shareholders := plan; distributes anything already accrued; sets `admin_revoked = true` |

Accounts per instruction, with IDL names and S/W flags, are printed by launch-plan (--json for machine use). Derived addresses (all depend on the mint): bonding curve bondingCurvePda(mint), sharing config feeSharingConfigPda(mint), pump creator vault creatorVaultPda(sharingConfig), PumpSwap creator vault ATA, and the canonical pool canonicalPumpPoolPda(mint), which is created at graduation.

There is no separate revoke step. The IDL lists revoke_fee_sharing_authority and transfer_fee_sharing_authority but publishes no accounts for them, and the SDK has no builder. The program message for error 6009 says it plainly: "sharing config can only be updated once".

2.2 Simulation results (mainnet, unsigned)

| Variant | Size | Result | Post-state (decoded with `readCurveState`) |
|---|---|---|---|
| reward_vault 90% + eacc_lock 10%, `Eschaton` / `ESC` / 30-char placeholder URI (`npm run launch-plan`) | 1,249 B → simulated with minimal metadata at 1,211 B | ok, ≈ 241k–262k CU (varies with the mint's PDA bumps) | creator = sharing config · v2 · admin revoked **YES** · 90% → `FJyCC2…Mq8h` [reward_vault PDA] · 10% → `79yyEy…N33e` [eacc_lock PDA] · curve fees protocol 95 / creator 30 bps · mcap 27.9 SOL |
| the same with real-length metadata (80-char IPFS URI) as a v0 transaction, `--lookup-table Hyif6eWb8x88RVrvjPfabsgRYnwkVnyByEXTVTXbUcyP` (an existing mainnet table holding the 14 mint-independent addresses) | 900 B (legacy: 1,300 B; with the launch's own 22-address table: 652 B) | ok, 242,642 CU, all three steps | identical · `status` would print `→ reward_vault 90% + eacc_lock 10%, admin revoked YES` |
| launch cost (creator balance pre/post) | — | — | ≈ 0.0113 SOL measured for a single-shareholder plan (rent for mint, curve, ATA and sharing config, plus base fees); the second shareholder adds 34 bytes of sharing-config rent (≈ 0.0002 SOL). Excludes priority fees, a dev buy and the lookup table |

Instruction trace: CreateV2 → InitializeMint2 → … → MintTo → SetAuthority → CreateFeeSharingConfig → ExtendAccount → MigrateBondingCurveCreator → UpdateFeeShares → DistributeCreatorFees. The simulation's signer is a stand-in for the launch wallet: pump's Global.fee_recipient, a funded system account, used only because sigVerify is off. The fixture fee-router/test/fixtures/launch-sim.json stores one such post-state, and tests decode it.

Finding: `creatorFeeBps` override. pump's Global says creator_fee_configurable = true with a 300 bps maximum, and create_v2 accepts creator_fee_bps. But in the simulated post-state the curve stores creator_fee_bps = 0, and the rate stays 30 bps. The deployed program ignores the override today, so launch-plan prints a WARNING when it is requested. Don't plan revenue around it.

2.3 Size

The second shareholder adds 34 bytes to update_fee_shares, so the 90/10 legacy transaction is over the limit even with the 30-character placeholder URI (1,249 B), and 1,300 B with a real IPFS URI (about 80 characters). When the real-length variant exceeds 1,232 B, launch-plan prints the lookup-table path:

  • ›Generate the mint keypair first: the bonding curve, sharing config and creator vaults derive from it.
  • ›npm run launch-plan -- --creator <launch wallet> --mint-address <mint> --uri <uri> prints the exact 22 table addresses: every account except the two signers and the two invoked programs, which must stay static.
  • ›From the launch wallet, one transaction: create_lookup_table + extend_lookup_table with those addresses. Rent is ≈ 0.0045 SOL at today's mainnet rate, refunded when the table is closed.
  • ›Wait one slot (a table is usable from the slot after its last extension), then add --lookup-table <table> to step 2's command. It simulates that exact v0 transaction against mainnet, unsigned.
  • ›Sign with the launch wallet + mint keypair and send the three instructions as that v0 transaction (652 B).
  • ›Optional, after launch: deactivate the table, then close it about 512 slots later to reclaim the rent.

--lookup-table accepts any existing tables. Run against the public table Hyif6e…UcyP, which already holds the 14 addresses every launch shares, the real-length launch comes to 900 B and simulates ok (§2.2). The v0 path works end to end with pump's programs. The launch's own table is still better, because it is smaller and no third party can change it.

Alternatives: shorter metadata no longer works, because 1,232 B leaves about 23 characters for name + symbol + URI and an IPFS CID alone is 59. Two transactions (create_v2 alone, then steps 2 and 3 immediately after) also work. Fees from trades in between belong to the launch wallet, and you can deposit them with fee-router deposit. This leaves a sniping window, so the single v0 transaction or a bundle is better.

A dev buy in the same transaction makes it larger still. Do it afterwards, as its own transaction.

2.4 What cannot be simulated before the coin exists

  • ›Real signatures: the simulation runs with sigVerify off.
  • ›The real mint: every derived PDA depends on it. Pass --mint-address <pubkey> to plan with the real one.
  • ›The real launch wallet: a stand-in signs in the simulation. The admin will be your wallet, and step 3 revokes it either way.
  • ›The real metadata: it changes instruction 1's data and the transaction size.
  • ›The launch's own lookup table: it must exist on chain before it can be simulated. Existing tables can be used with --lookup-table (§2.3).
  • ›eacc_lock's rent: until it is prefunded, launch-plan and status show it rent-exempt NO.
  • ›A second `update_fee_shares` (expected 6009): there is no room in the transaction. Appendix A shows the error live.
  • ›Fee flow: no trades exist until launch. crank simulates distributions once the coin trades.
  • ›Payouts to the reward vault: that account doesn't exist on mainnet until the program is deployed and initialized.

2.5 What you must do at launch time

  • ›Make the vault real and final. chain: deploy eacc_swarm to mainnet under the program id you will keep, run initialize (it creates the ["reward_vault"] account), and set programId in config/mainnet.json. Then npm run status must show mainnet: program deployed, vault initialized. Once the admin is revoked, the sharing config pays both PDAs of this program id and its creator side can't change that. Redeploying under a new program id later would strand the fees at the old addresses.
  • ›Prefund eacc_lock. CLUSTER=mainnet npm run prefund (dry run) prints both PDAs and the top-up, then CLUSTER=mainnet I_UNDERSTAND_MAINNET=1 FEE_RECIPIENT_KEYPAIR=<path> npm run prefund -- --live sends one plain transfer. It tops eacc_lock up to the cluster's 0-byte rent-exempt minimum, and an initialized reward_vault too if it is short. That minimum is 650,240 lamports on mainnet today and 890,880 at the default rent rate (localnet; the figure INTERFACES quotes). Sending more is harmless. npm run status must then show both recipients rent-exempt YES.
  • ›The token: Eschaton, ticker ESC, shares 90/10 (all in fee-token.json). Upload the metadata and set uri.
  • ›Generate the mint keypair yourself, offline (solana-keygen new -o <mint.json>). fee-router never creates or stores it; launch reads it from the file named by MINT_KEYPAIR. The creator is a dedicated hot wallet holding only the launch SOL, read from PAYER_KEYPAIR.
  • ›Dry-run the launch with the real keys: CLUSTER=mainnet PAYER_KEYPAIR=<creator.json> MINT_KEYPAIR=<mint.json> npm run launch (§2.7). It prints the plan, the checklist (the two lookup-table checks show WAIT until the table exists) and the SOL needed, and sends nothing. It must end in READY.
  • ›Send it: the same command with I_UNDERSTAND_MAINNET=1 and -- --live, then type the mint address (§2.7).
  • ›Verify: launch prints PASS only after re-reading the finalized sharing config. FEE_TOKEN_MINT=<mint> npm run status must also show Routing → reward_vault 90% + eacc_lock 10%, admin revoked YES. Then set "mint" in fee-token.json.
  • ›Start a crank loop from any hot wallet holding about 0.05 SOL for fees: CLUSTER=mainnet I_UNDERSTAND_MAINNET=1 PAYER_KEYPAIR=<path> npm run run -- --live --interval 600. Anyone can run it, and more than one cranker is harmless.

2.6 Launch checklist (blocking)

update_fee_shares revokes the admin in the same transaction, so a launch against the wrong addresses can never be corrected. launch-plan therefore ends with a PASS/FAIL checklist, and any FAIL prints BLOCKED — N of 11 launch checks failed: do not launch and exits 1. It never warns-and-continues and never sends; launch (§2.7) runs the same 11 checks and refuses to send on any FAIL.

| Check | Fails when |
|---|---|
| program id | the IDL fallback is used; the id is a localnet or devnet id (`config/localnet*.json`, `config/devnet.json`, the IDL `declare_id`); `config/mainnet.json` `programId` is unset/TBD; or `--program-id` differs from it |
| mainnet program state | the program isn't executable, or `Config` / `reward_vault` aren't owned by it (not initialized), or the RPC read failed |
| upgrade authority | `config/mainnet.json` `expectedUpgradeAuthority` is unset/TBD or malformed, or the authority read from the program's ProgramData account differs from it. Set it to the Squads multisig vault address (the timelocked upgrade path), or to `"immutable"`, which passes only when the program has no authority |
| program hash | `config/mainnet.json` `expectedProgramHash` is unset/TBD or not 64 hex, or differs from the deployed hash: sha256 of the ProgramData bytes after the 45-byte header, trailing zero bytes stripped (what `solana-verify get-program-hash` prints). Set it to the output of `solana-verify get-executable-hash target/deploy/eacc_swarm.so` on the verifiable build |
| recipients rent-exempt | either PDA is below the cluster's rent-exempt minimum (`npm run prefund` first) |
| shares | the shares don't sum to 10,000 or differ from `fee-token.json` (e.g. a `--shares` override) |
| creator and mint | `--creator` or `--mint-address` wasn't given (a stand-in was used) |
| metadata | name, symbol or uri is undecided |
| transaction size | over 1,232 B without `--lookup-table`, or the v0 transaction with the table is still over |
| simulation | the unsigned simulation (the v0 one with `--lookup-table`) failed, ran fewer than 3 steps, or used shortened metadata |
| post-state | the simulated sharing config's shareholders differ from the plan, or the admin isn't revoked |

Today's read-only mainnet run is BLOCKED (9 of 11: no mainnet program, upgrade authority or hash yet, no prefund, no creator/mint/uri), as expected. npm run test:localnet deploys the built .so with solana program deploy (padded ProgramData) and checks the on-chain hash equals sha256 of the zero-trimmed file, then makes it --final and checks "immutable" passes.

2.7 Sending the launch (npm run launch)

The operator command, from fee-router/:

# dry run: plan, checklist, priority fee, SOL needed; nothing signed or sent
CLUSTER=mainnet PAYER_KEYPAIR=<creator.json> MINT_KEYPAIR=<mint.json> MAINNET_RPC_URL=<rpc> \
  npm run launch -- --priority-fee-microlamports auto
# live: asks you to type the mint address
CLUSTER=mainnet I_UNDERSTAND_MAINNET=1 PAYER_KEYPAIR=<creator.json> MINT_KEYPAIR=<mint.json> MAINNET_RPC_URL=<rpc> \
  npm run launch -- --live --priority-fee-microlamports auto
# afterwards: reclaim the lookup table's rent (run twice: deactivate, then ≈ 512 slots later close)
CLUSTER=mainnet I_UNDERSTAND_MAINNET=1 PAYER_KEYPAIR=<creator.json> MINT_KEYPAIR=<mint.json> MAINNET_RPC_URL=<rpc> \
  npm run close-lut -- --live

Devnet rehearsal. pump.fun's programs, Global and fee config exist on devnet (read-only check). Once chain has deployed eacc_swarm there (after the devnet deployer is funded) and config/devnet.json has programId, expectedUpgradeAuthority and expectedProgramHash, the same commands run against devnet with throwaway devnet keys (not the mainnet mint keypair). --live needs no I_UNDERSTAND_MAINNET there, and the RPC is config/devnet.json's rpcUrl:

CLUSTER=devnet FEE_RECIPIENT_KEYPAIR=<devnet-funder.json> npm run prefund -- --live
CLUSTER=devnet PAYER_KEYPAIR=<devnet-creator.json> MINT_KEYPAIR=<devnet-mint.json> npm run launch -- --priority-fee-microlamports auto
CLUSTER=devnet PAYER_KEYPAIR=<devnet-creator.json> MINT_KEYPAIR=<devnet-mint.json> npm run launch -- --live --priority-fee-microlamports auto
CLUSTER=devnet PAYER_KEYPAIR=<devnet-creator.json> MINT_KEYPAIR=<devnet-mint.json> npm run close-lut -- --live   # twice

Priority fee and compute units. --priority-fee-microlamports <n> sets the compute-unit price; auto takes the 75th percentile of getRecentPrioritizationFees for the launch's writable accounts (payer first, at most 128). The default is 0. The hard cap is 200,000 µL/CU (--max-priority-fee-microlamports <n> overrides it): an explicit price above it blocks the launch (BLOCKED — priority fee), and auto is clamped to it. Every transaction (lookup table, launch, close-lut) carries SetComputeUnitLimit = its simulated units (simulated with the budget instructions at the maximum limit) + 20% + 1,000, and SetComputeUnitPrice when the price isn't 0. The plan shows the price, its source and cap, the launch's limit and the priority SOL, and the SOL-needed line includes it. Before the launch transaction, the extra final transaction check simulates the exact transaction with its budget, sizes it (≈ 700 B with the table) and recomputes the priority lamports (limit × price / 10⁶, rounded up) into the balance check.

Metadata comes from fee-token.json (--name/--symbol/--uri override it; the uri must be set). The shares are always fee-token.json's; there is no --shares on launch. Keys are only read from the two files and never printed; the RPC URL is printed redacted.

What it prints before sending: the mint, the creator, both recipients with their bps, the sharing config and the admin revoke, the initial buy (none: the plan has no dev buy), the lookup table, what will be sent, the priority fee, the SOL needed (launch accounts from the simulation, lookup-table rent, priority and signature fees; ≈ with a 0.01 SOL margin until the table exists) against the creator's balance, and the checklist.

The sequence with --live:

  • ›Refuses on mainnet without I_UNDERSTAND_MAINNET=1, if the RPC's genesis doesn't match CLUSTER, on any checklist FAIL (the two table-only checks wait for step 3), if the creator can't cover the plan (it must also hold ≥ 0.1 SOL so the launch simulates as the real signer), and if the typed mint address doesn't match. --yes skips typing off mainnet only.
  • ›Creates the address lookup table (authority and payer = the creator) and extends it with the launch's 22 non-signer accounts in its own transactions (create + 20 addresses, then the last 2), waits for finalized, then waits until the table is usable.
  • ›Reruns all 11 checks against the real table, plus final transaction (the exact transaction with its compute budget: size, simulation, priority lamports) and the exact cost, immediately before the launch transaction.
  • ›Sends the single v0 transaction create_v2 → create_fee_sharing_config → update_fee_shares (the update sets the 90/10 shares and revokes the sharing admin), signed by the creator and the mint keypair, and waits for finalized.
  • ›Re-reads the sharing config at finalized and prints PASS only if the curve's creator is the sharing config, the shareholders and bps equal the plan, and the admin is revoked; otherwise FAIL and exit 1.
  • ›Writes a receipt to fee-router/launches/<cluster>-<mint>.json (gitignored; LAUNCH_RECEIPT_DIR overrides): lookup-table and launch signatures, addresses, slots, the verification and an event log. No keys.

Reruns are safe. Every run first reads the mint, bonding curve and sharing config, and the receipt:

| On chain | What `launch --live` does |
|---|---|
| mint absent, no receipt table | the whole sequence |
| mint absent, receipt has an active table (e.g. the process died after step 2) | reuses the table (extends it if addresses are missing), then steps 3–6 |
| receipt has a pending launch signature | waits until it finalizes or its blockhash expires, then decides |
| mint exists, sharing config revoked | sends nothing; verifies and (re)writes the receipt, recovering the launch signature from the mint's history if the receipt was lost |
| mint exists, curve creator = this creator, no sharing config | never resends `create_v2`; sends `create_fee_sharing_config → update_fee_shares` |
| sharing config exists, admin = this creator, not revoked | sends `update_fee_shares` |
| anything else (another creator or admin) | refuses; nothing sent |

The launch is one atomic transaction and the mint keypair makes create_v2 single-use, so a duplicate can't land; the partial rows only arise if someone sends part of the launch by hand.

Reclaiming the table (`npm run close-lut`). The lookup table is only needed for the launch; its rent is ≈ 0.0062 SOL for 22 addresses. close-lut (dry run by default; --live, plus I_UNDERSTAND_MAINNET=1 on mainnet) finds the table in the receipt (--mint <pubkey> or MINT_KEYPAIR's pubkey; --table <pubkey> overrides, e.g. after a lost receipt). It is signed by the table's authority, PAYER_KEYPAIR, and refuses unless the launch is finished (sharing config revoked), so it can never take the table from a launch that still needs it. It works in two runs:

  • ›On an active table it sends deactivate_lookup_table.
  • ›While the deactivation slot is still in the SlotHashes sysvar (512 slots, ≈ 3.5 min), a rerun prints WAIT, says in how many slots the table can be closed, and sends nothing.
  • ›Once it has left SlotHashes, a rerun sends close_lookup_table and the rent goes back to the creator.
  • ›On a closed table it has nothing to do.

Each step is simulated first, and the receipt records the status and signatures.

npm run test:launch-localnet runs all of this end to end on the fees validator (28899), with pump.fun's programs and the accounts the launch touches read from mainnet (read-only) and preloaded. Cases:

  • ›a dry run with auto priority, and a price over the cap;
  • ›a wrong typed confirmation and an unfunded creator;
  • ›a crash after the lookup table, close-lut refused at that point, and the resume with a 1,000 µL/CU priority fee. The landed transaction must carry the compute budget plus the 3 launch instructions, and the fee charged must equal 2 signatures + limit × price;
  • ›a rerun after success and a lost receipt;
  • ›create_v2 landed alone (the two sharing-config steps are sent), and create_fee_sharing_config landed too (only update_fee_shares, the revoke, is sent);
  • ›close-lut: deactivate, an early rerun that waits, then the close with the rent returned;
  • ›no key bytes in any output or receipt.

It refuses to start if the fees ports are in use or another run holds .localnet/fees-tests.lock, and it stops its validator and removes its ledger and test keys. The live path has not run on devnet yet: config/devnet.json has no eacc_swarm program until chain deploys it.

3. Crank (npm run crank, npm run run)

| Phase | Instructions | Signer |
|---|---|---|
| bonding curve | pump `distribute_creator_fees_v2` | fee payer only |
| PumpSwap | pump-amm `transfer_creator_fees_to_pump_v2` + pump `distribute_creator_fees_v2` | fee payer only |
  • ›crank reads the token's phase, prints the routing verdict and the undistributed amount, then builds the instructions. By default it simulates unsigned on mainnet and prints one would pay <x> SOL → <address> [label] line per shareholder ([reward_vault PDA], [eacc_lock PDA]). --live needs CLUSTER=mainnet, I_UNDERSTAND_MAINNET=1 and PAYER_KEYPAIR. It refuses on any other cluster, and it won't crank live when the reward vault isn't a shareholder (--force overrides).
  • ›Rent guard: before simulating or sending, crank reads every shareholder. If one is below its rent-exempt minimum it prints REFUSED crank (<phase>): not rent-exempt — <name> <address> holds X of Y lamports: <fix> and exits 2. This happens in dry runs too, and --force doesn't override it. The fix is npm run prefund for eacc_lock, swarmctl init for a missing reward_vault, or a plain transfer for any other shareholder. run logs crank-refused and tries again next tick.
  • ›run loops: crank when the vault is a shareholder and at least --min-crank (0.01 SOL) is undistributed. At 0.01 SOL, a 5,000-lamport fee is 0.05% of the amount. --deposit adds the older claim → deposit path for setups without fee sharing (a wallet creator whose key is the funder). All steps log JSONL to fee-router/logs/.
  • ›Live evidence (reference case): crank --mint eacc simulates pump-amm transfer + pump distribute against mainnet: ok, 89,861 CU, would pay 0.033467663 SOL → FfLpuH…HGpv [social-fee-pda]. Anyone can trigger a distribution, but no one can redirect it.
  • ›parseSweep recognises curve-phase distributions (pump vault only, no AMM step), so status reports realized flow in both phases.

4. Fee tiers

Bonding curve (pump FeeConfig 8Wf5TiAheLUqBrKXeYg2JtAFFMWtKdG2BSFgqUcPVwTt, one tier): protocol 95 bps · creator 30 bps · LP 0. The curve starts at ≈ 27.96 SOL market cap (30 SOL virtual reserves). It completes after ≈ 85.0 SOL of real reserves at ≈ 411 SOL market cap, and migrates into the canonical PumpSwap pool (migration fee 0.015 SOL).

PumpSwap (FeeConfig 5PHirr8joyTMp9JMm6nW7hNDVyEYdkzDqazxPD7RaTjx, canonical SOL pools; market cap = supply × price in SOL). LP / protocol fees are 20 / 5 bps except in the first tier (2 / 93). Creator bps by market cap:

| Mcap ≥ (SOL) | 0 | 420 | 1,470 | 2,460 | 3,440 | 4,420 | 9,820 | 14,740 | 19,650 | 24,560 | 29,470 | 34,380 | 39,300 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| creator bps | 30 | **95** | 90 | 85 | 80 | 75 | 70 | 65 | 60 | 55 | 50 | 45 | 40 |
| Mcap ≥ (SOL) | 44,210 | 49,120 | 54,030 | 58,940 | 63,860 | 68,770 | 73,681 | 78,590 | 83,500 | 88,400 | 93,330 | 98,240 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| creator bps | 35 | 30 | 28 | 25 | 23 | 20 | 18 | 15 | 13 | 10 | 8 | 5 |

pump.fun can change these schedules at any time, and status reads the live ones. Each fee is ceil(quote × bps / 10_000). On PumpSwap, a buy's base is quote_amount_in_with_lp_fee − lp_fee and a sell's is quote_amount_out (confirmed against live BuyEvents, Appendix A).

5. Expected flow (arithmetic, not a forecast)

Creator fees/day = daily SOL volume × creator rate. The reward vault receives 90% of that and eacc_lock 10%. The table shows total creator fees.

| Daily volume | curve 0.30% | PumpSwap 420–1,470 SOL mcap 0.95% | ≈ 10k SOL mcap 0.70% | ≈ 50k SOL mcap 0.30% | ≥ 98k SOL mcap 0.05% |
|---|---|---|---|---|---|
| 10 SOL | 0.03 SOL | 0.095 SOL | 0.07 SOL | 0.03 SOL | 0.005 SOL |
| 100 SOL | 0.3 SOL | 0.95 SOL | 0.7 SOL | 0.3 SOL | 0.05 SOL |
| 1,000 SOL | 3 SOL | 9.5 SOL | 7 SOL | 3 SOL | 0.5 SOL |
| 10,000 SOL | 30 SOL | 95 SOL | 70 SOL | 30 SOL | 5 SOL |

For node rewards, multiply by 0.9. Only canonical pump pools pay a creator fee. Volume on other DEX pools pays their LPs. For scale only: the reference case (e/acc, ≈ $2M/day volume, 0.28% tier) realizes ≈ 15–50 SOL/day in creator fees, and it does not fund this project. Before launch, status prints this table at the live curve rate. mirror replays any row on localnet (§7).

Payout pacing. Settlement doesn't pay the vault out at once. Each 24 h epoch allocates floor(available × PAYOUT_FRACTION) by points, and production uses 1/60: payoutFraction in config/mainnet.example.json, with precedence --payout-fraction → PAYOUT_FRACTION → config → 1. Localnet demos keep 1. A one-off deposit therefore halves in about 41 days (59/60 per day). At a steady fee rate of F per day, payouts converge to F per day, with about 60 days of fees held as a buffer that smooths bursts.

swarmctl close-epoch enforces the pacing (SEC-10): on devnet and mainnet the fraction must be set explicitly (flag, env or config) or the command refuses, dry runs included; a fraction of 1 on mainnet also needs --allow-full-payout. Only localnet falls back to 1.

Minimum payout (SEC-13/14). A claim that credits a fresh owner less than the rent-exempt minimum fails at the runtime, and thousands of tiny allocations would cost more in claim fees than they pay. close-epoch therefore takes a minPayoutLamports floor: --min-payout-lamports → "minPayoutLamports" (decimal string) in config/<cluster>.json → the cluster's 0-byte rent-exempt minimum read from RPC (650,240 on mainnet today, 890,880 on localnet). A share below it is still in the tree with its points (so lifetime_points and zero-reward epochs settle as before) but with amount 0; its lamports stay in the vault as dust and roll into the next epoch's pool. The epoch file records min_payout_lamports and a below_min_payout list (owner, points, the share it would have got). Pass --min-payout-lamports 0 to disable. The program-side rate limit on claims is the chain workstream's (SEC-13).

Proof server (SEC-17). serve-proofs caches its RPC reads: posted epoch roots and existing claim receipts are immutable and cached for the process lifetime; unposted epochs and the claimed totals are re-read after 10 s. Missing receipts are re-read on every request (one batched read) so a claim shows up as soon as it lands. Below the cache, a global cap allows at most 16 RPC reads in flight with 256 waiting (--max-rpc-concurrency / PROOF_MAX_RPC_CONCURRENCY, --max-rpc-queue / PROOF_MAX_RPC_QUEUE), so cache hits never wait. In front, a per-IP token bucket allows 20 requests/s with a burst of 120 (--rate-per-ip / PROOF_RATE_PER_IP, 0 disables; --burst-per-ip / PROOF_BURST_PER_IP). Either limit answers 429 with Retry-After (seconds; /api/health is exempt). The defaults sit well above the dashboard's polling, dashboard_smoke.mjs and the e2e.

Behind a reverse proxy. The dashboard calls the proof server from the browser, so in production every visitor arrives through the TLS proxy. By default the bucket keys on the socket address and X-Forwarded-For is ignored (clients can't spoof their way into fresh buckets), which would put all visitors in the proxy's one bucket. Pass --trust-proxy <ip|cidr,...> (env PROOF_TRUST_PROXY, empty by default). When the socket peer is in that list, the bucket keys on the right-most X-Forwarded-For entry that isn't itself trusted (the left-most when every hop is trusted), so entries a client prepends are never believed. A missing or malformed header (any entry that isn't an IP, IPv4:port or [IPv6]:port) falls back to the socket address. IPv6 is supported and IPv4-mapped addresses (::ffff:127.0.0.1) are treated as IPv4. For Caddy or nginx on the same host, bind the proof server to loopback and trust only it:

cd settlement && CLUSTER=mainnet npm run serve-proofs -- --host 127.0.0.1 --port 8788 --trust-proxy 127.0.0.1,::1
location /api/ {
    proxy_pass http://127.0.0.1:8788;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Caddy's reverse_proxy 127.0.0.1:8788 sets X-Forwarded-For itself. Behind a CDN in front of that proxy, add the CDN's published ranges to --trust-proxy as CIDRs, or the CDN's edge addresses become the buckets. Signed epoch scores are defined in INTERFACES §7 (the coordinator's ed25519 attestation on a closed epoch's scores); checking them is settlement's signed-score work, not this section's.

6. Trust model

  • ›The split is locked at launch, for the creator side. A v2 sharing config accepts exactly one update_fee_shares. That update sets admin_revoked, and every later update fails with 6009 SharingConfigAdminRevoked (shown live in Appendix A). After launch, neither the launch wallet nor anyone else holding the admin key can change the split; only the trust points below can. No eacc-swarm key ever holds fees in transit.
  • ›Permissionless movement. Distribution is a crank anyone can run. It can delay fees, but it can't steal or redirect them. If no one cranks, fees wait in the creator vault.
  • ›What remains trusted:
  • ›pump.fun's admin powers. pump-fees admin_cto_sharing_config (signed by pump's pool authority and the pump-fees authority; it can install a new admin), pump admin_cto / set_creator, pump-amm admin_set_coin_creator, fee-schedule updates and program upgrades. This is pump.fun's community-takeover process, off-chain and at their discretion.
  • ›The eacc-swarm program. Its upgrade authority (a Squads multisig), coordinator (post_epoch) and pause switch govern how the vault pays out (INTERFACES §4). The program id must never change after launch (§2.5). The same upgrade authority governs eacc_lock: today buy_and_lock is the only instruction that moves its lamports, and only into e/acc that it locks, but an upgrade could change either PDA's rules. The multisig stays a trust point.
  • ›Anyone can verify it. status shows admin revoked YES and the shareholder list, read directly from the sharing-config account.

7. Localnet demo: proxy fee flow (mirror)

The pump.fun programs aren't on localnet, and the token doesn't exist yet. So mirror (localnet only) deposits a PROXY FEE FLOW into the localnet reward vault, labelled as such in its output and logs:

  • ›Default, `--source model`: daily volume (--volume-sol, default 100, or --volume-usd with --sol-usd) × creator bps (--creator-bps, or fee-token.json creatorFeeBps, or the live pump schedule: --tier curve or a PumpSwap market cap in SOL) × the vault's share, accrued cumulatively so no lamport is lost to rounding. --scale multiplies it.
  • ›`--source eacc`: e/acc's real mainnet creator-fee accrual, read-only, as a realistic-looking *stand-in* stream. The output says that e/acc's fees do not fund nodes.

8. fee-router commands

Every sender is a dry run (simulateTransaction) unless --live. Mainnet sends also need CLUSTER=mainnet and I_UNDERSTAND_MAINNET=1. Keys come only from FEE_RECIPIENT_KEYPAIR / PAYER_KEYPAIR paths and are never printed. RPC URLs are redacted. Fee token: --mint <pubkey|eacc> → FEE_TOKEN_MINT → fee-token.json. Fee-share PDAs (["reward_vault"], ["eacc_lock"]): --program-id → config/mainnet.json (programId "TBD" or null = not deployed) → IDL address (planned).

| Command | What it does |
|---|---|
| `status` | Read-only. Both recipients on mainnet and on `CLUSTER`: balance, account size and `rent-exempt YES/NO`, with a WARNING naming the fix. Not launched: the plan, the planned verdict (`→ reward_vault 90% + eacc_lock 10%, admin revoked YES`) and the expected-flow table. Bonding curve / PumpSwap: phase, fees, coin creator, sharing config, the **routing verdict**, unclaimed amount, sweeps and realized flow, volume estimate. Also the deposit-target vault on `CLUSTER` |
| `launch-plan` | §2: instructions, accounts, size, the lookup-table path when over 1,232 B, unsigned mainnet simulation (`--lookup-table <table>[,<table>]` adds the real-length v0 simulation), post-state verdict, what can't be simulated, and the blocking launch checklist (§2.6; exits 1 when BLOCKED). Never sends |
| `launch` | §2.7: the live launch (`PAYER_KEYPAIR` creator, `MINT_KEYPAIR`): lookup table, the 11 checks, one v0 launch transaction, `finalized`, verification, receipt. `--priority-fee-microlamports <n>\|auto` (cap 200,000, `--max-priority-fee-microlamports`). Dry run without `--live`; resumes on rerun |
| `close-lut` | §2.7: after the launch, deactivate the lookup table, then on a rerun ≈ 512 slots later close it (rent → creator). Prints when it can close; refuses while the launch is unfinished |
| `crank` | §3: permissionless distribution; simulated unless all three opt-ins; refuses while a shareholder isn't rent-exempt |
| `prefund` | Tops `eacc_lock`, and an initialized `reward_vault` if it's short, up to `CLUSTER`'s rent-exempt minimum with plain transfers. Dry run unless `--live`; `--payer <pubkey>` simulates from a wallet without its key. Never creates `reward_vault` (that's `swarmctl init`) and refuses an `eacc_lock` that isn't a system account with no data |
| `claim` | Wallet creator: `collect_coin_creator_fee` + `collect_creator_fee`, signed by the creator. Sharing config: the same crank |
| `deposit --sol <n>` | `fund_rewards` via `@eacc-swarm/sdk`, or `--transfer` for a plain transfer to the vault PDA |
| `run` | Loop: crank (default), optionally `--deposit` (claim → keep `--buffer` → deposit the rest) |
| `mirror` | §7, localnet only |

9. Tests

  • ›npm test: 112 tests, 11 of them for the launch checklist (one isolated FAIL per blocking condition, plus the committed plan coming out BLOCKED). Offline instruction building for the launch (IDL account names, signers, the 90/10 shareholder vector in update_fee_shares data, the real-length size over 1,232 B and its lookup-table size under it). Crank building for both phases (curve: pump only, paying both PDAs; PumpSwap: AMM + pump; only the fee payer signs), and the crank's rent guard refusing before any simulation. Phase detection (not launched, mint absent, bonding curve from the simulated 90/10 launch fixture, PumpSwap from the e/acc fixture), routing verdicts (90/10, 100%, third-party shares, unrevoked, wallet), recipient rent states and the prefund plan, eacc_lock derivation against @eacc-swarm/sdk, curve-phase sweep parsing, the proxy model, fee-token resolution (ESC, 90/10 default), and CLI refusals for every live path (including launch --live on mainnet without I_UNDERSTAND_MAINNET=1, --yes on mainnet, a missing MINT_KEYPAIR, and creator = mint). launch state classification (fresh, needs-sharing-config, launched, foreign) and post-launch verification (PASS on the 90/10 fixture, FAIL on mismatched shares). Priority fees (parsing, cap, auto percentile, compute-unit limit, rounding, budget instructions, writable accounts) and close-lut's phases (active, deactivating with slots left, closable; SlotHashes parsing); close-lut refusals.
  • ›npm run test:launch-localnet: the live launch path and close-lut on the fees validator (§2.7). 21/21 checks pass in ≈ 6 min (most of it waiting out the table's deactivation). It needs a v2 target/deploy/eacc_swarm.so (scripts/localnet/build-program.sh), because bootstrap deploys that file.
  • ›npm run test:localnet: the fees workstream's own validator (ports 28899/29900/28001/28002–28200, .localnet/*-fees-tests). It confirms the pump programs are absent on localnet. It then runs crank for an unlaunched token (skips) and crank --mint eacc (unsigned mainnet simulation from a localnet config). Next come status recipient rows (eacc_lock missing, rent-exempt NO), prefund (dry run, then --live, which brings eacc_lock to exactly the cluster's minimum; a rerun finds nothing to do), deposit (dry run, fund_rewards, transfer), run --deposit and both mirror sources, with vault deltas checked against the logs. status must then show both recipients rent-exempt. It also checks the deployed-program hash and upgrade authority of a real solana program deploy (§2.6). Finally the test stops the validator and removes its ledger. 21/21 checks pass. The pump side of the crank can't execute on localnet, so it is covered by the instruction-building tests and the mainnet simulations above.

10. Design note: optional buyback mode (not implemented)

This is unrelated to eacc_lock's v2 buy_and_lock, which buys e/acc with the 10% share and locks it, and never touches the reward vault. A buyback mode would spend SOL that arrived for rewards on buying the project token, and distribute the token instead of SOL:

  • ›Contract fit: the reward vault holds SOL and claim pays lamports (INTERFACES §4). Paying the token would need a token vault and a token claim (a chain change), or a separate merkle distributor over the same per-epoch points. SOL stays the default.
  • ›Execution: small buy_exact_quote_in chunks with a hard min_base_amount_out and a daily budget, via a private relay or bundle. Each buy pays the current tier's fees, and its creator share flows back into the vault.
  • ›Accounting: log every swap (signature, SOL in, tokens out, price) and publish the epoch's total next to the points.
  • ›Risks: price impact, MEV, and the optics of a project buying its own token. It should be opt-in and off by default.

Appendix A — reference case: a revoked sharing config is fixed

e/acc (CbcyNo7m1amFWqEQm2m4PLv1UNvpcL3C1Ujm6AkzpKoU) is the stake mint only. Its creator fees go to a third party, and none of it reaches the reward vault. It is documented here because it is the same mechanism this project uses, already live and locked: fee-router status --mint eacc shows Routing → NOT the reward vault, admin revoked YES.

| Fact | Value |
|---|---|
| Launch path | pump.fun bonding curve `9NknsvPygLiMV8fr2GXx8FWvbi5rWvh81WjGXLyGaiRJ` (`complete = true`) → canonical PumpSwap pool `4JAnKFddd5fFTk5PxDj3PpTWJuTEfSWQEjZJcgKQ9KwW` (pool creator = `pumpPoolAuthorityPda(mint)`) |
| Token | Token-2022, 6 decimals, supply 983,386,624.29, no mint/freeze authority, no transfer-fee extension |
| `coin_creator` | `5qKMaj2PBLQoSiH8bXAS3QJYkaq53p6vc7hvrX9hovd4` = `feeSharingConfigPda(mint)` |
| Sharing config | v2, active, admin `CALQ1EwjARtaW6FW6KQDZj4jhye9Vt4NvTzFwmg4JHqC`, **`admin_revoked = true`**, not editable |
| Shareholder (100%) | `FfLpuH4WPn2MR8Lqn1MpwQc1HtAPPqL3qvMWZjnFHGpv` = `socialFeePda("322216527", GitHub)`, GitHub `UsePaid` (Paid). It is a PDA itself, which shows that PDAs can be recipients |
| Fee tier (13:58 UTC) | market cap ≈ 54.8k SOL → tier ≥ 54,030: LP 20 · protocol 5 · creator **28** bps |
| Crank | a Paid wallet (`PaidybYx1q4xTYXMgHq1PTAsFkPsdJf6XG7KC1NUSJ8`) runs SweepCreatorFee → TransferCreatorFeesToPump → DistributeCreatorFees every ~2 min. Our unsigned `crank --mint eacc` simulation does the same (≈ 90k CU) |
| Realized creator fees | 6 h window to 13:10 UTC: 176 sweeps, 5.659 SOL ≈ **22.6 SOL/day**. The 24 h DexScreener volume × 28 bps ≈ 48 SOL/day |
| Other pools | Meteora DLMM ×4 and Raydium CLMM ×1: those fees go to LPs, with no creator fee |

The split is fixed once the admin is revoked: evidence. We sent an unsigned simulateTransaction of update_fee_shares, with the admin CALQ1… as authority, moving 100% to a reward-vault PDA. It fails with 6009 `SharingConfigAdminRevoked`: "Sharing config authority has been revoked - sharing config can only be updated once." The one update was the switch to UsePaid. Since then neither the creator nor the admin can change it. Only pump.fun's admin path or a pump-fees upgrade (§6) could. For the project token, that same one-update rule is what locks the routing to the reward vault at launch.

Trade-fee confirmation. BuyEvent in 2iGcm1eCvc… (buy_exact_quote_in, net quote 825,309,832 lamports) reports bps 20/5/28 and fees 1,650,620 / 412,655 / 2,310,868 lamports. These are exactly ceil(net × bps / 10_000), with buyback_fee 206,327 (50% of the protocol fee). Fixtures: fee-router/test/fixtures/{accounts,sweeps,trades,dexscreener}.json.

Sources and method

  • ›Decoding: accounts are decoded with @pump-fun/pump-sdk 2.0.0 and @pump-fun/pump-swap-sdk 1.20.0 (fee-router/src/pump.ts, lifecycle.ts). Instruction and account names come from the pump, pump-amm and pump-fees IDLs shipped with them.
  • ›Launch: fee-router/src/launch.ts builds the instructions. They are simulated with simulateTransaction({ sigVerify: false, replaceRecentBlockhash: true, accounts }) and the post-state is decoded through an overlay reader (simulated accounts over live ones).
  • ›Sweeps: getSignaturesForAddress(pump creator vault) → getTransaction → balance deltas (fee-router/src/flow.ts).
  • ›Market data: the DexScreener pair API, used for the reference case only.