Skip to content
90 changes: 90 additions & 0 deletions examples/mirror_node_account_balance/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"fmt"
"os"
"time"

hiero "github.com/hiero-ledger/hiero-sdk-go/v2/sdk"
)

func main() {
var client *hiero.Client
var err error

// Retrieving network type from environment variable HEDERA_NETWORK
client, err = hiero.ClientForName(os.Getenv("HEDERA_NETWORK"))
if err != nil {
panic(fmt.Sprintf("%v : error creating client", err))
}

// Retrieving operator ID from environment variable OPERATOR_ID
operatorAccountID, err := hiero.AccountIDFromString(os.Getenv("OPERATOR_ID"))
if err != nil {
panic(fmt.Sprintf("%v : error converting string to AccountID", err))
}

// Retrieving operator key from environment variable OPERATOR_KEY
operatorKey, err := hiero.PrivateKeyFromString(os.Getenv("OPERATOR_KEY"))
if err != nil {
panic(fmt.Sprintf("%v : error converting string to PrivateKey", err))
}

// Setting the client operator ID and key
client.SetOperator(operatorAccountID, operatorKey)

// MirrorNodeAccountBalanceQuery replaces AccountBalanceQuery, which the consensus node stops
// serving in release 0.77. It reads from the mirror node REST API, so it
// is free and needs no query payment.
balance, err := hiero.NewMirrorNodeAccountBalanceQuery().
SetAccountID(operatorAccountID).
Execute(client)
if err != nil {
panic(fmt.Sprintf("%v : error executing mirror node account balance query", err))
}

fmt.Printf("balance = %v\n", balance.Hbars.String())

// The account can also be addressed by anything the mirror node resolves: an EVM address, a
// public key alias, or a contract ID. A contract goes through SetAccountID as well -- there is
// no SetContractID, because the balances endpoint takes a contract through the same parameter.
//
// contractAccountID := hiero.AccountID{
// Shard: contractID.Shard, Realm: contractID.Realm, Account: contractID.Contract,
// }

// The mirror node ingests consensus state asynchronously and trails the network by a few
// seconds, so a balance read immediately after a transfer may still show the old value. Poll
// until the expected value appears rather than trusting a single read.
transfer, err := hiero.NewTransferTransaction().
AddHbarTransfer(operatorAccountID, hiero.NewHbar(-1)).
AddHbarTransfer(hiero.AccountID{Account: 3}, hiero.NewHbar(1)).
Execute(client)
if err != nil {
panic(fmt.Sprintf("%v : error executing transfer transaction", err))
}
if _, err = transfer.SetValidateStatus(true).GetReceipt(client); err != nil {
panic(fmt.Sprintf("%v : error getting transfer receipt", err))
}

spent := balance.Hbars.AsTinybar()
for attempt := 0; attempt < 10; attempt++ {
if attempt > 0 {
time.Sleep(2 * time.Second)
}

updated, err := hiero.NewMirrorNodeAccountBalanceQuery().
SetAccountID(operatorAccountID).
Execute(client)
if err != nil {
panic(fmt.Sprintf("%v : error executing mirror node account balance query", err))
}

if updated.Hbars.AsTinybar() < spent {
fmt.Printf("balance after transfer = %v\n", updated.Hbars.String())
return
}
}

fmt.Println("mirror node had not yet ingested the transfer")
}
36 changes: 25 additions & 11 deletions sdk/account_id.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hiero

