Skip to content

fix(fsm): recover account address from state key in bulk queries - #606

Open
infoboy27 wants to merge 1 commit into
canopy-network:developmentfrom
infoboy27:fix/query-accounts-empty-address
Open

fix(fsm): recover account address from state key in bulk queries#606
infoboy27 wants to merge 1 commit into
canopy-network:developmentfrom
infoboy27:fix/query-accounts-empty-address

Conversation

@infoboy27

Copy link
Copy Markdown

Symptom

POST /v1/query/accounts (the paginated/bulk query) returns an empty address for some accounts, while POST /v1/query/account resolves the same accounts correctly. Balances are correct in both. The Explorer accounts page renders Address as N/A.

Reproducible unauthenticated against a devnet node:

curl -s -X POST https://dev-rpc.canoliq.org/v1/query/accounts \
  -H 'Content-Type: application/json' \
  -d '{"height":0,"pageNumber":1,"perPage":20}'

Root cause

Both cases appear in a single response, through one code path — which is what identifies the cause:

[0..7] address=''                          amount=100000000         <- untouched genesis accounts
[8]    address='caebc6996cc405e85988f29…'  amount=1000999366320000  <- the validator

Accounts are keyed by address (KeyForAccountlib.JoinLenPrefix(accountPrefix, addr)), so the key is authoritative. But both bulk getters read only the iterator value and discard the key:

  • GetAccountss.unmarshalAccount(it.Value()), it.Key() unused.
  • GetAccountsPaginated — the page.Load callback is func(_, b []byte); the key goes to _.

Records whose stored value carries no Address therefore report empty. GetAccount is unaffected because it sets acc.Address from the requested address.

Entry [8] is the validator: reward crediting re-marshals it every block via SetAccount, and marshalAccount is lib.Marshal(account) — the whole struct, address included. So its value does carry the address and it renders correctly. Entries [0..7] have never transacted and still hold their original bytes.

Two implications worth noting:

  1. A node upgrade does not fix affected accounts — the legacy bytes persist in state until the account transacts, so the read path has to tolerate them.
  2. The RPC layer is not at fault. spendableAccountView (cmd/rpc/query.go:784) copies Address faithfully, as [8] proves. Separately, the reason it renders as "" rather than being omitted is that AccountView.Address (cmd/rpc/types.go:176) has no omitempty while lib.HexBytes.MarshalJSON always emits a string — cosmetic, and left alone here.

Fix

In both bulk getters, when the unmarshalled value has no address, derive it from the state key via AddressFromKey (fsm/key.go:114). Guarded by len(acc.Address) == 0, so accounts whose value already carries an address take the existing path untouched.

Flagging for review: slicing the key manually is incorrect here. lib.JoinLenPrefix (lib/util.go:807) writes a length byte per segment, so key[len(AccountPrefix()):] yields an address shifted by one byte. AddressFromKey decodes the length prefixes properly and takes the last segment.

Consensus impact: none

The only callers are read-only:

Caller Nature
cmd/rpc/query.go:80 the /v1/query/accounts handler
fsm/genesis.go:168 (ExportState) reached from cmd/rpc/query.go:507,525,530 (RPC state export/diff) and controller/block.go:825 (debugDumpHeaderDiff, logging only)

No state writes, no re-marshalling back into state, no participation in block hashing.

Verification

  • Regression test TestGetAccountsRecoversAddressFromKey writes an account record whose marshalled value omits the address (reproducing the legacy on-disk shape), then asserts both bulk getters report it. It fails before the fix (actual: []byte(nil), matching the live "") and passes after.
  • go test -count=1 ./fsm/ green.

Two pre-existing environment notes, unrelated to this change — each verified by git stashing the patch and reproducing the identical failure on the untouched tree:

  • The repo needs Go 1.26 (GOTOOLCHAIN=go1.26.0). Under Go 1.27 the transitive dependency cockroachdb/swiss fails to compile (undefined: getRuntimeHasher, fastrand64) as it reaches runtime internals via go:linkname.
  • go build ./... and go test ./cmd/rpc/ both fail with cmd/rpc/server.go:343:12: pattern all:web/explorer/dist: no matching files found unless the web frontend is built first, so a full-repo build isn't usable as a check in a bare clone.

Optional follow-up (deliberately not in this PR)

The empty-address records are a data artifact that persists for any account that never transacts. If you would rather normalise state than tolerate it on read, SetAccount already writes the address, so a one-off migration rewriting affected accounts would clear it — but that is a state write and would need consensus-safe sequencing. Happy to follow that route instead if you prefer.

/v1/query/accounts returns "address": "" for accounts whose stored value
does not carry an address, while /v1/query/account resolves the same
accounts correctly. The Explorer accounts page shows N/A as a result.

Accounts are keyed by address (KeyForAccount), so the key is
authoritative, but GetAccounts and GetAccountsPaginated read only the
iterator value and discard the key. Records written without the address
in the value therefore report empty. GetAccount is unaffected because it
sets acc.Address from the requested address.

Observed live on a devnet, where both cases appear in one response: eight
untouched genesis accounts return an empty address, while the validator
account - re-marshalled every block by reward crediting, so its value
does carry the address - renders correctly through the same code path.
Those legacy bytes persist until the account transacts, so the read path
has to tolerate them.

Derive the address from the key via AddressFromKey when the unmarshalled
value has none. Note that slicing the key manually is incorrect here:
lib.JoinLenPrefix writes a length byte per segment, so
key[len(AccountPrefix()):] is off by one byte.

Read-only change. The only callers are ExportState (RPC state export and
a debug logger) and the /v1/query/accounts handler - no state writes and
no hashing, so no consensus impact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant