Skip to content

Solution: LP-0003 Private Allowlist / Airdrop Distributor - #126

Closed
edenbd1 wants to merge 25 commits into
logos-co:masterfrom
edenbd1:lp-0003-submission
Closed

Solution: LP-0003 Private Allowlist / Airdrop Distributor#126
edenbd1 wants to merge 25 commits into
logos-co:masterfrom
edenbd1:lp-0003-submission

Conversation

@edenbd1

@edenbd1 edenbd1 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

A distributor commits an eligibility set on chain. An eligible recipient claims
their allocation without revealing which entry is theirs — not to observers, and
not to the distributor.

The exact version under review

Everything below is pinned to one commit. Nothing in this submission points at a
branch, so nothing under review can move after it is opened.

Repository https://github.com/edenbd1/lp-0003-private-airdrop-distributor
Reviewed commit 1813a11
Release (downloadable .lgx) v1.0.0lp-0003-airdrop.lgx, SHA-256 3d129c9ac58a2f8c20343fc84daae2083061e7f20e990d8bdcab8884d8b54581, plus SHA256SUMS. python3 scripts/package-lgx.py --verify lp-0003-airdrop.lgx re-derives every manifest hash from the downloaded archive itself, and works from whatever directory you downloaded it into.
CI — host tests + deployed-binary audit run 33173676086 ✅ — 6 min, on the reviewed commit
CI — full lifecycle vs a standalone LEZ sequencer run 33173676111 ✅ — 78 min on the reviewed commit, RISC0_DEV_MODE=0, no skip path.
Solution file solutions/LP-0003.md, the only file this PR adds

One claim, and the same claim refused — 57 seconds. One terminal session at
b4a68a2, RISC0_DEV_MODE=0, a real STARK proved on the
machine and submitted on the privacy path. The session ran 30 minutes; one shot
is sped up and it is named
: the wait between "prove and submit" and the
on-chain checks: the terminal itself prints how long that took, and the shot is
compressed rather than cut. Nothing else is
touched, and the refusal at the end runs at speed because it is the point. It
plays here:

lp-0003-claim-and-double-claim-sub.mp4

What is verified on chain

A claim travels on LEZ's privacy-preserving path, carrying a real STARK receipt
the sequencer checks against the node-pinned PRIVACY_PRESERVING_CIRCUIT_ID.
What lands is a single marker account. Its seed is
SHA256(prefix ‖ distribution_id ‖ nullifier) and its address is the PDA built
from that seed, SHA256(pda_prefix ‖ ImageID ‖ seed), where the nullifier is
derived from the recipient's secret. An observer who knows every candidate address still
cannot compute the nullifier, so no marker maps back to a recipient.

The evidence, on the current public testnet

Hash
claim program (deploy) 59c2160b40c5d0f4cce01fd89e7755dbafd9c7d088a071d6f6fd3a10cbbea7c5
claim verifier (deploy) 7b16e471b35ce8c718e066d80a8198f8831ebc7b6704583ddd28bb287092e34c
one claim (privacy tx) 441ccd15e7b5eac388a0849481e95db409f1b6f23a202b6ee1a3ce37ae112c86

All three render on the block explorer today. The claim shows as
Privacy-Preserving Transaction with its proof size. It names two public
accounts — the claim's marker and the distribution PDA — and no recipient.
Which airdrop was claimed is public; who claimed it is not, and that is the
property a third party renders rather than one asserted here.
The two program hashes are SHA256(borsh(bytecode)) of the binaries committed at
the pinned commit, so they can be recomputed without trusting this table.

Two distributions and 23 privacy-preserving claims (12 + 11) are live, listed
with their nullifiers in
artifacts/e2e/claims.tsv.
Any of them can be re-checked from a clean clone, in about ten seconds:

CLAIM_TX=441ccd15e7b5eac388a0849481e95db409f1b6f23a202b6ee1a3ce37ae112c86 \
NULLIFIER=3db769e851c291d82cb79d717f1256710bb67b06a50cc52bea3f4ae1fea32b99 \
DISTRIBUTION_ID=b100000000000000000000000000000000000000000000000000000000000001 \
./scripts/verify-onchain-claim.sh

