ox
Version:
Ethereum Standard Library
690 lines • 21.8 kB
JavaScript
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