Transactions in Ethereum: nonce, gas, and signatures
1. Overview
Every state change on Ethereum begins with a transaction. Most errors when sending transactions like “nonce too low”, “replacement transaction underpriced”, or “insufficient funds” relate to three fields: nonce, gas, and signature.
In this article, you’ll see how each field works. Then you’ll fetch the nonce yourself, calculate fees according to EIP-1559, sign the transaction offline, and send it to the network using web3j in a Spring Boot 3.x application.
2. Structure of an EIP-1559 transaction
Since the London upgrade, the most common transaction type is type 2 (EIP-1559). It comprises the following fields:
| Field | Meaning |
|---|---|
chainId |
The network ID (1 = Mainnet, 11155111 = Sepolia), prevents replay across chains |
nonce |
Transaction serial number for the sending account |
maxPriorityFeePerGas |
Maximum “tip” paid to validators, per unit of gas |
maxFeePerGas |
Maximum total fee you accept to pay per unit of gas |
gasLimit |
Maximum gas the transaction is allowed to consume |
to, value, data |
Recipient address, amount of wei to transfer, and contract call data |
accessList |
List of addresses/storage slots expected to be accessed (optional) |
yParity, r, s |
ECDSA signature |
Transactions do not have a from field. The sender’s address is recovered from the signature. This is a key point you’ll see again in section 6.
3. Project setup
Add web3j to pom.xml:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Declare the RPC node URL (Sepolia, Anvil, or local Hardhat work):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Create a Web3j bean:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" helps close the thread pool and HTTP client of web3j when the application stops.
4. Nonce
4.1. What is nonce
Nonce is a counter for the number of transactions an account (EOA) has sent. It starts at 0 and increases by exactly 1 after each transaction is included in a block. Nodes only accept transactions with a nonce exactly equal to the next value:
- Nonce too low: rejected with error
nonce too low. - Nonce too high: waits in the mempool until the gap is filled.
- Nonce equal to a pending transaction: treated as a replacement of the old transaction, if the fee is sufficiently higher (usually ≥ 10%).
Thanks to nonce, the same signed transaction cannot be replayed a second time.
4.2. Fetching nonce from the node
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Here we use PENDING instead of LATEST. LATEST only counts transactions already in a block. If you just sent a transaction and it hasn’t been mined yet, LATEST will return the old nonce. The next transaction will then replace the previous one instead of following it.
4.3. Managing nonce when sending concurrently
If multiple threads call pendingNonce at almost the same time, they may receive the same value. The simple approach is to ask the node only once, then increment it in memory:
@Component
public class NonceManager {
private final Web3j web3j;
private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
public NonceManager(Web3j web3j) {
this.web3j = web3j;
}
public BigInteger next(String address) {
AtomicLong counter = counters.computeIfAbsent(
address.toLowerCase(), a -> new AtomicLong(fetchPending(a)));
return BigInteger.valueOf(counter.getAndIncrement());
}
public void reset(String address) {
counters.remove(address.toLowerCase());
}
private long fetchPending(String address) {
try {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send().getTransactionCount().longValueExact();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
computeIfAbsent ensures the node is asked only once per address. getAndIncrement is an atomic operation, so two threads never receive the same nonce.
Note: If a transaction fails at the RPC step (e.g., rejected by the node), the allocated nonce will create a “gap”. All subsequent transactions will be stuck in the mempool. When an error occurs, call
reset()to resynchronize with the node. This approach is also not safe when multiple application instances share the same wallet. In that case, you need a distributed lock or a centralized signing service.
5. Gas
5.1. gasLimit and gasUsed
Each opcode in the EVM consumes a fixed amount of gas. A plain ETH transfer costs exactly 21,000 gas. Calling a contract costs more, depending on the logic inside.
gasLimitis the cap you set. If the EVM runs out before completion, the transaction reverts but you still pay for all the gas consumed.gasUsedis the actual amount of gas consumed. Unused gas is not charged.
For contract calls, estimate beforehand using eth_estimateGas:
public BigInteger estimateGas(Web3j web3j, String from, String to, String data) throws IOException {
var call = org.web3j.protocol.core.methods.request.Transaction
.createEthCallTransaction(from, to, data);
BigInteger estimate = web3j.ethEstimateGas(call).send().getAmountUsed();
return estimate.multiply(BigInteger.valueOf(120)).divide(BigInteger.valueOf(100));
}
We add a 20% safety margin. Chain state can change between estimation and when the transaction is mined.
5.2. Fees according to EIP-1559
The price per unit of gas has two parts:
- baseFee: self-adjusts by the protocol based on block fullness and is burned.
- priority fee (tip): paid to validators for prioritization.
The actual price you pay per gas is:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
A common formula is to set maxFeePerGas = 2 * baseFee + tip. This formula keeps transactions valid even if baseFee increases over several blocks, without overpaying.
public record FeeQuote(BigInteger maxPriorityFeePerGas, BigInteger maxFeePerGas) {}
public FeeQuote quoteFee(Web3j web3j) throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
Suppose baseFee = 20 gwei and tip = 1.5 gwei. For an ETH transfer, you would have:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Your wallet must have at least value + gasLimit * maxFeePerGas. If not, the node returns insufficient funds for gas * price + value error, even if the actual fee is much lower.
6. Signature
6.1. What to sign and with what
Ethereum uses ECDSA on the secp256k1 curve. For transaction type 2, the data being signed is:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
The result consists of r, s, and yParity. From the signature and data, anyone can recover the public key and thus derive the from address. Because chainId is part of the signed data, a transaction signed for Sepolia cannot be replayed on Mainnet (EIP-155).
6.2. Signing offline with web3j
@Component
public class TransactionSigner {
private static final BigInteger TRANSFER_GAS = BigInteger.valueOf(21_000);
public String signTransfer(Credentials credentials, long chainId, BigInteger nonce,
String to, BigInteger valueWei, FeeQuote fee) {
RawTransaction tx = RawTransaction.createEtherTransaction(
chainId, nonce, TRANSFER_GAS, to, valueWei,
fee.maxPriorityFeePerGas(), fee.maxFeePerGas());
byte[] signed = TransactionEncoder.signMessage(tx, credentials);
return Numeric.toHexString(signed);
}
}
Step-by-step explanation:
createEtherTransactionwith alongchainIdparameter creates an EIP-1559 transaction.TransactionEncoder.signMessageRLP-encodes, keccak256 hashes, signs with the private key incredentials, then re-encodes the signature.Numeric.toHexStringreturns a hex string ready to send viaeth_sendRawTransaction.
The resulting string always starts with the transaction type byte:
0x02f873827a6907845968...
This function doesn’t need a network connection. You can sign on an isolated machine, then move the hex string to another machine to send.
6.3. Testing the signature
The test below signs a transaction, decodes it back, and verifies that the recovered address matches the signer:
class TransactionSignerTest {
private static final Credentials ALICE = Credentials.create(
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318");
private static final String BOB = "0x3535353535353535353535353535353535353535";
private static final FeeQuote FEE = new FeeQuote(
Convert.toWei("1.5", Convert.Unit.GWEI).toBigInteger(),
Convert.toWei("41.5", Convert.Unit.GWEI).toBigInteger());
private final TransactionSigner signer = new TransactionSigner();
@Test
void signedTransferRecoversSenderAndKeepsFields() throws SignatureException {
String hex = signer.signTransfer(ALICE, 11155111L, BigInteger.valueOf(7), BOB,
Convert.toWei("0.01", Convert.Unit.ETHER).toBigInteger(), FEE);
assertThat(hex).startsWith("0x02");
var decoded = (SignedRawTransaction) TransactionDecoder.decode(hex);
assertThat(decoded.getFrom()).isEqualToIgnoringCase(ALICE.getAddress());
assertThat(decoded.getNonce()).isEqualTo(BigInteger.valueOf(7));
assertThat(((Transaction1559) decoded.getTransaction()).getChainId())
.isEqualTo(11155111L);
}
@Test
void differentChainIdProducesDifferentTransactionHash() {
BigInteger value = BigInteger.ONE;
String sepolia = signer.signTransfer(ALICE, 11155111L, BigInteger.ZERO, BOB, value, FEE);
String mainnet = signer.signTransfer(ALICE, 1L, BigInteger.ZERO, BOB, value, FEE);
assertThat(Hash.sha3(sepolia)).isNotEqualTo(Hash.sha3(mainnet));
}
}
The first test confirms what was said in section 2: from is derived from the signature, not stored in the transaction. The second test shows that chainId is part of the signed data. Hash.sha3 on the signed hex string is exactly the transaction hash that block explorers display.
7. Sending transaction and waiting for receipt
Putting it all together in a service:
@Service
public class PaymentService {
private final Web3j web3j;
private final NonceManager nonceManager;
private final TransactionSigner signer;
private final long chainId;
public PaymentService(Web3j web3j, NonceManager nonceManager, TransactionSigner signer,
@Value("${eth.chain-id}") long chainId) {
this.web3j = web3j;
this.nonceManager = nonceManager;
this.signer = signer;
this.chainId = chainId;
}
public TransactionReceipt pay(Credentials payer, String to, BigInteger valueWei) throws Exception {
BigInteger nonce = nonceManager.next(payer.getAddress());
FeeQuote fee = quoteFee();
String signedHex = signer.signTransfer(payer, chainId, nonce, to, valueWei, fee);
EthSendTransaction response = web3j.ethSendRawTransaction(signedHex).send();
if (response.hasError()) {
nonceManager.reset(payer.getAddress());
throw new IllegalStateException(response.getError().getMessage());
}
return new PollingTransactionReceiptProcessor(web3j, 2_000, 30)
.waitForTransactionReceipt(response.getTransactionHash());
}
private FeeQuote quoteFee() throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
}
PollingTransactionReceiptProcessor polls the node every 2 seconds, up to 30 times. When you receive a receipt, also check receipt.isStatusOK(). A value of false means the transaction was included in a block but reverted, and the fee is still deducted.
8. Common errors
| Message from node | Cause | Solution |
|---|---|---|
nonce too low |
Nonce already used | Resynchronize nonce with PENDING |
replacement transaction underpriced |
Nonce matches a pending transaction but fee is too low | Increase both maxFeePerGas and maxPriorityFeePerGas by at least 10% |
intrinsic gas too low |
gasLimit below minimum (21,000 + data cost) |
Use eth_estimateGas |
insufficient funds for gas * price + value |
Balance < value + gasLimit * maxFeePerGas |
Deposit more or reduce maxFeePerGas |
invalid chain id |
Signed with chainId different from node’s network |
Read chainId from config or eth_chainId |
Receipt status = 0x0 |
Reverted or ran out of gas during execution | Check contract logic, increase gasLimit |
Note: Don’t place private keys in
application.propertiesor environment variables on production servers. Self-signing withCredentialsis fine for dev, test, and internal tools. For wallets holding real funds, sign through KMS/HSM or a separate signer. Applications should only send data to be signed, never touch keys.
Note: Don’t manage nonce in memory yourself if you run multiple instances sharing the same wallet. Centralize all signing commands in one service, or give each instance its own wallet.
9. Conclusion
In this article, you saw the three components that determine whether an Ethereum transaction is accepted:
- Nonce ensures order and prevents replay. Fetch it via
PENDINGand allocate atomically when sending concurrently. - Gas comprises
gasLimit(how much) andmaxFeePerGas/maxPriorityFeePerGas(price). You pay onlygasUsed * min(maxFee, baseFee + tip). - Signature is ECDSA secp256k1 over data containing
chainId. It both proves ownership and is the sole source to derive thefromaddress.
With web3j, you can sign transactions completely offline, test with JUnit without needing a node, then send to the network with just eth_sendRawTransaction.