From a37eac686f6fff8bc9f9357df4feedf7b99c8d5a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 6 Nov 2026 18:01:09 -0800 Subject: [PATCH] Show why a recipient can't receive a token When an engine refuses a send because the recipient can't receive the asset yet, it throws a RecipientNotReadyError naming the reason (account not activated, token not added, issuer authorization missing) and the currency code. The send scene mapped only insufficient-funds and pending-funds errors to readable cards, so this refusal showed the generic "check your network connection" card. Map it to localized text naming what the recipient has to do, falling back to the engine's message for a reason the app doesn't know. --- CHANGELOG.md | 1 + src/__tests__/util/recipientNotReady.test.ts | 57 ++++++++++++++++++++ src/components/scenes/SendScene2.tsx | 9 ++++ src/locales/en_US.ts | 6 +++ src/locales/strings/enUS.json | 3 ++ src/util/recipientNotReady.ts | 53 ++++++++++++++++++ 6 files changed, 129 insertions(+) create mode 100644 src/__tests__/util/recipientNotReady.test.ts create mode 100644 src/util/recipientNotReady.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea74eb21598..2bf66ca9477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- changed: Send scene says why a recipient can't receive a token instead of showing a network error - fixed: Exchange info, gift card info, Houdini chain, and Zcash migration status refreshes running twice per interval ## 4.53.0 (staging) diff --git a/src/__tests__/util/recipientNotReady.test.ts b/src/__tests__/util/recipientNotReady.test.ts new file mode 100644 index 00000000000..39d45afe0ea --- /dev/null +++ b/src/__tests__/util/recipientNotReady.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from '@jest/globals' + +import { I18nError } from '../../components/cards/ErrorCard' +import { describeRecipientNotReady } from '../../util/recipientNotReady' + +/** The error an engine throws, as it arrives through the core bridge. */ +const makeRefusal = (reason: string): Error => { + const error = new Error('The engine message') + return Object.assign(error, { + name: 'RecipientNotReadyError', + reason, + currencyCode: 'USDC' + }) +} + +describe('describeRecipientNotReady', () => { + it('says the recipient account is not activated', () => { + const error = describeRecipientNotReady( + makeRefusal('accountNotActivated'), + 'Stellar' + ) + expect(error).toBeInstanceOf(I18nError) + expect(error?.title).toBe('Transaction Failure') + expect(error?.message).toBe( + "The recipient's Stellar account isn't activated, so it can't receive USDC yet." + ) + }) + + it('says the recipient has not added the token', () => { + expect( + describeRecipientNotReady(makeRefusal('tokenNotAdded'), 'Stellar') + ?.message + ).toBe( + "The recipient hasn't added USDC to their Stellar wallet, so it can't receive USDC yet." + ) + }) + + it('says the issuer has not authorized the recipient', () => { + expect( + describeRecipientNotReady(makeRefusal('notAuthorized'), 'Stellar') + ?.message + ).toBe("The USDC issuer hasn't authorized the recipient to receive USDC.") + }) + + it("shows the engine's message for a reason it does not know", () => { + expect( + describeRecipientNotReady(makeRefusal('somethingNew'), 'Stellar')?.message + ).toBe('The engine message') + }) + + it('leaves other errors alone', () => { + expect( + describeRecipientNotReady(new Error('Network down'), 'Stellar') + ).toBeUndefined() + expect(describeRecipientNotReady(undefined, 'Stellar')).toBeUndefined() + }) +}) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 16950034b4c..c7be074f458 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -124,6 +124,7 @@ import { parseOwnNetworkPayment, peekPaymentUri } from '../../util/paymentUri' +import { describeRecipientNotReady } from '../../util/recipientNotReady' import { disableAssetsCover, getStealthDisableAssets, @@ -3987,6 +3988,14 @@ const SendComponent: React.FC = props => { ) } + // The recipient can't receive this asset yet, so say what they need + // to do instead of blaming the network: + const recipientNotReady = describeRecipientNotReady( + error, + coreWallet.currencyInfo.displayName + ) + if (recipientNotReady != null) error = recipientNotReady + // Only set hasPendingTx to true when pending tx error occurs; // don't clear it for other errors as it may have been legitimately // set by handleTxUpdate or updatePendingTxState diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4ba6461f43d..5c37dfe2e6a 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1781,6 +1781,12 @@ const strings = { send_address_expired_error_message: 'Payment Address Expired', send_funds_not_spendable_error_message: 'These funds are not spendable yet. Wait for the wallet to finish syncing, then try again.', + send_recipient_account_not_activated_2s: + "The recipient's %1$s account isn't activated, so it can't receive %2$s yet.", + send_recipient_token_not_added_2s: + "The recipient hasn't added %1$s to their %2$s wallet, so it can't receive %1$s yet.", + send_recipient_not_authorized_1s: + "The %1$s issuer hasn't authorized the recipient to receive %1$s.", // Request Scene request_balance: 'You have %s', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2263dcb9d22..fdfa1fc6a47 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1386,6 +1386,9 @@ "send_address_expire_title": "Payment Address Expiration Time", "send_address_expired_error_message": "Payment Address Expired", "send_funds_not_spendable_error_message": "These funds are not spendable yet. Wait for the wallet to finish syncing, then try again.", + "send_recipient_account_not_activated_2s": "The recipient's %1$s account isn't activated, so it can't receive %2$s yet.", + "send_recipient_token_not_added_2s": "The recipient hasn't added %1$s to their %2$s wallet, so it can't receive %1$s yet.", + "send_recipient_not_authorized_1s": "The %1$s issuer hasn't authorized the recipient to receive %1$s.", "request_balance": "You have %s", "exchange_same_address_upgrade": "Asset Upgrade", "exchange_generic_error_title": "Exchange Error", diff --git a/src/util/recipientNotReady.ts b/src/util/recipientNotReady.ts new file mode 100644 index 00000000000..dfff6c74e1e --- /dev/null +++ b/src/util/recipientNotReady.ts @@ -0,0 +1,53 @@ +import { asObject, asString, asValue } from 'cleaners' +import { sprintf } from 'sprintf-js' + +import { I18nError } from '../components/cards/ErrorCard' +import { lstrings } from '../locales/strings' + +/** + * An engine refused a spend because the recipient can't receive the asset + * yet: an account that isn't activated, a token the recipient hasn't added, + * or an issuer that hasn't authorized them. + */ +const asRecipientNotReadyError = asObject({ + name: asValue('RecipientNotReadyError'), + reason: asString, + currencyCode: asString, + message: asString +}) + +/** + * Turns an engine's recipient refusal into a localized error the send scene + * can show, naming what the recipient has to do. Returns undefined for any + * other error. + */ +export function describeRecipientNotReady( + error: unknown, + chainName: string +): I18nError | undefined { + let refusal: ReturnType + try { + refusal = asRecipientNotReadyError(error) + } catch (cleanerError: unknown) { + return undefined + } + + const { currencyCode, reason } = refusal + const message = + reason === 'accountNotActivated' + ? sprintf( + lstrings.send_recipient_account_not_activated_2s, + chainName, + currencyCode + ) + : reason === 'tokenNotAdded' + ? sprintf( + lstrings.send_recipient_token_not_added_2s, + currencyCode, + chainName + ) + : reason === 'notAuthorized' + ? sprintf(lstrings.send_recipient_not_authorized_1s, currencyCode) + : refusal.message + return new I18nError(lstrings.transaction_failure, message) +}