Skip to content

GetBalanceChanges discards the readable balance changes when one node's amount is out of range #154

Description

@Platonenkov

This is not a request to stop throwing. #148 settled that, and the XML doc on GetBalanceChanges already states the behaviour and tells callers what to do about it:

Catch it and decide what an unrepresentable balance means for you; issue #150 tracks representing it instead.

That advice is right, and it is what a consumer ends up doing. The problem is that the API does not let a caller follow it. The walk covers every account the transaction touched, and one unreadable amount anywhere in it aborts the whole call — so the only decision available is all balance changes or none, including the ones that were perfectly readable, including the caller's own.

Reproduction

Against the published Xrpl 11.1.0 on .NET 10. One payment touching two trust lines: an ordinary USD line, and a third party's line holding a mega-supply token.

string metaJson = """
{
  "TransactionIndex": 0,
  "TransactionResult": "tesSUCCESS",
  "AffectedNodes": [
    {
      "ModifiedNode": {
        "LedgerEntryType": "RippleState",
        "LedgerIndex": "1111111111111111111111111111111111111111111111111111111111111111",
        "FinalFields": {
          "Balance":   { "currency": "USD", "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", "value": "30" },
          "LowLimit":  { "currency": "USD", "issuer": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", "value": "1000" },
          "HighLimit": { "currency": "USD", "issuer": "rGpeQzUWFu4fMhJHZ1Via5aJAo2b8sSuxG", "value": "0" }
        },
        "PreviousFields": {
          "Balance":   { "currency": "USD", "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", "value": "10" }
        }
      }
    },
    {
      "ModifiedNode": {
        "LedgerEntryType": "RippleState",
        "LedgerIndex": "2222222222222222222222222222222222222222222222222222222222222222",
        "FinalFields": {
          "Balance":   { "currency": "SCM", "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", "value": "9999999999999999e80" },
          "LowLimit":  { "currency": "SCM", "issuer": "rUn84CJZFcs1iJPFtEeZ2ny2ktcDcvsmqM", "value": "9999999999999999e80" },
          "HighLimit": { "currency": "SCM", "issuer": "rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK", "value": "0" }
        },
        "PreviousFields": {
          "Balance":   { "currency": "SCM", "issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji", "value": "9999999999999998e80" }
        }
      }
    }
  ]
}
""";

Meta meta = JsonSerializer.Deserialize<Meta>(metaJson, XrplJsonOptions.Default)!;
BalanceChanges.GetBalanceChanges(meta);

Observed:

affected nodes parsed: 2
THREW AmountOutOfRangeException: The amount '9999999999999999e80' is outside the range
System.Decimal can represent (about ±7.9e28). ...

The same walk with the second node removed returns what the first node holds:

affected nodes parsed: 1
accounts with changes: 2
  rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY: 20 USD
  rGpeQzUWFu4fMhJHZ1Via5aJAo2b8sSuxG: -20 USD

So the readable delta is not unavailable — it is discarded on the way out.

9999999999999999e80 is not a contrived value: it is the protocol maximum, and it is what wallets write when a user asks for an unlimited trust line. A payment only has to route through such a line, on an account the caller has never heard of, for this to happen.

What it costs a consumer

From migrating a wallet from 10.4.2 to 11.1.0.

The transaction history screen builds one row per transaction and awaits them together. A single transaction anywhere in an account's history whose metadata contains such an amount faulted the batch, so the screen showed nothing at all — and kept showing nothing, because a transaction does not age out of the ledger. This is the same "fails there permanently" property #150 describes for indexers, reached from the UI side.

The subscription notifier has the same shape with a quieter symptom. The SDK's stream loop survives a handler that throws, so the user is simply never told about that transaction's payment or balance change.

Both were fixed on the consumer side by catching AmountOutOfRangeException and returning an empty map. That is the documented advice and it works, but the result is worse than it needs to be: a transaction that moved the user's own USD now displays no amounts at all, because of an unrelated account's token on the same payment path.

Suggestion

Keep GetBalanceChanges exactly as it is — same signature, same exception, no behavioural change for anyone relying on it. Add a way to get the readable part:

public static Dictionary<string, List<Currency>> GetBalanceChanges(
    ITransactionMetadata metadata,
    out IReadOnlyList<AffectedNode> skipped);

or an overload taking an option, or a TryGetBalanceChanges. The shape matters less than the property: the walk continues past a node it cannot read, and the caller learns which nodes those were, so "decide what an unrepresentable balance means for you" becomes a decision the caller can actually make. foreach over AffectedNodes already isolates naturally — the change is a try around the per-node computation, not a redesign.

This is deliberately small and not a substitute for #150. Once amounts are representable across the protocol range there is nothing to skip and the addition becomes redundant, which seems like the right relationship between the two: this makes the interim behaviour usable without pre-empting the design question.

One smaller point in the same area, no separate issue: GetXrpQuantity and the RippleState path both read ValueAsNumber, so an XRP-only consumer inherits the token failure mode even when it never asked about tokens.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions