Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/delegation-core/src/caveats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { createExactCalldataTerms } from './exactCalldata';
export { createNativeTokenStreamingTerms } from './nativeTokenStreaming';
export { createERC20StreamingTerms } from './erc20Streaming';
export { createERC20TokenPeriodTransferTerms } from './erc20TokenPeriodTransfer';
export { createNonceTerms } from './nonce';
75 changes: 75 additions & 0 deletions packages/delegation-core/src/caveats/nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { isHexString } from '@metamask/utils';
import type { BytesLike } from '@metamask/utils';

import {
defaultOptions,
prepareResult,
type EncodingOptions,
type ResultValue,
} from '../returns';
import type { Hex } from '../types';

// char length of 32 byte hex string (including 0x prefix)
const MAX_NONCE_STRING_LENGTH = 66;

/**
* Terms for configuring a Nonce caveat.
*/
export type NonceTerms = {
/** The nonce as a hex string to allow bulk revocation of delegations. */
nonce: BytesLike;
};

/**
* Creates terms for a Nonce caveat that uses a nonce value for bulk revocation of delegations.
*
* @param terms - The terms for the Nonce caveat.
* @param encodingOptions - The encoding options for the result.
* @returns The terms as a 32-byte hex string.
* @throws Error if the nonce is invalid.
*/
export function createNonceTerms(
terms: NonceTerms,
encodingOptions?: EncodingOptions<'hex'>,
): Hex;
export function createNonceTerms(
terms: NonceTerms,
encodingOptions: EncodingOptions<'bytes'>,
): Uint8Array;
/**
* Creates terms for a Nonce caveat that uses a nonce value for bulk revocation of delegations.
*
* @param terms - The terms for the Nonce caveat.
* @param encodingOptions - The encoding options for the result.
* @returns The terms as a 32-byte hex string.
* @throws Error if the nonce is invalid.
*/
export function createNonceTerms(
terms: NonceTerms,
encodingOptions: EncodingOptions<ResultValue> = defaultOptions,
): Hex | Uint8Array {
const { nonce } = terms;

if (!nonce || nonce === '0x') {
throw new Error('Invalid nonce: must be a non-empty hex string');
}

if (typeof nonce !== 'string' || !nonce.startsWith('0x')) {
throw new Error('Invalid nonce: must be a valid hex string');
}

if (!isHexString(nonce)) {
throw new Error('Invalid nonce: must be a valid hex string');
}

if (nonce.length > MAX_NONCE_STRING_LENGTH) {
throw new Error('Invalid nonce: must be 32 bytes or less in length');
}

// Remove '0x' prefix for padding, then add it back
const nonceWithoutPrefix = nonce.slice(2);
const paddedNonce = nonceWithoutPrefix.padStart(64, '0'); // 64 hex chars = 32 bytes
const hexValue = `0x${paddedNonce}`;

return prepareResult(hexValue, encodingOptions);
}
1 change: 1 addition & 0 deletions packages/delegation-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
createNativeTokenStreamingTerms,
createERC20StreamingTerms,
createERC20TokenPeriodTransferTerms,
createNonceTerms,
} from './caveats';

export {
Expand Down
204 changes: 204 additions & 0 deletions packages/delegation-core/test/caveats/nonce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';

import { createNonceTerms } from '../../src/caveats/nonce';

describe('createNonceTerms', () => {
const EXPECTED_BYTE_LENGTH = 32; // 32 bytes for nonce

it('creates valid terms for simple nonce', () => {
const nonce = '0x1234567890abcdef';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000001234567890abcdef',
);
});

it('creates valid terms for zero nonce', () => {
const nonce = '0x0';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000000000000000000000',
);
});

it('creates valid terms for minimal nonce', () => {
const nonce = '0x1';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000000000000000000001',
);
});

it('creates valid terms for full 32-byte nonce', () => {
const nonce =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(nonce);
});

it('creates valid terms for uppercase hex nonce', () => {
const nonce = '0x1234567890ABCDEF';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000001234567890ABCDEF',
);
});

it('creates valid terms for mixed case hex nonce', () => {
const nonce = '0x1234567890AbCdEf';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000001234567890AbCdEf',
);
});

