Hartii developer docs

Games

Hartii OTC Link (beta)

Hartii OTC Link lets a maker publish "X tokens for Y QUAI" from their own wallet — no deposit, no escrow — and a taker fill it atomically in one transaction, so two parties who already agreed on a trade over DM, Discord or Telegram can settle it on-chain without trusting each other or a middleman. It's for teams and traders doing OTC deals who want a non-custodial way to finish the trade, not a general-purpose order book.

Live at hartiibiome.com/otc — explicit beta: tested by the project's own test suite, not an independent audit. Use small trades first.

What it is, in one picture

The maker never deposits anything. They approve() the exact offer amount to the contract and call createOffer, which only records the offer — no tokens move. The contract holds nothing at rest; it only ever touches the maker's tokens at the moment a taker fills, pulling them straight from the maker's wallet into the taker's wallet with transferFrom, verified by an exact balance delta on both sides. QUAI the taker sends goes straight to the maker (and the fee to the treasury); any surplus is refunded. One fill, one offer, done — there are no partial fills and no second transaction on the happy path.

Fee model and caps

CurrentHard cap (owner cannot exceed)
Taker service fee (feeBps)50 (0.5% of the QUAI leg)MAX_FEE_BPS = 200 (2%)
Minimum offer (minOfferNotional)1 QUAIMAX_MIN_OFFER_NOTIONAL = 1,000 QUAI
Maximum offer expiry—MAX_EXPIRY = 30 days, fixed in code
offersByMaker page size—MAX_PAGE_SIZE = 500, fixed in code
solidity
function quoteFill(uint256 id) public view returns (uint256 totalDue, uint256 fee, bool likelyFillable);

The fee is 0.5% of the QUAI leg (amountWanted), rounded down, and is added on top for the taker: totalDue = amountWanted + fee. The maker always receives the exact notional they asked for — the fee never comes out of the maker's side. It is charged only on a successful fill; a cancelled or expired offer never pays a fee. likelyFillable reflects a live check of the maker's current balance and allowance plus pause/expiry state — the page surfaces this as a stale / unfillable status so you don't try to fill something that will revert.

The minimum (1 QUAI, provisional pending live gas calibration, capped at 1,000 QUAI) applies to the QUAI leg (amountWanted), not the token amount offered — it exists to keep spam offers off the board, not to set a price floor.

The owner can raise the fee with setFee(bps) (reverts "fee cap" above 200) or move the minimum with setMinOfferNotional(value) (reverts "minimum cap" above 1,000 QUAI) — both are on-chain hard caps, not configuration that can be loosened later. Operationally, the fee will not change for the first 30 days after mainnet launch; that's a stated commitment, not something the contract itself enforces.

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, which can't be set to the zero address or the contract itself.

Gas, in practice

GasSource
createOffer≈ 172,000–192,000measured on Orchard (testnet, chain 15000)
fillOffer≈ 126,000measured on Orchard
cancelOffer≈ 35,000measured on Orchard

At the roughly 27,000 gwei gas price observed during Orchard testing, a ~130,000-gas transaction costs on the order of 3.5 QUAI. Gas price moves with network conditions — the live page always shows its own estimate (with your chosen headroom, default 20%) before you sign, not this table. Approval, creation, filling and cancelling are each their own transaction and each pay their own gas.

How it works

Maker

  1. Connect — Pelagus (desktop) or Blip (mobile), on Quai Mainnet (chain 9). Links and transactions are hard-pinned to chain 9; the page does not offer a testnet mode.
  2. Pick the token — paste a standard ERC-20 contract address (or Load one already shown on the board). The page reads its symbol, decimals, your balance and your current allowance to the contract live.
  3. Set amounts — how many tokens you're offering, how much QUAI you want, an optional taker-only address (leave blank for anyone), and an expiry (1/3/7/14/30 days, 7 days by default, 30 days maximum, or no deadline at all).
  4. Approve exactly — the page requests allowance for exactly the offer amount, never an open-ended approval. This is its own transaction and moves no tokens.
  5. Create — createOffer records the offer on-chain. This also moves no tokens; the maker's tokens stay in the maker's wallet until someone fills.
  6. Share — a link (/otc.html?offer=<id>&chain=9&v=1), a QR code, or an Open in Blip button for sending straight to a mobile wallet's in-app browser.

