Dev.to · 12 min read

Writing Efficient Smart Contracts with Solidity — Practical Patterns

Writing Efficient Smart Contracts with Solidity — Practical Patterns

Storage, reentrancy, gas, and the structural decisions that separate contracts that survive from contracts that drain. I have written my share of Solidity. The contract that taught me the most was not a DeFi product — it was a small NFT minting contract for a client, and it worked perfectly on testnet. On mainnet it cost users roughly $180 more per mint than our estimate said it should. The logic was right. The storage layout was wrong, and every single transaction paid for it. That experience pushed me to systematize everything I now apply on every contract I ship. This is not a Solidity tutorial from scratch — I assume you know the language basics. This is the pattern guide I wish someone had handed me before that mint: how to lay out storage, why order of operations can drain your treasury, how to spend less gas per call, and the failure modes that show up only after you deploy. Before Anything: Know What Gas Actually Buys Every pattern below exists for one of two reasons: correctness under adversarial conditions, or raw cost per operation. You need both in your head before you write a single line. Storage is the expensive part. Writing a fresh 32-byte slot costs 20,000 gas; updating an existing slot costs 5,000; reading a slot costs 2,100 (or 100 with warm access after the first read in a transaction). Compute is cheap in comparison — a SSTORE dwarfs almost any arithmetic. This asymmetry drives nearly every "efficiency" decision you will see. So the mental model is: treat the contract's storage as a public database you are paying rent on every single time it changes. Every pattern below either reduces how many slots change or protects the slots that matter. Pattern 1: Pack Your Storage Solidity lays out state variables in 32-byte slots in declaration order, and it does not repack across slots. That means the order you declare variables changes your contract's storage footprint — sometimes dramatically. // Expensive: 5 slots for 5 values uint256 id; // slot 0 address owner; // slot 1 uint64 created; // slot 2 uint64 expires; // slot 3 bool active; // slot 4 // Packed: 3 slots for the same data uint256 id; // slot 0 address owner; // slot 1 (address is 20 bytes) uint64 created; // slot 1 tail (8 bytes) — fits in the same slot uint64 expires; // still slot 1? No — packed with created above bool active; // slot 2 The packed layout merges owner (20 bytes) + created (8) + expires (8) into a single 36-byte region spanning two slots, and tucks bool active into the leftover byte of the second slot. The rule of thumb: group smaller types (bool, uint8, address, small uintN) together and keep uint256 values by themselves. A warning before you over-optimize: packing reads and writes is a one-time deployment saving per struct instance, but it can cost you on structs you read frequently, because a packed struct forces Solidity to do mask-and-shift arithmetic on every field read. For hot structs, test both layouts on a real gas profile before you commit. Pattern 2: Checks-Effects-Interactions — the Reentrancy Rule This is the single most important correctness pattern in Solidity, and it is older than any of the hacks you have read about. The rule is brutally simple: check conditions, update your own state, then talk to external contracts — in that order, always. // WRONG — external call before state update function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "insufficient balance"); (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); balances[msg.sender] -= amount; // state updated AFTER external call } // CORRECT — checks, effects, then interactions function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "insufficient balance"); balances[msg.sender] -= amount; // effect first (bool ok, ) = msg.sender.call{value: amount}(""); // interaction last require(ok, "transfer failed"); } In the wrong version, the attacker's fallback function re-enters withdraw before their balance is decremented — so the balance check passes a second time, and the contract keeps paying out. In the correct version, the balance is already lowered, so the re-entrant call fails the check and the attack dies in one line. If you take nothing else from this article, take this pattern. Roughly every reentrancy exploit on record violates exactly this ordering. Pattern 3: Guard High-Risk Functions with ReentrancyGuard Checks-Effects-Interactions covers the common case, but you cannot always restructure state so cleanly — some flows need multiple external calls (think a swap pipeline that talks to three pools). For those, add a mutex. abstract contract ReentrancyGuard { uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _status = _NOT_ENTERED; modifier nonReentrant() { require(_status == _NOT_ENTERED, "reentrancy"); _status = _ENTERED; _; _status = _NOT_ENTERED; } } Slap nonReentrant on every mutating function that touches external contracts. It costs a single storage write on entry and exit, and it converts an entire class of attacks into a plain require revert. Do not put it on every getter — only on state-changing, externally-touching functions, so you keep the gas cost where it earns its keep. Pattern 4: Use Custom Errors Instead of Require Strings Solidity 0.8.4 introduced custom errors, and they are strictly better than require(condition, "message") for gas: error InsufficientBalance(uint256 available, uint256 requested); error NotOwner(); error Paused(); function withdraw(uint256 amount) external { uint256 bal = balances[msg.sender]; if (bal < amount) revert InsufficientBalance(bal, amount); // ... } A string revert costs 36 bytes of calldata encoding plus the string's storage in the return data — roughly 500–600 extra gas on every failure path. A custom error with two uint256 parameters costs about half of that and gives you structured data to parse off-chain. In 2026, if you are still shipping require(msg.sender == owner, "Not owner"), your revert paths are wasting money on a codebase where failure is the common case. There is a second, quieter win: custom errors carry data. A wallet or a backend can read InsufficientBalance(12, 50) and render a meaningful message instead of guessing from a string. Your tooling improves for free. Pattern 5: Prefer Static-Int Data and Be Careful with Arrays Two structural habits keep contracts cheap: Static arrays beat dynamic ones for small, fixed collections. address[5] whitelist lives inline in its slot; address[] whitelist requires a separate storage region plus a SSTORE for the array length on every push. If you know the cap, declare it. Precompute outside the hot loop. Anything you can compute once per transaction, compute once: // Repeatedly recomputed on each iteration function bad(uint256[] calldata ids) external { for (uint256 i = 0; i < ids.length; i++) { require(ids[i] != address(0), "zero"); } } // Same check, computed once — and using calldata function good(uint256[] calldata ids) external { uint256 n = ids.length; for (uint256 i = 0; i < n; i++) { if (ids[i] == 0) revert InvalidId(); } } Notice the second version also takes calldata, not memory. Calldata reads cost 16 gas versus 3 for the first element plus 6 per further 32 bytes for memory — and copying a large array into memory in the first place can eat thousands of gas. Read-only parameters should be calldata unless you genuinely need to modify them. Pattern 6: Solidity 0.8+ Gave You Safe Math — Use It Pre-0.8, integer overflow silently wrapped: uint256(0) - 1 == 2^256 - 1. That is how the old ERC-20 era produced drained balances. Since 0.8.0, arithmetic reverts on overflow by default, so the compiler does the checking for you. What this means in practice: you no longer need SafeMath imports on 0.8+ codebases. What you do still need is to think about the places where you want a controlled wrap — unchecked blocks inside tight loops where you have already proven the bounds: function sum(uint256[] calldata vals) external pure returns (uint256 total) { for (uint256 i = 0; i < vals.length; i++) { unchecked { total += vals[i]; // safe: total can't exceed vals.length * max(uint256)… // but only if you prove it can't wrap for YOUR data } } } Only use unchecked when you have a proof, not a hunch. An overflow you did not see coming is the one you will never find in testing. Pattern 7: Structs, Events, and Logging Costs Events are cheap relative to storage — an event with indexed topics costs about 375 gas per topic plus 8 per byte of data. Use them generously for anything a frontend or an indexer will need, because reading past storage is expensive and reading events is essentially free. event Transfer(address indexed from, address indexed to, uint256 amount); Prefer indexed on addresses and identifiers, not on uint256 amounts — the index increases cost and you can query ranges on amounts in a normal indexer only if they are not indexed. Log every meaningful state change; it doubles as your audit trail and your cheap read path. Pattern 8: Pull Payments Over Push Payments A contract that pushes funds (calls transfer or .call{value:} to an arbitrary user) pays for the user's gas failures and opens a reentrancy surface on every payout. A pull payment flips the responsibility: the contract records that a user is owed funds, and the user calls withdraw when they want the money. mapping(address => uint256) public pendingWithdrawals; function claim() external nonReentrant { uint256 amount = pendingWithdrawals[msg.sender]; if (amount == 0) revert NothingOwed(); pendingWithdrawals[msg.sender] = 0; // effect first (bool ok, ) = msg.sender.call{value: amount}(""); if (!ok) pendingWithdrawals[msg.sender] = amount; // refund on failure } Pull payments are cheaper for the protocol (no per-recipient send failures), safer (the user's fallback cannot brick your loop), and they make your contract's accounting simpler to audit — every transfer is user-initiated. The one cost is UX: users must click "claim" instead of receiving automatically, and some of them will forget. Pattern 9: Prefer Immutables and Constants for Fixed Values If a value never changes after deployment, do not store it in mutable storage. constant values are inlined at compile time; immutable values are set once in the constructor and stored in code, not in a state slot. Both save a SLOAD on every access, and immutables mean you never face the "can I change this after launch" governance debate for something that was never meant to change. address public immutable owner; // set once, no slot read cost uint256 public constant MAX_SUPPLY = 10000; // inlined everywhere This one looks like a micro-optimization, but in a hot function it is a real 100-gas saving per access, and it removes a whole class of "who can change this" questions from your threat model. Pattern 10: Keep Modifiers Cheap and Pure Modifiers are inlined into the function body, so a modifier with logic runs on every entry. Keep them to require checks and nothing more: // Cheap, pure, no storage writes modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } // Expensive and surprising — this writes storage on every call modifier trackCalls() { callCount[msg.sender]++; _; } Anything that writes storage or calls external code inside a modifier hides cost and side effects where readers least expect them, so keep modifiers check-only and put accounting visibly in the body. The Failure Modes Nobody Warns You About These bit me, in order of how much they cost: Packed structs that read hot. I packed a struct that the UI read on every render. The mask-and-shift overhead made reads slower than the money saved on writes. Profile both ways. Reentrancy in the mint flow. My first NFT contract minted the token inside a loop after an external call. A user with a receive fallback could theoretically re-enter mid-mint. Checks-Effects-Interactions fixed it with one reorder. Assuming warm storage. I designed for cold reads everywhere and paid warm-access surprises. If a contract calls itself or uses nonReentrant, the second touch of a slot is cheaper — design for it. Events as an afterthought. No indexer data, and a frontend that had to call balanceOf per token instead of reading events. Total extra infrastructure cost, zero gas benefit. Pushing funds instead of letting users pull. Every failed send in a payout loop reverted the whole transaction and burned the caller's gas. Moving to pull payments removed the failure mode entirely. How I Actually Benchmark a Contract Two habits, both free, that catch 90% of the mistakes above before they reach a testnet deployment. First, write the gas test as a first-class test, not an afterthought. With Foundry, assert on gas ceilings per function and fail the suite when a refactor pushes a hot path over budget: // Foundry gas test function test_withdraw_under_gas_budget() public { uint256 before = gasleft(); vault.withdraw(100); uint256 used = before - gasleft(); assertLt(used, 120_000, "withdraw too expensive"); } A gas budget test converts a silent cost regression into a red build. It is the difference between learning about the $180 mint overcharge in your own test suite and learning about it from a user's transaction receipt. Second, forge snapshot your storage layout and diff it across refactors. Foundry's forge inspect shows you exactly how many slots your structs consume; if a "cleanup" PR quietly pushes a hot struct from 3 slots to 5, the snapshot diff catches it in review instead of in production. The Deployment Checklist Run this before you send any contract to mainnet: [ ] Storage packed (small types grouped, uint256 isolated) [ ] Every mutating function touching external contracts marked nonReentrant [ ] All external calls happen after state changes (Checks-Effects-Interactions) [ ] Custom errors, not require strings [ ] Read-only function parameters are calldata [ ] Loop bounds hoisted out of iterations; unchecked only with proof [ ] Events emitted for every meaningful state change, indexed on addresses [ ] Gas profiled on the exact storage layout you will deploy, not a draft [ ] A read path exists through events or getters that does not rely on past state scans [ ] A separate script that replays your contract against the top five historical reentrancy exploits That last checklist item is not paranoia. I run it because the one exploit I never want to read about is my own. Efficiency in Solidity is a three-way trade between gas cost, correctness, and code readability. The patterns above are the ones that survive all three — and the ones that minted that NFT contract did not, which is exactly why the gas bill came in $180 over estimate. Cheap contracts are not clever contracts. They are contracts that respect the cost of every slot and the order of every call. *Gulshan Yad

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Cybersecurity News