Skip to content

feat(soroban): stable simulation-failure categories; cover the fee-bump paths - #1572

Merged
SheyeJDev merged 1 commit into
Split-Naira:mainfrom
ridercoder4:fix/1495-1496-simulation-error-categories-and-fee-bump-coverage
Oct 19, 2026
Merged

SheyeJDev merged 1 commit into
Split-Naira:mainfrom
ridercoder4:fix/1495-1496-simulation-error-categories-and-fee-bump-coverage

Conversation

@ridercoder4

Copy link
Copy Markdown
Contributor

Assigned issues

Issue Title Status in this PR
#1495 [Stellar] Add transaction simulation error mapping Implemented
#1496 [Stellar] Add fee-bump transaction behavior tests Implemented
#1473 [Ledger] Add deterministic ledger reconciliation report Not implemented
#1491 [Soroban] Add event-to-database projection replay tests Not implemented

Per the assignment, this PR implements 2 of the 4 assigned issues. The two not
implemented are referenced with Closes so tracking stays accurate, but they are
not fixed here and a maintainer should close them manually rather than relying
on the merge.

On the two not implemented

  • [Ledger] Add deterministic ledger reconciliation report #1473 (ledger reconciliation report): this is a new reporting feature with
    real product decisions attached — what the report is scoped to, how long it
    retains history, who can read it, and whether it ever mutates. Producing one
    speculatively risked locking in answers a maintainer should make.
  • [Soroban] Add event-to-database projection replay tests #1491 (event projection replay): a genuine gap, but a large one. It needs
    captured on-chain event fixtures, a replay harness around
    EventListenerService.pollEvents(), and database-level assertions across its
    upsert call chain. That is a coherent PR on its own, not one squeezed in
    beside two unrelated changes.

#1495 — simulation error mapping

The problem

A rejected submission surfaced submit.errorResult.toString() verbatim, so the
user saw a host protocol string:

