Skip to content

Commit 741e6c2

Browse files
committed
refactor: extract expiry timestamp logic to reusable utility function
- Created extractExpiryTimestampFromDelegation() in time-utils.ts - Returns expiry timestamp or 0, making it reusable across components - Simplified review-gator-permission-item.tsx from ~60 lines to ~10 lines - Moved comprehensive tests from component to utility tests - Component tests now focus on UI behavior only
1 parent 24e518e commit 741e6c2

File tree

4 files changed

+380
-496
lines changed

4 files changed

+380
-496
lines changed

shared/lib/gator-permissions/time-utils.test.ts

Lines changed: 276 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { bigIntToHex } from '@metamask/utils';
1+
import { bigIntToHex, Hex } from '@metamask/utils';
22
import { Settings } from 'luxon';
3+
import { decodeDelegations } from '@metamask/delegation-core';
4+
import { getDeleGatorEnvironment } from '../delegation/environment';
35
import {
46
DAY,
57
FORTNIGHT,
@@ -13,10 +15,19 @@ import {
1315
convertMillisecondsToSeconds,
1416
convertTimestampToReadableDate,
1517
extractExpiryToReadableDate,
18+
extractExpiryTimestampFromDelegation,
1619
getPeriodFrequencyValueTranslationKey,
1720
GatorPermissionRule,
1821
} from './time-utils';
1922

23+
jest.mock('@metamask/delegation-core', () => ({
24+
decodeDelegations: jest.fn(),
25+
}));
26+
27+
jest.mock('../delegation/environment', () => ({
28+
getDeleGatorEnvironment: jest.fn(),
29+
}));
30+
2031
describe('time-utils', () => {
2132
beforeAll(() => {
2233
// Set Luxon to use UTC as the default timezone for consistent test results
@@ -215,4 +226,268 @@ describe('time-utils', () => {
215226
expect(result).toBe('');
216227
});
217228
});
229+
230+
describe('extractExpiryTimestampFromDelegation', () => {
231+
const mockChainId: Hex = '0x1';
232+
const mockPermissionContext: Hex = '0x00000000';
233+
234+
beforeEach(() => {
235+
jest.clearAllMocks();
236+
});
237+
238+
it('extracts expiry timestamp from valid delegation with TimestampEnforcer', () => {
239+
const mockExpiryTimestamp = 1767225600; // January 1, 2026
240+
const expiryHex = mockExpiryTimestamp.toString(16).padStart(32, '0');
241+
const termsHex = `0x${'0'.repeat(32)}${expiryHex}` as Hex;
242+
243+
(decodeDelegations as jest.Mock).mockReturnValue([
244+
{
245+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
246+
authority: '0x0000000000000000000000000000000000000000',
247+
caveats: [
248+
{
249+
enforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
250+
terms: termsHex,
251+
args: '0x',
252+
},
253+
],
254+
salt: 0n,
255+
signature: '0x',
256+
},
257+
]);
258+
259+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
260+
caveatEnforcers: {
261+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
262+
},
263+
});
264+
265+
const result = extractExpiryTimestampFromDelegation(
266+
mockPermissionContext,
267+
mockChainId,
268+
);
269+
expect(result).toBe(mockExpiryTimestamp);
270+
});
271+
272+
it('returns 0 when delegation has no TimestampEnforcer caveat', () => {
273+
(decodeDelegations as jest.Mock).mockReturnValue([
274+
{
275+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
276+
authority: '0x0000000000000000000000000000000000000000',
277+
caveats: [], // No TimestampEnforcer caveat
278+
salt: 0n,
279+
signature: '0x',
280+
},
281+
]);
282+
283+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
284+
caveatEnforcers: {
285+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
286+
},
287+
});
288+
289+
const result = extractExpiryTimestampFromDelegation(
290+
mockPermissionContext,
291+
mockChainId,
292+
);
293+
expect(result).toBe(0);
294+
});
295+
296+
it('returns 0 when delegation count is zero', () => {
297+
(decodeDelegations as jest.Mock).mockReturnValue([]);
298+
299+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
300+
caveatEnforcers: {
301+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
302+
},
303+
});
304+
305+
const result = extractExpiryTimestampFromDelegation(
306+
mockPermissionContext,
307+
mockChainId,
308+
);
309+
expect(result).toBe(0);
310+
});
311+
312+
it('returns 0 when delegation count is greater than one', () => {
313+
(decodeDelegations as jest.Mock).mockReturnValue([
314+
{
315+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
316+
authority: '0x0000000000000000000000000000000000000000',
317+
caveats: [],
318+
salt: 0n,
319+
signature: '0x',
320+
},
321+
{
322+
delegate: '0x276059c27095647e995b5db678800f8ce7f581dd',
323+
authority: '0x0000000000000000000000000000000000000000',
324+
caveats: [],
325+
salt: 0n,
326+
signature: '0x',
327+
},
328+
]);
329+
330+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
331+
caveatEnforcers: {
332+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
333+
},
334+
});
335+
336+
const result = extractExpiryTimestampFromDelegation(
337+
mockPermissionContext,
338+
mockChainId,
339+
);
340+
expect(result).toBe(0);
341+
});
342+
343+
it('returns 0 when terms have invalid length', () => {
344+
(decodeDelegations as jest.Mock).mockReturnValue([
345+
{
346+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
347+
authority: '0x0000000000000000000000000000000000000000',
348+
caveats: [
349+
{
350+
enforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
351+
terms: '0x1234' as Hex, // Invalid length (not 64 hex chars)
352+
args: '0x',
353+
},
354+
],
355+
salt: 0n,
356+
signature: '0x',
357+
},
358+
]);
359+
360+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
361+
caveatEnforcers: {
362+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
363+
},
364+
});
365+
366+
const result = extractExpiryTimestampFromDelegation(
367+
mockPermissionContext,
368+
mockChainId,
369+
);
370+
expect(result).toBe(0);
371+
});
372+
373+
it('returns 0 when timestamp is zero', () => {
374+
const zeroTermsHex = `0x${'0'.repeat(64)}` as Hex;
375+
376+
(decodeDelegations as jest.Mock).mockReturnValue([
377+
{
378+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
379+
authority: '0x0000000000000000000000000000000000000000',
380+
caveats: [
381+
{
382+
enforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
383+
terms: zeroTermsHex,
384+
args: '0x',
385+
},
386+
],
387+
salt: 0n,
388+
signature: '0x',
389+
},
390+
]);
391+
392+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
393+
caveatEnforcers: {
394+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
395+
},
396+
});
397+
398+
const result = extractExpiryTimestampFromDelegation(
399+
mockPermissionContext,
400+
mockChainId,
401+
);
402+
expect(result).toBe(0);
403+
});
404+
405+
it('returns 0 when decodeDelegations throws error', () => {
406+
(decodeDelegations as jest.Mock).mockImplementation(() => {
407+
throw new Error('Decoding failed');
408+
});
409+
410+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
411+
caveatEnforcers: {
412+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
413+
},
414+
});
415+
416+
const result = extractExpiryTimestampFromDelegation(
417+
mockPermissionContext,
418+
mockChainId,
419+
);
420+
expect(result).toBe(0);
421+
});
422+
423+
it('extracts expiry correctly with different timestamp', () => {
424+
const customExpiryTimestamp = 1744588800; // April 14, 2025
425+
const customExpiryHex = customExpiryTimestamp
426+
.toString(16)
427+
.padStart(32, '0');
428+
const customTermsHex = `0x${'0'.repeat(32)}${customExpiryHex}` as Hex;
429+
430+
(decodeDelegations as jest.Mock).mockReturnValue([
431+
{
432+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
433+
authority: '0x0000000000000000000000000000000000000000',
434+
caveats: [
435+
{
436+
enforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
437+
terms: customTermsHex,
438+
args: '0x',
439+
},
440+
],
441+
salt: 0n,
442+
signature: '0x',
443+
},
444+
]);
445+
446+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
447+
caveatEnforcers: {
448+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069',
449+
},
450+
});
451+
452+
const result = extractExpiryTimestampFromDelegation(
453+
mockPermissionContext,
454+
mockChainId,
455+
);
456+
expect(result).toBe(customExpiryTimestamp);
457+
});
458+
459+
it('handles case-insensitive enforcer address matching', () => {
460+
const mockExpiryTimestamp = 1767225600;
461+
const expiryHex = mockExpiryTimestamp.toString(16).padStart(32, '0');
462+
const termsHex = `0x${'0'.repeat(32)}${expiryHex}` as Hex;
463+
464+
(decodeDelegations as jest.Mock).mockReturnValue([
465+
{
466+
delegate: '0x176059c27095647e995b5db678800f8ce7f581dd',
467+
authority: '0x0000000000000000000000000000000000000000',
468+
caveats: [
469+
{
470+
enforcer: '0x1046BB45C8D673D4EA75321280DB34899413C069', // Mixed case
471+
terms: termsHex,
472+
args: '0x',
473+
},
474+
],
475+
salt: 0n,
476+
signature: '0x',
477+
},
478+
]);
479+
480+
(getDeleGatorEnvironment as jest.Mock).mockReturnValue({
481+
caveatEnforcers: {
482+
TimestampEnforcer: '0x1046bb45c8d673d4ea75321280db34899413c069', // Lower case
483+
},
484+
});
485+
486+
const result = extractExpiryTimestampFromDelegation(
487+
mockPermissionContext,
488+
mockChainId,
489+
);
490+
expect(result).toBe(mockExpiryTimestamp);
491+
});
492+
});
218493
});

