Blog › ICP guides
Solidity developer on retainer: smart contract security, gas optimization, and audit-ready code on monthly retainer
August 27, 2026 · ~22 min read
A DeFi lending protocol had a withdraw() function that read the user’s balance, called the recipient address, then zeroed the balance. The sequence was: check balance, send ETH via msg.sender.call{value: amount}(""), set balances[msg.sender] = 0. A fractional Solidity auditor on monthly retainer identified the checks-effects-interactions violation in the pre-deployment review, wrote a Foundry proof-of-concept using a ReentrantAttacker contract whose receive() function re-called withdraw(), and ran forge test — the attacker contract drained nine times its deposited balance in a single transaction. The fix moved the balance zeroing before the external call and added OpenZeppelin ReentrancyGuard as defense-in-depth.
The second issue found in the same pre-deployment audit: four privileged functions — setInterestRate, pause, setLiquidationThreshold, and upgradeToAndCall — were guarded only by require(msg.sender == owner) with no timelock or multisig. A single compromised private key would have given an attacker instant protocol control, with no governance delay that users could respond to. The remediation implemented a 48-hour TimelockController between governance proposals and execution and migrated owner to a 3-of-5 Safe multisig. No user-visible feature changed. The protocol’s external audit estimated that resolving these two findings pre-submission saved approximately $12,000 in audit firm pricing.
Solidity developers, smart contract auditors, and Web3 consultants on monthly retainer — fractional Solidity engineers, DeFi protocol advisors, and smart contract security consultants — do their highest-value work in the reentrancy protection, access control design, proxy upgrade architecture, gas optimization, and Foundry invariant testing that the protocol team defends to the DAO. This guide covers smart contract security in depth, proxy upgrade patterns, gas optimization, testing with Foundry and Hardhat, and Slither static analysis — and how to structure a Solidity developer retainer that makes the hours behind each security review visible.
Smart contract security
Smart contract security is irreversible in a way that no other software domain is: a vulnerability deployed to Ethereum mainnet cannot be patched without a proxy upgrade, and a reentrancy exploit that drains a vault cannot be undone by a rollback. A Solidity auditor on retainer reviews every new contract before deployment, identifies vulnerability patterns, and writes Foundry proof-of-concept exploits to confirm severity before recommending remediation.
Reentrancy and checks-effects-interactions
// VULNERABLE: external call before state update (violates checks-effects-interactions):
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// DANGER: external call happens BEFORE state update.
// A malicious contract's receive() can re-enter withdraw() here.
// balances[msg.sender] is still nonzero — re-entrant call succeeds.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // Too late — re-entrant calls already drained the vault.
}
}
// SECURE: checks-effects-interactions pattern + ReentrancyGuard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
error InsufficientBalance();
error TransferFailed();
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// nonReentrant modifier sets a lock before execution, clears it after.
// Any re-entrant call to a nonReentrant function reverts immediately.
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
if (amount == 0) revert InsufficientBalance(); // 1. CHECK
balances[msg.sender] = 0; // 2. EFFECT — state updated BEFORE external call
(bool success, ) = msg.sender.call{value: amount}(""); // 3. INTERACTION
if (!success) revert TransferFailed();
}
}
// Foundry PoC — proof-of-concept exploit test:
// test/ReentrancyPoC.t.sol
contract ReentrantAttacker {
VulnerableVault vault;
uint256 attackAmount;
constructor(VulnerableVault _vault) payable {
vault = _vault;
attackAmount = msg.value;
}
function attack() external {
vault.deposit{value: attackAmount}();
vault.withdraw();
}
// receive() is called when the vault sends ETH to this contract:
receive() external payable {
if (address(vault).balance >= attackAmount) {
vault.withdraw(); // Re-enter before vault updates balance
}
}
}
// forge test output for the PoC:
// [PASS] testReentracy() — attacker balance after: 9 ether (deposited 1 ether, drained 9)
Access control with OpenZeppelin AccessControl
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts-upgradeable/governance/TimelockControllerUpgradeable.sol";
contract LendingProtocol is AccessControl {
// Role definitions — keccak256 hashes of role names:
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
uint256 public interestRateBps;
uint256 public liquidationThresholdBps;
bool public paused;
error Paused();
error RateTooHigh();
constructor(address admin, address multisig) {
_grantRole(DEFAULT_ADMIN_ROLE, admin); // DEFAULT_ADMIN_ROLE can grant/revoke roles
_grantRole(ADMIN_ROLE, multisig); // Multisig holds the ADMIN_ROLE
_grantRole(PAUSER_ROLE, multisig); // Multisig can pause
// Set ADMIN_ROLE as the role admin for OPERATOR_ROLE:
// Only addresses with ADMIN_ROLE can grant/revoke OPERATOR_ROLE.
_setRoleAdmin(OPERATOR_ROLE, ADMIN_ROLE);
}
// onlyRole modifier — reverts if msg.sender does not hold the specified role:
function setInterestRate(uint256 rateBps) external onlyRole(ADMIN_ROLE) {
if (rateBps > 5000) revert RateTooHigh(); // max 50%
interestRateBps = rateBps;
}
function pause() external onlyRole(PAUSER_ROLE) {
paused = true;
}
function addOperator(address operator) external onlyRole(ADMIN_ROLE) {
_grantRole(OPERATOR_ROLE, operator);
}
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
}
// TimelockController — enforces a mandatory delay between proposal and execution:
// Governance flow:
// 1. Proposer calls schedule(target, value, data, predecessor, salt, delay)
// 2. delay (e.g. 48 hours) passes — transaction is queued on-chain
// 3. Executor calls execute(target, value, data, predecessor, salt)
// Users can monitor the timelock queue and exit the protocol before execution if they disagree.
// Grant timelock as the admin of the protocol contract:
// lendingProtocol.grantRole(ADMIN_ROLE, address(timelockController));
// lendingProtocol.grantRole(DEFAULT_ADMIN_ROLE, address(timelockController));
// lendingProtocol.renounceRole(DEFAULT_ADMIN_ROLE, deployer); // remove deployer's admin role
// Front-running protection — commit-reveal for price-sensitive operations:
contract CommitRevealAuction {
mapping(address => bytes32) public commitments;
mapping(address => uint256) public revealedBids;
// Commit phase: user submits keccak256(abi.encodePacked(bidAmount, secret)):
function commit(bytes32 commitment) external {
commitments[msg.sender] = commitment;
}
// Reveal phase: user reveals the actual bid after commit deadline:
function reveal(uint256 bidAmount, bytes32 secret) external {
bytes32 expectedCommitment = keccak256(abi.encodePacked(bidAmount, secret));
require(commitments[msg.sender] == expectedCommitment, "Commitment mismatch");
revealedBids[msg.sender] = bidAmount;
}
// Front-runners cannot extract the bid from the reveal transaction
// because the commit is already on-chain before the reveal is sent.
}
Smart contract architecture: proxy patterns
Ethereum smart contracts are immutable by default: once deployed, the bytecode cannot change. Proxy patterns separate the storage (proxy contract) from the logic (implementation contract), enabling upgrades by pointing the proxy at a new implementation. A Solidity architect on retainer selects the correct proxy pattern for the protocol’s upgrade requirements and designs the storage layout to prevent slot collisions.
UUPS, Transparent Proxy, and Diamond pattern
// UUPS (Universal Upgradeable Proxy Standard) — EIP-1822:
// The upgrade logic lives in the IMPLEMENTATION, not the proxy.
// Proxy is minimal (saves ~5,000 gas per deployment vs. Transparent).
// Risk: a buggy implementation that removes _authorizeUpgrade locks the proxy forever.
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract TokenV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
// initializer replaces constructor for upgradeable contracts:
function initialize(address owner) external initializer {
__Ownable_init(owner); // sets _owner; cannot be called again after initialization
__UUPSUpgradeable_init(); // registers UUPS interface
_totalSupply = 1_000_000e18;
_balances[owner] = _totalSupply;
}
// MUST be implemented — called by proxy's upgradeTo():
// Remove or make permissionless = anyone can upgrade your proxy = critical vulnerability.
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
function totalSupply() external view returns (uint256) { return _totalSupply; }
function balanceOf(address account) external view returns (uint256) { return _balances[account]; }
}
// Deploy: deploy TokenV1 implementation, then ERC1967Proxy pointing at it:
// ERC1967Proxy proxy = new ERC1967Proxy(address(tokenV1Impl), abi.encodeCall(TokenV1.initialize, (owner)));
// Upgrade: deploy TokenV2, call proxy.upgradeToAndCall(address(tokenV2Impl), ""):
// TokenV1(address(proxy)).upgradeToAndCall(address(tokenV2Impl), "");
// Storage layout MUST be preserved — adding new variables at the end is safe;
// removing or reordering existing variables corrupts storage.
// Storage layout for V2 — MUST keep V1 layout intact:
contract TokenV2 is TokenV1 {
// Correct: add new variable at the end (after V1's storage)
string public version; // new in V2 — appended to layout
// WRONG: inserting before _totalSupply shifts all slot assignments
// uint256 private _newVar; // DO NOT DO THIS — corrupts _balances mapping
}
// Transparent Proxy Pattern — upgrade function is in the PROXY, not the implementation:
// The proxy checks if msg.sender is the admin — admins see the proxy interface,
// other callers see the implementation interface (no function selector clash risk).
// Uses more gas per deployment because the proxy is larger.
// Diamond Pattern (EIP-2535) — for contracts that would exceed the 24KB bytecode limit:
// Multiple "facets" (implementation contracts) share a single proxy's storage.
// The proxy's fallback routes function selectors to the appropriate facet address.
// Used by large DeFi protocols (lending, DEX) with many features in a single address.
interface IDiamondCut {
enum FacetCutAction { Add, Replace, Remove }
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors; // which function selectors this facet handles
}
function diamondCut(FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata) external;
}
// Storage in Diamond — use EIP-2535 AppStorage pattern to avoid slot collisions between facets:
// Each facet reads/writes from a shared AppStorage struct at a fixed diamond storage slot.
library LibAppStorage {
bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("diamond.standard.app.storage");
struct AppStorage {
uint256 totalSupply;
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;
}
function diamondStorage() internal pure returns (AppStorage storage ds) {
bytes32 position = DIAMOND_STORAGE_POSITION;
assembly {
ds.slot := position // fixed storage slot — survives facet upgrades
}
}
}
Gas optimization
On Ethereum mainnet, every storage write costs 20,000 gas for a cold slot or 2,900 gas for a warm slot. At 30 gwei base fee and ETH at $3,000, a single cold storage write costs approximately $1.80. A Solidity architect on retainer audits storage slot usage, packs struct fields to minimize slots, replaces string error messages with custom errors, and identifies calldata vs. memory parameter opportunities that reduce gas on every call.
Storage slot packing and custom errors
// Storage slots are 32 bytes (256 bits). Variables share a slot if they fit.
// Rule: declare consecutive small-type variables together; they will be packed.
// BEFORE — 4 variables, 4 storage slots (4 × 20,000 = 80,000 gas to initialize all):
contract UnpackedToken {
uint256 public totalSupply; // slot 0 (32 bytes — full slot)
uint128 public maxSupply; // slot 1 (16 bytes — wastes 16 bytes)
uint64 public mintStartTime; // slot 2 (8 bytes — wastes 24 bytes)
uint64 public mintEndTime; // slot 3 (8 bytes — wastes 24 bytes)
}
// AFTER — packed: maxSupply + mintStartTime + mintEndTime share slot 1 (one slot = one SSTORE):
contract PackedToken {
uint256 public totalSupply; // slot 0
uint128 public maxSupply; // slot 1, bytes 0-15
uint64 public mintStartTime; // slot 1, bytes 16-23
uint64 public mintEndTime; // slot 1, bytes 24-31
// All three fit in 32 bytes: 16 + 8 + 8 = 32. One SSTORE instead of three.
}
// Custom errors — cheaper than require string messages:
// BEFORE: require(amount > 0, "Amount must be positive");
// Cost: encodes the string "Amount must be positive" (23 bytes) in the revert data.
// Each byte of revert data costs 4 gas (cold calldata).
// AFTER: revert AmountMustBePositive();
// Cost: 4 bytes (function selector hash only). ~200 gas cheaper per revert.
error InsufficientBalance(address user, uint256 requested, uint256 available);
error Unauthorized(address caller, bytes32 requiredRole);
error AmountMustBePositive();
error DeadlinePassed(uint256 deadline, uint256 current);
function transfer(address to, uint256 amount) external {
if (amount == 0) revert AmountMustBePositive();
uint256 bal = balances[msg.sender];
if (bal < amount) revert InsufficientBalance(msg.sender, amount, bal);
// unchecked — safe because we verified bal >= amount above:
unchecked {
balances[msg.sender] = bal - amount; // no overflow check needed
balances[to] += amount; // safe: total supply is constant
}
emit Transfer(msg.sender, to, amount);
}
// calldata vs. memory — use calldata for read-only function parameters:
// BEFORE: function batchTransfer(address[] memory recipients, uint256[] memory amounts)
// memory: arrays are copied from calldata to EVM memory — costs gas per element.
// AFTER: function batchTransfer(address[] calldata recipients, uint256[] calldata amounts)
// calldata: arrays are read directly from calldata — no copy, cheaper for read-only use.
// calldata saves ~300-600 gas per element for large arrays.
// Mappings vs. arrays — mappings are cheaper for random access; arrays are needed for iteration:
// mapping(address => uint256) balances; // O(1) lookup, O(1) write — no length, no iteration
// address[] public holders; // O(n) iteration, O(1) append — but expensive to search
// Yul assembly — for hot-path arithmetic where Solidity's overhead matters:
function efficientHash(bytes32 a, bytes32 b) internal pure returns (bytes32 result) {
assembly {
mstore(0x00, a) // store a at memory slot 0
mstore(0x20, b) // store b at memory slot 32
result := keccak256(0x00, 0x40) // hash 64 bytes starting at slot 0
// Cheaper than: keccak256(abi.encodePacked(a, b)) — avoids abi.encodePacked overhead
}
}
// forge snapshot — measure gas baseline for all functions:
// $ forge snapshot
// Writes .gas-snapshot file with gas cost per test function.
// Compare between branches: forge snapshot --diff .gas-snapshot
Testing with Foundry and Hardhat
Foundry’s forge provides the fastest Solidity test execution speed, native fuzzing, and invariant testing capabilities that catch edge cases no unit test suite finds. A Solidity developer on retainer writes fuzz tests that throw 10,000 random inputs at each function, defines protocol invariants that must hold across any sequence of transactions, and runs Slither static analysis to identify patterns that the compiler misses.
Foundry fuzz and invariant testing
// Foundry test — inherits Test from forge-std:
// forge test runs all contracts matching *Test.sol or *Test.sol in test/ directory.
import "forge-std/Test.sol";
import "../src/SecureVault.sol";
contract SecureVaultTest is Test {
SecureVault vault;
address alice = makeAddr("alice"); // forge-std helper: creates a labeled address
address attacker = makeAddr("attacker");
function setUp() public {
vault = new SecureVault();
// Give alice 10 ETH:
deal(alice, 10 ether);
}
// Unit test:
function test_Deposit() public {
vm.prank(alice); // next call is made from alice's address
vault.deposit{value: 1 ether}();
assertEq(vault.balances(alice), 1 ether);
}
// Fuzz test — Foundry generates random `amount` values automatically:
// runs = 256 by default (configurable in foundry.toml: [fuzz] runs = 10000)
function testFuzz_Withdraw(uint256 depositAmount) public {
// Bound the random input to a valid range:
depositAmount = bound(depositAmount, 1 wei, 10 ether);
deal(alice, depositAmount);
vm.startPrank(alice);
vault.deposit{value: depositAmount}();
uint256 balanceBefore = alice.balance;
vault.withdraw();
uint256 balanceAfter = alice.balance;
vm.stopPrank();
assertEq(balanceAfter - balanceBefore, depositAmount);
assertEq(vault.balances(alice), 0);
}
// Invariant test — defines a property that must hold after any sequence of calls:
// Foundry generates random call sequences and verifies the invariant after each.
function invariant_VaultBalanceEqualsDeposits() public {
// The vault's ETH balance must always equal the sum of all user balances.
// This invariant would catch a reentrancy drain: vault.balance < sum(balances).
assertGe(address(vault).balance, 0); // simplified: no negative balances
// Full invariant would require a handler contract tracking cumulative deposits.
}
}
// foundry.toml — configure fuzz runs and invariant campaigns:
// [fuzz]
// runs = 10000 # fuzz iterations per fuzz test
// max_test_rejects = 65536
// [invariant]
// runs = 256 # number of call sequences per invariant test
// depth = 15 # max calls per sequence
// forge coverage — measure test coverage:
// $ forge coverage --report summary
// Reports: branches, functions, lines, statements.
// Coverage < 85% on security-critical contracts is a pre-audit red flag.
// forge script — deploy and interact scripts:
// script/Deploy.s.sol:
import "forge-std/Script.sol";
contract DeployVault is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
SecureVault vault = new SecureVault();
console.log("Vault deployed at:", address(vault));
vm.stopBroadcast();
}
}
// $ forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast --verify
Slither static analysis
# Slither — static analysis framework by Trail of Bits:
# Detects: reentrancy, uninitialized variables, unprotected upgrades,
# arbitrary send, incorrect ERC-20/721 implementations, locked ETH,
# integer overflow (pre-0.8.0), assembly usage, dangerous delegatecall.
# Install and run:
$ pip install slither-analyzer
$ slither . --solc-remaps "@openzeppelin=node_modules/@openzeppelin"
# Output categories:
# High: reentrancy-eth, unchecked-lowlevel, suicidal, unprotected-upgrade
# Medium: events-maths, reentrancy-no-eth, uninitialized-local, tx-origin
# Low: unused-return, low-level-calls, calls-loop
# Info: similar-names, solc-version, assembly
# Targeted detectors — run only specific checks:
$ slither . --detect reentrancy-eth,unprotected-upgrade
# Exclude false positives with triage mode:
$ slither . --triage-mode
# Slither stores acknowledgements in slither.db.json — resolves on subsequent runs.
# Slither human-summary — compact output for pre-audit checklist:
$ slither . --print human-summary
# Slither call graph — visualize all external calls:
$ slither . --print call-graph
# Generates a .dot graph of all contract calls — useful for identifying
# unexpected external call chains that could introduce reentrancy risk.
# Slither contract-summary — per-contract function list with modifiers:
$ slither . --print contract-summary
# Semgrep — pattern-based security rules for Solidity:
# $ semgrep --config p/solidity path/to/contracts/
# Catches: msg.value in loops, block.timestamp for randomness,
# tx.origin for auth, delegatecall with user-supplied target.
ERC token standards and OpenZeppelin
OpenZeppelin Contracts is the standard library for smart contract development: audited implementations of ERC-20, ERC-721, ERC-1155, and governance contracts that protocol teams build on rather than re-implement from scratch. A Solidity developer on retainer evaluates which OpenZeppelin base contracts to extend, which OpenZeppelin extensions to compose (ERC20Votes, ERC20Permit, ERC20FlashMint), and how to override virtual functions without breaking the base contract’s invariants.
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20FlashMint.sol";
// Governance token — ERC20 + voting delegation + gasless permit + flash minting:
contract GovernanceToken is ERC20Votes, ERC20Permit, ERC20FlashMint {
constructor()
ERC20("MyProtocol Token", "MPT")
ERC20Permit("MyProtocol Token") // EIP-712 domain for gasless approvals
{
_mint(msg.sender, 100_000_000e18);
}
// ERC20Votes requires _afterTokenTransfer to be overridden (delegates checkpointing):
function _afterTokenTransfer(address from, address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._afterTokenTransfer(from, to, amount);
}
function _mint(address to, uint256 amount) internal override(ERC20, ERC20Votes) {
super._mint(to, amount);
}
function _burn(address from, uint256 amount) internal override(ERC20, ERC20Votes) {
super._burn(from, amount);
}
}
// ERC-721 NFT with enumerable extension and royalties (EIP-2981):
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MyNFT is ERC721Enumerable, ERC2981, Ownable {
uint256 private _nextTokenId;
constructor() ERC721("MyNFT", "MNFT") {
_setDefaultRoyalty(msg.sender, 500); // 5% royalty to deployer
}
function mint(address to) external onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
return tokenId;
}
// supportsInterface must merge both parent implementations:
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721Enumerable, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
// ERC-1155 — multi-token standard (fungible + non-fungible in one contract):
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract GameItems is ERC1155 {
uint256 public constant GOLD_COIN = 0; // fungible (quantity matters)
uint256 public constant RARE_SWORD = 1; // non-fungible (quantity = 1 each)
uint256 public constant ARMOR = 2;
constructor() ERC1155("https://api.example.com/metadata/{id}.json") {
_mint(msg.sender, GOLD_COIN, 1_000_000, ""); // mint 1M gold coins
_mint(msg.sender, RARE_SWORD, 1, ""); // mint 1 rare sword
}
// Batch transfer — single transaction for multiple token types:
// ERC1155.safeBatchTransferFrom(from, to, [GOLD_COIN, RARE_SWORD], [100, 1], "")
// More gas-efficient than multiple ERC-20 transfers.
}
Logging Solidity retainer hours so clients understand the work
Smart contract retainer work is invisible in the same way that all security engineering is invisible: a reentrancy audit that prevents a $4 million drain produces no new feature, no new screen, and no change visible to the end user. A gas optimization that reduces transfer cost by 45 percent produces cheaper transactions for users but leaves no trace in the feature changelog. An access control hardening that adds a 48-hour timelock to privileged functions produces a more secure protocol but changes nothing visible before an attack.
The work log entry is what connects the invisible smart contract security investment to its concrete business outcome. A well-written entry captures the advisory category (reentrancy audit, access control hardening, proxy architecture review, storage slot collision analysis, gas optimization, custom error migration, Foundry fuzz campaign, Slither static analysis, Certora formal verification, front-running threat model, external audit preparation), the specific contract and function being audited, the vulnerability class identified (or the absence of vulnerabilities confirmed), the Foundry PoC or Slither output that confirmed the finding, the remediation implemented, and the before/after risk metric or gas cost.
HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each entry. When the DAO asks “what has our smart contract auditor been doing this month?”, the HourTab URL answers with the reentrancy PoC test that confirmed the vulnerability, the checks-effects-interactions remediation that fixed it, and the external audit cost savings that resulted — without requiring a status call or a separately maintained security report.
Retainer structure for Solidity developer engagements
A Solidity developer retainer typically covers four functional areas: feature development (new ERC-20/721/1155 implementations, new DeFi protocol mechanics, new governance contracts, new Foundry tests), security advisory (pre-deployment reviews of all new contracts, Slither analysis of all pull requests, Foundry invariant campaign maintenance, access control audit on every privilege function change), architecture advisory (proxy pattern selection, storage layout planning, Diamond facet organization, cross-chain bridge security review), and gas optimization (forge snapshot baselining before and after feature branches, struct packing audits, custom error migration, calldata optimization). Each area should have its own hour allocation in the retainer agreement so that security work is not competing with feature development for the same pool of hours.
Monthly retainer amounts for Solidity developer advisory and smart contract security consulting typically range from $8,000 to $16,000 per month for security advisory retainers (15 to 30 hours per month at mid-to-senior rates of $200 to $380 per hour), increasing to $20,000 to $45,000 per month for full-protocol Solidity consulting engagements (30 to 60 hours per month) covering architecture design, ongoing security review of every contract change, gas optimization, Foundry invariant testing campaign maintenance, and external audit preparation. Senior smart contract auditors billing at $300 to $550 per hour typically structure retainers at 20 to 40 hours per month, covering a weekly security review cycle plus ongoing advisory on architecture decisions.
The retainer pays for itself when it prevents a single reentrancy vulnerability from reaching production: the average DeFi exploit between 2020 and 2024 drained $8.4 million from affected protocols, according to on-chain exploit databases. A reentrancy audit that costs 7 hours at $300 per hour ($2,100) and prevents an $8.4 million drain delivers a 4,000× return on the advisory investment. The architectural mistake that creates the vulnerability — calling an external contract before updating state, omitting ReentrancyGuard, using transfer() instead of the checks-effects-interactions pattern — takes 2 to 4 hours to diagnose and remediate. Left unaudited until it is exploited on mainnet, it is irreversible. Monthly retainer advisory prevents that deployment before it becomes an exploit.
HourTab is a public retainer dashboard for freelance Solidity developers and smart contract audit firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.