fix: Fix hardware wallet MMPay on EIP-7702 chains by gating 7702 paths on account keyring capability#8388
fix: Fix hardware wallet MMPay on EIP-7702 chains by gating 7702 paths on account keyring capability#8388
Conversation
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
<!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit c372c6d. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
…lly, preventing remaining hardware wallet prompts from appearing after a rejection
| account: string, | ||
| ): boolean { | ||
| const { keyrings } = messenger.call('KeyringController:getState'); | ||
| const keyring = keyrings.find((k: { type: string; accounts: string[] }) => |
There was a problem hiding this comment.
Minor, could we return this with a some instead?
There was a problem hiding this comment.
Switched to some here. Also changed the fallback to return false when the keyring isn't found, since we'd rather be restrictive by default and not allow 7702 for accounts we can't resolve. Applied the same change to the pay controller duplicate.
| } | ||
|
|
||
| const { transactionMeta } = await addTransaction( | ||
| const { transactionMeta, result: signingComplete } = await addTransaction( |
There was a problem hiding this comment.
Is the goal here just to stop further signing if one of them is rejected at the signing stage? Meaning we ensure the whole thing (including any parent transaction via pay controller) fails?
If so, could we massively simplify by just awaiting the signing here so we ensure each one doesn't start until the previous is signed? Then no Promise.race nor changes to CollectPublishHook nor any need for abortTransactionSigning?
The only downside is probably a few quick gas requests for limits and fees that are no longer running in parallel, but the typical approval and swap case should always have gas limits anyway, and these are typically background processes anyway (usually 7702) so little impact.
We can't just await result since it won't resolve until the whole batch does.
But we could just make a small local function like waitForSigned(transactionId) that listens to the transactionStatusUpdated event and resolves if status is signed or throws if failed?
There was a problem hiding this comment.
Is the goal here just to stop further signing if one of them is rejected at the signing stage?
Yes, the goal is to ensure transactions are signed sequentially so that hardware wallet prompts appear one at a time, and if a signing is rejected, the remaining transactions in the batch don't proceed (and remaining Ledger prompts don't appear).
Meaning we ensure the whole thing (including any parent transaction via pay controller) fails?
That's correct. If any signing fails, the error propagates out of the for loop into the catch block, which calls abortTransactionSigning for any remaining transactions and then calls collectHook.error() + resultCallbacks?.error(), failing the entire batch including the parent pay controller flow.
If so, could we massively simplify by just awaiting the signing here so we ensure each one doesn't start until the previous is signed? Then no Promise.race nor changes to CollectPublishHook nor any need for abortTransactionSigning?
I explored this approach and ran into a fundamental timing issue.
When addTransaction is called with requireApproval: false, it returns immediately - the signing chain (#processApproval > #approveTransaction > #signTransaction) runs as a detached async operation via the result promise. A waitForSigned helper that subscribes to transactionStatusUpdated after addTransaction returns has a race condition: the signed event can fire before the subscription is set up.
In practice this breaks the hardware wallet flow. The "Confirm on Ledger" modal never appears because the loop moves on before the signing flow has properly started - the event listener misses the status transitions entirely.
We can't subscribe before addTransaction either, because the transactionId isn't known until it returns.
The current CollectPublishHook.waitForSignedCount approach avoids this because the hook is passed into addTransaction and called from within the signing flow - it can't miss the event. The Promise.race with signingFailure handles the rejection path.
I think the current approach is the simplest one that's actually correct given the async topology here.
| ); | ||
|
|
||
| return keyring | ||
| ? (KEYRING_TYPES_SUPPORTING_7702 as string[]).includes(keyring.type) |
There was a problem hiding this comment.
Minor, this is obviously duplicated in transaction controller, but it lets pay have its own criteria also. Could expose via action in transaction controller but not urgent, so better a future PR when needed.
There was a problem hiding this comment.
I agree, this is a duplication for now since the pay controller uses its own messenger type. Happy to expose it via a TransactionController action in a follow-up PR.
| // If the batch returned a combined 7702 gas limit but the account cannot | ||
| // sign EIP-7702 authorizations (e.g. hardware wallet), re-estimate each | ||
| // transaction individually so callers never see is7702=true. | ||
| const supports7702 = accountSupports7702(messenger, firstTransaction.from); |
There was a problem hiding this comment.
Is this all redundant if we check this inside estimateGasBatch in the transaction controller?
Since this too will be a limitation everywhere as with batch add?
We already check isAtomicBatchSupported for example, so would just check this too.
There was a problem hiding this comment.
Good call - moved the account support check into estimateGasBatch in the transaction controller so it applies everywhere. When the account doesn't support 7702, estimateGasBatch now skips the 7702 path entirely and falls through to per-transaction estimation. Removed the redundant check and the individual re-estimation fallback from the pay controller.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 46856b2. Configure here.
|
|
||
| const chainResult = accountSupports7702 | ||
| ? is7702Result.find((result) => result.chainId === chainId) | ||
| : undefined; |
There was a problem hiding this comment.
Unnecessary RPC calls when account doesn't support 7702
Low Severity
estimateGasBatch unconditionally calls isAtomicBatchSupported (which performs on-chain RPC queries to check delegation status) before checking doesAccountSupportEIP7702. When the account doesn't support 7702 (e.g., hardware wallets), the entire is7702Result is discarded by setting chainResult to undefined. Moving the doesAccountSupportEIP7702 check before the isAtomicBatchSupported call would avoid unnecessary network round-trips for hardware wallet users.
Reviewed by Cursor Bugbot for commit 46856b2. Configure here.


Explanation
Fix hardware wallet mUSD conversion on EIP-7702 chains by gating 7702 paths on account keyring capability, and fix batch transaction signing to prevent remaining hardware wallet prompts after a rejection.
Summary
@metamask/transaction-controllerKeyringControllerGetStateActiontoAllowedActions— clients must addKeyringController:getStateto the TransactionController messenger's allowed actionsdoesAccountSupportEIP7702ineip7702.ts— checks keyring type viaKeyringController:getState, returnstrueonly for HD Key Tree and Simple Key Pair keyrings, falls back totruewhen keyring is unresolvedaddTransactionBatch7702 path ondoesAccountSupportEIP7702— hardware wallet accounts fall back to sequential/hook batch flow instead of attempting EIP-7702 batch signingaddTransactionfor the next, preventing remaining Ledger prompts from appearing after a rejectionCollectPublishHook.waitForSignedCount(count)to support sequential signing gating in the batch loopabortTransactionSigning@metamask/transaction-pay-controllerKeyringControllerGetStateActiontoAllowedActions— clients must addKeyringController:getStateto the TransactionPayController messenger's allowed actionsaccountSupports7702utility inutils/7702.tsandKEYRING_TYPES_SUPPORTING_7702constant intypes.tsaccountSupports7702flag throughPayStrategyGetQuotesRequestandPayStrategyExecuteRequesttypesuseExecuteflag onaccountSupports7702— ensures the relay API receives non-7702 requests for hardware wallets and returns quotes with proper individual gas limitsaccountSupports7702fromTransactionPayPublishHookinto strategyexecute()callsaccountSupports7702from fiat-quotes into relay-quotesestimateQuoteGasLimitsBatchwhen batch simulation returns a combined 7702 gas limit but account doesn't support 7702References
Checklist
Note
Medium Risk
Introduces breaking messenger permission changes (
KeyringController:getState) and alters batch signing/7702 execution paths, which can affect transaction submission behavior especially for hardware wallets.Overview
EIP-7702 support is now gated by keyring capability.
TransactionControlleraddsKeyringController:getStatetoAllowedActions, introducesdoesAccountSupportEIP7702, and uses it to skip the 7702 batch path (andestimateGasBatch7702 combined estimation) when the account’s keyring type is unsupported.Batch signing is made more robust for hardware wallets. Hook-based batch processing now waits for each transaction to finish signing before proceeding, adds targeted waiting via
CollectPublishHook.waitForSignedCount, and aborts remaining in-flight signings viaabortTransactionSigningwhen a batch fails.Transaction Pay flows now carry an explicit 7702 capability flag.
TransactionPayControllerrequiresKeyringController:getState, addsaccountSupports7702/KEYRING_TYPES_SUPPORTING_7702, threadsaccountSupports7702through quote/execute request types, and gates relay/Aggregator 7702-specific behavior based on that flag; tests and changelogs are updated accordingly.Reviewed by Cursor Bugbot for commit 46856b2. Bugbot is set up for automated code reviews on this repo. Configure here.