Hartii developer docs

Games

Hartii Claim (beta)

Hartii Claim lets a creator fund one time-limited campaign — native QUAI or a single standard ERC-20 — against a Merkle root of wallet allocations, then share one link; every eligible wallet claims its own allocation on its own gas, instead of the creator paying to send to each wallet one at a time. It's for airdrops, rewards and distributions aimed at wallets who may never all claim, where fronting gas for every recipient up front doesn't make sense.

Live at hartiibiome.com/claim — explicit beta: tested by the project's own test suite, not an independent audit. Start with a small campaign.

Claim vs Airdrop: which one to use

  • Hartii Airdrop pushes funds: the creator signs and pays gas for every batch (up to 500 wallets per transaction), so the whole distribution lands the moment the creator's own transactions confirm. Good for up to a few hundred wallets, or when you want it fully done in one sitting.
  • Hartii Claim pulls funds: the creator funds one pot and publishes a Merkle root; each wallet signs its own claim and pays its own gas (or has a relayer pay it via claimFor). Good for thousands of wallets, or when you don't want to front gas for recipients who may never claim — an unclaimed allocation just sits in the campaign until expiry, when the creator reclaims the remainder.

Fee model and caps

CurrentHard cap (owner cannot exceed)
Creation fee (createFee)5 QUAIMAX_CREATE_FEE = 50 QUAI
Per-claim fee (claimFee)0.05 QUAIMAX_CLAIM_FEE = 1 QUAI
Claims per claimFor batch—MAX_BATCH = 50, fixed in code
campaignsByCreator page size—MAX_PAGE_SIZE = 500, fixed in code
Campaign expiry—MAX_EXPIRY = 90 days, fixed in code
solidity
function quoteCreate(address token, uint256 total) public view returns (uint256 value, uint256 fee);

Funding a native QUAI campaign needs msg.value = createFee + totalAmount in the single createCampaign transaction. Funding a token campaign needs only msg.value = createFee; the token total is pulled separately via a prior exact approve. Creating a campaign requires this exact value ("incorrect value" otherwise) — but claiming is more forgiving: claim only requires msg.value >= claimFee, with any surplus refunded in the same transaction, and claimFor requires msg.value >= claimFee × n for an n-claim batch, refunded the same way.

The fee destination is not printed here — read it live with feeRecipient() on the contract (see Contract facts); it is referred to throughout as the treasury. The owner can redirect it with setFeeRecipient, and raise or lower both fees with setFees(creation, claiming) (reverts "fee cap" above either hard cap). Only the fee push to the treasury has a pending-balance fallback: it's bounded to 30,000 gas, and if it fails the amount is credited to pendingFees[treasury] instead of blocking the campaign, withdrawable any time via withdrawFees(to), including while paused. Payouts to claimers and creators do not have this fallback — see Beta caveats.

Gas, in practice

GasSource
claim≈ 103,000–128,000measured on Orchard with real transactions
claimFor, 50-leaf batch≈ 1.23Mmeasured on Orchard with real transactions
createCampaign≈ 268,000measured on Orchard with real transactions
reclaim≈ 79,000measured on Orchard with real transactions

These are Orchard (testnet) rehearsal numbers from real transactions; a mainnet gas smoke test is still pending. The live page always shows its own estimate (with your chosen headroom, default 20%) before you sign, not this table.

How it works

