ContactCardEvent.indexableContent() already indexed the public summary
and topics; add the public petName() tag alongside them so a card can be
found by the nickname its author gave the target user. petName()/summary()
read the public tag array, so any petname kept in the NIP-44 encrypted
content stays out of the index (privacy preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
Massive review of every Event subclass in Quartz that carries
human-authored plaintext at rest but was not participating in the NIP-50
full-text index (SearchableEvent).
Add SearchableEvent to 15 kinds whose content is plaintext searchable
text a user would look for:
- 9737 Bolt12 zap intent (comment) — mirrors 9734/9736
- 5302 / 5303 NIP-90 content / people search request (search query)
- 31871 / 31872 attestation / attestation request — sibling family was
already searchable
- 1315 roadstr road-event report (free-text comment)
- 45001 / 45003 Buzz forum post / comment (body)
- 48106 Buzz huddle guidelines
- 30176 / 30175 / 30177 / 10100 Buzz team / persona / managed-agent /
agent-profile (name, description, system prompt)
- 30620 Buzz workflow definition (name + YAML)
- 3302 Concord chat edit (replacement message text) — mirrors kind-9 chat
Widen indexableContent() on 8 kinds that already implemented
SearchableEvent but dropped natural-language text carried in tags:
- 30020 auction — category hashtags were written by build() but never
indexed
- 12473 Birdex — species names
- 30382 contact card — public topics
- 9002 NIP-29 group-metadata edit — hashtags
- 30054 Podcasting-2.0 episode — topics
- 38192 PS1 save — region name
- 1111 comment / 1311 live-activity chat — hashtags
Encrypted-at-rest (NIP-04, giftwraps, MLS/marmot, NWC, cashu), purely
structural (relay/follow lists, reactions, deletions, moderation/presence
signaling), and ephemeral events were reviewed and deliberately left out.
The NIP-31 alt tag was also left out: it is frequently kind-level client
boilerplate and would dilute relevance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
**Adding people.** The Members screen hid its search behind a FAB, so adding a
handful of people was "open dialog, search, pick, dialog closes, reopen" per
person. The field now lives at the bottom of the screen with its results rising
above it, like a chat composer: each pick lands in the roster above and clears
the query while the keyboard stays up. It clears the gesture bar and rides above
the IME, so the field you type into is not the part that gets covered.
**Promotions did nothing.** "Make moderator" published a kind-9000 and changed
nothing, on either client. Two reasons:
- NIP-29 carries roles inside the `p` tag; Buzz reads a top-level `role` tag
(`extract_tag_value(event, "role")`) and defaults to `member` without it. So
every promotion re-added the target as a plain member. PutUserEvent can now
carry that tag and Account maps our role onto Buzz's vocabulary before sending.
- That vocabulary is `owner`/`admin`/`member`/`guest`/`bot` — there is **no
moderator**, and a role the relay cannot parse fails the whole put-user. So the
action is hidden on Buzz rather than offered and silently dropped.
**The owner could not promote anyone.** membershipOf only mapped the literal
`admin` to ADMIN, but a Buzz channel's creator carries `owner` — leaving the one
person with full authority ranked below it, so "Make admin" never appeared. Both
role strings now mean ADMIN.
**The 3-dot button moved when tapped.** An expanded DropdownMenu still emits a
node into its parent, and it sat as a direct child of a `spacedBy(12.dp)` Row —
so opening the menu added a second gap and shoved the button sideways. Button and
menu now share a Box. ConcordMembersScreen had the identical bug and is fixed
too; GitBrowseUi looks like a third instance and is left alone as unrelated
territory.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting the
added member published the 9000, the relay narrated it, and after the roster
refreshed the member carries an `admin` badge. The 3-dot sits at the same pixel
column whether the menu is open or closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The community screen's FAB opened the NIP-29 create-group flow, and on a Buzz
relay it published two events and produced nothing: no channel in the list, no
channel anywhere. Two independent reasons, both silent.
**The create was rejected.** NIP-29 puts the id on the kind-9007 and leaves the
metadata to a following 9002. Buzz's ingest.rs validates the 9007 *before
storage* and rejects it with "invalid: channel name is required" unless the
create event itself carries a `name`; it also reads `about`, `visibility` and
`channel_type` off that same event. Our 9007 had only the `h` tag, so it never
stored, and the 9002 behind it addressed a channel that was never made. Send the
metadata on both events: relay29 ignores the extra tags and takes the 9002, Buzz
takes the 9007.
**The id was not a UUID.** Buzz keys channels by UUID and parses the `h` tag with
`val.parse::<Uuid>()`. Our 8-random-bytes hex id doesn't parse, so the relay
discarded it and created the channel under an id of its own — the app then opened
the id *it* had picked, which is why the one channel that did get created showed
a hex title over an empty feed. Generate a v4 UUID when the host speaks Buzz;
NIP-29 ids are opaque strings, so nothing else changes.
The screen matched NIP-29 rather than Buzz, too. It offered a photo, hashtags, a
geohash and four permission flags — of which Buzz's 9002 handler honours exactly
one (`visibility`, two-valued). Those controls looked like they configured the
channel and were dropped on the floor. On a Buzz relay it is now: name,
description, "Private channel" (Buzz's open/private in Buzz's words), and
"Forum channel" for `channel_type` — offered only on create, since Buzz has no
`channel_type` key on edit. Titled "New channel", because Buzz calls them
channels, and the FAB says so. Plain NIP-29 relays are untouched.
Also stops the NIP-11 gate blocking creation on Buzz relays, which advertise no
NIP-29 support yet implement 9007/9002, and makes RelayGroupMetadataViewModel's
`relay` snapshot state — it is assigned after first composition, so a plain var
left the screen stuck rendering its NIP-29 shape.
Verified against nosfabrica.communities.buzz.xyz: creating "amethyst-create-test2"
lands a real channel — right name in the title, Moderator badge, member count 1,
the relay's own "Vitor Pamplona created this channel" system line, and a row in
the channel list. BuzzChannelCreateTest covers both wire-level fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolve all `:quartz:compileTestKotlin*` warnings without changing any
test's behavior:
- Drop redundant bare `Secp256k1Instance` "force crypto lib load"
statements (and their now-unused imports). JVM object initialization is
thread-safe and lazy, and every one of these tests exercises crypto, so
the lib loads on first use regardless — the eager reference was a no-op
the K2 compiler flags as an unused expression.
- PairingEventTest: use `assertIs` instead of `assertTrue(x is T)` + cast.
- MergeQueryCorrectnessTest: drop `!!` that smart-cast already made moot.
- PodcastCommentScopeTest / MintExceptionTest: widen the declared type so
the `is` checks are genuine runtime checks rather than always-true.
- FetchAllIdleTimeoutTest / NostrConnectSignerServiceTest: opt in to
`ExperimentalCoroutinesApi` at the class level.
- GiantReqStreamTest: name the `WebSocketListener` overrides' parameters
to match the supertype.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyXakbYay4zNJ4cwoF4j7N
Two bugs found auditing the P2PK redeem path:
- Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't
mandate a case, but our key index is keyed by lowercase x-only (Hex.encode
is lowercase). An uppercase/mixed-case lock we actually hold the key for was
falsely rejected as unredeemable. Normalize the lock to lowercase before the
lookup, and compare identity-key locks case-insensitively.
- Multi-mint partial redeem: callers redeem one mint-group at a time, each
swapping + publishing. An unsignable P2PK lock in a later group threw only
after earlier groups were already spent + published, leaving a half-redeemed
state the user was told had failed. Add `firstUnsignableP2pkLock` /
`requireP2pkRedeemable` and pre-flight every group before redeeming any,
mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI).
Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing
for safety (prevents cross-protocol signature reuse when signing with the
identity key), not just for parsing. Adds tests for case-insensitive matching
and the pre-flight helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Follow-up to the P2PK redeem support:
- Gate the redeem signing-key gathering behind an actual P2PK lock. The
wallet P2PK key is decrypted from kind:17375 via the signer — a network
round-trip on a NIP-46 bunker (and a possible approval prompt). The common
case (a plain, unlocked token) needs none of it, so only fetch keys when
`anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy
CLI token-redeem path.
- Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON
arrays, so bail before the throwing JSON parse when the string isn't one.
Redeem parses every proof's secret once, so this drops a thrown+caught
exception per plain proof (also benefits the nutzap redeem path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs
to /v1/swap with no witness, so the mint rejected them with an opaque
`witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path
signed witnesses; the generic redeem path had no P2PK support at all.
- quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the
`P2PKUnredeemableException` it throws when a locked proof's key is
unknown, and a `CashuMintOperations.redeemToken` that signs then swaps.
- commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and
(local-signer-only) identity key, indexes them by x-only pubkey, and
routes through the P2PK-aware path. Add `describeRedeemError`, which tells
a user whose token is locked to their own identity key (e.g. Bey Wallet's
P2PK send) — but who is on a bunker/external signer that can't sign a raw
witness — to import their nsec elsewhere to claim it.
- amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys
(identity key only for a local NostrSignerInternal); the wallet ViewModel
wires them in and reports via `describeRedeemError`.
- cli: `amy cashu receive token` passes the same keys and reports a distinct
`p2pk_locked` error code.
Adds P2PKRedeemTest covering pass-through, x-only + compressed locks,
verifiable witnesses, and the unredeemable case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Two rows of "Kind conflicts — implemented but NOT registered in EventFactory" were
stale: 20001 and 39005 are both dispatched now, disambiguated by tag shape inside
the kind's block (`g` for BitChat presence, `h` for a Buzz thread summary). Split
the table into the disambiguated pair and the three where the incumbent really does
keep the slot, and record why the 39005 signal is safe — the NIP-29 relay-generated
39xxx family is `d`-addressed and never emits `h` — plus the fact that nothing
throws when it is wrong, so the failure is silent.
Also document reading Buzz's Rust without a checkout, which currently costs everyone
the same detour: KDoc across this package cites crate-relative paths
(`buzz-relay/src/handlers/...`) while the repo puts everything under `crates/`, and
`raw.githubusercontent.com` 404s on those paths with plain curl usually sandboxed —
`gh api ... contents/... | base64 -d` is the way in. Notes where the answers live
(handlers for what the relay emits, buzz-db/channel.rs for channel_type and the two
visibility values, desktop/src/features for what Buzz's own client renders — which
decides whether an event we publish is visible to anyone), and that the relay's tests
are the best spec: `channel_scoped_content_kinds_require_h_tags` is what establishes
that canvas and the forum kinds are per-channel, not per-workspace.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The in-chat markers read "member joined" / "visibility changed" — the relay's
raw payload type with the underscores swapped for spaces. Everything that makes
the line useful was parsed and then dropped: which member, who added them, and
what the setting changed TO.
Render the full sentence from the signed payload instead, with the pubkeys
resolved to the names the viewer knows them by and the avatar of whoever the
line is about:
member joined -> "Shawn was added by straycat" (actor != target)
-> "Vitor joined" (self-join, actor == target)
member removed -> "Bob was removed by Alice"
visibility changed -> "straycat made this channel open — anyone can find and join it"
-> "... made this channel private — invite only"
ttl changed -> "Alice set messages to disappear after 7 days" / turned off
topic/purpose -> the new value, or "cleared the topic" when blank
channel created/deleted/archived/restored, message deleted (+ public reason),
dm created -> named after their actor
The event is signed by the RELAY keypair, so the people come from the payload's
actor/target, never from note.author. Membership lines are about the member, so
that is whose avatar shows and whose profile the pill opens; everything else is
about the actor.
Also covers the neighbouring timeline narration, which had the same problem:
huddle joins/leaves now name the participant from the `p` tag instead of
"someone joined the huddle", job lines name the signer, and forum votes name
the voter. All of these move from hardcoded English into string resources.
Quartz's SystemMessagePayload was missing target_event_id, action_id,
reason_code, public_reason and participants — every field of the moderation
tombstone and the DM-created payload. The KDoc now carries the complete
vocabulary read off the relay's emit_system_message callers, and the type
strings are constants so the UI cannot typo a branch into dead code.
An unknown type still renders as "alice: some_new_type" rather than vanishing,
so a relay that grows new vocabulary stays legible.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four
"was added by" lines, "straycat created this channel", "Matthias Debernardini
joined" and "straycat made this channel open" all render with the right subject
avatar, in the chat and in the Messages-list preview.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kind:39005 is claimed by two unrelated relay-signed addressable events, and
EventFactory mapped it unconditionally to NIP-29's GroupPinnedEvent, leaving
Buzz's ThreadSummaryEvent unreachable:
GroupPinnedEvent d = group id, e = pinned message ids, no h, empty content
ThreadSummaryEvent d = thread root id, e = that root, h = channel, JSON content
Nothing throws on the mismatch, so a summary would have parsed as a pin list and
pinnedEventIds() would have reported the thread root as a pinned message.
Discriminate inside the kind's block on the `h` tag, following the kind-20001
BitChat/Buzz presence precedent already in this file. `h` is a safe signal in
both directions: the whole NIP-29 relay-generated 39xxx family (metadata,
admins, members, participants, supported-roles, pinned) is addressed by `d`
alone and never emits `h`, and neither builder does either, so both classes
round-trip through the factory on outbound signing as well.
Buzz publishes summaries live to channel subscribers (not only over its HTTP
bridge), so this is what makes it safe for a channel-scoped filter to ask for
39005 at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The BOLT12 zaps feature was built against a placeholder NIP identifier
("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has
now been assigned, so update the naming across every module:
- rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps`
(commonMain + commonTest) and every import referencing it.
- KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli.
- wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:`
(Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests).
- KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1".
- plan doc references `NIP-2421` -> `NIP-B1`.
Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms
draft (also placeholder "NIP-XX") untouched. quartz main + test compile
and spotless is clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.
Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.
Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.
The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.
Three actions, and Ignore is deliberately not Leave:
- Show -> writes the group into kind-10009 (Account.follow), after which the
ordinary joined-group path owns it and it syncs to other devices.
No kind-9021: the relay already has you in the roster, so this
records only your decision to surface it.
- Ignore -> local, reversible display choice. You stay in the roster and can
still open and post.
- Leave -> kind-9022 LeaveRequestEvent, the one that actually removes you.
A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.
The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.
Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit of the compressed-proof work found one real defect and one coverage gap.
Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount`
(or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called
the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the
`amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch)
that surfaced as an uncaught exception and abnormal exit instead of a clean
`Invalid`; the Android ingest path was already contained by LocalCache's broad
catch. Make the nullable stream accessor `TlvStream.tu64` return null for an
over-8-byte value so every amount read (invoice_amount, invreq_amount, offer
amount) degrades to a clean rejection. Regression-tested at the codec level.
Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's
note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and
thread the vector's note through the writer round-trip so 1005 is asserted.
The forged-proof, DoS, and reconstruction-accounting paths were reviewed and
found sound (the reconstructed root is only ever a BIP-340 message; the NIP
offer-binding gate still pins invoice_node_id to the offer's issuer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata`
is always withheld and other invoice fields may be elided for privacy, with
`proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying
enough to rebuild the invoice signature's merkle root. The verifier previously
reported these as unsupported (cryptoVerified = false), so a zap paid through a
real wallet never counted locally.
Implement the lightning/bolts#1346 reader:
- Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed
LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field
markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest).
Add emitMissingHashes as the writer dual, unify both on one tree builder.
- Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the
record's type bytes (not the full encoded TLV), and the payer proof signs
under fieldname `proof_signature` (not `signature`).
- Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field
range predicate; the verifier reconstructs on every proof (type 0 is always
the implied first omitted leaf) and drops the Unsupported result.
- Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop),
and rewire Bolt12ProofFixture onto it.
Validated byte-for-byte against the draft's own conformance suite
(bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are
rejected, and the writer reproduces every vector's compression fields exactly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Cross-backend defects in the kotlinx (native/iOS) NWC-321 parsers, found by the
audit and empirically reproduced. Jackson (JVM/Android) writes null-valued keys,
so a native peer parsing that output hit two bugs:
- parsePay/parseReceive crashed on `metadata: null` — `?.jsonObject` doesn't
short-circuit on JsonNull (a non-null element). Use `as? JsonObject`.
- parsePaySuccess/parseReceiveSuccess (and parsePay's string fields) read an
explicit JSON null as the literal string "null" via `?.jsonPrimitive?.content`.
Use `contentOrNull`.
Only affects the kotlinx path (Android/JVM use Jackson), but violates the KMP
mapper-interchangeability contract. Adds Nip47KotlinSerializationNullTest hitting
the kotlinx serializers directly so it's covered regardless of platform actual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Widen the fix beyond the plain stream chat message: every Buzz kind the chat
feed renders as a row — stream messages (40002), system lines (40099), diffs
(40008), and the agent-job (43001-43006) and huddle (48100-48103) lifecycle
events — is `h`-scoped and attaches to the same RelayGroupChannel as a kind-9
via consumeBuzzTimelineEvent. So all of them must count as a room's newest
message and toward its unread dot; leaving them out left the Messages-list
preview stale whenever the newest thing in a channel was one of these.
- Add `Event.isBuzzChatTimelineContent()` (quartz buzz) enumerating exactly the
kinds consumeBuzzTimelineEvent attaches / the chat renders — excluding edits
(folded into their target), canvas, and forum kinds. `isGroupChatContent()`
now ORs it in, so the initial scan, the live additive update, and the unread
dot all agree.
- These kinds carry JSON/diff in `content`, so previewing raw `content` would
dump `{"ephemeral_channel_id":…}`. Extract the in-chat labels into pure
helpers (`buzzSystemMessageText`, `buzzActivityLabel`,
`buzzTimelinePreviewSummary`) so the Messages-list preview shows the same
human-readable summary the chat row shows ("🔊 huddle started", "topic
changed", "⚙ job progress: …", "📄 <file>") instead of raw payload.
- Extend the regression test to cover stream/system/huddle counting and edit/
reaction not counting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
A Buzz stream-channel chat message is a kind-40002 StreamMessageV2Event, not a
NIP-C7 kind-9 ChatEvent. It is `h`-scoped and attaches to the same
RelayGroupChannel as a kind-9, but `isGroupChatContent()` only recognized
ChatEvent/PollEvent/ThreadEvent/CommentEvent, so the Messages-list "newest
message" logic (initial scan `newestChatNote` + the live additive
`filterRelevantRelayGroupMessages`) and the unread-dot check all skipped it.
Result: a Buzz channel's row never reflected its real chat and never updated
live as new messages arrived.
Include StreamMessageV2Event in `isGroupChatContent()` (the Buzz dialect of
NIP-29), fixing the Messages preview and unread dot in one place. Adds a
regression test asserting kind-40002 counts as group chat content while a
group-scoped reaction does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
Integrates BOLT12 zap-sending into the zap pipeline (nwc#2 `pay` returns the
payer proof). Account.sendBolt12Zap signs a 9737 intent, pays the offer over
NWC with the intent-bound payer_note, and — only if the returned proof passes
Bolt12ZapValidator — self-consumes and publishes the 9736; otherwise reports
"paid, no receipt" (fail-safe against a wallet that misroutes the note).
ZapPaymentHandler.zap now resolves each recipient's kind:10058 offer and
partitions recipients into a BOLT12 lane (offer present + NWC wallet configured)
and the existing lightning lane, sharing split weight across both so mixed
splits stay proportional. Anonymous/public follows the account zap type. Adds
Bolt12ZapBuilderTest proving the send-side assembly round-trips to a
validator-accepted, crypto-verified zap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Two issues found in an audit of the NWC changes:
1. (correctness, high) NIP-44 negotiation never triggered against real
wallets. The info event carries schemes in a single space-separated
tag value (["encryption", "nip44_v2 nip04"]), but encryptionSchemes()
returned tag.drop(1) = ["nip44_v2 nip04"], so the nip44_v2 membership
check never matched and every request fell back to NIP-04. Split each
tag value on whitespace in encryptionSchemes()/notificationTypes() so
both the spec's space-separated form and a multi-element tag normalize
to individual tokens. Adds NwcInfoEvent tests for the wire format.
2. (performance) NwcPaymentNotificationWatcher subscribed via
subscribeAsFlow, which accumulates every event into an ever-growing
list and re-emits the whole list per event — wrong for a lifetime
subscription (unbounded retention + O(n) rescan per event). Replace
with a raw client.subscribe listener (callbackFlow) that emits each
event once; reconnect re-delivery is still de-duped by the seen set.
Also documents why the watcher keys the account flow on pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main
note cache and found via a full-cache scan, with their blockchain verdicts held
in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the
lifecycle of the note being timestamped, so a deleted/pruned note leaked its
attestations, and consume(OtsEvent) invalidated the attestation's own
(observer-less) flow instead of the target's — so a live-arriving proof never
pinged the target's UI.
Mirror the recent edits→Note migration:
- Note gains a `timestamps` child collection (like `edits`/`reactions`), wired
into clearChildLinks/removeNote, so an attestation survives exactly as long as
its target and is collected when the target is pruned or deleted.
- consume(OtsEvent) anchors the proof on its target via the `e` tag and
invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically.
- Each attestation memoizes its own verdict in `Note.otsVerification`, so the
result shares the note's lifecycle. This replaces VerificationStateCache
(deleted) and the full-cache scan: the OTS pill now folds `note.timestamps`
via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Adds the generalized `pay` (BIP321 payment instruction, incl. BOLT12 `lno=`)
and `receive` NIP-47 methods, plus the UNSUPPORTED_PAYMENT_INSTRUCTION /
UNSUPPORTED_NETWORK error codes. The `pay` result carries `payer_proof`
(lnp1…) — the proof a kind:9736 BOLT12 zap needs. Wired through both
serialization backends (kotlinx + Jackson) with round-trip tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Every chat composer in Amethyst already had a picture/media attach button
(SelectFromGallery) except the minichat "thread" screen — the kind-1111
reply composer opened from the "N replies" chip — which was text-only. This
brings it to parity with every other chat.
- quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying
encrypted image imeta(s), combining reply()'s NIP-22 pointers with
imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test).
- commons: ConcordActions.buildChannelImageReply().
- Account.sendMinichatReply() now accepts imetas and routes per backend:
Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append
the URL to the content and carry a plaintext imeta on the comment; Buzz
appends the URL to the stream message content.
- Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so
the minichat and Concord composers build imeta the same way.
- MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload
dialog, encrypting only when the backend is end-to-end (Concord).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
Add Nip47DeepLink for the NWC-07 same-device pairing convention:
build/parse the `nostrnwc://connect` request (client -> wallet) and
the callback URI that returns the `nostr+walletconnect://` pairing code
(wallet -> client). All params are URI-encoded per the spec.
Also thread `useNip44` through LnZapPaymentRequestEvent.create so
pay_invoice requests can opt into NIP-44, matching createRequest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
The NIP was updated to publish a recipient's BOLT12 offer(s) in a dedicated
replaceable event (kind 10058, `bolt12_offer`) instead of a kind:0 field — this
is what ties an offer to a Nostr identity (the author's signature) and gives
BOLT12 zaps the send-side addressability that lightning zaps get from lud16.
- Bolt12OfferListEvent (kind 10058): a BaseReplaceableEvent holding one or more
`["offer","lno1..."]` tags (reusing OfferTag), with offers()/firstOffer()
accessors and create/updateOffers factories (mirrors ChatMessageRelayListEvent).
- Registered in EventFactory + a KindNames display entry.
- Tests: offers round-trip + factory typing, malformed-offer-tag filtering, and
updateOffers replacing the offer set while keeping other tags.
Discovery: a payer fetches the recipient's latest 10058 and picks an offer. The
app-side caching, subscription, editor, and payment intent follow in later commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
Verified against the Concord v2 reference client (Soapbox Armada,
src/concord-v2/lib/kinds.ts): a chat message edit is a dedicated KIND_EDIT =
3302 rumor, NOT a kind-1010 modification. It names the target with a single
`e` tag (no `k` — Armada adds `k` only to deletes), carries the replacement
text, and rides the channel/epoch binding. The fold applies only edits
authored by the original message's author (latest by CORD-02 §4 send time
`created_at*1000 + ms`), non-destructively.
The prior commit used kind-1010 TextNoteModificationEvent, which would not
interop with Armada. Corrected:
- New ConcordChatEditEvent (kind 3302) in quartz, registered in EventFactory;
ChannelChat.edit now builds it. orderingMs() honors the `ms` remainder tag.
- LocalCache.consume(ConcordChatEditEvent) wires the edit to its target note
and invalidates the edits flow; findLatestConcordEditForNote returns the
author-matching kind-3302 edits ordered by send time (latest wins).
- observeConcordEdit reads that finder instead of the kind-1010 machinery.
Send/compose/action-sheet plumbing is unchanged (it routes through
ChannelChat.edit). Note: Amethyst does not yet emit the `ms` remainder tag on
Concord rumors (a pre-existing, message-wide gap), so its own edits order at
one-second granularity; received Armada edits are ordered at full precision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Brings feed-post-style edits to Concord chat. A Concord message rumor is a
standard kind-9 event, so an edit reuses Amethyst's native kind-1010
TextNoteModificationEvent: a channel/epoch-bound rumor that e-tags the target
and carries the replacement text, wrapped and published on the same channel
plane as any other Chat Plane rumor. Receivers overlay the newest edit through
the existing shared machinery (LocalCache.findLatestModificationForNote), which
only applies edits authored by the original message's author, so a member can't
rewrite someone else's message. Clients that don't understand kind-1010 keep
showing the original text, so it degrades gracefully.
- ChannelChat.edit + ConcordActions.buildChannelEdit build/wrap the edit rumor.
- Account.editConcordChannelMessage gates to my own kind-9 messages and
publishes the wrap (local echo + relays), mirroring reactToConcordMessage so
the edit never leaks the private rumor id onto public relays.
- The chat bubble overlays the newest edit (RenderConcordEditedNote) with an
"(edited)" marker, matching the Buzz kind-40003 edit presentation.
- The long-press action sheet offers Edit on my own Concord messages; the
composer enters edit mode with an editing banner and publishes the edit on
send. The former onWantsToEditBuzz callback is generalized to
onWantsToEditChatMessage, shared by the Buzz and Concord surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
A public channel carries no delivered key, so a writer lists it under an
entry's `channels` with only {id, epoch, name}. `WireChannel.key` was a
required field, so kotlinx.serialization threw MissingFieldException — and
`decodeDocument`'s catch-all turned that one bad channel into an empty list,
silently dropping EVERY joined community from the kind-13302 list (communities
"won't load" at all).
- Default `WireChannel.key = ""` so a keyless (public) channel no longer throws.
- Decode entries one at a time and keep any we still can't parse verbatim in
`ConcordListResidue.unparsedEntries`, re-emitted on write — so one malformed
entry can never wipe the whole list, and a read-modify-write never deletes a
membership this version can't model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From an adversarial audit of the BOLT12-zap feature.
Security / correctness:
- Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had
no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is
payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified"
proof having paid nothing. Now cryptoVerified requires the invoice to be provably the
offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are
accepted but flagged unverified, not verified. Definite contradictions still hard-reject.
- Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified
(compressed / unbindable) proof carries a self-chosen preimage+amount with no settled-
payment guarantee, so counting it let anyone inflate a note's total for free. Unverified
entries stay stored + shown (dimmed), never summed.
- Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same
payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a
bigger amount tag). Keeps the stronger verification flag.
- Precision: divide millisats in BigDecimal, so fractional sats survive and match the
millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0).
Codec hardening (quartz):
- TLV length now range-checked (was a signed compare that let a high-bit BigSize length
slip through and get truncated by toInt()).
- BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative).
- bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char).
UI:
- ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain),
so a BOLT12-only zap correctly shows the zapped state.
- The reactions gallery renders the blank/unknown author for anonymous zaps, matching the
standalone card (was showing the throwaway ephemeral key's avatar).
Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length
rejection; model lower-amount dedup (both orderings), verified-only counting, and
fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8.
The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the
plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof
only proves payment to the *embedded* offer, not to the p-tagged recipient.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Speed:
- Bolt12ZapValidator reorders checks cheap-to-expensive: all structural, cross-event,
and payer-proof binding checks run first; the schnorr signature + proof crypto
verifications run only once an event has passed them. A malformed or mismatched
event now rejects with zero schnorr ops. Cannot change accept/reject, only which
reason a doubly-invalid event reports.
- validate() gains verifyEventSignature (default true); LocalCache.consume passes
false since the relay pipeline already verified the outer event — removing a
redundant schnorr on every ingested zap (3 verifies instead of 4).
- Bolt12Merkle precomputes SHA256("LnLeaf")/SHA256("LnBranch") once and hashes the
per-call "LnNonce"||first-tlv tag once per rootHash instead of once per record.
Tests:
- New validator rejections: preimage-mismatch, invalid-invoice-signature,
payer-tag-mismatch, proof-does-not-match-offer, plus the verifyEventSignature
skip-flag both ways (fixture gains corruptPaymentHash / breakInvoiceSignature).
- New commons NoteBolt12ZapTest: millisat→sat total, dedup by payment_hash,
verified-not-downgraded-by-unverified, remove-by-source, clearChildLinks, and
combined totals.
Docs: quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md captures the two
upstream-gated follow-ups (vector-driven interop test + compressed-proof merkle
reconstruction) for when lightning/bolts#1346 merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a
NIP-57 lightning zap is counted or shown. Sending is intentionally left for
later. Modeled on the lightning-zap scheme (synchronous, the proof carries the
amount, counted the moment it validates) rather than the onchain scheme (async
chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is
a self-contained synchronous check, so no resolver/backend is needed.
Model (commons):
- Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash
(the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into
updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts;
wired into clearChildLinks, moveAllReferencesTo, removeNote,
hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family.
Ingestion (LocalCache):
- consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then
addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and
live-activity channel routing branches; dispatch case.
Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND
(notifications, replies/reactions to notes & addresses, profile received-zaps,
live-activity goal + messages, nest room + collectors, notification dispatcher,
shared NotificationKinds, app-functions).
Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState
(both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author
resolution, muted-thread + own-event gates), NotificationKinds own-event exception,
ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators
(RoomZapsState, LiveStreamTopZappers, NestViewModel).
UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled
BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the
reactions row (payer avatars + amounts, unverified/compressed proofs dimmed);
reaction-row counter gate; KindNames / KindDisplayName entries.
Note: validated BOLT12 zaps are counted immediately; a compressed proof whose
signatures aren't yet verifiable (pending lightning/bolts#1346 merkle
reconstruction) is stored with cryptoVerified=false and dimmed in the gallery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Kotlin/Native marks the stdlib `assert()` with `@ExperimentalNativeApi`, so
the iOS test target (`compileTestKotlinIosSimulatorArm64`) failed to compile
without an opt-in — while JVM/Android were fine. Swap it for `assertTrue`
from `kotlin.test`, which needs no opt-in on any target and matches the
assertions already used in this file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP
(nostr-protocol/nips#2421): public, self-verifying zap events that prove a
BOLT12 payment without an LNURL server or recipient-operated receipt publisher.
Events (nip-88 style templates/tags/builders):
- Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737)
- shared tags: amount (msats), offer, proof, P (payer), zap_id, description
- registered both kinds in EventFactory
BOLT12 decoding (new, no existing KMP library):
- Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum,
no-length-limit bech32 for lno1 offers and lnp1 payer proofs
- Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers
- Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346)
- Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest
Validation:
- Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match,
payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>,
invoice_amount == amount) and returns a typed result with the payment-hash
dedup key
- Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof
BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported
as unverified pending the (still-draft) lightning/bolts#1346 test vectors
- Bolt12ZapBuilder assembles+signs the intent and the final zap
Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag
structure + factory typing, and validator accept/reject paths (self-signed
BOLT12 fixtures exercise the full merkle + schnorr path).
Note: this is the receive/verify + assembly layer only. Origination is blocked
on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing
lnp proofs; no Amethyst payment rail returns one today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
- docs(dm): note the profile Reports tab also reads reportsNamingUser
- refactor(dm): push report-tag typing to quartz and simplify the warning stack
- fix(dm): narrow report indexing and address final review findings
- perf(dm): resolve a 1:1 chat row's counterpart once per row
Reworks the Buzz relay's Community view to mirror Buzz's own sidebar
ordering instead of stacking the DM + Console entries on top of the
channels:
- Channels and Forums are now separate sections, split by the relay-signed
39000 `channel_type` (stream vs forum); DM-typed channels are excluded
from both (they belong to the DM section). Adds isBuzzForum() and the
forum/stream type constants alongside the existing DM reader.
- Direct Messages moves below the channels and renders inline: the most
recent conversations (avatar + name + last-activity time), a New-message
action in the section header, and a "See all N" row into the full inbox
when there are more than fit.
- Agent Console drops to a single footer card at the very bottom.
- Section headers get a consistent modern style (primary-colored labels
with optional trailing actions), replacing the old top-stacked action
cards. BuzzImportRow gains an onOpen tap target so a channel row opens
the chat while keeping its Add-to-list affordance.
Vanilla NIP-29 relays are unchanged (flat channel directory, no
forums/DMs/console).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Kind 20001 is claimed by both BitChat's GeohashPresenceEvent and Buzz's
PresenceUpdateEvent. EventFactory's flat when(kind) could only route one, so
Buzz presence never materialized (parsed as a geohash event) in either
direction — this was the deferred "EventFactory collision".
Disambiguate inside the shared 20001 branch by BitChat's required `g` (geohash)
tag: present -> GeohashPresenceEvent, absent -> PresenceUpdateEvent. Verified
against the Buzz Rust ground truth (buzz-sdk build_presence_update + the relay's
synthesize_presence read form): Buzz presence carries the status in content plus
a `status` tag (client) or a `p` tag (relay-synthesized), never a `g` tag, and
BitChat presence always carries `g` with empty content. Both inbound parse
(EventDeserializer) and outbound signing (EventAssembler) route through this
factory, so one guard fixes both.
Make it usable, not just parseable: add BuzzPresenceState (process-wide latest
online/away/offline per subject, mirroring BuzzTypingState), a
PresenceUpdateEvent.subjectPubKey() accessor (the `p` tag or the author), and a
LocalCache branch that records presence and drops the ephemeral without storing
it. Tests cover both Buzz wire shapes, the BitChat guard, and latest-wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8