UNPKG

micro-eth-signer

Version:

Minimal library for Ethereum transactions, addresses and smart contracts

660 lines (620 loc) 25.1 kB
import { keccak_256 } from '@noble/hashes/sha3'; import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'; import * as P from 'micro-packed'; import { type IWeb3Provider, type Web3CallArgs, add0x, ethHex, omit, strip0x, zip, } from '../utils.ts'; /* There is NO network code in the file. However, a user can pass NetProvider instance to createContract, and the method would do network requests with the api. There is some really crazy stuff going on here with Typescript types. */ function EPad<T>(p: P.CoderType<T>) { return P.padLeft(32, p, P.ZeroPad); } // Main difference between regular array: length stored outside and offsets calculated without length function ethArray<T>(inner: P.CoderType<T>): P.CoderType<T[]> { return P.wrap({ size: undefined, encodeStream: (w: P.Writer, value: T[]) => { U256BE_LEN.encodeStream(w, value.length); w.bytes(P.array(value.length, inner).encode(value)); }, decodeStream: (r: P.Reader): T[] => P.array(U256BE_LEN.decodeStream(r), inner).decodeStream(r.offsetReader(r.pos)), }); } const PTR = EPad(P.U32BE); const ARRAY_RE = /(.+)(\[(\d+)?\])$/; // TODO: is this correct? // Because u32 in eth is not real u32, just U256BE with limits... const ethInt = (bits: number, signed = false) => { if (!Number.isSafeInteger(bits) || bits <= 0 || bits % 8 !== 0 || bits > 256) throw new Error('ethInt: invalid numeric type'); const _bits = BigInt(bits); const inner = P.bigint(32, false, signed); return P.validate( P.wrap({ size: inner.size, encodeStream: (w: P.Writer, value: bigint) => inner.encodeStream(w, value), decodeStream: (r: P.Reader): bigint => inner.decodeStream(r), }), (value) => { // TODO: validate useful for narrowing types, need to add support in types? if (typeof value === 'number') value = BigInt(value); P.utils.checkBounds(value, _bits, !!signed); return value; } ); }; // Ugly hack, because tuple of pointers considered "dynamic" without any reason. function isDyn<T>(args: P.CoderType<T>[] | Record<string, P.CoderType<T>>) { let res = false; if (Array.isArray(args)) { for (let arg of args) if (arg.size === undefined) res = true; } else { for (let arg in args) if (args[arg].size === undefined) res = true; } return res; } // as const returns readonly stuff, remove readonly property type Writable<T> = T extends {} ? { -readonly [P in keyof T]: Writable<T[P]>; } : T; type ArrLike<T> = Array<T> | ReadonlyArray<T>; export type IsEmptyArray<T> = T extends ReadonlyArray<any> ? (T['length'] extends 0 ? true : false) : true; export type Component<T extends string> = { readonly name?: string; readonly type: T; }; export type NamedComponent<T extends string> = Component<T> & { readonly name: string }; export type BaseComponent = Component<string>; export type Tuple<TC extends ArrLike<Component<string>>> = { readonly name?: string; readonly type: 'tuple'; readonly components: TC; }; // Basic type support // int<M>: two’s complement signed integer type of M bits, 0 < M <= 256, M % 8 == 0. // prettier-ignore type IntIdxType = '' | '8' | '16' | '24' | '32' | '40' | '48' | '56' | '64' | '72' | '80' | '88' | '96' | '104' | '112' | '120' | '128' | '136' | '144' | '152' | '160' | '168' | '176' | '184' | '192' | '200' | '208' | '216' | '224' | '232' | '240' | '248' | '256'; type UintType = `uint${IntIdxType}`; type IntType = `int${IntIdxType}`; type NumberType = UintType | IntType; // bytes<M>: binary type of M bytes, 0 < M <= 32. // prettier-ignore type ByteIdxType = '' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20' | '21' | '22' | '23' | '24' | '25' | '26' | '27' | '28' | '29' | '30' | '31' | '32'; type ByteType = `bytes${ByteIdxType}`; // [{name: 'a', type: 'string'}, {name: 'b', type: 'uint'}] -> {a: string, b: bigint}; export type MapTuple<T> = T extends ArrLike<Component<string> & { name: string }> ? { [K in T[number] as K['name']]: MapType<K>; } : T extends ArrLike<Component<string>> ? // [{name: 'a', type: 'string'}, {type: 'uint'}] -> [string, bigint]; { [K in keyof T]: T[K] extends BaseComponent ? MapType<T[K]> : unknown; } : unknown; // prettier-ignore export type GetType<T extends string> = T extends `${infer Base}[]${infer Rest}` ? GetType<`${Base}${Rest}`>[] : // 'string[]' -> 'string'[] T extends `${infer Base}[${number}]${infer Rest}` ? GetType<`${Base}${Rest}`>[] : // 'string[3]' -> 'string'[] T extends 'address' ? string : T extends 'string' ? string : T extends 'bool' ? boolean : T extends NumberType ? bigint : T extends ByteType ? Uint8Array : unknown; // default // prettier-ignore export type MapType<T extends BaseComponent> = T extends Tuple<Array<Component<string>>> ? MapTuple<T['components']> : T extends Component<infer Type> ? GetType<Type> : unknown; // default // Re-use ptr for len. u32 should be enough. const U256BE_LEN = PTR; export type UnmapType<T> = T extends MapType<infer U> ? U : never; // NOTE: we need as const if we want to access string as values inside types :( export function mapComponent<T extends BaseComponent>(c: T): P.CoderType<MapType<Writable<T>>> { // Arrays (should be first one, since recursive) let m; if ((m = ARRAY_RE.exec(c.type))) { const inner = mapComponent({ ...c, type: m[1] }); if (inner.size === 0) throw new Error('mapComponent: arrays of zero-size elements disabled (possible DoS attack)'); // Static array if (m[3] !== undefined) { const m3 = Number.parseInt(m[3]); if (!Number.isSafeInteger(m3)) throw new Error(`mapComponent: wrong array size=${m[3]}`); let out = P.array(m3, inner); // Static array of dynamic values should be behind pointer too, again without reason. if (inner.size === undefined) out = P.pointer(PTR, out); return out as any; } else { // Dynamic array return P.pointer(PTR, ethArray(inner)) as any; } } if (c.type === 'tuple') { const components: (Component<string> & { name?: string })[] = (c as any).components; let hasNames = true; const args: P.CoderType<any>[] = []; for (let comp of components) { if (!comp.name) hasNames = false; args.push(mapComponent(comp)); } let out: any; // If there is names for all fields -- return struct, otherwise tuple if (hasNames) { const struct: Record<string, P.CoderType<unknown>> = {}; for (const arg of components) { if (struct[arg.name!]) throw new Error(`mapType: same field name=${arg.name}`); struct[arg.name!] = mapComponent(arg); } out = P.struct(struct); } else out = P.tuple(args); // If tuple has dynamic elements it becomes dynamic too, without reason. if (isDyn(args)) out = P.pointer(PTR, out); return out; } if (c.type === 'string') return P.pointer(PTR, P.padRight(32, P.string(U256BE_LEN), P.ZeroPad)) as any; if (c.type === 'bytes') return P.pointer(PTR, P.padRight(32, P.bytes(U256BE_LEN), P.ZeroPad)) as any; if (c.type === 'address') return EPad(P.hex(20, { isLE: false, with0x: true })) as any; if (c.type === 'bool') return EPad(P.bool) as any; if ((m = /^(u?)int([0-9]+)?$/.exec(c.type))) return ethInt(m[2] ? +m[2] : 256, m[1] !== 'u') as any; if ((m = /^bytes([0-9]{1,2})$/.exec(c.type))) { const parsed = +m[1]; if (!parsed || parsed > 32) throw new Error('wrong bytes<N> type'); return P.padRight(32, P.bytes(parsed), P.ZeroPad) as any; } throw new Error(`mapComponent: unknown component=${c}`); } // If only one arg -- use as is, otherwise construct tuple by tuple rules export type ArgsType<T extends ReadonlyArray<any> | undefined> = IsEmptyArray<T> extends true ? undefined // empty arr : T extends ReadonlyArray<any> ? T['length'] extends 1 // single elm ? MapType<T[0]> : MapTuple<T> : MapTuple<T>; // Because args and output are not tuple // TODO: try merge with mapComponent export function mapArgs<T extends ArrLike<Component<string>>>( args: T ): P.CoderType<ArgsType<Writable<T>>> { // More ergonomic input/output if (args.length === 1) return mapComponent(args[0] as any) as any; let hasNames = true; for (const arg of args) if (!arg.name) hasNames = false; if (hasNames) { const out: Record<string, P.CoderType<unknown>> = {}; for (const arg of args) { const name = (arg as any).name; if (out[name]) throw new Error(`mapArgs: same field name=${name}`); out[name] = mapComponent(arg as any) as any; } return P.struct(out) as any; } else return P.tuple(args.map(mapComponent)) as any; } export type FunctionType = Component<'function'> & { readonly inputs?: ReadonlyArray<Component<string>>; readonly outputs?: ReadonlyArray<Component<string>>; }; type ContractMethodDecode<T extends FunctionType, O = ArgsType<T['outputs']>> = IsEmptyArray<T['outputs']> extends true ? { decodeOutput: (b: Uint8Array) => void; } : { decodeOutput: (b: Uint8Array) => O }; type ContractMethodEncode<T extends FunctionType, I = ArgsType<T['inputs']>> = IsEmptyArray<T['inputs']> extends true ? { encodeInput: () => Uint8Array } : { encodeInput: (v: I) => Uint8Array }; type ContractMethodGas<T extends FunctionType, I = ArgsType<T['inputs']>> = IsEmptyArray<T['inputs']> extends true ? { estimateGas: () => Promise<bigint> } : { estimateGas: (v: I) => Promise<bigint> }; type ContractMethodCall< T extends FunctionType, I = ArgsType<T['inputs']>, O = ArgsType<T['outputs']>, > = IsEmptyArray<T['inputs']> extends true ? IsEmptyArray<T['outputs']> extends true ? { // no inputs, no outputs call: () => Promise<void>; } : { // no inputs, outputs call: () => Promise<O>; } : IsEmptyArray<T['outputs']> extends true ? { // inputs, no outputs call: (v: I) => Promise<void>; } : { // inputs, outputs call: (v: I) => Promise<O>; }; export type ContractMethod<T extends FunctionType> = ContractMethodEncode<T> & ContractMethodDecode<T>; export type ContractMethodNet<T extends FunctionType> = ContractMethod<T> & ContractMethodGas<T> & ContractMethodCall<T>; export type FnArg = { readonly type: string; readonly name?: string; readonly components?: ArrLike<FnArg>; readonly inputs?: ArrLike<FnArg>; readonly outputs?: ArrLike<FnArg>; readonly anonymous?: boolean; readonly indexed?: boolean; }; export type ContractTypeFilter<T> = { [K in keyof T]: T[K] extends FunctionType & { name: string } ? T[K] : never; }; export type ContractType<T extends Array<FnArg>, N, F = ContractTypeFilter<T>> = F extends ArrLike<FunctionType & { name: string }> ? { [K in F[number] as K['name']]: N extends IWeb3Provider ? ContractMethodNet<K> : ContractMethod<K>; } : never; function fnSignature(o: FnArg): string { if (!o.type) throw new Error('ABI.fnSignature wrong argument'); if (o.type === 'function' || o.type === 'event') return `${o.name || 'function'}(${(o.inputs || []).map((i) => fnSignature(i)).join(',')})`; if (o.type.startsWith('tuple')) { if (!o.components || !o.components.length) throw new Error('ABI.fnSignature wrong tuple'); return `(${o.components.map((i) => fnSignature(i)).join(',')})${o.type.slice(5)}`; } return o.type; } // Function signature hash export function evSigHash(o: FnArg): string { return bytesToHex(keccak_256(fnSignature(o))); } export function fnSigHash(o: FnArg): string { return evSigHash(o).slice(0, 8); } // High-level constructs for common ABI use-cases /* Call functions always takes two args: array/obj of input values and overrdides of tx params output is array/obj too, but if there is single input or output, then they processed as-is without wrapping in array/obj. if there is at least one named input/output (like (uin256 balance, address)) then it is processed as object, where unnamed elements is refered by index position. Unfortunately it is impossible to do args/kwargs, since named arguments can be before unnamed one. */ export function createContract<T extends ArrLike<FnArg>>( abi: T, net: IWeb3Provider, contract?: string ): ContractType<Writable<T>, IWeb3Provider>; export function createContract<T extends ArrLike<FnArg>>( abi: T, net?: undefined, contract?: string ): ContractType<Writable<T>, undefined>; export function createContract<T extends ArrLike<FnArg>>( abi: T, net?: IWeb3Provider, contract?: string ): ContractType<Writable<T>, undefined> { // Find non-uniq function names so we can handle overloads let nameCnt: Record<string, number> = {}; for (let fn of abi) { if (fn.type !== 'function') continue; const name = fn.name || 'function'; if (!nameCnt[name]) nameCnt[name] = 1; else nameCnt[name]++; } const res: Record<string, any> = {}; for (let fn of abi) { if (fn.type !== 'function') continue; let name = fn.name || 'function'; if (nameCnt[name] > 1) name = fnSignature(fn); const sh = fnSigHash(fn); const inputs = fn.inputs && fn.inputs.length ? mapArgs(fn.inputs) : undefined; const outputs = fn.outputs ? mapArgs(fn.outputs) : undefined; const decodeOutput = (b: Uint8Array) => outputs && outputs.decode(b); const encodeInput = (v: unknown) => concatBytes(hexToBytes(sh), inputs ? inputs.encode(v as any) : new Uint8Array()); res[name] = { decodeOutput, encodeInput }; // .call and .estimateGas call network, when net is available if (!net) continue; res[name].call = async (args: unknown, overrides: Web3CallArgs = {}) => { if (!contract && !overrides.to) throw new Error('No contract address'); const data = add0x(bytesToHex(encodeInput(args))); const callArgs = Object.assign({ to: contract, data }, overrides); return decodeOutput(hexToBytes(strip0x(await net.ethCall(callArgs)))); }; res[name].estimateGas = async (args: unknown, overrides: Web3CallArgs = {}) => { if (!contract && !overrides.to) throw new Error('No contract address'); const data = add0x(bytesToHex(encodeInput(args))); const callArgs = Object.assign({ to: contract, data }, overrides); return await net.estimateGas(callArgs); }; } return res as any; } type GetCons<T extends ArrLike<FnArg>> = Extract<T[number], { type: 'constructor' }>; type ConstructorType = Component<'constructor'> & { readonly inputs?: ReadonlyArray<Component<string>>; }; type ConsArgs<T extends ConstructorType> = IsEmptyArray<T['inputs']> extends true ? undefined : ArgsType<T['inputs']>; export function deployContract<T extends ArrLike<FnArg>>( abi: T, bytecodeHex: string, ...args: GetCons<T> extends never ? [args: unknown] : ConsArgs<GetCons<T>> extends undefined ? [] : [args: ConsArgs<GetCons<T>>] ): string { const bytecode = ethHex.decode(bytecodeHex); let consCall; for (let fn of abi) { if (fn.type !== 'constructor') continue; const inputs = fn.inputs && fn.inputs.length ? mapArgs(fn.inputs) : undefined; if (inputs === undefined && args !== undefined && args.length) throw new Error('arguments to constructor without any'); consCall = inputs ? inputs.encode(args[0] as any) : new Uint8Array(); } if (!consCall) throw new Error('constructor not found'); return ethHex.encode(concatBytes(bytecode, consCall)); } export type EventType = NamedComponent<'event'> & { readonly inputs: ReadonlyArray<Component<string>>; }; export type ContractEventTypeFilter<T> = { [K in keyof T]: T[K] extends EventType ? T[K] : never }; export type TopicsValue<T> = { [K in keyof T]: T[K] | null }; export type EventMethod<T extends EventType> = { decode: (topics: string[], data: string) => ArgsType<T['inputs']>; topics: (values: TopicsValue<ArgsType<T['inputs']>>) => (string | null)[]; }; export type ContractEventType<T extends Array<FnArg>, F = ContractEventTypeFilter<T>> = F extends ArrLike<EventType> ? { [K in F[number] as K['name']]: EventMethod<K>; } : never; // TODO: try to simplify further export function events<T extends ArrLike<FnArg>>(abi: T): ContractEventType<Writable<T>> { let res: Record<string, any> = {}; for (let elm of abi) { // Only named events supported if (elm.type !== 'event' || !elm.name) continue; const inputs = elm.inputs || []; let hasNames = true; for (let i of inputs) if (!i.name) hasNames = false; const plainInp = inputs.filter((i) => !i.indexed); const indexedInp = inputs.filter((i) => i.indexed); const indexed = indexedInp.map((i) => !['string', 'bytes', 'tuple'].includes(i.type) && !ARRAY_RE.exec(i.type) ? (mapArgs([i]) as any) : null ); const parser = mapArgs(hasNames ? plainInp : plainInp.map((i) => omit(i, 'name'))) as any; const sigHash = evSigHash(elm); res[elm.name] = { decode(topics: string[], _data: string) { const data = hexToBytes(strip0x(_data)); if (!elm.anonymous) { if (!topics[0]) throw new Error('No signature on non-anonymous event'); if (strip0x(topics[0]).toLowerCase() !== sigHash) throw new Error('Wrong signature'); topics = topics.slice(1); } if (topics.length !== indexed.length) throw new Error('Wrong topics length'); let parsed = parser ? parser.decode(data) : hasNames ? {} : []; const indexedParsed = indexed.map((p, i) => p ? p.decode(hexToBytes(strip0x(topics[i]))) : topics[i] ); if (plainInp.length === 1) parsed = hasNames ? { [plainInp[0].name!]: parsed } : [parsed]; if (hasNames) { let res = { ...parsed }; for (let [a, p] of zip(indexedInp, indexedParsed)) res[a.name!] = p; return res; } else return inputs.map((i) => (!i.indexed ? parsed : indexedParsed).shift()); }, topics(values: any[] | Record<string, any>) { let res = []; if (!elm.anonymous) res.push(add0x(sigHash)); // We require all keys to be set, even if they are null, to be sure nothing is accidentaly missed if ((hasNames ? Object.keys(values) : values).length !== inputs.length) throw new Error('Wrong topics args'); for (let i = 0, ii = 0; i < inputs.length && ii < indexed.length; i++) { const [input, packer] = [inputs[i], indexed[ii]]; if (!input.indexed) continue; const value = (values as any)[Array.isArray(values) ? i : inputs[i].name!]; if (value === null) { res.push(null); continue; } let topic: string; if (packer) topic = bytesToHex(packer.encode(value)); else if (['string', 'bytes'].includes(input.type)) topic = bytesToHex(keccak_256(value)); else { let m: any, parts: Uint8Array[]; if ((m = ARRAY_RE.exec(input.type))) parts = value.map((j: any) => mapComponent({ type: m[1] }).encode(j)); else if (input.type === 'tuple' && input.components) parts = input.components.map((j) => (mapArgs([j]) as any).encode(value[j.name!])); else throw new Error('Unknown unsized type'); topic = bytesToHex(keccak_256(concatBytes(...parts))); } res.push(add0x(topic)); ii++; } return res; }, }; } return res as any; } // Same as 'Transaction Action' on Etherscan, provides human readable interpritation of decoded data export type ContractABI = ReadonlyArray<FnArg & { readonly hint?: HintFn; readonly hook?: HookFn }>; export type ContractInfo = { abi: 'ERC20' | 'ERC721' | 'ERC1155' | ContractABI; symbol?: string; decimals?: number; // For useful common contracts/exchanges name?: string; // Stable coin price against USD price?: number; }; export type HintOpt = { contract?: string; amount?: bigint; contractInfo?: ContractInfo; contracts?: Record<string, ContractInfo>; }; export type HintFn = (value: unknown, opt: HintOpt) => string; export type HookFn = ( decoder: Decoder, contract: string, info: SignatureInfo, opt: HintOpt ) => SignatureInfo; type SignaturePacker = { name: string; signature: string; packer: P.CoderType<unknown>; hint?: HintFn; // Modifies decoder output. For multicall calls. hook?: HookFn; }; type EventSignatureDecoder = { name: string; signature: string; decoder: (topics: string[], _data: string) => unknown; hint?: HintFn; }; export type SignatureInfo = { name: string; signature: string; value: unknown; hint?: string }; export class Decoder { contracts: Record<string, Record<string, SignaturePacker>> = {}; sighashes: Record<string, SignaturePacker[]> = {}; evContracts: Record<string, Record<string, EventSignatureDecoder>> = {}; evSighashes: Record<string, EventSignatureDecoder[]> = {}; add(contract: string, abi: ContractABI): void { const ev: any = events(abi); contract = strip0x(contract).toLowerCase(); if (!this.contracts[contract]) this.contracts[contract] = {}; if (!this.evContracts[contract]) this.evContracts[contract] = {}; for (let fn of abi) { if (fn.type === 'function') { const selector = fnSigHash(fn); const value = { name: fn.name || 'function', signature: fnSignature(fn), packer: fn.inputs && fn.inputs.length ? (mapArgs(fn.inputs) as any) : undefined, hint: fn.hint, hook: fn.hook, }; this.contracts[contract][selector] = value; if (!this.sighashes[selector]) this.sighashes[selector] = []; this.sighashes[selector].push(value); } else if (fn.type === 'event') { if (fn.anonymous || !fn.name) continue; const selector = evSigHash(fn); const value = { name: fn.name, signature: fnSignature(fn), decoder: ev[fn.name]?.decode, hint: fn.hint, }; this.evContracts[contract][selector] = value; if (!this.evSighashes[selector]) this.evSighashes[selector] = []; this.evSighashes[selector].push(value); } } } method(contract: string, data: Uint8Array): string | undefined { contract = strip0x(contract).toLowerCase(); const sh = bytesToHex(data.slice(0, 4)); if (!this.contracts[contract] || !this.contracts[contract][sh]) return; const { name } = this.contracts[contract][sh]; return name; } // Returns: exact match, possible options of matches (array) or undefined. // Note that empty value possible if there is no arguments in call. decode( contract: string, _data: Uint8Array, opt: HintOpt ): SignatureInfo | SignatureInfo[] | undefined { contract = strip0x(contract).toLowerCase(); const sh = bytesToHex(_data.slice(0, 4)); const data = _data.slice(4); if (this.contracts[contract] && this.contracts[contract][sh]) { let { name, signature, packer, hint, hook } = this.contracts[contract][sh]; const value = packer ? packer.decode(data) : undefined; let res: SignatureInfo = { name, signature, value }; // NOTE: hint && hook fn is used only on exact match of contract! if (hook) res = hook(this, contract, res, opt); try { if (hint) res.hint = hint(value, Object.assign({ contract: add0x(contract) }, opt)); } catch (e) {} return res; } if (!this.sighashes[sh] || !this.sighashes[sh].length) return; let res: SignatureInfo[] = []; for (let { name, signature, packer } of this.sighashes[sh]) { try { res.push({ name, signature, value: packer ? packer.decode(data) : undefined }); } catch (err) {} } if (res.length) return res; return; } decodeEvent( contract: string, topics: string[], data: string, opt: HintOpt ): SignatureInfo | SignatureInfo[] | undefined { contract = strip0x(contract).toLowerCase(); if (!topics.length) return; const sh = strip0x(topics[0]); const event = this.evContracts[contract]; if (event && event[sh]) { let { name, signature, decoder, hint } = event[sh]; const value = decoder(topics, data); let res: SignatureInfo = { name, signature, value }; try { if (hint) res.hint = hint(value, Object.assign({ contract: add0x(contract) }, opt)); } catch (e) {} return res; } if (!this.evSighashes[sh] || !this.evSighashes[sh].length) return; let res: SignatureInfo[] = []; for (let { name, signature, decoder } of this.evSighashes[sh]) { try { res.push({ name, signature, value: decoder(topics, data) }); } catch (err) {} } if (res.length) return res; return; } }