Skip to content
Merged
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 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
Loading