Skip to content

Commit e554b84

Browse files
authored
feat: enforce sentinel redeemers for Uniswap permissions (#366)
* feat: add normalization for Uniswap redeemer rules and implement related tests * chore: update packageManager to yarn@4.16.0 in gator-permissions-snap and permissions-kernel-snap * fix: update server configuration to include allowedHosts and host settings * feat: add validation for non-empty addresses array in normalizeSentinelRedeemerRuleForOrigin and implement corresponding tests * feat: rename and update normalization function for permission requests with Sentinel redeemer rule * feat: add script to check facilitator addresses across environments * feat: update supported chains for Sentinel redeemer and add corresponding test * feat: enhance normalizePermissionRequestWithSentinelRedeemerRule to support specific request subtypes in tests * feat: refactor sentinel redeemer rule logic and update address handling
1 parent 0073ee6 commit e554b84

9 files changed

Lines changed: 507 additions & 191 deletions

File tree

packages/gator-permissions-snap/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
"typescript": "5.8.3",
8585
"typescript-eslint": "^8.39.0"
8686
},
87-
"packageManager": "yarn@4.14.1",
87+
"packageManager": "yarn@4.16.0",
8888
"engines": {
8989
"node": "^20 || >=22"
9090
},

packages/gator-permissions-snap/src/core/permissionRequestLifecycleOrchestrator.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { appendExpiryCaveatIfPresent } from './expiryCaveat';
3232
import { appendPayeeCaveatIfPresent } from './payeeCaveat';
3333
import type { PermissionIntroductionService } from './permissionIntroduction';
3434
import { appendRedeemerCaveatIfPresent } from './redeemerCaveat';
35+
import { normalizePermissionRequestWithSentinelRedeemerRule } from './sentinelRedeemer';
3536
import type {
3637
BaseContext,
3738
BaseMetadata,
@@ -159,6 +160,12 @@ export class PermissionRequestLifecycleOrchestrator {
159160
// Validate the permission request early, before showing any UI
160161
const validatedPermissionRequest =
161162
lifecycleHandlers.parseAndValidatePermission(permissionRequest);
163+
const normalizedPermissionRequest =
164+
normalizePermissionRequestWithSentinelRedeemerRule({
165+
origin,
166+
permissionRequest: validatedPermissionRequest,
167+
chainId,
168+
});
162169

163170
// Create shared dialog interface for both intro and confirmation
164171
const dialogInterface =
@@ -176,7 +183,7 @@ export class PermissionRequestLifecycleOrchestrator {
176183
.then((list) =>
177184
this.#existingPermissionsService.getExistingPermissionsStatusFromList(
178185
list,
179-
validatedPermissionRequest.permission,
186+
normalizedPermissionRequest.permission,
180187
),
181188
)
182189
.catch((error: unknown) => {
@@ -261,7 +268,7 @@ export class PermissionRequestLifecycleOrchestrator {
261268

262269
try {
263270
context = await lifecycleHandlers.buildContext(
264-
validatedPermissionRequest,
271+
normalizedPermissionRequest,
265272
);
266273
} catch (error) {
267274
await confirmationDialog.closeWithError(error as Error);
@@ -349,10 +356,10 @@ export class PermissionRequestLifecycleOrchestrator {
349356
});
350357

351358
// Address scan in the background; only update when the client resolves (non-blocking)
352-
const delegateAddress = validatedPermissionRequest.to;
359+
const delegateAddress = normalizedPermissionRequest.to;
353360
if (delegateAddress) {
354361
this.#trustSignalsClient
355-
.fetchAddressScan(validatedPermissionRequest.chainId, delegateAddress)
362+
.fetchAddressScan(normalizedPermissionRequest.chainId, delegateAddress)
356363
.then(async (result) => {
357364
scanAddressResult = result;
358365
return updateConfirmation({
@@ -456,7 +463,7 @@ export class PermissionRequestLifecycleOrchestrator {
456463
}
457464

458465
const response = await this.#resolveResponse({
459-
originalRequest: validatedPermissionRequest,
466+
originalRequest: normalizedPermissionRequest,
460467
modifiedContext: context,
461468
lifecycleHandlers,
462469
isAdjustmentAllowed,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { PermissionRequest } from '@metamask/7715-permissions-shared/types';
2+
import { extractDescriptorName } from '@metamask/7715-permissions-shared/utils';
3+
import { InvalidInputError } from '@metamask/snaps-sdk';
4+
import type { Hex } from '@metamask/utils';
5+
6+
/**
7+
* Confirmed prod Sentinel redeemer accounts. These are used on all chains with
8+
* gasless transactions enabled until the Sentinel networks endpoint exposes
9+
* redeemer accounts directly.
10+
*/
11+
export const SENTINEL_REDEEMER_ADDRESSES = [
12+
'0xb01caea8c6c47bbf4f4b4c5080ca642043359c2e',
13+
'0xc066ac5d385419b1a8c43a0e146fa439837a8b8c',
14+
'0xb42f812a44c22cc6b861478900401ee759ebead6',
15+
] as const satisfies readonly Hex[];
16+
17+
export const SENTINEL_SUPPORTED_CHAINS = [
18+
0x1, // Ethereum Mainnet
19+
0xa, // OP Mainnet
20+
0x38, // BNB Smart Chain Mainnet
21+
0x89, // Polygon Mainnet
22+
0xa4b1, // Arbitrum One
23+
0xa86a, // Avalanche C-Chain
24+
0xe708, // Linea Mainnet
25+
0x2105, // Base
26+
0x531, // Sei Network
27+
0x8f, // Monad
28+
0x10e6, // MegaETH Mainnet
29+
0x1079, // Tempo Mainnet Presto
30+
0x13b2, // Arc
31+
0xaa36a7, // Ethereum Sepolia
32+
0x14a34, // Base Sepolia
33+
] as const satisfies readonly number[];
34+
35+
const UNISWAP_HOSTNAME = 'uniswap.org';
36+
37+
const isSentinelRedeemerRuleRequiredForOriginAndChain = ({
38+
origin,
39+
chainId,
40+
}: {
41+
origin: string;
42+
chainId: number;
43+
}): boolean => {
44+
if (!SENTINEL_SUPPORTED_CHAINS.includes(chainId)) {
45+
return false;
46+
}
47+
48+
try {
49+
const { hostname } = new URL(origin);
50+
return (
51+
hostname === UNISWAP_HOSTNAME || hostname.endsWith(`.${UNISWAP_HOSTNAME}`)
52+
);
53+
} catch {
54+
return false;
55+
}
56+
};
57+
58+
const getRedeemerRule = (
59+
rules: PermissionRequest['rules'],
60+
): PermissionRequest['rules'][number] | undefined =>
61+
rules?.find((rule) => extractDescriptorName(rule.type) === 'redeemer');
62+
63+
/**
64+
* Ensures Uniswap permission requests can only be redeemed by Sentinel.
65+
*
66+
* @param options - Normalization options.
67+
* @param options.origin - Site origin that requested the permission.
68+
* @param options.permissionRequest - Validated permission request.
69+
* @param options.chainId - Numeric EIP-155 chain ID.
70+
* @returns The original request, or a copy with a Sentinel redeemer rule added.
71+
* @throws If a Uniswap request includes unsupported redeemer addresses.
72+
*/
73+
export function normalizePermissionRequestWithSentinelRedeemerRule<
74+
TRequest extends PermissionRequest,
75+
>({
76+
origin,
77+
permissionRequest,
78+
chainId,
79+
}: {
80+
origin: string;
81+
permissionRequest: TRequest;
82+
chainId: number;
83+
}): TRequest {
84+
if (!isSentinelRedeemerRuleRequiredForOriginAndChain({ origin, chainId })) {
85+
return permissionRequest;
86+
}
87+
88+
const sentinelRedeemerAddressSet = new Set(
89+
SENTINEL_REDEEMER_ADDRESSES.map((address) => address.toLowerCase()),
90+
);
91+
const redeemerRule = getRedeemerRule(permissionRequest.rules);
92+
const requestedRedeemerAddresses = redeemerRule?.data.addresses as
93+
| string[]
94+
| undefined;
95+
96+
if (
97+
requestedRedeemerAddresses !== undefined &&
98+
(!Array.isArray(requestedRedeemerAddresses) ||
99+
requestedRedeemerAddresses.length === 0)
100+
) {
101+
throw new InvalidInputError(
102+
'Invalid redeemer rule: must include a non-empty addresses array',
103+
);
104+
}
105+
106+
if (requestedRedeemerAddresses) {
107+
const unsupportedAddress = requestedRedeemerAddresses.find(
108+
(address) => !sentinelRedeemerAddressSet.has(address.toLowerCase()),
109+
);
110+
111+
if (unsupportedAddress) {
112+
throw new InvalidInputError(
113+
`Redeemer rule includes addresses other than allowed values: ${unsupportedAddress}. Permissions granted on this domain may only be redeemed via MetaMask Sentinel.`,
114+
);
115+
}
116+
117+
return permissionRequest;
118+
}
119+
120+
return {
121+
...permissionRequest,
122+
rules: [
123+
...(permissionRequest.rules ?? []),
124+
{
125+
type: 'redeemer',
126+
data: { addresses: [...SENTINEL_REDEEMER_ADDRESSES] },
127+
},
128+
],
129+
};
130+
}

packages/gator-permissions-snap/test/core/permissionRequestLifecycleOrchestrator.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ROOT_AUTHORITY,
55
createTimestampTerms,
66
createNonceTerms,
7+
createRedeemerTerms,
78
} from '@metamask/delegation-core';
89
import type { SnapElement } from '@metamask/snaps-sdk/jsx';
910
import { bigIntToHex, bytesToHex } from '@metamask/utils';
@@ -26,6 +27,7 @@ import {
2627
} from '../../src/core/existingpermissions/existingPermissionsService';
2728
import type { PermissionIntroductionService } from '../../src/core/permissionIntroduction';
2829
import { PermissionRequestLifecycleOrchestrator } from '../../src/core/permissionRequestLifecycleOrchestrator';
30+
import { SENTINEL_REDEEMER_ADDRESSES } from '../../src/core/sentinelRedeemer';
2931
import type { BaseContext } from '../../src/core/types';
3032
import type { ProfileSyncManager } from '../../src/profileSync/profileSync';
3133
import type { NonceCaveatService } from '../../src/services/nonceCaveatService';
@@ -590,6 +592,115 @@ describe('PermissionRequestLifecycleOrchestrator', () => {
590592
expect(result).toBeDefined();
591593
});
592594

595+
it('adds the sentinel redeemer rule for uniswap.org requests with no redeemer rule', async () => {
596+
const requestWithoutRedeemerRule = {
597+
...mockPermissionRequest,
598+
rules: [mockPermissionRequest.rules[0]],
599+
};
600+
601+
lifecycleHandlerMocks.applyContext.mockImplementation(
602+
({ originalRequest }) => ({
603+
...mockResolvedPermissionRequest,
604+
rules: originalRequest.rules,
605+
}),
606+
);
607+
608+
const result = await permissionRequestLifecycleOrchestrator.orchestrate(
609+
'https://app.uniswap.org',
610+
requestWithoutRedeemerRule,
611+
lifecycleHandlerMocks,
612+
);
613+
614+
expect(lifecycleHandlerMocks.buildContext).toHaveBeenCalledWith({
615+
...requestWithoutRedeemerRule,
616+
rules: [
617+
requestWithoutRedeemerRule.rules[0],
618+
{
619+
type: 'redeemer',
620+
data: { addresses: SENTINEL_REDEEMER_ADDRESSES },
621+
},
622+
],
623+
});
624+
625+
expect(result.approved).toBe(true);
626+
if (!result.approved) {
627+
throw new Error('Expected the permission request to be approved');
628+
}
629+
630+
const delegationsArray = decodeDelegations(result.response.context);
631+
const { contracts } = getChainMetadata({
632+
chainId: Number(mockPermissionRequest.chainId),
633+
});
634+
635+
expect(delegationsArray[0]?.caveats).toContainEqual({
636+
enforcer: contracts.redeemerEnforcer.toLowerCase(),
637+
args: '0x',
638+
terms: createRedeemerTerms({
639+
redeemers: SENTINEL_REDEEMER_ADDRESSES.map(
640+
(address) => address.toLowerCase() as Hex,
641+
),
642+
}),
643+
});
644+
});
645+
646+
it('preserves a uniswap.org redeemer rule that only contains sentinel addresses', async () => {
647+
const requestedRedeemerRule = {
648+
type: 'redeemer',
649+
data: { addresses: [SENTINEL_REDEEMER_ADDRESSES[0]] },
650+
};
651+
const requestWithSentinelRedeemerRule = {
652+
...mockPermissionRequest,
653+
rules: [mockPermissionRequest.rules[0], requestedRedeemerRule],
654+
};
655+
656+
lifecycleHandlerMocks.applyContext.mockImplementation(
657+
({ originalRequest }) => ({
658+
...mockResolvedPermissionRequest,
659+
rules: originalRequest.rules,
660+
}),
661+
);
662+
663+
await permissionRequestLifecycleOrchestrator.orchestrate(
664+
'https://uniswap.org',
665+
requestWithSentinelRedeemerRule,
666+
lifecycleHandlerMocks,
667+
);
668+
669+
expect(lifecycleHandlerMocks.buildContext).toHaveBeenCalledWith(
670+
requestWithSentinelRedeemerRule,
671+
);
672+
});
673+
674+
it('rejects uniswap.org redeemer rules with non-sentinel addresses', async () => {
675+
const requestWithUnsupportedRedeemerRule = {
676+
...mockPermissionRequest,
677+
rules: [
678+
mockPermissionRequest.rules[0],
679+
{
680+
type: 'redeemer',
681+
data: {
682+
addresses: [
683+
SENTINEL_REDEEMER_ADDRESSES[0],
684+
'0x1111111111111111111111111111111111111111',
685+
],
686+
},
687+
},
688+
],
689+
};
690+
691+
await expect(
692+
permissionRequestLifecycleOrchestrator.orchestrate(
693+
'https://app.uniswap.org',
694+
requestWithUnsupportedRedeemerRule,
695+
lifecycleHandlerMocks,
696+
),
697+
).rejects.toThrow(
698+
'Redeemer rule includes addresses other than allowed values: 0x1111111111111111111111111111111111111111. Permissions granted on this domain may only be redeemed via MetaMask Sentinel.',
699+
);
700+
701+
expect(lifecycleHandlerMocks.buildContext).not.toHaveBeenCalled();
702+
});
703+
593704
it('checks account upgrade status and triggers upgrade when needed', async () => {
594705
mockAccountController.getAccountUpgradeStatus.mockResolvedValueOnce({
595706
isUpgraded: false,

0 commit comments

Comments
 (0)