it('pads shorter hex values with leading zeros', () => {
const nonce = '0xff';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x00000000000000000000000000000000000000000000000000000000000000ff',
);
});

it('throws an error for empty nonce', () => {
const nonce = '0x';

expect(() => createNonceTerms({ nonce })).toThrow(
'Invalid nonce: must be a non-empty hex string',
);
});

it('throws an error for undefined nonce', () => {
expect(() => createNonceTerms({ nonce: undefined as any })).toThrow(
'Invalid nonce: must be a non-empty hex string',
);
});

it('throws an error for null nonce', () => {
expect(() => createNonceTerms({ nonce: null as any })).toThrow(
'Invalid nonce: must be a non-empty hex string',
);
});

it('throws an error for nonce without 0x prefix', () => {
const nonce = '1234567890abcdef' as any;

expect(() => createNonceTerms({ nonce })).toThrow(
'Invalid nonce: must be a valid hex string',
);
});

it('throws an error for invalid hex characters', () => {
const nonce = '0x1234567890abcdefg' as any;

expect(() => createNonceTerms({ nonce })).toThrow(
'Invalid nonce: must be a valid hex string',
);
});

it('throws an error for non-string nonce', () => {
const nonce = 123456 as any;

expect(() => createNonceTerms({ nonce })).toThrow(
'Invalid nonce: must be a valid hex string',
);
});

it('throws an error for nonce longer than 32 bytes', () => {
// 33 bytes (66 hex chars + 0x prefix = 68 chars total, which exceeds 66)
const nonce =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12' as any;

expect(() => createNonceTerms({ nonce })).toThrow(
'Invalid nonce: must be 32 bytes or less in length',
);
});

it('accepts nonce with exactly 32 bytes', () => {
// 32 bytes (64 hex chars + 0x prefix = 66 chars total)
const nonce =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(nonce);
});

it('throws an error for string that looks like hex but has odd length', () => {
const nonce = '0x123' as any;
// This should still work as we pad it
const result = createNonceTerms({ nonce });

expect(result).toStrictEqual(
'0x0000000000000000000000000000000000000000000000000000000000000123',
);
});

// Tests for bytes return type
describe('bytes return type', () => {
it('returns Uint8Array when bytes encoding is specified', () => {
const nonce = '0x1234567890abcdef';
const result = createNonceTerms({ nonce }, { out: 'bytes' });

expect(result).toBeInstanceOf(Uint8Array);
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
});

it('returns Uint8Array for minimal nonce with bytes encoding', () => {
const nonce = '0x1';
const result = createNonceTerms({ nonce }, { out: 'bytes' });

expect(result).toBeInstanceOf(Uint8Array);
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
// Should be 31 zeros followed by 1
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
expectedBytes[EXPECTED_BYTE_LENGTH - 1] = 1;
expect(Array.from(result)).toEqual(expectedBytes);
});

it('returns Uint8Array for zero nonce with bytes encoding', () => {
const nonce = '0x0';
const result = createNonceTerms({ nonce }, { out: 'bytes' });

expect(result).toBeInstanceOf(Uint8Array);
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
// Should be all zeros
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
expect(Array.from(result)).toEqual(expectedBytes);
});

it('returns Uint8Array for full nonce with bytes encoding', () => {
const nonce =
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const result = createNonceTerms({ nonce }, { out: 'bytes' });

expect(result).toBeInstanceOf(Uint8Array);
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
// Convert expected hex to bytes for comparison
const expectedBytes = [
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78,
0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef,
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef,
];
expect(Array.from(result)).toEqual(expectedBytes);
});

it('returns Uint8Array for padded hex values with bytes encoding', () => {
const nonce = '0xff';
const result = createNonceTerms({ nonce }, { out: 'bytes' });

expect(result).toBeInstanceOf(Uint8Array);
expect(result).toHaveLength(EXPECTED_BYTE_LENGTH);
// Should be 31 zeros followed by 0xff
const expectedBytes = new Array(EXPECTED_BYTE_LENGTH).fill(0);
expectedBytes[EXPECTED_BYTE_LENGTH - 1] = 0xff;
expect(Array.from(result)).toEqual(expectedBytes);
});
});
});
20 changes: 3 additions & 17 deletions packages/delegation-toolkit/src/caveatBuilder/nonceBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { type Hex, isHex, pad } from 'viem';
import { createNonceTerms } from '@metamask/delegation-core';
import { type Hex } from 'viem';

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

