Build/Mining & pools

Mining & pools

Mining ZKas is mining Kaspa. Same kHeavyHash, same ASICs, same stratum, same difficulty maths. Exactly five things differ, and only three of them are mandatory.

Proof of workkHeavyHashidentical to Kaspa
Mandatory changes3of five differences
Block subsidy60 → 57ZKAS after 5% dev fee, until DAA 6,983,500; 100% to the miner from it
Stratum changesnonevardiff, shares, ASICs
Merged miningoptionalnative never turns off

Differences from Kaspa

  1. The payout address is shielded, version 9 mandatory You pass a zkas: address to getBlockTemplate exactly like a kaspa: one, but the resulting coinbase output script is a raw 43-byte Orchard address, which is not a standard script class. If your pool checks that a coinbase output "looks like P2PK/P2SH" before accepting a template, that check rejects every ZKas template. Remove it.
    crypto/addresses/src/lib.rs        Version::ShieldedOrchard = 9
    crypto/txscript/src/standard.rs    pay_to_address_script -> pay_to_shielded_address
    crypto/txscript/src/script_class.rs  ShieldedOrchard => ScriptClass::NonStandard
  2. Until DAA 6,983,500 the coinbase can carry a mandatory 5% dev-fee output mandatory (from that block, the security fork, the dev fee ends: the first block at or past it pays the last dev note, and no later coinbase has one) Submit the node's template coinbase verbatim. If you rebuild or rewrite it, you must reproduce the node's expected transaction byte for byte, dev-fee output included, or the block is rejected.
    consensus/src/processes/coinbase.rs   expected_coinbase_transaction()
  3. The coinbase payload has an extra 32-byte field mandatory ZKas inserts the shielded state root between subsidy and the script-pubkey block:
    Kaspa: blue_score(8) | subsidy(8) |                    spk_version(2) | spk_len(1) | spk | extra
    ZKas:  blue_score(8) | subsidy(8) | shielded_root(32) | spk_version(2) | spk_len(1) | spk | extra
    Any pool that writes its extranonce at a hardcoded offset will corrupt the payload. Shift by 32 bytes, or better, parse the payload rather than index into it.
  4. Merged mining with Kaspa optional One hash earns both KAS and ZKAS. Native mining always remains valid, there is no flag day for miners. Section below.
  5. Custodial payouts optional Only if you mine to your own treasury rather than direct-to-miner. There are no transparent UTXOs to sweep, so you need shielded payout tooling. Section below.
Do (1), (2) and (3) and you are mining ZKas. Because the PoW is byte-identical to Kaspa, your stratum layer, share validation, vardiff, job distribution and difficulty maths need no changes at all.

Standing up native mining

  1. Run a node
    kaspad --appdir=<dir> --rpclisten=127.0.0.1:16810 --utxoindex
    Add --enable-unsynced-mining only when bootstrapping a brand-new network with no peers. Never on a pool mining the live chain.
  2. Point your bridge at the node's gRPCgetBlockTemplate / submitBlock behave exactly as on Kaspa.
    GetBlockTemplateRequest {
      pay_address: "zkas:...",
      extra_data:  <your extranonce>
    }
    The node rejects a pay_address whose HRP does not match its network, invalid prefix … means you pointed a zkas: address at a Kaspa node, or a pre-rebrand node at a zkas: address.
  3. Hand the header to miners over stratum, unchangedkHeavyHash ASICs, Bitmain, IceRiver, Goldshell, GodMiner firmware, work as-is.
  4. Submit with submitBlockNative blocks need no aux data.
  5. Pay miners to zkas: addressesThe simplest correct pool is a direct pool: no coinbase override, so the template pays the coinbase straight to the address the finding miner authorized with, and the chain mints the reward as a shielded note to that miner. The pool never holds funds and there is nothing to sweep, claim or sign.

Solo miners can use the packaged solo gateway instead, which bundles the node, an optional Kaspa parent and the bridge.

Template caching

This silently destroyed ~80% of blocks on the reference pool before it was found.

