Blockchain
Smart Contract Security: Lessons from Real-World Projects

Explore critical security considerations for blockchain development based on real-world projects and common vulnerabilities in smart contracts.
Most smart-contract security advice you read is theoretical. Mine is not. I built and shipped the backend for Banana NFT, an ERC721 collection that processed over $50K in mint volume with zero security incidents. The stack was Solidity, OpenZeppelin, Ethers.js, and Hardhat. Nothing exotic. What made it safe was not a clever trick, it was a set of boring decisions applied consistently.
Contracts are unforgiving in a way normal backends are not. If I ship a bug in a NestJS service, I patch it and redeploy in ten minutes. If I ship a bug in a deployed contract, the code is immutable and there is real money sitting behind it. That single constraint changes how you think about everything. Below are the lessons I actually paid for.
The sniping problem: bots read your metadata before you want them to
NFT collections have rare items. The moment your metadata is public, bots scan it, map which token IDs are rare, and then try to mint exactly those. If mint order is predictable, honest buyers lose and the bot operator flips the rares. This is the metadata-reveal sniping problem, and it is not hypothetical. It happens to every launch that leaves an opening.
I closed two openings. First, a time-based reveal window. The real metadata stays hidden behind a placeholder URI until a reveal timestamp passes. Before that moment nobody, bot or human, can read which token is rare, so there is nothing to snipe.
function tokenURI(uint256 id) public view override
returns (string memory)
{
if (block.timestamp < revealTime) {
return placeholderURI;
}
return string.concat(baseURI, _toString(id));
}
Second, I gated minting with a Merkle tree. The allowlist lives off-chain. I build the tree with merkletreejs and commit only the 32-byte root on-chain. Each eligible wallet gets a proof. On mint, the contract verifies the proof against the stored root using OpenZeppelin's MerkleProof. This gives cheap allowlist checks and, because the root is fixed at deploy, metadata and eligibility integrity that nobody can tamper with after the fact.
function mint(bytes32[] calldata proof) external payable {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(
MerkleProof.verify(proof, merkleRoot, leaf),
"not allowlisted"
);
_mintCycle(msg.sender);
}
I also enforced per-user minting cycles so a single wallet could not drain a phase. Reveal window plus Merkle gating plus per-user limits removed the economic reason to run a sniping bot in the first place.
Access control is where most contracts actually die
Reentrancy gets the headlines, but in practice the cheapest way to lose a contract is a function that should have been restricted and was not. I used OpenZeppelin's Ownable for admin actions: setting the reveal time, updating the base URI, withdrawing funds. Simple, audited, done.
The pitfalls are boring and that is exactly why they bite. A setter that forgets its onlyOwner modifier is a public setter. I treated every state-changing admin function as guilty until proven guarded.
function setRevealTime(uint256 t) external onlyOwner {
revealTime = t;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
Two habits saved me here. First, list every external and public function and ask "who is allowed to call this?" for each one, out loud, in review. Second, be deliberate about ownership transfer. A fat-fingered transfer to a wrong or zero address is unrecoverable. If you do not need to renounce ownership, do not expose the ability to do it by accident.
Gas versus security is a real tradeoff, and storage is where you win both
Gas optimization sounds like it competes with safety. Often it does. Inline assembly and unchecked math save gas and add risk. But some optimizations make the contract both cheaper and simpler, and simpler is safer.
The biggest win on Banana NFT was how I tracked minted tokens. The naive approach stores an array of token IDs per user. Arrays grow, cost gas on every push, and make your storage layout unpredictable. Instead I stored a range: a start and end token ID per user. A range is two numbers no matter how many tokens it covers.
struct MintRange {
uint128 start;
uint128 end;
}
mapping(address => MintRange) public userMint;
Combined with batch minting (assigning a contiguous block of IDs in one call instead of one transaction per token), this cut gas by roughly 40% and reduced storage by around 70% versus the array approach. The security benefit is a bonus: fewer storage writes means fewer places for state to go wrong, and a fixed-size struct has no unbounded loop hiding in it waiting to hit the block gas limit.
An unbounded loop over user-controlled data is not a performance bug. It is a denial-of-service vulnerability. Ranges make it impossible to write one by accident.
Test coverage is not optional when the code is immutable
I hold my backend services to good coverage. I held Banana NFT to a higher bar: over 95% coverage with Mocha and Chai under Hardhat. The reason is the immutability constraint I opened with. There is no hotfix. The test suite is the last line of defense before code that cannot be changed goes live with money behind it.
What I test on a contract is different from a normal service. I test the happy path, yes, but I spend most of my effort on the failure paths: minting before reveal, minting with a forged Merkle proof, minting past the per-user limit, calling an owner-only function as a stranger, and withdrawing to the wrong caller. Each of those tests asserts a revert, because a revert is the security boundary doing its job.
it("rejects a forged Merkle proof", async () => {
const bad = [ethers.ZeroHash];
await expect(
contract.connect(attacker).mint(bad)
).to.be.revertedWith("not allowlisted");
});
Coverage numbers alone lie. A high percentage of lines executed means nothing if you never assert the revert. I care that every guard has a test that proves it blocks the thing it is supposed to block.
A quick tour of the classics you must check for
Every contract review should walk the same short list. These are the vulnerability classes that have drained real protocols, and none of them are hard to check for once you know the shape.
- Reentrancy. An external call that hands control to an untrusted contract before you finish updating state. Follow checks-effects-interactions: update your storage first, send funds last. For anything non-trivial, add OpenZeppelin's
ReentrancyGuard. - Unchecked external calls. A raw
callreturns a success boolean that people ignore. If you do not check it, a silent failure looks like success. Check the return value or use a safe wrapper. - Integer issues. Solidity 0.8 reverts on overflow by default, which is a huge safety upgrade. But any block marked
uncheckedopts out of that protection. Treat everyuncheckedblock as code that needs a written justification. - Front-running. The mempool is public. Anyone can see your transaction before it confirms and act on it. The reveal window and committed Merkle root both defend against this: there is nothing profitable to front-run when the outcome is fixed in advance.
Takeaways
- Immutability plus money means you get one shot. Design and test like there is no patch, because there is not.
- Delayed reveal plus a committed Merkle root removes the economic incentive to snipe rare tokens.
- Audit access control by naming who may call every public and external function, one by one.
- Prefer storage patterns like ranges over arrays. They are cheaper and they eliminate whole classes of bugs.
- Chase revert-asserting tests, not just a coverage percentage. A guard without a test is a guess.
- Walk the classic vulnerability list every time: reentrancy, unchecked calls, integer issues, front-running.
None of this required deep cryptography or a formal-methods PhD. It required treating the contract as what it is: production infrastructure holding other people's money, with no undo button. Boring discipline, applied every time, is why $50K moved through Banana NFT and nothing broke.
Filed under
Share

