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)