Hartii Airdrop (beta)
Hartii Airdrop batch-sends QUAI or a single standard ERC-20 token to a list of wallets — pasted in, uploaded as a CSV, or pulled from a snapshot of a token's holders — in as few on-chain transactions as possible, each signed and paid for by the connected wallet. It's for token creators and community operators who need to pay many wallets at once instead of sending transfers one at a time.
Live at hartiibiome.com/airdrop — explicit beta: tested by the project's own test suite, not an independent audit. Use small batches first.
Fee model and caps
Every send quotes its fee live from the contract — the page never bakes in a fee value:
function quoteFee(uint256 n) public view returns (uint256) {
return flatFee + perRecipientFee * n;
}| Current | Hard cap (owner cannot exceed) | |
|---|---|---|
flatFee | 1 QUAI | MAX_FLAT_FEE = 10 QUAI |
perRecipientFee | 0.05 QUAI | MAX_PER_RECIPIENT_FEE = 1 QUAI |
| Recipients per transaction | — | MAX_RECIPIENTS = 500, fixed in code, not owner-settable |
The owner can change flatFee/perRecipientFee with setFees(flat, perRecipient), but the call
reverts ("fee cap") if either new value exceeds its hard cap above — those caps are constants in
the contract, not configuration that can be loosened later.
Each transaction pays its own flat fee. A list of 1,200 recipients splits into three transactions of 500 / 500 / 200 and pays the 1 QUAI flat fee three times, not once; the per-recipient fee scales with each batch's own size. Fees are quoted and paid in QUAI regardless of whether you're sending QUAI or a token — a token airdrop still needs QUAI in the wallet for the fee plus gas, on top of the token balance being sent.
The fee destination is not printed here — read it live with feeRecipient() on the contract (see
Contract facts below); the owner can change it with setFeeRecipient. If a
payment to that address fails, or needs more than the contract's bounded 30,000-gas push allows,
the fee is credited to that address's own pendingFees instead of blocking the airdrop, and stays
claimable via withdrawFees(to) at any time — including while the contract is paused.
Gas, in practice
| Gas | Source | |
|---|---|---|
| 3 recipients, QUAI | 203,936 | measured on a real mainnet transaction |
| 500 recipients, QUAI | ≈ 4.62M | Hardhat benchmark, repo compiler settings — not a live Quai measurement |
| 500 recipients, standard ERC-20 | ≈ 5.61M | Hardhat benchmark, repo compiler settings — not a live Quai measurement |
| Quai block gas limit | 50M | for scale: a full 500-recipient ERC-20 batch is ~11% of one block |
Quai's gas price floated around 26,000–28,000 gwei at deploy time, which puts a ~200,000-gas transaction at roughly 5–6 QUAI. Gas price moves with network conditions — the live page always shows its own estimate (with 20% headroom) before you sign, not this table.
How it works
Connect — Pelagus (desktop extension) or Blip (mobile), on Quai Mainnet (chain 9). The page checks the connected chain and account on every step and blocks anything else.
Pick the asset — QUAI, or an ERC-20 token's contract address. For a token, the page reads
symbol(),decimals()and your livebalanceOfdirectly from the chain.Add recipients, one of:
- Paste
address,amountrows, oraddressonly with one shared amount set above the box. - Upload a CSV (up to 10,000 rows / 2 MB). Headers, a BOM, CRLF and quoted fields are accepted.
- Holders of a HartiiLabs token — a snapshot pulled from the Labs holders API, with one shared amount for everyone returned. See the incomplete-snapshot caveat below.
Identical duplicate rows (same address, same amount) are merged silently; a duplicate address with a different amount is rejected and must be fixed at the source. The connected wallet is always excluded from a holder snapshot, so airdropping your own token never pays a fee to send to yourself.
- Paste
Review — the page reads the current fee for every planned batch and a live gas estimate (20% headroom) straight from the contract and RPC. An ERC-20 send needs an
approve()first, for exactly the remaining amount still to be sent — never an open-ended approval — and that approval is its own transaction; it does not send anything by itself.Send — one wallet signature per batch, in order. Progress (which batches went out, their transaction hashes and status) is saved in the browser, so a closed tab or a stalled wallet doesn't lose track of what already sent.
Recover, if needed — if a signature's outcome is unknown, the page never resends blindly. Paste the transaction hash from your wallet's own history; the page verifies it against the saved batch (same account, destination, calldata and value) before accepting it.
Export — a per-recipient CSV receipt (
address,amount,batch,status,transaction) once batches are confirmed.
Beta caveats
- Beta. Tested by the project's own test suite; no independent audit.
- One bad recipient reverts the whole batch. Every send is atomic: a single zero address, or (for a token) a single recipient that doesn't behave like a standard transfer target, reverts every recipient in that transaction. Already-confirmed earlier batches in the same plan stay sent — only the failing batch needs fixing and resending.
- Approvals are exact. The page requests allowance for exactly what the remaining plan needs, never an open-ended approval. Some ERC-20s refuse to raise an existing nonzero allowance directly — if yours does, reset it to zero in your wallet first; the page will not silently submit a second approval on your behalf.
- Each batch is its own transaction and pays its own flat fee — see Fee model and caps above.
- Non-standard and fee-on-transfer tokens are rejected. The contract checks the exact balance
delta on the pull from your wallet and on every push to a recipient; a token that takes a cut on
transfer, rebases, or returns no/false instead of
truefails with"unsupported token"and the whole airdrop reverts. Standard, unmodified ERC-20s only. - Holder snapshots can be incomplete. Paging follows the Labs holders API's
offset/nextOffsetcontract (live since Labs 1.4.0); if a response doesn't includenextOffset, paging stops where it is and the list is explicitly labelled incomplete — you must tick an acknowledgement box before using it. An indexed snapshot is also never a live on-chain read: it can lag the chain by whatever the indexer's own lag is.
Contract facts
| Mainnet address | 0x00151800430B518909Ab5368a8115eE84eC1B72f |
| Deploy transaction | 0x00690025dd0bb48945c018f4c6700656af76947bb1291762bf592cf612539ecc |
| Orchard rehearsal (testnet, chain 15000 — rehearsal only, never production) | 0x003703Bc620888fC137B807C36e12Cf51A76d17B |
function airdropQuai(address[] calldata recipients, uint256[] calldata amounts) external payable;
function airdropToken(address token, address[] calldata recipients, uint256[] calldata amounts) external payable;
function airdropTokenSame(address token, address[] calldata recipients, uint256 amountEach) external payable;
function quoteFee(uint256 n) public view returns (uint256);
function withdrawFees(address payable to) external; // pays msg.sender's own pendingFees
// owner-only
function setFees(uint256 flat, uint256 perRecipient) external; // reverts "fee cap" above the hard caps
function setFeeRecipient(address recipient) external;
function pause() external;
function unpause() external;
function transferOwnership(address nextOwner) external; // two-step, see below
function acceptOwnership() external; // callable only by pendingOwnerEvents: Airdrop(sender, token, count, total, fee), FeesUpdated(flatFee, perRecipientFee),
FeeRecipientUpdated(previousRecipient, recipient), FeesAccrued(recipient, amount),
FeesWithdrawn(recipient, to, amount), Paused(account), Unpaused(account),
OwnershipTransferStarted(owner, pendingOwner), OwnershipTransferred(previousOwner, owner).
Reverts: "not owner", "reentrant", "paused", "recipient count" (zero or over
MAX_RECIPIENTS), "length mismatch" (recipients/amounts arrays differ), "bad recipient" (zero
address or the contract itself), "zero amount", "insufficient value", "bad token" (token
address has no code), "unsupported token" (balance delta didn't match on pull or a push — covers
fee-on-transfer, rebasing and non-returning tokens), "token transfer failed", "recipient failed" (a QUAI push reverted), "refund failed", "no fees" / "withdraw failed"
(withdrawFees), "fee cap" (setFees above a hard cap), "not pending owner"
(acceptOwnership called by anyone else).
Owner controls, and their limits:
setFees— capped on-chain atMAX_FLAT_FEE(10 QUAI) /MAX_PER_RECIPIENT_FEE(1 QUAI); reverts above either.setFeeRecipient— redirects where the fee goes; cannot be set to the zero address or the contract itself.pause/unpause— blocks all three send functions (airdropQuai,airdropToken,airdropTokenSame) only.withdrawFees,setFees,setFeeRecipientand the ownership functions keep working while paused.transferOwnership/acceptOwnership— two-step: the current owner nominatespendingOwner, and only that address callingacceptOwnership()completes the handover. A mistyped new-owner address can't brick ownership the way a one-step transfer could.- The owner is not printed here — read it live with
owner()on the contract above.
What it cannot do: no upgrade path (not a proxy), no rescue or arbitrary-call function, no way
to pull a recipient's already-sent funds back, and no way to touch another account's accrued
pendingFees — withdrawFees(to) only ever pays pendingFees[msg.sender], keyed to whoever the
fee was originally meant for, not whoever currently holds the feeRecipient role. Pausing stops
new airdrops; it can't reach into one that's already been sent. Every state-changing function
(including the owner-only ones) shares one reentrancy lock, so an owner call and a send can never
interleave.
FAQ
Why did my batch revert?
Almost always one recipient. For QUAI, that's usually a contract address that rejects a plain
value transfer. For a token, it's usually the token itself: anything that isn't a standard
transfer/transferFrom returning true — fee-on-transfer, rebasing, or non-returning — reverts
the entire batch, not just that one recipient. Find the offending recipient or token, fix the
input, and resume review; batches that already confirmed stay confirmed.
How do I resume after closing the tab or a wallet timeout? Reopen the page with the same wallet — the in-progress plan restores from browser storage automatically. Use Check receipts / resume review to re-check any batch with an unresolved status. If a signature's outcome is genuinely unknown, paste its transaction hash from your wallet's own history rather than resending — the page verifies it against the saved batch before accepting it. Don't clear browser storage while a batch is pending or unknown.
Where do the fees go?
To the contract's feeRecipient — read it live with feeRecipient() rather than trusting a
cached value, since the owner can change it. If a payment to that address fails, it's held in
that address's own pendingFees balance and can be withdrawn at any time, including while the
contract is paused.
Can I airdrop any ERC-20?
Only a standard one: transfer/transferFrom that return true on success, with no fee, tax or
rebase on transfer. The contract verifies exact balance deltas on every transfer in and out of
itself; anything that doesn't match reverts with "unsupported token".