DEV Community

Cover image for I Just Built & Fuzz-Tested a Simple Bank Smart Contract Using Hardhat 3 + Viem + Solidity Tests
Keijzer Rozenberg
Keijzer Rozenberg

Posted on

I Just Built & Fuzz-Tested a Simple Bank Smart Contract Using Hardhat 3 + Viem + Solidity Tests

Today I leveled up my Web3 dev workflow.
Instead of only coding in Remix (which is great for learning), I set up a full professional flow:

Hardhat 3

Viem for deployment & contract interaction

Solidity-based tests (yes, like Foundry!)

Fuzz testing

Reentrancy-safe withdraw function

This was the first time I try testFuzz_...

I’ve always deployed contracts from Remix before. But today I forced myself to do it the “real engineer” way: automated tests, deterministic deployment scripts, event checks, and fuzzing.

Here’s the smart contract I built:
A simple on-chain bank that supports:

✔ Deposit
✔ Withdraw
✔ Account balance tracking
✔ Transaction history mapping
✔ Events
✔ Reentrancy protection

Contract Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; contract SimpleBank is ReentrancyGuard { struct TxRecord { address user; bytes32 action; // [ DEPOSIT | WITHDRAW ] uint256 amount; } // balances per user mapping(address => uint256) private balances; // transaction counter uint256 public transactionCount; // list of transaction per user mapping(uint256 => TxRecord) public transactions; // events event Deposit(address indexed user, uint256 amount); event Withdraw(address indexed user, uint256 amount); function deposit() external payable { require(msg.value > 0, "no zero deposit"); // update internal balances balances[msg.sender] += msg.value; // log transaction transactionCount += 1; transactions[transactionCount] = TxRecord({ user:msg.sender, action:bytes32("DEPOSIT"), amount:msg.value }); // event emit emit Deposit(msg.sender, msg.value); } function withdraw(uint256 amount) external nonReentrant { require(balances[msg.sender] >= amount, "not enough balance"); // update internal balances balances[msg.sender] -= amount; // interaction last (safer) (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "withdraw failed"); // log transaction transactionCount += 1; transactions[transactionCount] = TxRecord({ user:msg.sender, action:bytes32("WITHDRAW"), amount:amount }); // event emit emit Withdraw(msg.sender, amount); } function balanceOf(address user) external view returns (uint256) { return balances[user]; } } 
Enter fullscreen mode Exit fullscreen mode

Solidity Test

// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "forge-std/Test.sol"; import "../contracts/SimpleBank.sol"; contract SimpleBankTest is Test { SimpleBank private bank; address private user = address(0xBEEF); event Deposit(address indexed user, uint256 amount); event Withdraw(address indexed user, uint256 amount); function setUp() public { bank = new SimpleBank(); vm.deal(user, 10 ether); } function test_Deposit_IncreasesBalance_And_Records() public { vm.prank(user); bank.deposit{value: 1000}(); assertEq(bank.balanceOf(user), 1000); uint256 txCount = bank.transactionCount(); assertEq(txCount, 1); (address u, bytes32 action, uint256 amount) = bank.transactions(txCount); assertEq(u, user); assertEq(action, bytes32("DEPOSIT")); assertEq(amount, 1000); } function test_Withdraw_DecreasesBalance_And_Records() public { vm.prank(user); bank.deposit{value: 1000}(); vm.prank(user); bank.withdraw(10); assertEq(bank.balanceOf(user), 990); uint256 txCount = bank.transactionCount(); assertEq(txCount, 2); (, bytes32 action, uint256 amount) = bank.transactions(txCount); assertEq(action, bytes32("WITHDRAW")); assertEq(amount, 10); } function test_Revert_When_Withdraw_More_Than_Balance() public { vm.prank(user); vm.expectRevert(bytes("not enough balance")); bank.withdraw(1); } // Fuzz example function testFuzz_DepositWithdraw(uint128 amt) public { vm.assume(amt > 0 && amt <= 10 ether); // match vm.deal(user, 10 ether) vm.prank(user); bank.deposit{value: amt}(); assertEq(bank.balanceOf(user), amt); vm.prank(user); bank.withdraw(amt); assertEq(bank.balanceOf(user), 0); } function test_Events_Emitted() public { vm.prank(user); vm.expectEmit(true, true, false, true, address(bank)); emit Deposit(user, 1000); bank.deposit{value: 1000}(); vm.prank(user); vm.expectEmit(true, true, false, true, address(bank)); emit Withdraw(user, 10); bank.withdraw(10); } } 
Enter fullscreen mode Exit fullscreen mode

NodeJs Test

import { artifacts, network } from 'hardhat'; import assert from "node:assert/strict"; import test, { before } from 'node:test'; function bytes32ToString(bytes32Value: string): string { let hex = bytes32Value.startsWith("0x") ? bytes32Value.slice(2) : bytes32Value; while (hex.endsWith("00")) hex = hex.slice(0, -2); let out = ""; for (let i = 0; i < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16)); return out; } let publicClient: any; let walletClient: any; let contractAddress: `0x${string}`; let abi: any; before(async () => { const { viem } = await network.connect({ network: "hardhatOp", chainType: "op", }); publicClient = await viem.getPublicClient(); [walletClient] = await viem.getWalletClients(); const artifact = await artifacts.readArtifact("SimpleBank"); abi = artifact.abi; const deployHash = await walletClient.deployContract({ abi, bytecode: artifact.bytecode as `0x${string}` }) const receipt = await publicClient.waitForTransactionReceipt({ hash: deployHash }); contractAddress = receipt.contractAddress as `0x${string}`; }) const getBalance = async (addr: `0x${string}`) => { return (await publicClient.readContract({ address: contractAddress, abi, functionName: 'balanceOf', args: [addr] })) as bigint; }; const getLastTx = async () => { const raw = await publicClient.readContract({ address: contractAddress, abi, functionName: 'transactionCount', }); const txCount = BigInt(raw as any); const tx = await publicClient.readContract({ address: contractAddress, abi, functionName: 'transactions', args: [txCount] }); return {txCount, tx}; } test("deposit Increase balance and records DEPOSIT", async () => { const addr = walletClient.account.address as `0x${string}`; const before = await getBalance(addr); assert.equal(before, 0n); const txHash = await walletClient.writeContract({ address: contractAddress, abi, functionName: 'deposit', args: [], value: 1000n }); await publicClient.waitForTransactionReceipt({hash: txHash}); const after = await getBalance(addr); assert.equal(after, 1000n) const {txCount, tx} = await getLastTx(); assert.equal(txCount, 1n); assert.equal((tx[0] as string).toLowerCase(), addr.toLowerCase()) assert.equal(bytes32ToString(tx[1] as string), "DEPOSIT"); assert.equal(tx[2], 1000n) }) test("withdraw decrease balance and records WITHDRAW", async () => { const addr = walletClient.account.address as `0x${string}` const before = await getBalance(addr) assert.equal(before, 1000n) const txhash = await walletClient.writeContract({ address: contractAddress, abi, functionName: 'withdraw', args: [223n] }) await publicClient.waitForTransactionReceipt({hash: txhash}) const after = await getBalance(addr) assert.equal(after, 777n) }) test("withdraw more than balance should revert", async () => { try { const txHash = await walletClient.writeContract({ address: contractAddress, abi, functionName: 'withdraw', args: [1000000n] }) await publicClient.waitForTransactionReceipt({hash : txHash}) assert.fail('expected revert but tx succeeded') } catch (e: any) { const msg = String(e?.message || e); assert.ok(msg.includes('not enough balance'), "revert reason mismatch: " + msg); } }) 
Enter fullscreen mode Exit fullscreen mode

Output

Running Solidity tests

✔ test_Deposit_IncreasesBalance_And_Records()
✔ test_Withdraw_DecreasesBalance_And_Records()
✔ test_Revert_When_Withdraw_More_Than_Balance()
✔ test_Events_Emitted()
✔ testFuzz_DepositWithdraw(uint128) (runs: 256)

All good ✅

I used to think smart contract testing = just a few asserts in JS.

But Solidity-native testing + fuzzing feels like superpowers.

Lessons today:

💡 Remix is great to start, but real workflows = reproducible scripts + tests
💡 Fuzz testing catches edge cases you don’t think about
💡 Reentrancy guard is non-negotiable for contracts holding ETH
💡 Hardhat 3 + Viem + forge-std = chef’s kiss for Web3 devs 🍷✨

Next step: connecting a frontend (Wagmi + Next.js) + deploying to Sepolia.

If you're learning Web3 and still coding only inside Remix, try this setup next — it feels like stepping into Level 2.

If you want the full repo + guide, drop a comment!
Happy building and secure your withdraw calls 🔐🚀

Top comments (0)