# FCMP++ Full-Chain Membership Proofs — Specification > **Last updated:** 2026-09-04 (§7 step 2 anchor source reconciled — CEN-I12; step 1 rationale's retired staked arm removed — CEN-L12) > > **Parent document:** `docs/POST_QUANTUM_CRYPTOGRAPHY.md` ## Purpose This document is the comprehensive technical reference for Shekyl's FCMP++ (Full-Chain Membership Proofs) implementation. It covers the cryptographic structure, consensus rules, database schema, wallet integration, and performance characteristics. FCMP++ replaces ring signatures entirely. Every spend proves membership in the *entire* UTXO set without revealing which output is being spent. Combined with Shekyl's hybrid post-quantum spend authorization, this gives every transaction full-UTXO-set anonymity with quantum-resistant ownership — a combination no other cryptocurrency offers. This is the consensus-critical reference for implementors working on FCMP++ verification in `src/cryptonote_core/blockchain.cpp` and the Rust FFI layer in `rust/shekyl-fcmp/`. --- ## 1. Curve Tree Structure The FCMP++ anonymity set is the entire UTXO set, represented as a **curve tree** — a Merkle-like hash tree built over an elliptic curve cycle. ### Helios/Selene Alternating Layers The tree alternates between two elliptic curve groups: | Layer | Curve | Role | |-------|-------|------| | Leaves (layer 0) | Selene | 4-scalar tuples representing outputs | | Layer 1 | Helios | Hashes of Selene leaf groups | | Layer 2 | Selene | Hashes of Helios layer-1 groups | | Layer 3 | Helios | ... | | ... | alternating | ... | | Root | depends on depth | Single hash committing to the entire tree | **Helios** and **Selene** form a prime-order curve cycle (each curve's scalar field is the other's base field), enabling efficient recursive hash computations. The alternating structure allows the zero-knowledge proof to "step through" the tree without revealing which path was taken. The tree is **append-only**: outputs enter the tree via **deferred insertion** — they are added to a pending table at creation time and drain into the tree only after their type-specific maturity height is reached. The tree position assigned during drain is determined by the canonical `(maturity_height, global_output_index)` sort order, enforced by composite keys in LMDB. Tree position is **not** the same as global output index; explicit bidirectional mapping tables (`output_to_leaf`, `leaf_to_output`) track the relationship. Spent outputs remain in the tree permanently — removing them would reveal which output was spent, breaking the anonymity guarantee. ### 4-Scalar Leaf Format Each UTXO occupies one leaf in the tree. A leaf is a 4-scalar tuple (128 bytes): ```text Leaf = { O.x, I.x, C.x, H(pqc_pk) } ``` | Scalar | Source | Meaning | |--------|--------|---------| | `O.x` | x-coordinate of output public key | Identifies the output | | `I.x` | x-coordinate of key image | Prevents double-spending | | `C.x` | x-coordinate of Pedersen commitment | Binds the hidden amount | | `H(pqc_pk)` | `shekyl_fcmp_pqc_leaf_hash(ml_dsa_pk)` | Binds the ML-DSA-65 public key | The 4th scalar (`H(pqc_pk)`) is Shekyl-specific. It cryptographically binds the post-quantum public key to the curve tree leaf, creating the foundation for dual-layer security. Upstream Monero's FCMP++ uses a 3-scalar leaf; Shekyl extends this to 4 scalars. **x-only representation:** All three point scalars (`O.x`, `I.x`, `C.x`) are the x-coordinates only — y-coordinates are not stored in the tree or included in the flat leaf array. The circuit recovers y from x via the curve equation inside its `on_curve` gadget. This means a single output's leaf data is exactly 4 field elements (128 bytes), not 6 or 8. The hash function for the 4th scalar uses Blake2b-512 with domain separator `shekyl-pqc-leaf`, implemented once in `rust/shekyl-crypto-pq/src/derivation.rs` (`hash_pqc_public_key`, the SA-3a single source; `shekyl_fcmp::leaf::PqcLeafScalar` wraps it) and exposed over FFI via `shekyl_fcmp_pqc_leaf_hash()`. --- ## 2. Dual-Layer Security Model FCMP++ transactions achieve quantum-resistant spend authorization through two independent but linked layers. Both must hold for a spend to be valid. ### Layer 1: FCMP++ Membership Proof (In-Circuit PQC Commitment) The FCMP++ proof is a zero-knowledge argument that the prover knows openings to leaves in the curve tree whose 4th scalars are the `H(pqc_pk)` values supplied as public inputs. The proof does not reveal which leaves are spent — the entire UTXO set serves as the anonymity set. The `H(pqc_pk)` values are passed to `shekyl_fcmp_verify()` as the `pqc_pk_hashes_ptr` parameter. The proof succeeds only if the prover committed to leaves containing those exact hashes. ### Layer 2: Per-Input PQC Signature (Authorization) Each input carries a `PqcAuthentication` structure containing a hybrid Ed25519 + ML-DSA-65 signature over a canonical payload. The signature proves that the signer possesses the ML-DSA-65 secret key corresponding to the `pqc_pk` whose hash was proven in-circuit. ### Security Guarantee Together, the two layers guarantee: - **Layer 1** proves: "the spent output exists in the tree and its PQC public key hash is `H(pqc_pk)`" (anonymous, zero-knowledge). - **Layer 2** proves: "the signer knows the secret key for `pqc_pk`" (non-interactive, binding). An attacker who breaks only EC discrete log cannot forge the ML-DSA-65 signature. An attacker who breaks only ML-DSA cannot forge the FCMP++ curve tree membership proof (which is classical). The hybrid construction requires both to be compromised simultaneously. --- ## 3. Proof Format ### FCMP++ Membership Proof The proof blob (`fcmp_pp_proof` in `CtSigPrunable`) is an opaque byte array produced by the Rust prover (`shekyl_fcmp::proof::prove`), reached in production through `shekyl_sign_fcmp_transaction`. It encodes: - Generalized Schnorr Protocol (GSP) transcripts for each input - Curve tree path commitments across Helios/Selene layers - Key image linkability proofs - Pseudo-output balance commitments The proof verifier (`shekyl_fcmp_verify()`) checks: | Property | What is verified | |----------|-----------------| | Membership | Referenced leaves exist in the curve tree at `tree_root` | | Key images | Match the key images in the transaction inputs | | Pseudo outputs | Match the Pedersen commitments (balance proof) | | PQC binding | `H(pqc_pk)` values match the committed 4th leaf scalars | ### Proof Size The proof size scales with the number of inputs and the tree depth: | Inputs | Estimated proof size | |--------|---------------------| | 1 | ~2.5 KB | | 2 | ~4.5 KB | | 4 | ~8.5 KB | | 8 (max) | ~16.5 KB | --- ## 4. Transaction Format ### CTTypeFcmpPlusPlusPqc (type = 1) Shekyl's only non-coinbase transaction type. Defined in `ct_types.h`. ```text TransactionV3 { prefix: TransactionPrefixV3 ct_signatures: CtSig { type: CTTypeFcmpPlusPlusPqc // = 1 txnFee: u64 ecdhInfo: [EcdhTuple] outPk: [key] referenceBlock: hash // block hash anchoring curve tree snapshot pseudoOuts: [key] // in CtSigPrunable bp_plus: [BulletproofPlus] // range proofs (unchanged) curve_trees_tree_depth: u8 // tree depth at referenceBlock fcmp_pp_proof: bytes // opaque FCMP++ proof blob } pqc_auths: [PqcAuthentication] // one per input; hybrid Ed25519 + ML-DSA-65 } ``` **Only two CT type values exist.** The `ct_types.h` enum contains `CTTypeNull = 0` (coinbase only) and `CTTypeFcmpPlusPlusPqc = 1` (all non-coinbase spends). Legacy Monero types (`RCTTypeFull` through `RCTTypeBulletproofPlus`) are not defined; associated structs (`mgSig`, `clsag`, `rangeSig`, non-plus `Bulletproof`, `RCTConfig`, etc.) and ring / CLSAG signing and verification code have been removed from the codebase. The `CtSigBase` struct has no `mixRing` member. `CtSigPrunable` holds only `bulletproofs_plus`, `pseudoOuts`, `curve_trees_tree_depth`, and `fcmp_pp_proof`. The `serialize_ctsig_prunable` API has no `mixin` parameter. ### `tx_extra`: Hybrid KEM ciphertext tag (`0x06`) Outputs carry hybrid KEM material for per-output PQC key derivation. The field `tx_extra_pqc_kem_ciphertext` is tagged `TX_EXTRA_TAG_PQC_KEM_CIPHERTEXT` (`0x06` in `tx_extra.h`). The payload is a single `blob` whose length is **N × 1120** bytes: **N** concatenated hybrid ciphertexts, one per transaction output in **vout order**. Each 1120-byte entry is `x25519_ephemeral_pk[32] || ml_kem_768_ct[1088]` — the X25519 ephemeral public key followed by the ML-KEM-768 ciphertext (FIPS 203). Both components are required for correct hybrid KEM decapsulation. ### `tx_extra`: PQC leaf hash tag (`0x07`) Each output's derived ML-DSA-65 public key is hashed to produce a 32-byte `H(pqc_pk)` value (Blake2b-512 with domain separator `shekyl-pqc-leaf`, via `shekyl_fcmp_pqc_leaf_hash()`). These hashes are stored in the field `tx_extra_pqc_leaf_hashes`, tagged `TX_EXTRA_TAG_PQC_LEAF_HASHES` (`0x07` in `tx_extra.h`). The payload is a single `blob` of **N × 32** bytes: **N** concatenated 32-byte hashes, one per transaction output in **vout order**. The curve tree insertion code (`collect_outputs` in `blockchain_db.cpp`) extracts these hashes and commits them as the 4th leaf scalar. The field is **consensus-mandatory** (CEN-I19, ruled 2026-09-05): admission rejects any transaction with outputs that does not carry exactly one `0x07` of exactly `N × 32` bytes (and exactly one `0x06` of `N × 1120`), and the collector aborts rather than substituting a placeholder — on a v3-from-genesis chain there are no pre-feature outputs. (Until 2026-09-06 an absent or short field was zero-filled into the leaf: a leaf bound to nothing, unspendable, and a leaf set a faithful port would not have stored.) This field is emitted by both `construct_miner_tx` (coinbase) and `create_claim_transaction` (claim), and by the regular wallet transfer path. ### Coinbase KEM self-encapsulation Coinbase transactions do not carry `pqc_auths` (no real inputs to sign). Coinbase outputs still need a distinct per-output `H(pqc_pk)` in the curve tree. When `hard_fork_version >= HF_VERSION_FCMP_PLUS_PLUS_PQC` and the miner address includes a PQC encapsulation key, `construct_miner_tx` performs the same hybrid KEM encapsulation **to the miner’s own address** for each coinbase output as a transfer would: one 1120-byte hybrid ciphertext per output in the `0x06` blob, standard HKDF per-output derivation, shared secret wiped after use. This prevents all coinbase outputs to the same miner from sharing an identical `H(pqc_pk)` pattern (which would link rewards). Spending a matured coinbase then follows the normal recipient path (decapsulate from `tx_extra`, rederive per-output keys, sign with `pqc_auths` on the spend transaction). ### Transaction Hash Computation ```text tx_hash = cn_fast_hash(prefix_hash || base_ct_hash || pqc_auths_hash || prunable_hash) ``` `pqc_auths_hash` is `cn_fast_hash` of the canonical serialization of the full `pqc_auths` vector (see `cryptonote_format_utils.cpp`). Coinbase transactions omit PQC authorization fields; non-coinbase FCMP++ transactions must have `pqc_auths.size() == vin.size()`. The `prunable_hash` covers `fcmp_pp_proof`, `curve_trees_tree_depth`, `pseudoOuts`, and `BulletproofPlus` range proofs. --- ## 5. Block Header Commitment Each block header contains a `curve_tree_root` field (`crypto::hash`, 32 bytes) that commits to the state of the curve tree **at the block's own height** — after its parent connected and before the block's own drain: the state the block was built on, which the template reads at fill time, the node's per-height record stores under the block's height, the wallet client names as `drained_through = N − 1`, and the CT-2 KAT pins at height 61. The connect check compares the field against that state **at admission, before the block is added**; a mismatch rejects the block outright, never connect-then-pop. > **Ruled 2026-09-05 (fix authorized by Rick; census §7 #18).** This section > previously said "after processing all transactions in the block", a state > neither the template nor the client ever named, and the connect check > compared the field against the root read *after* the add — so the two > witnesses disagreed at every block where a leaf matures and every real > nettype would have halted at height 60 (the S1 of §7 #17). The check now > runs before the add against the tip root, on every nettype — the > FAKECHAIN skip around it was retired in the same PR once the test > generator computed real roots through the Rust curve-tree client. ### Serialization The field is always serialized (genesis-native, no version gating) in both the binary archive and Boost serialization. Initialized to `null_hash` at genesis. ### Block Template Creation `Blockchain::create_block_template` snapshots the current DB curve tree root into the header before mining begins. ### Block Validation `Blockchain::handle_block_to_main_chain` verifies that `curve_tree_root` matches the locally-computed tree root after `add_block` grows the tree. A mismatch rejects the block. ### RPC Exposure Every block-header reply carries `curve_tree_root` as a hex string. The wire type is `shekyl_rpc_types::BlockHeader` (`rust/shekyl-rpc-types/src/chain.rs`), shared by `get_block_header_by_height`, `get_block_header_by_hash`, `get_block_headers_range`, `get_last_block_header` and `get_block`. It replaced the C++ `block_header_response`, deleted in RK-5b — an implementer sent to that name would find nothing. ### `get_curve_tree_path` JSON-RPC The `get_curve_tree_path` endpoint returns Merkle authentication paths for one or more outputs. The wallet uses these paths to construct FCMP++ proofs. **Request:** `{ "output_indices": [uint64, ...] }` **Response:** ```json { "reference_block": "hex hash", "reference_height": 12345, "tree_depth": 3, "leaf_count": 4200, "paths": [...] } ``` Request is limited to 64 output indices per call (`MAX_OUTPUTS_PER_RPC_REQUEST`). Each `path_entry` contains a hex-encoded `path_blob` with the following binary layout: ```text Layer 0 (leaf layer): position[2] -- LE uint16, leaf index within the chunk leaf_scalars[N*128] -- all leaves in the chunk (N <= 38), 128 bytes each Layer 1..depth-1 (internal layers): position[2] -- LE uint16, child index within the parent chunk sibling_hashes[M*32] -- all children in the parent chunk (M <= chunk_width) ``` Chunk widths: 38 for Selene layers (even), 18 for Helios layers (odd). The verifier identifies the proven element by its position; all other entries in the chunk are authentication siblings. ### `get_curve_tree_info` JSON-RPC Returns the current curve tree state summary. **Request:** `{}` **Response:** `{ "root": hex, "depth": uint8, "leaf_count": uint64, "height": uint64 }` ### `get_curve_tree_checkpoint` JSON-RPC Retrieves a stored checkpoint at a specific block height (for fast-sync). Checkpoints are stored every `FCMP_CURVE_TREE_CHECKPOINT_INTERVAL` blocks. **Request:** `{ "block_height": uint64 }` **Response:** `{ "root": hex, "depth": uint8, "leaf_count": uint64, "block_height": uint64 }` Returns an error if no checkpoint exists at the requested height. --- ## 6. Per-Input Signed Payload Layout Implementation entry point: `cryptonote::get_transaction_signed_payload()` in `src/cryptonote_core/tx_pqc_verify.cpp` (declared in `tx_pqc_verify.h`). Each `pqc_auths[i]` signs a payload that commits to the full transaction state: ```text signed_payload_i = cn_fast_hash( serialize(TransactionPrefixV3) || serialize(CtSigningBody) || H(serialize(CtSigPrunable)) || serialize(PqcAuthHeader_i) || H(pqc_pk_0) || H(pqc_pk_1) || ... || H(pqc_pk_{N-1}) ) ``` `H(serialize(CtSigPrunable))` is `cn_fast_hash` of the serialized prunable data (`fcmp_pp_proof`, `pseudoOuts`, `curve_trees_tree_depth`, `BulletproofPlus`). This 32-byte digest directly binds the PQC signature to the FCMP++ proof, preventing an attacker from substituting different prunable data without invalidating PQC signatures. The final concatenation of **all** inputs' PQC public-key hashes binds each signature to the complete set of authorized keys, preventing key-substitution attacks where an attacker replaces one input's PQC key without invalidating other inputs' signatures. ### Coverage Analysis | Field | Covered via | Binding | |-------|-----------|---------| | `referenceBlock` | `CtSigningBody` (in `CtSigBase`) | Anchors the tree snapshot | | All key images | `TransactionPrefixV3` (in `vin`) | Prevents key image substitution | | `fcmp_pp_proof` | `H(CtSigPrunable)` in signed payload | Direct proof binding | | `pseudoOuts` | `H(CtSigPrunable)` in signed payload | Pseudo-output binding | | `curve_trees_tree_depth` | `H(CtSigPrunable)` in signed payload | Tree depth binding | | `BulletproofPlus` | `H(CtSigPrunable)` in signed payload | Range proof binding | | `H(pqc_pk)` values | `PqcAuthHeader_i` + all-inputs hash tail | Full PQC key binding | ### PqcAuthHeader Layout (Per-Input) ```text PqcAuthHeader_i { auth_version u8 scheme_id u8 flags u16 hybrid_public_key HybridPublicKey // for input i } ``` The `hybrid_signature` is excluded from the header (it is what is being computed). See `docs/POST_QUANTUM_CRYPTOGRAPHY.md` for the canonical encoding of `HybridPublicKey`. --- ## 7. Verification Order The following is the consensus-critical verification sequence for `CTTypeFcmpPlusPlusPqc` transactions in `Blockchain::check_tx_inputs`. Steps are ordered to fail fast on cheap checks before expensive proof verification. ### Step 0: Structural Pre-Checks - `tx.version == 3` - `tx.vout.size() >= 2` - `tx.vin.size() <= FCMP_MAX_INPUTS_PER_TX` - `tx.unlock_time < CRYPTONOTE_MAX_BLOCK_HEIGHT_SENTINEL` (Decision 13: timestamp-based unlock times are rejected in consensus) - All inputs are `txin_to_key` (no `txin_gen` except coinbase) - Key images are sorted and unique - No key image is already spent (double-spend check) ### Step 1: referenceBlock Validation ```text 1a. block_exists(rv.referenceBlock, &ref_height) must be true 1b. ref_height >= tip - FCMP_REFERENCE_BLOCK_MAX_AGE (not too old) 1c. ref_height <= tip - FCMP_REFERENCE_BLOCK_MIN_AGE (not too recent) ``` Constants from `cryptonote_config.h`: - `FCMP_REFERENCE_BLOCK_MAX_AGE = 100` (~3.3 hours at 2-minute blocks) - `FCMP_REFERENCE_BLOCK_MIN_AGE = 5` (reorg safety margin) > **Design rationale (MIN_AGE = 5):** Maturity is enforced by universal > deferred tree insertion: outputs only enter the curve tree after their > type-specific maturity period (coinbase: 60 blocks; every other output: 10 > blocks — the claim-era "staked" arm was retired with the confidential-staking > cutover and never existed in code, CEN-L12). MIN_AGE therefore only needs to > provide a reorg safety margin — 5 blocks (~10 minutes) is sufficient > to ensure the referenced tree state is stable. ### Step 2: Curve Tree State Lookup ```text 2a. tree_root = get_curve_tree_root_at(ref_height) 2b. tree_depth = get_curve_tree_depth_at(ref_height) 2c. rv.p.curve_trees_tree_depth == tree_depth ``` The tree root and depth at `referenceBlock` height anchor the proof. A mismatch in `curve_trees_tree_depth` is a consensus failure. **What the anchor is (ruled 2026-09-04, CEN-I12).** The anchor is the curve-tree state **at chain height `ref_height`**: the tree as it stands after `referenceBlock`'s *parent* connects and before `referenceBlock`'s own drain — a property of chain state, not of any one place that state is written down. Every existing keying names that one state: the reference block's header `curve_tree_root` was filled from it at template time (`blockchain.cpp:2006`), the per-height record stores it under key `ref_height` (written by the parent's connect under post-add keying, `blockchain_db.cpp:636`), the wallet client names it `drained_through = ref_height − 1` (`shekyl-curve-tree/src/client.rs:908`), and the CT-2 KAT pins it at height 61. Two witnesses of it therefore exist — the header field (the block's *attestation*) and the node's own per-height record — and CEN-B5 is the consensus check that binds them (§5): at admission, before the block's own drain, the header must equal the tip root. Until 2026-09-05 that binding held on no nettype — skipped on FAKECHAIN and, everywhere else, comparing the header against the root *after* the drain (census §7 #17, S1, fixed in §7 #18) — so the two witnesses agreed only because the template filled the header honestly. Today it holds on every nettype. One more reason the verifier reads its **own computed record**, never the header; a rewrite does the same in whatever form its store keeps that record. The prover holds no store and reads the header ([`CURVE_TREE_CLIENT.md`](design/CURVE_TREE_CLIENT.md) §3.3); today its only protection is its own leaf recompute against that header (`verify_root` refuses to build against a root it cannot reproduce), and B5 is the consensus-side binding of that header on every real nettype. The two reads name one value on every network, and B5 binds them on every one, so nothing makes the choice observable. *Reconciled 2026-09-04:* this paragraph previously narrated a header read the code stopped performing on 2026-04-13; the archaeology and the ruling are in [`CONSENSUS_RULE_CENSUS.md`](design/CONSENSUS_RULE_CENSUS.md) §7 #16. ### Step 3: Input Structural Checks (FCMP++ Specific) For each input `i` in `tx.vin`: ```text 3a. in_to_key.key_offsets.empty() == true (FCMP++ replaces ring members; no key offsets allowed) 3b. Key image y-normalization: bit 7 of byte 31 must be 0 (FCMP++ requires y-normalized key images) ``` ### Step 4: FCMP++ Proof Verification ```text proof = rv.p.fcmp_pp_proof key_images = [ tx.vin[i].k_image for i in 0..num_inputs ] pseudo_outs = rv.p.pseudoOuts pqc_hashes = [ shekyl_fcmp_pqc_leaf_hash(extract_ml_dsa_pk(pqc_auths[i])) for i in 0..num_inputs ] tree_root = (from Step 2a) tree_depth = rv.p.curve_trees_tree_depth result = shekyl_fcmp_verify( proof.data(), proof.size(), key_images_flat, num_inputs, pseudo_outs_flat, num_inputs, pqc_hashes_flat, num_inputs, tree_root, tree_depth ) ``` ### Step 5: PQC Commitment Cross-Check ```text for i in 0..num_inputs: ml_dsa_pk = extract_ml_dsa_component(pqc_auths[i].hybrid_public_key) computed_hash = shekyl_fcmp_pqc_leaf_hash(ml_dsa_pk) assert computed_hash == pqc_hashes[i] ``` Defense-in-depth check. May be omitted if pqc_hashes are computed directly from `pqc_auths` in the same code path. ### Step 6: Per-Input PQC Signature Verification ```text 6a. pqc_auths[i].auth_version == 1 6b. pqc_auths[i].scheme_id in {1, 2} (single-signer or multisig) 6c. pqc_auths[i].flags == 0 6d. Compute signed_payload_i 6e. shekyl_pqc_verify(scheme_id, hybrid_public_key, hybrid_signature, signed_payload_i) == true ``` ### Step 7: Bulletproof+ Range Proof Verification Standard BulletproofPlus verification for output range proofs, handled by `verCtSemanticsSimple` (batch verification of BP+ and pseudo-output sum checks) called from `ver_mixed_ct_semantics` in `tx_verification_utils.cpp`. --- ## 8. FFI Boundary (Rust ↔ C++) All FCMP++ cryptographic operations are implemented in Rust and called from C++ through the `shekyl-ffi` crate. ### Crate Architecture ```text rust/ ├── shekyl-encoding/ # Generic Bech32m blob encode/decode, proof HRP constants ├── shekyl-address/ # Network-aware segmented Bech32m address encoding ├── shekyl-fcmp/ # FCMP++ proof ops, curve tree, leaf hashing ├── shekyl-crypto-pq/ # PQC signing, KEM, derivation (re-exports shekyl-address) ├── shekyl-tx-builder/ # Native Rust tx signing: BP+, FCMP++, ECDH, PQC (replaces C++ FFI round-trips) ├── shekyl-ffi/ # C ABI exports (libshekyl_ffi.a) └── Cargo.toml # Workspace root ``` ### Key FFI Functions | C function | Rust source | Purpose | |-----------|-------------|---------| | `shekyl_sign_transaction()` | `shekyl-ffi/src/lib.rs` | Native Rust tx signing (BP+, FCMP++, ECDH, pseudo-outs) via `shekyl-tx-builder` | | `shekyl_fcmp_verify()` | `shekyl-ffi/src/legacy_fcmp.rs` | Verify FCMP++ proof | | `shekyl_fcmp_pqc_leaf_hash()` | `shekyl-ffi/src/lib.rs` | Hash ML-DSA-65 pubkey for leaf | | `shekyl_derive_pqc_leaf_hash()` | `shekyl-ffi/src/lib.rs` | Derive h_pqc from combined_ss (secret stays in Rust) | | `shekyl_derive_pqc_public_key()` | `shekyl-ffi/src/lib.rs` | Derive hybrid public key from combined_ss (secret stays in Rust) | | `shekyl_fcmp_outputs_to_leaves()` | `shekyl-ffi/src/lib.rs` | Convert outputs to 4-scalar leaves | | `shekyl_frost_sal_session_new()` | `shekyl-ffi/src/lib.rs` | Create FROST SAL session per input (Rust-only, `multisig` feature) | | `shekyl_frost_sal_get_rerand()` | `shekyl-ffi/src/lib.rs` | Get rerandomized output from session (Rust-only, `multisig` feature) | | `shekyl_frost_sal_aggregate_and_prove()` | `shekyl-ffi/src/lib.rs` | Aggregate FROST shares and produce FCMP++ proof (Rust-only, `multisig` feature) | | `shekyl_frost_coordinator_*()` | `shekyl-ffi/src/lib.rs` | Coordinator lifecycle: new, add_preprocess, nonce_sums, add_shares, aggregate (Rust-only, `multisig` feature) | | `shekyl_frost_signer_*()` | `shekyl-ffi/src/lib.rs` | Signer lifecycle: preprocess, sign (Rust-only, `multisig` feature) | | `shekyl_frost_sal_session_free()` | `shekyl-ffi/src/lib.rs` | Free FROST SAL session handle (Rust-only, `multisig` feature) | | `shekyl_frost_keys_import()` | `shekyl-ffi/src/lib.rs` | Import serialized FROST threshold keys (Rust-only, `multisig` feature) | | `shekyl_frost_keys_export()` | `shekyl-ffi/src/lib.rs` | Export serialized FROST threshold keys (Rust-only, `multisig` feature) | | `shekyl_frost_keys_group_key()` | `shekyl-ffi/src/lib.rs` | Extract 32-byte Ed25519T group key (Rust-only, `multisig` feature) | | `shekyl_frost_keys_validate()` | `shekyl-ffi/src/lib.rs` | Validate M-of-N params against threshold keys (Rust-only, `multisig` feature) | | `shekyl_frost_keys_free()` | `shekyl-ffi/src/lib.rs` | Free FROST threshold keys handle (Rust-only, `multisig` feature) | | `shekyl_pqc_verify()` | `shekyl-ffi/src/lib.rs` | Verify hybrid PQC signature | | `shekyl_kem_encapsulate()` | `shekyl-ffi/src/lib.rs` | Hybrid KEM encapsulation | | `shekyl_kem_decapsulate()` | `shekyl-ffi/src/lib.rs` | Hybrid KEM decapsulation | | `shekyl_address_encode()` | `shekyl-ffi/src/lib.rs` | Bech32m address encoding (network-aware) | | `shekyl_address_decode()` | `shekyl-ffi/src/lib.rs` | Bech32m address decoding (network-aware) | | `shekyl_encode_blob()` | `shekyl-ffi/src/lib.rs` | Generic Bech32m blob encoding with arbitrary HRP | | `shekyl_decode_blob()` | `shekyl-ffi/src/lib.rs` | Generic Bech32m blob decoding | ### C++ Header All non-multisig FFI declarations are in `src/shekyl/shekyl_ffi.h`. Functions use `#[no_mangle] pub extern "C" fn` in Rust and are declared with `extern "C"` linkage in the header. **Note:** FROST multisig FFI functions (marked "Rust-only" in the table above) exist in the Rust `shekyl-ffi` crate behind `#[cfg(feature = "multisig")]` but their C header declarations in `shekyl_ffi.h` have been removed. FROST multisig is consumed exclusively through the Rust wallet crates (`shekyl-engine-core`, `shekyl-wallet-rpc`), not through C++ code. ### Build Integration `cmake/BuildRust.cmake` compiles the entire Rust workspace into `libshekyl_ffi.a` (static library) with the `--locked` flag for reproducible builds. The static library is linked into C++ targets via `${SHEKYL_FFI_LINK_LIBS}`. ### FFI Invariants These invariants were established during the FCMP++ integration debugging and must be preserved by any code that touches the FFI boundary. 1. **`layers` (library) = `depth + 1` (LMDB).** LMDB stores a 0-indexed `tree_depth` where depth 0 means "leaves only, no intermediate layers" and depth 1 means "one Helios layer above Selene leaves." The upstream FCMP++ library's `layers` parameter is a 1-indexed count including the leaf layer. **C++ callers are responsible for the conversion:** they must pass `static_cast(lmdb_depth + 1)` to `shekyl_fcmp_verify`, which accepts `layers` directly and does not adjust. For `shekyl_sign_fcmp_transaction` the caller passes LMDB depth and the Rust signing path converts internally. (`shekyl_fcmp_prove` was the third function on this edge and was deleted 2026-08-22 with the legacy prove seam; the multisig witness path carries the same convention in `parse_prove_witness`.) 2. **Branch assembly must include all layers up to and including the root.** For a tree with `depth = D`, the witness must contain branch data for layers 1 through D inclusive. The loop condition is `layer <= depth`, not `layer < depth`. This applies to `assemble_tree_path_for_output` (test/RPC path assembly) and to any witness built for the multisig coordinator. (It also applied to `genRctFcmpPlusPlus`, deleted 2026-08-22.) 3. **LMDB stores raw curve points; the witness needs cycle scalars.** Each LMDB layer stores 32-byte hashes that are points on the layer's native curve (Selene for odd layers, Helios for even). When assembling branch data for the witness, these must be converted to scalars on the *parent* curve via `selene_point_to_helios_scalar` or `helios_point_to_selene_scalar` before insertion into the witness. 4. **`compute_leaf_count_at_height(H)` and `drain_pending_tree_leaves(H)` must agree.** Both answer "how many leaves are in the tree at height H." The canonical comparison is `maturity <= H` (not `maturity <= H + 1`). Any divergence between these two functions produces witnesses that hash to incorrect roots. 5. **FCMP++ key images are not y-normalized.** The key image `I = x * Hp(O)` is used as-is. Clearing the sign bit of byte 31 (Monero's `key_image_y_normalize`) breaks the Ed25519 batch verification because the prover computes `I` algebraically while the verifier would receive the modified value. 6. **PQC signing requires two phases.** `get_transaction_signed_payload` hashes all inputs' `hybrid_public_key` values. All public keys must be derived and placed into `tx.pqc_auths[i].hybrid_public_key` before any input is signed. A single-pass approach where key derivation and signing are interleaved produces payload hashes that don't match at verification time. --- ## 9. Database Schema (LMDB) The curve tree and related metadata are stored in five LMDB tables. ### Curve Tree Tables | Table | Key | Value | Purpose | |-------|-----|-------|---------| | `curve_tree_leaves` | `global_output_index` (u64) | 128-byte leaf data `{O.x, I.x, C.x, H(pqc_pk)}` | All UTXO leaves | | `curve_tree_layers` | `(layer_idx << 56 \| chunk_idx)` (u64) | 32-byte hash | Internal Helios/Selene layer hashes | | `curve_tree_meta` | key string (`"root"`, `"leaf_count"`, `"depth"`) | variable | Current tree state | | `curve_tree_checkpoints` | `block_height` (u64, MDB_INTEGERKEY) | `root[32] + depth[1] + leaf_count[8]` (41 bytes) | Periodic snapshots for fast sync | ### Transaction tables (pruned blob split) | Table | Key | Value | Purpose | |-------|-----|-------|---------| | `txs_pruned` | `tx_id` (u64) | prefix + `CtSigBase` only | Canonical pruned prefix | | `txs_pqc_auths` | `tx_id` (u64) | `pqc_auths` bytes (optional) | Split from `txs_pruned` so pruning can delete PQC auth data | | `txs_prunable` | `tx_id` (u64) | Bulletproofs+, FCMP++, pseudoOuts | Deleted after tx-data pruning | `get_pruned_tx_blob` / `get_tx_blob` concatenate `txs_pruned` + `txs_pqc_auths` (if present) + `txs_prunable` (if present). The in-memory `transaction::pqc_auths_offset` records the split point when serializing. ### Output Metadata Table | Table | Key | Value | Purpose | |-------|-----|-------|---------| | `output_metadata` | `global_output_index` (u64) | `output_pruning_metadata_t` (packed struct) | Wallet scanning after tx pruning | The `output_pruning_metadata_t` struct stores per-output scan data: output public key, Pedersen commitment, unlock_time, block height, and a pruned flag. This allows wallets to scan for owned outputs even after the full transaction data has been pruned. **Invariant (PQC restore):** `output_metadata` does **not** duplicate the `tx_extra` hybrid KEM ciphertexts (`TX_EXTRA_TAG_PQC_KEM_CIPHERTEXT`, `0x06`). Wallet restore and PQC key re-derivation still read those ciphertexts from the **transaction prefix** stored in `m_txs_pruned`. Any future “deep prune” mode must **not** delete or truncate `m_txs_pruned` in a way that removes `tx_extra` while leaving outputs discoverable, or ML-KEM ciphertexts needed for PQC material would be lost. ### Database API ```cpp // Curve tree state std::array get_curve_tree_root() const; uint8_t get_curve_tree_depth() const; uint64_t get_curve_tree_leaf_count() const; bool get_curve_tree_layer_hash(uint8_t layer, uint64_t chunk, uint8_t* hash_out) const; bool get_curve_tree_leaf(uint64_t global_output_index, uint8_t* leaf_out) const; // Checkpoints void save_curve_tree_checkpoint(uint64_t block_height); bool get_curve_tree_checkpoint(uint64_t block_height, std::vector& data) const; uint64_t get_latest_curve_tree_checkpoint_height() const; void prune_curve_tree_intermediate_layers(uint64_t checkpoint_height); // Output metadata (pruning support) void store_output_metadata(uint64_t global_output_index, const output_pruning_metadata_t& meta); output_pruning_metadata_t get_output_metadata(uint64_t global_output_index) const; bool is_output_pruned(uint64_t global_output_index) const; bool prune_tx_data(uint64_t depth = 0); // depth 0 → CRYPTONOTE_TX_PRUNE_DEPTH uint64_t get_last_pruned_tx_data_height() const; bool tx_has_verification_data(const crypto::hash& tx_hash) const; ``` --- ## 10. Per-Output PQC Key Derivation Every output has a unique PQC keypair derived deterministically from the combined KEM shared secret, enabling wallet restore from seed. ### Hybrid KEM (Unclamped Montgomery DH + ML-KEM-768) The sender performs a hybrid key encapsulation to derive a per-output shared secret. The classical component uses unclamped Montgomery DH over Curve25519 — **not** RFC 7748 X25519. The recipient's X25519 public key is not transmitted in the address; it is derived from the Ed25519 view public key via the canonical Edwards→Montgomery birational map. For the full specification of the X25519 derivation, unclamped DH semantics, and low-order point rejection rules, see `POST_QUANTUM_CRYPTOGRAPHY.md` §X25519 Binding to View Key and §DH Semantics. ```text 1. Montgomery DH: ss_classical = ephemeral_scalar * recipient_x25519_pk (unclamped; recipient_x25519_pk = EdwardsToMontgomery(view_pub)) 2. ML-KEM-768: ss_pq, ciphertext = ML-KEM-768.Encaps(recipient_ml_kem_pk) 3. Combined: shared_secret = HKDF-SHA-512( salt = "shekyl-kem-v1", ikm = ss_classical || ss_pq, info = "" ) ``` Recipients MUST reject low-order Montgomery points on `kem_ct_x25519` before performing DH (see `POST_QUANTUM_CRYPTOGRAPHY.md` §DH Semantics). The hybrid KEM ciphertexts are stored in `tx_extra` as `tx_extra_pqc_kem_ciphertext`: tag `TX_EXTRA_TAG_PQC_KEM_CIPHERTEXT` (`0x06`), field `blob` = concatenation of **N** 1120-byte hybrid ciphertexts (`x25519_ephemeral_pk[32] || ml_kem_768_ct[1088]`, N = number of outputs), in vout order. ### Per-Output Keypair Derivation From the combined shared secret, each output derives its own PQC keypair using the unified `OutputSecrets` HKDF stream (salt B = `shekyl-output-derive-v1`): ```text ml_dsa_seed = OutputSecrets.ml_dsa_seed // HKDF info: "shekyl-pqc-output" || output_index_le64 ml_dsa_keypair = ML-DSA-65.KeyGen(seed = ml_dsa_seed) ``` This is implemented in `rust/shekyl-crypto-pq/src/output.rs` as part of `construct_output` and `scan_output_recover`. The FFI functions `shekyl_construct_output` and `shekyl_scan_output_recover` return the PQC public key (and secret key for scan). For signing, `shekyl_sign_pqc_auth` derives the keypair internally from `combined_ss`, signs, and wipes — the ML-DSA secret key never crosses the FFI boundary. For public-key-only derivation (e.g., computing `h_pqc` for FCMP++ proofs or populating `tx.pqc_auths[i].hybrid_public_key` before signing), `shekyl_derive_pqc_leaf_hash` and `shekyl_derive_pqc_public_key` derive the keypair internally, extract only the public component, and zeroize the secret key — no secret material is returned. > **Note:** The legacy `shekyl_fcmp_derive_pqc_keypair` function has been > deleted. It used a separate HKDF salt A (`shekyl-pqc-derive-v1`) and > returned the ML-DSA secret key to C++. All callers have been migrated to > `shekyl_derive_pqc_leaf_hash` + `shekyl_sign_pqc_auth`. All `h_pqc` > leaf hashes from the pre-consolidation testnet are invalid. A testnet > reset is required. ### Wallet Restore from Seed Key derivation follows the frozen v1 pipeline implemented in `rust/shekyl-crypto-pq/src/account.rs` (the single source of truth; see the crate docstring for the full diagram and the `docs/V3_WALLET_DECISION_LOG.md` key-signature freeze entry for the rationale). In outline: ```text seed input mainnet/stagenet: BIP-39 24-word mnemonic (NFKD, PBKDF2-HMAC-SHA512 @ 2048 iters -> 64 B; passphrase opt-in, defaults to empty) testnet/fakechain: raw 32-byte seed master_seed_64 = HKDF-SHA-512(salt="shekyl-seed-normalize-v1", ikm=seed-input output, L=64) (the only secret the wallet file persists) salt = "shekyl-master-derive-v1--" spend_wide = HKDF-SHA-512(salt, ikm=master_seed_64, info="shekyl-ed25519-spend", L=64) view_wide = HKDF-SHA-512(salt, ikm=master_seed_64, info="shekyl-ed25519-view", L=64) d_z = HKDF-SHA-512(salt, ikm=master_seed_64, info="shekyl-ml-kem-768", L=64) spend_sk, view_sk = Scalar::from_bytes_mod_order_wide(...) (unclamped) ml_kem_768 (ek,dk) = keygen(ChaCha20Rng::from_seed( SHA3-256("shekyl-mlkem-chacha-seed" || d_z))) ``` All key material — classical and PQC — is therefore a pure function of the master seed, the network, and the seed format. On wallet open the full keypair set is rederived from `master_seed_64` via `shekyl_account_rederive`, and the recomputed classical address is checked against the wallet file's expected-address bytes (see `docs/WALLET_FILE_FORMAT_V1.md` for the failure taxonomy). On restore, the wallet re-scans the chain for owned outputs. Per-output PQC shared secrets are recomputed during the scan by decapsulating with the account-level ML-KEM decapsulation key; nothing per-output needs to be present in the wallet file for restore to succeed. (The legacy C++ `wallet2` scan path still caches a per-output combined shared secret in `transfer_details` as an implementation detail; that cache — and `wallet2.cpp` itself — is a deletion target at Phase 5 of `docs/design/WALLET_REWRITE_PLAN.md` and is not part of this specification.) --- ## 11. Bech32m Address Format Shekyl uses a segmented Bech32m address format to accommodate the large PQC key material while staying within the Bech32m checksum's proven error detection range. ### Format ```text shekyl1 / skpq1 / skpq21 ``` | Segment | HRP | Content | Max length | |---------|-----|---------|------------| | Classical | `shekyl1` | Version byte + spend pubkey + view pubkey | ~103 chars | | PQC part A | `skpq1` | First half of ML-KEM-768 public key | ~990 chars | | PQC part B | `skpq21` | Second half of ML-KEM-768 public key | ~990 chars | Each segment is independently Bech32m-encoded with its own checksum, keeping every segment under Bech32m's 1023-character proven detection limit. ### Display For human-readable display (QR codes, clipboard), only the classical segment is shown by default. The full address (all three segments) is used for machine-to-machine communication and is required for sending funds (the PQC segments carry the ML-KEM-768 encapsulation key; the X25519 public key is derived from the view key in the classical segment — see `POST_QUANTUM_CRYPTOGRAPHY.md` §X25519 Binding to View Key). ### Network HRPs Network discrimination is handled via the Human-Readable Part (HRP): | Network | Classical HRP | PQC part A HRP | PQC part B HRP | |---------|---------------|----------------|----------------| | Mainnet | `shekyl` | `skpq` | `skpq2` | | Testnet | `tshekyl` | `tskpq` | `tskpq2` | | Stagenet | `sshekyl` | `sskpq` | `sskpq2` | ### Implementation Address encoding lives in two standalone crates: - `rust/shekyl-encoding/` — generic Bech32m blob encode/decode with arbitrary HRPs. Also defines HRP constants for wallet proofs (`shekylspendproof`, `shekyltxproof`, `shekylreserveproof`, `shekylsig`, `shekylmultisig`, `shekylsigner`). - `rust/shekyl-address/` — network-aware segmented Bech32m address encoding. Depends on `shekyl-encoding`. Defines the `Network` enum, HRP lookup tables, and the `ShekylAddress` struct with `encode()` / `decode()` / `decode_for_network()`. `shekyl-crypto-pq` re-exports `shekyl-address` as its `address` module for backward compatibility. FFI exports: `shekyl_address_encode()`, `shekyl_address_decode()`, `shekyl_encode_blob()`, `shekyl_decode_blob()` — all in `rust/shekyl-ffi/src/lib.rs`, declared in `src/shekyl/shekyl_ffi.h`. Base58 has been fully removed from the C++ codebase. The address chokepoints (`get_account_address_as_str`, `get_account_address_from_str`) call the Rust FFI. Wallet proofs, message signatures, and signer keys use `shekyl_encode_blob` / `shekyl_decode_blob` with purpose-specific HRPs. There are no remaining Base58 code paths. --- ## 12. Checkpoint and Pruning Strategy ### Curve Tree Checkpoints The curve tree is checkpointed every `FCMP_CURVE_TREE_CHECKPOINT_INTERVAL` (10,000) blocks during `add_block`. Each checkpoint stores: ```text checkpoint = root[32 bytes] + depth[1 byte] + leaf_count[8 bytes] ``` Checkpoints are stored in the `curve_tree_checkpoints` LMDB table (MDB_INTEGERKEY, keyed by block height). **Purpose:** Fast-sync resumption. A syncing node can skip to the latest checkpoint and rebuild only the tree state from that point forward, rather than replaying the entire chain. ### Intermediate Layer Pruning `prune_curve_tree_intermediate_layers(checkpoint_height)` selectively removes intermediate layer entries (layers 1 through depth-2) whose chunk indices fall below the boundary implied by the previous checkpoint's `leaf_count`. Only chunks that are fully "sealed" by the previous checkpoint are deleted -- the current live layers, the leaf layer (layer 0), and the root layer are always preserved. Old checkpoint records (except the two most recent) are garbage-collected. Pruning is automatically triggered after each `save_curve_tree_checkpoint` call in `add_block`. Pruned layers can be recomputed on demand from the leaf data. ### Transaction Data Pruning `prune_tx_data(depth)` removes `txs_prunable` for transactions in blocks below `height - depth` (default `depth`: `CRYPTONOTE_TX_PRUNE_DEPTH` = 5000 when `depth == 0`). The prunable **hash** and the `txs_pqc_auths` slice stay: both are operands of the transaction's identity, and a pruned node that dropped them could no longer name what it kept. The complete body is served from shard archival (`docs/V3_STAKER_ARCHIVAL.md` set C), not from the pruned node. It stores `output_pruning_metadata_t` for each affected output, then deletes the prunable verification body. For RCT coinbase outputs, output lookups use amount `0` in the amount index (matching `add_transaction`), not the plaintext `vout.amount`. A `tx_prune_next_block` watermark in `m_properties` stores the first block height not yet processed (with one-time read of legacy `last_pruned_tx_data_height` as last-inclusive + 1) so runs are idempotent. If any expected transaction row is missing (`TX_DNE`) during a pruning batch, the batch now fails immediately and does not advance the watermark, preventing partial-prune state from being recorded as completed. `Blockchain::update_blockchain_pruning()` calls `prune_tx_data` when the node is in stripe-pruning mode so the chain prunes incrementally. The `--prune-blockchain` CLI flag triggers both stripe-based pruning and this tx-data pass at startup. --- ## 13. Performance Budget ### Transaction Size | Component | Per-input | Per-tx (2-in/2-out) | |-----------|----------|---------------------| | FCMP++ proof | ~2.5 KB | ~4.5 KB | | Pseudo outputs | 32 B | 64 B | | BP+ range proofs | — | ~1.5 KB | | `pqc_auths[i]` (single-signer, per input) | ~5.3 KB | ~10.6 KB | | `ecdhInfo` + `outPk` | — | ~256 B | | Prefix (vin, vout, extra) | — | ~0.5 KB | | **Total typical** | | **~17-18 KB** | ### Verification Time | Check | Time | Cacheable | |-------|------|-----------| | FCMP++ proof (per input) | ~35 ms | Yes | | BP+ range proofs (batched) | ~5 ms | Yes | | PQC auth (per input) | ~18 ms | Yes | | Structural checks | < 0.1 ms | No | | **Total (first verify)** | **~58 ms** (2-input) | | | **Total (cached, block inclusion)** | **~0.1 ms** | | ### Proof Generation Time (Wallet) | Scenario | Latency | |----------|---------| | Cold (first spend after restore) | ~60-90 seconds | | Precomputed paths (common case) | ~2-5 seconds | Wallet precomputation maintains tree paths for spendable outputs. When the user initiates a send and paths are precomputed, the remaining work is the GSP proof and PQC signing (~2-5 seconds). The Tauri GUI wallet shows a progress indicator for cold generation and triggers background precomputation on sync. --- ## 14. Verification Caching FCMP++ proof verification (~35 ms per input) is deterministic for a given `(proof, referenceBlock, key_images)` tuple. The mempool exploits this by storing a verification cache hash in `txpool_tx_meta_t`: ```text fcmp_verification_hash = cn_fast_hash(proof || referenceBlock || key_images) ``` ### Cache Fields Two fields were carved from the existing 76-byte padding in `txpool_tx_meta_t` (struct stays 192 bytes): | Field | Type | Purpose | |-------|------|---------| | `fcmp_verification_hash` | `crypto::hash` (32 bytes) | Deterministic cache key | | `fcmp_verified` | 1-bit flag | Whether verification has been cached | ### Cache Flow 1. **Mempool acceptance:** `tx_memory_pool::add_tx` stores the cache hash after successful FCMP++ verification. 2. **Block template / reorg:** `is_transaction_ready_to_go` checks the cached hash via `is_fcmp_verification_cached()`. If the recomputed hash matches and `fcmp_verified == 1`, it seeds `m_input_cache` to skip re-running `shekyl_fcmp_verify()`. 3. **Invalidation:** The cache is zeroed if the tx is removed and re-added to the pool, or if the tx blob changes. ### Impact Without caching, block validation cost scales as O(transactions x 58ms). With caching, the amortized cost for mempool-originated transactions approaches zero. Only transactions received directly in a block (not previously in the mempool) pay the full verification cost. --- ## 15. Staking and FCMP++ **Genesis disposition (2026-06 — transfer-shaped admission).** The leading genesis staking form ([`design/PHASE_2B_FSM_RETOOL.md`](design/PHASE_2B_FSM_RETOOL.md) §2.4) does **not** ship Decision **3C** below. Admission principal lives on the **main tree** as ordinary FCMP++ transfers (principal ↔ HKDF-derived **`P`** sub-wallet); only **bond** (gate 4) and **reward emission** are consensus-special. Cleartext `txout_to_staked_key` and the separate staking subtree are **deletion targets** for genesis — the subtree was **docs-only**, never implemented in production Rust/C++. **Historical — Decision 3C (confidential-claim path; not genesis):** Staked outputs (`txout_to_staked_key`) were designed to live in a **separate staking subtree** with a **160-byte / 5-scalar** leaf (Decision 3C — [`V3_STAKER_ARCHIVAL.md`](V3_STAKER_ARCHIVAL.md) §2, §6.4.3). The **main tree** (all non-staked outputs) keeps the **128-byte / 4-scalar** leaf, unchanged. ```text Staking-subtree leaf (160 B): [ 0: 32] O.x [ 32: 64] I.x [ 64: 96] C.x // C_stake = z·G + amount·H (plain Pedersen — no τ·H_t) [ 96:128] h_pqc = shekyl_fcmp_pqc_leaf_hash(ml_dsa_pk) [128:160] h_bind = H("stake-bind" ‖ tier ‖ creation_height) // consensus-set at inclusion ``` **Tier + creation** are bound by `h_bind` (5th scalar): consensus stamps exact `creation_height` at inclusion (the staker does not know mining height at build time), and the claim proves subtree membership + `h_bind` equality against the **revealed** `(tier, creation)`. The accrual window is then **pure arithmetic** (`creation < S ≤ creation + tier_lock`) — no historical-root checkpointing. **Implementation notes (Round 2):** - `SCALARS_PER_LEAF` generalizes from a const to a **per-tree parameter** (`=5` for the staking subtree, `=4` for the main tree); the 5th-position Selene generator is a **NUMS consensus constant** under the cbindgen consensus-constant guard, with a KAT. - The leaf-layer Selene MSM grows from `4·W` to `5·W` terms **for the staking subtree only** (~+25% on that hash); main-tree hashing and all Helios/Selene layers above the leaf are untouched. Off the wallet-scan/decap path (no block-loading impact). - The **locked witness header** `[O][I][C][h_pqc][x][y][z][a]` is **untouched** — `h_bind` is a public membership input recomputed by the verifier, **not** a witness field. - Stake-creation and unstake are **cross-tree transitions** (main↔subtree); `pop_block` rewinds both atomically. Leaves are append-only, so claim-after-unstake works. **Legacy note:** cleartext-claim code compared `H(pqc_pk)` on all output types; confidential claims require the staking-subtree `h_bind` binding above. No compatibility shim for pre-genesis implementations. **Universal deferred curve-tree insertion:** All outputs (coinbase, regular, and staked) are deferred: they enter a pending table at creation time and only drain into the curve tree once their type-specific maturity height is reached. Maturity heights are: - **Coinbase:** `block_height + CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW` (60) - **Regular:** `block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE` (10) - **Staked:** `max(effective_lock_until, block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE)` The `pending_tree_leaves` LMDB table uses a 16-byte composite key `BE(maturity_height) || BE(global_output_index)` with 128-byte leaf values (no DUPSORT — the composite key enforces canonical ordering). On each `add_block`, `drain_pending_tree_leaves` cursor-scans all entries with `maturity_height <= block_height` in `(maturity, output_index)` order, removes them from pending, journals each in `pending_tree_drain` (key: `BE(block_height) || BE(output_index)`, value: 136 bytes = 8-byte maturity + 128-byte leaf), writes the bidirectional mapping (`output_to_leaf`, `leaf_to_output`), and appends the leaf data to the curve tree growth batch. A separate journal `block_pending_additions` records each output added to pending by each block (key: `BE(block_height) || BE(output_index)`, value: 8-byte maturity). `pop_block` reads both journals to perform exact reversal: the drain journal restores drained leaves to pending and removes mapping entries; the block-pending journal deletes this block's pending additions by primary key — no reconstruction from the output DB state is needed. Because `FCMP_REFERENCE_BLOCK_MIN_AGE` (5) is now a reorg safety margin only (not a maturity enforcement mechanism), the tree is guaranteed to contain only matured outputs. **Lock-tier claim validation (`txin_stake_claim`) is retired.** Genesis staking does not use lock-tier claims. Archival reward emission is specified in [`design/REWARD_EMISSION_LEG.md`](design/REWARD_EMISSION_LEG.md) (membership-only backing + work payload; dedup on the bond record). FCMP++ membership proofs for ordinary spends are unchanged. **PQC ownership cross-check** for **regular** spends: `leaf[96:128] == shekyl_fcmp_pqc_leaf_hash(pqc_pk)`. Lock-tier staked-output / `txin_stake_claim` PQC checks, claim-reward fingerprinting rules, claim sub-path in `check_tx_inputs`, `create_claim_transaction`, batch pool balance, and stake-claim input sorting are **retired** with the claim-era model. Do not reintroduce them. Archival emission is a different vin ([`design/REWARD_EMISSION_LEG.md`](design/REWARD_EMISSION_LEG.md)). --- ## 16. Failure Modes | Check | Failure | Error | |-------|---------|-------| | referenceBlock unknown | Block hash not in DB | `tvc.m_verifivation_failed` | | referenceBlock too old | `ref_height < tip - MAX_AGE` | `tvc.m_verifivation_failed` | | referenceBlock too recent | `ref_height > tip - MIN_AGE` | `tvc.m_verifivation_failed` | | tree depth out of range | `curve_trees_tree_depth` 0 or > current | `tvc.m_verifivation_failed` | | key_offsets non-empty | Ring members present in FCMP++ tx | `tvc.m_verifivation_failed` | | key image not y-normalized | Sign bit set on key image | `tvc.m_verifivation_failed` | | FCMP++ proof invalid | `shekyl_fcmp_verify` returns false | `tvc.m_verifivation_failed` | | `pqc_auths` count mismatch | `pqc_auths.size() != vin.size()` | `tvc.m_verifivation_failed` | | PQC signature invalid | `shekyl_pqc_verify` returns false | `tvc.m_verifivation_failed` | | Key image double-spend | Key image already in DB | `tvc.m_double_spend` | | Stake claim PQC mismatch | Leaf `H(pqc_pk)` ≠ `pqc_auths[i]` hash | `tvc.m_verifivation_failed` | | Stake claim pool overdraw | Sum of all claim amounts > pool balance | `tvc.m_verifivation_failed` | | Stake claim amount overflow | `total_claimed` wraps `uint64_t` | `tvc.m_verifivation_failed` | --- ## 17. Constants | Constant | Value | Location | |----------|-------|----------| | `FCMP_REFERENCE_BLOCK_MAX_AGE` | 100 | `cryptonote_config.h` | | `FCMP_REFERENCE_BLOCK_MIN_AGE` | 5 (reorg safety margin) | `cryptonote_config.h` | | `FCMP_MAX_INPUTS_PER_TX` | 8 | `cryptonote_config.h` | | `FCMP_CURVE_TREE_CHECKPOINT_INTERVAL` | 10,000 | `cryptonote_config.h` | | `CTTypeFcmpPlusPlusPqc` | 1 | `ct_types.h` | | `TX_EXTRA_TAG_PQC_KEM_CIPHERTEXT` | 0x06 | `tx_extra.h` | | `TX_EXTRA_TAG_PQC_LEAF_HASHES` | 0x07 | `tx_extra.h` | | `ML_KEM_768_CT_BYTES` | 1088 | `tx_extra.h` | | `X25519_CT_BYTES` | 32 | `tx_extra.h` | | `HYBRID_KEM_CT_BYTES` | 1120 (32 + 1088) | `tx_extra.h` | | `PQC_LEAF_HASH_BYTES` | 32 | `tx_extra.h` | | `HF_VERSION_FCMP_PLUS_PLUS_PQC` | 1 | `cryptonote_config.h` | --- ## 18. Implementation Status | Component | Status | Location | |-----------|--------|----------| | Curve tree LMDB schema (leaves, layers, meta) | **Done** | `db_lmdb.h`, `db_lmdb.cpp` | | Curve tree checkpoint table | **Done** | `db_lmdb.h`, `db_lmdb.cpp` | | Output metadata table (`m_output_metadata`) | **Done** | `db_lmdb.h`, `db_lmdb.cpp` | | `curve_tree_root` in block header | **Done** | `cryptonote_basic.h`, `blockchain.cpp` | | referenceBlock age validation | **Done** | `blockchain.cpp` | | key_offsets empty check | **Done** | `blockchain.cpp` | | Key image y-normalization check | **Done** | `blockchain.cpp` | | FCMP++ proof FFI call | **Done** | `blockchain.cpp` → `shekyl_fcmp_verify()` | | Verification caching (mempool FCMP++ hash) | **Done** | `tx_pool.cpp`, `blockchain.cpp` | | `genRctFcmpPlusPlus` (wallet-side proof) | **Deleted 2026-08-22** | Had no caller; production uses `shekyl_sign_fcmp_transaction` | | Wallet tree-path precomputation | **Migrated to Rust** | `wallet2.cpp` was deleted 2026-08-19; the wallet stack is `shekyl-engine-*` / `shekyl-tx-builder` | | PQC key rederivation from stored secret | **Deleted 2026-08-19** with `wallet2.cpp` (it was the Phase-5 deletion target named here) | — | | Restore-from-seed PQC rederivation | **Done** (frozen v1 pipeline; `shekyl_account_rederive`) | `rust/shekyl-crypto-pq/src/account.rs` | | `prune_tx_data` + `txs_pqc_auths` split | **Done** | `db_lmdb.cpp`, `cryptonote_basic.h` | | `get_curve_tree_path` RPC | **Done** | `core_rpc_server.cpp` | | `get_curve_tree_info` RPC | **Done** | `core_rpc_server.cpp` | | `get_curve_tree_checkpoint` RPC | **Done** | `core_rpc_server.cpp` | | CI: Rust workspace + FCMP crate build | **Done** | `.github/workflows/build.yml` | | CI: Determinism check + Bech32m tests | **Done** | `.github/workflows/build.yml` | | Hardware device FCMP++ stubs | **Done** | `device.hpp`, `device_default.cpp`, `device_ledger.cpp` | | Trezor protocol legacy RCT removal | **Done** | `protocol.cpp`, `protocol.hpp` | | Legacy RCT stripping (types 1-6, all structs, all src/) | **Done** | `ct_types.h/cpp`, `ct_semantics.h/cpp`, all consumers | | Non-plus Bulletproof code removal | **Done** | `bulletproofs.h`, `bulletproofs.cc` | | `RCTConfig` parameter removal from tx construction | **Done** | `cryptonote_tx_utils.h/cpp`, `wallet2.h/cpp` | | RPC `low_mixin` field removal | **Done** | `core_rpc_server.cpp`, `core_rpc_server_commands_defs.h` | | Staked output curve-tree leaves | **Done** | `blockchain_db.cpp` | | Stake claim curve-tree leaf presence check | **Done** | `blockchain.cpp` (`check_stake_claim_input`) | | Stake claim wired in `check_tx_inputs` | **Done** | `blockchain.cpp` (FCMP++ handler, claim sub-path) | | Stake claim PQC ownership cross-check | **Done** | `blockchain.cpp` (FCMP++ handler, `H(pqc_pk)` leaf vs `pqc_auths`) | | Stake claim batch pool balance check | **Done** | `blockchain.cpp` (FCMP++ handler, sum-then-check) | | Stake claim sorted input enforcement | **Done** | `blockchain.cpp` (sorted-ins block handles `txin_stake_claim`) | | Stake claim key images in `remove_transaction` | **Done** | `blockchain_db.cpp` | | Integer-only reward computation | **Done** | `blockchain.cpp` (`check_stake_claim_input`, `mul128`/`div128_64`) | | Dead `check_ring_signature` removal | **Done** | `blockchain.cpp`, `blockchain.h` | | Dead `expand_transaction_2` removal | **Done** | `blockchain.cpp`, `blockchain.h` | | PQC `auth_version`/`flags` consensus checks | **Done** | `tx_pqc_verify.cpp` | | Single-signer key size validation | **Done** | `tx_pqc_verify.cpp` | | Dead `verRctNonSemanticsSimple` / cache removal | **Done** | `ct_semantics.h/cpp`, `tx_verification_utils.h/cpp` | | Universal deferred tree insertion | **Done** | `pending_tree_leaves` / `pending_tree_drain` / `block_pending_additions` / `output_to_leaf` / `leaf_to_output` DB tables, `blockchain_db.cpp`, `shekyl_types.h` | | Per-input `pqc_auths` field | **Done** | `cryptonote_basic.h` | | Per-input PQC signature verification | **Done** | `tx_pqc_verify.cpp` | | PQC signed payload binds prunable data + all H(pqc_pk) | **Done** | `tx_pqc_verify.cpp` | | `pqc_authentication` deserialization size bounds | **Done** | `cryptonote_basic.h` | | `pseudoOuts` gated in generic `CtSigBase` serializer | **Done** | `ct_types.h` | | `pop_block()` height symmetry fix | **Done** | `blockchain_db.cpp` | | Ring-based validation path removed (genesis-native) | **Done** | `blockchain.cpp` | | `tx_extra` KEM blob tag `0x06` (N × 1120 bytes hybrid ct) | **Done** | `tx_extra.h`, `cryptonote_format_utils.cpp` | | `tx_extra` leaf hash tag `0x07` (N × 32 bytes) | **Done** | `tx_extra.h`, `cryptonote_format_utils.cpp` | | Curve tree leaves use actual `H(pqc_pk)` from `tx_extra` | **Done** | `blockchain_db.cpp` (`collect_outputs`, `make_leaf`) | | Coinbase KEM self-encapsulation + `H(pqc_pk)` emission | **Done** | `cryptonote_tx_utils.cpp` (`construct_miner_tx`) | | Consensus rejects `CTTypeNull` for non-coinbase v3 txs | **Done** | `blockchain.cpp` (`check_tx_inputs`) | | Claim tx: `CTTypeFcmpPlusPlusPqc` with BP+ range proofs | **Done** | `wallet2.cpp` (`create_claim_transaction`) | | Claim tx: 2-output structure (reward + dummy change) | **Done** | `wallet2.cpp` (`create_claim_transaction`) | | Claim tx: hybrid KEM derivation for per-output PQC keys | **Done** | `wallet2.cpp` (`create_claim_transaction`) | | Claim tx: per-output PQC signing (not wallet master key) | **Done** | `wallet2.cpp` (`create_claim_transaction`) | | Claim tx: pseudo-outs as `zeroCommit(claim_amount)` | **Done** | `wallet2.cpp` (`create_claim_transaction`) | | Wallet KEM key generation (`kem_keypair_generate`) | **Done** | `account.cpp` | | Full hybrid ciphertext in tag 0x06 (1120 bytes/output) | **Done** | `cryptonote_tx_utils.cpp`, `wallet2.cpp` | | KEM decapsulation during wallet scanning | **Done** | `wallet2.cpp` (`process_new_transaction`) | | `transfer_selected_rct` FCMP++ (collapsed Rust signing) | **Done** | `wallet2.cpp` → `shekyl_sign_fcmp_transaction` | | `construct_tx_with_tx_key` KEM encap + 0x06/0x07 for outputs | **Done** | `cryptonote_tx_utils.cpp` | | Per-input PQC auth with derived ML-DSA-65 keys | **Done** | `wallet2.cpp` (`transfer_selected_rct`) | | Fee estimation for FCMP++ proof size | **Done** | `wallet2.cpp` (`estimate_rct_tx_size`) | | GUI wallet QR code (full Bech32m address) | **Done** | `shekyl-gui-wallet` | | GUI wallet fee preview on Send page | **Done** | `shekyl-gui-wallet` | | `ct::key::operator!=` for key-vs-key comparison | **Done** | `ct_types.h` | | PQC secret keys eliminated from C++ wallet (sign via `shekyl_sign_pqc_auth`) | **Done** | `wallet2.cpp`, `wallet2_ffi.cpp` | | MSVC-compatible `binary_archive` construction | **Done** | `wallet2.cpp` | | Stressnet tooling (load gen, monitor, config) | **Done** | `tests/stressnet/` | | 4-scalar leaf circuit (x-only + H(pqc_pk)) in monero-oxide fork | **Done** | `crypto/fcmps/` (monero-oxide `fcmp++` branch) | | `FcmpPlusPlus::verify` accepts `pqc_pk_hashes` parameter | **Done** | `shekyl-oxide/fcmp/fcmp++/src/lib.rs` (monero-oxide) | | 4-scalar leaf circuit audit scope | **Done** | `docs/AUDIT_SCOPE.md` | | Cargo-fuzz targets (6 targets) | **Done** | `rust/shekyl-fcmp/fuzz/`, `rust/shekyl-crypto-pq/fuzz/` | | Rust unit test suite (proof, tree, leaf, kem, address, derivation) | **Done** | `rust/shekyl-fcmp/src/`, `rust/shekyl-crypto-pq/src/` | | C++ unit tests (FCMP++ specific) | **Done** | `tests/unit_tests/fcmp.cpp` | | PQC rederivation benchmark (criterion) | **Done** | `rust/shekyl-crypto-pq/benches/pqc_rederivation.rs` | | CLSAG device interface removal | **Done** | `device.hpp`, `device_default.cpp/hpp`, `device_ledger.cpp/hpp` | | `get_outs`/`get_outs.bin` RPC removal | **Done** | `core_rpc_server.h/cpp`, `core_rpc_ffi.cpp`, `shekyl-daemon-rpc` | | Dead HF constant cleanup (mixin, CLSAG, etc.) | **Done** | `cryptonote_config.h` | | Zstd Levin P2P compression | **Done** | `levin_base.h/cpp`, `levin_compression.h/cpp`, `net_node.inl` | | `P2P_SUPPORT_FLAG_ZSTD_COMPRESSION` handshake flag | **Done** | `cryptonote_config.h` (0x02) | | Stake-claim rollback: watermark + pool-balance restoration | **Done** | `blockchain_db.cpp` (`remove_transaction`) | | Txpool `txin_stake_claim` key-image handling (6 functions) | **Done** | `tx_pool.cpp` | | `get_inputs_money_amount` / `check_inputs_overflow` stake-claim support | **Done** | `cryptonote_format_utils.cpp` | | `remove_transaction_keyimages` no-early-return fix | **Done** | `tx_pool.cpp` | | RPC `estimate_claim_reward` integer math fix | **Done** | `core_rpc_server.cpp` | | Staking unit tests (GTest) | **Done** | `tests/unit_tests/staking.cpp` | | Staking core tests (chaingen) | **Done** | `tests/core_tests/staking.cpp` + `staking.h` | | Staking tier edge-case tests (Rust) | **Done** | `rust/shekyl-staking/src/tiers.rs` | | Real `prove()` in `shekyl-fcmp` (SAL + FCMP circuit + pseudo-outs) | **Done** | `rust/shekyl-fcmp/src/proof.rs` | | Real `verify()` in `shekyl-fcmp` (batch verifiers: Ed25519/Selene/Helios) | **Done** | `rust/shekyl-fcmp/src/proof.rs` | | FFI `shekyl_fcmp_prove` returns `ShekylFcmpProveResult` with pseudo-outs | **Deleted 2026-08-22** | Export gone with the legacy prove seam; `ShekylFcmpProveResult` survives as the multisig coordinator's return type (`legacy_frost.rs`) | | FFI `shekyl_fcmp_verify` accepts `signable_tx_hash` parameter | **Done** | `rust/shekyl-ffi/src/lib.rs`, `shekyl_ffi.h` | | C++ callers updated for new FFI signatures | **Done** | `ct_semantics.cpp`, `blockchain.cpp` (`wallet2.cpp` deleted 2026-08-19) | | Staking reward fuzz target | **Done** | `rust/shekyl-staking/fuzz/fuzz_targets/fuzz_claim_reward.rs` | | FROST SAL module (`frost_sal.rs`) | **Done** | `rust/shekyl-fcmp/src/frost_sal.rs` | | `prove_with_sal()` for multisig proof construction | **Done** | `rust/shekyl-fcmp/src/proof.rs` | | FROST DKG key management (`frost_dkg.rs`) | **Done** | `rust/shekyl-fcmp/src/frost_dkg.rs` | | FROST SAL FFI (session new/get_rerand/aggregate_and_prove/free) | **Done** | `rust/shekyl-ffi/src/lib.rs`, `shekyl_ffi.h` | | FROST DKG FFI (keys import/export/validate/group_key/free) | **Done** | `rust/shekyl-ffi/src/lib.rs`, `shekyl_ffi.h` | | FFI `shekyl_fcmp_prove` variable-length witness format | **Deleted 2026-08-22** | The export is gone with the legacy prove seam; the witness format survives as `parse_prove_witness` / `shekyl_fcmp_build_witness_header` in `rust/shekyl-ffi/src/legacy_fcmp.rs` and `legacy_tx.rs`, both `#[cfg(feature = "multisig")]` and read by the FROST coordinator | | `genRctFcmpPlusPlus` accepts leaf chunk entries | **Deleted 2026-08-22** | Had no caller; production signing is `shekyl_sign_fcmp_transaction` (`CT_SURFACE_NAMING_PIN.md` §5 step 1) | | Daemon RPC `chunk_outputs_blob` in `get_curve_tree_path` | **Done** | `core_rpc_server.cpp`, `core_rpc_server_commands_defs.h` | | Wallet `fcmp_precomputed_path` stores `leaf_chunk_entries` | **Done** | `wallet2.h/cpp` | | C++ wallet FROST code removed | **Done** | `wallet2.h/cpp`, `wallet2_ffi.cpp`, `shekyl_ffi.h` (SHEKYL_MULTISIG blocks deleted) | | Rust FROST DKG ceremony (`MultisigDkgSession`) | **Done** | `rust/shekyl-engine-core/src/multisig/dkg.rs` | | Rust FROST signing orchestration (`MultisigSigningSession`) | **Done** | `rust/shekyl-engine-core/src/multisig/signing.rs` | | `FrostSigningCoordinator` (nonce aggregation + share collection) | **Done** | `rust/shekyl-fcmp/src/frost_sal.rs` | | Rust multisig group management (`MultisigGroup`) | **Done** | `rust/shekyl-engine-core/src/multisig/group.rs` | | FROST multisig RPC endpoints (signing only) | **Done** | `rust/shekyl-wallet-rpc/src/multisig_handlers.rs` | | FROST SAL unit tests (4 tests) | **Done** | `rust/shekyl-fcmp/src/frost_sal.rs` | | FROST DKG unit tests (4 tests) | **Done** | `rust/shekyl-fcmp/src/frost_dkg.rs` | | FROST FFI lifecycle tests (8 tests) | **Done** | `rust/shekyl-ffi/src/lib.rs` | | `shekyl-tx-builder` crate (native Rust signing) | **Done** | `rust/shekyl-tx-builder/` | | `shekyl_sign_transaction` FFI export | **Done** | `rust/shekyl-ffi/src/lib.rs`, `shekyl_ffi.h` | | Wallet RPC `native-sign` feature (`transfer_native`) | **Done** | `rust/shekyl-wallet-rpc/src/wallet.rs` | | `wallet2_ffi_prepare_transfer` / `_finalize_transfer` | **Done** | `src/wallet/wallet2_ffi.cpp`, `wallet2_ffi.h` | | `shekyl-tx-builder` unit tests (19 tests) | **Done** | `rust/shekyl-tx-builder/src/tests.rs` | | Rust `construct_output` (KEM + HKDF → O, C, enc, view_tag, PQC) | **Done** | `shekyl-crypto-pq/src/output.rs` | | Rust `scan_output_recover` (KEM decap + HKDF → B', ho, y, z, PQC) | **Done** | `shekyl-crypto-pq/src/output.rs` | | FFI `shekyl_construct_output` / `shekyl_scan_output_recover` | **Done** | `shekyl-ffi/src/lib.rs`, `shekyl_ffi.h` | | Rust PQC signing (`shekyl_sign_pqc_auth`, sk in Rust only) | **Done** | `shekyl-crypto-pq/src/output.rs`, `shekyl-ffi/src/lib.rs` | | Rust witness header assembly (`shekyl_fcmp_build_witness_header`) | **Done** | `shekyl-ffi/src/lib.rs` | | `construct_miner_tx` v3 → `shekyl_construct_output` | **Done** | `cryptonote_tx_utils.cpp` | | `construct_tx_with_tx_key` v3 → `shekyl_construct_output` | **Done** | `cryptonote_tx_utils.cpp` | | Wallet v3 scanner via `scan_output_recover` | **Done** | `wallet2.cpp` | | X25519-only view tag (sender + scanner) | **Done** | `output.rs`, `wallet2.cpp` | | `additional_tx_keys` removed for v3 | **Done** | `cryptonote_tx_utils.cpp` | | `CTTypeNull` serializes `outPk` + `enc_amounts` | **Done** | `ct_types.h` | | On-chain `outPk` for v3+ coinbase | **Done** | `blockchain_db.cpp` | | `check_commitment_mask_valid` rejects z=0/z=1 | **Done** | `blockchain.cpp` | | PQC salt unified to `shekyl-output-derive-v1` | **Done** | `derivation.rs`, `output.rs` | | `transfer_details::m_mask` → `crypto::secret_key` | **Done** | `wallet2.h`, `wallet2.cpp` | | Chaingen test infra for v3 HKDF outputs | **Done** | `chaingen.cpp`, `chaingen.h` | --- ## 19. Testing & Fuzzing ### Fuzz Targets Ten `cargo-fuzz` targets exercise the critical parsing, crypto, multisig, and staking boundaries: | Target | Crate | What it tests | |--------|-------|---------------| | `fuzz_fcmp_proof_deserialize` | `shekyl-fcmp` | Malformed, truncated, and oversized proof blobs | | `fuzz_curve_tree_leaf_hash` | `shekyl-fcmp` | Arbitrary 4×32-byte leaf inputs, PQC scalar boundary values | | `fuzz_block_header_tree_root` | `shekyl-fcmp` | Mismatched `curve_tree_root` between prove and verify | | `fuzz_bech32m_address_decode` | `shekyl-crypto-pq` | Random strings through Bech32m decoder, wrong HRPs, bad checksums | | `fuzz_kem_decapsulate` | `shekyl-crypto-pq` | Corrupted ML-KEM ciphertexts, wrong-length keys and ciphertexts | | `fuzz_multisig_verify` | `shekyl-crypto-pq` | Multisig verify path with malformed key blobs, signatures, and payloads | | `fuzz_multisig_key_blob` | `shekyl-crypto-pq` | Randomized multisig key-blob decode and bounds checks | | `fuzz_multisig_sig_blob` | `shekyl-crypto-pq` | Randomized multisig signature-blob decode and validation | | `fuzz_claim_reward` | `shekyl-staking` | Random accrual records; reward overflow, monotonicity, and bound invariants | | `fuzz_tx_deserialize_fcmp_type7` | `shekyl-fcmp` | Transaction-structured FCMP++ deserialization: pseudoOuts, proof blobs, PQC hashes, corrupted types | CI runs a smoke gate that ensures this required fuzz harness inventory exists (`.github/workflows/build.yml`, `verify fuzz harness inventory (smoke gate)`). For the full pre-release fuzz campaign (10M runs per harness), run: ```bash cd rust/shekyl-fcmp/fuzz && cargo +nightly fuzz run fuzz_fcmp_proof_deserialize -- -runs=10000000 cd rust/shekyl-fcmp/fuzz && cargo +nightly fuzz run fuzz_curve_tree_leaf_hash -- -runs=10000000 cd rust/shekyl-fcmp/fuzz && cargo +nightly fuzz run fuzz_block_header_tree_root -- -runs=10000000 cd rust/shekyl-fcmp/fuzz && cargo +nightly fuzz run fuzz_tx_deserialize_fcmp_type7 -- -runs=10000000 cd rust/shekyl-crypto-pq/fuzz && cargo +nightly fuzz run fuzz_bech32m_address_decode -- -runs=10000000 cd rust/shekyl-crypto-pq/fuzz && cargo +nightly fuzz run fuzz_kem_decapsulate -- -runs=10000000 cd rust/shekyl-crypto-pq/fuzz && cargo +nightly fuzz run fuzz_multisig_verify -- -runs=10000000 cd rust/shekyl-crypto-pq/fuzz && cargo +nightly fuzz run fuzz_multisig_key_blob -- -runs=10000000 cd rust/shekyl-crypto-pq/fuzz && cargo +nightly fuzz run fuzz_multisig_sig_blob -- -runs=10000000 cd rust/shekyl-staking/fuzz && cargo +nightly fuzz run fuzz_claim_reward -- -runs=10000000 ``` ### Rust Unit Tests Comprehensive tests cover prove/verify round-trips (including a full end-to-end `prove_verify_roundtrip` test that generates random Ed25519 keys, constructs a single-leaf tree, proves membership, verifies the proof, and checks that tampered key images and wrong tree roots are rejected), edge cases (empty inputs, max inputs, truncated proofs, tampered key images), hash grow/trim inverse properties, leaf serialization layout, PQC keypair derivation determinism, Bech32m address encoding/decoding, and cross-crate consistency between `hash_pqc_public_key` and `PqcLeafScalar::from_pqc_public_key`. ```bash cd rust && cargo test --workspace ``` ### Staking Tests Lock-tier `txin_stake_claim` / `txout_to_staked_key` unit and chaingen tests are **claim-era**. Do not treat them as a genesis gate. Archival emission and bond-post tests live with `REWARD_EMISSION_*` / `ARCHIVAL_*` and `tests/unit_tests/archival_*`.
Historical claim-era test inventory (do not implement from) #### C++ Unit Tests (`tests/unit_tests/staking.cpp`) - `txin_stake_claim` and `txout_to_staked_key` binary serialization round-trips (boundary values, all tiers) - Reward integer math: `mul128`/`div128_64` vs `double`-precision divergence at large `total_weighted_stake` - Cumulative reward over a multi-block accrual range - Dust floor-division edge cases (reward < 1 atomic unit) - `get_output_staking_info` for staked and non-staked outputs - `get_inputs_money_amount` with mixed `txin_to_key` + `txin_stake_claim` - `check_inputs_overflow` with large claim amounts - `check_inputs_types_supported` acceptance and rejection - Stake weight/yield multiplier tier ordering via FFI - `set_staked_tx_out` construction and variant type checks #### C++ Core Tests (`tests/core_tests/staking.cpp`) 18 chaingen replay tests covering: - **Lifecycle**: staked output creation with `construct_staked_tx` helper - **Invalid claims**: inverted range, oversized range (>10000), future height, wrong watermark, wrong amount, non-staked output, output not in tree - **Lock enforcement**: invalid tier (3) - **Rollback**: pool balance and watermark restoration via callbacks - **Txpool**: mempool key-image tracking - **Adversarial**: sorted-input enforcement, all-tiers staking #### Rust Tests (`rust/shekyl-staking/src/tiers.rs`) 10 edge-case tests: exhaustive invalid tier ID rejection (3..255), ordering invariants, positive parameter assertions, contiguous ID verification. #### Rust Fuzz (`rust/shekyl-staking/fuzz/`) `fuzz_claim_reward`: generates random accrual records and stake parameters, verifies no overflow, reward ≤ pool, weight monotonicity, and cumulative bounds.
### C++ Unit Tests `tests/unit_tests/fcmp.cpp` covers: - `CTTypeFcmpPlusPlusPqc` serialization round-trip - `key_image_y_normalize` correctness and idempotency - `referenceBlock` staleness constant validation - `key_offsets` empty enforcement for FCMP++ type - `get_pseudo_outs` routing (prunable vs base for FCMP++ type) - `curve_tree_root` block header serialization round-trip - Empty FCMP++ proof rejection by `shekyl_fcmp_verify` (in `check_tx_inputs`) - `compute_fcmp_verification_hash` determinism and cache-key sensitivity (6 tests) - `CRYPTONOTE_MAX_BLOCK_HEIGHT_SENTINEL` constant validation - `FCMP_REFERENCE_BLOCK_MIN_AGE` value and ordering assertions `tests/unit_tests/deferred_insertion.cpp` covers (Decision 14): - Outputs not drainable before their maturity height - Coinbase maturity window (60 blocks) boundary - Regular tx maturity window (10 blocks) boundary - Drain journal add/retrieve/remove atomicity round-trip - Insertion ordering determinism across two LMDB instances - Same-maturity drain order by output_index (regression for DUPSORT bug) - `block_pending_additions` journal round-trip - `output_to_leaf` / `leaf_to_output` bidirectional mapping round-trip - `pop_block` journal-driven reversal simulation `tests/unit_tests/pending_tree_fuzz.cpp` covers: - Add/remove round-trip for pending tree leaves - Multi-height drain correctness - Drain journal entry CRUD operations - Randomized stress test (100 leaves, random maturity heights) - Single-leaf removal from multi-leaf pending set - Composite-key ordering (same maturity, different output indices) - Block pending additions journal CRUD - Output↔leaf mapping CRUD and inverse correctness ### C++ Core Tests (chaingen) `tests/core_tests/fcmp_tests.cpp` covers: - `gen_fcmp_tx_valid`: full FCMP++ transaction construction (proof + PQC auth) and pool acceptance - `gen_fcmp_tx_double_spend`: double-spend rejection for FCMP++ transactions - `gen_fcmp_tx_reference_block_too_old`: stale referenceBlock rejection - `gen_fcmp_tx_reference_block_too_recent`: too-recent referenceBlock rejection - `gen_fcmp_tx_timestamp_unlock_rejected`: timestamp-based unlock_time rejection (Decision 13) ### PQC Rederivation Benchmark `rust/shekyl-crypto-pq/benches/pqc_rederivation.rs` uses Criterion to benchmark the full per-output key rederivation pipeline: 1. ML-KEM-768 decapsulation 2. HKDF-SHA-512 seed derivation + ML-DSA-65 keygen 3. Blake2b-512 public key hash Target: **< 100ms per output** on x86_64. ```bash cd rust/shekyl-crypto-pq && cargo bench --bench pqc_rederivation ``` --- ## monero-oxide Fork Integration Status The FCMP++ Rust crypto stack descends from the [Shekyl Foundation monero-oxide fork](https://github.com/Shekyl-Foundation/monero-oxide) (`fcmp++` branch). After the un-vendor (slice 2, `SHEKYL_OXIDE_UNVENDOR.md`), the proof/generator/support crates are **first-party** `shekyl-*` crates (relocated out of the vendored tree, no longer upstream-tracked); only the research crypto remains vendored under `rust/shekyl-oxide/crypto/`. `rust/shekyl-fcmp/Cargo.toml` consumes them by path: - `shekyl-fcmp-proofs` (first-party — the FCMP++ SAL/membership proofs; was `shekyl-fcmp-plus-plus`) - `shekyl-curve-generators` (first-party; was `shekyl-generators`) - `helioselene` (vendored crypto) - `ec-divisors` (vendored crypto) ### Current Pin The vendored snapshot source of truth is: - `rust/shekyl-oxide/UPSTREAM_MONERO_OXIDE_COMMIT` This metadata records the upstream repo/branch/commit used for the current vendored crate copy. ### Circuit Integration Status The `full-chain-membership-proofs` circuit in the monero-oxide fork has been modified to support Shekyl's 4-scalar leaf format. The `FcmpCurves` trait now includes `const EXTRA_LEAF_SCALARS: usize = 1`, and `Curves` in the `shekyl-fcmp-proofs` wrapper sets this to `1`. The `FcmpPlusPlus::verify` accepts `pqc_pk_hashes: Vec` to pass the 4th scalar through to the circuit. **x-only leaf optimization:** During implementation, we discovered that the upstream circuit's internal (x,y) coordinate representation for O, I, C was unnecessary for the flat leaf array and membership proof. The `on_curve` and blinding proof gadgets still operate on full (x,y) points internally, but the leaf flattening (`flatten_leaves`) and `tuple_member_of_list` membership gadget now use **x-only coordinates**: ```text Upstream (original): [O.x, O.y, I.x, I.y, C.x, C.y] — 6 scalars per output Shekyl (implemented): [O.x, I.x, C.x, H(pqc_pk)] — 4 scalars per output ``` This means the leaf layer width is `4 * LAYER_ONE_LEN` (not `6 * ...` or `8 * ...`). The y-coordinates are recovered from x via the curve equation inside the circuit's `on_curve` gadget — they are never stored in the tree or transmitted in the flat leaf array. **Completed upstream changes** (monero-oxide `fcmp++` branch): - `FcmpCurves::EXTRA_LEAF_SCALARS` trait constant with `leaf_tuple_width()` - `Path`, `Branches`, `RootBranch`, `InputProofData` extended with `output_extra_scalars` / `leaves_extra_scalars` - `flatten_leaves` produces `[O.x, I.x, C.x, extras...]` per output - `first_layer()` constrains `extra_leaf_vars == extra_leaf_public_values` and includes extras in `tuple_member_of_list` - Extra leaf scalars committed as dedicated 1-element branches on the C1 tape (after standard per-input branches, before the root branch) - `Input` carries `extra_leaf_scalars: Vec`; `proof_size()` accounts for extra branches - `Fcmp::verify` and `FcmpPlusPlus::verify` accept extra scalars - All tests use `ShekylCurves` with `EXTRA_LEAF_SCALARS = 1`; no backward-compatibility code for the 3-scalar Monero leaf format **Completed**: The `shekyl-fcmp` crate (`rust/shekyl-fcmp/src/proof.rs`) now contains real `prove()` and `verify()` implementations that call through the full FCMP++ stack: - `prove()` constructs `RerandomizedOutput`, `SpendAuthAndLinkability`, `OutputBlinds`, `Path`/`Branches`, `BranchBlind`s, and calls `Fcmp::prove()` to produce a complete FCMP++ proof with pseudo-outs. - `verify()` deserializes via `FcmpPlusPlus::read()`, initializes batch verifiers for Ed25519/Selene/Helios, and finalizes all three. - The FFI boundary (`shekyl-ffi`) passes `signable_tx_hash` for transaction binding and returns `ShekylFcmpProveResult` with proof + pseudo-outs. ### Upstream Security Fixes Status 19 commits on upstream `main` are not yet merged into `fcmp++`. The three security-critical commits have been audited against the Shekyl fork: | Commit | Issue | Status | |--------|-------|--------| | `b6d3e44` | Base58 overflow fix, identity/torsion point rejection | **Base58 fixed** (`checked_add` + non-canonical rejection). Identity/torsion checks already present in fork. | | `a941dff` | Varint length fix for zero | **Not applicable** — fork uses different formula that correctly returns 1 for zero. | | `c8be5d3` | Gate debug `Extra::write` assertions | **Not applicable** — fork's `Extra::write` was refactored without debug assertions. | **Base58 defense-in-depth note:** Shekyl core has fully migrated to Bech32m. All C++ Base58 code (`base58.{h,cpp}`, unit tests, fuzz targets, config prefixes) has been deleted. Address encoding uses `shekyl-address` via FFI; wallet proofs use `shekyl-encoding` via FFI. The monero-oxide fork's wallet still uses base58 via `shekyl-base58` (defense-in-depth fixes applied). Migration of the fork's address crate is deferred — the fork's deep Monero-style address type assumptions (Legacy, Subaddress, Integrated, Featured) make the migration disproportionately expensive relative to the fork's disposable nature. **Cargo hardening:** Both the monero-oxide fork and the Shekyl Rust workspace (`rust/Cargo.toml`) now enforce `overflow-checks = true` across all profiles (dev, release, test, bench) and `panic = "abort"` for dev and release. ### RELEASE-BLOCKER Items (monero-oxide) 7 items tagged `RELEASE-BLOCKER(shekyl)` in the fork must be resolved before audit signoff: | Item | File | Impact | |------|------|--------| | FCMP\_PARAMS safe API | `fcmp/fcmp++/src/lib.rs` | API quality | | Generated constant visibility | `fcmp/fcmp++/build.rs` | Encapsulation | | DKG offset introspection | Removed (`legacy_multisig.rs` deleted) | Resolved — legacy multisig removed; modern SAL in `sal/multisig.rs` | | On-curve constraint for `c` | `crypto/fcmps/src/gadgets/mod.rs` | Correctness | | Bulk block fetch | `rpc/src/lib.rs` | Sync performance | | Bulk height-based fetch | `rpc/src/lib.rs` | Sync performance | | Decoy validation | `rpc/src/lib.rs` | Consensus safety | The 3 RPC items only matter if Shekyl adopts `monero-rpc`/`monero-wallet` for wallet functionality. The on-curve constraint and DKG coupling are the items most likely to affect proof correctness. ### Upstream Sync Workflow Use the required workflow in `docs/SHEKYL_OXIDE_VENDORING.md`: 1. Upstream fix lands 2. Cherry-pick/merge into `Shekyl-Foundation/monero-oxide` 3. Test fork in isolation 4. Sync subtree into `rust/shekyl-oxide/` 5. Run full `shekyl-core` test/build gates 6. Commit with upstream reference --- ## 20. Two-Component Output Keys (O = xG + yT) ### Problem FCMP++ SAL (Spend-Authorization and Linkability) proves knowledge of `(x, y)` such that `O = xG + yT`. The `OpenedInputTuple::open` function enforces: ``` x*G + (y + r_o)*T == O~ => x*G + y*T == O ``` Legacy Monero-style outputs use single-component keys `O = xG` (equivalently `O = xG + 0*T`), so the SAL `y` for these outputs is zero. Previously, the wallet incorrectly passed the Pedersen commitment mask `z` as `y`, causing `open()` to check `x*G + z*T == O` which fails for all non-zero `z`. ### Design Every output public key is now: ``` O = Hs(derivation || i) * G + B + Hs_y(derivation || i) * T ``` Where: - `Hs(derivation || i)` is the existing x-derivation scalar (unchanged) - `Hs_y(derivation || i)` is a domain-separated y-derivation scalar using the `"shekyl_y"` salt prefix - `B` is the recipient's spend public key - The full spend key is `x = Hs(derivation || i) + b` - The SAL secret is `y = Hs_y(derivation || i)` ### Domain Separation and y-Derivation Migration **Canonical (PR-construct and beyond):** All per-output secrets (`ho`, `y`, `z`, `k_amount`, `view_tag_combined`, `amount_tag`, `ml_dsa_seed`) are derived from the unified HKDF-SHA-512 stream using the combined KEM shared secret (`X25519_ss || ML-KEM_ss`). Each uses a distinct HKDF info string: | Secret | HKDF Salt | HKDF Info | |--------|-----------|-----------| | `ho` (x-derivation) | `shekyl-output-derive-v1` | `shekyl-output-x \|\| output_index_le64` | | `y` (T-component) | `shekyl-output-derive-v1` | `shekyl-output-y \|\| output_index_le64` | | `z` (commitment mask) | `shekyl-output-derive-v1` | `shekyl-output-mask \|\| output_index_le64` | | `k_amount` | `shekyl-output-derive-v1` | `shekyl-output-amount-key \|\| output_index_le64` | | `view_tag_combined` | `shekyl-output-derive-v1` | `shekyl-output-view-tag \|\| output_index_le64` | | `amount_tag` | `shekyl-output-derive-v1` | `shekyl-output-amount-tag \|\| output_index_le64` | | `ml_dsa_seed` | `shekyl-output-derive-v1` | `shekyl-pqc-output \|\| output_index_le64` | For `ho`, `y`, `z`: 64 bytes are expanded and reduced mod Ed25519 scalar order `l` (wide reduce). For `k_amount`, `ml_dsa_seed`: 32 bytes expanded. For `view_tag_combined`, `amount_tag`: first byte of expanded output. Test vectors: `docs/test_vectors/PQC_OUTPUT_SECRETS.json` Reference implementation: `tools/reference/derive_output_secrets.py` **X25519-only view tag (fast scan):** A separate view tag is derived from the X25519 shared secret alone (no ML-KEM decapsulation needed), enabling fast pre-filtering during wallet sync: | Secret | HKDF Salt | HKDF Info | |--------|-----------|-----------| | `view_tag_x25519` | `shekyl-view-tag-x25519-v1` | `shekyl-view-tag \|\| output_index_le64` | The scanner checks `view_tag_x25519` first (cheap: one X25519 + one HKDF). On match, it performs full ML-KEM decap and verifies `view_tag_combined` as a cross-check (DoS hardening). **Interim path (legacy, removed):** The C++ `derivation_to_y_scalar` with Keccak domain separator `"shekyl_y"` was used during PR-foundation. As of PR-construct, all construction and scanning paths use the canonical HKDF derivation above via `shekyl_construct_output` (construction) and `shekyl_scan_output_recover` (scanning). The legacy Keccak derivation is no longer used on any consensus-critical path. ### Commitment Mask Independence The Pedersen commitment `C = z*G + amount*H` uses a mask `z` that has **nothing to do with `y`**. In the proof pipeline: - `y` is passed to `OpenedInputTuple::open` (SAL verification of O) - `z` is used to compute `r_c = a - z` for pseudo-out rerandomization - `a` is the desired pseudo-out blinding factor ### Encrypted Amounts Wire Format Per-output encrypted amounts use `enc_amounts` (9 bytes each) instead of the legacy `ecdhInfo` (`ecdhTuple`): ``` [8 bytes: amount XOR ecdhHash(k_amount)] [1 byte: amount_tag] ``` - `ecdhHash(k)` is `cn_fast_hash("amount" || k)`, truncated to 8 bytes - `amount_tag` is derived from HKDF `OutputSecrets.amount_tag` (1 byte) - `ecdhTuple`, `ecdhEncode`, and `ecdhDecode` have been removed from the codebase - Production signing uses `shekyl_sign_fcmp_transaction` which receives pre-computed 9-byte `enc_amount` values from `shekyl_construct_output` ### Encrypted Labels Wire Format (5-T, FA-11) Per-output logical labels use `enc_labels` in `CtSigBase` (9 bytes each), serialized immediately after `enc_amounts` and before `outPk`: ``` [8 bytes: label_plaintext XOR k_label[..8]] [1 byte: label_tag] ``` - Launch default **plaintext** is the sentinel block `0xFF…` (`SENTINEL_PLAINTEXT`); on-wire `enc_label` is `plaintext XOR k_label[..8]` per output — **never** a fixed `0xFF` wire constant (that would fingerprint all non-merchant pays). - `label_tag` is the first byte of HKDF-Expand(`shekyl-output-label-tag` ‖ index); verified at scan like `amount_tag` (integrity / fast-reject only — **not** a cleartext sentinel-vs-tag discriminator). - Included in `serialize_ctsig_base` (transaction binding / prehash). **Not** part of the FCMP++ leaf witness. - Label ciphertext has **no** Pedersen commitment backstop (unlike amounts). Prehash binding is the sole relay-tamper defense; AEAD is redundant once bound. CI: `fcmp.enc_label_binds_ctsig_base_prehash`. - ~~`genRctFcmpPlusPlus` rejects all-zero `enc_labels` outside fake/test device mode~~ — **superseded 2026-08-23 by a type; the guard itself was deleted 2026-08-22 with its host.** Kept as a record because the invariant outlived both. Deleting the guard lost no coverage: `genRctFcmpPlusPlus` had no caller, so the check was unreachable and enforced nothing. The invariant it *stated* was real, though not for the obvious reason — an all-zero *plaintext* is harmless, since `0x00…00 XOR k_label[..8]` is uniform and §5.7.10 holds for it exactly as for the sentinel. What the guard was aimed at is a path that writes the field **without encrypting it at all** (`fill_construct_tx_rct_stub` value-initialises `enc_labels` and never calls the label encryption), which puts a literal `00×9` on the wire, identical across every output, with `label_tag` zero where a derived tag is uniform. The Rust signing path *used to* carry the same shape: `enc_label` was a plain `[u8; 9]` on `OutputInfo`, so an unencrypted value was representable there too. It is not any longer. `EncryptedOutputField` (`shekyl-crypto-pq/src/encrypted_output_field.rs`) has no public byte constructor — in-process construction is `OutputData::enc_label_wire()` / `enc_amount_wire()` (the value `construct_output` assembled at encryption), and `Deserialize` serves the FFI JSON boundary — so on the in-process path an unencrypted field is **unrepresentable**, not merely rejected. Deliberately not implemented as "reject zeros": that treats one symptom, admits any other unencrypted constant, and can fire on a legitimate ciphertext that happens to be zero. - KAT: `PQC_OUTPUT_SECRETS.json` includes `enc_label_sentinel` / `enc_label_sentinel_9` wire octets. - `construct_output` / wallet signing supply pre-computed 9-byte values parallel to `enc_amount`. - **Indistinguishability invariant (normative home: `SUBADDRESS_UNDER_PQC.md` §5.7.10).** `enc_label` octets are computationally indistinguishable from uniform to any non-recipient, *independent of plaintext*; the real-label and sentinel wire distributions are identical. Real-label population is therefore **ungated** (the R2-F8 `cooperative_payment_requests` wallet flag was retired 2026-06-15) — it withholds nothing from an observer that the sentinel does not. The mandatory-uniform-wire pin is unchanged. Enforced by the `real_label_indistinguishable_from_sentinel` statistical KAT. ### Witness Header (256 bytes) ``` [O:32][I:32][C:32][h_pqc:32][x:32][y:32][z:32][a:32] ``` | Field | Purpose | |-------|---------| | O | Output public key (curve tree leaf) | | I | Key image generator Hp(O) | | C | Pedersen commitment (curve tree leaf) | | h_pqc | H(ml_dsa_pk) PQC leaf binding | | x | SAL spend secret key (`ho + b_spend`) | | y | SAL output-key secret (HKDF-derived) | | z | Pedersen commitment mask (HKDF-derived) | | a | Pseudo-out blinding factor | Assembly is performed by `shekyl_fcmp_build_witness_header` in Rust via a typed `ProveInputFields` `#[repr(C)]` struct, replacing raw `memcpy` calls. ### Security Properties - **Spend-auth vs discovery**: A view-key holder can compute `y` (derived from the shared secret), so `y` alone does not provide spend/view separation. Full separation requires a Carrot-style address scheme (V4 consideration). The y-component binds the key image to a recipient-controlled blinder, which is the SAL design requirement. - **FROST SAL multisig**: With `y` derived from the shared secret (not a FROST group key), the FROST SAL path operates with a per-output T-component. Gate behind "requires two-component address scheme" until V4. - **Coinbase commitment mask**: `zeroCommit(amount) = G + amount*H`, so the mask scalar is 1 (`ct::identity()`), not 0. --- ## 21. Wallet Proof Structure Shekyl wallet proofs (transaction proofs and reserve proofs) are a genesis-native design. There is no prior version, no migration path, and no backward-compatibility shims. The design draws on Monero's proof protocols as prior art but differs structurally due to the hybrid KEM construction. This section explains the design rationale so auditors familiar with Monero's DLEQ-based proofs understand why Shekyl's proofs use a different construction and why the difference is not a regression. ### Why Monero's DLEQ does not apply In Monero, the sender proves they constructed a transaction by demonstrating knowledge of the discrete log behind both `tx_pubkey` (the on-chain Diffie-Hellman ephemeral key) and the shared secret derivation base. A single DLEQ proof does double duty: it proves sender identity *and* binds the proof to a message. Shekyl replaces Diffie-Hellman with hybrid KEM (X25519 + ML-KEM-768), so there is no discrete-log relationship between the transaction key and the shared secret. The DLEQ construction is inapplicable, not merely unnecessary. ### Shekyl's decomposition Shekyl decomposes sender authentication and proof integrity into two independent mechanisms: - **KEM recomputation** handles "the prover really is the sender." The sender reveals `tx_key_secret` (a per-transaction ephemeral value, not a long-term key). The verifier re-runs deterministic KEM encapsulation with that key and checks that the resulting X25519 ephemeral public key and ML-KEM-768 ciphertext match the on-chain values. A match proves the prover produced the transaction's KEM material. - **Ed25519 Schnorr signature** handles "the proof contents are not tampered." The signature is bound to `H(domain_separator || txid || address || message || per_output_data)`, making the proof a sealed unit. Anyone holding the revealed `tx_key_secret` can produce a *different* proof for a different message, but they cannot take an existing proof and modify a field without invalidating the signature. The signature protects the proof from the verifier (or any third party who later obtains the key), not from the world. Each proof type uses a distinct domain separator in the Schnorr hash to prevent cross-type replay: | Proof Type | Domain Separator | |---|---| | Outbound TX | `shekyl-outbound-tx-proof-v1` | | Inbound TX | `shekyl-inbound-tx-proof-v1` | | Reserve | `shekyl-reserve-proof-v1` | ### Reserve proofs and the DLEQ requirement TX proofs assert "outputs were sent to / received by an address with certain amounts." Reserve proofs assert "outputs are owned and **unspent**." The unspent claim requires proving that the key image included in the proof was correctly derived: `key_image = (ho + b) · Hp(O)`. Without this proof, a malicious prover can substitute a random 32-byte value as the key image; the verifier checks the spent pool, finds no match, and incorrectly concludes the output is unspent. This is a reserve-proof forgery — the class of attack that breaks exchange proof-of-solvency. Reserve proofs therefore carry a standard two-base Schnorr DLEQ (64 bytes per output: challenge `c` + response `s`) proving `DL_G(P) == DL_{Hp(O)}(I)` where `P = O - y·T` and `I = key_image`. TX proofs do not need a DLEQ because they make no claim about spent status. The DLEQ challenge hash includes the bases, points, and a domain separator to prevent cross-protocol confusion: ```text c = H("shekyl-reserve-proof-dleq-v1" || G || Hp(O) || R1 || R2 || P || I || msg) ``` `G` is a constant but is included for completeness. `Hp(O)` varies per output and its inclusion is mandatory to prevent base-substitution attacks across different outputs. ### Wire Format | Proof Type | Header | Per-Output | Total | |---|---|---|---| | Outbound TX | 101 B (version[1] + tx_key_secret[32] + Schnorr[64] + count[4]) | 128 B (ho, y, z, k_amount) | `101 + 128*N` | | Inbound TX | 69 B (version[1] + Schnorr[64] + count[4]) | 128 B (ho, y, z, k_amount) | `69 + 128*N` | | Reserve | 69 B (version[1] + Schnorr[64] + count[4]) | 192 B (ho, y, k_amount, key_image, DLEQ) | `69 + 192*N` | The `output_count` field is 4 bytes (little-endian u32), supporting up to 2³² − 1 outputs per proof. TX proofs will typically have single-digit outputs. Reserve proofs from large wallets (exchanges doing proof-of-solvency) may contain thousands of entries; the 4-byte count avoids an artificial 65,535 cap that would require proof splitting under pressure. ### Proof version assertion The `version` byte is 1 for all current proof types. The verifier **must** check `version == CURRENT_PROOF_VERSION` as the very first operation, before any cryptographic work. On mismatch, the verifier returns an immediate error with a human-readable message. There is no version negotiation, no graceful degradation, and no fallback. A version mismatch is a developer error, not a user-recoverable condition. ### TX proofs carry `z`; reserve proofs do not TX proofs include the Pedersen commitment mask `z` (32 bytes per output) to enable direct commitment verification `C = z·G + amount·H`. This is defense-in-depth: 32 bytes per output on a proof with single-digit outputs is negligible, and the explicit check catches any mismatch between the decrypted amount and the on-chain commitment. Reserve proofs omit `z` to save 32 bytes per output (192 vs 224 per entry). This asymmetry is justified because reserve proofs can have hundreds or thousands of entries, and the saving is material. The soundness argument for omitting `z` follows. **HKDF binding argument (why omitting `z` from reserve proofs is sound):** The per-output secrets `ho`, `y`, `z`, and `k_amount` are all derived from the same HKDF-SHA-512 stream keyed by `combined_ss` with per-label domain separation (labels pinned in `docs/test_vectors/PQC_OUTPUT_SECRETS.json`). The verifier checks `O == ho·G + B + y·T` against the on-chain output key `O`. If this check passes, the `ho` and `y` in the proof are the correct HKDF outputs for this specific `combined_ss` and output index. Because `k_amount` is derived from the same HKDF stream with a different label, providing a *wrong* `k_amount` requires providing a different `combined_ss`, which forces a different `ho` and `y`, which fails the algebraic `O` check. The prover **cannot** decouple `k_amount` from `(ho, y)` without breaking HKDF. The decrypted amount is then `amount = enc_amount XOR k_amount[..8]`. Since `k_amount` is forced correct by the HKDF binding, and `enc_amount` is read from the blockchain (see below), the decrypted amount is the genuine amount committed to in `C`. The on-chain Bulletproofs+ consensus check independently guarantees `C` commits to a value in `[0, 2⁶⁴)`. **Critical invariant: `enc_amount` must be fetched from the blockchain.** The reserve proof does **not** carry `enc_amount` as a field. The verifier reads `enc_amount` from the on-chain transaction, indexed by the output reference in the proof. If the proof carried `enc_amount`, a malicious prover could provide a manipulated `enc_amount` alongside a manipulated `k_amount` such that the XOR produces any desired amount — the HKDF binding argument would not protect against this because the verifier's XOR input would be attacker-controlled. Implementations must assert that `enc_amount` comes from chain lookup, not from the proof. --- ## Related Documents - `docs/POST_QUANTUM_CRYPTOGRAPHY.md` — full PQC specification - `docs/PQC_MULTISIG.md` — multisig scheme (`scheme_id = 2`) - `docs/AUDIT_SCOPE.md` — 4-scalar leaf circuit security audit scope - `tests/stressnet/README.md` — stressnet operational guide (pre-audit gate) - `src/shekyl/shekyl_ffi.h` — FFI declarations - `src/fcmp/ct_semantics.h` — CT semantics verification declarations (`verCtSemantics*`, `get_tx_prehash`) - `rust/shekyl-fcmp/` — Rust FCMP++ proof implementation - `rust/shekyl-crypto-pq/` — PQC primitives, KEM, address encoding