Skip to content

feat(payments): typed failure taxonomy, retry/backoff, and real UI wiring - #150

Merged
wumibals merged 1 commit into
LadderMine:mainfrom
devwums:feature/142-payment-failure-taxonomy-retry-backoff
Aug 22, 2026
Merged

feat(payments): typed failure taxonomy, retry/backoff, and real UI wiring#150
wumibals merged 1 commit into
LadderMine:mainfrom
devwums:feature/142-payment-failure-taxonomy-retry-backoff

Conversation

@devwums

@devwums devwums commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives every payment operation a complete, typed failure taxonomy and well-defined retry/recovery behavior, per the issue.

SDK (sdks/typescript)

  • errors.ts: every VaultError/VaultL3 variant now has a typed error class (adds NotYetMaturedError, AmountExceedsBalanceError), plus network-level (RpcUnavailableError, RpcTimeoutError), wallet-level (WalletRejectedError, WalletDisconnectedError, WalletSigningFailedError), and ambiguous-submission (AmbiguousSubmissionError) errors. Every error now carries a retryClassification: 'retryable-safely' | 'retryable-with-new-intent' | 'not-retryable', so callers branch on one typed decision instead of ad hoc checks.
  • transactions.ts:
    • Fixes a real mapping bug: contract error code 4 was always mapped to AssetNotAllowedError, but VaultL3/L6/L12's relock raises the same code for NotYetMatured. mapContractError now disambiguates by the method being simulated. Code 7 now maps to AmountExceedsBalanceError instead of the generic VaultContractError fallback — VaultRouter's own code-7 variant (Unauthorized) is never actually raised anywhere in the contract (verified by grep against panic_with_error! call sites).
    • Adds retry-with-backoff-and-jitter (utils.ts) for transient network/timeout failures on getAccount/simulateTransaction, bounded at 3 attempts before surfacing a typed RPC error. Never retries a normal response that just happens to encode a simulation error — only actual transport failures.
    • Classifies signer.signTransaction() failures (rejected vs. disconnected vs. unrecognized) instead of letting them propagate unclassified.
    • Computes the transaction hash locally before calling sendTransaction, so an ambiguous network failure during submission (AmbiguousSubmissionError) still carries a hash the caller can poll to resolve the ambiguity before deciding to retry.
  • index.ts: re-exports the full error taxonomy — previously these classes weren't part of the package's public API surface at all.

App (app)

  • New app/src/lib/paymentFlow.ts: classifyPaymentError (shared by deposit and early-exit) and runPaymentSubmission, a dependency-injected submit → sign → wait → classify orchestrator extracted out of both components' inline handlers. This is what makes the deposit/early-exit failure UI states reachable via real, injected failures in tests (paymentFlow.test.ts) rather than only a hardcoded/manual trigger — the issue's acceptance criterion.
  • app/src/services/rpc.ts: same retry-with-backoff + RpcUnavailableError/RpcTimeoutError treatment as the SDK, mirrored by convention (the app and SDK are independently-versioned packages with separate lockfiles — same pattern already used for IntentStatus/PaymentStatus).
  • New app/src/lib/wallet/errors.ts: wallet-level errors for the app layer.
  • Wires deposit/page.tsx and EarlyExitModal.tsx through the shared pipeline, including a non-blocking toast.failed() notification — app/src/lib/toast.tsx already existed but was never called from either flow.

