DEV Community

An airdrop you can verify, not trust: building Merkle claims on TON

Crypto airdrops have a trust problem. Users spend months farming points on
the promise that a database they'll never see will eventually produce a fair
number. When the token launches, the operator publishes a result — and you
either believe it or you don't.

When we started building NovaPoints — a points-economy Telegram Mini App on
TON — we wanted the allocation math and the payout itself to be checkable
end-to-end. Not "trust us, the spreadsheet is fair," but: the rules are
published before the snapshot, every score is reproducible from disclosed
inputs, and the contract that pays out can only pay what the frozen snapshot
says.

This is a walkthrough of how it actually works — the FunC contract, the
TON-native Merkle structure (which is not the keccak tree you know from EVM),
and the parts we still can't make trustless.

The design contract

Four commitments, all verifiable:

  1. Rules before results. Season 1's gates (activity ratio, verified TON wallet, qualified referrals) and the per-user cap (50,000 NOVAP of a 25M pool) are published while the season runs — not decided at snapshot time.
  2. Reproducible scores. The score function is deterministic over disclosed inputs: activity, retention, missions, referrals, reputation, events, multiplied by a risk band (the anti-Sybil engine), a campaign multiplier, and a badge bonus. Each score row stores a SHA-256 of its input JSON — anyone can recompute it.
  3. A frozen snapshot. Finalization writes an immutable allocation table; the admin tooling refuses to touch a published snapshot.
  4. A contract that can only pay the snapshot. The claim contract holds a single 256-bit Merkle root and pays whatever a valid proof says — it has no allocation logic of its own.

TON doesn't do Merkle trees like Ethereum

On EVM, airdrop claims are a keccak binary Merkle tree: you send a list of
sibling hashes, the contract re-hashes up to the root. TON's equivalent is
more interesting — the VM itself does half the verification.

Every data structure on TON is a cell — up to 1023 bits plus 4 references,
identified by the hash of its contents. A key-value map is a Hashmap, a
Merkleized radix tree of cells. And TON has exotic cells: a
merkle_proof cell (type 0x03) is a normal subtree where branches not on
the proof path are replaced by their hashes. When a message carrying an
exotic cell is imported, the virtual machine validates that the subtree's
virtualized hash equals the hash stored in the proof header — the contract
never re-computes the tree at all.

Our claim tree is a Hashmap 256 keyed by each claimant's 256-bit account
id; the leaf is just Coins — the claimable amount:

​

d := cell.NewDict(256)
for _, e := range entries {
    key, _ := claimKey(e.Address)          // 256-bit account id, basechain only
    leaf := cell.BeginCell().MustStoreBigCoins(e.Amount).EndCell()
    d.Set(key, leaf)
}
root, _ := d.ToCell()                       // its hash IS the merkle root
​```



Generating a proof is equally direct — prune every branch except the
claimant's:

​

```go
pb := cell.NewMerkleProofBuilder(tree.Root)
slc, _ := pb.Root().AsDict(256).LoadValue(key)  // mark the leaf path
proof, _ := pb.CreateProof()                    // merkle_proof#03 cell
​```



## The contract is a verifier, not an allocator

`merkle_claim.fc` stores five things: the root, an admin, its jetton wallet,
a deadline, and a `claimed` dictionary. The entire claim path:

​

```func
cell proof = in_msg_body~load_ref();
(slice ps, int exotic?) = proof.begin_parse_exotic();   ;; XCTOS
throw_unless(err::bad_proof, exotic?);
throw_unless(err::bad_proof, ps~load_uint(8) == 3);     ;; merkle_proof#03
throw_unless(err::bad_proof, ps~load_uint(256) == root);;; root equality

cell dict = ps~load_ref();
(slice leaf, int found) = dict.udict_get?(256, ah);     ;; claimant's entry
throw_unless(err::no_allocation, found);
int amount = leaf~load_coins();

