micro-eth-signer
Version:
Minimal library for Ethereum transactions, addresses and smart contracts
387 lines (363 loc) • 17.4 kB
text/typescript
import { keccak_256 } from '@noble/hashes/sha3';
import { concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
import type { GetType as AbiGetType } from './abi/decoder.ts';
import { mapComponent } from './abi/decoder.ts';
import { addr } from './address.ts';
import { add0x, astr, ethHex, initSig, isObject, sign, strip0x, verify } from './utils.ts';
// EIP-191 signed data (https://eips.ethereum.org/EIPS/eip-191)
export type Hex = string | Uint8Array;
export interface TypedSigner<T> {
_getHash: (message: T) => string;
sign(message: T, privateKey: Hex, extraEntropy?: boolean | Uint8Array): string;
recoverPublicKey(signature: string, message: T): string;
verify(signature: string, message: T, address: string): boolean;
}
// 0x19 <1 byte version> <version specific data> <data to sign>.
// VERSIONS:
// - 0x19 <0x00> <intended validator address> <data to sign>
// - 0x19 <0x01> domainSeparator hashStruct(message)
// - 0x19 <0x45 (E)> <thereum Signed Message:\n" + len(message)> <data to sign>
function getSigner<T>(version: number, msgFn: (message: T) => Uint8Array): TypedSigner<T> {
if (version < 0 || version >= 256 || !Number.isSafeInteger(version))
throw new Error('Wrong version byte');
// bytes32 hash = keccak256(abi.encodePacked(byte(0x19), byte(0), address(this), msg.value, nonce, payload));
const getHash = (message: T) =>
keccak_256(concatBytes(new Uint8Array([0x19, version]), msgFn(message)));
// TODO: 'v' can contain non-undefined chainId, but not sure if it is used. If used, we need to check it with EIP-712 domain
return {
_getHash: (message: T) => ethHex.encode(getHash(message)),
sign(message: T, privateKey: Hex, extraEntropy: boolean | Uint8Array = true) {
const hash = getHash(message);
if (typeof privateKey === 'string') privateKey = ethHex.decode(privateKey);
const sig = sign(hash, privateKey, extraEntropy);
const end = sig.recovery === 0 ? '1b' : '1c';
return add0x(sig.toCompactHex() + end);
},
recoverPublicKey(signature: string, message: T) {
astr(signature);
const hash = getHash(message);
signature = strip0x(signature);
if (signature.length !== 65 * 2) throw new Error('invalid signature length');
const sigh = signature.slice(0, -2);
const end = signature.slice(-2);
if (!['1b', '1c'].includes(end)) throw new Error('invalid recovery bit');
const sig = initSig(hexToBytes(sigh), end === '1b' ? 0 : 1);
const pub = sig.recoverPublicKey(hash).toRawBytes(false);
if (!verify(sig, hash, pub)) throw new Error('invalid signature');
return addr.fromPublicKey(pub);
},
verify(signature: string, message: T, address: string): boolean {
const recAddr = this.recoverPublicKey(signature, message);
const low = recAddr.toLowerCase();
const upp = recAddr.toUpperCase();
if (address === low || address === upp) return true; // non-checksummed
return recAddr === address; // checksummed
},
};
}
// EIP-191/EIP-7749: 0x19 <0x00> <intended validator address> <data to sign>
// export const intendedValidator = getSigner(
// 0x00,
// ({ message, validator }: { message: Uint8Array; validator: string }) => {
// const { data } = addr.parse(validator);
// return concatBytes(hexToBytes(data), message);
// }
// );
// EIP-191: 0x19 <0x45 (E)> <thereum Signed Message:\n" + len(message)> <data to sign>
export const personal: TypedSigner<string | Uint8Array> = getSigner(
0x45,
(msg: string | Uint8Array) => {
if (typeof msg === 'string') msg = utf8ToBytes(msg);
return concatBytes(utf8ToBytes(`thereum Signed Message:\n${msg.length}`), msg);
}
);
// eip712 typed signed data on top of signed data (https://eips.ethereum.org/EIPS/eip-712)
// - V1: no domain, {name: string, type: string, value: any}[] - NOT IMPLEMENTED
// - V3: basic (no arrays and recursive stuff)
// - V4: V3 + support of arrays and recursive stuff
// TODO:
// https://eips.ethereum.org/EIPS/eip-4361: Off-chain authentication for Ethereum accounts to establish sessions
// There is two API for different usage-cases:
// - encodeData/signTyped, verifyTyped -> wallet like application, when we sign already constructed stuff ('web3.eth.personal.signTypedData')
// - encoder(type).encodeData/sign/verify -> if we construct data and want re-use types for different requests + type safety for static types.
// TODO: type is ABI type, but restricted
export type EIP712Component = { name: string; type: string };
export type EIP712Types = Record<string, readonly EIP712Component[]>;
// This makes 'bytes' -> Uint8Array, 'uint' -> bigint. However, we support 'string' for them (JSON in wallets),
// but for static types it is actually better to use strict types, since otherwise everything is 'string'. Address is string,
// but sending it in uint field can be mistake. Please open issue if you have use case where this behavior causes problems.
// prettier-ignore
type ProcessType<T extends string, Types extends EIP712Types> =
T extends `${infer Base}[]${infer Rest}` ? ProcessType<`${Base}${Rest}`, Types>[] : // 'string[]' -> 'string'[]
T extends `${infer Base}[${number}]${infer Rest}` ? ProcessType<`${Base}${Rest}`, Types>[] : // 'string[3]' -> 'string'[]
T extends keyof Types ? GetType<Types, T> | undefined : // recursive
AbiGetType<T>;
export type GetType<Types extends EIP712Types, K extends keyof Types & string> = {
[C in Types[K][number] as C['name']]: ProcessType<C['type'], Types>;
};
type Key<T extends EIP712Types> = keyof T & string;
// TODO: merge with abi somehow?
function parseType(s: string): {
base: string;
item: string;
type: string;
arrayLen: number | undefined;
isArray: boolean;
} {
let m = s.match(/^([^\[]+)(?:.*\[(.*?)\])?$/);
if (!m) throw new Error(`parseType: wrong type: ${s}`);
const base = m[1];
const isArray = m[2] !== undefined;
// TODO: check for safe integer
const arrayLen = m[2] !== undefined && m[2] !== '' ? Number(m[2]) : undefined;
if (arrayLen !== undefined && (!Number.isSafeInteger(arrayLen) || arrayLen.toString() !== m[2]))
throw new Error(`parseType: wrong array length: ${s}`);
let type = 'struct';
if (['string', 'bytes'].includes(base)) type = 'dynamic';
else if (['bool', 'address'].includes(base)) type = 'atomic';
else if ((m = /^(u?)int([0-9]+)?$/.exec(base))) {
const bits = m[2] ? +m[2] : 256;
if (!Number.isSafeInteger(bits) || bits <= 0 || bits % 8 !== 0 || bits > 256)
throw new Error('parseType: invalid numeric type');
type = 'atomic';
} else if ((m = /^bytes([0-9]{1,2})$/.exec(base))) {
const bytes = +m[1];
if (!bytes || bytes > 32) throw new Error(`parseType: wrong bytes<N=${bytes}> type`);
type = 'atomic';
}
const item = s.replace(/\[[^\]]*\]$/, '');
return { base, item, type, arrayLen, isArray };
}
// traverse dependency graph, find all transitive dependencies. Also, basic sanity check
function getDependencies(types: EIP712Types): Record<string, Set<string>> {
if (typeof types !== 'object' || types === null) throw new Error('wrong types object');
// Collect non-basic dependencies & sanity
const res: Record<string, Set<string>> = {};
for (const [name, fields] of Object.entries(types)) {
const cur: Set<string> = new Set(); // type may appear multiple times in struct
for (const { type } of fields) {
const p = parseType(type);
if (p.type !== 'struct') continue; // skip basic fields
if (p.base === name) continue; // self reference
if (!types[p.base]) throw new Error(`getDependencies: wrong struct type name=${type}`);
cur.add(p.base);
}
res[name] = cur;
}
// This should be more efficient with toposort + cycle detection, but I've already spent too much time here
// and for most cases there won't be a lot of types here anyway.
for (let changed = true; changed; ) {
changed = false;
for (const [name, curDeps] of Object.entries(res)) {
// Map here, because curDeps will change
const trDeps = Array.from(curDeps).map((i) => res[i]);
for (const d of trDeps) {
for (const td of d) {
if (td === name || curDeps.has(td)) continue;
curDeps.add(td);
changed = true;
}
}
}
}
return res;
}
function getTypes(types: EIP712Types) {
const deps = getDependencies(types);
const names: Record<string, string> = {};
// Build names
for (const type in types)
names[type] = `${type}(${types[type].map(({ name, type }) => `${type} ${name}`).join(',')})`;
const fullNames: Record<string, string> = {};
for (const [name, curDeps] of Object.entries(deps)) {
const n = [name].concat(Array.from(curDeps).sort());
fullNames[name] = n.map((i) => names[i]).join('');
}
const hashes = Object.fromEntries(Object.entries(fullNames).map(([k, v]) => [k, keccak_256(v)]));
// fields
const fields: Record<string, Set<string>> = {};
for (const type in types) {
const res: Set<string> = new Set();
for (const { name } of types[type]) {
if (res.has(name)) throw new Error(`field ${name} included multiple times in type ${type}`);
res.add(name);
}
fields[type] = res;
}
return { names, fullNames, hashes, fields };
}
// This re-uses domain per multiple requests, which is based on assumption that domain is static for different requests with
// different types. Please raise issue if you have different use case.
export function encoder<T extends EIP712Types>(types: T, domain: GetType<T, 'EIP712Domain'>) {
if (!isObject(domain)) throw Error(`wrong domain=${domain}`);
if (!isObject(types)) throw Error(`wrong types=${types}`);
const info = getTypes(types);
const encodeField = (type: string, data: any, withHash = true): Uint8Array => {
const p = parseType(type);
if (p.isArray) {
if (!Array.isArray(data)) throw new Error(`expected array, got: ${data}`);
if (p.arrayLen !== undefined && data.length !== p.arrayLen)
throw new Error(`wrong array length: expected ${p.arrayLen}, got ${data}`);
return keccak_256(concatBytes(...data.map((i) => encodeField(p.item, i))));
}
if (p.type === 'struct') {
const def = types[type];
if (!def) throw new Error(`wrong type: ${type}`);
const fieldNames = info.fields[type];
if (!isObject(data)) throw new Error(`encoding non-object as custom type ${type}`);
for (const k in data)
if (!fieldNames.has(k)) throw new Error(`unexpected field ${k} in ${type}`);
// TODO: use correct concatBytes (need to export from P?). This will easily crash with stackoverflow if too much fields.
const fields = [];
for (const { name, type } of def) {
// This is not mentioned in spec, but used in eth-sig-util
// Since there is no 'optional' fields inside eip712, it makes impossible to encode circular structure without arrays,
// but seems like other project use this.
// NOTE: this is V4 only stuff. If you need V3 behavior, please open issue.
if (types[type] && data[name] === undefined) {
fields.push(new Uint8Array(32));
continue;
}
fields.push(encodeField(type, data[name]));
}
const res = concatBytes(info.hashes[p.base], ...fields);
return withHash ? keccak_256(res) : res;
}
if (type === 'string' || type === 'bytes') {
if (type === 'bytes' && typeof data === 'string') data = ethHex.decode(data);
return keccak_256(data); // hashed as is!
}
// Type conversion is neccessary here, because we can get data from JSON (no Uint8Arrays/bigints).
if (type.startsWith('bytes') && typeof data === 'string') data = ethHex.decode(data);
if ((type.startsWith('int') || type.startsWith('uint')) && typeof data === 'string')
data = BigInt(data);
return mapComponent({ type }).encode(data);
};
const encodeData = <K extends Key<T>>(type: K, data: GetType<T, K>) => {
astr(type);
if (!types[type]) throw new Error(`Unknown type: ${type}`);
if (!isObject(data)) throw new Error('wrong data object');
return encodeField(type, data, false);
};
const structHash = (type: Key<T>, data: any) => keccak_256(encodeData(type, data));
const domainHash = structHash('EIP712Domain', domain);
// NOTE: we cannot use Msg here, since its already parametrized and everything will break.
const signer = getSigner(0x01, (msg: { primaryType: string; message: any }) => {
if (typeof msg.primaryType !== 'string') throw Error(`wrong primaryType=${msg.primaryType}`);
if (!isObject(msg.message)) throw Error(`wrong message=${msg.message}`);
if (msg.primaryType === 'EIP712Domain') return domainHash;
return concatBytes(domainHash, structHash(msg.primaryType, msg.message));
});
return {
encodeData: <K extends Key<T>>(type: K, message: GetType<T, K>): string =>
ethHex.encode(encodeData(type, message)),
structHash: <K extends Key<T>>(type: K, message: GetType<T, K>): string =>
ethHex.encode(structHash(type, message)),
// Signer
_getHash: <K extends Key<T>>(primaryType: K, message: GetType<T, K>): string =>
signer._getHash({ primaryType, message }),
sign: <K extends Key<T>>(
primaryType: K,
message: GetType<T, K>,
privateKey: Hex,
extraEntropy?: boolean | Uint8Array
): string => signer.sign({ primaryType, message }, privateKey, extraEntropy),
verify: <K extends Key<T>>(
primaryType: K,
signature: string,
message: GetType<T, K>,
address: string
): boolean => signer.verify(signature, { primaryType, message }, address),
recoverPublicKey: <K extends Key<T>>(
primaryType: K,
signature: string,
message: GetType<T, K>
): string => signer.recoverPublicKey(signature, { primaryType, message }),
};
}
export const EIP712Domain = [
{ name: 'name', type: 'string' }, // the user readable name of signing domain, i.e. the name of the DApp or the protocol.
{ name: 'version', type: 'string' }, // the current major version of the signing domain. Signatures from different versions are not compatible.
{ name: 'chainId', type: 'uint256' }, // the EIP-155 chain id. The user-agent should refuse signing if it does not match the currently active chain.
{ name: 'verifyingContract', type: 'address' }, // the address of the contract that will verify the signature. The user-agent may do contract specific phishing prevention.
{ name: 'salt', type: 'bytes32' }, // an disambiguating salt for the protocol. This can be used as a domain separator of last resort.
] as const;
export type DomainParams = typeof EIP712Domain;
const domainTypes = { EIP712Domain: EIP712Domain as DomainParams };
export type EIP712Domain = GetType<typeof domainTypes, 'EIP712Domain'>;
// Filter unused domain fields from type
export function getDomainType(domain: EIP712Domain) {
return EIP712Domain.filter(({ name }) => domain[name] !== undefined);
}
// Additional API without type safety for wallet-like applications
export type TypedData<T extends EIP712Types, K extends Key<T>> = {
types: T;
primaryType: K;
domain: GetType<T, 'EIP712Domain'>;
message: GetType<T, K>;
};
const getTypedTypes = <T extends EIP712Types, K extends Key<T>>(typed: TypedData<T, K>) => ({
EIP712Domain: getDomainType(typed.domain as any),
...typed.types,
});
function validateTyped<T extends EIP712Types, K extends Key<T>>(t: TypedData<T, K>) {
if (!isObject(t.message)) throw new Error('wrong message');
if (!isObject(t.domain)) throw new Error('wrong domain');
if (!isObject(t.types)) throw new Error('wrong types');
if (typeof t.primaryType !== 'string' || !t.types[t.primaryType])
throw new Error('wrong primaryType');
}
export function encodeData<T extends EIP712Types, K extends Key<T>>(
typed: TypedData<T, K>
): string {
validateTyped(typed);
return encoder(getTypedTypes(typed) as T, typed.domain).encodeData(
typed.primaryType,
typed.message
);
}
export function sigHash<T extends EIP712Types, K extends Key<T>>(typed: TypedData<T, K>): string {
validateTyped(typed);
return encoder(getTypedTypes(typed) as T, typed.domain)._getHash(
typed.primaryType,
typed.message
);
}
export function signTyped<T extends EIP712Types, K extends Key<T>>(
typed: TypedData<T, K>,
privateKey: Hex,
extraEntropy?: boolean | Uint8Array
): string {
validateTyped(typed);
return encoder(getTypedTypes(typed) as T, typed.domain).sign(
typed.primaryType,
typed.message,
privateKey,
extraEntropy
);
}
export function verifyTyped<T extends EIP712Types, K extends Key<T>>(
signature: string,
typed: TypedData<T, K>,
address: string
): boolean {
validateTyped(typed);
return encoder(getTypedTypes(typed) as T, typed.domain).verify(
typed.primaryType,
signature,
typed.message,
address
);
}
export function recoverPublicKeyTyped<T extends EIP712Types, K extends Key<T>>(
signature: string,
typed: TypedData<T, K>
): string {
return encoder(getTypedTypes(typed) as T, typed.domain).recoverPublicKey(
typed.primaryType,
signature,
typed.message
);
}
// Internal methods for test purposes only
export const _TEST: any = /* @__PURE__ */ { parseType, getDependencies, getTypes, encoder };