Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/keyring-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `accountSupports7702` method and `KeyringController:accountSupports7702` messenger action to check whether an account's keyring supports EIP-7702 authorization signing ([#8388](https://github.com/MetaMask/core/pull/8388))

### Changed

- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373))
Expand Down
47 changes: 47 additions & 0 deletions packages/keyring-controller/src/KeyringController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,53 @@ describe('KeyringController', () => {
});
});

describe('accountSupports7702', () => {
it('should return true for HD keyring accounts', async () => {
await withController(async ({ controller, initialState }) => {
const account = initialState.keyrings[0].accounts[0];
const result = await controller.accountSupports7702(account);
expect(result).toBe(true);
});
});

it('should return true for simple keyring accounts', async () => {
await withController(async ({ controller }) => {
const importedAccountAddress =
await controller.importAccountWithStrategy(
AccountImportStrategy.privateKey,
[privateKey],
);

const result = await controller.accountSupports7702(
importedAccountAddress,
);
expect(result).toBe(true);
});
});

it('should return false for non-HD and non-simple keyring accounts', async () => {
const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19';
stubKeyringClassWithAccount(MockKeyring, address);
await withController(
{ keyringBuilders: [keyringBuilderFactory(MockKeyring)] },
async ({ controller }) => {
await controller.addNewKeyring(MockKeyring.type);

const result = await controller.accountSupports7702(address);
expect(result).toBe(false);
},
);
});

it('should throw error if no keyring is found for the given account', async () => {
await withController(async ({ controller }) => {
await expect(controller.accountSupports7702('0x')).rejects.toThrow(
KeyringControllerErrorMessage.KeyringNotFound,
);
});
});
});

