Skip to content

Commit 47f8e57

Browse files
authored
feat: add support to automatically upgrade account (#22241)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR enables automatic account upgrades when Smart Transactions (STX) are turned off. It removes the UI condition that previously blocked the flow from initiating and refines the EIP-7702 authorization handling, signature normalization, and gasless eligibility logic. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added automatic account upgrade support ## **Related issues** Fixes: MetaMask/mobile-planning#2332 ## **Manual testing steps** ```gherkin Feature: Automatic account upgrade when Smart Transactions are off Scenario: User sends a transaction using USDC as the gas token Given the user account has not been upgraded on the test network And the account has a sufficient USDC balance When the user sends a transaction And selects USDC as the gas payment token Then the transaction should complete successfully And the account should be automatically upgraded after completion ``` ## **Screenshots/Recordings** [mobile-auto-upgrade.webm](https://github.com/user-attachments/assets/f73d9f2e-ed76-432d-9d76-8542b1f6bf70) <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds nonce-handling to the 7702 delegation publish path and simplifies gasless eligibility by relying on relay support, plus signature normalization utilities and tests. > > - **Confirmations (Gasless eligibility)**: > - Simplify `useIsGaslessSupported`: remove `isAtomicBatchSupported` checks; now uses relay support and excludes contract deployments. Updated tests accordingly. > - **Transaction Controller Init**: > - Wire `Delegation7702PublishHook` into `hooks.publish` when STX disabled or `sendBundle` unsupported. > - Add `getNextNonce` helper using `getNonceLock`/`releaseLock`; pass to `Delegation7702PublishHook`. Tests verify locking and hex conversion. > - **Delegation 7702 Publish Hook**: > - Accept `getNextNonce`; use it to build authorization list (fallback when tx nonce absent). > - Normalize authorization signature parts (`r`, `s`, `yParity`) using new util and `toHex`; improve calldata normalization. > - Expand tests for gasless/sponsored flows, deployments, relay responses, and errors. > - **Utils**: > - Add `stripSingleLeadingZero` with tests for hex normalization. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1b24923. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1b6e432 commit 47f8e57

File tree

8 files changed

+119
-114
lines changed

8 files changed

+119
-114
lines changed

app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts

Lines changed: 0 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { waitFor } from '@testing-library/react-native';
33
import { merge } from 'lodash';
44
import { transferConfirmationState } from '../../../../../util/test/confirm-data-helpers';
55
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
6-
import { isAtomicBatchSupported } from '../../../../../util/transaction-controller';
76
import { isRelaySupported } from '../../../../../util/transactions/transaction-relay';
87
import { transferTransactionStateMock } from '../../__mocks__/transfer-transaction-mock';
98
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
@@ -51,7 +50,6 @@ describe('useIsGaslessSupported', () => {
5150
const mockUseTransactionMetadataRequest = jest.mocked(
5251
useTransactionMetadataRequest,
5352
);
54-
const isAtomicBatchSupportedMock = jest.mocked(isAtomicBatchSupported);
5553
const isRelaySupportedMock = jest.mocked(isRelaySupported);
5654
const useGaslessSupportedSmartTransactionsMock = jest.mocked(
5755
useGaslessSupportedSmartTransactions,
@@ -63,7 +61,6 @@ describe('useIsGaslessSupported', () => {
6361
txParams: { from: '0x123', to: '0xabc' },
6462
} as unknown as TransactionMeta);
6563
isRelaySupportedMock.mockResolvedValue(false);
66-
isAtomicBatchSupportedMock.mockResolvedValue([]);
6764
useGaslessSupportedSmartTransactionsMock.mockReturnValue({
6865
isSmartTransaction: false,
6966
isSupported: false,
@@ -138,13 +135,6 @@ describe('useIsGaslessSupported', () => {
138135
describe('Gasless EIP-7702', () => {
139136
it('returns isSupported true and isSmartTransaction: false when EIP-7702 conditions met', async () => {
140137
isRelaySupportedMock.mockResolvedValue(true);
141-
isAtomicBatchSupportedMock.mockResolvedValue([
142-
{
143-
chainId: '0x1',
144-
isSupported: true,
145-
delegationAddress: '0xde1',
146-
},
147-
]);
148138

149139
const state = merge({}, transferTransactionStateMock);
150140
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
@@ -159,38 +149,8 @@ describe('useIsGaslessSupported', () => {
159149
});
160150
});
161151

162-
it('returns isSupported false and isSmartTransaction: false when atomicBatchSupported account not upgraded', async () => {
163-
isRelaySupportedMock.mockResolvedValue(true);
164-
isAtomicBatchSupportedMock.mockResolvedValue([
165-
{
166-
chainId: '0x1',
167-
isSupported: false,
168-
delegationAddress: undefined,
169-
},
170-
]);
171-
172-
const state = merge({}, transferTransactionStateMock);
173-
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
174-
state,
175-
});
176-
177-
await waitFor(() => {
178-
expect(result.current).toEqual({
179-
isSupported: false,
180-
isSmartTransaction: false,
181-
});
182-
});
183-
});
184-
185152
it('returns isSupported false and isSmartTransaction: false when relay not supported', async () => {
186153
isRelaySupportedMock.mockResolvedValue(false);
187-
isAtomicBatchSupportedMock.mockResolvedValue([
188-
{
189-
chainId: '0x1',
190-
isSupported: true,
191-
delegationAddress: '0xde1',
192-
},
193-
]);
194154

195155
const state = merge({}, transferTransactionStateMock);
196156
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
@@ -211,13 +171,6 @@ describe('useIsGaslessSupported', () => {
211171
txParams: { from: '0x123' }, // no "to"
212172
} as unknown as TransactionMeta);
213173
isRelaySupportedMock.mockResolvedValue(true);
214-
isAtomicBatchSupportedMock.mockResolvedValue([
215-
{
216-
chainId: '0x1',
217-
isSupported: true,
218-
delegationAddress: '0xde1',
219-
},
220-
]);
221174

222175
const state = merge({}, transferTransactionStateMock);
223176
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
@@ -234,32 +187,6 @@ describe('useIsGaslessSupported', () => {
234187

235188
it('returns isSupported false and isSmartTransaction: false when no matching chain support in atomicBatch', async () => {
236189
isRelaySupportedMock.mockResolvedValue(true);
237-
isAtomicBatchSupportedMock.mockResolvedValue([
238-
{
239-
chainId: '0x3',
240-
isSupported: true,
241-
delegationAddress: '0xde1',
242-
},
243-
]);
244-
245-
const state = merge({}, transferTransactionStateMock);
246-
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
247-
state,
248-
});
249-
250-
await waitFor(() => {
251-
expect(result.current).toEqual({
252-
isSupported: false,
253-
isSmartTransaction: false,
254-
});
255-
});
256-
});
257-
258-
it('returns isSupported false and isSmartTransaction: false if isAtomicBatchSupported returns undefined', async () => {
259-
isRelaySupportedMock.mockResolvedValue(true);
260-
isAtomicBatchSupportedMock.mockResolvedValue(
261-
undefined as unknown as ReturnType<typeof isAtomicBatchSupported>,
262-
);
263190

264191
const state = merge({}, transferTransactionStateMock);
265192
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
@@ -278,13 +205,6 @@ describe('useIsGaslessSupported', () => {
278205
isRelaySupportedMock.mockResolvedValue(
279206
undefined as unknown as ReturnType<typeof isRelaySupported>,
280207
);
281-
isAtomicBatchSupportedMock.mockResolvedValue([
282-
{
283-
chainId: '0x1',
284-
isSupported: true,
285-
delegationAddress: '0xde1',
286-
},
287-
]);
288208

289209
const state = merge({}, transferTransactionStateMock);
290210
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {

app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
22
import { useAsyncResult } from '../../../../hooks/useAsyncResult';
33
import { isRelaySupported } from '../../../../../util/transactions/transaction-relay';
4-
import { isAtomicBatchSupported } from '../../../../../util/transaction-controller';
54
import { Hex } from '@metamask/utils';
65
import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions';
76

@@ -20,7 +19,6 @@ export function useIsGaslessSupported() {
2019
const transactionMeta = useTransactionMetadataRequest();
2120

2221
const { chainId, txParams } = transactionMeta ?? {};
23-
const { from } = txParams ?? {};
2422

2523
const {
2624
isSmartTransaction,
@@ -31,17 +29,6 @@ export function useIsGaslessSupported() {
3129
const shouldCheck7702Eligibility =
3230
!pending && !isSmartTransactionAndBundleSupported;
3331

34-
const { value: atomicBatchSupportResult } = useAsyncResult(async () => {
35-
if (!shouldCheck7702Eligibility) {
36-
return undefined;
37-
}
38-
39-
return isAtomicBatchSupported({
40-
address: from as Hex,
41-
chainIds: [chainId as Hex],
42-
});
43-
}, [chainId, from, shouldCheck7702Eligibility]);
44-
4532
const { value: relaySupportsChain } = useAsyncResult(async () => {
4633
if (!shouldCheck7702Eligibility) {
4734
return undefined;
@@ -50,14 +37,8 @@ export function useIsGaslessSupported() {
5037
return isRelaySupported(chainId as Hex);
5138
}, [chainId, shouldCheck7702Eligibility]);
5239

53-
const atomicBatchChainSupport = atomicBatchSupportResult?.find(
54-
(result) => result.chainId.toLowerCase() === chainId?.toLowerCase(),
55-
);
56-
57-
// Currently requires upgraded account, can also support no `delegationAddress` in future.
5840
const is7702Supported = Boolean(
59-
atomicBatchChainSupport?.isSupported &&
60-
relaySupportsChain &&
41+
relaySupportsChain &&
6142
// contract deployments can't be delegated
6243
txParams?.to !== undefined,
6344
);

app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { NetworkController } from '@metamask/network-controller';
2+
import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller';
13
import {
24
PublishHook,
35
TransactionController,
@@ -7,29 +9,28 @@ import {
79
TransactionType,
810
type PublishBatchHookTransaction,
911
} from '@metamask/transaction-controller';
10-
import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller';
11-
import { NetworkController } from '@metamask/network-controller';
1212

13+
import { toHex } from '@metamask/controller-utils';
14+
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
15+
import { Hex } from '@metamask/utils';
1316
import { selectSwapsChainFeatureFlags } from '../../../../reducers/swaps';
1417
import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController';
1518
import { getGlobalChainId } from '../../../../util/networks/global-network';
1619
import { submitSmartTransactionHook } from '../../../../util/smart-transactions/smart-publish-hook';
20+
import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish';
21+
import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api';
1722
import { ExtendedMessenger } from '../../../ExtendedMessenger';
18-
import { buildControllerInitRequestMock } from '../../utils/test-utils';
1923
import { TransactionControllerInitMessenger } from '../../messengers/transaction-controller-messenger';
2024
import { ControllerInitRequest } from '../../types';
21-
import { TransactionControllerInit } from './transaction-controller-init';
25+
import { buildControllerInitRequestMock } from '../../utils/test-utils';
2226
import {
2327
handleTransactionAddedEventForMetrics,
2428
handleTransactionApprovedEventForMetrics,
2529
handleTransactionFinalizedEventForMetrics,
2630
handleTransactionRejectedEventForMetrics,
2731
handleTransactionSubmittedEventForMetrics,
2832
} from './event-handlers/metrics';
29-
import { Hex } from '@metamask/utils';
30-
import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish';
31-
import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api';
32-
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
33+
import { TransactionControllerInit } from './transaction-controller-init';
3334
import { TransactionPayPublishHook } from '@metamask/transaction-pay-controller';
3435

3536
jest.mock('@metamask/transaction-controller');
@@ -405,6 +406,7 @@ describe('Transaction Controller Init', () => {
405406
expect(Delegation7702PublishHookMock).toHaveBeenCalledWith({
406407
isAtomicBatchSupported: expect.any(Function),
407408
messenger: expect.any(Object),
409+
getNextNonce: expect.any(Function),
408410
});
409411
expect(mockDelegation7702Hook).toHaveBeenCalled();
410412
expect(result).toEqual({ transactionHash: '0xde702' });
@@ -609,4 +611,45 @@ describe('Transaction Controller Init', () => {
609611
expect(await optionFn?.(mockTransactionMeta)).toBe(false);
610612
});
611613
});
614+
615+
it('calls getNonceLock and releaseLock via Delegation7702PublishHook getNextNonce', async () => {
616+
const releaseLockMock = jest.fn();
617+
const getNonceLockMock = jest.fn().mockResolvedValue({
618+
nextNonce: 99,
619+
releaseLock: releaseLockMock,
620+
});
621+
622+
isSendBundleSupportedMock.mockResolvedValue(false);
623+
transactionControllerClassMock.mockImplementation(
624+
() =>
625+
({
626+
getNonceLock: getNonceLockMock,
627+
isAtomicBatchSupported: jest.fn().mockResolvedValue([]),
628+
}) as unknown as TransactionController,
629+
);
630+
631+
let capturedGetNextNonce:
632+
| ((address: string, networkClientId: string) => Promise<Hex>)
633+
| undefined;
634+
jest.mocked(Delegation7702PublishHook).mockImplementation((opts) => {
635+
capturedGetNextNonce = opts.getNextNonce;
636+
return {
637+
getHook: () => async () => ({ transactionHash: '0xde702' }),
638+
} as unknown as InstanceType<typeof Delegation7702PublishHook>;
639+
});
640+
641+
const hooks = testConstructorOption('hooks', {
642+
getNonceLock: getNonceLockMock,
643+
});
644+
645+
await hooks?.publish?.({
646+
...MOCK_TRANSACTION_META,
647+
chainId: '0x13',
648+
});
649+
650+
const resultNonce = await capturedGetNextNonce?.('0xabc', 'testNetwork');
651+
expect(getNonceLockMock).toHaveBeenCalledWith('0xabc', 'testNetwork');
652+
expect(releaseLockMock).toHaveBeenCalled();
653+
expect(resultNonce).toBe(toHex(99));
654+
});
612655
});

app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import {
4949
import { trace } from '../../../../util/trace';
5050
import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish';
5151
import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api';
52+
import { NetworkClientId } from '@metamask/network-controller';
53+
import { toHex } from '@metamask/controller-utils';
5254

5355
export const TransactionControllerInit: ControllerInitFunction<
5456
TransactionController,
@@ -164,6 +166,19 @@ export const TransactionControllerInit: ControllerInitFunction<
164166
}
165167
};
166168

169+
async function getNextNonce(
170+
transactionController: TransactionController,
171+
address: string,
172+
networkClientId: NetworkClientId,
173+
): Promise<Hex> {
174+
const nonceLock = await transactionController.getNonceLock(
175+
address,
176+
networkClientId,
177+
);
178+
nonceLock.releaseLock();
179+
return toHex(nonceLock.nextNonce);
180+
}
181+
167182
async function publishHook({
168183
transactionMeta,
169184
getState,
@@ -204,6 +219,8 @@ async function publishHook({
204219
transactionController,
205220
),
206221
messenger: initMessenger,
222+
getNextNonce: (address: string, networkClientId: NetworkClientId) =>
223+
getNextNonce(transactionController, address, networkClientId),
207224
}).getHook();
208225

209226
const result = await hook(transactionMeta, signedTransactionInHex);

app/util/transactions/hooks/delegation-7702-publish.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
waitForRelayResult,
2525
} from '../transaction-relay';
2626
import { Delegation7702PublishHook } from './delegation-7702-publish';
27+
import { NetworkClientId } from '@metamask/network-controller';
28+
import { Hex } from '@metamask/utils';
2729

2830
jest.mock('../transaction-relay');
2931
jest.mock('../../../core/Delegation/delegation', () => ({
@@ -98,6 +100,9 @@ describe('Delegation 7702 Publish Hook', () => {
98100
const signDelegationControllerMock: jest.MockedFn<
99101
DelegationControllerSignDelegationAction['handler']
100102
> = jest.fn();
103+
const getNextNonceMock: jest.MockedFn<
104+
(address: string, networkClientId: NetworkClientId) => Promise<Hex>
105+
> = jest.fn();
101106

102107
beforeEach(() => {
103108
jest.resetAllMocks();
@@ -144,6 +149,7 @@ describe('Delegation 7702 Publish Hook', () => {
144149
hookClass = new Delegation7702PublishHook({
145150
isAtomicBatchSupported: isAtomicBatchSupportedMock,
146151
messenger,
152+
getNextNonce: getNextNonceMock,
147153
});
148154

149155
isAtomicBatchSupportedMock.mockResolvedValue([]);

0 commit comments

Comments
 (0)