Skip to content

Commit 6f416e4

Browse files
fix: decode authorization util and prefix errors (#8656)
## Explanation ### Current EIP-7702 production failures across mobile and extension are surfacing as bare, unattributable strings in our error metrics — most prominently `failed to decode param in array[0] invalid JSON input` from Sentinel relay. 1. **Non-canonical RLP hex in authorization signatures.** `signAuthorization` slices raw 32-byte signature halves into `r` and `s` without canonicalizing. 2. **Bare error strings with no failure-surface attribution.** When a 7702 transaction fails, the same generic message can come from any of: core's `eth_sendRawTransaction`, the Relay strategy's `/execute` POST, the non-execute relay deposit path, or the Pay publish hook. Metrics can't distinguish them, and Relay's actual server error (`{ message: "Insufficient liquidity" }`) is discarded by `successfulFetch` in favour of a generic URL-leaking template. ### Solution **Canonicalize once, in core.** A new exported `decodeAuthorizationSignature(signature)` utility in `@metamask/transaction-controller` decodes a 65-byte EIP-7702 authorization signature into RLP-canonical `r`, `s`, and `yParity`. `signAuthorizationList` is refactored to use it, so every signed authorization tuple emitted by the controller is canonical out of the box. Mobile/extension can read straight off `txMeta.txParams.authorizationList` and submit to any backend without their own strip helpers. **Layered submission error prefixes.** Four prefixes, each applied at the lowest sensible layer, that cascade up the call stack to attribute every failure surface in metrics: | Layer | Prefix | Wraps | |---|---|---| | Core RPC | `RPC submit:` | `eth_sendRawTransaction` failures | | Relay strategy | `Relay submit:` | the entire `submitRelayQuotes` body | | Inner relay execute | `Relay execute:` | Relay `/execute` POST failures (cascades inside `Relay submit:`) | | Pay hook | `MetaMask Pay:` | the entire `TransactionPayPublishHook.#hookWrapper` | A private `relayFetch` helper in `relay-api.ts` replaces `successfulFetch` for Relay endpoints. On non-OK responses it surfaces the response body's `message` or `error` field as `<status> - <message>`, falling back to `<status>` alone — no URL leakage, server's actual reason preserved when present. ### Example errors as they appear in metrics ``` MetaMask Pay: Relay submit: Relay execute: 422 - Insufficient liquidity MetaMask Pay: Relay submit: RPC submit: nonce too low MetaMask Pay: Insufficient source token balance for relay deposit MetaMask Pay: Relay submit: Relay execute: 500 MetaMask Pay: Relay submit: Relay execute: 400 - failed to decode param in array[0] invalid JSON input RPC submit: replacement transaction underpriced ``` ## References - [CONF-1133](https://consensyssoftware.atlassian.net/browse/CONF-1133) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] 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) - [x] 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 [CONF-1133]: https://consensyssoftware.atlassian.net/browse/CONF-1133?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes transaction submission error handling and EIP-7702 signature decoding, which can affect downstream clients’ ability to submit transactions and how failures are reported. Main risk is altered error strings/prefixes and canonicalization behavior impacting integrations/tests. > > **Overview** > Adds a new exported `decodeAuthorizationSignature` helper for EIP-7702 that canonicalizes `r`/`s` (strips leading zeroes, uses `0x0` for zero) and derives `yParity`, and refactors authorization signing to use it. > > Standardizes failure-surface attribution by wrapping submission errors with layered prefixes: **`RPC submit:`** for `eth_sendRawTransaction` failures (including nested `data.message`), **`Relay submit:`** for Relay strategy execution, **`Relay execute:`** for Relay `/execute` POST failures, and **`MetaMask Pay:`** for Pay publish hook failures. > > Replaces Relay’s `successfulFetch` usage with a local `relayFetch` that preserves server-provided non-OK details (`<status> - <message|error>` or `<status>`) without leaking request URLs, and updates tests/changelogs accordingly. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1eb9b8c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ca69efc commit 6f416e4

16 files changed

Lines changed: 524 additions & 77 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

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

1419
## [20.1.0]
1520

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,25 @@ 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+
);
207+
});
208+
209+
it('wraps non-Error throws with the MetaMask Pay prefix', async () => {
210+
executeMock.mockRejectedValue('boom');
211+
212+
await expect(runHook()).rejects.toThrow('MetaMask Pay: boom');
197213
});
198214
});

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)