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
266 changes: 266 additions & 0 deletions solutions/LP-0002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
# Solution: LP-0002 Private M-of-N Multisig

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

> **Resubmission (fourth).** This addresses the review of
> [PR #133](https://github.com/logos-co/lambda-prize/pull/133), which found that
> `execute()` took the threshold from the caller, never bound the supplied
> recipient to the proposal, and transferred the whole treasury, with no test and
> a checklist that claimed enforcement anyway. All three are fixed:
>
> 1. **Nothing security-relevant comes from the caller any more.**
> `CreateProposal` freezes `threshold`, `recipient` and `amount` into the
> ProposalState header (`msig_core::ProposalHeader`, 124 bytes) next to
> `member_root` and `proposal_id`. `Execute { seed }` carries nothing the
> guest does not re-derive: the treasury a proposal may spend is
> `for_public_pda(msig_id, SHA256(TREASURY_DOMAIN || member_root))`, the
> treasury of the member set it was frozen on. In-guest it asserts, in order:
> the proposal is owned by this program; it has not executed before;
> `approval_count >= threshold`; the supplied recipient **is** the frozen
> recipient; the supplied seed **is** `treasury_seed(member_root)`; the
> supplied treasury is the program's PDA for that seed; the treasury holds
> at least `amount`. It then chains a transfer of **exactly `amount`**, not
> the balance, and rewrites the proposal with `executed = true`, so a
> proposal releases at most once. Because `CreateProposal` is permissionless
> and takes `member_root` from its caller, the treasury derivation is what
> binds a proposal to a member set: a proposal frozen on any other root can
> only ever reach the (unfunded) treasury of that root.
> 2. **Tests, in-process and on-chain.** Ten `msig_execute_*` state tests pin
> each rejection, the exact-amount release, and the foreign-member-set attack
> in three shapes; a `msig_core` test pins the header layout. Beyond that,
> both end-to-end scripts now **submit four deliberately wrong `Execute`
> transactions against a funded treasury and fail unless the chain drops
> each one**: at 1-of-2 approvals, naming a different recipient, naming a
> treasury seed not derived from the member set, and again after the
> release. Three of the four are in the live ledger below as transactions
> whose `getTransaction` returns `null`.
> 3. **The checklist now states what is enforced and where**, the write-up's
> "known limitation" block is replaced by the fix and its test list, and the
> error-condition table carries the new release-gate rejections.
>
> Because the guest changed, the program id changed, so the whole 2-of-3 flow was
> re-run on the live testnet with real STARKs and re-captured. The previous run's
> raw captures are kept under `evidence/prior-2026-08-27/` rather than deleted.

## Summary

A private M-of-N multisig primitive for the Logos Execution Zone (LEZ). Members
enroll a public leaf into an on-chain registry, a proposal freezes the member
set, and each approval is a privacy-preserving (ZK) transaction that proves
membership in the frozen member set and records a proposal-bound nullifier. The
on-chain ProposalState records only the frozen header and an opaque list of vote
nullifiers. It does not record which member approved. Once the count reaches the
frozen threshold, an Execute instruction releases the frozen amount to the frozen
recipient through a chained call to the `authenticated_transfer` builtin.

The design gives approver anonymity within the enrolled set of N public members;
the approval count is public, which specific member approved is hidden.

- **Per-member ZK membership proofs.** Each approval is a RISC0 zkVM
privacy-preserving transaction proving a depth-5 Merkle membership path against
the proposal's frozen `member_root`, with the member secret carried as a
private witness. The threshold is enforced by a public approval count, not by
signature aggregation. This is NOT FROST signature aggregation.
- **Proposal-bound nullifiers** prevent a member from approving the same
proposal twice, while leaving the same member free to approve a different
proposal.
- **Threshold-gated release, with every value frozen at proposal creation.**
`Execute` takes no threshold, recipient, amount or treasury from its caller:
it reads them from the ProposalState (the treasury is derived from the frozen
member root), checks the seven conditions listed above, moves exactly the
frozen amount to the frozen recipient, and marks the proposal executed.
Anyone may *submit* the release once M members have approved — that is
intended, and the submitter chooses nothing.
- **Live on LEZ testnet** (`testnet.lez.logos.co`), built on Logos LEZ **v0.2.4**
(the current testnet rev), program id
`3fNuZskqxs6NZUY4YoenCagQspckdsnjLQi1PUmh2nKa` (decimal words
`[3543567399, 1317535237, 3335128433, 2425199306, 3029700050, 984726237, 778219012, 1908251139]`).
The full 2-of-3 lifecycle plus the four rejected releases is chain-verified —
see Supporting Materials. The testnet is periodically wiped and redeployed
(it has happened twice during this submission's lifetime), so the evidence is
built to survive it: raw timestamped JSON-RPC responses for every ledger entry
are committed under `evidence/`, and the whole flow regenerates on the current
chain with one script (`evidence/README.md`).

## Repository

- **Repo:** https://github.com/jeefxM/lp-0002-private-multisig
- **Branch:** `main` · **Commit:** `da98a17d5` · **Rev:** Logos LEZ **v0.2.4**
(crate `lee`)
- **Licence: MIT and Apache-2.0, at your option**, for our contribution
(`LICENSE`, `LICENSE-APACHE`; `NOTICE` lists exactly which files are ours).
This repository is a fork of upstream Logos LEZ v0.2.4, whose own files stay
under upstream's MIT licence (Copyright Vac).
- **Program id (LEZ testnet):** `3fNuZskqxs6NZUY4YoenCagQspckdsnjLQi1PUmh2nKa`
- **Demo script:** `./demo.sh` (real STARKs by default) ·
**SPEL IDL:** `idl/lp0002-msig.idl.json` ·
**Basecamp module:** `basecamp/module.json`
- **Green CI on the default branch:**
https://github.com/jeefxM/lp-0002-private-multisig/actions/runs/34274880779
- **Video demo:** https://www.youtube.com/watch?v=CXzqWLvBY0A

## Approach

The design reimplements the Semaphore/MACI structural pattern — a public leaf
commitment in a Merkle set, an in-circuit membership proof, and a
domain-separated nullifier bound to the action — natively for the LEZ
privacy-preserving transaction model and the RISC0 zkVM. Neither codebase was
ported: both target an EVM/circom toolchain with no meaning against a RISC0
guest and LEZ's account model.

**Why the Logos stack.** The property this primitive sells is approver anonymity
that an operator cannot revoke. On a centralised alternative the operator sees
every approval as it arrives, so anonymity is a promise rather than a property,
and the operator can censor or fabricate one. LEZ removes both: the membership
witness never leaves the prover, the sequencer sees only an unattributable
commitment/nullifier pair, and the threshold release is program logic rather than
operator discretion. Three LEZ properties carry the design — the
privacy-preserving transaction model (a single tx mutates a PUBLIC counter while
its instruction data stays private, which is what makes a public threshold over
anonymous approvals possible at all), the account/nullifier model (a native
double-vote guard bound to a live shielded account), and RISC0 guest execution
(the membership check is ordinary Rust, not a hand-written circuit).

**Alternatives considered, and what did not work.** FROST/threshold signature
aggregation was rejected: it makes the signer set the aggregated object, and
anonymity then rests on the aggregation. We use a public count over per-member ZK
membership proofs instead — independently verifiable, trivially auditable, no
inter-member coordination — at the cost of one STARK per approval. A
derivation-only membership binding was tried and proved insufficient (the
first-round review was right to call it out); the approval now rides the member's
live shielded account with the id re-derived in-guest. Passing the release
parameters in the instruction was likewise wrong (the third-round review was
right to call it out); they are frozen in state at proposal creation instead.
Encoding chained-call data as a bare `u128` broke on-chain against the typed
`Instruction` enum. Funding a fresh treasury PDA by plain transfer fails outright
— `authenticated_transfer` would claim it `Authorized` and a PDA cannot sign — so
`InitTreasury` claims the PDA first; that asymmetry is captured as a regression
test.

**Full technical write-up** — threshold scheme, nullifier design, LEZ account
model, security assumptions, known limitations, benchmarks, reliability and error
tables:
https://github.com/jeefxM/lp-0002-private-multisig/blob/main/docs/LP-0002-solution.md

## Success Criteria Checklist

- [x] **Anonymous M-of-N approval.** Each approval is a RISC0 privacy-preserving
transaction proving depth-5 Merkle membership against the proposal's frozen
`member_root`; the state records only the header and opaque nullifiers.
- [x] **A member cannot approve twice.** Proposal-bound nullifier
`H(NULL_DOMAIN || secret || proposal_id)` with an in-guest already-recorded check.
- [x] **Threshold-gated release, enforced against frozen state.** `Execute { seed }`
reads `threshold`, `recipient` and `amount` from the ProposalState header
written at `CreateProposal` — never from the caller — derives the treasury
from the frozen `member_root`, and asserts program ownership,
not-yet-executed, `approval_count >= threshold`, recipient identity, the
treasury-seed binding, treasury PDA derivation and sufficient balance before
chaining a transfer of exactly the frozen amount. Pinned by ten
`msig_execute_*` tests and by four rejected on-chain releases in the ledger
below.
- [x] **Bound to a live shielded account, in-circuit** (first-round review item):
the guest asserts the rider's `AccountId` re-derives from the same secret and
that the rider is a live, non-default account.
- [x] **Live on LEZ testnet, chain-verified**, with raw JSON-RPC captures committed
and a one-script regeneration path: https://github.com/jeefxM/lp-0002-private-multisig/tree/main/evidence
- [x] **Reproducible demo at `RISC0_DEV_MODE=0`**: `./demo.sh`, verified green from
a fresh clone of this revision (`DEMO_EXIT=0`, 1 h 41 m, two genuine ~262 KB
succinct receipts, four rejected releases) —
`evidence/demo-realproof-v024.txt`.
- [x] **CI green on the default branch**: https://github.com/jeefxM/lp-0002-private-multisig/actions/runs/34274880779
(`msig-tests` plus `msig-e2e-devmode`, which runs the full flow, including the
four rejected releases, against a standalone sequencer).
- [x] **Compute cost measured** (no native CU/gas field on this rev): inner msig
guest 1,048,576 cycles, outer circuit 5,242,880 cycles, ~262 KB receipts —
https://github.com/jeefxM/lp-0002-private-multisig/blob/main/evidence/measure-run3.txt
- [x] **SPEL IDL**: `idl/lp0002-msig.idl.json` · **Basecamp module**:
`basecamp/module.json`, hosted as a signed multi-variant downloadable `.lgx`:
https://github.com/jeefxM/logos-lp0002-msig-module/releases/tag/v0.1.0
- [x] **Narrated video demo**: https://www.youtube.com/watch?v=CXzqWLvBY0A

## Supporting Materials

Live 2-of-3 run on `https://testnet.lez.logos.co`, 2026-09-08, real STARKs
(`RISC0_DEV_MODE` unset), program `3fNuZskqxs6NZUY4YoenCagQspckdsnjLQi1PUmh2nKa`,
on the chain generation that started that day (the previous generation was
reset at about 13:32 UTC; the run begins at block 169). Every entry below is
committed as a raw JSON-RPC request/response pair under
[`evidence/`](https://github.com/jeefxM/lp-0002-private-multisig/tree/main/evidence)
(`ledger.txt` indexes them, `_meta.json` timestamps the capture, and
`testnet-run3-hetzner3.txt` is the verbatim runner trace). To re-check any row:

```
curl -s -X POST https://testnet.lez.logos.co -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getTransaction","params":["<hash>"]}'
```

| Step | Transaction / account | Result |
| --- | --- | --- |
| Deploy | `e4d5e9e01007ec663216289530651ed808c69913cdd23c9f9be54c10175f8f7a` | block 169 |
| Enroll ×3 | `d5cee1d4372a979dd78fd6d6befceec3dc49d2a66cce6955fe8651e1a9916d71`, `a6476867ff93adb130e9e0b144880e066b8b6cd64d6270a0c054532918db9239`, `5959f17bd230d9444f650f0abdc09f70a082483c30cd934f1e55aed8033e8327` | block 170; registry `7HrtpJ8Z21tqSk3hWj7W4hSzdbWH1VnfvKmtAK4EkfPe`, root `4134150243015cc56281001b512134f3fb42fa43deaeb866cebe163e1feda4c1` |
| Create proposal | `bb93bc864ce256dc0c03a179a8e7150c10e502c24c0045d7af864c6a3a09fd2e` | block 174; proposal `4rVyTnqcP3jAT65P5tCCpnGNJNyvRscVhpveWK4umMUQ`; threshold 2, recipient `Hd2D7QqZjYE7XPGAzFAzXJ2f5tEMZfqssv1B2iG3DszC` and amount 100 frozen; treasury fixed by derivation to `7U9yHFopSegmqvPQEjeLX2trdDcThhoUiam2cNQL2ukT` |
| Approve #1 (anonymous) | `7db6733a13778e9bb7fabb0948393651910b94e4662d4d275308fe1a0bf03aa2` | block 260; count 0 → 1, nullifier `d0cf591a2e8fbf28e23d15c34b454813ae8a3c62590373290abd6280065622ef` |
| Init + fund treasury | `6101a3c4a04eff38603bc5bd8c927b717cb6cd35f87ee35aef4ecb417c5570e3`, `095749ab23d346f4784db80f668199833f8520028aebf42a0929a37e59e53002` | blocks 261, 262; treasury balance 100 |
| Approve #2 (anonymous) | `0dfd25b19c22e652f75d3f9734c550f8dff541e711b4e6478c343896a78628db` | block 295; count 1 → 2, nullifier `2164a9aeee29ff0e8e27cb2bf4b3054dd37740c2a73c9249aef9b454cbbeec13` |
| **Rejected**: release to a non-frozen recipient | `e207e477d142fa53609d4ce6fe6416219ee4746ad07eb8dcf14e1e868c719214` | `getTransaction` → `null`; never entered a block (tip 295 → 299); treasury untouched |
| **Rejected**: release naming a treasury seed not derived from the member set | `143ff797a9b13f86421db63d3786b290ac1519857234ac447eeabbbbeb4a6f52` | `getTransaction` → `null`; never entered a block (tip 299 → 303); treasury untouched |
| Release at threshold | `d71dbbd8423c3b41730b017e135dc87cd007d0a890f1269729f56d12374aff04` | block 304 — treasury 100 → 0, recipient 100, `executed = true` |
| **Rejected**: release replayed after execution | `2cd43f44f6bfd5d9e0ef5da7b31f441b17d93495459df719d3fe899bf07fc4bc` | `getTransaction` → `null`; never entered a block (tip 304 → 308) |

The fourth negative, a release attempted at 1-of-2 approvals, was submitted
before the second approval (tip 262) and stayed out of every block while the
tip advanced to 266; its evidence is the timestamped state snapshot
`evidence/rejected-execute-below-threshold.json` (treasury still funded,
count 1, `executed` false) rather than a hash, because an `Execute` carries
no signer and no nonce, so that attempt is byte-identical to — and shares its
hash with — the later successful release. The two vote nullifiers are
distinct and carry no member identity; the proposal account holds the
124-byte header plus two 32-byte nullifiers and nothing else.

## FURPS Self-Assessment

Full per-item assessment with evidence links is in the write-up:
https://github.com/jeefxM/lp-0002-private-multisig/blob/main/docs/LP-0002-solution.md

### Functionality

Anonymous M-of-N approval, proposal-bound double-vote rejection, threshold-gated
release against frozen state, and the in-circuit live-account binding are all
implemented and covered by 24 tests (17 state/apply-path, 5 circuit, 2 header
layout), of which ten pin the release gates, plus a live-testnet 2-of-3 whose
four wrong releases were rejected by the chain.

### Usability

One-command demo (`./demo.sh`), a per-step CLI walkthrough, a Basecamp `ui_qml`
module, and a narrated video. Real-proof runs need >6 GB RAM and ~25 min per
approve; that budget is documented up front, as is `LP0002_BLOCK_TIME` for
long local runs.

### Reliability

Every failure mode is enumerated with its guest source line and member-facing
meaning in `docs/lp0002-reliability.md`, including the seven release-gate
rejections added in this revision. Partial-approval state survives a sequencer
restart (`scripts/lp0002-resume.sh`, re-run green on this revision: the count of 1
survived a sequencer kill and restart, then reached the threshold and released).

### Performance

Measured on v0.2.4, 8 vCPU / 15 GB host: inner msig guest 1,048,576 cycles
(~3.8 min), outer privacy circuit 5,242,880 cycles (~24 min), succinct receipt
~262 KB. Public operations, including the rejected releases, are sub-second
RISC-V executions with no proof and no fee. Full table in
`docs/lp0002-benchmarks.md`.

### Supportability

Dual-audience docs (README walkthrough + full solution write-up), a NOTICE
delimiting our work from upstream, committed raw evidence with a regeneration
script, and CI running both the unit suite and the end-to-end flow against a
standalone sequencer.

## Terms & Conditions

By submitting this solution, I confirm that I have read and agree to the
[Terms & Conditions](https://github.com/logos-co/lambda-prize/blob/master/TERMS.md).
Loading