Transaktionen in Ethereum: nonce, gas und Signatur
1. Übersicht
Jede Zustandsänderung auf Ethereum beginnt mit einer Transaktion. Die meisten Fehler beim Senden von Transaktionen wie „nonce too low”, „replacement transaction underpriced” oder „insufficient funds” hängen mit drei Feldern zusammen: nonce, gas und Signatur.
In diesem Artikel schauts du dir an, wie jedes Feld funktioniert. Dann beschaffst du dir einen nonce, berechnest die Gebühren nach EIP-1559, signierst die Transaktion offline und sendest sie über web3j in einer Spring-Boot-3.x-Anwendung ins Netzwerk.
2. Struktur einer EIP-1559-Transaktion
Seit dem London-Upgrade ist der häufigste Transaktionstyp Typ 2 (EIP-1559). Er besteht aus folgenden Feldern:
| Feld | Bedeutung |
|---|---|
chainId |
Netzwerk-ID (1 = Mainnet, 11155111 = Sepolia), schützt vor Replay-Angriffen über Chains hinweg |
nonce |
Sequenznummer der Transaktion des sendenden Kontos |
maxPriorityFeePerGas |
Maximales „Trinkgeld” für den Validator pro Gas-Einheit |
maxFeePerGas |
Maximale Gesamtgebühr, die du pro Gas-Einheit akzeptierst |
gasLimit |
Maximale Menge an Gas, die die Transaktion verbrauchen darf |
to, value, data |
Empfängeradresse, transferierte Wei und Contract-Aufrufdaten |
accessList |
Liste erwarteter zugegriffener Adressen/Storage (optional) |
yParity, r, s |
ECDSA-Signatur |
Die Transaktion hat kein from-Feld. Die Sender-Adresse wird aus der Signatur wiederhergestellt. Das ist ein wichtiger Punkt, den du in Abschnitt 6 wiedersehen wirst.
3. Projekteinrichtung
Füge web3j zu deiner pom.xml hinzu:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Deklariere die URL des RPC-Knotens (Sepolia, Anvil oder lokale Hardhat sind alle möglich):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Erstelle einen 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" hilft dabei, den Thread-Pool und den HTTP-Client von web3j zu schließen, wenn die Anwendung beendet wird.
4. Nonce
4.1. Was ist ein Nonce
Ein Nonce ist ein Zähler für die Anzahl der Transaktionen, die ein Konto (EOA) gesendet hat. Er beginnt bei 0 und erhöht sich um genau 1 nach jeder Transaktion, die in einen Block aufgenommen wird. Ein Knoten akzeptiert nur Transaktionen mit einem Nonce, der dem nächsten erwarteten Wert genau entspricht:
- Nonce zu niedrig: wird mit Fehler
nonce too lowabgelehnt. - Nonce zu hoch: wartet im Mempool, bis die Lücke gefüllt wird.
- Nonce gleich einer ausstehenden Transaktion: gilt als Ersatz für die alte Transaktion, wenn die Gebühr ausreichend höher ist (üblicherweise ≥ 10%).
Dank des Nonce kann dieselbe signierte Transaktion nicht ein zweites Mal eingereicht werden.
4.2. Nonce vom Knoten abrufen
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Hier verwenden wir PENDING statt LATEST. LATEST zählt nur Transaktionen in Blöcken. Wenn du gerade eine Transaktion gesendet hast und sie noch nicht gemint wurde, gibt LATEST den alten Nonce zurück. Die nächste Transaktion würde dann die vorherige ersetzen, anstatt sie fortzusetzen.
4.3. Nonce-Verwaltung bei gleichzeitigem Senden
Wenn mehrere Threads gleichzeitig pendingNonce aufrufen, können sie denselben Wert erhalten. Eine einfache Lösung ist, den Knoten nur einmal zu befragen und dann im Speicher selbst zu erhöhen:
@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 stellt sicher, dass der Knoten nur einmal pro Adresse abgefragt wird. getAndIncrement ist eine atomare Operation, daher erhalten zwei Threads niemals denselben Nonce.
Hinweis: Wenn ein Transaktionsversand auf RPC-Ebene fehlschlägt (z. B. weil der Knoten sie ablehnt), entsteht ein „Loch” in der vergebenen Nonce. Alle nachfolgenden Transaktionen bleiben im Mempool stecken. Bei einem Fehler rufe
reset()auf, um dich mit dem Knoten zu synchronisieren. Diese Methode ist auch nicht sicher, wenn mehrere Anwendungsinstanzen dieselbe Wallet verwenden. In diesem Fall brauchst du verteilte Locks oder einen zentralisierten Signing-Service.
5. Gas
5.1. gasLimit und gasUsed
Jeder Opcode in der EVM verbraucht eine feste Gasmenge. Ein einfacher ETH-Transfer kostet genau 21.000 Gas. Ein Contract-Aufruf kostet mehr, je nach interner Logik.
gasLimitist die von dir gesetzte Obergrenze. Wenn die EVM das Gas aufbraucht, bevor sie fertig ist, revert die Transaktion, aber du zahlst trotzdem für all das verwendete Gas.gasUsedist die tatsächlich verbrauchte Gasmenge. Der überschüssige Teil wird nicht in Rechnung gestellt.
Für Contract-Aufrufe schätze vorher mit 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));
}
Wir addieren 20% als Sicherheitspuffer. Der Zustand der Chain kann sich zwischen der Schätzung und dem Mining der Transaktion ändern.
5.2. Gebühren nach EIP-1559
Der Preis pro Gas-Einheit hat zwei Komponenten:
- baseFee: wird vom Protokoll basierend auf der Block-Auslastung angepasst und wird verbrannt.
- priority fee (tip): wird an den Validator gezahlt, um bevorzugt zu werden.
Der tatsächliche Preis, den du pro Gas zahlst, ist:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
Eine gängige Formel ist maxFeePerGas = 2 * baseFee + tip. Diese Formel stellt sicher, dass die Transaktion auch dann gültig bleibt, wenn die baseFee über mehrere Blöcke hinweg ansteigt, und du zahlst nicht mehr als nötig.
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));
}
Angenommen, baseFee = 20 gwei und tip = 1,5 gwei. Bei einem ETH-Transfer hast du:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Deine Wallet muss mindestens value + gasLimit * maxFeePerGas haben. Ansonsten gibt der Knoten den Fehler insufficient funds for gas * price + value zurück, auch wenn die tatsächlichen Gebühren viel niedriger sind.
6. Signatur
6.1. Signieren von was und womit
Ethereum verwendet ECDSA auf der Kurve secp256k1. Bei Transaktionstyp 2 ist das signierte Datum:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
Das Ergebnis sind r, s und yParity. Aus der Signatur und den Daten kann jeder den öffentlichen Schlüssel wiederherstellen und damit die from-Adresse ableiten. Weil chainId im signierten Daten enthalten ist, kann eine für Sepolia signierte Transaktion nicht auf dem Mainnet eingereicht werden (EIP-155).
6.2. Offline-Signieren mit 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);
}
}
Schritt-für-Schritt-Erklärung:
createEtherTransactionmit demchainId-Parameter des Typslongerstellt eine EIP-1559-Transaktion.TransactionEncoder.signMessageführt RLP-Encoding durch, hasht mit keccak256, signiert mit dem privaten Schlüssel incredentialsund kodiert die Signatur neu.Numeric.toHexStringgibt einen Hex-String zurück, der übereth_sendRawTransactiongesendet werden kann.
Der resultierende String beginnt immer mit dem Transaktionstyp-Byte:
0x02f873827a6907845968...
Diese Funktion erfordert keine Netzwerkverbindung. Du kannst auf einem isolierten Gerät signieren und den Hex-String dann auf einen anderen Computer übertragen, um ihn zu versenden.
6.3. Signatur testen
Der folgende Test signiert eine Transaktion, dekodiert sie wieder und prüft, dass die wiederhergestellte Adresse mit dem Unterzeichner übereinstimmt:
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));
}
}
Der erste Test bestätigt das, was in Abschnitt 2 gesagt wurde: from wird aus der Signatur abgeleitet, nicht in der Transaktion gespeichert. Der zweite Test zeigt, dass chainId Teil der signierten Daten ist. Hash.sha3 auf dem signierten Hex-String ist genau der Transaction-Hash, den Block-Explorer anzeigen.
7. Transaktion senden und auf Quittung warten
Kombiniere die Teile in einem 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 fragt den Knoten alle 2 Sekunden ab, maximal 30 Mal. Wenn du eine Quittung erhältst, musst du zusätzlich receipt.isStatusOK() prüfen. Ein Wert von false bedeutet, dass die Transaktion im Block ist, aber revert, und die Gebühr wird trotzdem abgezogen.
8. Häufige Fehler
| Nachricht vom Knoten | Ursache | Abhilfe |
|---|---|---|
nonce too low |
Nonce wurde bereits verwendet | Synchronisiere den Nonce mit PENDING neu |
replacement transaction underpriced |
Gleicher Nonce wie die ausstehende Transaktion, aber Gebühr zu niedrig | Erhöhe sowohl maxFeePerGas als auch maxPriorityFeePerGas um mindestens 10% |
intrinsic gas too low |
gasLimit liegt unter dem Minimum (21.000 + data-Kosten) |
Verwende eth_estimateGas |
insufficient funds for gas * price + value |
Kontostand < value + gasLimit * maxFeePerGas |
Geld hinzufügen oder maxFeePerGas senken |
invalid chain id |
Mit chainId des anderen Netzwerks signiert |
Lese chainId aus Konfiguration oder eth_chainId |
Receipt status = 0x0 |
Revert oder Out-of-Gas während Ausführung | Prüfe Contract-Logik, erhöhe gasLimit |
Hinweis: Speichere den private key nicht in
application.propertiesoder Umgebungsvariablen auf einem Production-Server. Das Signieren mitCredentialsist für Entwicklungs-, Test- und interne Toolumgebungen geeignet. Für eine Wallet mit echtem Geld solltest du über KMS/HSM oder einen separaten Signing-Service signieren. Die Anwendung sollte nur die zu signierenden Daten senden, nie den Schlüssel anfassen.
Hinweis: Verwalte den Nonce nicht selbst im Speicher, wenn du mehrere Instanzen hast, die dieselbe Wallet verwenden. Konzentriere alle Signing-Befehle auf einen einzigen Service, oder gib jeder Instanz eine eigene Wallet.
9. Fazit
In diesem Artikel hast du dir die drei Komponenten angesehen, die bestimmen, ob eine Ethereum-Transaktion akzeptiert wird:
- Nonce stellt die Reihenfolge sicher und verhindert Replay-Angriffe. Besorge dir den Nonce mit
PENDINGund vergebe ihn atomar beim gleichzeitigen Senden. - Gas besteht aus
gasLimit(wieviel) undmaxFeePerGas/maxPriorityFeePerGas(wieviel Preis). Du zahlst nurgasUsed * min(maxFee, baseFee + tip). - Signatur – ECDSA secp256k1 über Daten, die
chainIdenthalten. Sie beweist das Eigentumsrecht und ist die einzige Quelle zur Ableitung derfrom-Adresse.
Mit web3j kannst du Transaktionen vollständig offline signieren, mit JUnit ohne Knoten testen und dann über eth_sendRawTransaction ins Netzwerk versenden.