Blog posts
go back

web3.js vs ethers.js

How Ethereum transactions work

April 5, 2023
written by
John Renner
Founding Engineer
Fraser Brown
Co-Founder & CTO
Blog posts
When building dapps that interact with Ethereum-based chains, Node.js developers typically reach for one of two JavaScript libraries: [web3.js][web3-docs] or [ethers.js][ethers-docs]. Both of these libraries wrap the [Ethereum JSON-RPC API][eth-jrpc-docs], providing useful functionality like [local signing](#separatingkeysfromtheAPIendpoint), [nonce tracking](#noncemanagement), and [Solidity ABI support](#ABI-management). In fact, most functionality is _the same_ in both libraries, which makes it difficult to choose between them. In this blog post, we explain why we ultimately prefer [ethers.js][ethers-docs]---after outlining the anatomy of Ethereum transactions, the JSON-RPC API, and why JavaScript libraries are helpful to begin with. <br> <br> In case you're familiar with most of these topics and want to skip forward, here's a table of contents:<br> <br> - [**The anatomy of an Ethereum transaction**](#theanatomyofanethereumtransaction) and how the Ethereum JSONRPC API works.<br> - [**Why use a library**](#whyusealibraryatall?) instead of the raw Ethereum API?<br> - [**Why we prefer ethers.js**](#whywepreferethers.jsoverweb3.js) over web3.js<br> - [**An FAQ**](#ethereumtransactionfaq) outlining some common questions about transactions, the Ethereum API, and ethers.js and web3.js.<br> <br> ## The anatomy of an Ethereum transaction<br> <br> The Ethereum [JSON-RPC API][eth-jrpc-docs] lets developers interact with the blockchain by querying---or even sending transactions to---an [Ethereum node][ethnode] over HTTP or WebSockets. The JSON-RPC API has two different methods for submitting Ethereum transactions: [`eth_sendTransaction()`][send] and [`eth_sendRawTransaction()`][sendraw]. The most important difference between these methods is the way they handle transaction signing: `eth_sendTransaction()` expects an unsigned transaction, whereas `eth_sendRawTransaction()` expects a signed transaction. We explain the distinction next, and then finish by showing an example Ethereum transaction using the raw JSON-RPC API.<br> <br> ### Why is signing important, how signing works, and the difference between `eth_sendTransaction()` and `eth_sendRawTransaction()`<br> <br> Each Ethereum transaction must be signed by the private key associated with the account sending the transaction. This ensures transaction _authenticity_---that the transaction truly originates from the sender account. Without signing, anyone could submit transactions "from" your account---maybe draining your account!---without detection. <br> <br> If you use `eth_sendTransaction()` to submit your Ethereum transaction, the RPC endpoint (e.g., an Ethereum node) you connect to must (1) sign your transaction with your private key and then (2) broadcast a request to the Ethereum network to [actually execute that transaction]( If you use `eth_sendRawTransaction()`, you're responsible for supplying the method with an _already signed_ transaction; you can sign transactions before sending them with the `signTransaction()` method, though it's more complex in practice (which we discuss [later](#separatingkeysfromtheapiendpoint)).<br> <br> ### An example transaction using the raw Ethereum JSON-RPC API<br> <br> The following example from the documentation of the [`eth_sendTransaction()`][send] method shows an Ethereum transaction sent using `curl` through the Ethereum JSON-RPC API:<br> <br> ```bash<br> # Request<br> curl -X POST --data '{<br> &nbsp;&nbsp;&nbsp;&nbsp;"jsonrpc": "2.0",<br> &nbsp;&nbsp;&nbsp;&nbsp;"id": 1,<br> &nbsp;&nbsp;&nbsp;&nbsp;"method": "eth_sendTransaction",<br> &nbsp;&nbsp;&nbsp;&nbsp;"params":[<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// address of sender<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"from": "0xb60e8dd61c5d32be8058bb8eb970870f07233155",<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// address of receiver (none if creating contract)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"to": "0xd46e8dd67c5d32be8058bb8eb970870f07244567",<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// the maximum amount of gas the sender is willing to provide to<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// execute the transaction (30400)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"gas": "0x76c0",<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// maximum gas price the sender is willing to pay (10000000000000)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"gasPrice": "0x9184e72a000",<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// the amount of wei to send as an integer (2441406250)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"value": "0x9184e72a",<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// for contract creation, the compiled code of the contract<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// for contract method calls, the encoding of the method and its arguments<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"data": "0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675"<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},<br> &nbsp;&nbsp;&nbsp;&nbsp;]<br> }'<br> <br> # Result<br> {<br> &nbsp;&nbsp;&nbsp;&nbsp;"id":1,<br> &nbsp;&nbsp;&nbsp;&nbsp;"jsonrpc": "2.0",<br> &nbsp;&nbsp;&nbsp;&nbsp;"result": "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331"<br> }<br> ```<br> <br> The `to`, `gas`, `gasPrice`, `value` and `data` fields in `eth_sendTransaction()` are optional:<br> <br> - The `to` field is not set when a transaction creates a new smart contract.<br> - The `gas` and `gasPrice` fields, which are used to pay for the execution of a transaction, have default values.<br> - The `value` field is only set if a transaction transfers ETH (the value is denominated in wei; 1 ETH is 10<sup>18</sup> wei).<br> - The `data` field isn't set for simple transfers (i.e., transactions that are not contract creations or contract calls).<br> <br> The result of sending a transaction is a transaction hash, which can be used to get a transaction receipt using [`eth_getTransactionReceipt()`][getreceipt]. The transaction receipt contains information about the status of a transaction---success or failure---as well as other metadata (e.g., the number of the block the transaction ended up in). <br> <br> ## Why use a library at all?<br> <br> It may not be immediately evident why developers use complex wrapper libraries like web3.js and ethers.js instead of using the Ethereum JSON-RPC API directly. But wrapper libraries are important because they provide support for:<br> <br> 1. [Separating keys from API endpoints](#separatingkeysfromtheapiendpoint).<br> 2. [Nonce management](#noncemanagement).<br> 3. [Gas price estimation](#gaspriceestimation).<br> 4. [ABI management](#abimanagement).<br> <br> In the next sections, we describe what all of these tasks _are_, and then outline<br> what makes them tricky. <br> <br> ### Separating keys from the API endpoint<br> <br> If you use `eth_sendTransaction()`, the transaction is signed by the Ethereum node, which means that node needs access to your private key. If you're running your own Ethereum node, it's possible---but risky!---to configure that node with access to your key (see the subsection on [private key management](#moreonprivatekeymanagement) directly below). If you're using a popular service like [Alchemy][alchemy] or [Infura]( to host your endpoint, though, things become untenable: these services don't support transaction signing because giving them access to your keys is a massive security risk. <br> <br> Since `eth_sendRawTransaction()` lets you separate the signing and submitting steps, it _can_ be used with services like Alchemy. This flow lets you restrict access to secret keys at the cost of implementing your own local signing logic. That's where web3.js and ethers.js come in: **both libraries implement local signing for you**, so you can keep your keys safe while using third-party node providers. The libraries' local signing essentially replicates the functionality of [`eth_signTransaction()`][sign] using a local private key; if you opt against a library, you must implement this logic manually, which requires detailed knowledge of how the signature is computed.<br> <br> #### A few words on private key management<br> <br> It is _extremely_ important to minimize the risk of exposing your private key: anyone who knows it can create valid transactions---meaning that an attacker who learns your private key can completely drain your wallet. Configuring your local Ethereum node with private key access dramatically increases the likelihood that something goes wrong, including:<br> <br> 1. Direct compromise. This might happen if the local machine isn't well secured, or if there is a bug in the node software.<br> <br> 2. Accidents. As one example, it's possible to accidentally send a test transaction to mainnet instead of a testnet. This might happen if the node is incorrectly configured.<br> <br> As a result of these (and _many other_) risks, it's considered good practice to separate keys from nodes. <br> <br> ### Nonce management<br> <br> All Ethereum transactions have a `nonce` field that must contain the number of transactions that the sender account has created (and the Ethereum network has executed, either successfully or not) prior to this transaction. The nonce ensures that a transaction can be processed at most once, which prevents "replay attacks". If the transaction's nonce is correct, the transaction can be executed. If it's too small---meaning less than or equal to the nonce value in the sender's most recently executed transaction---the Ethereum API will reject the transaction. Otherwise, if the nonce value is too large, it cannot be executed until all nonces less than this value have been used; in this case, the Ethereum node may queue this transaction and wait for others to execute.<br> <br> When you use `eth_sendTransaction()`, the Ethereum node can set the transaction's nonce field for you: it calculates the next nonce value (based on the sender's transaction history and any pending transactions), incorporates that value into the transaction, and then signs and submits the transaction to the network. If, instead, you sign transactions locally and submit them with `eth_sendRawTransaction()`, the Ethereum node cannot choose the nonce: the transaction is already signed when the node receives it, and any change to the transaction---including to the nonce field---would invalidate the signature, causing the transaction to be rejected.<br> <br> To do local signing, then, you must also do local nonce calculation. Local nonce calculation is more complex than it sounds: concurrent applications, for example, introduce race conditions into the calculation logic. **Both web3.js and ethers.js handle this (surprisingly complicated) nonce management for you**.<br> <br> ### Gas price estimation<br> <br> Recall that the execution cost of Ethereum transactions is measured in an abstract unit called "gas". A given Ethereum transaction always takes the same amount of gas, but the value of that gas (measured in ETH) fluctuates based on how busy the chain is. When you submit a transaction, you must declare how much ETH you're willing to spend to execute it. If the value is too low, validators won't execute your transaction; if the value is too high, you waste money.<br> <br> Just like nonces, gas price constraints must be encoded into a transaction<br> _before_ signing---so if you're signing locally, you're responsible for<br> gas price estimation. **Both web3.js and ethers.js implement gas estimation<br> for you.**<br> <br> ### ABI management<br> <br> On Ethereum, smart contract functions can be invoked by initiating a transaction with the contract's address. The [`eth_call()`][call] method from the JSON-RPC API, for example, takes the address of the smart contract you'd like to call in the `to` field. What about specifying the function _within_ that smart contract that you'd like to call, or providing arguments to that function? The transaction has a `data` field that holds arbitrary binary data, and that is where you shove the function and argument information.<br> <br> "Arbitrary binary data" doesn't square with how we're used to thinking of pieces of a Solidity smart contract. For example, consider this smart contract with two methods accepting typed arguments:<br> <br> ```solidity<br> contract Example {<br> &nbsp;&nbsp;&nbsp;&nbsp;function addOne(uint256 num) public pure returns (uint256) {<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return num + 1;<br> &nbsp;&nbsp;&nbsp;&nbsp;}<br> <br> &nbsp;&nbsp;&nbsp;&nbsp;function neg(bool p) public pure returns (bool) {<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return !p;<br> &nbsp;&nbsp;&nbsp;&nbsp;}<br> }<br> ```<br> <br> `addOne()` takes a `uint256` argument, while `neg()` takes a `bool` argument. To actually _call_ these methods using `eth_call()`, you [encode][abispec] both the signature of the method you intend to call (e.g., `addOne(uint256)`) and its argument (e.g., `num`) into the `data` field of the transaction. (Concretely, the first four bytes of the `data` field contain the first four bytes of the Keccak-256 hash of the function signature, and the remainder contains the actual values.) Similarly, when you invoke a Solidity contract's method through `eth_call()`, the return value is encoded and stored in the `result` field of the response object. The encoding and decoding of data for a smart contract is called its ABI (Application Binary Interface).<br> <br> Handling the encoding and decoding required by the ABI gets even more complicated when you consider that JavaScript applications often need to talk to Solidity smart contracts---and JavaScript and Solidity are very different languages. For example, Solidity's `uint256` can hold a value that doesn't fit into a JavaScript `number`; what's the JavaScript application supposed to do when it receives a `uint256`?! If you want to safely convert the return value of `addOne` into a JavaScript value that a frontend application can use, you need to load it into a JavaScript [`BigInt`][bigint]. This means there's _a lot_ of conversion going on to invoke even simple smart contract methods like `addOne`:<br> <br> !!! WARNING<br> &nbsp;&nbsp;&nbsp;&nbsp;*Invoking a method*: JS Value → Solidity Value → Binary Data<br> <br> &nbsp;&nbsp;&nbsp;&nbsp;and _even more_ conversions on the way out:<br> <br> &nbsp;&nbsp;&nbsp;&nbsp;*Reading method result*: Binary Data → Solidity Value → JS Value<br> <br> Mistakes in these conversions can result in expensive vulnerabilities. Thankfully, **web3.js and ethers.js handle encoding and decoding for you** by using the ABI description generated by the Solidity compiler to generate JavaScript classes. The JavaScript classes, in turn, handle value conversion across the Solidity-JavaScript boundary. In both examples below, you'll notice a distinct lack of conversions; instead, the libraries let you refer to our `Example` smart contract (line one) and then call `addOne` on that contract (line two) using normal JavaScript values. <br> <br> **web3.js**<br> <br> ```js<br> let contract = new web3.eth.Contract(abiJson, "0xd...");<br> let result = await contract.addOne(3).call();<br> ```<br> <br> **ethers.js**<br> <br> ```js<br> let contract = new Contract("0xd...", abiJson, provider);<br> let result = await contract.addOne(2);<br> ```<br> <br> ## Why we prefer ethers.js over web3.js<br> <br> Although both web3.js and ethers.js implement the features above, ethers.js has some distinct advantages.<br> <br> ### Flexible `Provider`/`Signer` ecosystem<br> <br> When implementing out-of-band transaction signing, web3.js has a pretty direct translation of the process [we described earlier](#separatingkeysfromtheapiendpoint):<br> <br> ```js<br> // web3.js example<br> let web3 = new Web3('https://...');<br> // specify your account<br> let account = web3.eth.accounts.privateKeyToAccount(privateKey);<br> // sign the transaction locally<br> let trx = await account.signTransaction({/*...*/});<br> // send the signed transaction to the node<br> await web3.eth.sendSignedTransaction(trx);<br> ```<br> <br> While this code is perfectly reasonable, it requires you to explicitly pass around both the `account` and `web3` object. Furthermore, web3.js doesn't provide standard API support for other methods of transaction signing, for example, using a Key Management Service.<br> <br> <br> In ethers.js, the responsibilities of signing are captured in the abstract `Signer` class, which can be implemented with in-memory wallets, [hardware wallets][ethers-hardware-wallets], or even remote key-signing services. Signers wrap `Provider`s that represent a connection to an Ethereum API endpoint. Altogether, signing transactions with ethers.js looks like this:<br> <br> ```js<br> import {LedgerSigner} from "@ethersproject/hardware-wallets";<br> <br> // connection to an Ethereum endpoint<br> let provider = new ethers.providers.JsonRpcProvider('https://...');<br> // the signer object<br> let signer;<br> if (local_signing) {<br> &nbsp;&nbsp;&nbsp;&nbsp;// sign with the local private key<br> &nbsp;&nbsp;&nbsp;&nbsp;signer = new ethers.Wallet(privateKey, provider);<br> } else {<br> &nbsp;&nbsp;&nbsp;&nbsp;// sign with a hardware wallet<br> &nbsp;&nbsp;&nbsp;&nbsp;signer = new LedgerSigner(provider);<br> }<br> <br> await signer.sendTransaction({/*...*/});<br> ```<br> <br> In addition to the basic `JsonRpcProvider` (line 1), ethers.js has [`Provider` implementations]( for many popular third-party endpoints such as [Etherscan][etherscan], [Alchemy][alchemy], [Cloudflare][cloudflare], and others. These implementations handle the particular authentication schemes and other quirks unique to these endpoints, which makes your life easier.<br> <br> ### More permissive license<br> <br> web3.js is licensed under the [LGPL][lgpl], meaning you can freely use it as a dependency, but any modified version of web3.js that you distribute must also be open source and licensed under the LGPL.<br> <br> ethers.js uses the [MIT license][mit-license] which allows any and all modifications with very few strings attached. Choosing an MIT-licensed library will keep your lawyers happy and give you more options as your project evolves.<br> <br> (Disclaimer: we are not lawyers, and we are certainly not your lawyers! Please consult your counsel for advice on the best license for you.)<br> <br> ### Smaller codesize<br> <br> If you're deploying your dapp in the browser---or in any context that requires it to be regularly downloaded---the size of your library is crucial to your application's responsiveness. ethers.js manages to support more functionality than web3.js in a much smaller package:<br> <br> **web3.js**: `1.1 MB` minified, `310 kb` gzipped<br> <br> **ethers.js**: `322 kb` minified, `104 kb` gzipped<br> <br> ## Ethereum transaction FAQ<br> <br> Here are answers to some of the transaction-related questions we've seen around the web.<br> <br> ### Q: What's the difference between `eth_sendTransaction()` and `eth_sendRawTransaction()`?<br> <br> See the section on [the difference between these two methods](#whyissigningimportanthowsigningworksandthedifferencebetweeneth_sendTransactionandeth_sendRawTransaction). <br> <br> ### Q: What is a nonce and why is it important?<br> <br> See our explanation of [nonce management](#noncemangagement).<br> <br> ### Q: Does the nonce increase if the transaction fails? What about if the transaction never reaches the node (e.g., because of a network issue)?<br> <br> If the transaction is received by an Ethereum node and executed by the network, the sender's nonce value increases. This is true even if the transaction fails during execution (e.g., because it doesn't include enough gas). If the transaction never reaches a node, or if it is rejected before execution (say, because of a bad signature), the sender's next nonce value _does not_ increase. <br> <br> Here's another way to think about it: every executed transaction, successful or not, is recorded in an Ethereum block. The sender's current nonce value is equal to the number of transactions from that sender recorded in all Ethereum blocks. This means that the sender's nonce value increases every time a new transaction from that sender is incorporated into an Ethereum block, and does not change otherwise.<br> <br> ### Q: What are `nonce too high` and `nonce too low` errors? How do you fix them?<br> <br> See our explanation of [nonce management](#noncemangagement). A `nonce too low` error for transaction `T` means that the node and network have already seen some other transaction `T'`, sent _by you_, with `T`'s nonce. A `nonce too high` error for transaction `T` means that the node and network haven't yet seen a transaction with a nonce of one less than `T`'s. Not all software will error if a nonce is too high; some implementations may hold the transaction and wait to release it once it's the transaction's "turn". <br> <br> ### Q: What is an out of gas error and how do I fix it?<br> <br> This error means that the amount of gas you sent with your transaction wasn't sufficient to execute that transaction. You'll need to increase your transaction's gas limit and re-submit. See our discussion of [gas price estimation](#gaspriceestimation) for more information.<br> <br> ### Q: Why shouldn't I give my local Ethereum node access to my private key?<br> <br> See our section on [separating keys from the API endpoint](#separatingkeysfromtheapiendpoint). Giving a local node access increases the risk of exposure of the key and theft of your funds. It can also lead to mistakes like accidentally sending valid transactions to the wrong network.<br> <br> [call]: (<br> [send]:<br> [sendraw]:<br> [sign]:<br> [getreceipt]:<br> [ethnode]:<br> [lgpl]:<br> [alchemy]:<br> [cloudflare]:<br> [etherscan]:<br> [ens]:<br> [mit-license]:<br> [eth-jrpc-docs]:<br> [web3-docs]:<br> [ethers-docs]:<br> [eth-call]:<br> [bigint]:<br> [ethers-hardware-wallets]:<br> [abi-bug]: #<br> [abispec]:<br>

Read more

Cubist & EigenLabs anti-slasher collaboration

Cubist is excited to announce a new partnership: we are working with EigenLabs to build anti-slashers that will help honest operators avoid getting slashed on EigenLayer.

September 19, 2023

Hardware-backed signing for MetaMask developers

Our Snap lets Snap- or dapp-developers use CubeSigner, our hardware-backed key management system, to safely sign transactions on behalf of their MetaMask users.

September 12, 2023

Intel SGX is broken (again)

Last week, security researcher Daniel Moghimi publicly announced the new Downfall attack that can steal private keys from Intel SGX hardware. In this post, we review the SGX architecture and discuss its underlying security problems. Then, we describe the process we used for evaluating which secure hardware to use in our key manager.

August 15, 2023