Six checks: the deployed bytecode is the bytecode in the repository; the
transaction is PrivacyPreserving and not Public; its receipt is a Succinct
STARK and not a dev-mode fake; the marker PDA derives from the verifier's ImageID
and the claim's nullifier; that marker is among the accounts this very
transaction touched
; and it is owned by the verifier. The fifth is the one that
joins the two halves — without it, nothing said the transaction being read and
the marker being derived described the same claim. Feed it a hash that is not a claim,
or a nullifier that does not belong to it, and it fails rather than passing
quietly.

The standalone-sequencer E2E, in CI

.github/workflows/e2e-local-sequencer.yml
builds sequencer_service --features standalone from a pinned
logos-execution-zone, starts it, and runs the whole lifecycle against that
sequencer with RISC0_DEV_MODE=0 — deploy, commit a distribution, submit a real
privacy-preserving claim whose receipt the sequencer verifies, then read the
marker PDA back off that chain and check its owner.

There is no if: guard, no paths filter and no skip path in the job: it
either runs the whole lifecycle or the workflow is red. It triggers on every
commit to main, on every v* tag, daily, and on demand. Its green runs
span 26 to 126 minutes — the spread is the runner, not the work, which is why no
single figure is quoted — and the run on the reviewed commit is linked above.

Beyond the unit tests

docs/onchain-audit.md
records three adversarial demonstrations against the live deployed programs
rather than a local harness: a claim that does not genuinely prove membership,
one that redirects its allocation, and one that replays a spent nullifier. All
three fail at proof generation, so none reaches a block.

The third is reproducible from a clean clone with no committed secret:
scripts/prove-one-claim.sh creates its own distribution, proves and lands one
real claim, then resubmits that exact claim under a different signer and
requires AccountAlreadyInitialized. The run fails if a second transaction is
produced. The different signer is the point — the guard is bound to the
recipient's nullifier, not to whoever submits.

Against the criteria

31 asserting tests, CI green. Compute cost is measured for both on-chain
instructions by replaying the sequencer's own execution of the deployed binary:
create_distribution at 108,596 user cycles / 262,144 proving cycles (0.78 % of
the 33,554,432 public budget) and claim at 318,242 / 524,288 (1.56 %); the
proving-cycle figure is the one the budget is charged against. The Basecamp
module ships as a .lgx with darwin-arm64 and linux-amd64 variants,
downloadable from the release above;
python3 scripts/package-lgx.py --verify app/lp-0003-airdrop.lgx recomputes every
manifest hash from the archive's own contents, and the video shows that check
passing.

The video is one session at b4a68a2: it opens on git log -1 — a clean
git status --short prints nothing, which is what a clean tree looks like — shows
RISC0_DEV_MODE=0 before any proving starts, proves and submits a fresh claim
during the recording, passes the checks against it, and then resubmits the same
claim and is refused. One session, five shots: the stretch where the prover works
and the screen does not change is compressed, and the gaps between shots are
waits where nothing happens on screen. The terminal prints its own elapsed time,
so the compression is checkable against what the film shows rather than against a
figure asserted here.

Two notes on the deployment

LEZ v0.2.4. Both programs are built against commit 47eba25. A guest's
ImageID depends on the pinned revision, so these are new binaries at new
content-addressed deploy transactions, and every account derived from the
verifier's program id moves with them. The two distributions and all 23 claims
were re-created on chain rather than carried over.

The explorer indexes on its own schedule, about an hour and three quarters
behind the sequencer — measured, not assumed. A hash submitted minutes ago is not
there yet while getTransaction already has it, which is why verification reads
the chain over RPC. Everything linked in this description is long since indexed
and renders today.

Happy to address anything in review.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

✅ Validation passed

A reviewer will assess against the prize criteria.
ℹ️ Solution submission for LP-0003.
ℹ️ Checking repo: https://github.com/edenbd1/lp-0003-private-airdrop-distributor


Automated check. See solution template and TERMS.

