Skip to content

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.

  • 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.

Entry point: pnpm --dir apps/billing-indexer dev boots src/interfaces/worker.ts via tsx. The worker:

  1. Connects to Postgres (DATABASE_URL).
  2. Loads SupportedChain records from packages/billing-shared.
  3. For each chain, creates a ViemChainReader and starts watching USDC.Transfer logs from BILLING_INDEXER_START_BLOCK_<chainId>.
  4. 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.

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.1
Content-Type: application/json
X-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:

Terminal window
node -e "import('@noble/curves/ed25519').then(m=>console.log(Buffer.from(m.ed25519.utils.randomPrivateKey()).toString('hex')))"

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?”.

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.

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 mainnet
BILLING_INDEXER_START_BLOCK_84532=5000000 # Base Sepolia
BILLING_INDEXER_START_BLOCK_1=0 # Ethereum mainnet
BILLING_INDEXER_START_BLOCK_137=0 # Polygon
BILLING_INDEXER_START_BLOCK_42161=0 # Arbitrum
BILLING_INDEXER_START_BLOCK_10=0 # Optimism

Per-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.

Terminal window
cp apps/billing-indexer/.env.example apps/billing-indexer/.env.billing-indexer
cd apps/billing-indexer
docker compose up -d # separate Postgres on :5435
pnpm dev

The local chain stack uses an Anvil fork with pre-mined USDC transfers so you can replay webhooks deterministically.

Terminal window
pnpm --dir apps/billing-indexer test

Suites use a fake ViemChainReader and assert on the exact bytes signed (so accidental payload changes are caught).