From f37a7d09f87aff8683f9c32944edf7e60607da7c Mon Sep 17 00:00:00 2001 From: sl Date: Sun, 10 May 2026 20:23:00 +0200 Subject: [PATCH] frontend: receive screen revamp --- CHANGELOG.md | 1 + .../copy/copy-address-button.module.css | 21 + .../components/copy/copy-address-button.tsx | 48 ++ .../assets/icons/arrow-circle-left-active.svg | 4 - .../icon/assets/icons/arrow-circle-left.svg | 4 - .../icons/arrow-circle-right-active.svg | 4 - .../icon/assets/icons/arrow-circle-right.svg | 4 - .../icon/assets/icons/chevron-up-blue.svg | 3 + .../src/components/icon/combined.module.css | 4 +- .../web/src/components/icon/combined.tsx | 6 +- frontends/web/src/components/icon/icon.tsx | 10 +- .../src/components/message/message.module.css | 1 - frontends/web/src/locales/en/app.json | 54 +- .../account/addresses/address-actions.tsx | 10 +- .../components/address-card.module.css | 48 ++ .../receive/components/address-card.tsx | 36 ++ .../components/address-cycler.module.css | 29 ++ .../receive/components/address-cycler.tsx | 50 ++ .../account/receive/components/guide.tsx | 59 --- .../components/more-options.module.css | 25 + .../receive/components/more-options.tsx | 41 ++ .../components/script-type-picker.module.css | 20 + .../receive/components/script-type-picker.tsx | 43 ++ .../components/use-receive-addresses.test.ts | 165 ++++++ .../components/use-receive-addresses.ts | 57 +++ .../components/verify-prompt.module.css | 16 + .../receive/components/verify-prompt.tsx | 26 + .../routes/account/receive/receive.module.css | 89 ++-- .../src/routes/account/receive/receive.tsx | 476 +++++------------- frontends/web/src/utils/address.test.ts | 37 +- frontends/web/src/utils/address.ts | 13 + .../web/tests/backup-reminder-allowed.test.ts | 2 +- frontends/web/tests/send.test.ts | 6 +- 33 files changed, 884 insertions(+), 528 deletions(-) create mode 100644 frontends/web/src/components/copy/copy-address-button.module.css create mode 100644 frontends/web/src/components/copy/copy-address-button.tsx delete mode 100644 frontends/web/src/components/icon/assets/icons/arrow-circle-left-active.svg delete mode 100644 frontends/web/src/components/icon/assets/icons/arrow-circle-left.svg delete mode 100644 frontends/web/src/components/icon/assets/icons/arrow-circle-right-active.svg delete mode 100644 frontends/web/src/components/icon/assets/icons/arrow-circle-right.svg create mode 100644 frontends/web/src/components/icon/assets/icons/chevron-up-blue.svg create mode 100644 frontends/web/src/routes/account/receive/components/address-card.module.css create mode 100644 frontends/web/src/routes/account/receive/components/address-card.tsx create mode 100644 frontends/web/src/routes/account/receive/components/address-cycler.module.css create mode 100644 frontends/web/src/routes/account/receive/components/address-cycler.tsx delete mode 100644 frontends/web/src/routes/account/receive/components/guide.tsx create mode 100644 frontends/web/src/routes/account/receive/components/more-options.module.css create mode 100644 frontends/web/src/routes/account/receive/components/more-options.tsx create mode 100644 frontends/web/src/routes/account/receive/components/script-type-picker.module.css create mode 100644 frontends/web/src/routes/account/receive/components/script-type-picker.tsx create mode 100644 frontends/web/src/routes/account/receive/components/use-receive-addresses.test.ts create mode 100644 frontends/web/src/routes/account/receive/components/use-receive-addresses.ts create mode 100644 frontends/web/src/routes/account/receive/components/verify-prompt.module.css create mode 100644 frontends/web/src/routes/account/receive/components/verify-prompt.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c3710b6269..84525328a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Fullscreen region select on mobile - Change portfolio chart default to year - iOS: live price widget with 24-hour chart +- Receive screen revamp ## v4.50.1 - Fix a bug that would delay showing watch-only accounts. diff --git a/frontends/web/src/components/copy/copy-address-button.module.css b/frontends/web/src/components/copy/copy-address-button.module.css new file mode 100644 index 0000000000..5fe62ea9de --- /dev/null +++ b/frontends/web/src/components/copy/copy-address-button.module.css @@ -0,0 +1,21 @@ +.copyBtn { + display: inline-flex; + justify-content: flex-start; + margin-top: 0; + padding: 0; + text-align: left; + text-decoration: none; +} + +.label { + align-items: center; + display: inline-flex; + font-size: var(--size-default); + gap: var(--space-quarter); + white-space: nowrap; +} + +.icon { + height: 18px; + width: 18px; +} diff --git a/frontends/web/src/components/copy/copy-address-button.tsx b/frontends/web/src/components/copy/copy-address-button.tsx new file mode 100644 index 0000000000..bb805f20af --- /dev/null +++ b/frontends/web/src/components/copy/copy-address-button.tsx @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/forms'; +import { Checked, Copy } from '@/components/icon/icon'; +import style from './copy-address-button.module.css'; + +type TProps = + | { mode: 'copy'; value: string; onClick?: never; className?: string } + | { mode: 'action'; onClick: () => void; value?: never; className?: string }; + +export const CopyAddressButton = (props: TProps) => { + const { mode, className } = props; + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) { + return; + } + const id = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(id); + }, [copied]); + + const handleClick = () => { + if (mode === 'action') { + props.onClick(); + return; + } + navigator.clipboard.writeText(props.value); + setCopied(true); + }; + + return ( + + ); +}; diff --git a/frontends/web/src/components/icon/assets/icons/arrow-circle-left-active.svg b/frontends/web/src/components/icon/assets/icons/arrow-circle-left-active.svg deleted file mode 100644 index a885959a92..0000000000 --- a/frontends/web/src/components/icon/assets/icons/arrow-circle-left-active.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontends/web/src/components/icon/assets/icons/arrow-circle-left.svg b/frontends/web/src/components/icon/assets/icons/arrow-circle-left.svg deleted file mode 100644 index 4e36af094d..0000000000 --- a/frontends/web/src/components/icon/assets/icons/arrow-circle-left.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontends/web/src/components/icon/assets/icons/arrow-circle-right-active.svg b/frontends/web/src/components/icon/assets/icons/arrow-circle-right-active.svg deleted file mode 100644 index 575d057029..0000000000 --- a/frontends/web/src/components/icon/assets/icons/arrow-circle-right-active.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontends/web/src/components/icon/assets/icons/arrow-circle-right.svg b/frontends/web/src/components/icon/assets/icons/arrow-circle-right.svg deleted file mode 100644 index 6cd941ea47..0000000000 --- a/frontends/web/src/components/icon/assets/icons/arrow-circle-right.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontends/web/src/components/icon/assets/icons/chevron-up-blue.svg b/frontends/web/src/components/icon/assets/icons/chevron-up-blue.svg new file mode 100644 index 0000000000..5db0429283 --- /dev/null +++ b/frontends/web/src/components/icon/assets/icons/chevron-up-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontends/web/src/components/icon/combined.module.css b/frontends/web/src/components/icon/combined.module.css index 11a9552b31..2abda3c13d 100644 --- a/frontends/web/src/components/icon/combined.module.css +++ b/frontends/web/src/components/icon/combined.module.css @@ -5,6 +5,8 @@ .caret { display: block; margin: 0 auto; + position: relative; + right: -8px; } .bitbox02 { @@ -12,4 +14,4 @@ max-width: min(264px, 80%); position: relative; right: -16px; -} +} \ No newline at end of file diff --git a/frontends/web/src/components/icon/combined.tsx b/frontends/web/src/components/icon/combined.tsx index 3360517b79..24f07f13d8 100644 --- a/frontends/web/src/components/icon/combined.tsx +++ b/frontends/web/src/components/icon/combined.tsx @@ -4,10 +4,12 @@ import { useDarkmode } from '@/hooks/darkmode'; import { BitBox02StylizedDark, BitBox02StylizedLight, CaretDown } from './icon'; import style from './combined.module.css'; -export const PointToBitBox02 = () => { +type TProps = { className?: string }; + +export const PointToBitBox02 = ({ className }: TProps) => { const { isDarkMode } = useDarkmode(); return ( -
+
{ isDarkMode ? () diff --git a/frontends/web/src/components/icon/icon.tsx b/frontends/web/src/components/icon/icon.tsx index 17f38c9ebf..e97c01078c 100644 --- a/frontends/web/src/components/icon/icon.tsx +++ b/frontends/web/src/components/icon/icon.tsx @@ -17,10 +17,6 @@ import arrowFloorUpRedSVG from './assets/icons/arrow-floor-up-red.svg'; import arrowFloorDownGreenSVG from './assets/icons/arrow-floor-down-green.svg'; import arrowFloorUpWhiteSVG from './assets/icons/arrow-floor-up-white.svg'; import arrowFloorDownWhiteSVG from './assets/icons/arrow-floor-down-white.svg'; -import arrowCircleLeftSVG from './assets/icons/arrow-circle-left.svg'; -import arrowCircleLeftActiveSVG from './assets/icons/arrow-circle-left-active.svg'; -import arrowCircleRightSVG from './assets/icons/arrow-circle-right.svg'; -import arrowCircleRightActiveSVG from './assets/icons/arrow-circle-right-active.svg'; import arrowSwapSVG from './assets/icons/arrow-swap.svg'; import bankDarkSVG from './assets/icons/bank.svg'; import bankLightSVG from './assets/icons/bank-light.svg'; @@ -31,6 +27,7 @@ import checkSVG from './assets/icons/check.svg'; import chevronRightDark from './assets/icons/chevron-right-dark.svg'; import chevronLeftDark from './assets/icons/chevron-left-dark.svg'; import chevronDownDark from './assets/icons/chevron-down-dark.svg'; +import chevronUpBlue from './assets/icons/chevron-up-blue.svg'; import cancelSVG from './assets/icons/cancel.svg'; import cogLightSVG from './assets/icons/cog-light.svg'; import cogDarkSVG from './assets/icons/cog-dark.svg'; @@ -134,10 +131,6 @@ export const ArrowFloorUpRed = (props: ImgProps) => ( (); export const ArrowFloorUpWhite = (props: ImgProps) => (); export const ArrowFloorDownWhite = (props: ImgProps) => (); -export const ArrowCirlceLeft = (props: ImgProps) => (); -export const ArrowCirlceLeftActive = (props: ImgProps) => (); -export const ArrowCirlceRight = (props: ImgProps) => (); -export const ArrowCirlceRightActive = (props: ImgProps) => (); export const ArrowSwap = (props: ImgProps) => (); export const BankDark = (props: ImgProps) => (); export const Bank = (props: ImgProps) => (); @@ -152,6 +145,7 @@ export const Check = (props: ImgProps) => ( (); export const ChevronRightDark = (props: ImgProps) => (); export const ChevronDownDark = (props: ImgProps) => (); +export const ChevronUpBlue = (props: ImgProps) => (); export const Cancel = (props: ImgProps) => (); export const CreditCardDark = (props: ImgProps) => (); export const CreditCard = (props: ImgProps) => (); diff --git a/frontends/web/src/components/message/message.module.css b/frontends/web/src/components/message/message.module.css index a845ee82a5..de2a66236e 100644 --- a/frontends/web/src/components/message/message.module.css +++ b/frontends/web/src/components/message/message.module.css @@ -14,7 +14,6 @@ } .content { - padding-top: 2px; width: 100%; } diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..dff4f379fb 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1335,44 +1335,9 @@ "insurance": "Insurance guide", "manageAccount": "Manage accounts guide", "manageDevice": "Manage device guide", - "receive": "Receive guide", "send": "Send guide", "walletConnect": "WalletConnect guide" }, - "receive": { - "address": { - "text": "You can give the address to others to send you some coins. Just make sure they are sending to the correct address.", - "title": "What do I do with an address?" - }, - "addressChange": { - "text": "As soon as you transact, a new address is automatically added to the list so there are always 20 addresses available which have never received any coins.", - "title": "When do the addresses change?" - }, - "addressFormats": { - "text": "By default, the address type is Native Segwit. This address type is widely adopted by other wallets/exchanges and gives you the best fee rates for everyday transactions. However, you may also choose to send to Taproot (Bitcoin only), which is the newest address type, but may not be widely supported yet.", - "title": "When do I use “Change address type”?" - }, - "howVerify": { - "text": "You can verify addresses directly on the BitBox during the send/receive process.", - "title": "How can I verify an address securely?" - }, - "plugout": { - "text": "No, once you sent coins to your BitBox address, you do not need to leave your BitBox plugged in. You are free to disconnect your BitBox.", - "title": "Do I need to leave my BitBox plugged in while receiving?" - }, - "why20": { - "text": "During start-up the app generates addresses derived from your seed to see if they have received funds. As the app can generate an almost infinite number of addresses, it could spend years determining the balance. To limit this search it stops after it sees 20 addresses that have never received funds. This is the \"gap limit\" and 20 is a de-facto standard though the number is arbitrary. These are the 20 addresses you can choose from.", - "title": "Why only 20 addresses?" - }, - "whyMany": { - "text": "To maintain privacy and security, never hand out the same address twice. If you have used an address, click on on the right arrow for a new address. You can generate up to 20 addresses at a time. Think of addresses like invoice numbers. All addresses are derived from your single backup seed.", - "title": "Why so many addresses?" - }, - "whyVerify": { - "text": "You shouldn't trust your computer to generate and display authentic addresses. It's big attack surface makes it significantly more vulnerable than a hardware wallet. The address can be verified directly on the BitBox display.", - "title": "Why should I verify the address securely?" - } - }, "send": { "change": { "text": "The change will be returned to a Taproot address if you have at least one Taproot UTXO. If you use coin control, the change will be returned to a Taproot address if there is at least one Taproot UTXO among the selected UTXOs. In all other cases, the change is returned to a Native Segwit address.", @@ -1729,17 +1694,26 @@ } }, "receive": { - "bitsuranceWarning": "This is an insured account, meaning it can only receive to Native Segwit. This is so you don't accidently receive to Taproot, which is not insured.", + "alwaysVerifyBody": "Be sure the sender has the same address displayed on your BitBox.", + "alwaysVerifyTitle": "Always verify address on BitBox", "changeScriptType": "Change address type", - "label": "Your address", + "continueOnBitBox": "Continue on BitBox", + "getNewAddress": "Get new address", + "moreOptions": "More options", "onlyThisCoin": { "description": "To receive other tokens, enable them in the settings. If you deposit other tokens, they might not be accessible.", "warning": "Make sure to only receive {{coinName}} on this address." }, "qrCodeCopiedMessage": "Copied!", - "scriptType": { - "p2tr": "Taproot (newest format)", - "p2wpkh": "Native Segwit (default)" + "scriptTypeHint": { + "p2tr": "Newest format", + "p2wpkh": "Default", + "p2wpkh-p2sh": "Compatibility" + }, + "scriptTypeName": { + "p2tr": "Taproot", + "p2wpkh": "Native segwit", + "p2wpkh-p2sh": "Wrapped segwit" }, "selectAccount": "Select account", "taprootWarning": "Note: Taproot is a new Bitcoin feature and is not yet widely adopted. Funds received on Taproot addresses may not be visible in third party watch-only wallets. Many wallets and exchanges are not yet able to send to Taproot addresses.", diff --git a/frontends/web/src/routes/account/addresses/address-actions.tsx b/frontends/web/src/routes/account/addresses/address-actions.tsx index d6482e271c..462ac09007 100644 --- a/frontends/web/src/routes/account/addresses/address-actions.tsx +++ b/frontends/web/src/routes/account/addresses/address-actions.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { AccountCode, TUsedAddress } from '@/api/account'; import { Button } from '@/components/forms'; -import { Copy, OutlinedFileProtectPrimary } from '@/components/icon'; +import { CopyAddressButton } from '@/components/copy/copy-address-button'; +import { OutlinedFileProtectPrimary } from '@/components/icon'; import style from './addresses.module.css'; type TProps = { @@ -18,12 +19,7 @@ export const AddressActions = ({ code, address, onCopy }: TProps) => { const navigate = useNavigate(); return (
- + onCopy(address)} /> {address.canSignMsg && ( + + {truncateDisplayAddress(addresses[activeIndex]?.displayAddress ?? '')} + + +
+ + ); +}; diff --git a/frontends/web/src/routes/account/receive/components/guide.tsx b/frontends/web/src/routes/account/receive/components/guide.tsx deleted file mode 100644 index de13b8de9d..0000000000 --- a/frontends/web/src/routes/account/receive/components/guide.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { useTranslation } from 'react-i18next'; -import { Entry } from '@/components/guide/entry'; -import { Guide } from '@/components/guide/guide'; - -type Props = { - hasMultipleAddresses: boolean; - hasDifferentFormats: boolean; -}; - -export const ReceiveGuide = ({ - hasMultipleAddresses, - hasDifferentFormats, -}: Props) => { - const { t } = useTranslation(); - return ( - - - - - - {hasMultipleAddresses && ( - <> - - - - {hasDifferentFormats && ( - - )} - - )} - - ); -}; diff --git a/frontends/web/src/routes/account/receive/components/more-options.module.css b/frontends/web/src/routes/account/receive/components/more-options.module.css new file mode 100644 index 0000000000..2b544c0696 --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/more-options.module.css @@ -0,0 +1,25 @@ +.moreOptionsWrap { + margin-top: calc(var(--space-quarter) * 3); + text-align: center; +} + +.moreOptionsLabel { + display: inline-flex; + align-items: center; + gap: var(--space-quarter); +} + +.moreOptionsChevron { + transform: rotate(180deg); + transition: transform 0.2s ease-out; +} + +.moreOptionsChevronOpen { + transform: rotate(0deg); +} + +@media (max-width: 768px) { + .showMoreOptions { + margin-bottom: calc(var(--space-default) * 1.5); + } +} diff --git a/frontends/web/src/routes/account/receive/components/more-options.tsx b/frontends/web/src/routes/account/receive/components/more-options.tsx new file mode 100644 index 0000000000..797d1bbae5 --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/more-options.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronUpBlue } from '@/components/icon'; +import { Button } from '@/components/forms'; +import style from './more-options.module.css'; + +type TProps = { + open: boolean; + onToggle: () => void; + children: ReactNode; +}; + +export const MoreOptions = ({ open, onToggle, children }: TProps) => { + const { t } = useTranslation(); + + return ( + <> +
+ +
+ {open && ( +
+ {children} +
+ )} + + ); +}; diff --git a/frontends/web/src/routes/account/receive/components/script-type-picker.module.css b/frontends/web/src/routes/account/receive/components/script-type-picker.module.css new file mode 100644 index 0000000000..918eedfe0d --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/script-type-picker.module.css @@ -0,0 +1,20 @@ +.navLabel { + margin: 0 0 var(--space-quarter); + font-size: 14px; + color: var(--color-secondary); +} + +.scriptTypes { + margin-top: var(--space-half); +} + +.scriptTypes label { + flex-direction: row !important; + align-items: baseline !important; +} + +.scriptTypeHint { + margin-left: var(--space-quarter); + font-size: 14px; + color: var(--color-secondary); +} diff --git a/frontends/web/src/routes/account/receive/components/script-type-picker.tsx b/frontends/web/src/routes/account/receive/components/script-type-picker.tsx new file mode 100644 index 0000000000..475e90f71a --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/script-type-picker.tsx @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useTranslation } from 'react-i18next'; +import { type ScriptType } from '@/api/account'; +import { Radio } from '@/components/forms'; +import { Message } from '@/components/message/message'; +import style from './script-type-picker.module.css'; + +type TProps = { + availableScriptTypes: ScriptType[]; + selectedIndex: number; + onChange: (index: number) => void; +}; + +export const ScriptTypePicker = ({ availableScriptTypes, selectedIndex, onChange }: TProps) => { + const { t } = useTranslation(); + + return ( +
+

{t('receive.changeScriptType')}

+ {availableScriptTypes.map((scriptType, i) => ( +
+ onChange(i)} + > + {t(`receive.scriptTypeName.${scriptType}`)} + + {t(`receive.scriptTypeHint.${scriptType}`)} + + + {scriptType === 'p2tr' && selectedIndex === i && ( + + {t('receive.taprootWarning')} + + )} +
+ ))} +
+ ); +}; diff --git a/frontends/web/src/routes/account/receive/components/use-receive-addresses.test.ts b/frontends/web/src/routes/account/receive/components/use-receive-addresses.test.ts new file mode 100644 index 0000000000..de0676808e --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/use-receive-addresses.test.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +import '../../../../../__mocks__/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import type { AccountCode, ScriptType, TReceiveAddress, TReceiveAddressList } from '@/api/account'; +import type { NonEmptyArray } from '@/utils/types'; + +vi.mock('@/i18n/i18n'); +vi.mock('@/hooks/api', () => ({ + useLoad: vi.fn(), +})); + +import { useLoad } from '@/hooks/api'; +import { useReceiveAddresses } from './use-receive-addresses'; + +const mockUseLoad = vi.mocked(useLoad); + +const addr = (id: string): TReceiveAddress => ({ + addressID: id, + address: `bc1q${id}`, + displayAddress: `bc1q ${id}`, +}); + +const makeGroup = ( + scriptType: ScriptType | null, + ...ids: string[] +): TReceiveAddressList => ({ + scriptType, + addresses: [addr(ids[0]!), ...ids.slice(1).map(addr)] as NonEmptyArray, +}); + +const CODE = 'btc-acct' as AccountCode; + +describe('useReceiveAddresses', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseLoad.mockReturnValue(undefined); + }); + + it('returns empty/undefined values while loading', () => { + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.availableScriptTypes).toEqual([]); + expect(result.current.addresses).toBeUndefined(); + expect(result.current.currentAddress).toBeUndefined(); + expect(result.current.hasMultipleScriptTypes).toBe(false); + expect(result.current.hasMultipleAddresses).toBe(false); + }); + + it('derives availableScriptTypes in priority order', () => { + const data: NonEmptyArray = [ + makeGroup('p2wpkh-p2sh', 'a1'), + makeGroup('p2tr', 'b1'), + makeGroup('p2wpkh', 'c1'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.availableScriptTypes).toEqual(['p2wpkh', 'p2tr', 'p2wpkh-p2sh']); + }); + + it('selects preferred script type index', () => { + const data: NonEmptyArray = [ + makeGroup('p2wpkh', 'a1'), + makeGroup('p2tr', 'b1'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, 'p2tr')); + expect(result.current.addressTypeIndex).toBe(1); + expect(result.current.currentAddress?.addressID).toBe('b1'); + }); + + it('falls back to index 0 when preferred type is unavailable', () => { + const data: NonEmptyArray = [ + makeGroup('p2wpkh', 'a1'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, 'p2tr')); + expect(result.current.addressTypeIndex).toBe(0); + expect(result.current.currentAddress?.addressID).toBe('a1'); + }); + + it('falls back to index 0 when preferredScriptType is undefined', () => { + const data: NonEmptyArray = [ + makeGroup('p2wpkh', 'a1'), + makeGroup('p2tr', 'b1'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.addressTypeIndex).toBe(0); + expect(result.current.currentAddress?.addressID).toBe('a1'); + }); + + it('reports hasMultipleScriptTypes correctly', () => { + const single: NonEmptyArray = [makeGroup('p2wpkh', 'a1')]; + const multi: NonEmptyArray = [ + makeGroup('p2wpkh', 'a1'), + makeGroup('p2tr', 'b1'), + ]; + + mockUseLoad.mockReturnValue(single); + const { result: r1 } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(r1.current.hasMultipleScriptTypes).toBe(false); + + mockUseLoad.mockReturnValue(multi); + const { result: r2 } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(r2.current.hasMultipleScriptTypes).toBe(true); + }); + + it('reports hasMultipleAddresses correctly', () => { + const single: NonEmptyArray = [makeGroup('p2wpkh', 'a1')]; + const multi: NonEmptyArray = [makeGroup('p2wpkh', 'a1', 'a2')]; + + mockUseLoad.mockReturnValue(single); + const { result: r1 } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(r1.current.hasMultipleAddresses).toBe(false); + + mockUseLoad.mockReturnValue(multi); + const { result: r2 } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(r2.current.hasMultipleAddresses).toBe(true); + }); + + it('setActiveIndex changes currentAddress', () => { + const data: NonEmptyArray = [makeGroup('p2wpkh', 'a1', 'a2', 'a3')]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.currentAddress?.addressID).toBe('a1'); + + act(() => result.current.setActiveIndex(2)); + expect(result.current.currentAddress?.addressID).toBe('a3'); + }); + + it('setAddressTypeIndex switches address group and resets activeIndex', () => { + const data: NonEmptyArray = [ + makeGroup('p2wpkh', 'a1', 'a2'), + makeGroup('p2tr', 'b1', 'b2'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + + act(() => result.current.setActiveIndex(1)); + expect(result.current.currentAddress?.addressID).toBe('a2'); + + act(() => result.current.setAddressTypeIndex(1)); + expect(result.current.activeIndex).toBe(0); + expect(result.current.currentAddress?.addressID).toBe('b1'); + }); + + it('filters out script types not in priority list', () => { + const data: NonEmptyArray = [ + makeGroup('p2pkh' as ScriptType, 'old1'), + makeGroup('p2wpkh', 'a1'), + ]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.availableScriptTypes).toEqual(['p2wpkh']); + }); + + it('handles null scriptType groups gracefully', () => { + const data: NonEmptyArray = [makeGroup(null, 'eth1')]; + mockUseLoad.mockReturnValue(data); + const { result } = renderHook(() => useReceiveAddresses(CODE, undefined)); + expect(result.current.availableScriptTypes).toEqual([]); + expect(result.current.currentAddress?.addressID).toBe('eth1'); + }); +}); diff --git a/frontends/web/src/routes/account/receive/components/use-receive-addresses.ts b/frontends/web/src/routes/account/receive/components/use-receive-addresses.ts new file mode 100644 index 0000000000..1306da439a --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/use-receive-addresses.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import { useLoad } from '@/hooks/api'; +import * as accountApi from '@/api/account'; + +const SCRIPT_TYPE_PRIORITY: accountApi.ScriptType[] = ['p2wpkh', 'p2tr', 'p2wpkh-p2sh']; + +export const useReceiveAddresses = ( + code: accountApi.AccountCode, + preferredScriptType: accountApi.ScriptType | undefined, +) => { + const receiveAddresses = useLoad(accountApi.getReceiveAddressList(code), [code]) ?? undefined; + const [addressTypeIndex, setAddressTypeIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(0); + + const availableScriptTypes = useMemo(() => { + if (!receiveAddresses) { + return []; + } + return SCRIPT_TYPE_PRIORITY.filter(st => + receiveAddresses.some(group => group.scriptType === st) + ); + }, [receiveAddresses]); + + useEffect(() => { + if (!receiveAddresses || availableScriptTypes.length === 0) { + return; + } + const idx = preferredScriptType ? availableScriptTypes.indexOf(preferredScriptType) : -1; + setAddressTypeIndex(idx >= 0 ? idx : 0); + }, [preferredScriptType, availableScriptTypes, receiveAddresses]); + + useEffect(() => { + setActiveIndex(0); + }, [code, addressTypeIndex]); + + const currentScriptType = availableScriptTypes[addressTypeIndex]; + const currentGroup = receiveAddresses?.find(group => group.scriptType === currentScriptType) + ?? receiveAddresses?.[0]; + const addresses = currentGroup?.addresses; + const currentAddress = addresses?.[activeIndex]; + const hasMultipleScriptTypes = availableScriptTypes.length > 1; + const hasMultipleAddresses = (addresses?.length ?? 0) > 1; + + return { + availableScriptTypes, + addressTypeIndex, + setAddressTypeIndex, + activeIndex, + setActiveIndex, + addresses, + currentAddress, + hasMultipleScriptTypes, + hasMultipleAddresses, + }; +}; diff --git a/frontends/web/src/routes/account/receive/components/verify-prompt.module.css b/frontends/web/src/routes/account/receive/components/verify-prompt.module.css new file mode 100644 index 0000000000..8a404739e3 --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/verify-prompt.module.css @@ -0,0 +1,16 @@ +.bb02 { + margin-top: calc(var(--space-default) * 1.5); +} + +.continueText { + margin: 0 0 var(--space-three-quarter); + font-size: 18px; + text-align: center; +} + +@media (max-width: 768px) { + .bb02Illustration { + max-width: min(200px, 70%); + margin: auto; + } +} diff --git a/frontends/web/src/routes/account/receive/components/verify-prompt.tsx b/frontends/web/src/routes/account/receive/components/verify-prompt.tsx new file mode 100644 index 0000000000..2d4ad40548 --- /dev/null +++ b/frontends/web/src/routes/account/receive/components/verify-prompt.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useTranslation } from 'react-i18next'; +import { PointToBitBox02 } from '@/components/icon'; +import { Message } from '@/components/message/message'; +import style from './verify-prompt.module.css'; + +type TProps = { + isTesting: boolean; +}; + +export const VerifyPrompt = ({ isTesting }: TProps) => { + const { t } = useTranslation(); + + return ( +
+

{t('receive.continueOnBitBox')}

+ {isTesting && ( + + {t('receive.verifyTestnetWarning')} + + )} + +
+ ); +}; diff --git a/frontends/web/src/routes/account/receive/receive.module.css b/frontends/web/src/routes/account/receive/receive.module.css index 06e637bc1a..625b9fa043 100644 --- a/frontends/web/src/routes/account/receive/receive.module.css +++ b/frontends/web/src/routes/account/receive/receive.module.css @@ -1,65 +1,50 @@ -.labels { - align-items: center; - display: flex; - justify-content: space-between; - margin: var(--space-half) 0; +.content { + display: flex; + flex-direction: column; + width: 100%; + max-width: 100%; + height: 100%; + margin: 0 auto; + padding-bottom: calc(var(--space-half) + var(--space-quarter)); + text-align: left; } -.label { - color: var(--color-secondary); - flex-grow: 1; - font-size: var(--size-default); - font-weight: 400; - margin: 0; - padding: 0 var(--space-quarter); +.actions { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: calc(var(--space-default) * 1.5); } -.qrCodeContainer { - margin-bottom: var(--space-default); - min-height: 260px; +.actions button { + margin-top: var(--space-quarter); } -.previous, -.next { - appearance: none; - background: none; - border: 0; - color: var(--color-blue); - font-size: var(--size-default); - line-height: 1; - padding: 0; - text-decoration: none; +.alwaysVerify { + margin: 0 0 var(--space-half); } -.previous img, -.next img { - display: block; - height: min(2.4rem, 36px); - width: min(2.4rem, 36px); +.alwaysVerifyTitle { + margin: 0 0 var(--space-quarter); + font-size: var(--size-default); } -.hide { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: var(--background-secondary); +.alwaysVerifyBody { + margin: 0; + font-size: 14px; } -.changeType { - appearance: none; - background: none; - border: none; - color: var(--color-secondary); - cursor: pointer; - display: inline-block; - font-size: var(--size-small); - text-align: center; - text-decoration: underline; - padding: var(--space-quarter); -} +@media (max-width: 768px) { + .actions { + margin-top: auto; + } + + .backButton { + display: none; + } -.messageContainer { - margin-top: var(--space-quarter); -} \ No newline at end of file + .actions button { + width: 100%; + } +} diff --git a/frontends/web/src/routes/account/receive/receive.tsx b/frontends/web/src/routes/account/receive/receive.tsx index 63a64181a6..ae74cd5c04 100644 --- a/frontends/web/src/routes/account/receive/receive.tsx +++ b/frontends/web/src/routes/account/receive/receive.tsx @@ -1,186 +1,104 @@ // SPDX-License-Identifier: Apache-2.0 -import React, { useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { AppContext } from '@/contexts/AppContext'; -import { useLoad } from '@/hooks/api'; -import { UseBackButton } from '@/hooks/backbutton'; import * as accountApi from '@/api/account'; import { setAccountReceiveScriptType } from '@/api/backend'; -import { alertUser } from '@/components/alert/Alert'; -import { getScriptName, isEthereumBased } from '@/routes/account/utils'; -import { CopyableInput } from '@/components/copy/Copy'; -import { Dialog, DialogButtons, DialogScrollContent } from '@/components/dialog/dialog'; -import { Button, Radio } from '@/components/forms'; -import { BackButton } from '@/components/backbutton/backbutton'; +import { Header, Main } from '@/components/layout'; +import { View, ViewContent } from '@/components/view/view'; +import { MobileHeader } from '@/routes/settings/components/mobile-header'; +import { Button } from '@/components/forms'; import { Message } from '@/components/message/message'; -import { ReceiveGuide } from './components/guide'; -import { Header } from '@/components/layout'; -import { QRCode } from '@/components/qrcode/qrcode'; -import { ArrowCirlceLeft, ArrowCirlceLeftActive, ArrowCirlceRight, ArrowCirlceRightActive } from '@/components/icon'; -import { connectKeystore } from '@/api/keystores'; +import { alertUser } from '@/components/alert/Alert'; +import { findAccount, getAddressURIPrefix } from '@/routes/account/utils'; +import { + handleVerifyAddressWithDeviceResult, + verifyAddressWithDevice, +} from '@/routes/account/components/verify-address'; +import { useReceiveAddresses } from './components/use-receive-addresses'; +import { AddressCard } from './components/address-card'; +import { VerifyPrompt } from './components/verify-prompt'; +import { MoreOptions } from './components/more-options'; +import { AddressCycler } from './components/address-cycler'; +import { ScriptTypePicker } from './components/script-type-picker'; import style from './receive.module.css'; -type TProps = { +type TWrapperProps = { accounts: accountApi.TAccount[]; code: accountApi.AccountCode; }; -type TAddressTypeDialogProps = { - open: boolean; - setOpen: (open: boolean) => void; - preselectedAddressType: number; - availableScriptTypes?: accountApi.ScriptType[]; - insured: boolean; - handleAddressTypeChosen: (addressType: number) => void | Promise; -}; - -const AddressTypeDialog = ({ - open, - setOpen, - preselectedAddressType, - availableScriptTypes, - insured, - handleAddressTypeChosen, -}: TAddressTypeDialogProps) => { - const { t } = useTranslation(); - const [addressType, setAddressType] = useState(preselectedAddressType); - - useEffect(() => { - setAddressType(preselectedAddressType); - }, [open, preselectedAddressType]); - - return ( - setOpen(false)} medium title={t('receive.changeScriptType')} > -
) => { - e.preventDefault(); - handleAddressTypeChosen(addressType); - }} - style={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }} - > - - {availableScriptTypes && availableScriptTypes.map((scriptType, i) => ( -
- setAddressType(i)} - title={getScriptName(scriptType)}> - {t(`receive.scriptType.${scriptType}`)} - - {scriptType === 'p2tr' && addressType === i && ( - - {t('receive.taprootWarning')} - - )} -
- ))} - {insured && ( - - {t('receive.bitsuranceWarning')} - - )} -
- - - -
-
- ); +type TProps = { + account: accountApi.TAccount; }; -// For BTC/LTC: all possible address types we want to offer to the user, ordered by priority (first one is default). -// Types that are not available in the addresses delivered by the backend should be ignored. -const scriptTypes: accountApi.ScriptType[] = ['p2wpkh', 'p2tr', 'p2wpkh-p2sh']; - -// Find index in list of receive addresses that matches the given script type, or -1 if not found. -const getIndexOfMatchingScriptType = ( - receiveAddresses: accountApi.TReceiveAddressList[], - scriptType: accountApi.ScriptType -): number => { - if (!receiveAddresses) { - return -1; - } - return receiveAddresses.findIndex(addrs => addrs.scriptType !== null && scriptType === addrs.scriptType); -}; +type TVerifyState = 'idle' | 'connecting' | 'verifying' | 'verified' | 'error'; -const getAvailableScriptTypes = ( - receiveAddresses: accountApi.TReceiveAddressList[], -): accountApi.ScriptType[] => { - return scriptTypes.filter(scriptType => getIndexOfMatchingScriptType(receiveAddresses, scriptType) >= 0); -}; -const getReceiveScriptTypeIndex = ( - availableScriptTypes: accountApi.ScriptType[], - receiveScriptType?: accountApi.ScriptType, -): number => { - if (!receiveScriptType) { - return 0; +export const Receive = ({ accounts, code }: TWrapperProps) => { + const account = findAccount(accounts, code); + if (!account) { + return null; } - const scriptTypeIndex = availableScriptTypes.findIndex(scriptType => scriptType === receiveScriptType); - return scriptTypeIndex >= 0 ? scriptTypeIndex : 0; + return ; }; -export const Receive = ({ - accounts, - code, -}: TProps) => { +const ReceiveInner = ({ account }: TProps) => { const { t } = useTranslation(); - const [verifying, setVerifying] = useState(false); - const [activeIndex, setActiveIndex] = useState(0); - // index into `availableScriptTypes`, or 0 if none are available. - const [addressType, setAddressType] = useState(0); - const [addressTypeDialog, setAddressTypeDialog] = useState(false); - const [currentAddresses, setCurrentAddresses] = useState(); - const [currentAddressIndex, setCurrentAddressIndex] = useState(0); - - const account = accounts.find(({ code: accountCode }) => accountCode === code); - const insured = account?.bitsuranceStatus === 'active'; - - // first array index: address types. second array index: unused addresses of that address type. - const receiveAddresses = useLoad(accountApi.getReceiveAddressList(code)); - const availableScriptTypes = receiveAddresses ? getAvailableScriptTypes(receiveAddresses) : undefined; - const hasManyScriptTypes = availableScriptTypes && availableScriptTypes.length > 1; - + const navigate = useNavigate(); const { isTesting } = useContext(AppContext); + const { code, receiveScriptType } = account; + + const { + availableScriptTypes, + addressTypeIndex, + setAddressTypeIndex, + activeIndex, + setActiveIndex, + addresses, + currentAddress, + hasMultipleScriptTypes, + hasMultipleAddresses, + } = useReceiveAddresses(code, receiveScriptType); + + const [verifyState, setVerifyState] = useState('idle'); + const [showMoreOptions, setShowMoreOptions] = useState(false); useEffect(() => { - if (receiveAddresses) { - setAddressType(getReceiveScriptTypeIndex( - getAvailableScriptTypes(receiveAddresses), - account?.receiveScriptType, - )); - } - }, [account?.receiveScriptType, receiveAddresses]); + setVerifyState('idle'); + }, [code, addressTypeIndex]); - useEffect(() => { - if (receiveAddresses) { - const scriptTypes = getAvailableScriptTypes(receiveAddresses); - const scriptType = scriptTypes[addressType] as accountApi.ScriptType; - let addressIndex = scriptTypes.length > 0 ? getIndexOfMatchingScriptType(receiveAddresses, scriptType) : 0; - if (addressIndex === -1) { - addressIndex = 0; - } - setCurrentAddressIndex(addressIndex); - setCurrentAddresses(receiveAddresses[addressIndex]?.addresses); + const handleVerify = useCallback(async () => { + if (!currentAddress) { + return; } - }, [addressType, receiveAddresses]); - - const handleAddressTypeChosen = async (addressType: number) => { - const scriptType = availableScriptTypes?.[addressType]; - - setActiveIndex(0); - setAddressType(addressType); - setAddressTypeDialog(false); - - if (!scriptType || account?.receiveScriptType === scriptType) { + setVerifyState('connecting'); + const result = await verifyAddressWithDevice({ + code, + addressID: currentAddress.addressID, + rootFingerprint: account.keystore.rootFingerprint, + onSecureVerificationStart: () => setVerifyState('verifying'), + }); + handleVerifyAddressWithDeviceResult(result, { + onUserAbort: () => setVerifyState('error'), + onConnectFailed: () => setVerifyState('error'), + onSkipDeviceVerification: () => setVerifyState('verified'), + onVerified: () => setVerifyState('verified'), + onVerifyFailed: () => setVerifyState('error'), + }); + }, [code, currentAddress, account]); + + const handleScriptTypeChange = useCallback(async (nextIndex: number) => { + const scriptType = availableScriptTypes[nextIndex]; + if (!scriptType || nextIndex === addressTypeIndex) { + return; + } + setAddressTypeIndex(nextIndex); + if (receiveScriptType === scriptType) { return; } - try { const response = await setAccountReceiveScriptType(code, scriptType); if (!response.success) { @@ -189,198 +107,82 @@ export const Receive = ({ } catch (err) { console.error('Failed to persist receive script type', err); } - }; - - const verifyAddress = async (addressesIndex: number) => { - if (!receiveAddresses || account === undefined) { - return; - } - const connectResult = await connectKeystore(account.keystore.rootFingerprint); - if (!connectResult.success) { - return; - } - - const hasSecureOutput = await accountApi.hasSecureOutput(code)(); - if (!hasSecureOutput.hasSecureOutput) { - setVerifying('insecure'); - // For the software keystore, the dialog is dismissed manually. - return; - } - - // For devices with a display, the dialog is dismissed by tapping the device. - setVerifying('secure'); - try { - const addressesAtIndex = receiveAddresses[addressesIndex] as accountApi.TReceiveAddressList; - const address = addressesAtIndex.addresses[activeIndex] as accountApi.TReceiveAddress; - await accountApi.verifyAddress(code, address.addressID); - } finally { - setVerifying(false); - } - }; - - const previous = (e: React.SyntheticEvent) => { - e.preventDefault(); - if (!verifying && activeIndex > 0) { - setActiveIndex(activeIndex - 1); - } - }; - - const next = (e: React.SyntheticEvent, numAddresses: number) => { - e.preventDefault(); - if (!verifying && activeIndex < numAddresses - 1) { - setActiveIndex(activeIndex + 1); - } - }; - - let uriPrefix = ''; - if (account) { - if (account.coinCode === 'btc' || account.coinCode === 'tbtc') { - uriPrefix = 'bitcoin:'; - } else if (account.coinCode === 'ltc' || account.coinCode === 'tltc') { - uriPrefix = 'litecoin:'; - } - } + }, [availableScriptTypes, addressTypeIndex, setAddressTypeIndex, receiveScriptType, code, t]); - const currentAddress = currentAddresses?.[activeIndex]; - const address = currentAddress?.address || ''; - const displayAddress = currentAddress?.displayAddress ?? ''; - const displayedMainAddress = verifying ? displayAddress : (address ? address.substring(0, 8) + '...' : ''); + const uriPrefix = getAddressURIPrefix(account.coinCode); + const isVerifying = verifyState === 'verifying'; return ( -
-
-
-
{t('receive.title', { accountName: account?.coinName })}} /> -
-
- { currentAddresses && ( -
-
- -
-
- { currentAddresses.length > 1 && ( - - )} -

- {t('receive.label')} {currentAddresses.length > 1 ? `(${activeIndex + 1}/${currentAddresses.length})` : ''} -

- { currentAddresses.length > 1 && ( - - )} -
- - { (hasManyScriptTypes || insured) && ( - +
+
+

{t('receive.title', { accountName: account.coinName })}

+ navigate(-1)} + title={t('receive.title', { accountName: account.coinName })} + /> + + } /> + + + {currentAddress && addresses && ( +
+
+ +

{t('receive.alwaysVerifyTitle')}

+

{t('receive.alwaysVerifyBody')}

+
+
+ + + {isVerifying ? ( + + ) : ( + <> + {(hasMultipleAddresses || hasMultipleScriptTypes) && ( + setShowMoreOptions(prev => !prev)} + > + {hasMultipleAddresses && ( + + )} + {hasMultipleScriptTypes && ( + + )} + )} - - -
+
+ - - {t('button.back')} -
- { verifying && ( -
- )} - { - setVerifying(false); - } : undefined} - medium centered> - {account && ( - <> - {verifying && ( - { - if (verifying === 'insecure') { - setVerifying(false); - } - return false; - }} /> - )} -
- { isEthereumBased(account.coinCode) && ( -

- - {t('receive.onlyThisCoin.warning', { - coinName: account.coinName, - })} -
- {t('receive.onlyThisCoin.description')} -

- )} - -

{t('receive.verifyInstruction')}

-
-
- -
- {isTesting && ( - - {t('receive.verifyTestnetWarning')} - - )} - - )} -
-
+ )}
-
-
-
- 1 : false} - hasDifferentFormats={receiveAddresses ? receiveAddresses.length > 1 : false} - /> -
+ )} + + + ); }; diff --git a/frontends/web/src/utils/address.test.ts b/frontends/web/src/utils/address.test.ts index 2bb501f01f..bef2f76ed5 100644 --- a/frontends/web/src/utils/address.test.ts +++ b/frontends/web/src/utils/address.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { truncateMiddle } from './address'; +import { truncateDisplayAddress, truncateMiddle } from './address'; describe('truncateMiddle', () => { it('truncates using the default tx detail format', () => { @@ -22,3 +22,38 @@ describe('truncateMiddle', () => { expect(truncateMiddle('')).toBe(''); }); }); + +describe('truncateDisplayAddress', () => { + it('truncates ETH: 0x xxxx ... xxxx xxxx', () => { + const eth = '0x 1b2a A51d 13fF b9a2 3726 EACc 6F5d E262 7262 8b3c'; + expect(truncateDisplayAddress(eth)) + .toBe('0x 1b2a ... 7262 8b3c'); + }); + + it('truncates BTC: xxxx xxxx ... xxxx xx', () => { + const btc = 'bc1q w508 d6qe jxtd g4y5 r3za rv00 0000 aaaa bb'; + expect(truncateDisplayAddress(btc)) + .toBe('bc1q w508 ... aaaa bb'); + }); + + it('returns short address as-is (<=4 groups)', () => { + expect(truncateDisplayAddress('bc1q w508 d6qe')).toBe('bc1q w508 d6qe'); + }); + + it('returns 4-group address as-is', () => { + expect(truncateDisplayAddress('abcd efgh ijkl mnop')).toBe('abcd efgh ijkl mnop'); + }); + + it('truncates 5+ groups', () => { + expect(truncateDisplayAddress('abcd efgh ijkl mnop qrst')) + .toBe('abcd efgh ... mnop qrst'); + }); + + it('returns empty string for empty input', () => { + expect(truncateDisplayAddress('')).toBe(''); + }); + + it('returns single group as-is', () => { + expect(truncateDisplayAddress('abcdefgh')).toBe('abcdefgh'); + }); +}); diff --git a/frontends/web/src/utils/address.ts b/frontends/web/src/utils/address.ts index a0ca711c86..d6550e48ae 100644 --- a/frontends/web/src/utils/address.ts +++ b/frontends/web/src/utils/address.ts @@ -15,3 +15,16 @@ export const truncateMiddle = ( return `${value.slice(0, leadingChars)}...${value.slice(-trailingChars)}`; }; + +// shortens a backend-formatted address (space-separated groups) for display. +// takes the first 2 and last 2 groups with "..." in between. +// short addresses (<=4 groups) are returned as-is. +export const truncateDisplayAddress = (displayAddress: string): string => { + const groups = displayAddress.split(/\s+/).filter(Boolean); + if (groups.length <= 4) { + return displayAddress; + } + const start = groups.slice(0, 2).join(' '); + const end = groups.slice(-2).join(' '); + return `${start} ... ${end}`; +}; diff --git a/frontends/web/tests/backup-reminder-allowed.test.ts b/frontends/web/tests/backup-reminder-allowed.test.ts index 2b262ae6f9..833dee31fd 100644 --- a/frontends/web/tests/backup-reminder-allowed.test.ts +++ b/frontends/web/tests/backup-reminder-allowed.test.ts @@ -81,7 +81,7 @@ const unlockWalletAndGetReceiveAddress = async ( await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); const addressLocator = page.locator('[data-testid="receive-address"]'); const receiveAddress = await getReceiveAddressData(page, host, servewalletPort); - await expect(addressLocator).toHaveValue(receiveAddress.displayAddress); + await expect(addressLocator).toContainText(receiveAddress.displayAddress); return receiveAddress.address; }; diff --git a/frontends/web/tests/send.test.ts b/frontends/web/tests/send.test.ts index 30a8462c62..d5afea4d08 100644 --- a/frontends/web/tests/send.test.ts +++ b/frontends/web/tests/send.test.ts @@ -38,7 +38,7 @@ test('Send BTC', async ({ page, host, frontendPort, servewalletPort, browserName await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); const addressLocator = page.locator('[data-testid="receive-address"]'); const receiveAddress = await getReceiveAddressData(page, host, servewalletPort); - await expect(addressLocator).toHaveValue(receiveAddress.displayAddress); + await expect(addressLocator).toContainText(receiveAddress.displayAddress); recvAdd = receiveAddress.address; expect(recvAdd).toContain('bcrt1'); console.log(`Receive address: ${recvAdd}`); @@ -95,7 +95,7 @@ test('Send BTC', async ({ page, host, frontendPort, servewalletPort, browserName await verifyButton.click(); const addressLocator = page.locator('[data-testid="receive-address"]'); const receiveAddress = await getReceiveAddressData(page, host, servewalletPort); - await expect(addressLocator).toHaveValue(receiveAddress.displayAddress); + await expect(addressLocator).toContainText(receiveAddress.displayAddress); secondAccountCode = getAccountCodeFromUrl(page.url()); recvAdd = receiveAddress.address; expect(recvAdd).toContain('bcrt1'); @@ -166,7 +166,7 @@ test('Send BTC', async ({ page, host, frontendPort, servewalletPort, browserName await verifyButton.click(); const addressLocator = page.locator('[data-testid="receive-address"]'); const receiveAddress = await getReceiveAddressData(page, host, servewalletPort); - await expect(addressLocator).toHaveValue(receiveAddress.displayAddress); + await expect(addressLocator).toContainText(receiveAddress.displayAddress); recvAdd = receiveAddress.address; expect(recvAdd).toContain('bcrt1'); console.log(`Receive address: ${recvAdd}`);