// PGN — the relay's utility token, and the discount schedule it buys. // // The token is a UTILITY token: holding it makes the relay cheaper and faster, spending it // burns it. It is not a share, not a claim on revenue, and not a governance token. // // This module has one job: turn an on-chain balance into a discount, honestly and verifiably. // It reads balanceOf over JSON-RPC with no dependencies, caches per address, and degrades to // the full list price whenever anything is unknown — an unreachable RPC, an unset token // address, or a chain that has not been deployed to yet. A discount you cannot verify is worse // than no discount, so the relay NEVER guesses one. import { TOKEN } from "./token.config.js"; import fs from "node:fs"; /** The running module, served verbatim so the schedule can be checked against the code. */ export function sourceOf() { return fs.readFileSync(new URL(import.meta.url), "utf8"); } const RPC = process.env.BASE_RPC || "https://mainnet.base.org"; const CACHE_MS = Number(process.env.PGN_CACHE_MS || 60_000); const BAL_TIMEOUT_MS = 4000; // Holders come from a block explorer, not from the RPC: an ERC-20's holders cannot be enumerated // over eth_call at any price, so the only alternative is replaying every Transfer log the contract // ever emitted. The explorer already did that. It is a third party, so it is NAMED in the response // and its values are cached and dated rather than presented as timeless truth. const EXPLORER = (process.env.PGN_EXPLORER || "https://base.blockscout.com").replace(/\/+$/, ""); const HOLDERS_CACHE_MS = Number(process.env.PGN_HOLDERS_CACHE_MS || 120_000); const HOLDERS_TIMEOUT_MS = Number(process.env.PGN_HOLDERS_TIMEOUT_MS || 8000); let holdersCache = { at: 0, data: null }; async function explorerJson(path) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), HOLDERS_TIMEOUT_MS); try { const r = await fetch(EXPLORER + path, { signal: ctl.signal, headers: { accept: "application/json" } }); if (!r.ok) throw new Error(`${path} -> HTTP ${r.status}`); return await r.json(); } finally { clearTimeout(timer); } } /** The company's own allocation wallets — labelled, so they are not miscounted as adopters. */ function ownAddressMap() { const m = new Map(); for (const a of TOKEN.allocations || []) { m.set(String(a.address).toLowerCase(), { bucket: a.bucket, note: a.note, kind: a.kind }); } return m; } /** Wei to tokens without ever putting a 27-digit integer through a double. */ function toTokens(wei, decimals) { const scale = 10n ** BigInt(decimals); return Number((wei * 10_000n) / scale) / 10_000; } async function readHolders(limit = 50) { const decimals = Number(process.env.PGN_DECIMALS || 18); const supply = Number(TOKEN.supply); const [counters, page] = await Promise.all([ explorerJson(`/api/v2/tokens/${TOKEN.address}/counters`), explorerJson(`/api/v2/tokens/${TOKEN.address}/holders`), ]); const own = ownAddressMap(); const rows = (page.items || []).slice(0, limit).map((h) => { const a = h.address || {}; const tokens = toTokens(BigInt(h.value || "0"), decimals); const mine = own.get(String(a.hash || "").toLowerCase()); return { address: a.hash || null, balanceTokens: tokens, sharePercent: supply ? (tokens / supply) * 100 : null, isContract: !!a.is_contract, label: mine ? mine.bucket : null, labelNote: mine ? mine.note : null, owned: !!mine, }; }); const sumTop = (n) => rows.slice(0, n).reduce((s, r) => s + r.balanceTokens, 0); const outside = rows.filter((r) => !r.owned); const outsideTokens = outside.reduce((s, r) => s + r.balanceTokens, 0); return { deployed: true, token: TOKEN.address, chainId: TOKEN.chainId, symbol: TOKEN.symbol, supply, holdersCount: Number(counters.token_holders_count ?? rows.length), holdersShown: rows.length, holders: rows, ownHolders: rows.length - outside.length, outsideHolders: outside.length, outsideTokens, outsidePercent: supply ? (outsideTokens / supply) * 100 : null, concentration: { top1Percent: supply ? (sumTop(1) / supply) * 100 : null, top3Percent: supply ? (sumTop(3) / supply) * 100 : null, top10Percent: supply ? (sumTop(10) / supply) * 100 : null, }, source: { name: "Base block explorer (Blockscout)", url: `${EXPLORER}/token/${TOKEN.address}`, asOf: new Date().toISOString() }, note: "A holder count is not a user count. Addresses labelled with a bucket are PIGEON's own allocations, funded at deployment rather than bought by anyone; only the unlabelled rows represent a party outside the company holding this. A token held almost entirely by its issuer has not been distributed, whatever the holder number says.", }; } /** * The tier table. Deliberately steep at the top: the point of the token is that a serious * agent can cut its postage by four fifths, not by a rounding error. */ export const TIERS = [ { id: "listed", minBalance: 0n, discountBps: 0, label: "no PGN held", postage: "0.002" }, { id: "holder", minBalance: 1_000n, discountBps: 1500, label: "1,000 PGN", postage: "0.0017" }, { id: "operator", minBalance: 25_000n, discountBps: 3500, label: "25,000 PGN", postage: "0.0013" }, { id: "fleet", minBalance: 250_000n, discountBps: 6000, label: "250,000 PGN", postage: "0.0008" }, { id: "backbone", minBalance: 2_500_000n, discountBps: 8000, label: "2,500,000 PGN", postage: "0.0004" }, ]; /** * The incentive mechanisms. Each one is implemented somewhere concrete, and the "where" field * says where — a mechanism with no implementation is a marketing claim, so none of these are. */ export const INCENTIVES = [ { id: "holder-discount", name: "Hold to pay less", what: "A tiered discount on postage for every paid route, read from your live on-chain balance at request time. No signup, no allowlist, no coupon — holding is the whole condition.", who: "any agent or operator", where: "GET /v1/token/tier?address=0x… and applied at quote time on the paid routes", }, { id: "throughput", name: "Hold to go faster", what: "The same balance also multiplies rate limits and queue priority. For an agent, being rate-limited costs more than postage does, so this is the incentive that matters most.", who: "agents with sustained traffic", where: "rate-limit multipliers by tier (see the tiers below)", }, { id: "burn-on-spend", name: "Pay in PGN and burn it", what: "Postage can be paid in PGN at an additional discount over the USDC price, and the PGN is burned rather than kept. Paying makes the remaining supply scarcer, so the discount is funded by the burn instead of by a promise.", who: "any agent paying for messages", where: "the pay-in-PGN option on the paid routes; the burn is a plain ERC-20 burn to 0x0", }, { id: "crawler-credit", name: "Crawler credit", what: "Agents and crawlers that hold the entry tier get the tool and metadata endpoints unmetered, so indexing the relay is free for anyone with skin in it but still costs a spammer.", who: "agentic crawlers and indexers", where: "the /v1/tools/* and /v1/discover surfaces for holders at the entry tier", }, { id: "referral-bounty", name: "Bring another agent", what: "A handle earns PGN from the ecosystem pool for each new agent it refers that goes on to transact. Paid from the ecosystem allocation, never from a mint, so the supply stays fixed.", who: "any registered handle", where: "the referral field on POST /v1/register", }, ]; const cache = new Map(); // lowercase address -> { at, balance } function hexToBigInt(hex) { if (typeof hex !== "string" || !hex.startsWith("0x")) return null; try { return BigInt(hex); } catch { return null; } } /** balanceOf(address) against the configured token, or null when it cannot be established. */ export async function balanceOf(address) { if (!TOKEN.address) return null; if (!/^0x[a-fA-F0-9]{40}$/.test(address || "")) return null; const key = address.toLowerCase(); const hit = cache.get(key); if (hit && Date.now() - hit.at < CACHE_MS) return hit.balance; const data = "0x70a08231" + key.slice(2).padStart(64, "0"); // balanceOf selector + padded address const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), BAL_TIMEOUT_MS); try { const r = await fetch(RPC, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: TOKEN.address, data }, "latest"] }), signal: ctl.signal, }); const j = await r.json(); const bal = hexToBigInt(j?.result); if (bal === null) return null; cache.set(key, { at: Date.now(), balance: bal }); return bal; } catch { return null; // unreachable RPC must never invent a discount } finally { clearTimeout(timer); } } /** The tier a balance earns. Unknowable balance => the listed tier, never a guessed one. */ export function tierFor(balanceWei, decimals = 18) { if (balanceWei === null || balanceWei === undefined) return { ...TIERS[0], reason: "balance could not be read, so the list price applies" }; const one = 10n ** BigInt(decimals); let chosen = TIERS[0]; for (const t of TIERS) if (balanceWei >= t.minBalance * one) chosen = t; return chosen; } /** List price in USD -> the price this address actually pays. */ export function applyDiscount(listPriceUsd, tier) { const bps = tier?.discountBps ?? 0; // the relay's list price is currency-formatted ("$0.002"), so strip anything that is not a digit or a dot const list = Number(String(listPriceUsd ?? "").replace(/[^0-9.]/g, "")); if (!Number.isFinite(list)) return null; const paid = list * (1 - bps / 10000); return { listPrice: Number(list.toFixed(6)), discountBps: bps, paid: Number(paid.toFixed(6)), saved: Number((list - paid).toFixed(6)) }; } /** The relay's list postage as a plain number, whatever format ctx hands over. */ function listPriceOf(ctx) { const raw = (typeof ctx?.postage === "function" ? ctx.postage() : ctx?.postageLabel) ?? "0.002"; return String(raw).replace(/[^0-9.]/g, "") || "0.002"; } export function tierTable() { return TIERS.map((t) => ({ id: t.id, minBalance: t.minBalance.toString(), label: t.label, discountPercent: t.discountBps / 100, multiplier: Number((1 - t.discountBps / 10000).toFixed(4)), postageAtList: t.postage, })); } export function register(store) { return (app, ctx) => { app.get("/v1/token", (_req, res) => { res.json({ token: { symbol: TOKEN.symbol, name: TOKEN.name, decimals: 18, chain: TOKEN.chain, chainId: TOKEN.chainId, address: TOKEN.address, deployed: !!TOKEN.address, standard: "ERC-20 (+EIP-2612 permit, burnable)", supply: TOKEN.supply, supplyFixed: true, mintable: false, owner: null, vesting: TOKEN.vesting, }, purpose: "Holding PGN makes the relay cheaper and faster; spending it burns it. It is not a share, not a claim on revenue, and not a governance token.", listPostage: "$" + listPriceOf(ctx), tiers: tierTable(), incentives: INCENTIVES, notDeployedNotice: TOKEN.address ? undefined : "The token is not deployed yet, so every tier currently resolves to the list price and GET /v1/token/tier returns deployed:false rather than inventing a balance.", endpoints: { tier: "GET /v1/token/tier?address=0x…", source: "GET /v1/token/source", spec: "GET /v1/token/spec", }, }); }); app.get("/v1/token/spec", (_req, res) => { res.json({ symbol: TOKEN.symbol, name: TOKEN.name, address: TOKEN.address, chainId: TOKEN.chainId, deployed: !!TOKEN.address, tiers: tierTable(), incentives: INCENTIVES, verifiedBy: "The contract has no mint function, no owner and no admin: the supply is fixed at construction and no key can change a balance, a fee or a freeze. Everything not circulating is allocated in the constructor.", howDiscountsAreProven: "balanceOf(address) over eth_call on Base, cached for 60 seconds. If the RPC is unreachable or the address is malformed the tier falls back to the list price — the relay never guesses a discount in your favour or against you.", }); }); // Live balances for every allocation bucket, so the distribution is checkable on the page // rather than taken on trust. Sums are included so a reader can see they total the supply. app.get("/v1/token/allocations", async (_req, res) => { if (!TOKEN.address) return res.json({ deployed: false, token: null, buckets: [] }); const decimals = Number(process.env.PGN_DECIMALS || 18); const buckets = []; let read = 0n; for (const a of TOKEN.allocations) { const bal = await balanceOf(a.address); if (bal !== null) read += bal; buckets.push({ bucket: a.bucket, percent: a.percent, address: a.address, kind: a.kind, note: a.note, balance: bal === null ? null : bal.toString(), balanceTokens: bal === null ? null : Number(bal) / 10 ** decimals, intendedTokens: Number(TOKEN.supply) * (a.percent / 100), }); } res.json({ deployed: true, token: TOKEN.address, chainId: TOKEN.chainId, supply: TOKEN.supply, supplyFixed: true, mintable: false, owner: null, buckets, sumOfBuckets: Number(read) / 10 ** decimals, // Buckets do not sum to the supply when tokens have been SOLD: a purchase moves them from // the float into a buyer's wallet, which is not a bucket. Report the gap instead of // leaving a reader to think supply is missing. outsideBuckets: Number(BigInt(TOKEN.supply) * 10n ** BigInt(decimals) - read) / 10 ** decimals, note: "The dev 5% is in the vesting contract, not a wallet, so it cannot be spent ahead of schedule. The sale float is carved out of the ecosystem allocation, not additional supply. Anything shown as outside the buckets is supply that has already been sold, plus the dev wallet's vested releases.", }); }); // WHO HOLDS IT. /allocations answers "where is the supply meant to be"; this answers "where is // it now", including holders the company did not plan for. The two disagreeing is the only // honest measure of whether anyone outside the company has any of this at all. app.get("/v1/token/holders", async (_req, res) => { if (!TOKEN.address) return res.json({ deployed: false, token: null, holders: null, holdersCount: null }); const age = Date.now() - holdersCache.at; if (holdersCache.data && age < HOLDERS_CACHE_MS) { return res.json({ ...holdersCache.data, cached: true, cacheAgeMs: age }); } try { const data = await readHolders(); holdersCache = { at: Date.now(), data }; return res.json({ ...data, cached: false }); } catch (e) { // An explorer we cannot reach is NOT a zero. Serving zeros as though freshly measured // would be indistinguishable from a real wipe-out — and "a number that is really our own // probing is not traffic" is the same rule one layer up. if (holdersCache.data) { return res.json({ ...holdersCache.data, cached: true, stale: true, asOf: new Date(holdersCache.at).toISOString(), warning: "The block explorer could not be reached. These are the last values read, not current ones.", error: String(e?.message || e), }); } return res.status(503).json({ deployed: true, token: TOKEN.address, holders: null, holdersCount: null, error: "could not read holders: " + String(e?.message || e), source: EXPLORER, fabricated: false, note: "Nothing is shown rather than a zero, because a guessed zero is indistinguishable from a real one.", }); } }); app.get("/v1/token/source", (_req, res) => { // Serve the RUNNING source, so a reader can check the schedule against the code that // actually applies it rather than against a copy. try { res.type("text/plain; charset=utf-8").send(sourceOf()); } catch (e) { res.status(500).json({ error: "source unavailable: " + e.message }); } }); app.get("/v1/token/tier", async (req, res) => { const address = String(req.query.address || "").trim(); if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { return res.status(400).json({ error: "address must be a 0x-prefixed 20-byte hex address" }); } if (!TOKEN.address) { return res.json({ address, deployed: false, balance: null, tier: TIERS[0].id, discountPercent: 0, listPrice: listPriceOf(ctx), price: listPriceOf(ctx), note: "The PGN contract is not deployed yet, so no balance can be read and the list price applies.", }); } const bal = await balanceOf(address); const tier = tierFor(bal, Number(process.env.PGN_DECIMALS || 18)); const listUsd = listPriceOf(ctx); const price = applyDiscount(listUsd, tier); res.json({ address, deployed: true, token: TOKEN.address, balance: bal === null ? null : bal.toString(), balanceTokens: bal === null ? null : Number(bal) / 10 ** Number(process.env.PGN_DECIMALS || 18), balanceReadable: bal !== null, tier: tier.id, tierLabel: tier.label, discountPercent: tier.discountBps / 100, listPrice: Number(listUsd), price: price?.paid ?? Number(listUsd), saves: price?.saved ?? 0, rateLimitMultiplier: Number((1 / (1 - tier.discountBps / 10000)).toFixed(2)), note: bal === null ? "Balance could not be read; the list price applies." : undefined, }); }); }; }