git blame, but for money: every pump.fun coin that pays a GitHub account, reverse-indexed straight from chain state.
$ payblame <login>
# payblame github:<login> id=#### type=User pda=####..####
# coins=89 listed=89 unclaimed=691.462 claimed=0.000 last-claim=never pending=26.091 SOL
#### (#### 10000bps 1/1 locked ) 10.546 SOL pending
#### (#### 10000bps 1/1 locked ) 3.267 SOL pending
#### (#### 10000bps 1/1 MUTABLE) 2.114 SOL pending
...
SAMPLE output: a real lookup of one GitHub recipient on 2026-09-25, with the login, id, PDA, coin addresses and symbols masked as ####. The numbers are as printed. This README doesn't name whose account it was. Run it on your own login.
Since February 2026, a pump.fun coin can send its creator fees to a GitHub account instead of a wallet. Anyone can launch a coin that names a dev, and the dev doesn't have to know it exists. The fees pile up in an on-chain escrow until the dev logs in to pump.fun and claims them. Payblame answers two questions from chain state alone, with no indexer, no pump.fun API and no database:
- Which coins pay this account? Give it a GitHub login, a GitHub or X numeric id, or a wallet. It lists every coin whose fee split includes that recipient, with the bps, the fees pending in each coin's vaults, whether the split is locked or MUTABLE, and what the account has claimed and left unclaimed.
- Who does this coin pay? Give it a mint. It prints the on-chain split, each recipient's claim record, and a diff between the handles the coin names in its metadata and the recipients the chain actually pays.
The trick is that pump.fun's SharingConfig account keeps its shareholders at fixed byte offsets. So "which of the 678,295 configs pay X?" becomes 10 memcmp filters that the RPC node runs for you.
listed != involved: recipients did not necessarily launch, endorse or know about these coins.
login ──GitHub REST──> numeric id ──PDA('social-fee-pda', id, 2)──> recipient R (SocialFeePda)
│
┌─────────────────────────────────────────────────────────────────┘
│ 10 x getProgramAccounts(pump_fees, dataSlice {0,0}):
│ memcmp(0, SharingConfig discriminator) AND memcmp(80 + 34*i, R) i = 0..9
v
{ config pubkey -> slot i } ── exact coin count, pubkeys only (no account data comes back)
│
│ first `limit` configs by address ──getMultipleAccounts(dataSlice {0,420})──> decode
v
per config C: PDA('creator-vault', C, pump) -> lamports - rentExempt(0)
ATA(PDA('creator_vault', C, pump_amm), WSOL) -> u64 amount @64
pending x bps / 10000 -> what R gets on the next distribute
│
└──> SocialFeePda R (179 bytes): total_claimed, last_claimed, lamports - rentExempt(179) = unclaimed
| offset | size | field |
|---|---|---|
| 0 | 8 | discriminator d84a0900388c5d4b = sha256("account:SharingConfig")[0..8] |
| 8 | 1 | bump |
| 9 | 1 | version (1 and 2 are both live) |
| 10 | 1 | status (0 paused, 1 active) |
| 11 | 32 | mint |
| 43 | 32 | admin |
| 75 | 1 | admin_revoked (1 = split locked, 0 = MUTABLE: the admin can rewrite the shareholders) |
| 76 | 4 | shareholder count n, u32 LE (1..10) |
| 80 + 34k | 32 | shareholder k address |
| 112 + 34k | 2 | shareholder k share, u16 LE bps (they sum to 10000) |
Bytes after 80 + 34n are zero. That is why the reverse index works: a recipient key at offset 80 + 34i can only match a real slot i. The one exception is the all-zero key, which would match every empty slot, and the library refuses it.
| offset | field |
|---|---|
| 0 | discriminator 8b6035112aa9ce96 |
| 8, 9 | bump, version |
| 10 | user_id: u32 length + UTF-8, at most 20 bytes. Usually the numeric account id as text, but not always (see below) |
| 14+len | platform u8 (0 pump, 1 X, 2 GitHub) |
| 15+len | total_claimed u64 (lamports) |
| 23+len | last_claimed u64 (unix seconds, 0 = never) |
| 31+len | total_stable_claimed u64, then 120 reserved bytes |
Every field ends by byte 59, so the network-wide ledger fetches all SocialFeePdas with dataSlice {0, 59} in a single call.
Some user_ids are text, not numbers. The program stores whatever string the account was created with. In the snapshot of 2026-09-25 13:48Z, 90 of the 12,274 SocialFeePdas held free text instead of a numeric id: X handles, names with spaces, URLs such as x.com/<handle>. By platform: 59 X, 25 GitHub, 2 Pump, 4 unnamed (3 and 4). None of them had claimed anything, and together they held 81.256 SOL unclaimed. payblame treats them like this:
decodeSocialFeePdareturns the text as it is stored.isNumericId(id)tells the two kinds apart.- The ledger counts a text-id account under its platform byte like any other, and ranks it by the same numbers. The 25 on the GitHub platform are part of
github-recipients(11,455 in that snapshot). - A text id is never sent to GitHub, because the REST route
/user/<id>only takes numbers.resolveIdsmaps it tonulland carries on with the numeric ids. - A masked row (the default) prints
github:####, like every other row.--ledger --revealprints the text as stored, asgithub:#<text>(non-ASCII becomes?), because the account holds no GitHub login to resolve. - The query grammar's
ghid:andx:only take digits. To look up a text-id account, pass its SocialFeePda address, or a mint whose split pays it. - The test fixture replaces every text id with a placeholder of the same byte length (
sample_x_0007,~07) at that placeholder's PDA (see Tests).
- A fee-shared coin's bonding-curve
creator(byte 49 of the curve) is its SharingConfig PDA. Fees accrue to the pump creator vaultPDA(["creator-vault", creator], pump)as lamports. After graduation they also accrue to the WSOL token account ofPDA(["creator_vault", creator], pump_amm). pending = max(0, pumpVault - rentExempt(0)) + ammVaultWsol. A permissionlessdistribute_creator_feessplits it by bps. For a SocialFeePda shareholder, pump.fun's claim flow then pays the user and updatestotal_claimed/last_claimed.unclaimed = lamports(SocialFeePda) - rentExempt(179).received = total_claimed + unclaimed.
For a recipient with c coins and a limit L: 10 getProgramAccounts probes, plus 1 getMultipleAccounts per 100 configs, plus 1 per 50 configs for the vaults, plus 1 DAS getAssetBatch. That is O(1) probes and O(min(c, L)) decoding, whatever the size of the program. The PDA math (sha256 plus an ed25519 decompression check: one Jacobi symbol in BigInt) runs locally with no dependencies.
npm i payblame # PLANNED: not yet on npm. Until then: git clone, then npm link
import { lookup, blameCoin, buildLedger } from 'payblame';
const rpcUrl = process.env.PAYBLAME_RPC_URL; // any mainnet RPC with getProgramAccounts + memcmp
const r = await lookup('<github-login>', { rpcUrl, limit: 50 });
console.log(r.totals.coins, r.account.unclaimedLamports / 1e9, r.account.lastClaimedAt);
console.log(r.blame.join('\n')); // git-blame text, ASCII only
const c = await blameCoin('<mint>', { rpcUrl });
console.log(c.config.shareholders, c.mismatch, c.diff); // declared vs on-chainpayblame <github-login> recipient blame (Format A)
payblame ghid:<numeric-id> | x:<numeric-id> | <wallet>
payblame <mint> coin blame + declared-vs-on-chain diff (Format B)
payblame --ledger every GitHub fee account on pump.fun (Format L), logins masked
payblame --ledger --reveal the same, with real GitHub logins (opt-in)
payblame <q> --json [--limit n] machine output (same shapes as the API)
env: PAYBLAME_RPC_URL (required), GITHUB_TOKEN (optional, 60 -> 5000 GitHub requests/h),
PAYBLAME_MASK_SECRET (optional, keeps masked row ids stable across runs)
$ payblame <mint>
# payblame ####..pump $#### "####" config=#### v2 active locked
# vault pending=0.030 SOL (pump 0.000 + pumpswap 0.030)
#### (github:<login> 10000bps) claimed=20376.511 unclaimed=0.000 last=2026-09-25
--- declared (name/symbol/description)
+++ on-chain (SharingConfig)
- x:@<declared-handle>
+ github:<login> 10000bps
SAMPLE output: a real coin on 2026-09-25, with the mint, names and handles masked. Its description says its fees go to an X handle "via" a fee platform. On-chain, 100% of its fees go to one GitHub account, the platform's, which claims regularly. Any forwarding to the X account happens off-chain. "Not on-chain" is not the same as "not paid": an intermediary may forward fees. The diff only shows what the chain can prove. node examples/coin-diff.mjs runs this lookup on its default mint.
$ payblame --ledger (2026-09-25T16:56Z)
# payblame --ledger 2026-09-25T16:56Z github-recipients=11458 logins=masked
# unclaimed=17504.168 claimed=86016.533 never-claimed=10286 (SOL)
row:aa5f6c33 (github:#### user) unclaimed=1869.443 claimed=0.000 last=never
row:f8253337 (github:#### org ) unclaimed=1292.372 claimed=0.000 last=never
row:c6b0b82d (github:#### org ) unclaimed=998.159 claimed=0.000 last=never
The ledger masks logins by default. A ranked list of real people by unclaimed SOL reads as a target list for "claim your fees" phishing, so --ledger, buildLedger() and the /api/ledger endpoint built on it print github:#### and a row id. A masked row keeps its numbers and its User/Org type. login becomes ####, and githubId and socialFeePda become null, because the PDA decodes to the GitHub id. rowId is row: plus 8 hex of HMAC-SHA256(secret, socialFeePda): it is stable for one secret and can't be reversed without it. --reveal (or buildLedger({ reveal: true })) prints the real logins. A lookup of one login, id, wallet or mint always shows who it pays, because you asked for that account. The chain is public and this is a convenience default, not a privacy guarantee.
Numbers are always key=value (unclaimed=1869.443 claimed=0.000 last=never), never a label followed by a number, so a cropped screenshot can't pair a login with the wrong amount.
Masking one lookup. maskLookup(result) is for output nobody asked for, such as an auto-run demo or a feed. It returns a copy of a coin or recipient result with no GitHub login (####), no declared handle (@####), no social user id (null) and no SocialFeePda address (####, because the PDA's account data holds the user id). A word of the coin's name or symbol that spells one of those becomes #### too. The masking covers the structure, the name/symbol/description text, the diff and the blame lines. Numbers, bps, mints, the SharingConfig and the vaults stay, so the output still explains the coin. The result adds masked: true, logins: "masked" and a loginsNote. Masking a masked result changes nothing.
One mask, one width. Everything payblame masks prints as the same four characters, #### (exported as MASK; LOGIN_MASK, HANDLE_MASK and ADDRESS_MASK are the same string). The width never depends on the real value, because the length of a login is a clue too. Fixed-width columns pad the mask with spaces.
lookup(q, opts), blameRecipient(input, opts), blameCoin(mint, opts), buildLedger(opts) (masked unless { reveal: true }; maskSecret for stable row ids), maskLedger(ledger, { secret }), maskLookup(result), MASK ('####'), isNumericId(userId), ticker(symbol) (strips a leading $, so $RapCat prints once as $RapCat), parseQuery(q), decodeSharingConfig(bytes), decodeSocialFeePda(bytes), socialFeePda(id, platform), sharingConfigPda(mint), creatorVaultPdas(creator), formatBlame, formatCoin, formatLedger, extractDeclared, diffDeclared, aggregateLedger.
Every network function takes { rpcUrl | rpc, githubToken?, fetch?, cache?, timeoutMs? }. Injecting fetch is how the tests replay recorded mainnet traffic. Errors are PayblameError with code set to BAD_INPUT, GITHUB_NOT_FOUND, NOT_PUMP, GITHUB_RATE_LIMIT or UPSTREAM, and an HTTP-ready status. Types are in src/index.d.ts.
Runtime dependencies: none. It needs Node >= 22 (node:crypto for sha256 and global fetch). Base58, Borsh decoding and the PDA math are hand-written.
Offline, on recorded mainnet accounts (node bench/decode.mjs, Apple M5, 10 cores, Darwin 25.5.0, Node v26.8.1, 2026-09-27):
| benchmark | result |
|---|---|
| decodeSharingConfig (2 shareholders, recorded account) | 186,835 decodes/s |
| decodeSocialFeePda (179 bytes) | 7,039,197 decodes/s |
| aggregateLedger over the 12,274 recorded SocialFeePdas (decode + aggregate + rank) | 0.7 ms (median of 20) |
| socialFeePda(id, 2) derivation (sha256 + BigInt ed25519 off-curve check) | 45,399 /s |
| creatorVaultPdas(config), 3 PDAs including the WSOL ATA | 15,731 /s |
On 2026-09-25 the same bench measured 5 ms, 5,729 /s and 1,898 /s for the last three rows. Two internal changes made the difference, with the same results (tests compare both forms): the off-curve check computes one Jacobi symbol of u*v instead of the square root (u*v^7)^((p-5)/8), and aggregateLedger reads the four fields it needs in place and keeps its top lists as it goes instead of sorting every GitHub account three times.
Live, network-bound (PAYBLAME_RPC_URL=... node bench/live.mjs <login> 5, same Mac, Helius mainnet RPC, 2026-09-25T13:55Z). The measured account was a GitHub recipient with 89 coins; this README doesn't name it. Pass any login to reproduce. The probe count is always 10, and the decode work grows with the number of coins:
| measurement | result |
|---|---|
payblame <login> end to end (89 coins), median of 5 |
782 ms (runs: 782, 486, 472, 1595, 1585) |
| RPC calls per lookup | 17 cold, 15 warm (rent constants cached) |
| Helius credits per lookup (gPA and DAS at 10, other calls at 1) | 116 cold, 114 warm |
| one reverse probe, slots 0..9, run one at a time | 101, 83, 86, 84, 84, 91, 44, 45, 80, 1132 ms |
| buildLedger: 1 getProgramAccounts over 12,274 SocialFeePdas, median of 3 | 278 ms |
The live numbers depend on your RPC and its load. In our runs, the same probe sometimes took 1–2 s. For scale: a full getProgramAccounts over all SharingConfigs, even with no data, returned 678,295 accounts, 155 MB, in 21.2 s. The probes return only the matching pubkeys. The largest recipient we found, a GitHub account with 18,883 coins, is one 4.3 MB probe that took about 1.1 s.
npm test # offline: decoders, PDAs, grammar, diff, formats, GitHub fallbacks, RPC retry,
# a synthetic 20,000-coin recipient, 7 end-to-end lookups replayed from recorded mainnet traffic,
# and the fixture guard (decodes every account in every fixture; fails on any non-placeholder id)
npm run record # re-record the fixtures (needs PAYBLAME_RPC_URL; PAYBLAME_RECORD_LOGIN for the recipient fixture)
The fixtures are recorded mainnet traffic, scrubbed before they are written (test/helpers/scrub.js, called by npm run record). GitHub logins become sample-login-NN, keyed by numeric id, so one account has the same placeholder in every fixture, including the ledger fixture. Handles a coin declares become sample_handle. GitHub REST bodies are cut down to { login, id, type }. DAS and DexScreener bodies keep only the fields payblame reads. Every pump coin is renamed Sample coin NN / SMPLNN and loses its description (unless the test needs it, in which case its handles are placeholders), because a coin's name can spell a person too. A numeric GitHub or X id is not kept either, because a public API turns it back into a login: every numeric user_id becomes a synthetic id from 9000000001 up (ten digits, far above any GitHub id issued so far), one per account and the same in every fixture, assigned in random order. The account moves to the PDA of its synthetic id, and so does every reference to it: its key and bump, the memcmp probe bytes, getAccountInfo/getMultipleAccounts keys, the SharingConfig shareholder slots, GitHub /user/<id> routes and bodies, cache keys and queries. In the ledger fixture, the 90 SocialFeePdas whose on-chain user_id is text carry a placeholder of the same byte length (sample_x_0007, or ~07 when the id is short) at the PDA of that placeholder. Lamports, platform, bps, claim fields and the coin accounts are as recorded, so the replays run the real decode path and print the recorded numbers, and each fixture's expect is re-derived by replaying its scrubbed transcript.
test/mask.test.js is the guard. It gunzips every fixture, parses every JSON body inside it, and decodes every base64 account by its discriminator: SocialFeePda user_ids, SharingConfig and BondingCurve fields, and a Borsh-string scan of anything else. It fails on any login, handle, text user_id, coin name or description word that isn't a placeholder, on any numeric GitHub/X id outside the synthetic range (in account bytes, GitHub routes and bodies, cache keys, the ledger's id map, queries and expected output), and on any SocialFeePda that doesn't derive from a synthetic or placeholder id: its own key and bump, and every shareholder slot or probe that names it must be a wallet or a SocialFeePda some fixture proves. The guard knows what a placeholder looks like. It doesn't list the real accounts. Test inputs written by hand use placeholders and synthetic ids too (sample-dev, sample-org, ghid:9000900001, x:9000900004). test/pda.test.js checks socialFeePda for synthetic ids against @solana/web3.js findProgramAddressSync, and the same findProgramAddress against real mainnet coin accounts (SharingConfig, bonding curve, creator vaults).
- PLANNED: per-coin lifetime distribution and a claim timeline. Lifetime SOL per coin only exists in
DistributeCreatorFeesEvent/SocialFeePdaClaimedself-CPI event data, so it needsgetSignaturesForAddressplusgetTransactiondecoding. Today payblame reports per-recipient lifetimetotal_claimed, which is exact, and per-coin pending amounts. - PLANNED: resolving X numeric ids to handles. Today X recipients show as
x:#<id>. - PLANNED: a watch mode (a webhook on
create_fee_sharing_configthat tells a GitHub user when a new coin starts paying them). - Pending is an upper bound. The pump program has a minimum distributable amount, so tiny piles may not move yet.
- The recipient path reads vaults by the SharingConfig address. It assumes the curve creator is the config, which held on every mint we checked. The coin path reads the curve and follows its actual creator.
- Truncation. Only the first
limitconfigs, by address, are decoded (max 250).totals.coinsis always exact. - GitHub REST allows 60 requests/h without a token. When limited, login-to-id falls back to the public avatar redirect (
github.com/<login>.pngredirects to/u/<id>), and id-to-login showsgithub:#<id>. - Organizations show as
type=Organization. Whether an org can claim through pump.fun is not verified here. - Layout changes: decoders check discriminators and lengths and flag unknown
SharingConfigversions (versionRecognized: false).
- nirholas/pumpfun-creator-rewards looks up earnings through pump.fun's private
swap-apifee-sharing endpoints. payblame uses no pump.fun endpoint. It reads chain state, so it keeps working if that API changes. - nirholas/pumpfun-claims-bot and pumpfun-github-claims are Telegram feeds of claims. They have no lookup and no unclaimed balances.
- @pump-fun/pump-sdk builds instructions and PDAs. The account layouts and seeds used here come from its 2.0.0 IDLs (
pump_fees.json,pump.json,pump_amm.json,src/pda.ts). - None of these reverse-index recipients with memcmp probes, print the whole-network ledger from one call, flag MUTABLE splits, or diff declared recipients against the on-chain split.
app/ is the code behind https://payblame.anyfee.workers.dev: the site (three.js, a procedural dot-matrix printer) and the functions that run this library on mainnet (/api/lookup, /api/ledger, /api/health, and ledger-cron every 15 minutes). It runs on Cloudflare Workers (the Workers Free plan) with a D1 database. The Netlify copy (payblame.netlify.app) is paused. The same app/ still builds and deploys on Netlify unchanged. It imports the library from src/; the library never imports anything from app/, and app/ is not part of the npm package.
cd app && npm ci && cp .env.example .env # then add HELIUS_API_KEY
npm test && npm run build && npm run dev # http://localhost:8888To deploy your own copy on Cloudflare, see Deploy to Cloudflare Workers: wrangler d1 create, one migration, three secrets, wrangler deploy. On the free plan the ledger is scanned in pages over several cron runs and a recipient lookup lists at most 20 coins (the count stays exact); the README lists what else changes. To deploy on Netlify instead, create a site from this repo: the root netlify.toml builds app/ and bundles its functions. Either way, set HELIUS_API_KEY, GITHUB_TOKEN (optional; without it GitHub allows 60 requests/h) and LEDGER_MASK_SECRET. app/README.md has the endpoints, env vars, schedules and credit costs.
MIT. See LICENSE.