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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
SmartTransactionStatuses,
} from '@metamask/smart-transactions-controller';
import { Hex } from '@metamask/utils';
import { NetworkClientId } from '@metamask/network-controller';
import { toHex } from '@metamask/controller-utils';
import { trace } from '../../../../shared/lib/trace';
import { getIsSmartTransaction } from '../../../../shared/modules/selectors';
import { getShieldGatewayConfig } from '../../../../shared/modules/shield';
Expand Down Expand Up @@ -346,6 +348,19 @@ function getUIState(flatState: ControllerFlatState) {
return { metamask: flatState };
}

async function getNextNonce(
transactionController: TransactionController,
address: string,
networkClientId: NetworkClientId,
): Promise<Hex> {
const nonceLock = await transactionController.getNonceLock(
address,
networkClientId,
);
nonceLock.releaseLock();
return toHex(nonceLock.nextNonce);
}

export async function publishHook({
flatState,
initMessenger,
Expand Down Expand Up @@ -373,6 +388,8 @@ export async function publishHook({
transactionController,
),
messenger: initMessenger,
getNextNonce: (address, networkClientId) =>
getNextNonce(transactionController, address, networkClientId),
}).getHook();

const result = await hook(transactionMeta, signedTx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
TransactionMeta,
TransactionType,
} from '@metamask/transaction-controller';
import { NetworkClientId } from '@metamask/network-controller';
import { Hex } from '@metamask/utils';
import { getDeleGatorEnvironment } from '../../../../../shared/lib/delegation';
import { GAS_FEE_TOKEN_MOCK } from '../../../../../test/data/confirmations/gas';
import { TransactionControllerInitMessenger } from '../../../controller-init/messengers/transaction-controller-messenger';
Expand Down Expand Up @@ -77,6 +79,10 @@ describe('Delegation 7702 Publish Hook', () => {
TransactionController['isAtomicBatchSupported']
> = jest.fn();

const getNextNonceMock: jest.MockedFn<
(address: string, networkClientId: NetworkClientId) => Promise<Hex>
> = jest.fn();

const signDelegationControllerMock: jest.MockedFn<
DelegationControllerSignDelegationAction['handler']
> = jest.fn();
Expand Down Expand Up @@ -135,6 +141,7 @@ describe('Delegation 7702 Publish Hook', () => {
hookClass = new Delegation7702PublishHook({
isAtomicBatchSupported: isAtomicBatchSupportedMock,
messenger,
getNextNonce: getNextNonceMock,
});

isAtomicBatchSupportedMock.mockResolvedValue([]);
Expand Down
29 changes: 23 additions & 6 deletions app/scripts/lib/transaction/hooks/delegation-7702-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
PublishHookResult,
TransactionMeta,
} from '@metamask/transaction-controller';
import { Hex, createProjectLogger } from '@metamask/utils';
import { Hex, add0x, createProjectLogger } from '@metamask/utils';
import { toHex } from '@metamask/controller-utils';
import { NetworkClientId } from '@metamask/network-controller';
import {
BATCH_DEFAULT_MODE,
Caveat,
Expand Down Expand Up @@ -41,6 +43,7 @@ import {
submitRelayTransaction,
waitForRelayResult,
} from '../transaction-relay';
import { stripSingleLeadingZero } from '../util';

const EMPTY_HEX = '0x';
const POLLING_INTERVAL_MS = 1000; // 1 Second
Expand All @@ -58,17 +61,28 @@ export class Delegation7702PublishHook {

#messenger: TransactionControllerInitMessenger;

#getNextNonce: (
address: string,
networkClientId: NetworkClientId,
) => Promise<Hex>;

constructor({
isAtomicBatchSupported,
messenger,
getNextNonce,
}: {
isAtomicBatchSupported: (
request: IsAtomicBatchSupportedRequest,
) => Promise<IsAtomicBatchSupportedResult>;
messenger: TransactionControllerInitMessenger;
getNextNonce: (
address: string,
networkClientId: NetworkClientId,
) => Promise<Hex>;
}) {
this.#isAtomicBatchSupported = isAtomicBatchSupported;
this.#messenger = messenger;
this.#getNextNonce = getNextNonce;
}

getHook(): PublishHook {
Expand Down Expand Up @@ -348,8 +362,11 @@ export class Delegation7702PublishHook {
transactionMeta: TransactionMeta,
upgradeContractAddress?: Hex,
): Promise<AuthorizationList> {
const { chainId, txParams } = transactionMeta;
const { from, nonce } = txParams;
const { chainId, txParams, networkClientId } = transactionMeta;
const { from, nonce: txNonce } = txParams;
const nextNonce = await this.#getNextNonce(from, networkClientId);

const nonce = txNonce ?? nextNonce;
Copy link
Member

@matthewwalsh0 matthewwalsh0 Nov 6, 2025

Choose a reason for hiding this comment

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

In what scenario is the nonce from the transaction missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't have the nonce because we set isExternalSign to true which skips it, but in the scenario where we are upgrading the account we need to pass to Sentinel the nonce.


log('Including authorization as not upgraded');

Expand Down Expand Up @@ -393,10 +410,10 @@ export class Delegation7702PublishHook {
}

#decodeAuthorizationSignature(signature: Hex) {
const r = signature.slice(0, 66) as Hex;
const s = `0x${signature.slice(66, 130)}` as Hex;
const r = stripSingleLeadingZero(signature.slice(0, 66)) as Hex;
Copy link
Member

@matthewwalsh0 matthewwalsh0 Nov 6, 2025

Choose a reason for hiding this comment

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

Why is this required?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here, Nick mentioned that Go doesn't handle cases properly when the underlying type is an integer, and suggested using a function to strip the leading zero. It’s a bit odd, though, because we have similar logic in the Transaction Controller — it retrieves the signature from the Keyring Controller, splits it into r, s, and v, and that flow works without issues.

const s = stripSingleLeadingZero(add0x(signature.slice(66, 130))) as Hex;
const v = parseInt(signature.slice(130, 132), 16);
const yParity = v - 27 === 0 ? ('0x' as const) : ('0x1' as const);
const yParity = toHex(v - 27 === 0 ? 0 : 1);

return {
r,
Expand Down
13 changes: 13 additions & 0 deletions app/scripts/lib/transaction/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
AddTransactionRequest,
addDappTransaction,
addTransaction,
stripSingleLeadingZero,
} from './util';

jest.mock('../ppom/ppom-util');
Expand Down Expand Up @@ -695,4 +696,16 @@ describe('Transaction Utils', () => {
});
});
});

describe('stripSingleLeadingZero', () => {
it('returns the same hex if it does not start with 0x0', () => {
expect(stripSingleLeadingZero('0x1a2b3c')).toBe('0x1a2b3c');
});

it('strips a single leading zero from the hex', () => {
expect(stripSingleLeadingZero('0x0123')).toBe('0x123');
expect(stripSingleLeadingZero('0x0abcdef')).toBe('0xabcdef');
expect(stripSingleLeadingZero('0x0001')).toBe('0x001');
});
});
});
7 changes: 7 additions & 0 deletions app/scripts/lib/transaction/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,10 @@ async function validateSecurity(request: AddTransactionRequest) {
handlePPOMError(error, 'Error validating JSON RPC using PPOM: ');
}
}

