This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Install dependencies (use frozen lockfile)
yarn install --frozen-lockfile
# Start development server (defaults to Solana devnet via .env.local)
yarn dev
# Start with localnet configuration
yarn dev:localnet
# Run tests
yarn test # Run all tests once
yarn vitest # Run tests in watch mode
yarn vitest path/to/test.test.ts # Run a single test file
# Code quality (uses Biome for linting and formatting)
yarn lint:check # Check for linting issues
yarn lint:fix # Fix linting and formatting issues automatically
yarn format:check # Check formatting only
yarn format:fix # Fix formatting only
yarn tsc --noEmit # Type check without building
yarn vis # Visualize bundle composition (vite-bundle-visualizer)
# Build and deploy
yarn build # Full tsc --build type check, then Vite production build (memory-heavy: NODE_OPTIONS max-old-space-size=32768)
yarn deploy # Build, then deploy to Arweave via @ar.io/deploy (ario-deploy); requires VITE_ARNS_NAME + DEPLOY_KEY (base64 wallet keyfile) env vars- Formatting: Biome with single quotes, 2-space indent
- Commits: Conventional Commits enforced via commitlint (
feat:,fix:,chore:, etc.) - Pre-commit: Husky runs lint-staged which auto-fixes with Biome on
*.{ts,tsx,js,md,json} - Path aliases: Use
@src/*for./src/*and@tests/*for./tests/*(configured in both tsconfig.json and vite.config.ts) - SVGs: Import as React components via vite-plugin-svgr; icon components live in
/src/components/icons/(only barrel export in the project) - Stale configs:
.eslintrc,.prettierrcandjest.config.jsonare all leftovers. Biome and Vitest are authoritative — don't add rules to the dead files
Components wrap in this order (outermost first):
ConnectionProvider → SolanaWalletProvider → WalletModalProvider → QueryClientProvider → GlobalDataProvider → WalletBridge → MathJaxContext → RouterProvider
The app runs on Solana (devnet by default, localnet and mainnet also supported):
@solana/kitfor type-safe RPC interactions (createSolanaRpc)@solana/wallet-adapter-reactfor wallet connection (Phantom, Solflare, Backpack via Wallet Standard auto-registration)@ar.io/sdkprovidesSolanaARIOReadableandSolanaARIOWriteablefor ar.io network interactions on Solana- Four Solana program IDs configured via env vars:
VITE_ARIO_CORE_PROGRAM_ID,VITE_ARIO_GAR_PROGRAM_ID,VITE_ARIO_ARNS_PROGRAM_ID,VITE_ARIO_ANT_PROGRAM_ID - RPC endpoint set via
VITE_SOLANA_RPC_URL(see.env.localfor devnet,.env.localnetfor localnet) WalletBridge(/src/components/WalletBridge.tsx) bridges the Solana wallet adapter to the ar.io SDK's signer interfacewalletAdapterBridge.ts(/src/utils/walletAdapterBridge.ts) converts wallet-adapter signers to@solana/kit-compatible signers
The kit RPC client is not the SDK's createCircuitBreakerRpc.
/src/utils/solanaRpc.ts builds it with createThrottledRpc, and getSolanaRpc(url)
in globalState.ts memoises one instance per endpoint — the rate gate lives inside
the client, so a single instance is what keeps the whole tab under one budget. Never
construct a second one ad hoc.
Each piece exists because the SDK's breaker measurably failed at it:
- A token bucket with AIMD — 10 req/s ceiling, halved on every 429, recovered by
+1 after 20 consecutive successes, floored at 1. A 429 always slows down: an
advertised
x-ratelimit-rps-limitmay only lower the rate further (× 0.9), never hold it up, because the SDK'smin(ceiling, advertised * 0.9)resolved straight back to the ceiling whenever a provider advertised a high limit — and public mainnet-beta advertises 250.Retry-Afteris honoured; a 429 without one pauses 1s. - No fallback by default.
VITE_SOLANA_FALLBACK_RPC_URLis opt-in and shares the same gate, so failing over can never multiply load. The SDK'sdefaultFallbackUrl()resolved to a public endpoint and sent it 100% of the app's traffic — whole-program scans included — for the full 60s reset window, at a flat 10 req/s that its throttle could not even see. Failover engages after 5 consecutive primary failures and re-probes the primary after 30s. - A 30s per-request timeout, deliberately generous: a whole-program scan legitimately takes seconds, and the SDK's 10s opossum timeout counted those as failures, which is one of the ways the circuit tripped under normal load.
With no fallback configured, a dead endpoint surfaces an error and the user switches endpoints in Settings — strictly better than silently flooding a public good.
-
Zustand for global state:
useGlobalState(/src/store/globalState.ts) - wallet info, SDK instances (arIOReadSDK,arIOWriteableSDK), Solana RPC instance, current epoch, Solana slot, themeuseSettings(/src/store/settings.ts) - user-configurable settings (Solana RPC URL, program IDs, Arweave GQL URL, sidebar state); persisted to localStorage with smart merge to prevent stale localhost URLsuseColumnPreferences(/src/store/columnPreferences.ts) - table column visibility
useSettingsis versioned (SETTINGS_VERSION+migrateSettings). Itsmergelets persisted values win over the build's defaults, so a stored RPC endpoint otherwise outlives the release that replaced it — when a provider token was rotated, every returning user kept calling the revoked one and got 401 while new visitors were fine. BumpSETTINGS_VERSIONwhenever a shipped default must reach existing users. -
React Query for server state; custom
queryKeyHashFnhandles non-serializable SolanaConnectionobjects in query keys -
IndexedDB (via Dexie) for persistent caching of observations, epochs and dashboard counts (
/src/store/db.ts). The database is named for the network tier, deliberately not for the endpoint: these are facts about the network, not about whichever provider was asked, so switching providers within a tier reuses the cache instead of re-running three program scans.
Network tier is inferred from the RPC URL string (localhost/127.0.0.1 -> localnet, then
devnet/testnet, else mainnet) in both settings.ts and globalState.ts, and it drives
the default program IDs, the IndexedDB name (solana-devnet, solana-localnet,
solana-mainnet), and which portal API preset applies. Program-id overrides are stored per
tier (solanaAddressSettingsByNetwork) so switching networks does not carry the other
network's addresses across. Changing the RPC URL in Settings rebuilds the RPC client, the
read SDK and the database handle, and clears arIOWriteableSDK — globalState subscribes
to useSettings for exactly that.
getGateways/getVaults/getBalances/getAllDelegates are whole-program
scans, and running them per browser made RPC cost scale with traffic. When
VITE_PORTAL_API_URL is set, the canonical queries read published static JSON
instead (/src/utils/portalApi.ts), served by ar-io-network-analyzer.
The fallback is not optional. Any failure — unset, unreachable, malformed, stale beyond 30 minutes, stamped with a different network, or derived from different Solana programs — returns null and the hook runs the live scan. The production build is published immutably to Arweave, so a hard dependency on a host that lapses would brick a permanent deploy.
Hooks reading the snapshot:
| Hook | Document | Filter |
|---|---|---|
useGatewaysQuery |
gateways.json |
— |
useVaultsQuery |
vaults.json |
— |
useAllBalances |
balances.json |
— |
useAllDelegates |
delegates.json |
— |
usePrimaryName |
primaryNames.json |
owner |
useArNSStats |
summary.json |
counts.arnsRecords |
Two of those are worth their own note:
useArNSStatsonly ever wanted a count, butgetArNSRecords({ limit: 1 })scans the whole ArNS program and deserializes every record before truncating in memory — so one number cost a full registry sweep per visitor.getPrimaryName(address)is an unfiltered whole-program scan that filters client-side, so resolving one wallet's name swept the entire set.
withdrawals.json is GAR Withdrawal accounts and is not vaults.json,
which is core-program Vault accounts — different datasets, not two views.
A write makes the snapshot wrong, and invalidating does not fix it. The
refetch downloads the same document the publisher generated before the write, so
the user who just signed is the one person guaranteed to see stale data. After a
write, @src/utils/snapshotFreshness makes that document read from chain for a
bounded window instead.
There is no sound way to ask a document whether it contains a write: it carries
generatedAt, which is when the publisher uploaded the file, not when it read
the chain, and comparing against the browser's clock fails on any machine whose
clock is off. So the window is a duration measured entirely on the client, long
enough to outlast MAX_SNAPSHOT_AGE_MS plus scan-to-publish latency. A failed
live read propagates rather than falling back — React Query would otherwise
cache the pre-write document as a success for the query's staleTime, which is
an hour for gateways and vaults.
Every write flow must pair invalidation with marking, and
invalidateWrittenDocuments(queryClient, ...names) does both in one call — the
query key and the document name are the same string, so they cannot drift. Mark
only the documents the transaction actually changes: a stake decrease moves
tokens into a withdrawal account and does not touch balances, and balances
is the most expensive scan on the network. Hand-placing the two separately does
not survive: gateways was once invalidated in seven flows and marked in none.
A snapshot is only worth it for an unfiltered whole-program scan. The test before moving any read: is the RPC call server-side filtered, and is the value freshness-sensitive? If either is yes, leave it on RPC.
useGatewayDelegates and useGatewayVaults were briefly served from the
snapshot and reverted. getGatewayDelegates/getGatewayVaults are
memcmp-filtered on the gateway pubkey at offset 8, so the node returns only
that gateway's rows — averaging under one row per gateway across the network.
Answering them from the published documents meant downloading every other
gateway's data (~154KB and ~142KB) to filter client-side. Both keys are also
invalidated after a write, where a snapshot lagging by a publish interval
renders the pre-write state.
useDelegateStakes stays on RPC for a different, harder reason:
getDelegations unions type: 'stake' rows from DELEGATION accounts with
type: 'vault' rows from WITHDRAWAL accounts, both keyed by delegator.
delegates.json covers only the stake half and carries no type, and
withdrawals.json cannot supply the other half because both public SDK
projections drop the withdrawal's owner. Serving half a wallet's position is
worse than spending the call.
The analyzer serves two separate APIs — check both before concluding
something is unpublished. /api/v1/portal/ holds the current-state documents
this app reads; /api/v1/ is the archive, with its own manifest at
/api/v1/index.json (documents: epochs, findings, gateways, network,
observers, plus an archive array of dated snapshots). /api/v1/portal/index.json
describes only the portal half, so reading it alone will make the archive look
absent.
Historical observations live there, at /api/v1/epochs/<index>.json — each
carrying observations[] with observer, pubkey, reportTxId,
submittedAt and gatewayResultsBase64. They survive because capture writes
them to SQLite before the accounts are swept.
That matters because RPC genuinely cannot serve them after the fact:
Observation PDAs are deleted by the permissionless close_observation once
an epoch distributes, and Epoch.observationsSubmitted keeps the count but not
the content. useObservations still reads live epochs straight from the GAR
program; for a closed epoch the archive is the only source.
The endpoint is user-configurable in Settings (portalApiUrl), seeded from
VITE_PORTAL_API_URL, with presets for the two published hosts. Unset still
means the snapshot reads start off, so removing the variable is a real
rollback. The network switcher moves the endpoint along with the RPC URL, but
only when one is in use.
/src/utils/analyzerApi.ts reads the /api/v1/ half, and is deliberately a
separate client from portalApi.ts rather than another PortalDocumentName:
- No
networkorprogramIdsstamp, so the portal client's mismatch guard has nothing to check. - Cadence varies per document. The portal republishes every ~10 minutes;
network.jsonand the gateway roster are rebuilt daily. Applying the portal's single 30-minute window to them would reject every one, silently and forever — so freshness is per document, and epoch documents have no window at all because history does not go stale. /api/v1/gateways.jsonis not/api/v1/portal/gateways.json. The first is ~316 analysed rows with DNS/ASN/cluster detail; the second is every gateway on chain. Same filename, different dataset.
Everything read from it is additive: unavailable means render without the panel, never an error.
The analysis layer is mainnet-only. Devnet publishes the portal documents
and nothing else, so these panels correctly disappear there and
useObservations falls back to the live read.
Two contract traps the publisher documents and the UI honours: economics is
always null, and infrastructure is zeroed when a run skips geolocation —
uniqueAsns: 0 is a degraded run, not a decentralised network.
Results bitmaps: count, never attribute. An observation's
gatewayResultsBase64 (gar-bitmap-v1-lsb) indexes into the gateway
registry's slot order for that epoch, and the archive publishes only a digest
of that ordering. A population count is therefore exact — countGatewayResults
— while naming which gateway failed would mean indexing historical bits
against today's registry. ObservationData.hasGatewayAttribution exists so
consumers branch on it: an empty failureSummaries must never render as "no
failures", which previously would have shown a green Passed for a gateway
whose result is simply unknown.
Custom hooks in /src/hooks/ follow this pattern:
const useDataHook = (params) => {
const sdk = useGlobalState((state) => state.arIOReadSDK);
return useQuery({
queryKey: ['keyName', ...deps],
queryFn: async () => {
/* SDK call */
},
staleTime: 5 * 60 * 1000,
enabled: !!requiredDeps,
});
};The QueryClient defaults (App.tsx) already set staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false and refetchOnReconnect: false — restate them in a hook only
to differ.
retry: 0 is a default not to override. The SDK wraps every read in withRetry
(3 attempts, exponential backoff with jitter, transient transport errors only). React
Query's default of 3 sits on top and multiplies to 12 attempts per failing query — each
potentially a whole-program scan — which is how a brief 429 becomes a sustained one. It also
retries what the SDK deliberately does not (account-not-found, deserialization failures),
where a re-run can never succeed.
Prefer a targeted account read over a convenience SDK method. fetchEpochLightweight
(/src/utils/epochFetch.ts) reads the Epoch PDA in one call where getEpoch() makes ~55
(per-gateway weights, name resolution, observations) — the same reasoning that makes
ARIO_TICKER a constant instead of a two-account getInfo() round trip. It returns
EpochDataWithCounters, whose observationsSubmitted / rewardsDistributed live on the
Epoch account and are the only durable record of participation once observation PDAs are
closed. Both are optional and the SDK fallback path omits them: treat absent as unknown,
never as zero.
When two callers need the same account, route both through queryClient.fetchQuery on the
shared key rather than fetching directly — GlobalDataProvider does this with
epochSettingsQueryKey, and reading it directly is what made EpochSettings a
three-times-per-load fetch.
GlobalDataProvider handles app-wide data initialization:
- Fetches current epoch and ticker on load
- Updates Solana slot periodically
- Cleans up stale IndexedDB cache
- Hash-based routing (
createHashRouter) with React Router v6 - All route components lazy-loaded with
React.lazy()except Dashboard - Routes:
/dashboard,/gateways,/gateways/:ownerId,/gateways/:ownerId/reports,/gateways/:ownerId/reports/:reportId,/gateways/:ownerId/observe,/staking,/observers,/balances,/balances/:walletAddress,/extensions
The SDK returns balances and stakes in mARIO; the UI shows ARIO. Convert at the boundary rather than mid-calculation, and never mix the two in one expression:
new mARIOToken(gateway.operatorStake).toARIO().valueOf() // reading -> display
new ARIOToken(amountToStake).toMARIO() // form input -> SDK writeTimestamps cross a units boundary in the same way. The programs store unix
seconds; the SDK's read methods convert with secToMs, so everything reaching
the app — getVaults, the portal snapshot documents — is in milliseconds and
new Date(value) is correct. Raw deserializeVault does not convert, so a
value taken straight off an account is in seconds and renders as 1970. Writes go
the other way: vaultedTransfer takes lockLengthMs and floors it to seconds
itself.
Write flows follow a fixed shape (see /src/components/modals/ReviewStakeModal.tsx):
a review modal collects input, BlockingMessageModal covers the wallet round-trip,
the call goes through arIOWriteableSDK with WRITE_OPTIONS, every affected React
Query key is invalidated by hand, then SuccessModal shows the tx id. Failures go to
showErrorToast. arIOWriteableSDK is undefined until a wallet with signing
capability connects, so guard on it before starting a flow.
vaultedTransfer sends tokens into a vault the recipient cannot touch until it
unlocks. TransferArioModal collects it behind a toggle and
ReviewLockedTransferModal commits it. Four things about it are not obvious:
- It stores a duration, not a date. The program computes
end_timestamp = clock.unix_timestamp + durationwhen the transaction lands, so the vault unlocks that long after confirmation, not at an instant the user chose.@src/utils/vaultLocktherefore works in whole days and the UI says "on or around". Do not add a time-of-day input on top of this — it would promise a precision the protocol cannot keep. - The SDK parameter is
revokable; the on-chain field isrevocable. Misspelling it silently sends a non-revocable vault, which nobody can undo. - The vault address depends on the recipient's vault counter, read before
signing. Another vault created for that recipient in the meantime makes the
derived address stale and the transaction fails — a hardware wallet's slower
confirmation widens the window.
@src/utils/vaultErrorsmaps that to a retry message rather than raw Anchor text. - No SDK estimator covers it.
getGasEstimatetakes an ArNSIntentandgetGarGasEstimateaGarGasWorkflow; a core-program vault is neither, souseVaultGasEstimatecomposesestimateRentLamports+estimateGasFeedirectly. Rent dominates — a vault deposits for a Vault PDA (110 bytes) and the vault's own token account, roughly two orders of magnitude more SOL than the plain transfer beside it.
Protocol limits worth knowing before changing the form: a vault must hold at least
100 ARIO (VaultBelowMinimum, 6014), a locked transfer to yourself is rejected
(SelfTransfer, 6003), and the lock bounds the portal enforces (14 days to ~12
years) are the SDK's, not readable from chain.
@ar.io/sdk/web for the app-level API (ARIO, ARIORead, mARIOToken,
GatewayWithAddress); @ar.io/sdk/solana for on-chain primitives (PDA helpers,
deserializers, program ids). Never import from bare @ar.io/sdk.
- Gateways: Network nodes that serve data, can be staked to
- Staking: ARIO token staking/delegation to gateways
- Observers: Monitor gateway performance and generate reports
- Epochs: Time periods for rewards and assessments
- Vaults: Token locking for withdrawals
/src/components/- Reusable UI components (flat structure with/forms,/modals,/panels,/chartssubdirs)/src/hooks/- Data fetching and business logic hooks/src/pages/- Route page components (one directory per page withindex.tsx)/src/store/- Zustand state management/src/utils/- Helper functions (includeswalletAdapterBridge.tsfor Solana signer conversion)/tokens/- Design token definitions (primitives.json consumed by Tailwind config)/tests/- Test files (some also co-located in/src/)
- Vitest with globals enabled (no need to import
describe,it,expect, etc.) vitest.config.tsextends the base Vite config withtest: { globals: true }- Legacy
jest.config.jsonexists but is unused; tests run via vitest only
RPC endpoints come from the environment and are never committed — .env* is
gitignored, .env.example documents the shape. A provider URL carries an auth token
in its path, so treat it as sensitive in source, but note that Vite inlines it into the
bundle: it is published to every visitor regardless, and permanently so via Arweave.
The endpoint is protected at the provider (referrer allowlist, per-method rate limits),
not by keeping the token out of git.
Both deploy workflows pass VITE_SOLANA_RPC_URL and VITE_SOLANA_MAINNET_RPC_URL from
repository secrets. Production has a verify-secrets gate that fails the run when either
is empty, and ships a permanent build, so it must not be weakened.
The public fallbacks are not a safety net, and the mainnet one is not usable at
all. api.mainnet-beta.solana.com answers 403 Access forbidden to any request
carrying an Origin header — which every browser request has. The identical call
returns 200 from curl, which sends none, so this is easy to "verify" wrongly from a
terminal. An unset mainnet secret therefore does not degrade the app so much as
hollow it out: the shell renders and snapshot-backed views still populate, while
every RPC-backed read fails — starting with the current epoch and everything gated
behind it — so it reads as a permanently loading page rather than an error.
api.devnet.solana.com does answer browsers, rate-limited.
The portal snapshot API is a separate variable, VITE_PORTAL_API_URL, and is
deliberately outside that gate: empty is a supported state that falls back to
direct RPC, and removing the variable is the documented rollback.
Three more variables are opt-in with no usable default:
VITE_SOLANA_FALLBACK_RPC_URL— a second endpoint to fail over to. It once defaulted to the public Solana RPC; only point it at an endpoint you are entitled to saturate (see Solana RPC Client).VITE_PORTAL_MAINNET_API_URL/VITE_PORTAL_DEVNET_API_URL— presets offered in Settings for flipping between published hosts. They fall back to the public hosts when unset and apply only on an explicit choice, so an emptyVITE_PORTAL_API_URLstill ships with the snapshot reads off.
- Node.js 24.16.0 required (see
.nvmrc); workflows readnode-version-file: .nvmrc - Environment variables use
VITE_prefix - Sourcemaps are deliberately not emitted — they were ~13MB per deploy, stored permanently on Arweave, and only existed to symbolicate Sentry traces
- Pre-commit hooks run Biome via Husky
- CI/CD:
develop-> GitHub Pages (staging);main-> Firebase + Arweave (production, permanent). PRs publish an Arweave preview - Tailwind CSS with custom design tokens in
/tokens/, Rubik font, dark mode viaselectorstrategy