Skip to main content
glnc

Field notes

I don't trust a single free RPC, so my CLI makes them vote

by Arya Rahimi · · 12 min read

A free RPC endpoint can hand you a wrong answer and never tell you it was wrong. Stale, lagging, rate-limited, serving a cached block from four minutes ago. You get a number, it looks like a balance, and there is nothing in the response that says "this is wrong." So glnc balance doesn't ask one RPC. It asks three, groups the answers, and returns the instant a majority agrees, without running a node. This post is about the streaming voting engine underneath the --rpc-quorum flag: how glnc compares multiple RPC endpoints, detects disagreement, and reaches quorum from the CLI. How the vote runs, how a slow dissenter gets aborted mid-flight, and exactly what the verdict looks like in JSON.

Compare multiple RPC endpoints: three vote, two agree, one is cut off

Here is the command. majority mode, against vitalik.eth on Ethereum.

glnc balance vitalik.eth --chain ethereum --rpc-quorum=majority
fetching ethereum...
✓ ethereum (194ms)
⬢ ETHEREUM (7 tokens)
Asset Amount USD Value
───────────────────────────────────────────────
ETH 5.67692 $11,507.46
USDC 0xA0b8…eB48 31.127137 $31.13
USDT 0xdAC1…1ec7 290.246219 $289.82
DAI 0x6B17…1d0F 4.572078 $4.57
WETH 0xC02a…6Cc2 1.461898 $2,960.72
stETH 0xae7a…fE84 0.00001 —
wstETH 0x7f39…2Ca0 0.000013 —
AAVE 0x7Fc6…DaE9 0.010152 —
Grand Total: $14,793.70+
(+ indicates some asset prices are unavailable)
⚠ Prices unavailable from CoinGecko (rate-limited or upstream error)
USD values shown as "—". Try again in 60s, or run with --json to inspect meta.sources.prices.

The pretty output looks like any other balance lookup. The interesting part is invisible until you add --json and read the per-chain ballot. Three RPCs were queried. Two returned the same value. The third never got to finish. The per-URL ballot lives on the chain entry's quorum block, at data.wallets[0].chains[0].quorum.sources for a single-chain run, not under meta:

glnc balance vitalik.eth --chain ethereum --rpc-quorum=majority --json | jq '.data.wallets[0].chains[0].quorum.sources'
[
{ "url": "https://ethereum-rpc.publicnode.com/", "status": "fulfilled", "error": null },
{ "url": "https://eth.drpc.org/", "status": "fulfilled", "error": null },
{ "url": "https://eth.merkle.io/", "status": "rejected", "error": "aborted" }
]

The chain-level agreement label on this run is unanimous, but only two of three actually voted. The threshold for a three-RPC quorum is two. The moment publicnode and drpc returned the same normalized value, the math was settled: a third opinion could not change the outcome. So the engine aborted the still-pending request to eth.merkle.io, drained it as rejected/aborted, and returned. 194 milliseconds, because it did not wait for the slow one. That abort is the difference between this post and the README.

Why a single public RPC returns a wrong balance with no warning

Every tool that reads a balance reads it from somewhere. Point at one free public endpoint and you have inherited that endpoint's bad day with no way to know it happened. The only way to detect a stale RPC response from the terminal is disagreement: ask more than one and see whether they line up. The failure modes are quiet:

  • Lagging. The node is a few blocks behind head and hands you a balance that was true ninety seconds ago. No error. Just an old number wearing a fresh timestamp.
  • Cached. A load balancer in front of the RPC serves a stale cached eth_getBalance to shed load. The response is a 200. The value is from a block that is no longer the tip.
  • Rate-limited into a partial. The endpoint throttles you mid-multicall and some token reads come back empty, so the balance is right but incomplete, or the whole thing degrades silently.
  • Just wrong. Misconfigured archive pruning, a bad fork, an endpoint pointed at the wrong network. Rare, but you cannot rule it out from a single response.

In every case the response is well-formed. There is no field that says "I am stale." A single RPC cannot detect its own staleness; that is the definition of the problem, and the only signal available without running your own node. glnc does not verify on-chain truth. It surfaces vendor disagreement and lets you decide.

The quorum modes: any, majority, all (named exactly)

