Skip to content

Commit 0f3013b

Browse files
authored
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.
1 parent 8509472 commit 0f3013b

File tree

6 files changed

+377
-0
lines changed

6 files changed

+377
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"test:watch": "jest --watch"
4545
},
4646
"dependencies": {
47+
"@ethereumjs/rlp": "^4.0.1",
4748
"@ethereumjs/util": "^8.1.0",
4849
"@metamask/abi-utils": "^3.0.0",
4950
"@metamask/utils": "^11.0.1",

src/index.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Array [
2121
"decrypt",
2222
"decryptSafely",
2323
"getEncryptionPublicKey",
24+
"signEIP7702Authorization",
25+
"recoverEIP7702Authorization",
26+
"hashEIP7702Authorization",
2427
]
2528
`);
2629
});

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './personal-sign';
22
export * from './sign-typed-data';
33
export * from './encryption';
4+
export * from './sign-eip7702-authorization';
45
export { concatSig, normalize } from './utils';
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { bufferToHex, privateToAddress } from '@ethereumjs/util';
2+
3+
import {
4+
signEIP7702Authorization,
5+
recoverEIP7702Authorization,
6+
EIP7702Authorization,
7+
hashEIP7702Authorization,
8+
} from './sign-eip7702-authorization';
9+
10+
const TEST_PRIVATE_KEY = Buffer.from(
11+
'4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3418bb0',
12+
'hex',
13+
);
14+
15+
const TEST_ADDRESS = bufferToHex(privateToAddress(TEST_PRIVATE_KEY));
16+
17+
const TEST_AUTHORIZATION: EIP7702Authorization = [
18+
8545,
19+
'0x1234567890123456789012345678901234567890',
20+
1,
21+
];
22+
23+
const EXPECTED_AUTHORIZATION_HASH = Buffer.from(
24+
'b847dee5b33802280f3279d57574e1eb6bf5d628d7f63049e3cb20bad211056c',
25+
'hex',
26+
);
27+
28+
const EXPECTED_SIGNATURE =
29+
'0xebea1ac12f17a56a514dfecbcbc8bbee7b089fa3fcee31680d1e2c1588f623df7973cab74e12536678995377da38c96c65c52897750b73462c6760ef2737dba41b';
30+
31+
describe('signAuthorization', () => {
32+
describe('signAuthorization()', () => {
33+
it('should produce the correct signature', () => {
34+
const signature = signEIP7702Authorization({
35+
privateKey: TEST_PRIVATE_KEY,
36+
authorization: TEST_AUTHORIZATION,
37+
});
38+
39+
expect(signature).toBe(EXPECTED_SIGNATURE);
40+
});
41+
42+
it('should throw if private key is null', () => {
43+
expect(() =>
44+
signEIP7702Authorization({
45+
privateKey: null as any,
46+
authorization: TEST_AUTHORIZATION,
47+
}),
48+
).toThrow('Missing privateKey parameter');
49+
});
50+
51+
it('should throw if private key is undefined', () => {
52+
expect(() =>
53+
signEIP7702Authorization({
54+
privateKey: undefined as any,
55+
authorization: TEST_AUTHORIZATION,
56+
}),
57+
).toThrow('Missing privateKey parameter');
58+
});
59+
60+
it('should throw if authorization is null', () => {
61+
expect(() =>
62+
signEIP7702Authorization({
63+
privateKey: TEST_PRIVATE_KEY,
64+
authorization: null as any,
65+
}),
66+
).toThrow('Missing authorization parameter');
67+
});
68+
69+
it('should throw if authorization is undefined', () => {
70+
expect(() =>
71+
signEIP7702Authorization({
72+
privateKey: TEST_PRIVATE_KEY,
73+
authorization: undefined as any,
74+
}),
75+
).toThrow('Missing authorization parameter');
76+
});
77+
78+
it('should throw if chainId is null', () => {
79+
expect(() =>
80+
signEIP7702Authorization({
81+
privateKey: TEST_PRIVATE_KEY,
82+
authorization: [
83+
null as unknown as number,
84+
TEST_AUTHORIZATION[1],
85+
TEST_AUTHORIZATION[2],
86+
],
87+
}),
88+
).toThrow('Missing chainId parameter');
89+
});
90+
91+
it('should throw if contractAddress is null', () => {
92+
expect(() =>
93+
signEIP7702Authorization({
94+
privateKey: TEST_PRIVATE_KEY,
95+
authorization: [
96+
TEST_AUTHORIZATION[0],
97+
null as unknown as string,
98+
TEST_AUTHORIZATION[2],
99+
],
100+
}),
101+
).toThrow('Missing contractAddress parameter');
102+
});
103+
104+
it('should throw if nonce is null', () => {
105+
expect(() =>
106+
signEIP7702Authorization({
107+
privateKey: TEST_PRIVATE_KEY,
108+
authorization: [
109+
TEST_AUTHORIZATION[0],
110+
TEST_AUTHORIZATION[1],
111+
null as unknown as number,
112+
],
113+
}),
114+
).toThrow('Missing nonce parameter');
115+
});
116+
});
117+
118+
describe('hashAuthorization()', () => {
119+
it('should produce the correct hash', () => {
120+
const hash = hashEIP7702Authorization(TEST_AUTHORIZATION);
121+
122+
expect(hash).toStrictEqual(EXPECTED_AUTHORIZATION_HASH);
123+
});
124+
125+
it('should throw if authorization is null', () => {
126+
expect(() =>
127+
hashEIP7702Authorization(null as unknown as EIP7702Authorization),
128+
).toThrow('Missing authorization parameter');
129+
});
130+
131+
it('should throw if authorization is undefined', () => {
132+
expect(() =>
133+
hashEIP7702Authorization(undefined as unknown as EIP7702Authorization),
134+
).toThrow('Missing authorization parameter');
135+
});
136+
137+
it('should throw if chainId is null', () => {
138+
expect(() =>
139+
hashEIP7702Authorization([
140+
null as unknown as number,
141+
TEST_AUTHORIZATION[1],
142+
TEST_AUTHORIZATION[2],
143+
]),
144+
).toThrow('Missing chainId parameter');
145+
});
146+
147+
it('should throw if contractAddress is null', () => {
148+
expect(() =>
149+
hashEIP7702Authorization([
150+
TEST_AUTHORIZATION[0],
151+
null as unknown as string,
152+
TEST_AUTHORIZATION[2],
153+
]),
154+
).toThrow('Missing contractAddress parameter');
155+
});
156+
157+
it('should throw if nonce is null', () => {
158+
expect(() =>
159+
hashEIP7702Authorization([
160+
TEST_AUTHORIZATION[0],
161+
TEST_AUTHORIZATION[1],
162+
null as unknown as number,
163+
]),
164+
).toThrow('Missing nonce parameter');
165+
});
166+
});
167+
168+
describe('recoverAuthorization()', () => {
169+
it('should recover the address from a signature', () => {
170+
const recoveredAddress = recoverEIP7702Authorization({
171+
authorization: TEST_AUTHORIZATION,
172+
signature: EXPECTED_SIGNATURE,
173+
});
174+
175+
expect(recoveredAddress).toBe(TEST_ADDRESS);
176+
});
177+
178+
it('should throw if signature is null', () => {
179+
expect(() =>
180+
recoverEIP7702Authorization({
181+
signature: null as unknown as string,
182+
authorization: TEST_AUTHORIZATION,
183+
}),
184+
).toThrow('Missing signature parameter');
185+
});
186+
187+
it('should throw if signature is undefined', () => {
188+
expect(() =>
189+
recoverEIP7702Authorization({
190+
signature: undefined as unknown as string,
191+
authorization: TEST_AUTHORIZATION,
192+
}),
193+
).toThrow('Missing signature parameter');
194+
});
195+
196+
it('should throw if authorization is null', () => {
197+
expect(() =>
198+
recoverEIP7702Authorization({
199+
signature: EXPECTED_SIGNATURE,
200+
authorization: null as unknown as EIP7702Authorization,
201+
}),
202+
).toThrow('Missing authorization parameter');
203+
});
204+
205+
it('should throw if authorization is undefined', () => {
206+
expect(() =>
207+
recoverEIP7702Authorization({
208+
signature: EXPECTED_SIGNATURE,
209+
authorization: undefined as unknown as EIP7702Authorization,
210+
}),
211+
).toThrow('Missing authorization parameter');
212+
});
213+
});
214+
215+
describe('sign-and-recover', () => {
216+
const testCases = {
217+
zeroChainId: [0, '0x1234567890123456789012345678901234567890', 1],
218+
highChainId: [98765, '0x1234567890123456789012345678901234567890', 1],
219+
zeroNonce: [8545, '0x1234567890123456789012345678901234567890', 0],
220+
highNonce: [8545, '0x1234567890123456789012345678901234567890', 98765],
221+
zeroContractAddress: [1, '0x0000000000000000000000000000000000000000', 1],
222+
allZeroValues: [0, '0x0000000000000000000000000000000000000000', 0],
223+
} as { [key: string]: EIP7702Authorization };
224+
225+
it.each(Object.entries(testCases))(
226+
'should sign and recover %s',
227+
(_, authorization) => {
228+
const signature = signEIP7702Authorization({
229+
privateKey: TEST_PRIVATE_KEY,
230+
authorization,
231+
});
232+
233+
const recoveredAddress = recoverEIP7702Authorization({
234+
authorization,
235+
signature,
236+
});
237+
238+
expect(recoveredAddress).toBe(TEST_ADDRESS);
239+
},
240+
);
241+
});
242+
});

src/sign-eip7702-authorization.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { encode } from '@ethereumjs/rlp';
2+
import { ecsign, publicToAddress, toBuffer } from '@ethereumjs/util';
3+
import { bytesToHex } from '@metamask/utils';
4+
import { keccak256 } from 'ethereum-cryptography/keccak';
5+
6+
import { concatSig, isNullish, recoverPublicKey } from './utils';
7+
8+
/**
9+
* The authorization struct as defined in EIP-7702.
10+
*
11+
* @property chainId - The chain ID or 0 for any chain.
12+
* @property contractAddress - The address of the contract being authorized.
13+
* @property nonce - The nonce of the signing account (at the time of submission).
14+
*/
15+
export type EIP7702Authorization = [
16+
chainId: number,
17+
contractAddress: string,
18+
nonce: number,
19+
];
20+
21+
/**
22+
* Sign an authorization message with the provided private key.
23+
*
24+
* @param options - The signing options.
25+
* @param options.privateKey - The private key to sign with.
26+
* @param options.authorization - The authorization data to sign.
27+
* @returns The '0x'-prefixed hex encoded signature.
28+
*/
29+
export function signEIP7702Authorization({
30+
privateKey,
31+
authorization,
32+
}: {
33+
privateKey: Buffer;
34+
authorization: EIP7702Authorization;
35+
}): string {
36+
validateEIP7702Authorization(authorization);
37+
38+
if (isNullish(privateKey)) {
39+
throw new Error('Missing privateKey parameter');
40+
}
41+
42+
const messageHash = hashEIP7702Authorization(authorization);
43+
44+
const { r, s, v } = ecsign(messageHash, privateKey);
45+
46+
// v is either 27n or 28n so is guaranteed to be a single byte
47+
const vBuffer = toBuffer(v);
48+
49+
return concatSig(vBuffer, r, s);
50+
}
51+
52+
/**
53+
* Recover the address of the account that created the given authorization
54+
* signature.
55+
*
56+
* @param options - The signature recovery options.
57+
* @param options.signature - The '0x'-prefixed hex encoded message signature.
58+
* @param options.authorization - The authorization data that was signed.
59+
* @returns The '0x'-prefixed hex address of the signer.
60+
*/
61+
export function recoverEIP7702Authorization({
62+
signature,
63+
authorization,
64+
}: {
65+
signature: string;
66+
authorization: EIP7702Authorization;
67+
}): string {
68+
validateEIP7702Authorization(authorization);
69+
70+
if (isNullish(signature)) {
71+
throw new Error('Missing signature parameter');
72+
}
73+
74+
const messageHash = hashEIP7702Authorization(authorization);
75+
76+
const publicKey = recoverPublicKey(messageHash, signature);
77+
78+
const sender = publicToAddress(publicKey);
79+
80+
return bytesToHex(sender);
81+
}
82+
83+
/**
84+
* Hash an authorization message according to the signing scheme.
85+
* The message is encoded according to EIP-7702.
86+
*
87+
* @param authorization - The authorization data to hash.
88+
* @returns The hash of the authorization message as a Buffer.
89+
*/
90+
export function hashEIP7702Authorization(
91+
authorization: EIP7702Authorization,
92+
): Buffer {
93+
validateEIP7702Authorization(authorization);
94+
95+
const encodedAuthorization = encode(authorization);
96+
97+
const message = Buffer.concat([
98+
Buffer.from('05', 'hex'),
99+
encodedAuthorization,
100+
]);
101+
102+
return Buffer.from(keccak256(message));
103+
}
104+
105+
/**
106+
* Validates an authorization object to ensure all required parameters are present.
107+
*
108+
* @param authorization - The authorization object to validate.
109+
* @throws {Error} If the authorization object or any of its required parameters are missing.
110+
*/
111+
function validateEIP7702Authorization(authorization: EIP7702Authorization) {
112+
if (isNullish(authorization)) {
113+
throw new Error('Missing authorization parameter');
114+
}
115+
116+
const [chainId, contractAddress, nonce] = authorization;
117+
118+
if (isNullish(chainId)) {
119+
throw new Error('Missing chainId parameter');
120+
}
121+
122+
if (isNullish(contractAddress)) {
123+
throw new Error('Missing contractAddress parameter');
124+
}
125+
126+
if (isNullish(nonce)) {
127+
throw new Error('Missing nonce parameter');
128+
}
129+
}

0 commit comments

Comments
 (0)