export function stripSingleLeadingZero(hex: string): string {
if (!hex.startsWith('0x0') || hex.length <= 3) {
return hex;
}
return `0x${hex.slice(3)}`;
}
69 changes: 2 additions & 67 deletions ui/pages/confirmations/hooks/gas/useIsGaslessSupported.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { act } from 'react-dom/test-utils';
import { genUnapprovedContractInteractionConfirmation } from '../../../../../test/data/confirmations/contract-interaction';
import { getMockConfirmStateForTransaction } from '../../../../../test/data/confirmations/helper';
import { renderHookWithConfirmContextProvider } from '../../../../../test/lib/confirmations/render-helpers';
import { isAtomicBatchSupported } from '../../../../store/controller-actions/transaction-controller';
import { isRelaySupported } from '../../../../store/actions';
import { useIsGaslessSupported } from './useIsGaslessSupported';
import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions';
Expand All @@ -17,8 +16,6 @@ jest.mock('../../../../store/actions', () => ({

jest.mock('./useGaslessSupportedSmartTransactions');

const CHAIN_ID_MOCK = '0x5';

async function runHook() {
const { result } = renderHookWithConfirmContextProvider(
useIsGaslessSupported,
Expand All @@ -35,7 +32,6 @@ async function runHook() {
}

describe('useIsGaslessSupported', () => {
const isAtomicBatchSupportedMock = jest.mocked(isAtomicBatchSupported);
const isRelaySupportedMock = jest.mocked(isRelaySupported);
const useGaslessSupportedSmartTransactionsMock = jest.mocked(
useGaslessSupportedSmartTransactions,
Expand All @@ -44,7 +40,6 @@ describe('useIsGaslessSupported', () => {
beforeEach(() => {
jest.resetAllMocks();

isAtomicBatchSupportedMock.mockResolvedValue([]);
isRelaySupportedMock.mockResolvedValue(false);
useGaslessSupportedSmartTransactionsMock.mockReturnValue({
isSmartTransaction: false,
Expand Down Expand Up @@ -73,13 +68,6 @@ describe('useIsGaslessSupported', () => {
describe('if smart transaction disabled', () => {
it('returns true if chain supports EIP-7702 and account is supported and relay supported and send bundle supported', async () => {
isRelaySupportedMock.mockResolvedValue(true);
isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
isSupported: true,
delegationAddress: '0x123',
},
]);

const result = await runHook();

Expand All @@ -89,27 +77,8 @@ describe('useIsGaslessSupported', () => {
});
});

it('returns false if account not upgraded', async () => {
isRelaySupportedMock.mockResolvedValue(true);
isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
isSupported: false,
delegationAddress: undefined,
},
]);

const result = await runHook();

expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: false,
});
});

it('returns false if chain does not support EIP-7702', async () => {
isRelaySupportedMock.mockResolvedValue(true);
isAtomicBatchSupportedMock.mockResolvedValue([]);
isRelaySupportedMock.mockResolvedValue(false);

const result = await runHook();

Expand All @@ -120,14 +89,7 @@ describe('useIsGaslessSupported', () => {
});

it('returns false if upgraded account not supported', async () => {
isRelaySupportedMock.mockResolvedValue(true);
isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
isSupported: false,
delegationAddress: '0x123',
},
]);
isRelaySupportedMock.mockResolvedValue(false);

const result = await runHook();

Expand All @@ -139,13 +101,6 @@ describe('useIsGaslessSupported', () => {

it('returns false if relay not supported', async () => {
isRelaySupportedMock.mockResolvedValue(false);
isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
isSupported: true,
delegationAddress: '0x123',
},
]);

const result = await runHook();

Expand All @@ -169,18 +124,6 @@ describe('useIsGaslessSupported', () => {
});
});

it('returns false if atomic batch returns no matching chainId', async () => {
isRelaySupportedMock.mockResolvedValue(true);
isAtomicBatchSupportedMock.mockResolvedValue([
{ chainId: '0x1', isSupported: true, delegationAddress: '0x123' },
]);
const result = await runHook();
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: false,
});
});

it('returns pending state when useGaslessSupportedSmartTransactions is pending', async () => {
useGaslessSupportedSmartTransactionsMock.mockReturnValue({
isSmartTransaction: true,
Expand All @@ -189,19 +132,11 @@ describe('useIsGaslessSupported', () => {
});

// these mocks shouldn't be called when pending is true
isAtomicBatchSupportedMock.mockResolvedValue([
{
chainId: CHAIN_ID_MOCK,
isSupported: true,
delegationAddress: '0x123',
},
]);
isRelaySupportedMock.mockResolvedValue(true);

const result = await runHook();

// since pending=true, 7702 eligibility shouldn't be checked yet
expect(isAtomicBatchSupportedMock).not.toHaveBeenCalled();
expect(isRelaySupportedMock).not.toHaveBeenCalled();

expect(result).toStrictEqual({
Expand Down
24 changes: 2 additions & 22 deletions ui/pages/confirmations/hooks/gas/useIsGaslessSupported.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { TransactionMeta } from '@metamask/transaction-controller';
import { Hex } from '@metamask/utils';
import { useAsyncResult } from '../../../../hooks/useAsync';
import { isAtomicBatchSupported } from '../../../../store/controller-actions/transaction-controller';
import { useConfirmContext } from '../../context/confirm';
import { isRelaySupported } from '../../../../store/actions';
import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions';
Expand All @@ -21,8 +19,7 @@ export function useIsGaslessSupported() {
const { currentConfirmation: transactionMeta } =
useConfirmContext<TransactionMeta>();

const { chainId, txParams } = transactionMeta ?? {};
const { from } = txParams ?? {};
const { chainId } = transactionMeta ?? {};

const {
isSmartTransaction,
Expand All @@ -33,17 +30,6 @@ export function useIsGaslessSupported() {
const shouldCheck7702Eligibility =
!pending && !isSmartTransactionAndBundleSupported;

const { value: atomicBatchSupportResult } = useAsyncResult(async () => {
if (!shouldCheck7702Eligibility) {
return undefined;
}

return isAtomicBatchSupported({
address: from as Hex,
chainIds: [chainId],
});
}, [chainId, from, shouldCheck7702Eligibility]);

const { value: relaySupportsChain } = useAsyncResult(async () => {
if (!shouldCheck7702Eligibility) {
return undefined;
Expand All @@ -52,14 +38,8 @@ export function useIsGaslessSupported() {
return isRelaySupported(chainId);
}, [chainId, shouldCheck7702Eligibility]);

const atomicBatchChainSupport = atomicBatchSupportResult?.find(
(result) => result.chainId.toLowerCase() === chainId.toLowerCase(),
);

// Currently requires upgraded account, can also support no `delegationAddress` in future.
const is7702Supported = Boolean(
atomicBatchChainSupport?.isSupported &&
relaySupportsChain &&
relaySupportsChain &&
// contract deployments can't be delegated
transactionMeta?.txParams.to !== undefined,
);
Expand Down
Loading