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 low abgelehnt.
  • 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.

  • gasLimit ist 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.
  • gasUsed ist 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:

  • createEtherTransaction mit dem chainId-Parameter des Typs long erstellt eine EIP-1559-Transaktion.
  • TransactionEncoder.signMessage führt RLP-Encoding durch, hasht mit keccak256, signiert mit dem privaten Schlüssel in credentials und kodiert die Signatur neu.
  • Numeric.toHexString gibt einen Hex-String zurück, der über eth_sendRawTransaction gesendet 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.properties oder Umgebungsvariablen auf einem Production-Server. Das Signieren mit Credentials ist 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 PENDING und vergebe ihn atomar beim gleichzeitigen Senden.
  • Gas besteht aus gasLimit (wieviel) und maxFeePerGas/maxPriorityFeePerGas (wieviel Preis). Du zahlst nur gasUsed * min(maxFee, baseFee + tip).
  • Signatur – ECDSA secp256k1 über Daten, die chainId enthalten. Sie beweist das Eigentumsrecht und ist die einzige Quelle zur Ableitung der from-Adresse.

Mit web3j kannst du Transaktionen vollständig offline signieren, mit JUnit ohne Knoten testen und dann über eth_sendRawTransaction ins Netzwerk versenden.

ethereumevmweb3jtransactionjava