billing-indexer
apps/billing-indexer is the chain watcher. It subscribes to USDC
Transfer(address,address,uint256) events on every supported chain, matches
each transfer against the treasury address, and delivers an Ed25519-signed
webhook to auth-app. It does not share a database with auth-app — the
webhook is the only contract between them.
Why a separate service
Section titled “Why a separate service”- Independent deploys. The indexer can be restarted, reindexed, or upgraded without touching auth-app or the player-facing stack.
- Bounded blast radius. A bug in the chain-reader cannot directly mutate user records; only a well-formed signed webhook can.
- Replayability. The indexer’s Postgres is append-only — every webhook attempt is recorded. Failed deliveries can be re-driven from a job.
Bootstrap
Section titled “Bootstrap”Entry point: pnpm --dir apps/billing-indexer dev boots
src/interfaces/worker.ts via tsx. The worker:
- Connects to Postgres (
DATABASE_URL). - Loads
SupportedChainrecords frompackages/billing-shared. - For each chain, creates a
ViemChainReaderand starts watchingUSDC.Transferlogs fromBILLING_INDEXER_START_BLOCK_<chainId>. - On every tick, calls
sweepConfirmations— promotes pending deliveries to ready when their block has enough confirmations.
A small HTTP server on PORT (default 9090) exposes /healthz and
/metrics.
Webhook delivery
Section titled “Webhook delivery”On every confirmed transfer, the indexer builds a canonical payload
(buildPayload.ts), signs it with Ed25519
(@workspace/billing-shared/webhook-signature.ts), and POSTs to
AUTH_APP_WEBHOOK_URL. Retries use exponential backoff up to
BILLING_WEBHOOK_MAX_ATTEMPTS.
Headers sent on every delivery:
POST /api/internal/billing/webhook HTTP/1.1Content-Type: application/jsonX-Weiqi-Signature: <base64 ed25519 signature>X-Weiqi-Key-Id: <BILLING_INDEXER_KEY_ID>X-Weiqi-Timestamp: <unix ms>auth-app verifies the signature using
AUTH_APP_BILLING_INDEXER_PUBLIC_KEY — the public counterpart of
BILLING_INDEXER_WEBHOOK_PRIVATE_KEY. Generate a new keypair with:
node -e "import('@noble/curves/ed25519').then(m=>console.log(Buffer.from(m.ed25519.utils.randomPrivateKey()).toString('hex')))"Domain types
Section titled “Domain types”src/domain/webhook.ts:1-26:
export type DeliveryIntent = { id: string; chainId: number; txHash: `0x${string}`; logIndex: number; from: `0x${string}`; to: `0x${string}`; amount: bigint; // USDC has 6 decimals blockNumber: bigint; confirmations: number; attempts: number; nextAttemptAt: Date;};
export type DeliveryRecord = { intent: DeliveryIntent; status: DeliveryStatus; lastAttemptAt: Date | null; responseCode: number | null; responseBody: string | null;};
export type DeliveryStatus = | "pending" | "ready" | "delivering" | "delivered" | "failed";The DeliveryStatus state machine is the single place to look when
debugging “why didn’t this payment upgrade the user to PRO?”.
Free / Pro entitlements
Section titled “Free / Pro entitlements”packages/auth-app owns the entitlement logic; the indexer only delivers
the intent. The relevant env (in auth-app) is:
PRO_UPGRADE_PRICE_WEI— one-time upgrade price.TOPUP_PRICE_PER_ENERGY_WEI— per-energy top-up.COMMON_DAILY_ENERGY,PRO_DAILY_ENERGY— daily grants per tier.PRO_TIER_DURATION_DAYS,PRO_TIER_GRACE_DAYS— PRO window and grace period.
The indexer is unaware of all of these; it only reports that X USDC arrived at the treasury from user Y.
Supported chains
Section titled “Supported chains”Defined in packages/billing-shared/src/chains.registry.ts. Each entry pairs
a chainId with RPC + WSS URLs. Default start blocks live in
ponder.config.ts and can be overridden per chain via env:
BILLING_INDEXER_START_BLOCK_8453=12000000 # Base mainnetBILLING_INDEXER_START_BLOCK_84532=5000000 # Base SepoliaBILLING_INDEXER_START_BLOCK_1=0 # Ethereum mainnetBILLING_INDEXER_START_BLOCK_137=0 # PolygonBILLING_INDEXER_START_BLOCK_42161=0 # ArbitrumBILLING_INDEXER_START_BLOCK_10=0 # OptimismPer-chain overrides:
BASE_RPC_URL=BASE_RPC_WSS_URL=ETHEREUM_RPC_URL=ETHEREUM_RPC_WSS_URL=POLYGON_RPC_URL=POLYGON_RPC_WSS_URL=ARBITRUM_RPC_URL=ARBITRUM_RPC_WSS_URL=OPTIMISM_RPC_URL=OPTIMISM_RPC_WSS_URL=Leave any URL empty to skip that chain.
Local dev
Section titled “Local dev”cp apps/billing-indexer/.env.example apps/billing-indexer/.env.billing-indexercd apps/billing-indexerdocker compose up -d # separate Postgres on :5435pnpm devThe local chain stack uses an Anvil fork with pre-mined USDC transfers so you can replay webhooks deterministically.
pnpm --dir apps/billing-indexer testSuites use a fake ViemChainReader and assert on the exact bytes signed
(so accidental payload changes are caught).