Hartii developer docs

Games

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:

solidity
function quoteFee(uint256 n) public view returns (uint256) {
    return flatFee + perRecipientFee * n;
}
CurrentHard cap (owner cannot exceed)
flatFee1 QUAIMAX_FLAT_FEE = 10 QUAI
perRecipientFee0.05 QUAIMAX_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

GasSource
3 recipients, QUAI203,936measured on a real mainnet transaction
500 recipients, QUAI≈ 4.62MHardhat benchmark, repo compiler settings — not a live Quai measurement
500 recipients, standard ERC-20≈ 5.61MHardhat benchmark, repo compiler settings — not a live Quai measurement
Quai block gas limit50Mfor 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

  1. 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.

  2. Pick the asset — QUAI, or an ERC-20 token's contract address. For a token, the page reads symbol(), decimals() and your live balanceOf directly from the chain.

  3. Add recipients, one of:

    • Paste address,amount rows, or address only 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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 true fails 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/nextOffset contract (live since Labs 1.4.0); if a response doesn't include nextOffset, 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 address0x00151800430B518909Ab5368a8115eE84eC1B72f
Deploy transaction0x00690025dd0bb48945c018f4c6700656af76947bb1291762bf592cf612539ecc
Orchard rehearsal (testnet, chain 15000 — rehearsal only, never production)0x003703Bc620888fC137B807C36e12Cf51A76d17B
solidity
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 pendingOwner

Events: 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 at MAX_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, setFeeRecipient and the ownership functions keep working while paused.
  • transferOwnership / acceptOwnership — two-step: the current owner nominates pendingOwner, and only that address calling acceptOwnership() 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".

Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.