Skip to content

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

Open
edenbd1 wants to merge 13 commits into
logos-co:masterfrom
edenbd1:lp-0003-submission
Open

Solution: LP-0003 Private Allowlist / Airdrop Distributor#126
edenbd1 wants to merge 13 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 7f0530e
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.
Demo video https://youtu.be/rtCGv0zqC4I — ten minutes, narrated. A separate 44-second session, filmed at the commit above, plays below.
CI — host tests + deployed-binary audit run 32675568496
CI — full lifecycle vs a standalone LEZ sequencer run 32675568443 ✅ and, independently, run 32675681910 ✅ — the same commit proved twice, from nothing, 53 min and 2 h 05
Solution file solutions/LP-0003.md, the only file this PR adds

One claim, and the same claim refused — 44 seconds. One terminal session at
the commit above, RISC0_DEV_MODE=0, a real STARK proved on the machine and
submitted on the privacy path. The session ran 16 min 55 s; one shot is sped
up and it is named
: the wait between "prove and submit" and the five on-chain
checks, 12 min 56 s of proving compressed to 4.5 seconds. 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 whose address is
SHA256(prefix ‖ distribution_id ‖ nullifier), 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.

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, and nothing naming a
distribution or a recipient — the privacy property, rendered by a third party.
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

Five 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; and that marker is owned by the verifier. The last one
is the part no transaction lookup can fake. 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. A run takes 58–80
minutes (median ~76), and the run for 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

30 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 a single take on the pinned commit: it opens on git log -1 and a
clean git status, shows RISC0_DEV_MODE=0 before any proving starts, proves and
submits a fresh claim during the recording, passes the five checks against it, and
then resubmits the same claim and is refused. The stretch where the proof runs and
nothing on screen changes is played at eight times speed; nothing is cut.

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.
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