A browser-native, 24/7 linear television network where every display surface is programmable advertising inventory, paid for on Robinhood Chain.
Everything the network earns — airtime revenue and token tax — is used to buy Anduril pre-stock, which is then distributed to holders. The treasury page shows income in, pre-stock bought and pre-stock distributed.
Open the homepage and you are already watching the station. The live picture is the splash: it plays full-bleed behind the fold exactly as it is going out, and everything that explains the network is stacked underneath it in full-width slices. The page is plain 2D - no WebGL - so it behaves the same on every device.
You are not buying a thirty-second spot. Every surface runs a continuous descending auction. It asks a price that falls with time; pay it and the surface is yours, on air, until somebody pays more than you did. A sale ratchets the ask up to twice what you paid and time walks it back down again, so a wanted surface gets expensive and an unwanted one gets cheap. Payment settles on chain; the station verifies the on-chain event itself, puts the run on air immediately, and issues an AirLog receipt when it ends.
AIRTIME is an independent product. Robinhood Chain is used as payment infrastructure.
- What is actually built
- Architecture
- Local development
- Database
- Media and storage
- Robinhood Chain configuration
- Contract deployment
- Testnet faucet workflow
- Treasury
- How synchronized television works
- How BillboardSurface works
- How to create a new placement
- How payments are verified
- Security model
- Performance
- Testing
- Production deployment
- Deploying to Railway
- Deploying to Vercel
- Environment variables
- Routes
| Area | Status |
|---|---|
| Synchronized linear TV engine (VOD, HLS, ad breaks, bumpers, operator interrupts) | Working |
| Data-driven placement / inventory system (no hardcoded surfaces) | Working |
| 3D studio with GLTF-driven surfaces, live textures, camera choreography | Working |
| WYSIWYG creative preview on the real surface (3D and 2D) | Working |
| Continuous descending-price auction per surface, server-authoritative, EIP-712 signed quotes | Working |
| Inventory holds, expiry, double-booking prevention (transactional) | Working |
| On-chain payment + independent server-side event verification | Working |
| Automatic activation, completion and AirLog generation | Working |
| Public board of surfaces, program guide, campaign and AirLog pages | Working |
| Control room: programming, placements incl. visual editor, moderation, payments, audit | Working |
| Treasury: derived airtime revenue + operator-recorded pre-stock ledger | Working |
| House showcase cards on unbooked surfaces (always badged EXAMPLE) | Working |
| Privacy-conscious first-party delivery analytics | Working |
| Non-WebGL fallback, reduced motion, mobile layout | Working |
| Foundry contract tests, unit/API tests, Playwright end-to-end | Working |
flowchart TB
subgraph Browser
Station["Station shell<br/>(single video element)"]
Studio["R3F studio<br/>GLTF surfaces"]
HUD["Glass HUD<br/>purchase flow"]
Wallet["wagmi / viem<br/>injected · WalletConnect"]
end
subgraph Server["Next.js server"]
API["Route handlers<br/>Zod-validated"]
Engine["Broadcast engine<br/>schedule · blocks"]
Ads["Ad engine<br/>auction · quotes · runs"]
Verify["Payment verifier<br/>reads canonical events"]
Ticker["Scheduler tick (1s)<br/>expire · activate · complete"]
Bus["Realtime bus → SSE"]
end
DB[("PostgreSQL<br/>Drizzle ORM")]
Storage[("Object storage<br/>creatives")]
Chain["Robinhood Chain<br/>AirtimePayments.sol"]
Station <-->|state, activations| API
Studio -->|textures| Station
HUD -->|upload, quote, confirm| API
Wallet -->|purchase tx| Chain
API --> Engine & Ads & DB & Storage
Ticker --> Engine & Ads & Verify
Verify -->|getLogs / receipt| Chain
Verify --> DB
Bus -->|events| Station
API --> Bus
sequenceDiagram
autonumber
participant U as Advertiser
participant W as Wallet
participant A as AIRTIME server
participant C as AirtimePayments
participant S as Scheduler
U->>A: Sign in with Ethereum (SIWE)
U->>A: Upload creative
A->>A: Sniff magic bytes, decode, re-encode, hash (keccak256)
U->>A: Take the surface at the current ask
A->>A: TX: lock placement, refuse if held or inside a guaranteed run, read the ask, HOLD (~3 min)
A-->>U: EIP-712 signed Quote (quoteId, creativeHash, guaranteed window, amount, nonce)
U->>W: purchase(quote, signature)
W->>C: tx with msg.value == amount
C->>C: verify signature, buyer, expiry, nonce, chain, token
C-->>C: emit AirtimePurchased(...)
C->>C: forward funds to treasury
U->>A: hint tx hash (optional)
A->>C: read receipt / logs from its own RPC
A->>A: match quoteId, buyer, placement, creativeHash, token, amount, window
A->>A: PAID → AIRING at once, outbidding whoever paid less for the surface
A->>A: the ask resets to 2x the price paid and starts descending again
S->>S: when a higher bid lands → COMPLETED + AirLog for the outgoing run
stateDiagram-v2
[*] --> DRAFT
DRAFT --> VALIDATING: creative attached
VALIDATING --> READY_TO_PURCHASE: valid / approved
VALIDATING --> REJECTED: moderation
DRAFT --> READY_TO_PURCHASE: valid creative
READY_TO_PURCHASE --> AWAITING_PAYMENT: quote issued (inventory HELD)
AWAITING_PAYMENT --> READY_TO_PURCHASE: quote expired (hold released)
AWAITING_PAYMENT --> PAID: on-chain event verified
PAID --> QUEUED: activation scheduled
QUEUED --> AIRING: reserved window starts
AIRING --> COMPLETED: window ends + AirLog
QUEUED --> CANCELLED: operator pull
AIRING --> CANCELLED: operator pull
COMPLETED --> REFUNDED: operator refund
REJECTED --> [*]
COMPLETED --> [*]
contracts/ Foundry project (AirtimePayments.sol + tests + deploy script)
drizzle/ Generated SQL migrations
public/models/ studio.glb (built from scripts/build-studio-gltf.ts)
scripts/ build-studio-gltf, migrate, seed, reset, deploy-local, worker
src/app/ Routes: station, watch, guide, queue, airtime, campaign, airlog, control-room, api/*
src/components/
station/ Player engine (pure), player, overlays, analytics, shell
studio/ R3F studio: BroadcastStudio, BillboardSurface, BroadcastScreen,
StudioMonitor, InteractiveMesh, CameraRig, EnvironmentalLights,
ReflectionSurface, BroadcastTicker3D, PlacementHighlight, textures, perf
hud/ Status rail, wallet, broadcast log, guide, inventory, buy control, panels
airtime/ Purchase flow, creative upload, airtime picker, 2D preview, wallet hooks
control-room/ Admin API hooks, UI primitives, visual placement editor
src/server/
broadcast/ Linear schedule engine
ads/ auction · quotes · campaigns · creatives · activation (runs)
chain/ RPC client, EIP-712 quote signer, payment verifier
media/ validation (magic bytes, MP4 parser), storage, media provider
db/ Drizzle schema, client (Postgres or embedded PGlite), seed
auth/ SIWE + admin sessions, signed upload tickets
worker/ Scheduler tick
tests/ unit · api · e2e
Requirements: Node 20+, pnpm, and (for the payment path) Foundry.
pnpm installEverything below runs without Docker or a Postgres install: with DATABASE_URL empty the app uses PGlite, real Postgres compiled to WASM, stored in ./.pglite.
pnpm studio:buildWrites public/models/studio.glb (a real glTF binary, ~132 KB, 72 meshes, 19 named display surfaces) plus studio.meshes.json used by the control room.
anvil --chain-id 31337 --block-time 1pnpm contract:build
pnpm contract:deploy:localcontract:deploy:local deploys AirtimePayments, wires anvil account #9 as the quote signer (matching the development fallback key) and writes the address into .env.local.
pnpm devOpen http://localhost:3000. On first boot the app migrates, seeds placements, creates the admin user and fills 12 hours of programming with public open-movie sources labelled DEV DATA.
The console prints the admin credentials (defaults to admin@airtime.local / airtime-dev). The control room is at /control-room.
To buy airtime locally, connect the AIRTIME Dev Wallet connector (anvil account #1, enabled by NEXT_PUBLIC_DEV_WALLET_PRIVATE_KEY) — no browser extension needed.
Reserved airtime is minutes away, which is inconvenient to test. In Control room → Settings you can jump the station clock forward or back. The offset is persisted, applied to playback offsets, quote expiry, activation and completion, and every connected browser re-syncs immediately.
pnpm db:reset # wipe the embedded database and local uploads
pnpm db:seed # migrate + seed without starting the serverPostgreSQL via Drizzle ORM. Schema: src/server/db/schema.ts; migrations in drizzle/.
erDiagram
channels ||--o{ programs : "library"
channels ||--o{ program_blocks : "timeline"
channels ||--o{ placements : "inventory"
channels ||--o{ live_sources : "HLS"
placements ||--o{ campaigns : ""
creatives ||--o{ campaigns : ""
campaigns ||--o{ quotes : ""
campaigns ||--o{ reservations : "lane holds"
campaigns ||--o| payments : "verified on chain"
campaigns ||--o{ ad_activations : ""
campaigns ||--o| air_logs : "receipt"
ad_activations ||--o{ analytics_events : "delivery"
Rules the schema enforces:
- Money is never a float. On-chain amounts are
numeric(78,0)(wei) and handled asbigintin code. Multipliers are integer basis points. - All timestamps are UTC (
timestamptz). paymentshas unique indexes onquote_idand on(tx_hash, log_index)— a purchase can be recorded exactly once.reservationscarry alane; two reservations in one lane may never overlap whileHELD(unexpired) orCONFIRMED.
Changing the schema:
pnpm db:generate # drizzle-kit generate
pnpm db:migrate # applyFor a real Postgres, set DATABASE_URL and run pnpm db:migrate.
Advertiser uploads are treated as hostile input:
- Size checked against the placement limit.
- Type sniffed from magic bytes (
file-type) — the client's MIME and extension are never trusted, and the claimed extension must agree with the real content. - Images are decoded and re-encoded with
sharp, which strips metadata and any embedded payload, and downscaled to the placement's maximum. - Videos are parsed by a small in-repo ISO-BMFF reader (
src/server/media/mp4.ts) for real duration, dimensions, codec four-cc and audio-track presence. Only H.264/AV1 MP4 is accepted. - Text creatives are stripped of control characters and length-limited.
HTML and JavaScript creatives are not supported at all — there is no code path that renders advertiser markup.
StorageProvider (local or any S3-compatible bucket, signed with SigV4 over plain fetch) and MediaProvider (renditions/posters) are interfaces, so a hosted video pipeline can be dropped in without touching the rest of the system. Locally stored creatives are served by /media/[...key] with a fixed content type, nosniff, sandbox CSP and byte-range support.
| Mainnet | Testnet | |
|---|---|---|
| Name | Robinhood Chain | Robinhood Chain Testnet |
| Chain ID | 4663 |
46630 |
| Native currency | ETH | ETH |
| Public RPC | https://rpc.mainnet.chain.robinhood.com |
https://rpc.testnet.chain.robinhood.com |
Select the network with NEXT_PUBLIC_CHAIN_ENV=local | testnet | mainnet.
Do not hammer the public RPC in production. Point ROBINHOOD_MAINNET_RPC_URL / ROBINHOOD_TESTNET_RPC_URL at a dedicated provider (Alchemy or similar); the server batches requests and the payment watcher only scans from the block a quote was issued at.
Wallets: any injected EVM wallet, plus WalletConnect when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is set. Robinhood Wallet connects through those standard flows. If the wallet is on the wrong network, the UI offers a Switch to Robinhood Chain action.
Payment assets: native ETH works out of the box. ERC-20 support exists in the contract (SafeERC20, owner-allowlisted) and in the client asset list, but an ERC-20 only becomes selectable once its real, verified contract address is configured (e.g. NEXT_PUBLIC_USDG_ADDRESS). No token addresses are invented anywhere in this repository.
contracts/src/AirtimePayments.sol is deliberately small: verify a signed quote, take payment once, emit the canonical event, forward funds. No scheduling, media or business logic on chain.
cd contracts
forge build
forge test -vv # 24 tests: replay, expiry, tampering, wrong chain, pause, fuzzDeploy to Robinhood Chain Testnet:
export DEPLOYER_PRIVATE_KEY=0x...
export QUOTE_SIGNER_ADDRESS=0x... # address of AIRTIME_QUOTE_SIGNER_PRIVATE_KEY
export TREASURY_ADDRESS=0x...
export ROBINHOOD_TESTNET_RPC_URL=https://rpc.testnet.chain.robinhood.com
pnpm contract:deploy:testnetThen set NEXT_PUBLIC_AIRTIME_PAYMENT_CONTRACT and AIRTIME_PAYMENT_CONTRACT_DEPLOY_BLOCK in the environment. The control room shows the live contract, quote signer and treasury so a mismatch is immediately visible.
Rotating the backend signing key is setQuoteSigner(newSigner); previously signed quotes stop being accepted (covered by a test).
- Add Robinhood Chain Testnet to your wallet: chain id
46630, RPChttps://rpc.testnet.chain.robinhood.com, currency ETH. - Fund the deployer and a buyer address from the Robinhood Chain testnet faucet.
- Deploy the contract as above; keep the deployment block.
- Set
NEXT_PUBLIC_CHAIN_ENV=testnetand the RPC/contract variables, then start the app. - Buy a placement. The station reads the
AirtimePurchasedevent from its own RPC before the campaign becomesPAID.
A configured share of network income (100% by default) buys Anduril pre-stock for distribution to holders. Two kinds of number meet on /treasury, and the distinction is enforced in the code:
| Source | Can the site prove it? | |
|---|---|---|
| Airtime revenue | Derived from confirmed payments, each verified against an on-chain AirtimePurchased event |
Yes |
| Token tax received | Recorded by the operator in the control room | No — shown as a recorded figure with an optional reference |
| Anduril pre-stock bought | Recorded by the operator (happens through a broker) | No |
| Distributed to holders | Recorded by the operator | No |
Nobody can type in airtime revenue, and the operator ledger never pretends to be chain data. Distributions are refused if they exceed the pre-stock recorded as held. The allocation percentage is set in Control room → Settings, and entries are added in Control room → Treasury. Every entry is written to the audit log.
The page carries an explicit disclosure: it is not an offer, a prospectus, or investment advice.
Surfaces nobody has booked can show a house showcase card so an empty network still demonstrates what the billboards do. These are drawn procedurally from text only — no third-party artwork — and always carry a permanent EXAMPLE badge plus "this space is available". They never enter the public board, never produce an AirLog, and are not counted as revenue. Cards live in the showcase_creatives table, seeded against the two studio billboards; every other surface is deliberately left bare so genuine availability is obvious.
Programming is a timeline of program_blocks per channel — never "autoplay a random video".
gantt
dateFormat HH:mm
axisFormat %H:%M
title MAIN channel timeline
section Timeline
Big Buck Bunny (VOD) :a1, 00:00, 10m
Commercial break (AD_BREAK) :crit, a2, after a1, 2m
Tears of Steel (VOD) :a3, after a2, 12m
Breaking (LIVE_HLS, manual) :active, a4, after a3, 2m
- Every viewer asks the server for the block at
serverNow()and seeks tooffset = now − block.startsAt(plus aresumeOffsetSecwhen an interrupt split the block). Everyone sees the same frame. - The browser never trusts its own clock:
useServerClockSyncmeasures round-trip time against/api/timeand keeps the lowest-latency offset sample, re-sampling every 30 s. - Drift is corrected continuously: under 0.35 s nothing happens, up to 2.5 s the playback rate is nudged (±6 %), beyond that the player seeks. Logic lives in
playerEngine.tsand is unit-tested. LIVE_HLSblocks use native HLS where available andhls.jsotherwise. VOD delivered over HLS is still offset-synchronised.- During an
AD_BREAK, the full-screen campaign that owns that window takes the main stream (video or image); if nothing is booked, a house slate names the next program. - An operator can interrupt at any moment (
insertManualBlock): overlapping blocks are trimmed or split, and the timeline resumes afterwards.ensureScheduleHorizonkeeps 12 hours of programming ahead by rotating the channel library with ad breaks between programs. - Channel-changing architecture exists (
MAIN,MARKETS,MUSIC,COMMUNITY,AFTER_HOURSare seeded); V1 ships withMAINactive.
No fabricated "live viewer" counts appear anywhere.
<BillboardSurface> maps a dynamic image/video texture onto a named GLTF mesh — or onto its own plane when the placement is positioned by transform instead.
<BillboardSurface
placement={placement} // data from the database
surface={surfaces["Billboard_Left"]}
campaign={activeCampaign} // paid campaign currently on air, or null
preview={previewCreative} // advertiser's WYSIWYG preview, or null
allowVideo={tier.videoSurfaces}
/>What it does:
- Chooses what to display: preview → paid campaign → AIRTIME house graphic. Unsold surfaces show a procedural house card, never a fake advertisement.
- Builds the texture (image baked into a canvas with true-black letterboxing for
FIT,repeat/offsetcropping forFILL, or aVideoTextureseeked to the campaign's own offset). - Cross-fades in place when the campaign changes — no page reload — and disposes the previous texture 500 ms later.
- Pauses video textures that face away from the camera, are far away, or when the tab is hidden.
- Raycast hover produces a very subtle emissive edge plus a small contextual label; clicking dollies the camera toward the surface and opens the purchase panel beside it.
- Disposes its material and textures on unmount.
The main display (<BroadcastScreen>) shares the station player's single <video> element as a VideoTexture, so the 2D picture and the 3D screen can never disagree, and hosts the overlay placements (lower third, sponsor bug) as planes in front of it.
Nothing about inventory is hardcoded. Two ways to add a surface:
From the control room (no code): /control-room/placements → New. Give it an id, type, aspect ratio, media types and auction rules, and either pick a GLTF mesh (Pick lets you click any mesh in the live studio) or leave the mesh empty and place it with the transform gizmo. Save; the studio picks it up over the realtime bus and it becomes buyable immediately.
From code (seed): add an entry to BASE_PLACEMENTS in src/server/db/seed.ts.
Fields that matter:
| Field | Meaning |
|---|---|
type |
FULLSCREEN (owns the main stream during ad breaks), OVERLAY, ENVIRONMENT (3D surface), SPONSORSHIP |
kind |
Free-form sub-kind that drives overlay layout: commercial, lower_third, ticker, sponsor_bug, billboard, … |
lane |
Exclusivity group. Reservations in one lane may never overlap; overlays designed to coexist simply use different lanes |
ownsMainStream |
An airing campaign here replaces the main broadcast picture. This is what makes commercials, channel takeovers and sponsored station-ID bumpers work without special-casing a placement type |
meshName |
GLTF mesh to map the texture onto, or null for a transform-placed plane |
transform |
Position / rotation / scale for mesh-less placements (edited with the gizmo) |
availability.inventoryMode |
CONTINUOUS (the occupant is on the surface the whole time it holds it) or AD_BREAK (the occupant owns the picture during every commercial break) |
auction.openingPriceWei / auction.floorPriceWei |
Where the ask starts on a surface that has never sold, and the level it never decays below |
auction.decaySeconds |
How long the ask takes to walk from the top of a descent down to its floor, linearly |
auction.takeoverPremiumBps |
Where the ask restarts after a sale: 20000 = 2x what the buyer paid |
auction.minIncrementBps |
How much a challenger has to beat the occupant by: 500 = +5% |
auction.minHoldSeconds |
Runtime a buyer is guaranteed before anybody can outbid them |
auction.maxHoldSeconds |
Hard cap on a single run. 0, the default, means it runs until outbid |
requiresModeration |
Campaign cannot be quoted until a moderator approves the creative |
maxWidth/maxHeight/maxFileBytes/allowsAudio/allowsClickThrough |
Creative rules enforced during validation |
Seeded placements — one screen, one panel either side, four things to buy:
| Placement | What it is | Length | Opens at |
|---|---|---|---|
SHOW |
The picture itself, whenever a break is not on | up to 30 minutes | 0.01 ETH |
AD |
The picture during every commercial break | up to 30 seconds | 0.01 ETH |
PANEL_LEFT |
The panel left of the picture, always on | up to 30 seconds | 0.01 ETH |
PANEL_RIGHT |
The panel right of the picture, always on | up to 30 seconds | 0.01 ETH |
Everything opens at the same price and the market separates them. A show is worth more than a spot, so a show clears higher — nothing in the code makes it so.
A show is up to half an hour, which is not something anybody wants to push through an upload form, so a submission can be a link instead. The station fetches it itself before it will sell airtime against it: it refuses private addresses and plain http, reads the content type, parses the MP4 container or sums the HLS playlist for the real running time, and checks the origin sends CORS headers so the frame can be copied into the WebGL texture. The URL is what gets hashed into the quote, so the buyer is committing to that exact address.
Direct video (.mp4, .webm) and HLS (.m3u8) play. A watch page — YouTube, Vimeo, Twitch — is rejected with an explanation, because playing one means embedding somebody else's player, and no third-party markup or script is ever loaded into this station.
The shipped studio (public/models/studio.meshes.json) is deliberately spare: four sellable surfaces — Screen_Main, LED_Ribbon, Billboard_Left and Billboard_Right — all facing the viewer square on, plus the architecture and lighting around them. Earlier layouts had an anchor desk, wing walls, a rear video wall and a wall of small monitors; they were removed because inventory nobody can read is not worth selling. Add a mesh in scripts/build-studio-gltf.ts with extras.surface and it becomes sellable the moment a placement points at it.
The browser never decides that something is paid.
- The backend signs an EIP-712
QuotebindingquoteId,buyer,placementIdhash,creativeHash,startAt,endAt,paymentToken,amount,expiresAt,nonce. The domain separator includeschainIdand the contract address, so a quote cannot be replayed on another chain or deployment. AirtimePayments.purchase()re-checks the signature, the caller, expiry, the quote id, the buyer nonce, token support and the exact amount, records the quote as consumed, forwards funds to the treasury and emitsAirtimePurchased.- The browser may hint a transaction hash. The server uses it only as a lookup key: it fetches the receipt from its own RPC, finds an
AirtimePurchasedlog emitted by the configured contract address, and re-checks quote id, buyer, placement hash, creative hash, token, amount and time window against the quote it signed, then requires N confirmations. - The same verification runs from the scheduler every few seconds without any browser involvement (
pollAwaitingPayments), so closing the tab cannot lose a payment. Expired quotes keep a short grace window because the contract itself honours a transaction mined within the quote's validity. - Only then:
PAID → QUEUED, hold becomesCONFIRMED, activation scheduled, realtime event published.
A pending transaction is never treated as paid, and payments unique indexes make double-recording impossible.
- Advertiser content is hostile input. No HTML, no JavaScript, no iframes. Files are sniffed, decoded, re-encoded and re-hashed server-side; the creative that airs is the one whose hash was signed into the quote and emitted on chain.
- Strict CSP with a per-request nonce (
src/proxy.ts):object-src 'none',frame-ancestors 'none', explicit allowlists for RPC, media and wallet origins. Creatives are served sandboxed withnosniff. - Authentication: advertisers sign in with SIWE (single-use, expiring server-issued nonces; EOA and ERC-1271 signatures). Admins use email + bcrypt password. Both are HttpOnly, SameSite=Lax, Secure-in-production JWT cookies. Admin routes are gated in the proxy and re-verified in every handler.
- Uploads require a signed, short-lived upload ticket bound to wallet + placement, on top of the session.
- All API input is validated with Zod. State-changing endpoints check same-origin and are rate limited per IP.
- Secrets never reach the browser: the quote signer key, session/upload secrets and storage credentials are server-only. The signer key holds no funds; the treasury is a separate address configured on the contract.
- Every administrative mutation is written to
audit_logswith actor, action, target and details. - Filenames are sanitised and storage keys are validated against path traversal.
- Analytics stores only a daily-salted hash of a per-tab random id: no cookies, no cross-day joins, no PII.
- The television starts immediately in the DOM; the studio is a lazy
next/dynamicimport and never blocks playback. The 2D picture hands over to the 3D screen only once the scene reports ready. - Three performance tiers are chosen from device hints (cores, memory, mobile, save-data) and lowered at runtime by
PerformanceMonitor: DPR cap, planar reflection resolution, post-processing, shadow map size, environment resolution and whether video textures run at all. - One
<video>element feeds both the DOM player and the 3D main screen and monitors, instead of one decoder per surface. - Textures and materials are disposed on change and unmount; offscreen video textures pause.
- The studio model is ~132 KB; the landing payload is nowhere near tens of megabytes.
prefers-reduced-motionremoves parallax and makes transitions instant; mobile uses a simplified camera rig and keeps the scene.
pnpm test # Vitest: the price curve, player sync, MP4 parser, full API integration
pnpm contract:test # Foundry: 24 tests incl. replay, expiry, wrong chain, fuzz
pnpm test:e2e # Playwright: the complete vertical slice against a local chainThe integration suite (tests/api/inventory.test.ts) runs against a real in-memory Postgres and covers creative validation (including a disguised HTML file), quote signing and verification against the contract's EIP-712 domain, inventory holds, concurrent quotes for the same window where exactly one wins, quote expiry releasing inventory, event-matched payment verification, replay rejection, activation, completion, AirLog contents and schedule synchronisation.
The Playwright suite deploys AirtimePayments to a fresh anvil chain, builds and starts the production server, then drives the browser through: watch the station → open a studio billboard → sign in → upload a creative → preview it → read the live descending ask → receive a quote → pay with a local wallet → server verifies the on-chain event → the run is on air immediately and the surface reports its occupant and its new takeover price → the buyer hands the surface back → AirLog page renders with the transaction.
- Database: provision Postgres, set
DATABASE_URL, runpnpm db:migrate. - Storage: set
STORAGE_PROVIDER=s3with bucket, region, endpoint, credentials and a CDNSTORAGE_PUBLIC_BASE_URL. - Chain: deploy the contract, set
NEXT_PUBLIC_AIRTIME_PAYMENT_CONTRACT,AIRTIME_PAYMENT_CONTRACT_DEPLOY_BLOCK, a dedicated RPC URL andAIRTIME_PAYMENT_CONFIRMATIONS(2+ on mainnet). - Secrets: generate
AIRTIME_SESSION_SECRET,AIRTIME_UPLOAD_SECRET,AIRTIME_QUOTE_SIGNER_PRIVATE_KEYandADMIN_PASSWORD. The app refuses to start in production without them. - Dev data: set
AIRTIME_SEED_DEV_DATA=false. Seeded programming is refused on mainnet regardless. - Scheduler: exactly one instance must run the ticker. Either run a single web instance, or set
AIRTIME_DISABLE_TICKER=trueon the web tier and runpnpm workeronce. - Realtime: the SSE bus is in-process. For multiple web instances, bridge
src/server/realtime/bus.tsto Redis or PostgresLISTEN/NOTIFY; the event shapes stay the same.
pnpm build && pnpm startRailway runs the app as a long-lived container, which suits AIRTIME better than a serverless host: the in-process scheduler works as designed, so no cron is needed, and a volume gives creative uploads a real disk.
railway init --name airtime
railway add --database postgres
railway add --service airtime
railway domain --service airtime # note the port it reports
railway volume add -m /data # persistent creative storage
railway up --service airtimeSet these on the app service before the first build, because NEXT_PUBLIC_* values are baked into the browser bundle at build time:
| Variable | Value |
|---|---|
DATABASE_URL |
${{Postgres.DATABASE_URL}} |
STORAGE_PROVIDER / STORAGE_LOCAL_DIR |
local / /data/storage (the volume mount) |
NEXT_PUBLIC_APP_URL |
the generated domain |
NEXT_PUBLIC_CHAIN_ENV, ROBINHOOD_*_RPC_URL |
chain selection and RPC |
AIRTIME_QUOTE_SIGNER_PRIVATE_KEY, AIRTIME_SESSION_SECRET, AIRTIME_UPLOAD_SECRET, ADMIN_PASSWORD |
required; the app refuses to boot in production without them |
Two things that will bite otherwise:
- Target port. Railway injects
PORT(8080), andnext starthonours it. A generated domain defaults to port 3000, which answers 502. Point it at the port from the boot log:railway domain update <domain> --port 8080. - pnpm version.
packageManageris pinned inpackage.jsonandpnpm-workspace.yamldeclarespackages, because pnpm 9 rejects a workspace file without that field.
The boot log prints the platform, the database in use and which scheduler is active — check it after the first deploy.
The repository root is the Next.js app, so Vercel needs no root-directory override — import the repo and it builds. vercel.json pins the framework, the pnpm install command, the cron schedule and per-function limits; .vercelignore keeps the Foundry project and the test suites out of the deployment.
Three parts of AIRTIME assume a long-lived server with a disk. On Vercel each request gets a short-lived, read-only container, so they are wired differently and the app refuses to start with a configuration that cannot work:
| Off Vercel | On Vercel | |
|---|---|---|
| Database | embedded PGlite on disk | required: DATABASE_URL to managed Postgres. The embedded fallback is refused with an explanatory error |
| Creative storage | ./storage on disk |
required: STORAGE_PROVIDER=s3 plus bucket, credentials and STORAGE_PUBLIC_BASE_URL. Local storage is refused |
| Scheduler | setInterval every second |
Vercel Cron calls /api/cron/tick every minute, and /api/queue, /api/activations, /api/board and /api/broadcast/state tick opportunistically (at most once every 4s) so traffic keeps the station moving between firings |
- Import the repository on Vercel. Framework detection is Next.js; leave the root directory at the repository root.
- Provision Postgres (Vercel Postgres, Neon, Supabase) and set
DATABASE_URL. Migrations run on boot under a Postgres advisory lock, so simultaneous cold starts cannot race; setAIRTIME_MIGRATE_ON_BOOT=falseto run them from a deploy step instead. - Provision object storage and set the
STORAGE_*variables. - Set the secrets:
AIRTIME_QUOTE_SIGNER_PRIVATE_KEY,AIRTIME_SESSION_SECRET,AIRTIME_UPLOAD_SECRET,ADMIN_PASSWORDandAIRTIME_CRON_SECRET. The app refuses to boot in production without the first four, and the cron endpoint returns 401 without the last. - Set the chain variables:
NEXT_PUBLIC_CHAIN_ENV, a dedicatedROBINHOOD_*_RPC_URL,NEXT_PUBLIC_AIRTIME_PAYMENT_CONTRACT,AIRTIME_PAYMENT_CONTRACT_DEPLOY_BLOCKandTREASURY_ADDRESS. - Set
AIRTIME_SEED_DEV_DATA=false. Seeded programming is refused on mainnet regardless.
- Scheduling granularity. Cron fires once a minute, so with no traffic a campaign can go on air up to a minute late. Any request to the station tightens that to a few seconds. Slots are on a five-minute grid, so this is within tolerance; for exact-second activation, run the app on a normal server where the in-process scheduler is used.
- Realtime. Server-Sent Events streams close after
AIRTIME_SSE_LIFETIME_MS(50s by default, under the 60s Hobby function limit) and the browser reconnects automatically. On a plan with longer limits, raise that value andmaxDurationinvercel.jsontogether. - Cron on Hobby. Hobby projects only run cron once a day. Minute-level scheduling needs Pro, or an external scheduler calling
/api/cron/tickwith the bearer secret. pnpm workeris for self-hosted deployments and is not used on Vercel.
The boot log prints the detected platform, the database in use and which scheduler is active, and warns about any configuration that cannot work on the host.
See .env.example. Secrets must never be placed in NEXT_PUBLIC_* variables.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres. Empty in development → embedded PGlite in ./.pglite |
NEXT_PUBLIC_APP_URL |
Public origin |
NEXT_PUBLIC_CHAIN_ENV |
local · testnet · mainnet |
ROBINHOOD_MAINNET_RPC_URL / ROBINHOOD_TESTNET_RPC_URL |
Server-side RPC (use a dedicated provider) |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
Enables the WalletConnect connector |
AIRTIME_QUOTE_SIGNER_PRIVATE_KEY |
Secret. Signs EIP-712 quotes; holds no funds |
NEXT_PUBLIC_AIRTIME_PAYMENT_CONTRACT |
Deployed AirtimePayments |
AIRTIME_PAYMENT_CONTRACT_DEPLOY_BLOCK |
Lower bound for the payment watcher |
AIRTIME_PAYMENT_CONFIRMATIONS |
Confirmations required before PAID |
TREASURY_ADDRESS |
Where the contract forwards payments |
AIRTIME_SESSION_SECRET / AIRTIME_UPLOAD_SECRET |
Secrets. Session and upload-ticket signing |
ADMIN_EMAIL / ADMIN_PASSWORD |
Initial control-room account |
STORAGE_* |
Storage provider configuration |
NEXT_PUBLIC_MEDIA_ORIGINS |
Extra origins allowed by CSP for programming media |
AIRTIME_SEED_DEV_DATA |
Seed DEV DATA programming (never on mainnet) |
NEXT_PUBLIC_USDG_ADDRESS |
Optional verified ERC-20; unset means not selectable |
NEXT_PUBLIC_DEV_WALLET_PRIVATE_KEY |
Development/E2E only local wallet connector |
AIRTIME_DISABLE_TICKER |
Disable the in-process scheduler (multi-instance deployments) |
AIRTIME_CRON_SECRET |
Secret. Bearer token /api/cron/tick requires; mandatory in production |
AIRTIME_MIGRATE_ON_BOOT |
Run migrations at boot (default true, advisory-locked) |
AIRTIME_SSE_LIFETIME_MS |
How long a realtime stream stays open before the client reconnects |
| Route | Purpose |
|---|---|
/ |
The front page: live picture as the splash, then how it works, the board, treasury, chat and the room |
/watch |
2D station with guide and broadcast log (no WebGL required) |
/guide |
Program guide |
/station |
The auditorium: the 3D room, the picture and the two panels |
/queue |
Public board: who is standing on which surface, and who was outbid off one recently |
/airtime |
All inventory, plus your campaigns |
/airtime/[placementId] |
Conventional purchase page with WYSIWYG preview |
/campaign/[id] |
Campaign status, creative hashes and payment |
/airlog/[id] |
Shareable proof-of-air receipt |
/treasury |
Income in, Anduril pre-stock bought and distributed |
/info |
Public explainer: what the network is, what is for sale, what the chain proves |
/docs |
Advertiser and operator documentation |
/control-room |
Master control (authenticated) |
API: /api/time, /api/events (SSE), /api/treasury, /api/showcase, /api/broadcast/state, /api/broadcast/guide, /api/queue, /api/activations, /api/placements, /api/placements/[id]/surface, /api/board, /api/auth/*, /api/creatives*, /api/campaigns*, /api/analytics, /api/airlog/[id], /api/admin/*.
One <video> element carries the whole station: the programme, and whatever a buyer has taken the main picture with. The 3D screen samples that same element, so picture and sound can never disagree, and environment surfaces are always silent. Sound starts muted because browsers refuse to autoplay audio; the control in the status rail unmutes and sets the level, the choice is remembered per browser, and if the autoplay policy refuses an unmuted start the player falls back to muted playback and asks for a click rather than stalling.
Programming is unrated. There is no TV rating system, no age gate and no viewer discretion advisory: everything that runs during station time is a user submission that was paid for, and placements flagged requiresModeration get a policy check by a moderator, which is not a rating. That disclosure appears on first visit, permanently in the footer, and in full at /docs#content.
The chain proves payment: that a specific buyer paid a specific amount for a quote bound to a placement, a time window and a content hash. It does not measure viewing.
Delivery numbers on an AirLog come from AIRTIME's own first-party analytics — sessions that had the station open, whether the creative loaded, whether the tab was visible, video completions and clicks. They are presented separately from the payment facts and are never described as verified or on-chain impressions.