Skip to content

Commit a0acf82

Browse files
authored
feat: add redeemer rule (#212)
* feat: add redeemer execution rule and related types to permission requests * feat: update execution rules to include GenericRule and remove KnownRule and UnknownRule * feat: remove ExpiryRule, RedeemerRule, and GenericRule from permission types * feat: update changelog to remove deprecated execution rules and clarify permission request parameters * feat: refine Rule and PermissionRequest types documentation for clarity
1 parent 5c652a5 commit a0acf82

8 files changed

Lines changed: 286 additions & 19 deletions

packages/smart-accounts-kit/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Optional `redeemer` on `PermissionRequestParameter` maps to a `redeemer` execution rule; granted permission responses checksum-normalize redeemer addresses in `rules`.
13+
1014
## [1.2.0]
1115

1216
### Added

packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ export type { GetSupportedExecutionPermissionsResult } from './erc7715Types';
2222
* // {
2323
* // "native-token-allowance": {
2424
* // "chainIds": [1, 137],
25-
* // "ruleTypes": ["expiry"]
25+
* // "ruleTypes": ["expiry", "redeemer"]
2626
* // },
2727
* // "erc20-token-allowance": {
2828
* // "chainIds": [1],
29-
* // "ruleTypes": []
29+
* // "ruleTypes": ["expiry"]
3030
* // }
3131
* // }
32+
* //
33+
* // Which strings appear in `ruleTypes` is defined by the wallet; when supported,
34+
* // `"redeemer"` indicates the wallet accepts a redeemer execution rule.
3235
* ```
3336
*/
3437
export async function erc7715GetSupportedExecutionPermissionsAction(

packages/smart-accounts-kit/src/actions/erc7715Mapping.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
PermissionTypes as RpcPermissionTypes,
99
Rule,
1010
} from '@metamask/7715-permission-types';
11-
import { hexToNumber, toHex } from 'viem';
11+
import { getAddress, hexToNumber, isAddress, toHex, type Hex } from 'viem';
1212

1313
import { isDefined, toHexOrThrow } from '../utils';
1414
import type {
@@ -38,22 +38,39 @@ import type {
3838
export function permissionRequestToRpc(
3939
parameters: PermissionRequestParameter,
4040
): PermissionRequest<RpcPermissionTypes> {
41-
const { chainId, from, expiry } = parameters;
41+
const { chainId, from, expiry, redeemer } = parameters;
4242

4343
const converter = getPermissionRequestToRpcConverter(
4444
parameters.permission.type,
4545
);
4646

47-
const rules: Rule[] = isDefined(expiry)
48-
? [
49-
{
50-
type: 'expiry',
51-
data: {
52-
timestamp: expiry,
53-
},
54-
},
55-
]
56-
: [];
47+
const rules: Rule[] = [];
48+
if (isDefined(expiry)) {
49+
rules.push({
50+
type: 'expiry',
51+
data: {
52+
timestamp: expiry,
53+
},
54+
});
55+
}
56+
if (isDefined(redeemer)) {
57+
if (redeemer.length === 0) {
58+
throw new Error(
59+
'Invalid redeemers: must specify at least one redeemer address',
60+
);
61+
}
62+
const addresses: Hex[] = [];
63+
for (const addr of redeemer) {
64+
if (!isAddress(addr)) {
65+
throw new Error('Invalid redeemers: must be a valid address');
66+
}
67+
addresses.push(getAddress(addr));
68+
}
69+
rules.push({
70+
type: 'redeemer',
71+
data: { addresses },
72+
});
73+
}
5774

5875
const optionalFields = {
5976
...(from ? { from } : {}),
@@ -313,9 +330,40 @@ export function permissionResponsesFromRpc(
313330
...permission,
314331
chainId: hexToNumber(permission.chainId),
315332
permission: permissionTypeFromRpc(permission.permission),
333+
rules: normalizeRulesFromRpc(permission.rules),
316334
}));
317335
}
318336

337+
/**
338+
* Checksums addresses in `redeemer` rules; other rules are returned unchanged.
339+
*
340+
* @param rules - Rules from the wallet RPC response.
341+
* @returns The same list with normalized redeemer addresses, or null/undefined if that was the input.
342+
*/
343+
function normalizeRulesFromRpc(
344+
rules: Rule[] | null | undefined,
345+
): Rule[] | null | undefined {
346+
if (rules === undefined || rules === null) {
347+
return rules;
348+
}
349+
return rules.map((rule) => {
350+
if (rule.type !== 'redeemer') {
351+
return rule;
352+
}
353+
const rawAddresses = (rule.data as { addresses?: unknown } | undefined)
354+
?.addresses;
355+
if (!Array.isArray(rawAddresses)) {
356+
return rule;
357+
}
358+
return {
359+
type: 'redeemer',
360+
data: {
361+
addresses: rawAddresses.map((addr) => getAddress(addr as Hex)),
362+
},
363+
};
364+
});
365+
}
366+
319367
/**
320368
* Converts RPC permission type data to developer-friendly types.
321369
* Converts hex amount fields to bigint.

packages/smart-accounts-kit/src/actions/erc7715Types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
PermissionTypes as RpcPermissionTypes,
33
PermissionRequest as RpcPermissionRequest,
44
PermissionResponse as RpcPermissionResponse,
5+
Rule,
56
} from '@metamask/7715-permission-types';
67
import type {
78
Client,
@@ -13,6 +14,8 @@ import type {
1314
Address,
1415
} from 'viem';
1516

17+
export type { Rule };
18+
1619
// =============================================================================
1720
// Developer-facing types
1821
// These types represent the public API. Use bigint, number, Hex, and Address for
@@ -111,6 +114,10 @@ export type PermissionRequestParameter = {
111114
to: Hex;
112115
from?: Address | undefined | null;
113116
expiry?: number | undefined | null;
117+
/**
118+
* When set, adds a `redeemer` execution rule: only these addresses may redeem the permission.
119+
*/
120+
redeemer?: readonly Address[] | undefined | null;
114121
};
115122

116123
/**
@@ -143,7 +150,7 @@ export type PermissionRequest<TPermission extends PermissionTypes> = {
143150
from?: Hex;
144151
to: Hex;
145152
permission: TPermission;
146-
rules?: Record<string, unknown>[] | null;
153+
rules?: Rule[] | null;
147154
};
148155

149156
/**

packages/smart-accounts-kit/src/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export { erc7715GetGrantedExecutionPermissionsAction as getGrantedExecutionPermi
6767
export {
6868
type GetSupportedExecutionPermissionsResult,
6969
type GetGrantedExecutionPermissionsResult,
70+
type Rule,
7071
type SupportedPermissionInfo,
7172
type PermissionTypes,
7273
type NativeTokenStreamPermission,

packages/smart-accounts-kit/test/actions/erc7715GetGrantedExecutionPermissionsAction.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
} from '@metamask/7715-permission-types';
55
import { stub } from 'sinon';
66
import type { Client } from 'viem';
7-
import { createClient, custom } from 'viem';
7+
import { createClient, custom, getAddress } from 'viem';
88
import { beforeEach, describe, expect, it } from 'vitest';
99

1010
import {
@@ -79,6 +79,37 @@ describe('erc7715GetGrantedExecutionPermissionsAction', () => {
7979
]);
8080
});
8181

82+
it('checksum-normalizes redeemer addresses in rules', async () => {
83+
const responseWithRedeemer: RpcGetGrantedExecutionPermissionsResult = [
84+
{
85+
...mockPermission,
86+
rules: [
87+
{
88+
type: 'redeemer',
89+
data: {
90+
addresses: ['0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'],
91+
},
92+
},
93+
],
94+
},
95+
];
96+
stubRequest.resolves(responseWithRedeemer);
97+
98+
const result =
99+
await erc7715GetGrantedExecutionPermissionsAction(mockClient);
100+
101+
expect(result[0]?.rules).to.deep.equal([
102+
{
103+
type: 'redeemer',
104+
data: {
105+
addresses: [
106+
getAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'),
107+
],
108+
},
109+
},
110+
]);
111+
});
112+
82113
it('should set retryCount to 0', async () => {
83114
stubRequest.resolves(mockResponse);
84115

packages/smart-accounts-kit/test/actions/erc7715Mapping.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Hex } from 'viem';
2+
import { getAddress } from 'viem';
23
import { describe, expect, it } from 'vitest';
34

45
import {
@@ -7,7 +8,10 @@ import {
78
permissionTypeFromRpc,
89
rpcSupportedPermissionsToDeveloper,
910
} from '../../src/actions/erc7715Mapping';
10-
import type { RpcGetSupportedExecutionPermissionsResult } from '../../src/actions/erc7715Types';
11+
import type {
12+
RpcGetGrantedExecutionPermissionsResult,
13+
RpcGetSupportedExecutionPermissionsResult,
14+
} from '../../src/actions/erc7715Types';
1115

1216
describe('erc7715Mapping', () => {
1317
const basePermissionFields = {
@@ -179,6 +183,45 @@ describe('erc7715Mapping', () => {
179183
},
180184
});
181185
});
186+
187+
it('checksum-normalizes redeemer rule addresses', () => {
188+
const rpcPermissions = [
189+
{
190+
...basePermissionFields,
191+
rules: [
192+
{
193+
type: 'redeemer',
194+
data: {
195+
addresses: ['0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'],
196+
},
197+
},
198+
],
199+
permission: {
200+
type: 'native-token-stream',
201+
isAdjustmentAllowed: true,
202+
data: {
203+
amountPerSecond: '0x64',
204+
startTime: 1700000000,
205+
},
206+
},
207+
},
208+
];
209+
210+
const result = permissionResponsesFromRpc(
211+
rpcPermissions as RpcGetGrantedExecutionPermissionsResult,
212+
);
213+
214+
expect(result[0]?.rules).toStrictEqual([
215+
{
216+
type: 'redeemer',
217+
data: {
218+
addresses: [
219+
getAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'),
220+
],
221+
},
222+
},
223+
]);
224+
});
182225
});
183226

184227
describe('rpcSupportedPermissionsToDeveloper', () => {
@@ -261,6 +304,97 @@ describe('erc7715Mapping', () => {
261304
});
262305
});
263306

307+
it('adds redeemer rule with checksummed addresses', () => {
308+
const permissionRequest = {
309+
chainId: 1,
310+
permission: {
311+
type: 'native-token-stream',
312+
data: { amountPerSecond: 0x1n },
313+
isAdjustmentAllowed: false,
314+
},
315+
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
316+
redeemer: ['0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'],
317+
} as const;
318+
319+
const result = permissionRequestToRpc(permissionRequest);
320+
321+
expect(result.rules).toStrictEqual([
322+
{
323+
type: 'redeemer',
324+
data: {
325+
addresses: [
326+
getAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'),
327+
],
328+
},
329+
},
330+
]);
331+
});
332+
333+
it('adds expiry then redeemer when both are set', () => {
334+
const permissionRequest = {
335+
chainId: 1,
336+
permission: {
337+
type: 'native-token-stream',
338+
data: { amountPerSecond: 0x1n },
339+
isAdjustmentAllowed: false,
340+
},
341+
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
342+
expiry: 1234567890,
343+
redeemer: ['0x1111111111111111111111111111111111111111'],
344+
} as const;
345+
346+
const result = permissionRequestToRpc(permissionRequest);
347+
348+
expect(result.rules).toStrictEqual([
349+
{
350+
type: 'expiry',
351+
data: { timestamp: 1234567890 },
352+
},
353+
{
354+
type: 'redeemer',
355+
data: {
356+
addresses: [
357+
getAddress('0x1111111111111111111111111111111111111111'),
358+
],
359+
},
360+
},
361+
]);
362+
});
363+
364+
it('throws when redeemer is empty', () => {
365+
const permissionRequest = {
366+
chainId: 1,
367+
permission: {
368+
type: 'native-token-stream',
369+
data: { amountPerSecond: 0x1n },
370+
isAdjustmentAllowed: false,
371+
},
372+
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
373+
redeemer: [],
374+
} as const;
375+
376+
expect(() => permissionRequestToRpc(permissionRequest)).toThrow(
377+
'Invalid redeemers: must specify at least one redeemer address',
378+
);
379+
});
380+
381+
it('throws when redeemer contains invalid address', () => {
382+
const permissionRequest = {
383+
chainId: 1,
384+
permission: {
385+
type: 'native-token-stream',
386+
data: { amountPerSecond: 0x1n },
387+
isAdjustmentAllowed: false,
388+
},
389+
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
390+
redeemer: ['0x1234'],
391+
} as const;
392+
393+
expect(() => permissionRequestToRpc(permissionRequest)).toThrow(
394+
'Invalid redeemers: must be a valid address',
395+
);
396+
});
397+
264398
it('converts native-token-periodic: bigint → hex', () => {
265399
const permissionRequest = {
266400
chainId: 1,

0 commit comments

Comments
 (0)