Skip to content

fix(frontend): friendly toast when chain-fusion signer is unavailable#13145

Draft
sbpublic wants to merge 19 commits into
feat/frontend/track-cfs-sign-eventfrom
fix/frontend/signer-unavailable-toast
Draft

fix(frontend): friendly toast when chain-fusion signer is unavailable#13145
sbpublic wants to merge 19 commits into
feat/frontend/track-cfs-sign-eventfrom
fix/frontend/signer-unavailable-toast

Conversation

@sbpublic

@sbpublic sbpublic commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Second of two PRs from the spec. During a recent incident the chain-fusion signer could not sign for any user because OISY's backend ran out of TCycles. Users saw a raw, alarming toast:

Something went wrong while sending the transaction. / Ledger error: {"InsufficientFunds":{"balance":"45738950"}}

That number is OISY's backend cycles balance, not the user's token balance — but it reads like a wallet error. This PR replaces that raw leak with a calm message across every affected flow, and is stacked on #13144 (it reuses the isSignerCanisterPaymentError guard added there).

It also distinguishes a second payment failure mode: an exhausted per-user ICRC-2 allowance towards the signer. That is a per-user signing limit, not a wallet-wide outage, so an outage-style "it's on us, we're fixing it" tone would be wrong — it gets its own message.

Spec: docs/ai/spec-driven-development/specs/2026-06-18-impr-cfs-signing-error-toast-and-cfs-sign-event.md (Part A).

