Skip to content

Commit

Permalink
feat: add mev protection for broadcasting (#8375)
Browse files Browse the repository at this point in the history
* feat: add mev protection

* add mev protection to swaps

* add changeset

* use the params parameter of axios

* versioning correction

* mev param documentation

* add unit tests

* add test

* fix tests

* fix test

* fix test
  • Loading branch information
Canestin authored Nov 29, 2024
1 parent 22a775b commit c45ee45
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 18 deletions.
9 changes: 9 additions & 0 deletions .changeset/few-toes-drive.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = (
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function SwapAction({
}: Props) {
const [initData, setInitData] = useState<InitSwapResult | null>(null);
const [signedOperation, setSignedOperation] = useState<SignedOperation | null>(null);
const mevProtected = useSelector(mevProtectionSelector);
const device = useSelector(getCurrentDevice);
const deviceRef = useRef(device);
const { account: fromAccount, parentAccount: fromParentAccount } = swapTransaction.swap.from;
Expand All @@ -83,6 +85,7 @@ export default function SwapAction({
const broadcast = useBroadcast({
account: fromAccount,
parentAccount: fromParentAccount,
broadcastConfig: { mevProtected },
});

const exchange = useMemo(
Expand Down
22 changes: 17 additions & 5 deletions apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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<SendFundsNavigatorStackParamList, ScreenName.SendSummary>
Expand Down Expand Up @@ -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<Operation> => {
invariant(account, "account not present");
const mainAccount = getMainAccount(account, parentAccount);
Expand All @@ -215,6 +224,7 @@ export const broadcastSignedTx = async (
.broadcast({
account: mainAccount,
signedOperation,
broadcastConfig,
})
.then(op => {
log(
Expand All @@ -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<Operation> =>
broadcastSignedTx(account, parentAccount, signedOperation),
[account, parentAccount],
broadcastSignedTx(account, parentAccount, signedOperation, broadcastConfig),
[account, parentAccount, broadcastConfig],
);
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -22,12 +24,13 @@ const PlatformCompleteExchange: React.FC<Props> = ({
},
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<Transaction>();
const [signedOperation, setSignedOperation] = useState<SignedOperation>();
const [error, setError] = useState<Error>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -79,10 +80,12 @@ export function Confirmation({

const [swapData, setSwapData] = useState<InitSwapResult | null>(null);
const [signedOperation, setSignedOperation] = useState<SignedOperation | null>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
129 changes: 126 additions & 3 deletions libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ethers } from "ethers";
import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId";
import {
encodeERC1155OperationId,
Expand All @@ -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";
Expand All @@ -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: {
Expand All @@ -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 => {
Expand All @@ -64,16 +85,118 @@ describe("EVM Family", () => {

describe("broadcast.ts", () => {
beforeAll(() => {
jest
.spyOn(API, "broadcastTransaction")
.mockImplementation(async () => mockedBroadcastResponse as any);
mockBroadcastTransactions();
});

afterAll(() => {
jest.restoreAllMocks();
});

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),
Expand Down
Loading

0 comments on commit c45ee45

Please sign in to comment.