diff --git a/solutions/LP-0002.md b/solutions/LP-0002.md new file mode 100644 index 00000000..c8ffc217 --- /dev/null +++ b/solutions/LP-0002.md @@ -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":[""]}' +``` + +| 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).