Command Palette

Search for a command to run...

2.1k
Blog

Shipping an ERC-20 Launchpad: Vesting, KYC Gating, and a Slither Audit

A token launchpad is mostly about the things that go wrong. Cliff/linear vesting, allowlist gating, and the static-analysis pass that caught issues before mainnet.

A client wanted to launch their project's ERC-20 token with a proper distribution: team and investor allocations on a vesting schedule, a gated sale for KYC'd participants, and a clean mainnet deploy they could point auditors and investors at. No rug-pull optics, no unlocked team tokens dumping on day one.

I built the launchpad contracts and the deploy pipeline. Here's what the work actually involved, and most of it is about preventing the failure modes rather than the happy path.

Vesting: Cliff + Linear

The core requirement was vesting with a cliff followed by linear release. Team tokens vest over 24 months with a 6-month cliff: nothing for the first 6 months, then a linear unlock until month 24.

The naive implementation stores a start time and computes the vested amount on each claim. The math is simple but the edge cases aren't:

function vestedAmount(address beneficiary) public view returns (uint256) {
    Schedule memory s = schedules[beneficiary];
    if (block.timestamp < s.start + s.cliff) {
        return 0;
    }
    if (block.timestamp >= s.start + s.duration) {
        return s.total;
    }
    uint256 elapsed = block.timestamp - s.start;
    return (s.total * elapsed) / s.duration;
}

The released amount is vestedAmount - alreadyClaimed. A few things that bit me, or that I designed around:

Cliff returns zero, not a prorated amount. Before the cliff, vested is exactly 0. After the cliff it jumps to the full prorated amount, as if linear vesting had been accruing the whole time (including during the cliff). That's the standard interpretation, but it's worth stating explicitly because "cliff" is ambiguous. Some people expect accrual to start at the cliff.

Integer division truncates. (total * elapsed) / duration rounds down. Over the full schedule this means the last claim picks up the dust. I made sure the >= start + duration branch returns the exact total so nobody is left with unclaimable wei.

Revocability. The client wanted to revoke unvested tokens if a team member left. That adds a revoked flag and means vestedAmount has to freeze at the revocation timestamp. Easy to get wrong if you only test the non-revoked path.

KYC Allowlist Gating

The token sale was restricted to KYC-verified participants. The verification happened off-chain (a compliance provider), and the result was an allowlist. I used the same Merkle tree pattern I'd use for an NFT allowlist: the root on-chain, proofs distributed to participants.

function purchase(uint256 amount, bytes32[] calldata proof) external payable {
    require(saleActive, "sale inactive");
    require(
        MerkleProof.verify(proof, kycRoot, keccak256(abi.encodePacked(msg.sender))),
        "not KYC verified"
    );
    require(purchased[msg.sender] + amount <= maxPerWallet, "cap exceeded");
    // ... handle payment, mint/transfer, record purchase
}

The Merkle approach keeps gas low and avoids storing thousands of addresses on-chain. The tradeoff is that updating the allowlist means recomputing and re-publishing the root, so we batched KYC approvals and updated the root on a schedule rather than per-approval.

The Slither Pass

Before mainnet, I ran Slither, a static analyzer for Solidity, across the contracts. This is non-negotiable for anything handling real money. It caught several things worth fixing.

Reentrancy in the purchase flow. The first version transferred tokens before updating the purchased mapping. Slither flagged the classic reentrancy pattern. Even though the token transfer itself wasn't an obvious reentrancy vector, I restructured to checks-effects-interactions: update state first, transfer last. Free safety, so no reason not to.

Unchecked return value on a transfer. An ERC-20 transfer can return false instead of reverting. Slither flagged that I wasn't checking it. I switched to OpenZeppelin's SafeERC20 safeTransfer, which reverts on failure.

Public functions that could be external. A minor gas optimisation. Functions only called externally were marked public. Slither suggests external where applicable, which saves a bit of gas on calldata handling.

Timestamp dependence. Slither warns on block.timestamp use because miners have minor influence over it. For vesting on a month-scale schedule, a few seconds of miner manipulation is irrelevant, so I documented this as an accepted finding rather than a bug. Knowing why a warning is safe to ignore is part of the job.

Slither isn't a substitute for a human audit. It finds known patterns, not novel logic bugs. But it's a cheap first pass that catches the embarrassing stuff before a paid auditor (or an attacker) does.

Deploy Pipeline

The mainnet deploy used Hardhat with a scripted, verifiable process:

  1. Deploy to a testnet fork first, run the full test suite against it
  2. Deploy to mainnet with constructor args logged
  3. Verify source on Etherscan immediately (an unverified token contract reads as a red flag)
  4. Transfer ownership to the client's multisig, not an EOA
  5. Renounce any privileged functions that weren't needed post-launch

Step 4 mattered most for trust. Investor due-diligence checks who controls the contract. A single externally-owned account holding owner privileges is a rug-pull waiting to happen. A multisig with known signers is the baseline expectation.

What I'd Change

Use OpenZeppelin's VestingWallet where it fits. I wrote custom vesting because the client needed revocability and per-beneficiary schedules in one contract. But for simpler cases, OZ's audited VestingWallet is the right default. Rolling your own vesting math is exactly the kind of thing that should make you nervous.

Formal invariant testing. I tested specific scenarios, but I'd now reach for Foundry's invariant/fuzz testing to assert properties like "total claimed across all beneficiaries never exceeds total allocated" against randomised inputs. Property-based testing finds the edge cases you didn't think to write.

A timelock on admin actions. Even with a multisig, instant admin actions are a trust gap. A timelock, where privileged changes are announced and only executable after a delay, gives token holders time to react. It's standard for serious projects and I'd build it in from the start next time.

A token launchpad looks like a few hundred lines of Solidity. The actual product is the discipline around it: the analysis pass, the verified deploy, the multisig handoff, the renounced privileges. The code is the easy part.

Related posts