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
| Current | Hard cap (owner cannot exceed) | |
|---|---|---|
Creation fee (createFee) | 5 QUAI | MAX_CREATE_FEE = 50 QUAI |
Per-claim fee (claimFee) | 0.05 QUAI | MAX_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 |
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
| Gas | Source | |
|---|---|---|
claim | ≈ 103,000–128,000 | measured on Orchard with real transactions |
claimFor, 50-leaf batch | ≈ 1.23M | measured on Orchard with real transactions |
createCampaign | ≈ 268,000 | measured on Orchard with real transactions |
reclaim | ≈ 79,000 | measured 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
Connect — Pelagus (desktop) or Blip (mobile), on Quai Mainnet (chain 9).
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.
Add recipients, one of:
- Paste
address,amountrows, 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.
- Paste
Build the Merkle root — computed client-side from exactly the reviewed rows (a progress bar covers large lists).
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.Approve exactly — ERC-20 campaigns only, for exactly the campaign total, never open-ended; native QUAI needs no approval.
Fund —
createCampaignescrows 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.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
- Open the shared link, or paste the campaign ID into Claim / review.
- 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.
- 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. - 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.
- 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. leafCounthas no on-chain ceiling.createCampaignonly 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/leavesrecomputes 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 withRetry-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
pendingFeesfallback 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 address | 0x000a15D7542e77B69a4F1cF57D1fA8e889DED23E |
| Deploy transaction | 0x0028007fa24669cb9947c8fab982c8b8dccbec42c720dfb9d973dd2a0263e7e5 |
| Orchard rehearsal (testnet, chain 15000 — rehearsal only, never production) | 0x0048BCbf3aF64DB6CF3E6D5C26cEa2EB7Db203ca |
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 pendingOwnerEvents: CampaignCreated, Claimed, Reclaimed, FeesUpdated, FeeRecipientUpdated,
FeesAccrued, FeesWithdrawn, Paused, Unpaused, OwnershipTransferStarted,
OwnershipTransferred.
Owner controls, and their limits:
setFees— capped on-chain atMAX_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— blockscreateCampaign,claimandclaimForonly.reclaimandwithdrawFeesare 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 nominatespendingOwner, and only that address callingacceptOwnership()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 string | Where | Meaning |
|---|---|---|
"not owner" | any owner-only function | caller isn't the current owner |
"reentrant" | any state-changing function | reentrancy guard tripped |
"paused" | createCampaign, claim, claimFor | contract is paused; reclaim/withdrawFees are unaffected |
"zero amount or leaves" | createCampaign | totalAmount or leafCount is zero |
"zero root" | createCampaign | merkleRoot is bytes32(0) |
"bad expiry" | createCampaign | expiry isn't in (now, now + 90 days] |
"metadata too long" | createCampaign | metadataURI is over 200 bytes |
"bad token" | createCampaign | token is this contract, or (if not the zero address) has no code |
"incorrect value" | createCampaign | msg.value doesn't exactly equal quoteCreate's expected value |
"id exists" | createCampaign | campaign ID collision (practically unreachable — IDs are derived from creator + per-creator nonce) |
"insufficient fee" | claim, claimFor | msg.value is below the required fee (claimFee, or claimFee × n for a batch) |
"batch size" | claimFor | the batch is empty or over MAX_BATCH (50) |
"campaign closed" | claim, claimFor, reclaim | campaign doesn't exist, or is already closed |
"campaign expired" | claim, claimFor | past the campaign's expiry timestamp |
"invalid claim" | claim, claimFor | index 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, withdrawFees | target is the zero address or the contract itself |
"already claimed" | claim, claimFor | that leaf index's bit is already set |
"proof too long" | claim, claimFor | proof array is over 256 entries |
"invalid proof" | claim, claimFor | the recomputed Merkle root doesn't match the campaign's stored root |
"not creator" | reclaim | caller isn't the campaign's creator |
"not expired" | reclaim | called 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 check | the transfer/transferFrom call itself reverted |
"refund failed" | claim, claimFor | refunding the claimer's overpaid fee surplus failed |
"no fees" | withdrawFees | caller has no pendingFees to claim |
"withdraw failed" | withdrawFees | the native push to the given to address failed |
"fee cap" | setFees | requested createFee or claimFee exceeds its hard cap |
"not pending owner" | acceptOwnership | caller isn't the nominated pendingOwner |
"page limit" | campaignsByCreator | requested 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.