HIP-1022: Native Balance Reads
Abstract
tokens answers one question about an account on a declared chain: how much of
that chain's own currency it holds, as the chain itself reports it.
It is named for a family it deliberately does not serve. Enumerating an
address's fungible positions, and enumerating its non-fungible ones, are
questions no chain answers in a single call — they are indexer questions, and
the indexer relationship belongs to a different capability. This HIP is mostly
about why that refusal is better than a route that returns an empty list.
Implementation: apps/web3 in hanzoai/cloud.
Motivation
A wallet surface needs a balance, and the deployment already holds the upstream (HIP-1020). The temptation is to make the same surface answer "and what else does this address hold", because that is what a wallet shows. Doing it over plain RPC means walking a token list and calling each contract — which returns a number that silently omits whatever the list missed. A caller cannot tell that answer from a complete one, so it is worse than no answer at all.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
1. Declared chain, validated principal
Both rules are HIP-1020's and apply unchanged: the chain MUST be one the
registry declares (apps/web3/web3.go:174), and the caller MUST carry a
validated principal (apps/web3/web3.go:167). A balance read is an upstream
call, so it is spending the same budget the relay in HIP-1021 spends.
2. The address is checked before it becomes an upstream call
An address MUST be 0x followed by forty hexadecimal digits
(apps/web3/web3.go:359), and a malformed one is refused locally
(apps/web3/web3.go:341). This is a cheap shape check, not a checksum
validation: it exists so a typo fails immediately instead of becoming an
upstream round trip that fails slowly.
3. The balance is the chain's own quantity, verbatim
The value is returned as the 0x-quantity string the RPC returned
(apps/web3/web3.go:322). It MUST NOT be converted to a floating-point number,
and SHOULD NOT be rendered as a decimal by this capability: a wei value does not
survive a float64, and choosing a decimal place means knowing a denomination the
registry does not declare. Callers convert with a big-integer type, at the point
where they also know what they want to display.
4. A balance is a fact or it is not
If the chain does not answer, or answers something that is not a quantity, the
read FAILS (apps/web3/web3.go:346). It MUST NOT report zero.
This is the deliberate opposite of HIP-1020 §3, where a chain that will not
answer still describes itself with live: false. The difference is what the
zero would mean: for a height it is a real value on a fresh chain and absence
is the honest encoding, and for a balance it is the difference between an empty
account and an unanswered question — which is the difference between showing
someone their wallet and showing them somebody's idea of it.
5. What this capability refuses, and why
- Enumerating fungible positions. There is no chain call that answers "every
token this address holds" (
apps/web3/web3.go:327). An answer assembled from a token list is incomplete by exactly the amount the list is out of date, and nothing in the response says so. - Non-fungible holdings. Ownership and metadata are not one chain call
either (
apps/web3/web3.go:22). A route returning an empty list forever would look like a feature and be a lie. - Historical balances. The read is at the chain head. A read at a past block
is an archive capability (
apps/web3/web3.go:344), and the registry declares nothing about whether an upstream retains history — so this capability MUST NOT offer a block parameter that a declared chain may silently fail to honour.
The remedy for the first two is an indexer, and a capability that owns an indexer relationship may answer them. It is not this one, and it MUST NOT be made to look like this one by adding a field here that is populated sometimes.
Rationale
The alternative to refusing is a best-effort list with a partial: true flag.
It fails in the way optional honesty always fails: the flag is read by whoever
implemented the client carefully and by nobody else, and the surface that
matters — a wallet page — renders the same either way. Refusing keeps the
guarantee simple enough to hold: everything this capability returns is a value
the chain itself proved.
The alternative to returning a string is returning a number, which is what every caller wants until the first account holds more than about nine quadrillion of anything.
Security Considerations
An address is public and a balance is public, so there is nothing here to leak between tenants; the principal check exists for the upstream's sake, not the data's.
The shape check on the address bounds what this capability will put into an upstream call on a caller's behalf. It is not sanitisation — the value travels as a JSON parameter, not as a path or a query — but it keeps an arbitrary caller-supplied string from reaching a chain as an address-shaped argument.
The refusals in §5 are a security property as well as an honesty one: a caller who believes an incomplete holdings list is complete will act on it, and this capability would have supplied the belief.
References
- HIP-1020 — Chain Registry
- HIP-1021 — Chain JSON-RPC Endpoint
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.