ProCat Solutions
A Web3 detour: smart contracts, presale systems, lessons learned
A Solidity and OpenZeppelin token presale with whitelist and referrals, gas on Polygon and Arbitrum, an Ethers.js backend, testing, audits and takeaways.
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. This post is not about blockchain as a phenomenon, but about what it means to develop and operate smart contracts for a team that otherwise builds backend systems. At the end we also write about what we brought back from it into our “normal” work.
Solidity and OpenZeppelin: do not write your own
The first and most important rule we learned in Solidity development: we do not write the standard components ourselves (ERC-20, ERC-721, access control, reentrancy protection). The OpenZeppelin library provides these in audited, widely used form, and every custom implementation only adds risk.
Custom code starts where the business logic starts: the presale contract that determines who can buy, when, at what price and how much. A few principles we follow there:
- 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 in case of a bug that is the only emergency brake, - money movement follows the checks-effects-interactions pattern and is protected by
ReentrancyGuard.
A deployed contract cannot be fixed. That single sentence reshapes every decision: error handling, testing and permissions are stricter than in any backend we had written before.
Presale with whitelist and referrals
A typical presale system has three parts: the contract that handles the purchase, the backend that keeps the whitelist and the referral codes, and the frontend where the user connects a wallet.
For the whitelist, the naive solution (a list of addresses in the contract) is expensive: adding each address is a transaction. We use a Merkle tree instead: the backend computes the root of the tree of eligible addresses, only that goes into the contract, and at purchase time the user sends a Merkle proof that the contract verifies. Updating the whitelist is then a single transaction.
We deliberately split the referral logic between the chain and the backend. The contract only records as an event which address bought and with which referral identifier; commission calculation, eligibility checks and payout scheduling happen in the backend. That way the rules can change without redeploying the contract, and the chain holds no data that should not be public.
Polygon, Arbitrum and gas
Transaction fees on Ethereum mainnet are unaffordable for most presales, so projects run on Polygon or Arbitrum. Both are EVM-compatible and the contract code deploys unchanged, but operations differ:
- gas price and block time are different, so we tune the transaction-confirmation waiting logic per network,
- Arbitrum’s L2 fee structure also includes the L1 data cost, which depends on transaction size; large calldata (a long Merkle proof, for instance) is more expensive here than you would expect,
- Polygon occasionally produces network congestion and reorganised blocks (reorgs), so event processing only treats a transaction as final after several confirmations.
Gas optimisation in contract code is worth real money: packing storage variables, avoiding unnecessary writes, using events instead of storage. Hardhat’s gas report flags on every test run when something got more expensive.
An Ethers.js backend and chain watching
Our backend talks to the chain in Node.js with Ethers.js. It has two jobs: sending transactions (updating the whitelist root, payouts) and watching events (purchases).
Event watching is what caused most of the operational headaches. WebSocket-based watching is unreliable; the connection drops silently. So we switched 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. Event processing is idempotent, keyed on the transaction hash and log index.
On the sending side, nonce management and replacing “stuck” transactions need dedicated logic: 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 does not go through either.
As for testing and audits: we test smart contracts considerably 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; this is not optional, because the cost of a bug is the users’ money.
Most of the time an audit does not find a critical bug, but assumptions the developer never wrote down: what if the owner key is lost, what happens to the funds left in the contract after the presale ends, who is allowed to call the emergency stop.
What carried over?
Web3 remained a detour for us rather than a main direction, but several things stayed with us:
- 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 the default pattern for every webhook and message queue we handle,
- explicitly describing and testing the permission model for every mutating operation,
- the need for independent review of critical code paths, even when there is no formal audit.
These patterns are proving useful in our next projects too, in the voice-based systems, where processing call events raises exactly the same problems.