UNPKG

ox

Version:

Ethereum Standard Library

690 lines 21.8 kB
import * as AbiParameters from './AbiParameters.js'; import * as Address from './Address.js'; import * as Bytes from './Bytes.js'; import * as Errors from './Errors.js'; import * as Hash from './Hash.js'; import * as Hex from './Hex.js'; import * as Json from './Json.js'; import * as Solidity from './Solidity.js'; /** * Asserts that [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) is valid. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.assert({ * domain: { * name: 'Ether!', * version: '1', * chainId: 1, * verifyingContract: * '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' * }, * primaryType: 'Foo', * types: { * Foo: [ * { name: 'address', type: 'address' }, * { name: 'name', type: 'string' }, * { name: 'foo', type: 'string' } * ] * }, * message: { * address: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9', * name: 'jxom', * foo: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9' * } * }) * ``` * * @param value - The Typed Data to validate. */ export function assert(value) { const { domain, message, primaryType, types } = value; const validateValue = (type, value, name) => { // Array types: validate length (if fixed) and recurse into each element. const arrayMatch = type.match(Solidity.arrayRegex); if (arrayMatch) { const [, elementType, lengthStr] = arrayMatch; if (!Array.isArray(value)) throw new InvalidArrayError({ name, type, value }); if (lengthStr) { const expected = Number.parseInt(lengthStr, 10); if (value.length !== expected) throw new InvalidArrayLengthError({ name, type, expectedLength: expected, givenLength: value.length, }); } for (const element of value) validateValue(elementType, element, name); return; } const integerMatch = type.match(Solidity.integerRegex); if (integerMatch && (typeof value === 'number' || typeof value === 'bigint')) { const [, base, size_] = integerMatch; // If number cannot be cast to a sized hex value, it is out of range // and will throw. Hex.fromNumber(value, { signed: base === 'int', size: Number.parseInt(size_ ?? '', 10) / 8, }); } if (type === 'address' && typeof value === 'string' && !Address.validate(value)) throw new Address.InvalidAddressError({ address: value, cause: new Address.InvalidInputError(), }); const bytesMatch = type.match(Solidity.bytesRegex); if (bytesMatch) { const [, size] = bytesMatch; if (size && Hex.size(value) !== Number.parseInt(size, 10)) throw new BytesSizeMismatchError({ expectedSize: Number.parseInt(size, 10), givenSize: Hex.size(value), }); } const struct = types[type]; if (struct) { validateReference(type); validateData(struct, value); } }; const validateData = (struct, data) => { for (const param of struct) { const { name, type } = param; validateValue(type, data[name], name); } }; // Validate domain types. if (types.EIP712Domain && domain) { if (typeof domain !== 'object') throw new InvalidDomainError({ domain }); validateData(types.EIP712Domain, domain); } // Validate message types. if (primaryType !== 'EIP712Domain') { if (types[primaryType]) validateData(types[primaryType], message); else throw new InvalidPrimaryTypeError({ primaryType, types }); } } /** * Creates [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) [`domainSeparator`](https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator) for the provided domain. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.domainSeparator({ * name: 'Ether!', * version: '1', * chainId: 1, * verifyingContract: * '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' * }) * // @log: '0x9911ee4f58a7059a8f5385248040e6984d80e2c849500fe6a4d11c4fa98c2af3' * ``` * * @param domain - The domain for which to create the domain separator. * @returns The domain separator. */ export function domainSeparator(domain) { return hashDomain({ domain, }); } /** * Encodes typed data in [EIP-712 format](https://eips.ethereum.org/EIPS/eip-712): `0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message)`. * * @example * ```ts twoslash * import { TypedData, Hash } from 'ox' * * const data = TypedData.encode({ * // [!code focus:33] * domain: { * name: 'Ether Mail', * version: '1', * chainId: 1, * verifyingContract: * '0x0000000000000000000000000000000000000000' * }, * types: { * Person: [ * { name: 'name', type: 'string' }, * { name: 'wallet', type: 'address' } * ], * Mail: [ * { name: 'from', type: 'Person' }, * { name: 'to', type: 'Person' }, * { name: 'contents', type: 'string' } * ] * }, * primaryType: 'Mail', * message: { * from: { * name: 'Cow', * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' * }, * to: { * name: 'Bob', * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' * }, * contents: 'Hello, Bob!' * } * }) * // @log: '0x19012fdf3441fcaf4f30c7e16292b258a5d7054a4e2e00dbd7b7d2f467f2b8fb9413c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e' * // @log: (0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message)) * * const hash = Hash.keccak256(data) * ``` * * @param value - The Typed Data to encode. * @returns The encoded Typed Data. */ export function encode(value) { const { domain = {}, message, primaryType } = value; const types = { EIP712Domain: extractEip712DomainTypes(domain), ...value.types, }; // Need to do a runtime validation check on addresses, byte ranges, integer ranges, etc // as we can't statically check this with TypeScript. assert({ domain, message, primaryType, types, }); // Memoize `keccak256(encodeType(t))` per type for the duration of this // encode call. Same `(primaryType, types)` is hashed once instead of once // per nested struct / array element. const typeHashes = new Map(); // Typed Data Format: `0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message)` const parts = ['0x19', '0x01']; if (domain) parts.push(hashDomain({ domain, types, typeHashes, })); if (primaryType !== 'EIP712Domain') parts.push(hashStruct({ data: message, primaryType, types, typeHashes, })); return Hex.concat(...parts); } /** * Encodes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) schema for the provided primaryType. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.encodeType({ * types: { * Foo: [ * { name: 'address', type: 'address' }, * { name: 'name', type: 'string' }, * { name: 'foo', type: 'string' } * ] * }, * primaryType: 'Foo' * }) * // @log: 'Foo(address address,string name,string foo)' * ``` * * @param value - The Typed Data schema. * @returns The encoded type. */ export function encodeType(value) { const { primaryType, types } = value; let result = ''; const unsortedDeps = findTypeDependencies({ primaryType, types }); unsortedDeps.delete(primaryType); const deps = [primaryType, ...Array.from(unsortedDeps).sort()]; for (const type of deps) { result += `${type}(${(types[type] ?? []) .map(({ name, type: t }) => `${t} ${name}`) .join(',')})`; } return result; } /** * Gets [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) schema for EIP-721 domain. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.extractEip712DomainTypes({ * name: 'Ether!', * version: '1', * chainId: 1, * verifyingContract: * '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' * }) * // @log: [ * // @log: { 'name': 'name', 'type': 'string' }, * // @log: { 'name': 'version', 'type': 'string' }, * // @log: { 'name': 'chainId', 'type': 'uint256' }, * // @log: { 'name': 'verifyingContract', 'type': 'address' }, * // @log: ] * ``` * * @param domain - The EIP-712 domain. * @returns The EIP-712 domain schema. */ export function extractEip712DomainTypes(domain) { return [ typeof domain?.name === 'string' && { name: 'name', type: 'string' }, domain?.version && { name: 'version', type: 'string' }, (typeof domain?.chainId === 'number' || typeof domain?.chainId === 'bigint') && { name: 'chainId', type: 'uint256', }, domain?.verifyingContract && { name: 'verifyingContract', type: 'address', }, domain?.salt && { name: 'salt', type: 'bytes32' }, ].filter(Boolean); } /** * Gets the payload to use for signing typed data in [EIP-712 format](https://eips.ethereum.org/EIPS/eip-712). * * @example * ```ts twoslash * import { Secp256k1, TypedData, Hash } from 'ox' * * const payload = TypedData.getSignPayload({ * // [!code focus:99] * domain: { * name: 'Ether Mail', * version: '1', * chainId: 1, * verifyingContract: * '0x0000000000000000000000000000000000000000' * }, * types: { * Person: [ * { name: 'name', type: 'string' }, * { name: 'wallet', type: 'address' } * ], * Mail: [ * { name: 'from', type: 'Person' }, * { name: 'to', type: 'Person' }, * { name: 'contents', type: 'string' } * ] * }, * primaryType: 'Mail', * message: { * from: { * name: 'Cow', * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' * }, * to: { * name: 'Bob', * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' * }, * contents: 'Hello, Bob!' * } * }) * * const signature = Secp256k1.sign({ * payload, * privateKey: '0x...' * }) * ``` * * @param value - The typed data to get the sign payload for. * @returns The payload to use for signing. */ export function getSignPayload(value) { return Hash.keccak256(encode(value)); } /** * Hashes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) domain. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.hashDomain({ * domain: { * name: 'Ether Mail', * version: '1', * chainId: 1, * verifyingContract: * '0x0000000000000000000000000000000000000000' * } * }) * // @log: '0x6192106f129ce05c9075d319c1fa6ea9b3ae37cbd0c1ef92e2be7137bb07baa1' * ``` * * @param value - The Typed Data domain and types. * @returns The hashed domain. */ export function hashDomain(value) { const { domain, types, typeHashes } = value; return hashStruct({ data: domain, primaryType: 'EIP712Domain', types: { ...types, EIP712Domain: types?.EIP712Domain || extractEip712DomainTypes(domain), }, typeHashes, }); } /** * Hashes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) struct. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.hashStruct({ * types: { * Foo: [ * { name: 'address', type: 'address' }, * { name: 'name', type: 'string' }, * { name: 'foo', type: 'string' } * ] * }, * primaryType: 'Foo', * data: { * address: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9', * name: 'jxom', * foo: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9' * } * }) * // @log: '0x996fb3b6d48c50312d69abdd4c1b6fb02057c85aa86bb8d04c6f023326a168ce' * ``` * * @param value - The Typed Data struct to hash. * @returns The hashed Typed Data struct. */ export function hashStruct(value) { const { data, primaryType, types, typeHashes } = value; const encoded = encodeData({ data, primaryType, types, typeHashes, }); return Hash.keccak256(encoded); } /** * Serializes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) schema into string. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * TypedData.serialize({ * domain: { * name: 'Ether!', * version: '1', * chainId: 1, * verifyingContract: * '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' * }, * primaryType: 'Foo', * types: { * Foo: [ * { name: 'address', type: 'address' }, * { name: 'name', type: 'string' }, * { name: 'foo', type: 'string' } * ] * }, * message: { * address: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9', * name: 'jxom', * foo: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9' * } * }) * // @log: "{"domain":{},"message":{"address":"0xb9cab4f0e46f7f6b1024b5a7463734fa68e633f9","name":"jxom","foo":"0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9"},"primaryType":"Foo","types":{"Foo":[{"name":"address","type":"address"},{"name":"name","type":"string"},{"name":"foo","type":"string"}]}}" * ``` * * @param value - The Typed Data schema to serialize. * @returns The serialized Typed Data schema. w */ export function serialize(value) { const { domain: domain_, message: message_, primaryType, types, } = value; const normalizeData = (struct, value) => { const data = { ...value }; for (const param of struct) { const { name, type } = param; if (type === 'address') data[name] = data[name].toLowerCase(); } return data; }; const domain = (() => { if (!domain_) return {}; const type = types.EIP712Domain ?? extractEip712DomainTypes(domain_); return normalizeData(type, domain_); })(); const message = (() => { if (primaryType === 'EIP712Domain') return undefined; if (!types[primaryType]) return {}; return normalizeData(types[primaryType], message_); })(); return Json.stringify({ domain, message, primaryType, types }, (_, value) => { if (typeof value === 'bigint') return value.toString(); return value; }); } /** * Checks if [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) is valid. * * @example * ```ts twoslash * import { TypedData } from 'ox' * * const valid = TypedData.validate({ * domain: { * name: 'Ether!', * version: '1', * chainId: 1, * verifyingContract: * '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' * }, * primaryType: 'Foo', * types: { * Foo: [ * { name: 'address', type: 'address' }, * { name: 'name', type: 'string' }, * { name: 'foo', type: 'string' } * ] * }, * message: { * address: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9', * name: 'jxom', * foo: '0xb9CAB4F0E46F7F6b1024b5A7463734fa68E633f9' * } * }) * // @log: true * ``` * * @param value - The Typed Data to validate. */ export function validate(value) { try { assert(value); return true; } catch { return false; } } /** Thrown when the bytes size of a typed data value does not match the expected size. */ export class BytesSizeMismatchError extends Errors.BaseError { name = 'TypedData.BytesSizeMismatchError'; constructor({ expectedSize, givenSize, }) { super(`Expected bytes${expectedSize}, got bytes${givenSize}.`); } } /** Thrown when the domain is invalid. */ export class InvalidDomainError extends Errors.BaseError { name = 'TypedData.InvalidDomainError'; constructor({ domain }) { super(`Invalid domain "${Json.stringify(domain)}".`, { metaMessages: ['Must be a valid EIP-712 domain.'], }); } } /** Thrown when the primary type of a typed data value is invalid. */ export class InvalidPrimaryTypeError extends Errors.BaseError { name = 'TypedData.InvalidPrimaryTypeError'; constructor({ primaryType, types, }) { super(`Invalid primary type \`${primaryType}\` must be one of \`${JSON.stringify(Object.keys(types))}\`.`, { metaMessages: ['Check that the primary type is a key in `types`.'], }); } } /** Thrown when the struct type is not a valid type. */ export class InvalidStructTypeError extends Errors.BaseError { name = 'TypedData.InvalidStructTypeError'; constructor({ type }) { super(`Struct type "${type}" is invalid.`, { metaMessages: ['Struct type must not be a Solidity type.'], }); } } /** Thrown when an array-typed value is not an array. */ export class InvalidArrayError extends Errors.BaseError { name = 'TypedData.InvalidArrayError'; constructor({ name, type, value, }) { super(`Value for field \`${name}\` of type \`${type}\` is not an array. Got \`${typeof value}\`.`); } } /** Thrown when a fixed-length array does not match its declared length. */ export class InvalidArrayLengthError extends Errors.BaseError { name = 'TypedData.InvalidArrayLengthError'; constructor({ name, type, expectedLength, givenLength, }) { super(`Expected fixed-length array \`${type}\` for field \`${name}\` to have ${expectedLength} elements, got ${givenLength}.`); } } /** @internal */ export function encodeData(value) { const { data, primaryType, types } = value; const typeHashes = value.typeHashes ?? new Map(); const encodedTypes = [{ type: 'bytes32' }]; const encodedValues = [ hashType({ primaryType, types, typeHashes }), ]; for (const field of types[primaryType] ?? []) { const [type, value] = encodeField({ types, name: field.name, type: field.type, value: data[field.name], typeHashes, }); encodedTypes.push(type); encodedValues.push(value); } return AbiParameters.encode(encodedTypes, encodedValues); } /** @internal */ export function hashType(value) { const { primaryType, types, typeHashes } = value; const cached = typeHashes?.get(primaryType); if (cached) return cached; const encodedHashType = Hex.fromString(encodeType({ primaryType, types })); const hash = Hash.keccak256(encodedHashType); typeHashes?.set(primaryType, hash); return hash; } /** @internal */ export function encodeField(properties) { let { types, name, type, value, typeHashes } = properties; if (types[type] !== undefined) return [ { type: 'bytes32' }, Hash.keccak256(encodeData({ data: value, primaryType: type, types, typeHashes })), ]; if (type === 'bytes') { const prepend = value.length % 2 ? '0' : ''; value = `0x${prepend + value.slice(2)}`; return [{ type: 'bytes32' }, Hash.keccak256(value, { as: 'Hex' })]; } if (type === 'string') return [ { type: 'bytes32' }, Hash.keccak256(Bytes.fromString(value), { as: 'Hex' }), ]; if (type.lastIndexOf(']') === type.length - 1) { const arrayMatch = type.match(Solidity.arrayRegex); const parsedType = arrayMatch ? arrayMatch[1] : type.slice(0, type.lastIndexOf('[')); const fixedLength = arrayMatch?.[2] ? Number.parseInt(arrayMatch[2], 10) : undefined; if (!Array.isArray(value)) throw new InvalidArrayError({ name, type, value }); if (fixedLength !== undefined && value.length !== fixedLength) throw new InvalidArrayLengthError({ name, type, expectedLength: fixedLength, givenLength: value.length, }); const typeValuePairs = value.map((item) => encodeField({ name, type: parsedType, types, value: item, typeHashes, })); return [ { type: 'bytes32' }, Hash.keccak256(AbiParameters.encode(typeValuePairs.map(([t]) => t), typeValuePairs.map(([, v]) => v))), ]; } return [{ type }, value]; } /** @internal */ export function findTypeDependencies(value, results = new Set()) { const { primaryType: primaryType_, types } = value; // `/^\w*/u` always matches, so `match` is never null. const match = primaryType_.match(/^\w*/u); const primaryType = match[0]; if (results.has(primaryType) || types[primaryType] === undefined) return results; results.add(primaryType); for (const field of types[primaryType]) findTypeDependencies({ primaryType: field.type, types }, results); return results; } /** @internal */ function validateReference(type) { // Struct type must not be a Solidity type. if (type === 'address' || type === 'bool' || type === 'string' || type.startsWith('bytes') || type.startsWith('uint') || type.startsWith('int')) throw new InvalidStructTypeError({ type }); } //# sourceMappingURL=TypedData.js.map