Skip to content

Commit 0eeba0f

Browse files
committed
chore: validate that custodians did not change parameters when signing
1 parent 895209c commit 0eeba0f

File tree

5 files changed

+140
-18
lines changed

5 files changed

+140
-18
lines changed

packages/snap/snap.manifest.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "git+https://github.com/MetaMask/snap-institutional-wallet.git"
88
},
99
"source": {
10-
"shasum": "pAM+McrqnMYKY0ctbnqvhb2POFwcXh5wdz8DnklnoXc=",
10+
"shasum": "A/ave6b+Ko6b+/uCp/2aBbK7++A/IfWIKQUni8CFfGI=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",
@@ -35,6 +35,7 @@
3535
"https://apps-portal.safe.global": {}
3636
},
3737
"initialPermissions": {
38+
"snap_notify": {},
3839
"endowment:network-access": {},
3940
"endowment:ethereum-provider": {},
4041
"endowment:keyring": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Renders the onboarding interface.
3+
*
4+
* @param errorMessage - The error message to display.
5+
* @returns The result of the dialog.
6+
*/
7+
export async function renderErrorMessage(errorMessage: string) {
8+
await snap.request({
9+
method: 'snap_notify',
10+
params: {
11+
type: 'inApp',
12+
message: errorMessage,
13+
},
14+
});
15+
}

packages/snap/src/lib/helpers/interface.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
ComponentOrElement,
33
DialogResult,
4+
DialogType,
45
GetInterfaceStateResult,
56
Json,
67
ResolveInterfaceResult,
@@ -91,13 +92,18 @@ export async function resolveInterface(
9192
* Shows a dialog using the provided ID.
9293
*
9394
* @param id - The ID for the dialog.
95+
* @param type - The type of dialog to display.
9496
* @returns A promise that resolves to a string.
9597
*/
96-
export async function showDialog(id: string): Promise<DialogResult> {
98+
export async function showDialog(
99+
id: string,
100+
type?: DialogType,
101+
): Promise<DialogResult> {
97102
return snap.request({
98103
method: 'snap_dialog',
99104
params: {
100105
id,
106+
...(type ? { type } : {}),
101107
},
102108
});
103109
}

packages/snap/src/lib/helpers/transaction.ts

+89-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import type { TypedTransaction } from '@ethereumjs/tx';
12
import { TransactionFactory } from '@ethereumjs/tx';
23

34
import logger from '../../logger';
45
import { createCommon, formatTransactionData } from '../../util';
56
import { hexlify } from '../../util/hexlify';
67
import { TRANSACTION_TYPES } from '../constants';
8+
import type { EthSignTransactionRequest } from '../types/EthSignTransactionRequest';
79
import type { ITransactionDetails } from '../types/ITransactionDetails';
810
import type { IEIP1559TxParams, ILegacyTXParams } from '../types/ITXParams';
911

@@ -38,19 +40,10 @@ export class TransactionHelper {
3840
transaction: ITransactionDetails,
3941
chainId: string,
4042
): Promise<{ v: string; r: string; s: string }> {
41-
const common = createCommon(transaction, chainId);
42-
4343
if (transaction.signedRawTransaction) {
4444
logger.info('Transaction is signed', transaction.signedRawTransaction);
45-
const signedRawTransaction = Buffer.from(
46-
transaction.signedRawTransaction.substring(2),
47-
'hex',
48-
);
49-
50-
const tx = TransactionFactory.fromSerializedData(signedRawTransaction, {
51-
common,
52-
});
5345

46+
const tx = this.getTypedTransaction(transaction, chainId);
5447
return {
5548
v: hexlify(tx.v?.toString() ?? '0'),
5649
r: hexlify(tx.r?.toString() ?? '0'),
@@ -78,4 +71,90 @@ export class TransactionHelper {
7871
s: tx.s,
7972
};
8073
}
74+
75+
static getTypedTransaction(
76+
transactionDetails: ITransactionDetails,
77+
chainId: string,
78+
): TypedTransaction {
79+
if (!transactionDetails.signedRawTransaction) {
80+
throw new Error('Transaction is not signed');
81+
}
82+
83+
const common = createCommon(transactionDetails, chainId);
84+
85+
const signedRawTransaction = Buffer.from(
86+
transactionDetails.signedRawTransaction.substring(2),
87+
'hex',
88+
);
89+
90+
const tx = TransactionFactory.fromSerializedData(signedRawTransaction, {
91+
common,
92+
});
93+
94+
return tx;
95+
}
96+
97+
/**
98+
* We need to be certain that the custodian did not alter any of the fields
99+
* which was previously allowed in MMI but cannot work in the institutional snap
100+
* because the snap keyring does not allow us to return anything other than the signature
101+
*
102+
* @param request - The original request we got from the keyring
103+
* @param transactionDetails - The transaction details we got from the custodian
104+
*/
105+
106+
static validateTransaction(
107+
request: EthSignTransactionRequest,
108+
transactionDetails: ITransactionDetails,
109+
): {
110+
isValid: boolean;
111+
error?: string;
112+
} {
113+
const tx = this.getTypedTransaction(transactionDetails, request.chainId);
114+
// Validated nonce, gas price, maxFeePerGas, maxPriorityFeePerGas and gas limit
115+
if (Number(request.nonce) !== Number(tx.nonce)) {
116+
return {
117+
isValid: false,
118+
error: `Custodian altered the nonce from the request: ${Number(
119+
request.nonce,
120+
)} to ${Number(tx.nonce)}`,
121+
};
122+
}
123+
if (
124+
'gasPrice' in request &&
125+
'gasPrice' in tx &&
126+
Number(request.gasPrice) !== Number(tx.gasPrice)
127+
) {
128+
return {
129+
isValid: false,
130+
error: `Custodian altered the gas price from the request: ${request.gasPrice} to ${tx.gasPrice}`,
131+
};
132+
}
133+
134+
if (
135+
'maxFeePerGas' in request &&
136+
'maxFeePerGas' in tx &&
137+
Number(request.maxFeePerGas) !== Number(tx.maxFeePerGas)
138+
) {
139+
return {
140+
isValid: false,
141+
error: `Custodian altered the maxFeePerGas from the request: ${request.maxFeePerGas} to ${tx.maxFeePerGas}`,
142+
};
143+
}
144+
145+
if (
146+
'maxPriorityFeePerGas' in request &&
147+
'maxPriorityFeePerGas' in tx &&
148+
Number(request.maxPriorityFeePerGas) !== Number(tx.maxPriorityFeePerGas)
149+
) {
150+
return {
151+
isValid: false,
152+
error: `Custodian altered the maxPriorityFeePerGas from the request: ${request.maxPriorityFeePerGas} to ${tx.maxPriorityFeePerGas}`,
153+
};
154+
}
155+
156+
return {
157+
isValid: true,
158+
};
159+
}
81160
}

packages/snap/src/requestsManager.ts

+27-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { emitSnapKeyringEvent, KeyringEvent } from '@metamask/keyring-api';
22
import type { Json } from '@metamask/snaps-sdk';
33

4+
import { renderErrorMessage } from './features/error-message/render';
45
import { TransactionHelper } from './lib/helpers/transaction';
56
import type { ICustodianApi } from './lib/types';
67
import type {
@@ -48,6 +49,15 @@ export class RequestManager {
4849
}
4950

5051
async getChainIdFromPendingRequest(id: string): Promise<string> {
52+
const transactionRequest = this.getRequestParams(id);
53+
if (!transactionRequest.chainId) {
54+
throw new Error(`Request ${id} has no chainId`);
55+
}
56+
57+
return transactionRequest.chainId;
58+
}
59+
60+
getRequestParams(id: string): EthSignTransactionRequest {
5161
if (!this.#state.requests[id]) {
5262
throw new Error(`Request ${id} not found`);
5363
}
@@ -59,12 +69,7 @@ export class RequestManager {
5969
throw new Error(`Request ${id} has invalid params`);
6070
}
6171

62-
const transactionRequest = requestParams[0] as EthSignTransactionRequest;
63-
if (!transactionRequest.chainId) {
64-
throw new Error(`Request ${id} has no chainId`);
65-
}
66-
67-
return transactionRequest.chainId;
72+
return requestParams[0] as EthSignTransactionRequest;
6873
}
6974

7075
async clearAllRequests(): Promise<void> {
@@ -145,6 +150,22 @@ export class RequestManager {
145150
chainId,
146151
);
147152

153+
const validationResult = TransactionHelper.validateTransaction(
154+
this.getRequestParams(requestId),
155+
transactionResponse,
156+
);
157+
158+
if (!validationResult.isValid) {
159+
// First show a dialog with the error message
160+
if (validationResult.error) {
161+
const errorMessage = `Transaction ${custodianTransactionId} was signed by custodian but failed validation: ${validationResult.error}`;
162+
await renderErrorMessage(errorMessage);
163+
}
164+
await this.emitRejectedEvent(requestId);
165+
await this.removePendingRequest(requestId);
166+
return;
167+
}
168+
148169
const updatedTransaction = {
149170
...request,
150171
fulfilled: true,

0 commit comments

Comments
 (0)