diff --git a/.changeset/slow-knives-juggle.md b/.changeset/slow-knives-juggle.md new file mode 100644 index 00000000000..8b63c0fc246 --- /dev/null +++ b/.changeset/slow-knives-juggle.md @@ -0,0 +1,7 @@ +--- +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/live-common": minor +--- + +Add feature flag for address poisoning operations filter diff --git a/apps/ledger-live-desktop/src/mvvm/features/Portfolio/hooks/usePortfolioViewModel.ts b/apps/ledger-live-desktop/src/mvvm/features/Portfolio/hooks/usePortfolioViewModel.ts index f539d857ee9..0b282a0fe46 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Portfolio/hooks/usePortfolioViewModel.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Portfolio/hooks/usePortfolioViewModel.ts @@ -8,6 +8,7 @@ import { isAddressPoisoningOperation } from "@ledgerhq/coin-framework/operation" import { Operation, AccountLike } from "@ledgerhq/types-live"; import { TFunction } from "i18next"; import { useFilterTokenOperationsZeroAmount } from "~/renderer/actions/settings"; +import { useAddressPoisoningOperationsFamilies } from "@ledgerhq/live-common/hooks/useAddressPoisoningOperationsFamilies"; export interface PortfolioViewModelResult { readonly totalAccounts: number; @@ -34,16 +35,22 @@ export const usePortfolioViewModel = (): PortfolioViewModelResult => { } = useWalletFeaturesConfig("desktop"); const { t } = useTranslation(); const [shouldFilterTokenOpsZeroAmount] = useFilterTokenOperationsZeroAmount(); + const addressPoisoningFamilies = useAddressPoisoningOperationsFamilies({ + shouldFilter: shouldFilterTokenOpsZeroAmount, + }); const filterOperations = useCallback( (operation: Operation, account: AccountLike) => { // Remove operations linked to address poisoning const removeZeroAmountTokenOp = - shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); + shouldFilterTokenOpsZeroAmount && + isAddressPoisoningOperation(operation, account, { + families: addressPoisoningFamilies ? addressPoisoningFamilies : undefined, + }); return !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [shouldFilterTokenOpsZeroAmount, addressPoisoningFamilies], ); const totalAccounts = accounts.length; diff --git a/apps/ledger-live-desktop/src/renderer/screens/account/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/account/index.tsx index 7017aeca344..e64385de41f 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/account/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/account/index.tsx @@ -34,6 +34,7 @@ import { AccountLike, Account, Operation } from "@ledgerhq/types-live"; import { State } from "~/renderer/reducers"; import { getLLDCoinFamily } from "~/renderer/families"; import NftEntryPoint from "LLD/features/NftEntryPoint"; +import { useAddressPoisoningOperationsFamilies } from "@ledgerhq/live-common/hooks/useAddressPoisoningOperationsFamilies"; type Params = { id?: string; @@ -91,16 +92,24 @@ const AccountPage = ({ const PendingTransferProposals = specific?.PendingTransferProposals; const bgColor = useTheme().colors.background.card; const [shouldFilterTokenOpsZeroAmount] = useFilterTokenOperationsZeroAmount(); + const addressPoisoningFamilies = useAddressPoisoningOperationsFamilies({ + shouldFilter: shouldFilterTokenOpsZeroAmount, + }); const filterOperations = useCallback( (operation: Operation, account: AccountLike) => { // Remove operations linked to address poisoning const removeZeroAmountTokenOp = - shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); + shouldFilterTokenOpsZeroAmount && + isAddressPoisoningOperation( + operation, + account, + addressPoisoningFamilies ? { families: addressPoisoningFamilies } : undefined, + ); return !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [shouldFilterTokenOpsZeroAmount, addressPoisoningFamilies], ); const currency = mainAccount?.currency; diff --git a/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx index be57af7a83a..881149ca8f3 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx @@ -29,6 +29,7 @@ import SwapWebViewEmbedded from "./components/SwapWebViewEmbedded"; import BannerSection from "./components/BannerSection"; import { MarketBanner as MarketBannerFeature } from "@features/market-banner"; import Portfolio from "LLD/features/Portfolio"; +import { useAddressPoisoningOperationsFamilies } from "@ledgerhq/live-common/hooks/useAddressPoisoningOperationsFamilies"; // This forces only one visible top banner at a time export const TopBannerContainer = styled.div` @@ -53,16 +54,24 @@ export default function DashboardPage() { [accounts], ); const [shouldFilterTokenOpsZeroAmount] = useFilterTokenOperationsZeroAmount(); + const addressPoisoningFamilies = useAddressPoisoningOperationsFamilies({ + shouldFilter: shouldFilterTokenOpsZeroAmount, + }); const filterOperations = useCallback( (operation: Operation, account: AccountLike) => { // Remove operations linked to address poisoning const removeZeroAmountTokenOp = - shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); + shouldFilterTokenOpsZeroAmount && + isAddressPoisoningOperation( + operation, + account, + addressPoisoningFamilies ? { families: addressPoisoningFamilies } : undefined, + ); return !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [shouldFilterTokenOpsZeroAmount, addressPoisoningFamilies], ); const { isFeatureFlagsAnalyticsPrefDisplayed, analyticsOptInPromptProps } = diff --git a/apps/ledger-live-mobile/src/screens/Analytics/Operations/useOperationsV1.ts b/apps/ledger-live-mobile/src/screens/Analytics/Operations/useOperationsV1.ts index 187f87b6a1d..399f591de61 100644 --- a/apps/ledger-live-mobile/src/screens/Analytics/Operations/useOperationsV1.ts +++ b/apps/ledger-live-mobile/src/screens/Analytics/Operations/useOperationsV1.ts @@ -1,24 +1,33 @@ import { groupAccountsOperationsByDay } from "@ledgerhq/coin-framework/lib/account/groupOperations"; import { isAddressPoisoningOperation } from "@ledgerhq/coin-framework/lib/operation"; -import { Operation, AccountLike } from "@ledgerhq/types-live"; +import { AccountLike, Operation } from "@ledgerhq/types-live"; import { useCallback } from "react"; import { useSelector } from "~/context/hooks"; import { filterTokenOperationsZeroAmountEnabledSelector } from "~/reducers/settings"; +import { useAddressPoisoningOperationsFamilies } from "@ledgerhq/live-common/hooks/useAddressPoisoningOperationsFamilies"; export function useOperationsV1(accounts: AccountLike[], opCount: number) { const shouldFilterTokenOpsZeroAmount = useSelector( filterTokenOperationsZeroAmountEnabledSelector, ); + const addressPoisoningFamilies = useAddressPoisoningOperationsFamilies({ + shouldFilter: shouldFilterTokenOpsZeroAmount, + }); + const filterOperation = useCallback( (operation: Operation, account: AccountLike) => { - // Remove operations linked to address poisoning const removeZeroAmountTokenOp = - shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); + shouldFilterTokenOpsZeroAmount && + isAddressPoisoningOperation( + operation, + account, + addressPoisoningFamilies ? { families: addressPoisoningFamilies } : undefined, + ); return !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [shouldFilterTokenOpsZeroAmount, addressPoisoningFamilies], ); const { sections, completed } = groupAccountsOperationsByDay(accounts, { diff --git a/libs/coin-framework/src/operation.test.ts b/libs/coin-framework/src/operation.test.ts index d046b1c80e8..341ae516f9f 100644 --- a/libs/coin-framework/src/operation.test.ts +++ b/libs/coin-framework/src/operation.test.ts @@ -69,6 +69,36 @@ describe("Operation.ts", () => { expect(isAddressPoisoningOperation(operation, account)).toBe(false); }); + + it("shouldn't filter operation if it's a Account at 0 value", () => { + const account = genAccount("myAccount", { currency: ethereum }); + const tokenAccount = genTokenAccount(0, account, lobster); + const operation = { + ...genOperation(account, tokenAccount, account.operations, new Prando("")), + value: new BigNumber(0), + }; + + expect(isAddressPoisoningOperation(operation, account)).toBe(false); + }); + + it("should use options.families array when provided (feature-flag path)", () => { + const account = genAccount("myAccount", { currency: ethereum }); + const tokenAccount = genTokenAccount(0, account, usdc); + const operation = { + ...genOperation(account, tokenAccount, account.operations, new Prando("")), + value: new BigNumber(0), + }; + const families = ["evm"]; + + expect(isAddressPoisoningOperation(operation, tokenAccount, { families: families })).toBe( + true, + ); + expect( + isAddressPoisoningOperation(operation, tokenAccount, { + families: ["algorand"], + }), + ).toBe(false); + }); }); describe("isOldestPendingOperation", () => { diff --git a/libs/coin-framework/src/operation.ts b/libs/coin-framework/src/operation.ts index 3597b5ca147..f52ad2f5f1c 100644 --- a/libs/coin-framework/src/operation.ts +++ b/libs/coin-framework/src/operation.ts @@ -235,18 +235,29 @@ export const isConfirmedOperation = ( ? account.blockHeight - operation.blockHeight + 1 >= confirmationsNb : false; +type AddressPoisoningFilterOptions = { + families?: string[] | null; +}; + export const isAddressPoisoningOperation = ( operation: Operation, account: AccountLike, + options?: AddressPoisoningFilterOptions, ): boolean => { - const impactedFamilies = getEnv("ADDRESS_POISONING_FAMILIES").split(","); - const isTokenAccount = account.type === "TokenAccount"; + if (!operation.value.isZero() || account.type !== "TokenAccount") return false; - return ( - isTokenAccount && - impactedFamilies.includes(account.token.parentCurrency.family) && - operation.value.isZero() - ); + const family = account.token.parentCurrency.family; + + if (options?.families) { + return options.families.includes(family); + } + + // Fallback to environment variable if no families are provided to be retro-compatible + const impactedFamilies = getEnv("ADDRESS_POISONING_FAMILIES") + .split(",") + .map(s => s.trim()); + + return impactedFamilies.includes(family); }; /** diff --git a/libs/ledger-live-common/.unimportedrc.json b/libs/ledger-live-common/.unimportedrc.json index 6d1f77662ee..2d3c637b23f 100644 --- a/libs/ledger-live-common/.unimportedrc.json +++ b/libs/ledger-live-common/.unimportedrc.json @@ -168,6 +168,7 @@ "src/hoc/withRemountableWrapper.tsx", "src/hooks/recoverFeatureFlag.ts", "src/hooks/useAccountsWithFundsListener.ts", + "src/hooks/useAddressPoisoningOperationsFamilies.ts", "src/hooks/useBroadcast.ts", "src/hooks/useDBRaw.ts", "src/hooks/useDebounce.ts", diff --git a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts index 2e58c7167f1..5885939feac 100644 --- a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts +++ b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts @@ -781,6 +781,23 @@ export const DEFAULT_FEATURES: Features = { quickActionCtas: true, }, }, + addressPoisoningOperationsFilter: { + ...DEFAULT_FEATURE, + enabled: true, + params: { + families: [ + "evm", + "tron", + "solana", + "xrp", + "stellar", + "hedera", + "algorand", + "cardano", + "cosmos", + ], + }, + }, }; // Firebase SDK treat JSON values as strings diff --git a/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.test.ts b/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.test.ts new file mode 100644 index 00000000000..5b9e1945c66 --- /dev/null +++ b/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.test.ts @@ -0,0 +1,151 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook } from "@testing-library/react"; +import { getEnv } from "@ledgerhq/live-env"; +import * as featureFlags from "../featureFlags"; +import { useAddressPoisoningOperationsFamilies } from "./useAddressPoisoningOperationsFamilies"; + +jest.mock("../featureFlags", () => ({ + ...jest.requireActual("../featureFlags"), + useFeature: jest.fn(), +})); +jest.mock("@ledgerhq/live-env", () => ({ + getEnv: jest.fn(), +})); + +describe("useAddressPoisoningOperationsFamilies", () => { + const mockedUseFeature = jest.mocked(featureFlags.useFeature); + const mockedGetEnv = jest.mocked(getEnv); + + beforeEach(() => { + jest.clearAllMocks(); + mockedGetEnv.mockReturnValue("evm,tron"); + }); + + it("returns null when shouldFilter is false", () => { + mockedUseFeature.mockReturnValue({ + enabled: true, + params: { families: ["evm", "tron"] }, + }); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: false }), + ); + + expect(result.current).toBe(null); + expect(mockedUseFeature).toHaveBeenCalledWith("addressPoisoningOperationsFilter"); + expect(mockedGetEnv).not.toHaveBeenCalled(); + }); + + it("returns null when feature is disabled", () => { + mockedUseFeature.mockReturnValue({ + enabled: false, + params: { families: ["evm", "tron"] }, + }); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current).toBe(null); + expect(mockedGetEnv).not.toHaveBeenCalled(); + }); + + it("returns null when feature is null/undefined", () => { + mockedUseFeature.mockReturnValue(null); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current).toBe(null); + expect(mockedGetEnv).not.toHaveBeenCalled(); + }); + + it("returns array from feature params when enabled with non-empty families", () => { + mockedUseFeature.mockReturnValue({ + enabled: true, + params: { families: ["evm", "tron", "solana"] }, + }); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current).not.toBe(null); + expect(result.current).toBeInstanceOf(Array); + expect(result.current?.includes("evm")).toBe(true); + expect(result.current?.includes("tron")).toBe(true); + expect(result.current?.includes("solana")).toBe(true); + expect(result.current?.length).toBe(3); + expect(mockedGetEnv).not.toHaveBeenCalled(); + }); + + it("falls back to getEnv when feature has no families or empty array", () => { + mockedUseFeature.mockReturnValue({ + enabled: true, + params: { families: [] }, + }); + mockedGetEnv.mockReturnValue("evm,tron"); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current).not.toBe(null); + expect(result.current?.includes("evm")).toBe(true); + expect(result.current?.includes("tron")).toBe(true); + expect(result.current?.length).toBe(2); + expect(mockedGetEnv).toHaveBeenCalledWith("ADDRESS_POISONING_FAMILIES"); + }); + + it("falls back to getEnv when feature params families is not an array", () => { + mockedUseFeature.mockReturnValue({ + enabled: true, + params: {}, + }); + mockedGetEnv.mockReturnValue("evm,tron"); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current).not.toBe(null); + expect(result.current?.includes("evm")).toBe(true); + expect(result.current?.includes("tron")).toBe(true); + expect(mockedGetEnv).toHaveBeenCalledWith("ADDRESS_POISONING_FAMILIES"); + }); + + it("trims family values when using getEnv fallback", () => { + mockedUseFeature.mockReturnValue({ enabled: true, params: { families: [] } }); + mockedGetEnv.mockReturnValue(" evm , tron , solana "); + + const { result } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + expect(result.current?.includes("evm")).toBe(true); + expect(result.current?.includes("tron")).toBe(true); + expect(result.current?.includes("solana")).toBe(true); + expect(result.current?.includes(" evm ")).toBe(false); + }); + + it("returns stable array reference when dependencies do not change", () => { + mockedUseFeature.mockReturnValue({ + enabled: true, + params: { families: ["evm", "tron"] }, + }); + + const { result, rerender } = renderHook(() => + useAddressPoisoningOperationsFamilies({ shouldFilter: true }), + ); + + const firstArray = result.current; + rerender(); + const secondArray = result.current; + + expect(firstArray).toBe(secondArray); + }); +}); diff --git a/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.ts b/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.ts new file mode 100644 index 00000000000..cbcf438a654 --- /dev/null +++ b/libs/ledger-live-common/src/hooks/useAddressPoisoningOperationsFamilies.ts @@ -0,0 +1,31 @@ +import { getEnv } from "@ledgerhq/live-env"; +import { useFeature } from "../featureFlags"; +import { useMemo } from "react"; + +type AddressPoisoningOperationsFilterArgs = { + shouldFilter: boolean; +}; + +export function useAddressPoisoningOperationsFamilies({ + shouldFilter, +}: AddressPoisoningOperationsFilterArgs): string[] | null { + const addressPoisoningOperationsFilterFeature = useFeature("addressPoisoningOperationsFilter"); + + return useMemo(() => { + if (!shouldFilter) return null; + + const isFeatureEnabled = addressPoisoningOperationsFilterFeature?.enabled; + + if (!isFeatureEnabled) return null; + + const families = + Array.isArray(addressPoisoningOperationsFilterFeature?.params?.families) && + addressPoisoningOperationsFilterFeature.params.families.length > 0 + ? addressPoisoningOperationsFilterFeature.params.families + : getEnv("ADDRESS_POISONING_FAMILIES") + .split(",") + .map((s: string) => s.trim()); + + return families; + }, [shouldFilter, addressPoisoningOperationsFilterFeature]); +} diff --git a/libs/ledgerjs/packages/types-live/src/feature.ts b/libs/ledgerjs/packages/types-live/src/feature.ts index 055b4f1a275..f257c08ae7f 100644 --- a/libs/ledgerjs/packages/types-live/src/feature.ts +++ b/libs/ledgerjs/packages/types-live/src/feature.ts @@ -299,6 +299,7 @@ export type Features = CurrencyFeatures & { lldOnboardingEnableSync: Feature_OnboardingEnableSync; lwmWallet40: Feature_LwmWallet40; lwdWallet40: Feature_LwdWallet40; + addressPoisoningOperationsFilter: Feature_AddressPoisoningOperationsFilter; }; /** @@ -717,6 +718,10 @@ export type Feature_NewSendFlow = Feature<{ families?: string[]; }>; +export type Feature_AddressPoisoningOperationsFilter = Feature<{ + families: string[]; +}>; + export type Feature_CounterValue = DefaultFeature; export type Feature_MockFeature = DefaultFeature; export type Feature_DisableNftSend = DefaultFeature;