LNS — LEZ Name Service: full review
jefdiesel/logos-names @ 238d036 (2026-10-15) · forum post:
lns-lez-name-service-lez-names-private-by-default/2016
· testnet registry 66nUvzJtTxA729aUpTcAWDrb7cV9Heb2bDzceHQhLYad ·
this is the merged, single-file write-up of three separate review passes done over 2026-10-15/16 —
a read-only security/privacy review, a mocked build-and-test pass (no nix available yet), and a
follow-up real build-and-run through the official nix pipeline and an unmodified Basecamp install.
Worth backing, but not as submitted. The design is genuinely good — content-addressed
identifiers, a sealed-record format that degrades gracefully, correct use of LEZ's private-account
proof path for holder privacy, and unusually honest spec prose that already discloses most of its own
limits. The code itself builds and runs cleanly, both as isolated logic and as a real module inside a
real, unmodified Basecamp against the live testnet. But two confirmed, unmitigated bugs in the
program itself — front-running on Register and a Configure deploy race —
are exploitable today against the live registry, and deserve a fix and re-review before this is treated
as the canonical, ecosystem-wide registry SPEC.md positions it as. A third, unrelated bug (doubled
thousand-separator commas, confirmed to be a Qt QML engine quirk, not LNS's own code) affects every
price and balance shown in the app.
- Part 1 — Security & privacy review (read-only, no code run)
- Part 2 — Build & test, mocked (no nix available yet:
cargo test+ an offscreen QML harness) - Part 3 — Build & run, for real (official nix build, real Basecamp, live testnet)
- What's still not tested, across all three passes
Security & privacy review
Read-only: cloned, read SPEC.md / docs/RFP-private-naming.md /
source, did not run curl | sh, did not post anywhere, did not build or run any
code for this part (see Parts 2 and 3 for that).
Seven leads, confirmed or refuted
Front-running on Register: no commit-reveal, no binding to the real name
core/src/lib.rs, plan()'s Register arm (~line 470) requires only
that payer signs and can afford the fee — nothing ties the call to knowledge of the name
behind id. apply()'s Effect::Register (~line 628) accepts any
first caller: if !pre_data.is_empty() { assert!(after(held.expires, GRACE_MS) <= now) } —
that is the entire check. id is a plain instruction field, not something a private-account
proof hides (account-privacy hides who signed, not the instruction's own data). An observer who
sees the real registrant's id in a pending transaction can submit their own
Register for the same id first, with empty sealed and any payer —
they can never learn the name (that's not reversible), but they can permanently squat the slot and
grief the real registrant, and can renew indefinitely to extend the block. No commit-reveal exists
anywhere in the program.
Why it matters: exploitable today against the live testnet registry, by anyone watching the mempool, for the cost of one registration fee — the strongest finding in this review.
Configure deploy race: first caller sets admin, treasury and prices
core/src/lib.rs, Effect::WriteSettings (~line 682):
if let Some(settings) = load_settings(pre_data) { assert!(signer == settings.admin) } — when
the settings account is empty (fresh deploy), load_settings returns None and
the check is skipped entirely; the first Configure call, from anyone, sets admin,
treasury and prices. SPEC.md §8 already discloses this ("whoever deploys a registry configures it at
once and checks that the admin is theirs") but the mitigation is informal — "at once" is not atomic
with deployment, and nothing in the program enforces a race-free window. A bot watching for new program
deployments of this bytecode could front-run the real Configure call and redirect every
future registration/renewal fee to itself.
Suggested fix: bind the first Configure to the deploying key, or
require it in the same transaction as deployment — given SPEC.md §11 treats this as the canonical
registry others are expected to converge on, this deserves an actual program fix, not just a
documented race.
Global salt makes the name→key dictionary reusable forever, against every registry
seal/src/lib.rs:16,83: MASTER_SALT = b"logos-names/v2" is a fixed literal,
pbkdf2_hmac_array::<Sha256, 32>(name, MASTER_SALT, 100_000). SPEC.md §1 frames the
cost as protection against "a targeted guess" — but a global salt means a candidate-name dictionary is
computed once and reused forever, against every registry, past or future. PBKDF2-SHA256 at 100k
iterations is not GPU-resistant (it's one primitive repeated, not a memory-hard function like Argon2);
a single consumer GPU plausibly does low tens of thousands of derivations/sec, so a dictionary of a few
million short/common names (exactly the names people actually register) is hours, not years, on one
card, and embarrassingly parallel across many. This is a bulk, reusable, precomputable attack, not a
targeted one.
Suggested fix: SPEC.md's "100,000 times the cost of one hash... does not protect a short or obvious name from a targeted guess" sentence should say plainly that the real cost is paid once and amortized over the whole dictionary and every future registry — readers should not come away thinking per-name guessing is the threat model.
owner_tag enumeration against public accounts, retroactively
core/src/lib.rs:307: owner_tag(id, account) = SHA256("logos-names/v3/owner" ‖ id ‖
account) — plain SHA-256, no slow derivation, by design (the doc comment says "anyone who knows
both can compute it"). That's correct for its purpose (verifying a claimed owner), but it means: given
a known id (i.e. a name you already know) and the enumerable set of public accounts that
have ever appeared on-chain, testing "is account X this name's owner" is a cheap SHA-256 per candidate.
"Holders are hidden" holds for private-account holders; for a name held by a public account, a third
party can discover the holder after the fact, not just by observing the registration live as
§1 already says ("a public holder is seen signing") — they can do it retroactively too, with no
observation of the original transaction required.
Side note tying this to the salt lead above: whoever runs the sequencer's RPC already sees which record address a client reads, per SPEC.md §1's own "connection" caveat — combined with a precomputed name→id dictionary, an RPC operator is specifically well-positioned to correlate "which names are being looked up, from where, how often" over time. Not a new hole, but worth naming as the compounding case.
Suggested fix: a sharper line in SPEC.md §1 than the current "is seen signing," which only covers real-time observation.
now-claim abuse on Register/Renew/grace
Traced it through: the only constraint is "valid only in a block whose timestamp is ≥ now"
(plan.timestamp_window(now..)). A low now claim can't shorten someone else's
name (Renew takes max(expires, now) + term, so a lower claim is simply absorbed), and a
high now claim can't pull a lapse forward, because the transaction itself isn't includable
until the chain's real timestamp reaches that claimed value — by which point the name would have
genuinely lapsed anyway. This part is sound.
Gateway vs. "no server in between"
The forum post's claim is correctly scoped ("the client talks to the LEZ sequencer directly")
and resolver/names.js does exactly that — the gateway (gateway/worker.js) is a
separate, optional HTTP convenience layer, and README.md:197-198 is explicit: "A gateway is a
convenience, not a trust anchor or a private channel: it learns every name asked of it and can alter
what it serves." That's an honest disclosure, not a hidden tension. Did not run the curl | sh
installer per instruction; it's a standard fetch-and-pipe pattern, worth a signed-release path
eventually but out of scope for what this pass was asked to check.
npk/vpk payment unlinkability
LNS doesn't implement its own private-transfer cryptography; SPEC.md §6 has a name publish a static
npk/vpk and payments go through LEZ's own existing shielded-transfer
mechanism (same reused-address pattern as a Zcash unified address). Whether repeated payments to one
name are unlinkable is a property of LEZ's own shielded pool, not of this registry — auditing that is a
separate, much larger task outside this repo. Worth flagging to whoever owns that layer, not a finding
against LNS specifically.
Scope/tier realism
The 2–3 week estimate is already validated by elapsed history (first commit 2026-10-06, head 2026-10-15 — literally within that window), so it's not a speculative guess. The S-tier sizing is a different question: this is proposed as the canonical, ecosystem-wide name registry (SPEC.md §11 — "clients on a network must agree on one registry, as they agree on one token contract"), and the two confirmed program bugs above (both exploitable against the live registry today, both with zero mitigation in the program logic) are exactly the class of defect that a quick S-tier review pass is liable to miss. The code itself isn't large or sprawling; the review burden is high because of what it's being positioned as. Recommendation: fund it, but gate fee release on the front-running and deploy-race bugs being fixed and re-reviewed — not on backing out the tier.
Build & test, mocked
basecamp-module/ (core, Rust, module id names) and
basecamp/Main.qml (view, module id names_ui) · no nix or real Basecamp
install available in this sandbox at the time, so the core's own logic was built and tested directly
with cargo, and the real, unmodified view was loaded and driven through a real Qt 6 engine
with a scripted mock of its core-module calls. Superseded for the "is it real" question by Part 3 below,
kept here because it independently confirms the Rust logic against the spec's own test vectors and
caught the comma-formatting bug.
19/19 tests pass, including one that checks against the spec's own worked example
basecamp-module/rust-lib's real logic (registry.rs, changes.rs
— name derivation, sealing/opening, instruction encoding; 2,176 of the crate's ~2,600 lines) doesn't
touch the Logos module SDK at all. The one place that does (lib.rs's wallet
submodule, which calls the LEZ wallet module) is #[cfg(not(test))] — compiled out entirely
under cargo test. So a 25-line stub of the one SDK method it references was enough to let
Cargo resolve the dependency graph, with the stub's own body never actually reached. Result:
running 19 tests test registry::tests::a_name_derives_the_identifier_and_key_the_specification_gives ... ok ... test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.69s
That specific test decodes the name jef through the crate's own derive()
and checks the result against SPEC.md §10's published test vectors byte for byte — so
this isn't just "the code is internally consistent," it's "the shipped code agrees with the document
that's supposed to describe it." Full output in probes/cargo_test_output.txt.
Also tried cargo build --lib — the real deployable artifact, not just tests. That fails
cleanly at a missing generated/provider_gen.rs, which only the official
logos-module-builder (via nix) produces from the NamesModule trait. Expected,
not a defect — this is the genuine boundary of what's buildable without the project's own nix toolchain.
Part 3 crosses this boundary with the real builder.
The real, unmodified QML view — five screens, zero warnings or errors
basecamp/Main.qml imports only QtQuick and QtQuick.Layouts —
no Logos.Theme/Logos.Controls to stub out, unlike an unrelated dufs-basecamp
view reviewed earlier in the same pass. It talks to its core module the same way that one did:
logos.callModule/logos.callModuleAsync/logos.request. Built a
small bridge (probes/harness3.cpp) implementing all three with canned JSON matching
SPEC.md §12.4's documented shapes exactly, keyed by module/method and (for
resolve/page) by the actual name argument — so both a registered and an
unregistered name produce their real, different screens, not a single canned response reused
everywhere.
Loaded and drove it through five real states (search, a resolved name, an unregistered name's register flow, the pay panel, settings): zero QML warnings, zero errors, on every single one. That specifically ruled out a recurrence of a bug class seen elsewhere — a signal or property assigned on a Qt type that doesn't actually have it, only caught by loading through a real engine, not by reading the source.





Every price and balance in the app displays with doubled commas
Noticed it first in the register and pay screenshots above: 100,,000,
5,,000,,000. Main.qml:661-663's grouped() function is the
standard, textbook JS idiom for thousand-separators:
function grouped(digits) {
return String(digits).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
Checked whether this was an artifact of the mock bridge or the harness: it is not. Isolated it
completely — a 7-line QML file with no app code at all (probes/regex_test.qml), run through
a different, simpler harness:
qml: RESULT1: 5,,000,,000 qml: RESULT2: 100,,000
Ran the identical regex through Node (V8) for comparison: 5,000,000 and
100,000 — correct, single commas, as the standard idiom should produce. This is a genuine
regex-engine bug in Qt 6.4.2's QML JavaScript engine (a global replace with a zero-width
\B assertion combined with a capturing group inside a lookahead produces a duplicate match
at each insertion point) — not a logic bug in grouped() itself, which is exactly the code
any other JS engine would run correctly.
Still not independently re-confirmed against the real, nix-packaged Basecamp build's own Qt version — see Part 3, which got a real Basecamp running but couldn't reach this specific screen without a funded LEZ Wallet.
Suggested fix: replace the regex with a manual grouping loop (trivial, five lines, no engine-specific regex behavior to depend on), or test the existing regex specifically against the actual Qt6 version the release build pins before shipping — don't assume Node/browser testing during development transfers to the QML runtime.
Build & run, for real
Follow-up pass once nix became available in this sandbox: built both
.lgx packages the official way via logos-module-builder, installed
them into a real, unmodified LogosBasecamp-Desktop AppImage, and drove it with
xdotool/ffmpeg against the live testnet — no mocks anywhere in this part.
Both .lgx packages built the official way, via the binary cache
Installed nix single-user as root (no sudo): manual /nix creation, and — since nix
refuses to run as root if root itself is a member of nixbld — a dedicated non-root
nixbld1 user as the group's sole member, rather than adding root. nix.conf:
experimental-features = nix-command flakes, sandbox = false (sandboxed builds
need user namespaces, unavailable here), accept-flake-config = true (trusts each flake's
own nixConfig.extra-substituters, here cache.nix.logos.co/public). Installed
nix 2.35.3.
cd basecamp-module && nix build .#lgx-portable # → logos-names-module-lib.lgx cd ../basecamp && nix build .#lgx-portable # → logos-names_ui-module.lgx
Checked both build logs directly for what was actually built locally versus fetched as a pre-built store path — this was the explicit stop condition for this task (report immediately if nix tries to compile Qt from source):
grep -c "^building '" probes/nix-build-core.log → 1 grep -c "^building '" probes/nix-build-ui.log → 7
All 8 local build steps are small packaging derivations specific to this repo — metadata.json, the
.lidl asset, the bundle, the final .lgx — not Qt, not boost, not openssl.
Everything else (cairo, qtbase, cargo 1.89.0, …) came from cache.nix.logos.co/public as
substituted store paths. Confirmed via tar -tzf that both outputs are real, complete
module packages (manifest.json, .lidl asset, and — for the core — the real
names_plugin.so plus its linked boost/openssl shared libraries; for the UI — the real
Main.qml, qmldir, metadata.json). This is the real,
officially-generated artifact — provider_gen.rs and the staged
logos-rust-sdk-src that logos-module-builder produces — not the hand-stubbed
SDK substitute Part 2 had to use to get cargo test --lib running without nix.
Getting an unmodified LogosBasecamp-Desktop running in this cage
Downloaded the real AppImage from logos-co/logos-basecamp releases (the correct repo
name — logos-co/Basecamp doesn't exist). No FUSE in this cage, so extracted instead of
running it directly (--appimage-extract). Three environment problems had to be fixed, none
of them in the app's own code:
- Wrapper script picks the wrong interpreter.
usr/bin/LogosBasecampis an auto-generated shell script, not the real binary — it execs the real ELF at the hidden pathusr/bin/.LogosBasecamp.elf, which has its own baked-in dynamic-linker interpreter. Running the wrapper always used the system's old interpreter regardless of env vars; invoked the real ELF directly instead. - glibc/libstdc++ version mismatch. The AppImage needs glibc/libstdc++ newer than this cage
ships. Assembled a matching Ubuntu 24.04 libc/libstdc++/ld.so set, then used
patchelfto permanently set both.LogosBasecamp.elf's and.logos_host.elf's interpreter and rpath — this is the robust fix (an exportedLD_LIBRARY_PATHtried first broke the shell's own coreutils, since the system's oldld.socan't safely bootstrap a newer libc found via a search path;patchelfchanges which interpreter the kernel uses for that ELF, which correctly covers every child process Basecamp spawns too, with no env var needed afterward). - A fresh
--user-dirstarts empty. The app aborted cleanly (main_ui.so: not found) until the AppImage's own bundledusr/modules/andusr/plugins/(desktop shell, package manager, capability/storage/state modules) were copied into the fresh user dir by hand — these aren't auto-seeded. Also neededLOGOS_HOST_PATHset explicitly so each module's spawnedlogos_hosthelper could find the (also glibc-patched) binary.
After those three fixes, the app started cleanly on Xvfb (:77, xcb platform) and both
LNS .lgx files were installed by hand into modules/names/ and
plugins/names_ui/.
"jef.lez" resolved for real, and the unregistered-name flow works too

--user-dir: the real "Logos Basecamp. ALPHA" welcome screen.


HEuPeXNm…MRR1Hi, Held until 15 October 2027, Paid privately: Yes, a real logos.account hex value. Live on-chain data, not a mock.
A search right after another once left the result panel stale
On the first attempt to search an unregistered name right after the "jef" lookup, the search field
visibly updated but the result panel kept showing the stale previous result — through both pressing
Return and then explicitly clicking the search button (media/bc-shot10.png). A second,
otherwise identical attempt a few seconds later worked correctly on the first try. The app's own log
doesn't record individual resolve calls at any visible level, so this couldn't be root-caused with
certainty; the failed attempt landed within a second or two of one of the app's own periodic (every
30s) background wallet-connectivity polls, which the log shows blocking for up to 3000 ms — a
plausible but unconfirmed explanation, not a confirmed bug.
What's still not tested, across all three passes
- The register/transfer/release write flows end to end. Part 2's mock only answered reads; Part 3 reached the real register CTA but needs a running, funded LEZ Wallet to go further — out of scope for both passes.
- The comma-formatting bug against real on-chain prices. Confirmed as a genuine Qt QML engine bug in Part 2 (isolated with a 7-line repro, independent of LNS's own code), but not re-confirmed on the real Basecamp build's own Qt version in Part 3, for the same wallet-dependency reason.
- LEZ's own shielded-transfer/nullifier implementation (npk/vpk unlinkability, security review lead 7) — out of scope, a separate and much larger audit surface than this registry.
- The guest/zkVM program diffed line-for-line against
core/src/lib.rs— confirmed they share theplan/applyentry points per SPEC.md §12.1, spot-checked the shared logic, didn't diff the guest binary build itself. - The one flaky stale-panel observation (Part 3) — only two data points were collected; not established as a real, reproducible bug.
- macOS, Windows, or any platform other than Linux x86_64.
- A release-profile build of either module — both builds used the default dev/debug profile
nix build .#lgx-portableproduces.
Environment: Linux 6.17 x86_64. Part 1 was read-only, no code run. Part 2 used system
Qt 6.4.2 (Debian bookworm packages) and Rust 1.94.0 (rustup; the pinned 1.98.1 wasn't yet published in
that rustup channel). Part 3 used nix 2.35.3, the project's own pinned
logos-module-builder revision, and the real LogosBasecamp-Desktop AppImage.
The repo was cloned fresh and never modified in any of the three passes; all build/test work happened
in scratch directories outside it. Probes and raw logs to re-run or verify any of this are in
probes/.