Creator

  1. Connect — Pelagus (desktop) or Blip (mobile), on Quai Mainnet (chain 9).

  2. Pick the asset — leave blank for native QUAI, or paste a standard ERC-20 address; the page reads its symbol, decimals and your live balance.

  3. Add recipients, one of:

    • Paste address,amount rows, or address-only rows with one shared amount above the box (the Split available balance helper divides up to 90% of your native balance, minus the creation fee, across however many unique address-only rows you've pasted — it's a starting point, not a gas guarantee; the page's live estimate is checked before you sign either way).
    • Upload a CSV (the page builds trees of up to 50,000 rows / 5 MB locally; Hartii's hosted leaves storage takes at most 10,000 rows — above that the page tells you up front to self-host the leaves file instead, see step 5).
    • Import holders of a HartiiLabs token — a paged snapshot from the Labs holders API, with one shared amount for everyone returned. Your own connected wallet is excluded from the snapshot (same as Airdrop). An indexed snapshot can be incomplete or stale; the page labels paging coverage explicitly.

    Duplicate rows for the same address are merged by adding their amounts; an invalid row (bad address, bad amount, malformed line) is flagged inline without blocking the valid rows from being reviewed.

  4. Build the Merkle root — computed client-side from exactly the reviewed rows (a progress bar covers large lists).

  5. Download a backup, then publish or verify leaves — either publish to Hartii's own storage (POST /api/claims/leaves, up to 10,000 leaves, 10 publishes per IP per hour, idempotent on retry) or point Self-hosted leaves URL at your own absolute HTTPS file. Either way, the page re-fetches and re-verifies the published file's root, campaign ID, total and leaf count against your local tree before letting you fund.

  6. Approve exactly — ERC-20 campaigns only, for exactly the campaign total, never open-ended; native QUAI needs no approval.

  7. Fund — createCampaign escrows the total (native QUAI travels with the funding transaction itself; a token is pulled via the prior approval) and pays the creation fee, in one transaction.

  8. Share — a link (/claim.html?campaign=<id>&chain=9&v=1), a QR code, an Open in Blip button, or your device's native share sheet. Campaign IDs aren't sequential — nextCampaignId(creator) is a hash of the creator's own address and their running campaign count, so two creators' campaigns never collide and an ID can't be guessed from a previous one.

Claimer

  1. Open the shared link, or paste the campaign ID into Claim / review.
  2. The page reads the campaign on-chain and independently re-verifies the published (or self-hosted) leaves file's root, ID, total and leaf count before showing anything as claimable — a leaves file that doesn't match the on-chain root is rejected, not trusted.
  3. If your connected wallet owns an unclaimed leaf, Claim allocation becomes available. Each claim costs the live claimFee (0.05 QUAI by default) plus gas; any surplus you send is refunded in the same transaction.
  4. One wallet signature per leaf. A wallet can hold more than one leaf in the same campaign (distinct leaf indices for the same address are independent, independently claimable allocations) — the page claims them one at a time.
  5. If the hosted leaves file is temporarily unavailable, use Recover from a leaves.json file to load the creator's own backup instead.

Relayer (claimFor)

Anyone can pay the gas for someone else's claim — claim always pays the committed account, never msg.sender, so a sponsor or relayer can cover gas without ever being able to redirect the allocation. claimFor(campaignId, claims[]) batches up to MAX_BATCH (50) claims into one transaction, paying claimFee × n up front with any surplus refunded. The batch is fully atomic: one invalid proof, already-claimed leaf, or failed delivery anywhere in it reverts every claim in the batch, including the fee — no bitmap bit is set and no partial payment survives. The live page claims one leaf per wallet action; claimFor is reachable only through the contract ABI, for your own relayer tooling.

Reclaim

Once a campaign's expiry has passed, only its creator can call reclaim(campaignId) — it pays out the exact tracked remaining balance to the creator and permanently closes the campaign (any further claim then reverts with "campaign closed"). Reclaim works even while the contract is paused.

A quirk right after expiry: for a short window the RPC node can still be simulating against a block that predates the expiry timestamp. On Quai this bites harder than a stale gas estimate — quais has to populate an access list via quai_createAccessList before it can even sign the transaction, and that simulation runs against the same lagging state, failing with Access list creation failed due to VM error: execution reverted. A fixed gas limit can't work around this; the transaction can't be built at all until the node catches up. The page handles it with an automatic retry: once its own latest-block read confirms the campaign has expired but the access-list simulation still reverts as "not expired," it shows "Expired — waiting for the chain to confirm, retrying…" and retries roughly every 10 seconds, for up to 5 minutes, before sending normally — only surfacing an error if that whole window elapses without the simulation passing.

Beta caveats

  • Only immutable, fee-less ERC-20s are safe. The contract checks exact balance deltas on every transfer in and out of itself — fee-on-transfer, rebasing and non-returning tokens are rejected at creation ("unsupported token"). But that check only runs at the moment of a transfer: a token whose admin turns on a transfer tax after a campaign is already funded will fail every remaining claim and the eventual reclaim, both with "unsupported token" — the escrowed balance for that one campaign is then stuck, with no rescue function to recover it. Other campaigns, funded in other tokens or QUAI, are unaffected; accounting is strictly per-campaign.
  • leafCount has no on-chain ceiling. createCampaign only requires it to be nonzero — the Merkle root, not the declared count, is what actually gates a claim. The page's tree builder handles up to 50,000 leaves / 5 MiB; hosted storage accepts at most 10,000 leaves — a larger campaign self-hosts its leaves file (point Self-hosted leaves URL at an absolute HTTPS link before publishing).
  • Hosted leaves storage is a convenience, not a guarantee. POST /api/claims/leaves recomputes the root server-side rather than trusting the upload (rejecting any mismatch), caps a file at 10,000 leaves (413 with self-host guidance), allows 10 publishes per IP per hour (429 with Retry-After), and writes to a Cloudflare KV namespace. Its same-origin check is defense-in-depth, not an abuse control. Republishing the same file is idempotent (200, not a new write), but there's no deletion, no expiry and no durability SLA. Keep the downloaded backup; a self-hosted URL works exactly the same way to the page.
  • Claim and reclaim payouts are not covered by the fee's pending-balance fallback. Only the treasury's fee push is bounded to 30,000 gas with a pendingFees fallback on failure. A payout to a claiming wallet, or a reclaim payout to the creator, uses a plain unbounded-gas value transfer and simply reverts the whole transaction if the recipient can't accept it — e.g. a creator contract that doesn't accept plain QUAI transfers can't reclaim native funds at all.
  • Approvals are exact. The page requests allowance for exactly the campaign total, never open-ended. Some older ERC-20s refuse to raise an existing nonzero allowance directly — reset to zero in your wallet first if yours behaves that way.
  • No rescue, no upgrade, no partial claims. Each leaf pays its exact committed amount or nothing; there's no proxy, no owner rescue function, and no way for anyone — including the owner — to reach into an existing campaign's escrow, bitmap or accounting.

Contract facts

Mainnet address0x000a15D7542e77B69a4F1cF57D1fA8e889DED23E
Deploy transaction0x0028007fa24669cb9947c8fab982c8b8dccbec42c720dfb9d973dd2a0263e7e5
Orchard rehearsal (testnet, chain 15000 — rehearsal only, never production)0x0048BCbf3aF64DB6CF3E6D5C26cEa2EB7Db203ca
solidity
struct Claim { uint256 index; address account; uint256 amount; bytes32[] proof; }

function createCampaign(address token, uint256 totalAmount, bytes32 merkleRoot, uint256 leafCount, uint256 expiry, string calldata metadataURI) external payable returns (uint256 id);
function claim(uint256 id, uint256 index, address account, uint256 amount, bytes32[] calldata proof) external payable;
function claimFor(uint256 id, Claim[] calldata claims) external payable;
function reclaim(uint256 id) external;
function quoteCreate(address token, uint256 total) public view returns (uint256 value, uint256 fee);
function isClaimed(uint256 id, uint256 index) public view returns (bool);
function nextCampaignId(address creator) public view returns (uint256 id);
function campaignsByCreator(address creator, uint256 offset, uint256 limit) external view returns (uint256[] memory ids);
function campaigns(uint256 id) external view returns (address creator, address token, uint256 totalAmount, uint256 remaining, bytes32 merkleRoot, uint256 leafCount, uint256 expiry, bool closed, string memory metadataURI);
function withdrawFees(address payable to) external;        // pays msg.sender's own pendingFees
function createFee() external view returns (uint256);
function claimFee() external view returns (uint256);
function paused() external view returns (bool);
function feeRecipient() external view returns (address);   // the treasury

// owner-only
function setFees(uint256 creation, uint256 claiming) 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: CampaignCreated, Claimed, Reclaimed, FeesUpdated, FeeRecipientUpdated, FeesAccrued, FeesWithdrawn, Paused, Unpaused, OwnershipTransferStarted, OwnershipTransferred.

Owner controls, and their limits:

  • setFees — capped on-chain at MAX_CREATE_FEE (50 QUAI) / MAX_CLAIM_FEE (1 QUAI); reverts "fee cap" above either.
  • setFeeRecipient — redirects the treasury; cannot be set to the zero address or the contract itself.
  • pause / unpause — blocks createCampaign, claim and claimFor only. reclaim and withdrawFees are never blocked by pause — a creator can always reclaim an expired campaign's remainder, and anyone can always withdraw their own accrued fee balance.
  • transferOwnership / acceptOwnership — two-step: the current owner nominates pendingOwner, and only that address calling acceptOwnership() completes the handover.
  • The owner is not printed here — read it live with owner() on the contract above.

What it cannot do: no proxy or upgrade path, no rescue or arbitrary-call function, no owner override of a campaign's escrow, bitmap or accounting, and no on-chain cap on leafCount (the Merkle root, not the declared count, is the real gate — see Beta caveats). Accounting is strictly per-campaign: remaining is tracked separately for every campaign, so one campaign can never pay out of another's escrow. Every state-changing function, including the owner-only ones, shares one reentrancy lock.

Revert reasons

Revert stringWhereMeaning
"not owner"any owner-only functioncaller isn't the current owner
"reentrant"any state-changing functionreentrancy guard tripped
"paused"createCampaign, claim, claimForcontract is paused; reclaim/withdrawFees are unaffected
"zero amount or leaves"createCampaigntotalAmount or leafCount is zero
"zero root"createCampaignmerkleRoot is bytes32(0)
"bad expiry"createCampaignexpiry isn't in (now, now + 90 days]
"metadata too long"createCampaignmetadataURI is over 200 bytes
"bad token"createCampaigntoken is this contract, or (if not the zero address) has no code
"incorrect value"createCampaignmsg.value doesn't exactly equal quoteCreate's expected value
"id exists"createCampaigncampaign ID collision (practically unreachable — IDs are derived from creator + per-creator nonce)
"insufficient fee"claim, claimFormsg.value is below the required fee (claimFee, or claimFee × n for a batch)
"batch size"claimForthe batch is empty or over MAX_BATCH (50)
"campaign closed"claim, claimFor, reclaimcampaign doesn't exist, or is already closed
"campaign expired"claim, claimForpast the campaign's expiry timestamp
"invalid claim"claim, claimForindex is out of range, amount is zero, or amount exceeds what's left in the campaign
"bad recipient"leaf account on claim/claimFor, setFeeRecipient, transferOwnership, withdrawFeestarget is the zero address or the contract itself
"already claimed"claim, claimForthat leaf index's bit is already set
"proof too long"claim, claimForproof array is over 256 entries
"invalid proof"claim, claimForthe recomputed Merkle root doesn't match the campaign's stored root
"not creator"reclaimcaller isn't the campaign's creator
"not expired"reclaimcalled before the campaign's expiry
"native transfer failed"internal payout (claim/claimFor, reclaim)a native-QUAI push to the leaf account or the creator failed — the whole transaction reverts, no pending-balance fallback
"unsupported token"token transfer check (createCampaign funding, claim/claimFor/reclaim payout)balance delta didn't match exactly, or balanceOf didn't return a plain uint256 — taxed, rebasing or non-returning token
"token transfer failed"token transfer checkthe transfer/transferFrom call itself reverted
"refund failed"claim, claimForrefunding the claimer's overpaid fee surplus failed
"no fees"withdrawFeescaller has no pendingFees to claim
"withdraw failed"withdrawFeesthe native push to the given to address failed
"fee cap"setFeesrequested createFee or claimFee exceeds its hard cap
"not pending owner"acceptOwnershipcaller isn't the nominated pendingOwner
"page limit"campaignsByCreatorrequested limit exceeds MAX_PAGE_SIZE (500)

FAQ

Why did my claim revert? Most often "already claimed" (that leaf index was already claimed — possibly by you, from another device) or "invalid proof" (the leaves file you're reading doesn't match the campaign's on-chain root; re-open the campaign link or ask the creator for the authoritative leaves.json). "campaign expired" and "campaign closed" mean the campaign is past its claim window or already reclaimed.

I have more than one allocation in the same campaign — do I claim once or several times? Once per leaf index. A duplicate address row from a CSV import is merged into one leaf when the creator builds the tree, but if the creator's source data genuinely lists you at more than one index (e.g. two separate reward reasons), each index is its own leaf and its own claim.

Can someone else pay the gas for my claim? Yes. claim always pays the committed account, not whoever sends the transaction, so a relayer, a friend or a batching service can cover gas (and the claim fee) through claimFor without ever being able to redirect your allocation.

What happens to unclaimed allocations? They stay in the campaign's escrow, tracked in remaining, until expiry (at most 90 days after creation). After that, only the creator can call reclaim to pull out exactly what's left — there's no automatic reclaim and no way for the owner or anyone else to do it on the creator's behalf.

Can the creator cancel a campaign early? No. There's no cancel function — only reclaim, and only after expiry. Pick a short expiry (as little as one day) if you want an early exit option.

Where do the fees go? To the contract's feeRecipient — read it live rather than trusting a cached value, since the owner can change it. It's referred to here as the treasury. If the bounded 30,000-gas fee push fails, it's credited to that address's own pendingFees and withdrawable at any time, including while paused. Payouts to claimers and creators don't have this fallback — see Beta caveats.

Is this audited? No independent audit. It's covered by the project's own test suite, including taxed/rebasing/ non-returning token fixtures, reentrancy, atomic-batch rollback and paused/reclaim/withdraw interactions. Treat it as beta and start with a small campaign.

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.