diff --git a/.changeset/few-toes-drive.md b/.changeset/few-toes-drive.md new file mode 100644 index 000000000000..63879489940e --- /dev/null +++ b/.changeset/few-toes-drive.md @@ -0,0 +1,9 @@ +--- +"@ledgerhq/types-live": minor +"@ledgerhq/coin-evm": minor +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/live-common": minor +--- + +add mev protection diff --git a/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx b/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx index 9b9b41a4e35e..1f2ea48e31b3 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; import { Trans } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; import DeviceAction from "~/renderer/components/DeviceAction"; import StepProgress from "~/renderer/components/StepProgress"; @@ -12,6 +12,7 @@ import { getEnv } from "@ledgerhq/live-env"; import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock"; import { DeviceBlocker } from "~/renderer/components/DeviceAction/DeviceBlocker"; import { closeModal } from "~/renderer/actions/modals"; +import { mevProtectionSelector } from "~/renderer/reducers/settings"; import connectApp from "@ledgerhq/live-common/hw/connectApp"; const action = createAction(getEnv("MOCK") ? mockedEventEmitter : connectApp); const Result = ( @@ -55,10 +56,12 @@ export default function StepConnectDevice({ onConfirmationHandler?: Function; onFailHandler?: Function; }) { + const mevProtected = useSelector(mevProtectionSelector); const dispatch = useDispatch(); const broadcast = useBroadcast({ account, parentAccount, + broadcastConfig: { mevProtected }, }); const tokenCurrency = (account && account.type === "TokenAccount" && account.token) || undefined; const request = useMemo( diff --git a/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx b/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx index b5c5c6411246..a260b8a66c53 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx @@ -19,6 +19,7 @@ import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock"; import DeviceAction from "~/renderer/components/DeviceAction"; import Text from "~/renderer/components/Text"; import { getCurrentDevice } from "~/renderer/reducers/devices"; +import { mevProtectionSelector } from "~/renderer/reducers/settings"; import connectApp from "@ledgerhq/live-common/hw/connectApp"; import initSwap from "@ledgerhq/live-common/exchange/swap/initSwap"; import { Device } from "@ledgerhq/types-devices"; @@ -72,6 +73,7 @@ export default function SwapAction({ }: Props) { const [initData, setInitData] = useState(null); const [signedOperation, setSignedOperation] = useState(null); + const mevProtected = useSelector(mevProtectionSelector); const device = useSelector(getCurrentDevice); const deviceRef = useRef(device); const { account: fromAccount, parentAccount: fromParentAccount } = swapTransaction.swap.from; @@ -83,6 +85,7 @@ export default function SwapAction({ const broadcast = useBroadcast({ account: fromAccount, parentAccount: fromParentAccount, + broadcastConfig: { mevProtected }, }); const exchange = useMemo( diff --git a/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts b/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts index be7cafaa0ff8..1d39a05b9231 100644 --- a/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts +++ b/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts @@ -5,7 +5,13 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { Platform } from "react-native"; import { log } from "@ledgerhq/logs"; import { useRoute, useNavigation } from "@react-navigation/native"; -import type { Account, AccountLike, SignedOperation, Operation } from "@ledgerhq/types-live"; +import type { + Account, + AccountLike, + SignedOperation, + Operation, + BroadcastConfig, +} from "@ledgerhq/types-live"; import type { Transaction } from "@ledgerhq/live-common/generated/types"; import { UserRefusedOnDevice } from "@ledgerhq/errors"; import { getMainAccount } from "@ledgerhq/live-common/account/helpers"; @@ -22,7 +28,7 @@ import { formatTransaction } from "@ledgerhq/live-common/transaction/index"; import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; import { execAndWaitAtLeast } from "@ledgerhq/live-common/promise"; import { getEnv } from "@ledgerhq/live-env"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { TransactionRefusedOnDevice } from "@ledgerhq/live-common/errors"; import { StackNavigationProp } from "@react-navigation/stack"; import { updateAccountWithUpdater } from "../actions/accounts"; @@ -37,6 +43,7 @@ import type { SendFundsNavigatorStackParamList } from "../components/RootNavigat import type { SignTransactionNavigatorParamList } from "../components/RootNavigator/types/SignTransactionNavigator"; import type { AlgorandClaimRewardsFlowParamList } from "~/families/algorand/Rewards/ClaimRewardsFlow/type"; import type { StellarAddAssetFlowParamList } from "~/families/stellar/AddAssetFlow/types"; +import { mevProtectionSelector } from "~/reducers/settings"; type Navigation = | StackNavigatorNavigation @@ -196,11 +203,13 @@ export const useSignWithDevice = ({ type SignTransactionArgs = { account: AccountLike; parentAccount: Account | null | undefined; + broadcastConfig?: BroadcastConfig; }; export const broadcastSignedTx = async ( account: AccountLike, parentAccount: Account | null | undefined, signedOperation: SignedOperation, + broadcastConfig?: BroadcastConfig, ): Promise => { invariant(account, "account not present"); const mainAccount = getMainAccount(account, parentAccount); @@ -215,6 +224,7 @@ export const broadcastSignedTx = async ( .broadcast({ account: mainAccount, signedOperation, + broadcastConfig, }) .then(op => { log( @@ -227,11 +237,11 @@ export const broadcastSignedTx = async ( }; // TODO move to live-common -function useBroadcast({ account, parentAccount }: SignTransactionArgs) { +function useBroadcast({ account, parentAccount, broadcastConfig }: SignTransactionArgs) { return useCallback( async (signedOperation: SignedOperation): Promise => - broadcastSignedTx(account, parentAccount, signedOperation), - [account, parentAccount], + broadcastSignedTx(account, parentAccount, signedOperation, broadcastConfig), + [account, parentAccount, broadcastConfig], ); } @@ -242,11 +252,13 @@ export function useSignedTxHandler({ account: AccountLike; parentAccount: Account | null | undefined; }) { + const mevProtected = useSelector(mevProtectionSelector); const navigation = useNavigation(); const route = useRoute(); const broadcast = useBroadcast({ account, parentAccount, + broadcastConfig: { mevProtected }, }); const dispatch = useDispatch(); const mainAccount = getMainAccount(account, parentAccount); diff --git a/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx b/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx index 65b04df8bf44..e148edfa4b66 100644 --- a/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx +++ b/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { StyleSheet } from "react-native"; +import { useSelector } from "react-redux"; import { SafeAreaView } from "react-native-safe-area-context"; import { TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { useBroadcast } from "@ledgerhq/live-common/hooks/useBroadcast"; @@ -8,6 +9,7 @@ import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; import { PlatformExchangeNavigatorParamList } from "~/components/RootNavigator/types/PlatformExchangeNavigator"; import { ScreenName } from "~/const"; import { useTransactionDeviceAction, useCompleteExchangeDeviceAction } from "~/hooks/deviceActions"; +import { mevProtectionSelector } from "~/reducers/settings"; import { SignedOperation } from "@ledgerhq/types-live"; import { Transaction } from "@ledgerhq/live-common/generated/types"; @@ -22,12 +24,13 @@ const PlatformCompleteExchange: React.FC = ({ }, navigation, }) => { + const mevProtected = useSelector(mevProtectionSelector); const { fromAccount: account, fromParentAccount: parentAccount } = request.exchange; let tokenCurrency: TokenCurrency | undefined; if (account.type === "TokenAccount") tokenCurrency = account.token; - const broadcast = useBroadcast({ account, parentAccount }); + const broadcast = useBroadcast({ account, parentAccount, broadcastConfig: { mevProtected } }); const [transaction, setTransaction] = useState(); const [signedOperation, setSignedOperation] = useState(); const [error, setError] = useState(); diff --git a/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx b/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx index 33f95ce438a7..127e573defc4 100644 --- a/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx +++ b/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState, useMemo, useRef } from "react"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { StyleSheet, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useNavigation } from "@react-navigation/native"; @@ -31,6 +31,7 @@ import { ScreenName } from "~/const"; import type { SwapNavigatorParamList } from "~/components/RootNavigator/types/SwapNavigator"; import { useInitSwapDeviceAction, useTransactionDeviceAction } from "~/hooks/deviceActions"; import { BigNumber } from "bignumber.js"; +import { mevProtectionSelector } from "~/reducers/settings"; export type DeviceMeta = { result: { installed: InstalledItem[] } | null | undefined; @@ -79,10 +80,12 @@ export function Confirmation({ const [swapData, setSwapData] = useState(null); const [signedOperation, setSignedOperation] = useState(null); + const mevProtected = useSelector(mevProtectionSelector); const dispatch = useDispatch(); const broadcast = useBroadcast({ account: fromAccount, parentAccount: fromParentAccount, + broadcastConfig: { mevProtected }, }); const tokenCurrency = fromAccount && fromAccount.type === "TokenAccount" ? fromAccount.token : null; diff --git a/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts b/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts index 98064c26fee3..1d4f5b198dca 100644 --- a/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts +++ b/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts @@ -496,6 +496,26 @@ describe("EVM Family", () => { expect(await LEDGER_API.broadcastTransaction(currency, "0xSigneTx")).toEqual("0xHash"); }); + + it("should include mevProtected=true in the request parameters when specified", async () => { + const mockRequest = jest.spyOn(axios, "request").mockImplementationOnce(async () => ({ + data: { + result: "0xHash", + }, + })); + + await LEDGER_API.broadcastTransaction(currency, "0xSignedTx", { mevProtected: true }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + mevProtected: true, + }), + }), + ); + + mockRequest.mockRestore(); + }); }); describe("getBlockByHeight", () => { diff --git a/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts b/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts index f23805e0bc5c..bbd68708b650 100644 --- a/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts +++ b/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts @@ -1,3 +1,4 @@ +import { ethers } from "ethers"; import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; import { encodeERC1155OperationId, @@ -8,7 +9,9 @@ import { getCryptoCurrencyById, getTokenById } from "@ledgerhq/cryptoassets"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { Account, TokenAccount } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; +import axios from "axios"; import * as API from "../../api/node/rpc.common"; +import LEDGER_API from "../../api/node/ledger"; import broadcast from "../../broadcast"; import buildOptimisticOperation from "../../buildOptimisticOperation"; import { getEstimatedFees } from "../../logic"; @@ -26,6 +29,15 @@ jest.useFakeTimers(); jest.mock("../../config"); const mockGetConfig = jest.mocked(getCoinConfig); +jest.mock("ethers"); +const mockEthers = jest.mocked(ethers); +jest + .spyOn(mockEthers.providers.StaticJsonRpcProvider.prototype, "sendTransaction") + .mockResolvedValue(Promise.resolve({ hash: "0xH4sH" })); + +jest.mock("axios"); +const mockAxios = jest.mocked(axios); + const currency: CryptoCurrency = { ...getCryptoCurrencyById("ethereum"), ethereumLikeInfo: { @@ -44,6 +56,15 @@ const account: Account = makeAccount( ); const mockedBroadcastResponse = "0xH4sH"; +const mockBroadcastTransactions = () => { + jest + .spyOn(API, "broadcastTransaction") + .mockImplementation(async () => mockedBroadcastResponse as any); + jest + .spyOn(LEDGER_API, "broadcastTransaction") + .mockImplementation(async () => mockedBroadcastResponse as any); +}; + describe("EVM Family", () => { beforeAll(() => { mockGetConfig.mockImplementation((): any => { @@ -64,9 +85,7 @@ describe("EVM Family", () => { describe("broadcast.ts", () => { beforeAll(() => { - jest - .spyOn(API, "broadcastTransaction") - .mockImplementation(async () => mockedBroadcastResponse as any); + mockBroadcastTransactions(); }); afterAll(() => { @@ -74,6 +93,110 @@ describe("EVM Family", () => { }); describe("broadcast", () => { + describe("MEV Protection", () => { + beforeAll(() => { + jest.spyOn(LEDGER_API, "broadcastTransaction").mockRestore(); + jest.spyOn(API, "broadcastTransaction").mockRestore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + mockBroadcastTransactions(); + }); + + const coinTransaction: EvmTransaction = { + amount: new BigNumber(100), + useAllAmount: false, + subAccountId: "id", + recipient: "0x51DF0aF74a0DBae16cB845B46dAF2a35cB1D4168", // michel.eth + feesStrategy: "custom", + family: "evm", + mode: "send", + nonce: 0, + gasLimit: new BigNumber(21000), + chainId: 1, + maxFeePerGas: new BigNumber(100), + maxPriorityFeePerGas: new BigNumber(100), + type: 2, + }; + const broadcastArgs = { + account, + signedOperation: { + operation: buildOptimisticOperation(account, coinTransaction), + signature: "0xS1gn4tUR3", + }, + }; + + it("Ledger node MEV ON/OFF", async () => { + mockGetConfig.mockImplementation((): any => ({ + info: { + node: { + type: "ledger", + }, + }, + })); + + mockAxios.request.mockResolvedValue({ data: mockedBroadcastResponse }); + const axiosSpy = jest.spyOn(mockAxios, "request"); + + // MEV OFF + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: false }, + }); + + expect(axiosSpy).toHaveBeenCalled(); + + const requestConfigOff = axiosSpy.mock.calls[0][0] as { params: any }; + const urlParamsOff = new URLSearchParams(requestConfigOff.params).toString(); + + // MEV ON + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: true }, + }); + + expect(axiosSpy).toHaveBeenCalledTimes(2); + + const requestConfigOn = axiosSpy.mock.calls[1][0] as { params: any }; + const urlParamsOn = new URLSearchParams(requestConfigOn.params).toString(); + + expect(urlParamsOff).toContain("mevProtected=false"); + expect(urlParamsOn).toContain("mevProtected=true"); + }); + + it("External node MEV ON/OFF", async () => { + mockGetConfig.mockImplementation((): any => ({ + info: { + node: { + type: "external", + uri: "https://my-rpc.com", + }, + }, + })); + + const providerSpy = jest.spyOn(mockEthers.providers, "StaticJsonRpcProvider"); + + // MEV OFF + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: false }, + }); + + // MEV ON + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: true }, + }); + + expect(providerSpy).toHaveBeenCalledTimes(1); + expect(providerSpy).toHaveBeenNthCalledWith(1, "https://my-rpc.com"); + }); + }); + it("should broadcast the coin transaction and fill the blank in the optimistic transaction", async () => { const coinTransaction: EvmTransaction = { amount: new BigNumber(100), diff --git a/libs/coin-modules/coin-evm/src/api/node/ledger.ts b/libs/coin-modules/coin-evm/src/api/node/ledger.ts index 02598d249ff2..bb70766385b5 100644 --- a/libs/coin-modules/coin-evm/src/api/node/ledger.ts +++ b/libs/coin-modules/coin-evm/src/api/node/ledger.ts @@ -254,10 +254,12 @@ export const getFeeData: NodeApi["getFeeData"] = async (currency, transaction) = /** * Broadcast a serialized transaction and returns its hash + * @param broadcastConfig.mevProtected - Optional flag indicating whether the transaction should be protected against MEV attacks. */ export const broadcastTransaction: NodeApi["broadcastTransaction"] = async ( currency, signedTxHex, + broadcastConfig, ) => { const config = getCoinConfig(currency).info; const { node } = config || /* istanbul ignore next */ {}; @@ -271,8 +273,10 @@ export const broadcastTransaction: NodeApi["broadcastTransaction"] = async ( method: "POST", url: `${getEnv("EXPLORER")}/blockchain/v4/${node.explorerId}/tx/send`, data: { tx: signedTxHex }, + params: { + mevProtected: Boolean(broadcastConfig?.mevProtected), + }, }); - return hash; }; diff --git a/libs/coin-modules/coin-evm/src/api/node/types.ts b/libs/coin-modules/coin-evm/src/api/node/types.ts index 247d6010e1cf..9ff2e619c636 100644 --- a/libs/coin-modules/coin-evm/src/api/node/types.ts +++ b/libs/coin-modules/coin-evm/src/api/node/types.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { Account } from "@ledgerhq/types-live"; +import { Account, BroadcastConfig } from "@ledgerhq/types-live"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { Transaction as EvmTransaction, FeeData } from "../../types"; import { EvmConfigInfo } from "../../config"; @@ -26,7 +26,11 @@ export type NodeApi = { getTransactionCount: (currency: CryptoCurrency, address: string) => Promise; getGasEstimation: (account: Account, transaction: EvmTransaction) => Promise; getFeeData: (currency: CryptoCurrency, transaction: EvmTransaction) => Promise; - broadcastTransaction: (currency: CryptoCurrency, signedTxHex: string) => Promise; + broadcastTransaction: ( + currency: CryptoCurrency, + signedTxHex: string, + broadcastConfig?: BroadcastConfig, + ) => Promise; getBlockByHeight: ( currency: CryptoCurrency, blockHeight: number | "latest", diff --git a/libs/coin-modules/coin-evm/src/broadcast.ts b/libs/coin-modules/coin-evm/src/broadcast.ts index 910a5016e6e8..29205ed58f7f 100644 --- a/libs/coin-modules/coin-evm/src/broadcast.ts +++ b/libs/coin-modules/coin-evm/src/broadcast.ts @@ -9,9 +9,10 @@ import { getNodeApi } from "./api/node/index"; export const broadcast: AccountBridge["broadcast"] = async ({ account, signedOperation: { signature, operation }, + broadcastConfig, }) => { const nodeApi = getNodeApi(account.currency); - const hash = await nodeApi.broadcastTransaction(account.currency, signature); + const hash = await nodeApi.broadcastTransaction(account.currency, signature, broadcastConfig); return patchOperationWithHash(operation, hash); }; diff --git a/libs/ledger-live-common/src/hooks/useBroadcast.ts b/libs/ledger-live-common/src/hooks/useBroadcast.ts index dd43d701c291..2fc453fbc125 100644 --- a/libs/ledger-live-common/src/hooks/useBroadcast.ts +++ b/libs/ledger-live-common/src/hooks/useBroadcast.ts @@ -1,6 +1,12 @@ import { useCallback } from "react"; import { log } from "@ledgerhq/logs"; -import type { SignedOperation, Operation, AccountLike, Account } from "@ledgerhq/types-live"; +import type { + SignedOperation, + Operation, + AccountLike, + Account, + BroadcastConfig, +} from "@ledgerhq/types-live"; import { getEnv } from "@ledgerhq/live-env"; import { formatOperation, getMainAccount } from "../account/index"; import { getAccountBridge } from "../bridge/index"; @@ -9,9 +15,10 @@ import { execAndWaitAtLeast } from "../promise"; type SignTransactionArgs = { account?: AccountLike | null; parentAccount?: Account | null; + broadcastConfig?: BroadcastConfig; }; -export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => { +export const useBroadcast = ({ account, parentAccount, broadcastConfig }: SignTransactionArgs) => { const broadcast = useCallback( async (signedOperation: SignedOperation): Promise => { if (!account) throw new Error("account not present"); @@ -26,6 +33,7 @@ export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => const operation = await bridge.broadcast({ account: mainAccount, signedOperation, + broadcastConfig, }); log( "transaction-summary", @@ -34,7 +42,7 @@ export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => return operation; }); }, - [account, parentAccount], + [account, parentAccount, broadcastConfig], ); return broadcast; diff --git a/libs/ledgerjs/packages/types-live/src/bridge.ts b/libs/ledgerjs/packages/types-live/src/bridge.ts index d730a207ecf1..5dd0ba17d2dd 100644 --- a/libs/ledgerjs/packages/types-live/src/bridge.ts +++ b/libs/ledgerjs/packages/types-live/src/bridge.ts @@ -48,12 +48,17 @@ export type PreloadStrategy = Partial<{ preloadMaxAge: number; }>; +export type BroadcastConfig = { + mevProtected: boolean; +}; + /** * */ export type BroadcastArg = { account: A; signedOperation: SignedOperation; + broadcastConfig?: BroadcastConfig; }; /**