UNPKG

0xweb

Version:

Contract package manager and other web3 tools

732 lines (655 loc) 28.1 kB
import { secp256k1 } from '@noble/curves/secp256k1' import { TEth } from '@dequanto/models/TEth'; import { $buffer } from './$buffer'; import { $contract } from './$contract'; import { $is } from './$is'; import { $hex } from './$hex'; import { $address } from './$address'; import { $signSerializer } from './$signSerializer'; import { $rlp } from '@dequanto/abi/$rlp'; import { $require } from './$require'; import type { Web3Client } from '@dequanto/clients/Web3Client'; import type { Rpc, RpcTypes } from '@dequanto/rpc/Rpc'; import { $crypto } from './$crypto'; import { $config } from './$config'; import { HDKey } from '@scure/bip32' import { mnemonicToSeedSync } from '@scure/bip39' import { TPlatform } from '@dequanto/models/TPlatform'; import { EoAccount } from '@dequanto/models/TAccount'; export namespace $sig { export async function signTypedData(typedData: Partial<RpcTypes.TypedData>, account: TEth.EoAccount, rpc?: Rpc): Promise<TSignature> { if (account.key != null) { return await KeyUtils.withKey(account, account => $ec.$eip191.signTypedData(typedData, account)); } let signer = await $rpc.ensureRpcSigner(account, rpc); return $rpc.signTypedData(signer, typedData, account); } export async function sign(message: string | Uint8Array, account: TEth.EoAccount, rpc?: Rpc): Promise<TSignature> { if (account.key != null) { return await KeyUtils.withKey(account, account => $ec.sign(message, account)); } let signer = await $rpc.ensureRpcSigner(account, rpc); return $rpc.sign(signer, message, account); } export async function signMessage(message: string | Uint8Array, account: TEth.EoAccount, mix?: Rpc | Web3Client): Promise<TSignature> { if (account.key != null) { return await KeyUtils.withKey(account, account => $ec.$eip191.signMessage(message, account)); } let signer = await $rpc.ensureRpcSigner(account, mix); return $rpc.signMessage(signer, message, account); } export async function signTx(tx: TEth.TxLike, account: TEth.EoAccount, rpc?: Rpc): Promise<TEth.Hex> { tx.from ??= account.address; if ($hex.isEmpty(account.key)) { let signer = await $rpc.ensureRpcSigner(account, rpc); return $rpc.signTx(signer, tx); } return await KeyUtils.withKey(account, account => $ec.signTx(tx, account)); } export function recover(digest: string | TEth.Hex | Uint8Array, signature: TEth.Hex | { v, r, s }): TEth.Address { return $ec.recoverAddress(digest, signature); } export function recoverMessage(digest: string | TEth.Hex | Uint8Array, signature: TEth.Hex | { v, r, s }): TEth.Address { return $ec.$eip191.recoverAddressFromMessage(digest, signature); } export function recoverTx(signedTxRaw: TEth.Hex) { let txSigned = TxDeserializer.deserialize(signedTxRaw); let { v, r, s } = txSigned; let tx = { ...txSigned, v: void 0, r: void 0, s: void 0 } as TEth.TxLike; let hex = TxSerializer.serialize(tx); let hash = $contract.keccak256(hex, 'buffer') let address = $ec.recoverAddress(hash, { v, r, s }); return address; } export namespace $rpc { export async function ensureRpcSigner (account: TEth.EoAccount, mix: Rpc | Web3Client): Promise<Rpc> { if (account.signer) { return account.signer as any; } $require.notNull(mix, `Rpc signer is not provided for ${account.address}`); let rpc = 'getRpc' in mix ? await mix.getRpc() : mix; return rpc; } export async function signTx(rpc: Rpc, tx: TEth.TxLike): Promise<TEth.Hex> { let body = { type: $hex.ensure(tx.type), nonce: $hex.ensure(tx.nonce), to: $hex.ensure(tx.to), from: $hex.ensure(tx.from), gas: $hex.ensure(tx.gas ?? (tx as any).gasLimit /** alias */), value: $hex.ensure(tx.value), input: $hex.ensure(tx.input ?? tx.data), gasPrice: $hex.ensure(tx.gasPrice), maxPriorityFeePerGas: $hex.ensure(tx.maxPriorityFeePerGas), maxFeePerGas: $hex.ensure(tx.maxFeePerGas), accessList: tx.accessList, chainId: $hex.ensure(tx.chainId), } let hex = await rpc.eth_signTransaction(body as any); return hex as TEth.Hex; } export async function signTypedData(rpc: Rpc, typedData: Partial<RpcTypes.TypedData>, account: TEth.EoAccount) { let sig = await rpc.eth_signTypedData_v4(account.address, typedData); return utils.splitSignature(sig as TEth.Hex); } export async function sign(rpc: Rpc, message: string | Uint8Array, account: TEth.EoAccount) { let challenge = $hex.ensure(message); let sig = await rpc.eth_sign(account.address, challenge); return utils.splitSignature(sig as TEth.Hex); } export async function signMessage(rpc: Rpc, message: string | Uint8Array, account: TEth.EoAccount): Promise<TSignature> { let challenge = $hex.ensure(message); let sig = await rpc.personal_sign(challenge, account.address); return utils.splitSignature(sig); } } export namespace $ec { export function signTx(tx: TEth.TxLike, account: TEth.EoAccount): TEth.Hex { let hex = TxSerializer.serialize(tx); let hashed = $contract.keccak256(hex, 'buffer'); let sig = sign(hashed, account, Number(tx.chainId)); let signed = TxSerializer.serialize(tx, sig); return signed; } export function signTypedData(typedData: Partial<RpcTypes.TypedData>, account: TEth.EoAccount) { let challenge = $signSerializer.serializeTypedData(typedData as any); return sign(challenge, account); } export function sign(challenge: string | Uint8Array, account: TEth.EoAccount, chainId?: number): TSignature { const sig = secp256k1.sign( utils.toUint8Array(challenge) as Uint8Array, utils.toUint8Array(account.key, { encoding: 'hex' }) as Uint8Array ); let r = sig.r.toString(16).padStart(64, '0'); let s = sig.s.toString(16).padStart(64, '0'); // https://eips.ethereum.org/EIPS/eip-155 let v = (chainId != null ? sig.recovery + chainId * 2 + 35 : sig.recovery + 27 ).toString(16); return { v: `0x${v}`, r: `0x${r}`, s: `0x${s}`, signature: `0x${r}${s}${v}`, signatureVRS: `0x${v}${r}${s}` } } export function recoverAddress(digest: string | TEth.Hex | Uint8Array, signature: TEth.Hex | { v, r, s }): TEth.Address { if (typeof digest === 'string' && $is.Hex(digest) === false) { digest = utils.toUint8Array(digest, { encoding: 'utf8' }); } const publicKey = recoverPubKey(digest as TEth.Hex | Uint8Array, signature); const address = $contract.keccak256(`0x${publicKey.substring(4)}`).slice(-40) return $address.toChecksum(`0x${address}`); } export function recoverPubKey(digest: TEth.Hex | Uint8Array, signature: TEth.Hex | { v, r, s }) { let { v, r, s } = $is.Hex(signature) ? utils.splitSignature(signature) : signature; let recovery = utils.toYParity(v); r = r.substring(2); s = s.substring(2); const publicKey = secp256k1 .Signature .fromCompact(`${r}${s}`) .addRecoveryBit(recovery) .recoverPublicKey($hex.raw($hex.ensure(digest))) .toHex(false); return `0x${publicKey}` } // https://eips.ethereum.org/EIPS/eip-191 export namespace $eip191 { export function signTypedData(typedData: Partial<RpcTypes.TypedData>, account: TEth.EoAccount) { let challenge = $signSerializer.serializeTypedData(typedData as any); return signMessage(challenge, account); } export function signMessage(challenge: string | Uint8Array, account: TEth.EoAccount) { const buffer = utils.toUint8Array(challenge); const hash = hashPersonalMessage(buffer); return sign(hash, account); } export function recoverAddressFromMessage(challenge: string | Uint8Array, signature: TEth.Hex | { v, r, s }): TEth.Address { const buffer = utils.toUint8Array(challenge); const hash = hashPersonalMessage(buffer); return recoverAddress(hash, signature); } function hashPersonalMessage(buffer: Uint8Array) { const prefix = $buffer.fromString(`\u0019Ethereum Signed Message:\n${buffer.length}`, 'utf-8') return $contract.keccak256($buffer.concat([prefix, buffer])) } } } export namespace $account { export function generate (opts?: { name?: string, platform?: TPlatform }): TEth.EoAccount { const bytes = $crypto.randomBytes(32); const key = $buffer.toHex(bytes); const address = $address.toChecksum(getAddressFromPlainKey(key)); return { ...(opts ?? {}), type: 'eoa', key, address } } export function fromMnemonic(mnemonic: string, index?: number): TEth.EoAccount export function fromMnemonic(mnemonic: string, path: string): TEth.EoAccount export function fromMnemonic(mnemonic: string, mix?: number | string): TEth.EoAccount export function fromMnemonic(mnemonic: string, mix: number | string = 0): TEth.EoAccount { const path = typeof mix === 'number' ? `m/44'/60'/0'/0/${mix}` : mix; const seed = mnemonicToSeedSync(mnemonic); const hdKey = HDKey.fromMasterSeed(seed, ); const account = hdKey.derive(path); const privateKey = $hex.ensure(account.privateKey); return { type: 'eoa', key: privateKey, address: getAddressFromPlainKey(privateKey), }; } export async function fromKey(key: EoAccount['key']): Promise<TEth.EoAccount> { return <EoAccount> { type: 'eoa', address: await getAddressFromKey(key), key: key }; } /** The key may be encrypted */ export function getAddressFromKey(key: TKey): Promise<TEth.Address> { return KeyUtils.withKey({ key }, account => { const publicKey = secp256k1.getPublicKey($buffer.fromHex(account.key), false); const publicKeyHex = $buffer.toHex(publicKey); const address = $contract.keccak256(`0x${publicKeyHex.substring(4)}`).slice(-40) return $address.toChecksum(`0x${address}`); }); } export function getAddressFromPlainKey(key: TEth.Hex): TEth.Address { const publicKey = secp256k1.getPublicKey($buffer.fromHex(key), false); const publicKeyHex = $buffer.toHex(publicKey); const address = $contract.keccak256(`0x${publicKeyHex.substring(4)}`).slice(-40) return $address.toChecksum(`0x${address}`); } } export namespace $key { export async function encrypt (key: TEth.Hex, secret: string) { let encrypted = await $crypto.encrypt(key, { secret, encoding: 'hex'}); return `p1:${encrypted}`; } } namespace utils { export function splitSignature(signature: TEth.Hex): TSignature { let r = '0x' + signature.substring(2, 2 + 64); let s = '0x' + signature.substring(2 + 64, 2 + 64 + 64); let v = '0x' + signature.substring(2 + 64 + 64); return { r, s, v, signature } as TSignature; } export function toUint8Array(message: string | Uint8Array, opts?: { encoding?: 'utf8' | 'hex' }): Uint8Array { if (typeof message === 'string') { let encoding = opts?.encoding; if (encoding == null && $is.Hex(message)) { encoding = 'hex'; message = message.substring(2); } if (encoding === 'hex') { return $buffer.fromHex(message); } return $buffer.fromString(message, opts?.encoding ?? 'utf8'); } return message; } export function toYParity (v: TEth.Hex | number) { let vNum = Number(v); let recovery: 0 | 1; if (vNum === 0 || vNum === 1) { recovery = vNum; } else if (vNum === 27 || vNum === 28) { recovery = (vNum - 27) as 0 | 1; } else if (vNum > 35) { vNum -= 35; recovery = vNum % 2 === 0 ? 0 : 1; } else { throw new Error(`Invalid signature v value: ${v}`); } return recovery; } } export namespace TxSerializer { export function serialize(tx: TEth.TxLike, sig?: TSignature | TEth.Hex): TEth.Hex { if (typeof sig === 'string') { sig = utils.splitSignature(sig); } const type = getTransactionType(tx); switch (type) { case 'eip1559' /* 2 */: return serializeTransactionEIP1559(tx, sig); case 'eip2930' /* 1 */: return serializeTransactionEIP2930(tx, sig); default: return serializeTransactionLegacy(tx, sig); } } // https://eips.ethereum.org/EIPS/eip-1559 // https://eips.ethereum.org/EIPS/eip-2930 function getTransactionType(tx: TEth.TxLike): 'legacy' | 'eip1559' | 'eip2930' { if (tx.type != null) { switch (Number(tx.type)) { case 0: return 'legacy'; case 1: return 'eip2930'; case 2: return 'eip1559'; } } if (tx.maxFeePerGas != null || tx.maxPriorityFeePerGas != null) { return 'eip1559'; } if (tx.gasPrice != null) { if (tx.accessList != null) { return 'eip2930' } return 'legacy'; } throw new Error(`Invalid transaction type: ${tx.type}`) } function serializeTransactionEIP1559(tx: TEth.TxLike, sig?: TSignature): TEth.Hex { const serializedAccessList = serializeAccessList(tx.accessList) const serializedTransaction = [ $to.hex(tx.chainId), $to.hex(tx.nonce), $to.hex(tx.maxPriorityFeePerGas), $to.hex(tx.maxFeePerGas), $to.hex(tx.gas ?? (tx as any).gasLimit /** alias */), $to.hex(tx.to), $to.hex(tx.value), $to.hexNoTrim(tx.data ?? tx.input), serializedAccessList, ]; if (sig) { serializedTransaction.push( $to.hexTrimmed(utils.toYParity(sig.v) === 1 ? 1 : null), // yParity $to.hexTrimmed(sig.r), $to.hexTrimmed(sig.s), ); } return $hex.concat([ '0x02', $rlp.encode(serializedTransaction), ]); } function serializeTransactionEIP2930(tx: TEth.TxLike, sig?: TSignature): TEth.Hex { const serializedAccessList = serializeAccessList(tx.accessList) const serializedTransaction = [ $to.hex(tx.chainId), $to.hex(tx.nonce), $to.hex(tx.gasPrice), $to.hex(tx.gas ?? (tx as any).gasLimit /** alias */), $to.hex(tx.to), $to.hex(tx.value), $to.hexNoTrim(tx.data ?? tx.input), serializedAccessList, ] if (sig) { serializedTransaction.push( $to.hexTrimmed(utils.toYParity(sig.v) === 1 ? 1 : null), // yParity $to.hexTrimmed(sig.r), $to.hexTrimmed(sig.s), ) } return $hex.concat([ '0x01', $rlp.encode(serializedTransaction), ]); } function serializeTransactionLegacy(tx: TEth.TxLike, sig?: TSignature): TEth.Hex { let serializedTransaction = [ $to.hex(tx.nonce), $to.hex(tx.gasPrice), $to.hex(tx.gas ?? (tx as any).gasLimit /** alias */), $to.hex(tx.to), $to.hex(tx.value), $to.hexNoTrim(tx.data ?? tx.input), ]; let v = tx.chainId; if (sig?.v != null) { v = (Number(sig.v) % 2 === 1 ? 0 : 1) + 2 * Number(tx.chainId) + 35; //v = 27 + (Number(sig.v) % 2 === 1 ? 0 : 1); //v = null; } serializedTransaction.push(...[ $to.hexTrimmed(v), $to.hexTrimmed(sig?.r), $to.hexTrimmed(sig?.s), ]); return $rlp.encode(serializedTransaction); } function serializeAccessList(accessList?: TEth.TxLike['accessList']): $rlp.RecursiveArray<TEth.Hex> { let serializedAccessList = [] as $rlp.RecursiveArray<TEth.Hex>[]; if (accessList == null || accessList.length === 0) { return serializedAccessList; } for (let i = 0; i < accessList.length; i++) { const { address, storageKeys } = accessList[i]; $require.Address(address); for (let j = 0; j < storageKeys.length; j++) { storageKeys[j] = $hex.padBytes(storageKeys[j], 32); } serializedAccessList.push([address, storageKeys]) } return serializedAccessList } namespace $to { export function hex (mix) { if (mix == null || (typeof mix === 'number' && mix === 0) || (typeof mix === 'bigint' && mix === 0n)) { return '0x'; } let hex = $hex.ensure(mix); if (hex === '0x0') { return '0x'; } return hex; } export function hexTrimmed (mix) { if (mix == null || (typeof mix === 'number' && mix === 0) || (typeof mix === 'bigint' && mix === 0n)) { return '0x'; } let hex = $hex.ensure(mix); if (hex === '0x0') { return '0x'; } if (hex.startsWith('0x00')) { hex = $hex.trimBytes(hex); } return hex; } export function hexNoTrim (mix) { if (mix == null || (typeof mix === 'number' && mix === 0) || (typeof mix === 'bigint' && mix === 0n)) { return '0x'; } let hex = $hex.ensure(mix); if (hex === '0x0') { return '0x'; } return hex; } } } export namespace TxDeserializer { export function deserialize(txHex: TEth.Hex): TEth.TxSigned { let type = getSerializedTransactionType(txHex); switch (type) { case 'eip1559': return parseTransactionEIP1559(txHex); case 'eip2930': return parseTransactionEIP2930(txHex); default: return parseTransactionLegacy(txHex); } } function getSerializedTransactionType(tx: TEth.Hex): 'legacy' | 'eip1559' | 'eip2930' { const serializedType = $hex.getBytes(tx, 0, 1); if (serializedType === '0x02') { return 'eip1559'; } if (serializedType === '0x01') { return 'eip2930'; } if (serializedType === '0x00' || Number(serializedType) >= 0xc0) { return 'legacy'; } throw new Error(`Invalid tx type ${tx}`); } export function toTransactionArray(serializedTransaction: string) { return $rlp.decode(`0x${serializedTransaction.slice(4)}`) } function parseTransactionEIP1559(txHex: TEth.Hex): TEth.TxSigned { const type = 2; const transactionArray = toTransactionArray(txHex) const [ chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to, value, data, accessList, v, r, s, ] = transactionArray as any[] if (transactionArray.length !== 9 && transactionArray.length !== 12) { throw new Error(`Invalid EIP1559 tx array length: ${transactionArray.length}`) } let tx = <TEth.TxSigned>{ type, chainId: Number(chainId), nonce: $to.number(nonce, 0n), maxPriorityFeePerGas: $to.bigint(maxPriorityFeePerGas, 0n), maxFeePerGas: $to.bigint(maxFeePerGas, 0n), gas: $to.bigint(gas), to: $to.hex(to), value: $to.bigint(value, 0n), data: $to.hex(data), accessList: parseAccessList(accessList), v: $to.number(v, 0), r: r ? $hex.padBytes(r, 32) : r, s: s ? $hex.padBytes(s, 32) : s, }; return tx; } function parseTransactionEIP2930(txHex: TEth.Hex): TEth.TxSigned { const type = 1; const transactionArray = toTransactionArray(txHex) const [ chainId, nonce, gasPrice, gas, to, value, data, accessList, v, r, s, ] = transactionArray as any[]; if (transactionArray.length !== 8 && transactionArray.length !== 11) { throw new Error(`Invalid EIP2930 tx array length: ${transactionArray.length}`) } let tx = <TEth.TxSigned>{ type, chainId: Number(chainId), nonce: $to.number(nonce), gasPrice: $to.bigint(gasPrice), gas: $to.bigint(gas), to: $to.hex(to), value: $to.bigint(value), data: $to.hex(data), accessList: parseAccessList(accessList), v: $to.number(v, 0), r: r ? $hex.padBytes(r, 32) : r, s: s ? $hex.padBytes(s, 32) : s, }; return tx; } function parseTransactionLegacy(txHex: TEth.Hex): TEth.TxSigned { const type = 0; const transactionArray = $rlp.decode(txHex); const [ nonce, gasPrice, gas, to, value, data, chainIdOrV_, r, s ] = transactionArray as any; let hasSig = $hex.isEmpty(r) === false; let v: number = hasSig ? Number(chainIdOrV_) : null; let chainId: number; if ($hex.isEmpty(chainIdOrV_) === false) { if (hasSig === false) { chainId = Number(chainIdOrV_); } else { if (v > 35) { chainId = Math.floor((v - 35) / 2); } } } if (transactionArray.length !== 6 && transactionArray.length !== 9) { throw new Error(`Invalid legacy tx array length: ${transactionArray.length}`); } let tx = <TEth.TxSigned>{ type, chainId: chainId, nonce: $to.number(nonce), gasPrice: $to.bigint(gasPrice), gas: $to.bigint(gas), to: $to.hex(to), value: $to.bigint(value), data: $to.hex(data), v: hasSig ? v : null, r: r ? $hex.padBytes(r, 32) : r, s: s ? $hex.padBytes(s, 32) : s, }; return tx; } function parseAccessList(accessList_: $rlp.RecursiveArray<TEth.Hex>): TEth.AccessListItem[] { if (accessList_.length === 0 || accessList_ === '0x') { return void 0; } const accessList: TEth.AccessListItem[] = [] for (let i = 0; i < accessList_.length; i++) { const [address, storageKeys] = accessList_[i] as [TEth.Hex, TEth.Hex[]] accessList.push({ address: address, storageKeys: storageKeys.map(x => x), }); } return accessList } namespace $to { export function bigint(value: TEth.Hex, $default = void 0): bigint { return $hex.isEmpty(value) ? $default : BigInt(value); } export function hex(value: TEth.Hex): TEth.Hex { return $hex.isEmpty(value) ? void 0 : value; } export function number(value: TEth.Hex, $default = void 0): number { return $hex.isEmpty(value) ? $default : Number(value); } } } export type TSignature = { v: TEth.Hex r: TEth.Hex s: TEth.Hex signature?: TEth.Hex signatureVRS?: TEth.Hex }; } type TKey = TEth.Hex | `p1:0x${string}`; export namespace KeyUtils { const rgx = /^p1:/ export async function withKey <TReturn> (account: TEth.EoAccount, fn: (account: TEth.EoAccount) => TReturn): Promise<TReturn> { let encryptionMatch = rgx.exec(account.key); if (encryptionMatch == null) { return fn(account); } let secret = resolveSecret(); let hex = account.key.substring(encryptionMatch[0].length) as TEth.Hex; let key = await $crypto.decrypt(hex, { secret, encoding: 'hex', }); let accountDecrypted = { address: account.address, key }; try { return fn(accountDecrypted); } finally { delete accountDecrypted.key; key = null; } } function resolveSecret(): string { let pin = $config.get('pin'); if (pin != null) { return pin; } if (typeof process !== 'undefined') { let pin = process.env.PIN; if (pin != null) { return pin; } } throw new Error('Account key is encrypted, please provide a PIN to unlock it.'); } }