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];
}
}
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);
}
}
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);
}
})
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)