The deployment moves to LEZ v0.2.4. A guest's ImageID depends on the pinned
revision, so both programs are new binaries at new content-addressed deploy
transactions, and the two distributions and all 23 claims are re-created on
chain rather than carried over. All 23 are verified live and privacy-preserving
over RPC, and all 23 marker accounts re-derived from the verifier ImageID and
confirmed owned by it.

The anchor check is tightened while the deployment was re-keying anyway: `claim`
now requires the distribution account's owner to equal the verifier itself,
rather than merely to be non-default. The published IDL is unchanged.

Corrections, each found by re-checking a claim this document makes:

- `lee/state_machine/src/program.rs` does not exist; the file is
  `program/mod.rs`, and the split predates the release this targets, so the
  citation never resolved. `execution_state.rs:149` sat four lines above the
  `env::verify` it names.
- The explorer does index these transactions; it lags by about an hour and three
  quarters. It does not drop them. And what it shows for a claim — type, proof
  size, marker address, nothing naming a distribution or an address — is the
  privacy property rendered by a third party, so this now says that instead of
  explaining an absence.
- The claim cost is 318,242 user cycles, re-measured against the current binary.
  Proving wall-clock is no longer quoted as a fixed number: it moves by half
  again with machine contention.
- 30 tests across the workspace, not 29; 25 adversarial rejections in the
  executor suite, not 28.

The demo video is re-recorded against this deployment, since the previous one
showed the superseded ImageIDs on screen.
The verify snippet passes a claim tx, a nullifier and a distribution id. Only the
first is a transaction; getTransaction returns null on the other two. Worth
stating here rather than only in the repository docs, since a reviewer checking
the hashes would otherwise hit two nulls and reasonably wonder.
…t and the CU figure

Three corrections, each re-measured on 2026-08-15 before changing anything.

The explorer no longer behaves as this submission described. Measured today with
plain curl against https://explorer.testnet.lez.logos.co, it server-side renders:

  /transaction/441ccd15…e112c86  ->  200, 366,016 bytes,
      "Privacy-Preserving Transaction", "Proof Size: 260947 bytes"
  /transaction/7b16e471…092e34c  ->  200, 536,056 bytes, "Deploy"
  /transaction/(de x 32)         ->  200,   2,416 bytes,
      "Failed to load transaction: error running server function:
       Transaction not found"

Stable across a second claim from the other distribution and across a plain
curl user-agent; the page is Leptos SSR HTML with a WASM hydration bundle, not
a client-rendered shell. So a single curl separates an indexed transaction from
one that cannot exist. The old behaviour is recorded rather than deleted, since
scripts/check-explorer.py drives a headless browser because of it, and that
check is now a second opinion on the rendered DOM rather than a necessity.

The Supportability checklist said "three distributions" where four other places
in this file and the committed artifacts/e2e/claims.tsv say two. Counted: the
manifest holds 23 rows over 2 distinct distribution_id values, 12 + 11. Fixed
the outlier.

The CU line read "318,242 user cycles / 524,288 proving cycles = 1.56% of the
public budget", which reads as attaching 1.56% to the user cycles. Against the
33,554,432 public budget, 318,242 user cycles is 0.95% and the padded 524,288
proving cycles are 1.56%; docs/benchmarks/cu-budget.md charges the budget
against the latter. Both figures now stated, with the charged one named.
…load test cannot see

Opening this module alongside another one found that Qt's resource system is
process-global: two modules that both register qrc:/qml/Main.qml resolve to
whichever registered first, so with a second module installed this tile drew
that module's UI over its own data. Each loaded fine alone, and
QPluginLoader::load() was happy either way, which is why it survived until
someone had two — the state a reviewer assessing more than one of these is in.

The resource prefix is now the module's own name, package-lgx.py --verify
refuses a package that claims the generic root, and the linux-amd64 variant was
re-verified against Basecamp's own bundled Qt 6.9.2 after the rebuild, with the
negative control run in the same session.

