ProCat Solutions
Our Web3 detour: what unfixable smart contracts taught us
A token presale in Solidity and OpenZeppelin, a Merkle-tree whitelist, referrals, gas on Polygon and Arbitrum, an Ethers.js backend, audits and what stayed with us.
A deployed smart contract cannot be fixed. If it has a bug, the bug stays, and users pay for it with their money. Over the past two years we worked on several Web3 projects: token presale systems, whitelist and referral logic, crypto payment integration on EVM-compatible networks. For a team that otherwise builds backend systems, that one constraint rewrote every decision. What follows is what developing and operating smart contracts meant in practice, and what we brought back from it into our usual work.

Don’t write your own
The first rule we learned in Solidity: we do not write the standard components ourselves. ERC-20, ERC-721, access control and reentrancy protection already exist in the OpenZeppelin library, audited and widely used. Every custom implementation only adds risk.
Custom code starts where the business logic does: in the presale contract that decides who can buy, when, at what price and how much. There we follow four principles:
- contract state is minimal; anything not needed for an on-chain decision stays off-chain,
- every mutating function is tied to an explicit permission (
OwnableorAccessControl), - the contract can be paused (
Pausable), because when a bug surfaces that is the only emergency brake, - money movement follows the checks-effects-interactions pattern and is protected by
ReentrancyGuard.
Because nothing can be patched later, error handling, testing and permissions are stricter than in any backend we had written before.
A Merkle tree instead of a list
A typical presale system has three parts. The contract handles the purchase, the backend keeps the whitelist and the referral codes, and the frontend is where the user connects a wallet.
The naive whitelist, a list of addresses stored in the contract, is expensive: every new address is a transaction. We use a Merkle tree. The backend computes the root of the tree of eligible addresses, only that root goes into the contract, and at purchase time the user sends a Merkle proof that the contract verifies. Updating the whitelist takes a single transaction.
We split the referral logic between the chain and the backend on purpose. The contract records only an event: which address bought, with which referral identifier. Commission calculation, eligibility checks and payout scheduling happen in the backend. The rules can change without redeploying the contract, and the chain holds no data that should not be public.

Polygon, Arbitrum and gas
Ethereum mainnet fees are unaffordable for most presales, so the projects run on Polygon or Arbitrum. Both are EVM-compatible and the contract code deploys unchanged. Operating them is a different matter:
- gas price and block time differ, so we tune the confirmation-waiting logic per network,
- Arbitrum’s L2 fee also covers the L1 data cost, which depends on transaction size; large calldata, such as a long Merkle proof, costs more there than you would expect,
- Polygon occasionally produces congestion and reorganised blocks (reorgs), so event processing treats a transaction as final only after several confirmations.
Gas optimisation in contract code is worth real money: packing storage variables, avoiding unnecessary writes, emitting events instead of storing data. Hardhat’s gas report flags anything that got more expensive on every test run.
WebSockets die quietly
Our backend talks to the chain from Node.js with Ethers.js. It has two jobs: sending transactions (whitelist root updates, payouts) and watching events (purchases).
Event watching caused most of the operational headaches. WebSocket-based watching is unreliable; the connection drops without a word. We moved to polling: the backend records the last processed block and periodically fetches the events produced since, always staying a few blocks behind the chain head because of reorgs. Processing is idempotent, keyed on transaction hash and log index.
Sending needs its own logic for nonce management and for replacing stuck transactions. A transaction sent with a low gas price can hang for hours when the network gets more expensive, and while it sits there, the next nonce cannot go through.
Audits find the unwritten assumptions
We test smart contracts far more thoroughly than backend code: unit tests under Hardhat for every branch, fork tests against real network state, fuzz tests on the calculation functions. Before deployment we commission an independent audit. It is mandatory, because the cost of a bug is the users’ money.
An audit rarely turns up a critical bug. What it finds are the assumptions the developer never wrote down: what if the owner key is lost, what happens to funds left in the contract after the presale ends, who is allowed to call the emergency stop.
What stayed
Web3 remained a detour for us and never became a main line of work. Several habits stayed:
- the “this cannot be fixed” mindset: we design migrations, payment flows and external integrations as if they could not be undone,
- idempotent, replayable event processing became our default for every webhook and message queue,
- the permission model is written down explicitly and tested for every mutating operation,
- critical code paths get an independent review, even without a formal audit.
The same patterns have paid off in our next projects, the voice-based systems, where processing call events raises exactly the same problems.