export const nonce = 'nonce';

// char length of 32 byte hex string
const MAX_NONCE_STRING_LENGTH = 66;

export type NonceBuilderConfig = {
/**
* A nonce as a hex string to allow bulk revocation of delegations.
Expand All @@ -28,19 +26,7 @@ export const nonceBuilder = (
): Caveat => {
const { nonce: nonceValue } = config;

if (!nonceValue || nonceValue === '0x') {
throw new Error('Invalid nonce: must be a non-empty hex string');
}

if (!isHex(nonceValue)) {
throw new Error('Invalid nonce: must be a valid hex string');
}

if (nonceValue.length > MAX_NONCE_STRING_LENGTH) {
throw new Error('Invalid nonce: must be 32 bytes or less in length');
}

const terms = pad(nonceValue, { size: 32 });
const terms = createNonceTerms({ nonce: nonceValue });

const {
caveatEnforcers: { NonceEnforcer },
Expand Down
3 changes: 1 addition & 2 deletions packages/delegator-e2e/test/caveats/allowedCalldata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
CounterContract,
randomBytes,
publicClient,
stringToUnprefixedHex,
} from '../utils/helpers';
import { encodeFunctionData, Hex, hexToBigInt, slice } from 'viem';
import { expectUserOperationToSucceed } from '../utils/assertions';
Expand Down Expand Up @@ -248,7 +247,7 @@ const runTest_expectFailure = async (
],
...gasPrice,
}),
).rejects.toThrow(stringToUnprefixedHex(expectedError));
).rejects.toThrow(expectedError);

const counterAfter = await aliceCounter.read.count?.();
expect(counterAfter, 'Expected count to remain 0n').toEqual(0n);
Expand Down
3 changes: 1 addition & 2 deletions packages/delegator-e2e/test/caveats/allowedMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
deployCounter,
CounterContract,
publicClient,
stringToUnprefixedHex,
} from '../utils/helpers';
import { encodeFunctionData, AbiFunction, Hex, toFunctionSelector } from 'viem';
import { expectUserOperationToSucceed } from '../utils/assertions';
Expand Down Expand Up @@ -242,7 +241,7 @@ const runTest_expectFailure = async (
],
...gasPrice,
}),
).rejects.toThrow(stringToUnprefixedHex(expectedError));
).rejects.toThrow(expectedError);

const counterAfter = await aliceCounter.read.count();
expect(counterAfter, 'Expected count to remain 0n').toEqual(0n);
Expand Down
3 changes: 1 addition & 2 deletions packages/delegator-e2e/test/caveats/allowedTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
CounterContract,
randomBytes,
publicClient,
stringToUnprefixedHex,
} from '../utils/helpers';
import { encodeFunctionData, Hex } from 'viem';
import { expectUserOperationToSucceed } from '../utils/assertions';
Expand Down Expand Up @@ -218,7 +217,7 @@ const runTest_expectFailure = async (
],
...gasPrice,
}),
).rejects.toThrow(stringToUnprefixedHex(expectedError));
).rejects.toThrow(expectedError);

const counterAfter = await aliceCounter.read.count();
expect(counterAfter, 'Expected count to remain 0n').toEqual(0n);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
CounterContract,
randomBytes,
publicClient,
stringToUnprefixedHex,
} from '../utils/helpers';
import { encodeFunctionData, Hex, concat, slice } from 'viem';
import { expectUserOperationToSucceed } from '../utils/assertions';
Expand Down Expand Up @@ -246,7 +245,7 @@ const runTest_expectFailure = async (
],
...gasPrice,
}),
).rejects.toThrow(stringToUnprefixedHex(expectedError));
).rejects.toThrow(expectedError);

const counterAfter = await aliceCounter.read.count();
expect(counterAfter, 'Expected count to remain 0n').toEqual(0n);
Expand Down
Loading
Loading