describe('getEncryptionPublicKey', () => {
describe('when the keyring for the given address supports getEncryptionPublicKey', () => {
it('should return the correct encryption public key', async () => {
Expand Down
25 changes: 20 additions & 5 deletions packages/keyring-controller/src/KeyringController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export type KeyringControllerRemoveAccountAction = {
handler: KeyringController['removeAccount'];
};

export type KeyringControllerAccountSupports7702Action = {
type: `${typeof name}:accountSupports7702`;
handler: KeyringController['accountSupports7702'];
};

export type KeyringControllerStateChangeEvent = {
type: `${typeof name}:stateChange`;
payload: [KeyringControllerState, Patch[]];
Expand Down Expand Up @@ -256,7 +261,8 @@ export type KeyringControllerActions =
| KeyringControllerAddNewKeyringAction
| KeyringControllerCreateNewVaultAndKeychainAction
| KeyringControllerCreateNewVaultAndRestoreAction
| KeyringControllerRemoveAccountAction;
| KeyringControllerRemoveAccountAction
| KeyringControllerAccountSupports7702Action;

export type KeyringControllerEvents =
| KeyringControllerStateChangeEvent
Expand Down Expand Up @@ -1921,10 +1927,14 @@ export class KeyringController<
return keyring.type;
}

/**
* Constructor helper for registering this controller's messeger
* actions.
*/
async accountSupports7702(account: string): Promise<boolean> {
const keyringType = await this.getAccountKeyringType(account);
return (
keyringType === (KeyringTypes.hd as string) ||
keyringType === (KeyringTypes.simple as string)
);
}

#registerMessageHandlers(): void {
this.messenger.registerActionHandler(
`${name}:signMessage`,
Expand Down Expand Up @@ -2025,6 +2035,11 @@ export class KeyringController<
`${name}:removeAccount`,
this.removeAccount.bind(this),
);

this.messenger.registerActionHandler(
`${name}:accountSupports7702`,
this.accountSupports7702.bind(this),
);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fix perps withdraw to Arbitrum USDC showing inflated transaction fee by bypassing same-token filter when `isHyperliquidSource` is set ([#8387](https://github.com/MetaMask/core/pull/8387))
- Fix mUSD conversion for hardware wallets on EIP-7702 chains by gating relay and Across 7702 paths on `KeyringController:accountSupports7702` ([#8388](https://github.com/MetaMask/core/pull/8388))

## [19.0.3]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe('Across Quotes', () => {
const calculateGasCostMock = jest.mocked(calculateGasCost);

const {
accountSupports7702Mock,
messenger,
estimateGasMock,
estimateGasBatchMock,
Expand Down Expand Up @@ -203,6 +204,7 @@ describe('Across Quotes', () => {
getGasBufferMock.mockReturnValue(1.0);
getSlippageMock.mockReturnValue(0.005);

accountSupports7702Mock.mockResolvedValue(true);
findNetworkClientIdByChainIdMock.mockReturnValue('mainnet');
estimateGasMock.mockResolvedValue({
gas: '0x5208',
Expand Down Expand Up @@ -1253,6 +1255,44 @@ describe('Across Quotes', () => {
);
});

it('re-estimates individually when batch returns 7702 but account does not support it', async () => {
accountSupports7702Mock.mockResolvedValue(false);

estimateGasBatchMock.mockResolvedValue({
totalGasLimit: 51000,
gasLimits: [51000],
});

estimateGasMock.mockResolvedValue({
gas: '0x5208',
simulationFails: undefined,
});

successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
approvalTxns: [
{
chainId: 1,
data: '0xaaaa' as Hex,
to: '0xapprove1' as Hex,
value: '0x1' as Hex,
},
],
}),
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(result[0].original.metamask.is7702).toBe(false);
expect(result[0].original.metamask.gasLimits).toHaveLength(2);
expect(estimateGasMock).toHaveBeenCalledTimes(2);
});

it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => {
const estimateQuoteGasLimitsSpy = jest.spyOn(
quoteGasUtils,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ async function calculateSourceNetworkCost(
const orderedTransactions = getAcrossOrderedTransactions({ quote });
const { swapTx } = quote;
const swapChainId = toHex(swapTx.chainId);
const gasEstimates = await estimateQuoteGasLimits({
let gasEstimates = await estimateQuoteGasLimits({
fallbackGas: acrossFallbackGas,
messenger,
transactions: orderedTransactions.map((transaction) => ({
Expand All @@ -413,7 +413,43 @@ async function calculateSourceNetworkCost(
value: transaction.value ?? '0x0',
})),
});
const { batchGasLimit, is7702 } = gasEstimates;
const { batchGasLimit } = gasEstimates;

const accountSupports7702 = await messenger.call(
'KeyringController:accountSupports7702',
from,
);

// If the chain returned a combined 7702 gas limit but the account cannot sign
// EIP-7702 authorizations (e.g. hardware wallet), re-estimate each transaction
// individually so the submit path receives per-transaction gas limits.
if (gasEstimates.is7702 && !accountSupports7702) {
const individualResults = await Promise.all(
orderedTransactions.map((transaction) =>
estimateQuoteGasLimits({
fallbackGas: acrossFallbackGas,
messenger,
transactions: [
{
chainId: toHex(transaction.chainId),
data: transaction.data,
from,
gas: transaction.gas,
to: transaction.to,
value: transaction.value ?? '0x0',
},
],
}),
),
);
gasEstimates = {
...gasEstimates,
is7702: false,
gasLimits: individualResults.map((result) => result.gasLimits[0]),
};
}

const { is7702 } = gasEstimates;

if (is7702) {
if (!batchGasLimit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('Across Submit', () => {
const successfulFetchMock = jest.mocked(successfulFetch);

const {
accountSupports7702Mock,
addTransactionBatchMock,
addTransactionMock,
estimateGasMock,
Expand All @@ -126,6 +127,7 @@ describe('Across Submit', () => {
},
});

accountSupports7702Mock.mockResolvedValue(true);
estimateGasMock.mockResolvedValue({
gas: '0x5208',
simulationFails: undefined,
Expand Down Expand Up @@ -259,6 +261,40 @@ describe('Across Submit', () => {
);
});

it('disables 7702 batch when account does not support 7702', async () => {
accountSupports7702Mock.mockResolvedValue(false);

const nonIs7702Quote = {
...QUOTE_MOCK,
original: {
...QUOTE_MOCK.original,
metamask: {
gasLimits: [
{ estimate: 21000, max: 21000 },
{ estimate: 22000, max: 22000 },
],
is7702: false,
},
},
} as unknown as TransactionPayQuote<AcrossQuote>;

await submitAcrossQuotes({
messenger,
quotes: [nonIs7702Quote],
transaction: TRANSACTION_META_MOCK,
isSmartTransaction: jest.fn(),
});

expect(addTransactionBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
disable7702: true,
disableHook: false,
disableSequential: false,
gasLimit7702: undefined,
}),
);
});

it('submits a single transaction when no approvals', async () => {
const noApprovalQuote = {
...QUOTE_MOCK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function submitAcrossQuotes(
log('Executing quotes', request);

const { quotes, messenger, transaction } = request;

let transactionHash: Hex | undefined;

for (const quote of quotes) {
Expand Down Expand Up @@ -117,8 +118,14 @@ async function submitTransactions(
messenger: TransactionPayControllerMessenger,
): Promise<Hex | undefined> {
const { swapTx } = quote.original.quote;
const { gasLimits: quoteGasLimits, is7702 } = quote.original.metamask;
const { gasLimits: quoteGasLimits, is7702: apiIs7702 } =
quote.original.metamask;
const { from } = quote.request;
const accountSupports7702 = await messenger.call(
'KeyringController:accountSupports7702',
from,
);
const is7702 = apiIs7702 && accountSupports7702;
const chainId = toHex(swapTx.chainId);
const orderedTransactions = getAcrossOrderedTransactions({
quote: quote.original.quote,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ describe('Relay Quotes Utils', () => {
const getSlippageMock = jest.mocked(getSlippage);

const {
accountSupports7702Mock,
messenger,
estimateGasMock,
estimateGasBatchMock,
Expand Down Expand Up @@ -215,6 +216,7 @@ describe('Relay Quotes Utils', () => {
...getDefaultRemoteFeatureFlagControllerState(),
});

accountSupports7702Mock.mockResolvedValue(true);
isEIP7702ChainMock.mockReturnValue(true);
isRelayExecuteEnabledMock.mockReturnValue(false);
getGasBufferMock.mockReturnValue(1.0);
Expand Down Expand Up @@ -320,6 +322,27 @@ describe('Relay Quotes Utils', () => {
expect(body.originGasOverhead).toBeUndefined();
});

it('omits originGasOverhead when account does not support 7702 even on EIP-7702 chain with relay execute enabled', async () => {
isRelayExecuteEnabledMock.mockReturnValue(true);
accountSupports7702Mock.mockResolvedValue(false);

successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.originGasOverhead).toBeUndefined();
});

it('sends request with EXACT_INPUT trade type when isMaxAmount is true', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,13 @@ async function getSingleQuote(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const useExactInput = isMaxAmount || request.isPostQuote;

const accountSupports7702 = await messenger.call(
'KeyringController:accountSupports7702',
from,
);

const useExecute =
accountSupports7702 &&
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relay quotes missing gas re-estimation for hardware wallets

Medium Severity

Unlike the across-quotes path, relay-quotes doesn't re-estimate individual gas limits when estimateQuoteGasLimits returns is7702: true but the account is a hardware wallet. The quote stores is7702: true with a single combined gas limit in metamask.gasLimits. When relay-submit later disables 7702 for the hardware wallet, the first batch transaction gets the combined 7702 gas value (meant for all transactions together) and subsequent transactions get undefined gas. The across path explicitly handles this with per-transaction re-estimation, but the relay path does not.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bce8c09. Configure here.

isRelayExecuteEnabled(messenger) &&
isEIP7702Chain(messenger, sourceChainId);

Expand Down
Loading