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:
| Model | User Day-180 action | Pros | Cons |
|---|---|---|---|
| Merkle claim | Connect wallet, generate proof, sign claim tx | Permissionless, low operator trust | Lost-key users lose everything, complex Merkle tooling, claim deadline forces rush |
| Push distribution | Nothing (auto-receive) | Zero friction | Forced taxable event, operator-trust-heavy, no claim-time sybil filter |
| Registered claim (chosen) | Connect wallet, sign verify message, claim | User opt-in (tax control), single contract holds state, simple UI | Lost-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
// 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
- Connect wallet on
airdrop.sanect.com - Dashboard shows points + projected allocation for each open phase
- User clicks "Claim Phase 1"
- Wallet signs
personal_signof message:(off-chain confirmation that the user actively wants to claim — a UX gesture, not a security primitive; on-chain check is msg.sender)sanect airdrop claim — phase {N} wallet: {address} nonce: {timestamp} - App calls
AirdropDistributor.claim(phase)from the user's wallet - Contract:
- Reads
allocations[phase][msg.sender] - If
claimedAt != 0, revert (already claimed) - Set
claimedAt = block.timestamp - Transfer
liquidSNCT to msg.sender immediately - Vest schedule for
vestedportion now ticks linearly over 180 days - Emit
Claimed(phase, user, liquid, vested)
- Reads
- Liquid SNCT lands in user's wallet
- UI now shows "Vest progress: 0% / 180 days" with daily claim button
- User periodically calls
claimVested(phase)to pull the unlocked portion (orclaimAll()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:
- Indexer finishes computing points for the phase window
- Anti-sybil pipeline runs, produces cluster filter
- Final allocation CSV generated (address, liquid, vested)
- CSV published publicly on IPFS + airdrop.sanect.com/snapshots/phaseN.csv
- 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
setAllocationswindow (beforelockPhase) - 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
totalAllocatedin SNCT beforeopensAt - Recommend: fund the contract with 75M SNCT before each
configurePhasecall - 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
nonReentrantmodifier 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:
- Snapshot CSV at IPFS hash X
- Indexer source code public at GitHub
- Anyone re-runs indexer against ETH block + sanect block → reproduces CSV
- Anyone diffs CSV against contract state via subgraph query
- Diff = 0 → distribution is verifiably correct
Public verification after phase locks:
phases[N].locked == truephases[N].totalAllocatedmatches CSV sumgetAllocation(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
- OpenZeppelin upgradability? Recommend NO — immutable contract is more credible. If a bug found, deploy V2 + migrate via batch.
- Pausability? Recommend NO during claim — defeats the trustless guarantee. Operator can
revertUnclaimedif needed but can't freeze claims. - Per-user max enforcement? Not needed — total cap is per-phase (75M); whales who hit the per-phase cap simply receive their share unscaled.
claimAll()gas limit? Cap at 4 phases worth of work per tx (small constant) → ~320k gas worst case.- Signature replay protection? The personal_sign message is UX-only, not a security primitive.
msg.senderis the security primitive. No replay risk.
Test plan
- [ ]
setAllocationsonly callable by operator - [ ]
setAllocationsreverts afterlockPhase - [ ]
claimreverts if not phase-open, if already claimed, if no allocation - [ ]
claimtransfers exactliquidamount - [ ]
claimVestedlinearly releases over 180d, no over-claim possible - [ ]
claimVestedreturns 0 beforeclaimedAt - [ ]
claimVestedreturns full vested at +180d - [ ]
revertUnclaimedonly callable afterrevertsAt - [ ]
revertUnclaimedreturns exact unclaimed amount (totalAllocated - totalClaimed) - [ ]
claimAllacross multiple phases is gas-bounded - [ ] All transfers respect ERC20 return value
- [ ] Reentrancy guard holds for both
claimandclaimVested - [ ] Total allocated per phase ≤ 75M enforced at lock time
Build effort
| Component | Effort |
|---|---|
| Contract + Foundry tests | 3-4 days |
| Internal audit + fuzz | 2 days |
| External audit (Zellic / Veridise) | 2-3 weeks |
| Batch-distribution script | 1 day |
| Subgraph for read-side | 2 days |
| Frontend claim flow | 3-4 days |
Total: ~5 weeks engineering + audit. Comfortably fits Phase 1 = Day 180 timeline if started Day 7.
Deployment plan
| Day | Step |
|---|---|
| Day 7 | Start contract development |
| Day 21 | Internal audit + fuzz complete; engage external auditor |
| Day 50 | External audit complete + fixes |
| Day 60 | Deploy to mainnet under protocol-governance Safe |
| Day 70 | Verified on scan.sanect.com, contract owner = governance Safe |
| Day 90 | Phase 1 indexer cutoff (last block scored for Phase 1) |
| Day 100 | Anti-sybil pipeline final pass |
| Day 173 | Snapshot CSV published, appeal window opens |
| Day 179 | lockPhase(1) |
| Day 180 | configurePhase(1, ts, ts+365d) + opensAt reached → users claim |
| Day 270, 360, 450 | Phases 2, 3, 4 repeat the T-30/T-7/T-0 cycle |
| Day 545 (Phase 1 +365d) | revertUnclaimed(1) sweeps dormant to Treasury |