Shekyl Stats

Refreshing...

Network

Connected
Seed Nodes Active
--

Chain

Current Block
0
Network Height
—
Top Block Hash
--
Block Time Target
2 min

Rewards

Last Block Reward
0.000000 SKL
Difficulty
0
Estimated Hash Rate
0 H/s

Supply

Circulating Supply
--
Remaining Supply
--
Total Burned
0 SKL

Economics

Release Multiplier
0
Burn Rate
0%
Economy Fee
--
Standard Fee
--
Priority Fee
--

Staking

Stake Ratio
--
Staker Pool
0.000000 SKL
Staker Emission Share
0%
Total Staked
N/A
Staking Height
N/A

Protocol

Transaction Format
TransactionV3
Membership Proof
FCMP++
Spend Auth
Ed25519 + ML-DSA-65
Confidentiality
Stealth + BP+

Node

TX Pool Size
0
Database Size
0 B
Node Version
--
Sync Status
Syncing
All Documentation

PQC Multisig

M-of-N post-quantum multisig: signature-list design, security model, and size analysis.

PQC Multisig V3.1: Equal-Participants Multisig with Per-Output Forward Privacy

Status: DRAFT v1.1 — incorporates three rounds of adversarial wargame feedback and the resolved prover-verification mechanism Supersedes: PQC_MULTISIG.md (original), the standalone V3.1 governance and receiving drafts, and v1.0 of this consolidated spec Companion: PQC_MULTISIG_V3_1_ANALYSIS.md (size analysis, attack catalog, cryptographer review targets, design rationale) Consensus impact: the scheme-2 key blob and its check (CEN-I16, CEN-I18), the leaf key scalar of a scheme-2 spend (CEN-I15), and the 0x0D clause of CEN-I19 (§7.4–§7.5).


Table of Contents

  1. Purpose and Scope
  2. Design Principles
  3. Threat Model
  4. Roles
  5. Group Setup
  6. Address Format
  7. Receiving Outputs
  8. Wallet Scanning and Receive-Time Validation
  9. Spend Intent
  10. Canonical Construction
  11. Spending: Prover and Signing
  12. Messages and Transport
  13. State Machine and Counter Recovery
  14. Security Properties
  15. Forward Compatibility
  16. Implementation Plan
  17. Appendix A: Canonical Test Vectors
  18. Appendix B: Mapping from Original Spec

1. Purpose and Scope

Shekyl V3.1 multisig replaces the original coordinator-based design with an equal-participants model that:

  • Eliminates the central coordinator role as a power center
  • Achieves deterministic transaction construction
  • Provides per-output forward privacy on the receive side (Option C model)
  • Composes cleanly with the existing scheme_id = 2 consensus rules

This document is the single source of truth. It supersedes the original PQC_MULTISIG.md and the two split drafts that were merged into v1.0, as well as v1.0 itself.

No consensus changes are made by V3.1. All bindings, checks, and authorizations rely on rules and code paths already present in V3.


2. Design Principles

  1. Equal participants under Option E′. Proposal, construction, signing, and assembly are shared. There is no mandatory prover. (Rotating-prover / Option D scaffold is deleted — see §4 / §11.)
  2. Deterministic construction. Given a spend intent and a committed chain snapshot, every participant produces byte-identical transaction bytes. There is no interface latitude.
  3. Per-output forward privacy. Each output to a multisig group derives N fresh ephemeral hybrid keypairs. Two spends from the same group are cryptographically indistinguishable from spends by two different groups of the same cardinality.
  4. No consensus changes. Every binding is achievable within existing V3 rules. The worst-case failure of a wallet-layer bug is a failed broadcast, never a chain split.
  5. Get it right — defer speculative crypto, ship E′. In V3.1 scope: Option E′ threshold FROST SAL on y (spend_auth_version = 0x02; §15.4a). Out of V3.1 scope: the deleted Option D mandatory-prover / 1/N-loss scaffold; the full key rotation protocol (V3.2); chain-anchored group registries (V3.3+); composite / lattice-only auth size (§15.4b, waits on lattice-threshold maturity beyond IR 8214C). Authorization is already PQ (M × ML-DSA); do not read "deferral" as "multisig is classical," and do not read §15.4a FROST-on-y as "out of scope."
  6. Honest-signer protocol invariants. Where consensus cannot enforce a property without a hard fork, the property is enforced at the wallet layer by honest signers. These invariants are enumerated in §2.7 and each one is made mechanically unbypassable in supported client stacks.
  7. Forward-compatible primitives. Cryptographic primitives that might change in future versions (spend-auth keys, auth schemes) are abstracted behind a version byte so future schemes slot in without protocol rewrites.

2.7 Honest-Signer Invariants (Authoritative List)

Several security properties in V3.1 cannot be enforced at the consensus layer without a hard fork. These properties are instead enforced at the wallet layer by honest signers. This section is the authoritative list of such invariants; the signing path in supported wallets MUST enforce each one before producing a signature.

#InvariantEnforced in§reference
I1Spend intent passes all invariant checks in §9.2Pre-signing verification§9.2
I2Chain state fingerprint matches signer's local viewPre-signing verification§9.3
I3FCMP++ proof binds to signer's independently-computed signing payloadPre-signing verification§11.3
I4BP+ range proofs verify against signer's independently-computed commitmentsPre-signing verification§10.2
I5Output public key O matches the assigned prover's spend-auth pubkeyPre-signing verification§11.3
I6tx_hash commitment agrees across all M SignatureShares before assemblyPre-assembly verification§11.5

Implementation requirements:

  • Each invariant MUST be checked in the core signing path. It MUST NOT be possible to produce a valid SignatureShare without the corresponding check having executed and returned success.
  • On any invariant violation, the wallet MUST:
    1. Abort the signing operation
    2. Publish a signed InvariantViolation message (type 0x09; see §12.2.6)
    3. Move the intent to REJECTED state
  • Wallets MUST NOT expose --unsafe-skip-verification flags or equivalents. Tests that bypass invariants MUST be gated behind compile-time feature flags that are excluded from release builds.
  • Interop test suite (see §16.8) MUST include cases where a malicious client attempts each invariant violation; all conforming clients MUST reject identically.

This list is closed. New invariants require a spec update and reviewer sign-off before addition.


3. Threat Model

3.1 In scope

AdversaryCapabilitiesDefended by
Malicious senderConstructs outputs to grief recipients§8.2 multisig check: the output is received but unspendable and named (§7.6); a sender can always burn an output
Malicious group member (single)Tries to spend alone, redirect funds, or DoSM-of-N threshold; §2.7 I5 honest-signer prover verification; veto
Malicious proverTries to construct invalid or substitute proof§2.7 I3, I4 signer-side proof verification before signing
Malicious assemblerTries to broadcast tampered tx§2.7 I6 tx-hash commitments
Network observerTries to identify groups, link spends§6 file-based addresses; §7 per-output ephemeral keys; §12 encrypted transport
Malicious relay operatorDrops, reorders, injects messages§12.4 mandatory multi-relay with operator uniqueness; §13.3 heartbeat protocol
Network partitionCauses state divergence§13.4 CounterProof recovery
Scanner resource exhaustionBurns scanner CPU via griefing outputs§7.6 per-sender griefing scores + hard caps

3.2 Out of scope

ThreatReason
M-of-N collusionDefeats any multisig by definition
Compromise of group's enduring KEM private keysCatastrophic by design; mitigated by V3.2 full rotation
Quantum break of both ML-KEM and X25519 simultaneouslyThe hybrid scheme's whole point
Permanent loss of a participant's keys1/N of group's outputs become unrecoverable; documented limitation; V3.1 requires setup-time acknowledgment per §5.4
FCMP++ prover liveness on permanent participant loss1/N of outputs locked; V4 FROST SAL fixes
Selective disclosure by M signers to outside auditorInherent to any threshold scheme

3.3 Accepted but bounded threats

Griefing via malformed multisig output. A malicious sender can construct outputs that appear to target a multisig group (correct tx_extra fields, correct group-id claim) but whose KEM ciphertexts or spend-auth pubkey bindings do not correspond to the group's real keys. The recipient's wallet attempts decap and receive-time validation, rejects, and the output is discarded. The attacker pays the fee; the recipient gets nothing.

This attack is bounded by attacker fee cost. §7.6 specifies scanner- side resource limits (per-sender griefing scores, hard caps, 7-day cooldowns) to bound the scanner CPU cost of sustained griefing. A consensus-layer fix would require a chain-anchored group registry (V3.3+ candidate); accepted as residual risk for V3.1.

3.4 Attacks mitigated by previous work

AttackMitigation
Scheme downgrade (output committed scheme_id=2, spent as scheme_id=1)§7.5 indirect binding via the leaf commitment opening (PL-D3) + pqc_auth size check; expected_scheme_id for defense in depth (the expected_group_id leg is deleted — §5.3)
Key substitution within a groupExisting verify_multisig Check 8 (key uniqueness)
Signer index manipulationverify_multisig: indices in range, ascending, and exactly the key blob's bitmap (§7.5)
Key substitution by a signerverify_multisig: each revealed key must hash to the committed h_i at its index (§7.5)
Blob truncation/paddingExact-length parses in MultisigKeyBlob / MultisigSigContainer; CEN-I16's floor and ceiling
Replay across groupsaddress-fingerprint binding in the canonical signing intent (I1; §5.3 — replaces the retired group_id)
Replay within groupintent_id + tx_counter + expires_at + reference_block_hash + kem_randomness_seed freshness

4. Roles

Product path (Option E′): roles are Proposer / Signer / Assembler only. There is no mandatory Prover. Sections that still name a rotating Prover (and I7 / ProverReceipt / grinding) describe the deleted Option D scaffold — retained for archaeology until those sections are rewritten; they are not operational guidance for E′.

RoleAuthorityWhoAdversarial bound
ProposerPublishes signed spend intentAny group memberSigners veto by refusing to sign
SignerProduces hybrid signature over canonical payload; FROST share on yAny M of the N membersCannot individually authorize; needs M−1 collaborators
AssemblerCollects M signatures, broadcastsAny group member with M sigsCan only broadcast what signers produced
ProverFCMP++ proof for a specific outputOption D only — deleted—

Under E′, FCMP++ proving is not a privileged single-member role: the group constructs proofs under the shared spend path without a 1/N permanent-loss assignment. V4 lattice work does not reintroduce that role.


5. Group Setup

5.1 Group parameters

A group is defined by:

  • n_total: total signers, 1 ≤ n_total ≤ MAX_MULTISIG_PARTICIPANTS
  • m_required: threshold, 1 ≤ m_required ≤ n_total
  • group_version: 0x01 for V3.1 (reserved for future rotation)
  • spend_auth_version: 0x02 for Option E′ (product path — threshold FROST SAL on y; dealer-mode). 0x01 is never issued (mandatory-prover Option D scaffold is deleted, not shipped). A later mutually-distrusting DKG ceremony mode, if any, gets its own version byte — do not overload 0x01.
  • N hybrid signing keypairs (Ed25519 + ML-DSA-65), one per participant
  • N hybrid KEM keypairs (X25519 + ML-KEM-768), one per participant

MAX_MULTISIG_PARTICIPANTS (MSW-G) = 5 (decided 2026-07-15 overhaul; withdraws same-day provisional 8).

Enforced. MAX_MULTISIG_PARTICIPANTS = 5 in rust/shekyl-crypto-pq/src/multisig.rs, mirrored in shekyl-address and shekyl-wire under compile-time pins. The address, the key container, the 0x0D entry and the dealer split each refuse n > 5.

