feat(payments): typed failure taxonomy, retry/backoff, and real UI wiring - #150
Merged
wumibals merged 1 commit intoAug 22, 2026
Conversation
…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
|
@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
approved these changes
Aug 22, 2026
wumibals
left a comment
Contributor
There was a problem hiding this comment.
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 —
AssetNotAllowedErrorbeing hardcoded for code 4 whenVaultL3/L6/L12'srelockraises the same code forNotYetMaturedis 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 againstpanic_with_error!thatVaultRouter's own code-7Unauthorizedvariant is never actually raised (so code 7 can safely meanAmountExceedsBalanceErroreverywhere it's actually seen) shows real diligence rather than a guess. - Computing the transaction hash locally before calling
sendTransaction, soAmbiguousSubmissionErrorstill 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
runPaymentSubmissionas 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_FOUNDfrom the existing confirmation-polling asretryable-with-new-intent(check state before resubmitting, don't trust local retry-exhaustion) rather than a definite failure is the right call givenwaitForTransactionalready 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Gives every payment operation a complete, typed failure taxonomy and well-defined retry/recovery behavior, per the issue.
SDK (
sdks/typescript)errors.ts: everyVaultError/VaultL3variant now has a typed error class (addsNotYetMaturedError,AmountExceedsBalanceError), plus network-level (RpcUnavailableError,RpcTimeoutError), wallet-level (WalletRejectedError,WalletDisconnectedError,WalletSigningFailedError), and ambiguous-submission (AmbiguousSubmissionError) errors. Every error now carries aretryClassification:'retryable-safely' | 'retryable-with-new-intent' | 'not-retryable', so callers branch on one typed decision instead of ad hoc checks.transactions.ts:AssetNotAllowedError, butVaultL3/L6/L12'srelockraises the same code forNotYetMatured.mapContractErrornow disambiguates by the method being simulated. Code 7 now maps toAmountExceedsBalanceErrorinstead of the genericVaultContractErrorfallback —VaultRouter's own code-7 variant (Unauthorized) is never actually raised anywhere in the contract (verified by grep againstpanic_with_error!call sites).utils.ts) for transient network/timeout failures ongetAccount/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.signer.signTransaction()failures (rejected vs. disconnected vs. unrecognized) instead of letting them propagate unclassified.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)app/src/lib/paymentFlow.ts:classifyPaymentError(shared by deposit and early-exit) andrunPaymentSubmission, 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/RpcTimeoutErrortreatment as the SDK, mirrored by convention (the app and SDK are independently-versioned packages with separate lockfiles — same pattern already used forIntentStatus/PaymentStatus).app/src/lib/wallet/errors.ts: wallet-level errors for the app layer.deposit/page.tsxandEarlyExitModal.tsxthrough the shared pipeline, including a non-blockingtoast.failed()notification —app/src/lib/toast.tsxalready existed but was never called from either flow.Scope notes
VaultErrorenums acrossvault_router/vault_l3/vault_l6/vault_l12already covered everything needed.placeholderSubmissionHash's existing comment). Wiring@yieldladder/sdkas a realappdependency needs pnpm workspace/build-artifact changes beyond this issue's scope — same reasoning the prior contributor already documented there.VaultRouter.withdraw's two-hop pattern (vault call, thentoken::Client::transfer) doesn't need a special "half-completed withdrawal" state — a single Soroban host-function invocation commits or reverts as a whole, soTransactionFailedErroralready covers it correctly. Documented inline on that error class.waitForTransaction/getTransactionpolling already re-queries chain state on each attempt and never reports a bareNOT_FOUNDas a confirmed failure (TransactionTimedOutError, notTransactionFailedError) — 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
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).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/changed).Closes #142