Skip to content

Commit 51bc3da

Browse files
fix(settings): list hidden native coins in accounts settings
1 parent d5b0a90 commit 51bc3da

8 files changed

Lines changed: 167 additions & 56 deletions

File tree

.changeset/wild-donkeys-hunt.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@ledgerhq/live-common": minor
3+
"ledger-live-desktop": minor
4+
"live-mobile": minor
5+
---
6+
7+
Fix hidden assets not appearing in Settings > Accounts. Native coins hidden from the asset detail page are now resolved from the crypto registry and listed alongside hidden tokens, and a single failing token lookup no longer empties the whole list.

apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/BlacklistedTokens.test.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe("BlacklistedTokens", () => {
6767

6868
expect(screen.getByText("Hidden tokens")).toBeInTheDocument();
6969
expect(
70-
screen.getByText(/You can hide tokens by going to the parent account then right-clicking/i),
70+
screen.getByText(/You can hide an asset from its detail page using the options menu/i),
7171
).toBeInTheDocument();
7272
// When there are no blacklisted tokens, the count should not be displayed
7373
expect(screen.queryByText(/\d+ token/)).not.toBeInTheDocument();
@@ -153,13 +153,61 @@ describe("BlacklistedTokens", () => {
153153
expect(mockFindTokenById).toHaveBeenCalledWith("ethereum/erc20/usdt");
154154
});
155155

156-
it("handles async loading errors gracefully", async () => {
157-
mockFindTokenById.mockRejectedValue(new Error("Token not found"));
156+
it("renders a native coin row resolved from the registry without a CAL lookup", async () => {
157+
render(<BlacklistedTokens />, {
158+
initialState: { settings: { blacklistedTokenIds: ["bitcoin"] } },
159+
});
160+
161+
// Native coins are resolved from the crypto registry, not via CAL
162+
expect(mockFindTokenById).not.toHaveBeenCalled();
163+
164+
await waitFor(() => {
165+
expect(screen.getByText("1 token")).toBeInTheDocument();
166+
});
167+
168+
// Toggle visibility to reveal the Bitcoin row
169+
fireEvent.click(screen.getByText("1 token"));
170+
171+
await waitFor(() => {
172+
expect(screen.getAllByText("Bitcoin").length).toBeGreaterThan(0);
173+
});
174+
});
175+
176+
it("isolates a failing token lookup without discarding the rest of the list", async () => {
177+
mockFindTokenById.mockImplementation(async (tokenId: string) => {
178+
if (tokenId === "ethereum/erc20/invalid") throw new Error("Token not found");
179+
return mockUsdtToken;
180+
});
181+
182+
render(<BlacklistedTokens />, {
183+
initialState: {
184+
settings: { blacklistedTokenIds: ["ethereum/erc20/invalid", "ethereum/erc20/usdt"] },
185+
},
186+
});
187+
188+
await waitFor(() => {
189+
expect(screen.getByText("2 tokens")).toBeInTheDocument();
190+
});
191+
192+
fireEvent.click(screen.getByText("2 tokens"));
193+
194+
// The failing id is dropped, the resolvable token still renders
195+
await waitFor(() => {
196+
expect(screen.getByText("Tether USD")).toBeInTheDocument();
197+
});
198+
});
199+
200+
it("logs a warning and renders no rows when loading rejects", async () => {
201+
// A synchronous throw from the store escapes the helper's per-id `.catch`,
202+
// so the whole load rejects and the component falls back to an empty list.
203+
mockFindTokenById.mockImplementation(() => {
204+
throw new Error("Store unavailable");
205+
});
158206

159207
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
160208

161209
render(<BlacklistedTokens />, {
162-
initialState: { settings: { blacklistedTokenIds: ["ethereum/erc20/invalid"] } },
210+
initialState: { settings: { blacklistedTokenIds: ["ethereum/erc20/usdt"] } },
163211
});
164212

165213
await waitFor(() => {
@@ -169,6 +217,9 @@ describe("BlacklistedTokens", () => {
169217
);
170218
});
171219

220+
fireEvent.click(screen.getByText("1 token"));
221+
expect(screen.queryByText("Tether USD")).not.toBeInTheDocument();
222+
172223
consoleWarnSpy.mockRestore();
173224
});
174225
});

apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/BlacklistedTokens.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import { useBridgeSync } from "@ledgerhq/live-common/bridge/react/index";
1414
import Track from "~/renderer/analytics/Track";
1515
import IconAngleDown from "~/renderer/icons/AngleDown";
1616
import type { CryptoCurrency } from "@domain/entity-currency-crypto";
17-
import type { TokenCurrency } from "@domain/entity-currency-token";
17+
import type { CryptoOrTokenCurrency } from "@domain/entity-currency";
1818

1919
type BlacklistedTokenSection = {
2020
parentCurrency: CryptoCurrency;
21-
tokens: TokenCurrency[];
21+
assets: CryptoOrTokenCurrency[];
2222
};
2323

2424
export default function BlacklistedTokens() {
@@ -103,17 +103,17 @@ export default function BlacklistedTokens() {
103103

104104
{sectionVisible && (
105105
<div>
106-
{sections.map(({ parentCurrency, tokens }) => (
106+
{sections.map(({ parentCurrency, assets }) => (
107107
<Box key={parentCurrency.id}>
108108
<BlacklistedTokensSectionHeader>
109109
<Text ff="Inter|Bold" fontSize={2} color="neutral.c60">
110110
{parentCurrency.name}
111111
</Text>
112112
</BlacklistedTokensSectionHeader>
113113
<Body>
114-
{tokens.map((token: TokenCurrency) => (
115-
<BlacklistedTokenRow key={token.id}>
116-
<CryptoCurrencyIcon currency={token} size={28} />
114+
{assets.map((asset: CryptoOrTokenCurrency) => (
115+
<BlacklistedTokenRow key={asset.id}>
116+
<CryptoCurrencyIcon currency={asset} size={28} />
117117
<Text
118118
style={{
119119
marginLeft: 10,
@@ -123,9 +123,9 @@ export default function BlacklistedTokens() {
123123
color="neutral.c100"
124124
fontSize={3}
125125
>
126-
{token.name}
126+
{asset.name}
127127
</Text>
128-
<IconContainer onClick={() => onShowToken(token.id)}>
128+
<IconContainer onClick={() => onShowToken(asset.id)}>
129129
<IconCross size={16} />
130130
</IconContainer>
131131
</BlacklistedTokenRow>

apps/ledger-live-desktop/static/i18n/en/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5511,7 +5511,7 @@
55115511
},
55125512
"tokenBlacklist": {
55135513
"title": "Hidden tokens",
5514-
"desc": "You can hide tokens by going to the parent account then right-clicking on the token and selecting 'Hide token'.",
5514+
"desc": "You can hide an asset from its detail page using the options menu, or hide a token by going to the parent account then right-clicking on the token and selecting 'Hide token'.",
55155515
"count": "1 token",
55165516
"count_other": "{{count}} tokens"
55175517
},

apps/ledger-live-mobile/src/screens/Settings/Accounts/index.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,11 @@ describe("AccountsSettings - BlacklistedTokens", () => {
203203
});
204204

205205
it("handles async loading errors gracefully", async () => {
206-
mockFindTokenById.mockRejectedValue(new Error("Token not found"));
206+
// A synchronous throw from the store escapes the helper's per-id `.catch`,
207+
// so the whole load rejects and the component falls back to an empty list.
208+
mockFindTokenById.mockImplementation(() => {
209+
throw new Error("Store unavailable");
210+
});
207211
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
208212

209213
render(<AccountsSettings navigation={mockNavigation} route={mockRoute} />, {

apps/ledger-live-mobile/src/screens/Settings/Accounts/index.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useSelector, useDispatch } from "~/context/hooks";
44
import { TouchableOpacity, View, StyleSheet, SectionList } from "react-native";
55
import { loadBlacklistedTokenSections as loadBlacklistedTokenSectionsBase } from "@ledgerhq/live-common/account/index";
66
import { CryptoCurrency } from "@domain/entity-currency-crypto";
7-
import { TokenCurrency } from "@domain/entity-currency-token";
7+
import { CryptoOrTokenCurrency } from "@domain/entity-currency";
88
import { DefaultTheme, useTheme } from "styled-components/native";
99
import SettingsRow from "~/components/SettingsRow";
1010
import { showToken } from "~/actions/settings";
@@ -24,7 +24,7 @@ import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers";
2424
type BlacklistedTokenSection = {
2525
key: string;
2626
parentCurrency: CryptoCurrency;
27-
data: TokenCurrency[];
27+
data: CryptoOrTokenCurrency[];
2828
};
2929

3030
async function loadBlacklistedTokenSections(
@@ -34,7 +34,7 @@ async function loadBlacklistedTokenSections(
3434
return sections.map(section => ({
3535
key: section.parentCurrency.id,
3636
parentCurrency: section.parentCurrency,
37-
data: section.tokens,
37+
data: section.assets,
3838
}));
3939
}
4040

@@ -59,14 +59,14 @@ export default function AccountsSettings({
5959
);
6060

6161
const renderItem = useCallback(
62-
({ item: token }: { item: TokenCurrency }) => (
62+
({ item: asset }: { item: CryptoOrTokenCurrency }) => (
6363
<View style={styles.row}>
6464
<View style={styles.rowIconContainer}>
65-
<CurrencyIcon currency={token} size={20} />
65+
<CurrencyIcon currency={asset} size={20} />
6666
</View>
67-
<LText style={styles.rowTitle}>{token.name}</LText>
67+
<LText style={styles.rowTitle}>{asset.name}</LText>
6868
<TouchableOpacity
69-
onPress={() => dispatch(showToken(token.id))}
69+
onPress={() => dispatch(showToken(asset.id))}
7070
style={styles.cta}
7171
hitSlop={hitSlop}
7272
>
@@ -77,7 +77,7 @@ export default function AccountsSettings({
7777
[colors, dispatch],
7878
);
7979

80-
const keyExtractor = useCallback((token: TokenCurrency) => token.id, []);
80+
const keyExtractor = useCallback((asset: CryptoOrTokenCurrency) => asset.id, []);
8181

8282
const renderHeader = useCallback(
8383
() => (

libs/ledger-live-common/src/account/helpers.test.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe("loadBlacklistedTokenSections", () => {
131131

132132
expect(result).toHaveLength(1);
133133
expect(result[0].parentCurrency.id).toBe("ethereum");
134-
expect(result[0].tokens).toEqual([mockUsdtToken]);
134+
expect(result[0].assets).toEqual([mockUsdtToken]);
135135
});
136136

137137
it("should group multiple tokens from the same parent currency", async () => {
@@ -145,8 +145,8 @@ describe("loadBlacklistedTokenSections", () => {
145145

146146
expect(result).toHaveLength(1);
147147
expect(result[0].parentCurrency.id).toBe("ethereum");
148-
expect(result[0].tokens).toHaveLength(2);
149-
expect(result[0].tokens).toEqual([mockUsdtToken, mockUsdcToken]);
148+
expect(result[0].assets).toHaveLength(2);
149+
expect(result[0].assets).toEqual([mockUsdtToken, mockUsdcToken]);
150150
});
151151

152152
it("should create separate sections for different parent currencies", async () => {
@@ -160,9 +160,9 @@ describe("loadBlacklistedTokenSections", () => {
160160

161161
expect(result).toHaveLength(2);
162162
expect(result[0].parentCurrency.id).toBe("ethereum");
163-
expect(result[0].tokens).toEqual([mockUsdtToken]);
163+
expect(result[0].assets).toEqual([mockUsdtToken]);
164164
expect(result[1].parentCurrency.id).toBe("polygon");
165-
expect(result[1].tokens).toEqual([mockMaticUsdtToken]);
165+
expect(result[1].assets).toEqual([mockMaticUsdtToken]);
166166
});
167167

168168
it("should filter out null/undefined tokens", async () => {
@@ -178,8 +178,8 @@ describe("loadBlacklistedTokenSections", () => {
178178

179179
expect(result).toHaveLength(1);
180180
expect(result[0].parentCurrency.id).toBe("ethereum");
181-
expect(result[0].tokens).toHaveLength(2);
182-
expect(result[0].tokens).toEqual([mockUsdtToken, mockUsdcToken]);
181+
expect(result[0].assets).toHaveLength(2);
182+
expect(result[0].assets).toEqual([mockUsdtToken, mockUsdcToken]);
183183
});
184184

185185
it("should handle complex scenario with mixed parent currencies and null tokens", async () => {
@@ -197,11 +197,11 @@ describe("loadBlacklistedTokenSections", () => {
197197

198198
expect(result).toHaveLength(2);
199199
expect(result[0].parentCurrency.id).toBe("ethereum");
200-
expect(result[0].tokens).toHaveLength(2);
201-
expect(result[0].tokens).toEqual([mockUsdtToken, mockUsdcToken]);
200+
expect(result[0].assets).toHaveLength(2);
201+
expect(result[0].assets).toEqual([mockUsdtToken, mockUsdcToken]);
202202
expect(result[1].parentCurrency.id).toBe("polygon");
203-
expect(result[1].tokens).toHaveLength(1);
204-
expect(result[1].tokens).toEqual([mockMaticUsdtToken]);
203+
expect(result[1].assets).toHaveLength(1);
204+
expect(result[1].assets).toEqual([mockMaticUsdtToken]);
205205
});
206206

207207
it("should call findTokenById for all token IDs in parallel", async () => {
@@ -215,4 +215,42 @@ describe("loadBlacklistedTokenSections", () => {
215215
expect(mockFindTokenById).toHaveBeenCalledWith("token2");
216216
expect(mockFindTokenById).toHaveBeenCalledWith("token3");
217217
});
218+
219+
it("should resolve a native coin id from the registry without a CAL lookup", async () => {
220+
const result = await loadBlacklistedTokenSections(["bitcoin"]);
221+
222+
expect(mockFindTokenById).not.toHaveBeenCalled();
223+
expect(result).toHaveLength(1);
224+
expect(result[0].parentCurrency.id).toBe("bitcoin");
225+
expect(result[0].assets).toHaveLength(1);
226+
expect(result[0].assets[0].id).toBe("bitcoin");
227+
});
228+
229+
it("should merge a native coin and a token sharing the same parent currency", async () => {
230+
mockFindTokenById.mockResolvedValueOnce(mockUsdtToken);
231+
232+
const result = await loadBlacklistedTokenSections(["ethereum", "ethereum/erc20/usdt"]);
233+
234+
expect(result).toHaveLength(1);
235+
expect(result[0].parentCurrency.id).toBe("ethereum");
236+
expect(result[0].assets).toHaveLength(2);
237+
expect(result[0].assets[0].id).toBe("ethereum");
238+
expect(result[0].assets[1]).toEqual(mockUsdtToken);
239+
});
240+
241+
it("should isolate a failing token lookup without discarding the other entries", async () => {
242+
mockFindTokenById.mockResolvedValueOnce(mockUsdtToken);
243+
mockFindTokenById.mockRejectedValueOnce(new Error("CAL unavailable"));
244+
mockFindTokenById.mockResolvedValueOnce(mockUsdcToken);
245+
246+
const result = await loadBlacklistedTokenSections([
247+
"ethereum/erc20/usdt",
248+
"ethereum/erc20/unknown",
249+
"ethereum/erc20/usdc",
250+
]);
251+
252+
expect(result).toHaveLength(1);
253+
expect(result[0].parentCurrency.id).toBe("ethereum");
254+
expect(result[0].assets).toEqual([mockUsdtToken, mockUsdcToken]);
255+
});
218256
});

libs/ledger-live-common/src/account/helpers.ts

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CryptoCurrency } from "@domain/entity-currency-crypto";
2-
import { getCryptoCurrencyById } from "@domain/entity-currency-crypto";
3-
import type { TokenCurrency } from "@domain/entity-currency-token";
2+
import { findCryptoCurrencyById } from "@domain/entity-currency-crypto";
3+
import type { CryptoOrTokenCurrency } from "@domain/entity-currency";
44
import { getCryptoAssetsStore } from "@ledgerhq/ledger-wallet-framework/cryptoAssetsStore";
55

66
export { filterAccountsExcludingBlacklisted } from "./filterAccountsExcludingBlacklisted";
@@ -24,31 +24,42 @@ export {
2424
} from "@ledgerhq/ledger-wallet-framework/account/index";
2525

2626
/**
27-
* Load blacklisted tokens and organize them into sections by parent currency
28-
* @param tokenIds - Array of token IDs to load
29-
* @returns Array of sections with parent currency and tokens
27+
* Load blacklisted (hidden) assets and organize them into sections by parent currency.
28+
* Hidden ids can be native coins (grouped under themselves) as well as tokens (grouped
29+
* under their parent). Token lookups are isolated so one failure keeps the rest of the list.
3030
*/
3131
export async function loadBlacklistedTokenSections(
32-
tokenIds: string[],
33-
): Promise<Array<{ parentCurrency: CryptoCurrency; tokens: TokenCurrency[] }>> {
34-
const tokens = await Promise.all(
35-
tokenIds.map(tokenId => getCryptoAssetsStore().findTokenById(tokenId)),
32+
assetIds: string[],
33+
): Promise<Array<{ parentCurrency: CryptoCurrency; assets: CryptoOrTokenCurrency[] }>> {
34+
const resolved = await Promise.all(
35+
assetIds.map(async (assetId): Promise<CryptoOrTokenCurrency | undefined> => {
36+
const coin = findCryptoCurrencyById(assetId);
37+
if (coin) {
38+
return coin;
39+
}
40+
return getCryptoAssetsStore()
41+
.findTokenById(assetId)
42+
.catch(() => undefined);
43+
}),
3644
);
3745

38-
const sections: Array<{ parentCurrency: CryptoCurrency; tokens: TokenCurrency[] }> = [];
39-
40-
for (const token of tokens) {
41-
if (token) {
42-
const parentCurrency = getCryptoCurrencyById(token.parentCurrencyId);
43-
const index = sections.findIndex(s => s.parentCurrency === parentCurrency);
44-
if (index < 0) {
45-
sections.push({
46-
parentCurrency,
47-
tokens: [token],
48-
});
49-
} else {
50-
sections[index].tokens.push(token);
51-
}
46+
const sections: Array<{ parentCurrency: CryptoCurrency; assets: CryptoOrTokenCurrency[] }> = [];
47+
48+
for (const asset of resolved) {
49+
if (!asset) continue;
50+
51+
const parentCurrency =
52+
asset.type === "TokenCurrency" ? findCryptoCurrencyById(asset.parentCurrencyId) : asset;
53+
if (!parentCurrency) continue;
54+
55+
const index = sections.findIndex(s => s.parentCurrency === parentCurrency);
56+
if (index < 0) {
57+
sections.push({
58+
parentCurrency,
59+
assets: [asset],
60+
});
61+
} else {
62+
sections[index].assets.push(asset);
5263
}
5364
}
5465

0 commit comments

Comments
 (0)