Kaspa pools commonly cache one template and rewrite the miner's payout into it per worker, and the usual shortcut is "rewrite the last coinbase output". On ZKas (until the dev fee ends at DAA 6,983,500) the last coinbase output can be the dev-fee output, and the second-to-last may be the red-block reward. Rewriting outputs.last() therefore hijacks the dev fee, leaves the red reward stale, and every such block is rejected with:

"coinbase transaction is not built as expected"

Two correct options:

  • Re-point by scanning outputs in reverse and skipping the dev-fee output, it pays the dev recipient, a version-0 script whose first 43 bytes are the dev fund's Orchard address; or
  • simplest and always correct: call getBlockTemplate per miner and cache nothing.

Watch the node log for that message. A non-zero rate means you are losing blocks, not that miners are sending bad shares. The node's own modify_block_template() shows the correct shape: keep blue score, subsidy and the shielded commitment, and re-point only the miner's own outputs.

Merged mining with Kaspa (aux-PoW)

A ZKas block is valid if either holds:

Native
the block's own kHeavyHash header clears the block's ZKas target. Ordinary mining. This is the backbone, chain liveness never depends on Kaspa miners. Accepted at all times
Aux-PoW
the block carries proof that a parent kHeavyHash block did work clearing ZKas's own target and is cryptographically bound to this ZKas block

The aux proof is checked against ZKas's target from its own DAA retarget, not Kaspa's. Kaspa's target is far harder, so hashes that fail Kaspa routinely clear ZKas, merged mining adds ZKas security at essentially zero marginal cost. The verifier tries native first, then aux, and a valid native block can never be invalidated by a malformed or stripped aux proof because aux data is excluded from the block hash.

Binding chain

parent header a real Kaspa block hash_merkle_root leaf 0 = coinbase parent coinbase ZKMM ‖ H_zk ZKas block H_zk kHeavyHash(parent) ≤ ZKas target ← check (c) work check (b) inclusion check (a) binding H_zk excludes aux_pow, so it is stable whichever parent later carries it , and a parent committing to H_a can never be reused for H_b.
Verification runs right to left: the parent's work must clear ZKas's target, its merkle root must contain the coinbase, and that coinbase must commit exactly this block's hash.
  1. Build the ZKas block and compute H_zkH_zk is computed over the explicit header fields and excludes aux_pow, so it is stable no matter which parent later carries it.
    The single most common cause of a rejected merged block. H_zk commits the ZKas coinbase through hash_merkle_root, and the coinbase carries the mandatory dev-fee output. Use the template coinbase verbatim. If you rebuild it, the H_zk you committed will not equal the hash of the block the node validates, the binding check fails, and, since a merged block has no native PoW to fall back on, submitBlock returns block has invalid proof-of-work.
  2. Embed the commitment in the parent's coinbase payload (legacy form, valid only before DAA 6,983,500; from then use the ZKM1 form). In the miner-writable extra_data slot, the only field you can set in a real Kaspa block without modifying Kaspa. Exactly once; duplicate or ambiguous commitments are rejected, erring safe.
    MERGE_MINE_MAGIC (4 bytes, ASCII "ZKMM") || H_zk (32 bytes)
  3. Mine the parent header with kHeavyHash against ZKas's bits
  4. Submit the ZKas block with the aux proof attached
    AuxPow {
        parent_header:          Header,       // the mined parent
        parent_coinbase:        Transaction,  // carries ZKMM || H_zk
        coinbase_merkle_branch: Vec<Hash>,    // coinbase (leaf 0) -> root, max 64
    }

What the verifier checks

CheckAssertion
Bindingcommitted_hash(parent_coinbase) == H_zk
Inclusionfold(coinbase, branch) == parent_header.hash_merkle_root
WorkkHeavyHash(parent_header, nonce) <= ZKas target

The parent need not be a valid Kaspa block, only that real kHeavyHash work, bound to H_zk, cleared ZKas's target. Stealing another block's PoW fails: a parent committing to H_a cannot be reused for H_b.

