Hartii developer docs

QuaiAxe

QaxeDepositFactory reference

QaxeDepositFactory (0x0000c6aE51b3698a2945D68696b59cf52dbB3a94) derives QuaiAxe deposit addresses, turns a deposit into a collected, vault-accounted balance, and exposes the batch entry points a keeper uses. It deployed and permanently owns one QaxeBurnVault — only this factory may call that vault's accountDeposit.

QaxeForwarder — the one-shot deposit contract

solidity
contract QaxeForwarder {
    constructor(address payable vault) payable {
        selfdestruct(vault);
    }
}

That's the entire contract. Its constructor runs once, immediately forwards the deployment transaction's entire value (which, per CREATE2 semantics, is also the address's existing balance when deployed with value: 0 — see below) into the vault via selfdestruct, and the account is erased in the same transaction: EXTCODESIZE on the address is 0 again immediately after, because creation and destruction happen in one transaction. The address is fully reusable for the next reward.

The forwarder's creation code is identical for every miner — it only closes over the vault address, which is the same for every deposit this factory manages. That means its hash, INIT_CODE_HASH, is a single constant:

solidity
function initCode() public view returns (bytes memory) {
    return abi.encodePacked(type(QaxeForwarder).creationCode, abi.encode(address(vault)));
}
// INIT_CODE_HASH = keccak256(initCode()), set once in the constructor

Live value: INIT_CODE_HASH = 0x93921cd2500d1f18799ed6c2728cfd6983ac9afde51abc838963cbfea5597dea — read directly from factory.INIT_CODE_HASH() on mainnet and confirmed to equal keccak256(factory.initCode()).

Address derivation (EIP-1014 CREATE2)

Everything that varies between miners lives in the salt:

text
salt = keccak256(abi.encode(payout, burnBps, nonce))
       // payout is forced to the zero address when burnBps == 10000 (100% burn — no payout wallet)

address = last20(keccak256(0xff ++ factory ++ salt ++ INIT_CODE_HASH))

Solidity, in the factory:

solidity
function saltOf(address payout, uint256 burnBps, uint256 nonce) public pure returns (bytes32) {
    return keccak256(abi.encode(payout, burnBps, nonce));
}

function addressOf(address payout, uint256 burnBps, uint256 nonce) public view returns (address) {
    return address(uint160(uint256(keccak256(abi.encodePacked(
        bytes1(0xff), address(this), saltOf(payout, burnBps, nonce), INIT_CODE_HASH)))));
}

Changing any field — the payout wallet, the burn share, or the nonce — changes the address completely, so nobody can collect a miner's balance under settings the miner didn't choose. The site's client-side derivation (quaiaxe/src/lib/deposit-address.js) mirrors this exactly and is cross-checked against the on-chain findAddress/addressOf — see integrate.

Grinding for a valid mining address

Not every CREATE2 prediction is usable. QuaiAxe requires the address to be:

  1. In the Cyprus-1 shard — first byte 0x00.
  2. On the Quai ledger, not the UTXO side — top bit of the second byte clear (byte1 & 0x80 == 0).
solidity
function isMiningAddress(address a) public pure returns (bool) {
    uint160 v = uint160(a);
    return (v >> 152) == 0 && ((v >> 144) & 0x80) == 0;
}

findAddress grinds nonce = start, start+1, … until it finds one, capped at MAX_GRIND = 4096 tries (reverts "No address in range" if none is found in range — vanishingly unlikely given the odds per try):

solidity
function findAddress(address payout, uint256 burnBps, uint256 start)
    external view returns (address depositAddress, uint256 nonce)

Off-chain clients grind the exact same way (same predicate, same cap) so they never need to call this on-chain — findAddress exists as the on-chain cross-check, and the Orchard rehearsal proves the two agree (scripts/orchard-rehearse-qaxe-v2.cjs, check 2).

Collecting a deposit

collect(address payout, uint256 burnBps, uint256 nonce, address refundTo) external returns (uint256 gross)

  1. Computes the deposit address and reads its balance (gross); reverts "Nothing to collect" if zero.
  2. CREATE2-deploys a QaxeForwarder at exactly that address; reverts "Forwarder not deployed" if the deployed address doesn't match the prediction (should never happen — a sanity check).
  3. Calls vault.accountDeposit(depositAddress, payout, burnBps, gross, refundTo, measuredGas) — the vault validates payout/burnBps itself and reverts the whole collection if they're invalid (e.g. burnBps outside [1000, 10000]).
  4. Emits Collected(depositAddress, payout, burnBps, nonce, gross).

measuredGas is this call's own gas spend up to that point, plus COLLECT_TAIL_GAS = 10000 (work outside the measurement: the vault call, the event, the return) and, when this transaction's top-level caller (tx.origin) is msg.sender — i.e. it's a plain, non-relayed call — the transaction's own base cost and calldata cost (TX_BASE_GAS = 21000 + _calldataGas(), which sums 4 gas per zero calldata byte and 16 per nonzero byte, matching the EVM's own intrinsic-gas pricing exactly). A contract that calls collect through a relay does not get that base-cost claim — only the transaction that actually paid it can claim it back. See burn-vault for how the vault turns this into an actual refund.

collectMany(Deposit[] calldata deposits, address refundTo) external returns (uint256 collected)

Batches many {payout, burnBps, nonce} entries. Each entry is collected through the external collectOne (callable only by the factory itself: require(msg.sender == address(this), "Only self")), wrapped in a try/catch — one entry's revert is isolated and reported via CollectFailed(depositAddress, reason), never aborting the rest of the batch. The transaction's shared base/calldata overhead is attributed once, to the first successful entry only.

Measured: a 100-entry batch of fresh full-burn deposits uses ≈12.2M gas (12,197,585 gas measured re-running test/QaxeDeposit.test.cjs locally), comfortably inside Quai's 50M block gas limit.

collectAndSweep(Deposit[] calldata deposits, uint256 minTokensOut) external returns (uint256 collected)

collectMany, refunded to msg.sender, followed by a sweep only if the vault is currently ready (vault.canSweep()) and minTokensOut != 0. The sweep attempt is wrapped in try/catch: a cooldown, a stale quote, or any other sweep-side revert is caught and reported as SweepSkipped(reason) — it never reverts the collections that already happened. This is the entry point a keeper normally calls: it collects everything it can and takes the sweep if and only if one is actually due.

Events

EventFields
CollecteddepositAddress (indexed), payout (indexed), burnBps, nonce, gross
CollectFaileddepositAddress (indexed), reason (raw revert bytes)
SweepSkippedreason (raw revert bytes)

Constants

ConstantValueMeaning
COLLECT_TAIL_GAS10000Factory work outside its own gas measurement (the vault call, event, return) — calibrated against real receipts.
TX_BASE_GAS21000The EVM's own transaction base cost, claimable only by the entry that actually paid it.
MAX_GRIND4096Cap on findAddress's nonce search.

Per-entry isolation and refunds

Every collect (standalone or inside a batch) is isolated: one hostile or misconfigured entry (bad burnBps, an empty balance, a payout contract that reverts) cannot take down a batch, and never takes the vault or factory's shared state down with it — the vault's own nonReentrant guard and checks-effects-interactions ordering hold per-entry. A refund is paid out of that deposit's own refundAllowance, bounded at 3% of its own gross — one deposit's collection can never be funded out of another deposit's principal.

QaxeLens — batch reads

QaxeLens (0x00472f13e471B97429a45bF1890187a8AB850E8f) is stateless — it holds no storage of its own and is not bound to any particular vault. Deploy it once, use it against any QaxeBurnVault:

solidity
function read(address vault, address[] calldata addrs) external view returns (
    uint256[] memory balances, uint256[] memory received, uint256[] memory contributed,
    uint256[] memory kept, uint256[] memory pending)

For each address in addrs: its raw wallet balance (what's sitting at the deposit address, uncollected), and the vault's own received/contributed/kept/pending for that address, in one call. This is what the site and keeper use to avoid one RPC round trip per deposit address — see integrate and keeper.

Orchard rehearsal results

scripts/orchard-rehearse-qaxe-v2.cjs proves, on a real (non-mainnet) Quai chain, everything Hardhat's in-process EVM cannot: real account-creation gas costs, a real self-destruct clearing code size to zero, and real Quai gas-pricing behavior. Last recorded run: PASS 12/12, covering:

  1. The factory-created vault (plain CREATE) is Cyprus-1-valid and correctly bound.
  2. Off-chain CREATE2 derivation == addressOf == findAddress, ground to a real Cyprus-1, Quai-ledger address.
  3. A plain 21,000-gas transfer lands on the no-code deposit address.
  4. collectMany deploys and self-destructs the forwarder in one transaction: code size is 0 again afterward, and the vault credits the deposit address correctly.
  5. The same address is funded and collected a second time, proving reuse.
  6. sweep() buys on a real Hartii curve and burns: token totalSupply drops by exactly the burned amount reported.
  7. Refund-over-cost never exceeds 1.0 for any measured operation (collect, repeat-collect, sweep).
  8. QaxeLens reads agree with direct contract reads.
  9. Vault solvency (balance >= feesOwed + totalPending + prepaid) holds throughout.

The rehearsal uses a throwaway token launched on Orchard (there is no live Hartii curve there), so the graduated-pool buy path is not exercised on Orchard specifically — that path is covered by the Hardhat suite against a real graduated curve fixture, matching mainnet QAXE's actual state.

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.