Derivation: MAX = 2f+1 at f=2 — classical majority-threshold BFT for the largest group we intend to serve. Consumer: largest group served. Not a resource bound, reward-zone fill, or power-of-two bias fix. The operator chooses m-of-n ≤ MAX; MAX is the largest n tooling ships. Rule-21 reopen: only if a named consumer requires n>5 and zone/address usability are dispositioned. See V3_1_MULTISIG_RUST_ENGINE.md §0.3.

5.2 Distributed Key Generation — not the ship default

Product path (Option E′, §15.4a / design §0.5): the owner is the trusted dealer. Generate b and y_group, Shamir-split y_group, write participant files (§6.6). No DKG. TRaccoon-class papers assume the same trusted KeyGen. The split is shekyl_fcmp::frost_sal::dealer_split: Shamir over the Ed25519 scalar field at x = 1..=n, verification shares share_i·T, y_group gone once the shares exist.

Mutually-distrusting parties (buyer/seller/arbitrator) MAY get a DKG ceremony behind a later spend_auth_version / ceremony mode — not 0x02, and not compiled into the dealer-mode default.

When that mode exists:

  • Production wallet builds MUST NOT compile a path that distributes the threshold spend secret without the ceremony that mode requires.
  • A transport-only shared secret (message AEAD), if any, is distinct from y_group and must not be confused with spend authorization.

(Older prose requiring DKG for every V3.1 group applied to mandatory- prover Option D and is withdrawn for the E′ product path.)

5.3 Group identity — the address fingerprint (group_id retired)

The group's identity is the address fingerprint (§6.3): frame("shekyl/multisig-address-v3", name_id, [canonical(MultisigAddressPayload)]), where name_id is the network's name binding: a group is derived from keys and addresses, which exist before any genesis, so it binds the name, not the genesis-derived id (NETWORK_IDENTITY.md NI-R15). It is per-group, computable by every participant from the address alone, and covers the group's whole public state (B_group, Y_group, the N KEM pubkeys, both version axes, m/n). Four independent implementers exchange the address file and compare the 67-char fingerprint string: same bytes → same fingerprint, or the address is wrong.

The former multisig_group_id — Keccak-256 over the leaf MultisigKeyContainer — is deleted. It had no consumer under E′; it hashed the container's per-output KEM-derived keys, so it produced a different value for every output while being named for the group; and its one long-term caller (wallet2::create_pqc_multisig_group) threw on every input. Deleted with it: verify_multisig's check 9 + the expected_group_id parameter, the FFI shekyl_pqc_multisig_group_id / shekyl_pqc_verify_with_group_id, and DOMAIN_SEP_V31. Verify is now a 9-check pipeline (FFI error code 9 retired, 10/11 unchanged). Version-axis binding survives in the fingerprint: the canonical payload includes spend_auth_version, so a version change still yields a distinct identity.

5.4 1/N permanent-loss acknowledgment — withdrawn for E′

Mandatory-prover Option D required a 1/N loss acknowledgment (assigned prover holds per-output y). Option E′ deletes the mandatory prover — threshold FROST on y_group means any M honest devices can spend. The §5.4 acknowledgment UI is not part of the E′ product path.

