Skip to content

Commit

Permalink
Adds signAuthorization method for EIP-7702 (#407)
Browse files Browse the repository at this point in the history
* Add methods for signing, hashing, and recovering authorizations, as per 7702

* Add signAuthorization components to index.ts and index.test.ts

* Fix linting errors

* Remove incorrect note from comment.

* Renamed a couple of authorization symbols to explicitly be EIP-7702, shuffled non-exported members to the bottom of the file, and renamed a few test constants to aid readability. Also used it.each for multiple test cases.
  • Loading branch information
jeffsmale90 authored Jan 26, 2025
1 parent 8509472 commit 0f3013b
Show file tree
Hide file tree
Showing 6 changed files with 377 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@ethereumjs/rlp": "^4.0.1",
"@ethereumjs/util": "^8.1.0",
"@metamask/abi-utils": "^3.0.0",
"@metamask/utils": "^11.0.1",
Expand Down
3 changes: 3 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Array [
"decrypt",
"decryptSafely",
"getEncryptionPublicKey",
"signEIP7702Authorization",
"recoverEIP7702Authorization",
"hashEIP7702Authorization",
]
`);
});
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './personal-sign';
export * from './sign-typed-data';
export * from './encryption';
export * from './sign-eip7702-authorization';
export { concatSig, normalize } from './utils';
242 changes: 242 additions & 0 deletions src/sign-eip7702-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { bufferToHex, privateToAddress } from '@ethereumjs/util';

import {
signEIP7702Authorization,
recoverEIP7702Authorization,
EIP7702Authorization,
hashEIP7702Authorization,
} from './sign-eip7702-authorization';

const TEST_PRIVATE_KEY = Buffer.from(
'4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3418bb0',
'hex',
);

const TEST_ADDRESS = bufferToHex(privateToAddress(TEST_PRIVATE_KEY));

const TEST_AUTHORIZATION: EIP7702Authorization = [
8545,
'0x1234567890123456789012345678901234567890',
1,
];

const EXPECTED_AUTHORIZATION_HASH = Buffer.from(
'b847dee5b33802280f3279d57574e1eb6bf5d628d7f63049e3cb20bad211056c',
'hex',
);

const EXPECTED_SIGNATURE =
'0xebea1ac12f17a56a514dfecbcbc8bbee7b089fa3fcee31680d1e2c1588f623df7973cab74e12536678995377da38c96c65c52897750b73462c6760ef2737dba41b';

describe('signAuthorization', () => {
describe('signAuthorization()', () => {
it('should produce the correct signature', () => {
const signature = signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: TEST_AUTHORIZATION,
});

expect(signature).toBe(EXPECTED_SIGNATURE);
});

it('should throw if private key is null', () => {
expect(() =>
signEIP7702Authorization({
privateKey: null as any,
authorization: TEST_AUTHORIZATION,
}),
).toThrow('Missing privateKey parameter');
});

it('should throw if private key is undefined', () => {
expect(() =>
signEIP7702Authorization({
privateKey: undefined as any,
authorization: TEST_AUTHORIZATION,
}),
).toThrow('Missing privateKey parameter');
});

it('should throw if authorization is null', () => {
expect(() =>
signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: null as any,
}),
).toThrow('Missing authorization parameter');
});

it('should throw if authorization is undefined', () => {
expect(() =>
signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: undefined as any,
}),
).toThrow('Missing authorization parameter');
});

it('should throw if chainId is null', () => {
expect(() =>
signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: [
null as unknown as number,
TEST_AUTHORIZATION[1],
TEST_AUTHORIZATION[2],
],
}),
).toThrow('Missing chainId parameter');
});

it('should throw if contractAddress is null', () => {
expect(() =>
signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: [
TEST_AUTHORIZATION[0],
null as unknown as string,
TEST_AUTHORIZATION[2],
],
}),
).toThrow('Missing contractAddress parameter');
});

it('should throw if nonce is null', () => {
expect(() =>
signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization: [
TEST_AUTHORIZATION[0],
TEST_AUTHORIZATION[1],
null as unknown as number,
],
}),
).toThrow('Missing nonce parameter');
});
});

describe('hashAuthorization()', () => {
it('should produce the correct hash', () => {
const hash = hashEIP7702Authorization(TEST_AUTHORIZATION);

expect(hash).toStrictEqual(EXPECTED_AUTHORIZATION_HASH);
});

it('should throw if authorization is null', () => {
expect(() =>
hashEIP7702Authorization(null as unknown as EIP7702Authorization),
).toThrow('Missing authorization parameter');
});

it('should throw if authorization is undefined', () => {
expect(() =>
hashEIP7702Authorization(undefined as unknown as EIP7702Authorization),
).toThrow('Missing authorization parameter');
});

it('should throw if chainId is null', () => {
expect(() =>
hashEIP7702Authorization([
null as unknown as number,
TEST_AUTHORIZATION[1],
TEST_AUTHORIZATION[2],
]),
).toThrow('Missing chainId parameter');
});

it('should throw if contractAddress is null', () => {
expect(() =>
hashEIP7702Authorization([
TEST_AUTHORIZATION[0],
null as unknown as string,
TEST_AUTHORIZATION[2],
]),
).toThrow('Missing contractAddress parameter');
});

it('should throw if nonce is null', () => {
expect(() =>
hashEIP7702Authorization([
TEST_AUTHORIZATION[0],
TEST_AUTHORIZATION[1],
null as unknown as number,
]),
).toThrow('Missing nonce parameter');
});
});

describe('recoverAuthorization()', () => {
it('should recover the address from a signature', () => {
const recoveredAddress = recoverEIP7702Authorization({
authorization: TEST_AUTHORIZATION,
signature: EXPECTED_SIGNATURE,
});

expect(recoveredAddress).toBe(TEST_ADDRESS);
});

it('should throw if signature is null', () => {
expect(() =>
recoverEIP7702Authorization({
signature: null as unknown as string,
authorization: TEST_AUTHORIZATION,
}),
).toThrow('Missing signature parameter');
});

it('should throw if signature is undefined', () => {
expect(() =>
recoverEIP7702Authorization({
signature: undefined as unknown as string,
authorization: TEST_AUTHORIZATION,
}),
).toThrow('Missing signature parameter');
});

it('should throw if authorization is null', () => {
expect(() =>
recoverEIP7702Authorization({
signature: EXPECTED_SIGNATURE,
authorization: null as unknown as EIP7702Authorization,
}),
).toThrow('Missing authorization parameter');
});

it('should throw if authorization is undefined', () => {
expect(() =>
recoverEIP7702Authorization({
signature: EXPECTED_SIGNATURE,
authorization: undefined as unknown as EIP7702Authorization,
}),
).toThrow('Missing authorization parameter');
});
});

describe('sign-and-recover', () => {
const testCases = {
zeroChainId: [0, '0x1234567890123456789012345678901234567890', 1],
highChainId: [98765, '0x1234567890123456789012345678901234567890', 1],
zeroNonce: [8545, '0x1234567890123456789012345678901234567890', 0],
highNonce: [8545, '0x1234567890123456789012345678901234567890', 98765],
zeroContractAddress: [1, '0x0000000000000000000000000000000000000000', 1],
allZeroValues: [0, '0x0000000000000000000000000000000000000000', 0],
} as { [key: string]: EIP7702Authorization };

it.each(Object.entries(testCases))(
'should sign and recover %s',
(_, authorization) => {
const signature = signEIP7702Authorization({
privateKey: TEST_PRIVATE_KEY,
authorization,
});

const recoveredAddress = recoverEIP7702Authorization({
authorization,
signature,
});

expect(recoveredAddress).toBe(TEST_ADDRESS);
},
);
});
});
129 changes: 129 additions & 0 deletions src/sign-eip7702-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { encode } from '@ethereumjs/rlp';
import { ecsign, publicToAddress, toBuffer } from '@ethereumjs/util';
import { bytesToHex } from '@metamask/utils';
import { keccak256 } from 'ethereum-cryptography/keccak';

import { concatSig, isNullish, recoverPublicKey } from './utils';

/**
* The authorization struct as defined in EIP-7702.
*
* @property chainId - The chain ID or 0 for any chain.
* @property contractAddress - The address of the contract being authorized.
* @property nonce - The nonce of the signing account (at the time of submission).
*/
export type EIP7702Authorization = [
chainId: number,
contractAddress: string,
nonce: number,
];

/**
* Sign an authorization message with the provided private key.
*
* @param options - The signing options.
* @param options.privateKey - The private key to sign with.
* @param options.authorization - The authorization data to sign.
* @returns The '0x'-prefixed hex encoded signature.
*/
export function signEIP7702Authorization({
privateKey,
authorization,
}: {
privateKey: Buffer;
authorization: EIP7702Authorization;
}): string {
validateEIP7702Authorization(authorization);

if (isNullish(privateKey)) {
throw new Error('Missing privateKey parameter');
}

const messageHash = hashEIP7702Authorization(authorization);

const { r, s, v } = ecsign(messageHash, privateKey);

// v is either 27n or 28n so is guaranteed to be a single byte
const vBuffer = toBuffer(v);

return concatSig(vBuffer, r, s);
}

/**
* Recover the address of the account that created the given authorization
* signature.
*
* @param options - The signature recovery options.
* @param options.signature - The '0x'-prefixed hex encoded message signature.
* @param options.authorization - The authorization data that was signed.
* @returns The '0x'-prefixed hex address of the signer.
*/
export function recoverEIP7702Authorization({
signature,
authorization,
}: {
signature: string;
authorization: EIP7702Authorization;
}): string {
validateEIP7702Authorization(authorization);

if (isNullish(signature)) {
throw new Error('Missing signature parameter');
}

const messageHash = hashEIP7702Authorization(authorization);

const publicKey = recoverPublicKey(messageHash, signature);

const sender = publicToAddress(publicKey);

return bytesToHex(sender);
}

/**
* Hash an authorization message according to the signing scheme.
* The message is encoded according to EIP-7702.
*
* @param authorization - The authorization data to hash.
* @returns The hash of the authorization message as a Buffer.
*/
export function hashEIP7702Authorization(
authorization: EIP7702Authorization,
): Buffer {
validateEIP7702Authorization(authorization);

const encodedAuthorization = encode(authorization);

const message = Buffer.concat([
Buffer.from('05', 'hex'),
encodedAuthorization,
]);

return Buffer.from(keccak256(message));
}

/**
* Validates an authorization object to ensure all required parameters are present.
*
* @param authorization - The authorization object to validate.
* @throws {Error} If the authorization object or any of its required parameters are missing.
*/
function validateEIP7702Authorization(authorization: EIP7702Authorization) {
if (isNullish(authorization)) {
throw new Error('Missing authorization parameter');
}

const [chainId, contractAddress, nonce] = authorization;

if (isNullish(chainId)) {
throw new Error('Missing chainId parameter');
}

if (isNullish(contractAddress)) {
throw new Error('Missing contractAddress parameter');
}

if (isNullish(nonce)) {
throw new Error('Missing nonce parameter');
}
}
Loading

0 comments on commit 0f3013b

Please sign in to comment.