Skip to content

Solution: LP-0002 Private M-of-N Multisig - #125

Open
edenbd1 wants to merge 14 commits into
logos-co:masterfrom
edenbd1:lp-0002-submission
Open

Solution: LP-0002 Private M-of-N Multisig#125
edenbd1 wants to merge 14 commits into
logos-co:masterfrom
edenbd1:lp-0002-submission

Conversation

@edenbd1

@edenbd1 edenbd1 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

A threshold multisig for LEZ where an approval is unlinkable to the member who
gave it — including to the other members — and the chain records only that the
threshold was met.

Redeployed on 2026-08-24, and the gate now moves value. The earlier version
of this submission proved a threshold and wrote a marker; passing the gate did
not do anything. The multisig now holds a treasury, and execute transfers
from it to a recipient held by the native transfer program — so what the gate
releases is spendable, rather than sitting in an account only the verifier owns
and no instruction can release. Everything below is that deployment, in blocks
20856-20880.

Only the verifier was redeployed. membership_lez is byte-for-byte the binary
it has been since block 4459, so MEMBERSHIP_LEZ_PROGRAM_ID — the pin that
stops a chained call reaching anything but the audited membership program —
never moved. Keeping it there took work: with lto = "fat", unreachable code
in a linked crate shifts the ELF, and the first build of this revision moved
the membership ImageID purely because multisig-core had gained an
account-layout module the membership guest never calls. Gating that behind a
feature the membership guest does not enable brings the ImageID back exactly.

Forty-seven seconds, two scripts, no arguments. One terminal session at the commit
above running verify-onchain-lifecycle.sh and then verify-onchain.sh: the
eight transactions and the variant each carries, then the six accounts decoded
field by field, down to the recipient's balance. Nothing is sped up — the two
scripts answer in about eleven seconds between them. Narrated, with the captions
burned in.

lp-0002-threshold-moves-value-sub.mp4

What is verified on chain

Each approve declares a ChainedCall to a LEZ-native membership program, so
the privacy circuit composes it with a real env::verify and the sequencer
checks the receipt against the node-pinned PRIVACY_PRESERVING_CIRCUIT_ID. The
member set and the threshold are folded into the PDA address, so neither can be
invented nor lowered — a forged configuration resolves to an address nobody ever
created.

Transactions — a 2-of-3 lifecycle on the current public testnet

Step Transaction Block
deploy membership_lez fb8eb10f7f394286c109cb6502a1c95294180523f30d06f707fc087a589bea98 4459
deploy multisig_verifier 2d6f720e3c6dd8d876c8617eada5ddcd3c13a978b2edcb1921a3de73231e82e2 20856
create_multisig a8d8422ae2c46566b15c31954647974d3e95eadbe0b560eac4ab609c9a25ab55 20857
fund_treasury 2844eef12695ab0d3c6d55832e94ae316638dd7400735d2f393875a30bb6a5c2 20858
create_proposal b194da9ba24e1a17a7bec0d64da0d252a96c6edc96208a778a7e77e71fed9826 20859
approve (member A, privacy tx) d13813094f36c1b60c02350adbc272ce5aa88dd7d87ab409a3e36436e70a91c0 20869
approve (member B, privacy tx) 9f7c541c187c6ed284f67b0f5c6f0942de0ed98ff7e589dc81955ceed7219719 20879
execute 00ea68384758097dba8b648605b4ecf65d9535ba6b497af335d4fcf2be7f75ae 20880

Check any of them:

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

The two deployment hashes are SHA256(borsh(bytecode)) of the binaries committed
under artifacts/programs/, so they can be recomputed from the repository
without trusting this table.

The accounts, which are the stronger evidence

All six are owned by the verifier program. ./scripts/verify-onchain.sh reads
them back and decodes every record against the offsets published in
docs/account-layout.md.

Account Address Balance
multisig Hx7Ni2riURJfgng4QAXb3RBq9ZMjrvwj7JREjut9PuBC 0
treasury xtngupTp3tcQU9faCbND73KhCpYqfBqKhmoepQAXoVx 2 → 1
proposal 9EjLSnKjXB4r8SgBEHcgqf36nkqfyUVXuqSsDzCTRvg7 0
approval marker A Awd6RNVpMdPkvALi6ubdZyffKb3Bw3HimbUKtUP7YV6F 0
approval marker B 6dmnyiGZu6KtVy6jxyEMvb4HG8N59AuKey7wB9RjbCTV 0
execution marker BP6buTDdFThPYWU3NFGSsD8k3ymewStQo4hj86tAwq1a 0

