UNPKG

ox

Version:

Ethereum Standard Library

912 lines (837 loc) 25.3 kB
import type * as abitype from 'abitype' 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 type { Compute } from './internal/types.js' import * as Json from './Json.js' import * as Solidity from './Solidity.js' export type TypedData = abitype.TypedData export type Domain = abitype.TypedDataDomain export type Parameter = abitype.TypedDataParameter // TODO: Make reusable for Viem? export type Definition< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, /// primaryTypes = typedData extends TypedData ? keyof typedData : string, > = primaryType extends 'EIP712Domain' ? EIP712DomainDefinition<typedData, primaryType> : MessageDefinition<typedData, primaryType, primaryTypes> export type EIP712DomainDefinition< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends 'EIP712Domain' = 'EIP712Domain', /// schema extends Record<string, unknown> = typedData extends TypedData ? abitype.TypedDataToPrimitiveTypes<typedData> : Record<string, unknown>, > = { types?: typedData | undefined } & { primaryType: | 'EIP712Domain' | (primaryType extends 'EIP712Domain' ? primaryType : never) domain: schema extends { EIP712Domain: infer domain } ? domain : Compute<Domain> message?: undefined } export type MessageDefinition< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends keyof typedData = keyof typedData, /// primaryTypes = typedData extends TypedData ? keyof typedData : string, schema extends Record<string, unknown> = typedData extends TypedData ? abitype.TypedDataToPrimitiveTypes<typedData> : Record<string, unknown>, message = schema[primaryType extends keyof schema ? primaryType : keyof schema], > = { types: typedData } & { primaryType: | primaryTypes // show all values | (primaryType extends primaryTypes ? primaryType : never) // infer value domain?: | (schema extends { EIP712Domain: infer domain } ? domain : Compute<Domain>) | undefined message: { [_: string]: any } extends message // Check if message was inferred ? Record<string, unknown> : message } /** * 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< const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | 'EIP712Domain', >(value: assert.Value<typedData, primaryType>): void { const { domain, message, primaryType, types } = value as unknown as assert.Value const validateData = ( struct: readonly Parameter[], data: Record<string, unknown>, ) => { for (const param of struct) { const { name, type } = param const value = data[name] 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 as Hex.Hex) !== Number.parseInt(size, 10)) throw new BytesSizeMismatchError({ expectedSize: Number.parseInt(size, 10), givenSize: Hex.size(value as Hex.Hex), }) } const struct = types[type] if (struct) { validateReference(type) validateData(struct, value as Record<string, unknown>) } } } // 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 }) } } export declare namespace assert { type Value< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, > = Definition<typedData, primaryType> type ErrorType = | Address.InvalidAddressError | BytesSizeMismatchError | InvalidPrimaryTypeError | Hex.fromNumber.ErrorType | Hex.size.ErrorType | Errors.GlobalErrorType } /** * 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: Domain): Hex.Hex { return hashDomain({ domain, }) } export declare namespace domainSeparator { type ErrorType = hashDomain.ErrorType | Errors.GlobalErrorType } /** * 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< const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | 'EIP712Domain', >(value: encode.Value<typedData, primaryType>): Hex.Hex { const { domain = {}, message, primaryType } = value as encode.Value const types = { EIP712Domain: extractEip712DomainTypes(domain), ...value.types, } as TypedData // 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, }) // Typed Data Format: `0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message)` const parts: Hex.Hex[] = ['0x19', '0x01'] if (domain) parts.push( hashDomain({ domain, types, }), ) if (primaryType !== 'EIP712Domain') parts.push( hashStruct({ data: message, primaryType, types, }), ) return Hex.concat(...parts) } export declare namespace encode { type Value< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, > = Definition<typedData, primaryType> type ErrorType = | extractEip712DomainTypes.ErrorType | hashDomain.ErrorType | hashStruct.ErrorType | assert.ErrorType | Errors.GlobalErrorType } /** * 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: encodeType.Value): string { 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 } export declare namespace encodeType { type Value = { primaryType: string types: TypedData } type ErrorType = findTypeDependencies.ErrorType | Errors.GlobalErrorType } /** * 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: Domain | undefined, ): Parameter[] { 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) as Parameter[] } export declare namespace extractEip712DomainTypes { type ErrorType = Errors.GlobalErrorType } /** * 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< const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | 'EIP712Domain', >(value: encode.Value<typedData, primaryType>): Hex.Hex { return Hash.keccak256(encode(value)) } export declare namespace getSignPayload { type ErrorType = | Hash.keccak256.ErrorType | encode.ErrorType | Errors.GlobalErrorType } /** * 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: hashDomain.Value): Hex.Hex { const { domain, types } = value return hashStruct({ data: domain, primaryType: 'EIP712Domain', types: { ...types, EIP712Domain: types?.EIP712Domain || extractEip712DomainTypes(domain), }, }) } export declare namespace hashDomain { type Value = { /** The Typed Data domain. */ domain: Domain /** The Typed Data types. */ types?: | { EIP712Domain?: readonly Parameter[] | undefined [key: string]: readonly Parameter[] | undefined } | undefined } type ErrorType = hashStruct.ErrorType | Errors.GlobalErrorType } /** * 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: hashStruct.Value): Hex.Hex { const { data, primaryType, types } = value const encoded = encodeData({ data, primaryType, types, }) return Hash.keccak256(encoded) } export declare namespace hashStruct { type Value = { /** The Typed Data struct to hash. */ data: Record<string, unknown> /** The primary type of the Typed Data struct. */ primaryType: string /** The types of the Typed Data struct. */ types: TypedData } type ErrorType = | encodeData.ErrorType | Hash.keccak256.ErrorType | Errors.GlobalErrorType } /** * 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< const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | 'EIP712Domain', >(value: serialize.Value<typedData, primaryType>): string { const { domain: domain_, message: message_, primaryType, types, } = value as unknown as serialize.Value const normalizeData = ( struct: readonly Parameter[], value: Record<string, unknown>, ) => { const data = { ...value } for (const param of struct) { const { name, type } = param if (type === 'address') data[name] = (data[name] as string).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 }) } export declare namespace serialize { type Value< typedData extends TypedData | Record<string, unknown> = TypedData, primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, > = Definition<typedData, primaryType> type ErrorType = Json.stringify.ErrorType | Errors.GlobalErrorType } /** * 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< const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | 'EIP712Domain', >(value: assert.Value<typedData, primaryType>): boolean { try { assert(value) return true } catch { return false } } export declare namespace validate { type ErrorType = assert.ErrorType | Errors.GlobalErrorType } /** Thrown when the bytes size of a typed data value does not match the expected size. */ export class BytesSizeMismatchError extends Errors.BaseError { override readonly name = 'TypedData.BytesSizeMismatchError' constructor({ expectedSize, givenSize, }: { expectedSize: number; givenSize: number }) { super(`Expected bytes${expectedSize}, got bytes${givenSize}.`) } } /** Thrown when the domain is invalid. */ export class InvalidDomainError extends Errors.BaseError { override readonly name = 'TypedData.InvalidDomainError' constructor({ domain }: { domain: unknown }) { 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 { override readonly name = 'TypedData.InvalidPrimaryTypeError' constructor({ primaryType, types, }: { primaryType: string; types: TypedData | Record<string, unknown> }) { 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 { override readonly name = 'TypedData.InvalidStructTypeError' constructor({ type }: { type: string }) { super(`Struct type "${type}" is invalid.`, { metaMessages: ['Struct type must not be a Solidity type.'], }) } } /** @internal */ export function encodeData(value: { data: Record<string, unknown> primaryType: string types: TypedData }): Hex.Hex { const { data, primaryType, types } = value const encodedTypes: AbiParameters.Parameter[] = [{ type: 'bytes32' }] const encodedValues: unknown[] = [hashType({ primaryType, types })] for (const field of types[primaryType] ?? []) { const [type, value] = encodeField({ types, name: field.name, type: field.type, value: data[field.name], }) encodedTypes.push(type) encodedValues.push(value) } return AbiParameters.encode(encodedTypes, encodedValues) } /** @internal */ export declare namespace encodeData { type ErrorType = | AbiParameters.encode.ErrorType | encodeField.ErrorType | hashType.ErrorType | Errors.GlobalErrorType } /** @internal */ export function hashType(value: { primaryType: string types: TypedData }): Hex.Hex { const { primaryType, types } = value const encodedHashType = Hex.fromString(encodeType({ primaryType, types })) return Hash.keccak256(encodedHashType) } /** @internal */ export declare namespace hashType { type ErrorType = | Hex.fromString.ErrorType | encodeType.ErrorType | Hash.keccak256.ErrorType | Errors.GlobalErrorType } /** @internal */ export function encodeField(properties: { types: TypedData name: string type: string value: any }): [type: AbiParameters.Parameter, value: Hex.Hex] { let { types, name, type, value } = properties if (types[type] !== undefined) return [ { type: 'bytes32' }, Hash.keccak256(encodeData({ data: value, primaryType: type, types })), ] 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 parsedType = type.slice(0, type.lastIndexOf('[')) const typeValuePairs = (value as [AbiParameters.Parameter, any][]).map( (item) => encodeField({ name, type: parsedType, types, value: item, }), ) return [ { type: 'bytes32' }, Hash.keccak256( AbiParameters.encode( typeValuePairs.map(([t]) => t), typeValuePairs.map(([, v]) => v), ), ), ] } return [{ type }, value] } /** @internal */ export declare namespace encodeField { type ErrorType = | AbiParameters.encode.ErrorType | Hash.keccak256.ErrorType | Bytes.fromString.ErrorType | Errors.GlobalErrorType } /** @internal */ export function findTypeDependencies( value: { primaryType: string types: TypedData }, results: Set<string> = new Set(), ): Set<string> { const { primaryType: primaryType_, types } = value 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 */ export declare namespace findTypeDependencies { type ErrorType = Errors.GlobalErrorType } /** @internal */ function validateReference(type: string) { // 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 }) }