Skip to content

Commit ffb21fc

Browse files
committed
feat: added nonce enforcer as caveat in delegation core
1 parent 9c62033 commit ffb21fc

5 files changed

Lines changed: 284 additions & 17 deletions

File tree

packages/delegation-core/src/caveats/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { createExactCalldataTerms } from './exactCalldata';
55
export { createNativeTokenStreamingTerms } from './nativeTokenStreaming';
66
export { createERC20StreamingTerms } from './erc20Streaming';
77
export { createERC20TokenPeriodTransferTerms } from './erc20TokenPeriodTransfer';
8+
export { createNonceTerms } from './nonce';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { isHexString } from '@metamask/utils';
2+
import type { BytesLike } from '@metamask/utils';
3+
4+
import {
5+
defaultOptions,
6+
prepareResult,
7+
type EncodingOptions,
8+
type ResultValue,
9+
} from '../returns';
10+
import type { Hex } from '../types';
11+
12+
// char length of 32 byte hex string (including 0x prefix)
13+
const MAX_NONCE_STRING_LENGTH = 66;
14+
15+
/**
16+
* Terms for configuring a Nonce caveat.
17+
*/
18+
export type NonceTerms = {
19+
/** The nonce as a hex string to allow bulk revocation of delegations. */
20+
nonce: BytesLike;
21+
};
22+
23+
/**
24+
* Creates terms for a Nonce caveat that uses a nonce value for bulk revocation of delegations.
25+
*
26+
* @param terms - The terms for the Nonce caveat.
27+
* @param encodingOptions - The encoding options for the result.
28+
* @returns The terms as a 32-byte hex string.
29+
* @throws Error if the nonce is invalid.
30+
*/
31+
export function createNonceTerms(
32+
terms: NonceTerms,
33+
encodingOptions?: EncodingOptions<'hex'>,
34+
): Hex;
35+
export function createNonceTerms(
36+
terms: NonceTerms,
37+
encodingOptions: EncodingOptions<'bytes'>,
38+
): Uint8Array;
39+
/**
40+
* Creates terms for a Nonce caveat that uses a nonce value for bulk revocation of delegations.
41+
*
42+
* @param terms - The terms for the Nonce caveat.
43+
* @param encodingOptions - The encoding options for the result.
44+
* @returns The terms as a 32-byte hex string.
45+
* @throws Error if the nonce is invalid.
46+
*/
47+
export function createNonceTerms(
48+
terms: NonceTerms,
49+
encodingOptions: EncodingOptions<ResultValue> = defaultOptions,
50+
): Hex | Uint8Array {
51+
const { nonce } = terms;
52+
53+
if (!nonce || nonce === '0x') {
54+
throw new Error('Invalid nonce: must be a non-empty hex string');
55+
}
56+
57+
if (typeof nonce !== 'string' || !nonce.startsWith('0x')) {
58+
throw new Error('Invalid nonce: must be a valid hex string');
59+
}
60+
61+
if (!isHexString(nonce)) {
62+
throw new Error('Invalid nonce: must be a valid hex string');
63+
}
64+
65+
if (nonce.length > MAX_NONCE_STRING_LENGTH) {
66+
throw new Error('Invalid nonce: must be 32 bytes or less in length');
67+
}
68+
69+
// Remove '0x' prefix for padding, then add it back
70+
const nonceWithoutPrefix = nonce.slice(2);
71+
const paddedNonce = nonceWithoutPrefix.padStart(64, '0'); // 64 hex chars = 32 bytes
72+
const hexValue = `0x${paddedNonce}`;
73+
74+
return prepareResult(hexValue, encodingOptions);
75+
}

packages/delegation-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
createNativeTokenStreamingTerms,
1313
createERC20StreamingTerms,
1414
createERC20TokenPeriodTransferTerms,
15+
createNonceTerms,
1516
} from './caveats';
1617