A seventh account is not the program's, and it is the point of the exercise: the
recipient, 8kexXda8j5hPegPeHXzUM9PhvjYNFLpN8wN8PvG5iDhn, went 0 → 1. It
is held by the native transfer program rather than by the verifier, which is what
makes what it received spendable instead of stranded.

The treasury's balance is the one number nothing except a real execution could
produce: fund_treasury put 2 in and execute moved 1 out, and the two sides of
that move are visible in two accounts owned by two different programs. Each
approval marker exists only because a membership proof was verified on chain, and
nothing in the pair names a member — read them and you learn the threshold was
met, and nothing about who met it.

On the block explorer, measured rather than asserted

All eight render. Checked at 03:52 UTC on 2026-08-24, once the explorer's
index had passed block 20880.

The index trails the sequencer — on this chain, roughly an hour and three
quarters — so a transaction submitted recently answers "Transaction not found"
there while getTransaction already resolves it. That is an indexing delay, not
a gap, and it affects anything recent by anyone. These eight were written in
blocks 20856-20880 and were behind the index for about three hours.

And a curl size comparison under-reports public transactions, which is
worth stating because this description used to recommend one. Three sizes,
measured on these very hashes:

what over curl why
a hash that cannot exist 2,415 bytes the page stops at the loading shell
a public transaction ~4.5-5 kB complete — hash, Type: Public Transaction, Program ID, instruction-data size, signature count, and every account with its nonce
a deployment or a privacy-preserving transaction 370-640 kB the same page, plus the proof bytes, which are almost all of it

A public transaction's page is small because a public transaction carries no
proof, not because anything is missing from it. So the size tells you which kind
of transaction you fetched, and it does not tell you whether it is there —
Type: does. scripts/check-explorer.py classifies on that marker rather than
on a length, with an impossible hash as the control, and aborts if the control
ever classifies as found.

# where the explorer's index is, and where the sequencer is
curl -s https://explorer.testnet.lez.logos.co/ | grep -o 'block_id[^,]*' | head -1

# the control: a hash that cannot exist
curl -s "https://explorer.testnet.lez.logos.co/transaction/$(python3 -c 'print("ff"*32)')" | wc -c

Correction. An earlier version of this description said the explorer was a
WASM application returning an identical shell for every /transaction/<hash>
URL, so an indexed transaction and one that cannot exist were byte-identical over
curl. That was true when check-explorer.py was written — it is why the script
drives a browser at all. It stopped being true on 2026-08-15, when the
explorer began rendering server-side; and as the table above shows, it stopped
being true only partly, which is the sort of half-change that turns a shortcut
into a wrong answer.

Note on versions

The testnet was reset onto a newer chain in August, so everything here was
rebuilt against LEZ v0.2.4 and redeployed. That is also why the repository
vendors SPEL: no published SPEL release builds against a current LEZ, and
vendor/spel/PATCH.md documents every change, how to reproduce the directory,
and when it should be deleted.

Against the criteria

97 tests, CI green on Linux and macOS, including a workflow that runs the
whole lifecycle against a real standalone LEZ sequencer with RISC0_DEV_MODE=0.
Fifty-nine of the 97 run the built verifier binary through the sequencer's own
executor rather than the host crate: 30 rejections and honest controls, 22 on the
state it writes and the value it moves, 5 on the IDL and the docs, 2 pinning the
committed membership binary. CU costs are measured rather than estimated, per
instruction and per machine. The Basecamp module ships as a .lgx with
darwin-arm64 and linux-amd64 variants and was verified loading in Basecamp 0.2.2,
not merely built.

Happy to address anything in review.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

❌ Validation failed

Fix before review:

  • ❌ Missing sections in solutions/LP-0002.md: ## Repository.

ℹ️ Solution submission for LP-0002.
ℹ️ Checking repo: https://github.com/edenbd1/lp-0002-private-multisig


Automated check. See solution template and TERMS.

