Commit Graph
2879 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 723c90c0d7 Merge pull request #3787 from vitorpamplona/claude/geohash-single-level-precision-gise2y
Add CONTINENT geohash level for whole-globe location channels
2026-07-28 19:47:52 -04:00
Claude 5f3848c47d feat: allow single-char (continent) geohash precision in location channels
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
2026-07-28 23:20:54 +00:00
Vitor PamplonaandClaude Opus 5 9e69198623 style: import the symbols the two merged proposals referenced inline
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>
2026-07-28 19:15:29 -04:00
Vitor PamplonaandClaude Opus 5 4e42528591 Merge PR: fix(namecoin): don't leak lookup exceptions through resolve()
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>
2026-07-28 19:06:54 -04:00
Vitor PamplonaandGitHub ae6f56283b Merge pull request #3782 from vitorpamplona/feat/buzz-messages-toggle
fix(buzz): make the Messages toggle read the list it claims to change
2026-07-28 17:31:21 -04:00
Vitor PamplonaandGitHub c9e79e31f8 Merge pull request #3783 from vitorpamplona/claude/highlight-excess-spaces-0678he
Trim highlight context to bounded window, collapse whitespace
2026-07-28 17:25:17 -04:00
Claude 0851768a8b fix: only tag explicit http(s) URLs as r references
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
2026-07-28 20:51:35 +00:00
Vitor PamplonaandClaude Opus 5 618ef66e75 fix(buzz): make the Messages toggle read the list it claims to change
"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>
2026-07-28 16:50:49 -04:00
Claude 4d24e2b09c fix: collapse scraped whitespace when reconstructing highlight context
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
2026-07-28 20:47:53 +00:00
Claude 3ffa259cff fix: resolve LocalCache override, dropdown deprecation, and shadowed extension warnings
- 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
2026-07-28 15:37:01 +00:00
Vitor PamplonaandGitHub 4179a6312a Merge pull request #3747 from vitorpamplona/claude/quartz-negentropy-stalls-6g9ukr
Fix NIP-77 negentropy stall against relays that refuse via NOTICE
2026-07-27 15:28:33 -04:00
Claude 9de8eae5fd test: broad live NIP-77 validation across 30 public relays
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
2026-07-27 19:23:46 +00:00
Claude 30b9e589d4 fix: harden negentropy window-splitting and error classification
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
2026-07-27 19:13:14 +00:00
Claude 264866bcbb fix: detect NIP-77 negentropy refusal sent as a connection-level NOTICE
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
2026-07-27 18:12:01 +00:00
Claude b30d39e627 Merge remote-tracking branch 'origin/main' into claude/buzz-amethyst-support-nfxog7
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-27 17:58:02 +00:00
Vitor PamplonaandGitHub 855435fcc1 Merge pull request #3742 from vitorpamplona/fix/buzz-create-channel
fix(buzz): make channel create, add-member and role changes actually work
2026-07-27 13:16:32 -04:00
Claude 4c42648c35 feat(quartz): index the public petname on contact cards (30382)
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
2026-07-27 15:59:54 +00:00
Claude 052da60f9c feat(quartz): index missing SearchableEvent kinds and tag-borne text
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
2026-07-27 15:33:11 +00:00
Vitor PamplonaandClaude Opus 5 ceb9f9c3db feat(buzz): dock the member search at the bottom, and fix promotions on Buzz
**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>
2026-07-27 11:14:29 -04:00
Vitor PamplonaandClaude Opus 5 fa6b272213 fix(buzz): make "create" on a Buzz relay actually create a channel
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>
2026-07-27 09:52:30 -04:00
Claude 03eeb02378 test(quartz): clean up compiler warnings across the test suites
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
2026-07-27 04:10:53 +00:00
Vitor Pamplona ba1baa359f Merge branch 'main' into test/buzz-agent-support 2026-07-26 23:02:48 -04:00
Vitor PamplonaandGitHub bce35074fc Merge pull request #3734 from vitorpamplona/claude/cashu-token-claim-error-6s6dym
Support P2PK witness signing for pasted Cashu tokens
2026-07-26 22:28:23 -04:00
Claude 3c440a6d27 fix(cashu): case-insensitive P2PK lock match + all-or-nothing redeem
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
2026-07-27 01:26:36 +00:00
Claude ee5efba88d perf(cashu): avoid needless wallet-key decrypt and secret parse on redeem
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
2026-07-27 01:21:29 +00:00
Claude 63ff055b53 feat(cashu): claim P2PK-locked tokens and clear errors when we can't
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
2026-07-26 23:39:49 +00:00
Vitor PamplonaandClaude Opus 5 6a5d55aa9b docs(quartz): correct the Buzz kind-conflict table and document how to read Buzz's source
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>
2026-07-26 19:18:53 -04:00
Claude aaf7affedc Merge remote-tracking branch 'origin/main' into claude/buzz-amethyst-support-nfxog7
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
2026-07-26 22:41:48 +00:00
Vitor PamplonaandClaude Opus 5 8df98c34df feat(buzz): say who joined and what changed in the kind-40099 system lines
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>
2026-07-26 18:19:01 -04:00
Vitor PamplonaandClaude Opus 5 75a5e91681 fix(quartz): route kind-39005 by tag shape so Buzz thread summaries stop parsing as NIP-29 pin lists
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>
2026-07-26 17:52:12 -04:00
Claude 67223d612f refactor: rename BOLT12 zaps placeholder NIP to the assigned NIP-B1
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
2026-07-26 20:32:16 +00:00
Vitor PamplonaandClaude Opus 5 bb87d7755a feat(buzz): ask before showing channels somebody added you to
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>
2026-07-26 12:44:36 -04:00
Claude 2e92d787a8 feat(buzz): notify the requester when their agent job finishes or fails
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
2026-07-26 15:05:31 +00:00
Claude 0b19a89b0c feat(buzz): geode BuzzMembershipPolicy — self-host the agent channel
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
2026-07-26 14:12:51 +00:00
Vitor PamplonaandGitHub 90dc9a874e Merge pull request #3685 from vitorpamplona/claude/nip-2421-pr-review-6znvdd
Add NIP-XX BOLT12 zap support with validation and UI integration
2026-07-24 23:26:28 -04:00
Claude 9caf330879 fix(bolt12): don't throw on an oversized tu64; cover proof_note path
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
2026-07-25 03:00:51 +00:00
Claude 7535d791f3 feat(bolt12): verify compressed payer proofs via merkle reconstruction
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
2026-07-25 02:35:16 +00:00
Vitor PamplonaandGitHub 122321cd94 Merge pull request #3704 from vitorpamplona/claude/buzz-community-star-add-buttons-knx2ry
Support Buzz stream messages in group chat and improve chat previews
2026-07-24 21:46:58 -04:00
Claude f62ecb7ad2 fix(nwc): kotlinx pay/receive parsers mishandle explicit JSON null
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
2026-07-25 01:27:49 +00:00
Claude 107ff18366 fix(buzz): count all Buzz chat-timeline kinds as a room's last message
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
2026-07-25 00:54:22 +00:00
Claude affc547b0e fix(buzz): update Messages-list preview + unread dot for Buzz stream channels
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
2026-07-25 00:38:34 +00:00
Claude 79fee9492e Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
2026-07-24 23:50:40 +00:00
Vitor PamplonaandGitHub b4e8719ee2 Merge pull request #3701 from vitorpamplona/claude/nip47-spec-compliance-1c9ook
Add NWC payment notifications and deep-link pairing
2026-07-24 19:43:01 -04:00
Claude ec4928fc0d Merge remote-tracking branch 'origin/main' into claude/nip-2421-pr-review-6znvdd
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-24 23:15:54 +00:00
Claude 26272a6c3b feat(bolt12): send BOLT12 zaps over NWC, preferred when offered
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
2026-07-24 22:48:36 +00:00
Vitor PamplonaandGitHub eddffdbd3e Merge pull request #3702 from vitorpamplona/claude/ots-notes-lifecycle-ok3mz3
Anchor OTS attestations to target notes, replace verification cache
2026-07-24 18:44:29 -04:00
Claude 9789ff61f4 fix(nip47): parse space-separated encryption/notification tags; drop accumulating notification subscription
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
2026-07-24 22:42:12 +00:00
Claude a65d2b9342 refactor(ots): anchor OTS attestations on the Note lifecycle, drop the verification cache
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
2026-07-24 22:18:27 +00:00
Claude 6ce61f0dc8 Merge remote-tracking branch 'origin/main' into claude/chat-picture-sending-consistency-qit0pz
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
2026-07-24 22:05:36 +00:00
Claude 7d3f7f7ca4 feat(nwc): add pay/receive methods for BOLT12 (nostr-wallet-connect/nwc#2)
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
2026-07-24 21:38:32 +00:00