# Prefractal: a nested FROST+MuSig2 signer **WARNING: EXPERIMENTAL.** Neither the scheme nor this implementation has been reviewed by anyone outside the project. Do not use it to protect anything of value. The construction comes from [frosty-musig][frosty], which is unaudited research code, and it is built on this repository's `frost` module, which is itself marked experimental and unstable. [frosty]: https://github.com/jesseposner/frosty-musig ## What it does It lets a FROST `t`-of-`n` group occupy **one participant slot** of an ordinary MuSig2 (BIP 327) session. The group publishes one ordinary MuSig2 public nonce and one ordinary MuSig2 partial signature. Cosigners need no support for any of this and cannot tell a group is involved. The motivating shape is a 2-of-2 taproot output where one of the two "participants" is really a threshold group. ## The signing equation Each member `i` of the participating set computes ``` s_i = k1_i + b_frost * b_musig * k2_i + e * a * lambda_i * g * gacc * d_i ``` - `k1_i`, `k2_i` — the member's two nonce scalars, both negated iff the OUTER final nonce has odd Y. - `b_frost` — this module's nonce-binding coefficient (below). - `b_musig`, `e`, `a`, `g`, `gacc` — all from the OUTER MuSig2 session: the nonce coefficient, the BIP 340 challenge over the aggregate key, the key-aggregation coefficient of the group's threshold public key, and the aggregate key's parity bookkeeping. - `lambda_i` — the member's Lagrange interpolating value over the participating set. - `d_i` — the member's secret share. The group's wire nonce is its FROST aggregate nonce with the second component premultiplied by `b_frost`: ``` pubnonce = (R1, b_frost * R2) ``` The aggregator sums the members' shares. That is a plain sum, with no interpolation, because `lambda_i` is already folded into each share. ## Three deliberate deviations from BIP 445 These are the parts a reviewer should look at hardest. Each one is a considered trade, and each one is enforced or pinned somewhere in the code. ### 1. `b_frost` does not commit to the message BIP 445's nonce coefficient hashes the message. This module's does not: ``` b_frost = tagged_hash("Prefractal/noncecoef", ser32(u) || sorted ser32 ids || aggnonce66 || cbytes_ext(thresh_pk)) ``` **Why.** The protocols this module targets publish the group's wire nonce *before the message exists*. A lightning channel's funding signer publishes a verification nonce at commitment number `N` long before the transaction that nonce will sign has been built. A coefficient that hashed the message could not be computed in round one and rebuilt identically in round two. **Why it is not fatal.** The outer coefficient `b_musig` *does* commit to the message, via `secp256k1_musig_nonce_process_internal`, and it multiplies `b_frost` in every term where `b_frost` appears. The product binds the message. This is the same trade the `iceberg` module makes, for the same reason, with its own `Iceberg/noncecoef` tag. **What is different from BIP 445's preimage.** The message is dropped, and the threshold public key is hashed in its full 33-byte extended encoding rather than x-only, because the key is used as a full point everywhere downstream (see deviation 2) and the binding should cover the point that is actually in play. **Pinned by.** `run_prefractal_midstate_test` checks the tagged-hash constant against a freshly initialised one. Nothing else in the tree would notice a changed `b_frost`; it would simply produce signatures that do not verify. ### 2. There is no `g_frost` factor Stock FROST negates the secret share when the threshold public key has odd Y: ```c /* frost/session_impl.h:664 */ session_i->g_times_gacc_parity = cache_i->gacc_parity ^ pk_odd; /* frost/session_impl.h:797-800 */ if (session_i->g_times_gacc_parity) { secp256k1_scalar_negate(&d, &d); } ``` It does this because standalone FROST produces a BIP 340 x-only signature, so the effective secret is normalised to the even-Y representative of the threshold key. **Here that must not happen.** The threshold public key is an *inner participant* of the outer key aggregation. It enters `secp256k1_musig_pubkey_agg` as a full 33-byte point, and MuSig2 does no per-participant parity normalisation: the only key-side flip is at the aggregate level, off the OUTER keyagg cache. So the group's members must reconstruct `d` with `d*G = thresh_pk` exactly as dealt, whatever its Y parity. **The trap.** It is tempting to say "the FROST tweak cache is the identity, so the frost key-side factor is 1". That is **false**. The factor is `g * gacc`. An identity cache gives `gacc = 1`, but `g` is still `-1` for every threshold key with odd Y — roughly half of all groups. An implementation that reused `secp256k1_frost_get_session_values`'s key-side handling would produce a signer that works for even-Y groups and fails for odd-Y ones. **Pinned by.** The test suite carries two *fixed* threshold secret keys, one of each Y parity, and `run_prefractal_odd_y_group_key_test` asserts the parity of its own fixture so it cannot quietly stop testing what it is named after. This was verified by mutation: injecting the `pk_odd` negation makes the odd-Y test fail while the even-Y one still passes. With a randomly seeded fixture that would have been a coin flip per run. ### 3. The FROST tweak cache must be the identity `tacc == 0` and `gacc_parity == 0`, checked by every entry point that takes a cache. **Why.** The target protocols tweak only the *outer* aggregate key — the BIP 341 key-path tweak is applied to the MuSig2 keyagg cache and handled by the stock outer session. A frost-level tweak would add an `e * g * tacc` term that the aggregator would have to fold in, and this module's aggregator is a plain sum. **Where it is checked.** In `secp256k1_prefractal_sign` and `secp256k1_prefractal_partial_sig_verify`, not only in `secp256k1_prefractal_partial_sig_agg`. Checking only at aggregation would be too late and too weak: the signing path would never see the cache, so nothing would tie the key a member signed under to the cache that was validated. `sign` and `partial_sig_verify` additionally require `thresh_pk` to equal the cache's own key, so the two arguments cannot disagree. A tweak-aware aggregation variant (folding `e * g_musig * tacc`, as frosty-musig's `nested_frost_partial_sig_agg` does) is a possible later extension. It is not implemented. ## Rules the caller must follow ### One secnonce, one signature The usual FROST rule, and this module cannot enforce it any better than FROST can. `secp256k1_prefractal_sign` wipes the secnonce, so a second call with the same one fails — including when the first call failed for some other reason, which is why a member whose signing attempt was refused must generate a fresh nonce rather than retry. Deployments that derive nonces deterministically from a session label (which is how a protocol gets a nonce it can publish early and rebuild later) inherit a sharper version of the rule: **one label signs one message, group-wide**. Two different messages under one label leak the secret share, and nothing raises an error. ### The round-two signer set must equal the round-one set Not a subset — the same set. `lambda_i` and the aggregate nonce are both defined over the participating set. If round one aggregates over `C` and only `S ⊂ C` signs, then the nonce terms of `C \ S` are still in `R` while their key shares are absent from `sum(s_i)`, and `sum_{i in S} lambda_i^C * d_i != d`. The result is an invalid signature with no error raised at signing time. This is worth stating explicitly because the `iceberg` module in this same repository *does* tolerate a subset: its `2t-1` / `t` split comes from VSS interpolation over the contributions, and FROST has no equivalent. Callers porting between the two must not transpose the rule. `run_prefractal_partial_sig_verify_test` covers the detectable half of this: a share made for one signer set does not verify under another. ### Nonces at infinity A FROST aggregate nonce component may legitimately be the point at infinity (BIP 445 NonceAgg), but a MuSig2 public nonce has no encoding for one. Both columns can reach infinity independently — the first is passed through unscaled, the second only after the `b_frost` multiplication — and `secp256k1_prefractal_nonce_agg` refuses both. Such a session has to be restarted with fresh nonces. ## API All four functions are sessionless: every call takes its session parameters explicitly, so there are no opaque session objects, no new magics and no `*_SIZE` constants to keep synchronised across bindings. | Function | Role | | --- | --- | | `secp256k1_prefractal_nonce_agg` | round one: group wire nonce + unscaled aggnonce | | `secp256k1_prefractal_sign` | round two: one member's partial signature | | `secp256k1_prefractal_partial_sig_verify` | identifiable abort | | `secp256k1_prefractal_partial_sig_agg` | sum shares into a MuSig2 partial signature | `aggnonce_out` from `nonce_agg` is an internal value, not a wire value: it is the *unscaled* FROST aggregate, and it must be handed back to `sign` and `partial_sig_verify` unchanged. The wire value is `pubnonce_out`, an ordinary 66-byte MuSig2 public nonce. Members generate their nonces with the stock `secp256k1_frost_nonce_gen`. This module adds no nonce generation of its own. ## Relationship to the other modules - **`frost`** stays pure, vector-pinned BIP 445. This module deliberately does not live inside it: the deviations above are not BIP 445, and keeping them behind their own opt-in flag gives auditors a clean scope boundary. It also keeps `frost`'s dependency graph honest — `frost` depends only on `schnorrsig`, and every pure-FROST consumer would otherwise have to build `musig` too. - **`musig`** is used unmodified, through its internals. Cosigners run stock MuSig2 throughout. - **`iceberg`** solves the same outer problem with a different inner scheme. The two differ in ways that do not transfer: iceberg's quorum is `2t-1` in round one and `t` in round two and it tolerates a round-two subset; prefractal uses `t` in both rounds and requires set equality. Iceberg cannot express 2-of-2 or 3-of-4; prefractal can. ## Build The module depends on both `frost` and `musig` and forces them on. ``` cmake -B build -DSECP256K1_ENABLE_MODULE_PREFRACTAL=ON -DSECP256K1_BUILD_TESTS=ON cmake --build build && ./build/bin/tests --target=prefractal ``` ``` ./autogen.sh ./configure --enable-experimental --enable-module-prefractal make && make check ``` Three files order their module blocks differently, and the constraints point in opposite directions. Anyone adding a module by copying this one should read this rather than copying `iceberg`'s positions: - `src/secp256k1.c` — the include goes **after** `frost` and `musig`, because the module calls their `static` internals and the whole library is one translation unit. - `src/CMakeLists.txt` — the block goes **before** both, because its `set()` calls are only observed by blocks that run later. - `configure.ac` — the block likewise goes **before** the `musig` block, *not* at `iceberg`'s position further down. `configure.ac` orders `musig` and `frost` ahead of `iceberg`, and iceberg's late `enable_module_musig=yes` is harmless only because `musig` defaults to yes. `frost` defaults to **no**, so a late force-enable would leave `-DENABLE_MODULE_FROST=1` unemitted while `AM_CONDITIONAL` still observed the mutation. `frost` is also the first default-OFF module anything depends on, which breaks the dependency-guard idiom used everywhere else in both build systems. The existing `DEFINED X AND NOT X` (CMake) and `x$X = xno` (autotools) tests read as "the user disabled it explicitly" only for default-ON modules, and are true by default for a default-OFF one. Neither build system can distinguish an explicit disable from the default once both are in the cache, so enabling `prefractal` simply implies `frost`; the guard is kept for `musig`, where it still means what it says.