diff --git a/web3js-ext/example/valueTransfer.js b/web3js-ext/example/valueTransfer.js index 59c7ecce9..0be0dc96f 100644 --- a/web3js-ext/example/valueTransfer.js +++ b/web3js-ext/example/valueTransfer.js @@ -5,6 +5,8 @@ const priv = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 const addr = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const to = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"; const url = "http://localhost:8545"; +const contractAddr = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +const data_increment = "0xd09de08a"; // Counter.sol:increment() async function main() { let provider = new Web3.providers.HttpProvider(url); @@ -15,34 +17,35 @@ async function main() { let sender = web3.eth.accounts.privateKeyToAccount(priv); console.log({ sender }); + /* let tx = { from: sender.address, to: to, value: 1e9, // nonce: await web3.eth.getTransactionCount(addr), // gas: 21000, - gasPrice: 25e9, - type: 8, + // gasPrice: 25e9, + type: 0x08, }; + /*/ + let tx = { + from: sender.address, + to: contractAddr, + value: 0, + nonce: await web3.eth.getTransactionCount(addr), + gas: 100_000, + gasPrice: 25e9, + data: data_increment, + data: "0xdeadbeef", // trigger error + type: 0x30, + } + //*/ let signResult = await web3.eth.accounts.signTransaction(tx, sender.privateKey); console.log({ signResult }); - //* - // TODO: auto-assign jsonrpc and id fields - let sendResult = await web3.eth.provider.request({ - jsonrpc: "2.0", id: "1", - method: "klay_sendRawTransaction", params: [signResult.rawTransaction] }); - let txhash = sendResult.result; - console.log({ sendResult }); - - // web3.eth.sendSignedTransaction would wait for tx mining, but provider.requestt do not. - // TODO: modify sendSignedTransaction to use klay_sendRawTransaction - await new Promise((r) => setTimeout(r, 2000)); - /*/ let sendResult = await web3.eth.sendSignedTransaction(signResult.rawTransaction); let txhash = sendResult.transactionHash; - //*/ let receipt = await web3.eth.getTransactionReceipt(txhash); console.log({ receipt }); diff --git a/web3js-ext/src/web3/klaytn_rpc.ts b/web3js-ext/src/web3/klaytn_rpc.ts new file mode 100644 index 000000000..e69de29bb diff --git a/web3js-ext/src/web3/klaytn_tx.ts b/web3js-ext/src/web3/klaytn_tx.ts index 248ed6911..4abb36505 100644 --- a/web3js-ext/src/web3/klaytn_tx.ts +++ b/web3js-ext/src/web3/klaytn_tx.ts @@ -22,7 +22,7 @@ const web3jsAllowedTransactionKeys = [ // web3.js may strip or reject some Klaytn-specific transaction fields. // To prserve transaction fields around web3js function calls, use saveCustomFields. -function saveCustomFields(tx: any): any { +export function saveCustomFields(tx: any): any { // Save fields that are not allowed in web3.js const savedFields: any = {}; for (const key in tx) { @@ -58,7 +58,7 @@ export async function prepareTransaction( let txData = { ...tx, ...savedFields }; - // Below fields might + // Below fields might be // (1) not specified at the first place, // (2) or lost during prepareTransactionForSigning, // (3) or not populated by prepareTransactionForSigning. @@ -129,6 +129,7 @@ export class KlaytnTx extends LegacyTransaction { value: toHex(this.value), from: this.from ? this.from : undefined, data: bytesToHex(this.data), + input: bytesToHex(this.data), chainId: this.chainId ? toHex(this.chainId) : undefined, }); if (this.v && this.r && this. s) { diff --git a/web3js-ext/src/web3/rpc.ts b/web3js-ext/src/web3/rpc.ts deleted file mode 100644 index 405b94e45..000000000 --- a/web3js-ext/src/web3/rpc.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Web3Context, Web3PromiEvent } from "web3-core"; -import { Bytes, DataFormat, FormatType, TransactionReceipt, EthExecutionAPI } from "web3-types"; -import { SendSignedTransactionOptions, SendSignedTransactionEvents, sendSignedTransaction } from "web3-eth"; - -// A wrapper around the sendRawTransaction RPC. There is no such RPC named "sendSignedTransaction". -// See web3-eth/src/rpc_method_wrappers.ts:sendSignedTransaction -// See web3-eth/src/utils/try_send_transaction.ts -export function klay_sendSignedTransaction< - ReturnFormat extends DataFormat, - ResolveType = FormatType, ->( - web3Context: Web3Context, - signedTransaction: Bytes, - returnFormat: ReturnFormat, - options: SendSignedTransactionOptions = { checkRevertBeforeSending: true }, -): Web3PromiEvent> { - return sendSignedTransaction(web3Context, signedTransaction, returnFormat, options); -} \ No newline at end of file diff --git a/web3js-ext/src/web3/send_transaction.ts b/web3js-ext/src/web3/send_transaction.ts new file mode 100644 index 000000000..c643afa60 --- /dev/null +++ b/web3js-ext/src/web3/send_transaction.ts @@ -0,0 +1,427 @@ +import { Web3Context, Web3PromiEvent } from "web3-core"; +import { + Bytes, DataFormat, FormatType, TransactionCall, TransactionReceipt, + EthExecutionAPI, ETH_DATA_FORMAT, DEFAULT_RETURN_FORMAT, ContractAbi, AbiErrorFragment +} from "web3-types"; +import { format, toHex, rejectIfTimeout, pollTillDefined } from "web3-utils"; +import { + SendSignedTransactionOptions, SendSignedTransactionEvents, sendSignedTransaction, + transactionReceiptSchema, getTransactionReceipt, call, + RevertReason, RevertReasonWithCustomError +} from "web3-eth"; +import { + ContractExecutionError, + InvalidResponseError, + TransactionRevertedWithoutReasonError, + TransactionRevertInstructionError, + TransactionRevertWithCustomError, + TransactionSendTimeoutError, + TransactionPollingTimeoutError, + Eip838ExecutionError, +} from 'web3-errors'; +import { isAbiErrorFragment, decodeContractErrorData } from "web3-eth-abi"; +import _ from "lodash"; + +import { KlaytnTxFactory } from "@klaytn/ethers-ext"; + +import { saveCustomFields } from "./klaytn_tx"; + +// Platform-independent NodeJS timeout types +type TimeoutT = ReturnType; +type IntervalT = ReturnType; + +// A wrapper around the sendRawTransaction RPC. There is no such RPC named "sendSignedTransaction". +// See web3-eth/src/rpc_method_wrappers.ts:sendSignedTransaction +// See web3-eth/src/utils/try_send_transaction.ts +export function klay_sendSignedTransaction< + ReturnFormat extends DataFormat, + ResolveType = FormatType, +>( + web3Context: Web3Context, + signedTransaction: Bytes, + returnFormat: ReturnFormat, + options: SendSignedTransactionOptions = { checkRevertBeforeSending: true }, +): Web3PromiEvent> { + + // Short circuit if the transaction is an Ethereum transaction + const signedTransactionFormattedHex = format( + { format: 'bytes' }, + signedTransaction, + ETH_DATA_FORMAT, + ); + const typeByte = signedTransactionFormattedHex.substring(0, 4); + if (!KlaytnTxFactory.has(typeByte)) { + return sendSignedTransaction(web3Context, signedTransaction, returnFormat, options); + } + + // Parse the signed KlaytnTx + const unSerializedTransaction = KlaytnTxFactory.fromRLP(signedTransactionFormattedHex).toObject(); + const unSerializedTransactionForCall = _.clone(unSerializedTransaction); + saveCustomFields(unSerializedTransactionForCall); + + // Because modifying the rpc name to "klay_sendRawTransaction" is not trivial, + // we resort to reimplement the whole logic. + + const doCheck = async ( + promiEvent: Web3PromiEvent>, + ) => { + if (options.checkRevertBeforeSending) { + const reason = await getRevertReason( + web3Context, + unSerializedTransactionForCall, + options.contractAbi, + ); + if (reason !== undefined) { + const error = await getTransactionError( + web3Context, + unSerializedTransactionForCall, + undefined, + undefined, + options.contractAbi, + reason, + ); + + if (promiEvent.listenerCount('error') > 0) { + promiEvent.emit('error', error); + } + + return error; + } + } + return null; + } + + const doSend = async ( + promiEvent: Web3PromiEvent>, + ) => { + if (promiEvent.listenerCount('sending') > 0) { + promiEvent.emit('sending', signedTransactionFormattedHex); + } + + const transactionHash = await trySendTransaction( + web3Context, + signedTransactionFormattedHex + ); + const transactionHashFormatted = format( + { format: 'bytes32' }, + transactionHash as Bytes, + returnFormat + ); + + if (promiEvent.listenerCount('sent') > 0) { + promiEvent.emit('sent', signedTransactionFormattedHex); + } + if (promiEvent.listenerCount('transactionHash') > 0) { + promiEvent.emit('transactionHash', transactionHashFormatted); + } + + return transactionHash; + } + + const doWait = async ( + promiEvent: Web3PromiEvent>, + resolve: (data: ResolveType) => void, + reject: (reason: unknown) => void, + transactionHash: string, + ) => { + const transactionReceipt = await waitForTransactionReceipt( + web3Context, + transactionHash, + returnFormat, + ) + const transactionReceiptFormatted = format( + transactionReceiptSchema, + transactionReceipt, + returnFormat, + ); + console.log("receipt", transactionReceiptFormatted); + + if (promiEvent.listenerCount('receipt') > 0) { + promiEvent.emit('receipt', transactionReceiptFormatted); + } + + if (promiEvent.listenerCount('confirmation') > 0) { + // Klaytn transactions are immediately confirmed. + promiEvent.emit('confirmation', { + confirmations: format({ format: 'uint' }, 1 as number, returnFormat), + receipt: transactionReceiptFormatted, + latestBlockHash: format( + { format: 'bytes32' }, + transactionReceipt.blockHash as Bytes, + returnFormat, + ), + }); + } + + if (options?.transactionResolver) { + resolve( + options?.transactionResolver( + transactionReceiptFormatted, + ) as unknown as ResolveType, + ); + } else if (transactionReceipt.status === BigInt(0)) { + const error = await getTransactionError( + web3Context, + unSerializedTransactionForCall, + transactionReceiptFormatted, + undefined, + options?.contractAbi, + ); + + if (promiEvent.listenerCount('error') > 0) { + promiEvent.emit('error', error); + } + + reject(error); + } else { + resolve(transactionReceiptFormatted as unknown as ResolveType); + } + + } + + const doError = async ( + promiEvent: Web3PromiEvent>, + reject: (reason: unknown) => void, + error: any, + ) => { + let _error = error; + + if (_error instanceof ContractExecutionError && web3Context.handleRevert) { + _error = await getTransactionError( + web3Context, + unSerializedTransactionForCall, + undefined, + undefined, + options?.contractAbi, + ); + } + + if ( + (_error instanceof InvalidResponseError || + _error instanceof ContractExecutionError || + _error instanceof TransactionRevertWithCustomError || + _error instanceof TransactionRevertedWithoutReasonError || + _error instanceof TransactionRevertInstructionError) && + promiEvent.listenerCount('error') > 0 + ) { + promiEvent.emit('error', _error); + } + + reject(_error); + } + + const promiEvent = new Web3PromiEvent>( + (resolve, reject) => { + setImmediate(() => { + (async () => { + try { + const checkError = await doCheck(promiEvent); + if (checkError) { + reject(checkError); + return; + } + + const transactionHash = await doSend(promiEvent); + await doWait(promiEvent, resolve, reject, transactionHash); + } catch (error) { + await doError(promiEvent, reject, error); + } + })() + }); + }); + return promiEvent; +} + +// Re-implemented trySendTransaction because it's not exported. +// See web3-eth/src/utils/try_send_transaction.ts +export async function trySendTransaction( + web3Context: Web3Context, + rawTransaction: string, +): Promise { + const sendRpc = async () => { + return web3Context.requestManager.send({ + method: "klay_sendRawTransaction", + params: [rawTransaction], + }) as Promise; + }; + + const sendTimeout = web3Context.transactionSendTimeout; + const sendTimeoutError = new TransactionSendTimeoutError({ + numberOfSeconds: sendTimeout / 1000 + }); + const [sendTimeoutId, rejectOnSendTimeout] = rejectIfTimeout(sendTimeout, sendTimeoutError); + + // Will not implement transactionBlockTimeout because + // (1) rejectIfBlockTimeout is not exported + // (2) it's too complex to copy and paste + // (3) transactionSendTimeout does the same job anyway. + + try { + return await Promise.race([ + sendRpc(), + rejectOnSendTimeout, + ]) + } finally { + clearTimeout(sendTimeoutId as TimeoutT); + } +} + +// Re-implemented waitForTransactionReceipt because it's not exported. +// See web3-eth/src/utils/wait_for_transaction_receipt.ts +export async function waitForTransactionReceipt( + web3Context: Web3Context, + transactionHash: Bytes, + returnFormat: ReturnFormat, +): Promise { + const pollingInterval = + web3Context.transactionReceiptPollingInterval ?? web3Context.transactionPollingInterval; + const awaitableTransactionReceipt = pollTillDefined(async () => { + try { + return getTransactionReceipt(web3Context, transactionHash, returnFormat); + } catch (error) { + console.warn('An error happen while trying to get the transaction receipt', error); + return undefined; + } + }, pollingInterval); + + const pollTimeout = web3Context.transactionPollingTimeout; + const pollTimeoutError = new TransactionPollingTimeoutError({ + numberOfSeconds: pollTimeout / 1000, + transactionHash, + }); + const [pollTimeoutId, rejectOnPollTimeout] = rejectIfTimeout(pollTimeout, pollTimeoutError); + + // Will not implement transactionBlockTimeout because + // (1) rejectIfBlockTimeout is not exported + // (2) it's too complex to copy and paste + // (3) transactionPollingTimeout does the same job anyway. + + try { + return await Promise.race([ + awaitableTransactionReceipt, + rejectOnPollTimeout, + ]); + } finally { + clearTimeout(pollTimeoutId as TimeoutT); + } +} + +// Re-implemented getTransactionError because it's not exported. +// See web3-eth/src/utils/get_transaction_error.ts +export async function getTransactionError( + web3Context: Web3Context, + transactionFormatted?: TransactionCall, + transactionReceiptFormatted?: FormatType, + receivedError?: unknown, + contractAbi?: ContractAbi, + knownReason?: string | RevertReason | RevertReasonWithCustomError, +) { + let _reason: string | RevertReason | RevertReasonWithCustomError | undefined = knownReason; + + console.log(web3Context.handleRevert, transactionFormatted, contractAbi); + web3Context.handleRevert = true; + if (transactionFormatted) transactionFormatted.value = "0x0"; + if (receivedError) { + _reason = parseTransactionError(receivedError); + } else if (web3Context.handleRevert && transactionFormatted !== undefined) { + _reason = await getRevertReason(web3Context, transactionFormatted, contractAbi); + } + + let error: + | TransactionRevertedWithoutReasonError> + | TransactionRevertInstructionError> + | TransactionRevertWithCustomError>; + if (_reason === undefined) { + error = new TransactionRevertedWithoutReasonError< + FormatType + >(transactionReceiptFormatted); + } else if (typeof _reason === 'string') { + error = new TransactionRevertInstructionError>( + _reason, + undefined, + transactionReceiptFormatted, + ); + } else if ( + (_reason as RevertReasonWithCustomError).customErrorName !== undefined && + (_reason as RevertReasonWithCustomError).customErrorDecodedSignature !== undefined && + (_reason as RevertReasonWithCustomError).customErrorArguments !== undefined + ) { + const reasonWithCustomError: RevertReasonWithCustomError = + _reason as RevertReasonWithCustomError; + error = new TransactionRevertWithCustomError>( + reasonWithCustomError.reason, + reasonWithCustomError.customErrorName, + reasonWithCustomError.customErrorDecodedSignature, + reasonWithCustomError.customErrorArguments, + reasonWithCustomError.signature, + transactionReceiptFormatted, + reasonWithCustomError.data, + ); + } else { + error = new TransactionRevertInstructionError>( + _reason.reason, + _reason.signature, + transactionReceiptFormatted, + _reason.data, + ); + } + + return error; +} + +// See web3-eth/src/utils/get_revert_reason.ts +export const parseTransactionError = (error: unknown, contractAbi?: ContractAbi) => { + if ( + error instanceof ContractExecutionError && + error.innerError instanceof Eip838ExecutionError + ) { + if (contractAbi !== undefined) { + const errorsAbi = contractAbi.filter(abi => + isAbiErrorFragment(abi), + ) as unknown as AbiErrorFragment[]; + decodeContractErrorData(errorsAbi, error.innerError); + + return { + reason: error.innerError.message, + signature: error.innerError.data?.slice(0, 10), + data: error.innerError.data?.substring(10), + customErrorName: error.innerError.errorName, + customErrorDecodedSignature: error.innerError.errorSignature, + customErrorArguments: error.innerError.errorArgs, + } as RevertReasonWithCustomError; + } + + return { + reason: error.innerError.message, + signature: error.innerError.data?.slice(0, 10), + data: error.innerError.data?.substring(10), + } as RevertReason; + } + + if ( + error instanceof InvalidResponseError && + !Array.isArray(error.innerError) && + error.innerError !== undefined + ) { + return error.innerError.message; + } + + throw error; +}; + +// See web3-eth/src/utils/get_revert_reason.ts +export async function getRevertReason< + ReturnFormat extends DataFormat = typeof DEFAULT_RETURN_FORMAT, +>( + web3Context: Web3Context, + transaction: TransactionCall, + contractAbi?: ContractAbi, + returnFormat: ReturnFormat = DEFAULT_RETURN_FORMAT as ReturnFormat, +): Promise { + try { + await call(web3Context, transaction, web3Context.defaultBlock, returnFormat); + return undefined; + } catch (error) { + return parseTransactionError(error, contractAbi); + } +} \ No newline at end of file diff --git a/web3js-ext/src/web3/web3.ts b/web3js-ext/src/web3/web3.ts index addaa6f20..937f319cc 100644 --- a/web3js-ext/src/web3/web3.ts +++ b/web3js-ext/src/web3/web3.ts @@ -2,26 +2,32 @@ import Web3, {Bytes, Transaction, Web3Context} from "web3"; import { signTransaction, SignTransactionResult } from "web3-eth-accounts"; import { bytesToHex } from "web3-utils"; import { DataFormat, DEFAULT_RETURN_FORMAT } from "web3-types"; -import { SendTransactionOptions, sendSignedTransaction } from "web3-eth"; +import { SendTransactionOptions } from "web3-eth"; import _ from "lodash"; import { prepareTransaction } from "./klaytn_tx"; -import { klay_sendSignedTransaction } from "./rpc"; +import { klay_sendSignedTransaction } from "./send_transaction"; export class KlaytnWeb3 extends Web3 { constructor(provider: any) { + // TODO: Override default values to fit Klaytn network. + // transactionSendTimeout = 50*1000 + // The Web3 constructor. See web3/src/web3.ts super(provider); // Override web3.eth.accounts. See web3/src/accounts.ts:initAccountsForContext // The functions are bound to 'this' object. + // TODO: override more web3.eth.accounts methods this.eth.accounts.signTransaction = this.accounts_signTransaction(this); // Override web3.eth RPC method wrappers. See web3-eth/src/web3_eth.ts:Web3Eth - // Note that web3.eth methods call eth_ RPCs to Klaytn node, - // except a few below methods call klay_ RPCs despite its name 'web3.eth'. + // Note that web3.eth methods should simply call eth_ RPCs to Klaytn node, + // except a few methods below which call klay_ RPCs despite its name 'web3.eth'. this.eth.getProtocolVersion = this.eth_getProtocolVersion(this); this.eth.sendSignedTransaction = this.eth_sendSignedTransaction(this); + + // TODO: Connect web3.klay, web3.net, etc from @klaytn/web3rpc } // Below methods return a function bound to the context 'web3'.