Skip to content

AirdropDistributor.sol — spec ​

Locked 2026-06-25. Single contract handles all 4 phases of the sanect airdrop (300M SNCT total). User-initiated claim with on-chain signature verification. Internal 180-day linear vest.

Design choice — why this shape ​

We considered three architectures:

ModelUser Day-180 actionProsCons
Merkle claimConnect wallet, generate proof, sign claim txPermissionless, low operator trustLost-key users lose everything, complex Merkle tooling, claim deadline forces rush
Push distributionNothing (auto-receive)Zero frictionForced taxable event, operator-trust-heavy, no claim-time sybil filter
Registered claim (chosen)Connect wallet, sign verify message, claimUser opt-in (tax control), single contract holds state, simple UILost-key users still lose, but they wouldn't have claimed Merkle either

Chosen: registered claim. Operator pre-loads per-user allocations into the contract via batch txs before each phase opens. Users verify and claim on their own schedule within a generous 365-day window per phase. Unclaimed amount after 365 days reverts to Treasury.

Contract interface ​

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract AirdropDistributor {
    IERC20 public immutable snct;
    address public operator;        // protocol-governance multisig
    address public treasury;        // unclaimed reverts here

    struct Allocation {
        uint128 liquid;             // sent immediately on claim()
        uint128 vested;             // linearly released over 180d from claimedAt
        uint64  claimedAt;          // 0 = unclaimed; timestamp on claim()
        uint128 vestedClaimed;      // amount of vested portion already withdrawn
    }

    // phase (1..4) → user → allocation
    mapping(uint8 => mapping(address => Allocation)) public allocations;

    // phase → state
    struct Phase {
        uint64 opensAt;             // claim window opens
        uint64 revertsAt;           // unclaimed swept to treasury
        uint128 totalAllocated;     // sum of liquid+vested loaded
        uint128 totalClaimed;       // sum of liquid claimed so far
        bool   locked;              // operator can't modify allocations after lock
    }
    mapping(uint8 => Phase) public phases;

    uint64 public constant VEST_DURATION = 180 days;

    // Events
    event AllocationSet(uint8 indexed phase, address indexed user, uint128 liquid, uint128 vested);
    event PhaseConfigured(uint8 indexed phase, uint64 opensAt, uint64 revertsAt);
    event PhaseLocked(uint8 indexed phase);
    event Claimed(uint8 indexed phase, address indexed user, uint128 liquid, uint128 vestedRegistered);
    event VestedClaimed(uint8 indexed phase, address indexed user, uint128 amount);
    event Reverted(uint8 indexed phase, uint128 amount);
    event OperatorTransferred(address indexed prev, address indexed next);

    // ── Operator-only ────────────────────────────────────────────────

    /// Batch-load allocations for a phase. Idempotent — re-calling with
    /// the same user overwrites (allows post-snapshot fixes during the
    /// 7-day appeal window).
    function setAllocations(
        uint8 phase,
        address[] calldata users,
        uint128[] calldata liquid,
        uint128[] calldata vested
    ) external onlyOperator phaseUnlocked(phase);

    /// Configure phase open and revert timestamps.
    function configurePhase(uint8 phase, uint64 opensAt, uint64 revertsAt) external onlyOperator;

    /// After loading + appeal window, lock the phase. No more allocation
    /// changes possible — community can audit final state on-chain.
    function lockPhase(uint8 phase) external onlyOperator;

    /// After revertsAt, sweep all unclaimed amount to treasury.
    function revertUnclaimed(uint8 phase) external;

    /// Standard ownership transfer (for handing operator role to Safe).
    function transferOperator(address next) external onlyOperator;

    // ── User-facing ──────────────────────────────────────────────────

    /// Claim the liquid portion + register vest schedule.
    /// Requires msg.sender == eligible address.
    function claim(uint8 phase) external phaseOpen(phase);

    /// Pull the currently-vested portion of an already-claimed allocation.
    /// Can be called multiple times across the 180-day vest.
    function claimVested(uint8 phase) external;

    /// Convenience: claim() + claimVested() across every phase open for caller.
    function claimAll() external;

    /// View currently-claimable amount.
    function claimable(uint8 phase, address user)
        external view returns (uint128 liquidPart, uint128 vestedAvailableNow);

    /// View vest progress for already-claimed allocation.
    function vestProgress(uint8 phase, address user)
        external view returns (uint128 totalVested, uint128 alreadyClaimed, uint128 claimableNow, uint64 fullyVestedAt);
}

Claim flow ​

User journey ​

  1. Connect wallet on airdrop.sanect.com
  2. Dashboard shows points + projected allocation for each open phase
  3. User clicks "Claim Phase 1"
  4. Wallet signs personal_sign of message:
    sanect airdrop claim — phase {N}
    wallet: {address}
    nonce: {timestamp}
    (off-chain confirmation that the user actively wants to claim — a UX gesture, not a security primitive; on-chain check is msg.sender)
  5. App calls AirdropDistributor.claim(phase) from the user's wallet
  6. Contract:
    • Reads allocations[phase][msg.sender]
    • If claimedAt != 0, revert (already claimed)
    • Set claimedAt = block.timestamp
    • Transfer liquid SNCT to msg.sender immediately
    • Vest schedule for vested portion now ticks linearly over 180 days
    • Emit Claimed(phase, user, liquid, vested)
  7. Liquid SNCT lands in user's wallet
  8. UI now shows "Vest progress: 0% / 180 days" with daily claim button
  9. User periodically calls claimVested(phase) to pull the unlocked portion (or claimAll() to claim across all phases at once)

Vest math ​

vestStart = allocation.claimedAt
vestEnd   = vestStart + 180 days
now       = block.timestamp

