Skip to content
Merged
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
5 changes: 5 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Export `decodeAuthorizationSignature` utility that decodes a 65-byte EIP-7702 authorization signature into RLP-canonical `r`, `s`, and `yParity` ([#8656](https://github.com/MetaMask/core/pull/8656))
- All `eth_sendRawTransaction` failures are prefixed `RPC submit:` for failure-surface attribution in error metrics

### Changed

- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3924,7 +3924,9 @@ describe('TransactionController', () => {

rpcRequestMock.mockRejectedValueOnce(error);

await expect(controller.stopTransaction('2')).rejects.toThrow(error);
await expect(controller.stopTransaction('2')).rejects.toThrow(
'RPC submit: Another reason',
);

const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter(
([request]) => request.method === 'eth_sendRawTransaction',
Expand Down Expand Up @@ -4276,7 +4278,9 @@ describe('TransactionController', () => {

rpcRequestMock.mockRejectedValueOnce(error);

await expect(controller.speedUpTransaction('2')).rejects.toThrow(error);
await expect(controller.speedUpTransaction('2')).rejects.toThrow(
'RPC submit: Another reason',
);

const sendRawTransactionCalls = rpcRequestMock.mock.calls.filter(
([request]) => request.method === 'eth_sendRawTransaction',
Expand All @@ -4285,6 +4289,39 @@ describe('TransactionController', () => {
expect(controller.state.transactions).toHaveLength(1);
});

it('extracts nested data.message and prefixes it with RPC submit', async () => {
const error = {
message: 'Outer message',
data: { message: 'Nested rpc error message' },
};
const { controller } = setupController({
options: {
state: {
transactions: [
{
id: '2',
chainId: toHex(5),
networkClientId: NETWORK_CLIENT_ID_MOCK,
status: TransactionStatus.submitted,
type: TransactionType.retry,
time: 123456789,
txParams: {
from: ACCOUNT_MOCK,
gasPrice: '0x1',
},
},
],
},
},
});

rpcRequestMock.mockRejectedValueOnce(error);

await expect(controller.speedUpTransaction('2')).rejects.toThrow(
'RPC submit: Nested rpc error message',
);
});

it('creates additional transaction with increased gas', async () => {
const { controller } = setupController({
network: MOCK_LINEA_MAINNET_NETWORK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3372,7 +3372,7 @@ export class TransactionController extends BaseController<
const errorMessage =
errorObject?.data?.message ?? errorObject?.message ?? String(error);

throw new Error(errorMessage);
throw new Error(`RPC submit: ${errorMessage}`);
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export {
WalletDevice,
} from './types';
export { mergeGasFeeEstimates } from './utils/gas-flow';
export { decodeAuthorizationSignature } from './utils/eip7702';
export {
isEIP1559Transaction,
normalizeTransactionParams,
Expand Down
88 changes: 88 additions & 0 deletions packages/transaction-controller/src/utils/eip7702.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { AuthorizationList } from '../types';
import type { TransactionMeta } from '../types';
import {
DELEGATION_PREFIX,
decodeAuthorizationSignature,
doesAccountSupportEIP7702,
doesChainSupportEIP7702,
generateEIP7702BatchTransaction,
Expand Down Expand Up @@ -257,6 +258,93 @@ describe('EIP-7702 Utils', () => {
expect(result?.[1]?.nonce).toBe('0x125');
expect(result?.[2]?.nonce).toBe('0x126');
});

it('strips leading zeroes from signature r and s to produce RLP-canonical hex', async () => {
const signatureWithLeadingZeros =
`0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456781122334455667788990011223344556677889900112233445566778899001122${'1c'}` as Hex;

signAuthorizationMock
.mockReset()
.mockResolvedValueOnce(signatureWithLeadingZeros);

const result = await signAuthorizationList({
authorizationList: AUTHORIZATION_LIST_MOCK,
messenger: controllerMessenger,
transactionMeta: TRANSACTION_META_MOCK,
});

expect(result?.[0]?.r).toBe(
'0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678',
);
expect(result?.[0]?.s).toBe(
'0x1122334455667788990011223344556677889900112233445566778899001122',
);
expect(result?.[0]?.yParity).toBe('0x1');
});
});

describe('decodeAuthorizationSignature', () => {
it('decodes a signature with no leading zeros into r, s, and yParity', () => {
const result = decodeAuthorizationSignature(AUTHORIZATION_SIGNATURE_MOCK);

expect(result).toStrictEqual({
r: '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292',
s: '0xda533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf',
yParity: '0x1',
});
});

it('strips a single leading zero nibble from r', () => {
const signature =
`0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;

const result = decodeAuthorizationSignature(signature);

expect(result.r).toBe(
'0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678',
);
expect(result.s).toBe(
'0x1122334455667788990011223344556677889900112233445566778899001122',
);
});

it('strips multiple leading zero bytes from r', () => {
const signature =
`0x000000abcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;

const result = decodeAuthorizationSignature(signature);

expect(result.r).toBe(
'0xabcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd',
);
});

it('returns 0x0 when r is all zeroes (canonical zero)', () => {
const signature =
`0x0000000000000000000000000000000000000000000000000000000000000000${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;

const result = decodeAuthorizationSignature(signature);

expect(result.r).toBe('0x0');
});

it('returns yParity 0x0 when v is 27', () => {
const signature =
`0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1b` as Hex;

const result = decodeAuthorizationSignature(signature);

expect(result.yParity).toBe('0x0');
});

it('returns yParity 0x1 when v is 28', () => {
const signature =
`0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1c` as Hex;

const result = decodeAuthorizationSignature(signature);

expect(result.yParity).toBe('0x1');
});
});

describe('doesChainSupportEIP7702', () => {
Expand Down
45 changes: 38 additions & 7 deletions packages/transaction-controller/src/utils/eip7702.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,43 @@ export async function signAuthorizationList({
return signedAuthorizationList;
}

/**
* Decode a 65-byte EIP-7702 authorization signature into RLP-canonical
* `r`, `s`, and `yParity` (no leading zero nibbles, `0x0` for zero).
*
* @param signature - The 65-byte signature.
* @returns The decoded authorization fields.
*/
export function decodeAuthorizationSignature(signature: Hex): {
r: Hex;
s: Hex;
yParity: Hex;
} {
// eslint-disable-next-line id-length
const r = toCanonicalHex(signature.slice(0, 66));
// eslint-disable-next-line id-length
const s = toCanonicalHex(signature.slice(66, 130));
// eslint-disable-next-line id-length
const v = parseInt(signature.slice(130, 132), 16);
const yParity = toCanonicalHex(toHex(v - 27 === 0 ? 0 : 1));

return { r, s, yParity };
}

/**
* Strip leading zero nibbles from a hex string to produce its RLP-canonical
* form. Accepts input with or without a `0x` prefix; always returns
* `0x`-prefixed. An all-zero input is preserved as `0x0`.
*
* @param value - Hex string with or without a `0x` prefix.
* @returns The canonical `0x`-prefixed hex string.
*/
function toCanonicalHex(value: string): Hex {
const raw = value.startsWith('0x') ? value.slice(2) : value;
const stripped = raw.replace(/^0+/u, '');
return stripped.length === 0 ? '0x0' : `0x${stripped}`;
}

/**
* Signs an authorization.
*
Expand Down Expand Up @@ -284,13 +321,7 @@ async function signAuthorization(
},
);

// eslint-disable-next-line id-length
const r = signature.slice(0, 66) as Hex;
// eslint-disable-next-line id-length
const s = add0x(signature.slice(66, 130));
// eslint-disable-next-line id-length
const v = parseInt(signature.slice(130, 132), 16);
const yParity = toHex(v - 27 === 0 ? 0 : 1);
const { r, s, yParity } = decodeAuthorizationSignature(signature as Hex);

const result: Required<Authorization> = {
address,
Expand Down
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Stop synthesising a native gas-fee required token in `parseRequiredTokens`, only token-transfer assets are returned now ([#8554](https://github.com/MetaMask/core/pull/8554))
- Add layered submission error prefixes for failure-surface attribution in error metrics ([#8656](https://github.com/MetaMask/core/pull/8656))
- `MetaMask Pay:` wraps all errors from the Pay publish hook
- `Relay submit:` wraps all errors from the relay strategy
- `Relay execute:` cascades inside `Relay submit:` for `/execute` POST failures
- Relay non-OK responses now surface as `<status> - <body message or error>` (or just `<status>`), replacing the previous URL-leaking generic fetch failure message

## [20.1.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,25 @@ describe('TransactionPayPublishHook', () => {
);
});

it('throws errors from submit', async () => {
it('throws errors from submit prefixed with MetaMask Pay', async () => {
executeMock.mockRejectedValue(new Error('Test error'));

await expect(runHook()).rejects.toThrow('Test error');
await expect(runHook()).rejects.toThrow('MetaMask Pay: Test error');
});

it('cascades MetaMask Pay prefix on top of strategy-level prefixes', async () => {
executeMock.mockRejectedValue(
new Error('Relay submit: Relay execute: backend boom'),
);

await expect(runHook()).rejects.toThrow(
'MetaMask Pay: Relay submit: Relay execute: backend boom',
);
});

it('wraps non-Error throws with the MetaMask Pay prefix', async () => {
executeMock.mockRejectedValue('boom');

await expect(runHook()).rejects.toThrow('MetaMask Pay: boom');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class TransactionPayPublishHook {
return await this.#publishHook(transactionMeta, _signedTx);
} catch (error) {
log('Error', error);
throw error;
const message = error instanceof Error ? error.message : String(error);
throw new Error(`MetaMask Pay: ${message}`);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,38 @@ describe('RelayStrategy', () => {
});
expect(submitRelayQuotesMock).toHaveBeenCalledWith(executeRequest);
});

it('wraps execute errors with the Relay submit prefix', async () => {
const executeRequest = {
messenger,
quotes: [],
transaction: request.transaction,
isSmartTransaction: jest.fn(),
} as PayStrategyExecuteRequest<RelayQuote>;

submitRelayQuotesMock.mockRejectedValue(
new Error('Relay execute: 422 - Insufficient liquidity'),
);

const strategy = new RelayStrategy();
await expect(strategy.execute(executeRequest)).rejects.toThrow(
'Relay submit: Relay execute: 422 - Insufficient liquidity',
);
});

it('wraps non-Error throws with the Relay submit prefix', async () => {
const executeRequest = {
messenger,
quotes: [],
transaction: request.transaction,
isSmartTransaction: jest.fn(),
} as PayStrategyExecuteRequest<RelayQuote>;

submitRelayQuotesMock.mockRejectedValue('boom');

const strategy = new RelayStrategy();
await expect(strategy.execute(executeRequest)).rejects.toThrow(
'Relay submit: boom',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export class RelayStrategy implements PayStrategy<RelayQuote> {
async execute(
request: PayStrategyExecuteRequest<RelayQuote>,
): ReturnType<PayStrategy<RelayQuote>['execute']> {
return await submitRelayQuotes(request);
try {
return await submitRelayQuotes(request);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Relay submit: ${message}`);
}
}
}
Loading
Loading