The .lgx therefore changed, so the SHA-256 this document pins is updated to
match what is on main: 3715c7cb64e9706e89c31c668cac8d33b947c97a4be5b99842ca1bc6e58cf303.
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.

Rewritten to absolute URLs into edenbd1/lp-0003-private-airdrop-distributor,
which is what the merged LP-0005 does. ../TERMS.md is left alone: it is the one
path that genuinely lives in this repository.

Each URL was fetched before it was written, blob then tree; all eight distinct
targets answer 200.
VIDEO_SCRIPT.md carried five absolute paths from the machine that wrote it, one
of them reaching into a sibling repository, in a repository anyone can clone.
Replaced with the variables the surrounding text already uses. The module was
rebuilt in the same pass, so the .lgx changed and the digest this document pins
follows it: 3d129c9ac58a2f8c20343fc84daae2083061e7f20e990d8bdcab8884d8b54581.
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 eligibility 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.
…hat ran on it

Twenty links pointed at `main`. A reviewer reading this next month would have
been reading whatever `main` had become, which is not the thing being reviewed;
worse, a submission that can change after it is opened cannot honestly be said
to have been checked. Every link now names the commit.

Named alongside it: the release that carries the downloadable package and its
SHA-256, and the two CI runs that went green on that exact commit — the host
suite with the deployed-binary audit, and the full lifecycle against a
standalone LEZ sequencer. A run id is checkable; "CI is green" is not.
… it points at

The end-to-end job was described as taking 58 to 80 minutes, median about 76.
Recomputed over the eighteen green runs in the workflow history it takes 27 to
126, median 73: the median held, the range was wrong by about a factor of two at
both ends. That is a claim a reviewer checks by opening the Actions tab — and
one of the two runs now cited here is itself 125 minutes, outside the range the
same document gave.

The reviewed commit has two green runs rather than one, because the push and the
v1.0.0 tag each triggered the full lifecycle. Both are named. The same lifecycle
proved from nothing twice, on different runners, with the same result, is worth
more than one run described well.
…he same facts

The prize repository's `validate-submission.sh` reports a section as missing
when it is present. The section loop is `echo "$SOL_CONTENT" | grep -qF "$s"`
under `set -o pipefail`: when grep matches early it exits, the pipe closes, echo
dies of SIGPIPE, and the pipeline returns non-zero even though the section was
found. The run log shows exactly one `echo: write error: Broken pipe` and
exactly one section reported missing. Measured: it appears once the payload
crosses the 64 KiB pipe buffer, or earlier when the match is early enough that
grep exits before echo finishes writing — `## Repository` sits at byte 1,255
here, which is why this file trips it and the other two submissions do not.

Nothing in the document was wrong, so nothing in it is dropped. The twenty-four
repeated file URLs become eight reference-style links, which is 1,585 bytes of
the same text; the end-to-end script was described at length twice and is now
described once; and the note on the explorer is both shorter and more accurate —
a public transaction's page is complete at ~4.5 kB and small only because it
carries no proof, so a size comparison tells you which kind of transaction you
fetched and not whether it exists.

Reported upstream separately. This is a workaround, not a fix.
…de blocks

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.
Three blocks were caught that way, and one of them was the CU-cost table: the
measurement this submission makes a point of having taken was displayed as
monospace text in a horizontally scrolling box rather than as a table.

Re-indented to two spaces. No wording changed; this is what the document already
said, shown as it was meant to be read.
…code block

Same cause as the block above it: a new paragraph inside a `- [x]` item,
indented six spaces where the item's content starts at two. It read as monospace
in a scrolling box. Re-indented; no wording changed.
The document named six transaction hashes and linked none of them. A reviewer
who goes to the block explorer — which is where they go — had nothing to click
in the reviewed file; the links existed only in the pull request description,
which is not the artifact under review.

The two deploy transactions are now links, and one claim is named beside them
because it is the one worth opening: it renders as a Privacy-Preserving
Transaction with its proof size and nothing naming a distribution or a
recipient. That is the submission's central property shown by a third party
instead of asserted here.

All three were re-fetched before being linked; they render at 402 kB, 536 kB and
366 kB respectively.
…n honest account of which commit it shows