--rpc-quorum takes exactly three values: any, majority, all. They are not subtle variations. They are three different contracts with the network. (unanimous, plurality, single show up in output too; those are agreement labels, not flags. Do not pass them.)

any (default): sequential first-success. It tries each configured URL in order and returns on the first one that answers, without touching the rest. This is the same sequential first-success shape as an ethers FallbackProvider; it is not a race and there is no "fastest responder." It is a for loop that stops early. Untried URLs are marked not-tried, the agreement label is single. It exists so the default behavior does not triple the load on shared free-tier RPCs. It cannot detect disagreement, because it only ever asks one. any is the speed default, not a defense.

majority: parallel fan-out with plurality fallback. It queries all endpoints in parallel, groups the responses by normalized value as they settle, and returns the instant one group crosses the majority threshold. If no group ever reaches majority (a real three-way split, say), it falls back to plurality: the largest group wins, ties broken by first arrival. Dissenters are recorded in disagreements[]. This is the only mode that emits the yellow disagreement warning to stderr.

all: parallel, fail-closed. It also fans out in parallel, but it throws the instant a second distinct value appears. No "using majority" fallback, no yellow warning. A disagreement is an error, full stop. glnc catches that RpcDisagreementError per chain and renders a red chain-level error instead of a balance. all is for the case where you would rather get nothing than get a number you cannot fully trust.

Scope matters. --rpc-quorum is accepted only on balance and tx, the commands that fan out across providers. Pass it to gas and the parser rejects it. And per-chain RPC redundancy (chains) varies: Bitcoin is a single endpoint and physically cannot vote; Solana and zkSync run 2-of-2; Ethereum runs three public RPCs (publicnode, drpc, merkle). The meta.sources.rpc.providers map tells you which chains actually fanned out on any given run.

How the disagreement-detection vote actually runs

This is the systems core. For majority and all, the engine computes a threshold up front: majorityThreshold = floor(N/2) + 1. For three RPCs that is two. Then it does not Promise.all. It streams. It races the pending set, processes one settled result at a time, and re-evaluates after each.

# src/chains/_evm.js — the streaming loop (the shape, trimmed)
const majorityThreshold = Math.floor(totalUrls / 2) + 1; // 3 RPCs → 2
while (pendingIndices.size > 0) {
const slot = await Promise.race([...pendingIndices].map(toPromise));
pendingIndices.delete(slot.idx);
groups.get(slot.normalizedKey).push(slot); // group by value
if (mode === 'all' && groups.size > 1) {
abortAndDrainPending();
throw new RpcDisagreementError({ urls, values }); // fail closed
}
if (groupSize >= majorityThreshold) {
abortAndDrainPending(); // cut the losers
return winner; // return now
}
// else: re-check whether majority is still reachable at all
}

There are three ways to exit early, and all of them call abortAndDrainPending() before returning:

  • Unassailable majority. A value's group reaches majorityThreshold. A third opinion cannot change the outcome, so return immediately. This is the path the capture took: publicnode and drpc agreed, that's two of three, done.
  • Majority impossible. Even if every still-pending URL joined the current best group, it still could not reach threshold. No point waiting; stop and fall back to plurality.
  • Winner decided. The leader's group is already large enough that no combination of pending results can overtake or tie-beat it (ties go to first arrival). With two of three fast RPCs agreeing and one slow RPC still in flight, the winner is decided. The slow RPC, if it ever answered, would only change the label, never the value. So glnc does not wait for it.

abortAndDrainPending() is the load-bearing piece. It calls abort() on each still-pending request (cancelling both the internal timeout and the underlying undici connection) and pushes a { status: 'rejected', error: 'aborted' } entry so every queried URL gets an entry in sources. Without it, one slow RPC could pin the event loop for as long as its timeout, and you would be back to waiting on the slowest endpoint even though you already had your answer.

Here is what that buys you, measured. The same query in majority mode returned in 194ms. In all mode (which has no early-exit-on-majority and waits for every endpoint, including the same slow eth.merkle.io that majority aborted) returned in 10,029ms. Same balance, same RPCs, identical output body. Roughly fifty times slower on this run, because all has to hear from everyone before it can be sure no one disagrees. The exact ratio swings with how slow the laggard is; the point is that all is bounded by the slowest RPC and majority is not. That gap is the price of fail-closed.