if (now >= vestEnd) {
    fullyVested = allocation.vested;
} else {
    fullyVested = allocation.vested * (now - vestStart) / 180 days;
}

claimableNow = fullyVested - allocation.vestedClaimed;

Operator workflow per phase ​

T-30 days before phase opens:

  1. Indexer finishes computing points for the phase window
  2. Anti-sybil pipeline runs, produces cluster filter
  3. Final allocation CSV generated (address, liquid, vested)
  4. CSV published publicly on IPFS + airdrop.sanect.com/snapshots/phaseN.csv
  5. Appeal window opens: 7 days for community to flag wrong addresses

T-7 days: 6. Process appeals, update CSV 7. Operator runs batch script: setAllocations(phase, batch, liquid, vested) in chunks of ~500 users per tx (~10-20 batches for 50k users)

T-1 day: 8. configurePhase(phase, opensAt, revertsAt) — set timestamps 9. lockPhase(phase) — community-verifiable: contract refuses further allocation changes

T-0 (claim opens): 10. Phase is open; users start claiming on their own schedule

T+365 days post-phase-open: 11. Anyone can call revertUnclaimed(phase) to sweep dormant allocations to Treasury

Security model ​

Operator key compromise:

  • Operator can only modify allocations during the setAllocations window (before lockPhase)
  • Once locked, allocations are immutable until expiry
  • Mitigation: operator is the protocol-governance 3-of-5 Safe, not an EOA

Phase pre-funding:

  • Contract must hold ≥ phase's totalAllocated in SNCT before opensAt
  • Recommend: fund the contract with 75M SNCT before each configurePhase call
  • Operator multisig retains ownership of unallocated balance

Reentrancy:

  • All token transfers happen at end of function (CEI pattern)
  • Phase-state mutations happen before external snct.transfer() calls
  • Standard OpenZeppelin nonReentrant modifier on claim functions

Wrong-amount allocation:

  • Total allocated per phase is checked: sum(liquid+vested) for all users ≤ 75M
  • Locked phase emits canonical totals so community can verify total ≤ 75M

Frontrunning:

  • Not applicable — claim outcome is deterministic per address, no MEV
  • Operator setAllocations txs are not MEV-attractive

Auditability ​

Public verification before phase locks:

  1. Snapshot CSV at IPFS hash X
  2. Indexer source code public at GitHub
  3. Anyone re-runs indexer against ETH block + sanect block → reproduces CSV
  4. Anyone diffs CSV against contract state via subgraph query
  5. Diff = 0 → distribution is verifiably correct

Public verification after phase locks:

  • phases[N].locked == true
  • phases[N].totalAllocated matches CSV sum
  • getAllocation(phase, user) returns matches for any sample address

Sizing the contract ​

  • Bytecode: ~6-8 KB (well under EIP-170's 24.5 KB)
  • Storage per allocation: 3 slots (96 bytes) → 4 phases × 50k users = 200k slots × 96 = ~19 MB total state
  • Total gas to load all allocations for 1 phase (50k users at 500/batch): ~500M gas split across ~100 txs
  • Per-claim gas: ~80k (transfer + storage updates)

Open implementation choices ​

  1. OpenZeppelin upgradability? Recommend NO — immutable contract is more credible. If a bug found, deploy V2 + migrate via batch.
  2. Pausability? Recommend NO during claim — defeats the trustless guarantee. Operator can revertUnclaimed if needed but can't freeze claims.
  3. Per-user max enforcement? Not needed — total cap is per-phase (75M); whales who hit the per-phase cap simply receive their share unscaled.
  4. claimAll() gas limit? Cap at 4 phases worth of work per tx (small constant) → ~320k gas worst case.
  5. Signature replay protection? The personal_sign message is UX-only, not a security primitive. msg.sender is the security primitive. No replay risk.

Test plan ​

  • [ ] setAllocations only callable by operator
  • [ ] setAllocations reverts after lockPhase
  • [ ] claim reverts if not phase-open, if already claimed, if no allocation
  • [ ] claim transfers exact liquid amount
  • [ ] claimVested linearly releases over 180d, no over-claim possible
  • [ ] claimVested returns 0 before claimedAt
  • [ ] claimVested returns full vested at +180d
  • [ ] revertUnclaimed only callable after revertsAt
  • [ ] revertUnclaimed returns exact unclaimed amount (totalAllocated - totalClaimed)
  • [ ] claimAll across multiple phases is gas-bounded
  • [ ] All transfers respect ERC20 return value
  • [ ] Reentrancy guard holds for both claim and claimVested
  • [ ] Total allocated per phase ≤ 75M enforced at lock time

Build effort ​

ComponentEffort
Contract + Foundry tests3-4 days
Internal audit + fuzz2 days
External audit (Zellic / Veridise)2-3 weeks
Batch-distribution script1 day
Subgraph for read-side2 days
Frontend claim flow3-4 days

Total: ~5 weeks engineering + audit. Comfortably fits Phase 1 = Day 180 timeline if started Day 7.

Deployment plan ​

DayStep
Day 7Start contract development
Day 21Internal audit + fuzz complete; engage external auditor
Day 50External audit complete + fixes
Day 60Deploy to mainnet under protocol-governance Safe
Day 70Verified on scan.sanect.com, contract owner = governance Safe
Day 90Phase 1 indexer cutoff (last block scored for Phase 1)
Day 100Anti-sybil pipeline final pass
Day 173Snapshot CSV published, appeal window opens
Day 179lockPhase(1)
Day 180configurePhase(1, ts, ts+365d) + opensAt reached → users claim
Day 270, 360, 450Phases 2, 3, 4 repeat the T-30/T-7/T-0 cycle
Day 545 (Phase 1 +365d)revertUnclaimed(1) sweeps dormant to Treasury