From the security fork: the bound ZKM1 form mainnet DAA 6,983,500 · testnet from genesis

From mainnet DAA 6,983,500 (about 15 October 2026, ~15:00 UTC) every node accepts merged blocks only in the bound form below; the legacy ZKMM‖H_zk form is refused. Testnet runs these rules from its genesis, so a pool can test its implementation now. Native (non-merged) mining is unchanged.

  1. Take the ZKas template header and compute its nonce-free hash: the normal block hash with the nonce field set to 0 (timestamp and every other field as in the template): H_nf = header_hash(header with nonce = 0).
  2. Compute the commitment: C = BlockHash("zkas_aux_bind1" ‖ genesis_hash ‖ H_nf), where BlockHash is the block-hash function itself (Blake2b-256 keyed with "BlockHash") and genesis_hash is the ZKas network's genesis (mainnet and testnet differ).
  3. Put ZKM1 followed by the 64-character lowercase hex of C into the parent coinbase extra_data, exactly once. The parent must carry no ZKMM tag.
  4. Mine the parent against ZKas's bits as before.
  5. When the parent is solved, set the ZKas block's nonce to the first 8 bytes of the parent's header hash, read little-endian: nonce = u64_le(hash(parent_header)[0..8]). This changes the ZKas block hash but not H_nf, so the commitment stays valid. Then submit the ZKas block with the aux witness as before.
Check (post-fork)Assertion
Nonce bindingzkas_header.nonce == u64_le(hash(parent_header)[0..8])
Bindingthe parent coinbase carries ZKM1‖hex(C) exactly once and no ZKMM
Inclusion / Workunchanged
Own coinbasea ZKas coinbase may carry no ZKMM/ZKM1 tag (from DAA 6,897,100 on mainnet)

Reference implementation: miner/src/merged.rs build_aux_pow_bound in the node repository. Also from the fork: the dev-fee output disappears (miners keep 100%), and miner rewards accrue in one carried slot, paid as one note every ~100 DAA or when the payout script changes; read pendingRecipient/pendingValue from GetShieldedCoinbaseRewards for unpaid rewards. Submit the node's coinbase verbatim, as before.

Dual-chain operation

To earn KAS and ZKAS from one hash: pull the parent template from a real Kaspa node, embed the commitment (ZKMM‖H_zk before DAA 6,983,500, ZKM1 from it) in its coinbase extra_data, mine it against ZKas's bits, and submit the solved parent to both chains, to Kaspa as a normal block, to ZKas as an aux block. If the Kaspa node is unreachable or in IBD, fall back to a synthetic parent so ZKas blocks keep flowing; that round earns no KAS but the chain stays live.

In the reference bridge this is entirely environment-driven, no code changes:

ZKAS_MERGED_MINING=1                 # enable aux-PoW mode
ZKAS_KASPA_NODE=<host:port>          # a real Kaspa node gRPC
ZKAS_KASPA_PAY=kaspa:qz9v...         # where KAS rewards are paid

Tag formats in the wild: ZKMM<hash> (before the fork), ZKM1<commitment> (from DAA 6,983,500) and /zkas/…. Index all three if you are measuring merge-mining participation. The reference bridge (zkas-pool, solo-dual-mode v1.0.12+) switches to ZKM1 by itself at the fork score.

RPC and wire reference

Header field
Header.aux_pow: Option<Box<AuxPow>> (borsh; None = native). Excluded from hashing, so H_zk is unchanged by it
P2P
BlockHeader.auxPow = field 15, borsh bytes; empty means native
gRPC
RpcBlockHeader.auxPow = field 16. RpcRawHeader.aux_pow is a borsh-hex string
Constants
MERGE_MINE_MAGIC = b"ZKMM" (commitment = hex of H_zk, before the fork); MERGE_MINE_MAGIC_V1 = b"ZKM1" (commitment = hex of the bound commitment, from DAA 6,983,500)
Submit merged blocks via the RpcRawHeader / wRPC path. The plain gRPC RpcBlockHeader → Header conversion sets aux_pow = None and silently drops the proof. Native blocks are unaffected.