The video is recorded and linked, so the two places that still said it was
outstanding are gone. The security write-up is presented by what the design
defends against rather than by the order in which it was built; every property
and every test it refers to is unchanged.
`lee/state_machine/src/program.rs` does not exist: the file is `program/mod.rs`,
and the split predates the LEZ release this targets, so the path never resolved.
`execution_state.rs:149` sat four lines above the `env::verify` it names; the
call is at 153, and 149-155 brackets it with its comment.

The executor suite is 25 adversarial rejections plus five honest controls, not
28 rejections.
The file said none of the seven transactions rendered on the block
explorer; that was true when written and is no longer. Re-measured on
2026-08-15 by rendering each /transaction/<hash> page in a headless
browser, with an impossible hash as the control:

  - all seven render — the two approvals as Privacy-Preserving
    Transaction, the two deploys as Program Deployment Transaction, the
    other three as Public Transaction;
  - the control still renders Transaction not found, so the positives
    are distinguishable from the shell the WASM app serves for every URL;
  - getTransaction returns a result for all seven on the RPC.

The explorer's front page was at block 8633 and the sequencer at 8676 on
the same pass — a 43-block lag, against 4351 vs 4496 when this was first
written. The indexing delay is kept in the file as history rather than as
a current claim, because it still applies to anything submitted recently.
… count

Three numbers in this file were checked against docs/cu-costs.md in the
linked repo on 2026-08-15 and two of them were wrong in the submission's
favour, which is the worst direction to be wrong in.

Proving time. The file led with 149 s and 154 s. cu-costs.md records
those as the LEZ v0.2.0 local-sequencer measurement; the v0.2.4 run that
actually produced the deployed lifecycle measured 440 s and 469 s on
2026-08-12. v0.2.4 is roughly three times more expensive than v0.2.0 and
a second machine shows the same ratio (CI, 1264 s to 4033 s), so the old
figure understated the current chain by about 3x. Now quoted at 440/469
with the version, the date and the contention it was measured under, and
with the idle-laptop 437 s and the 935 s contended figure alongside so
the number cannot be read as a best case.

Cycle count. The file said approve costs 335,564 user cycles;
cu-costs.md says 337,105. Re-ran the repo's own measurement,
cargo test -p multisig-verifier-tests -- --ignored --nocapture, which
reports 337,105 for approve (and 267,055 / 363,510 / 461,604 for execute
at M=1/3/5, matching the doc). Corrected in both places.

lifecycle.tsv. The file said the timings were written into it as though
it were a committed artefact. It is not committed: deploy-and-run.sh
writes it into WORK, which defaults to .testnet/, and .gitignore excludes
/.testnet*/ deliberately because that directory also holds member keys.
The claim now says what is true — the script writes it during the run,
it is not committed, and the reviewable record is the table in
cu-costs.md.

Also dropped the stale note that the old estimate had been four times too
pessimistic. That was true of v0.2.0 and is not true of v0.2.4, so it
would have been a fourth wrong number.
This document lives in lambda-prize/solutions/, so a link written as
docs/DEPLOYMENT.md resolves there and 404s. Eight of its nine repository links
did, including every "here is how to check this" pointer — the worst place to
lose a reader.

Rewritten to absolute URLs into edenbd1/lp-0002-private-multisig, which is what
the merged LP-0005 does (23 absolute links, one relative: ../TERMS.md, the only
path that genuinely lives in this repository and the only one left alone here).

Each URL was fetched before it was written, blob then tree, and every one of the
nine distinct targets answers 200.
…ckable

The criterion named three PDAs, said they were owned by the verifier, and told
the reader to re-verify with ./scripts/verify-onchain.sh. Read as a first-time
reviewer with nothing but a clone, both halves needed work.

The explorer has an account view and renders all three. Each shows Program Owner
7AyJ7x4DuAa58ALGqLXYwqdhEvQLCz5A2GFdcDrwyzUZ, which base58-decodes to
5bb40082…a5966f82 — exactly what "spel program-id" prints for the verifier binary
committed in the repository. That is the entire claim, checkable in a browser,
and this document offered no link to it. It does now.

