Blockchain 0x300 – Wallets and Signing transactions
So far, we’ve built the core of our blockchain and implemented basic transactions. But there’s a massive security hole in our system. A blockchain without security is like a safe without a lock — anyone can tamper with it.
Consider the following transaction from our last tutorial. Alice transfers $100 to Bob:
new Transaction("Alice", "Bob", 100);
On the surface, it looks fine. But here’s the problem — anyone could create this transaction. Trudy could write new Transaction("Alice", "Trudy", 1000) and there’s absolutely nothing stopping her. Our blockchain has no way to verify that Alice actually authorized this transfer.
Without a security mechanism, the system cannot distinguish between the real Alice and an imposter. That’s a dealbreaker.
In this post, we’re going to fix this by adding a proper security layer using public-key cryptography — ensuring that only the rightful owner of an account can authorize transactions.
Introducing Public-Key Cryptography
Every user in our blockchain will now have two cryptographic keys:
- Private Key — kept secret, used to sign transactions.
- Public Key — shared with others, used to verify transaction authenticity.
In other words, each account on the blockchain is defined by a key pair. Your public key is essentially your address — it’s how others identify you on the network. Your private key is what proves you own that address.
I’m not going to dive deep into the theory of public-key cryptography here — we have a complete tutorial series on cryptography that explains each and every important concept in detail. If you’re unfamiliar with how asymmetric encryption works, I’d recommend checking that out first.
Let’s get back to the original story.
When Alice wants to send funds, the process goes like this:
- She creates a transaction with her public key as the sender.
- She signs the transaction data using her private key — this produces a digital signature.
- She broadcasts the signed transaction to the network.
- Other nodes use her public key to verify the signature.