shared/lib/gator-permissions/time-utils.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { bigIntToHex, Hex, hexToBigInt } from '@metamask/utils';
22
import { DateTime } from 'luxon';
3+
import { decodeDelegations } from '@metamask/delegation-core';
4+
import { getDeleGatorEnvironment } from '../delegation/environment';
35
import {
46
DAY,
57
FORTNIGHT,
@@ -119,3 +121,72 @@ export const extractExpiryToReadableDate = (
119121

120122
return '';
121123
};
124+
125+
/**
126+
* Extracts the expiry timestamp from a delegation context.
127+
* Based on the TimestampEnforcer contract encoding:
128+
* - Terms are 32 bytes total
129+
* - First 16 bytes: timestampAfterThreshold (uint128)
130+
* - Last 16 bytes: timestampBeforeThreshold (uint128) - this is the expiry
131+
*
132+
* @param permissionContext - The delegation context hex string
133+
* @param chainId - The chain ID hex string
134+
* @returns The expiration timestamp in seconds, or 0 if no expiration exists
135+
*/
136+
export const extractExpiryTimestampFromDelegation = (
137+
permissionContext: Hex,
138+
chainId: Hex,
139+
): number => {
140+
try {
141+
const delegations = decodeDelegations(permissionContext);
142+
143+
if (delegations.length !== 1) {
144+
return 0;
145+
}
146+
147+
const delegation = delegations[0];
148+
if (!delegation) {
149+
return 0;
150+
}
151+
152+
const chainIdNumber = parseInt(chainId, 16);
153+
const environment = getDeleGatorEnvironment(chainIdNumber);
154+
const timestampEnforcerAddress =
155+
environment.caveatEnforcers.TimestampEnforcer.toLowerCase();
156+
157+
const timestampCaveat = delegation.caveats.find(
158+
(caveat) => caveat.enforcer.toLowerCase() === timestampEnforcerAddress,
159+
);
160+
161+
if (!timestampCaveat) {
162+
return 0;
163+
}
164+
165+
// Extract the expiry from the terms
166+
// Terms are 32 bytes (64 hex characters)
167+
// Last 16 bytes (32 hex chars) = timestampBeforeThreshold (uint128)
168+
const terms = timestampCaveat.terms as Hex;
169+
170+
// Remove '0x' prefix if present
171+
const termsHex = terms.startsWith('0x') ? terms.slice(2) : terms;
172+
173+
// Validate length: should be 64 hex characters (32 bytes)
174+
if (termsHex.length !== 64) {
175+
return 0;
176+
}
177+
178+
// Extract last 32 hex characters (16 bytes) = timestampBeforeThreshold (expiry)
179+
const expiryHex = `0x${termsHex.slice(32)}`;
180+
181+
// Convert to number (uint128 fits in JavaScript's safe integer range)
182+
const expiry = Number(BigInt(expiryHex));
183+
184+
if (!expiry || expiry === 0) {
185+
return 0;
186+
}
187+
188+
return expiry;
189+
} catch (error) {
190+
return 0;
191+
}
192+
};

0 commit comments

Comments
 (0)