Taker

  1. Open the link (or find the offer on the public board — any live offer is enumerable on-chain, even if the maker only ever shared it by DM).
  2. Review — exact amounts, the live service fee, gas estimate and raw calldata, plus a stale/unfillable check against the maker's current balance and allowance.
  3. Accept & swap — one signature. The contract pulls the maker's tokens, pays the maker their QUAI and the treasury its fee, and refunds any surplus you sent, all in the same transaction.

My offers

  • Cancel any of your own still-active offers at any time, including while the contract is paused — cancellation is never blocked.
  • Withdraw an unclaimed credit: if a QUAI push to you (as maker or as the treasury) failed during a fill, it's held as a withdrawable balance under your address rather than lost or retried automatically. withdraw(to) claims it, also available while paused.

Beta caveats

  • Not a guaranteed-execution order. Anyone eligible can fill an open offer first, the maker can cancel at any time, and an offer can go stale if the maker's balance or allowance changed since they created it — quoteFill's likelyFillable flag (shown on the page as stale / unfillable) reflects this live, but it's a snapshot, not a lock.
  • Hartii does not vet makers or tokens. Anyone can create an offer for any contract that looks like a standard ERC-20. Verify an offer came from someone you trust before filling it.
  • Taxed, rebasing, false-return and no-return tokens are unsupported. The contract checks the exact balance delta on the pull from the maker; anything that doesn't match reverts with "unsupported token" and the whole fill rolls back (gas may still be spent).
  • Legacy allowance resets. Some older ERC-20s refuse to raise an existing non-zero allowance directly. If your token behaves that way, reset the allowance to zero in your wallet first — the page will not silently submit a second approval for you.
  • ERC-20 ↔ ERC-20 is not supported in v1. Every offer is a standard ERC-20 token for native QUAI only; token-for-token is deferred.
  • Offers are always public, even one only ever shared by direct link or DM — there is no private order book.
  • The fee at fill time is whatever feeBps is then, not what it was when the offer was created; reading it live before you sign is the only way to know the exact total.

Contract facts

Mainnet address0x0053ad39062D08662C1fA88E9F61B9E1C5a2C9d9
Deploy transaction0x0057006294389278f177a8daa236ce77d37219e044ce6e136b080c9761b52d57
Orchard rehearsal (testnet, chain 15000 — rehearsal only, never production)0x002aB097a674261a379e69d359C6bE57b23463B9
solidity
function createOffer(address tokenOffered, uint256 amountOffered, uint256 amountWanted, address takerOnly, uint64 expiry) external returns (uint256 id);
function fillOffer(uint256 id) external payable;
function cancelOffer(uint256 id) external;
function quoteFill(uint256 id) public view returns (uint256 totalDue, uint256 fee, bool likelyFillable);
function offersByMaker(address maker, uint256 offset, uint256 limit) external view returns (uint256[] memory ids);
function withdraw(address payable to) external;         // pays msg.sender's own pendingFees + pendingPayout
function offers(uint256 id) external view returns (address maker, address tokenOffered, uint256 amountOffered, uint256 amountWanted, address takerOnly, uint64 expiry, bool active, bool filled);
function offerCount() external view returns (uint256);
function feeBps() external view returns (uint256);
function minOfferNotional() external view returns (uint256);
function paused() external view returns (bool);
function feeRecipient() external view returns (address);   // the treasury

// owner-only
function setFee(uint256 bps) external;                  // reverts "fee cap" above MAX_FEE_BPS (200)
function setMinOfferNotional(uint256 value) external;   // reverts "minimum cap" above MAX_MIN_OFFER_NOTIONAL
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: OfferCreated, OfferFilled, OfferCancelled, FeesUpdated, MinOfferNotionalUpdated, FeeRecipientUpdated, FeesAccrued, PayoutAccrued, Withdrawn, Paused, Unpaused, OwnershipTransferStarted, OwnershipTransferred.

