-
-
Notifications
You must be signed in to change notification settings - Fork 229
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds
signAuthorization
method for EIP-7702 (#407)
* 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
1 parent
8509472
commit 0f3013b
Showing
6 changed files
with
377 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}, | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
Oops, something went wrong.