Skip to content

Payment transaction nodeAccountID becomes misaligned during multi-node query retries #1614

@leninmehedy

Description

@leninmehedy

Description

When executing a query with multiple nodes configured via SetNodeAccountIDs(), the Hedera Go SDK generates payment transactions that become misaligned with the actual target node during retry attempts. This causes queries to fail with INVALID_NODE_ACCOUNT errors because nodes reject payment transactions that specify a different node ID than the one receiving the request.

The Problem

The SDK's query.go implementation has a synchronization bug between payment transaction generation and node selection during retries:

  • generatePayments() creates payment transactions for all nodes in the node list
  • Returns only the last payment transaction created
  • When advanceRequest() moves to the next node during retry, the payment transaction still references the wrong node
  • Result: Payment says "for node 0.0.4" but the query is sent to node 0.0.5

Impact

  • Multi-node queries fail intermittently with INVALID_NODE_ACCOUNT errors
  • Network resilience is compromised (can't effectively retry across nodes)
  • Applications must implement workarounds (query one node at a time)
  • Affects all query types: AccountInfoQuery, ContractInfoQuery, etc.

Root Cause Analysis

Location: vendor/github.com/hashgraph/hedera-sdk-go/v2/query.go

Line 248-254: makeRequest() regenerates all payment transactions but only uses the last one:

func (q *Query) makeRequest() interface{} {
    if q.client != nil && q.isPaymentRequired {
        tx, err := q.generatePayments(q.client, q.queryPayment)  // ← Generates ALL payments
        if err != nil {
            return q.pb
        }
        q.pbHeader.Payment = tx  // ← Uses only the LAST payment!
    }
    return q.pb
}

Line 232-242: generatePayments() loops through all nodes but returns only the last transaction:

func (q *Query) generatePayments(client *Client, cost Hbar) (*services.Transaction, error) {
    var tx *services.Transaction
    var err error
    for _, nodeID := range q.nodeAccountIDs.slice {  // ← Loops through ALL nodes
        txnID := TransactionIDGenerate(client.operator.accountID)
        tx, err = _QueryMakePaymentTransaction(
            txnID,
            nodeID.(AccountID),  // ← Creates payment for this specific node
            client.operator,
            cost,
        )
        // ...
    }
    return tx, nil  // ← Returns only the LAST transaction created!
}

The Bug: When advanceRequest() cycles to the next node during retry, makeRequest() regenerates payments but always uses the last one, causing a mismatch.

Steps to reproduce

Here is a sample code to explain, but it's not what caused me to encounter it:

package main

import (
    "fmt"
    "github.com/hashgraph/hedera-sdk-go/v2"
)

func main() {
    // Setup client
    client := hedera.ClientForTestnet()
    client.SetOperator(
        hedera.AccountID{Shard: 0, Realm: 0, Account: 2},
        hedera.PrivateKeyFromString("your-operator-private-key"),
    )

    // Configure multiple nodes for retry
    nodeAccountIDs := []hedera.AccountID{
        {Shard: 0, Realm: 0, Account: 3},
        {Shard: 0, Realm: 0, Account: 4},
        {Shard: 0, Realm: 0, Account: 5},
    }

    // Create query with multiple target nodes
    query := hedera.NewAccountInfoQuery().
        SetAccountID(hedera.AccountID{Shard: 0, Realm: 0, Account: 2}).
        SetNodeAccountIDs(nodeAccountIDs).
        SetMaxRetry(5)

    // Execute - SDK will retry across nodes, triggering the bug
    _, err := query.Execute(client)
    if err != nil {
        fmt.Printf("Query failed: %v\n", err)
    }
}

Expected Behavior
When the query retries to different nodes, each payment transaction's nodeAccountID should match the target node:

Attempt 1: Query → node 0.0.3, Payment nodeAccountID: 0.0.3 ✓
Attempt 2: Query → node 0.0.4, Payment nodeAccountID: 0.0.4 ✓
Attempt 3: Query → node 0.0.5, Payment nodeAccountID: 0.0.5 ✓

Actual Behavior
The payment transaction's nodeAccountID doesn't match the query target:

Attempt 1: Query → node 0.0.3, Payment nodeAccountID: 0.0.5 ✗ INVALID_NODE_ACCOUNT
Attempt 2: Query → node 0.0.4, Payment nodeAccountID: 0.0.5 ✗ INVALID_NODE_ACCOUNT  
Attempt 3: Query → node 0.0.5, Payment nodeAccountID: 0.0.5 ✓ (works by luck)

Evidence from Logs
Decoding the protobuf request shows the mismatch (it was trying to query using account 0.0.5, but in the transaction it had 0.0.4:

Query {
  cryptogetAccountInfo {
    header {
      payment {
        signedTransactionBytes {
          bodyBytes {
            nodeAccountID: 0.0.4  // ← Payment is for node 0.0.4
            // ...
          }
        }
      }
    }
    accountID: 0.0.2
  }
}

Additional context

Proposed Fixes

Option 1: Use Payment Transaction Array Index
Match payment to node using the current retry index:

func (q *Query) makeRequest() interface{} {
    if q.client != nil && q.isPaymentRequired {
        // Generate payment transactions once
        if len(q.paymentTransactions) == 0 {
            _, err := q.generatePayments(q.client, q.queryPayment)
            if err != nil {
                return q.pb
            }
        }

        // Use the payment transaction matching the current node index
        currentIndex := q.nodeAccountIDs.index
        if currentIndex < len(q.paymentTransactions) {
            q.pbHeader.Payment = q.paymentTransactions[currentIndex]
        }
    }
    return q.pb
}

Option 2: Generate Payment Per Node On-Demand (no need for generatePayments method)

func (q *Query) makeRequest() interface{} {
    if q.client != nil && q.isPaymentRequired {
        // Get the current node being queried
        currentNodeID := q.nodeAccountIDs.slice[q.nodeAccountIDs.index].(AccountID)

        // Generate payment transaction for ONLY the current node
        txnID := TransactionIDGenerate(q.client.operator.accountID)
        tx, err := _QueryMakePaymentTransaction(
            txnID,
            currentNodeID,  // ← Always matches the query target
            q.client.operator,
            q.queryPayment,
        )
        if err != nil {
            return q.pb
        }
        q.pbHeader.Payment = tx
    }
    return q.pb
}

Advantages of Option 2:

  • Simpler logic (no array management)
  • Each payment is fresh for the current node
  • More resilient to internal state changes

Workaround for Applications
Until the SDK is fixed, applications can work around this by querying one node at a time (this is what I am using atm):

// Manually implement node retry logic
for _, nodeID := range nodeAccountIDs {
    query := hedera.NewAccountInfoQuery().
        SetAccountID(accountID).
        SetNodeAccountIDs([]hedera.AccountID{nodeID}). // ← ONE node at a time
        SetMaxRetry(1) // Only retry once per node

    result, err = query.Execute(client)
    if err == nil {
        break // Success!
    }
    // Log and try next node
}

Hedera network

other

Version

v2.50.0

Operating system

Linux

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions