Network settings & member RPC endpoints (spec 069)¶
FairWins is a multi-network control plane. Every asset carries its own chainId, reads fan
out across all supported networks at once, and the connected chain matters only when a
transaction is actually signed. Two consequences shaped this feature:
- Network settings are member configuration, not a tool. The panel moved out of the Tools nav group onto the account button beside Preferences.
- The RPC route belongs to the member. A member with their own Alchemy/QuickNode/ Chainstack endpoint can point any network at it, with a failover behind it and whatever credential their provider expects — and every read in the app takes that route.
Where things live¶
| Concern | File |
|---|---|
| Persistence, validation, redaction, CSP host list | frontend/src/lib/network/endpointStore.js |
Resolution (member → build default), probe |
frontend/src/lib/network/rpcEndpoints.js |
React binding (useRpcEndpoints, useEndpointsRevision) |
frontend/src/hooks/useRpcEndpoints.js |
| Every ethers read provider | frontend/src/utils/rpcProvider.js |
| Wallet transports | frontend/src/wagmi.js |
| UI | frontend/src/components/account/NetworkPanel.jsx + NetworkEndpointForm.jsx |
Panel host (tab id network, unchanged) |
frontend/src/pages/WalletPage.jsx |
Resolution order¶
Per chain, highest precedence first:
- the member's endpoint for that chain (Network settings)
NETWORKS[chainId].rpcUrl— itselfVITE_RPC_URL_*or a curated public endpoint
With a member endpoint configured the route becomes a failover chain:
makeReadProvider(url, chainId) returns an ethers.FallbackProvider at quorum 1 in that
case, so a rate-limited or down provider hands over instead of taking the network with it.
getReadProvider(chainId) is the preferred entry point in new code — it resolves the
endpoint itself and cannot be called with a URL that contradicts the member's settings.
Never read NETWORKS[chainId].rpcUrl and build a provider by hand. That bypasses the
member's route, and the bypass is invisible in review. Go through makeReadProvider /
getReadProvider, or getRpcUrlForChain(chainId) when a service client needs a bare URL.
Build-curated failover (NETWORKS[chainId].rpcFailoverUrl)¶
A chain can also declare its own failover at build time, independent of any member
configuration — defaultRpcFailoverUrlForChain(chainId) reads NETWORKS[chainId].rpcFailoverUrl.
On a member's default settings this participates as:
Ethereum Classic (61) was the only chain with one until release 1.14.0 task 8 generalized the
pattern to every EVM mainnet the app supports (1, 10, 137, 8453, 42161): each now pairs a
VITE_RPC_URL_<CHAIN> primary (defaulting to a public publicnode.com endpoint — a deploy-time
override may point it at a keyed QuickNode endpoint instead, see frontend/.env.example) with a
distinct VITE_RPC_URL_<CHAIN>_FAILOVER (defaulting to a second independent public provider,
drpc.org, mirroring the relay-gateway's own Polygon pair in
services/relay-gateway/src/config/chains.js) — so a keyed primary going dark still leaves the
chain readable. Both makeReadProvider (ethers reads) and wagmi.js's transportFor (wallet
reads) consume this — a chain-declared failover with no member override still wires viem's
fallback([...]) transport, not just the ethers path, so a member who has configured nothing
gets the same redundancy on every read surface.
This layer is orthogonal to a member's own failoverUrl (above): once a member sets their own
primary endpoint, rpcFailoverUrl stops participating directly — the member's failover, or the
build's rpcUrl default behind it, takes over instead (unchanged by this generalization).
QuickNode per-chain endpoints¶
There are two unrelated QuickNode credentials per chain, and the difference is the whole of this section: one is a secret, one cannot be.
| Archive endpoint | SPA endpoint | |
|---|---|---|
| Lives in | Secret Manager (fairwins-quicknode-<chain>-url) |
a Cloud Build trigger substitution |
| Reaches | the workstation (MAINNET_RPC_URL, …) |
the bundle, as VITE_RPC_URL_<CHAIN> |
| Who can read it | operators who can impersonate fairwins-ops@ |
anyone who loads the app |
| Restriction | none — it is protected by being secret | referrer/origin allow-list on the endpoint itself |
VITE_ values compile into the client bundle, so the SPA's endpoint is public the moment it ships
(spec 097 rule 5). That is not a leak to be plugged — it is a property of build-time configuration.
The correct response is a QuickNode endpoint whose own settings restrict it to the app's origins,
which is safe to publish. Never pass an archive URL to the SPA build: moving a credential into
VITE_ does not hide it, it publishes it.
Neither is committed. cloudbuild.yaml and cloudbuild.staging.yaml declare four substitutions —
_RPC_URL_MAINNET, _RPC_URL_OPTIMISM, _RPC_URL_BASE, _RPC_URL_ARBITRUM — that default to
empty and are set on the trigger. Empty falls through to the committed publicnode.com primary
('' || 'https://…'), so an unset trigger reproduces the previous bundle exactly. They ride the
mainnet-cohort image only; the staging-testnet build deliberately gets none of them, because
chains 1/10/8453/42161 are mainnet and constitution III forbids a testnet build carrying a route to
mainnet state.
Polygon 137 is not in that set — it is the app's own chain and rides the existing global
VITE_RPC_URL build arg.
Provisioning and rotating the archive side is documented in
docs/developer-guide/workstation-secrets.md §"QuickNode endpoints are PER-CHAIN".
Rules that are not negotiable¶
- Credentials never enter a URL that could be logged. Header/bearer keys ride on an
ethers
FetchRequest(or viemfetchOptions.headers). Anything rendered or logged goes throughredactRpcUrlfirst — protocol + host, with/…stating that a credential exists. This is the T148 leak class (a keyed URL printed into CI logs); do not reintroduce it. - The API key goes to the primary endpoint only. A failover is usually a different provider; fanning the member's credential out to a second host is a decision we do not make on their behalf. A failover that needs its own key carries it in its URL.
- Endpoint settings are device-scoped and never backed up. They live in the non-wallet
global preference blob (
fw_global_prefs.network_endpoints) because reads happen with no wallet connected. They are deliberately absent fromlib/backup/syncedObjects.js(spec 032) so a provider credential never rides into an exportable backup — do not add an entry there. - A wrong-chain endpoint cannot be saved. The panel's "Test" asks the endpoint for
eth_chainId; a mismatch is blocked, because such an endpoint would silently serve another network's state into every balance, position and policy read for this one. An unreachable endpoint saves with the failure shown — a member may be configuring ahead of a provider or CSP change — but nothing claims it works. - HTTPS only (plus
http://localhostfor dev). WebSocket endpoints are rejected with a reason: the read providers are HTTP.
The CSP grant is part of this feature¶
The browser blocks fetch to anything the production connect-src does not admit
(frontend/nginx.conf + nginx.conf.template), so the policy decides what a member can
actually configure. Members run their own nodes — in their cloud on a domain we cannot know
at build time, or locally — and a static header served to everyone cannot express a
per-member allowlist, so the RPC grant is scheme-wide:
https:— any host. A curated provider list would have made every self-hosted endpoint a dead network.http://localhost:*,http://127.0.0.1:*,http://[::1]:*— the local-node case.http://is otherwise unusable from an https page (mixed content), and loopback is the only http origin browsers treat as potentially trustworthy.
The cost, stated plainly: with script-src carrying 'unsafe-inline', an injected script
could POST to any https host rather than only to a curated list. The XSS entry point is
unchanged; what widens is where data could go afterwards. That trade was made deliberately to
make member-run nodes real. https: is granted to connect-src only — a test asserts
script-src, frame-src and img-src never carry a bare https: grant.
CSP_RPC_GRANTS in endpointStore.js mirrors what the header admits and is what the endpoint
form validates against; src/test/nginxCspConnectSrc.test.js asserts the two stay in sync in
both nginx files.
A LAN node (http://192.168.x.x:8545) is refused, with the reason: mixed-content blocking
is not ours to grant. The two paths that work are https (put the node behind TLS) or an SSH
tunnel to localhost.
A local or self-hosted node must allow cross-origin requests from the app's origin (e.g.
geth --http.corsdomain=https://…). That is the most common cause of a "Could not reach"
probe result, and the message says so before it says "offline". Note also that Chrome's
private-network-access rules can require a preflight for public→loopback requests, so a node
that answers curl may still refuse the browser until CORS is configured.
Changing an endpoint at runtime¶
- Reads pick up a change on the next lookup. Provider instances are memoized, so read
hooks include
useEndpointsRevision()in their memo dependencies (usePortfolio,useAccountAssets,useEarnPositions,useTransfer). A new memoized provider path should do the same. - Wallet transports are built once at module load in
wagmi.js(localStorage is synchronous, so member endpoints apply from the first render of a session). A change therefore needs an app reload before wallet-signed transactions use it — the panel says so plainly rather than implying an instant switch.
Network switching¶
Switching remains a wallet action via wagmi.switchChain, but it is framed as optional in
the panel: assets carry their own chain, reads span all of them, and only sending on a
network the wallet is not currently on requires a switch — which the sending surfaces
(Transfer, Pay, Earn, ClearPath, custody) already prompt for at submit time. Bitcoin (spec
061) has no wallet switch at all and stays display-only here.