From 807aeaed8f6a15534c44f115b08f3d268debaa99 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 14 Jul 2021 18:31:32 -0230 Subject: [PATCH] Allow `TypedDataUtils` to be called unbound Previously the functions exposed as `TypedDataUtils` could only be called from the `TypedDataUtils` object. If they were called unbound, they would throw errors because of the use of `this`. They have all been updated to no longer rely upon `this`, so they now work the same way regardless how they are bound when called. They are still exported as the `TypedDataUtils` object, so this should not change the API. This was done to simplify the code, specifically to make it easier for functions outside of `TypedDataUtils` to reuse code inside of `TypedDataUtils`. Some types required adjustments, as type mistakes were brought to light that TypeScript for some reason wasn't aware of when these were declared as properties of the `TypedDataUtils` object. These were fixed by adding two type assertions, making the `types` parameter to `hashStruct` and `hashType` more strict, and by making the `typedData` parameter to `eip712Hash` more strict. The type assertions (warranted or not) preserve the types used previously. We can replace them later with validation. --- src/index.test.ts | 123 +++++++++++++ src/index.ts | 461 ++++++++++++++++++++++++---------------------- 2 files changed, 362 insertions(+), 222 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 8d95a630..890e016d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1250,3 +1250,126 @@ it('signedTypeMessage V4 with recursive types', function () { '0xf2ec61e636ff7bb3ac8bc2a4cc2c8b8f635dd1b2ec8094c963128b358e79c85c5ca6dd637ed7e80f0436fe8fce39c0e5f2082c9517fe677cc2917dcd6c84ba881c', ); }); + +it('unbound sign typed data utility functions', function () { + const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'mother', type: 'Person' }, + { name: 'father', type: 'Person' }, + ], + }, + domain: { + name: 'Family Tree', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + primaryType: 'Person' as const, + message: { + name: 'Jon', + mother: { + name: 'Lyanna', + father: { + name: 'Rickard', + }, + }, + father: { + name: 'Rhaegar', + father: { + name: 'Aeris II', + }, + }, + }, + }; + + const { encodeData, encodeType, hashStruct, hashType, eip712Hash } = + sigUtil.TypedDataUtils; + + expect(encodeType('Person', typedData.types)).toBe( + 'Person(string name,Person mother,Person father)', + ); + + expect(ethUtil.bufferToHex(hashType('Person', typedData.types))).toBe( + '0x7c5c8e90cb92c8da53b893b24962513be98afcf1b57b00327ae4cc14e3a64116', + ); + + expect( + ethUtil.bufferToHex( + encodeData('Person', typedData.message.mother, typedData.types, 'V4'), + ), + ).toBe( + `0x${[ + '7c5c8e90cb92c8da53b893b24962513be98afcf1b57b00327ae4cc14e3a64116', + 'afe4142a2b3e7b0503b44951e6030e0e2c5000ef83c61857e2e6003e7aef8570', + '0000000000000000000000000000000000000000000000000000000000000000', + '88f14be0dd46a8ec608ccbff6d3923a8b4e95cdfc9648f0db6d92a99a264cb36', + ].join('')}`, + ); + expect( + ethUtil.bufferToHex( + hashStruct('Person', typedData.message.mother, typedData.types, 'V4'), + ), + ).toBe('0x9ebcfbf94f349de50bcb1e3aa4f1eb38824457c99914fefda27dcf9f99f6178b'); + + expect( + ethUtil.bufferToHex( + encodeData('Person', typedData.message.father, typedData.types, 'V4'), + ), + ).toBe( + `0x${[ + '7c5c8e90cb92c8da53b893b24962513be98afcf1b57b00327ae4cc14e3a64116', + 'b2a7c7faba769181e578a391a6a6811a3e84080c6a3770a0bf8a856dfa79d333', + '0000000000000000000000000000000000000000000000000000000000000000', + '02cc7460f2c9ff107904cff671ec6fee57ba3dd7decf999fe9fe056f3fd4d56e', + ].join('')}`, + ); + expect( + ethUtil.bufferToHex( + hashStruct('Person', typedData.message.father, typedData.types, 'V4'), + ), + ).toBe('0xb852e5abfeff916a30cb940c4e24c43cfb5aeb0fa8318bdb10dd2ed15c8c70d8'); + + expect( + ethUtil.bufferToHex( + encodeData( + typedData.primaryType, + typedData.message, + typedData.types, + 'V4', + ), + ), + ).toBe( + `0x${[ + '7c5c8e90cb92c8da53b893b24962513be98afcf1b57b00327ae4cc14e3a64116', + 'e8d55aa98b6b411f04dbcf9b23f29247bb0e335a6bc5368220032fdcb9e5927f', + '9ebcfbf94f349de50bcb1e3aa4f1eb38824457c99914fefda27dcf9f99f6178b', + 'b852e5abfeff916a30cb940c4e24c43cfb5aeb0fa8318bdb10dd2ed15c8c70d8', + ].join('')}`, + ); + expect( + ethUtil.bufferToHex( + hashStruct( + typedData.primaryType, + typedData.message, + typedData.types, + 'V4', + ), + ), + ).toBe('0xfdc7b6d35bbd81f7fa78708604f57569a10edff2ca329c8011373f0667821a45'); + expect( + ethUtil.bufferToHex( + hashStruct('EIP712Domain', typedData.domain, typedData.types, 'V4'), + ), + ).toBe('0xfacb2c1888f63a780c84c216bd9a81b516fc501a19bae1fc81d82df590bbdc60'); + expect(ethUtil.bufferToHex(eip712Hash(typedData, 'V4'))).toBe( + '0x807773b9faa9879d4971b43856c4d60c2da15c6f8c062bd9d33afefb756de19c', + ); +}); diff --git a/src/index.ts b/src/index.ts index 9ee44bf3..a02f96bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,256 +74,273 @@ const TYPED_MESSAGE_SCHEMA = { }; /** - * A collection of utility functions used for signing typed data + * Encodes an object by encoding and concatenating each of its members + * + * @param {string} primaryType - Root type + * @param {Object} data - Object to encode + * @param {Object} types - Type definitions + * @param {Version} version - The EIP-712 version the encoding should comply with + * @returns {Buffer} - Encoded representation of an object */ -const TypedDataUtils = { - /** - * Encodes an object by encoding and concatenating each of its members - * - * @param {string} primaryType - Root type - * @param {Object} data - Object to encode - * @param {Object} types - Type definitions - * @param {Version} version - The EIP-712 version the encoding should comply with - * @returns {Buffer} - Encoded representation of an object - */ - encodeData( - primaryType: string, - data: Record, - types: Record, - version: Version, - ): Buffer { - const encodedTypes = ['bytes32']; - const encodedValues = [this.hashType(primaryType, types)]; - - if (version === 'V4') { - const encodeField = (name, type, value) => { - if (types[type] !== undefined) { - return [ - 'bytes32', - value == null // eslint-disable-line no-eq-null - ? '0x0000000000000000000000000000000000000000000000000000000000000000' - : ethUtil.keccak(this.encodeData(type, value, types, version)), - ]; - } +function encodeData( + primaryType: string, + data: Record, + types: Record, + version: Version, +): Buffer { + const encodedTypes = ['bytes32']; + const encodedValues: unknown[] = [hashType(primaryType, types)]; + + if (version === 'V4') { + const encodeField = (name, type, value) => { + if (types[type] !== undefined) { + return [ + 'bytes32', + value == null // eslint-disable-line no-eq-null + ? '0x0000000000000000000000000000000000000000000000000000000000000000' + : ethUtil.keccak(encodeData(type, value, types, version)), + ]; + } - if (value === undefined) { - throw new Error(`missing value for field ${name} of type ${type}`); - } + if (value === undefined) { + throw new Error(`missing value for field ${name} of type ${type}`); + } - if (type === 'bytes') { - return ['bytes32', ethUtil.keccak(value)]; - } + if (type === 'bytes') { + return ['bytes32', ethUtil.keccak(value)]; + } - if (type === 'string') { - // convert string to buffer - prevents ethUtil from interpreting strings like '0xabcd' as hex - if (typeof value === 'string') { - value = Buffer.from(value, 'utf8'); - } - return ['bytes32', ethUtil.keccak(value)]; + if (type === 'string') { + // convert string to buffer - prevents ethUtil from interpreting strings like '0xabcd' as hex + if (typeof value === 'string') { + value = Buffer.from(value, 'utf8'); } + return ['bytes32', ethUtil.keccak(value)]; + } - if (type.lastIndexOf(']') === type.length - 1) { - const parsedType = type.slice(0, type.lastIndexOf('[')); - const typeValuePairs = value.map((item) => - encodeField(name, parsedType, item), - ); - return [ - 'bytes32', - ethUtil.keccak( - ethAbi.rawEncode( - typeValuePairs.map(([t]) => t), - typeValuePairs.map(([, v]) => v), - ), + if (type.lastIndexOf(']') === type.length - 1) { + const parsedType = type.slice(0, type.lastIndexOf('[')); + const typeValuePairs = value.map((item) => + encodeField(name, parsedType, item), + ); + return [ + 'bytes32', + ethUtil.keccak( + ethAbi.rawEncode( + typeValuePairs.map(([t]) => t), + typeValuePairs.map(([, v]) => v), ), - ]; - } + ), + ]; + } - return [type, value]; - }; + return [type, value]; + }; - for (const field of types[primaryType]) { - const [type, value] = encodeField( - field.name, - field.type, - data[field.name], - ); - encodedTypes.push(type); - encodedValues.push(value); - } - } else { - for (const field of types[primaryType]) { - let value = data[field.name]; - if (value !== undefined) { - if (field.type === 'bytes') { - encodedTypes.push('bytes32'); - value = ethUtil.keccak(value); - encodedValues.push(value); - } else if (field.type === 'string') { - encodedTypes.push('bytes32'); - // convert string to buffer - prevents ethUtil from interpreting strings like '0xabcd' as hex - if (typeof value === 'string') { - value = Buffer.from(value, 'utf8'); - } - value = ethUtil.keccak(value); - encodedValues.push(value); - } else if (types[field.type] !== undefined) { - encodedTypes.push('bytes32'); - value = ethUtil.keccak( - this.encodeData(field.type, value, types, version), - ); - encodedValues.push(value); - } else if (field.type.lastIndexOf(']') === field.type.length - 1) { - throw new Error( - 'Arrays are unimplemented in encodeData; use V4 extension', - ); - } else { - encodedTypes.push(field.type); - encodedValues.push(value); + for (const field of types[primaryType]) { + const [type, value] = encodeField( + field.name, + field.type, + data[field.name], + ); + encodedTypes.push(type); + encodedValues.push(value); + } + } else { + for (const field of types[primaryType]) { + let value = data[field.name]; + if (value !== undefined) { + if (field.type === 'bytes') { + encodedTypes.push('bytes32'); + value = ethUtil.keccak(value); + encodedValues.push(value); + } else if (field.type === 'string') { + encodedTypes.push('bytes32'); + // convert string to buffer - prevents ethUtil from interpreting strings like '0xabcd' as hex + if (typeof value === 'string') { + value = Buffer.from(value, 'utf8'); } + value = ethUtil.keccak(value); + encodedValues.push(value); + } else if (types[field.type] !== undefined) { + encodedTypes.push('bytes32'); + value = ethUtil.keccak( + encodeData( + field.type, + // TODO: Add validation to ensure this is a string-indexed + // object, so that this type cast can be removed + value as Record, + types, + version, + ), + ); + encodedValues.push(value); + } else if (field.type.lastIndexOf(']') === field.type.length - 1) { + throw new Error( + 'Arrays are unimplemented in encodeData; use V4 extension', + ); + } else { + encodedTypes.push(field.type); + encodedValues.push(value); } } } + } - return ethAbi.rawEncode(encodedTypes, encodedValues); - }, + return ethAbi.rawEncode(encodedTypes, encodedValues); +} - /** - * Encodes the type of an object by encoding a comma delimited list of its members - * - * @param {string} primaryType - Root type to encode - * @param {Object} types - Type definitions - * @returns {string} - Encoded representation of the type of an object - */ - encodeType( - primaryType: string, - types: Record, - ): string { - let result = ''; - let deps = this.findTypeDependencies(primaryType, types).filter( - (dep) => dep !== primaryType, - ); - deps = [primaryType].concat(deps.sort()); - for (const type of deps) { - const children = types[type]; - if (!children) { - throw new Error(`No type definition specified: ${type}`); - } - result += `${type}(${types[type] - .map(({ name, type: t }) => `${t} ${name}`) - .join(',')})`; +/** + * Encodes the type of an object by encoding a comma delimited list of its members + * + * @param {string} primaryType - Root type to encode + * @param {Object} types - Type definitions + * @returns {string} - Encoded representation of the type of an object + */ +function encodeType( + primaryType: string, + types: Record, +): string { + let result = ''; + let deps = findTypeDependencies(primaryType, types).filter( + (dep) => dep !== primaryType, + ); + deps = [primaryType].concat(deps.sort()); + for (const type of deps) { + const children = types[type]; + if (!children) { + throw new Error(`No type definition specified: ${type}`); } - return result; - }, + result += `${type}(${types[type] + .map(({ name, type: t }) => `${t} ${name}`) + .join(',')})`; + } + return result; +} - /** - * Finds all types within a type definition object - * - * @param {string} primaryType - Root type - * @param {Object} types - Type definitions - * @param {Array} results - current set of accumulated types - * @returns {Array} - Set of all types found in the type definition - */ - findTypeDependencies( - primaryType: string, - types: Record, - results: string[] = [], - ): string[] { - [primaryType] = primaryType.match(/^\w*/u); - if (results.includes(primaryType) || types[primaryType] === undefined) { - return results; - } - results.push(primaryType); - for (const field of types[primaryType]) { - for (const dep of this.findTypeDependencies(field.type, types, results)) { - !results.includes(dep) && results.push(dep); - } - } +/** + * Finds all types within a type definition object + * + * @param {string} primaryType - Root type + * @param {Object} types - Type definitions + * @param {Array} results - current set of accumulated types + * @returns {Array} - Set of all types found in the type definition + */ +function findTypeDependencies( + primaryType: string, + types: Record, + results: string[] = [], +): string[] { + [primaryType] = primaryType.match(/^\w*/u); + if (results.includes(primaryType) || types[primaryType] === undefined) { return results; - }, + } + results.push(primaryType); + for (const field of types[primaryType]) { + for (const dep of findTypeDependencies(field.type, types, results)) { + !results.includes(dep) && results.push(dep); + } + } + return results; +} - /** - * Hashes an object - * - * @param {string} primaryType - Root type - * @param {Object} data - Object to hash - * @param {Object} types - Type definitions - * @param {Version} version - The EIP-712 version the hash should comply with - * @returns {Buffer} - Hash of an object - */ - hashStruct( - primaryType: string, - data: Record, - types: Record, - version: Version, - ): Buffer { - return ethUtil.keccak(this.encodeData(primaryType, data, types, version)); - }, +/** + * Hashes an object + * + * @param {string} primaryType - Root type + * @param {Object} data - Object to hash + * @param {Object} types - Type definitions + * @returns {Buffer} - Hash of an object + */ +function hashStruct( + primaryType: string, + data: Record, + types: Record, + version: Version, +): Buffer { + return ethUtil.keccak(encodeData(primaryType, data, types, version)); +} - /** - * Hashes the type of an object - * - * @param {string} primaryType - Root type to hash - * @param {Object} types - Type definitions - * @returns {Buffer} - Hash of an object - */ - hashType(primaryType: string, types: Record): Buffer { - return ethUtil.keccak(this.encodeType(primaryType, types)); - }, +/** + * Hashes the type of an object + * + * @param {string} primaryType - Root type to hash + * @param {Object} types - Type definitions + * @returns {Buffer} - Hash of an object + */ +function hashType( + primaryType: string, + types: Record, +): Buffer { + return ethUtil.keccak(encodeType(primaryType, types)); +} - /** - * Removes properties from a message object that are not defined per EIP-712 - * - * @param {Object} data - typed message object - * @returns {Object} - typed message object with only allowed fields - */ - sanitizeData( - data: TypedData | TypedMessage, - ): TypedMessage { - const sanitizedData: Partial> = {}; - for (const key in TYPED_MESSAGE_SCHEMA.properties) { - if (data[key]) { - sanitizedData[key] = data[key]; - } - } - if ('types' in sanitizedData) { - sanitizedData.types = { EIP712Domain: [], ...sanitizedData.types }; +/** + * Removes properties from a message object that are not defined per EIP-712 + * + * @param {Object} data - typed message object + * @returns {Object} - typed message object with only allowed fields + */ +function sanitizeData( + data: TypedData | TypedMessage, +): TypedMessage { + const sanitizedData: Partial> = {}; + for (const key in TYPED_MESSAGE_SCHEMA.properties) { + if (data[key]) { + sanitizedData[key] = data[key]; } - return sanitizedData as Required>; - }, + } + if ('types' in sanitizedData) { + sanitizedData.types = { EIP712Domain: [], ...sanitizedData.types }; + } + return sanitizedData as Required>; +} - /** - * Signs a typed message as per EIP-712 and returns its keccak hash - * - * @param {Object} typedData - Types message data to hash as per eip-712 - * @param {Version} version - The EIP-712 version the hash should comply with - * @returns {Buffer} - keccak hash of the resulting signed message - */ - eip712Hash( - typedData: Partial>, - version: Version, - ): Buffer { - const sanitizedData = this.sanitizeData(typedData); - const parts = [Buffer.from('1901', 'hex')]; +/** + * Signs a typed message as per EIP-712 and returns its keccak hash + * + * @param {Object} typedData - Types message data to hash as per eip-712 + * @returns {Buffer} - keccak hash of the resulting signed message + */ +function eip712Hash( + typedData: TypedData | TypedMessage, + version: Version, +): Buffer { + const sanitizedData = sanitizeData(typedData); + const parts = [Buffer.from('1901', 'hex')]; + parts.push( + hashStruct( + 'EIP712Domain', + sanitizedData.domain, + sanitizedData.types, + version, + ), + ); + if (sanitizedData.primaryType !== 'EIP712Domain') { parts.push( - this.hashStruct( - 'EIP712Domain', - sanitizedData.domain, + hashStruct( + // TODO: Validate that this is a string, so this type cast can be removed. + sanitizedData.primaryType as string, + sanitizedData.message, sanitizedData.types, version, ), ); - if (sanitizedData.primaryType !== 'EIP712Domain') { - parts.push( - this.hashStruct( - sanitizedData.primaryType, - sanitizedData.message, - sanitizedData.types, - version, - ), - ); - } - return ethUtil.keccak(Buffer.concat(parts)); - }, + } + return ethUtil.keccak(Buffer.concat(parts)); +} + +/** + * A collection of utility functions used for signing typed data + */ +const TypedDataUtils = { + encodeData, + encodeType, + findTypeDependencies, + hashStruct, + hashType, + sanitizeData, + eip712Hash, }; function concatSig(v: Buffer, r: Buffer, s: Buffer): string {