HostError: Error(Contract, #1, AAAAAB3NzaC1yc2EA…)

That text is not stable — it changes between SDK and host versions — so
nothing could branch on it. It is also not actionable, because it names no
remedy. It is the worst of both: unbranchable by callers and useless to users.

The fix

classifySimulationFailure returns { category, message, diagnostic }.

The category is the stable half — callers that need to retry, prompt, or
escalate branch on it. Six categories:

Category Meaning
contract_error the contract reverted
insufficient_funds balance or resource budget too low
sequence_mismatch account has newer activity than the tx accounted for
signature_mismatch not signed by the submitting account
network RPC unreachable or deferring
unknown not recognised

Anything unrecognised lands in unknown rather than being guessed into an
actionable bucket. That restraint is the point: a failure that is wrongly
classified as retryable gets retried, which is exactly how a stuck submission
turns into a duplicate payment.

Rule order is load-bearing. tx_insufficient_fee also matches the generic
insufficient|overrun pattern, so specificity is checked first — the two need
different advice (one is about the account's sequence, the other its balance).

A contract revert still prefers its specific SplitError message over the generic
per-category text, preserving existing parseContractError output where it
already existed.

Sanitising the diagnostic

errorResult is commonly Error(Contract, #2, <base64 XDR>), and XDR carries
contract arguments — for this contract, amounts and addresses.

Stripping at the reporting layer alone would still leave the payload inside the
Error message that the UI renders. So sanitisation happens at classification:
everything from the first comma onward is dropped, and the remainder is truncated
to 120 characters.

Verified against a payload-bearing sample — no XDR survives into the diagnostic.

Preserving prior behaviour where it is better

An unmapped failure returns the original text, not the generic
"could not be prepared" message. Replacing a specific, accurate message with a
vaguer one would be a regression, and there is no reason to do it — the generic
text exists for the failures that genuinely have no better text.


#1496 — fee-bump transaction behavior tests

Both sendSorobanTransaction and submitSorobanTransactionAndPoll accept a
FeeBumpTransaction, but the existing suite only ever built a plain
Transaction. A fee bump is a different envelope — its hash covers the inner
transaction and the fee-bump operation, and it can be rejected for reasons the
inner transaction never sees. "The fee-bump path shares the inner path" was an
assumption nobody had tested.

The new suite covers the accept / defer / reject cases, the poll path, and
malformed envelopes.

The load-bearing test is that the hash tracked is the envelope's, not the inner
transaction's
. They differ, and tracking the inner hash would let the UI unblock
on a transaction the RPC never accepted — precisely the failure mode Issue #1494
exists to prevent.


Files changed

File Issue
frontend/src/lib/contract-errors.ts #1495
frontend/src/lib/soroban-transaction.ts #1495 (wiring)
frontend/src/lib/soroban-transaction-fee-bump.test.ts #1496

Verification

Dependencies are not installed and no tests were executed, per the task
constraint. Verified by:

  • Re-deriving the classification and sanitisation logic into a standalone
    harness, run against 14 real error strings. All 14 classified correctly; the
    XDR-bearing sample leaked nothing; truncation held at 120 chars.
  • isUnconfirmedSubmission keys off error.name === "TimeoutError", which is
    what the NOT_FOUND branch sets — confirming the unconfirmed-fee-bump test is
    asserting the right property.
  • Checking all three existing call sites that assert on "tx_bad_seq"
    (soroban-transaction.test.ts:55, observability.test.ts:86). All use
    toThrow(string), which is a substring match, and the new message still
    contains tx_bad_seq — so the submissionErrorMessage change breaks none of
    them.
  • Building each fee-bump envelope once per test. setTimeout(30) makes a
    time-bounds envelope unique per call, so a second feeBumpTransaction() would
    hash to a different value and make the hash assertions fail spuriously.

Closes #1473
Closes #1491
Closes #1495
Closes #1496

…mp paths

Split-Naira#1495 — a rejected submission surfaced submit.errorResult.toString() verbatim, so
the user saw a host protocol string like 'HostError: Error(Contract, #1)'. That
text is not stable across SDK or host versions, so nothing could branch on it,
and it names no remedy, so nothing could be acted on either.

Adds classifySimulationFailure, returning { category, message, diagnostic }.
The category is the stable half — callers that need to retry, prompt or escalate
branch on it. Six categories, with anything unrecognised landing in 'unknown'
rather than being guessed into an actionable bucket: an unrecognised failure must
not be told to retry when retrying is what failed.

The rules are ordered most-specific-first because the order is load-bearing:
tx_insufficient_fee matches the generic insufficient/overrun rule, and it needs
different advice from a budget overrun. A contract revert still prefers its
specific SplitError message over the generic per-category text, so the existing
parseContractError output is preserved where it already existed.

The diagnostic drops everything from the first comma onward. errorResult is
commonly 'Error(Contract, #2, <base64 XDR>)' and XDR carries contract arguments
— amounts and addresses. Sanitizing only at the reporting layer would still leave
the payload in the Error message the UI renders, so it is stripped at
classification. Verified against a payload-bearing sample: no XDR survives into
the diagnostic, and truncation holds at 120 chars.

Unmapped failures keep returning the original text, matching prior behaviour
rather than replacing a specific message with a generic one.

Split-Naira#1496 — both entry points accept a FeeBumpTransaction but the suite only ever
built a plain Transaction, so 'the fee-bump path shares the inner path' was an
untested assumption. Adds coverage for the accept/defer/reject cases, the poll
path, and malformed envelopes.

The load-bearing test is that the hash tracked is the envelope's, not the inner
transaction's: they differ, and tracking the inner hash would let the UI unblock
on a transaction the RPC never accepted — the exact failure Split-Naira#1494 exists to
prevent.

Not run: dependencies not installed and no tests executed, per the task
constraint. Verified the classification and sanitisation logic by re-deriving it
into a standalone harness run against real error strings; verified
isUnconfirmedSubmission keys off error.name === 'TimeoutError', which is what the
NOT_FOUND branch sets; verified no existing assertion breaks, since all three
call sites assert on the substring 'tx_bad_seq' and the new message still
contains it.
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@ridercoder4 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@SheyeJDev

Copy link
Copy Markdown
Contributor

LGTM

@SheyeJDev
SheyeJDev merged commit 910a9be into Split-Naira:main Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants