Skip to content

Commit dfe2b38

Browse files
committed
fix(transaction-controller, transaction-pay-controller): canonicalize EIP-7702 authorization signatures and add layered submission error prefixes
Centralizes EIP-7702 authorization signature decoding in the transaction controller via a new `decodeAuthorizationSignature` utility that returns `r`, `s`, and `yParity` in RLP-canonical form (no leading zero nibbles, `0x0` for zero). go-ethereum-style decoders (Sentinel relay, public RPCs, Relay quote endpoints) reject non-canonical integers at decode time, surfacing as bare `failed to decode param in array[0] invalid JSON input` errors in production metrics. With canonical decoding centralised here, clients consume signed authorization tuples directly without needing their own per-field strip helpers. Adds layered submission error prefixes that cascade up the call stack to attribute every failure surface in error-tracking metrics: - `RPC submit: <reason>` on `eth_sendRawTransaction` failures - `Relay submit: <reason>` wraps every error from `submitRelayQuotes` - `Relay execute: <reason>` wraps Relay `/execute` POST failures (cascades inside `Relay submit:` to distinguish the gasless-execute path from the non-execute deposit path) - `MetaMask Pay: <reason>` wraps every error from the Pay publish hook, cascading any inner prefixes Example cascades surfaced in metrics: - `MetaMask Pay: Relay submit: Relay execute: Insufficient liquidity` - `MetaMask Pay: Relay submit: RPC submit: nonce too low` - `MetaMask Pay: Insufficient source token balance for relay deposit` Also adds a private `relayFetch` helper in `relay-api.ts` that surfaces the Relay server's `message` or `error` response field (or status code) on non-OK responses, replacing the previous URL-leaking generic `Fetch failed with status '500' for request 'https://api.relay.link/...'` message.
1 parent 0caca27 commit dfe2b38

16 files changed

Lines changed: 614 additions & 188 deletions

packages/transaction-controller/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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))
13+
- All `eth_sendRawTransaction` failures are prefixed `RPC submit:` for failure-surface attribution in error metrics
14+
1015
### Changed
1116

1217
- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))

