From 6c3841a60bfd3537182794a400ad8f30e8f93884 Mon Sep 17 00:00:00 2001 From: Jonatan Date: Wed, 9 Sep 2026 11:39:14 -0400 Subject: [PATCH] fix(fsm): recover account address from state key in bulk queries /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) --- fsm/account.go | 25 +++++++++++-- fsm/account_addressfromkey_test.go | 57 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 fsm/account_addressfromkey_test.go diff --git a/fsm/account.go b/fsm/account.go index dfdd2339b9..e297b737f7 100644 --- a/fsm/account.go +++ b/fsm/account.go @@ -57,6 +57,15 @@ func (s *StateMachine) GetAccounts() (result []*Account, err lib.ErrorI) { if err != nil { return nil, err } + // accounts are keyed by address, so the key is authoritative - legacy + // records whose value omits the address would otherwise report empty + if len(acc.Address) == 0 { + addr, e := AddressFromKey(it.Key()) + if e != nil { + return nil, e + } + acc.Address = addr.Bytes() + } result = append(result, acc) } // return the result @@ -68,11 +77,21 @@ func (s *StateMachine) GetAccountsPaginated(p lib.PageParams) (page *lib.Page, e // create a new 'accounts' page page, res := lib.NewPage(p, AccountsPageName), make(AccountPage, 0) // load the page using the account prefix iterator - err = page.Load(AccountPrefix(), false, &res, s.store, func(_, b []byte) (err lib.ErrorI) { + err = page.Load(AccountPrefix(), false, &res, s.store, func(k, b []byte) (err lib.ErrorI) { acc, err := s.unmarshalAccount(b) - if err == nil { - res = append(res, acc) + if err != nil { + return + } + // accounts are keyed by address, so the key is authoritative - legacy + // records whose value omits the address would otherwise report empty + if len(acc.Address) == 0 { + addr, e := AddressFromKey(k) + if e != nil { + return e + } + acc.Address = addr.Bytes() } + res = append(res, acc) return }) return diff --git a/fsm/account_addressfromkey_test.go b/fsm/account_addressfromkey_test.go new file mode 100644 index 0000000000..b1c5a119a7 --- /dev/null +++ b/fsm/account_addressfromkey_test.go @@ -0,0 +1,57 @@ +package fsm + +import ( + "testing" + + "github.com/canopy-network/canopy/lib" + "github.com/canopy-network/canopy/lib/crypto" + "github.com/stretchr/testify/require" +) + +// TestGetAccountsRecoversAddressFromKey ensures the bulk account getters report +// an address even when the stored value does not carry one. +// +// Accounts are keyed by address, so the key is authoritative. Records written +// without the address in the value still exist in live state (observed on the +// canoLiq devnet: untouched genesis accounts returned "address": "" from +// /v1/query/accounts, while the same accounts resolved correctly through the +// single-account query, which derives the address from the request). Those +// bytes are never rewritten unless the account transacts, so the read path has +// to tolerate them. +func TestGetAccountsRecoversAddressFromKey(t *testing.T) { + addr := newTestAddress(t) + + // write an account record whose marshalled value omits the address, + // reproducing the legacy on-disk shape + writeAddresslessAccount := func(t *testing.T, sm StateMachine, a crypto.AddressI, amount uint64) { + t.Helper() + bz, err := sm.marshalAccount(&Account{Amount: amount}) // no Address + require.NoError(t, err) + require.NoError(t, sm.Set(KeyForAccount(a), bz)) + } + + t.Run("GetAccounts", func(t *testing.T) { + sm := newTestStateMachine(t) + writeAddresslessAccount(t, sm, addr, 100000000) + + got, err := sm.GetAccounts() + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, addr.Bytes(), got[0].Address, + "address must be recovered from the state key") + require.EqualValues(t, 100000000, got[0].Amount) + }) + + t.Run("GetAccountsPaginated", func(t *testing.T) { + sm := newTestStateMachine(t) + writeAddresslessAccount(t, sm, addr, 100000000) + + page, err := sm.GetAccountsPaginated(lib.PageParams{PageNumber: 1, PerPage: 10}) + require.NoError(t, err) + results, ok := page.Results.(*AccountPage) + require.True(t, ok) + require.Len(t, *results, 1) + require.Equal(t, addr.Bytes(), (*results)[0].Address, + "address must be recovered from the state key") + }) +}