If the signature checks out, the transaction is legit. If it doesn’t — either someone tampered with the data or they’re trying to impersonate Alice — the transaction gets rejected.
This ensures two things:
- Authentication — only the real owner of the private key can authorize spending.
- Integrity — transactions are tamper-proof. Change even one byte of the transaction data, and the signature becomes invalid.
Mathematically, the signing process looks like this:
T = (sender, receiver, amount) // The transaction data
h = H(T) // Hash the transaction
σ = Sign(privateKey, h) // Sign the hash with the sender’s private key
And verification:
Verify(publicKey, h, σ) → true/false
Simple concept, powerful security. Now let’s implement it.
The Wallet Class
First, we need a way to generate key pairs. That’s what the Wallet class does — it represents a user’s account on the blockchain.
public class Wallet {
private PrivateKey privateKey;
private PublicKey publicKey;
public Wallet() {
generateKeyPair();
}
private void generateKeyPair() {
try {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
this.privateKey = keyPair.getPrivate();
this.publicKey = keyPair.getPublic();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public PrivateKey getPrivateKey() {
return privateKey;
}
public PublicKey getPublicKey() {
return publicKey;
}
}
What’s happening here? When a new Wallet is created, it automatically generates an RSA key pair with a 2048-bit key size. The private key stays within the wallet (Alice never shares this with anyone), and the public key can be shared freely — it acts as her identity on the blockchain.
In real-world blockchains like Bitcoin, they use elliptic curve cryptography (ECDSA) instead of RSA, but the concept is identical. RSA is simpler to understand and Java has great built-in support for it, so we’ll stick with that.
Upgrading CryptoUtils
Our CryptoUtils class needs new methods for signing and verifying transactions. Let’s add them:
public static byte[] applySignature(PrivateKey privateKey, String data) {
try {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(data.getBytes());
return sig.sign();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static boolean verifySignature(PublicKey publicKey, String data, byte[] signature) {
try {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(data.getBytes());
return sig.verify(signature);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static String getStringFromKey(Key key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
Three new methods:
applySignature()— takes a private key and data string, returns a digital signature (as a byte array). It usesSHA256withRSA, which means the data is first hashed with SHA-256, then signed with RSA.verifySignature()— takes a public key, the original data, and a signature, returnstrueif the signature is valid. This is what other nodes use to check if a transaction is legit.getStringFromKey()— converts a cryptographic key to a Base64 string. We need this because our hash function works with strings, and we need to include the sender’s public key in the hash calculation.
Upgrading the Transaction Class
This is where the biggest changes happen. Our Transaction class now uses PublicKey instead of a plain string for the sender, and it can be signed and verified:
public class Transaction {
private PublicKey sender;
private String recipient;
private double amount;
private byte[] signature;
public Transaction(PublicKey sender, String recipient, double amount) {
this.sender = sender;
this.recipient = recipient;
this.amount = amount;
}
private String getData() {
return CryptoUtils.getStringFromKey(sender) + recipient + amount;
}
public void signTransaction(PrivateKey privateKey) {
String data = getData();
signature = CryptoUtils.applySignature(privateKey, data);
}
public boolean isValid() {
if (signature == null)
return false;
return CryptoUtils.verifySignature(sender, getData(), signature);
}
@Override
public String toString() {
return sender + " -> " + recipient + ": " + amount;
}
}
Let’s walk through the key methods:
getData()— concatenates the sender’s public key (as a string), the recipient, and the amount. This is the data that gets signed.signTransaction()— takes a private key and creates a digital signature over the transaction data. Only the person who owns the matching private key can create a valid signature.isValid()— first checks if the transaction has been signed at all (unsigned transactions are always invalid). Then it verifies that the signature matches the sender’s public key and the transaction data.
Here’s the critical part — if Trudy tries to sign a transaction where Alice is the sender, the verification will fail. Why? Because Trudy’s private key doesn’t match Alice’s public key. The math simply doesn’t work out. The verifySignature() call returns false, and the transaction gets rejected.
Updating the Chain Class
Now we need to wire validation into our chain. The addTransaction() method now checks if a transaction is valid before accepting it:
public void addTransaction(Transaction tx) {
if (tx.isValid()) {
transactionPool.addTransaction(tx);
} else {
System.out.println("Invalid transaction! Rejected.");
}
}
That’s it. One simple check at the gate. If the transaction’s signature doesn’t verify against the sender’s public key, it gets rejected. No exceptions.
The rest of the Chain class stays the same — minePendingTransactions(), isChainValid(), and printChain() all work exactly as before. The security layer sits at the transaction level, not the chain level.
Putting It All Together
Let’s see how it all works. Here’s our updated Main class:
public class Main {
public static void main(String[] args) {
Wallet alice = new Wallet();
Wallet eve = new Wallet();
Chain chain = new Chain();
// Create a valid transaction — Alice signs with her own private key
Transaction tx1 = new Transaction(alice.getPublicKey(), "Bob", 50);
tx1.signTransaction(alice.getPrivateKey());
chain.addTransaction(tx1);
// Eve tries to forge a transaction as Alice — signs with Eve’s private key
Transaction fakeTx = new Transaction(alice.getPublicKey(), "Eve", 100);
fakeTx.signTransaction(eve.getPrivateKey());
chain.addTransaction(fakeTx); // Should be rejected!
// Mine transactions
chain.minePendingTransactions();
chain.printChain();
}
}
What happens when you run this?
- Alice’s transaction goes through — she created it with her public key as the sender and signed it with her private key. The keys match. Valid.
- Eve’s forged transaction gets rejected — she set Alice’s public key as the sender but signed it with her own private key. The keys don’t match.
isValid()returnsfalse. The chain prints"Invalid transaction! Rejected."and moves on.
Only Alice’s legitimate transaction ends up in the mined block. Eve’s attempt at fraud is caught before it even enters the transaction pool. This is exactly how real blockchains prevent impersonation — the math doesn’t lie.
Wrapping Up
Let’s look at what we’ve added in this tutorial:
- Wallet class — generates RSA key pairs, giving each user a unique identity on the blockchain
- Digital signatures — transactions are signed with private keys and verified with public keys
- CryptoUtils upgrades — new methods for signing, verifying, and converting keys
- Transaction validation — the chain now rejects any transaction with an invalid or missing signature
Our blockchain has gone from a toy to something with real security guarantees. Nobody can forge transactions, nobody can impersonate another user, and any tampering is immediately detectable.
The full source code is available on GitHub: distributed-blockchain (branch 0x300)
But we’re still not done. Right now, there’s no concept of account balances — Alice can send $1,000,000 even if she only has $50. And our blockchain still runs on a single machine. In the upcoming tutorials, we’ll tackle balance validation and start distributing our blockchain across a network. The real fun is just getting started.