(_, int seen) = claimed.udict_get?(256, ah);
throw_if(err::already_claimed, seen);
claimed~udict_set(256, ah, ...);
send_jettons(jw, sender, amount, qid);
​```



Three properties fall out of this design:

- **It can't pay anyone else.** The sender's address hash *is* the dict key —
   a proof for your address only pays your address. There's no "claim to a
   different destination" parameter to exploit.
- **It can't pay more than the snapshot.** The amount is in the leaf, fixed
   at tree-build time. `set_root` exists for the monthly vesting tranches
   (25% at TGE, then three monthly roots) — rotating the root is the only
   way to change what's payable, and every rotation is a public on-chain
   event anyone can diff against the published dataset.
- **Proofs never expire because of other people's claims.** `claimed` lives
   in a separate dictionary, so the tree — and every outstanding proof —
   stays valid until the deadline.

The deadline matters too: claims are first-come within a window, and the
admin `withdraw` path exists explicitly for sweeping unclaimed tokens
*after* expiry — they roll into the next season's pool rather than sitting
in limbo.

## The backend half: from snapshot to proof

On-chain is the easy half. The flow that feeds it:

1. Season ends → allocations finalize → snapshot marked `published`.
2. `GenerateClaimTree` pulls every allocation with a **verified wallet on
   the target network** — only verified, basechain (`wc=0`) addresses become
   leaves — builds the dict, and stores its full BoC in `claim_trees`.
   Regeneration is hard-blocked once any claim is `pending`/`claimed`.
3. **Wallets lock at tree time.** Each bound wallet gets `locked_at` set —
   you can't swap your linked address after the snapshot commits you.
4. Proofs are generated lazily: when a user opens the claim screen, the API
   prunes their branch out of the stored dict BoC, caches `proof_boc`, and
   hands it to the Mini App.
5. TON Connect sends the claim tx; the worker confirms it via the
   contract's `is_claimed` get-method rather than trusting the client.

## The risk engine is a multiplier, not a judge

One decision we're happy with: anti-Sybil output doesn't delete people — it
*scales* them. The score function multiplies the weighted activity score by
a risk band (`normal: 1, monitor: .75, suspicious: .25, high: 0`). A flagged
account still sees its real score in-app; the multiplier is just math in the
same published formula, not an invisible blacklist. Fraud signals (shared
IP clusters, device patterns, referral rings) feed the band, and the band
feeds the published score — so "why is my allocation X" always has a
recomputable answer.

## What this does *not* prove

Honesty section, because "verifiable" gets overclaimed:

- **The rules are still ours.** We picked the gates, the weights, the risk
  bands. Verification proves the result matches the published rules — it
  can't prove the rules were *good*.
- **The risk signals are off-chain.** Bands are inputs we produce from
  internal telemetry. You can verify a score used a `suspicious` multiplier;
  you can't verify why that flag fired. Publishing aggregate signal stats is
  on our list.
- **Verification needs the dataset.** Root + claims are on-chain, but
  recomputing your leaf needs the full allocation table — we export it as a
  public dataset (IPFS at TGE) for exactly this reason.

## The boring trust signals that matter more than code

- Minter admin is `addr_none` — revoked at deploy. Supply is 100M, forever.
  (`EQDvO-h0g6m5SN_EN-ikseFdtjtvET_ytKRCm2A26qXaIYGV` on Tonviewer.)
- 79M NOVAP — airdrop pool + liquidity reserve — sits in a standard
  audited multisig-v2 2-of-3: `EQAcU52wnFWIh500eDs2RUEurOc-pIj409IAJZMWX_Inig29`.
- Founder allocation vests 10 months behind a second 2-of-3 escrow; LP
  timelocks 12 months at TGE.

Season 1 is live now — closes at 1,000 eligible users or Dec 31, 2026.
If you want to check any of this yourself: `novapoints.ton/token` has every
address, and the claim contract source goes up on verifier.ton.org when the
tree ships at TGE.

*Built by Codiologism — questions and audit-brain welcome in the comments.*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)