Docs / Developer API
Verify reward runs
A coin whose deployer share goes to its holders pays them in ZEC, in runs. Every run can be checked from the chain by anyone, with a public RPC, without trusting Sapling's servers or this API: the API only tells you which transactions to look at.
What anyone can verify today, with no trust in us
- The fingerprint committed on chain. Before any ZEC moves, the run's release transaction commits the snapshot's sha256 and its slot on chain, in the vault program's
RewardsReleasedevent, and moves the coin's whole pot to the rewards wallet. The snapshot published with the run must hash to exactly that fingerprint: it cannot be changed after the release without the hash no longer matching. - The share math. Every holder's share follows from the snapshot and the pot the release moved on chain, with one formula, below. Recompute every share and compare.
- Every payout on chain. Every payout signature is a transfer of ZEC from the rewards wallet to the holder's ZEC account, for exactly the share (less the one time rent, when the payment opened that account). What was not paid goes back to the coin's pot in the run's closing transaction, which closes the run on chain.
The run is GET /v1/rewards/runs/{id}: its snapshot, the transactions (releaseSignature, each payout's signature, returnSignature), the eligibility inputs and the excluded wallets. Every run is also listed on /rings.
The formulas
snapshot = JSON.stringify({ mint, slot, holders: [[wallet, balance], ...] })
holders sorted by wallet address, balances as integer strings
hash = sha256(snapshot)
committed on chain by the release
eligible : balance / 10^6 × zecPerToken × zecUsd ≥ minHoldingUsd
share_i = floor(pot × balance_i ÷ Σ balances)
pot = the ZEC the release moved on chain
paid_i = share_i − rentZec_i
rentZec_i = 0 unless the payment opened the account
returned = pot − Σ shares paid
back to the coin's pot when the run closes- The snapshot is taken at a random slot, not announced. A coin runs when its pot is worth $25 or more, at most once an hour.
- A holder is eligible with $20 or more of the coin at the snapshot. The prices used are stored with the run and published in
eligibility:zecPerToken,zecUsdandminHoldingUsd, withholdersSeen, the wallets holding any before the minimum. - Shares are floored, so their sum is at most the pot. The rounding, and every share that could not be paid, goes back to the pot for the next run.
Who is left out
A snapshot adds up every token account of the coin per wallet, then leaves out:
- Program addresses. Every address off the ed25519 curve: the bonding curve, the pool, lockers and vaults. They cannot receive ZEC as a wallet does.
- Burn addresses.
1nc1nerator11111111111111111111111111111111and11111111111111111111111111111111. - Sapling's own wallets. The treasury, the buyback wallet, the rewards wallet and the keeper, published per run in
excluded. The treasury, buyback and rewards wallets are the ones the vault program's Config holds on chain; the rewards wallet of a run is also in its release event, and the keeper is the fee payer of its release transaction.
Every other wallet holding at least the minimum is in the snapshot.
The one time account rent
A holder whose wallet has no ZEC account cannot receive ZEC until one exists. The payment opens it, and the account's rent, valued in ZEC at that moment, is taken from that holder's share once and sent to the treasury in the same transaction. The holder receives shareZec − rentZec; the payout says so in its rentZec. From then on the account exists, and later runs pay the full share.
A share worth less than twice the rent is not paid that way: it rolls over (reason no-account), as does a share to a frozen account (frozen) or a share of zero (zero).
The complete holder set
The checks above prove that the published snapshot is the committed one and that every ZEC of the pot went where the formulas say. Proving that it is the complete holder set at its slot means knowing every token account of the coin as it was at that slot. No Solana RPC answers for a past slot directly, so the script rebuilds it: every token account of the coin now (getProgramAccounts), rolled back through every transaction of the coin since the slot (getSignaturesForAddress on the coin's mint, then getTransaction for each). It compares the rebuilt set's hash with the one committed on chain.
- What it needs. An RPC that answers
getProgramAccountsand serves the coin's transactions since the slot. The usual RPC providers keep the full transaction history, so any run can be checked through them. A node that keeps only recent history can check recent runs only. - How long it takes. It grows with the coin's transactions since the slot. A quiet coin a few hours after its run: a few transactions, seconds. A busy coin half a day after its run: 452 transactions, about a minute and a half.
- One limit. A transfer between two wallets with the plain
Transferinstruction does not name the coin's mint, so it is not in the mint's history. An account emptied and closed that way after the slot is not rebuilt, and the script reports the difference it causes.
The script
One run, every check above, PASS or FAIL for each. It uses Sapling's own functions for the snapshot hash, the eligibility and the shares, and reads every fact from your RPC. Add --at-slot to rebuild the complete holder set as well.
It needs only @saplingcash/sdk (0.3.0 or later, which carries this math) and @solana/web3.js, in an ES module project:
npm init -y && npm pkg set type=module
npm install @saplingcash/sdk @solana/web3.js tsx
# save the script below as verify-reward-run.ts, then:
npx tsx verify-reward-run.ts 425 --rpc https://your-rpc.example
npx tsx verify-reward-run.ts 425 --rpc https://your-rpc.example --at-slot/**
* Verify a holder-rewards run end to end, trusting neither Sapling's API nor its servers for anything that matters.
* The API only says which transactions to look at; every fact is read from Solana through your own RPC, and the math is
* Sapling's own (snapshotJson, snapshotHash, rewardShares, eligibleHolders: the SDK carries the same code the keeper
* runs), not a rewrite. It needs only @saplingcash/sdk (0.3.0 or later) and @solana/web3.js.
*
* npx tsx examples/verify-reward-run.ts <run id> --rpc <url> [--api <base url>] [--at-slot]
*
* It prints PASS or FAIL for:
* (a) the snapshot: sha256 of the published snapshot equals the run's snapshotHash, and the hash and the slot that the
* release transaction committed on chain (its RewardsReleased event, emitted by the pinned vault program); the
* release moved exactly that pot from the coin's pot account to the rewards wallet
* (b) the shares: every share is floor(pot × balance ÷ total) for the pot released on chain; every holder in the
* snapshot passes the run's $ minimum and none is an excluded wallet, a program address or a burn address
* (c) the payouts: every payout signature is a transfer of ZEC from the rewards wallet to the holder's ZEC account of
* exactly the share, or, for a holder whose ZEC account the payment opened, the share less that account's one time
* rent (sent to the treasury in the same transaction); what was not paid went back to the pot in the closing
* transaction, which closed the run on chain
*
* --at-slot: also rebuilds the whole holder set at the snapshot's slot from the chain (every token account of the coin
* now, rolled back through every transaction of the coin after the slot) and compares its hash with the committed one.
* It needs an RPC that answers getProgramAccounts and serves the coin's transactions since the slot (the usual providers
* keep the full history; a node with recent history only covers recent runs). Its time grows with those transactions:
* a few seconds for a quiet coin, about a minute and a half for 452 transactions.
*/
import { Connection, PublicKey } from "@solana/web3.js";
import { BURN_ADDRESSES, MAINNET_PINNED, Sapling, aggregateHolders, coinPotAccount, eligibleHolders, rewardShares, snapshotHashHex, snapshotJson, vaultConfigAddress, vaultConfigWallets, vaultEvents, zecAta, type RewardRunDetail } from "@saplingcash/sdk";
const args = process.argv.slice(2);
const flag = (name: string) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const runId = Number(args.find((a) => /^\d+$/.test(a)));
const rpc = flag("--rpc") ?? process.env.RPC_URL;
if (!runId || !rpc) {
console.error("Usage: npx tsx examples/verify-reward-run.ts <run id> --rpc <url> [--api <base url>] [--at-slot]");
process.exit(2);
}
const AT_SLOT = args.includes("--at-slot");
const conn = new Connection(rpc, "confirmed");
const sapling = new Sapling({ ...(flag("--api") ? { baseUrl: flag("--api") } : {}), apiKey: process.env.SAPLING_API_KEY || undefined });
const VAULT = new PublicKey(MAINNET_PINNED.vaultProgram);
const ZEC = MAINNET_PINNED.zecMint;
/** the coins' token program (pump.fun plants Token-2022 coins) */
const TOKEN_2022 = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
let failures = 0;
const check = (ok: boolean, text: string) => {
if (!ok) failures++;
console.log(` ${ok ? "PASS" : "FAIL"} ${text}`);
};
const note = (text: string) => console.log(` ${text}`);
const short = (s: string) => `${s.slice(0, 6)}…${s.slice(-4)}`;
const zec = (zat: bigint) => `${(Number(zat) / 1e8).toFixed(8)} ZEC`;
/** A transaction as the RPC's JSON answers it (any transaction version: read over plain JSON-RPC, never decoded here). */
interface Tx {
slot: number;
err: unknown;
logs: string[];
/** every account key in index order, lookup-table ones included; [0] is the fee payer */
keys: string[];
pre: TokenBalance[];
post: TokenBalance[];
}
interface TokenBalance {
accountIndex: number;
mint: string;
owner?: string;
uiTokenAmount: { amount: string };
}
/** a transaction, asked again when the RPC is busy (a 429 waits as long as it says); null when the RPC does not have it */
async function tx(signature: string): Promise<Tx | null> {
for (let i = 0; ; i++) {
try {
const r = await fetch(rpc!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTransaction", params: [signature, { encoding: "json", commitment: "confirmed", maxSupportedTransactionVersion: 1 }] }) });
if (r.status === 429) throw new Busy(Number(r.headers.get("retry-after")) || null);
const j = (await r.json()) as { result?: { slot: number; meta: { err: unknown; logMessages?: string[]; preTokenBalances?: TokenBalance[]; postTokenBalances?: TokenBalance[]; loadedAddresses?: { writable: string[]; readonly: string[] } }; transaction: { message: { accountKeys: string[] } } } | null; error?: { message: string } };
if (j.error) throw new Error(j.error.message);
const t = j.result;
if (!t) return null;
return { slot: t.slot, err: t.meta.err, logs: t.meta.logMessages ?? [], keys: [...t.transaction.message.accountKeys, ...(t.meta.loadedAddresses?.writable ?? []), ...(t.meta.loadedAddresses?.readonly ?? [])], pre: t.meta.preTokenBalances ?? [], post: t.meta.postTokenBalances ?? [] };
} catch (e) {
if (i === 9) throw e;
const wait = e instanceof Busy && e.retryAfter ? e.retryAfter * 1000 : 1000 * 2 ** Math.min(i, 5);
await new Promise((r) => setTimeout(r, wait));
}
}
}
class Busy extends Error {
constructor(readonly retryAfter: number | null) {
super("the RPC is busy (429)");
}
}
/** how much each token account of `mint` changed in a transaction (post − pre), by address; created accounts flagged */
function deltas(t: Tx, mint: string): Map<string, { delta: bigint; created: boolean; owner: string | undefined }> {
const keys = t.keys;
const out = new Map<string, { delta: bigint; created: boolean; owner: string | undefined }>();
const pre = new Map(t.pre.filter((b) => b.mint === mint).map((b) => [b.accountIndex, b]));
const post = new Map(t.post.filter((b) => b.mint === mint).map((b) => [b.accountIndex, b]));
for (const i of new Set([...pre.keys(), ...post.keys()])) {
const a = BigInt(pre.get(i)?.uiTokenAmount.amount ?? "0");
const b = BigInt(post.get(i)?.uiTokenAmount.amount ?? "0");
out.set(keys[i]!, { delta: b - a, created: !pre.has(i), owner: post.get(i)?.owner ?? pre.get(i)?.owner });
}
return out;
}
async function pool<T, R>(items: T[], n: number, f: (x: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
while (next < items.length) {
const i = next++;
out[i] = await f(items[i]!);
}
}));
return out;
}
const run: RewardRunDetail = await sapling.rewards.run(runId);
console.log(`Run ${run.id}: $${run.symbol} ${run.mint}, ${run.status}, snapshot slot ${run.snapshotSlot}, ${run.eligible} holders`);
if (run.status !== "done") console.log(" (the run is not finished: payouts and the closing transaction may still be missing)");
if (!run.releaseSignature) {
console.log(" FAIL no release transaction: nothing was committed on chain");
process.exit(1);
}
// ---------------------------------------------------------------- (a) the snapshot and its commitment on chain
console.log("\n(a) The snapshot, committed on chain");
const release = await tx(run.releaseSignature);
if (!release) throw new Error(`the RPC does not have the release transaction ${run.releaseSignature}`);
check(release.err === null, `release transaction ${short(run.releaseSignature)} succeeded (slot ${release.slot})`);
const events = vaultEvents(VAULT, release.logs).filter((e) => e.name === "RewardsReleased");
check(events.length === 1, `exactly one RewardsReleased event from the pinned vault program ${short(VAULT.toBase58())}`);
const ev = events[0]?.data as { mint: PublicKey; run: number; amount: { toString(): string }; snapshot_slot: { toString(): string }; snapshot_hash: number[]; rewards_wallet: PublicKey } | undefined;
if (!ev) process.exit(1);
const pot = BigInt(ev.amount.toString());
const evHash = Buffer.from(ev.snapshot_hash).toString("hex");
const rewardsWallet = ev.rewards_wallet.toBase58();
const keeper = release.keys[0]!;
check(ev.mint.toBase58() === run.mint, `the event names the coin ${short(run.mint)}`);
check(evHash === run.snapshotHash, `the event's snapshot hash ${evHash.slice(0, 16)}… equals the run's snapshotHash`);
check(ev.snapshot_slot.toString() === run.snapshotSlot && release.slot >= Number(run.snapshotSlot), `the event's snapshot slot ${ev.snapshot_slot.toString()} equals the run's, and the release came after it (slot ${release.slot})`);
check(run.potZec === null || BigInt(run.potZec) === pot, `the pot released on chain is ${zec(pot)} (the run says ${run.potZec === null ? "nothing" : zec(BigInt(run.potZec))})`);
const potAccount = coinPotAccount(new PublicKey(run.mint), VAULT).toBase58();
const rewardsZec = zecAta(new PublicKey(rewardsWallet)).toBase58();
const rd = deltas(release, ZEC);
check(rd.get(potAccount)?.delta === -pot && rd.get(rewardsZec)?.delta === pot, `the release moved exactly ${zec(pot)} from the coin's pot ${short(potAccount)} to the rewards wallet ${short(rewardsWallet)}`);
type Snapshot = { mint: string; slot: number; holders: [string, string][] };
const snap = run.snapshot as Snapshot | null;
let holders: [string, bigint][] | null = null;
if (snap) {
holders = snap.holders.map(([o, b]) => [o, BigInt(b)]);
const hash = snapshotHashHex(snapshotJson(snap.mint, snap.slot, holders));
check(hash === run.snapshotHash && hash === evHash, `sha256 of the published snapshot (${holders.length} holders) is ${hash.slice(0, 16)}…, the hash committed on chain`);
check(snap.mint === run.mint && String(snap.slot) === run.snapshotSlot, "the snapshot names the run's coin and slot");
} else {
note("the snapshot is withheld (it names a shielded launch's deployer): its hash is still the one on chain, the shares are checked from the payouts");
}
// ---------------------------------------------------------------- (b) the shares, and who may be in the snapshot
console.log("\n(b) The shares and the eligibility rules");
const list: [string, bigint][] = holders ?? run.payouts.map((p, i) => [p.owner ?? `(withheld ${i})`, BigInt(p.balance)]);
const shares = rewardShares(pot, list);
const total = list.reduce((s, [, b]) => s + b, 0n);
const byOwner = new Map(run.payouts.map((p, i) => [p.owner ?? `(withheld ${i})`, p]));
const wrong = shares.filter((s) => {
const p = byOwner.get(s.owner);
return !p || BigInt(p.balance) !== s.balance || BigInt(p.shareZec) !== s.share;
});
check(byOwner.size === shares.length && wrong.length === 0, `all ${shares.length} shares are floor(${pot} × balance ÷ ${total}) (Σ ${zec(shares.reduce((s, x) => s + x.share, 0n))} of the ${zec(pot)} pot)`);
for (const w of wrong.slice(0, 5)) note(`${w.owner}: expected ${w.share}, published ${byOwner.get(w.owner)?.shareZec ?? "nothing"}`);
if (holders) {
const onCurve = holders.filter(([o]) => !PublicKey.isOnCurve(new PublicKey(o).toBytes()));
check(onCurve.length === 0, "no program address (off the ed25519 curve: curve, pool, lockers) is in the snapshot");
const vc = await conn.getAccountInfo(vaultConfigAddress(VAULT));
const cfg = vc ? vaultConfigWallets(VAULT, vc.data) : null;
const excluded = new Map<string, string>([...BURN_ADDRESSES.map((a) => [a, "burn"] as [string, string]), [rewardsWallet, "rewards wallet (the event's)"], [keeper, "keeper (the release's fee payer)"]]);
if (cfg) {
excluded.set(cfg.treasury.toBase58(), "treasury (vault Config now)");
excluded.set(cfg.buybackWallet.toBase58(), "buyback wallet (vault Config now)");
}
const hit = holders.filter(([o]) => excluded.has(o));
check(hit.length === 0, `none of the excluded wallets is in it: ${[...excluded.values()].filter((v) => v !== "burn").join(", ")}, the burn addresses`);
if ("eligibility" in run && run.eligibility) {
const e = run.eligibility;
const kept = eligibleHolders(new Map(holders), Number(e.zecPerToken), Number(e.zecUsd), Number(e.minHoldingUsd));
check(kept.length === holders.length && kept.every(([o], i) => o === holders![i]![0]), `every holder is worth at least $${e.minHoldingUsd} at ${e.zecPerToken} ZEC per token and $${e.zecUsd} per ZEC, in the canonical order (${e.holdersSeen} wallets held any)`);
} else note("this API answer has no eligibility inputs: the $ minimum was not checked");
}
// ---------------------------------------------------------------- (c) every payout on chain, and the rollover
console.log("\n(c) The payouts on chain");
const paid = run.payouts.filter((p) => p.signature);
const sigs = [...new Set(paid.map((p) => p.signature!))];
const txs = new Map((await pool(sigs, 4, async (s) => [s, await tx(s)] as const)).map(([s, t]) => [s, t]));
const treasury = MAINNET_PINNED.treasury;
const treasuryZec = zecAta(new PublicKey(treasury)).toBase58();
let bad = 0;
let opened = 0;
let sentNet = 0n;
let rentTotal = 0n;
for (const sig of sigs) {
const t = txs.get(sig);
const rows = paid.filter((p) => p.signature === sig);
const problems: string[] = [];
if (!t || t.err !== null) problems.push(t ? "the transaction failed" : "the RPC does not have it");
else {
const d = deltas(t, ZEC);
const expected = new Map<string, bigint>();
let rent = 0n;
let gross = 0n;
for (const p of rows) {
const share = BigInt(p.shareZec);
const r = BigInt("rentZec" in p ? (p.rentZec ?? "0") : "0");
gross += share;
rent += r;
if (!p.owner) continue; // a shielded launch's deployer: no address to look at; its amount is in the totals
const ata = zecAta(new PublicKey(p.owner)).toBase58();
expected.set(ata, share - r);
const got = d.get(ata);
if (!got || got.delta !== share - r) problems.push(`${short(p.owner)} received ${got?.delta ?? 0n}, not ${share - r}`);
if (got && got.owner !== p.owner) problems.push(`${short(ata)} is not ${short(p.owner)}'s`);
if (r > 0n) {
opened++;
if (!got?.created) problems.push(`${short(p.owner)} paid ${r} of rent, but the payment did not open its ZEC account`);
} else if (got?.created) problems.push(`${short(p.owner)}'s ZEC account was opened, but no rent was taken`);
}
if (rent > 0n) expected.set(treasuryZec, rent);
if (d.get(rewardsZec)?.delta !== -gross) problems.push(`the rewards wallet sent ${-(d.get(rewardsZec)?.delta ?? 0n)}, not ${gross}`);
if (rent > 0n && d.get(treasuryZec)?.delta !== rent) problems.push(`the treasury received ${d.get(treasuryZec)?.delta ?? 0n} of rent, not ${rent}`);
// nothing else moved ZEC in it (the deployer of a shielded launch, when withheld, is the one account allowed)
const withheld = rows.filter((p) => !p.owner);
const others = [...d].filter(([a, x]) => a !== rewardsZec && !expected.has(a) && x.delta !== 0n);
if (others.length > withheld.length) problems.push(`${others.length - withheld.length} other ZEC account(s) changed`);
sentNet += gross - rent;
rentTotal += rent;
}
if (problems.length) {
bad++;
note(`${short(sig)}: ${problems.join("; ")}`);
}
}
const confirmed = run.payouts.filter((p) => p.status === "confirmed");
check(bad === 0 && paid.length === confirmed.length, `${confirmed.length} payouts in ${sigs.length} transactions: each a ZEC transfer from the rewards wallet to the holder's ZEC account of exactly the share${opened ? `, less the one time rent for the ${opened} account(s) the payment opened (${zec(rentTotal)} to the treasury)` : ""}`);
check(BigInt(run.paidZec) === sentNet, `paid to holders: ${zec(sentNet)} (the run says ${zec(BigInt(run.paidZec))})`);
const skipped = run.payouts.filter((p) => p.status === "skipped");
const rest = pot - confirmed.reduce((s, p) => s + BigInt(p.shareZec), 0n);
const reasons = [...skipped.reduce((m, p) => m.set(p.reason ?? "unknown", (m.get(p.reason ?? "unknown") ?? 0) + 1), new Map<string, number>())].map(([r, n]) => `${n} ${r}`);
note(`not paid: ${skipped.length ? reasons.join(", ") : "none"} (worth ${zec(skipped.reduce((s, p) => s + BigInt(p.shareZec), 0n))}); with the rounding, ${zec(rest)} to return to the pot`);
if (run.returnSignature) {
const ret = await tx(run.returnSignature);
const d = ret ? deltas(ret, ZEC) : null;
const closed = (ret?.logs ?? []).some((l) => /Instruction: CloseRewardsRun/.test(l));
check(!!ret && ret.err === null && closed && (rest === 0n || (d?.get(rewardsZec)?.delta === -rest && d?.get(potAccount)?.delta === rest)), `the closing transaction ${short(run.returnSignature)} returned ${zec(rest)} to the coin's pot and closed the run on chain`);
check(BigInt(run.rolledOverZec) === rest, `rolled over: ${zec(rest)} (the run says ${zec(BigInt(run.rolledOverZec))})`);
} else check(run.status !== "done", "a finished run has its closing transaction");
// ---------------------------------------------------------------- --at-slot: the whole holder set, rebuilt from the chain
if (AT_SLOT) {
console.log("\n(--at-slot) The complete holder set at the snapshot's slot");
const S = Number(run.snapshotSlot);
const mint = new PublicKey(run.mint);
const now = await conn.getProgramAccounts(TOKEN_2022, { filters: [{ memcmp: { offset: 0, bytes: mint.toBase58() } }], dataSlice: { offset: 32, length: 40 } });
const accounts = new Map(now.filter((a) => a.account.data.length === 40).map((a) => [a.pubkey.toBase58(), { owner: new PublicKey(a.account.data.subarray(0, 32)).toBase58(), amount: a.account.data.readBigUInt64LE(32) }]));
// every transaction of the coin after the slot, newest first, back to the slot
const after: string[] = [];
let before: string | undefined;
for (;;) {
const page = await conn.getSignaturesForAddress(mint, { limit: 1000, ...(before ? { before } : {}) });
for (const s of page) if (s.slot > S) after.push(s.signature);
if (page.length < 1000 || page[page.length - 1]!.slot <= S) break;
before = page[page.length - 1]!.signature;
}
note(`${accounts.size} token accounts of the coin now; ${after.length} transactions of the coin after slot ${S}: reading them…`);
// the balance at the slot is the one just before the oldest transaction after it that touched the account (`after`
// is newest first, so a larger index is older, also inside one slot); an account that transaction created held 0
const seen = new Map<string, { order: number; owner: string; amount: bigint }>();
const all = await pool(after, 4, tx);
all.forEach((t, order) => {
if (!t) return;
const keys = t.keys;
const pre = new Map(t.pre.filter((b) => b.mint === run.mint).map((b) => [b.accountIndex, b]));
for (const b of t.post.filter((x) => x.mint === run.mint).concat([...pre.values()])) {
const k = keys[b.accountIndex]!;
const prev = seen.get(k);
if (prev && prev.order >= order) continue;
const p = pre.get(b.accountIndex);
seen.set(k, { order, owner: (p ?? b).owner!, amount: p ? BigInt(p.uiTokenAmount.amount) : 0n });
}
});
const atS = new Map(accounts);
for (const [k, v] of seen) atS.set(k, { owner: v.owner, amount: v.amount });
const cfgInfo = await conn.getAccountInfo(vaultConfigAddress(VAULT));
const cfg = cfgInfo ? vaultConfigWallets(VAULT, cfgInfo.data) : null;
const balances = aggregateHolders([...atS.values()].map((a) => ({ owner: new PublicKey(a.owner), amount: a.amount })), [rewardsWallet, keeper, ...(cfg ? [cfg.treasury.toBase58(), cfg.buybackWallet.toBase58()] : [])]);
const e = "eligibility" in run && run.eligibility ? run.eligibility : null;
if (!e) note("no eligibility inputs in this API answer: cannot apply the $ minimum");
else {
const rebuilt = eligibleHolders(balances, Number(e.zecPerToken), Number(e.zecUsd), Number(e.minHoldingUsd));
const hash = snapshotHashHex(snapshotJson(run.mint, S, rebuilt));
check(hash === evHash, `rebuilt from the chain: ${rebuilt.length} eligible holders, sha256 ${hash.slice(0, 16)}… ${hash === evHash ? "equals" : "differs from"} the committed hash`);
if (hash !== evHash && holders) {
const pub = new Map(holders);
const mine = new Map(rebuilt);
for (const [o, b] of rebuilt) if (pub.get(o) !== b) note(`${o}: ${b} at the slot, ${pub.get(o) ?? "absent"} in the snapshot`);
for (const [o, b] of holders) if (!mine.has(o)) note(`${o}: in the snapshot with ${b}, not found at the slot`);
note("a plain transfer between two wallets does not name the coin's mint, so this rebuild can miss an account closed after the slot");
}
}
}
console.log(failures ? `\nFAIL: ${failures} check(s) failed` : "\nPASS: every check passed");
process.exitCode = failures ? 1 : 0;