Reading the quorum verdict in JSON (meta.sources.rpc)

The pretty output hides the quorum. The JSON does not. There are two places to look, and they live at two distinct paths. The per-chain ballot is at .data.wallets[N].chains[M].quorum.sources (shown in the cold open): one entry per URL, each fulfilled or rejected, with the abort reason. The run-level summary is at top-level .meta.sources.rpc, a sibling of data, not nested inside it:

glnc balance vitalik.eth --chain ethereum --rpc-quorum=majority --json | jq '.meta.sources.rpc'
{
"ok": true,
"chainsFailed": [],
"providers": { "ethereum": "https://ethereum-rpc.publicnode.com/" },
"disagreements": [],
"degraded": []
}

The agreement label on a clean run is one of unanimous, majority, plurality, or single. unanimous means every RPC that voted agreed. majority means a group crossed threshold while at least one dissented. plurality means no group reached threshold and the largest group won the fallback. single is the any-mode shape: only one was asked.

When there is a value disagreement in majority mode, glnc prints this to stderr, and only to stderr, only when it is an interactive TTY, never when output is piped or --json. The line below is the format glnc emits when this happens. I cannot summon a live finalized-balance disagreement on demand, so treat this as the shape, not a captured run:

# the line glnc prints to stderr on a majority-mode disagreement (documented format)
! ethereum: balance disagreement (publicnode=5.67692, drpc=5.67100) — using majority

The ! is yellow. The transaction disagreement variant is identical with a different noun. There is a separate degradation warning for when fewer RPCs respond than the mode requested: ! ethereum: quorum degraded (majority requested, 2/3 RPCs responded) — see meta.sources.rpc.degraded. Because both warnings are TTY-only and JSON-suppressed, in CI or cron you do not read the stderr line. You read the JSON meta and gate on it.

Which brings up --strict, and a trap. --strict exits 3 on any partial result. And partial is true when any of these hold: the RPC summary is not ok, there were disagreements, the quorum degraded, CoinGecko prices failed, or the token list fell back. So you can get exit 3 from a run where every RPC agreed perfectly, because prices were rate-limited. I have a capture of exactly that: --rpc-quorum=all --strict exited 3 while the RPCs were in full agreement; the only thing wrong was CoinGecko throttling. Do not read an exit 3 as "the RPCs disagreed." Read it as "something degraded," then check meta.sources.rpc.disagreements to learn whether it was the RPCs or just prices. The troubleshooting notes cover the exit codes in full.

Adjudicate the RPC disagreement yourself with tx --raw

If you do not trust glnc's normalization either (fair), tx --raw hands you the upstream RPC getTransaction response verbatim, under the schema glnc.tx-raw/v1. No reshaping. What it does not drop is the quorum check: even in raw mode, if the RPCs diverged or the quorum degraded, glnc still surfaces that to stderr. --raw forces JSON on stdout, and the warnings go to stderr only, so the divergence signal survives even when you are piping the raw payload into your own tooling. You get the original bytes and the disagreement flag, and you decide what to do with both.

The honest limits of cross-checking untrusted RPC nodes

I would rather be precise about what this is not than oversell it.

  • any is not a defense. It is the default because it is cheap and kind to free-tier RPCs, but it queries one endpoint and inherits one endpoint's bad day. If you want the cross-check, you have to ask for majority or all. The default does not protect you.
  • This detects disagreement, not fraud. It is not a Byzantine fault-tolerant protocol and it is not a fraud proof. If two of three RPCs are wrong in the same way (same stale cache layer, same bad fork), they out-vote the correct one and the quorum happily agrees with the wrong answer. Quorum catches divergence, not coordinated error.
  • A majority is a majority of what you asked. The vote is over the RPCs glnc happened to query for that chain. Ethereum gets three; Solana and zkSync get two; Bitcoin gets one and cannot vote at all. The panel is fixed per chain and not user-configurable on balance and tx; you cannot add your own endpoint to the vote, and the three public Ethereum RPCs may sit in front of overlapping upstream infrastructure. Treat N=3 as three vendors, not three independent nodes. The verdict is only as good as the panel, and the panel is small and public.
  • There is no on-chain truth here. glnc does not run a node and does not verify state against the chain. It compares what vendors say to each other. When they agree, you have more confidence. When they disagree, you have a flag. Neither is proof.