And verify-onchain.sh needs the working directory a lifecycle run leaves behind.
From a clean clone it says so and exits 1 — correct behaviour, wrong instruction:
the criterion pointed a reviewer at the one command they cannot run. A clone plus
./scripts/demo.sh does the same check from public data, and that is what the
criterion names now.
The paragraph on transaction paths ended by gesturing at other people's
outcomes, which is not evidence and is not this submission's to cite. The
argument stands on its own and is stronger for it: on the public path the
sequencer re-executes the program host-side, so the approval logic runs in the
clear and there is neither a proof nor any privacy. That is a fact about the
runtime, checkable at the file and lines already quoted.
…therwise

Three things in here described a deployment that no longer exists.

The reference-integration section argued that performing the transfer was
deliberately out of scope: the multisig proved a threshold, wrote an execution
marker, and left the transfer to a caller. That argument was honest and the
primitive is still general, but the criterion names a treasury transfer as its
example, and a treasury transfer is a better answer than an essay about one. The
multisig now owns a treasury, and `execute` moves value out of it into a
recipient held by the native transfer program — so what the gate releases is
spendable rather than stranded. The balance falling from 2 to 1 is a fact a
reviewer reads off the chain in one call.

The account addresses were the previous verifier's. A program's identity on LEZ
is its ImageID and every PDA derives from it, so redeploying moved all six. The
six current ones are named, with the program owner that base58-decodes to the
ImageID `spel program-id` computes from the binary committed here.

And the explorer paragraph still claimed the site was a WASM shell that made a
size comparison meaningless. That was true when the check script was written and
is why it drives a browser; it stopped being true on 2026-08-15. A document that
keeps a superseded measurement without dating it is asking to be trusted on the
wrong one.

Test count corrected from 61 to the 97 the workspace now runs.
…ich video shows what

Twenty-three links pointed at `main`, so a reviewer reading this later would have
been reading whatever `main` had become rather than the version under review.
Every one now names the commit, and every path behind them was checked to exist
there.

The header also names the CI run that went green on that commit — "CI is green"
is a claim about a branch at a moment, a run id is a thing anyone can open — and
it separates the two videos. The ten-minute walkthrough was recorded before the
2026-08-24 redeploy, so every identifier it shows on screen is superseded; a
short reading of the current deployment is attached to the pull request. Leaving
the older one unlabelled invited a reviewer to check a hash from the screen
against a chain that no longer has it.
Inside a `- [x] …` item the content column is 2, so a new paragraph indented six
spaces sits four beyond it and GitHub renders it as an indented code block. Ten
blocks were caught that way — the whole explanation of what the threshold gates,
the treasury's balance, the explorer measurement, the Basecamp load test — each
displayed as monospace text in a horizontally scrolling box instead of as prose.

Re-indented to two spaces. Not a word changed; this is what the document already
said, shown as it was meant to be read.
…iewed commit

The header named only the host suite. The end-to-end job, which builds LEZ at a
pinned revision, starts a standalone sequencer and drives the whole 2-of-3
lifecycle against it with RISC0_DEV_MODE at zero, finished green on the same
commit in two hours and forty-eight minutes. A run id is checkable; a claim that
the lifecycle works is not.
It was described as a fifty-second silent reading because that is what it was
when the line was written. It now carries a voice track, and it runs forty-seven
seconds. A duration in a submission is a small claim, but it is a checkable one.
…he runs that go with the commit

The 47-second film attached to the pull request is evidence a reviewer has to
watch. Its narration is now committed as
`recordings/lp-0002-threshold-moves-value.srt`, so it can be read instead, and
`scripts/check-transcript.py` ties that text to this film rather than asserting
it belongs: cues ordered and landing inside the film, and four anchors requiring
the frame to show what the narration is talking about at the second it says it —
where the line is "both approvals carry the privacy-preserving variant", the
frame must show the variant column.

The film shows `ad2267e` on screen and the reviewed commit is later, because a
film's transcript can only be committed after the film exists. Everything
between the two is documentation: the transcript, the checker, an evidence
document, and one correction to the explorer section of `docs/DEPLOYMENT.md`,
which had illustrated a `curl` check with a hash from the superseded deployment
and a size that holds only for transactions carrying a proof.
`docs/recording-evidence.md` says so, and `git diff` confirms it.

Every link is re-pinned to the commit that carries all of it, and the two runs
named are the ones that went green on that commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant