Skip to content

Commit 7593eaa

Browse files
xavier-brochardclaudezone-live
authored
fix: exclude target asset from Quick Buy pay-with (TSA-660) (#31455)
## **Description** <!-- mms-check: type=text required=true --> The Quick Buy bottom sheet's "Pay with" picker listed every token the user holds — including the very asset being bought or sold. Selecting it created a source == destination pair that can never produce quotes ("No quotes available", `1 ETH = 1 ETH`). The destination-exclusion logic existed only in the *default* source-token preselection (`selectDefaultSourceToken`), not in the picker list itself. This PR: - Extracts the destination-matching predicate into an exported `isSameAsset(token, other)` helper (`app/components/Views/SocialLeaderboard/utils/tokenSelection.ts`): chainId + case-insensitive address match, with a symbol fallback only for non-EVM (CAIP) chains to cover differing CAIP-19 forms. No symbol fallback on EVM to avoid fake-token symbol collisions. `selectDefaultSourceToken` now delegates to it — behavior unchanged. - Filters the "Pay with" options in `useQuickBuyController` against the destination asset, so the buy target never appears in the picker. - Adds a fallback effect: if the currently-selected pay token resolves to the destination asset (e.g. selection made before ERC-20 metadata normalized the destination address), it re-selects the best non-destination holding, or clears the selection — never silently keeps an unquotable pair. Jira: [TSA-660](https://consensyssoftware.atlassian.net/browse/TSA-660) <img height="790" alt="Simulator Screenshot - iPhone 17 Pro - 2026-06-11 at 14 04 39" src="https://github.com/user-attachments/assets/c368ef86-dd64-4bb1-8f82-7a3d60507465" /> ## **Changelog** <!-- mms-check: type=changelog required=true --> CHANGELOG entry: null ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: #31414 ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Quick Buy pay-with token list Scenario: user opens the pay-with picker for a token they hold Given the user holds USDC on Base And the user opens Quick Buy for USDC on Base from the token details page When the user opens the "Pay with" token picker Then USDC on Base is not listed as a payment option And other held tokens (e.g. ETH on Base) are listed Scenario: user holds only the asset being bought Given the user holds only USDC on Base And the user opens Quick Buy for USDC on Base When the "Pay with" selection resolves Then no payment token is auto-selected And the buy button stays disabled ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- Generated with the help of the pr-description AI skill --> [TSA-660]: https://consensyssoftware.atlassian.net/browse/TSA-660?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped to Social Leaderboard Quick Buy token selection with broad unit tests; no auth, payments, or bridge execution path changes beyond picker/filter logic. > > **Overview** > Fixes **TSA-660** by stopping Quick Buy from offering or keeping a **pay-with token that matches the buy target**, which produced unquotable same-asset pairs. > > A shared **`isSameAsset`** helper is added in `tokenSelection.ts` (same chain + case-insensitive address; symbol fallback only on non-EVM CAIP chains). **`selectDefaultSourceToken`** now uses it instead of inline matching. > > In **`useQuickBuyController`**, held tokens are filtered into **`sourceTokenOptions`** so the destination never appears in the picker, and a new **effect** re-picks when metadata later shows the selected source is actually the destination (e.g. after ERC-20 address normalization). > > Tests cover filtering, cross-chain same-address tokens, Solana CAIP vs mint forms, sole-holding edge cases, and the post-resolve fallback. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 849ad98. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Antonio Regadas <antonio.regadas@consensys.net>
1 parent 67ec7c2 commit 7593eaa

3 files changed

Lines changed: 338 additions & 18 deletions

File tree

app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import {
2929
formatMinimumReceived,
3030
} from '../../../../../../UI/Bridge/utils/currencyUtils';
3131
import { isGaslessQuote } from '../../../../../../UI/Bridge/utils/isGaslessQuote';
32-
import { selectDefaultSourceToken } from '../../../../utils/tokenSelection';
32+
import {
33+
isSameAsset,
34+
selectDefaultSourceToken,
35+
} from '../../../../utils/tokenSelection';
3336
import type {
3437
QuickBuyAmountDisplayMode,
3538
QuickBuyAnalyticsContext,
@@ -298,7 +301,7 @@ export function useQuickBuyController(
298301
} = useQuickBuySetup(target);
299302

300303
// ─── Buy "Pay with" options (tokens the user holds) ─────────────────────
301-
const { options: sourceTokenOptions } = usePayWithTokens();
304+
const { options: heldTokenOptions } = usePayWithTokens();
302305
const [selectedSourceToken, setSelectedSourceToken] = useState<
303306
BridgeToken | undefined
304307
>(undefined);
@@ -307,9 +310,10 @@ export function useQuickBuyController(
307310
// the auto-select effect is allowed to correct a stale selection.
308311
const isManualSelectionRef = useRef(false);
309312

310-
// Dest-token lookup key passed to `selectDefaultSourceToken` so the
311-
// destination is filtered out of source candidates and not preselected as
312-
// pay-with. Reads from `positionTokenFromSetup` once available because
313+
// Dest-token lookup key used to exclude the destination from the "Pay with"
314+
// options and from the default source-token selection — a source equal to
315+
// the destination can never produce a quote.
316+
// Reads from `positionTokenFromSetup` once available because
313317
// `useQuickBuySetup` normalises `address` to match what `sourceTokenOptions`
314318
// contains — bare hex for EVM (zero address for native, mint hex for
315319
// ERC-20) and CAIP-19 for non-EVM. Comparing against the raw
@@ -337,6 +341,20 @@ export function useQuickBuyController(
337341
target.tokenSymbol,
338342
]);
339343

344+
// "Pay with" options surfaced to the picker: the asset being bought is
345+
// excluded so the user can never select a source equal to the buy target
346+
// (TSA-660). While ERC-20 metadata resolves, `destLookupKey` falls back to
347+
// the raw target values; the non-EVM symbol fallback in `isSameAsset`
348+
// covers cross-format mismatches in that window, and the dest is filtered
349+
// out on the next render once the normalised address lands.
350+
const sourceTokenOptions = useMemo(
351+
() =>
352+
destLookupKey
353+
? heldTokenOptions.filter((token) => !isSameAsset(token, destLookupKey))
354+
: heldTokenOptions,
355+
[heldTokenOptions, destLookupKey],
356+
);
357+
340358
// Auto-select default source token using smart priority rules (see
341359
// `selectDefaultSourceToken`). `destLookupKey` is passed so the destination
342360
// is deprioritized and not preselected when the user has other holdings.
@@ -363,6 +381,21 @@ export function useQuickBuyController(
363381
destLookupKey,
364382
]);
365383

384+
// If the current selection turns out to BE the destination asset — e.g. it
385+
// was picked while ERC-20 metadata was still resolving and the normalised
386+
// dest address only matched afterwards — fall back to the best non-dest
387+
// option instead of silently keeping a same-token pair that can never
388+
// quote. Clears the manual flag so the auto-select effect can take over
389+
// again if the fallback yields nothing.
390+
useEffect(() => {
391+
if (!selectedSourceToken || !destLookupKey) return;
392+
if (!isSameAsset(selectedSourceToken, destLookupKey)) return;
393+
isManualSelectionRef.current = false;
394+
setSelectedSourceToken(
395+
selectDefaultSourceToken(sourceTokenOptions, destChainId, destLookupKey),
396+
);
397+
}, [selectedSourceToken, destLookupKey, sourceTokenOptions, destChainId]);
398+
366399
// ─── Sell mode: position token (what the user is selling) ──────────────
367400
const positionToken = usePositionTokenBalance(target, positionTokenFromSetup);
368401

app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts

Lines changed: 272 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import useIsInsufficientBalance from '../../../../../UI/Bridge/hooks/useInsuffic
2828
import { useLatestBalance } from '../../../../../UI/Bridge/hooks/useLatestBalance';
2929
import { usePriceImpactViewData } from '../../../../../UI/Bridge/hooks/usePriceImpactViewData';
3030
import type { BridgeToken } from '../../../../../UI/Bridge/types';
31-
import { selectDefaultSourceToken } from '../../../utils/tokenSelection';
31+
import {
32+
isSameAsset,
33+
selectDefaultSourceToken,
34+
} from '../../../utils/tokenSelection';
3235
import { usePayWithTokens } from './hooks/usePayWithTokens';
3336
import { usePositionTokenBalance } from './hooks/usePositionTokenBalance';
3437
import { useQuickBuyController } from './hooks/useQuickBuyController';
@@ -1857,6 +1860,274 @@ describe('useQuickBuyController', () => {
18571860
});
18581861
});
18591862

