Skip to content

Commit e791b66

Browse files
committed
feat: add invalid account check when querying account balance
Signed-off-by: dosi <dosi.kolev@limechain.tech>
1 parent a9422ea commit e791b66

8 files changed

Lines changed: 76 additions & 49 deletions

examples/mirror_node_account_balance/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ func main() {
3333
// Setting the client operator ID and key
3434
client.SetOperator(operatorAccountID, operatorKey)
3535

36-
// MirrorNodeAccountBalanceQuery replaces the deprecated AccountBalanceQuery, which the
37-
// consensus node stops serving in release 0.77. It reads from the mirror node REST API, so it
36+
// MirrorNodeAccountBalanceQuery replaces AccountBalanceQuery, which the consensus node stops
37+
// serving in release 0.77. It reads from the mirror node REST API, so it
3838
// is free and needs no query payment.
3939
balance, err := hiero.NewMirrorNodeAccountBalanceQuery().
4040
SetAccountID(operatorAccountID).

sdk/errors.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ func (e ErrHederaNetwork) Error() string {
116116
}
117117

118118
// ErrHederaPreCheckStatus is returned by Transaction.Execute and QueryBuilder.Execute if an exceptional status is
119-
// returned during _Network side validation of the sent transaction.
119+
// returned during _Network side validation of the sent transaction. MirrorNodeAccountBalanceQuery
120+
// also returns it, mapping an unknown account onto StatusInvalidAccountID.
120121
type ErrHederaPreCheckStatus struct {
121122
TxID TransactionID
122123
Status Status

sdk/mirror_node_account_balance_query.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ package hiero
44

55
import (
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"net/url"
910
)
1011

1112
// MirrorNodeAccountBalanceQuery retrieves an account's or contract's hbar balance from the mirror
12-
// node REST API, replacing AccountBalanceQuery. It is free and requires no operator. The mirror node
13-
// trails the network by a few seconds, so results are not read-after-write consistent.
13+
// node REST API, replacing AccountBalanceQuery. It is free and requires no operator.
14+
//
15+
// The mirror node trails the network by a few seconds, so results are not read-after-write
16+
// consistent: a freshly created account fails with StatusInvalidAccountID until it is ingested.
1417
type MirrorNodeAccountBalanceQuery struct {
1518
accountID *AccountID
1619
maxAttempts uint64
@@ -48,7 +51,7 @@ func (q *MirrorNodeAccountBalanceQuery) GetMaxAttempts() uint64 {
4851
return q.maxAttempts
4952
}
5053

51-
// Execute executes the Query with the provided client
54+
// Execute executes the query with the provided client
5255
func (q *MirrorNodeAccountBalanceQuery) Execute(client *Client) (MirrorNodeAccountBalance, error) {
5356
if client == nil {
5457
return MirrorNodeAccountBalance{}, errNoClientProvided
@@ -96,7 +99,6 @@ func (q *MirrorNodeAccountBalanceQuery) resolveAttempts(client *Client) uint64 {
9699
return maxAttempts
97100
}
98101

99-
// buildURL escapes the filter: an alias is opaque bytes and must not alter the query string.
100102
func (q *MirrorNodeAccountBalanceQuery) buildURL(mirrorBaseURL string) string {
101103
params := url.Values{}
102104
params.Set("account.id", q.accountID._MirrorNodePathID())
@@ -118,7 +120,6 @@ func (q *MirrorNodeAccountBalanceQuery) validateNetworkOnIDs(client *Client) err
118120
return nil
119121
}
120122

121-
// fetchAccountBalances GETs the balances endpoint through the shared retry core.
122123
func fetchAccountBalances(client *Client, endpoint string, attempts uint64) ([]byte, error) {
123124
resp, err := mirrorNodeGetWithRetry(client, endpoint, attempts, mirrorNodeDefaultTimeout)
124125
if err != nil {
@@ -128,16 +129,21 @@ func fetchAccountBalances(client *Client, endpoint string, attempts uint64) ([]b
128129
return mirrorNodeReadBody(resp)
129130
}
130131

131-
// parseAccountBalances reads an empty list as a zero balance: the endpoint returns one for an
132-
// unknown account rather than a 404, so zero is not proof the account exists.
132+
// parseAccountBalances maps an empty balances list onto StatusInvalidAccountID, the status
133+
// AccountBalanceQuery returned for an unknown account. The endpoint reports one as 200 with no rows.
133134
func parseAccountBalances(body []byte) (MirrorNodeAccountBalance, error) {
134135
var raw accountBalancesResponseJSON
135136
if err := json.Unmarshal(body, &raw); err != nil {
136137
return MirrorNodeAccountBalance{}, fmt.Errorf("failed to unmarshal response: %w", err)
137138
}
138139

140+
if raw.Balances == nil {
141+
return MirrorNodeAccountBalance{}, errors.New("mirror node response has no balances array")
142+
}
143+
144+
// An existing account with no hbar returns "balance": 0, so an empty list only means unknown.
139145
if len(raw.Balances) == 0 {
140-
return MirrorNodeAccountBalance{Hbars: HbarFromTinybar(0)}, nil
146+
return MirrorNodeAccountBalance{}, ErrHederaPreCheckStatus{Status: StatusInvalidAccountID}
141147
}
142148

143149
return MirrorNodeAccountBalance{Hbars: HbarFromTinybar(raw.Balances[0].Balance)}, nil
@@ -149,6 +155,5 @@ type accountBalancesResponseJSON struct {
149155
}
150156

151157
type accountBalanceJSON struct {
152-
Account string `json:"account"`
153-
Balance int64 `json:"balance"`
158+
Balance int64 `json:"balance"`
154159
}

sdk/mirror_node_account_balance_query_e2e_test.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package hiero
55
// SPDX-License-Identifier: Apache-2.0
66

77
import (
8+
"fmt"
89
"testing"
910
"time"
1011

@@ -32,6 +33,10 @@ func mirrorHbarBalanceEventually(env *IntegrationTestEnv, query *MirrorNodeAccou
3233
}
3334
}
3435

36+
if err == nil {
37+
err = fmt.Errorf("mirror node did not reach the expected state within %d attempts", mirrorHbarBalanceRetryAttempts)
38+
}
39+
3540
return balance, err
3641
}
3742

@@ -160,6 +165,16 @@ func TestIntegrationMirrorNodeAccountBalanceQueryCanGetContractBalance(t *testin
160165
require.NoError(t, err)
161166
contractID := *receipt.ContractID
162167

168+
defer func() {
169+
_, err := NewContractDeleteTransaction().
170+
SetContractID(contractID).
171+
SetTransferAccountID(env.Client.GetOperatorAccountID()).
172+
Execute(env.Client)
173+
require.NoError(t, err)
174+
_, err = NewFileDeleteTransaction().SetFileID(fileID).Execute(env.Client)
175+
require.NoError(t, err)
176+
}()
177+
163178
contractAccountID := AccountID{Shard: contractID.Shard, Realm: contractID.Realm, Account: contractID.Contract}
164179

165180
tx, err := NewTransferTransaction().
@@ -178,26 +193,18 @@ func TestIntegrationMirrorNodeAccountBalanceQueryCanGetContractBalance(t *testin
178193
require.NoError(t, err)
179194
assert.Equal(t, NewHbar(1).AsTinybar(), balance.Hbars.AsTinybar())
180195

181-
_, err = NewContractDeleteTransaction().
182-
SetContractID(contractID).
183-
SetTransferAccountID(env.Client.GetOperatorAccountID()).
184-
Execute(env.Client)
185-
require.NoError(t, err)
186-
_, err = NewFileDeleteTransaction().
187-
SetFileID(fileID).
188-
Execute(env.Client)
189-
require.NoError(t, err)
190196
}
191197

192-
func TestIntegrationMirrorNodeAccountBalanceQueryNonExistentAccountIsZero(t *testing.T) {
198+
func TestIntegrationMirrorNodeAccountBalanceQueryNonExistentAccountErrors(t *testing.T) {
193199
t.Parallel()
194200
env := NewIntegrationTestEnv(t)
195201
defer CloseIntegrationTestEnv(env, nil)
196202

197-
balance, err := NewMirrorNodeAccountBalanceQuery().
203+
_, err := NewMirrorNodeAccountBalanceQuery().
198204
SetAccountID(AccountID{Account: 999999999}).
199205
Execute(env.Client)
200206

201-
require.NoError(t, err, "an unknown account is an empty balances array, not an error")
202-
assert.Zero(t, balance.Hbars.AsTinybar())
207+
var status ErrHederaPreCheckStatus
208+
require.ErrorAs(t, err, &status)
209+
assert.Equal(t, StatusInvalidAccountID, status.Status)
203210
}

sdk/mirror_node_account_balance_query_unit_test.go

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package hiero
55
// SPDX-License-Identifier: Apache-2.0
66

77
import (
8+
"errors"
89
"fmt"
910
"net/http"
1011
"net/url"
@@ -116,32 +117,47 @@ func TestUnitMirrorNodeAccountBalanceQueryResolvesPublicKeyAlias(t *testing.T) {
116117
assert.Equal(t, gotAccountID, url.QueryEscape(gotAccountID), "alias must need no escaping")
117118
}
118119

119-
func TestUnitMirrorNodeAccountBalanceQueryResolvesContractID(t *testing.T) {
120+
func TestUnitMirrorNodeAccountBalanceQueryNonExistentAccountErrors(t *testing.T) {
120121
var gotAccountID string
121-
client := newMockMirrorClient(t, "contract.example.com:443", balancesHandler(t, &gotAccountID,
122-
`{"balances":[{"account":"0.0.98765","balance":777}]}`))
122+
client := newMockMirrorClient(t, "missing.example.com:443", balancesHandler(t, &gotAccountID,
123+
`{"timestamp":null,"balances":[],"links":{"next":null}}`))
124+
125+
_, err := NewMirrorNodeAccountBalanceQuery().
126+
SetAccountID(AccountID{Account: 999999999}).
127+
Execute(client)
128+
129+
var status ErrHederaPreCheckStatus
130+
require.ErrorAs(t, err, &status)
131+
assert.Equal(t, StatusInvalidAccountID, status.Status)
132+
assert.Contains(t, err.Error(), "INVALID_ACCOUNT_ID")
133+
}
134+
135+
func TestUnitMirrorNodeAccountBalanceQueryZeroBalanceIsNotMissing(t *testing.T) {
136+
var gotAccountID string
137+
client := newMockMirrorClient(t, "zerobalance.example.com:443", balancesHandler(t, &gotAccountID,
138+
`{"balances":[{"account":"0.0.42","balance":0}]}`))
123139

124-
contractID := ContractID{Shard: 0, Realm: 0, Contract: 98765}
125140
balance, err := NewMirrorNodeAccountBalanceQuery().
126-
SetAccountID(AccountID{Shard: contractID.Shard, Realm: contractID.Realm, Account: contractID.Contract}).
141+
SetAccountID(AccountID{Account: 42}).
127142
Execute(client)
128143

129144
require.NoError(t, err)
130-
assert.Equal(t, HbarFromTinybar(777), balance.Hbars)
131-
assert.Equal(t, "0.0.98765", gotAccountID)
145+
assert.Zero(t, balance.Hbars.AsTinybar())
132146
}
133147

134-
func TestUnitMirrorNodeAccountBalanceQueryNonExistentAccountIsZero(t *testing.T) {
148+
func TestUnitMirrorNodeAccountBalanceQueryMissingBalancesArrayIsMalformed(t *testing.T) {
135149
var gotAccountID string
136-
client := newMockMirrorClient(t, "missing.example.com:443", balancesHandler(t, &gotAccountID,
137-
`{"timestamp":null,"balances":[],"links":{"next":null}}`))
150+
client := newMockMirrorClient(t, "nobalances.example.com:443", balancesHandler(t, &gotAccountID,
151+
`{"timestamp":null,"links":{"next":null}}`))
138152

139-
balance, err := NewMirrorNodeAccountBalanceQuery().
140-
SetAccountID(AccountID{Account: 999999999}).
153+
_, err := NewMirrorNodeAccountBalanceQuery().
154+
SetAccountID(AccountID{Account: 7}).
141155
Execute(client)
142156

143-
require.NoError(t, err, "an empty balances array is a zero balance, not an error")
144-
assert.Equal(t, HbarFromTinybar(0), balance.Hbars)
157+
require.Error(t, err)
158+
assert.Contains(t, err.Error(), "no balances array")
159+
var status ErrHederaPreCheckStatus
160+
assert.False(t, errors.As(err, &status), "a malformed payload must not read as a missing account")
145161
}
146162

147163
func TestUnitMirrorNodeAccountBalanceQueryNoAccountIDErrorsBeforeRequest(t *testing.T) {

sdk/mirror_node_rest_helpers.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ const (
2525
)
2626

2727
// mirrorNodeRestBaseURL returns the client's mirror node REST API base URL, erroring when no
28-
// mirror network is configured. A caller whose endpoint is served on a different port against a
29-
// local node overrides it afterwards.
28+
// mirror network is configured.
3029
func mirrorNodeRestBaseURL(client *Client) (string, error) {
3130
if client == nil {
3231
return "", errNoClientProvided

sdk/mirror_node_rest_helpers_unit_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func TestUnitMirrorNodeValidateURL(t *testing.T) {
176176
}
177177

178178
// A URL no request could satisfy must fail before the retry loop spends its budget on it.
179-
func TestUnitMirrorNodeGetAndPostRejectInvalidURLWithoutRequesting(t *testing.T) {
179+
func TestUnitMirrorNodeGetAndPostRejectInvalidURL(t *testing.T) {
180180
client, err := _NewMockClient()
181181
require.NoError(t, err)
182182

@@ -285,18 +285,18 @@ func TestUnitMirrorNodeWalkPagesFollowsNextAcrossPages(t *testing.T) {
285285
func TestUnitMirrorNodeWalkPagesStopsAtPageCap(t *testing.T) {
286286
t.Parallel()
287287

288-
const cap = 3
288+
const maxPages = 3
289289
pages := 0
290290
forever := "/api/v1/things?page=next"
291291

292-
err := mirrorNodeWalkPages("https://mirror.example.com/api/v1/things", cap,
292+
err := mirrorNodeWalkPages("https://mirror.example.com/api/v1/things", maxPages,
293293
func(pageURL string) ([]byte, error) { pages++; return []byte(`{}`), nil },
294294
func(body []byte) (*string, error) { return &forever, nil },
295295
)
296296

297297
require.Error(t, err)
298298
assert.Contains(t, err.Error(), "exceeded pagination cap of 3 pages")
299-
assert.Equal(t, cap, pages, "no more than the cap may be fetched")
299+
assert.Equal(t, maxPages, pages, "no more than the cap may be fetched")
300300
}
301301

302302
func TestUnitMirrorNodeWalkPagesPropagatesErrors(t *testing.T) {

sdk/registered_node_address_book_query.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,8 @@ func (q *RegisteredNodeAddressBookQuery) Execute(client *Client) (RegisteredNode
9393
return q.walkPages(endpoint, q.resolveAttempts(client))
9494
}
9595

96-
// resolveEndpoint discovers the mirror node REST base URL on the client and
97-
// returns the initial query URL. A local node serves this endpoint on 8084
98-
// rather than the default mirrorNodeRestBaseURL port.
96+
// resolveEndpoint returns the initial query URL. A local node serves this
97+
// endpoint on 8084 rather than on the client's mirror REST port.
9998
func (q *RegisteredNodeAddressBookQuery) resolveEndpoint(client *Client) (string, error) {
10099
mirrorUrl, err := mirrorNodeRestBaseURL(client)
101100
if err != nil {

0 commit comments

Comments
 (0)