Skip to content

Commit 15794d8

Browse files
committed
Add method decodePermissionFromPermissionContextForOrigin to GatorPermissionsController
- rejects any request from an origin other than the gator permission snap - attempts to identify the permission type, and decode the data - rejects any request where a unique permission is unable to be decoded Also renames GatorPermissionsController.test.ts
1 parent dac2789 commit 15794d8

File tree

9 files changed

+1582
-2
lines changed

9 files changed

+1582
-2
lines changed

packages/gator-permissions-controller/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"dependencies": {
5050
"@metamask/7715-permission-types": "^0.3.0",
5151
"@metamask/base-controller": "^8.3.0",
52+
"@metamask/delegation-core": "^0.2.0-rc.1",
53+
"@metamask/delegation-deployments": "^0.12.0",
5254
"@metamask/snaps-sdk": "^9.0.0",
5355
"@metamask/snaps-utils": "^11.0.0",
5456
"@metamask/utils": "^11.8.0"

packages/gator-permissions-controller/src/GatorPermissionContoller.test.ts renamed to packages/gator-permissions-controller/src/GatorPermissionsController.test.ts

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import type { AccountSigner } from '@metamask/7715-permission-types';
22
import { Messenger, deriveStateFromMetadata } from '@metamask/base-controller';
3+
import {
4+
createTimestampTerms,
5+
createNativeTokenStreamingTerms,
6+
ROOT_AUTHORITY,
7+
} from '@metamask/delegation-core';
8+
import {
9+
CHAIN_ID,
10+
DELEGATOR_CONTRACTS,
11+
} from '@metamask/delegation-deployments';
312
import type { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers';
413
import type { SnapId } from '@metamask/snaps-sdk';
5-
import type { Hex } from '@metamask/utils';
14+
import { hexToBigInt, numberToHex, type Hex } from '@metamask/utils';
615

16+
import { DELEGATION_FRAMEWORK_VERSION } from './decodePermission';
717
import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController';
818
import GatorPermissionsController from './GatorPermissionsController';
919
import {
@@ -453,6 +463,215 @@ describe('GatorPermissionsController', () => {
453463
`);
454464
});
455465
});
466+
467+
describe('decodePermissionFromPermissionContextForOrigin', () => {
468+
const chainId = CHAIN_ID.sepolia;
469+
const contracts =
470+
DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][chainId];
471+
472+
const delegatorAddressA =
473+
'0x1111111111111111111111111111111111111111' as Hex;
474+
const delegateAddressB =
475+
'0x2222222222222222222222222222222222222222' as Hex;
476+
const metamaskOrigin = 'https://metamask.io';
477+
const buildMetadata = (justification: string) => ({
478+
justification,
479+
origin: metamaskOrigin,
480+
});
481+
482+
let controller: GatorPermissionsController;
483+
484+
beforeEach(() => {
485+
controller = new GatorPermissionsController({
486+
messenger: getMessenger(),
487+
});
488+
});
489+
490+
it('decodes a native-token-stream permission successfully', async () => {
491+
const {
492+
TimestampEnforcer,
493+
NativeTokenStreamingEnforcer,
494+
ExactCalldataEnforcer,
495+
NonceEnforcer,
496+
} = contracts;
497+
498+
const delegator = delegatorAddressA;
499+
const delegate = delegateAddressB;
500+
501+
const timestampBeforeThreshold = 1720000;
502+
const expiryTerms = createTimestampTerms(
503+
{ timestampAfterThreshold: 0, timestampBeforeThreshold },
504+
{ out: 'hex' },
505+
);
506+
507+
const initialAmount = 123456n;
508+
const maxAmount = 999999n;
509+
const amountPerSecond = 1n;
510+
const startTime = 1715664;
511+
const streamTerms = createNativeTokenStreamingTerms(
512+
{ initialAmount, maxAmount, amountPerSecond, startTime },
513+
{ out: 'hex' },
514+
);
515+
516+
const caveats = [
517+
{
518+
enforcer: TimestampEnforcer,
519+
terms: expiryTerms,
520+
args: '0x',
521+
} as const,
522+
{
523+
enforcer: NativeTokenStreamingEnforcer,
524+
terms: streamTerms,
525+
args: '0x',
526+
} as const,
527+
{ enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const,
528+
{ enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const,
529+
];
530+
531+
const delegation = {
532+
delegate,
533+
delegator,
534+
authority: ROOT_AUTHORITY as Hex,
535+
caveats,
536+
};
537+
538+
const result =
539+
await controller.decodePermissionFromPermissionContextForOrigin({
540+
origin: controller.permissionsProviderSnapId,
541+
chainId,
542+
delegation,
543+
metadata: buildMetadata('Test justification'),
544+
});
545+
546+
expect(result.chainId).toBe(numberToHex(chainId));
547+
expect(result.address).toBe(delegator);
548+
expect(result.signer).toStrictEqual({
549+
type: 'account',
550+
data: { address: delegate },
551+
});
552+
expect(result.permission.type).toBe('native-token-stream');
553+
expect(result.expiry).toBe(timestampBeforeThreshold);
554+
// amounts are hex-encoded in decoded data; startTime is numeric
555+
expect(result.permission.data.startTime).toBe(startTime);
556+
// BigInt fields are encoded as hex; compare after decoding
557+
expect(hexToBigInt(result.permission.data.initialAmount)).toBe(
558+
initialAmount,
559+
);
560+
expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount);
561+
expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe(
562+
amountPerSecond,
563+
);
564+
expect(result.permission.justification).toBe('Test justification');
565+
});
566+
567+
it('throws when origin does not match permissions provider', async () => {
568+
await expect(
569+
controller.decodePermissionFromPermissionContextForOrigin({
570+
origin: 'not-the-provider',
571+
chainId: 1,
572+
delegation: {
573+
delegate: '0x1',
574+
delegator: '0x2',
575+
authority: ROOT_AUTHORITY as Hex,
576+
caveats: [],
577+
},
578+
metadata: buildMetadata(''),
579+
}),
580+
).rejects.toThrow('Origin not-the-provider not allowed');
581+
});
582+
583+
it('throws when enforcers do not identify a supported permission', async () => {
584+
const { TimestampEnforcer, ValueLteEnforcer } = contracts;
585+
586+
const expiryTerms = createTimestampTerms(
587+
{ timestampAfterThreshold: 0, timestampBeforeThreshold: 100 },
588+
{ out: 'hex' },
589+
);
590+
591+
const caveats = [
592+
{
593+
enforcer: TimestampEnforcer,
594+
terms: expiryTerms,
595+
args: '0x',
596+
} as const,
597+
// Include a forbidden/irrelevant enforcer without required counterparts
598+
{ enforcer: ValueLteEnforcer, terms: '0x', args: '0x' } as const,
599+
];
600+
601+
await expect(
602+
controller.decodePermissionFromPermissionContextForOrigin({
603+
origin: controller.permissionsProviderSnapId,
604+
chainId,
605+
delegation: {
606+
delegate: delegatorAddressA,
607+
delegator: delegateAddressB,
608+
authority: ROOT_AUTHORITY as Hex,
609+
caveats,
610+
},
611+
metadata: buildMetadata(''),
612+
}),
613+
).rejects.toThrow('Failed to decode permission');
614+
});
615+
616+
it('throws when authority is not ROOT_AUTHORITY', async () => {
617+
const {
618+
TimestampEnforcer,
619+
NativeTokenStreamingEnforcer,
620+
ExactCalldataEnforcer,
621+
NonceEnforcer,
622+
} = contracts;
623+
624+
const delegator = delegatorAddressA;
625+
const delegate = delegateAddressB;
626+
627+
const timestampBeforeThreshold = 2000;
628+
const expiryTerms = createTimestampTerms(
629+
{ timestampAfterThreshold: 0, timestampBeforeThreshold },
630+
{ out: 'hex' },
631+
);
632+
633+
const initialAmount = 1n;
634+
const maxAmount = 2n;
635+
const amountPerSecond = 1n;
636+
const startTime = 1715000;
637+
const streamTerms = createNativeTokenStreamingTerms(
638+
{ initialAmount, maxAmount, amountPerSecond, startTime },
639+
{ out: 'hex' },
640+
);
641+
642+
const caveats = [
643+
{
644+
enforcer: TimestampEnforcer,
645+
terms: expiryTerms,
646+
args: '0x',
647+
} as const,
648+
{
649+
enforcer: NativeTokenStreamingEnforcer,
650+
terms: streamTerms,
651+
args: '0x',
652+
} as const,
653+
{ enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const,
654+
{ enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const,
655+
];
656+
657+
const invalidAuthority =
658+
'0x0000000000000000000000000000000000000000' as Hex;
659+
660+
await expect(
661+
controller.decodePermissionFromPermissionContextForOrigin({
662+
origin: controller.permissionsProviderSnapId,
663+
chainId,
664+
delegation: {
665+
delegate,
666+
delegator,
667+
authority: invalidAuthority,
668+
caveats,
669+
},
670+
metadata: buildMetadata(''),
671+
}),
672+
).rejects.toThrow('Failed to decode permission');
673+
});
674+
});
456675
});
457676

458677
/**

packages/gator-permissions-controller/src/GatorPermissionsController.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@ import type { HandleSnapRequest, HasSnap } from '@metamask/snaps-controllers';
1010
import type { SnapId } from '@metamask/snaps-sdk';
1111
import { HandlerType } from '@metamask/snaps-utils';
1212

13+
import type { DecodedPermission } from './decodePermission';
14+
import {
15+
getPermissionDataAndExpiry,
16+
identifyPermissionByEnforcers,
17+
reconstructDecodedPermission,
18+
} from './decodePermission';
1319
import {
1420
GatorPermissionsFetchError,
1521
GatorPermissionsNotEnabledError,
1622
GatorPermissionsProviderError,
23+
OriginNotAllowedError,
24+
PermissionDecodingError,
1725
} from './errors';
1826
import { controllerLog } from './logger';
1927
import type { StoredGatorPermissionSanitized } from './types';
@@ -22,6 +30,7 @@ import {
2230
type GatorPermissionsMap,
2331
type PermissionTypesWithCustom,
2432
type StoredGatorPermission,
33+
type DelegationDetails,
2534
} from './types';
2635
import {
2736
deserializeGatorPermissionsMap,
@@ -155,6 +164,12 @@ export type GatorPermissionsControllerDisableGatorPermissionsAction = {
155164
handler: GatorPermissionsController['disableGatorPermissions'];
156165
};
157166

167+
export type GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction =
168+
{
169+
type: `${typeof controllerName}:decodePermissionFromPermissionContextForOrigin`;
170+
handler: GatorPermissionsController['decodePermissionFromPermissionContextForOrigin'];
171+
};
172+
158173
/**
159174
* All actions that {@link GatorPermissionsController} registers, to be called
160175
* externally.
@@ -163,7 +178,8 @@ export type GatorPermissionsControllerActions =
163178
| GatorPermissionsControllerGetStateAction
164179
| GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction
165180
| GatorPermissionsControllerEnableGatorPermissionsAction
166-
| GatorPermissionsControllerDisableGatorPermissionsAction;
181+
| GatorPermissionsControllerDisableGatorPermissionsAction
182+
| GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction;
167183

168184
/**
169185
* All actions that {@link GatorPermissionsController} calls internally.
@@ -268,6 +284,11 @@ export default class GatorPermissionsController extends BaseController<
268284
`${controllerName}:disableGatorPermissions`,
269285
this.disableGatorPermissions.bind(this),
270286
);
287+
288+
this.messagingSystem.registerActionHandler(
289+
`${controllerName}:decodePermissionFromPermissionContextForOrigin`,
290+
this.decodePermissionFromPermissionContextForOrigin.bind(this),
291+
);
271292
}
272293

273294
/**
@@ -490,4 +511,76 @@ export default class GatorPermissionsController extends BaseController<
490511
this.#setIsFetchingGatorPermissions(false);
491512
}
492513
}
514+
515+
/**
516+
* Decodes a permission context into a structured permission for a specific origin.
517+
*
518+
* This method validates the caller origin, decodes the provided `permissionContext`
519+
* into delegations, identifies the permission type from the caveat enforcers,
520+
* extracts the permission-specific data and expiry, and reconstructs a
521+
* {@link DecodedPermission} containing chainId, account addresses, signer, type and data.
522+
*
523+
* @param args - The arguments to this function.
524+
* @param args.origin - The caller's origin; must match the configured permissions provider Snap id.
525+
* @param args.chainId - Numeric EIP-155 chain id used for resolving enforcer contracts and encoding.
526+
* @param args.delegation - delegation representing the permission.
527+
* @param args.metadata - metadata included in the request.
528+
* @param args.metadata.justification - the justification as specified in the request metadata.
529+
* @param args.metadata.origin - the origin as specified in the request metadata.
530+
*
531+
* @returns A decoded permission object suitable for UI consumption and follow-up actions.
532+
* @throws If the origin is not allowed, the context cannot be decoded into exactly one delegation,
533+
* or the enforcers/terms do not match a supported permission type.
534+
*/
535+
public async decodePermissionFromPermissionContextForOrigin({
536+
origin,
537+
chainId,
538+
delegation: { caveats, delegator, delegate, authority },
539+
metadata: { justification, origin: specifiedOrigin },
540+
}: {
541+
origin: string;
542+
chainId: number;
543+
metadata: {
544+
justification: string;
545+
origin: string;
546+
};
547+
delegation: DelegationDetails;
548+
}): Promise<DecodedPermission> {
549+
if (origin !== this.permissionsProviderSnapId) {
550+
throw new OriginNotAllowedError({ origin });
551+
}
552+
553+
try {
554+
const enforcers = caveats.map((caveat) => caveat.enforcer);
555+
556+
const permissionType = identifyPermissionByEnforcers({
557+
enforcers,
558+
chainId,
559+
});
560+
561+
const { expiry, data } = getPermissionDataAndExpiry({
562+
chainId,
563+
caveats,
564+
permissionType,
565+
});
566+
567+
const permission = reconstructDecodedPermission({
568+
chainId,
569+
permissionType,
570+
delegator,
571+
delegate,
572+
authority,
573+
expiry,
574+
data,
575+
justification,
576+
specifiedOrigin,
577+
});
578+
579+
return permission;
580+
} catch (error) {
581+
throw new PermissionDecodingError({
582+
cause: error as Error,
583+
});
584+
}
585+
}
493586
}

0 commit comments

Comments
 (0)