Owner controls, and their limits:

  • setFee — capped on-chain at MAX_FEE_BPS (200 = 2%); reverts above it.
  • setMinOfferNotional — capped on-chain at MAX_MIN_OFFER_NOTIONAL (1,000 QUAI); reverts above it.
  • setFeeRecipient — redirects the treasury; cannot be set to the zero address or the contract itself.
  • pause / unpause — blocks createOffer and fillOffer only. cancelOffer and withdraw are never blocked by pause, by design — a maker can always get out of an active offer and anyone can always claim an unclaimed credit.
  • 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 partial fills, no ERC-20-for-ERC-20 offers (deferred to a future version), and no way for the owner to reach into an already-created offer or an already-completed fill. The contract never holds a maker's tokens at rest — it only ever moves them in the same transaction as a fill. 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"createOffer, fillOffercontract is paused; cancel/withdraw are unaffected
"bad token"createOffertoken is the zero address, this contract, or has no code
"zero amount"createOfferamountOffered or amountWanted is zero
"below minimum notional"createOfferamountWanted is under minOfferNotional
"bad expiry"createOfferexpiry isn't 0 and isn't in (now, now + 30 days]
"bad taker"createOffertakerOnly is the maker or the contract itself
"offer not active"fillOffer, cancelOfferoffer already filled or cancelled
"offer expired"fillOfferpast its expiry timestamp
"self fill"fillOffertaker is the maker
"not authorized taker"fillOfferoffer is restricted to a different address
"insufficient value"fillOffermsg.value is below the live totalDue
"insufficient allowance or balance"fillOffermaker's live balance or allowance no longer covers the offer
"unsupported token"fillOffer (internal transfer check)balance delta on pull/push didn't match exactly — taxed, rebasing, or non-returning token
"token transfer failed"fillOffer (internal transfer check)the transferFrom call itself failed
"refund failed"fillOffersurplus refund to the taker failed; whole fill reverts
"not maker"cancelOffercaller isn't the offer's maker
"no balance"withdrawcaller has no pendingFees/pendingPayout to claim
"withdraw failed"withdrawthe native push to the given to address failed
"bad recipient"constructor, setFeeRecipient, transferOwnership, withdrawtarget is the zero address or the contract itself
"fee cap"setFeerequested bps exceeds MAX_FEE_BPS (200)
"minimum cap"setMinOfferNotionalrequested value exceeds MAX_MIN_OFFER_NOTIONAL
"not pending owner"acceptOwnershipcaller isn't the nominated pendingOwner
"page limit"offersByMakerrequested limit exceeds MAX_PAGE_SIZE (500)

FAQ

Why did my fill revert? Most often the maker's balance or allowance changed since you loaded the offer — someone else filled it, the maker moved tokens, or the maker reduced the approval. Reload the offer; if it now shows stale / unfillable, it genuinely can't be filled as-is.

Can the maker pull the rug after I've reviewed an offer? Yes, in the sense that they can cancel it, spend the tokens elsewhere (which makes it unfillable), or reduce their allowance at any time before your fill transaction confirms — the contract never locks the maker's tokens. That's the trade-off of zero-deposit: nothing is ever custodied, but nothing is ever locked in for you either.

Can I do a token-for-token swap? Not in this version. Every offer is a standard ERC-20 for native QUAI; ERC-20-for-ERC-20 is deferred to a future release.

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 a fee push fails, it's held in that address's own pendingFees and can be withdrawn at any time, including while the contract is paused.

What happens if the maker's QUAI payout fails to send? It's credited to the maker's own pendingPayout and withdrawable at any time via withdraw(to) — the fill still completes and the taker still receives the tokens either way.

Is this audited? No independent audit. It's covered by the project's own test suite, including taxed/false-return token fixtures, reentrancy, and paused/cancel/withdraw interactions. Treat it as beta and start with small trades.

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.