import (
"encoding/base32"
"encoding/hex"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -339,21 +340,34 @@ const (
EvmAddress
)

func (id *AccountID) _MirrorNodeRequest(client *Client, populateType string) (map[string]interface{}, error) {
if client.mirrorNetwork == nil || len(client.GetMirrorNetwork()) == 0 {
return nil, errors.New("mirror node is not set")
// _MirrorNodePathID renders the AccountID as the mirror node accepts it: shard.realm.num when a
// number is set, otherwise an EVM-address alias as bare hex or a public key alias as unpadded
// base32. Alias forms carry no shard.realm prefix.
func (id AccountID) _MirrorNodePathID() string {
if id.Account != 0 {
return fmt.Sprintf("%d.%d.%d", id.Shard, id.Realm, id.Account)
}

mirrorUrl, err := client.GetMirrorRestApiBaseUrl()
switch {
case id.AliasEvmAddress != nil:
return hex.EncodeToString(*id.AliasEvmAddress)
case id.AliasKey != nil:
if aliasBytes, err := protobuf.Marshal(id.AliasKey._ToProtoKey()); err == nil {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(aliasBytes)
}
return id.String()
default:
return id.String()
}
}

func (id *AccountID) _MirrorNodeRequest(client *Client) (map[string]interface{}, error) {
mirrorUrl, err := mirrorNodeRestBaseURL(client)
if err != nil {
return nil, err
}

if populateType == "account" {
mirrorUrl = fmt.Sprintf("%s/accounts/%s", mirrorUrl, hex.EncodeToString(*id.AliasEvmAddress))
} else {
mirrorUrl = fmt.Sprintf("%s/accounts/%s", mirrorUrl, id.String())
}
mirrorUrl = fmt.Sprintf("%s/accounts/%s", mirrorUrl, id._MirrorNodePathID())

resp, err := http.Get(mirrorUrl) // #nosec
if err != nil {
Expand All @@ -373,7 +387,7 @@ func (id *AccountID) _MirrorNodeRequest(client *Client, populateType string) (ma
// Should be used after generating `AccountId.FromEvmAddress()` because it sets the `Account` field to `0`
// automatically since there is no connection between the `Account` and the `evmAddress`
func (id *AccountID) PopulateAccount(client *Client) error {
result, err := id._MirrorNodeRequest(client, "account")
result, err := id._MirrorNodeRequest(client)
if err != nil {
return err
}
Expand All @@ -394,7 +408,7 @@ func (id *AccountID) PopulateAccount(client *Client) error {

// PopulateEvmAddress gets the actual `AliasEvmAddress` field of the `AccountId` from the Mirror Node.
func (id *AccountID) PopulateEvmAddress(client *Client) error {
result, err := id._MirrorNodeRequest(client, "evmAddress")
result, err := id._MirrorNodeRequest(client)
if err != nil {
return err
}
Expand Down
41 changes: 41 additions & 0 deletions sdk/account_id_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package hiero
// SPDX-License-Identifier: Apache-2.0

import (
"encoding/base32"
"encoding/hex"
"encoding/json"
"net/http"
Expand All @@ -14,6 +15,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
protobuf "google.golang.org/protobuf/proto"
)

func TestUnitAccountIDChecksumFromString(t *testing.T) {
Expand Down Expand Up @@ -398,3 +400,42 @@ func TestUnitAccountIDPopulateWithDifferentPorts(t *testing.T) {
})
}
}

// Covers the three shapes /accounts/{idOrAliasOrEvmAddress} accepts.
func TestUnitAccountIDMirrorNodePathID(t *testing.T) {
t.Parallel()

// Plain account number.
assert.Equal(t, "0.0.1234", AccountID{Account: 1234}._MirrorNodePathID())

// EVM-address alias: bare hex, no shard.realm prefix.
evm := []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99,
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x23, 0x45, 0x67}
assert.Equal(t, "00112233445566778899aabbccddeeff01234567",
AccountID{AliasEvmAddress: &evm}._MirrorNodePathID())

// Public-key alias: base32 (no padding) of the serialized key, no shard.realm prefix.
key, err := PrivateKeyGenerateEd25519()
require.NoError(t, err)
aliasID := *key.PublicKey().ToAccountID(0, 0)

path := aliasID._MirrorNodePathID()
assert.NotContains(t, path, "0.0.", "public-key alias must not carry a shard.realm prefix")

aliasBytes, err := protobuf.Marshal(aliasID.AliasKey._ToProtoKey())
require.NoError(t, err)
expected := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(aliasBytes)
assert.Equal(t, expected, path)

// The encoding must decode back to the original alias bytes.
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(path)
require.NoError(t, err)
assert.Equal(t, aliasBytes, decoded)
}

func TestUnitAccountIDMirrorNodePathIDWithoutNumOrAlias(t *testing.T) {
t.Parallel()

assert.Equal(t, "0.0.0", AccountID{}._MirrorNodePathID())
assert.Equal(t, "1.2.0", AccountID{Shard: 1, Realm: 2}._MirrorNodePathID())
}
4 changes: 3 additions & 1 deletion sdk/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var errNoClientOrTransactionID = errors.New("`client` must have an `_Operator` o
var errNoClientOrTransactionIDOrNodeId = errors.New("`client` must be provided or both `nodeId` and `transactionId` must be set") // nolint
var errClientOperatorSigning = errors.New("`client` must have an `_Operator` to sign with the _Operator")
var errNoClientProvided = errors.New("`client` must be provided and have an _Operator")
var errMirrorNodeAccountBalanceQueryNoAccountID = errors.New("`accountID` must be set on MirrorNodeAccountBalanceQuery")
var errQueryPaymentRequiresOperator = errors.New("`client` must have an _Operator to pay for a query")
var errTransactionIsNotFrozen = errors.New("transaction is not frozen")
var errInnerTransactionShouldBeFrozen = errors.New("inner transaction should be frozen")
Expand Down Expand Up @@ -115,7 +116,8 @@ func (e ErrHederaNetwork) Error() string {
}

// ErrHederaPreCheckStatus is returned by Transaction.Execute and QueryBuilder.Execute if an exceptional status is
// returned during _Network side validation of the sent transaction.
// returned during _Network side validation of the sent transaction. MirrorNodeAccountBalanceQuery
// also returns it, mapping an unknown account onto StatusInvalidAccountID.
type ErrHederaPreCheckStatus struct {
TxID TransactionID
Status Status
Expand Down
27 changes: 4 additions & 23 deletions sdk/fee_estimate_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ package hiero
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/hiero-ledger/hiero-sdk-go/v2/proto/services"
Expand Down Expand Up @@ -169,13 +167,9 @@ func (q *FeeEstimateQuery) estimateSingleTransaction(client *Client, tx Transact

// callGetFeeEstimate calls the fee estimate REST API endpoint
func (q *FeeEstimateQuery) callGetFeeEstimate(client *Client, protoTx *services.Transaction) (FeeEstimateResponse, error) {
if client.mirrorNetwork == nil || len(client.GetMirrorNetwork()) == 0 {
return FeeEstimateResponse{}, errors.New("mirror node is not set")
}

mirrorUrl, err := client.GetMirrorRestApiBaseUrl()
mirrorUrl, err := mirrorNodeRestBaseURL(client)
if err != nil {
return FeeEstimateResponse{}, errors.Wrap(err, "failed to get mirror REST API base URL")
return FeeEstimateResponse{}, err
}

isLocalHost := strings.Contains(mirrorUrl, "localhost") || strings.Contains(mirrorUrl, "127.0.0.1")
Expand All @@ -198,23 +192,10 @@ func (q *FeeEstimateQuery) callGetFeeEstimate(client *Client, protoTx *services.
if err != nil {
return FeeEstimateResponse{}, errors.Wrapf(err, "failed to call fee estimate API after %d attempts", q.maxAttempts)
}
if resp == nil {
return FeeEstimateResponse{}, errors.Wrap(errors.New("received nil response"), "failed to call fee estimate API")
}
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
return FeeEstimateResponse{}, errors.Wrap(fmt.Errorf("received non-200 response: %d, details: %s", resp.StatusCode, body), "failed to call fee estimate API")
}
return FeeEstimateResponse{}, errors.Wrap(fmt.Errorf("received non-200 response: %d", resp.StatusCode), "failed to call fee estimate API")
}

defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
body, err := mirrorNodeReadBody(resp)
if err != nil {
return FeeEstimateResponse{}, errors.Wrap(err, "failed to read response body")
return FeeEstimateResponse{}, errors.Wrap(err, "failed to call fee estimate API")
}

var response FeeEstimateResponse
Expand Down
6 changes: 3 additions & 3 deletions sdk/mirror_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,16 @@ func (node *_MirrorNode) getScheme() (string, error) {

// Standard HTTPS ports
if port == 443 {
return "https", nil
return mirrorNodeSchemeHTTPS, nil
}

// Standard HTTP ports
if port == 80 {
return "http", nil
return mirrorNodeSchemeHTTP, nil
}

// For other ports, assume HTTPS for security
return "https", nil
return mirrorNodeSchemeHTTPS, nil
}

func (node *_MirrorNode) getBaseRestUrl() (string, error) {
Expand Down
9 changes: 9 additions & 0 deletions sdk/mirror_node_account_balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package hiero

// SPDX-License-Identifier: Apache-2.0

// MirrorNodeAccountBalance is the hbar balance returned by MirrorNodeAccountBalanceQuery.
// Token balances are not included; the balances endpoint does not return them.
type MirrorNodeAccountBalance struct {
Hbars Hbar
}
Loading
Loading