packages/transaction-controller/src/TransactionController.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3924,7 +3924,9 @@ describe('TransactionController', () => {
39243924

39253925
rpcRequestMock.mockRejectedValueOnce(error);
39263926

3927-
await expect(controller.stopTransaction('2')).rejects.toThrow(error);
3927+
await expect(controller.stopTransaction('2')).rejects.toThrow(
3928+
'RPC submit: Another reason',
3929+
);
39283930

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

42774279
rpcRequestMock.mockRejectedValueOnce(error);
42784280

4279-
await expect(controller.speedUpTransaction('2')).rejects.toThrow(error);
4281+
await expect(controller.speedUpTransaction('2')).rejects.toThrow(
4282+
'RPC submit: Another reason',
4283+
);
42804284

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

4292+
it('extracts nested data.message and prefixes it with RPC submit', async () => {
4293+
const error = {
4294+
message: 'Outer message',
4295+
data: { message: 'Nested rpc error message' },
4296+
};
4297+
const { controller } = setupController({
4298+
options: {
4299+
state: {
4300+
transactions: [
4301+
{
4302+
id: '2',
4303+
chainId: toHex(5),
4304+
networkClientId: NETWORK_CLIENT_ID_MOCK,
4305+
status: TransactionStatus.submitted,
4306+
type: TransactionType.retry,
4307+
time: 123456789,
4308+
txParams: {
4309+
from: ACCOUNT_MOCK,
4310+
gasPrice: '0x1',
4311+
},
4312+
},
4313+
],
4314+
},
4315+
},
4316+
});
4317+
4318+
rpcRequestMock.mockRejectedValueOnce(error);
4319+
4320+
await expect(controller.speedUpTransaction('2')).rejects.toThrow(
4321+
'RPC submit: Nested rpc error message',
4322+
);
4323+
});
4324+
42884325
it('creates additional transaction with increased gas', async () => {
42894326
const { controller } = setupController({
42904327
network: MOCK_LINEA_MAINNET_NETWORK,

packages/transaction-controller/src/TransactionController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3372,7 +3372,7 @@ export class TransactionController extends BaseController<
33723372
const errorMessage =
33733373
errorObject?.data?.message ?? errorObject?.message ?? String(error);
33743374

3375-
throw new Error(errorMessage);
3375+
throw new Error(`RPC submit: ${errorMessage}`);
33763376
}
33773377
}
33783378

packages/transaction-controller/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export {
129129
WalletDevice,
130130
} from './types';
131131
export { mergeGasFeeEstimates } from './utils/gas-flow';
132+
export { decodeAuthorizationSignature } from './utils/eip7702';
132133
export {
133134
isEIP1559Transaction,
134135
normalizeTransactionParams,

packages/transaction-controller/src/utils/eip7702.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { AuthorizationList } from '../types';
1818
import type { TransactionMeta } from '../types';
1919
import {
2020
DELEGATION_PREFIX,
21+
decodeAuthorizationSignature,
2122
doesAccountSupportEIP7702,
2223
doesChainSupportEIP7702,
2324
generateEIP7702BatchTransaction,
@@ -257,6 +258,93 @@ describe('EIP-7702 Utils', () => {
257258
expect(result?.[1]?.nonce).toBe('0x125');
258259
expect(result?.[2]?.nonce).toBe('0x126');
259260
});
261+
262+
it('strips leading zeroes from signature r and s to produce RLP-canonical hex', async () => {
263+
const signatureWithLeadingZeros =
264+
`0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456781122334455667788990011223344556677889900112233445566778899001122${'1c'}` as Hex;
265+
266+
signAuthorizationMock
267+
.mockReset()
268+
.mockResolvedValueOnce(signatureWithLeadingZeros);
269+
270+
const result = await signAuthorizationList({
271+
authorizationList: AUTHORIZATION_LIST_MOCK,
272+
messenger: controllerMessenger,
273+
transactionMeta: TRANSACTION_META_MOCK,
274+
});
275+
276+
expect(result?.[0]?.r).toBe(
277+
'0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678',
278+
);
279+
expect(result?.[0]?.s).toBe(
280+
'0x1122334455667788990011223344556677889900112233445566778899001122',
281+
);
282+
expect(result?.[0]?.yParity).toBe('0x1');
283+
});
284+
});
285+
286+
describe('decodeAuthorizationSignature', () => {
287+
it('decodes a signature with no leading zeros into r, s, and yParity', () => {
288+
const result = decodeAuthorizationSignature(AUTHORIZATION_SIGNATURE_MOCK);
289+
290+
expect(result).toStrictEqual({
291+
r: '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292',
292+
s: '0xda533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf',
293+
yParity: '0x1',
294+
});
295+
});
296+
297+
it('strips a single leading zero nibble from r', () => {
298+
const signature =
299+
`0x0abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;
300+
301+
const result = decodeAuthorizationSignature(signature);
302+
303+
expect(result.r).toBe(
304+
'0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678',
305+
);
306+
expect(result.s).toBe(
307+
'0x1122334455667788990011223344556677889900112233445566778899001122',
308+
);
309+
});
310+
311+
it('strips multiple leading zero bytes from r', () => {
312+
const signature =
313+
`0x000000abcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;
314+
315+
const result = decodeAuthorizationSignature(signature);
316+
317+
expect(result.r).toBe(
318+
'0xabcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd',
319+
);
320+
});
321+
322+
it('returns 0x0 when r is all zeroes (canonical zero)', () => {
323+
const signature =
324+
`0x0000000000000000000000000000000000000000000000000000000000000000${'1122334455667788990011223344556677889900112233445566778899001122'}1b` as Hex;
325+
326+
const result = decodeAuthorizationSignature(signature);
327+
328+
expect(result.r).toBe('0x0');
329+
});
330+
331+
it('returns yParity 0x0 when v is 27', () => {
332+
const signature =
333+
`0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1b` as Hex;
334+
335+
const result = decodeAuthorizationSignature(signature);
336+
337+
expect(result.yParity).toBe('0x0');
338+
});
339+
340+
it('returns yParity 0x1 when v is 28', () => {
341+
const signature =
342+
`0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cf1c` as Hex;
343+
344+
const result = decodeAuthorizationSignature(signature);
345+
346+
expect(result.yParity).toBe('0x1');
347+
});
260348
});
261349

262350
describe('doesChainSupportEIP7702', () => {

packages/transaction-controller/src/utils/eip7702.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,43 @@ export async function signAuthorizationList({
247247
return signedAuthorizationList;
248248
}
249249

250+
/**
251+
* Decode a 65-byte EIP-7702 authorization signature into RLP-canonical
252+
* `r`, `s`, and `yParity` (no leading zero nibbles, `0x0` for zero).
253+
*
254+
* @param signature - The 65-byte signature.
255+
* @returns The decoded authorization fields.
256+
*/
257+
export function decodeAuthorizationSignature(signature: Hex): {
258+
r: Hex;
259+
s: Hex;
260+
yParity: Hex;
261+
} {
262+
// eslint-disable-next-line id-length
263+
const r = toCanonicalHex(signature.slice(0, 66));
264+
// eslint-disable-next-line id-length
265+
const s = toCanonicalHex(signature.slice(66, 130));
266+
// eslint-disable-next-line id-length
267+
const v = parseInt(signature.slice(130, 132), 16);
268+
const yParity = toCanonicalHex(toHex(v - 27 === 0 ? 0 : 1));
269+
270+
return { r, s, yParity };
271+
}
272+
273+
/**
274+
* Strip leading zero nibbles from a hex string to produce its RLP-canonical
275+
* form. Accepts input with or without a `0x` prefix; always returns
276+
* `0x`-prefixed. An all-zero input is preserved as `0x0`.
277+
*
278+
* @param value - Hex string with or without a `0x` prefix.
279+
* @returns The canonical `0x`-prefixed hex string.
280+
*/
281+
function toCanonicalHex(value: string): Hex {
282+
const raw = value.startsWith('0x') ? value.slice(2) : value;
283+
const stripped = raw.replace(/^0+/u, '');
284+
return stripped.length === 0 ? '0x0' : `0x${stripped}`;
285+
}
286+
250287
/**
251288
* Signs an authorization.
252289
*
@@ -284,13 +321,7 @@ async function signAuthorization(
284321
},
285322
);
286323

287-
// eslint-disable-next-line id-length
288-
const r = signature.slice(0, 66) as Hex;
289-
// eslint-disable-next-line id-length
290-
const s = add0x(signature.slice(66, 130));
291-
// eslint-disable-next-line id-length
292-
const v = parseInt(signature.slice(130, 132), 16);
293-
const yParity = toHex(v - 27 === 0 ? 0 : 1);
324+
const { r, s, yParity } = decodeAuthorizationSignature(signature as Hex);
294325

295326
const result: Required<Authorization> = {
296327
address,

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Add layered submission error prefixes for failure-surface attribution in error metrics ([#8656](https://github.com/MetaMask/core/pull/8656))
13+
- `MetaMask Pay:` wraps all errors from the Pay publish hook
14+
- `Relay submit:` wraps all errors from the relay strategy
15+
- `Relay execute:` cascades inside `Relay submit:` for `/execute` POST failures
16+
- Relay non-OK responses now surface as `<status> - <body message or error>` (or just `<status>`), replacing the previous URL-leaking generic fetch failure message
17+
1018
## [20.1.0]
1119

1220
### Added

packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,19 @@ describe('TransactionPayPublishHook', () => {
190190
);
191191
});
192192

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

196-
await expect(runHook()).rejects.toThrow('Test error');
196+
await expect(runHook()).rejects.toThrow('MetaMask Pay: Test error');
197+
});
198+
199+
it('cascades MetaMask Pay prefix on top of strategy-level prefixes', async () => {
200+
executeMock.mockRejectedValue(
201+
new Error('Relay submit: Relay execute: backend boom'),
202+
);
203+
204+
await expect(runHook()).rejects.toThrow(
205+
'MetaMask Pay: Relay submit: Relay execute: backend boom',
206+
);
197207
});
198208
});

packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export class TransactionPayPublishHook {
4747
return await this.#publishHook(transactionMeta, _signedTx);
4848
} catch (error) {
4949
log('Error', error);
50-
throw error;
50+
const message = error instanceof Error ? error.message : String(error);
51+
throw new Error(`MetaMask Pay: ${message}`);
5152
}
5253
}
5354

packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,38 @@ describe('RelayStrategy', () => {
108108
});
109109
expect(submitRelayQuotesMock).toHaveBeenCalledWith(executeRequest);
110110
});
111+
112+
it('wraps execute errors with the Relay submit prefix', async () => {
113+
const executeRequest = {
114+
messenger,
115+
quotes: [],
116+
transaction: request.transaction,
117+
isSmartTransaction: jest.fn(),
118+
} as PayStrategyExecuteRequest<RelayQuote>;
119+
120+
submitRelayQuotesMock.mockRejectedValue(
121+
new Error('Relay execute: 422 - Insufficient liquidity'),
122+
);
123+
124+
const strategy = new RelayStrategy();
125+
await expect(strategy.execute(executeRequest)).rejects.toThrow(
126+
'Relay submit: Relay execute: 422 - Insufficient liquidity',
127+
);
128+
});
129+
130+
it('wraps non-Error throws with the Relay submit prefix', async () => {
131+
const executeRequest = {
132+
messenger,
133+
quotes: [],
134+
transaction: request.transaction,
135+
isSmartTransaction: jest.fn(),
136+
} as PayStrategyExecuteRequest<RelayQuote>;
137+
138+
submitRelayQuotesMock.mockRejectedValue('boom');
139+
140+
const strategy = new RelayStrategy();
141+
await expect(strategy.execute(executeRequest)).rejects.toThrow(
142+
'Relay submit: boom',
143+
);
144+
});
111145
});

0 commit comments

Comments
 (0)