1863+
describe('sourceTokenOptions destination filtering (TSA-660)', () => {
1864+
it('excludes the asset being bought from the pay-with options (case-insensitive address match)', () => {
1865+
const destChecksumAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
1866+
(useQuickBuySetup as jest.Mock).mockReturnValue({
1867+
chainId: '0x1',
1868+
destToken: {
1869+
address: destChecksumAddress,
1870+
chainId: '0x1',
1871+
decimals: 6,
1872+
symbol: 'USDC',
1873+
name: 'USD Coin',
1874+
},
1875+
isLoading: false,
1876+
isUnsupportedChain: false,
1877+
});
1878+
const usdcOnMainnet = createSourceToken({
1879+
address: destChecksumAddress.toLowerCase(),
1880+
chainId: '0x1',
1881+
symbol: 'USDC',
1882+
tokenFiatAmount: 3000,
1883+
});
1884+
const ethOnMainnet = createSourceToken({
1885+
symbol: 'ETH',
1886+
tokenFiatAmount: 2000,
1887+
});
1888+
(usePayWithTokens as jest.Mock).mockReturnValue({
1889+
options: [usdcOnMainnet, ethOnMainnet],
1890+
isLoading: false,
1891+
});
1892+
1893+
const { result } = renderHook(() =>
1894+
useQuickBuyController(
1895+
createTarget({
1896+
tokenAddress: destChecksumAddress,
1897+
tokenSymbol: 'USDC',
1898+
}),
1899+
jest.fn(),
1900+
),
1901+
);
1902+
1903+
expect(result.current.sourceTokenOptions).toEqual([ethOnMainnet]);
1904+
});
1905+
1906+
it('keeps a same-address token on a different chain in the pay-with options', () => {
1907+
const sharedAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
1908+
(useQuickBuySetup as jest.Mock).mockReturnValue({
1909+
chainId: '0x1',
1910+
destToken: {
1911+
address: sharedAddress,
1912+
chainId: '0x1',
1913+
decimals: 6,
1914+
symbol: 'USDC',
1915+
name: 'USD Coin',
1916+
},
1917+
isLoading: false,
1918+
isUnsupportedChain: false,
1919+
});
1920+
const usdcOnBase = createSourceToken({
1921+
address: sharedAddress.toLowerCase(),
1922+
chainId: '0x2105',
1923+
symbol: 'USDC',
1924+
tokenFiatAmount: 3000,
1925+
});
1926+
(usePayWithTokens as jest.Mock).mockReturnValue({
1927+
options: [usdcOnBase],
1928+
isLoading: false,
1929+
});
1930+
1931+
const { result } = renderHook(() =>
1932+
useQuickBuyController(
1933+
createTarget({ tokenAddress: sharedAddress, tokenSymbol: 'USDC' }),
1934+
jest.fn(),
1935+
),
1936+
);
1937+
1938+
expect(result.current.sourceTokenOptions).toEqual([usdcOnBase]);
1939+
});
1940+
1941+
it('excludes the non-EVM destination when CAIP forms differ but the symbol matches', () => {
1942+
const solChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
1943+
const solNative = createSourceToken({
1944+
address: `${solChainId}/slip44:501`,
1945+
chainId: solChainId as BridgeToken['chainId'],
1946+
symbol: 'SOL',
1947+
tokenFiatAmount: 5000,
1948+
});
1949+
const usdcOnSolana = createSourceToken({
1950+
address: `${solChainId}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`,
1951+
chainId: solChainId as BridgeToken['chainId'],
1952+
symbol: 'USDC',
1953+
tokenFiatAmount: 1000,
1954+
});
1955+
(useQuickBuySetup as jest.Mock).mockReturnValue({
1956+
chainId: solChainId,
1957+
destToken: {
1958+
address: 'So11111111111111111111111111111111111111112',
1959+
chainId: solChainId as BridgeToken['chainId'],
1960+
symbol: 'SOL',
1961+
name: 'Solana',
1962+
decimals: 9,
1963+
image: '',
1964+
},
1965+
isLoading: false,
1966+
isUnsupportedChain: false,
1967+
});
1968+
(usePayWithTokens as jest.Mock).mockReturnValue({
1969+
options: [solNative, usdcOnSolana],
1970+
isLoading: false,
1971+
});
1972+
1973+
const { result } = renderHook(() =>
1974+
useQuickBuyController(
1975+
createTarget({
1976+
chain: 'solana',
1977+
tokenAddress: 'So11111111111111111111111111111111111111112',
1978+
tokenSymbol: 'SOL',
1979+
}),
1980+
jest.fn(),
1981+
),
1982+
);
1983+
1984+
expect(result.current.sourceTokenOptions).toEqual([usdcOnSolana]);
1985+
});
1986+
1987+
it('returns no pay-with options and no selection when the destination is the only holding', () => {
1988+
const nativeAddress = '0x0000000000000000000000000000000000000000';
1989+
(useQuickBuySetup as jest.Mock).mockReturnValue({
1990+
chainId: '0x1',
1991+
destToken: {
1992+
address: nativeAddress,
1993+
chainId: '0x1',
1994+
decimals: 18,
1995+
symbol: 'ETH',
1996+
name: 'Ether',
1997+
},
1998+
isLoading: false,
1999+
isUnsupportedChain: false,
2000+
});
2001+
(usePayWithTokens as jest.Mock).mockReturnValue({
2002+
options: [createSourceToken()],
2003+
isLoading: false,
2004+
});
2005+
2006+
const { result } = renderHook(() =>
2007+
useQuickBuyController(
2008+
createTarget({ tokenAddress: nativeAddress, tokenSymbol: 'ETH' }),
2009+
jest.fn(),
2010+
),
2011+
);
2012+
2013+
expect(result.current.sourceTokenOptions).toEqual([]);
2014+
expect(result.current.sourceToken).toBeUndefined();
2015+
});
2016+
2017+
it('falls back to another token when the selected pay token resolves to the destination asset', () => {
2018+
const nativeAddress = '0x0000000000000000000000000000000000000000';
2019+
const ethOnMainnet = createSourceToken({
2020+
symbol: 'ETH',
2021+
tokenFiatAmount: 2000,
2022+
});
2023+
const usdcOnMainnet = createSourceToken({
2024+
address: '0xTokenUSDC',
2025+
symbol: 'USDC',
2026+
tokenFiatAmount: 1000,
2027+
});
2028+
(usePayWithTokens as jest.Mock).mockReturnValue({
2029+
options: [ethOnMainnet, usdcOnMainnet],
2030+
isLoading: false,
2031+
});
2032+
2033+
// Setup initially resolves a non-ETH destination, so ETH (native on the
2034+
// dest chain) is auto-selected as the pay-with token.
2035+
const { result, rerender } = renderHook(() =>
2036+
useQuickBuyController(
2037+
createTarget({ tokenAddress: nativeAddress, tokenSymbol: 'ETH' }),
2038+
jest.fn(),
2039+
),
2040+
);
2041+
expect(result.current.sourceToken?.symbol).toBe('ETH');
2042+
2043+
// The destination then normalises to native ETH — the very token that is
2044+
// selected. The selection must fall back to another holding instead of
2045+
// keeping an invalid same-token pair.
2046+
(useQuickBuySetup as jest.Mock).mockReturnValue({
2047+
chainId: '0x1',
2048+
destToken: {
2049+
address: nativeAddress,
2050+
chainId: '0x1',
2051+
decimals: 18,
2052+
symbol: 'ETH',
2053+
name: 'Ether',
2054+
},
2055+
isLoading: false,
2056+
isUnsupportedChain: false,
2057+
});
2058+
rerender(undefined);
2059+
2060+
expect(result.current.sourceTokenOptions).toEqual([usdcOnMainnet]);
2061+
expect(result.current.sourceToken?.symbol).toBe('USDC');
2062+
});
2063+
});
2064+
2065+
describe('isSameAsset', () => {
2066+
const solChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
2067+
2068+
it('matches EVM addresses case-insensitively on the same chain', () => {
2069+
const token = createSourceToken({
2070+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
2071+
chainId: '0x1',
2072+
symbol: 'USDC',
2073+
});
2074+
2075+
const result = isSameAsset(token, {
2076+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
2077+
chainId: '0x1' as BridgeToken['chainId'],
2078+
});
2079+
2080+
expect(result).toBe(true);
2081+
});
2082+
2083+
it('treats the same address on a different chain as a different asset', () => {
2084+
const token = createSourceToken({
2085+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
2086+
chainId: '0x2105',
2087+
symbol: 'USDC',
2088+
});
2089+
2090+
const result = isSameAsset(token, {
2091+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
2092+
chainId: '0x1' as BridgeToken['chainId'],
2093+
});
2094+
2095+
expect(result).toBe(false);
2096+
});
2097+
2098+
it('falls back to symbol matching on non-EVM chains when address forms differ', () => {
2099+
const token = createSourceToken({
2100+
address: `${solChainId}/slip44:501`,
2101+
chainId: solChainId as BridgeToken['chainId'],
2102+
symbol: 'SOL',
2103+
});
2104+
2105+
const result = isSameAsset(token, {
2106+
address: 'So11111111111111111111111111111111111111112',
2107+
chainId: solChainId as BridgeToken['chainId'],
2108+
symbol: 'SOL',
2109+
});
2110+
2111+
expect(result).toBe(true);
2112+
});
2113+
2114+
it('does NOT fall back to symbol matching on EVM chains', () => {
2115+
const token = createSourceToken({
2116+
address: '0xfakefakefakefakefakefakefakefakefakefake',
2117+
chainId: '0x1',
2118+
symbol: 'USDC',
2119+
});
2120+
2121+
const result = isSameAsset(token, {
2122+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
2123+
chainId: '0x1' as BridgeToken['chainId'],
2124+
symbol: 'USDC',
2125+
});
2126+
2127+
expect(result).toBe(false);
2128+
});
2129+
});
2130+
18602131
describe('selectDefaultSourceToken', () => {
18612132
const native = (chainId: string, fiat = 1000): BridgeToken =>
18622133
createSourceToken({

0 commit comments

Comments
 (0)