Scope notes

  • No contract changes. The taxonomy work is entirely SDK/app-side — the existing VaultError enums across vault_router/vault_l3/vault_l6/vault_l12 already covered everything needed.
  • Real wallet signing / real transaction submission in the app remain the same deliberate placeholders as before (see placeholderSubmissionHash's existing comment). Wiring @yieldladder/sdk as a real app dependency needs pnpm workspace/build-artifact changes beyond this issue's scope — same reasoning the prior contributor already documented there.
  • Partial-completion edge case: VaultRouter.withdraw's two-hop pattern (vault call, then token::Client::transfer) doesn't need a special "half-completed withdrawal" state — a single Soroban host-function invocation commits or reverts as a whole, so TransactionFailedError already covers it correctly. Documented inline on that error class.
  • Network partition edge case: waitForTransaction/getTransaction polling already re-queries chain state on each attempt and never reports a bare NOT_FOUND as a confirmed failure (TransactionTimedOutError, not TransactionFailedError) — this PR classifies that as 'retryable-with-new-intent' so callers know to check state before resubmitting rather than trusting local retry-exhaustion.

Test plan

  • SDK: pnpm typecheck && pnpm build && pnpm test — 38/38 passing, including new coverage for the code-4/code-7 mapping fix, wallet-signing classification, ambiguous-submission hash, and RPC retry/backoff (success-after-retry, exhausted → typed error, non-network errors pass through unmodified).
  • App: pnpm typecheck && pnpm test && pnpm lint — 40/40 tests passing (only pre-existing, unrelated lint warnings in other files), including new coverage for the classifier and the full submit → sign → wait → classify pipeline with real injected wallet/network/on-chain failures.
  • Contracts: unaffected by this PR (no files under contracts/ changed).

Closes #142

…ring

## SDK (sdks/typescript)

- errors.ts: every VaultError/VaultL3 variant now has a typed error class
  (adds NotYetMaturedError, AmountExceedsBalanceError), plus network-level
  (RpcUnavailableError, RpcTimeoutError), wallet-level (WalletRejectedError,
  WalletDisconnectedError, WalletSigningFailedError), and ambiguous-
  submission (AmbiguousSubmissionError) errors. Every error now carries a
  `retryClassification`: 'retryable-safely' | 'retryable-with-new-intent' |
  'not-retryable', so the UI branches on one typed decision instead of ad
  hoc checks.
- transactions.ts:
  - Fixes a real mapping bug: code 4 was always mapped to
    AssetNotAllowedError, but VaultL3/L6/L12's `relock` raises the same
    code for NotYetMatured. mapContractError now disambiguates by the
    method being simulated. Code 7 now maps to AmountExceedsBalanceError
    instead of falling into the generic VaultContractError bucket
    (VaultRouter's own code-7 variant is never actually raised anywhere in
    the contract — verified by grep).
  - Adds retry-with-backoff-and-jitter (utils.ts) for transient network/
    timeout failures on getAccount/simulateTransaction, bounded at 3
    attempts before surfacing a typed RPC error. Never retries a normal
    response that just happens to encode a simulation error.
  - Classifies signer.signTransaction() failures (rejected vs disconnected
    vs unrecognized) instead of letting them propagate unclassified.
  - Computes the transaction hash locally before calling sendTransaction,
    so an ambiguous network failure during submission (AmbiguousSubmission
    Error) still carries a hash the caller can poll to resolve the
    ambiguity before deciding to retry.
- index.ts: re-exports the full error taxonomy (previously errors weren't
  part of the public API surface at all).

## App (app)

- New app/src/lib/paymentFlow.ts: classifyPaymentError (shared by deposit
  and early-exit) and runPaymentSubmission, a dependency-injected
  submit -> sign -> wait -> classify orchestrator extracted out of both
  components' inline handlers — this is what makes the failure UI states
  reachable via real, injected failures in tests (paymentFlow.test.ts)
  rather than only a hardcoded/manual trigger.
- app/src/services/rpc.ts: same retry-with-backoff + RpcUnavailableError/
  RpcTimeoutError treatment as the SDK, mirrored by convention (the app
  and SDK are independently-versioned packages with separate lockfiles —
  see existing IntentStatus/PaymentStatus comments for the same pattern).
- app/src/lib/wallet/errors.ts: wallet-level errors for the app layer.
- Wires deposit/page.tsx and EarlyExitModal.tsx through the shared
  pipeline, including a non-blocking toast.failed() notification
  (app/src/lib/toast.tsx existed but was never called from either flow).

## Scope notes

- No contract changes — the taxonomy work is entirely SDK/app-side; the
  existing VaultError enums already covered everything needed.
- Real wallet signing / real transaction submission in the app remain the
  same deliberate placeholders as before (see placeholderSubmissionHash's
  existing comment) — wiring `@yieldladder/sdk` as a real app dependency
  needs pnpm workspace/build-artifact changes beyond this issue's scope,
  same reasoning the prior contributor already documented.
- The "partial-completion" edge case in the issue (VaultRouter.withdraw's
  two-hop vault-then-token::Client::transfer) doesn't need special
  handling: a single Soroban host-function invocation commits or reverts
  as a whole, so TransactionFailedError already covers it correctly —
  documented inline on that error class.

Closes LadderMine#142
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

@devwums is attempting to deploy a commit to the wumibals' projects Team on Vercel.

A member of the Team first needs to authorize it.

@wumibals wumibals left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the typed failure taxonomy, retry/backoff, and real UI wiring PR (closes #142).

  • Every error carrying a retryClassification (retryable-safely/retryable-with-new-intent/not-retryable) is the right abstraction here — it moves the "should I retry, and how" decision out of ad hoc per-call-site logic and into one typed property callers branch on. That's the difference between a taxonomy that's actually usable and one that's just a longer list of error classes.
  • Good catch on the code-4 mapping bug — AssetNotAllowedError being hardcoded for code 4 when VaultL3/L6/L12's relock raises the same code for NotYetMatured is exactly the kind of cross-contract collision that's easy to miss and would have silently mis-classified a legitimate "too early to relock" error as an asset-allowlist rejection. Disambiguating by the method being simulated is the correct fix, and verifying via grep against panic_with_error! that VaultRouter's own code-7 Unauthorized variant is never actually raised (so code 7 can safely mean AmountExceedsBalanceError everywhere it's actually seen) shows real diligence rather than a guess.
  • Computing the transaction hash locally before calling sendTransaction, so AmbiguousSubmissionError still carries a hash the caller can poll, is the same pattern already validated in the SDK's earlier confirmation-lifecycle work (#149/#146-adjacent) — good consistency applying it here to submission ambiguity specifically, not just confirmation ambiguity.
  • Never retrying a normal response that just encodes a simulation error (only actual transport failures) is an important distinction — conflating the two would mean silently retrying a call that's going to fail identically every time, burning attempts on a deterministic rejection instead of surfacing it immediately.
  • Extracting runPaymentSubmission as a dependency-injected orchestrator is what actually makes the acceptance criterion achievable — reachable failure-UI states via real injected failures in tests, not a hardcoded/manual trigger. That's a meaningfully more honest test than what a lot of "error handling" PRs settle for.
  • The partial-completion reasoning for VaultRouter.withdraw's two-hop pattern (a single Soroban host-function invocation commits or reverts as a whole, so no special half-completed state is needed) is correct and worth having documented inline rather than left as an assumption someone has to rediscover.
  • Classifying a bare NOT_FOUND from the existing confirmation-polling as retryable-with-new-intent (check state before resubmitting, don't trust local retry-exhaustion) rather than a definite failure is the right call given waitForTransaction already re-queries chain state on each attempt.
  • Test coverage is thorough and actually run: 38/38 SDK tests including the code-4/code-7 fix and RPC retry/backoff edge cases (success-after-retry, exhaustion, non-network-errors-pass-through), 40/40 app tests including the full submit → sign → wait → classify pipeline with real injected failures.

CI is green across Soroban contracts, Next.js dashboard, and TypeScript SDK. Vercel's FAILURE is the usual unauthorized deployment integration link, unrelated to the code.

Approving — careful, well-verified error taxonomy work with a couple of genuinely valuable bug fixes along the way.

@wumibals
wumibals merged commit fcdcdc4 into LadderMine:main Aug 22, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments 5/8: Failure taxonomy and blockchain/network-failure handling with retry/backoff

2 participants