Transactions dans Ethereum : nonce, gas et signatures
1. Vue d’ensemble
Chaque changement d’état sur Ethereum commence par une transaction. La plupart des erreurs lors de l’envoi d’une transaction comme « nonce too low », « replacement transaction underpriced » ou « insufficient funds » sont liées à trois champs : nonce, gas et signature.
Dans cet article, vous verrez comment chaque champ fonctionne. Ensuite, vous récupérerez vous-même le nonce, calculerez les frais selon EIP-1559, signerez une transaction hors ligne et l’enverrez sur le réseau avec web3j dans une application Spring Boot 3.x.
2. Structure d’une transaction EIP-1559
Depuis la mise à niveau London, le type de transaction le plus courant est le type 2 (EIP-1559). Il comprend les champs suivants :
| Champ | Signification |
|---|---|
chainId |
ID du réseau (1 = Mainnet, 11155111 = Sepolia), protection contre les rejeux entre chaînes |
nonce |
Numéro de séquence de la transaction du compte émetteur |
maxPriorityFeePerGas |
Pourboire (« tip ») maximal versé au validateur, calculé par unité de gas |
maxFeePerGas |
Frais totaux maximaux que vous acceptez de payer par unité de gas |
gasLimit |
Quantité maximale de gas que la transaction peut dépenser |
to, value, data |
Adresse du destinataire, montant en wei transféré et données d’appel du contrat |
accessList |
Liste des adresses/stockages prévus d’être accédés (optionnel) |
yParity, r, s |
Signature ECDSA |
La transaction n’a pas de champ from. L’adresse de l’expéditeur est récupérée à partir de la signature. C’est un point clé que vous verrez à nouveau à la section 6.
3. Préparation du projet
Ajoutez web3j à pom.xml :
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Déclarez l’URL du nœud RPC (Sepolia, Anvil ou Hardhat local fonctionnent tous) :
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Créez un bean Web3j :
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" aide à fermer le thread pool et le client HTTP de web3j quand l’application s’arrête.
4. Nonce
4.1. Qu’est-ce que le nonce
Le nonce est un compteur du nombre de transactions qu’un compte (EOA) a envoyées. Il commence à 0 et augmente d’exactement 1 après chaque transaction incluse dans un bloc. Un nœud n’accepte que les transactions avec un nonce exactement égal à la valeur suivante :
- Nonce inférieur : rejeté avec l’erreur
nonce too low. - Nonce supérieur : attend dans le mempool jusqu’à ce que l’écart soit comblé.
- Nonce égal à une transaction en attente : est considérée comme remplaçant l’ancienne transaction, si les frais sont suffisamment plus élevés (généralement ≥ 10%).
Grâce au nonce, la même transaction signée ne peut pas être rejouée une deuxième fois.
4.2. Récupérer le nonce du nœud
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Ici, nous utilisons PENDING au lieu de LATEST. LATEST compte uniquement les transactions incluses dans un bloc. Si vous venez d’envoyer une transaction et qu’elle n’a pas encore été minée, LATEST retournera l’ancien nonce. La transaction suivante remplacera alors la précédente au lieu de la suivre.
4.3. Gérer le nonce lors d’envois concurrents
Si plusieurs threads appellent pendingNonce presque au même moment, ils pourraient recevoir la même valeur. Un moyen simple est de n’interroger le nœud qu’une fois, puis d’incrémenter en mémoire :
@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 garantit que le nœud n’est interrogé qu’une fois par adresse. getAndIncrement est une opération atomique, donc deux threads n’obtiendront jamais le même nonce.
Remarque : Si une transaction échoue lors de l’étape RPC (par exemple, rejetée par le nœud), le nonce alloué créera un « trou ». Chaque transaction suivante sera bloquée dans le mempool. En cas d’erreur, appelez
reset()pour resynchroniser avec le nœud. Cette approche n’est pas sûre si plusieurs instances d’application partagent le même portefeuille. Dans ce cas, vous avez besoin d’un verrou distribué ou d’un service de signature centralisé.
5. Gas
5.1. gasLimit et gasUsed
Chaque opcode dans l’EVM consomme une quantité de gas fixe. Un simple transfert d’ETH consomme exactement 21 000 gas. Un appel de contrat en coûte davantage, selon la logique interne.
gasLimitest la limite que vous définissez. Si l’EVM épuise le gas avant la fin de l’exécution, la transaction reverte, mais vous perdez quand même les frais pour tout le gas utilisé.gasUsedest la quantité de gas réellement consommée. La partie restante n’est pas facturée.
Pour un appel de contrat, estimez d’abord avec 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));
}
Nous ajoutons 20% de marge de sécurité. L’état de la chaîne peut changer entre le moment de l’estimation et celui où la transaction est minée.
5.2. Frais selon EIP-1559
Le prix par unité de gas comprend deux parties :
- baseFee : ajusté automatiquement par le protocole selon la plénitude du bloc, et brûlé.
- priority fee (tip) : versé au validateur pour être prioritaire.
Le prix réel que vous payez par gas est :
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
Une formule courante consiste à définir maxFeePerGas = 2 * baseFee + tip. Cette formule permet à la transaction de rester valide même si le baseFee augmente sur plusieurs blocs, sans que vous payiez plus que nécessaire.
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));
}
Supposons que baseFee = 20 gwei et tip = 1,5 gwei. Pour un transfert d’ETH, vous aurez :
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Votre portefeuille doit avoir au moins value + gasLimit * maxFeePerGas. Sinon, le nœud retourne l’erreur insufficient funds for gas * price + value, même si les frais réels sont bien inférieurs.
6. Signature
6.1. Que signer et avec quoi
Ethereum utilise ECDSA sur la courbe secp256k1. Pour une transaction de type 2, les données signées sont :
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
Le résultat comprend r, s et yParity. À partir de la signature et des données, n’importe qui peut récupérer la clé publique, d’où on peut déduire l’adresse from. Comme chainId est inclus dans la partie signée, une transaction signée pour Sepolia ne peut pas être rejouée sur Mainnet (EIP-155).
6.2. Signer hors ligne avec 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);
}
}
Explication étape par étape :
createEtherTransactionavec le paramètrechainIdde typelongcrée une transaction EIP-1559.TransactionEncoder.signMessageeffectue le RLP-encoding, le hachage keccak256, signe avec la clé privée danscredentials, puis encode la signature complète.Numeric.toHexStringretourne une chaîne hex prête à être envoyée viaeth_sendRawTransaction.
La chaîne résultante commence toujours par l’octet du type de transaction :
0x02f873827a6907845968...
Cette fonction n’a besoin d’aucune connexion réseau. Vous pouvez signer sur une machine isolée, puis apporter la chaîne hex sur une autre machine pour l’envoyer.
6.3. Tester la signature
Le test ci-dessous signe une transaction, la décode et vérifie que l’adresse récupérée correspond au signataire :
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));
}
}
Le premier test confirme ce qui a été dit à la section 2 : from est déduit de la signature, il n’est pas stocké dans la transaction. Le second test montre que chainId fait partie des données signées. Hash.sha3 sur la chaîne hex signée est exactement le hash de transaction affiché par les explorateurs de blocs.
7. Envoyer une transaction et attendre le reçu
Rassemblez les pièces dans un 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 interroge le nœud toutes les 2 secondes, 30 fois au maximum. Lorsque vous recevez un reçu, vous devez également vérifier receipt.isStatusOK(). La valeur false signifie que la transaction a été incluse dans un bloc mais a reverté, et les frais sont quand même déduits.
8. Erreurs courantes
| Message du nœud | Cause | Solution |
|---|---|---|
nonce too low |
Le nonce a déjà été utilisé | Resynchronisez le nonce avec PENDING |
replacement transaction underpriced |
Même nonce qu’une transaction en attente mais frais insuffisants | Augmentez à la fois maxFeePerGas et maxPriorityFeePerGas d’au moins 10% |
intrinsic gas too low |
gasLimit inférieur au minimum (21 000 + coût de data) |
Utilisez eth_estimateGas |
insufficient funds for gas * price + value |
Solde < value + gasLimit * maxFeePerGas |
Rechargez ou diminuez maxFeePerGas |
invalid chain id |
Signée avec un chainId différent du réseau du nœud |
Lisez chainId depuis la configuration ou eth_chainId |
Reçu status = 0x0 |
Revert ou manque de gas lors de l’exécution | Vérifiez la logique du contrat, augmentez gasLimit |
Remarque : Ne laissez jamais la clé privée dans
application.propertiesou dans des variables d’environnement sur un serveur de production. La signature avecCredentialsconvient pour les environnements de développement, de test et les outils internes. Pour un portefeuille contenant de l’argent réel, signez via un KMS/HSM ou un service de signature séparé. L’application ne doit envoyer que les données à signer, jamais accéder aux clés.
Remarque : Ne gérez pas le nonce en mémoire si vous exécutez plusieurs instances partageant le même portefeuille. Centralisez toutes les commandes de signature dans un seul service, ou assignez un portefeuille dédié à chaque instance.
9. Conclusion
Dans cet article, vous avez vu les trois composants qui déterminent si une transaction Ethereum est acceptée :
- Nonce garantit l’ordre et la protection contre les rejeux. Récupérez-le selon
PENDINGet allouez-le de manière atomique lors d’envois concurrents. - Gas comprend
gasLimit(combien) etmaxFeePerGas/maxPriorityFeePerGas(à quel prix). Vous ne payez quegasUsed * min(maxFee, baseFee + tip). - Signature ECDSA secp256k1 sur les données contenant
chainId. Elle prouve à la fois la propriété et est la seule source pour déduire l’adressefrom.
Avec web3j, vous pouvez signer les transactions entièrement hors ligne, les tester avec JUnit sans avoir besoin d’un nœud, puis les envoyer sur le réseau avec juste eth_sendRawTransaction.