diff --git a/__tests__/confirmTxPage.test.js b/__tests__/confirmTxPage.test.js
index 0c63a245..8c703c8a 100644
--- a/__tests__/confirmTxPage.test.js
+++ b/__tests__/confirmTxPage.test.js
@@ -23,6 +23,8 @@ jest.mock('../app/constants', () => {
return {
...theme,
CENTER: {},
+ ICONS: {},
+ USDB_TOKEN_ID: 'usdb-token-id',
};
});
@@ -35,6 +37,22 @@ jest.mock('react-native-email-link', () => ({
openComposer: jest.fn(),
}));
+// Pulled in transitively by the recipient card. Ships untranspiled ESM.
+jest.mock('expo-image', () => ({ Image: () => null }));
+jest.mock('react-native-country-flag', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
+// The recipient pill's labels come from `i18next.t` directly (recipientCard is
+// shared with the pre-send screen, which is not a hook context).
+jest.mock('i18next', () => ({
+ __esModule: true,
+ default: {
+ t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
+ },
+}));
+
jest.mock('../app/functions', () => ({
copyToClipboard: jest.fn(),
}));
@@ -49,11 +67,6 @@ jest.mock('../app/functions/lrc20/formatTokensBalance', () => ({
default: (amount, decimals) => `tokens-${amount}-${decimals}`,
}));
-jest.mock('../app/functions/displayCorrectDenomination', () => ({
- __esModule: true,
- default: ({ amount }) => `denom-${amount}`,
-}));
-
jest.mock('../app/functions/customUUID', () => ({
__esModule: true,
default: () => 'uuid',
@@ -78,6 +91,7 @@ jest.mock('../app/functions/lnurl', () => ({
jest.mock('../app/functions/sendBitcoin/getPhonePaymentAddress', () => ({
canonicalizePhonePaymentAddress: value => value,
+ getPhonePaymentDisplay: () => null,
}));
jest.mock('../app/hooks/themeColors', () => () => ({
@@ -101,19 +115,6 @@ jest.mock('../context-store/appStatus', () => ({
useAppStatus: () => ({ screenDimensions: { width: 400, height: 800 } }),
}));
-jest.mock('../context-store/context', () => ({
- useGlobalContextProvider: () => ({
- masterInfoObject: {
- fiatCurrency: 'USD',
- userBalanceDenomination: 'sats',
- },
- }),
-}));
-
-jest.mock('../context-store/nodeContext', () => ({
- useNodeContext: () => ({ fiatStats: { coin: 'USD', value: 100000000 } }),
-}));
-
jest.mock('../context-store/globalContacts', () => ({
useGlobalContactsInfo: () => ({ decodedAddedContacts: [] }),
}));
@@ -189,13 +190,24 @@ function balanceInputProps(renderer) {
return nodes.length ? nodes[0].props.componentProps : null;
}
+function renderedText(renderer) {
+ const RN = require('react-native');
+ return renderer.root
+ .findAllByType(RN.Text)
+ .flatMap(node => node.props.children)
+ .filter(child => typeof child === 'string');
+}
+
function satTextProps(renderer) {
const nodes = renderer.root.findAllByProps({ testID: 'formatted-sat-text' });
return nodes.length ? nodes[0].props.componentProps : null;
}
+// paymentType lives at the top level of a transaction, not in `details` — the
+// recipient pill reads it from there.
const successfulOutgoingTx = {
- details: { amount: 1500, direction: 'OUTGOING', paymentType: 'lightning' },
+ paymentType: 'lightning',
+ details: { amount: 1500, direction: 'OUTGOING' },
};
const fiatPaymentDisplay = {
@@ -256,7 +268,7 @@ describe('ConfirmTxPage amount rendering', () => {
expect(props.forceCurrency).toBe('EUR');
});
- test('uses token metadata for an LRC20 send rendered through FormattedBalanceInput', async () => {
+ test('uses the send screen ticker for an LRC20 send rendered through FormattedBalanceInput', async () => {
mockSparkInformation = {
tokens: {
'token-id': { tokenMetadata: { tokenTicker: 'USDB', decimals: 6 } },
@@ -272,7 +284,12 @@ describe('ConfirmTxPage amount rendering', () => {
},
},
displayAmount: '12.34',
- paymentDisplay: { denomination: 'fiat', forceCurrency: 'USD', forceFiatStats: null },
+ displayTokenTicker: 'USDB',
+ paymentDisplay: {
+ denomination: 'fiat',
+ forceCurrency: 'USD',
+ forceFiatStats: null,
+ },
});
const props = balanceInputProps(renderer);
@@ -280,4 +297,68 @@ describe('ConfirmTxPage amount rendering', () => {
expect(props.customCurrencyCode).toBe('USDB');
expect(props.maxDecimals).toBe(6);
});
+
+ test('keeps the fiat display for a USDB-funded send the send screen showed as fiat', async () => {
+ mockSparkInformation = {
+ tokens: {
+ 'token-id': { tokenMetadata: { tokenTicker: 'USDB', decimals: 6 } },
+ },
+ };
+ const renderer = await renderConfirm({
+ transaction: {
+ details: {
+ amount: 5000,
+ direction: 'OUTGOING',
+ isLRC20Payment: true,
+ LRC20Token: 'token-id',
+ },
+ },
+ displayAmount: '12.34',
+ // no displayTokenTicker: the send screen labelled this one "$"
+ paymentDisplay: {
+ denomination: 'fiat',
+ forceCurrency: 'USD',
+ forceFiatStats: null,
+ },
+ });
+
+ const props = balanceInputProps(renderer);
+ expect(props).not.toBeNull();
+ expect(props.customCurrencyCode).toBeFalsy();
+ expect(props.forceCurrency).toBe('USD');
+ expect(props.maxDecimals).toBe(2);
+ });
+});
+
+describe('ConfirmTxPage recipient pill', () => {
+ beforeEach(() => {
+ mockSparkInformation = { tokens: {} };
+ });
+
+ test('names the funding asset for a bitcoin-funded lightning send', async () => {
+ const renderer = await renderConfirm({ transaction: successfulOutgoingTx });
+
+ expect(renderedText(renderer)).toContain(
+ 'wallet.sendPages.sendPaymentScreen.lightningPayment',
+ );
+ });
+
+ test('names the dollar balance for a USD-funded bolt11 (recorded as spark)', async () => {
+ const renderer = await renderConfirm({
+ transaction: {
+ paymentType: 'spark',
+ details: {
+ amount: 1500,
+ direction: 'OUTGOING',
+ isLRC20Payment: true,
+ LRC20Token: 'usdb-token-id',
+ destinationChain: 'lightning',
+ },
+ },
+ });
+
+ expect(renderedText(renderer)).toContain(
+ 'wallet.sendPages.sendPaymentScreen.dollarPayment',
+ );
+ });
});
diff --git a/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js b/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js
index e6c588e1..aa09138b 100644
--- a/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js
+++ b/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js
@@ -4,6 +4,7 @@ import getPhonePaymentAddress, {
getPhonePaymentCountry,
canonicalizePhonePaymentAddress,
getPhonePostProvider,
+ getPhonePaymentDisplay,
fetchPhonePaymentInvoice,
PROVIDER_COUNTRY_CURRENCY,
} from '../../../app/functions/sendBitcoin/getPhonePaymentAddress';
@@ -335,3 +336,31 @@ describe('fetchPhonePaymentInvoice', () => {
await expect(fetchPhonePaymentInvoice(args)).rejects.toThrow();
});
});
+
+describe('getPhonePaymentDisplay', () => {
+ it('returns iso code + international-formatted number for a KE provider address', () => {
+ expect(getPhonePaymentDisplay(KE)).toEqual({
+ isoCode: 'KE',
+ formatted: '+254 717 252303',
+ });
+ });
+
+ it('formats a ZM address whose local part is national (0977…)', () => {
+ const res = getPhonePaymentDisplay('0977123456@bitzed.xyz');
+ expect(res.isoCode).toBe('ZM');
+ expect(res.formatted.startsWith('+260')).toBe(true);
+ });
+
+ it('handles a POST-provider (Burundi) address', () => {
+ expect(getPhonePaymentDisplay('25779561234@exchanger.mysatoshis.bi').isoCode).toBe('BI');
+ });
+
+ it('returns null for a non-phone lightning address', () => {
+ expect(getPhonePaymentDisplay('satoshi@walletofsatoshi.com')).toBeNull();
+ });
+
+ it('returns null for non-string / malformed input', () => {
+ expect(getPhonePaymentDisplay(undefined)).toBeNull();
+ expect(getPhonePaymentDisplay('no-at-sign')).toBeNull();
+ });
+});
diff --git a/__tests__/recipientCard.test.js b/__tests__/recipientCard.test.js
new file mode 100644
index 00000000..4f28461e
--- /dev/null
+++ b/__tests__/recipientCard.test.js
@@ -0,0 +1,164 @@
+// The recipient pill names AND illustrates the asset that LEFT the wallet, not the
+// rail it took — no lightning bolts or spark logos for bitcoin/dollar sends. USDB
+// rides the same LRC20 rails as any other token, so the token id is the only thing
+// separating "Dollar payment" from "Token payment" — these assertions are what stops
+// a dollar-funded send from reading "Bitcoin payment" or showing a bolt again.
+
+const USDB = 'usdb-token-id';
+
+jest.mock('expo-image', () => ({ Image: () => null }));
+jest.mock('react-native-country-flag', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../app/constants', () => ({
+ COLORS: {},
+ ICONS: {},
+ USDB_TOKEN_ID: 'usdb-token-id',
+}));
+jest.mock(
+ '../app/components/admin/homeComponents/contacts/internalComponents/profileImage',
+ () => ({ __esModule: true, default: () => null }),
+);
+jest.mock('i18next', () => ({
+ __esModule: true,
+ default: {
+ t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
+ },
+}));
+
+const {
+ resolveRecipientDisplay,
+} = require('../app/components/admin/homeComponents/sendBitcoin/components/recipientCard');
+
+const LIGHTNING = 'wallet.sendPages.sendPaymentScreen.lightningPayment';
+const DOLLAR = 'wallet.sendPages.sendPaymentScreen.dollarPayment';
+const TOKEN = 'wallet.sendPages.sendPaymentScreen.tokenPayment';
+
+const tx = (paymentType, details = {}) => ({ paymentType, details });
+
+describe('resolveRecipientDisplay payment asset label', () => {
+ it('labels a bitcoin-funded lightning send "Bitcoin payment"', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('lightning', { isLRC20Payment: false }),
+ });
+ expect(resolved).toMatchObject({
+ kind: 'asset',
+ asset: 'bitcoin',
+ displayName: LIGHTNING,
+ });
+ });
+
+ it('labels a dollar-funded lightning send "Dollar payment"', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('lightning', {
+ isLRC20Payment: true,
+ LRC20Token: USDB,
+ }),
+ });
+ // Asset, not rail: dollars over lightning is a dollar icon, never a bolt.
+ expect(resolved).toMatchObject({
+ kind: 'asset',
+ asset: 'dollar',
+ displayName: DOLLAR,
+ });
+ });
+
+ it('labels a dollar-funded spark send "Dollar payment"', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
+ });
+ expect(resolved).toMatchObject({
+ kind: 'asset',
+ asset: 'dollar',
+ displayName: DOLLAR,
+ });
+ });
+
+ it('shows the dollar icon for a USD-funded bolt11 (recorded as spark)', () => {
+ // payments.js writes this shape: the swap leaves as USDB over spark rails,
+ // but the destination is a bolt11. Either way the user sent dollars.
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('spark', {
+ isLRC20Payment: true,
+ LRC20Token: USDB,
+ destinationChain: 'lightning',
+ }),
+ });
+ expect(resolved).toMatchObject({
+ kind: 'asset',
+ asset: 'dollar',
+ displayName: DOLLAR,
+ });
+ });
+
+ it('labels a non-USDB LRC20 send "Token payment" and keeps the spark logo', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('spark', {
+ isLRC20Payment: true,
+ LRC20Token: 'some-other-token-id',
+ }),
+ });
+ expect(resolved).toMatchObject({ kind: 'spark', displayName: TOKEN });
+ });
+
+ it('labels a bitcoin-funded spark send "Bitcoin payment"', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('spark', { isLRC20Payment: false, LRC20Token: '' }),
+ });
+ expect(resolved).toMatchObject({
+ asset: 'bitcoin',
+ displayName: LIGHTNING,
+ });
+ });
+
+ it('labels an on-chain send "Bitcoin payment"', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('bitcoin'),
+ });
+ expect(resolved).toMatchObject({
+ asset: 'bitcoin',
+ displayName: LIGHTNING,
+ });
+ });
+
+ it('shows the dollar icon for a dollar-funded on-chain send', () => {
+ const resolved = resolveRecipientDisplay({
+ transaction: tx('bitcoin', { isLRC20Payment: true, LRC20Token: USDB }),
+ });
+ expect(resolved).toMatchObject({ asset: 'dollar', displayName: DOLLAR });
+ });
+
+ it('names the asset + chain for an external stablecoin, not the spark type', () => {
+ const resolved = resolveRecipientDisplay({
+ // A stablecoin send is also paymentType 'spark' with LRC20Token USDB, so
+ // this only passes while the stablecoin check runs ahead of both.
+ transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
+ stablecoinInfo: { asset: 'USDC', label: 'Base' },
+ });
+ expect(resolved.kind).toBe('stablecoin');
+ expect(resolved.displayName).toBe(
+ 'screens.inAccount.confirmTxPage.stablecoinDesc:{"asset":"USDC","chain":"Base"}',
+ );
+ });
+
+ it('prefers a named recipient over any asset label', () => {
+ const resolved = resolveRecipientDisplay({
+ contactInfo: { name: 'Satoshi' },
+ transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
+ });
+ expect(resolved).toMatchObject({ kind: 'contact', displayName: 'Satoshi' });
+ });
+
+ it('returns null when there is nothing to name', () => {
+ expect(resolveRecipientDisplay({})).toBeNull();
+ });
+
+ it('falls through a contact with no name and no image instead of an empty pill', () => {
+ const resolved = resolveRecipientDisplay({
+ contactInfo: { uuid: 'abc' },
+ transaction: tx('bitcoin'),
+ });
+ expect(resolved).toMatchObject({ kind: 'asset', asset: 'bitcoin' });
+ });
+});
diff --git a/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js b/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js
index c9a9b5e7..97c5b61d 100644
--- a/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js
+++ b/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js
@@ -1,8 +1,7 @@
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useMemo } from 'react';
-import { Image } from 'expo-image';
import { ThemeText } from '../../../../../functions/CustomElements';
-import { CENTER, COLORS, FONT, ICONS, SIZES } from '../../../../../constants';
+import { CENTER, FONT, SIZES } from '../../../../../constants';
import GetThemeColors from '../../../../../hooks/themeColors';
import formatSparkPaymentAddress from '../functions/formatSparkPaymentAddress';
import { useNavigation } from '@react-navigation/native';
@@ -10,24 +9,14 @@ import { InputTypes } from 'bitcoin-address-parser';
import ContactProfileImage from '../../contacts/internalComponents/profileImage';
import normalizeLNURLAddress from '../../../../../functions/lnurl/normalizeLNURLAddress';
import ProfileImageRow from '../../contacts/internalComponents/profileImageRow';
-import ThemeIcon from '../../../../../functions/CustomElements/themeIcon';
-import { HIDDEN_OPACITY } from '../../../../../constants/theme';
import { useTranslation } from 'react-i18next';
-
-// LNURL/lightning-address domain → ICONS key for the provider brand logo.
-const LNURL_PROVIDER_ICONS = {
- 'aqua.net': 'aqua',
- 'blink.sv': 'blink',
- 'breez.tips': 'breez',
- 'cake.cash': 'cake',
- 'coinos.io': 'coinos',
- 'mannabitcoin.com': 'mannabitcoin',
- 'cluborange.org': 'cluborange',
- 'strike.me': 'strike',
- 'tether.me': 'tether',
- 'walletofsatoshi.com': 'walletofsatoshi',
- 'zeuspay.com': 'zeuspay',
-};
+import {
+ paymentAssetLabel,
+ RecipientAvatar,
+ resolveAssetAvatar,
+ resolveRecipientDisplay,
+} from './recipientCard';
+import ThemeIcon from '../../../../../functions/CustomElements/themeIcon';
export default function InvoiceInfo({
paymentInfo,
@@ -38,13 +27,17 @@ export default function InvoiceInfo({
isSplitPayment,
splitRecipients = [],
isUsingBranta,
+ isDollarBalance,
+ isToken,
}) {
const formmateedSparkPaymentInfo = formatSparkPaymentAddress(
paymentInfo,
undefined,
true,
);
- const { t } = useTranslation();
+ // Labels come from `i18next.t` inside recipientCard, which doesn't subscribe
+ // this component to language changes — the hook does.
+ useTranslation();
const { backgroundOffset, backgroundColor } = GetThemeColors();
const navigate = useNavigation();
const splitContacts = splitRecipients?.map(({ contact }) => contact);
@@ -60,23 +53,21 @@ export default function InvoiceInfo({
!isSplitPayment &&
fromPage !== 'contacts' &&
!isLNURLPay &&
- paymentType !== 'lightning';
+ paymentType !== 'lightning' &&
+ paymentType !== 'spark';
const isClickable = !isSplitPayment && !showsFullAddress;
- // LNURL: resolve the human-readable "user@host", match the host to a provider
- // logo, and drop "@host" when we have a logo (the logo conveys the provider).
+ // LNURL: resolve the human-readable "user@host". The shared resolver picks the
+ // provider brand logo, a mobile-money country flag + formatted phone number, or
+ // the plain address, so the pre-send and post-send screens stay in sync.
const normalizedLNURL = isLNURLPay
? normalizeLNURLAddress(paymentInfo?.data?.address) ??
paymentInfo?.data?.address ??
''
: '';
- const lnurlDomain = normalizedLNURL.includes('@')
- ? normalizedLNURL.split('@')[1]?.toLowerCase()
- : '';
- const providerIconKey = LNURL_PROVIDER_ICONS[lnurlDomain];
- const lnurlDisplayText = providerIconKey
- ? normalizedLNURL.split('@')[0]
- : normalizedLNURL;
+ const lnurlResolved = isLNURLPay
+ ? resolveRecipientDisplay({ lnurlAddress: normalizedLNURL })
+ : null;
// On-chain / spark addresses: 4-char groups with alternating weight for
// easy visual validation (mirrors depositQRView).
@@ -99,63 +90,44 @@ export default function InvoiceInfo({
if (isLNURLPay) {
paymentContent = (
-
- {providerIconKey ? (
-
- ) : (
-
- )}
-
-
-
- );
- } else if (paymentType === 'lightning') {
- paymentContent = (
-
-
-
+
+
+ );
+ } else if (paymentType === 'lightning' || paymentType === 'spark') {
+ // Same avatar the success screen uses, so the icon can't drift between the
+ // two screens.
+ paymentContent = (
+
+
+
+
+
);
} else {
- // bitcoin / spark / lrc20
+ // bitcoin / spark / lrc20: show the full address so the user can verify it
+ // against what they scanned/pasted before signing.
paymentContent = (
+ {/* The flag renders 1.6x wider than `size`, so scale it to fit the
+ circle's diameter instead of getting cropped by the border radius. */}
+
+
+ );
+ }
+
+ if (isProvider) {
+ return (
+
+
+
+ );
+ }
+
+ if (resolved?.kind === 'spark') {
+ return (
+
+
+
+ );
+ }
+
+ if (resolved?.kind === 'stablecoin') {
+ return (
+
+
+
+ );
+ }
+
+ if (resolved?.kind === 'asset') {
+ const isDollar = resolved.asset === 'dollar';
+ return (
+
+
+
+ );
+ }
+
+ // branta / contact / lightning fallback all go through ContactProfileImage,
+ // which renders the user-icon fallback when there is no uri.
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ circle: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ providerCircle: {
+ backgroundColor: COLORS.white,
+ borderColor: COLORS.gray,
+ },
+ fill: {
+ width: '100%',
+ height: '100%',
+ },
+ assetIcon: {
+ width: '50%',
+ height: '50%',
+ },
+});
diff --git a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js
index 84f95081..ae851a74 100644
--- a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js
+++ b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js
@@ -217,6 +217,11 @@ export default function SendPaymentScreen(props) {
? masterInfoObject?.defaultSpendToken || 'Bitcoin'
: 'Bitcoin';
+ const selectedContactInfo = contactInfo || paymentInfo?.blitzContactInfo;
+
+ // Intentionally keyed off `contactInfo` only: including blitzContactInfo here
+ // would swap the token selector for the BTC/USD chooser and unpin the payment
+ // method for @username sends. Display is handled by the InvoiceInfo props.
const useFullTokensDisplay =
enabledLRC20 &&
isSparkPayment &&
@@ -239,7 +244,14 @@ export default function SendPaymentScreen(props) {
? paymentInfo?.data?.maxSendable / 1000
: 0;
- const selectedLRC20Asset = masterTokenInfo?.tokenName || defaultToken;
+ // Tokens only exist on Spark — a bolt11/on-chain/LNURL invoice can never be
+ // funded with the default spend token. Before the decode lands paymentNetwork
+ // is undefined, so keep the user's pick: processSparkAddress reads it to
+ // decide isLRC20.
+ const selectedLRC20Asset =
+ !paymentInfo?.paymentNetwork || useFullTokensDisplay
+ ? masterTokenInfo?.tokenName || defaultToken
+ : 'Bitcoin';
const seletctedToken =
masterTokenInfo?.details ||
sparkInformation?.tokens?.[selectedLRC20Asset] ||
@@ -247,6 +259,12 @@ export default function SendPaymentScreen(props) {
const tokenDecimals = seletctedToken?.tokenMetadata?.decimals ?? 0;
const tokenBalance = seletctedToken?.balance ?? 0;
const isUsingLRC20 = selectedLRC20Asset?.toLowerCase() !== 'bitcoin';
+ // The ticker actually shown on this screen. USDB-funded sends that weren't
+ // picked through the token selector display as fiat, so the confirm screen
+ // must be told the label instead of re-deriving it from the tx's token info.
+ const displayTokenTicker = isUsingLRC20
+ ? seletctedToken?.tokenMetadata?.tokenTicker
+ : '';
const sendingAmount = paymentInfo?.sendAmount || 0;
const canEditAmount = paymentInfo?.canEditPayment === true;
@@ -1301,9 +1319,10 @@ export default function SendPaymentScreen(props) {
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
- blitzContactInfo: paymentInfo?.blitzContactInfo,
+ blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
+ displayTokenTicker,
},
},
],
@@ -1331,9 +1350,10 @@ export default function SendPaymentScreen(props) {
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
- blitzContactInfo: paymentInfo?.blitzContactInfo,
+ blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
+ displayTokenTicker,
},
},
],
@@ -1373,6 +1393,8 @@ export default function SendPaymentScreen(props) {
fiatValueConvertedSendAmount,
paymentValidation,
displayAmount,
+ displayTokenTicker,
+ selectedContactInfo,
]);
const handleSelectPaymentMethod = useCallback(
@@ -1446,15 +1468,6 @@ export default function SendPaymentScreen(props) {
});
}, [paymentInfo?.verificationURL, navigate, t]);
- const sendingAsset =
- selectedLRC20Asset === 'Bitcoin'
- ? !isLightningPayment &&
- !isBitcoinPayment &&
- !(isSparkPayment && receiverExpectsCurrency === 'sats')
- ? t('constants.dollars_upper')
- : t('constants.bitcoin_upper')
- : seletctedToken?.tokenMetadata?.tokenTicker;
-
if (
(!Object.keys(paymentInfo).length && !errorMessage) ||
!sparkInformation.didConnect
@@ -1501,9 +1514,7 @@ export default function SendPaymentScreen(props) {
forceCurrency={primaryDisplay.forceCurrency}
forceFiatStats={primaryDisplay.forceFiatStats}
activeOpacity={!sendingAmount ? 0.5 : 1}
- customCurrencyCode={
- isUsingLRC20 ? seletctedToken?.tokenMetadata?.tokenTicker : ''
- }
+ customCurrencyCode={displayTokenTicker}
maxDecimals={isUsingLRC20 ? tokenDecimals : 2}
/>
{uiState === 'CONFIRM_PAYMENT' && !isUsingLRC20 && (
@@ -1549,13 +1560,16 @@ export default function SendPaymentScreen(props) {
{uiState === 'CONFIRM_PAYMENT' && (
)}
{uiState === 'CHOOSE_METHOD' && (
diff --git a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js
index f7a86cc9..ed6ee0d2 100644
--- a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js
+++ b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js
@@ -502,6 +502,7 @@ export default function StablecoinSendScreen() {
transaction: pendingTx,
paymentDisplay: primaryDisplayRef.current,
displayAmount: rawInput,
+ stablecoinInfo: { asset, label: chainLabel },
},
},
],
@@ -528,6 +529,7 @@ export default function StablecoinSendScreen() {
error: err.message,
lnurlAddress: undefined,
blitzContactInfo: undefined,
+ stablecoinInfo: { asset, label: chainLabel },
},
},
],
@@ -553,6 +555,8 @@ export default function StablecoinSendScreen() {
clearCountdown,
t,
rawInput,
+ chainLabel,
+ asset,
]);
const handleEmoji = newDescription => {
@@ -780,11 +784,11 @@ export default function StablecoinSendScreen() {
content={`${truncateAddress(address)}`}
/>
-
+ /> */}
)}
@@ -926,20 +930,22 @@ const styles = StyleSheet.create({
width: '80%',
flexDirection: 'row',
alignItems: 'center',
- justifyContent: 'space-between',
+ justifyContent: 'center',
padding: 12,
borderRadius: 16,
...CENTER,
marginTop: 30,
},
destinationContent: {
- flexShrink: 1,
+ flex: 1,
flexDirection: 'row',
alignItems: 'center',
+ justifyContent: 'center',
},
destinationChevron: {
opacity: 0.8,
- marginLeft: 8,
+ position: 'absolute',
+ right: 12,
},
receiveAmount: {
opacity: HIDDEN_OPACITY,
@@ -953,6 +959,7 @@ const styles = StyleSheet.create({
borderRadius: 20,
},
quoteValue: {
+ flexShrink: 1,
fontSize: SIZES.medium,
includeFontPadding: false,
},
diff --git a/app/functions/sendBitcoin/getPhonePaymentAddress.js b/app/functions/sendBitcoin/getPhonePaymentAddress.js
index ba6fc80f..15667240 100644
--- a/app/functions/sendBitcoin/getPhonePaymentAddress.js
+++ b/app/functions/sendBitcoin/getPhonePaymentAddress.js
@@ -62,6 +62,30 @@ export function getPhonePaymentCountry(address) {
return match ? match[0] : null;
}
+// Human-readable display for a phone-payment provider address (either LNURL or
+// POST provider). Returns the provider country iso code (for a flag) and the
+// phone number in international format, or null for non-phone addresses. The
+// local part is national for some providers (ZM -> 0977…), so we parse trying
+// the international form first, then the resolved country.
+export function getPhonePaymentDisplay(address) {
+ const isoCode = getPhonePaymentCountry(address);
+ if (!isoCode) return null;
+ const local = address.slice(0, address.indexOf('@'));
+ const attempts = [
+ [local.startsWith('+') ? local : `+${local}`, undefined],
+ [local, isoCode],
+ ];
+ for (const [value, defaultCountry] of attempts) {
+ try {
+ const parsed = parsePhoneNumberWithError(value, defaultCountry);
+ if (parsed.isValid()) {
+ return { isoCode, formatted: parsed.formatInternational() };
+ }
+ } catch {}
+ }
+ return { isoCode, formatted: local };
+}
+
// Returns the provider lightning addresses the input is valid for, in
// PHONE_PAYMENT_PROVIDERS order (KE before ZM). Accepts national or
// international input. A bare national number in the overlapping 075/076/077
diff --git a/app/screens/inAccount/confirmTxPage.js b/app/screens/inAccount/confirmTxPage.js
index d95c8a03..65f98fc9 100644
--- a/app/screens/inAccount/confirmTxPage.js
+++ b/app/screens/inAccount/confirmTxPage.js
@@ -1,4 +1,4 @@
-import { StyleSheet, View, TouchableOpacity, ScrollView } from 'react-native';
+import { StyleSheet, View, TouchableOpacity } from 'react-native';
import { CENTER, COLORS, FONT, SIZES } from '../../constants';
import { useNavigation } from '@react-navigation/native';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -20,29 +20,28 @@ import formatTokensNumber from '../../functions/lrc20/formatTokensBalance';
import { useTranslation } from 'react-i18next';
import { useAppStatus } from '../../../context-store/appStatus';
import DropdownMenu from '../../functions/CustomElements/dropdownMenu';
-import displayCorrectDenomination from '../../functions/displayCorrectDenomination';
-import { useGlobalContextProvider } from '../../../context-store/context';
-import { useNodeContext } from '../../../context-store/nodeContext';
import customUUID from '../../functions/customUUID';
import { useGlobalContactsInfo } from '../../../context-store/globalContacts';
import { getSingleContact } from '../../../db';
import { getCachedProfileImage } from '../../functions/cachedImage';
-import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH } from '../../constants/theme';
+import { INSET_WINDOW_WIDTH } from '../../constants/theme';
import normalizeLNURLAddress from '../../functions/lnurl/normalizeLNURLAddress';
import { isBlitzLNURLAddress } from '../../functions/lnurl';
import { canonicalizePhonePaymentAddress } from '../../functions/sendBitcoin/getPhonePaymentAddress';
import FormattedBalanceInput from '../../functions/CustomElements/formattedBalanceInput';
+import {
+ RecipientAvatar,
+ resolveRecipientDisplay,
+} from '../../components/admin/homeComponents/sendBitcoin/components/recipientCard';
const confirmTxAnimation = require('../../assets/confirmTxAnimation.json');
const errorTxAnimation = require('../../assets/errorTxAnimation.json');
export default function ConfirmTxPage(props) {
const { sparkInformation } = useSparkWallet();
const { screenDimensions } = useAppStatus();
- const { masterInfoObject } = useGlobalContextProvider();
- const { fiatStats } = useNodeContext();
const navigate = useNavigation();
const { showToast } = useToast();
- const { backgroundOffset, textColor } = GetThemeColors();
+ const { backgroundOffset, backgroundColor, textColor } = GetThemeColors();
const { theme, darkModeType } = useGlobalThemeContext();
const animationRef = useRef(null);
const { t } = useTranslation();
@@ -57,9 +56,13 @@ export default function ConfirmTxPage(props) {
const lnurlUsername = lnurlAddress?.split('@')[0]?.toLowerCase();
const blitzContactInfo = props.route.params?.blitzContactInfo;
const displayAmount = props.route.params?.displayAmount;
+ const stablecoinInfo = props.route.params?.stablecoinInfo;
// The display currency the user entered/reviewed the payment in (e.g. EUR),
// passed from the send screens so the success amount matches what they saw.
const paymentDisplay = props.route.params?.paymentDisplay;
+ // Set only when the send screen actually labelled the amount with a token
+ // ticker. USDB-funded sends carry token info on the tx but were shown as fiat.
+ const displayTokenTicker = props.route.params?.displayTokenTicker;
const didSucceed = !hasError || isLNURLAuth;
@@ -87,18 +90,39 @@ export default function ConfirmTxPage(props) {
const showAddBlitzContact =
didSucceed && !isLNURLAuth && blitzContactInfo && !isAlreadyBlitzContact;
- const paymentNetwork = paymentInformation?.sendingUUID
- ? t('screens.inAccount.expandedTxPage.contactPaymentType')
- : paymentInformation?.isGift
- ? t('constants.gift')
- : transaction?.paymentType;
+ // Only on-chain and cross-chain stablecoin sends actually leave the user
+ // waiting — a lightning tx is written 'pending' but settles immediately.
+ const showPendingMessage =
+ transaction?.paymentStatus === 'pending' &&
+ (paymentInformation?.isFlashnetStablecoin ||
+ transaction?.paymentType === 'bitcoin');
- const showPendingMessage = transaction?.paymentStatus === 'pending';
- const isFlashnetStablecoinPending =
- paymentInformation?.isFlashnetStablecoin &&
- transaction?.paymentStatus === 'pending';
+ // Recipient "name card": show a real avatar (branta logo, LNURL provider logo,
+ // contact image, or mobile-money country flag) + name for single-recipient sends.
+ // All the data is already in the route params / tx details — no extra wiring.
+ const recipientResolved = resolveRecipientDisplay({
+ lnurlAddress,
+ contactInfo: blitzContactInfo,
+ brantaName: paymentInformation?.brantaMerchantName,
+ brantaLogo: paymentInformation?.brantaMerchantLogo,
+ transaction,
+ stablecoinInfo,
+ });
+ const showRecipientCard = !isLNURLAuth && !!recipientResolved;
- const paymentFee = paymentInformation?.fee;
+ const statusSubtitle = isLNURLAuth
+ ? t('screens.inAccount.confirmTxPage.lnurlAuthSuccess')
+ : !didSucceed
+ ? t('screens.inAccount.confirmTxPage.paymentErrorMessage')
+ : showPendingMessage
+ ? t('screens.inAccount.confirmTxPage.sendingInProgress')
+ : t('screens.inAccount.confirmTxPage.confirmMessage', {
+ context:
+ paymentInformation.direction?.toLowerCase() === 'outgoing'
+ ? 'sent'
+ : 'received',
+ });
+ const buttonText = t('constants.done');
const errorMessage = hasError || t('errormessages.genericError');
@@ -132,102 +156,6 @@ export default function ConfirmTxPage(props) {
animationRef.current?.play();
}, []);
- if (isFlashnetStablecoinPending) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- navigate.popToTop();
- });
- });
- }}
- textContent={t('constants.continue')}
- />
-
- );
- }
-
if (props.route.params?.isSplitPayment && props.route.params?.isRequset) {
return (
@@ -272,204 +200,128 @@ export default function ConfirmTxPage(props) {
return (
-
- {!isLNURLAuth && (
-
+
- )}
+ {(didSucceed || amount > 0) && !isLNURLAuth && (
+
+ {displayAmount && paymentDisplay ? (
+
+ ) : (
+
+ )}
+
+ )}
- {didSucceed && !isLNURLAuth && (
-
- {displayAmount && paymentDisplay ? (
-
- ) : (
-
- )}
-
- )}
+ {isLNURLAuth && (
+
+ )}
+ {!didSucceed && (
+
+ )}
- {isLNURLAuth && (
- )}
-
-
- {didSucceed && !isLNURLAuth && (
-
-
-
-
-
-
-
-
-
-
- )}
- {!didSucceed && !isLNURLAuth && (
-
-
-
-
-
- )}
- {!didSucceed && !isLNURLAuth && (
-
-
+ navigate.navigate('ErrorScreen', {
+ errorMessage: recipientResolved.fullAddress,
+ })
+ }
+ style={[
+ styles.recipientCard,
+ { backgroundColor: backgroundOffset },
]}
- selectedValue=""
- placeholder={t('screens.inAccount.confirmTxPage.sendReport')}
- onSelect={async item => {
- if (item.value === 'email') {
- try {
- await openComposer({
- to: 'blake@blitzwalletapp.com',
- subject: 'Payment Failed',
- body: String(errorMessage),
- });
- } catch (err) {
- console.log('Email composer error:', err);
- }
- } else if (item.value === 'clipboard') {
- copyToClipboard(String(errorMessage), showToast);
- }
- }}
- showClearIcon={false}
- showVerticalArrows={false}
- translateLabelText={false}
- customButtonStyles={{
- backgroundColor: 'transparent',
- }}
- textStyles={{
- ...CENTER,
- textDecorationLine: 'underline',
- }}
- />
-
- )}
-
+ >
+
+
+
+ )}
+
+ {!didSucceed && !isLNURLAuth && (
+ {
+ if (item.value === 'email') {
+ try {
+ await openComposer({
+ to: 'blake@blitzwalletapp.com',
+ subject: 'Payment Failed',
+ body: String(errorMessage),
+ });
+ } catch (err) {
+ console.log('Email composer error:', err);
+ }
+ } else if (item.value === 'clipboard') {
+ copyToClipboard(String(errorMessage), showToast);
+ }
+ }}
+ showClearIcon={false}
+ showVerticalArrows={false}
+ translateLabelText={false}
+ customButtonStyles={{
+ backgroundColor: 'transparent',
+ }}
+ textStyles={{
+ ...CENTER,
+ }}
+ />
+ )}
+
{showAddContact && (