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
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:
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 constructorLive 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:
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:
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:
- In the Cyprus-1 shard — first byte
0x00. - On the Quai ledger, not the UTXO side — top bit of the second byte clear (
byte1 & 0x80 == 0).
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):
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)
- Computes the deposit address and reads its balance (
gross); reverts"Nothing to collect"if zero. CREATE2-deploys aQaxeForwarderat exactly that address; reverts"Forwarder not deployed"if the deployed address doesn't match the prediction (should never happen — a sanity check).- Calls
vault.accountDeposit(depositAddress, payout, burnBps, gross, refundTo, measuredGas)— the vault validatespayout/burnBpsitself and reverts the whole collection if they're invalid (e.g.burnBpsoutside[1000, 10000]). - 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
| Event | Fields |
|---|---|
Collected | depositAddress (indexed), payout (indexed), burnBps, nonce, gross |
CollectFailed | depositAddress (indexed), reason (raw revert bytes) |
SweepSkipped | reason (raw revert bytes) |
Constants
| Constant | Value | Meaning |
|---|---|---|
COLLECT_TAIL_GAS | 10000 | Factory work outside its own gas measurement (the vault call, event, return) — calibrated against real receipts. |
TX_BASE_GAS | 21000 | The EVM's own transaction base cost, claimable only by the entry that actually paid it. |
MAX_GRIND | 4096 | Cap 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:
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:
- The factory-created vault (plain
CREATE) is Cyprus-1-valid and correctly bound. - Off-chain CREATE2 derivation ==
addressOf==findAddress, ground to a real Cyprus-1, Quai-ledger address. - A plain 21,000-gas transfer lands on the no-code deposit address.
collectManydeploys and self-destructs the forwarder in one transaction: code size is0again afterward, and the vault credits the deposit address correctly.- The same address is funded and collected a second time, proving reuse.
sweep()buys on a real Hartii curve and burns: tokentotalSupplydrops by exactly theburnedamount reported.- Refund-over-cost never exceeds
1.0for any measured operation (collect, repeat-collect, sweep). QaxeLensreads agree with direct contract reads.- 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.