Skip to content

Commit a2b97a6

Browse files
committed
centralize permission context validation logic
Extracted and reused shared validation functions across token stream and periodic permission contexts. Updated docs formatting and test assertions to reflect revised validation messages.
1 parent f2a7c55 commit a2b97a6

8 files changed

Lines changed: 297 additions & 185 deletions

File tree

docs/addingNewPermissionTypes.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This guide explains how to add a new permission type to the MetaMask Permissions
55
## Overview
66

77
A permission type consists of several components:
8+
89
1. Permission definition and registration
910
2. Type definitions
1011
3. Validation logic
@@ -17,6 +18,7 @@ A permission type consists of several components:
1718
### 1. Create Permission Directory
1819

1920
Create a new directory under `packages/gator-permissions-snap/src/permissions/` for your permission type. For example:
21+
2022
```
2123
packages/gator-permissions-snap/src/permissions/yourPermissionType/
2224
```
@@ -27,7 +29,11 @@ Create a `types.ts` file in your permission directory with the following structu
2729

2830
```typescript
2931
import { z } from 'zod';
30-
import { zHexStr, zPermission, zMetaMaskPermissionData } from '@metamask/7715-permissions-shared/types';
32+
import {
33+
zHexStr,
34+
zPermission,
35+
zMetaMaskPermissionData,
36+
} from '@metamask/7715-permissions-shared/types';
3137

3238
// Define your permission metadata type
3339
// Metadata is anything derived from the context that is not editable, such as validtion errors
@@ -177,13 +183,15 @@ export const yourPermissionDefinition: PermissionDefinition<
177183
### 8. Register the Permission
178184

179185
1. Add your permission type to the permission handler factory:
186+
180187
```typescript
181188
case 'your-permission-type':
182189
handler = createPermissionHandler(yourPermissionDefinition);
183190
break;
184191
```
185192

186193
2. Add your permission to the default offers in `snap-permission-registry.ts`:
194+
187195
```typescript
188196
export const DEFAULT_OFFERS: GatorPermission[] = [
189197
// ... existing permissions
@@ -258,11 +266,13 @@ export const YourPermissionForm = ({
258266
Then, add your form to the permission type selector in `packages/site/src/pages/index.tsx`:
259267

260268
1. Import your form component:
269+
261270
```typescript
262271
import { YourPermissionForm } from '../components/permissions';
263272
```
264273

265274
2. Add your permission type to the select options:
275+
266276
```typescript
267277
<select
268278
id="permissionType"
@@ -276,6 +286,7 @@ import { YourPermissionForm } from '../components/permissions';
276286
```
277287

278288
3. Add the form component to the conditional rendering:
289+
279290
```typescript
280291
{permissionType === 'your-permission-type' && (
281292
<YourPermissionForm
@@ -287,6 +298,7 @@ import { YourPermissionForm } from '../components/permissions';
287298
```
288299

289300
Key points for implementing permission forms:
301+
290302
1. Use the `StyledForm` component for consistent styling
291303
2. Implement proper type safety with TypeScript
292304
3. Use controlled form components with React state

packages/gator-permissions-snap/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snap-7715-permissions.git"
88
},
99
"source": {
10-
"shasum": "/p0aPugcJzMS6wNekI/ZtyJUQ/5EsupvbPDiT4cPebg=",
10+
"shasum": "ZA6QEmzn+1LTSlIH7hGCFhIyeu9eO8g2xw312S4bdSE=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { parseUnits, formatUnits } from 'viem';
2+
import {
3+
convertReadableDateToTimestamp,
4+
getStartOfTodayUTC,
5+
TIME_PERIOD_TO_SECONDS,
6+
} from '../utils/time';
7+
import { TimePeriod } from '../core/types';
8+
9+
export interface ValidationErrors {
10+
[key: string]: string;
11+
}
12+
13+
/**
14+
* Validates and parses a token amount string to BigInt
15+
* @param amount - The amount string to validate
16+
* @param decimals - Token decimals
17+
* @param fieldName - Name of the field for error messages
18+
* @param allowZero - Whether zero values are allowed (default: false)
19+
* @returns Object containing the parsed amount and any validation error
20+
*/
21+
export function validateAndParseAmount(
22+
amount: string | undefined,
23+
decimals: number,
24+
fieldName: string,
25+
allowZero = false,
26+
): { amount: bigint | undefined; error: string | undefined } {
27+
if (!amount) {
28+
return { amount: undefined, error: undefined };
29+
}
30+
31+
try {
32+
const parsedAmount = parseUnits(amount, decimals);
33+
if (!allowZero && parsedAmount <= 0n) {
34+
return {
35+
amount: undefined,
36+
error: `${fieldName} must be greater than 0`,
37+
};
38+
}
39+
if (allowZero && parsedAmount < 0n) {
40+
return {
41+
amount: undefined,
42+
error: `${fieldName} must be greater than or equal to 0`,
43+
};
44+
}
45+
return { amount: parsedAmount, error: undefined };
46+
} catch (error) {
47+
return { amount: undefined, error: `Invalid ${fieldName.toLowerCase()}` };
48+
}
49+
}
50+
51+
/**
52+
* Validates a start time to ensure it's today or later
53+
* @param startTime - The start time string to validate
54+
* @returns Validation error message or undefined if valid
55+
*/
56+
export function validateStartTime(startTime: string): string | undefined {
57+
try {
58+
const startTimeDate = convertReadableDateToTimestamp(startTime);
59+
if (startTimeDate < getStartOfTodayUTC()) {
60+
return 'Start time must be today or later';
61+
}
62+
return undefined;
63+
} catch (error) {
64+
return 'Invalid start time';
65+
}
66+
}
67+
68+
/**
69+
* Validates an expiry time to ensure it's in the future
70+
* @param expiry - The expiry time string to validate
71+
* @returns Validation error message or undefined if valid
72+
*/
73+
export function validateExpiry(expiry: string): string | undefined {
74+
try {
75+
const expiryDate = convertReadableDateToTimestamp(expiry);
76+
const nowSeconds = Math.floor(Date.now() / 1000);
77+
if (expiryDate < nowSeconds) {
78+
return 'Expiry must be in the future';
79+
}
80+
return undefined;
81+
} catch (error) {
82+
return 'Invalid expiry';
83+
}
84+
}
85+
86+
/**
87+
* Validates that max amount is greater than initial amount
88+
* @param maxAmount - The maximum amount as BigInt
89+
* @param initialAmount - The initial amount as BigInt
90+
* @returns Validation error message or undefined if valid
91+
*/
92+
export function validateMaxAmountVsInitialAmount(
93+
maxAmount: bigint | undefined,
94+
initialAmount: bigint | undefined,
95+
): string | undefined {
96+
if (
97+
maxAmount !== undefined &&
98+
initialAmount !== undefined &&
99+
maxAmount < initialAmount
100+
) {
101+
return 'Max amount must be greater than initial amount';
102+
}
103+
return undefined;
104+
}
105+
106+
/**
107+
* Calculates amount per second from amount per period
108+
* @param amountPerPeriod - The amount per period as BigInt
109+
* @param timePeriod - The time period
110+
* @param decimals - Token decimals
111+
* @returns Formatted amount per second string
112+
*/
113+
export function calculateAmountPerSecond(
114+
amountPerPeriod: bigint,
115+
timePeriod: TimePeriod,
116+
decimals: number,
117+
): string {
118+
return formatUnits(
119+
amountPerPeriod / TIME_PERIOD_TO_SECONDS[timePeriod],
120+
decimals,
121+
);
122+
}
123+
124+
/**
125+
* Validates a period duration (for periodic permissions)
126+
* @param periodDuration - The period duration string to validate
127+
* @returns Object containing parsed duration and any validation error
128+
*/
129+
export function validatePeriodDuration(periodDuration: string): {
130+
duration: number | undefined;
131+
error: string | undefined;
132+
} {
133+
try {
134+
const duration = parseInt(periodDuration, 10);
135+
if (isNaN(duration) || duration <= 0) {
136+
return {
137+
duration: undefined,
138+
error: 'Period duration must be greater than 0',
139+
};
140+
}
141+
return { duration, error: undefined };
142+
} catch (error) {
143+
return { duration: undefined, error: 'Invalid period duration' };
144+
}
145+
}

packages/gator-permissions-snap/src/permissions/erc20TokenStream/context.ts

Lines changed: 53 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import { formatUnitsFromString } from '../../utils/balance';
88
import {
99
convertReadableDateToTimestamp,
1010
convertTimestampToReadableDate,
11-
getStartOfTodayUTC,
1211
TIME_PERIOD_TO_SECONDS,
1312
} from '../../utils/time';
13+
import {
14+
validateAndParseAmount,
15+
validateStartTime,
16+
validateExpiry,
17+
validateMaxAmountVsInitialAmount,
18+
calculateAmountPerSecond,
19+
} from '../contextValidation';
1420
import type {
1521
Erc20TokenStreamContext,
1622
Erc20TokenStreamPermissionRequest,
@@ -212,88 +218,64 @@ export async function deriveMetadata({
212218

213219
const validationErrors: Erc20TokenStreamMetadata['validationErrors'] = {};
214220

215-
let maxAmountBigInt: bigint | undefined;
216-
let initialAmountBigInt: bigint | undefined;
217-
let amountPerSecondBigInt: bigint | undefined;
218-
let amountPerSecond = 'Unknown';
219-
if (permissionDetails.maxAmount) {
220-
try {
221-
maxAmountBigInt = parseUnits(permissionDetails.maxAmount, decimals);
222-
if (maxAmountBigInt < 0n) {
223-
validationErrors.maxAmountError = 'Max amount must be greater than 0';
224-
maxAmountBigInt = undefined;
225-
}
226-
} catch (error) {
227-
validationErrors.maxAmountError = 'Invalid max amount';
228-
}
221+
// Validate max amount
222+
const maxAmountResult = validateAndParseAmount(
223+
permissionDetails.maxAmount,
224+
decimals,
225+
'Max amount',
226+
false, // Disallow zero for max amount
227+
);
228+
if (maxAmountResult.error) {
229+
validationErrors.maxAmountError = maxAmountResult.error;
229230
}
230231

231-
if (permissionDetails.initialAmount) {
232-
try {
233-
initialAmountBigInt = parseUnits(
234-
permissionDetails.initialAmount,
235-
decimals,
236-
);
237-
if (initialAmountBigInt < 0n) {
238-
validationErrors.initialAmountError =
239-
'Initial amount must be greater than 0';
240-
initialAmountBigInt = undefined;
241-
}
242-
} catch (error) {
243-
validationErrors.initialAmountError = 'Invalid initial amount';
244-
}
232+
// Validate initial amount
233+
const initialAmountResult = validateAndParseAmount(
234+
permissionDetails.initialAmount,
235+
decimals,
236+
'Initial amount',
237+
true, // Allow zero for initial amount
238+
);
239+
if (initialAmountResult.error) {
240+
validationErrors.initialAmountError = initialAmountResult.error;
245241
}
246242

247-
try {
248-
amountPerSecondBigInt = parseUnits(
249-
permissionDetails.amountPerPeriod,
243+
// Validate amount per period
244+
const amountPerPeriodResult = validateAndParseAmount(
245+
permissionDetails.amountPerPeriod,
246+
decimals,
247+
'Amount per period',
248+
);
249+
let amountPerSecond = 'Unknown';
250+
if (amountPerPeriodResult.error) {
251+
validationErrors.amountPerPeriodError = amountPerPeriodResult.error;
252+
} else if (amountPerPeriodResult.amount) {
253+
amountPerSecond = calculateAmountPerSecond(
254+
amountPerPeriodResult.amount,
255+
permissionDetails.timePeriod,
250256
decimals,
251257
);
252-
if (amountPerSecondBigInt <= 0n) {
253-
validationErrors.amountPerPeriodError =
254-
'Amount per period must be greater than 0';
255-
amountPerSecondBigInt = undefined;
256-
} else {
257-
amountPerSecond = formatUnits(
258-
amountPerSecondBigInt /
259-
TIME_PERIOD_TO_SECONDS[permissionDetails.timePeriod],
260-
decimals,
261-
);
262-
}
263-
} catch (error) {
264-
validationErrors.amountPerPeriodError = 'Invalid amount per period';
265258
}
266259

267-
try {
268-
const startTimeDate = convertReadableDateToTimestamp(
269-
permissionDetails.startTime,
270-
);
271-
272-
if (startTimeDate < getStartOfTodayUTC()) {
273-
validationErrors.startTimeError = 'Start time must be today or later';
274-
}
275-
} catch (error) {
276-
validationErrors.startTimeError = 'Invalid start time';
260+
// Validate start time
261+
const startTimeError = validateStartTime(permissionDetails.startTime);
262+
if (startTimeError) {
263+
validationErrors.startTimeError = startTimeError;
277264
}
278265

279-
try {
280-
const expiryDate = convertReadableDateToTimestamp(expiry);
281-
const nowSeconds = Math.floor(Date.now() / 1000);
282-
283-
if (expiryDate < nowSeconds) {
284-
validationErrors.expiryError = 'Expiry must be in the future';
285-
}
286-
} catch (error) {
287-
validationErrors.expiryError = 'Invalid expiry';
266+
// Validate expiry
267+
const expiryError = validateExpiry(expiry);
268+
if (expiryError) {
269+
validationErrors.expiryError = expiryError;
288270
}
289271

290-
if (
291-
maxAmountBigInt !== undefined &&
292-
initialAmountBigInt !== undefined &&
293-
maxAmountBigInt < initialAmountBigInt
294-
) {
295-
validationErrors.maxAmountError =
296-
'Max amount must be greater than initial amount';
272+
// Validate max amount vs initial amount
273+
const maxVsInitialError = validateMaxAmountVsInitialAmount(
274+
maxAmountResult.amount,
275+
initialAmountResult.amount,
276+
);
277+
if (maxVsInitialError) {
278+
validationErrors.maxAmountError = maxVsInitialError;
297279
}
298280

299281
return {

0 commit comments

Comments
 (0)