hiero-ledger / hiero-ledger/hiero-sdk-java

Sync getReceipt()/getRecord() resubmit a THROTTLED_AT_CONSENSUS transaction under new IDs without rebinding the response, causing duplicate transfers

Open
#2,947 0 comments 0 reactions 1 assignee Claimed by @mustafauzunn View on GitHub
bug p1
Dominant language
Java
Stars
264
Forks
192
Avg merge
2d
Merged PRs (30d)
39

Description

## Problem

When a transaction finalizes as `THROTTLED_AT_CONSENSUS`, the synchronous `TransactionResponse.getReceipt()` resubmits it under a freshly generated transaction ID (`retryTransaction`). The new receipt is returned, but `TransactionResponse.transactionId` is `public final` and stays bound to the original, throttled attempt.

Consequences:

- `getRecord()` calls `getReceipt()` internally, so it triggers its own resubmission, then queries the record for the **original** ID and returns the throttled record. `TransactionRecordQuery` never validates the receipt status, so no exception is raised.
- Every subsequent `getReceipt()` / `getRecord()` on the same response object resubmits again.
- A single `getRecord()` call is enough to cause damage: the replacement executes on the network, the caller receives a record saying `THROTTLED_AT_CONSENSUS`, and the natural application reaction is a manual retry. For a `CryptoTransfer` that pays the recipient twice.
- Retained signer functions re-sign the regenerated body, so the resubmission really executes for the normal `sign(key)` / operator case.
- `regenerateTransactionId` always mints an operator-payer ID. Unlike the JS SDK, there is no guard refusing to retry when the original payer is not the client operator, so a retry can also silently switch the fee payer.

Only the synchronous path is affected; `getReceiptAsync` has no retry logic.

## Where

- `sdk/src/main/java/com/hedera/hashgraph/sdk/TransactionResponse.java`
- `transactionId` declared `public final` (~L56)
- `getReceipt(Client, Duration)` retry loop + `retryTransaction` (~L131-181), never rebinds the ID
- `getRecord(Client, Duration)` (~L312-316) calls `getReceipt` then `getRecordQuery(client)` built from the stale `transactionId`
- `sdk/src/main/java/com/hedera/hashgraph/sdk/TransactionRecordQuery.java` `getExecutionState`: a throttled receipt status falls to `default` → `SUCCESS`
- `sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java` `regenerateTransactionId` (~L1710)

## Reproduction

1. Submit a `TransferTransaction`, keep the `TransactionResponse`.
2. The transaction finalizes as `THROTTLED_AT_CONSENSUS` (network-wide consensus throttle for that operation type exhausted; no funds moved).
3. Call `response.getReceipt(client)` → SDK resubmits under ID B, B executes, receipt for B returned.
4. Call `response.getRecord(client)` → SDK resubmits again under ID C, C executes, record for A (`THROTTLED_AT_CONSENSUS`) returned.

Step 4 alone (without step 3) also executes one replacement and still reports the original as throttled.

## Context

- The retry was added in 2.48.0 and reworked with backoff in 2.55.0. There are no unit or integration tests in the repo referencing `THROTTLED_AT_CONSENSUS` or `retryTransaction`.
- JS and Go both rebind the response's transaction ID after a successful retry (JS: `this.transactionId = this.transaction.transactionId` with the comment "need to set the transactionId again in case we are doing getRecord afterwards"; Go: `response.TransactionID = resp.TransactionID`). JS additionally refuses to retry when the payer is not the operator. Java is the only SDK with the stale binding.

## Severity

Medium. Not externally triggerable on mainnet without saturating the network-wide throttle for the operation type, but the outcome is silent duplicate fund movement with a wrong result reported and no application-side workaround, since the resubmission happens inside the SDK.

## Proposed fix

1. Make the bound transaction ID mutable (private field + getter, or drop `final`) and rebind it after a successful retry so `getReceiptQuery`, `getRecordQuery` and `getRecord` follow the new ID. Also rebind `nodeId` to the node the retry was submitted to.
2. `getRecord` should reuse the receipt/ID from the retry instead of re-entering the retry path a second time.
3. Port the JS guard: refuse to retry when the original transaction's payer is not the client operator.
4. Consider validating the receipt status in `TransactionRecordQuery` (Go's `SetValidateStatus` does) so a throttled record can never be returned as a normal answer.
5. Unit test with a mocked throttled receipt asserting exactly one resubmission and a record fetched under the new ID; integration test if a throttled status can be induced locally.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.