Install the multi-RPC quorum command line and make them vote

npm install -g glnc
# npm output shape; exact package count and timing vary
added 1 package in 2s

No API key, no account, no server. It runs locally against public RPCs.

glnc balance vitalik.eth --chain ethereum --rpc-quorum=majority --json
{ "schema": "glnc.balance/v1", "ts": "…", "ok": true, "data": { … }, "meta": { … } }

The full envelope, including the top-level JSON envelope's meta.sources.rpc summary, is ready to gate on in CI. The broader point is not the flag; the README already documents the flag. It is the posture. Reading chain data from a single free RPC means trusting a stranger's infrastructure with no way to audit it. Asking three and watching whether they agree costs you a few extra connections and, when the panel is healthy, zero extra latency, because the engine returns the moment the math is settled and aborts the rest. You do not need to run a node to stop trusting one endpoint blindly. You need a second opinion, and a third one to break the tie. If you came from the browser, my notes on why I built it and how it compares to glnc vs cast sit next to this one; the decode nested multisend calldata and wallet balance Slack alerts posts cover the other commands.

FAQ

How do I compare multiple RPC endpoints and detect disagreement from the CLI? Run glnc balance <address> --chain ethereum --rpc-quorum=majority. glnc fans out to several public RPCs in parallel, groups the responses by normalized value as they settle, and returns the instant a majority agrees. Dissenters are recorded in the run-level meta.sources.rpc.disagreements list, and the per-chain ballot is at data.wallets[].chains[].quorum.sources. In an interactive terminal a value disagreement also prints a yellow "balance disagreement" line to stderr. The flag takes exactly three values: any, majority, all.

How do I detect a stale RPC response from the terminal? Query more than one endpoint and compare. A single RPC cannot detect its own staleness; disagreement between independent endpoints is the only signal available without running your own node. Run glnc balance <address> --chain ethereum --rpc-quorum=majority and a lagging or cached endpoint shows up as a dissenting value in the per-chain quorum.sources ballot, recorded in meta.sources.rpc.disagreements, and as a yellow disagreement warning on stderr when output is an interactive TTY.

What if a public RPC returns the wrong balance? Use --rpc-quorum=majority to cross-check the value against several RPCs, or --rpc-quorum=all to fail closed. In majority mode glnc returns the value a majority of endpoints agree on and flags any dissenter. In all mode it throws RpcDisagreementError and renders a red chain-level error the instant a second distinct value appears, so you get nothing rather than a number you cannot trust. Note: if two RPCs are wrong the same way they out-vote the correct one. Quorum catches divergence, not coordinated error.

Can I verify an RPC endpoint without running a node? Yes, by consensus rather than by verification. glnc does not run a node and does not check state against the chain. It queries several independent public RPCs and compares their answers to each other. When they agree you have more confidence; when they disagree you get a flag in meta.sources.rpc.disagreements. This is disagreement detection over untrusted RPC nodes, not a fraud proof or a Byzantine fault-tolerant protocol, and the verdict is only as good as the small public panel that was queried.

Is this a CLI alternative to ethers FallbackProvider? It serves a different goal. FallbackProvider is about availability: it tries providers in sequence so a request succeeds if any endpoint is up. glnc's default any mode is that same sequential first-success behavior, not a race. The point of the majority and all modes is consensus: query in parallel, group by value, and surface or fail on disagreement, with a streaming engine that aborts the slow losers once the majority math is settled. It is a CLI, not a library, and it prints the full ballot under the per-chain quorum block and a summary at meta.sources.rpc.

Why did glnc exit 3 even though the RPCs agreed? Because --strict exits 3 on any partial result, and "partial" covers more than RPC disagreement. It is true if the RPC summary is not ok, any disagreement was recorded, the quorum degraded, CoinGecko prices failed, or the token list fell back. A run where every RPC agrees can still exit 3 if prices were rate-limited. Read exit 3 as "something degraded," then inspect meta.sources.rpc.disagreements to see whether it was the RPCs or just prices.