Locked 2026-08-18. Sprint ends 2026-08-31 23:59 UTC.
Lens keeps its name and its repo. It stops being "see what leaks by accident" and becomes "reveal exactly one thing on purpose". A lens narrows the field of view: everything outside the frame stays dark. The old leak-scoring code is not thrown away, it becomes the pre-check that runs before anything is shared.
Product line: Prove one thing about your STRK20 activity. Nothing else.
STRK20 gives you two settings today: reveal nothing, or let an auditor decrypt a key that opens your entire history forward and backward. The docs say it plainly, no mechanism allows sharing less than a full viewing key. Around 60 of the 79 sprint projects build the private send. Almost nobody builds the way back out.
Verified against the live chain on 2026-08-18, not assumed:
get_note(note_id) -> Note{packed_value, token}is a public view. An anonymous caller with no wallet read a real note and got0x9a150aa3369a34b9c0a1e053f2dbd95f705f8ef5df3eed6e7322828b1870c3, matching that note'sEncNoteCreatedevent exactly. A bogus id returns zero.nullifier_exists(nullifier) -> boolis public, so a verifier can check spent or unspent with no key at all.- Note cells are WriteOnce, so a disclosure cannot be backdated or rewritten.
channel_key = h(CHANNEL_KEY_TAG, sender_addr, sender_priv, recipient_addr, recipient_pub)is a Poseidon output. Handing one over does not expose the master viewing key.
Design consequence, and it bounds v1: both the note's location and its amount
mask derive from the same channel_key, so the smallest sound disclosure unit
without a ZK circuit is one channel, meaning one counterparty in one
direction. Not one individual note. We state that in the docs rather than
implying finer granularity than we have.
Two roles. Holder holds shielded activity. Verifier needs proof of one fact: an exchange, an accountant, a landlord, a counterparty, a DAO.
Rule that governs every screen: the verifier never needs a wallet, an account, or an install. Verification is a browser page that reads mainnet. If we break that, we lose the adoption argument.
This is the differentiator. Monero proofs, Zcash ZIP 311, and both sprint competitors are one-way strings the Holder generates and hopes is what was wanted. Nobody has the request half.
- Compose. Verifier opens
/request, no wallet. Picks a claim and a scope: "prove receipts from0xABCbetween Aug 1 and Aug 18", or "prove shielded USDC of at least 10,000". Adds who is asking, and an expiry. - Send. Lens returns a request link. The verifier sends it however they already talk to the person. The request bounds what can be answered, so nobody can be talked into oversharing.
- Open. Holder opens the link, connects Ready. Lens derives the channel keys for exactly the named scope, locally in the browser. No key reaches a server, because there is no server in this path.
- Pre-check. Before anything is shared, Lens shows what this disclosure
exposes and what it does not: "This opens both directions of your lane with
0xABC: 6 notes, including 2 outside the dates you were asked about. It does not reveal your balance, your other counterparties, or your viewing key." This screen is the olddetectanddecidecode doing real work. - Approve and anchor. Holder confirms. Lens builds the bundle (scope,
claim values, in-scope channel keys, request id, expiry) and signs one
mainnet transaction to the Disclosure Registry, anchoring
hash(bundle), the requester, and the expiry. The bundle never goes on chain, only its hash. - Return. The bundle goes back through the link, out of band.
- Verify. Verifier opens
/verify. The browser computes note ids, callsget_note, unmasks amounts, checksnullifier_exists, then checks the registry anchor, the requester, the expiry, and the revocation flag. Green panel with the exact claim, or a specific reason it failed. - Revoke. Any time later the Holder hits Revoke, one mainnet transaction. The proof page then shows REVOKED and when. It does not erase what the Verifier already saw, and a retained channel key keeps working. This is authorization revocation, not access revocation.
Same pipeline without steps 1 and 2. For "here is your receipt". Produces a shareable proof link. This is the flow other sprint projects can embed.
A payment app in this sprint adds a "prove this payment" button. It calls the Lens SDK with a note reference, gets a link, hands it to its user. This is the Turbine Cash shape that won the nearest adjacent contest: be the piece other builders integrate, not another standalone app.
| Claim | Question it answers | Soundness |
|---|---|---|
| Relationship | "Did 0xABC pay me, and how much" |
Sound. Identity bound by channel_exists |
| Income over a period | "What came in during this window" | Sound. Sum of relationship claims |
| Cut. Not soundly verifiable, see below |
Balance floor is cut from v1. A note's nullifier binds to the owner's
private viewing key, so a verifier cannot recompute one. A supplied nullifier
is therefore an unverifiable assertion: a Holder could hand over any felt
that happens to be absent from the pool and call the note unspent. Proving
"I still hold this" needs a circuit, or the master key we exist to avoid
handing over. spentStatus stays in the code as the owner's own view and is
documented as not being evidence. Found on Day 1 while writing read.ts,
before anything was built on top of it.
Identity binding, the piece that makes the rest sound. A channel key alone
proves only that notes exist at some locations. The pool's public
channel_exists(channel_marker) closes it: the marker is computed from the
channel key plus both addresses and the recipient's registered public key, so
if the pool says it exists, the pool is attesting that this key belongs to
that pair in that direction. subchannel_exists does the same for the token.
Without this step anyone could fund their own lane and present it as a payment
from someone else, which is covered by a test.
Stretch, only if days remain: source of funds, linking a public Deposit to
the notes it created. Deferred because it needs the deposit-to-note link, the one
claim not yet proven end to end.
Carried over from the old HIDDEN-VS-VISIBLE.md, because that discipline was the
best thing about the old project:
- A disclosure is scoped, not zero-knowledge. Everything inside the scope is revealed in full. We do not call it a ZK proof.
- Channel granularity, not note granularity. Stated on the pre-check screen.
- Revocation withdraws authorization and makes that withdrawal publicly checkable. It cannot un-see what someone already read, and a retained channel key keeps working. Say so on the revoke button.
- Deposits and withdrawals stay public. Lens does not make them private.
Following the build-process phases: risky core first, tests as we go, verify against reality, reproducible from the first commit.
scope.ts parse and serialize a request and its scope pure
derive.ts channel key, note id, nullifier, amount unmask pure <-- LOAD BEARING
read.ts RPC: get_note, nullifier_exists, events I/O only
claim.ts scope + reads -> a claim, or a reason it fails pure, deterministic
expose.ts what this bundle reveals (old detect/decide) pure
bundle.ts build, serialize, hash the bundle pure
registry.cairo anchor / revoke / is_valid mainnet
Routes: /request, /disclose/[req], /verify/[bundle], /disclosures.
The load-bearing module is derive.ts, and one specific unknown inside it.
get_note returns a single packed_value felt, but the docs publish the
enc_amount and enc_token formulas separately. The packing layout is not
documented. Read it out of the SDK source, do not guess it.
Day 1 to 2, and this is a gate.
- Pull
@starkware-libs/starknet-privacy-sdkfrom GitHub Packages, Node >= 24. Read its note decode path. - Locate the mainnet pool address. It is not in the public docs, only the
Sepolia v2.0 pool
0x0254a6b2997ef52e9f830ce1f543f6b29768295e8d17e2267d672c552cfe0d91. It ships asPRIVACY_POOL_ADDRESSin the SDK and AVNU packages. - Make one real shielded payment to ourselves on Sepolia.
- Write
derive.tsand one test: from(channel_key, token, index, salt), compute the note id, callget_note, unmask, and assert the amount equals what we actually sent.
Reads confirmed working today:
https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/demo,
get_note selector
0x415b4dc014f2ddfa618072aa2ac01257ef9600c971c994ac51d1fb5d842e95.
Gate: if that test is not green by end of Day 2, the fine-grained claims die and we fall back to a coarser product built on public events plus registry anchoring. Decide it on Day 2, not Day 9.
derive, claim, expose, bundle are pure and get tests as they are written.
All I/O stays in read.ts so the decision layer runs with no network. Keep the
existing 14 break-it tests, retargeted at disclosure rules. The one that matters
most: a bundle whose scope does not cover the claim must fail closed.
Verification is arithmetic and RPC reads. No LLM anywhere near a verdict. If we add prose later, it narrates a result the rules already decided.
npm run doctorextends the existing script: hit the RPC, confirmget_noteandnullifier_existsexist on the deployed class, confirm the registry answers, print the pool address and network in use.- Committed fixture: one real Sepolia disclosure bundle plus its expected
verdict, so
npm testand the demo run offline. - One-command bring-up, documented as the commands actually run, not remembered.
Every external assumption gets a live check before code depends on it. Done for
get_note. Still to check: the packed-value layout, the mainnet pool address,
and whether Ready exposes what the signing step needs. Anything not run gets
written down as not verified.
The vault page keeps working while the disclosure routes are built. Guard every RPC call so a dead endpoint degrades to a clear message instead of a crash.
Delete the abandoned auction code on Day 1: cairo/src/lib.cairo is still
Tender, and /lots, /new, src/lib/auction.ts, src/lib/commitment.ts belong
to a product we are not shipping. Leaving them makes the repo read as unfocused
to a panel skimming 79 entries.
| Day | Work | Done when |
|---|---|---|
| 1 | Delete auction code. SDK in. Mainnet pool address found | doctor prints both networks |
| 2 | derive.ts plus the amount-recovery test on a real note |
gate: test green |
| 3 | registry.cairo anchor, revoke, is_valid. Sepolia deploy |
contract tests pass |
| 4 | bundle.ts and /verify end to end, no wallet |
a stranger verifies in a browser |
| 5 | /request and /disclose with Ready |
full loop on Sepolia |
| 6 | Mainnet cutover. Deploy registry, first real disclosure | 3 mainnet tx hashes recorded |
| 7 | expose.ts pre-check, ported from detect and decide |
pre-check screen live |
| 8 | Balance floor and income claims | tests green, running on mainnet |
| 9 | Revocation end to end, /disclosures dashboard |
a revoked bundle fails to verify |
| 10 | Fixtures, offline path, doctor hardening, bring-up | a clean clone runs |
| 11 | Offer the embed to 2-3 sprint payment projects | one integration, or a written offer |
| 12 | README, docs, strk20.json, demo video |
all four fields filled |
| 13 | Buffer. Freeze by 18:00 UTC on Aug 31 | final push done early |
It currently has four empty fields. It is the deliverable, not an afterthought.
Fill transactions, contracts, demo_video, demo_url as each becomes real,
starting Day 6, not on Day 12.
- Integration depth, 30%. Channel keys, note ids, packed values, nullifiers, viewing key derivation, SDK note scanning, and a deployed Cairo contract. The old Lens read two public events. This reaches the encrypted layer.
- Working mainnet product, 30%. Every disclosure and every revocation is a mainnet transaction, so using the product satisfies the requirement instead of bolting transactions on afterwards.
- Innovation, 25%. Two sprint entries sit in the disclosure space. The verifier-issued request and on-chain revocation exist nowhere, on any chain.
- Docs and open source, 15%. Already the old project's strongest axis. Keep Apache 2.0, keep the honest accounting, keep the tests.
Supersedes the schedule below. Three things changed it: docs/PRODUCT.md found a
second load-bearing assumption, the build process now treats the user journey as
a build requirement rather than polish, and the goal is a product that still
works after 31 August rather than a demo that survives until it.
Pipeline, one job per file:
derive.ts keys and locations pure DONE
read.ts public pool reads I/O DONE
claim.ts verified or not, and why pure DONE
expose.ts what this reveals pure next
bundle.ts build, serialise, digest pure next
registry timestamp and revoke chain built, undeployed
Journey, which decides what "working" means from outside the code:
verifier asks -> Holder opens -> signs once -> sees the exposure
-> approves -> anchors -> verifier checks -> can revoke
Six answers, one line each:
- Primary user: the person who was paid and now has to prove it.
- Job they came to do: answer "where did this come from" without over-sharing.
- Primary action: approve a scoped disclosure.
- Minimum we must ask for: one wallet signature. Nothing else.
- What we infer instead of asking: the counterparty's public key, the notes, the amounts, the exposure, the whole claim.
- Where value first appears: the exposure preview, before anything is shared.
A viewing key can be derived deterministically from one wallet signature.
The Wallet API exposes three methods and none of them yield a viewing key, and the docs forbid asking a user for theirs. Without derivation there is no dapp. StarkWare's Privacy Bridge does exactly this and persists nothing but the read-only key. Reproduce it and check a round trip before building on it.
Not just the happy path. Each of derive, preview, anchor, verify, revoke needs:
- Loading: say what is happening. Proving and RPC reads are slow enough to look broken.
- Success: what changed, and the next useful action.
- Empty: "this lane has no payments" is a real answer, not a bug.
- Error: what failed, what is still safe, whether retrying helps. No RPC error codes in front of a user.
The verify page's failure states carry unusual weight, because a verifier seeing
a confusing error will assume fraud rather than a network blip. Every failure
reason in claim.ts already returns a plain sentence for this reason.
One signature at sign-in, one transaction to anchor, one to revoke. Nothing else. Explain each in product language before requesting it, treat rejection as a normal outcome, and keep explorer links secondary. The verifier signs nothing and connects nothing, ever: that is a product rule, not an implementation detail.
Can someone who did not build this reach the value without me in the room? The honest answer today is no, which is why the contrast panel is built early, not last: the old way spills a whole history, the Lens way shows one line. If that does not land in ten seconds when a stranger looks at it, the weakness is in the concept and we need to know while there is time.
| Day | Work | Done when |
|---|---|---|
| 1 | Reproduce signature-derived viewing key, round trip on sepolia | gate: our key opens a note we made |
| 2 | Mainnet: fund, declare, deploy registry, first anchor | 3 mainnet tx, strk20.json populated |
| 3 | bundle.ts and the verify page, no wallet |
a stranger verifies in a browser |
| 4 | Request flow and the exposure preview | full loop, sepolia |
| 5 | Contrast panel, then show it to someone cold | they explain it back correctly |
| 6 | Halfway checkpoint. Core loop end to end on mainnet | if not, cut scope now |
| 7 | Revocation end to end, four states everywhere | a revoked bundle fails clearly |
| 8 | Errors, empty states, human messages | no raw RPC text reaches a user |
| 9 | CLI verifier, so proofs outlive the site | verify from a terminal |
| 10 | Reproducibility, fixtures, doctor, one-command bring-up | clean clone runs |
| 11 | README, docs, demo video | submittable |
| 12 | Buffer | freeze 18:00 UTC, 31 Aug |
- The risky assumption. 2. The core loop end to end. 3. The primary journey.
- Reproducibility. 5. Every integration verified live. 6. Errors and the four states. 7. Visual polish.
Polish is last. An unusable flow with good typography is wasted work.
Works technically, survives failure, is understandable, gives feedback, is reproducible, can be demonstrated. All six, at once. A passing test is a component of done, not done.
Never fake success. Fixtures and offline paths are legitimate and get labelled as what they are. A verified badge for something that was not verified is indistinguishable from fraud from where a judge sits, and it would destroy the one thing this product sells.
The instruction is a product that functions after the sprint. Concretely that means four things get built even though no criterion demands them:
- No server in the trust path. Keys derived in memory, bundles passed between two people, verification from public reads. If we vanish, proofs still check.
- A CLI verifier, so a proof does not depend on our site being up.
- A documented bundle format, so another wallet can adopt it. That is the only realistic distribution route, since we cannot read keys other wallets hold.
- The completeness warning on the verifier's own screen. A disclosure proves a payment happened and can never prove another did not. A verifier who thinks otherwise has been misled by our interface even when every claim is true.
The Day 2 gate is green. Risks 1 and 2 below are closed.
- Packed-value layout resolved from source, not guessed.
packed_value = salt * 2^128 + encAmount, whereencAmount = (h(ENC_AMOUNT_TAG, channel_key, token, index, 0, salt) + amount) mod 2^128. Source:sdk/src/utils/encryptions.tsin starkware-libs/starknet-privacy. The salt travels inside the packed value, so a channel key alone decrypts the lane. A verifier needs nothing further from the Holder. This is the disclosure primitive, and it is smaller than expected. - Confirmed against live chain data. The real note read on 2026-08-18 splits into exactly a 120 bit salt and a 128 bit field, matching the layout.
- Mainnet pool found and verified live:
0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a,get_versionreturns2.0,get_noteandnullifier_existsboth answer an anonymous call. It ships asPRIVACY_POOL_ADDRESSin@avnu/avnu-sdk. src/core/derive.tswritten and green. 14 tests, 8 of them asserted against the Cairo implementation's own generated vectors (fixtures/cairo-reference-data.json, copied from the upstream Apache 2.0 repo). Independent TypeScript checked against Cairo output, not against itself.npm run doctorrewritten to check both networks live: pool version,get_noteon an unwritten cell reading zero,nullifier_exists, the derive vectors, and the offline fixture. All green.- Dead code removed:
/lots,/new,src/lib/auction.ts,src/lib/commitment.ts,tenderCalldata, the Tender Cairo contract, andWalletAccountV6Tag.tsx(599 lines, imported by nothing). Cairo package renamed tolens_registry. - Bug found and fixed in passing: the default Sepolia RPC was
starknet-sepolia.public.blastapi.io, which now returns "Blast API is no longer available" for every method. The mainnet lava default still works. Both defaults now point at Cartridge, verified answeringstarknet_chainIdon both networks.
npx tsc --noEmit clean, 32 tests passing.
Not yet verified: no note has been decrypted from a channel key we derived ourselves end to end, because that needs a funded Sepolia account and a real shielded payment. The arithmetic matches Cairo and the on-chain layout matches the format, but the full loop is unproven until Day 3. Next up.
Packed-value layout is undocumented.Closed Day 1, read from SDK source and checked against Cairo vectors.Mainnet pool address not published.Closed Day 1, found in@avnu/avnu-sdkand verified live.- SDK needs Node >= 24 and GitHub Packages auth. Environment friction. If the local box fights us, move to a clean container rather than repairing it, and commit the container as the deliverable.
- Competitors in the lane:
SodiqAbdulwaris/strk-discloseandEndPx/zkpayslip. Both are one-way proof generators. Ship the request and revocation halves early, they are the separation. - Solo build, 13 days. The schedule front-loads mainnet to Day 6 so a bad week still leaves a working mainnet product.