Nethereum.Signer
NuGet:
Nethereum.Signer| Source:src/Nethereum.Signer/
Nethereum.Signer
Cryptographic signing library for Ethereum transactions, messages, and typed data using secp256k1 elliptic curve cryptography.
Overview
Nethereum.Signer is the core cryptographic engine for all signing operations in Nethereum. It provides secure key generation, transaction signing (Legacy, EIP-155, EIP-1559, EIP-7702), message signing, and signature recovery using the secp256k1 elliptic curve (same as Bitcoin and Ethereum).
Key Features:
- Secure random EC key pair generation using BouncyCastle
- All Ethereum transaction types: Legacy, EIP-155 (replay protection), EIP-1559 (fee market), EIP-7702 (account abstraction)
- Ethereum message signing with "\x19Ethereum Signed Message:\n" prefix
- Signature recovery (ecRecover) to derive addresses from signatures
- Deterministic ECDSA (RFC 6979) for reproducible signatures
- Support for external signers (hardware wallets, key vaults)
- Uses BouncyCastle for cryptography (NBitcoin.Secp256k1 on .NET 6+)
Installation
dotnet add package Nethereum.Signer
Or via Package Manager Console:
Install-Package Nethereum.Signer
Dependencies
External:
- BouncyCastle.Cryptography (net472, net6.0+) or Portable.BouncyCastle (other frameworks)
- NBitcoin.Secp256k1 (net6.0+ for optimized signing)
Nethereum:
- Nethereum.Hex - Hex encoding/decoding
- Nethereum.Util - Keccak-256 hashing, address utilities
- Nethereum.RLP - RLP encoding for transactions
- Nethereum.ABI - ABI encoding for typed data (EIP-712)
- Nethereum.Model - Transaction and signature models
Key Concepts
secp256k1 Elliptic Curve
Ethereum uses the secp256k1 elliptic curve (same as Bitcoin) for public-key cryptography:
- Private Key: 256-bit random number (64 hex characters)
- Public Key: EC point derived from private key (128 hex characters uncompressed)
- Address: Last 20 bytes of Keccak-256 hash of public key
ECDSA Signatures
Ethereum signatures consist of three components:
- r: 32 bytes - x-coordinate of random EC point
- s: 32 bytes - proof computed from private key and message
- v: 1 byte - recovery ID (allows deriving public key from signature)
Combined signature format: 0x + r (64 hex) + s (64 hex) + v (2 hex) = 132 hex characters
Both ECDSASignature and EthECDSASignature expose predicates for
spec compliance:
IsLowS—s ≤ N/2(EIP-2 malleability guard; signatures wheres > N/2are rejected by Ethereum consensus since Homestead).IsCanonical— stricter:0 < r < NAND0 < s ≤ N/2. Use this to validate any received signature (transaction, EIP-7702 authorization tuple, off-chain message) before address recovery. EIP-7702 specifically requires canonical form on each auth entry.
Ethereum Message Signing
Ethereum messages are prefixed before signing to prevent signing malicious transactions:
"\x19Ethereum Signed Message:\n" + message.length + message
This prefix ensures that a signed message cannot be a valid transaction.
Transaction Types
- Legacy: Original transaction format (no replay protection)
- EIP-155: Adds chain ID for replay protection
- EIP-1559: Fee market with base fee + priority fee
- EIP-7702: Account abstraction with authorization lists
Quick Start
using Nethereum.Signer;
using Nethereum.Hex.HexConvertors.Extensions;
// Generate new key pair
var key = EthECKey.GenerateKey();
string privateKey = key.GetPrivateKey();
string address = key.GetPublicAddress();
Console.WriteLine($"Address: {address}");
Console.WriteLine($"Private Key: {privateKey}");
// Sign a message
var signer = new EthereumMessageSigner();
string message = "Hello Ethereum!";
string signature = signer.EncodeUTF8AndSign(message, key);
// Recover address from signature
string recoveredAddress = signer.EncodeUTF8AndEcRecover(message, signature);
Console.WriteLine($"Recovered: {recoveredAddress}");
Usage Examples
Example 1: Generate and Use EC Keys
using Nethereum.Signer;
using Nethereum.Hex.HexConvertors.Extensions;
// Generate new random key pair
var key = EthECKey.GenerateKey();
// Get private key (KEEP SECRET!)
string privateKeyHex = key.GetPrivateKey();
byte[] privateKeyBytes = key.GetPrivateKeyAsBytes();
// Get public key
byte[] publicKey = key.GetPubKey(); // Uncompressed (65 bytes with 0x04 prefix)
byte[] publicKeyCompressed = key.GetPubKey(true); // Compressed (33 bytes)
// Get Ethereum address
string address = key.GetPublicAddress();
Console.WriteLine($"Address: {address}"); // 0x...
// Recreate key from existing private key
var existingKey = new EthECKey("0xb5b1870957d373ef0eeffecc6e4812c0fd08f554b37b233526acc331bf1544f7");
Console.WriteLine($"Restored address: {existingKey.GetPublicAddress()}");
Example 2: Sign Ethereum Messages (Real Test Example)
using Nethereum.Signer;
using Nethereum.Hex.HexConvertors.Extensions;
// Example from EthereumMessageSignerTests.cs
var privateKey = "0xb5b1870957d373ef0eeffecc6e4812c0fd08f554b37b233526acc331bf1544f7";
var address = "0x12890D2cce102216644c59daE5baed380d84830c";
var message = "Hello from Nethereum";
var signer = new EthereumMessageSigner();
var key = new EthECKey(privateKey);
// Sign message (automatically adds Ethereum prefix and hashes)
string signature = signer.EncodeUTF8AndSign(message, key);
// Expected signature from test
var expectedSignature = "0xe20e42c13fbf52a5d65229f4dd1dcd3255691166ce2852456631baf4836afd4630480609a76794ee3018c5514ee3a0592031cf2490e7356dffe4ed202606f5181c";
Console.WriteLine($"Signature: {signature}");
Console.WriteLine($"Match: {signature == expectedSignature}");
// Recover signer's address from signature
string recoveredAddress = signer.EncodeUTF8AndEcRecover(message, signature);
Console.WriteLine($"Recovered address: {recoveredAddress}");
Console.WriteLine($"Match original: {address.Equals(recoveredAddress, StringComparison.OrdinalIgnoreCase)}");
Example 3: Verify MetaMask / MEW Signatures (Real Test Example)
using Nethereum.Signer;
using Nethereum.Util;
// Verify signature from MyEtherWallet (from EthereumMessageSignerTests.cs)
var address = "0xe651c5051ce42241765bbb24655a791ff0ec8d13";
var message = "wee test message 18/09/2017 02:55PM";
var mewSignature = "0xf5ac62a395216a84bd595069f1bb79f1ee08a15f07bb9d9349b3b185e69b20c60061dbe5cdbe7b4ed8d8fea707972f03c21dda80d99efde3d96b42c91b2703211b";
var signer = new EthereumMessageSigner();
string recoveredAddress = signer.EncodeUTF8AndEcRecover(message, mewSignature);
bool isValid = address.IsTheSameAddress(recoveredAddress);
Console.WriteLine($"MEW signature valid: {isValid}");
Console.WriteLine($"Expected: {address}");
Console.WriteLine($"Recovered: {recoveredAddress}");
// This works with signatures from:
// - MetaMask
// - MyEtherWallet (MEW)
// - Ledger
// - Trezor
// - Any wallet following EIP-191 standard
Example 4: Sign EIP-155 Transaction with Chain ID (Real Test Example)
using Nethereum.Signer;
using Nethereum.Model;
using Nethereum.RLP;
using Nethereum.Hex.HexConvertors.Extensions;
using System.Numerics;
// Example from Eip155SignerTests.cs
var privateKey = "4646464646464646464646464646464646464646464646464646464646464646";
var key = new EthECKey(privateKey);
// Create transaction with chain ID (EIP-155 for replay protection)
var nonce = 9.ToBytesForRLPEncoding();
var gasPrice = BigInteger.Parse("20000000000").ToBytesForRLPEncoding();
var gasLimit = 21000.ToBytesForRLPEncoding();
var to = "0x3535353535353535353535353535353535353535".HexToByteArray();
var value = BigInteger.Parse("1000000000000000000").ToBytesForRLPEncoding();
var data = "".HexToByteArray();
var chainId = 1.ToBytesForRLPEncoding(); // Mainnet
var tx = new LegacyTransactionChainId(nonce, gasPrice, gasLimit, to, value, data, chainId);
// Sign transaction
var signer = new LegacyTransactionSigner();
signer.SignTransaction(privateKey.HexToByteArray(), tx);
// V value includes chain ID: v = {0,1} + CHAIN_ID * 2 + 35
Console.WriteLine($"V value: {tx.Signature.V.ToIntFromRLPDecoded()}"); // 37 for mainnet
// Get signed transaction bytes (ready to broadcast)
byte[] signedTxBytes = tx.GetRLPEncoded();
string signedTxHex = signedTxBytes.ToHex(true);
Console.WriteLine($"Signed tx: {signedTxHex}");
// Recover signer from signed transaction
var recoveredTx = new LegacyTransactionChainId(signedTxBytes);
string recoveredAddress = recoveredTx.GetKey().GetPublicAddress();
Console.WriteLine($"Signer: {recoveredAddress}");
Console.WriteLine($"Match: {key.GetPublicAddress() == recoveredAddress}");
Example 5: Sign EIP-1559 Transaction (Fee Market)
using Nethereum.Signer;
using Nethereum.Model;
using Nethereum.Hex.HexConvertors.Extensions;
var privateKey = "0xb5b1870957d373ef0eeffecc6e4812c0fd08f554b37b233526acc331bf1544f7";
// EIP-1559 transaction with maxFeePerGas and maxPriorityFeePerGas
// Transaction1559's numeric fields are EvmUInt256, which has implicit
// conversions from long/ulong, so plain literals are enough here.
long chainId = 1; // Mainnet
long nonce = 5;
long maxPriorityFeePerGas = 2000000000; // 2 gwei
long maxFeePerGas = 100000000000; // 100 gwei
long gasLimit = 21000;
var to = "0x3535353535353535353535353535353535353535";
long value = 1000000000000000000; // 1 ETH
var data = ""; // Data is a hex string, not raw bytes
var tx = new Transaction1559(
chainId,
nonce,
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
to,
value,
data,
null // access list
);
// Sign (SignTransaction mutates tx and also returns the signed RLP hex)
var signer = new Transaction1559Signer();
string signedTxHex = signer.SignTransaction(privateKey.HexToByteArray(), tx);
// Transaction type 0x02 for EIP-1559
byte[] signedTx = tx.GetRLPEncoded();
Console.WriteLine($"Type: 0x{signedTx[0]:X2}"); // 0x02
// Recover signer: decode the RLP back into a Transaction1559 (there is no
// Transaction1559(byte[]) constructor), then recover via the GetKey() extension.
var recovered = Transaction1559Encoder.Current.Decode(signedTx);
Console.WriteLine($"Signer: {recovered.GetKey().GetPublicAddress()}");
Example 6: Hash and Sign Raw Messages
using Nethereum.Signer;
using Nethereum.Util;
using Nethereum.Hex.HexConvertors.Extensions;
using System.Text;
var privateKey = "0xb5b1870957d373ef0eeffecc6e4812c0fd08f554b37b233526acc331bf1544f7";
var key = new EthECKey(privateKey);
// Method 1: Sign with Ethereum prefix (most common)
var signer = new EthereumMessageSigner();
string message = "test";
string signature1 = signer.EncodeUTF8AndSign(message, key);
Console.WriteLine($"Ethereum signature: {signature1}");
// Method 2: Hash message yourself, then sign with prefix
var hasher = new Sha3Keccack();
byte[] messageHash = hasher.CalculateHash(Encoding.UTF8.GetBytes(message));
string signature2 = signer.Sign(messageHash, key);
Console.WriteLine($"Pre-hashed signature: {signature2}");
// Method 3: Sign raw hash WITHOUT Ethereum prefix (not recommended)
var rawSigner = new MessageSigner();
string signature3 = rawSigner.Sign(messageHash, key);
Console.WriteLine($"Raw signature: {signature3}");
// signature1 != signature2: Sign(messageHash, key) applies the prefix to the 32-byte hash, whereas EncodeUTF8AndSign applies it to the message bytes
Console.WriteLine($"Signatures match: {signature1 == signature2}"); // False
Example 7: Verify Signature with AllowOnlyLowS (Prevent Malleability)
using Nethereum.Signer;
using System.Text;
var privateKey = "0x4646464646464646464646464646464646464646464646464646464646464646";
var key = new EthECKey(privateKey);
var message = Encoding.UTF8.GetBytes("test message");
// Sign message
var signer = new MessageSigner();
string signatureHex = signer.Sign(message, key);
// Parse signature: MessageSigner.Sign returns r|s|v (65 raw bytes), NOT a
// DER-encoded blob, so it must go through EthECDSASignatureFactory, not the
// EthECDSASignature(byte[] derSig) constructor.
var signature = EthECDSASignatureFactory.ExtractECDSASignature(signatureHex);
// Verify with low-S enforcement (prevents signature malleability)
bool isValid = key.VerifyAllowingOnlyLowS(message, signature);
Console.WriteLine($"Signature valid (low-S only): {isValid}");
// Without low-S enforcement (accepts both high and low S values)
bool isValidAny = key.Verify(message, signature);
Console.WriteLine($"Signature valid (any S): {isValidAny}");
// Why enforce low-S?
// ECDSA signatures have two valid S values (s and n-s)
// Bitcoin/Ethereum enforce low-S to prevent transaction malleability
// Always use VerifyAllowingOnlyLowS for security
Example 8: Shared Secret Calculation (ECDH)
using Nethereum.Signer;
using Nethereum.Hex.HexConvertors.Extensions;
// Alice generates key pair
var aliceKey = EthECKey.GenerateKey();
Console.WriteLine($"Alice address: {aliceKey.GetPublicAddress()}");
// Bob generates key pair
var bobKey = EthECKey.GenerateKey();
Console.WriteLine($"Bob address: {bobKey.GetPublicAddress()}");
// Alice calculates shared secret using Bob's public key
var bobPublicKey = new EthECKey(bobKey.GetPubKey(), false);
byte[] aliceSharedSecret = aliceKey.CalculateCommonSecret(bobPublicKey);
// Bob calculates shared secret using Alice's public key
var alicePublicKey = new EthECKey(aliceKey.GetPubKey(), false);
byte[] bobSharedSecret = bobKey.CalculateCommonSecret(alicePublicKey);
// Both shared secrets are identical
Console.WriteLine($"Alice secret: {aliceSharedSecret.ToHex(true)}");
Console.WriteLine($"Bob secret: {bobSharedSecret.ToHex(true)}");
Console.WriteLine($"Secrets match: {aliceSharedSecret.SequenceEqual(bobSharedSecret)}");
// Use shared secret for symmetric encryption (AES, etc.)
// This is the basis of ECIES (Elliptic Curve Integrated Encryption Scheme)
API Reference
EthECKey
Ethereum elliptic curve key pair.
// Constructors
public EthECKey(string privateKeyHex);
public EthECKey(byte[] keyData, bool isPrivate);
// Static methods
public static EthECKey GenerateKey();
public static EthECKey GenerateKey(byte[] seed);
// Properties & Methods
public string GetPrivateKey(); // Hex string with 0x prefix
public byte[] GetPrivateKeyAsBytes();
public byte[] GetPubKey(bool compressed = false); // false = 65 bytes uncompressed, true = 33 bytes compressed
public string GetPublicAddress(); // Ethereum address (0x...)
// Signing & Verification
public EthECDSASignature Sign(byte[] hash);
public EthECDSASignature SignAndCalculateV(byte[] hash);
public bool Verify(byte[] hash, EthECDSASignature signature);
public bool VerifyAllowingOnlyLowS(byte[] hash, EthECDSASignature signature);
// ECDH
public byte[] CalculateCommonSecret(EthECKey publicKey);
EthereumMessageSigner
Sign and verify Ethereum messages with standard prefix.
public class EthereumMessageSigner : MessageSigner
{
// Sign message (adds Ethereum prefix)
public string EncodeUTF8AndSign(string message, EthECKey key);
public override string Sign(byte[] message, EthECKey key);
public override string HashAndSign(byte[] message, EthECKey key);
// Recover signer address
public string EncodeUTF8AndEcRecover(string message, string signature);
public override string EcRecover(byte[] message, string signature);
public string HashAndEcRecover(string message, string signature); // inherited from MessageSigner
// Hash with Ethereum prefix
public byte[] HashPrefixedMessage(string message);
public byte[] HashPrefixedMessage(byte[] message);
}
Public key recovery
public static EthECKey RecoverFromSignature(EthECDSASignature signature, byte[] hash);
public static EthECKey RecoverFromSignature(EthECDSASignature signature, int recId, byte[] hash);
public static EthECKey RecoverFromSignature(EthECDSASignature signature, byte[] hash, BigInteger chainId);
public static EthECKey RecoverFromParityYSignature(EthECDSASignature signature, byte[] hash);
The recovery methods return null when no public key recovers from the signature; EcRecover throws instead.
var signature = EthECDSASignatureFactory.FromComponents(rBytes, sBytes, new byte[] { 27 });
Assert.Null(EthECKey.RecoverFromSignature(signature, hash));
Assert.Throws<ArgumentException>(() => new MessageSigner().EcRecover(hash, signature));
VerifyTransaction on a typed transaction (EIP-2930, EIP-1559, EIP-4844, EIP-7702) returns false unless the y-parity is 0 or 1 and r and s are canonical (s in the lower half of the curve order), and returns false when no sender recovers. EthECKey.RecoverFromParityYSignature and GetSenderAddress do not check them: RecoverFromParityYSignature recovers with V as given and returns null only when no key recovers, and GetSenderAddress / GetPublicKey throw InvalidOperationException when no sender recovers.
decoded.SetSignature(new Signature { R = signature.R, S = signature.S, V = new byte[] { 2 } });
Assert.False(decoded.VerifyTransaction());
Backed by FoundationsSignerDocExampleTests.Given_ASignatureThatRecoversNoKey_When_Recovered_Then_RecoverFromSignatureReturnsNullAndEcRecoverThrows and FoundationsSignerDocExampleTests.Given_AYParityAboveOne_When_TheTypedTransactionIsVerified_Then_VerificationFails.
Secp256k1Contexts
public static class Secp256k1Contexts
{
public static bool IsInitialised { get; }
public static void EnsureInitialised();
}
NBitcoin.Secp256k1 builds its precomputation tables on first use, which is a cost the first signature or recovery would otherwise pay. EnsureInitialised() runs one sign and one recovery under a lock, once per process, so a host can pay it at startup; later calls return immediately. It does nothing on targets without the NBitcoin.Secp256k1 backend (below .NET Core 3.1).
Secp256k1Contexts.EnsureInitialised();
Assert.True(Secp256k1Contexts.IsInitialised);
Backed by FoundationsSignerDocExampleTests.Given_TheSecp256k1Contexts_When_EnsureInitialisedIsCalled_Then_TheyAreReportedInitialised.
BN128Curve
Nethereum.Signer.Crypto.BN128Curve implements the alt_bn128 operations of the EIP-196 and EIP-197 precompiles. G1 addition and scalar multiplication (Add, Mul, TryAdd, TryMul) run on a managed implementation shared with the EVM ecAdd and ecMul precompiles; Pairing and TryPairing read G1 through BouncyCastle. On .NET 8 and 9 the G2 subgroup check and the field arithmetic under it are compiled with MethodImplOptions.AggressiveOptimization, because under tiered dynamic PGO concurrent pairings wrongly refused valid input. Add, Mul and Pairing take the precompile input bytes and throw ArgumentException on invalid input (a coordinate not in the field, a point not on the curve, a G2 point outside the subgroup, a pairing input whose length is not a multiple of 192). The Try forms report the same conditions as false, for callers that cannot afford an exception:
public static byte[] Add(byte[] input);
public static byte[] Mul(byte[] input);
public static byte[] Pairing(byte[] input);
public static bool TryAdd(byte[] input, out byte[] output);
public static bool TryMul(byte[] input, out byte[] output);
public static bool TryPairing(byte[] input, out byte[] output);
Assert.True(BN128Curve.TryAdd(validInput, out var sum));
Assert.False(BN128Curve.TryAdd(offCurveInput, out var none));
Assert.Throws<ArgumentException>(() => BN128Curve.Add(offCurveInput));
Backed by FoundationsSignerDocExampleTests.Given_AnInvalidBn128Point_When_AddedWithTryAddAndAdd_Then_TryAddReturnsFalseAndAddThrows.
EnrRecordSigner
Nethereum.Signer.Enr.EnrRecordSigner.Sign(EnrRecord record, EthECKey privateKey) signs an EIP-778 node record and Verify(EnrRecord record) checks it. Verify returns false for a signature with a high s.
EnrRecordSigner.Sign(record, key);
Assert.True(EnrRecordSigner.Verify(record));
Backed by FoundationsSignerDocExampleTests.Given_ASignedEnrRecordWhoseSignatureIsMadeHighS_When_Verified_Then_ItIsRejected.
MessageSigner
Raw message signing (without Ethereum prefix).
public class MessageSigner
{
public virtual string Sign(byte[] message, EthECKey key);
public string Sign(byte[] message, string privateKey);
public virtual string HashAndSign(byte[] plainMessage, EthECKey key);
public string HashAndSign(string plainMessage, string privateKey);
public string HashAndSign(byte[] plainMessage, string privateKey);
public virtual string EcRecover(byte[] hashMessage, string signature);
public virtual string HashAndEcRecover(string plainMessage, string signature);
public byte[] Hash(byte[] plainMessage);
}
Transaction Signers
Every transaction signer mutates the transaction in place (sets its Signature)
and returns the signed transaction as an RLP-encoded hex string.
// Legacy transactions
public class LegacyTransactionSigner
{
public string SignTransaction(byte[] privateKey, LegacyTransaction transaction);
public string SignTransaction(byte[] privateKey, LegacyTransactionChainId transaction);
}
// EIP-1559 / EIP-7702 / EIP-4844 transactions all share TypeTransactionSigner<T>
public class Transaction1559Signer : TypeTransactionSigner<Transaction1559> { }
public class Transaction7702Signer : TypeTransactionSigner<Transaction7702> { }
public class Transaction4844Signer : TypeTransactionSigner<Transaction4844> { }
public class TypeTransactionSigner<T> where T : SignedTypeTransaction
{
public string SignTransaction(string privateKey, T transaction);
public string SignTransaction(byte[] privateKey, T transaction);
public string SignTransaction(EthECKey ecKey, T transaction);
}
// Authorization lists (EIP-7702)
public class Authorisation7702Signer
{
public Authorisation7702Signed SignAuthorisation(string privateKey, Authorisation7702 authorisation);
public Authorisation7702Signed SignAuthorisation(byte[] privateKey, Authorisation7702 authorisation);
public Authorisation7702Signed SignAuthorisation(EthECKey ecKey, Authorisation7702 authorisation);
public List<Authorisation7702Signed> SignAuthorisations(string privateKey, List<Authorisation7702> authorisations);
public List<Authorisation7702Signed> SignAuthorisations(byte[] privateKey, List<Authorisation7702> authorisations);
public List<Authorisation7702Signed> SignAuthorisations(EthECKey ecKey, List<Authorisation7702> authorisations);
}
EthECDSASignature
ECDSA signature representation.
public class EthECDSASignature
{
public byte[] R { get; }
public byte[] S { get; }
public byte[] V { get; set; }
public EthECDSASignature(BigInteger r, BigInteger s, byte[] v);
public EthECDSASignature(ECDSASignature signature);
public EthECDSASignature(byte[] derSig); // Parses a DER-encoded signature, NOT r|s|v
public bool IsLowS { get; }
public bool IsCanonical { get; }
public byte[] ToDER();
public static EthECDSASignature FromDER(byte[] sig);
public static string CreateStringSignature(EthECDSASignature signature);
}
To parse a raw r|s|v signature (the format returned by MessageSigner.Sign /
EthereumMessageSigner.EncodeUTF8AndSign), use EthECDSASignatureFactory
instead of the byte[] derSig constructor:
public static class EthECDSASignatureFactory
{
public static EthECDSASignature FromComponents(byte[] r, byte[] s);
public static EthECDSASignature FromComponents(byte[] r, byte[] s, byte v);
public static EthECDSASignature FromComponents(byte[] r, byte[] s, byte[] v);
public static EthECDSASignature FromComponents(byte[] rs);
public static EthECDSASignature FromSignature(ISignature signature);
public static EthECDSASignature ExtractECDSASignature(string signature); // "0x" + r + s + v hex string
}
IEthExternalSigner / EthExternalSignerBase
External signer abstraction for hardware wallets and key vaults (Ledger, Trezor,
Azure Key Vault, AWS KMS). Implementations derive from EthExternalSignerBase,
which handles recovery-ID calculation and only requires the public key and the
raw ECDSA signing step:
public interface IEthExternalSigner
{
bool CalculatesV { get; }
ExternalSignerTransactionFormat ExternalSignerTransactionFormat { get; }
Task<string> GetAddressAsync();
Task<EthECDSASignature> SignAsync(byte[] rawBytes);
Task<EthECDSASignature> SignEthereumMessageAsync(byte[] rawBytes);
Task<EthECDSASignature> SignAsync(byte[] rawBytes, BigInteger chainId);
Task SignAsync(LegacyTransaction transaction);
Task SignAsync(LegacyTransactionChainId transaction);
Task SignAsync(Transaction1559 transaction);
Task SignAsync(Transaction7702 transaction);
Task SignAsync(Transaction4844 transaction);
Task<string> SignTypedDataJsonAsync(string typedDataJson, string messageKeySelector = "message");
bool Supported1559 { get; }
}
public abstract class EthExternalSignerBase : IEthExternalSigner
{
// Implementors provide these two:
protected abstract Task<byte[]> GetPublicKeyAsync();
protected abstract Task<ECDSASignature> SignExternallyAsync(byte[] bytes);
// Everything else (address derivation, recovery-ID calculation,
// per-transaction-type signing, typed-data signing) is provided.
}
Related Packages
Used By (Consumers)
- Nethereum.Accounts - Account management with key-based signing
- Nethereum.HDWallet - BIP32/BIP39 hierarchical deterministic wallets
- Nethereum.Signer.EIP712 - EIP-712 typed structured data signing
- Nethereum.Signer.Ledger - Ledger hardware wallet integration
- Nethereum.Signer.Trezor - Trezor hardware wallet integration
- Nethereum.Signer.AzureKeyVault - Azure Key Vault signing
- Nethereum.Signer.AWSKeyManagement - AWS KMS signing
Dependencies
- Nethereum.Hex - Hex encoding/decoding
- Nethereum.Util - Keccak hashing, address utilities
- Nethereum.RLP - RLP encoding
- Nethereum.ABI - ABI encoding
- Nethereum.Model - Transaction models
Important Notes
Private Key Security
NEVER expose private keys in production code:
// ❌ WRONG - Hard-coded private key
var key = new EthECKey("0x1234567890abcdef...");
// ✅ CORRECT - Load from secure storage
string privateKey = Environment.GetEnvironmentVariable("PRIVATE_KEY");
var key = new EthECKey(privateKey);
// ✅ BETTER - Use hardware wallet or key vault
// See Nethereum.Signer.Ledger, Nethereum.Signer.AzureKeyVault
Signature Malleability
Always use VerifyAllowingOnlyLowS to prevent signature malleability:
// ❌ WRONG - Allows high-S signatures (malleable)
bool valid = key.Verify(hash, signature);
// ✅ CORRECT - Enforces low-S (prevents malleability)
bool valid = key.VerifyAllowingOnlyLowS(hash, signature);
Chain ID for Replay Protection
Always specify chain ID for EIP-155+ transactions:
// ❌ WRONG - No replay protection
var tx = new LegacyTransaction(nonce, gasPrice, gasLimit, to, value, data);
// ✅ CORRECT - EIP-155 with chain ID
var chainId = 1; // Mainnet
var tx = new LegacyTransactionChainId(nonce, gasPrice, gasLimit, to, value, data, chainId.ToBytesForRLPEncoding());
Ethereum Message Prefix
Use EthereumMessageSigner (not MessageSigner) for user-facing messages:
// ❌ WRONG - No Ethereum prefix (could sign malicious transaction)
var signer = new MessageSigner();
string sig = signer.Sign(message, key);
// ✅ CORRECT - Adds Ethereum prefix (safe for user messages)
var signer = new EthereumMessageSigner();
string sig = signer.EncodeUTF8AndSign(message, key);
BouncyCastle vs NBitcoin.Secp256k1
EthECKey.SignRecoverable defaults to true (NBitcoin.Secp256k1) on .NET 8+ and false on earlier targets; on .NET 6+ you can enable it explicitly:
#if NET6_0_OR_GREATER
// Enable recoverable signatures (uses NBitcoin.Secp256k1)
EthECKey.SignRecoverable = true;
#endif
This is faster and avoids post-signature recovery ID calculation.
Thread Safety
EthECKey instances are not thread-safe. Don't share key instances across threads without synchronization.