Add a CONTINENT(1) level to GeohashChannelLevel so the coarsest selectable
geohash channel is a single character (~5000 km, one of 32 cells for the
globe), previously floored at REGION(2). Every precision picker iterates
GeohashChannelLevel.ordered, so the map picker, Teleport, "Near me" list and
New-geohash-chat all pick it up automatically; GeohashChatsScreen now labels a
1-char cell "Continent" instead of showing no level.
Adds the "Continent" label and "~5000 km" chip subtitle, and extends the
GeohashChannelLevel test coverage. REGION..BUILDING (2-8 chars) still mirror
the Bitchat channel levels; CONTINENT is an Amethyst extension beyond them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtvo2iNSnG3M21QwbFPznU
Both nostr proposals merged just now (82e72369, bdb4b03e) referenced types
by fully-qualified name inside function bodies, which CLAUDE.md's Kotlin
style rule forbids. Merged them as authored rather than rewriting someone
else's patch mid-merge; this is the follow-up.
- NamecoinNameResolver: import kotlinx.coroutines.CancellationException.
Both catch sites were inline-qualified (one added by the proposal, one
already there), and the sibling resolvers in this same package
(Nip05Client, UserHexResolver) already import it.
- desktop Main.kt: import the five notification symbols in the block the
proposal touched — the two Preferences* factories and the three
Local*Notification* CompositionLocals. Each occurs exactly once, so no
fully-qualified stragglers are left behind for those names.
Deliberately scoped to that block: Main.kt has ~130 other inline
fully-qualified names, and sweeping them belongs in its own change, not
tacked onto a style follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges nostr proposal bdb4b03e into main:
- Catch NamecoinLookupException in performLookup and return null, restoring
resolve()'s documented "null on any failure" contract. nameShowWithFallback
throws NameNotFound / NameExpired / ServersUnreachable, which escaped
resolve() and reached Nip05State.checkAndUpdate as a hard error.
- CancellationException is rethrown first so coroutine cancellation is not
swallowed.
- resolveDetailed() is unaffected: it goes through performLookupDetailed(),
which still distinguishes each failure reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Posts were publishing a flood of bogus `r` tags — the v1.13.0 release note
shipped 11 junk references such as `https://.deb/`, `https://window.nostr/`,
`https://kind:30166/` and `https://crowdin.pretended462/`.
The reference extractor `findURLs` fed the raw, scheme-less UrlDetector output
straight into `r` tags. That detector is deliberately eager: to let the
rich-text renderer linkify a bare `example.com`, it also reports every
`word.word`, `word/word` or `word:port` token with no real-TLD whitelist.
Prose is full of those (`.deb`, `.rpm`, `[database].backend`,
`nostr-wallet-connect/nwc`, `~2.5x`, `@mentions`), so each one became a
reference on the published note. The rich-text side already guards against
this via UrlParser (TLD validation + scheme separation); the tag path never
got the same guard.
Require an explicit http/https scheme and a valid TLD before a detected URL
becomes a reference. Rendering is untouched (separate parser), so bare domains
still show as links — they just no longer pollute the tags.
Adds regression coverage over the exact fragments from the v1.13.0 note plus
checks that real, explicitly-schemed links are still extracted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8KXx8UQ6mgyxjBSWW6sQV
"Remove from Messages" was hardcoded on every surface, so it never showed
an "Add to Messages" counterpart for a channel that was already off the
list, and the Buzz workspace rows read a session-local snapshot of the
kind-10009 list that was seeded once and only ever grew — a channel taken
off Messages still rendered as a disabled "Added", leaving no way back.
Removal itself always worked (verified on-device: the kind-10009
republished and the row left Messages); what was missing was any read of
that list on the way back.
- RelayGroupListState: expose liveRelayGroupIds, the joined groups as
normalized GroupIds, so the UI can ask whether a channel is on Messages
without string-matching a raw relay url another client may not have
normalized the way we do.
- RelayGroupTopBar / BuzzImportRow: one Add/Remove toggle driven by that
flow. Remove no longer pops back — you stay a member reading the
channel, and staying is what makes the entry flip so the action is
visibly undoable. Leave still pops.
- BuzzRelayImportViewModel: track "added" against the live list instead of
a one-shot seed, and add remove(); add() now also clears the dismissal
so a relay's kind-44100 re-announcement isn't filtered back out.
- AccountViewModel: addRelayGroupToMessages() as the counterpart to
removeRelayGroupFromMessages(); acceptChannelInvite() delegates to it.
Buzz DMs had the same one-way shape for a different reason: hiding is a
relay-side per-viewer flag (kind-41012 -> the kind-30622 snapshot), and
rebuildRows dropped hidden DMs on the floor, so a hidden conversation was
gone for good. There is no unhide command — re-opening is the unhide, a
kind-41010 with the same participants resolving to the same canonical
channel. Hidden DMs are now projected into their own list behind a
collapsible "Hidden (N)" header, faded but still openable, each offering
"Add to Messages". Also added to the community view's inline DM rows,
which had no menu at all and are where DMs actually live — the full inbox
sits behind a "see all" row that only appears above six DMs, so in a small
workspace the hidden section would have been unreachable.
Both list screens now leave bottom room for the FAB, which the Scaffold's
padding deliberately doesn't account for; the last row's overflow menu was
sitting underneath it.
Adds SimpleGroupListEventTest covering the removal path, including that a
renamed channel still matches (removal keys on group id + relay only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind:9802 highlight with no `context` tag falls back to reconstructing
the surrounding passage from the W3C `textquoteselector` prefix/suffix.
Those fragments are scraped from the source web page, so they carry the
page's block-boundary whitespace (runs of newlines/spaces between DOM
nodes). Glued in verbatim as `prefix + content + suffix`, they render as a
stack of blank lines above the marked quote.
Collapse each whitespace run in the prefix/suffix to a single space (and
trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The
highlight's own `content` is left verbatim so its offsets inside the
reconstructed context stay exact for the in-context marker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
- Rename LocalCache Dao overrides to match supertype parameter names
(getOrCreateUser: pubkey->hex, getOrCreateNote: idHex->hex,
getOrCreateAddressableNote: key->address) so named-argument calls stay safe.
- Replace deprecated MenuAnchorType with ExposedDropdownMenuAnchorType in the
Buzz dropdown composables.
- Remove the buzzChannelType() extension in BuzzChannelMetadata, now shadowed by
the equivalent (stricter) member on GroupMetadataEvent, and drop its unused
imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VBP6HGj9M2kzA4WKAivjC
Adds NegentropyMultiRelayLiveTest (gated NEG_MULTI=1): runs negentropySyncOrFetch
against 30 reachable public relays and asserts none hangs. Live run: 0 hangs,
0 errors — 10 reconciled via native negentropy, 20 fell over to paging, across
9+ relay softwares (strfry, ditto, purplepag.es, nostr.wine, nostr-rs-relay,
NFDB, rockstr, wot-relay, nostrcheck).
Confirms both fallback paths: the NOTICE fast-path (~1-4s) for relays whose
refusal names negentropy / unknown-envelope, and the idle-watchdog backstop
(~20s) for the rest (e.g. damus silently ignores NEG-OPEN, snort answers
"Unknown message type: NEG-OPEN") — now reliable because NOTICE/CLOSED no longer
reset the watchdog. Matrix recorded in the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
Audit follow-ups on the NIP-77 client, found while reviewing the notice-rejection fix:
- isOverflow was too broad. A bare "too many"/"too large" match meant a
NON-shrinking error ("too many requests", "too many concurrent subscriptions")
was read as a set-too-large overflow. Such an error doesn't shrink with the
window, so every created_at split re-triggers it and reconcileWindows walks
toward 1-second leaves, queueing up to ~2^31 Filters (OOM + relay hammering).
Tightened to result-set-qualified phrases (too many records / too many query
results / result set too large / max_sync_events); rate/quota errors now fail
over to paging.
- Added a MAX_WINDOWS (100k) backstop in reconcileWindows: a wording-independent
guard that bails to paging if a split ever fails to converge, so no novel
overflow-looking-but-non-shrinking error can storm.
- Window split dropped future-dated events. On overflow the upper child was
copy(until = hi) with hi = until ?: now(), so once any split happened, events
with created_at > now() (clock skew) were excluded though the un-split path
included them. The upper child now keeps the window's original until (may be
null = unbounded); the split math still uses now() so it converges.
- Hardened the NOTICE rejection matcher. isNegentropyRejectionNotice matched
bare "envelope"/"NEG-OPEN"/"NEG-MSG"; since a NOTICE has no subId and every
connection listener sees it, an unrelated notice on a shared connection could
abort a healthy reconcile mid-handshake. Narrowed to "negentropy"/"unknown
envelope"; the now-un-defeated idle watchdog is the wording-independent backstop.
- NegentropyStoreSync up-direction memory: haveBatches was an UNLIMITED channel
drained by a single network-bound uploader, so a first push of a large store
buffered O(local-set) ids. Bounded it like needBatches to back-pressure the
reconcile.
- Docs: flagged negentropySyncOrFetch's O(delivered) cross-phase dedup memory
(steer bulk mirrors to negentropySync/negentropyReconcile); corrected the
stale onEvent "reader thread" note (it runs on the delivery consumer).
Tests: NegentropyErrorClassificationTest pins both wording classifiers;
NegentropyRejectionFallbackTest adds a rate-limit NEG-ERR case asserting paging
after exactly one NEG-OPEN per phase (no split storm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
Relays that advertise NIP-77 in NIP-11 but refuse it at runtime answer a
NEG-OPEN with a connection-level NOTICE (which carries no subId) instead of a
subId-addressed NEG-ERR:
- strfry with negentropy disabled: "ERROR: bad msg: negentropy disabled"
- purplepag.es (no NEG envelope): "failed to parse envelope: unknown envelope label"
reconcileStreaming only routed NegMsg/NegErr for its exact subId into the driver
channel, so the NOTICE was dropped and the driver blocked in receiveWithinIdle
with no terminating frame. Worse, the connection-level idle watchdog was bumped
by every relay message, so unrelated refusal chatter (a rejected keep-alive REQ
being re-CLOSED on re-sync) reset it forever and it never fired. Net effect:
negentropySync/negentropyReconcile hung against relay.primal.net and
purplepag.es, and negentropySyncOrFetch never reached its paging fallback.
Fix, in reconcileStreaming's connection listener:
- route a CLOSED for our NEG subId into the driver as a terminal failure;
- treat a negentropy-refusal NOTICE as terminal, bound to this session by
phase (before the first valid NEG frame) + wording (isNegentropyRejectionNotice),
so an unrelated NOTICE on a healthy relay mid-reconcile cannot abort a
progressing sync;
- stop bumping the idle clock on NOTICE/CLOSED so refusal chatter can no longer
keep a dead sync alive; it now advances only on real progress (this session's
NEG frames and the download REQs' events/EOSEs).
The refusal now surfaces as NegentropySyncException(UNAVAILABLE); negentropySync
throws promptly and negentropySyncOrFetch pages the same filter (both relays
answer ordinary REQs fine). Verified live: primal 0-event hang -> pages 11.7k
events in 6.4s; purplepag.es 0-event hang -> pages continuously.
Tests:
- NegentropyRejectionFallbackTest: offline, deterministic; a scripted fake
relay answers NEG-OPEN with each observed NOTICE and asserts the sync throws
UNAVAILABLE fast and negentropySyncOrFetch sets pagedFallback.
- NegentropyStallRepro: gated live repro against the three real relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
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>
Finished/failed jobs now land in the Notifications tab, addressed to the requester.
1. NotificationFeedFilter: an early-return branch accepts a JobResultEvent (43004) or
JobErrorEvent (43006) when it p-tags me (the requester) and isn't my own event —
mirroring the existing Buzz-DM branch, since I don't "follow" the workspace bot and
the job kinds aren't in the generic relevance path. It maps to the generic NoteCard,
so it renders the result (PR URL) / error text.
2. JobErrorEvent now carries the requester as a `p` tag (new requester() accessor + a
`requester` param on build, mirroring JobResultEvent); the scheduler passes
job.requester on both error paths. Previously a failed job wasn't addressed to anyone,
so a failure could never notify. Tests updated.
Relay sourcing (verified): a job outcome reaches LocalCache via the always-on `#h`
joined-group chat tail on the workspace relay (RELAY_GROUP_ALL_TIMELINE_KINDS includes
43001-43006), so for a shared channel the team has joined, results are pulled continuously
and now notify. A channel you haven't joined (or a job event with no `#h`) would still need
a dedicated `#p`=me subscription (mirroring BuzzDmDiscovery) — not added, since the
support-channel model always has members joined.
App compiles (fdroidDebug); quartz + commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized
Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO.
- quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42
handshake, then layers Buzz's two authorization rules: (1) only members (the team)
may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted
membership for its connection when its AUTH event carries an owner-signed `auth` tag
whose owner is a member and whose signature authorizes that agent. An unauthenticated
read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries.
Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a
policy can't emit events; the job channel doesn't need them). 8 unit tests.
- cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine.
Members = admins + --members.
Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true),
an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite
(member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered).
Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust
stack) and when you'd need each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
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