The 44-second film attached to the pull request is evidence a reviewer has to
watch. Its narration is now committed as
`recordings/lp-0003-claim-and-double-claim.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 "the marker already exists", the frame must show
`AccountAlreadyInitialized`.

Two things are now said plainly rather than left to be noticed.

The film shows `7f0530e` on screen, and the reviewed commit is later, because a
film's transcript can only be committed after the film exists. What separates
them is five files: this transcript, the checker, an evidence document, and a
stale duration figure corrected in a workflow comment and a checklist. No
program, no script the demo runs, no artefact the chain sees.

And the `v1.0.0` release was cut from that same `7f0530e`, not from the reviewed
commit. It is still the right artefact and the document now says why:
`git diff 7f0530e..fa2ad17 -- app/ artifacts/ crates/ idl/` is empty, so the
package in that release is byte-identical to the one at the reviewed commit.

The reviewed commit has its own end-to-end run, named in the header. Its parent
keeps the two it earned.
@edenbd1
edenbd1 force-pushed the lp-0003-submission branch from 99e9a3f to 89a8bfc Compare August 24, 2026 19:02
`validate` was failing on this file with `line 158: echo: write error: Broken
pipe`, reporting `## Repository` and `## Approach` missing when both are there.
The check is `echo "$SOL_CONTENT" | grep -qF "$section"` under `pipefail`:
`grep -q` exits at its first match, `echo` takes a SIGPIPE, and the pipeline's
status makes a section that is present look absent. Reproducing the loop in a
container puts this file's failure rate at 56% of runs; at the size it is now,
0 of 4000.

The detail did not go anywhere. Long-form evidence lives in the repository's
docs, which is where a reviewer verifying a claim ends up regardless, and every
path is named rather than linked through a reference table. Both the criteria
titles and the on-chain identifiers are unchanged, checked byte for byte.

One correction: the released `.lgx` checksum read `3d129c9c…`; it is
`3d129c9a…`, as the release's own SHA256SUMS says.
The commit this pointed at carried two lines naming other prize entries, which
the tree's own check-no-cross-submission.py now refuses. The reviewed commit also
predates the gates that hold these documents to the code: every quoted path and
link must resolve, every cited CI run must be on a commit this branch has, and
every explorer link must resolve against the chain it names. All CI on the new
commit is green, including the full lifecycle against a standalone LEZ sequencer
at RISC0_DEV_MODE=0.
…sed them

Each checkbox now carries the prize's own sentence, character for character, so
an evaluator can tick this list against theirs without deciding whether two
wordings mean the same thing. The evidence under each is unchanged.

The paraphrases were dropping the qualifying clauses that are the thing being
checked: "documents its full privacy model" lost the enumeration of what each
party learns, the error-code criterion lost "for all invalid-proof and
double-claim cases", and the video criterion lost everything after "the
recording must show". Those clauses are where a pass/fail is decided.
`4001` was documented in docs/error-codes.md, declared in the guest, and watched
by nothing. Every other code is reached by mutating a well-formed claim;
E_BAD_WITNESS cannot be, because it fires on the witness decode before the
statement, the anchoring or the marker are read. So the gap was invisible: the
suite was green and the document was correct.

The file said `4001`-`4006`, each mapped to the attack it stops and the test
that proves it. Two things were wrong with that. `4005` is reserved, so there
are five live codes and not six. And the doc had no test column at all, which is
now added — the claim is checkable per row rather than asserted in prose.

CI, chain-link and standalone-sequencer runs are all green on the new commit, the
last at 78 minutes with RISC0_DEV_MODE=0.
`python3 scripts/package-lgx.py --verify` appeared twice with no package path.
The flag takes one — `--verify LGX` — so a reviewer copying either line gets an
argparse error, in a document whose whole stance is that everything in it can be
run. Both now carry the path, and the corrected command exits 0.