1718
export {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { createNonceTerms } from '../../src/caveats/nonce';
4+
5+
describe('createNonceTerms', () => {
6+
const EXPECTED_BYTE_LENGTH = 32; // 32 bytes for nonce
7+
8+
it('creates valid terms for simple nonce', () => {
9+
const nonce = '0x1234567890abcdef';
10+
const result = createNonceTerms({ nonce });
11+
12+
expect(result).toStrictEqual(
13+
'0x0000000000000000000000000000000000000000000000001234567890abcdef',
14+
);
15+
});
16+
17+
it('creates valid terms for zero nonce', () => {
18+
const nonce = '0x0';
19+
const result = createNonceTerms({ nonce });
20+
21+
expect(result).toStrictEqual(
22+
'0x0000000000000000000000000000000000000000000000000000000000000000',
23+
);
24+
});
25+
26+
it('creates valid terms for minimal nonce', () => {
27+
const nonce = '0x1';
28+
const result = createNonceTerms({ nonce });
29+
30+
expect(result).toStrictEqual(
31+
'0x0000000000000000000000000000000000000000000000000000000000000001',
32+
);
33+
});
34+
35+
it('creates valid terms for full 32-byte nonce', () => {
36+
const nonce =
37+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
38+
const result = createNonceTerms({ nonce });
39+
40+
expect(result).toStrictEqual(nonce);
41+
});
42+
43+
it('creates valid terms for uppercase hex nonce', () => {
44+
const nonce = '0x1234567890ABCDEF';
45+
const result = createNonceTerms({ nonce });
46+
47+
expect(result).toStrictEqual(
48+
'0x0000000000000000000000000000000000000000000000001234567890ABCDEF',
49+
);
50+
});
51+
52+
it('creates valid terms for mixed case hex nonce', () => {
53+
const nonce = '0x1234567890AbCdEf';
54+
const result = createNonceTerms({ nonce });
55+
56+
expect(result).toStrictEqual(
57+
'0x0000000000000000000000000000000000000000000000001234567890AbCdEf',
58+
);
59+
});
60+
61+
it('pads shorter hex values with leading zeros', () => {
62+
const nonce = '0xff';
63+
const result = createNonceTerms({ nonce });
64+
65+
expect(result).toStrictEqual(
66+
'0x00000000000000000000000000000000000000000000000000000000000000ff',
67+
);
68+
});
69+
70+
it('throws an error for empty nonce', () => {
71+
const nonce = '0x';
72+
73+
expect(() => createNonceTerms({ nonce })).toThrow(
74+
'Invalid nonce: must be a non-empty hex string',
75+
);
76+
});
77+
78+
it('throws an error for undefined nonce', () => {
79+
expect(() => createNonceTerms({ nonce: undefined as any })).toThrow(
80+
'Invalid nonce: must be a non-empty hex string',
81+
);
82+
});
83+
84+
it('throws an error for null nonce', () => {
85+
expect(() => createNonceTerms({ nonce: null as any })).toThrow(
86+
'Invalid nonce: must be a non-empty hex string',
87+
);
88+
});
89+
90+
it('throws an error for nonce without 0x prefix', () => {
91+
const nonce = '1234567890abcdef' as any;
92+
93+
expect(() => createNonceTerms({ nonce })).toThrow(
94+
'Invalid nonce: must be a valid hex string',
95+
);
96+
});
97+
98+
it('throws an error for invalid hex characters', () => {
99+
const nonce = '0x1234567890abcdefg' as any;
100+
101+
expect(() => createNonceTerms({ nonce })).toThrow(
102+
'Invalid nonce: must be a valid hex string',
103+
);
104+
});
105+
106+
it('throws an error for non-string nonce', () => {
107+
const nonce = 123456 as any;
108+
109+
expect(() => createNonceTerms({ nonce })).toThrow(
110+
'Invalid nonce: must be a valid hex string',
111+
);
112+
});
113+
114+
it('throws an error for nonce longer than 32 bytes', () => {
115+
// 33 bytes (66 hex chars + 0x prefix = 68 chars total, which exceeds 66)
116+
const nonce =
117+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12' as any;
118+
119+
expect(() => createNonceTerms({ nonce })).toThrow(
120+
'Invalid nonce: must be 32 bytes or less in length',
121+
);
122+
});
123+
124+
it('accepts nonce with exactly 32 bytes', () => {
125+
// 32 bytes (64 hex chars + 0x prefix = 66 chars total)
126+
const nonce =
127+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
128+
const result = createNonceTerms({ nonce });
129+
130+
expect(result).toStrictEqual(nonce);
131+
});
132+
133+
it('throws an error for string that looks like hex but has odd length', () => {
134+
const nonce = '0x123' as any;
135+
// This should still work as we pad it
136+
const result = createNonceTerms({ nonce });
137+
138+
expect(result).toStrictEqual(
139+
'0x0000000000000000000000000000000000000000000000000000000000000123',
140+
);
141+
});
142+
143+
// Tests for bytes return type
144+
describe('bytes return type', () => {
145+
it('returns Uint8Array when bytes encoding is specified', () => {
146+
const nonce = '0x1234567890abcdef';
147+
const result = createNonceTerms({ nonce }, { out: 'bytes' });
148+
149+
expect(result).toBeInstanceOf(Uint8Array);
150+
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
151+
});
152+
153+
it('returns Uint8Array for minimal nonce with bytes encoding', () => {
154+
const nonce = '0x1';
155+
const result = createNonceTerms({ nonce }, { out: 'bytes' });
156+
157+
expect(result).toBeInstanceOf(Uint8Array);
158+
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
159+
// Should be 31 zeros followed by 1
160+
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
161+
expectedBytes[EXPECTED_BYTE_LENGTH - 1] = 1;
162+
expect(Array.from(result)).toEqual(expectedBytes);
163+
});
164+
165+
it('returns Uint8Array for zero nonce with bytes encoding', () => {
166+
const nonce = '0x0';
167+
const result = createNonceTerms({ nonce }, { out: 'bytes' });
168+
169+
expect(result).toBeInstanceOf(Uint8Array);
170+
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
171+
// Should be all zeros
172+
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
173+
expect(Array.from(result)).toEqual(expectedBytes);
174+
});
175+
176+
it('returns Uint8Array for full nonce with bytes encoding', () => {
177+
const nonce =
178+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
179+
const result = createNonceTerms({ nonce }, { out: 'bytes' });
180+
181+
expect(result).toBeInstanceOf(Uint8Array);
182+
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
183+
// Convert expected hex to bytes for comparison
184+
const expectedBytes = [
185+
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78,
186+
0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef,
187+
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef,
188+
];
189+
expect(Array.from(result)).toEqual(expectedBytes);
190+
});
191+
192+
it('returns Uint8Array for padded hex values with bytes encoding', () => {
193+
const nonce = '0xff';
194+
const result = createNonceTerms({ nonce }, { out: 'bytes' });
195+
196+
expect(result).toBeInstanceOf(Uint8Array);
197+
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
198+
// Should be 31 zeros followed by 0xff
199+
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
200+
expectedBytes[EXPECTED_BYTE_LENGTH - 1] = 0xff;
201+
expect(Array.from(result)).toEqual(expectedBytes);
202+
});
203+
});
204+
});

packages/delegation-toolkit/src/caveatBuilder/nonceBuilder.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import { type Hex, isHex, pad } from 'viem';
1+
import { createNonceTerms } from '@metamask/delegation-core';
2+
import { type Hex } from 'viem';
23

34
import type { DeleGatorEnvironment, Caveat } from '../types';
45

56
export const nonce = 'nonce';
67

7-
// char length of 32 byte hex string
8-
const MAX_NONCE_STRING_LENGTH = 66;
9-
108
export type NonceBuilderConfig = {
119
/**
1210
* A nonce as a hex string to allow bulk revocation of delegations.
@@ -28,19 +26,7 @@ export const nonceBuilder = (
2826
): Caveat => {
2927
const { nonce: nonceValue } = config;
3028

31-
if (!nonceValue || nonceValue === '0x') {
32-
throw new Error('Invalid nonce: must be a non-empty hex string');
33-
}
34-
35-
if (!isHex(nonceValue)) {
36-
throw new Error('Invalid nonce: must be a valid hex string');
37-
}
38-
39-
if (nonceValue.length > MAX_NONCE_STRING_LENGTH) {
40-
throw new Error('Invalid nonce: must be 32 bytes or less in length');
41-
}
42-
43-
const terms = pad(nonceValue, { size: 32 });
29+
const terms = createNonceTerms({ nonce: nonceValue });
4430

4531
const {
4632
caveatEnforcers: { NonceEnforcer },

0 commit comments

Comments
 (0)