Payout

Direct, recommended

Run with no coinbase override. Every template pays the coinbase to the address the finding miner authorized with, and the chain mints it as a shielded note to that miner's own zkas: address. The pool never holds miner funds, there is nothing to claim, and no signature is ever needed. This is by far the simplest correct ZKas pool, and it is a full solo pool.

Custodial, understand what you are taking on

ZKas has no transparent UTXOs. You cannot sweep with Kaspa payout tooling; you need a wallet that builds Orchard transactions.

  • Reward discovery is easy. Coinbase mints are public, recipient and value are visible in GetShieldedBlocks with no viewing key, so you can watch your own treasury from a plain node stream. Use GetShieldedCoinbaseRewards for the income view, keyed by (coinbaseTxid, outputIndex).
  • Paying out is where the cost is. Each transaction spends at most 38 notes at ~2.4 core-seconds per note. A treasury accumulating one coinbase note per block accumulates 86,400 notes per day. Paying that out one note at a time is hours of CPU.
  • The fix is consolidation, not more cores. Merge many small notes into few large ones in the background so a payout spends 3 notes instead of 300. zkas-walletd --auto-consolidate is on by default with a ceiling of 500.
A coinbase pays its mergeset, not its finder. Matching "blocks I found" against "coins I received" will not reconcile. Attribute income from GetShieldedCoinbaseRewards over the recipients you care about.
Do not set a coinbase override on a live pool until your payout path is complete and end-to-end tested. Overriding redirects every reward to you with no machinery yet to pay it back out.

Full sizing model, measured numbers and the payout playbook: Wallets & walletd.

Error message → cause

coinbase transaction is not built as expected
You rebuilt or rewrote the coinbase and it does not match consensus. Almost always the dev-fee output, or a hardcoded payload offset that ignores the 32-byte shielded commitment. Applies to native blocks
block has invalid proof-of-work on a merged submission
The H_zk you committed into the parent coinbase is not the hash of the block you submitted, so the binding check failed and a merged block has no native fallback. Usually the same root cause: the coinbase you bound differs from the coinbase you submitted
invalid prefix zkas / invalid prefix kaspa
Address HRP mismatch between your pay address and the node's network
Aux proof accepted by your bridge but the node says native-only
Merged mining is gated on that network and the block's DAA score is below activation. Mine natively until then
Template fetch works, submission always fails, shares look fine
You cache and rewrite templates. See template caching
Hashrate looks right but block rate collapsed
Difficulty retargets over ~44 minutes. An abrupt hashrate exit strands the chain at too-high difficulty and stretches block times until it corrects

Source map

Source map: the exact files and symbols6 entries
Emission, dev fee, payload layout
consensus/src/processes/coinbase.rs, SUBSIDY_HALVING_INTERVAL_MONTHS, TAIL_SUBSIDY_*, dev_fee_cut(), expected_coinbase_transaction(), serialize/modify/deserialize_coinbase_payload(), calc_block_subsidy()
Shielded coinbase minting
consensus/src/processes/shielded.rs, build_coinbase_mint()
Merged mining
consensus/core/src/auxpow.rs, consensus/pow/src/auxpow.rs, verify_aux_pow(), check_pow_dual(), check_pow_gated(); consensus/core/src/hashing/header.rs proves aux is excluded
Template building
mining/src/block_template/builder.rs, modify_block_template()
Network parameters
consensus/core/src/config/params.rs, constants.rs, network.rs
Reference bridge
bridge/src/kaspaapi.rs (template fetch, submit, merged parent), merged.rs (aux assembly, dual submit), client_handler.rs (per-vendor stratum handshakes, job watchdog), share_handler.rs (validation, vardiff)

What joining does to difficulty, rewards and everyone else's income: merged-mining economics.

Verified against zkas-rusty at zkas-v1.0.9. If this page contradicts the code, the code is right. Report an error.