(If a future mutual-distrust mode reintroduces a single-holder liveness dependency, restore an acknowledgment under that mode's version byte — do not revive it for 0x02.)

5.5 Setup ceremony (informative summary)

Concrete steps for participants forming a new group:

  1. Each participant generates fresh hybrid signing and KEM keypairs.
  2. Participants exchange signing public keys and KEM public keys via authenticated out-of-band channels (cryptographic verification: each participant signs a setup attestation with their hybrid signing key over the canonical encoding of all participants' public keys).
  3. Each participant independently computes the address fingerprint (§5.3 / §6.3) and verifies all others derived the same 67-char string.
  4. Participants jointly run the DKG ceremony for group_shared_secret.
  5. Each participant constructs the full multisig address locally; all should produce byte-identical addresses.
  6. Each participant completes the 1/N risk acknowledgment (§5.4).
  7. Address is exported as a file (too large for QR/clipboard at most N values).
  8. Participants store group state: their own keypairs, the N pubkeys of others, the address fingerprint (§5.3), group_version, spend_auth_version, threshold parameters, DKG-derived shared secret, acknowledgments, and an initial tx_counter = 0.

6. Address Format

6.1 Bech32m encoding with new HRP

Multisig addresses use a new Bech32m human-readable prefix:

single-sig:     shekyl1<classical+tag>/<pqc_a>/<pqc_b>   (full string; POST_QUANTUM_CRYPTOGRAPHY.md §Address Format)
single-sig tn:  tshekyl1<...>/<...>/<...>                (classical HRP `tshekyl`; PQC `tskpq`/`tskpq2`)
multisig:       shekyl1m1<fingerprint 32B>               (names the fingerprint, not the payload; §6.2)
multisig tn:    shekyltest1m1<fingerprint 32B>

The single-sig address is a three-segment full string (each segment under Bech32m's 1023-char bound; the classical segment carries the ek_bind_tag). The multisig payload is far past that bound and is file-based — so the shekyl1m HRP encodes the fixed 32-byte fingerprint (67 chars, §6.2/§6.3), never the group payload. The visible m suffix prevents wallet confusion. Wallets MUST type-check the HRP at parse time.

Reserved: shekyl1n... (rotated-key multisig, V3.2+). Do not issue.

6.2 Multisig address payload

MultisigAddressPayload {
    version:             u8   (= 0x02)
    group_version:       u8   (= 0x01)
    spend_auth_version:  u8   (= 0x02, Option E′)
    network_byte:        u8
    n_total:             u8   (1..=MAX_MULTISIG_PARTICIPANTS = 5)
    m_required:          u8   (1..=n_total)

    b_group:             [u8; 32]   // B = b·G. Every member holds b.
    y_group:             [u8; 32]   // Y = y·T. y is FROST-shared, M-of-N.

    group_kem_pubkey:    HybridKemPubkey
    // X25519 (32 B) + ML-KEM-768 (1184 B) = 1216 B. Every member holds the
    // secret. A payer's one 0x06 ciphertext per output is to this key.

    hybrid_kem_pubkeys:  [HybridKemPubkey; n_total]
    // Each member's own key, member order (participant index 0..n_total).
    // The per-member 0x0D ciphertexts are to these keys.
}

The wire order is the field order. A payer needs B and Y to build O = ho·G + B + (y_group + y_kem)·T, and both kinds of KEM key to build the output's 0x06 and 0x0D fields (§7.1). The points are opaque compressed Edwards points in the address; the output constructor checks that each is a prime-order, non-identity point. There is no stored checksum: integrity is the fingerprint (§6.3). A version other than 0x02 is refused.

The X25519 half of every KEM key is one canonical Montgomery u-coordinate: the integer is strictly less than 2^255 − 19, so bit 255 is clear. X25519 ignores that bit and reduces modulo the prime, and the address compares keys as bytes. A second encoding of the group key would be a different byte string and the same point, and the group secret would open that member's slot. new, from_canonical_bytes and the output constructor refuse it, and they refuse a key that repeats another.

Canonical payload: 70 + (N + 1) × 1216 bytes (HEADER_LEN(6) + 2 × GROUP_POINT_LEN(32) + (1 + N) × 1216). This is the fingerprint preimage and the file contents.

The full address is file-based, not a Bech32m string. At these sizes it is far past Bech32m's ~1023-character checksum-validity limit, so the Bech32m string form (§6.1) encodes the fingerprint (32 B → 67 chars), the short identifier participants compare.

NCanonical bytes
12,502 B
23,718 B
34,934 B
57,366 B

6.3 Address handling, fingerprint UX, and provenance

Wallets MUST handle multisig addresses via file export and import: canonical payload written to a file, transferred via authenticated channel, imported at recipient end.

Fingerprint display: wallets MUST display a 32-byte fingerprint during send confirmation:

address_fingerprint = frame("shekyl/multisig-address-v3", name_id,
                            [canonical(MultisigAddressPayload)])
name_id = cSHAKE256(S = "shekyl/network-name-v1", X = "shekyl/<network>")[..16]

frame is the signing envelope (NETWORK_IDENTITY.md §2.4.2): cSHAKE256 under the domain, over the 16-byte binding then each field behind its u64 little-endian length.

Vectors: docs/test_vectors/MULTISIG_FINGERPRINT_V3.json, rebuilt by scripts/kat/multisig_fingerprint_oracle.py.

The address is on no consensus path (no C++ mirror, no FFI, no leaf), so nothing requires byte-identity with the daemon — the only reason the consensus-parity Keccak-256 primitive (Rust shekyl_crypto_hash::keccak256, C++ cn_fast_hash) exists. The address instead has multiple independent implementers, for whom "Keccak-256" is ambiguous (original 0x01 vs SHA3 0x06 padding fail silently); cSHAKE256 has one meaning and its customization string makes domain separation structural. This fingerprint is also the group's identity — it retires multisig_group_id (which has no consumer under E′ and hashes a per-output container under a per-group name; the code removal is the cross-language follow-up slice). With B/Y in the payload it covers the group's whole public state.

The fingerprint MUST be displayed in three parallel representations:

  1. Hex format (64 characters, grouped as 4-char blocks for reading)
  2. Grouped-word checksum phrase (derived from the fingerprint via a fixed wordlist; 10-word phrase, stable, deterministic, for human verbal verification)
  3. Structured metadata badge: (m)-of-(n), spend_auth v(X), group v(Y)

Provenance tracking: wallets MUST persist address provenance in local state:

AddressProvenance {
    address_fingerprint:    [u8; 32]
    first_imported_at:      u64
    imported_from_source:   string  (file path, URL, QR, etc.)
    user_assigned_label:    string
    last_used_at:           u64
    prior_fingerprints:     [[u8; 32]]  // history of changed fingerprints for same label
}

Dual confirmation on changes: when a user initiates a payment to a label that previously resolved to a different fingerprint, the wallet MUST require dual confirmation:

  1. Display the new fingerprint prominently alongside the old
  2. Display the user-assigned label and warn that the underlying address has changed
  3. Require a second confirmation step (e.g., typing "CONFIRM CHANGED")

This protects against address file substitution attacks where an attacker swaps a victim's address file between payments.

6.4 Mandatory fingerprint verification UI

For every multisig send, the sender's wallet MUST:

  1. Compute and display the recipient address fingerprint (all three representations from §6.3)
  2. Require explicit user confirmation that the displayed fingerprint matches what the recipient communicated out-of-band
  3. Refuse to construct the transaction if confirmation is not given

This is the primary defense against social-engineering attacks on multisig addresses.

6.5 Future: chain-anchored group registry

A V3.3+ candidate enhancement would add a CreateGroup transaction type that commits a group's pubkeys on-chain at a short identifier. Addresses would reference the on-chain group by short hash (~100 B address). This is explicitly out of V3.1 scope and would be a consensus change.

6.6 The member file

The dealer gives each member one file (shekyl-multisig::member_file, layout version 1). It holds everything that member needs to receive and to sign:

FieldSizeWhat it is
magic, version4 + 1 B"SKMF", 1
address payloadu32_le length + §6.2 payloadthe group's public state and identity
member_index1 B0..n
b32 Bthe group-plaintext spend scalar
y_share32 Bthis member's share of y_group
verification sharesn × 32 Bevery member's, member order
group KEM secret2432 BX25519 (32) ‖ ML-KEM-768 decapsulation key (2400)
member KEM secret2432 Bthis member's own, same layout
transport secret32 Bkeys the ceremony blobs (§12)

y_share is the Shamir share at FROST participant member_index + 1. shekyl_fcmp::frost_sal::threshold_keys loads it, with the verification shares and the address's Y_group, into the member's ThresholdKeys<Ed25519T>, and refuses a file whose shares would not sign: a non-canonical share, a verification share that is not a prime-order point, a share that is not its own verification share's discrete log, shares that do not open Y_group, and a share off the group's polynomial. Each refusal is its own FrostKeyError variant.

The transport secret is shekyl_crypto_pq::derivation::derive_group_transport_secret: HKDF-SHA-512 with salt salt_for(network) (the network's name_id, then shekyl-master-derive-v3), input keying material the dealer's 32-byte group seed, and info shekyl/multisig-transport-v1. The dealer derives it once and writes the same value into every member file. The file stores the bytes; it does not derive them.

This is the plaintext serialization. Keeping the file encrypted at rest is the wallet's job, as for every other key file. Every secret is wiped on drop, and the type's Debug prints none of them.


7. Receiving Outputs

7.1 Output construction

A multisig output is a solo output to the group, plus one entry per member. The constructor is shekyl-crypto-pq::multisig_output::construct_multisig_output.

def construct_multisig_output(tx_key_secret, address, amount, output_index, network_id):
    assert output_index <= 255            # the 0x0D entry names it in one byte

    # The solo part: the solo output core, to the group.
    spend_key = address.b_group + address.y_group           # checked prime-order, non-identity
    ct_group, ss_group = encapsulate(tx_key_secret, address.group_kem_pubkey, output_index)
    ho, y_kem, z, k_amount, k_label = derive_output_secrets(ss_group, output_index)
    O = ho·G + spend_key + y_kem·T       # = ho·G + B + (y_group + y_kem)·T
    C = z·G + amount·H
    enc_amount, enc_label = encrypt(k_amount, amount), encrypt(k_label, SENTINEL)

    # The per-member part.
    cts, hashes = [], []
    for i in range(address.n_total):
        ct_i, ss_i = encapsulate(tx_key_secret, address.hybrid_kem_pubkeys[i], output_index)
        pk_i = member_keypair(ss_i).public                   # §7.2
        hashes.append(frame32("shekyl/multisig-key-hash-v1", network_id, [pk_i]))
        cts.append(ct_i)
        # ss_i and the keypair are wiped here.

    committed = 0x02 ‖ n ‖ m ‖ hashes[0] ‖ … ‖ hashes[n-1]  # 3 + 32·n bytes

    # The leaf commits to the committed form, blinded from the group secret.
    (CM, record) = pqc_leaf_commitment(ss_group, output_index, committed)

    # The hashes travel encrypted under the group secret.
    key = derive_multisig_key_hashes_key(ss_group, output_index)
    enc_hashes = ChaCha20Poly1305(key).encrypt(nonce = 0^12,
                                               msg = hashes[0] ‖ … ‖ hashes[n-1],
                                               aad = u8(output_index) ‖ u8(n))

    return O, C, enc_amount, enc_label, ct_group,     # the solo fields; ct_group is the 0x06 entry
           (CM, record),                              # the 0x07 entry
           (output_index, n, cts, enc_hashes)         # the 0x0D entry

The solo constructor's per-output PQC key is never made for a multisig output. Every member holds the group secret and could derive it, so a leaf over it would let any one member satisfy the PQ leg alone.

Why the hashes are encrypted. A spend reveals the committed form (§7.5). In the clear at receive time, the hashes would match it and link the spend to its output.

Why the hash key needs no nonce. The key is derived per output from a fresh KEM secret and the output index, and encrypts one plaintext.

7.2 Derivations

ValueFromLabel or domain
ho, y_kem, z, k_amount, k_label, leaf blindsthe group secret ss_groupthe solo shekyl-output-* labels, unchanged
hash AEAD keyss_groupshekyl-output-multisig-key-hashes (same PRK as the solo labels)
member i's per-output signing keypairmember i's secret ss_iHKDF-SHA-512(ikm = ss_i).expand("shekyl-v31-hybrid-sign", 64): bytes 0..32 seed Ed25519, 32..64 seed ML-DSA-65
h_ipk_iframe(shekyl/multisig-key-hash-v1, network_id, [pk_i]), 32 B

h_i binds the chain's network id because consensus compares it at every spend (§7.5). Every label is registered in CRYPTO_DOMAIN_REGISTRY.tsv.

7.3 Deterministic encapsulation

Every encapsulation in a multisig output, the group's and each member's, uses the solo deterministic encapsulation. Its per-recipient seed is derive_kem_seed(tx_key_secret, recipient_x25519, recipient_ml_kem, output_index), which binds the recipient's two public keys. So two recipients of one output never share encapsulation randomness: one member decapsulating its own ciphertext learns nothing about another member's.

tx_secret_key MUST be freshly generated per transaction.

7.4 tx_extra

Per transaction:

TagNamePayload
0x06TX_EXTRA_TAG_PQC_KEM_CIPHERTEXTexactly one field, 1120 × n_outputs B: a multisig output's entry is its group ciphertext, the same shape as a solo output's
0x07TX_EXTRA_TAG_PQC_LEAF_ENTRIESexactly one field, 64 × n_outputs B: a multisig output's entry is CM ‖ record over the committed form
0x0DTX_EXTRA_TAG_PQC_MULTISIGat most one field: one entry per multisig output, strictly ascending by output index

A 0x0D entry is

out_idx(1) ‖ n(1) ‖ ct_1 … ct_n (1120 B each) ‖ enc(h_1 … h_n) (32·n B) ‖ tag (16 B)

2 + 1152·n + 16 bytes. It carries n so the field parses without the address. CEN-I19's multisig clause (check_pqc_multisig_shape) refuses a second 0x0D field, an empty one, entries out of order or repeated, and an entry naming an output the transaction does not have. The parser refuses n outside 1..=5 and a short or long entry. A coinbase's closed grammar does not admit 0x0D, so a coinbase cannot pay a group.

Cost per multisig output, across the three fields: 1120 + 64 + 2 + 1152·n + 16 bytes, about 4.7 KB at n = 3 and 7.0 KB at n = 5. The n per-member ciphertexts are what keep the PQ leg M-of-N; a key every member could derive would make it 1-of-N. Under the 24,576-byte relay cap on extra (CEN-M4), one transaction carries at most five multisig outputs to a 3-member group, or three to a 5-member group, beside a change output.

Privacy. A 0x0D field says that a transaction pays a multisig group, and each entry gives the group's size. That is inherent to Option E′. The entries carry nothing linkable to the group across transactions: the ciphertexts are fresh per output and the hashes are encrypted.

Retired and reserved tags (never reused; each parses as unknown):

TagDisposition
0x05Retired. A per-output (scheme_id, group_id) entry with no producer and no reader.
0x08RESERVED for the group-rotation / migration transaction; producer undesigned (rule 23).
0x09Retired. Per-member view-tag hints, never produced; 0x0D replaced it.
0x0ARetired. Per-member spend-auth pubkeys, never produced; 0x0D replaced it.

7.5 Spend-time consensus binding

The key blob a multisig spend reveals in pqc_auths[i].hybrid_public_key (scheme 2) is

committed form (0x02 ‖ n ‖ m ‖ h_1 … h_n) ‖ signer_bitmap(1) ‖ pk_{i_1} ‖ … ‖ pk_{i_m}

3 + 32·n + 1 + 1996·m bytes; the bitmap has exactly m bits set, none at or above n, and the keys follow in ascending member order. The signature blob is sig_count ‖ m × HybridSignature ‖ m × index, its indices exactly the bitmap's set bits.

  1. The leaf's 4th scalar is CM.x with CM = k·G_k + r·J, k = H_ℓ(committed form) (PL-D3).
  2. Both consensus verifiers derive k from the committed form only (pqc_leaf_key_bytes, PqcKeyScalar::from_pqc_auth), and the FCMP++ proof opens the spent leaf to K = k·G_k. A blob whose committed form is not the output's does not open.
  3. verify_multisig (CEN-I18) parses both blobs, checks m signatures and that their indices are the bitmap's, checks each revealed pk_i against h_i under this network's id, refuses duplicate keys, then verifies each signature under shekyl/pqc-auth-tx-multisig-v1.

So m signers spend without the other n − m: they reveal only their own keys, and every member can read every h_j from the 0x0D entry. A non-signer's key never reaches the chain. CEN-I16's structural floor for a scheme-2 key blob is MULTISIG_KEY_BLOB_MIN = 2032 bytes (a 1-of-1).

7.6 Outputs a group cannot spend

A sender can always make an output unspendable. Under E′ that shows up as an output the group owns (its view pair matches and the amount opens) whose 0x0D entry is missing or wrong, or whose leaf does not commit to the entry's hashes. The scanner keeps such an output, names its transaction, and marks it received-but-unspendable, naming the check that failed (UnspendableReason::MultisigEntryAbsent when the 0x0D entry is missing, and one variant per refusal of open_multisig_entry; §8.2). It is never offered to coin selection.

7.7 Wallet send-side requirements

When sending to a multisig recipient, the sender's wallet MUST:

  1. Display the address fingerprint (§6.3) and require user confirmation
  2. Refuse an address whose version is not 0x02, whose spend_auth_version it does not implement, whose n_total > MAX_MULTISIG_PARTICIPANTS or m_required > n_total, or whose points or KEM keys do not parse
  3. Build the output with construct_multisig_output (§7.1) and write its 0x06, 0x07 and 0x0D parts into the transaction's single fields
  4. Surface the per-output size (§7.4)

8. Wallet Scanning and Receive-Time Validation

8.1 Scanning as the group

Every member scans with the solo scanner, as the group: its view pair is the group KEM secret with spend point B_group + Y_group, and its spend secret is b. An output paid to the group is found exactly as a solo wallet finds its own: the 0x06 ciphertext decapsulates, the view tag matches, recover_recipient_spend_pubkey returns B_group + Y_group, the amount opens, and the key image is compute_output_key_image with b. So balance and spent-detection are local: no member needs another to see what the group holds.

8.2 The multisig check

For each output the group's view pair matches, the member then runs open_multisig_entry in place of the solo leaf comparison (shekyl-scanner, MultisigMemberScan):

  1. Find the 0x0D entry for the output's index. A missing entry is UnspendableReason::MultisigEntryAbsent.
  2. Check its n is the group's (MultisigGroupSize). Decrypt the hashes under the output's group secret (AEAD-authenticated, with out_idx ‖ n as associated data; MultisigHashesUndecryptable).
  3. Refuse a repeated decrypted hash (MultisigDuplicateKeyHash). Every member sees the whole list, and verify_multisig would refuse the same bytes as duplicate keys.
  4. Decapsulate ct_i with the member's own KEM secret (MultisigMemberDecap), derive pk_i, and check h_i against it (MultisigMemberKeyHash). A sender lying about this member is caught here.
  5. Rebuild the committed form from the n hashes and the group's m, and check that the published 0x07 entry is its leaf commitment (MultisigLeafMismatch). A sender lying about any other member, while leaving the leaf over the honest list, is caught here. A sender who keeps every ss_i, replaces another member's hash, and recomputes the leaf is not caught by this member: that member does not hold the other member's KEM secret. The cosigners refuse that committed form before they sign (slice 3).

A missing 0x07 entry stays PqcLeafEntryAbsent. The solo leaf comparison never runs for a group output: its leaf never commits to the solo per-output key.

8.3 Versions

A wallet refuses an address whose version is not 0x02 (§6.2). A spend's key blob with a container version other than 0x02 is refused by consensus (PqcVerifyError::ContainerVersion); 0x01 was never issued.

8.4 Persistence

A group output is persisted as a solo output is. Nothing multisig-specific is stored at receive time: at spend time each signer re-derives its pk_i by decapsulating ct_i again, and the committed form is rebuilt from the transaction's 0x0D entry, as the solo spend path re-derives its secrets from chain data. Restoring a member from its member file and rescanning reproduces everything.

8.5 Cost

An output that is not the group's costs exactly a solo scan. An output that is costs one more decapsulation, one ML-DSA-65 key generation, one AEAD open and one leaf commitment.


9. Spend Intent

9.1 Schema

SpendIntent {
    // Versioning
    version:                  u8 (= 1)
    intent_id:                [u8; 32]   // random per intent

    // Group binding — the address fingerprint (§5.3; was `group_id`)
    address_fingerprint:      [u8; 32]

    // Proposer
    proposer_index:           u8
    proposer_sig:             HybridSignature   // over all other fields

    // Temporal binding
    created_at:               u64
    expires_at:               u64
    tx_counter:               u64
    reference_block_height:   u64
    reference_block_hash:     [u8; 32]

    // Content
    recipients: [
        { address: Bech32mAddress, amount: u64 }
    ]   // sorted
    fee:                      u64
    input_global_indices:     [u64]   // sorted ascending

    // Determinism anchor
    kem_randomness_seed:      [u8; 32]   // 32 fresh random bytes

    // Chain state fingerprint
    chain_state_fingerprint:  [u8; 32]   // see §9.3
}

9.2 Invariants (verified before any signer signs; honest-signer invariant I1)

  1. version == 1
  2. address_fingerprint matches the verifier's group (§5.3)
  3. proposer_index < n_total
  4. proposer_sig verifies against hybrid_signing_pubkeys[proposer_index]
  5. created_at ≤ now ≤ expires_at
  6. expires_at - created_at ≤ 86400 (24-hour validity max)
  7. tx_counter equals the group's currently-expected counter
  8. reference_block_height ≥ FCMP_REFERENCE_BLOCK_MIN_AGE blocks behind tip and ≤ FCMP_REFERENCE_BLOCK_MAX_AGE
  9. reference_block_hash matches the chain's block at reference_block_height per the verifier's local view
  10. All input_global_indices are owned by the group, unspent, and eligible at the reference height, with each input's assigned_prover_index locally known
  11. Recipients are sorted; no duplicate (address, amount) tuples
  12. sum(recipient.amount) + fee == sum(input.amount) per local view
  13. kem_randomness_seed is unique within the group's history of seen_intents (replay/linkability prevention)
  14. chain_state_fingerprint matches the verifier's local fingerprint (see §9.3); mismatch → do not sign; trigger sync

9.3 Chain state fingerprint (honest-signer invariant I2)

Members must agree on chain state before signing. Each intent commits to:

chain_state_fingerprint = keccak256(
    reference_block_hash ||
    sorted_concat(input_global_indices) ||
    sorted_concat(input_eligible_heights) ||
    sorted_concat(input_amounts) ||
    sorted_concat(input_assigned_prover_indices)
)

Where keccak256 — here and in every live hash definition below — is original-padding Keccak-256 (0x01 padding, NOT FIPS-202 SHA3-256's 0x06), implemented as shekyl_crypto_hash::keccak256 and byte-identical to the C++ daemon's cn_fast_hash (the C++/ABI side keeps the CryptoNote name; see SHEKYL_MULTISIG_WIRE_FORMAT.md §2.4).

The proposer computes this. Each verifier independently recomputes from their local view. Mismatch indicates state divergence or manipulation: do not sign; trigger sync.

Including input_assigned_prover_indices ensures all members agree on which prover is responsible for each input — any disagreement on this is itself a state divergence that must be resolved before signing.

9.4 Intent hash

intent_hash = keccak256(canonical_serialize(SpendIntent))

intent_hash is the durable identifier. All subsequent messages reference it.


10. Canonical Construction

10.1 Algorithm

Given verified SpendIntent, every member runs:

  1. Pre-flight verification (§9.2 invariants). On any failure, publish Veto; do not proceed.
  2. Output derivation (§7.1). For each recipient (including change output, if any), derive output public key, KEM ciphertexts, leaf hash, spend-auth pubkeys, and set O to the assigned prover's pubkey.
  3. Transaction prefix construction. Inputs reference key images computed from each input's prover-assigned y (§11.1); outputs are derived per step 2; tx_extra includes KEM ciphertexts, leaf hashes, view tag hints, spend-auth pubkeys.
  4. CT base. Type = CTTypeFcmpPlusPlusPqc (= 1); ecdh info, commitment masks, pseudo outputs all deterministic from intent.
  5. Compute signing_payload (§10.4).

10.2 Bulletproof+ range proofs (deterministic from intent)

Bulletproof+ range proofs use fresh-looking randomness deterministically derived from the intent. This lets every participant independently reconstruct byte-identical BP+ bytes while keeping the randomness unpredictable to an external observer.

bp_plus_randomness = HKDF_Expand(
    intent.kem_randomness_seed,
    b"shekyl-v31-bp-plus-randomness" || u64_le(output_index_in_tx),
    64
)

Properties:

  • Per-output unique (indexed by output)
  • Per-intent unique (tied to fresh kem_randomness_seed)
  • Reproducible by all group members (deterministic from intent data they all hold)
  • Unpredictable to external observers (derived from a group-only secret chain seed)

Signer verification (honest-signer invariant I4): Each signer independently constructs the BP+ proof from this derivation and verifies that the bytes match what the prover published. If they differ: prover equivocated or produced incorrect proof; publish Veto and abort.

This approach was chosen over "prover produces fresh randomness, signers verify" because it preserves full deterministic construction (every participant produces byte-identical tx bytes). The cryptographic question ("is HKDF-derived BP+ randomness sound?") is isolated to one well-defined concern that cryptographer review explicitly covers (see PQC_MULTISIG_V3_1_ANALYSIS.md §7 review target).

10.3 Change output handling

When the group sends to itself (a change output):

  • The change recipient is the group's own multisig address
  • The Option C + Solution C construction (§7.1) applies identically
  • N KEM encapsulations to the group's own KEM pubkeys
  • N fresh per-output ephemeral signing keypairs derived
  • N fresh per-output ephemeral classical spend-auth keypairs derived
  • All N spend-auth pubkeys published in tx_extra tag 0x0A
  • O set to the change output's assigned prover (could be a different participant than any input's assigned prover)
  • Leaf hash committed

There is no single-sig change escape hatch. Change outputs are full multisig-bound outputs requiring scheme_id=2 authorization to spend, identical to any other multisig output.

10.4 Canonical signing payload

signing_payload = keccak256(
    serialize(TransactionPrefixV3) ||
    serialize(RctSigBase) ||
    keccak256(serialize(RctSigPrunable_skeleton)) ||
    serialize(PqcAuthHeader) ||
    H(hybrid_pubkeys[0]) || ... || H(hybrid_pubkeys[n_total-1])
)

Where RctSigPrunable_skeleton excludes the FCMP++ proof (comes from the prover asynchronously). Its hash is included separately in the signature share commitment (§12.2.1).

10.5 Tiebreaker for conflicting intents

When two proposers publish conflicting intents at the same tx_counter:

winner = intent for which the prover (per §11.1) emits a signed
         ProverReceipt first, where ProverReceipt is published only
         after full invariant verification

ProverReceipt mechanics (strengthened from v1.0 in response to R3):

ProverReceipt {
    prover_index:        u8
    intent_hash:         [u8; 32]
    received_at:         u64
    local_counter:       u64   // monotonic, prover-local, increments per receipt
    sig:                 HybridSignature
}

Requirements:

  • Prover emits ProverReceipt only after completing full §9.2 invariant verification on the intent. A malformed or invariant-failing intent never earns a receipt.
  • Prover MUST publish the receipt to all subscribed relays simultaneously (multi-relay mandatory per §12.4)
  • local_counter is prover-local, monotonic, increments once per receipt. Two receipts from the same prover with non-monotonic counters is equivocation (§12.2.4)
  • Signers observing conflicting intents wait conflict window (default: 30 seconds) after the first intent before acting, to allow the prover's receipt to propagate across relays
  • Signers accept the intent for which the ProverReceipt shows the lowest local_counter (earliest-observed by prover)
  • Members who already signed the losing intent publish a Veto to reset

Why the monotonic counter matters: an attacker controlling network delivery to the prover can attempt to equivocate (claim "received A first" to some signers, "received B first" to others). The monotonic local_counter in the receipt makes equivocation detectable: two receipts with the same counter or out-of-order counters = equivocation = prover marked untrusted via EquivocationProof (§12.2.4).

Why not hash-based tiebreaking: grindable by attacker varying intent content fields. Prover-receipt shifts the tiebreaker to prover's observation order, which requires network-level asymmetry to exploit rather than content grinding.


11. Spending: Prover and Signing

11.1 Rotating prover assignment (sender-computable)

Product path (Option E′): this entire subsection is deleted machinery — E′ has no mandatory prover (§15.4a / design §0.5). Retained as historical specification of the Option D scaffold.

Naming: "rotating prover assignment" ≠ §15.2 V3.2 "full key rotation protocol." E′ deletes the former; the latter survives.

For each output being spent, the prover is determined deterministically from data the sender knew at construction time:

rotating_prover_index(group_id, output_index_in_tx, tx_secret_key_hash,
                      reference_block_hash, n_total) -> u8

prover_index = first_byte(
    cn_fast_hash(
        group_id ||
        u64_le(output_index_in_tx) ||
        tx_secret_key_hash ||
        reference_block_hash
    )
) mod n_total

Where:

  • group_id: from recipient address (sender knows)
  • output_index_in_tx: position of this output within its transaction (sender knows; not consensus-assigned)
  • tx_secret_key_hash = cn_fast_hash(sender_tx_secret_key): sender knows; derivable from tx_public_key only by the sender
  • reference_block_hash: from the spend intent or output's associated tx data (sender knows at construction)
  • n_total: from recipient address

Properties:

  • Sender-computable: every input is known to the sender before broadcasting
  • Deterministic: every group member, given the same output, computes the same prover_index
  • Unpredictable to observers: tx_secret_key_hash is not derivable from on-chain data alone (tx_public_key is related but the hash adds a layer; a motivated observer can attempt to correlate but cannot pre-compute)
  • Roughly uniform: cryptographic hash mod N is uniform over any reasonable input distribution

Rotation protects against accident and load, not a hostile sender. "Grinding resistance" in the authorization sense is unreachable by construction: the assignment is sender-computable, so a sender who wants a chosen prover can iterate tx_secret_key / tx_secret_key_hash until they get it. Reframe: rotation spreads prover duty so one participant does not accidentally own every proof; it does not stop a motivated sender from biasing assignments.

Lead figure (availability griefing, not authorization): ~n_total expected tries to force one output onto a chosen prover; ~k · n_total to land k preferred assignments across k independent targets (separate 1-output txs). That is cheap (~hashes, fee-bounded) — an availability surface (MS-4/MS-5), not a steal path (auth remains M × ML-DSA). Do not lead with scare-N^k: forcing all k outputs in one tx onto the same prover under one shared tx_secret_key_hash is still ~(1/n)^k per trial, but that is not the everyday attack — k separate txs cost ~k·N. Cryptographer formalization of the joint tail remains optional (PQC_MULTISIG_V3_1_ANALYSIS.md §7); it is not a Phase 6 ship gate.

Recipient-side verification at receive time confirms the sender's computed assignment matches the one the group independently derives. Mismatch rejects the output.

11.2 Prover responsibilities per output

The prover for an input:

  1. Computes the FCMP++ proof using their per-output classical spend-auth secret y_prover_i (derived via the "shekyl-v31-classical-spend" KDF label from their shared secret)
  2. Publishes a ProverOutput message (§12.2) containing the proof

The prover holds ONLY the per-output classical spend-auth keys for outputs they were assigned. Compromise of one prover's host exposes their per-output keys for those outputs only.

11.3 Signer verification of prover assignment (honest-signer invariant I5)

Before producing a signature, honest signers MUST verify:

def verify_prover_assignment_and_proof(intent, input, prover_output):
    # Step 1: Recompute assigned prover from persisted output metadata
    persisted = get_persisted_output(input.output_id)
    assigned_prover = persisted.assigned_prover_index  # cached at receive

    # Step 2: Read Y_assigned from persisted state
    Y_assigned = persisted.spend_auth_pubkeys[assigned_prover]

    # Step 3: Verify output public key matches assigned prover
    if persisted.output_pubkey != Y_assigned:
        # Should never happen if receive-time validation passed
        raise PersistedStateInconsistent

    # Step 4: Verify the FCMP++ proof binds to the assigned pubkey
    if not fcmp_verify(
        prover_output.fcmp_proof,
        input.key_image,
        persisted.output_pubkey,   # proof must bind to Y_assigned
        intent.reference_block_hash
    ):
        return False

    # Step 5: Verify BP+ proofs against independently-computed bytes (I4)
    if not verify_bp_plus_deterministic(intent, output_commitments,
                                         prover_output.bp_plus):
        return False

    # Step 6: Verify the key image is consistent with Y_assigned
    # (FCMP++ verify already does this internally, but explicit check
    # guards against future implementation drift)
    if not key_image_binds_to_pubkey(input.key_image, Y_assigned):
        return False

    return True

This check uses only publicly-verifiable data. No participant needs to know another participant's shared secret. spend_auth_pubkeys is persisted locally at receive time and is the full N-pubkey list.

Enforcement level (§2.7): this check is unbypassable in supported wallets. It runs in the core signing path; any return-false or exception-raise causes:

  1. Signing aborted
  2. InvariantViolation message published (type 0x09)
  3. Intent moved to REJECTED state

11.4 Signing protocol (non-interactive scheme_id=2)

Each signer in the M-of-N selected subset:

  1. Receives intent + ProverOutput
  2. Independently reconstructs the canonical transaction (§10)
  3. Verifies the FCMP++ proof against signing_payload (I3)
  4. Verifies BP+ proofs match deterministic derivation (I4)
  5. Verifies prover assignment (I5, §11.3)
  6. Computes the final tx_hash (including the prover's proof)
  7. Produces hybrid (Ed25519 + ML-DSA-65) signature over signing_payload — under the multisig scheme domain SCHEME_DOMAIN_PQC_AUTH_TX_MULTISIG (shekyl/pqc-auth-tx-multisig-v1), not the single-signer domain (SA-2 / SA-R-5, docs/design/SIGNATURE_ALIGNMENT.md). A participant signature is therefore not interchangeable with a single-signer signature over the same payload. This is a scheme-level signing domain, distinct from the retired group-id separator DOMAIN_SEP_V31 (§5.3) — it separates the scheme, not a group. Production participant signing is unbuilt; the primitive is HybridEd25519MlDsa::sign(sk, SCHEME_DOMAIN_PQC_AUTH_TX_MULTISIG, signing_payload).
  8. Publishes SignatureShare (§12.2.1) including the tx_hash and proof commitments

11.5 Assembly (honest-signer invariant I6)

Any member with M valid SignatureShare messages:

  1. Verifies all M tx_hash commitments agree
  2. Verifies all M FCMP++ proof commitments agree
  3. Verifies all M BP+ proof commitments agree
  4. Any disagreement → publish EquivocationProof (§12.2.4) and abort
  5. Otherwise, constructs pqc_auth blob with scheme_id=2 layout, attaches to transaction, submits to daemon

Multiple members may attempt assembly simultaneously. Network picks whichever broadcast succeeds first.

11.6 The 1/N permanent loss limitation

A participant who permanently loses their keys cannot serve as prover for the outputs they were assigned. Approximately 1/N of group outputs become permanently unspendable.

NLoss per missing key
3~33%
5~20%
7~14%

This is an accepted V3.1 limitation. Users MUST complete §5.4 acknowledgment at group setup. Wallets MUST surface, on the multisig dashboard, the estimated percentage of value held by each participant's prover responsibility.

V4 FROST SAL eliminates this entirely. V3.2 may add a key escrow protocol as mitigation.

11.7 Rate limiting (by signing pubkey, not index)

To prevent intent-spam DoS:

  • Each proposer's hybrid signing public key (not proposer_index) may have at most 1 active intent per group at a time (active = state in {PROPOSED, VERIFIED, PROVER_READY, SIGNED})
  • Rate limit is group-wide configurable at setup (default: 1 active intent per signing pubkey; maximum 1 new proposal per 5 minutes per signing pubkey)
  • Keying off signing pubkey prevents a malicious member with multiple proposer_index slots from multi-indexing their way around the limit
  • New proposals from the same signing pubkey violating the limit are rejected with rate-limit veto

This bounds verification work to ≤ N concurrent intents.


12. Messages and Transport

12.1 Common envelope

MultisigEnvelope {
    version:        u8 (= 1)
    group_id:       [u8; 32]
    message_type:   u8                  // ENCRYPTED in payload
    intent_hash:    [u8; 32]
    sender_index:   u8
    sender_sig:     HybridSignature     // over all above + payload
    payload:        EncryptedBlob
}

The envelope's message_type is encrypted in the payload (§12.3) to prevent role-pattern leakage. Cleartext envelope fields: version, group_id, sender_index, intent_hash, sender_sig, encrypted payload.

12.2 Message types (encrypted)

TypeNamePurpose
0x01SpendIntentProposer publishes
0x02ProverOutputFCMP++ proof (BP+ is now in main tx per §10.2)
0x03SignatureShareSigner's hybrid signature + commitments
0x04VetoRefusal or abort
0x05ProverReceiptProver's tiebreaker acknowledgment
0x06HeartbeatLiveness + censorship detection
0x07CounterProofState recovery
0x08GroupStateSummaryPeriodic synchronization
0x09InvariantViolationSigned notice that an honest-signer invariant failed
0x0ARotationIntent (RESERVED)V3.2 full rotation protocol; reserved in V3.1
0x0BEquivocationProofProver equivocation evidence

12.2.1 SignatureShare structure

SignatureShare {
    signer_index:              u8
    hybrid_sig:                HybridSignature
    tx_hash_commitment:        [u8; 32]
    fcmp_proof_commitment:     [u8; 32]
    bp_plus_proof_commitment:  [u8; 32]
}

12.2.4 Prover equivocation detection

If a malicious prover sends different ProverOutput messages to different signer subsets, signature shares will disagree on fcmp_proof_commitment. Members publish:

EquivocationProof {
    prover_index:    u8
    intent_hash:     [u8; 32]
    proof_a:         ProverOutput  // including prover_sig
    proof_b:         ProverOutput  // different, including prover_sig
}

Also detected: two ProverReceipt messages from the same prover for the same intent with non-monotonic or duplicate local_counter values.

12.2.6 InvariantViolation structure

InvariantViolation {
    reporter_index:      u8
    intent_hash:         [u8; 32]
    invariant_id:        u8         // which of I1-I7 from §2.7 was violated
    evidence:            bytes      // intent or proof bytes demonstrating the violation
    reporter_sig:        HybridSignature
}

Publishing an InvariantViolation is how honest signers signal that they refused to sign a specific intent. Other members treat a published InvariantViolation as a strong signal to also refuse, and to investigate whether their own state disagrees.

12.3 Encryption

Every ceremony blob's payload is sealed with XChaCha20-Poly1305 (shekyl_crypto_pq::multisig_blob; Round 0 ruling R-4):

key    = HKDF-SHA-512(ikm = transport_secret).expand(
             "shekyl/multisig-blob-key-v1" ‖ intent_hash ‖ u8(message_type) ‖ u8(sender_index), 32)
aad    = version ‖ address_fingerprint ‖ intent_hash ‖ u8(sender_index)   // 66 bytes, BLOB_AAD_LEN
sealed = nonce(24, random) ‖ XChaCha20-Poly1305(key, nonce, aad, payload) ‖ tag(16)

transport_secret is the group transport secret every member file holds (§6.6), derived by the dealer under salt_for. The associated data is the envelope's prefix through the sender index (SHEKYL_MULTISIG_WIRE_FORMAT.md §4.3). The message type is a key field and stays inside the payload (SS12.5). The sender signature is not associated data: its preimage is the sealed payload, so binding it would make sealing and signing depend on each other. The nonce is drawn at random for every blob: a nonce derived from a persisted counter would repeat after a wallet is restored from a backup, and a repeated nonce under this AEAD leaks the XOR of two payloads and the Poly1305 key. A ceremony's message counter orders blobs and refuses replays; it is not an input to the AEAD. A blob sealed for one header does not open as another.

12.4 Multi-relay with operator uniqueness (mandatory)

Members MUST publish each message to at least 3 independent relays operated by disjoint operators. The relay list is part of group state.

Operator uniqueness enforcement:

  1. Wallets MUST consume a signed relay directory (updated via GitHub releases or published on-chain as a special metadata transaction). The directory maps relay URLs to operator identifiers.
  2. At group setup, each participant MUST select relays from at least 3 distinct operators per the directory.
  3. Each Heartbeat message (§13.3) includes the sender's observed relay operator IDs — the operator IDs corresponding to relays where that member has actually received messages in the last interval.
  4. Members compare observed operator IDs from heartbeats; if all heartbeats come from relays operated by the same entity (or a small subset), they flag this as potential centralization and warn users.

This closes the attack where a single operator running three relays under different names could satisfy "3 relays" without providing actual censorship resistance.

12.5 Cleartext envelope minimization

The encrypted message_type prevents passive observers from inferring roles (only-prover-sends-0x02, only-signers-send-0x03). Observers see encrypted blobs at varying sizes addressed to a stable group_id.

Future V3.2 traffic padding + batched delivery can strengthen this; not in V3.1 scope.

12.6 Transport bindings

Nostr relay binding: Each message posted as Nostr kind-30000 replaceable event; d tag includes group_id hash + unique message identifier. Nostr signature is for relay acceptance only.

Direct P2P binding: Members connect via mTLS with hybrid certificates when topology permits. Messages still subject to envelope /encryption requirements.

File binding (air-gap) with opaque naming:

Rather than the previous draft's metadata-leaking filename convention, file transport now uses:

  • Random opaque filenames (e.g., shekyl-ms-<random64hex>.bin)
  • Encrypted manifest inside the file metadata (not in filename) that contains the group_id, intent_hash, message_type, sender_index for wallet ingestion
  • Display-only filenames in the UI (e.g., "Intent 0x1a2b... message from member 3") derived from the encrypted manifest once decrypted; never written to disk

This preserves air-gap compatibility while preventing filesystem metadata leakage when files end up on shared media, USB drives, cloud backups, or forensic images.


13. State Machine and Counter Recovery

13.1 Per-intent state

PROPOSED       → intent received, not yet verified
VERIFIED       → §9.2 invariants pass
PROVER_READY   → ProverOutput received; FCMP++ and BP+ verification pass
SIGNED         → this member produced and published SignatureShare
ASSEMBLED      → M signatures observed
BROADCAST      → tx confirmed in mempool / on-chain
REJECTED       → veto/invariant-violation threshold reached or chain-rejected
TIMED_OUT      → expires_at reached without BROADCAST

13.2 tx_counter advancement

tx_counter advances ONLY upon observed chain state, not local optimism. Specifically: tx_counter increments to k+1 when a member observes the broadcast tx confirmed in their local chain at height ≥ N confirmations (default N=3; configurable).

13.3 Heartbeat protocol

Members publish Heartbeat every HEARTBEAT_INTERVAL (default 5 min) to all subscribed relays:

Heartbeat {
    sender_index:             u8
    timestamp:                u64
    last_seen_intent:         [u8; 32]
    observed_relay_ops:       [RelayOperatorId]   // which operators this member sees
    local_tx_counter:         u64
    sig:                      HybridSignature
}

Members compare heartbeats to detect:

  • Missing heartbeats from a specific member (offline or censored)
  • Disagreement on last_seen_intent (relay censorship)
  • Collapse of observed relay operator diversity (censorship + operator collusion)
  • tx_counter divergence
  • Time skew

Action on anomaly: retry across all subscribed relays, escalate to user, do not advance state optimistically.

13.4 CounterProof recovery (strengthened formalization)

When a member is at stale tx_counter, recovery uses cryptographic chain proof with explicit advancement-lineage verification:

CounterProof {
    sender_index:           u8
    advancing_to:           u64
    tx_hash:                [u8; 32]
    block_height:           u64
    block_hash:             [u8; 32]
    tx_position:            u16
    consumed_inputs:        [[u8; 32]]   // key images of consumed inputs
    resulting_outputs:      [[u8; 32]]   // output pubkeys produced by the tx
    intent_hash:            [u8; 32]     // the intent this tx broadcast
    sender_sig:             HybridSignature
}

Verification rules (strengthened from v1.0):

A stale member receiving a CounterProof MUST verify, in order:

  1. block_hash matches their local chain at block_height (if local chain lacks this block, wait for sync; do not reject)
  2. tx_hash appears at tx_position in that block
  3. tx.pqc_auths[i].scheme_id == 2 for all inputs (multisig spend)
  4. pqc_key_scalar(tx.pqc_auths[i].hybrid_public_key) matches the tracked key scalar of an output in local state with matching group_id
  5. The consumed_inputs listed in CounterProof match the tx's actual input key images exactly (no loose matching)
  6. All consumed_inputs are in local state as tracked unspent outputs owned by the group
  7. intent_hash references an intent the member has seen (or, if unseen, a note is logged: member was absent during proposal)
  8. sender_sig verifies

Advancement rule: only after all checks pass, the member:

  1. Marks all consumed_inputs as spent in local state
  2. Adds all resulting_outputs to scanning (if they belong to the group)
  3. Advances tx_counter to advancing_to

If any check fails:

  • If the failure is "I don't have the block yet": wait for sync; do not reject; do not advance
  • If the failure is "I don't recognize these inputs as my tracked outputs": trigger full wallet rescan from reference height; do not advance based on this CounterProof
  • If the failure is structural (scheme_id wrong, leaf hash doesn't match): CounterProof is invalid; do not advance; publish Veto

This formalization prevents:

  • Attackers forging CounterProofs for arbitrary on-chain transactions (must reference the group's actual tracked inputs)
  • Loose matching leading to false advancement (exact input/output match required)
  • Advancement without state consistency (rescan forced if local state is out of sync)

13.5 Disagreement resolution

Conflicting intents same counter → §10.5 ProverReceipt with monotonic counter

Proposer disappears → expires_at → TIMED_OUT

Prover disappears for an output → intent times out; rotating prover means different outputs have different provers; 1/N permanent-loss per missing key

Chain reorg of reference_block → intents referencing orphaned blocks transition to TIMED_OUT; re-propose with new reference

Prover equivocation → §12.2.4 detection and EquivocationProof

Honest-signer invariant violation → §2.7 InvariantViolation published; intent REJECTED


14. Security Properties

14.1 Authorization

PropertyMechanism
No unilateral spendscheme_id=2 consensus requires M PQC signatures
No unilateral redirectDeterministic construction; signers reconstruct and verify
No wrong-prover spend§11.3 honest-signer prover assignment verification (I5)
No silent acceptance of a malformed group output§8.2 multisig check (received but unspendable, named)
No invariant bypass§2.7 mechanical enforcement in signing path

14.2 Privacy

PropertyMechanism
Per-output forward privacyOption C N-fold KEM fan-out + per-output ephemeral keys
Spend-to-spend unlinkability (within scheme-2)Different ephemeral N-key blobs per spend — but see stream attribution below
Group identity privacy from passive observergroup_id not on-chain; encrypted transport
Group sizeNot private. Each 0x0D entry gives the receiving group's n (§7.4); inherent to Option E′
Role-pattern privacy from relay observersEncrypted message_type in envelope
Filesystem metadata privacyOpaque filenames + encrypted manifest (§12.6)

Stream attribution (honest statement, 2026-07-15). Do not write "accepted given negligible multisig volume" — that is circular: the anonymity set stays small because volume is negligible. The load- bearing privacy fact for scheme_id = 2 spends:

  • Every scheme-2 spend is provably one entity's (the M-of-N group that authorized it). One linkage event (exchange deposit/withdrawal, KYC'd counterparty, etc.) retroactively deanonymizes the whole history of that stream.
  • The scheme-2 FCMP set is the multisig outputs by construction — harmless at low N; it is not a separate "multisig set" to grow into.
  • Change-output leak lands on senders (who choose to pay a multisig address), not on the group as a special consensus class.

Operator docs and UX must treat multisig as a labeled custody stream, not as "same privacy as solo at small volume."

14.3 Liveness

PropertyStatus
Any M honest signers can advanceYes (assuming assigned prover is among them)
Proposer disappearance recoveryYes (timeout + re-propose)
Signer disappearance recoveryYes if M others remain
Prover disappearance per-outputLimited; 1/N outputs lock per missing prover (V4 fixes)
Network partition recoveryYes via formalized CounterProof
Relay censorship resistanceMulti-relay + operator uniqueness + heartbeat
Scanner resource boundsHard caps + per-sender griefing scores (§7.6)

14.4 Integrity

PropertyMechanism
Tx hash integrity through assemblytx_hash_commitment in SignatureShare (I6)
Prover proof integrityfcmp_proof_commitment + bp_plus_proof_commitment
Prover non-equivocationEquivocationProof detection (§12.2.4)
Counter integrityFormalized CounterProof advancement lineage (§13.4)
Replay resistanceintent_id, kem_randomness_seed freshness, expires_at, reference_block_hash, tx_counter
Invariant enforcement§2.7 mechanical, unbypassable in signing path

15. Forward Compatibility

15.1 Reserved namespace

ItemPurpose
group_version = 0x01V3.1; higher values for future rotated groups
spend_auth_version = 0x02Option E′ (15.4a threshold classical SAL on y). 0x01 never issued. Higher values only for a later mutually-distrusting / lattice-auth path — do not overload 0x02.
HRP shekyl1n...Rotated-key multisig (V3.2+)
TX_EXTRA_TAG_MULTISIG_MIGRATION (0x08)migration transactions — reserved slot in §7.4's table, no code symbol until designed
Message type 0x0A (RotationIntent)V3.2 full rotation protocol

Superseded by group_id deletion (2026-07-18, MS-5 PR-B). Items 1–3 below analyze how multisig_group_id bound the version bytes into its preimage. multisig_group_id is now deleted (§5.3): group identity is the address fingerprint — cSHAKE256(canonical(MultisigAddressPayload), …) — whose canonical payload carries group_version and spend_auth_version as real wire fields. The version-binding goal of MSW-4/5 is met by the fingerprint's preimage, not by a group_id hash; the group_id-preimage mechanics below are historical.

Honesty pin (2026-07-14 — P0-k / R1-F-11; historical — see the supersession note above). When written, these rows were intent, not substrate. The multisig_group_id mechanics described below are now deleted (§5.3); items 1–3 are retained only as the reasoning record for MSW-4/5:

  1. group_version is fused with MULTISIG_CONTAINER_VERSION. multisig_group_id passes the compile-time constant as group_version, not container.version. Wire-encoding version and protocol-semantics version are different jobs sharing one byte. When a v2 container exists under the current code, group_id would silently hash v1's group_version. MSW-4 unfuses: parse accepts a known-version set; group_id reads container.version.
  2. spend_auth_version is not a container wire field. It is a hardcoded SPEND_AUTH_VERSION_ED25519 argument into the group_id preimage (and appears as the first byte of the tx_extra spend-auth tag at receive time — wallet layer). Address/group_id binding may be the right carrier (§15.5); it is currently so by accident, not named decision. MSW-5 pins the disposition.
  3. Reserved-namespace KATs are incomplete. Existing group_id_v31_includes_version_fields covers scheme_id and spend_auth_version via with_versions; it does not vary group_version. A reserved byte never set to a second value is indistinguishable from a constant.

V3.2 / V4 / 2030+ migration paths run through these bytes. They are genesis-frozen the same way PQC_MAX_*_BLOB is — the multisig feature gate does not protect them. Track A owns the fix.

V3.1 provides the necessary hooks once MSW-4/5 land:

  • Separated container vs group version (or one byte with both jobs named and group_id reading the wire)
  • Named spend_auth_version carrier
  • Exercised reserved-namespace KATs
  • Reserved message type 0x0A / tx_extra tag 0x08 (unchanged)

15.2 V3.2 full rotation protocol (hooks reserved, protocol deferred)

Naming collision (2026-07-15). This section is participant / group key rotation (new KEM keys, new group_id, migration txs). It is unrelated to §11.1 "rotating prover assignment," which Option E′ deletes. Do not read "rotation deleted" (E′ / mandatory prover) as striking this V3.2 key-rotation work.

V3.1 reserves the message type and namespace for rotation but does NOT implement the rotation protocol itself. The rotation protocol will be specified and shipped in V3.2 as a focused release.

Rationale for deferral: rotation is a complex protocol with multiple rotation modes (individual, group, spend-auth upgrade), migration transaction semantics, race conditions during the rotation window, and privacy considerations on migration txs. These flaws will surface only through actual use. The V3.2 timeline is explicitly chosen so that design flaws surface when real users depend on the feature, not in a rushed pre-launch implementation. This is not a scope-protection argument; it is a design-maturity argument.

V3.1 provides the necessary hooks once MSW-4/5 land (see §15.1 honesty pin). V3.2 will add:

  • Full RotationIntent protocol
  • Individual participant key rotation
  • Full group rotation (new group_id)
  • Migration transactions consuming old outputs, producing new

Key escrow protocol as 1/N loss mitigation — struck for E′ (2026-07-15). There is no mandatory-prover 1/N loss to mitigate under spend_auth_version = 0x02. If a future mutual-distrust mode reintroduces single-holder liveness, escrow belongs under that version byte — not inherited silently into V3.2 from Option D.

15.3 Address size / group registry (re-priced 2026-07-15)

MSW-8 deletes vestigial address hybrid_sign_pubkeys (PER_PARTICIPANT_LEN 3200 → 1216). After that cut:

Nbech32m chars (approx)note
2~3,900QR-able under alphanumeric cap ~4,296
3~5,850file-friendly
5~9,740still a file; not 25k

Chain-anchored group registry (§6.5) is an optimization, not a V3.1 critical-path prerequisite. The fossil made 15k–36k chars look unusable; that weight was the unread field. Traffic-padding / heartbeat privacy work remains a separate V3.3-candidate.

Does not reopen MAX_MULTISIG_PARTICIPANTS (MSW-G=5).

15.4 V4 path — two items, two blockers (split 2026-07-14; posture pin same day)

Earlier prose bundled "FROST SAL + pure-PQC spend-auth" behind a single NIST-lattice gate. That was wrong twice: it parked 15.4a behind NIST, and the phrase "pure-PQC spend-auth" is ambiguous (pin below).

Posture first — multisig is already PQ for authorization

Scheme_id=2 verify check 9 (the final, crypto check — see §5.3; returns CryptoVerifyFailed = FFI code 10) is M × Ed25519 + M × ML-DSA (shekyl-crypto-pq multisig). The key container is hybrid by construction (SINGLE_KEY_CANONICAL_LEN = 1996). Forging an M-of-N spend requires breaking ML-DSA-65 M times. Threshold authorization is quantum-resistant today.

Classical exposure lives in the FCMP++ layer, not in multisig:

  • SAL / membership: Ed25519 (SPEND_AUTH_VERSION_ED25519 = 0x02; 0x01 never issued — the never-shipped Option-D scaffold value; SpendAuthAndLinkability in shekyl-fcmp, compiled unconditionally).
  • Leaf {O.x, I.x, C.x, CM.x} for every output on the chain.
  • Solo has the identical posture — classical membership/SAL + hybrid PQC auth bound through the leaf commitment opening. Multisig adds zero classical exposure; it carries M hybrid signatures instead of one.

Quantum degradation of the SAL is graceful for funds, not for privacy. An adversary who breaks Ed25519 learns each assigned prover's y and can produce the SAL themselves — the mandatory-prover dependency evaporates — and M × ML-DSA still authorizes the spend. Classical SAL is a liveness dependency (1/N permanent-loss), not a compromise path. Curve break against FCMP++ membership is privacy retroactive (HNDL on the anonymity set); that concern is real, not multisig-specific, and multisig neither helps nor hurts it.

Consequence for Track B: grinding, rotation bias, hostage fraction, veto / heartbeat / griefing are availability engineering (MS-4 / MS-5 / R-F), not cryptography. They do not belong in the Phase 6 cryptographer queue. Funds are freezable under those attacks, not stealable via them. Shipping multisig now is an economics question (~2.4× per-tx size vs solo for 5-of-5; ≪ five solos — V3_ROLLOUT.md) — whether threshold custody is worth that cost — not a crypto-maturity question.

Phrase pin — what "pure-PQC spend-auth" is not

ReadingMeaningStatus
(a) Lattice-only auth signingDrop the Ed25519 half of the hybrid scheme_id=2 key/sig container; M-of-N or composite becomes lattice-only at the authorization layerAchievable in principle; size/FIPS story; not what spend_auth_version gates
(b) Lattice SALReplace Ed25519 O / y with a lattice key verified inside FCMP++Impossible while FCMP++ is the membership proof (Helios/Selene circuit verifies a curve equation)

spend_auth_version gates the SAL key scheme (it is the container's version byte and the address's spend_auth_version), not the hybrid signature scheme inside the container. spend_auth_version = 0x02 means 15.4a (threshold classical SAL / FROST group-shaped y under a two-component address) — still Ed25519 points in the circuit. It does not mean (b). Reading (a) is a separate auth-layer evolution (scheme id / blob layout), tracked under 15.4b's size motivation when lattice threshold maturity allows a composite or lattice-only auth path.

15.4a — FROST SAL / Option E′ (threshold y; no mandatory prover)

Product shape (2026-07-15): Option E′ — see V3_1_MULTISIG_RUST_ENGINE.md §0.5. Two-component O = ho·G + B + y·T (output.rs:286-304) splits trust axes:

  • b / B: group-plaintext view+link key → local key images / balance without co-signer ceremony.
  • y: FROST M-of-N group secret with per-output tweak y_out = y_group + y_kem (E′, not fixed-y E).
  • Ship: dealer-mode (owner = trusted KeyGen), MAX=5, spend_auth_version = 0x02, 0x01 never issued.
  • Deletes: mandatory prover, rotation/grinding, heartbeat, counter_proof, I7 receive-time prover check, griefing scores.

Fixes: 1/N permanent-loss lock and the availability surface that existed only because a single assigned prover held y. Does not change quantum authorization strength (already M × ML-DSA). Does not shrink auth blobs (15.4b).

Built: the two-component output (derive_output_secrets asserts y ≠ 0), the E′ address (B + Y_group + (N+1)×KEM), the 0x0D receive path, the dealer split of y_group, and the SAL leg as a FROST signature through modular_frost's machines (shekyl_fcmp::frost_sal; keys offset by y_kem − o_blind, refused when they do not open the rerandomized output). M-of-N spends of real E′ outputs verify (rust/shekyl-fcmp/tests/frost_spend.rs). Remaining: the signing ceremony and its held nonce (shekyl-multisig), transaction assembly, the wallet. Not blocked on NIST.

Watch (15.4b size only): dPN25 (T≤8, ~2.7 KB), TALUS/ML-DSA-threshold. Do not lead with TRaccoon (wrong N, coordinator, no DKG).

Coexistence: E′ is the issued V3.1 path. A later stack (lattice auth, mutual-distrust DKG mode) lands beside it under a new version/HRP — §15.5 + MSW-4/5 discriminability. See design doc §0.4 / §0.5.

15.4b — Composite / lattice-only auth (size, not SAL)

Fixes: M-of-N hybrid signature-list blob size, F-1 bound pressure, full-reward-zone tension with large N, N-cap size lens. Does not fix the mandatory prover (that is 15.4a). Does not add PQ authorization (already present).

Blocked on (external): lattice threshold maturity. NIST IR 8214C (final 2026-01-20) is a reference-material collection, not a standardization — packages → analysis → MPTC characterization report (~2027) that "may include recommendations for future processes." Actual standardization is that later process (2030+ plausible). The deferral hardened.

Watch list (not V3.0/V3.1 adoption):

LineRegime / noteFit for Shekyl N≤8
Threshold Raccoon (del Pino et al., eprint 2024/184)~13 KiB sigs, ~40 KiB/user, T≤1024; trusted KeyGen; coordinator combine; not FIPSWrong regime; imports F-3; not FIPS 204
dPN25 (del Pino–Niot)Compact T≤8, ~2.7 KiB Dilithium-family; not FIPS 204Exact N; best size win on paper
Tanuki (MPTS 2026 preview)2-round + preprocessing; Raccoon-compatibleWatch
TALUS (MPTS 2026)Threshold ML-DSA, 1-round onlinePrimary watch — thresholds shipped fips204

All of the above threshold the auth layer only. None are a lattice SAL. None touch 15.4a.

Genesis / MSW-G: MAX=5 is 2f+1 at f=2 (largest group served), not a zone fill. A future composite auth (15.4b) may reopen the size lens for larger N; genesis freezes on today's signature-list economics with that explicit 15.4b expiry.

15.5 No implicit upgrades

Outputs created under one spend_auth_version MUST NOT be reinterpreted under another. Upgrading requires explicit migration transaction. This prevents silent misreinterpretation and preserves auditability across scheme transitions.

Implication for Stage 4 / V4: this rule forbids in-place evolution of V3.1 durable fields into V4 shapes. V4 is a coexisting rewrite (§15.4). Do not design V3.1 persistence for "Stage 4 will evolve this type" — design it so the version discriminator routes to the correct stack forever.


16. Implementation Plan

16.1 Rust modules — E′ / MS-5 (the shekyl-multisig crate)

The multisig ceremony lives in its own crate shekyl-multisig (MS-1(a)); its dependency list is the "no transport" ban. The Option-D modules the earlier plan listed — prover.rs (rotating prover), signing.rs, transport/* (nostr / p2p / relay directory), heartbeat.rs, counter_proof.rs, construction.rs, and the per-intent state.rs FSM — are deleted: E′ has no prover, no heartbeat, no counter-proof, and the wallet owns no transport (it emits/consumes a self-authenticating blob over a bring-your-own channel; MS-5 S1 landed this).

rust/shekyl-multisig/src/
├── lib.rs                 — crate root + re-exports
├── ceremony.rs            — FrostCeremony FSM (four-blob / two-round-trip /
│                            pqc-last); both nonce shapes; the ConsumedNonce
│                            persist-before-use typestate; NonceCounterSink;
│                            SpendRequest / SpendResponse blob newtypes
├── intent.rs              — SpendIntent type, canonical serialization
├── messages.rs            — envelope + E′ message types (Option-D discriminants excised)
├── invariants.rs          — honest-signer invariant checks
├── group_descriptor.rs    — public group identity (no share, no KEM secret)
└── build.rs               — FCMP reference-block consts from config JSON

rust/shekyl-engine-core/src/engine/signer.rs
├── MultisigSignerV2        — EngineSignerKind marker (spend_auth 0x02);
│                             SigningCeremony = shekyl_multisig::FrostCeremony
└── MultisigNonceSink       — engine-core's durable NonceCounterSink impl (body: S2)

rust/shekyl-multisig/src/member_file.rs
└── MemberFile — §6.6 (stores the transport secret; does not derive it)

rust/shekyl-crypto-pq/src/derivation.rs
└── derive_group_transport_secret — §6.6

rust/shekyl-crypto-pq/src/multisig.rs
├── MultisigCommittedForm, MultisigKeyBlob     — §7.5
├── multisig_key_hash (shekyl/multisig-key-hash-v1)
└── verify_multisig                            — CEN-I18

rust/shekyl-crypto-pq/src/multisig_output.rs
├── construct_multisig_output                  — §7.1
├── derive_multisig_member_keypair             — §7.2
└── open_multisig_entry                        — §8.2

rust/shekyl-scanner/src/multisig_scan.rs
└── MultisigMemberScan — §8.2

rust/shekyl-scanner/src/scan.rs
└── Scanner::new_multisig_member — §8.1–§8.2

16.2 New tx_extra tags

rust/shekyl-wire/src/tx_extra/mod.rs (not in the coinbase grammar CEN-I20):

  • TX_EXTRA_TAG_PQC_MULTISIG = 0x0D, MultisigEntry, CEN-I19's check_pqc_multisig_shape (§7.4)
  • Retired, never reused: 0x09, 0x0A. Reserved (spec table only, no code symbol): 0x08

16.3 Defense-in-depth wiring fixes

MSW-6 (landed). The tx-wide expected_scheme_id DiD that forced tx-wide scheme agreement is withdrawn (option a — dropped outright, not exempted). Its stated purpose (a cross-input scheme-downgrade defense) was vacuous: expected_scheme was derived from pqc_auths[0] itself (self-referential), and per-output scheme binding is the leaf commitment opening (PL-D3; formerly the leaf hash h_pqc = H(hybrid_public_key)), not this check. Its actual effect was to foreclose a solo(1)/multisig(2) cross-model linkage — co-spending proves common control across key models (the 2026-09-11 reading added "the only proof, since separate FCMP++ txs are unlinkable"; that premise was false under PL-D1 and holds again since PL-D3, 2026-09-14). That belongs in the wallet, not consensus. Re-based 2026-09-14 (PL-D1, FCMP_SPEND_LINKABILITY.md): the first of the two grounds recorded here — "no externality: the FCMP++ proof ranges over the whole tree, so no other party's anonymity set shrinks" — was struck while every FCMP++ spend identified its input by the public 4th leaf scalar (PL-D1); PL-D3 (2026-09-14) restored it. In the interval the conclusion stood on the remaining ground alone: (2) Shekyl already permits exactly this opt-in class — a scheme_id=2 spend provably marks the spender, shipped as a disclosed opt-in cost — so refusing an opt-in cross-model link while permitting the multisig mark would be incoherent. It is therefore a wallet coin-selection invariant, which must land as a blocking E′ / MS-5 ship gate (a coin-selection rule that never crosses key models, with a test, + the disclosure line — see the FOLLOWUPS residue), not a consensus mechanism. (Not TM-1: that disposition rests on the linkage being impossible to mechanize — shared-operator personas are unlinkable by construction — which does not transfer to a case where the mechanism existed and worked.) Each input is still validated per-input (scheme ∈ {1,2}, blob length, signature). Cross-scheme confusion remains prevented by length disjointness (MSW-2); archival core never sees funding pqc_auths.

  • src/cryptonote_core/blockchain.cpp tx-wide expected_scheme_id → MSW-6 landed: no tx-wide scheme agreement in either verifier (the CEN-I18 validator and the submit verifier, verifier.rs).
  • group_id DiD for scheme_id=2: MS-8 retired (the leaf already binds the committed form; no-op)

16.4 Modified C++ — RETIRED

Retired 2026-07-15 (Phase 0). Do not implement wallet2 / cryptonote_tx_utils multisig paths. Wallet-side multisig logic lands in Rust behind the multisig Cargo feature (V3_1_MULTISIG_RUST_ENGINE.md, MS-2). Allowed C++ surface: LMDB / chain-DB persistence of consensus-visible bytes; existing scheme_id ∈ {1,2} verify into Rust FFI; MSW-6 tx-layer scheme rule. Historical bullets deleted rather than kept as "superseded" temptation.

16.5 New address parsing

  • rust/shekyl-encoding/src/lib.rs: shekyl1m HRP
  • rust/shekyl-address/: MultisigAddress type with spend_auth_version handling

16.6 GUI wallet changes

  • Multisig page: file import/export for addresses
  • Multisig page: mandatory fingerprint verification dialog (3 representations)
  • Multisig page: 1/N loss acknowledgment gate at group setup
  • Multisig page: prover-responsibility distribution view
  • Multisig page: address provenance tracking UI with change warnings
  • Settings: relay configuration (minimum 3 with operator diversity check)
  • DKG ceremony UI for group setup
  • Invariant violation alerts surfaced to user

16.7 Feature flag structure

Status: planned sketch — these flags are not in any Cargo.toml today. Do not copy-paste the block below into a crate; it names the intended gate axes for Track B / Option E′. Live today: the multisig feature on engine-core, shekyl-fcmp and shekyl-fcmp-proofs (F-6 CI lane, .github/workflows/multisig-feature.yml). frost-sal-v4 and unsafe-testing-only land when E′ / simple-mode fixtures are implemented (after Option A orchestration DELETE).

# PLANNED (not present in workspace Cargo.toml as of 2026-07-15)
[features]
default = []
multisig = []                       # scaffolding / CI compile (F-6) — PARTIAL: exists
frost-sal-v4 = []                   # Option E′ product path (15.4a) — NOT YET
unsafe-testing-only = []            # simple-mode fixtures, dev only — NOT YET
# Mutual exclusion: cargo enforces at compile time (when landed)

CI verifies release builds do not contain simple-mode symbols. F-6: CI must build check/clippy/test with --features multisig.

frost-sal-v4 is E′'s planned gate (was specified, never built — now the first real coexistence boundary for spend_auth_version = 0x02). The Option A MultisigGroup wrapper is deleted; the SAL layer it gates is shekyl_fcmp::frost_sal, and the Option A orchestration (multisig/{dkg,group,signing}.rs, R1-F-3) is deleted. Do not park the rejected fixed pqc_public_key fossil behind this flag.

16.8 Test matrix

Functional:

  • 2-of-3, 3-of-5, 5-of-5 happy paths (receive + spend)
  • Single-sig → multisig, multisig → multisig
  • Change outputs (group → self)
  • Staked outputs

Adversarial (per-invariant):

  • Malicious proposer attacks each of §9.2 invariants (I1)
  • Chain state fingerprint manipulation (I2)
  • Malicious prover: wrong payload, malformed proof (I3)
  • Malicious prover: incorrect BP+ randomness (I4)
  • Wrong-prover key image attack (I5)
  • tx_hash commitment disagreement in signing (I6)

Adversarial (receive, §8.2):

  • A sender lies about one member's key hash: that member sees MemberKeyHash, every other member LeafMismatch
  • Damaged or missing 0x0D entry, damaged leaf, wrong out_idx, wrong group size
  • A member decapsulating another member's ciphertext cannot pass that member's hash

Adversarial (other):

  • Network partition + CounterProof recovery (including malformed proofs)
  • Relay censorship; operator collusion detection
  • Conflicting simultaneous intents (ProverReceipt tiebreaking)
  • Rotation rule grinding attempts
  • Prover equivocation (detection + EquivocationProof)
  • Sustained griefing: per-sender score effectiveness, state bounds
  • Address fingerprint change: dual confirmation triggers
  • Rate limit bypass via multi-indexing (by signing key)
  • Unknown spend_auth_version silent skip
  • Simple-mode absence in release builds

Interop:

  • Malicious client produces invariant-violating SignatureShare; all conforming clients reject identically
  • Cross-platform determinism: same intent → same bytes on Linux, macOS, Windows, x86_64, ARM64

Performance:

  • Scanner cost at 10k+ tx/block with 5%, 10%, 25% multisig adoption
  • Griefing score lookup performance
  • Prover proof construction time
  • Multi-relay overhead

16.9 Fuzz targets

fuzz_spend_intent_deserialize
fuzz_construction_determinism
fuzz_envelope_parser
fuzz_multisig_address_parse
fuzz_view_tag_hint_check
fuzz_rotating_prover_assignment      (uniformity + grindability)
fuzz_counter_proof_verifier          (strengthened rules)
fuzz_equivocation_proof_verifier
fuzz_invariant_violation_parser
fuzz_spend_auth_pubkey_derivation
fuzz_receive_time_validation

16.10 Rollout sequencing

PhaseDurationContent
14-6 wkReceiving model (Option C + Solution C), spend-auth derivation, tx_extra tag 0x0A, address format, wallet-side filtering with griefing scores, defense-in-depth wiring
24-6 wkGovernance protocol, invariants (§2.7), state machine, CounterProof, heartbeat with operator IDs, multi-relay + directory, DKG mandatory enforcement, InvariantViolation message type
33-4 wkGUI: fingerprint (3-representation) verification, 1/N acknowledgment gate, prover distribution view, address provenance tracking, relay diversity check, DKG ceremony UI
43-4 wkTest matrix, fuzz harness, cross-platform determinism, interop tests across conforming clients
52-3 wkExternal adversarial review (reviewer round 4)
6TBDCryptographer review of specific targets (see ANALYSIS §7)

Total estimate: 16-23 weeks engineering + cryptographer review.


17. Appendix A: Canonical Test Vectors

Normative. Implementations MUST produce byte-identical output to these vectors for the input conditions specified. Any implementation that cannot is non-conforming.

Option-D residue (2026-07-18, MS-5 PR-B). Several vectors below are shaped for the withdrawn Option-D flow and name machinery that is now deleted: group_id (A.1 — identity is the address fingerprint, §5.3), construct_multisig_output / assigned_prover_index (A.2 — no constructor and no mandatory prover under E′; the two-component O = ho·G + B_group + y_out·T constructor lands in S2/S4), and the CounterProof vector (A.5 — §11 deleted machinery). spend_auth_version is 0x02, not 1. These specs are rewritten to E′ shape as S2/S4 lands the live crypto; treat the Option-D-named outputs here as historical until then.

Test vectors are maintained in a separate file test_vectors/v3.1/ alongside the implementation. This appendix enumerates required vectors with their structure; actual bytes will be generated at implementation time from the reference Rust implementation and cross-verified by at least two independent implementations before being locked.

A.1 Required vectors

  1. Group setup vector

    • Input: fixed 5 hybrid signing keypairs, fixed 5 hybrid KEM keypairs, fixed parameters (n=5, m=3, group_version=1, spend_auth_version=1)
    • Output: canonical serialized address, computed group_id, fingerprint (hex + word-phrase + structured metadata)
  2. Receive-output vector

    • Input: fixed recipient address (from A.1), fixed tx_secret_key, fixed output_index_in_tx, fixed reference_block_hash, fixed amount
    • Output: full construct_multisig_output result — the 0x06 ciphertext, the output key O, the commitment, the 0x07 entry (CM ‖ record over the committed form), and the 0x0D entry (member ciphertexts, encrypted key hashes)
  3. Spend-intent vector

    • Input: fixed group state (from A.1), fixed input list, fixed recipient list, fixed parameters
    • Output: canonical serialized SpendIntent, intent_hash, chain_state_fingerprint
  4. Full-construction vector

    • Input: fixed spend intent (from A.3), fixed chain reference data
    • Output: canonical transaction bytes (prefix + rct_base + rct_prunable_skeleton + pqc_auth_header), signing_payload hash, deterministic BP+ bytes
  5. CounterProof vector

    • Input: fixed consumed inputs, fixed resulting outputs, fixed block data
    • Output: canonical CounterProof bytes, verification pass/fail cases (including malformed attempts)
  6. Prover-assignment vector

    • Input: grid of (group_id, output_index_in_tx, tx_secret_key_hash, reference_block_hash, n_total)
    • Output: expected prover_index for each combination (uniformity verification)

A.2 Generation and verification protocol

  1. Vectors generated by the reference Rust implementation
  2. Each vector cross-verified by a second independent implementation (e.g., C++ via FFI)
  3. Vectors locked in the repository; changes require a spec version bump
  4. Client certification: any implementation claiming V3.1 conformance MUST pass all vectors in this appendix

18. Appendix B: Mapping from Original Spec

This document supersedes:

  • PQC_MULTISIG.md (original; coordinator-based)
  • PQC_MULTISIG_V3_1.md (governance draft)
  • PQC_MULTISIG_V3_1_RECEIVING.md (Option C receiving draft)
  • PQC_MULTISIG.md v1.0 (consolidated draft; superseded by v1.1)

All material from predecessors is consolidated here. Predecessor documents can be deleted from the repo once this document is merged. The original PQC_MULTISIG.md should be updated to a single-paragraph deprecation pointer.

For attack analysis, size analysis, and design rationale, see the companion document PQC_MULTISIG_V3_1_ANALYSIS.md.