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.
Differences from Kaspa
- The payout address is shielded, version 9 mandatory
You pass a
zkas:address togetBlockTemplateexactly like akaspa: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
- 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()
- The coinbase payload has an extra 32-byte field mandatory
ZKas inserts the shielded state root between
subsidyand the script-pubkey block: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.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
- 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.
- 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.
Standing up native mining
- Run a nodeAdd
kaspad --appdir=<dir> --rpclisten=127.0.0.1:16810 --utxoindex
--enable-unsynced-miningonly when bootstrapping a brand-new network with no peers. Never on a pool mining the live chain. - Point your bridge at the node's gRPC
getBlockTemplate/submitBlockbehave exactly as on Kaspa.The node rejects aGetBlockTemplateRequest { pay_address: "zkas:...", extra_data: <your extranonce> }pay_addresswhose HRP does not match its network,invalid prefix …means you pointed azkas:address at a Kaspa node, or a pre-rebrand node at azkas:address. - Hand the header to miners over stratum, unchangedkHeavyHash ASICs, Bitmain, IceRiver, Goldshell, GodMiner firmware, work as-is.
- Submit with
submitBlockNative blocks need no aux data. - 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
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
getBlockTemplateper 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
- Build the ZKas block and compute
H_zkH_zkis computed over the explicit header fields and excludesaux_pow, so it is stable no matter which parent later carries it.The single most common cause of a rejected merged block.H_zkcommits the ZKas coinbase throughhash_merkle_root, and the coinbase carries the mandatory dev-fee output. Use the template coinbase verbatim. If you rebuild it, theH_zkyou 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,submitBlockreturnsblock has invalid proof-of-work. - Embed the commitment in the parent's coinbase payload (legacy form, valid only before DAA 6,983,500; from then use the
ZKM1form). In the miner-writableextra_dataslot, 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)
- Mine the parent header with kHeavyHash against ZKas's bits
- 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
| Check | Assertion |
|---|---|
| Binding | committed_hash(parent_coinbase) == H_zk |
| Inclusion | fold(coinbase, branch) == parent_header.hash_merkle_root |
| Work | kHeavyHash(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.
- 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). - Compute the commitment:
C = BlockHash("zkas_aux_bind1" ‖ genesis_hash ‖ H_nf), whereBlockHashis the block-hash function itself (Blake2b-256 keyed with"BlockHash") andgenesis_hashis the ZKas network's genesis (mainnet and testnet differ). - Put
ZKM1followed by the 64-character lowercase hex ofCinto the parent coinbaseextra_data, exactly once. The parent must carry noZKMMtag. - Mine the parent against ZKas's bits as before.
- 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 notH_nf, so the commitment stays valid. Then submit the ZKas block with the aux witness as before.
| Check (post-fork) | Assertion |
|---|---|
| Nonce binding | zkas_header.nonce == u64_le(hash(parent_header)[0..8]) |
| Binding | the parent coinbase carries ZKM1‖hex(C) exactly once and no ZKMM |
| Inclusion / Work | unchanged |
| Own coinbase | a 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, soH_zkis unchanged by it- P2P
BlockHeader.auxPow= field 15, borsh bytes; empty means native- gRPC
RpcBlockHeader.auxPow= field 16.RpcRawHeader.aux_powis a borsh-hex string- Constants
MERGE_MINE_MAGIC = b"ZKMM"(commitment = hex ofH_zk, before the fork);MERGE_MINE_MAGIC_V1 = b"ZKM1"(commitment = hex of the bound commitment, from DAA 6,983,500)
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
GetShieldedBlockswith no viewing key, so you can watch your own treasury from a plain node stream. UseGetShieldedCoinbaseRewardsfor 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-consolidateis on by default with a ceiling of 500.
GetShieldedCoinbaseRewards over the recipients you care about.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-workon a merged submission- The
H_zkyou 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.rsproves 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.
zkas-rusty at zkas-v1.0.9. If this page contradicts the code, the code is right. Report an error.