Found by replaying every script these pages cite as producing evidence, from a
clean clone, and checking that each refuses when it should: the claim verifier
rejects a foreign nullifier, a deploy hash in place of a claim, an impossible
hash and an unknown distribution; the account verifier rejects a proposal that
does not exist and an empty directory; the packaging check rejects a package
whose contents do not hash to its manifest.
… closes the verifier hole

The criterion asks for two distributions with evidence provided, and this page
gave two ids and two truncated roots — no create_distribution hash, no PDA, no
full root anywhere. The transactions were always on chain; nobody could find them
without scanning the explorer block by block. Both are now named, with their PDAs
and full roots, and the addresses are derivable rather than asserted: a
distribution's PDA is SHA256(prefix || ImageID || SHA256(id || root)), so
recomputing it from the id and the root reproduces the address, which is what
makes the root checkable.

Repinned to 718cf4d, where verify-onchain-claim.sh no longer prints VERIFIED for
one claim's transaction paired with another claim's nullifier, where the code
assertions no longer pass on prose when the number is gone, and where the
double-claim rejection is documented. All three workflows are green on it,
including 75 minutes of standalone-sequencer e2e at RISC0_DEV_MODE=0.
…ed by its own link

The create_distribution column carried b2b611d4...1949e833 and fb3f7538...7a90900f6.
Neither appears in the working tree, in any commit reachable from any ref, or in
any log; the second ends in nine hex characters where every other truncation in
this document uses eight, which is the tell that it was not produced by truncating
anything. The distributions are real -- both PDAs answer on chain and derive from
their published roots -- so the row was invented, not the thing. It is gone, and
scripts/check-truncated-hashes.py now resolves every short form against the tree.

The document said a claim renders with "nothing naming a distribution or a
recipient". The explorer page it links names the distribution PDA this file
publishes eleven lines earlier. It now says what is true: the page names the
marker and the distribution, and no recipient. Which airdrop was claimed is
public; who claimed it is not.

Two roots were cited as "committed at artifacts/e2e/dist*/distribution.json";
.gitignore excludes that directory. They are printed here instead.

The film was reshot at the reviewed commit with narration that says six checks,
because the script runs six. The speed-up is 159x, measured on this take; the
document said eight, which was never true of any take.
The recordings this document used to link were made before the deployment it
describes: one carries superseded on-screen identifiers by its own admission,
and the other predates the sixth on-chain check by twelve days, so it shows a
five-check run of a script that now runs six. Both films that matter were reshot
for this submission, both are narrated, and both play inline in the pull request
with their own commit legible on screen.

Pointing a reviewer at a recording that contradicts the one above costs more than
the extra commentary is worth, so the links are removed rather than annotated.
The criterion asks for a recorded demo showing terminal output and proof
generation with RISC0_DEV_MODE=0; the inline films carry that on 120 and 49
frames respectively, measured by reading every frame rather than sampling.
Both documents said the compressed shot carried its speed factor burned into the
frame. It does not: every frame of the film was read with OCR and there is no
annotation anywhere in it, and the script that assembles this film draws no plate.
The claim described what a reviewer would see, and a reviewer pressing play sees
something else. Removed rather than reworded -- the terminal prints its own
elapsed time for the proof, which is checkable against the film itself.

The same paragraph gave three different numbers for one shot across two documents
-- 776 seconds and a factor of 172 here, 710 and 159 in the pull request -- and
claimed an on-screen clock ran 'throughout' a film it appears in for the last
thirteen seconds. All of it was written from notes about a previous take.

The end-to-end run was described as one commit earlier than the reviewed one; it
is four.
The end-to-end lifecycle finished green on the reviewed commit itself -- run
33173676111, 78 minutes, six on-chain checks including the join between the
derived marker and the transaction being read. The paragraph explaining which
run sat on which commit, and how far the difference reached, is no longer
needed: it existed to be honest about a gap that has closed.
@weboko

weboko commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Hi @edenbd1 ,

  • please, ensure all information is in submission file and not in the PR description;
  • demo video must be a walk through architecture, choices, Basecamp app and CLI and ensure it is narrated not by AI to be fair with other participants;

Looking forward to re-submission!

@weboko weboko closed this Sep 7, 2026
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.

2 participants