UNPKG

@hethers/transactions

Version:

Utilities for decoding and encoding Hedera Hashgraph transactions for hethers.

336 lines (304 loc) 13 kB
"use strict"; import { AccountLike, asAccountString, getAccountFromAddress, getAddress, getAddressFromAccount, isAddress } from "@hethers/address"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import { arrayify, BytesLike, hexDataLength, hexlify, } from "@ethersproject/bytes"; import { Zero } from "@hethers/constants"; import { computePublicKey } from "@hethers/signing-key"; import { Logger } from "@hethers/logger"; import { version } from "./_version"; import * as base64 from "@ethersproject/base64"; import { AccountCreateTransaction, AccountId, ContractCreateTransaction, ContractExecuteTransaction, ContractId, FileAppendTransaction, FileCreateTransaction, Hbar, HbarUnit, PublicKey as HederaPubKey, Transaction as HederaTransaction, TransactionId, TransferTransaction } from "@hashgraph/sdk"; import { proto } from "@hashgraph/proto"; import Long from "long"; const logger = new Logger(version); /////////////////////////////// // Exported Types export type AccessList = Array<{ address: string, storageKeys: Array<string> }>; // Input allows flexibility in describing an access list export type AccessListish = AccessList | Array<[string, Array<string>]> | Record<string, Array<string>>; export enum TransactionTypes { legacy = 0, eip2930 = 1, eip1559 = 2, } export type UnsignedTransaction = { to?: AccountLike, from?: AccountLike, gasLimit?: BigNumberish, data?: BytesLike, value?: BigNumberish, chainId?: number type?: number; accessList?: AccessListish; maxPriorityFeePerGas?: BigNumberish; maxFeePerGas?: BigNumberish; nodeId?: AccountLike, customData?: Record<string, any>; } export interface Transaction { transactionId: string; hash?: string; to?: string; from?: string; gasLimit: BigNumber; data: string; value: BigNumber; chainId: number; r?: string; s?: string; v?: number; // EIP-2930; Type 1 & EIP-1559; Type 2 accessList?: AccessList; } type HederaTransactionContents = { transactionId: string, hash: string, to?: string, from: string, gasLimit: BigNumber, value: BigNumber, data: string } /////////////////////////////// function handleNumber(value: string): BigNumber { if (value === "0x") { return Zero; } return BigNumber.from(value); } export function computeAlias(key: BytesLike | string, isED25519Type?: boolean): string { const publicKey = computePublicKey(key, false, isED25519Type); return computeAliasFromPubKey(publicKey); } export function computeAliasFromPubKey(pubKey: string): string { return `0.0.${base64.encode(pubKey)}`; } function accessSetify(addr: string, storageKeys: Array<string>): { address: string, storageKeys: Array<string> } { return { address: getAddress(addr), storageKeys: (storageKeys || []).map((storageKey, index) => { if (hexDataLength(storageKey) !== 32) { logger.throwArgumentError("invalid access list storageKey", `accessList[${addr}:${index}]`, storageKey) } return storageKey.toLowerCase(); }) }; } export function accessListify(value: AccessListish): AccessList { if (Array.isArray(value)) { return (<Array<[string, Array<string>] | { address: string, storageKeys: Array<string> }>>value).map((set, index) => { if (Array.isArray(set)) { if (set.length > 2) { logger.throwArgumentError("access list expected to be [ address, storageKeys[] ]", `value[${index}]`, set); } return accessSetify(set[0], set[1]) } return accessSetify(set.address, set.storageKeys); }); } const result: Array<{ address: string, storageKeys: Array<string> }> = Object.keys(value).map((addr) => { const storageKeys: Record<string, true> = value[addr].reduce((accum, storageKey) => { accum[storageKey] = true; return accum; }, <Record<string, true>>{}); return accessSetify(addr, Object.keys(storageKeys).sort()) }); result.sort((a, b) => (a.address.localeCompare(b.address))); return result; } function isAccountLike(str: any) { str = str.toString(); const m = str.split('.').map((e: string) => parseInt(e)).filter((e: number) => e >= 0).length; return m == 3; } function validateMemo(memo: string, memoType: string) { if (memo.length > 100 || memo.length === 0) { logger.throwArgumentError(`invalid ${memoType} memo`, Logger.errors.INVALID_ARGUMENT, { memo: memo }); } } export function serializeHederaTransaction(transaction: UnsignedTransaction, pubKey?: HederaPubKey): HederaTransaction { let tx: HederaTransaction; const arrayifiedData = transaction.data ? arrayify(transaction.data) : new Uint8Array(); const gas = numberify(transaction.gasLimit ? transaction.gasLimit : 0); if (transaction.customData.isCryptoTransfer) { tx = new TransferTransaction() .addHbarTransfer(asAccountString(transaction.from), new Hbar(transaction.value.toString(), HbarUnit.Tinybar).negated()) .addHbarTransfer(asAccountString(transaction.to), new Hbar(transaction.value.toString(), HbarUnit.Tinybar)); } else if (transaction.to) { tx = new ContractExecuteTransaction() .setFunctionParameters(arrayifiedData) .setGas(gas); if (transaction.value) { (tx as ContractExecuteTransaction).setPayableAmount(Hbar.fromTinybars(transaction.value?.toString())); } if (transaction.customData.usingContractAlias) { (tx as ContractExecuteTransaction).setContractId(ContractId.fromEvmAddress(0, 0, transaction.to.toString())); } else { (tx as ContractExecuteTransaction).setContractId(asAccountString(transaction.to)); } } else { if (transaction.customData.bytecodeFileId) { tx = new ContractCreateTransaction() .setBytecodeFileId(transaction.customData.bytecodeFileId) .setConstructorParameters(arrayifiedData) .setInitialBalance(transaction.value?.toString()) .setMaxAutomaticTokenAssociations(transaction.customData?.maxAutomaticTokenAssociations ?? 0) .setGas(gas); if (transaction.customData.contractAdminKey) { const inputKey = transaction.customData.contractAdminKey; const keyInitializer: proto.IKey = {}; if (inputKey.toString().startsWith('0x')) { if (isAddress(inputKey)) { const account = getAccountFromAddress(inputKey); keyInitializer.contractID = { shardNum: new Long(numberify(account.shard)), realmNum: new Long(numberify(account.realm)), contractNum: new Long(numberify(account.num)) }; } else { keyInitializer.ECDSASecp256k1 = arrayify(inputKey); } } if (isAccountLike(inputKey)) { const account = inputKey.split('.').map((e: string) => parseInt(e)); keyInitializer.contractID = { shardNum: new Long(account[0]), realmNum: new Long(account[1]), contractNum: new Long(account[2]) } } const key = HederaPubKey._fromProtobufKey(proto.Key.create(keyInitializer)); (tx as ContractCreateTransaction).setAdminKey(key); } if (transaction.customData.contractMemo) { validateMemo(transaction.customData.contractMemo, 'contract'); (tx as ContractCreateTransaction).setContractMemo(transaction.customData.contractMemo); } } else { if (transaction.customData.fileChunk && transaction.customData.fileId) { tx = new FileAppendTransaction() .setContents(transaction.customData.fileChunk) .setFileId(transaction.customData.fileId) .setChunkSize(4096); } else if (!transaction.customData.fileId && transaction.customData.fileChunk) { // only a chunk, thus the first one tx = new FileCreateTransaction() .setContents(transaction.customData.fileChunk) .setKeys([transaction.customData.fileKey ? transaction.customData.fileKey : pubKey ]); } else if (transaction.customData.publicKey) { const { publicKey, initialBalance } = transaction.customData; tx = new AccountCreateTransaction() .setKey(HederaPubKey.fromString(publicKey.toString())) .setInitialBalance(Hbar.fromTinybars(initialBalance.toString())); } else { logger.throwArgumentError( "Cannot determine transaction type from given custom data. Need either `to`, `fileChunk`, `fileId` or `bytecodeFileId`", Logger.errors.INVALID_ARGUMENT, transaction); } } } if (transaction.customData.memo) { validateMemo(transaction.customData.memo, 'tx'); tx.setTransactionMemo(transaction.customData.memo); } const account = getAccountFromAddress(transaction.from.toString()); tx.setTransactionId( TransactionId.generate(new AccountId({ shard: numberify(account.shard), realm: numberify(account.realm), num: numberify(account.num) }))) .setNodeAccountIds([AccountId.fromString(transaction.nodeId.toString())]) .freeze(); return tx; } export async function parse(rawTransaction: BytesLike): Promise<Transaction> { const payload = arrayify(rawTransaction); let parsed; try { parsed = HederaTransaction.fromBytes(payload); } catch (error) { logger.throwArgumentError(error.message, "rawTransaction", rawTransaction); } const tx = parsed.transactionId; const nanos = tx.validStart.nanos.toString().padStart(9, '0'); const seconds = tx.validStart.seconds.toString().padStart(10, '0'); let contents = { transactionId: `${tx.accountId.toString()}-${seconds}-${nanos}`, hash: hexlify(await parsed.getTransactionHash()), //stringify? from: getAddressFromAccount(parsed.transactionId.accountId.toString()), } as HederaTransactionContents; if (parsed instanceof ContractExecuteTransaction) { parsed = parsed as ContractExecuteTransaction; contents.to = getAddressFromAccount(parsed.contractId?.toString()); contents.gasLimit = handleNumber(parsed.gas.toString()); contents.value = parsed.payableAmount ? handleNumber(parsed.payableAmount.toTinybars().toString()) : handleNumber('0'); contents.data = parsed.functionParameters ? hexlify(parsed.functionParameters) : '0x'; } else if (parsed instanceof ContractCreateTransaction) { parsed = parsed as ContractCreateTransaction; contents.gasLimit = handleNumber(parsed.gas.toString()); contents.value = parsed.initialBalance ? handleNumber(parsed.initialBalance.toBigNumber().toString()) : handleNumber('0'); // TODO IMPORTANT! We are setting only the constructor arguments and not the whole bytecode + constructor args contents.data = parsed.constructorParameters ? hexlify(parsed.constructorParameters) : '0x'; } else if (parsed instanceof FileCreateTransaction) { parsed = parsed as FileCreateTransaction; contents.data = hexlify(Buffer.from(parsed.contents)); } else if (parsed instanceof FileAppendTransaction) { parsed = parsed as FileAppendTransaction; contents.data = hexlify(Buffer.from(parsed.contents)); } else if (parsed instanceof TransferTransaction) { // TODO populate value / to? } else if (parsed instanceof AccountCreateTransaction) { parsed = parsed as AccountCreateTransaction; contents.value = parsed.initialBalance ? handleNumber(parsed.initialBalance.toBigNumber().toString()) : handleNumber('0'); } else { return logger.throwError(`unsupported transaction`, Logger.errors.UNSUPPORTED_OPERATION, { operation: "parse" }); } // TODO populate r, s ,v return { ...contents, chainId: 0, r: '', s: '', v: 0, }; } function numberify(num: BigNumberish) { return BigNumber.from(num).toNumber(); }