diff --git a/package.json b/package.json index 3728aebf..4c4385e6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.test.ts b/src/index.test.ts index 60f8f417..cc43e536 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,6 +21,9 @@ Array [ "decrypt", "decryptSafely", "getEncryptionPublicKey", + "signEIP7702Authorization", + "recoverEIP7702Authorization", + "hashEIP7702Authorization", ] `); }); diff --git a/src/index.ts b/src/index.ts index 48ddf59b..e1dcb4f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/sign-eip7702-authorization.test.ts b/src/sign-eip7702-authorization.test.ts new file mode 100644 index 00000000..3c062197 --- /dev/null +++ b/src/sign-eip7702-authorization.test.ts @@ -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); + }, + ); + }); +}); diff --git a/src/sign-eip7702-authorization.ts b/src/sign-eip7702-authorization.ts new file mode 100644 index 00000000..b73f3b3d --- /dev/null +++ b/src/sign-eip7702-authorization.ts @@ -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'); + } +} diff --git a/yarn.lock b/yarn.lock index 1ce70e50..da56d317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,6 +894,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/eth-sig-util@workspace:." dependencies: + "@ethereumjs/rlp": ^4.0.1 "@ethereumjs/util": ^8.1.0 "@lavamoat/allow-scripts": ^2.3.1 "@metamask/abi-utils": ^3.0.0