A speed and latency test for the Pi Network — with Pi sign-in, a daily leaderboard that pays its winner, donations, ads and a built-in news system.
Live on Testnet and Mainnet · Runs anywhere Node runs · No test server of your own required
A complete, production-shaped Pi app rather than a demo. It measures two different things and is honest about the difference:
Speed comes from the official OpenSpeedTest widget, embedded in the page. It runs on their servers, so none of the test traffic touches yours — the cheapest VPS is enough.
Latency is measured by this project's own engine: a real HTTPS round trip from the visitor's browser to ten major services, with the first sample discarded so the figure excludes DNS and TLS setup. A service that does not answer is reported as unreachable, which is information in itself.
Everything else — accounts, leaderboard, rewards, donations, ads, news, the admin dashboard — is built here.
If you are building a Pi app, the Pi integration in this repository is probably worth more to you than the speed test. It covers, working and in production:
| Authentication | Both mechanisms — OAuth (Pi Sign-in) in ordinary browsers and Pi.authenticate() inside the Pi Browser — chosen at runtime, with server-side verification |
| U2A payments | Donations, full three-phase flow, with stale-payment recovery |
| A2U payments | Automated payouts from the app wallet, run in the background so no proxy timeout can strand them |
| Ads | Interstitial and rewarded, with server-side verification of every rewarded view |
| Share | Pi.openShareDialog with Web Share and clipboard fallbacks |
Each of those took real debugging. The gotchas section below is the list of things that cost time and are not obvious from the documentation.
Measurement
- OpenSpeedTest widget for download and upload
- Ten-target latency engine with live results, colour bands and a verdict
- Every result compared against the site-wide average for that target
- Monitor mode: repeat the latency test every 1, 5 or 15 minutes to catch intermittent problems
- Bandwidth requirements calculator and download-time estimator
- Personal history with a trend chart and CSV export; public aggregate statistics
Pi Network
- Sign in with Pi, in the Pi Browser and in any ordinary browser
- History follows the account, not the browser; anonymous runs are migrated on first sign-in
- Donations in Pi with a message carried as the transaction memo
- Daily leaderboard ranked by each user's best run, with an automated reward payout
- Optional ad gates before sign-in and before the latency test
Operations
- Admin dashboard at
/admin, access by Pi username, every runtime setting editable without a restart - News system: posts written in the dashboard, server-rendered for crawlers, latest three on the home page
- Terms of Service and Privacy Policy written to match what the code actually does
- Four complete themes, responsive to small phones, installable as a web app,
prefers-reduced-motionhonoured
git clone https://github.com/<you>/internet-speed.git
cd internet-speed
npm install
npm run fonts
cp .env.example .env
npm run devOpen http://localhost:8080. Requires Node.js 18 or newer.
Everything except Pi sign-in, payments and ads works with no Pi configuration at all. Leave PI_CLIENT_ID empty and the sign-in button hides itself; leave PI_API_KEY empty and the donation panel stays hidden. Nothing breaks.
With Docker:
cp .env.example .env
docker compose up -d --buildDEPLOY.md is the full guide: DNS, Docker, Nginx, Let's Encrypt, the Pi Developer Portal, caching behind a CDN, backups, and a troubleshooting table for every failure mode we hit.
Two ready-made environment templates ship with the project:
cp .env.testnet.example .env # Testnet deployment
cp .env.mainnet.example .env # Mainnet deploymentTestnet and Mainnet are the same code with different configuration. Each links to the other, and they keep entirely separate databases — a Testnet result can never influence a Mainnet payout.
Everything is environment variables, with the operationally interesting ones also editable from the dashboard at runtime.
| Variable | Purpose |
|---|---|
SITE_URL |
Public address; the OAuth redirect URI is derived from it |
HASH_SALT |
Secret salt for IP hashing. Set it and never change it |
ADMIN_USERNAMES |
Pi usernames allowed into /admin, comma separated |
PI_CLIENT_ID |
OAuth client ID from the Developer Portal |
PI_API_KEY |
Server API key; needed for payments and ad verification |
PI_WALLET_PRIVATE_SEED |
App wallet seed; needed for reward payouts only |
PI_NETWORK |
testnet or mainnet |
ALT_NETWORK_URL |
Address of the counterpart deployment |
TRUST_PROXY |
Set to 1 behind Nginx. Not optional |
COOKIE_SAMESITE |
none when Pi frames the app |
See .env.example for the complete list with explanations.
config/site.config.js brand, widget URL, ping targets, Pi settings
server/
index.js Express, CSP, compression, cache policy, rate limits
db.js SQLite: users, sessions, runs, samples, donations,
rewards, ad views, posts, settings
settings.js typed settings schema, database backed
payout.js background A2U payouts with restart recovery
util/pi.js token verification and the payment API
util/a2u.js App-to-User payments via pi-backend
util/ads.js rewarded ad verification
util/markup.js safe Markdown subset for news posts
util/run-token.js signed single-use token proving a run took time
routes/ auth, runs, stats, payments, rewards, ads, admin, news
public/
index.html home: speed test, latency, leaderboard, news
pages/ statistics, requirements, guide, about
admin/ dashboard
legal/ Terms of Service and Privacy Policy
css/tokens.css four themes, spacing, type and motion tokens
js/ one module per concern, no build step
SQLite through better-sqlite3. One file, data/internet-speed.db, is the entire database.
There is no bundler, no transpiler and no node_modules in the browser. The frontend is ES modules served as written, which means a stack trace points at a real line, a deploy is a file copy, and the site still works in five years without a dependency audit. For an application of this size that is a better trade than a build pipeline.
The things that cost the most time, collected so they cost you less.
Authentication
- There are two mechanisms and they are not interchangeable. Pi Sign-in (OAuth) refuses to run inside the Pi Browser;
Pi.authenticate()only exists wherewindow.Pidoes. - Detect the Pi Browser by user agent only.
window.Piexists in every browser because the SDK script loads anywhere — using it for detection sends ordinary browsers down the SDK path, where they fail with apostMessageorigin error. Pi.init()returns a promise and must settle before any other SDK method runs.Pi.authenticate()needs at leastusernameandpayments. Withusernamealone Pi answers a bare "Authentication failed".- The SDK's own session does not survive a page load. Call
Pi.authenticate()again beforePi.createPayment(), or createPayment silently does nothing at all — no error, no callback.
Payments
- A2U pays a uid, not a wallet address. Pi supplies the destination. Never ask a user for their address.
wallet_addressscope is required for A2U: without it Pi answers401 missing_scopebecause it will not reveal the recipient's public key. The grant belongs to the user, so anyone who signed in before you added the scope must sign in again.- Pi allows one open server payment at a time. An unfinished one blocks every later payout until it is completed or cancelled.
pi-backendsets no timeouts, so a Horizon call can hang indefinitely. Run payouts in the background and poll — never inside the HTTP request, where a proxy read timeout leaves the row stuck forever.pi-backendpulls insodium-native, which ships glibc prebuilds only. On Alpine it crashes the process with nothing in the logs. Use a glibc base image.
Ads
- A rewarded ad requires an authenticated user. "Rewarded ad before sign-in" is impossible by design; use an interstitial there.
- Verify every rewarded view server-side at
/v2/ads_network/status/:adId. Onlymediator_ack_status === "granted"counts.
Framing, CSP and cookies
- Pi embeds the app in an iframe, so
X-Frame-Optionsmust not be sent. Disable Helmet's frameguard and rely onframe-ancestors. - The SDK calls
https://socialchain.app/v2/medirectly from inside the Pi Browser. Leaving it out ofconnect-srcsurfaces as — again — a generic "Authentication failed". - Because Pi frames the app, your cookies are third-party:
SameSite=Noneis required, which requiresSecure, which requiresTRUST_PROXYparsed as a number. A string is read as an IP list and silently leavesreq.securefalse.
Latency is measured in the visitor's browser and reported to the server, so it cannot be fully verified. Attaching money to an unverifiable number invites exactly what you would expect.
The project raises the cost rather than pretending otherwise: the server issues a signed single-use token before each run, the submission must present it and must have taken a plausible amount of wall-clock time, only signed-in accounts rank, and a run needs several reachable targets to qualify. That is friction, not proof.
If you run this on Mainnet with amounts worth attacking, use the admin-pays mode and review winners before releasing funds. The dashboard is built for exactly that.
See CONTRIBUTING.md.
The speed test engine is OpenSpeedTest, free for personal and commercial use. Per its terms the "Provided by OpenSpeedtest.com" credit stays beneath the widget and in the footer — please keep it if you reuse this.
Fonts are Manrope and Space Grotesk, self-hosted.
Released under the PiOS License.
In short: you may use, modify and distribute this — including commercially — for applications on the official Pi Network. You may not use it to infringe Pi Network intellectual property, to attack Pi Network systems, or to build something competitive with Pi Network. The software comes with no warranty.
Pi, Pi Network and the Pi logo are trademarks of the Pi Community Company. This project is independent and not endorsed by them.