0xweb
Version:
Contract package manager and other web3 tools
346 lines (311 loc) • 13.1 kB
text/typescript
import { $buffer } from './$buffer';
import { $bigint } from './$bigint';
import { $abiCoder } from '@dequanto/abi/$abiCoder';
import { $contract } from './$contract';
import { TEth } from '@dequanto/models/TEth';
import { $is } from './$is';
export namespace $signSerializer {
interface MessageTypes {
EIP712Domain: MessageTypeProperty[];
[additionalProperties: string]: MessageTypeProperty[];
}
interface MessageTypeProperty {
name: string;
type: string;
}
interface EIP712TypedData {
name: string;
type: string;
value: any;
}
interface TypedMessage<T extends MessageTypes> {
types: T;
primaryType: string;
domain: {
name?: string;
version?: string;
chainId?: number;
verifyingContract?: string;
};
message: Record<string, unknown>;
}
type TypedData = string | EIP712TypedData | EIP712TypedData[];
const TYPED_MESSAGE_SCHEMA = {
type: 'object',
properties: {
types: {
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
type: { type: 'string' },
},
required: ['name', 'type'],
},
},
},
primaryType: { type: 'string' },
domain: { type: 'object' },
message: { type: 'object' },
},
required: ['types', 'primaryType', 'domain', 'message'],
};
export function serializeTypedData<T extends MessageTypes>(data: TypedData | TypedMessage<T>, version: 'V3' | 'V4' = 'V4') {
let serializer = new TypedDataSerializer()
switch (version) {
case 'V3':
return serializer.serialize(data, false);
case 'V4':
default:
return serializer.serialize(data, true);
}
}
/**
* A collection of utility functions used for signing typed data
*/
class TypedDataSerializer {
/**
* 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
* @returns {Buffer} - Encoded representation of an object
*/
private encodeData(
primaryType: string,
data: Record<string, any>,
types: Record<string, MessageTypeProperty[]>,
useV4 = true
): TEth.Hex {
const encodedTypes = ['bytes32'];
const encodedValues = [this.hashType(primaryType, types)];
if (useV4) {
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, useV4)),
];
}
if (value === undefined) {
throw new Error(`missing value for field ${name} of type ${type}`);
}
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.fromString(value);
}
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(
$abiCoder.encode(
typeValuePairs.map(([t]) => t),
typeValuePairs.map(([, v]) => v)
)
),
];
}
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.fromString(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, useV4)
);
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 (let i = 0; i < encodedValues.length; i++) {
let val = encodedValues[i];
if (typeof val === 'bigint') {
encodedValues[i] = $bigint.toHex(val) as any;
}
}
return $abiCoder.encode(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
*/
private encodeType(
primaryType: string,
types: Record<string, MessageTypeProperty[]>
): 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(',')})`;
}
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
*/
private findTypeDependencies(
primaryType: string,
types: Record<string, MessageTypeProperty[]>,
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);
}
}
return results;
}
/**
* 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
*/
private hashStruct(
primaryType: string,
data: Record<string, any>,
types: Record<string, any>,
useV4 = true
): Uint8Array {
return ethUtil_keccak(this.encodeData(primaryType, data, types, useV4));
}
/**
* Hashes the type of an object
*
* @param {string} primaryType - Root type to hash
* @param {Object} types - Type definitions
* @returns {Buffer} - Hash of an object
*/
private hashType(primaryType: string, types: Record<string, any>): Uint8Array {
return ethUtil_keccak(this.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
*/
private sanitizeData<T extends MessageTypes>(
data: Partial<TypedData | TypedMessage<T>>
): TypedMessage<T> {
const sanitizedData: Partial<TypedMessage<T>> = {};
for (const key in TYPED_MESSAGE_SCHEMA.properties) {
if (data[key]) {
sanitizedData[key] = data[key];
}
}
if ('types' in sanitizedData) {
sanitizedData.types = { EIP712Domain: [], ...sanitizedData.types };
}
return sanitizedData as Required<TypedMessage<T>>;
}
/**
* Signs a typed message as per EIP-712 and returns its keccak hash
*
* @param {Object} typedData - Types message data to sign
* @returns {Buffer} - keccak hash of the resulting signed message
*/
serialize<T extends MessageTypes>(
typedData: Partial<TypedData | TypedMessage<T>>,
useV4 = true
): Uint8Array {
const sanitizedData = this.sanitizeData(typedData);
const parts = [$buffer.fromHex('1901')];
parts.push(
this.hashStruct(
'EIP712Domain',
sanitizedData.domain,
sanitizedData.types,
useV4
)
);
if (sanitizedData.primaryType !== 'EIP712Domain') {
parts.push(
this.hashStruct(
sanitizedData.primaryType,
sanitizedData.message,
sanitizedData.types,
useV4
)
);
}
return ethUtil_keccak($buffer.concat(parts));
}
};
function ethUtil_keccak(mix: string | TEth.BufferLike) {
const bytes = typeof mix === 'string' && $is.Hex(mix) === false
? $buffer.fromString(mix)
: $buffer.ensure(mix);
return $contract.keccak256(bytes, 'buffer');
}
}