Blockchain in Zig - PART 2
Disclaimers
This post keeps following the legendary blog series by Ivan Kuznetsov. If you haven’t read Part 1 of “Blockchain in Zig” yet, I recommend doing that first so you know where the code is coming from.
Same deal as before: this is not a blockchain 101, it’s a translation journey from Go to Zig with all the “uhh, how do I do this in Zig?” moments left in. Expect rough edges.
Repo is still the same 👉 Mario-SO/zblk, it has a Part 1 and a Part 2 branch. Feel free to star it and yell at me on socials if you spot something silly.
Recap
Last time we got a minimal blockchain working: we could mine blocks, link them with hashes, and print them out. The problem? Mining a block was basically instant. That’s not how real blockchains work, there should be work involved.
Today we’re adding a basic Proof-of-Work (PoW) system so miners have to grind for a valid hash. No rewards yet, no difficulty adjustments—just the core loop that makes mining slow on purpose.
Picking a Difficulty
No PoW without difficulty. We’ll use the same idea as the Go implementation: define how many leading zero bits a block hash needs.
pub const TARGET_POW_BITS = 24;
Twenty‑four bits means we want the block hash to start with 3 zero bytes. It’s a nice balance: slow enough that you feel the grind, fast enough that we don’t wait forever while debugging.
Blocks Grow Up
To validate PoW later we have to remember which nonce produced the winning hash. That means the Block struct from Part 1 got one more field:
const Block = struct {
timestamp: i64,
data: []const u8,
prevblockhash: [32]u8,
hash: [32]u8,
nonce: usize,
// ...
};
Notice how the hash arrays are still fixed-size [32]u8. A SHA‑256 digest is always 32 bytes, so having the compiler enforce that shape saves us from a ton of bugs.
Inside Block.init the nonce starts as undefined. We only lock it in when mining actually succeeds.
Turning Difficulty into Bytes
The original Go code uses big.Int to compute the mining target. In Zig we stay closer to the metal and build the target by hand:
fn make_target(comptime target_bits: u8) [32]u8 {
var out: [32]u8 = [_]u8{0} ** 32;
const shift: u16 = @intCast(256 - @as(u16, target_bits));
const byte_from_right: usize = shift / 8;
const bit_in_byte: u3 = @intCast(shift % 8);
const be_index: usize = 31 - byte_from_right;
out[be_index] = @as(u8, 1) << bit_in_byte;
return out;
}
What’s happening here?
- We start with 256 zero bits (all bytes cleared).
- We figure out where to place the single
1bit that defines the upper boundary (target). - Everything to the right stays zero, so any hash less than or equal to this array passes.
This is effectively the same math as 1 << (256 - targetBits) in Go, just done manually because Zig doesn’t have a built-in big.Int type yet.
ProofOfWork Struct
Now for the fun part. We wrap all PoW logic in its own struct:
const ProofOfWork = struct {
block: *Block,
target: [32]u8,
alloc: Allocator,
pub fn init(alloc: Allocator, block: *Block) Self {
return .{
.block = block,
.target = make_target(TARGET_POW_BITS),
.alloc = alloc,
};
}
// ...
};
Keeping the allocator around lets us reuse the arena created by the blockchain. No extra copies, no random heap allocations, just everything scoped nicely.
Preparing the Data Buffet
The PoW loop repeatedly hashes block header + nonce. In the Go version that header is a byte slice. In Zig we build it on the fly and own the memory for the duration of the attempt:
fn prepare_data(self: *Self, nonce: usize) ![]u8 {
var list: std.ArrayListUnmanaged(u8) = .{};
errdefer list.deinit(self.alloc);
try list.appendSlice(self.alloc, self.block.prevblockhash[0..]);
try list.appendSlice(self.alloc, self.block.data);
var ts_buf: [32]u8 = undefined;
const ts_bytes = try std.fmt.bufPrint(&ts_buf, "{d}", .{self.block.timestamp});
try list.appendSlice(self.alloc, ts_bytes);
var tb_buf: [16]u8 = undefined;
const tb_bytes = try std.fmt.bufPrint(&tb_buf, "{d}", .{TARGET_POW_BITS});
try list.appendSlice(self.alloc, tb_bytes);
var nonce_buf: [32]u8 = undefined;
const nonce_bytes = try std.fmt.bufPrint(&nonce_buf, "{d}", .{nonce});
try list.appendSlice(self.alloc, nonce_bytes);
return try list.toOwnedSlice(self.alloc);
}
It looks verbose because Zig makes the implicit explicit. Every temporary buffer is a fixed-size stack array, every conversion to bytes happens through std.fmt, and memory is released by the caller once we’re done hashing.
Mining Time
With the data ready, the mining loop becomes pretty straightforward:
pub fn run(self: *Self) !void {
var nonce: usize = 0;
while (true) : (nonce += 1) {
const data = try self.prepare_data(nonce);
defer self.alloc.free(data);
var h = sha256.init(.{});
h.update(data);
h.final(self.block.hash[0..]);
if (std.mem.order(u8, &self.block.hash, &self.target) != .gt) {
self.block.nonce = nonce;
return;
}
}
}
std.mem.order compares two byte arrays. Because both the hash and the target are 32-byte big-endian values, the result matches what Go does with Cmp.
Also notice the defer self.alloc.free(data); line—each attempt allocates a buffer, so we have to hand it back immediately or we’d leak like crazy.
Verifying Proofs
Once a block is found we should be able to check the proof later:
pub fn validate(self: *Self) !bool {
var ts_buf: [32]u8 = undefined;
const ts_bytes = try std.fmt.bufPrint(&ts_buf, "{d}", .{self.block.timestamp});
var tb_buf: [16]u8 = undefined;
const tb_bytes = try std.fmt.bufPrint(&tb_buf, "{d}", .{TARGET_POW_BITS});
var nonce_buf: [32]u8 = undefined;
const nonce_bytes = try std.fmt.bufPrint(&nonce_buf, "{d}", .{self.block.nonce});
var h = sha256.init(.{});
h.update(self.block.prevblockhash[0..]);
h.update(self.block.data);
h.update(ts_bytes);
h.update(tb_bytes);
h.update(nonce_bytes);
h.final(self.block.hash[0..]);
return std.mem.order(u8, &self.block.hash, &self.target) != .gt;
}
Yes, we recompute the hash into the block itself. That’s fine because validation is the last thing we do before printing. If you dislike mutating during validation, feel free to copy into a temporary buffer.
Blockchain.mine Gets Real
The mining function we wrote in Part 1 now delegates to PoW:
pub fn mine(self: *Self, data: []const u8) !void {
const a = self.arena.allocator();
const owned_data = try a.dupe(u8, data);
const prev = self.lastHash();
var blk = Block.init(owned_data, prev);
var pow = ProofOfWork.init(a, &blk);
try pow.run();
try self.blocks.append(a, blk);
}
- We keep ownership of the transaction data inside the arena.
- We mine the block before appending it, so
hashandnonceare valid. - Appending copies the mined block into
ArrayListUnmanaged, which is exactly what we want.
The rest of main() just prints everything and calls validate() on each block for good measure.
Running It
Here’s what I get locally (your hashes and nonces will differ, but pay attention to the leading zeros):
Mining the block containing "alice->bob: 5"
000000a90ff32a2fdd3a08c5dc6b1ed8ea30e6c9e4a3d701a7d60a5d23541eac
Mining the block containing "bob->carol: 2"
00000018e1c75a391346f5f89076ef66a6f3a79c5bf32cc4b1b754debe9548e8
* Block #0
├─ Timestamp : 1729892401
├─ Data : alice->bob: 5
├─ Hash : 000000a90ff32a2fdd3a08c5dc6b1ed8ea30e6c9e4a3d701a7d60a5d23541eac
├─ Nonce : 7193312
└─ PrevHash : 0000000000000000000000000000000000000000000000000000000000000000
PoW: true
│
▼
* Block #1
├─ Timestamp : 1729892405
├─ Data : bob->carol: 2
├─ Hash : 00000018e1c75a391346f5f89076ef66a6f3a79c5bf32cc4b1b754debe9548e8
├─ Nonce : 12290445
└─ PrevHash : 000000a90ff32a2fdd3a08c5dc6b1ed8ea30e6c9e4a3d701a7d60a5d23541eac
PoW: true
Watching those hashes tick while the miner chugs along is weirdly satisfying. Also, yes, the debug prints do spam the console while mining, I kept them in on purpose so you can feel the grind.
Final Words
We now have a blockchain where every block proves it did some work. Still missing: persistence, transactions, addresses, fancy networking stuff… but that’s future Mario’s problem.
If you end up trying different difficulties or refactoring the PoW data prep, let me know. Thanks for sticking around 🫡