From e339e63c873deaf0a58cf632fd645a92172e9a1e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:32:44 +0100 Subject: [PATCH 1/2] feat: add userAddresses support for linked address selection - Create useAddressItems hook for consistent address handling across screens - Update buy.screen.tsx to support linked addresses (userAddresses) - Update sell.screen.tsx to support linked addresses (userAddresses) - Fix double div in swap.screen.tsx - Fix incorrect form rules in swap.screen.tsx (bankAccount/asset/currency -> sourceAsset/targetAsset) The hook generates address items that include: - All addresses from linked wallets (userAddresses) - Current session address - 'Switch address' option for wallet switching This enables cross-chain operations where users can receive funds on linked addresses (e.g., Lightning via Bitcoin wallet). --- src/hooks/address-items.hook.ts | 84 +++++++++++++++++++++++++++++++++ src/screens/buy.screen.tsx | 36 ++++---------- src/screens/sell.screen.tsx | 43 +++++------------ src/screens/swap.screen.tsx | 37 +++++++-------- 4 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 src/hooks/address-items.hook.ts diff --git a/src/hooks/address-items.hook.ts b/src/hooks/address-items.hook.ts new file mode 100644 index 00000000..a19e8a9c --- /dev/null +++ b/src/hooks/address-items.hook.ts @@ -0,0 +1,84 @@ +import { Blockchain, Session, UserAddress, useAuthContext, useUserContext } from '@dfx.swiss/react'; +import { useMemo } from 'react'; +import { addressLabel } from 'src/config/labels'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useBlockchain } from './blockchain.hook'; + +export interface AddressItem { + address?: string; + addressLabel: string; + label: string; + chain?: Blockchain; +} + +interface UseAddressItemsParams { + availableBlockchains?: Blockchain[]; +} + +interface UseAddressItemsResult { + addressItems: AddressItem[]; + userSessions: (Session | UserAddress)[]; +} + +/** + * Hook to generate address items for blockchain/address selection. + * Supports linked addresses (userAddresses) for cross-chain operations. + */ +export function useAddressItems({ availableBlockchains }: UseAddressItemsParams = {}): UseAddressItemsResult { + const { session } = useAuthContext(); + const { userAddresses } = useUserContext(); + const { translate } = useSettingsContext(); + const { toString } = useBlockchain(); + + // Combine current session with linked addresses, removing duplicates + const userSessions = useMemo(() => { + return [session, ...userAddresses].filter( + (a, i, arr) => a && arr.findIndex((b) => b?.address === a.address) === i, + ) as (Session | UserAddress)[]; + }, [session, userAddresses]); + + // Create address items with their blockchains + const userAddressItems = useMemo(() => { + return userSessions.map((a) => ({ + address: a.address, + addressLabel: addressLabel(a), + blockchains: a.blockchains, + })); + }, [userSessions]); + + // Filter blockchains based on available blockchains and user's linked addresses + const validBlockchains = useMemo(() => { + const userBlockchains = userAddressItems.flatMap((a) => a.blockchains).filter((b, i, arr) => arr.indexOf(b) === i); + + return availableBlockchains + ? userBlockchains.filter((b) => availableBlockchains.includes(b)) + : userBlockchains; + }, [userAddressItems, availableBlockchains]); + + // Generate address items for dropdown + const addressItems: AddressItem[] = useMemo(() => { + if (userAddressItems.length === 0 || validBlockchains.length === 0) { + return []; + } + + const items: AddressItem[] = validBlockchains.flatMap((blockchain) => { + const addressesForBlockchain = userAddressItems.filter((a) => a.blockchains.includes(blockchain)); + return addressesForBlockchain.map((a) => ({ + address: a.address, + addressLabel: a.addressLabel, + label: toString(blockchain), + chain: blockchain, + })); + }); + + // Add "Switch address" option + items.push({ + addressLabel: translate('screens/buy', 'Switch address'), + label: translate('screens/buy', 'Login with a different address'), + }); + + return items; + }, [userAddressItems, validBlockchains, toString, translate]); + + return { addressItems, userSessions }; +} diff --git a/src/screens/buy.screen.tsx b/src/screens/buy.screen.tsx index 2a1b1499..6e48548c 100644 --- a/src/screens/buy.screen.tsx +++ b/src/screens/buy.screen.tsx @@ -51,11 +51,11 @@ import { BuyCompletion } from '../components/payment/buy-completion'; import { PrivateAssetHint } from '../components/private-asset-hint'; import { QuoteErrorHint } from '../components/quote-error-hint'; import { SanctionHint } from '../components/sanction-hint'; -import { addressLabel } from '../config/labels'; import { useAppHandlingContext } from '../contexts/app-handling.context'; import { useLayoutContext } from '../contexts/layout.context'; import { useSettingsContext } from '../contexts/settings.context'; import { useWalletContext } from '../contexts/wallet.context'; +import { AddressItem, useAddressItems } from '../hooks/address-items.hook'; import { useAppParams } from '../hooks/app-params.hook'; import { useBlockchain } from '../hooks/blockchain.hook'; import useDebounce from '../hooks/debounce.hook'; @@ -68,19 +68,13 @@ enum Side { GET = 'GET', } -interface Address { - address: string; - label: string; - chain?: Blockchain; -} - interface FormData { amount: string; currency: Fiat; paymentMethod: FiatPaymentMethod; asset: Asset; targetAmount: string; - address: Address; + address: AddressItem; } interface ValidatedData extends BuyPaymentInfo { @@ -121,6 +115,11 @@ export default function BuyScreen(): JSX.Element { const { rootRef } = useLayoutContext(); const { isInitialized } = useAppHandlingContext(); + const filteredAssets = assets && filterAssets(Array.from(assets.values()).flat(), assetFilter); + const targetBlockchains = availableBlockchains?.filter((b) => filteredAssets?.some((a) => a.blockchain === b)); + + const { addressItems } = useAddressItems({ availableBlockchains: targetBlockchains }); + const [availableAssets, setAvailableAssets] = useState(); const [paymentInfo, setPaymentInfo] = useState(); const [customAmountError, setCustomAmountError] = useState(); @@ -148,23 +147,6 @@ export default function BuyScreen(): JSX.Element { setValue(field, value, { shouldValidate: true }); } - const filteredAssets = assets && filterAssets(Array.from(assets.values()).flat(), assetFilter); - const blockchains = availableBlockchains?.filter((b) => filteredAssets?.some((a) => a.blockchain === b)); - - const addressItems: Address[] = - session?.address && blockchains?.length - ? [ - ...blockchains.map((b) => ({ - address: addressLabel(session), - label: toString(b), - chain: b, - })), - { - address: translate('screens/buy', 'Switch address'), - label: translate('screens/buy', 'Login with a different address'), - }, - ] - : []; const availablePaymentMethods = [FiatPaymentMethod.BANK]; // no instant payments ATM @@ -564,11 +546,11 @@ export default function BuyScreen(): JSX.Element { {!hideTargetSelection && ( - + rootRef={rootRef} name="address" items={addressItems} - labelFunc={(item) => blankedAddress(item.address, { width })} + labelFunc={(item) => blankedAddress(item.addressLabel, { width })} descriptionFunc={(item) => item.label} full forceEnable diff --git a/src/screens/sell.screen.tsx b/src/screens/sell.screen.tsx index e3b8fce9..68eed785 100644 --- a/src/screens/sell.screen.tsx +++ b/src/screens/sell.screen.tsx @@ -40,7 +40,6 @@ import { BankAccountSelector } from 'src/components/order/bank-account-selector' import { AddressSwitch } from 'src/components/payment/address-switch'; import { PaymentInformationContent } from 'src/components/payment/payment-info-sell'; import { PrivateAssetHint } from 'src/components/private-asset-hint'; -import { addressLabel } from 'src/config/labels'; import { Urls } from 'src/config/urls'; import { useLayoutContext } from 'src/contexts/layout.context'; import { useWindowContext } from 'src/contexts/window.context'; @@ -54,6 +53,7 @@ import { CloseType, useAppHandlingContext } from '../contexts/app-handling.conte import { AssetBalance } from '../contexts/balance.context'; import { useSettingsContext } from '../contexts/settings.context'; import { useWalletContext } from '../contexts/wallet.context'; +import { AddressItem, useAddressItems } from '../hooks/address-items.hook'; import { useAppParams } from '../hooks/app-params.hook'; import { useBlockchain } from '../hooks/blockchain.hook'; import useDebounce from '../hooks/debounce.hook'; @@ -68,19 +68,13 @@ enum Side { GET = 'GET', } -interface Address { - address: string; - label: string; - chain?: Blockchain; -} - interface FormData { bankAccount: BankAccount; currency: Fiat; asset: Asset; amount: string; targetAmount: string; - address: Address; + address: AddressItem; } interface CustomAmountError { @@ -126,6 +120,11 @@ export default function SellScreen(): JSX.Element { const { toString } = useBlockchain(); const { rootRef } = useLayoutContext(); + const filteredAssets = assets && filterAssets(Array.from(assets.values()).flat(), assetFilter); + const sourceBlockchains = availableBlockchains?.filter((b) => filteredAssets?.some((a) => a.blockchain === b)); + + const { addressItems } = useAddressItems({ availableBlockchains: sourceBlockchains }); + const [availableAssets, setAvailableAssets] = useState(); const [customAmountError, setCustomAmountError] = useState(); const [errorMessage, setErrorMessage] = useState(); @@ -153,32 +152,16 @@ export default function SellScreen(): JSX.Element { const availableBalance = selectedAsset && findBalance(selectedAsset); useEffect(() => { - availableAssets && getBalances(availableAssets, selectedAddress?.address, selectedAddress?.chain).then(setBalances); - }, [getBalances, availableAssets]); + if (availableAssets && selectedAddress?.address) { + getBalances(availableAssets, selectedAddress.address, selectedAddress.chain).then(setBalances); + } + }, [getBalances, availableAssets, selectedAddress]); // default params function setVal(field: FieldPath, value: FieldPathValue>) { setValue(field, value, { shouldValidate: true }); } - const filteredAssets = assets && filterAssets(Array.from(assets.values()).flat(), assetFilter); - const blockchains = availableBlockchains?.filter((b) => filteredAssets?.some((a) => a.blockchain === b)); - - const addressItems: Address[] = - session?.address && blockchains?.length - ? [ - ...blockchains.map((b) => ({ - address: addressLabel(session), - label: toString(b), - chain: b, - })), - { - address: translate('screens/buy', 'Switch address'), - label: translate('screens/buy', 'Login with a different address'), - }, - ] - : []; - useEffect(() => { const activeBlockchain = walletBlockchain ?? blockchain; const blockchains = activeBlockchain ? [activeBlockchain as Blockchain] : availableBlockchains ?? []; @@ -595,11 +578,11 @@ export default function SellScreen(): JSX.Element { {!hideTargetSelection && ( - + rootRef={rootRef} name="address" items={addressItems} - labelFunc={(item) => blankedAddress(item.address, { width })} + labelFunc={(item) => blankedAddress(item.addressLabel, { width })} descriptionFunc={(item) => item.label} full forceEnable diff --git a/src/screens/swap.screen.tsx b/src/screens/swap.screen.tsx index 10dcf8bb..6ab38844 100644 --- a/src/screens/swap.screen.tsx +++ b/src/screens/swap.screen.tsx @@ -556,9 +556,8 @@ export default function SwapScreen(): JSX.Element { } const rules = Utils.createRules({ - bankAccount: Validations.Required, - asset: Validations.Required, - currency: Validations.Required, + sourceAsset: Validations.Required, + targetAsset: Validations.Required, amount: Validations.Required, }); @@ -652,23 +651,21 @@ export default function SwapScreen(): JSX.Element { />
-
- - rootRef={rootRef} - name="targetAsset" - placeholder={translate('general/actions', 'Select') + '...'} - items={targetAssets} - labelFunc={(item) => item.name} - balanceFunc={findBalanceString} - assetIconFunc={(item) => item.name as AssetIconVariant} - descriptionFunc={(item) => item.description} - filterFunc={(item: Asset, search?: string | undefined) => - !search || item.name.toLowerCase().includes(search.toLowerCase()) - } - hideBalanceWhenClosed - full - /> -
+ + rootRef={rootRef} + name="targetAsset" + placeholder={translate('general/actions', 'Select') + '...'} + items={targetAssets} + labelFunc={(item) => item.name} + balanceFunc={findBalanceString} + assetIconFunc={(item) => item.name as AssetIconVariant} + descriptionFunc={(item) => item.description} + filterFunc={(item: Asset, search?: string | undefined) => + !search || item.name.toLowerCase().includes(search.toLowerCase()) + } + hideBalanceWhenClosed + full + />
From 8206dde6b8c287525fed51914bbf62d9fca0b069 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:39:07 +0100 Subject: [PATCH 2/2] refactor: use useAddressItems hook in swap.screen.tsx - Remove duplicated userSessions, userAddressItems, sourceBlockchains, targetBlockchains, addressItems logic from swap.screen.tsx - Use shared useAddressItems hook for consistent behavior across all screens - Hook now returns availableBlockchains for asset filtering - Remove unused userSessions from hook return value This eliminates ~60 lines of duplicated code and ensures consistent address/blockchain selection behavior across buy, sell, and swap screens. --- src/hooks/address-items.hook.ts | 4 +- src/screens/swap.screen.tsx | 78 +++++++-------------------------- 2 files changed, 18 insertions(+), 64 deletions(-) diff --git a/src/hooks/address-items.hook.ts b/src/hooks/address-items.hook.ts index a19e8a9c..0e748000 100644 --- a/src/hooks/address-items.hook.ts +++ b/src/hooks/address-items.hook.ts @@ -17,7 +17,7 @@ interface UseAddressItemsParams { interface UseAddressItemsResult { addressItems: AddressItem[]; - userSessions: (Session | UserAddress)[]; + availableBlockchains: Blockchain[]; } /** @@ -80,5 +80,5 @@ export function useAddressItems({ availableBlockchains }: UseAddressItemsParams return items; }, [userAddressItems, validBlockchains, toString, translate]); - return { addressItems, userSessions }; + return { addressItems, availableBlockchains: validBlockchains }; } diff --git a/src/screens/swap.screen.tsx b/src/screens/swap.screen.tsx index 6ab38844..ff6ecf90 100644 --- a/src/screens/swap.screen.tsx +++ b/src/screens/swap.screen.tsx @@ -3,12 +3,10 @@ import { Asset, AssetCategory, Blockchain, - Session, Swap, SwapPaymentInfo, TransactionError, TransactionType, - UserAddress, Utils, Validations, useAsset, @@ -16,7 +14,6 @@ import { useAuthContext, useSessionContext, useSwap, - useUserContext, } from '@dfx.swiss/react'; import { AssetIconVariant, @@ -37,7 +34,6 @@ import { useEffect, useState } from 'react'; import { FieldPath, FieldPathValue, useForm, useWatch } from 'react-hook-form'; import { PaymentInformationContent } from 'src/components/payment/payment-info-sell'; import { PrivateAssetHint } from 'src/components/private-asset-hint'; -import { addressLabel } from 'src/config/labels'; import { Urls } from 'src/config/urls'; import { useLayoutContext } from 'src/contexts/layout.context'; import { useWindowContext } from 'src/contexts/window.context'; @@ -55,6 +51,7 @@ import { CloseType, useAppHandlingContext } from '../contexts/app-handling.conte import { AssetBalance } from '../contexts/balance.context'; import { useSettingsContext } from '../contexts/settings.context'; import { useWalletContext } from '../contexts/wallet.context'; +import { AddressItem, useAddressItems } from '../hooks/address-items.hook'; import { useAppParams } from '../hooks/app-params.hook'; import { useBlockchain } from '../hooks/blockchain.hook'; import { useAddressGuard } from '../hooks/guard.hook'; @@ -66,19 +63,12 @@ enum Side { GET = 'GET', } -interface Address { - address?: string; - addressLabel: string; - label: string; - chain?: Blockchain; -} - interface FormData { sourceAsset: Asset; targetAsset: Asset; amount: string; targetAmount: string; - address: Address; + address: AddressItem; } interface CustomAmountError { @@ -102,7 +92,6 @@ export default function SwapScreen(): JSX.Element { const { logout } = useSessionContext(); const { session } = useAuthContext(); const { width } = useWindowContext(); - const { userAddresses } = useUserContext(); const { assets, getAssets } = useAssetContext(); const { getAsset, isSameAsset } = useAsset(); const { navigate } = useNavigation(); @@ -171,53 +160,20 @@ export default function SwapScreen(): JSX.Element { const availableBalance = selectedSourceAsset && findBalance(selectedSourceAsset); const filteredAssets = assets && filterAssets(Array.from(assets.values()).flat(), assetFilter); + const filteredBlockchains = filteredAssets + ?.map((a) => a.blockchain) + .filter((b, i, arr) => arr.indexOf(b) === i); - const userSessions = [session, ...userAddresses].filter( - (a, i, arr) => a && arr.findIndex((b) => b?.address === a.address) === i, - ) as (Session | UserAddress)[]; - - const userAddressItems = userSessions.map((a) => ({ - address: a.address, - addressLabel: addressLabel(a), - blockchains: a.blockchains, - })); - - // Source blockchains: all blockchains from user addresses (including linked addresses like Lightning) - const sourceBlockchains = userAddressItems - .flatMap((a) => a.blockchains) - .filter((b, i, arr) => arr.indexOf(b) === i) - .filter((b) => filteredAssets?.some((a) => a.blockchain === b)); - - const targetBlockchains = userAddressItems - .flatMap((a) => a.blockchains) - .filter((b, i, arr) => arr.indexOf(b) === i) - .filter((b) => filteredAssets?.some((a) => a.blockchain === b)); - - const addressItems: Address[] = - userAddressItems.length > 0 && targetBlockchains?.length - ? [ - ...targetBlockchains.flatMap((b) => { - const addresses = userAddressItems.filter((a) => a.blockchains.includes(b)); - return addresses.map((a) => ({ - address: a.address, - addressLabel: a.addressLabel, - label: toString(b), - chain: b, - })); - }), - { - addressLabel: translate('screens/buy', 'Switch address'), - label: translate('screens/buy', 'Login with a different address'), - }, - ] - : []; + const { addressItems, availableBlockchains: swapBlockchains } = useAddressItems({ + availableBlockchains: filteredBlockchains, + }); useEffect(() => { - const blockchainSourceAssets = getAssets(sourceBlockchains ?? [], { sellable: true, comingSoon: false }); + const blockchainSourceAssets = getAssets(swapBlockchains ?? [], { sellable: true, comingSoon: false }); const activeSourceAssets = filterAssets(blockchainSourceAssets, assetFilter); setSourceAssets(activeSourceAssets); - const activeTargetBlockchains = blockchain ? [blockchain as Blockchain] : (targetBlockchains ?? []); + const activeTargetBlockchains = blockchain ? [blockchain as Blockchain] : (swapBlockchains ?? []); const blockchainTargetAssets = getAssets(activeTargetBlockchains ?? [], { buyable: true, comingSoon: false }); const activeTargetAssets = filterAssets(blockchainTargetAssets, assetFilter); setTargetAssets(activeTargetAssets); @@ -237,9 +193,7 @@ export default function SwapScreen(): JSX.Element { getAssets, blockchain, walletBlockchain, - sourceBlockchains?.length, - targetBlockchains?.length, - userAddresses.length, + swapBlockchains?.length, ]); useEffect(() => { @@ -252,11 +206,11 @@ export default function SwapScreen(): JSX.Element { } }, [amountIn, selectedSourceAsset]); - useEffect(() => setAddress(), [session?.address, translate, blockchain, userAddresses, addressItems.length]); + useEffect(() => setAddress(), [session?.address, translate, blockchain, addressItems.length]); - // When assetOut is set and userAddresses are loaded, ensure the correct blockchain is selected + // When assetOut is set and addresses are loaded, ensure the correct blockchain is selected useEffect(() => { - if (assetOut && userAddresses.length > 0) { + if (assetOut && addressItems.length > 1) { const assetOutBlockchain = assetOut.split('/')[0]; const hasAddressForBlockchain = addressItems.some((a) => a.chain === assetOutBlockchain); @@ -266,7 +220,7 @@ export default function SwapScreen(): JSX.Element { switchBlockchain(assetOutBlockchain as Blockchain); } } - }, [assetOut, userAddresses.length, addressItems.length]); + }, [assetOut, addressItems.length]); useEffect(() => { if (selectedAddress) { @@ -670,7 +624,7 @@ export default function SwapScreen(): JSX.Element { {!hideTargetSelection && ( - + rootRef={rootRef} name="address" items={addressItems}