Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this needs to accept ArrayBuffer, we should consider how we want to validate that - should we throw if the value is a zero-length ArrayBuffer, or should we consider that 0x00 - "0x" has a specific meaning (null) which I don't think zero-length ArrayBuffer does?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, please take a look


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to accept ArrayBuffer also, as that's included in the BytesLike type. The intention is the caller can decide whether to use ArrayBuffer or string - ideally we would perform the operation in whatever data type was specified - unless there's performance / overhead benefits to doing type conversion.

We could either:

  • convert ArrayBuffer to string (and do the length fixing with the string)
  • convert string to ArrayBuffer (and do the length fixing with the ArrayBuffer)
  • add a length parameter to the prepareResult function, so that we can just pass the raw nonce after validation

Also, can we drop this check entirely, as line 61 does the same check, but also validates the "body" of the hex string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, please take a look


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we please add some tests for ArrayBuffer input ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, please take a look

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
Loading