Findings from a three-pass review of PR #122 (agent/standard-nostr-messaging): two independent conformance/security reviews of the branch diff, and one architectural evaluation of the key and identity design asking whether the complexity is necessary and whether anything can be merged.
Part 1 records defects already fixed on the branch, so the record of what was found survives the fix. Parts 2–4 are open — design decisions and documentation gaps that are deliberately not being resolved by a silent commit.
Line references are against 8c9ab80 unless stated otherwise. The branch head is now c4431a0.
Part 1 — Fixed in c4431a0
All of these were verified against the tree before the fix. Nothing in this section needs action; it is here so the findings are documented rather than only repaired.
1.1 The V.12 conformance fixture would have accepted an unattested name
Found independently by all three passes.
specification.md:3992 pinned the positive kind-0 profile fixture as content.zkcoins = "exactly {version, network, address, pk0, nk_commit, ivpk, relays, addr_sig}" — eight fields, no name_sig — while :2722 mandates "exactly the fields {…, addr_sig, name_sig}". Two "exactly" clauses, nine fields versus eight.
The fixture is reached by resolving a name (:3988 starts from a NIP-05 response), which is precisely the condition triggering §4.3 check (iv) at :1580. The baseline conformance fixture therefore described an object the normative rule rejects, on the exact discovery path that demands the missing field. Two paragraphs later, :4004 requires a profile with name_sig absent to be rejected.
A harness built from V.12 would have accepted exactly the unattested name name_sig exists to prevent — a hosting provider holding the node-held op key putting a name on an identity whose seed holder never consented.
Two adjacent sites had the same gap: the §7.3 Name checklist at :2734 listed "the reverse nip05 match plus, for messaging, kind-10050 readiness" and never named the name_sig check at all; and negative vector M-16 recorded a name change without requiring it.
Fixed: nine-field fixture, acceptance after checks (i)–(iv), name_sig named in the §7.3 checklist, M-16 requires verification before recording, and NameConsent added to the reserved-context registry in §1.1, which it was missing from despite that list being exhaustive and "Reusing a context for two purposes is forbidden."
1.2 A mandatory profile was described as optional in the same document
specification.md:2707 read: "publishing a kind-0 profile, and carrying a NIP-05 name at all, are optional". :1519 and :2722 say MUST. Decision D7 still said "opt-in". Independent implementations would have exposed incompatible account lifecycles.
Fixed: mandatory for every account the app and API layers serve; explicitly no obligation on a kernel-only deployment or on an ordinary Nostr peer.
1.3 The capability gate could never be entered
specification.md:3113 said the API invokes OpenPullChallenge/Pull/… "only for an already-authorised caller". OpenPullChallenge issues the nonce that authorisation requires, so by the letter no caller could ever obtain one and every ownership/grant pull flow was impossible.
Fixed: OpenPullChallenge may be invoked for an unauthorised caller subject to rate limiting; the remaining procedures require a verified challenge response.
1.4 The kernel/API boundary was only half carried through
PR #122 makes the API layer the sole public surface and sole capability gate, but retained text assigned both to the kernel:
- the container stack listed
zkcoins-node as "scanner · prover · capability-gated API" (:2407);
- two seam bullets had a publisher back end "run the kernel alone" (
:2343, :2350), contradicting the deployment table at :2335 and the fact that the §7.6 hand-off is a public endpoint;
implementation-mandate.md:23 still told node to build "the capability-gated REST API".
Fixed: six containers with a separate zkcoins-api, only it listening publicly; every deployment with any public surface runs an API container, and only a validating node runs the kernel alone.
This surfaced a gap none of the passes reported as its own finding: the implementation mandate had no api layer at all. It listed three repositories, told node to build the REST surface, and defined done as "node ✓ and sdk ✓ and app ✓". After this PR nothing in it built the surface the PR makes mandatory. The mandate now has four layers with a specific brief for api — the /v1/ contract, the capability gate including chan_bind against its own authoritative host, the closed feature set, NIP-05 under wallet, and the kernel.v1 client.
1.5 An overclaim about a compromised API layer
architecture.md:64 claimed a compromised API layer can "refuse and mislead its own users while forging, moving, and double-spending nothing".
The "moving nothing" is false, and §6.6 Send-intent integrity already says why on develop: the thin wallet runs no Poseidon and cannot recompute output_coins_root from the templates it posted, so any component between wallet and prover can "redirect a send's outputs to a party of its choosing, or drop an output — including the per-asset change coin — entirely (burn), within one cooperative signature", with an effect on the sender "the same as theft".
Fixed: the claim is narrowed to what holds — no forged proof, no double-spend — and the page now points at the residual instead of implying it away. The underlying protocol residual is pre-existing and documented; only the architecture page's summary of it was wrong.
1.6 Kernel Info carried API-owned configuration
Info.features = 19 reported the closed {wallet, explorer, publisher, lightning_bridge, mail_bridge} set as kernel state, though :2307 says an API operator decides those independently. /v1/info cannot truthfully advertise independently enabled features by relaying a kernel value.
Fixed: renamed to kernel_parts reporting {scanner, prover, publisher}; the API constructs features from its own configuration.
1.7 Five errors on the new keys page
| Site |
Defect |
keys-and-identities.md:120 |
the address listed as appearing "on Bitcoin" — only the per-transition nullifier (Pkᵢ, Rᵢ) reaches Bitcoin. Invites the conclusion that a stable account address is publicly linkable on-chain |
:320 |
gift-wrap signatures attributed to op. Per :2744 the kind-1059 wrapper is signed "under its own fresh random one-time wrapper key"; only the kind-13 seal uses op. An implementer following the table produces linkable gift wraps. The kind-1421 ACK op_sig was also missing from a table claiming completeness |
:9, :44 |
"Everything descends from one seed" and "four jobs", while the page's own diagram shows five branches including A/4' = op_secret |
:304 |
chan_bind sourced "from the node's own hostname"; §7.8 requires the API layer's authoritative public host and forbids kernel re-derivation |
:32-36 |
the relationship diagram drew addr --> prof, ivpk --> prof, oppk --> prof, prof --> name as solid arrows, which the legend two lines below defines as derivation. The profile carries those values; it derives nothing |
Fixed. The diagram now uses three arrow styles — solid for derivation, thick for carriage, dotted for signature — because the distinction is load-bearing: a carried value is a copy the profile did not compute, and a wrong copy is caught by the signatures and by nothing else.
1.8 Smaller corrections
- The name enters two preimages, not one:
name_message, the only one in which the seed holder attests it, and the ordinary op-signed kind-0 serialization. Three sites claimed one.
txn_sig is the only Schnorr object placed on-chain and half-aggregated, not the protocol's only Schnorr object — addr_sig, name_sig, op_sig, ownership-proof responses and Nostr event signatures are all BIP-340.
- A contact record carries one current name;
:1665 still allowed "several", against the one-name-in-force rule.
- The V.12 name-loss fixture does depend on the retained authenticated endpoints it starts from;
:4069 claimed it does not.
- The genesis runbook hashed
network-params.json without bootstrap_pubkey, so the deployed artefact's canonical hash would not have matched the normative tuple.
Part 2 — Open: the publisher's key provenance
This is the highest-value finding and it is not editorial.
A publisher must publish a kind-30421 profile with eight closed fields {fee_address, fee_asset_id, fee, relays, pk0, nk_commit, ivpk, addr_sig} (:2718, :2771). That addr_sig is BIP-340(sk₀, invoice_message) (:1543), and :1555 states the consequence plainly: "issuing an Invoice requires the wallet (sk₀ is SPEND-branch, wallet-only)."
Against that, the deployment model says four times that a publisher needs almost nothing:
:2335 — "It needs a key and a relay, no name and no domain."
:2349 — "a publisher back end runs on a key and a relay."
:2775 — "Running a publisher therefore requires a key and a relay, never a domain, a name, or a CA."
keys-and-identities.md:346 — "Running a publisher therefore takes a key and a relay."
A conforming kind-30421 profile requires four seed-derived keys, not one:
| Key |
Path |
Why the publisher needs it |
op |
A/2' |
signs the kind-30421 event; the d tag is its hex op_pubkey |
sk₀ |
A/0'/0' |
produces addr_sig — wallet-only, and :2468 says the SPEND branch is "the only thing that is never part of a node deployment" |
ivk |
A/1'/0' |
publishes ivpk, decrypts the fee CoinProof |
nk |
A/3' |
computes nk_commit, which must satisfy H(pk0 ‖ nk_commit) == fee_address |
A second gap follows: there is no path by which a publisher can ever spend its fee income. Crediting a received coin is itself a state-advancing transition signed by skᵢ — :784, "Every state-advancing transition — a send, a receive, and a mint — consumes its state's one-time key Pkᵢ" — so a publisher accumulating fees must run receive transitions with a prover and a signing wallet. The publisher back end is specified with the publisher feature alone (:2335), and the feature array is closed and fail-closed (:2309). Nothing grants it the wallet feature its own revenue would require.
The keys page argues against itself on the same point: "The publisher has no protocol key material of its own beyond an ordinary account … Running a publisher therefore takes a key and a relay." The qualifier concedes the entire seed-rooted tree; the "therefore" does not follow.
The natural resolution is that a publisher operator is an ordinary account holder running a wallet alongside the back end. A search of specification.md, risks.md, architecture.md, and implementation-mandate.md found no statement anywhere pairing a publisher with a wallet, a seed, or its own account. That one missing sentence makes four repeated claims read as false and leaves the fee-coin lifecycle unspecified.
Decision needed. If a publisher operator does hold a wallet, this is a documentation fix. If a genuinely seedless publisher is intended, kind-30421 needs a binding for fee_address that is not sk₀, and this is a design change.
Part 3 — Open: design questions
3.1 name_sig has no sequence number and no expiry
name_message = H("zkCoins/v1/NameConsent" ‖ network ‖ u32-be name_len ‖ UTF-8(name) ‖ op_pubkey) binds no time and no ordering, yet a valid signature is treated as proof of the current name.
Attack. After the holder moves from old@example to new@example, a compromised host that holds op retains the old, still-valid name_sig and publishes a fresh, later-dated kind-0 carrying the old name. A known contact adopts the stale name silently.
A monotonic name_sequence bound into the preimage, pinned at the highest value seen, with rollback rejected, would close it. Note honestly that withholding all newer events remains a liveness limitation no signature can fix.
3.2 A name change is adopted without forward NIP-05 resolution
:1701 allows a changed nip05 under a pinned op_pubkey to be adopted silently once name_sig verifies under the pinned pk0. :1697 forbids a DNS or NIP-05 request for a known recipient.
Attack. A counterparty signs victim@trusted.example with its own sk₀ and republishes its profile under its pinned op_pubkey. Existing contacts display the victim's name, because the signature verifies and the real NIP-05 mapping — which would point elsewhere — is never queried. M-17 protects against reassignment only if somebody performs the resolution.
name_sig proves holder consent, not name-provider authorisation. The two are being conflated. A fix would leave a newly claimed name unpresented until a forward mapping to the pinned op_pubkey succeeds, while preserving DNS-free messaging and payment by key.
3.3 The mandatory kind-0 object links a name to an on-chain trace
The zkcoins object is mandatory, public, unencrypted, and permanently archived by ordinary Nostr clients. It publishes pk0 beside a human-readable name.
pk0 is also the account's genesis nullifier key on Bitcoin — :283 selects the first index "whose Pk₀ has never been published on Bitcoin". A passive archivist can therefore collect kind-0 profiles, extract each pk0, scan Bitcoin for the matching genesis nullifier, and obtain a permanent searchable link between a person's name, Nostr identity, payment identity, account existence, and first-transition timing — surviving any later profile replacement.
Both the mandatory object and the removal of the privacy-warning apparatus were deliberate operator decisions and are not re-litigated here. What is new is the Pk₀-to-chain linkage, which was not part of that reasoning and is stronger than what §4.7 currently discloses. Recorded so the decision can be revisited on complete information, or confirmed on it.
3.4 A/2' is the one tree position with no recorded decision
Appendix A of the implementation mandate has no decision row for A/2' or for the 1798' purpose index. Both exist only as normative spec text (:233, :240, :245). Every other tree position has one — D10 for A/3', D12 for A/4', D14 for ovk's role. So op-as-a-leaf is not a decision that was weighed and written down; it is the position that was never recorded.
The consequence is permanent: :3103 states none of {ivk, ovk, op, nk, op_secret} can be rotated independently, and "a subject who suspects the bundle itself is compromised … MUST move to a new account". For ivk, ovk, and nk that is unavoidable — they are baked into the address or into every prior coin's encryption. For op it is an artefact of tree shape: A/2' is a leaf where A/0' and A/1' are branches. A/2'/j' would cost one index.
What that forecloses is understated. :2493 frames the abandoned-node residual as a privacy residual, but :2613 concedes the sharper fact: a provider holding the bundle can "read messages, impersonate the account on Nostr, and publish a new kind-0 advertising a different zkcoins payment identity under the same op_pubkey." op is the only bundle member whose compromise is active rather than passive.
Op rotation would not fix the view residual — ivk still decrypts future coins — so it does not remove the move-accounts rule. What it would buy is stopping an ex-operator acting as you while keeping the address, the funds, and the account. The mechanism already exists: addr_sig covers op_pubkey, so a fresh profile with a new op_pubkey and a new addr_sig under the same pk0 is verifiable as address-holder-authorised. Costs: contacts re-pin, and name_sig is re-issued.
v1 is frozen (:429), so any tree change is v2 — but the shape becomes effectively permanent once v1 ships, which is why it is raised now.
3.5 op_secret's rationale is the weaker of the two available
:245 justifies A/4' only by saying the NAV randomness derivation "never shares key material with the Nostr signature". That argues against op_secret = op and nothing else. It does not rule out nav_rand = HKDF("NavRand", nk ‖ counter) or ivk ‖ counter, either of which would satisfy every stated constraint and shrink the operational bundle from 161 to 129 bytes.
The reason that actually forecloses those is stated elsewhere and never connected to the decision: nav_rand is disclosed. The opening {nav, nav_rand} travels to every coin recipient and disclosure verifier (:351, :393-396). The key feeding a routinely-disclosed KDF stream must have no other role — which A/4' has and nk (a circuit witness bound to the address) and ivk (whose public half is in every profile) do not.
The branch is correct; the justification leaves a correct decision looking arbitrary, which is what invites the merge proposal.
3.6 D10 does not address the derivation question it invites
D10 fully justifies nk leaving the SPEND branch. It says nothing about why nk is not derived from ivk. The reason is decisive and should be recorded: ivk leaves the account as an irrevocable bearer capability inside zkavk, so nk = f(ivk) would give every holder of a history link nullifier-derivation power over the account.
3.7 Two smaller items
- Pinning versus kind-30421. One
op_pubkey can now author two payment-identity blocks that may legitimately differ. The §4.3 pin rule is written for kind-0 and Invoice and is silent on the publisher profile. Not pinning is probably correct — fee redirection is self-defeating because the fee coin shares one ocr with the payment (:1344) — but the reasoning appears nowhere, and a naive implementer pinning by op_pubkey would either warn spuriously or adopt silently.
- V.12 lacks pinned
name_message bytes. The fixtures exercise name_sig semantically but pin no digest, no signature bytes, and no framing mutations. A signer and verifier sharing the same wrong network encoding, length framing, or normalization would pass every existing fixture. There is also no producer-side test that a served account publishes its mandatory name and profile before setup completes.
Part 4 — Simplifications considered and rejected
Recorded so they are not re-proposed. The evaluation set out to reduce the key inventory and could not remove a single key or merge a single pair without losing a stated requirement or a documented capability tier.
| Proposal |
Why it fails |
op = sk₀ / op_pubkey = Pk₀ |
Puts a SPEND-branch key on a node (Requirement 5). Also hands over the OwnershipProof authority — the only authority for /v1/pull, /v1/attest/balance, /v1/grants |
op_pubkey = Pk₀ + H(Pk₀)·G, a public tweak that would delete addr_sig |
The op holder recovers sk₀ = op − H(Pk₀). Classic non-hardened parent-key leak; converts the whole operational bundle into spend authority. Flagged loudly because it is the only construction that achieves the stated goal |
address = H(op_pubkey ‖ nk_commit) |
Makes the payment identity a function of a node-held key; breaks the genesis check at :707 and inverts Requirement 5. :1699 is the one-line refutation: "the holder of op is not necessarily the holder of the payment address" |
op = f(ivk) |
ivk ships as an irrevocable bearer zkavk. Every history link would confer profile-signing and ViewGrant minting, falsifying the "Read-only … carries no spend authority" property at :2246 |
nk = f(ivk) |
Same leak, plus it erases the deliberate ivk-any-node / nk-own-node-only asymmetry at :267-268 |
Merge ivk and ovk |
Collapses the 32-byte incoming-only zkavk tier. ivk already opens the SelfDeliveryRecordV1 enumerating outgoing coins (:1477); only K_out keeps their plaintext out. D14 records the split as deliberate |
Merge addr_sig and name_sig |
Forces the name into invoice_message, forbidden at :1528; breaks Requirement 11 replaceability; contradicts :1703 ("addr_sig covers no name"); and increases wallet-signing events, since the two change at different rates |
Drop addr_sig, rely on the pin |
The pin is trust-on-first-use and has nothing to compare against on first contact — exactly the substitution attack at :1555 |
Drop op_secret |
nav_rand is disclosed to every recipient; see 3.5 |
| Merge any Bech32m HRP pair |
Saves nothing — an HRP string is free — and forfeits the mandatory wrong-HRP rejection rule at :552 |
Drop nk_commit from the address preimage |
Permits two accounts under one address (:275); the genesis check and clause 4 both depend on it |
Stop rotating skᵢ |
Every transition of an account becomes linkable on-chain; rotation plus the hidden rotation edge is what preserves Requirement 2 |
On unifying the payment and Nostr identities specifically: it is not possible, and the reason is the custody boundary. sk₀ is wallet-only by Requirement 5; op must be node-held so the account can receive, decrypt, and acknowledge while the user sleeps. No single key can be on both sides of that line. Three objects binding two identities is minimal, because they do three different jobs — addr_sig binds transport fields at first contact when no pin exists, the pin binds them across subsequent contacts, and name_sig binds a mutable non-seed-derived label that must stay separable from both.
On the kind-30421 duplication introduced by this PR — adding pk0, nk_commit, ivpk, addr_sig so the publisher profile is self-contained — the evaluation found it warranted. Without it a fee-bearing hand-off would need a second event and a cross-check, dragging the naming layer into a path that must work for an anonymous, nameless publisher. The d tag set to the author's own op_pubkey is separately redundant: an addressable event keyed (kind, pubkey, d) with d fixed to the author degenerates to a replaceable event.
Method
Three passes, each with its own working copy at the same commit:
- two independent reviews of the branch diff, one for conformance and internal consistency, one for threat model and logic;
- one architectural evaluation gated on demonstrated protocol understanding before it was permitted to judge — it had to answer, from the text, which keys the circuit witnesses, why
nk_commit is in the address, why the spend key rotates, what survives recovery from the seed alone, and what Requirement 10 forbids.
The V.12 defect in 1.1 was found independently by all three. Four of the five keys-page errors in 1.7 were found by only one pass, which is the argument for running more than one.
Everything asserted here was checked against the tree before being written down.
Findings from a three-pass review of PR #122 (
agent/standard-nostr-messaging): two independent conformance/security reviews of the branch diff, and one architectural evaluation of the key and identity design asking whether the complexity is necessary and whether anything can be merged.Part 1 records defects already fixed on the branch, so the record of what was found survives the fix. Parts 2–4 are open — design decisions and documentation gaps that are deliberately not being resolved by a silent commit.
Line references are against
8c9ab80unless stated otherwise. The branch head is nowc4431a0.Part 1 — Fixed in
c4431a0All of these were verified against the tree before the fix. Nothing in this section needs action; it is here so the findings are documented rather than only repaired.
1.1 The V.12 conformance fixture would have accepted an unattested name
Found independently by all three passes.
specification.md:3992pinned the positive kind-0 profile fixture ascontent.zkcoins= "exactly{version, network, address, pk0, nk_commit, ivpk, relays, addr_sig}" — eight fields, noname_sig— while:2722mandates "exactly the fields{…, addr_sig, name_sig}". Two "exactly" clauses, nine fields versus eight.The fixture is reached by resolving a name (
:3988starts from a NIP-05 response), which is precisely the condition triggering §4.3 check (iv) at:1580. The baseline conformance fixture therefore described an object the normative rule rejects, on the exact discovery path that demands the missing field. Two paragraphs later,:4004requires a profile withname_sigabsent to be rejected.A harness built from V.12 would have accepted exactly the unattested name
name_sigexists to prevent — a hosting provider holding the node-heldopkey putting a name on an identity whose seed holder never consented.Two adjacent sites had the same gap: the §7.3 Name checklist at
:2734listed "the reversenip05match plus, for messaging, kind-10050 readiness" and never named thename_sigcheck at all; and negative vectorM-16recorded a name change without requiring it.Fixed: nine-field fixture, acceptance after checks (i)–(iv),
name_signamed in the §7.3 checklist,M-16requires verification before recording, andNameConsentadded to the reserved-context registry in §1.1, which it was missing from despite that list being exhaustive and "Reusing a context for two purposes is forbidden."1.2 A mandatory profile was described as optional in the same document
specification.md:2707read: "publishing a kind-0 profile, and carrying a NIP-05 name at all, are optional".:1519and:2722say MUST. Decision D7 still said "opt-in". Independent implementations would have exposed incompatible account lifecycles.Fixed: mandatory for every account the app and API layers serve; explicitly no obligation on a kernel-only deployment or on an ordinary Nostr peer.
1.3 The capability gate could never be entered
specification.md:3113said the API invokesOpenPullChallenge/Pull/… "only for an already-authorised caller".OpenPullChallengeissues the nonce that authorisation requires, so by the letter no caller could ever obtain one and every ownership/grant pull flow was impossible.Fixed:
OpenPullChallengemay be invoked for an unauthorised caller subject to rate limiting; the remaining procedures require a verified challenge response.1.4 The kernel/API boundary was only half carried through
PR #122 makes the API layer the sole public surface and sole capability gate, but retained text assigned both to the kernel:
zkcoins-nodeas "scanner · prover · capability-gated API" (:2407);:2343,:2350), contradicting the deployment table at:2335and the fact that the §7.6 hand-off is a public endpoint;implementation-mandate.md:23still toldnodeto build "the capability-gated REST API".Fixed: six containers with a separate
zkcoins-api, only it listening publicly; every deployment with any public surface runs an API container, and only a validating node runs the kernel alone.This surfaced a gap none of the passes reported as its own finding: the implementation mandate had no
apilayer at all. It listed three repositories, toldnodeto build the REST surface, and defined done as "node ✓ and sdk ✓ and app ✓". After this PR nothing in it built the surface the PR makes mandatory. The mandate now has four layers with a specific brief forapi— the/v1/contract, the capability gate includingchan_bindagainst its own authoritative host, the closed feature set, NIP-05 underwallet, and thekernel.v1client.1.5 An overclaim about a compromised API layer
architecture.md:64claimed a compromised API layer can "refuse and mislead its own users while forging, moving, and double-spending nothing".The "moving nothing" is false, and §6.6 Send-intent integrity already says why on
develop: the thin wallet runs no Poseidon and cannot recomputeoutput_coins_rootfrom the templates it posted, so any component between wallet and prover can "redirect a send's outputs to a party of its choosing, or drop an output — including the per-asset change coin — entirely (burn), within one cooperative signature", with an effect on the sender "the same as theft".Fixed: the claim is narrowed to what holds — no forged proof, no double-spend — and the page now points at the residual instead of implying it away. The underlying protocol residual is pre-existing and documented; only the architecture page's summary of it was wrong.
1.6 Kernel
Infocarried API-owned configurationInfo.features = 19reported the closed{wallet, explorer, publisher, lightning_bridge, mail_bridge}set as kernel state, though:2307says an API operator decides those independently./v1/infocannot truthfully advertise independently enabled features by relaying a kernel value.Fixed: renamed to
kernel_partsreporting{scanner, prover, publisher}; the API constructsfeaturesfrom its own configuration.1.7 Five errors on the new keys page
keys-and-identities.md:120addresslisted as appearing "on Bitcoin" — only the per-transition nullifier(Pkᵢ, Rᵢ)reaches Bitcoin. Invites the conclusion that a stable account address is publicly linkable on-chain:320op. Per:2744the kind-1059 wrapper is signed "under its own fresh random one-time wrapper key"; only the kind-13 seal usesop. An implementer following the table produces linkable gift wraps. The kind-1421 ACKop_sigwas also missing from a table claiming completeness:9,:44A/4' = op_secret:304chan_bindsourced "from the node's own hostname"; §7.8 requires the API layer's authoritative public host and forbids kernel re-derivation:32-36addr --> prof,ivpk --> prof,oppk --> prof,prof --> nameas solid arrows, which the legend two lines below defines as derivation. The profile carries those values; it derives nothingFixed. The diagram now uses three arrow styles — solid for derivation, thick for carriage, dotted for signature — because the distinction is load-bearing: a carried value is a copy the profile did not compute, and a wrong copy is caught by the signatures and by nothing else.
1.8 Smaller corrections
name_message, the only one in which the seed holder attests it, and the ordinaryop-signed kind-0 serialization. Three sites claimed one.txn_sigis the only Schnorr object placed on-chain and half-aggregated, not the protocol's only Schnorr object —addr_sig,name_sig,op_sig, ownership-proof responses and Nostr event signatures are all BIP-340.:1665still allowed "several", against the one-name-in-force rule.:4069claimed it does not.network-params.jsonwithoutbootstrap_pubkey, so the deployed artefact's canonical hash would not have matched the normative tuple.Part 2 — Open: the publisher's key provenance
This is the highest-value finding and it is not editorial.
A publisher must publish a kind-30421 profile with eight closed fields
{fee_address, fee_asset_id, fee, relays, pk0, nk_commit, ivpk, addr_sig}(:2718,:2771). Thataddr_sigisBIP-340(sk₀, invoice_message)(:1543), and:1555states the consequence plainly: "issuing anInvoicerequires the wallet (sk₀is SPEND-branch, wallet-only)."Against that, the deployment model says four times that a publisher needs almost nothing:
:2335— "It needs a key and a relay, no name and no domain.":2349— "a publisher back end runs on a key and a relay.":2775— "Running a publisher therefore requires a key and a relay, never a domain, a name, or a CA."keys-and-identities.md:346— "Running a publisher therefore takes a key and a relay."A conforming kind-30421 profile requires four seed-derived keys, not one:
opA/2'dtag is its hexop_pubkeysk₀A/0'/0'addr_sig— wallet-only, and:2468says the SPEND branch is "the only thing that is never part of a node deployment"ivkA/1'/0'ivpk, decrypts the feeCoinProofnkA/3'nk_commit, which must satisfyH(pk0 ‖ nk_commit) == fee_addressA second gap follows: there is no path by which a publisher can ever spend its fee income. Crediting a received coin is itself a state-advancing transition signed by
skᵢ—:784, "Every state-advancing transition — a send, a receive, and a mint — consumes its state's one-time keyPkᵢ" — so a publisher accumulating fees must run receive transitions with a prover and a signing wallet. The publisher back end is specified with thepublisherfeature alone (:2335), and the feature array is closed and fail-closed (:2309). Nothing grants it thewalletfeature its own revenue would require.The keys page argues against itself on the same point: "The publisher has no protocol key material of its own beyond an ordinary account … Running a publisher therefore takes a key and a relay." The qualifier concedes the entire seed-rooted tree; the "therefore" does not follow.
The natural resolution is that a publisher operator is an ordinary account holder running a wallet alongside the back end. A search of
specification.md,risks.md,architecture.md, andimplementation-mandate.mdfound no statement anywhere pairing a publisher with a wallet, a seed, or its own account. That one missing sentence makes four repeated claims read as false and leaves the fee-coin lifecycle unspecified.Decision needed. If a publisher operator does hold a wallet, this is a documentation fix. If a genuinely seedless publisher is intended, kind-30421 needs a binding for
fee_addressthat is notsk₀, and this is a design change.Part 3 — Open: design questions
3.1
name_sighas no sequence number and no expiryname_message = H("zkCoins/v1/NameConsent" ‖ network ‖ u32-be name_len ‖ UTF-8(name) ‖ op_pubkey)binds no time and no ordering, yet a valid signature is treated as proof of the current name.Attack. After the holder moves from
old@exampletonew@example, a compromised host that holdsopretains the old, still-validname_sigand publishes a fresh, later-dated kind-0 carrying the old name. A known contact adopts the stale name silently.A monotonic
name_sequencebound into the preimage, pinned at the highest value seen, with rollback rejected, would close it. Note honestly that withholding all newer events remains a liveness limitation no signature can fix.3.2 A name change is adopted without forward NIP-05 resolution
:1701allows a changednip05under a pinnedop_pubkeyto be adopted silently oncename_sigverifies under the pinnedpk0.:1697forbids a DNS or NIP-05 request for a known recipient.Attack. A counterparty signs
victim@trusted.examplewith its ownsk₀and republishes its profile under its pinnedop_pubkey. Existing contacts display the victim's name, because the signature verifies and the real NIP-05 mapping — which would point elsewhere — is never queried.M-17protects against reassignment only if somebody performs the resolution.name_sigproves holder consent, not name-provider authorisation. The two are being conflated. A fix would leave a newly claimed name unpresented until a forward mapping to the pinnedop_pubkeysucceeds, while preserving DNS-free messaging and payment by key.3.3 The mandatory kind-0 object links a name to an on-chain trace
The
zkcoinsobject is mandatory, public, unencrypted, and permanently archived by ordinary Nostr clients. It publishespk0beside a human-readable name.pk0is also the account's genesis nullifier key on Bitcoin —:283selects the first index "whosePk₀has never been published on Bitcoin". A passive archivist can therefore collect kind-0 profiles, extract eachpk0, scan Bitcoin for the matching genesis nullifier, and obtain a permanent searchable link between a person's name, Nostr identity, payment identity, account existence, and first-transition timing — surviving any later profile replacement.Both the mandatory object and the removal of the privacy-warning apparatus were deliberate operator decisions and are not re-litigated here. What is new is the
Pk₀-to-chain linkage, which was not part of that reasoning and is stronger than what §4.7 currently discloses. Recorded so the decision can be revisited on complete information, or confirmed on it.3.4
A/2'is the one tree position with no recorded decisionAppendix A of the implementation mandate has no decision row for
A/2'or for the1798'purpose index. Both exist only as normative spec text (:233,:240,:245). Every other tree position has one — D10 forA/3', D12 forA/4', D14 forovk's role. Soop-as-a-leaf is not a decision that was weighed and written down; it is the position that was never recorded.The consequence is permanent:
:3103states none of{ivk, ovk, op, nk, op_secret}can be rotated independently, and "a subject who suspects the bundle itself is compromised … MUST move to a new account". Forivk,ovk, andnkthat is unavoidable — they are baked into the address or into every prior coin's encryption. Foropit is an artefact of tree shape:A/2'is a leaf whereA/0'andA/1'are branches.A/2'/j'would cost one index.What that forecloses is understated.
:2493frames the abandoned-node residual as a privacy residual, but:2613concedes the sharper fact: a provider holding the bundle can "read messages, impersonate the account on Nostr, and publish a new kind-0 advertising a differentzkcoinspayment identity under the sameop_pubkey."opis the only bundle member whose compromise is active rather than passive.Op rotation would not fix the view residual —
ivkstill decrypts future coins — so it does not remove the move-accounts rule. What it would buy is stopping an ex-operator acting as you while keeping the address, the funds, and the account. The mechanism already exists:addr_sigcoversop_pubkey, so a fresh profile with a newop_pubkeyand a newaddr_sigunder the samepk0is verifiable as address-holder-authorised. Costs: contacts re-pin, andname_sigis re-issued.v1 is frozen (
:429), so any tree change is v2 — but the shape becomes effectively permanent once v1 ships, which is why it is raised now.3.5
op_secret's rationale is the weaker of the two available:245justifiesA/4'only by saying the NAV randomness derivation "never shares key material with the Nostr signature". That argues againstop_secret = opand nothing else. It does not rule outnav_rand = HKDF("NavRand", nk ‖ counter)orivk ‖ counter, either of which would satisfy every stated constraint and shrink the operational bundle from 161 to 129 bytes.The reason that actually forecloses those is stated elsewhere and never connected to the decision:
nav_randis disclosed. The opening{nav, nav_rand}travels to every coin recipient and disclosure verifier (:351,:393-396). The key feeding a routinely-disclosed KDF stream must have no other role — whichA/4'has andnk(a circuit witness bound to the address) andivk(whose public half is in every profile) do not.The branch is correct; the justification leaves a correct decision looking arbitrary, which is what invites the merge proposal.
3.6 D10 does not address the derivation question it invites
D10 fully justifies
nkleaving the SPEND branch. It says nothing about whynkis not derived fromivk. The reason is decisive and should be recorded:ivkleaves the account as an irrevocable bearer capability insidezkavk, sonk = f(ivk)would give every holder of a history link nullifier-derivation power over the account.3.7 Two smaller items
op_pubkeycan now author two payment-identity blocks that may legitimately differ. The §4.3 pin rule is written for kind-0 andInvoiceand is silent on the publisher profile. Not pinning is probably correct — fee redirection is self-defeating because the fee coin shares oneocrwith the payment (:1344) — but the reasoning appears nowhere, and a naive implementer pinning byop_pubkeywould either warn spuriously or adopt silently.name_messagebytes. The fixtures exercisename_sigsemantically but pin no digest, no signature bytes, and no framing mutations. A signer and verifier sharing the same wrong network encoding, length framing, or normalization would pass every existing fixture. There is also no producer-side test that a served account publishes its mandatory name and profile before setup completes.Part 4 — Simplifications considered and rejected
Recorded so they are not re-proposed. The evaluation set out to reduce the key inventory and could not remove a single key or merge a single pair without losing a stated requirement or a documented capability tier.
op = sk₀/op_pubkey = Pk₀/v1/pull,/v1/attest/balance,/v1/grantsop_pubkey = Pk₀ + H(Pk₀)·G, a public tweak that would deleteaddr_sigopholder recoverssk₀=op − H(Pk₀). Classic non-hardened parent-key leak; converts the whole operational bundle into spend authority. Flagged loudly because it is the only construction that achieves the stated goaladdress = H(op_pubkey ‖ nk_commit):707and inverts Requirement 5.:1699is the one-line refutation: "the holder ofopis not necessarily the holder of the payment address"op = f(ivk)ivkships as an irrevocable bearerzkavk. Every history link would confer profile-signing and ViewGrant minting, falsifying the "Read-only … carries no spend authority" property at:2246nk = f(ivk)ivk-any-node /nk-own-node-only asymmetry at:267-268ivkandovkzkavktier.ivkalready opens theSelfDeliveryRecordV1enumerating outgoing coins (:1477); onlyK_outkeeps their plaintext out. D14 records the split as deliberateaddr_sigandname_siginvoice_message, forbidden at:1528; breaks Requirement 11 replaceability; contradicts:1703("addr_sigcovers no name"); and increases wallet-signing events, since the two change at different ratesaddr_sig, rely on the pin:1555op_secretnav_randis disclosed to every recipient; see 3.5:552nk_commitfrom the address preimage:275); the genesis check and clause 4 both depend on itskᵢOn unifying the payment and Nostr identities specifically: it is not possible, and the reason is the custody boundary.
sk₀is wallet-only by Requirement 5;opmust be node-held so the account can receive, decrypt, and acknowledge while the user sleeps. No single key can be on both sides of that line. Three objects binding two identities is minimal, because they do three different jobs —addr_sigbinds transport fields at first contact when no pin exists, the pin binds them across subsequent contacts, andname_sigbinds a mutable non-seed-derived label that must stay separable from both.On the kind-30421 duplication introduced by this PR — adding
pk0,nk_commit,ivpk,addr_sigso the publisher profile is self-contained — the evaluation found it warranted. Without it a fee-bearing hand-off would need a second event and a cross-check, dragging the naming layer into a path that must work for an anonymous, nameless publisher. Thedtag set to the author's ownop_pubkeyis separately redundant: an addressable event keyed(kind, pubkey, d)withdfixed to the author degenerates to a replaceable event.Method
Three passes, each with its own working copy at the same commit:
nk_commitis in the address, why the spend key rotates, what survives recovery from the seed alone, and what Requirement 10 forbids.The V.12 defect in 1.1 was found independently by all three. Four of the five keys-page errors in 1.7 were found by only one pass, which is the argument for running more than one.
Everything asserted here was checked against the tree before being written down.