Note: base is feat/frontend/track-cfs-sign-event (PR #13144). GitHub will retarget this to main once that merges. Review only the commits on this branch (everything layered on top of the #13144 base).

Changes

  • Add a new top-level sign namespace i18n (distinct from the dApp-signer signer namespace), translated into all supported languages (the 13 in the Languages enum; ar is unused → falls back to English). Two keys:
    • sign.error.unavailable — wallet-wide outage (e.g. backend out of cycles).
    • sign.error.limit_reached — per-user signing limit (exhausted allowance).
  • signer.errors.ts: SignerCanisterPaymentError now records whether the underlying ledger error was a nested InsufficientAllowance, exposed via a new isSignerCanisterAllowanceError guard (alongside isSignerCanisterPaymentError).
  • Add toastsSignerUnavailableOr({ err, fallback }) in toasts.store.ts: for a SignerCanisterPaymentError it shows sign.error.limit_reached (allowance) or sign.error.unavailable (any other payment failure) — without appending the raw ledger text (still consoleError-logged); otherwise it defers to the caller's fallback.
  • Route the user-facing signer-error catch sites through it:
    • ETH send (token + NFT), BTC send, SOL send (keeping the mapSolanaErrorMsg fallback).
    • The central WalletConnect execute() wrapper in lib/services/wallet-connect.services.ts — covers WC personal_sign / eth_sign / sends / signPsbt for all chains in one place.
    • OpenCryptoPay's inline failedPaymentError (friendly/limit message for the payment cases; tracking keeps the raw message).
  • Map Schnorr signer errors (getSchnorrPublicKey / signWithSchnorr): they previously threw the raw candid Err (stale TODO). The signer returns EthAddressError for Schnorr too, so they now route through the existing mapSignerCanisterGetEthAddressError — a PaymentError becomes a SignerCanisterPaymentError, so the new messages reach SOL signing like the ETH/BTC flows.
  • Genuine user errors (invalid address, insufficient token balance, gas) keep their existing specific toasts.
  • Add a self-describing, always-present event_code (success, cfs_payment_failed_backend_out_of_cycles, cfs_payment_failed_user_allowance_exhausted, cfs_generic_error) so "all payment failures" is filterable from one property (the cfs_payment_failed_ prefix), and move the event to a dedicated event_context=cfs (not the dApp-signer's signer). event_code (cause) and result_error_severity (alerting) are derived from one classification.
  • On the allowance case, fire a best-effort background allow_signing re-grant (replenishSignerAllowance, new signer-allowance.services.ts) so the next attempt can succeed without a page reload -- non-fatal (never signs out), single-flight, no auto-retry. The allow_signing rate limiter stays the real per-user cap.
  • Tag the cfs_sign event severity for the allowance case: result_error_severity = major (a per-user limit working as intended) instead of blocker, so it is not over-reported as an incident on dashboards (backend payment failures stay blocker, other signer errors critical).
  • Document the behavior in PRODUCT.md.

Tests

  • signer.errors.spec.ts: isSignerCanisterAllowanceError is true for Ledger{Withdraw,Transfer}FromError → InsufficientAllowance and false for the funds/other payment variants and non-payment errors; an allowance error is still an isSignerCanisterPaymentError.
  • toasts.store.spec.ts: shows sign.error.unavailable for a non-allowance payment error, sign.error.limit_reached for an allowance error (neither leaks the raw ledger detail), and defers to the fallback for non-payment / nullish errors.
  • signer.canister.spec.ts: getSchnorrPublicKey / signWithSchnorr map a returned PaymentError to a SignerCanisterPaymentError.
  • Existing EthSendTokenWizard / BtcSendTokenWizard / wallet-connect.services specs still pass.
  • prettier, eslint --max-warnings 0, i18n:types + i18n:keys, and svelte-check (no problems in changed files) pass.

Divergence from the spec

The spec (docs/ai/spec-driven-development/specs/2026-06-18-impr-cfs-signing-error-toast-and-cfs-sign-event.md) has been updated in this PR to match what shipped; the items below summarise the notable deviations from the original draft.

  • Per-user allowance message added beyond the original spec: an InsufficientAllowance payment error is a per-user signing limit (the caller's ICRC-2 allowance towards the signer is exhausted), distinct from the wallet-wide cycles outage, so it gets sign.error.limit_reached rather than the outage message. The distinction is frontend-only — the variant is already present in the signer's candid, so no CFS change is needed.
  • Swap flows left unchanged: SwapEthWizard (and siblings) already surface a generic swap.error.failed_unexpectedly message — the raw error only goes to tracking, never the UI — so there was no raw-text leak to fix. EIP-2612 permits surface through these same swap/approve catches, so they're covered by the existing generic message.
  • WalletConnectReview.svelte not touched: its catch handles session approve/reject, which does not call a signer method, so it can't receive a SignerCanisterPaymentError.

🤖 Generated with Claude Code — model: Claude Opus 4.8 (claude-opus-4-8)

sbpublic and others added 4 commits June 18, 2026 16:24
A generic, calm message for chain-fusion signing outages, in a new top-level
`sign` namespace (distinct from the dApp-signer `signer` namespace). Reused
across sends and message-signing flows. Locale files synced (translations
follow); en.json is the source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Centralises the signer-outage detection: shows the calm sign.error.unavailable
toast (without appending the raw ledger error) when the error is a
SignerCanisterPaymentError, otherwise defers to the caller's fallback. The raw
error is still logged to the console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route the signer-error catch sites through toastsSignerUnavailableOr so the
backend-out-of-cycles outage shows the calm sign.error.unavailable message
instead of the raw 'Ledger error: …' text. Covers ETH/BTC/SOL sends, ETH NFT
send, WalletConnect signing (central execute() wrapper, all chains), and the
OpenCryptoPay inline failure. Genuine user errors keep their specific toasts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sbpublic and others added 3 commits June 19, 2026 07:36
A distinct message for the per-user signing-limit case (exhausted ICRC-2
allowance towards the signer), separate from the wallet-wide outage message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An exhausted ICRC-2 spender allowance is a per-user signing limit, not the
wallet-wide cycles outage, so the generic 'working on a fix' message is wrong
for it. Detect the nested InsufficientAllowance via a new
isSignerCanisterAllowanceError guard and show sign.error.limit_reached for that
case, keeping sign.error.unavailable for other payment failures. Applied in the
toast helper and the OpenCryptoPay inline failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent errors

getSchnorrPublicKey/signWithSchnorr threw the raw candid Err (stale TODO). The
signer returns EthAddressError for Schnorr too, so route it through the existing
mapSignerCanisterGetEthAddressError: a PaymentError now becomes a
SignerCanisterPaymentError, so the signer-unavailable / per-user-limit toast
reaches SOL signing like the ETH/BTC flows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sbpublic added a commit that referenced this pull request Jun 19, 2026
…simulator

Adds the 'allowance' mode (exhausted per-user ICRC-2 allowance) so the new
sign.error.limit_reached message can be reproduced alongside payment/internal/
signing. Merged latest PR #13145 so the limit_reached path is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sbpublic and others added 2 commits June 19, 2026 08:04
Bring the spec in line with what shipped: the per-user allowance distinction
(sign.error.limit_reached + isSignerCanisterAllowanceError), the as-shipped
catch-site wiring (central WC execute(), OpenCryptoPay; swaps/WalletConnectReview
deliberately not wired), the Schnorr error-mapping fix (new A5), and updated
acceptance criteria + tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…blocker

An exhausted-allowance payment error is a per-user limit working as intended, so
tagging it 'blocker' (like a real cycles outage) over-reports it on dashboards.
trackCfsSign now sets result_error_severity = major for the allowance case,
blocker for other backend payment failures, critical otherwise. Also expands the
spec Goal to capture the 'our fault' (cycles) vs 'as intended' (allowance)
distinction, and syncs PRODUCT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sbpublic

Copy link
Copy Markdown
Collaborator Author

Review — ✅ LGTM (toast half), and base #13144

Reviewed both PRs in this stack against the spec.

Base PR #13144 (tracking) — ✅ LGTM: all 11 paid signer calls wrapped, enums + severity added, withCfsSignTracking always re-throws, token_network derived, tests present. (Full notes on that PR.)

This PR #13145 (toast) — ✅ LGTM. Faithful to Part A, with two well-judged extensions.

Matches the spec:

  • New top-level sign namespace (not send.*), both keys with the agreed wording; all 14 locales + i18n.d.ts synced.
  • toastsSignerUnavailableOr omits the raw error so the scary Ledger error: … text is gone.
  • Wired into ETH (send + NFT catches), BTC, SOL, the central WalletConnect execute() wrapper (covers personal_sign/eth_sign for all chains in one place), and OpenCryptoPay's inline failure screen.

Extensions beyond the original spec — both good calls:

  • Allowance vs. outage split: SignerCanisterPaymentError.insufficientAllowance distinguishes an exhausted per-user ICRC-2 allowance (sign.error.limit_reached, severity major) from a wallet-wide cycles outage (sign.error.unavailable, severity blocker). Correctly detects nested InsufficientAllowance in the transfer/withdraw error.
  • Schnorr mapping: replaced the stale throw response.Err TODO with mapSignerCanisterGetEthAddressError, so SOL (Schnorr) signing failures actually become SignerCanisterPaymentError and reach the toast. Closes a real gap.

Verified the one deliberate skip: swaps/EIP-2612 permits show a static swap.error.failed_unexpectedly and only send the raw error to tracking — no raw-text leak there, so the skip is justified.

Non-blocking follow-up: during an outage a swap shows "swap failed unexpectedly" rather than the nicer "signing unavailable" message. Not a leak, just less specific — could route swaps through toastsSignerUnavailableOr later for consistency.

sbpublic and others added 8 commits June 19, 2026 09:52
…imit

On an exhausted-allowance error, fire a best-effort allow_signing re-grant
(replenishSignerAllowance) so the next signing attempt can succeed without a page
reload. It mirrors the boot loader's allow_signing call but is non-fatal (never
signs the user out) and single-flight. No auto-retry of the original operation;
the limit_reached message still shows. The allow_signing rate limit remains the
real per-user cap. Wired into the toast helper (sends + WalletConnect) and the
OpenCryptoPay catch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an always-present event_code that encodes the full outcome semantics, so
'all payment failures' is filterable from one property (shared cfs_payment_failed_
prefix) instead of OR-ing severities: success, cfs_payment_failed_backend_out_of_cycles,
cfs_payment_failed_user_allowance_exhausted, cfs_generic_error. event_code (cause)
and result_error_severity (alerting weight) are derived from one classification so
they never disagree. Also move the event to a dedicated event_context=cfs (not the
dApp-signer's 'signer'), settling the old naming collision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…toast' into fix/frontend/signer-unavailable-toast
unavailable: 'Signing is temporarily unavailable. Please try again shortly.'
limit_reached: 'You've reached your signing limit. Please try again shortly.'

Drops the chain list and the 'we're working on a fix' admission so the outage
message reads neutrally (not like an incident on our side); the two now share a
cadence and closing. Synced the quoted copy in spec + PRODUCT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual translations of the two signer toast strings (unavailable, limit_reached)
into every language in the Languages enum (cs, de, es, fr, hi, it, ja, ko-KR, pl,
pt, ru, vi, zh-CN). Arabic (ar.json) is not in the supported set, so it's left to
fall back to English. Formality matches each locale's existing tone (Sie/vous/
usted/você/Вы formal; it informal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sbpublic

Copy link
Copy Markdown
Collaborator Author

Follow-up — final design (Part A)

Two refinements since the review above — both good:

  • Neutral, chain-agnostic toast copy: sign.error.unavailable → "Signing is temporarily unavailable. Please try again shortly." and sign.error.limit_reached → "You've reached your signing limit. Please try again shortly." Avoids naming chains and avoids sounding like an incident on our side.
  • Background allowance re-grant (A6): on an allowance error, a best-effort replenishSignerAllowance() re-grants allow_signing — single-flight, non-fatal (never signs out), no auto-retry — so the next attempt usually succeeds without a reload, still bounded by the allow_signing rate limiter (the real per-user cap).

Still ✅ LGTM.

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.

1 participant