Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions solutions/LP-0002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
# Solution: LP-0002 - Private M-of-N Multisig

**Submitted by:** Davit Maisuradze ([@jeefxM](https://github.com/jeefxM))

## Summary

An anonymous M-of-N multisig program for the Logos Execution Zone: a treasury is
controlled by `N` members, and a proposal releases funds once `M` of them
approve, with each individual approval staying **anonymous among the member
set**. Built natively for the LEZ privacy-preserving-transaction model and the
RISC0 zkVM (no Semaphore/MACI code ported, just the structural pattern
reimplemented for LEZ).

- **Anonymous approval** is a real RISC0 STARK (~174 s at `RISC0_DEV_MODE=0`)
that proves in-guest Merkle membership in the frozen member set without
revealing which member, records a proposal-bound nullifier (no double votes),
and increments the public count. The member's secret travels only as a private
witness and never touches the chain.
- **Membership is bound to shielded accounts by derivation.** Each member's
membership secret IS their real shielded-account nullifier secret key (`nsk`),
HD-derived from the LEZ key tree. Control of the `nsk` is control of the
account. The enrolled leaf is `H(LEAF_DOMAIN || nsk)`, a one-way hash, so the
public registry never links a leaf (or a later vote) to an on-chain account.
Stated honestly: this is derivation-binding, not an in-circuit proof that the
leaf maps to a live account in the chain's commitment tree.
- **Threshold-gated treasury release.** Once `approval_count >= M`, `Execute`
drains the treasury PDA to the recipient via a chained `authenticated_transfer`
call (only the owning program can move an account's balance).
- **Live on LEZ testnet** under deployed program id
`HjHCub28GrUNgd2QuJ2SPob7YmaUgDRCGXwbt2jt4UWn`, with a full 2-of-3 lifecycle
(proposal `Hf84MVjY`) landed on chain and independently verifiable.
- **Reference Logos Basecamp module** (`ui_qml` plugin) that derives a leaf and
casts a real anonymous vote through the same client path, plus a one-command
reproducible demo at `RISC0_DEV_MODE=0`.

## Repository

- **Repo:** https://github.com/jeefxM/lp-0002-private-multisig
- **Branch:** `main`
- **Program id (LEZ testnet):** `HjHCub28GrUNgd2QuJ2SPob7YmaUgDRCGXwbt2jt4UWn`
- **Video Demo:** https://www.youtube.com/watch?v=CXzqWLvBY0A
- **License:** MIT (see `LICENSE` and `NOTICE` in the repo).

The full write-up (threshold scheme, nullifier design, LEZ account model with the
`nonce` and `program_owner` handling, security assumptions, known limitations,
integration guide) lives in the repo at
[`docs/LP-0002-solution.md`](https://github.com/jeefxM/lp-0002-private-multisig/blob/main/docs/LP-0002-solution.md).
Proving-time and cost proxies are in `docs/lp0002-benchmarks.md`; failure modes
and error codes in `docs/lp0002-reliability.md`; the architecture map and diagram
in `docs/ARCHITECTURE.md`.

## Approach

Reimplemented the Semaphore/MACI structural pattern (public leaf commitment in a
Merkle set, in-circuit membership proof, domain-separated action-bound nullifier)
natively for LEZ + RISC0. No FROST, no signature aggregation: the threshold is a
public count over per-member ZK membership proofs.

- **Threshold scheme:** depth-5 Merkle member set (32 slots). `Enroll` publishes
leaf `H(LEAF_DOMAIN || nsk)`; `CreateProposal` freezes `member_root` +
`proposal_id` with count 0; `Approve` proves Merkle membership in-guest (without
revealing which leaf) and increments the count; `Execute` releases the treasury
at threshold `M` (supplied as an argument, so one deployed program serves any
M-of-N up to 32 members).
- **Nullifier design:** `nullifier = H(NULL_DOMAIN || nsk || proposal_id)`,
domain-separated from the leaf hash. Proposal-bound, so a member can vote on
different proposals but not twice on the same one; two distinct members produce
two distinct nullifiers; the proposal state stores only root + id + count +
opaque nullifiers, never any member identity.
- **Shielded-account binding:** membership secret = the member's real
shielded-account `nsk` (`SeedHolder -> SecretSpendingKey ->
produce_private_key_holder(i).nullifier_secret_key`). Derivation-binding
(control of the key = control of the account), stated honestly as not an
in-circuit live-account proof.
- **LEZ account-model compatibility:** the approve runner reads the proposal's
**live nonce** right before proving (private accounts can't provide the fresh
zero-nonce the public multisig path expects), and because only an account's
owning program can move its balance, the treasury is `authenticated_transfer`-
owned and `Execute` chains a call into it (`InitTreasury` first claims the PDA
under msig's PDA authorization). Full detail in `docs/LP-0002-solution.md`.

## Success Criteria Checklist

Mapped to the prize's criteria. `[x]` met, `[~]` partial (honest scope noted),
`[ ]` open.

### Functionality

- [~] **Anonymous M-of-N approval by a shielded-account holder.** The approval is
a privacy-preserving ZK tx; on-chain state records only root + id + count +
opaque nullifiers, no member identity. Each member's secret IS their real
HD-derived shielded-account `nsk`, so membership is bound to a shielded account
by derivation. Partial because the binding is by derivation, not an in-circuit
proof that the leaf maps to a live account in the commitment tree (that path
was not a proven primitive for a custom program on this testnet rev).
- [x] **Threshold confirmed without recording who approved.** `Execute` asserts
`count >= M`; recorded nullifiers are opaque.
- [x] **No double-voting.** Proposal-bound nullifier with an in-guest
already-recorded check.
- [x] **Execution unlinkable to any individual member.** `Execute` reads only the
public count and debits via `authenticated_transfer`; it references no secret
or leaf.
- [~] **Client-side proving on a standard laptop.** Proving is client-side; a real
`RISC0_DEV_MODE=0` approve was measured at ~174 s on the 16-core build host.
Standard-laptop timing is not separately measured; proving is RAM/CPU-bound, so
a laptop will be slower.
- [x] **Reference integration: threshold-gated treasury transfer on testnet.** The
2-of-3 run releases a treasury to a recipient at threshold 2.
- [x] **At least one instance on testnet, proposal approved by threshold and
executed, reproducible with evidence.** Program `HjHCub28...`, proposal
`Hf84MVjY`, tx hashes below.
- [x] **Full documentation and a clean public repository.** Delivered at
`github.com/jeefxM/lp-0002-private-multisig`.

### Usability

- [~] **Module/SDK to build Logos modules.** `msig_core` is a reusable scheme
crate (byte-identical hashing/Merkle math in-guest and client) and the `run_*`
bins are a worked client; a separately packaged SDK crate is not split out.
- [~] **Logos Basecamp app GUI.** A native `ui_qml` Basecamp plugin
(`basecamp/`, prebuilt `msig_plugin.so`) loads in Basecamp's host and casts a
real anonymous vote through the same client path (sidecar spawns
`run_approve_secret`, a real `RISC0_DEV_MODE=0` STARK), demonstrated in the
video. Prebuilt downloadable assets are not separately hosted.
- [~] **SPEL IDL.** `idl/lp0002-msig.idl.json` (declares spec `spel-0.1`,
address `HjHCub28...`) describes all five instructions. It is hand-authored
JSON; full conformance to the upstream SPEL toolchain is unverified.

### Reliability

- [~] **Proof-generation failures surface a clear error.** The runner propagates
proving errors; a polished member-facing error UX is not built.
- [ ] **Partial-approval resume across client restarts.** Approvals are durable
on-chain (count + nullifier list persist), so a partial set survives restarts at
the state layer, but a client resume/recovery flow is not implemented. Open.
- [x] **Deterministic, documented error conditions.** Guest asserts carry stable
strings ("approver is not an enrolled member", "approval nullifier already
recorded (double vote)", "approval count below threshold", "proposal id
mismatch"); negative tests pin non-member and double-vote rejection.

### Performance

- [ ] **CU cost of each on-chain operation.** Open. This nssa v0.1.2 rev exposes
no compute-unit / gas / fee field (verified absent in the wallet CLI,
chain-info tx/block, and `common/transaction.rs`). Documented with defensible
proxies instead of a fabricated number: real proof ~174 s, succinct receipt
~224 KB, and the approve tx (carrying the receipt) vs a tiny no-proof Execute tx
as a relative block-size proxy. See `docs/lp0002-benchmarks.md`.

### Supportability

- [x] **Deployed and tested on LEZ testnet.** Program `HjHCub28...`, 2-of-3 run.
- [~] **End-to-end integration tests against a standalone sequencer, in CI.** The
8 state tests + 4 circuit tests exercise the full flow in-process and run in CI
under `RISC0_DEV_MODE=1`; a standalone-sequencer end-to-end CI job is not wired
(the reproducible `scripts/lp0002-demo.sh` covers that path locally).
- [x] **CI green on the default branch.** `.github/workflows/lp0002-ci.yml` runs
the msig core/state/circuit tests + runner build and is green on `main`.
- [~] **README documents end-to-end usage.** The LP-0002 README + write-up cover
deployment, the program address, and the run commands; a full CLI/Basecamp
step-by-step walkthrough is partial.
- [x] **Reproducible demo script at `RISC0_DEV_MODE=0`.** `scripts/lp0002-demo.sh`
runs green end-to-end against a local standalone sequencer (two real proofs,
count = 2, treasury drained, recipient credited); re-verified from a fresh
checkout on a reviewer-equivalent toolchain state.
- [x] **Narrated video showing terminal output confirming `RISC0_DEV_MODE=0`.**
https://www.youtube.com/watch?v=CXzqWLvBY0A

## FURPS Self-Assessment

### Functionality

A working private M-of-N multisig: enroll, create-proposal, anonymous threshold
approval (a privacy-preserving ZK tx), and threshold-gated treasury release
through `authenticated_transfer`. The 2-of-3 live run is the M-of-N proof. The
threshold is a public count over per-member ZK membership proofs, not FROST
signature aggregation. Functional tests run under `RISC0_DEV_MODE=1` for logic
coverage; the ZK soundness evidence is the real `RISC0_DEV_MODE=0` approve STARK
landing and applying on the live sequencer.

### Usability

`msig_core` gives integrators byte-identical hashing and Merkle math, so a client
cannot diverge the root or nullifier computation from the guest. The `run_*` bins
are copy-able worked examples for each instruction, including the hard privacy
approve (live nonce fetch, witness assembly, proof, submit). The Basecamp `ui_qml`
module drives the same client path from a GUI. Limitation: no packaged
stand-alone SDK crate in this rev.

### Reliability

The verifier's failure modes are deterministic and carry stable assert strings,
re-checked by the negative circuit tests (non-member, double vote) and the
apply-rejection state tests. Approvals are durable on-chain (the count and the
nullifier list persist), so a partial approval set survives client restarts at
the state layer. Limitation: a client-side resume/recovery UX over that durable
state is not implemented; proof-failure surfacing is functional but not polished.

### Performance

This rev exposes no CU / gas / fee surface, so we document defensible proxies
instead of fabricating a number: a real `RISC0_DEV_MODE=0` approve proof takes
about 174 s on the build host; the on-chain receipt is a succinct STARK of about
224 KB; and the approve transaction (carrying the receipt) is substantially
larger on-chain than a tiny no-proof Execute transaction, the closest stand-in
for per-op cost on a chain with no exposed gas meter.

### Supportability

This solution plus `docs/LP-0002-solution.md` document the cryptographic approach,
the nullifier scheme, the LEZ account model (nonce and `program_owner`), the
security assumptions, the known limitations, and the integration guide. The
LP-0002 CI workflow runs the msig core/state/circuit tests under
`RISC0_DEV_MODE=1` and is green on `main`. A standalone-sequencer end-to-end CI
job is the one open item.

## Evidence (live on `testnet.lez.logos.co`)

2-of-3 threshold run, HD-nsk-derived members, program `HjHCub28...`, `RISC0_DEV_MODE=0`:

- Proposal `Hf84MVjY` (member_root `38ea719c`, proposal_id `9f1c47a2`)
- Approve #0 (member 0): `09c9cf27`, real STARK 174.18 s, count 0 → 1, vote nullifier `748015dc`
- Approve #1 (member 1): `83007dcd`, real STARK 173.78 s, count 1 → 2, vote nullifier `7d37760a`
- InitTreasury `9bfb9fde` / `6696b49d`; Fund 20 `7db0d6c7`; Execute `deed4d0c`
- On-chain assert: count = 2, treasury = 0, recipient = 20, all as expected

The two vote nullifiers are distinct (two different members); the proposal state
stores only root + id + count + opaque nullifiers. Treasury and recipient are
fresh PDAs (uninitialized before the run), so the credit of exactly 20 is
attributable solely to this execute. Any hash is verifiable with
`wallet chain-info transaction --hash <hash>`.

## Reproduce

From a clean clone (real proofs, the default gate; install Rust + the RISC0
toolchain via `rzup install` first, see the repo README):

```bash
./demo.sh # or, explicitly: RISC0_DEV_MODE=0 scripts/lp0002-demo.sh
```

It boots its own local standalone sequencer and drives deploy → enroll×3 →
create_proposal → approve×2 (two real STARKs) → init treasury → fund → execute →
assert (count = 2, treasury drained, recipient credited).

## Known limitations (honest scope)

- **Member set is public.** Anonymity is over which member approved, within a
public enrolled set (the standard Semaphore model, consistent with the prize's
"only member identity and vote are private" scope).
- **Derivation-binding, not in-circuit live-account binding** (see Functionality).
- **32-member cap** (`TREE_DEPTH = 5`); a deeper tree is a one-line change at
higher proof cost.
- **No CU/gas surface** on this rev (documented with proxies).
- **No client-side partial-approval resume UX** (state persists on-chain).
- Demo keys/amounts are throwaway constants; single-signer enrollment in this rev.

## Terms & Conditions

By submitting this solution, I confirm that I have read and agree to the
[Terms & Conditions](../TERMS.md).
Loading