UNPKG

@hethers/abstract-signer

Version:

An Abstract Class for describing an Hedera Hashgraph Signer for hethers.

432 lines (370 loc) 16.2 kB
"use strict"; import { Provider, TransactionRequest, TransactionResponse } from "@hethers/abstract-provider"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import {arrayify, Bytes, BytesLike, hexlify} from "@ethersproject/bytes"; import { Deferrable, defineReadOnly, resolveProperties, shallowCopy } from "@ethersproject/properties"; import { Logger } from "@hethers/logger"; import { version } from "./_version"; import { Account, asAccountString, getAddressFromAccount, getChecksumAddress } from "@hethers/address"; import { SigningKey } from "@hethers/signing-key"; import { AccountId, ContractCallQuery, ContractId, PublicKey as HederaPubKey, TransactionId, PrivateKey, Hbar } from "@hashgraph/sdk"; const logger = new Logger(version); const allowedTransactionKeys: Array<string> = [ "accessList", "chainId", "customData", "data", "from", "gasLimit", "maxFeePerGas", "maxPriorityFeePerGas", "to", "type", "value", "nodeId" ]; // EIP-712 Typed Data // See: https://eips.ethereum.org/EIPS/eip-712 export interface TypedDataDomain { name?: string; version?: string; chainId?: BigNumberish; verifyingContract?: string; salt?: BytesLike; }; export interface TypedDataField { name: string; type: string; }; // Sub-classes of Signer may optionally extend this interface to indicate // they have a private key available synchronously export interface ExternallyOwnedAccount { readonly address?: string; readonly account?: Account; readonly alias?: string; readonly privateKey: string; readonly isED25519Type?: boolean; } // Sub-Class Notes: // - A Signer MUST always make sure, that if present, the "from" field // matches the Signer, before sending or signing a transaction // - A Signer SHOULD always wrap private information (such as a private // key or mnemonic) in a function, so that console.log does not leak // the data // @TODO: This is a temporary measure to preserve backwards compatibility // In v6, the method on TypedDataSigner will be added to Signer export interface TypedDataSigner { _signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string>; } function checkError(method: string, error: any, txRequest: Deferrable<TransactionRequest>) { switch (error.status._code) { // insufficient gas case 30: return logger.throwError("insufficient funds for gas cost", Logger.errors.CALL_EXCEPTION, {tx: txRequest}); // insufficient payer balance case 10: return logger.throwError("insufficient funds in payer account", Logger.errors.INSUFFICIENT_FUNDS, {tx: txRequest}); // insufficient tx fee case 9: return logger.throwError("transaction fee too low", Logger.errors.INSUFFICIENT_FUNDS, {tx: txRequest}) // invalid signature case 7: return logger.throwError("invalid transaction signature", Logger.errors.UNKNOWN_ERROR, {tx: txRequest}); // invalid contract id case 16: return logger.throwError("invalid contract address", Logger.errors.INVALID_ARGUMENT, {tx: txRequest}); // contract revert case 33: return logger.throwError("contract execution reverted", Logger.errors.CALL_EXCEPTION, {tx: txRequest}); } throw error; } export abstract class Signer { readonly provider?: Provider; readonly _signingKey: () => SigningKey; readonly isED25519Type?: boolean; /////////////////// // Sub-classes MUST implement these // Returns the checksum address abstract getAddress(): Promise<string> // Returns the signed prefixed-message. This MUST treat: // - Bytes as a binary message // - string as a UTF8-message // i.e. "0x1234" is a SIX (6) byte string, NOT 2 bytes of data abstract signMessage(message: Bytes | string): Promise<string>; /** * Signs a transaction with the key given upon creation. * The transaction can be: * - FileCreate - when there is only `fileChunk` field in the `transaction.customData` object * - FileAppend - when there is both `fileChunk` and a `fileId` fields * - ContractCreate - when there is a `bytecodeFileId` field * - ContractCall - when there is a `to` field present. Ignores the other fields * * @param transaction - the transaction to be signed. */ abstract signTransaction(transaction: TransactionRequest): Promise<string>; // Returns a new instance of the Signer, connected to provider. // This MAY throw if changing providers is not supported. abstract connect(provider: Provider): Signer; /** * Creates an account for the specified public key and sets initial balance. * @param pubKey * @param initialBalance */ abstract createAccount(pubKey: BytesLike, initialBalance?: BigInt): Promise<TransactionResponse>; readonly _isSigner: boolean; /////////////////// // Sub-classes MUST call super constructor() { logger.checkAbstract(new.target, Signer); defineReadOnly(this, "_isSigner", true); } async getGasPrice(): Promise<BigNumber> { this._checkProvider("getGasPrice"); return await this.provider.getGasPrice(); } /////////////////// // Sub-classes MAY override these async getBalance(): Promise<BigNumber> { this._checkProvider("getBalance"); return await this.provider.getBalance(this.getAddress()); } // Populates "from" if unspecified, and estimates the gas for the transaction async estimateGas(transaction: Deferrable<TransactionRequest>): Promise<BigNumber> { this._checkProvider("estimateGas"); const tx = await resolveProperties(this.checkTransaction(transaction)); // cost-answer query on hedera return await this.provider.estimateGas(tx); } // super classes should override this for now async call(txRequest: Deferrable<TransactionRequest>): Promise<string> { this._checkProvider("call"); const tx = await resolveProperties(this.checkTransaction(txRequest)); const to = asAccountString(tx.to); const from = asAccountString(await this.getAddress()); const nodeID = AccountId.fromString(asAccountString(tx.nodeId)); const paymentTxId = TransactionId.generate(from); const hederaTx = new ContractCallQuery() .setFunctionParameters(arrayify(tx.data)) .setNodeAccountIds([nodeID]) .setGas(BigNumber.from(tx.gasLimit).toNumber()) .setPaymentTransactionId(paymentTxId); if(tx.customData.usingContractAlias) { hederaTx.setContractId(ContractId.fromEvmAddress(0, 0, tx.to.toString())); } else { hederaTx.setContractId(to); } const walletKey = this.isED25519Type ? PrivateKey.fromStringED25519(this._signingKey().privateKey) : PrivateKey.fromStringECDSA(this._signingKey().privateKey); const sdkClient = this.provider.getHederaClient(); sdkClient.setOperator(AccountId.fromString(from), walletKey); const MULTIPLIER = 1.1; const cost = await hederaTx.getCost(sdkClient); const costWithBuffer = Hbar.fromTinybars( cost._valueInTinybar.multipliedBy(MULTIPLIER).toFixed(0) ) hederaTx.setQueryPayment(costWithBuffer); try { const response = await hederaTx.execute(sdkClient); return hexlify(response.bytes); } catch (error) { return checkError('call', error, tx); } } /** * Composes a transaction which is signed and sent to the provider's network. * @param transaction - the actual tx */ async sendTransaction(transaction: Deferrable<TransactionRequest>): Promise<TransactionResponse> { const tx = await resolveProperties(transaction); if (tx.to) { const signed = await this.signTransaction(tx); return await this.provider.sendTransaction(signed); } else { const contractByteCode = tx.data; let chunks = splitInChunks(Buffer.from(contractByteCode.toString()).toString(), 4096); const fileCreate = { customData: { fileChunk: chunks[0], fileKey: HederaPubKey.fromString(this._signingKey().compressedPublicKey) } }; const signedFileCreate = await this.signTransaction(fileCreate); const resp = await this.provider.sendTransaction(signedFileCreate); for (let chunk of chunks.slice(1)) { const fileAppend = { customData: { fileId: resp.customData.fileId.toString(), fileChunk: chunk } }; const signedFileAppend = await this.signTransaction(fileAppend); await this.provider.sendTransaction(signedFileAppend); } const contractCreate = { gasLimit: tx.gasLimit, value:tx.value||0, customData: { bytecodeFileId: resp.customData.fileId.toString(), // @ts-ignore maxAutomaticTokenAssociations: transaction.customData.maxAutomaticTokenAssociations } }; const signedContractCreate = await this.signTransaction(contractCreate); return await this.provider.sendTransaction(signedContractCreate); } } async getChainId(): Promise<number> { this._checkProvider("getChainId"); const network = await this.provider.getNetwork(); return network.chainId; } /** * Checks if the given transaction is usable. * Properties - `from`, `nodeId`, `gasLimit` * @param transaction - the tx to be checked */ checkTransaction(transaction: Deferrable<TransactionRequest>): Deferrable<TransactionRequest> { for (const key in transaction) { if (allowedTransactionKeys.indexOf(key) === -1) { logger.throwArgumentError("invalid transaction key: " + key, "transaction", transaction); } } const tx = shallowCopy(transaction); if (!tx.nodeId) { this._checkProvider(); // provider present, we can go on const submittableNodeIDs = this.provider.getHederaNetworkConfig(); if (submittableNodeIDs.length > 0) { tx.nodeId = submittableNodeIDs[randomNumBetween(0, submittableNodeIDs.length-1)].toString(); } else { logger.throwError("Unable to find submittable node ID. The signer's provider is not connected to any usable network"); } } if (tx.from == null) { tx.from = this.getAddress(); } else { // Make sure any provided address matches this signer tx.from = Promise.all([ Promise.resolve(tx.from), this.getAddress() ]).then((result) => { if (result[0].toString().toLowerCase() !== result[1].toLowerCase()) { logger.throwArgumentError("from address mismatch", "transaction", transaction); } return result[0]; }); } tx.gasLimit = transaction.gasLimit; return tx; } /** * Populates any missing properties in a transaction request. * Properties affected - `to`, `chainId` * @param transaction */ async populateTransaction(transaction: Deferrable<TransactionRequest>): Promise<TransactionRequest> { const tx: Deferrable<TransactionRequest> = await resolveProperties(this.checkTransaction(transaction)) if (tx.to != null) { tx.to = Promise.resolve(tx.to).then(async (to) => { if (to == null) { return null; } return getChecksumAddress(getAddressFromAccount(to)); }); // Prevent this error from causing an UnhandledPromiseException tx.to.catch((error) => { }); } let isCryptoTransfer = false; if (tx.to && tx.value) { if (!tx.data && !tx.gasLimit) { isCryptoTransfer = true; } else if (tx.data && !tx.gasLimit) { logger.throwError("gasLimit is not provided. Cannot execute a Contract Call"); } else if (!tx.data && tx.gasLimit) { this._checkProvider(); if ((await this.provider.getCode(tx.to)) === '0x') { logger.throwError("receiver is an account. Cannot execute a Contract Call"); } } } tx.customData = {...tx.customData, isCryptoTransfer}; const customData = await tx.customData; // FileCreate and FileAppend always carry a customData.fileChunk object const isFileCreateOrAppend = customData && customData.fileChunk; // CreateAccount always has a publicKey const isCreateAccount = customData && customData.publicKey; if (!isFileCreateOrAppend && !isCreateAccount && !tx.customData.isCryptoTransfer && tx.gasLimit == null) { return logger.throwError("cannot estimate gas; transaction requires manual gas limit", Logger.errors.UNPREDICTABLE_GAS_LIMIT, {tx: tx}); } return await resolveProperties(tx); } /////////////////// // Sub-classes SHOULD leave these alone _checkProvider(operation?: string): void { if (!this.provider) { logger.throwError("missing provider", Logger.errors.UNSUPPORTED_OPERATION, { operation: (operation || "_checkProvider") }); } } static isSigner(value: any): value is Signer { return !!(value && value._isSigner); } } export class VoidSigner extends Signer implements TypedDataSigner { readonly address: string; constructor(address: string, provider?: Provider) { logger.checkNew(new.target, VoidSigner); super(); defineReadOnly(this, "address", address); defineReadOnly(this, "provider", provider || null); } getAddress(): Promise<string> { return Promise.resolve(this.address); } _fail(message: string, operation: string): Promise<any> { return Promise.resolve().then(() => { logger.throwError(message, Logger.errors.UNSUPPORTED_OPERATION, { operation: operation }); }); } signMessage(message: Bytes | string): Promise<string> { return this._fail("VoidSigner cannot sign messages", "signMessage"); } signTransaction(transaction: Deferrable<TransactionRequest>): Promise<string> { return this._fail("VoidSigner cannot sign transactions", "signTransaction"); } createAccount(pubKey: BytesLike, initialBalance?: BigInt): Promise<TransactionResponse> { return this._fail("VoidSigner cannot create accounts", "createAccount"); } _signTypedData(domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>): Promise<string> { return this._fail("VoidSigner cannot sign typed data", "signTypedData"); } connect(provider: Provider): VoidSigner { return new VoidSigner(this.address, provider); } } /** * Generates a random integer in the given range * @param min - range start * @param max - range end */ function randomNumBetween(min: number, max: number): number { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Splits data (utf8) into chunks with the given size * @param data * @param chunkSize */ function splitInChunks(data: string, chunkSize: number): string[] { const chunks = []; let num = 0; while (num <= data.length) { const slice = data.slice(num, chunkSize + num); num += chunkSize; chunks.push(slice); } return chunks; }