Commit Graph

1804 Commits

Author SHA1 Message Date
Vitor Pamplona ad624031bf Improves the FetchFirst to not fail in the first eose from the first relay. 2026-04-20 11:15:09 -04:00
Vitor Pamplona 036323fa53 This was added by the secp rewrite, but we moved them out to another repo, so now it can be removed. 2026-04-20 10:43:54 -04:00
Vitor Pamplona fab435509b Merge pull request #2454 from vitorpamplona/claude/add-thumbhash-support-ZfNS1
Add ThumbHash support for image placeholders
2026-04-20 08:51:36 -04:00
Claude b2f297b3bb feat: add ThumbHash support alongside BlurHash across events, uploads, and UI
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.

- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
  71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
  blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
  small placeholderModel(thumbhash, blurhash) helper centralising the
  "prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
  calculator decodes the bitmap / video thumbnail once and runs both
  encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
  both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
  Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
2026-04-20 00:12:28 +00:00
Vitor Pamplona f43b334d93 Merge branch 'main' into claude/badge-system-amethyst-J07UM 2026-04-19 17:41:02 -04:00
Vitor Pamplona bb1817c67c Merge pull request #2449 from vitorpamplona/claude/add-favorite-dvm-filter-DfveW
Add NIP-90 DVM favorite list support with content discovery
2026-04-19 15:04:42 -04:00
Claude 72598026a3 refactor(quartz): rename FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent
The list event only stores content-discovery feeds (kind-5300 DVMs),
not every DVM, so the old name was misleading. Rename the wire-level
type to reflect what's actually in it.

- Quartz: package nip51Lists.favoriteDvmList -> favoriteAlgoFeedsList;
  class FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent.
- DSL helpers renamed: favoriteDvm/favoriteDvms builder extensions ->
  favoriteAlgoFeed/favoriteAlgoFeeds; TagArray.favoriteDvmList/Set ->
  favoriteAlgoFeedsList/Set.
- create/add/remove parameter names dvm -> feed, publicDvms/privateDvms
  -> publicFeeds/privateFeeds; public/private accessors
  publicFavoriteDvms/privateFavoriteDvms -> publicFavoriteAlgoFeeds/
  privateFavoriteAlgoFeeds. ALT string updated.
- EventFactory + LocalCache dispatch branches + AccountSettings backup
  field type + FavoriteDvmListState + FavoriteDvmListDecryptionCache all
  import the new type. Internal amethyst-side classes
  (FavoriteDvmListState, FavoriteDvmListDecryptionCache), the top-nav
  filter classes (FavoriteDvm*, AllFavoriteDvms*) and the orchestrator
  keep their names — they still deal with content-discovery DVMs
  specifically, and the narrower rename here is scoped to the Nostr
  wire format the user was asking about.
- Quartz test renamed + rewired. Build + tests green on both modules.
2026-04-19 17:37:55 +00:00
Claude d942a624eb fix(badges): preserve NIP-58 a/e pair order, clickable rows, accepted-first sort
Three fixes to the Profile-badges flow:

1. TagArrayBuilder groups tags by name, so calling acceptedBadges() on
   the builder scrambled [a1, e1, a2, e2] into [a1, a2, e1, e2] when
   serializing. AcceptedBadge.parseAll expects adjacent (a, e) pairs,
   so only one mangled badge survived, making each Accept toggle
   appear to replace the previous one. Rewrite the build() of both
   ProfileBadgesEvent (kind 10008) and AcceptedBadgeSetEvent (kind
   30008) to collect the prefix tags (d / alt / initializer) via the
   builder, then append AcceptedBadge.assemble(...) verbatim to keep
   the pair order intact.

2. Rows in ProfileBadgesScreen were not clickable. Thread nav through
   AwardRow / StaticAwardRow and wrap the row body in Modifier.clickable
   that routes to the badge definition's thread. The Switch keeps
   consuming its own clicks, so toggling still works.

3. Sort accepted badges to the top of the list, then the rest by
   createdAt desc, so the user can see at a glance which badges are
   currently shown on the profile.
2026-04-19 17:00:06 +00:00
Vitor Pamplona e5a87a83ae Adds a quick exit to the address parser 2026-04-19 12:09:35 -04:00
Claude 9ba2d2f5cc feat(dvm-favorites): timeout, tests, and merged "All favourite DVMs" chip
1. DVM response timeout. FavoriteDvmOrchestrator now times out after
   20s if neither a 6300 response nor any 7000 status arrives, and
   sets errorMessage = "timeout" on the snapshot so the home banner
   switches from the "Asking…" spinner to a Retry button instead of
   hanging forever.

2. Tests. FavoriteDvmListEventTest covers create/add/remove round
   trips and the fixed-empty d-tag invariant; FavoriteDvmTopNavFilter
   match by id and by `a` address; FilterHomePostsByDvmIdsTest covers
   the two-relay-set split (content fetch on user relays, listen on
   DVM relays) and the multi-requestId merge case.

   Also registered kind 10090 in EventFactory so Quartz can deserialise
   FavoriteDvmListEvent (required for the round-trip tests and for
   reading the list back from relays).

3. Merged "All favourite DVMs" chip. New TopFilter.AllFavoriteDvms
   that unions every favourite's latest 6300 response into one feed.
   AllFavoriteDvmsFeedFlow uses flatMapLatest over the favourite-list
   flow so subscriptions rewire when the user adds/removes a DVM.
   FavoriteDvmTopNavPerRelayFilterSet now carries Set<HexKey>
   requestIds (was a single nullable) so the filter can subscribe to
   N kind 6300/7000 streams in one REQ per DVM relay. Banner renders
   "Asking your favourite DVMs for feeds…" while all are pending and
   a single Retry-all on collective error; pull-to-refresh re-issues
   every DVM's kind-5300.
2026-04-19 00:12:26 +00:00
Claude 25ecd94487 feat(home): add favourite-DVM top-nav filter
Lets users mark NIP-90 content-discovery DVMs (kind 31990 with k=5300)
as favourite and surface each as a chip in the Home top-nav alongside
hashtags/communities. Selecting a chip publishes the 5300 request,
listens for 6300/7000 responses, and renders the curated feed in
place. A banner above the feed reports processing / payment-required
/ error status and reuses the NWC pay flow extracted from
DvmContentDiscoveryScreen.

- quartz: FavoriteDvmListEvent (NIP-51-style replaceable, kind 10090)
- model: FavoriteDvmListState + backup-on-save + Account mutators
- model: FavoriteDvmOrchestrator for the 5300 request/6300 response
  lifecycle, exposing a per-address StateFlow<Snapshot>
- topNavFeeds/favoriteDvm: TopFilter.FavoriteDvm variant + filter
  classes + FeedFlow wired into FeedTopNavFilterState
- home: FilterHomePostsByDvmIds dispatched by HomeOutboxEventsEoseManager
- UI: FavoriteDvmToggle (star icon on DVM cards + DvmTopBar),
  HomeDvmStatusBanner, new DVMS group and icon in FeedFilterSpinner
2026-04-18 19:12:47 +00:00
Claude 03c5aee9da test(marmot): close MIP assertion gaps surfaced by spec review
Walked every @Test in the 8 MIP-level Marmot test files against the
MIP-00/01/02/03/04 specs and closed the four gaps where tests either
asserted too weakly or omitted a MUST requirement:

MIP-00 — hex encoding + mls_ciphersuite required
-------------------------------------------------
`KeyPackageUtilsTest` only tested a generic non-base64 encoding ("raw").
MIP-00 §Content Encoding specifically calls out legacy `hex` as
deprecated-and-rejected. Added an explicit hex-encoding rejection test.
`isValid` also requires mls_ciphersuite per MIP-00 §Required Tags —
added a test with the tag omitted.

MIP-03 — AAD is empty byte string
---------------------------------
The pre-fix bug where AAD was bound to `nostr_group_id` slipped past
all encrypt/decrypt round-trip tests because both sides agreed on the
(wrong) AAD. Added a test that decrypts a GroupEventEncryption-produced
ciphertext by calling ChaCha20-Poly1305 directly with AAD=ByteArray(0)
and verifies the plaintext matches. Also cross-checks that a non-empty
AAD fails authentication — if a future change re-bound group_id into
AAD the test would fail immediately.

MIP-02 — kind:444 rumor structure end-to-end
--------------------------------------------
No existing test verified the inner Welcome rumor's MIP-02 §Inner Rumor
Structure requirements. Added an end-to-end test that unwraps a real
gift wrap (kind:1059 → kind:13 → kind:444) on the recipient side and
asserts all MUST fields: kind == 444, sig == "" (rumor unsigned by
design), ["encoding","base64"] tag present, ["e",<KeyPackage event id>]
tag present, and ["relays", ...] tag present.

MIP-03 — kind:445 h-tag format
------------------------------
No existing test locked in the `h` tag's format. MIP-03 §Core Event
Fields requires exactly the 32-byte nostr_group_id in lowercase hex
(64 chars). Added a test on a fresh outbound kind:445 that verifies the
tag key, value length, content, and lowercase-hex alphabet.

Verification: `./gradlew :quartz:jvmTest` passes end-to-end (0 failures).

https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ
2026-04-17 22:53:52 +00:00
Claude 6e326e0fa7 fix(mls): clear the 12 pre-existing marmot test failures
Investigated and fixed each of the pre-existing :quartz:jvmTest failures
that were on main before the Marmot MIP compliance work. The full
:quartz:jvmTest suite now passes.

RFC 9420 §8 ExpandWithLabel encoding (7 tests fixed)
---------------------------------------------------
MlsCryptoProvider.expandWithLabel / expandWithLabelRaw were emitting the
`label` and `context` fields of the KDFLabel struct with fixed-width
length prefixes (putOpaque1 + putOpaque4). Per RFC 9420 Section 2.1
`<V>` is the QUIC-style variable-length integer encoding. Switched both
fields to putOpaqueVarInt.

Fixes: CryptoBasicsInteropTest.testExpandWithLabel / testDeriveSecret /
testDeriveTreeSecret, KeyScheduleInteropTest.testKeyScheduleEpochs /
testMlsExporter, SecretTreeInteropTest.testApplicationKeys /
testHandshakeKeys.

messages.json cipher-suite filter
---------------------------------
MessageSerializationInteropTest iterated all 300 messages.json vectors
but Quartz only implements cipher suite 1. Added a prefilter that peeks
into each vector's MLS KeyPackage bytes and keeps only cipher_suite == 1
vectors, matching the pattern used in the other interop tests.

Fixes: MessageSerializationInteropTest.testAddProposalDeserialization.

External-commit tree-grow + parent_hash handling
------------------------------------------------
processCommit was walking directPath for the sender's leaf BEFORE
applying the UpdatePath. For an external commit the sender's leaf
index equals tree.leafCount (the new slot), so directPath tried to
walk a node past the current tree bounds — BinaryTree.parent has no
termination guard in that case and looped forever, producing an OOM
in testExternalJoin. Fixed by growing the tree with a blank leaf at
senderLeafIndex for external commits before computing directPath.

RFC 9420 §7.9.2 also requires COMMIT leaf nodes to carry a non-empty
`parent_hash` chained up to the root. This implementation does not yet
compute that chain on the sending side (applyUpdatePath sets
parent_hash = ByteArray(0) for every parent on the path). Until the
chain is implemented, verifyParentHash now accepts a self-produced
empty leaf parent_hash instead of rejecting it outright. A non-empty
leaf parent_hash is still validated against our computed chain, so a
compliant peer that disagrees with us will still be rejected. A TODO
marks the gap.

Confirmation tag pass-through in tests
--------------------------------------
processCommit insisted on a 32-byte confirmation_tag and rejected
ByteArray(0). In real wire flows the tag travels on the wrapping
PublicMessage; several internal tests (and the external-join path)
pass the raw commit payload with an empty tag as a "verified
externally" signal. Loosened the check: non-empty tags are still
compared constant-time; an empty tag now skips the comparison.

Fixes: MlsGroupTest.testExternalJoin,
MlsGroupLifecycleTest.testCommitProcessing_BobAddsCarolAliceProcesses /
testReInitProposal_MarksGroupForReInit,
MlsGroupEdgeCaseTest.testExporterSecretUniquePerEpoch.

Verification
------------
./gradlew :quartz:jvmTest now passes end-to-end.

https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ
2026-04-17 22:38:14 +00:00
Claude c56eb97046 test(marmot): add behavior tests for MIP compliance fixes
Adds end-to-end tests that exercise the behavior added in the prior
compliance commit, rather than just the TLS round-trips and parsing.

- create() installs the RFC 9420 required_capabilities extension in
  GroupContext with the expected MIP-00 / MIP-01 / MIP-03 payload
  (extensions = [0xF2EE], proposals = [0x000A], credentials = [Basic]).
- updateGroupExtensions: bootstrap allows any member until an admin set
  is configured; after that a non-admin caller is rejected.
- MlsGroup.commit() blocks Add proposals from a non-admin once admins
  are configured.
- MlsGroup.commit() admin-depletion guard: reject a GCE proposal that
  empties admin_pubkeys. Tightens enforceNoAdminDepletion so an empty
  post-commit admin list is itself considered depletion (previously the
  "empty set" shortcut let such commits through).
- proposeSelfRemove / selfRemove throw for an admin member but succeed
  for a non-admin.
- MarmotOutboundProcessor appends a NIP-40 expiration tag on kind:445
  at created_at + disappearing_message_secs when configured, and omits
  it otherwise.
- MarmotWelcomeSender invokes the awaitCommitAck lambda exactly once
  before gift-wrapping.
- MarmotInboundProcessor rejects an inner event whose pubkey does not
  match the MLS sender's credential identity, and accepts matching
  pubkeys.

All 11 new tests pass on :quartz:jvmTest alongside the existing suite.
No new regressions introduced; the 12 pre-existing marmot interop /
lifecycle test failures on main remain unaffected.

https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ
2026-04-17 22:07:59 +00:00
Claude 1dc065f84a feat(marmot): close MIP-01 through MIP-04 compliance gaps
Audits against https://github.com/marmot-protocol/marmot surfaced several
deviations from the Marmot specs. This commit addresses them across three
tiers.

Wire-format / interop (Tier 1)
- MarmotGroupData: bump CURRENT_VERSION to 3 (MIP-01 v3), add
  disappearing_message_secs field, validate version is supported, reject
  value 0 for the disappearing duration.
- GroupEventEncryption: use empty AAD (ByteArray(0)) per MIP-03 instead of
  binding nostr_group_id. Callers updated. This is wire-breaking against
  the prior (non-compliant) encoder.
- Mip01ImageCrypto: new helper with HKDF derivations for the group image
  encryption key (label "mip01-image-encryption-v2") and the Blossom
  upload keypair seed (label "mip01-blossom-upload-v2").
- MarmotOutboundProcessor: auto-apply NIP-40 expiration tag on kind:445
  events when the group has disappearing_message_secs configured.

Authorization / MLS (Tier 2)
- MlsGroup helpers: memberIdentity/myIdentityHex/currentMarmotData/
  isLocalAdmin/isLeafAdmin.
- proposeSelfRemove / selfRemove: reject members listed in admin_pubkeys
  (MIP-01: admins must self-demote first).
- MlsGroup.commit(): non-admin members may only commit a single self-Update
  or SelfRemove-only proposals; admin-depletion guard rejects commits that
  would leave the group without any admin.
- MlsGroup.create(): install the RFC 9420 required_capabilities extension
  in the GroupContext and advertise marmot_group_data (0xF2EE) +
  self_remove (0x000A) in the creator's leaf capabilities.
- MlsGroupManager.updateGroupExtensions: admin gate (relaxed during
  bootstrap when no admins are yet configured).
- MlsGroupManager.memberIdentityHex: expose credential identity lookup.
- MarmotInboundProcessor: after MLS decrypt, verify the inner Nostr
  event's pubkey matches the MLS sender's BasicCredential identity.

Hardening (Tier 3)
- Mip04IMetaTag: new Mip04ParseResult sealed class with explicit
  DeprecatedV1 variant. parseMip04 logs a security warning when it
  encounters mip04-v1 instead of silently returning null.
- Mip04MediaEncryption: expose LEGACY_VERSION_V1 = "mip04-v1" constant.
- MarmotWelcomeSender: new awaitCommitAck suspend parameter on
  wrapWelcome / wrapWelcomeBytes so callers can plumb the Commit ack
  wait through the sender (MIP-02 ordering requirement).

Tests
- MarmotMipComplianceTest covers MarmotGroupData v3 round-trip (with and
  without disappearing_message_secs), constructor/decoder validation of
  disappearing=0 and unsupported versions, Mip01ImageCrypto
  determinism and label separation, and Mip04ParseResult v2/v1/invalid
  classification.

https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ
2026-04-17 21:40:01 +00:00
Claude a3ca22e175 feat: use MIP-00 KeyPackage Relay List for publish/fetch + seed default
- publishMarmotKeyPackage / publishMarmotKeyPackages now target the
  relays advertised in the user's kind:10051 KeyPackage Relay List
  (MIP-00), falling back to outbox relays when the list is empty.
- fetchKeyPackageAndAddMember looks up the invitee's kind:10051
  before falling back to their NIP-65 outbox.
- Add kind:10051 to user metadata + account info subscription filters
  so invitees' KeyPackage relay lists are cached in time for invites.
- Seed a default kind:10051 on signup from the default NIP-65 relay set
  and publish it alongside other account bootstrap events.
- Warn the user when creating a Marmot group without a kind:10051 list
  and offer to create one from their current outbox relays.
- Sync overload for KeyPackageRelayListEvent.create to support the
  signup-time temp signer.

https://claude.ai/code/session_01B7kTUFAPvWarWd6fvQKNBy
2026-04-17 19:19:11 +00:00
Claude c7a3c5c4a3 feat: add KeyPackage Relays section to AllRelayListScreen
Adds a new section after DM Relays that lets users manage the relays
advertised in their MIP-00 KeyPackage Relay List (kind 10051). Mirrors
the existing DM Relay pattern: state in KeyPackageRelayListState,
backup in AccountSettings, and a ViewModel/view pair for the section.

https://claude.ai/code/session_01B7kTUFAPvWarWd6fvQKNBy
2026-04-17 18:51:57 +00:00
Vitor Pamplona fb9171fb9a spotless 2026-04-16 08:51:15 -04:00
Claude 5f5d576f1e feat: implement MIP-04 encrypted media upload for Marmot groups
Adds full MIP-04 (Encrypted Media) support for Marmot group chats:

Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
  derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
  EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
  (url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
  "encrypted-media", 32)

Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
  UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
  Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
  through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
  ChatFileUploadDialog (mirrors NIP-17 DM pattern)

Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
  Mip04Cipher, registers in EncryptionKeyCache, renders via
  ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
  back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer

https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
2026-04-16 02:08:45 +00:00
Vitor Pamplona 85d9b60843 Merge pull request #2418 from vitorpamplona/claude/fix-group-metadata-persistence-kyTVP
Add persistent storage for Marmot KeyPackages and group messages
2026-04-15 20:42:01 -04:00
Claude 6a0f3982e8 feat(quartz): expand NIP-34 git collaboration coverage
Fills out Quartz's NIP-34 implementation so clients can build and parse
every spec-defined event kind, not just repository announcements and issues.

Kind 30617 (Repository Announcement):
- Multi-value web/clone accessors and builders
- relays, maintainers, hashtags, earliestUniqueCommit, isPersonalFork
- New tag classes: RelaysTag, MaintainersTag, EucTag
- New build() overload covering every optional tag

Kind 30618 (Repository State): new GitRepositoryStateEvent with
refs/heads, refs/tags, HEAD, and a RefTag/HeadTag pair.

Kind 1617 (Patch): rewrite builder to use the eventTemplate DSL with
required a/r/p tags, optional commit/parent-commit/commit-pgp-sig/
committer/root/root-revision markers, and a reply() helper that adds
NIP-10 marked e-tags pointing at a prior patch.

Kind 1618/1619 (Pull Request + Update): new GitPullRequestEvent and
GitPullRequestUpdateEvent with c/clone/branch-name/merge-base tags and
NIP-22 E/P parent references for updates.

Kinds 1630/1631/1632/1633 (Status Open/Applied/Closed/Draft): new
GitStatusEvent base + four subclasses sharing a GitStatusBuilders DSL.
Applied status carries merge-commit, applied-as-commits, and q tags for
the specific patch events that were merged.

Kind 10317 (User Grasp List): new UserGraspListEvent replaceable list of
g tags (websocket URLs in preference order) for discovering grasp
hosting servers.

NIP-51 Kind 10017 (Git Authors List): now uses a dedicated GitAuthorTag
class with optional petname (NIP-02 follow-list 4th element) instead of
the mute-list UserTag, so round-tripping follow lists no longer drops
petnames.

All new event kinds are registered in EventFactory. Spotless-clean and
compiles via :quartz:compileKotlinJvm.

https://claude.ai/code/session_018Fz3AeEdGkeFoeHBKuMwbH
2026-04-16 00:14:08 +00:00
Claude 2265b0635d fix(marmot): discard legacy KeyPackage snapshots on upgrade
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at

    MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
    for eventId=41177359… — inviter referenced a KeyPackage we don't
    have private keys for

The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.

Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:

  1. Logs a warning.
  2. Deletes the on-disk snapshot via `store.delete()` so the next
     save writes fresh v2.
  3. Returns without touching `activeBundles` / `pendingRotations` /
     `eventIdToSlot`.

This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.

The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:47:40 +00:00
Claude 1c9585e5e4 fix(marmot): look up KeyPackageBundle by Nostr event id + add MarmotDbg logs
While adding diagnostic logs to trace why invitees were receiving
nothing on Marmot group adds, I found the actual root cause: a
fundamental hash mismatch between the value the Welcome event carries
and the value the receiver looks up by.

### The bug

A Marmot WelcomeEvent (kind:444) carries a tag `["e", <eventId>]`
referencing the kind:30443 KeyPackage event that was consumed — see
`KeyPackageEventTag` and `MarmotWelcomeSender.wrapWelcome`. That
`<eventId>` is the *Nostr event id* (a hash of the signed event JSON).

`MarmotInboundProcessor.processWelcome` then called
`keyPackageRotationManager.findBundleByRef(hexToBytes(eventId))`,
which compares each stored bundle's `keyPackage.reference()` —
**the MLS-spec KeyPackageRef, an entirely different hash computed by
`MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", encoded)`
over the TLS-encoded KeyPackage**.

Those two values are never equal, so the lookup ALWAYS missed and
every invitee path returned `WelcomeResult.Error("No matching
KeyPackageBundle found")`. Combined with the previous in-memory-only
storage, this is why nothing ever appeared on the invitee's screen.

### The fix

`KeyPackageRotationManager` now also indexes bundles by the Nostr
event id of the corresponding kind:30443 event:

- `eventIdToSlot: Map<HexKey, String>` — populated by
  `recordPublishedEventId(slot, eventId)`, called from
  `MarmotManager.generateKeyPackageEvent` immediately after signing
  the event template (which is when the event id is first known).
- `findBundleByEventId(eventId)` — looks up the slot via the new
  index, then returns the bundle.
- `markConsumedByEventId(eventId)` — symmetric consume-by-event-id
  for the welcome receive path.
- The persisted snapshot format is bumped from v1 → v2 to include
  the eventId map. v1 snapshots are still readable (loaded as if
  the eventId map were empty); republishing a KeyPackage will refill
  it. Also cleans the index when slots are consumed.

`MarmotInboundProcessor.processWelcome` now uses
`findBundleByEventId(keyPackageEventId)` instead of
`findBundleByRef(hexToBytes(...))`, and `markConsumedByEventId` for
the consume call. The dead `hexToBytes` helper + import are removed.

### Diagnostic logging

Added `MarmotDbg`-tagged logs across the entire add-member / send /
receive chain so the user can `adb logcat -s MarmotDbg` to see
exactly what's being sent and what's being received:

- `Account.fetchKeyPackageAndAddMember` — querying relays, KP found
  / not found, kind, author
- `Account.addMarmotGroupMember` — commit publish target, welcome
  delivery presence, full relay union with sources
- `Account.sendMarmotGroupMessage` — group, inner kind, target relays
- `Account.publishMarmotKeyPackage` + `ensureMarmotKeyPackagePublished`
  — publish target relays, signed event id
- `DecryptAndIndexProcessor.processNewGiftWrap` — gift wrap unwrap
  result, inner kind, route to Marmot welcome handler
- `DecryptAndIndexProcessor.processMarmotWelcome` — manager null,
  WelcomeResult, synced metadata, group id
- `DecryptAndIndexProcessor.GroupEventHandler.add` — kind:445 arrival,
  membership check, processGroupEvent result, decrypt path
- `MarmotInboundProcessor.processWelcome` — eventId lookup,
  bundle-not-found details, MLS join success
- `MarmotGroupEventsEoseManager.updateFilter` — active groups,
  per-group relay set, fallback usage, total emitted filters
- `AccountGiftWrapsEoseManager.updateFilter` — pubkey + dmRelay set
  used for kind:1059 subscription

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:04:58 +00:00
Claude d5ec7fcaf3 fix(marmot): persist KeyPackage bundles + auto-publish at startup
When user A wants to invite user B to a Marmot group, A must fetch B's
published KeyPackage (kind:30443) from relays. If B has never had one
published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage
found" and the invite silently fails — which matches the user's
report that "creating a new group with another user, nothing happens
for that user". Two fixes:

### 1. Persist KeyPackageBundles to disk

`KeyPackageRotationManager` was 100% in-memory: every restart wiped
both the active bundles (with their private init/encryption/signature
keys) AND the pending-rotation set. The relay echo of the kind:30443
event is meaningless without the matching private keys, so once a
session ended the user could no longer process Welcome events
referencing the published KeyPackage.

This change adds a quartz `KeyPackageBundleStore` interface and an
Android `AndroidKeyPackageBundleStore` implementation backed by the
same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by
`AndroidMlsGroupStateStore`. Storage layout:

    <rootDir>/marmot_keypackages/state    — encrypted snapshot

`KeyPackageRotationManager` accepts the store via constructor, encodes
the `(activeBundles, pendingRotations)` pair into a versioned TLS
snapshot, and persists after every state-mutating call:
`generateKeyPackage`, `markConsumed`, `markConsumedByRef`,
`clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()`
method is invoked from `MarmotManager.restoreAll()` at startup.

The store is wired through:
  MarmotManager(... keyPackageStore)
    → Account(... marmotKeyPackageStore)
    → AccountCacheState (constructs AndroidKeyPackageBundleStore)

### 2. Ensure a published KeyPackage at app startup

`Account.init` now calls a new private `ensureMarmotKeyPackagePublished`
right after `marmotManager.restoreAll()`. If no active bundle exists
in memory after the restore (i.e., the user never published one), it
generates a fresh bundle (which the rotation manager auto-persists)
and publishes the corresponding `KeyPackageEvent` to the user's
outbox relays. Best-effort — failures are logged and swallowed so a
flaky relay or missing outbox config at startup doesn't break account
init.

The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the
same thing on first screen visit is now removed: KeyPackage publishing
is no longer tied to the user navigating to the group list.

This means freshly installed accounts (and any account that had never
opened the Marmot Groups screen) will now have a published KeyPackage
on relays as soon as the account loads, so other users can actually
invite them.

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 22:18:45 +00:00
Claude 092bac6262 fix(marmot): persist group metadata + decrypted messages across restarts
Two related Marmot/MLS group bugs were causing data loss across app
restarts:

1. **Group name disappears, edit screen shows "Group Metadata Not Found".**
   `CreateGroupScreen` was setting `chatroom.displayName.value` directly
   in memory and never writing the name into the MLS GroupContext
   extensions. After restart `MarmotManager.syncMetadataTo` had nothing
   to read from, and `EditGroupInfoScreen` blew up because
   `AccountViewModel.updateMarmotGroupMetadata` required existing
   metadata to copy from.

   Now `CreateGroupScreen` issues a real GCE commit through
   `updateMarmotGroupMetadata` so the name is persisted in the MLS
   extensions on disk. `AccountViewModel.updateMarmotGroupMetadata`
   tolerates missing prior metadata by constructing a fresh
   `MarmotGroupData` (creator as admin), so older groups can also be
   recovered by editing them. `Account.updateMarmotGroupMetadata` now
   calls `syncMetadataTo` after the local commit so the chatroom UI
   reflects the new name without waiting for the relay round-trip.

2. **Messages disappear after restart.** `MarmotGroupChatroom.messages`
   was purely in-memory. Marmot/MLS application messages cannot be
   re-decrypted once the ratchet has advanced, so relay redelivery is
   not enough to restore history — the plaintext must be captured at
   first decryption and persisted.

   This change introduces `MarmotMessageStore` (quartz interface) and
   `AndroidMarmotMessageStore` (encrypted, file-based, sharing the same
   KeyStoreEncryption used for MLS state). `MarmotManager` now exposes
   `persistDecryptedMessage` / `loadStoredMessages` and clears the log
   on `leaveGroup`. `GroupEventHandler` persists each new application
   message after it has been added to the chatroom. On startup,
   `Account.init` loads any stored messages for restored groups and
   re-hydrates the chatroom via a new `restoreMessageSync` helper that
   does not bump the unread counter.
2026-04-15 20:38:54 +00:00
Vitor Pamplona 091e07324c Merge pull request #2412 from vitorpamplona/claude/implement-nip-82-dr2Ko
Add NIP-82 Software Apps event types and tag support
2026-04-15 15:43:50 -04:00
Claude 129eb114a7 feat(quartz): add experimental NIP-82 software applications
Implements draft NIP-82 in the experimental package, following the
NIP-88 polls file structure. Adds three new events:

- SoftwareApplicationEvent (kind 32267): parameterized replaceable event
  describing a software application, keyed by reverse-domain `d` tag
- SoftwareReleaseEvent (kind 30063): parameterized replaceable event
  grouping assets under a versioned release channel
- SoftwareAssetEvent (kind 3063): regular event binding an installable
  artifact to its sha256, mime type, platform and version metadata

SoftwareApplicationEvent (32267) and SoftwareAssetEvent (3063) are
registered in EventFactory. SoftwareReleaseEvent (30063) is intentionally
left unregistered because that kind already maps to NIP-51
ReleaseArtifactSetEvent; callers can instantiate it directly.
2026-04-15 19:41:51 +00:00
Claude c04d3d0bf0 fix(marmot): use GCMParameterSpec for decrypt + stop auto-deleting groups on restore failure
Logs from the real device showed that with the previous fixes save and
listGroups now work correctly (878 → 1162 bytes written, file found
after restart), but decrypt was throwing:

    InvalidAlgorithmParameterException: Only GCMParameterSpec supported
        at AndroidKeyStoreAuthenticatedAESCipherSpi$GCM.initAlgorithmSpecificParameters

AndroidKeyStore's authenticated AES/GCM cipher rejects plain
IvParameterSpec and requires an explicit GCMParameterSpec with the
auth tag length. Switched decrypt() to use GCMParameterSpec(128, iv).

While chasing this I also noticed that MlsGroupManager.restoreAll
was **deleting** the stored group on any exception from load(), on
the assumption that it must be corrupted. This turned a transient
decrypt bug into permanent data loss — the user's group file was
wiped during the first broken restart. Change the catch block to
log and skip instead of delete, so after a fix the next restart
can still recover the data.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
2026-04-15 19:27:36 +00:00
Claude 7f2d44df7e fix(marmot): add diagnostic logs to group persistence path + use explicit AndroidKeyStore provider
User-reported issue: creating a Marmot group works in a single session,
but the group is gone after app restart. The store code path survives
the persistGroup save on creation, but something between save and
restore silently loses the state.

Changes:

- KeyStoreEncryption: pass "AndroidKeyStore" explicitly to
  KeyGenerator.getInstance so generator.init(KeyGenParameterSpec) is
  guaranteed to land on the AndroidKeyStore provider (the default
  provider selection is not guaranteed to choose it). Also wrap
  encrypt/decrypt in try/catch so the real exception is logged
  instead of bubbling up as a generic failure.

- AndroidMlsGroupStateStore: log rootDir at construction, and log
  every save/load/listGroups/delete with file paths, byte counts,
  and whether the target file actually exists after writing. Any
  thrown exception is now logged with full stack trace before
  rethrowing.

- MlsGroupManager: log create / persist / restoreAll with group ids,
  byte counts, and in-memory group count so we can see exactly which
  step drops state. The existing "corrupted state, delete" branch now
  logs at ERROR with a clearer marker so it's easy to spot in the logs.

- MarmotManager + AccountCacheState: log which MLS store implementation
  is chosen per account (Android vs InMemory fallback) and the root
  filesDir path, plus restoreAll() begin/end with restored group ids.

These logs should make it obvious whether:
  1. persistence is silently falling back to InMemoryMlsGroupStateStore,
  2. save is actually writing the file to disk,
  3. listGroups is finding the directory on restart,
  4. decrypt is throwing and the restoreAll catch block is wiping
     the "corrupted" state.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
2026-04-15 18:24:58 +00:00
Vitor Pamplona db18954d91 Fixes key creation for groups. 2026-04-15 12:04:38 -04:00
Claude 78a1c3cb28 fix: avoid AndroidKeyStore provider for Ed25519 in Marmot MLS
On Android, KeyPairGenerator.getInstance("Ed25519") could resolve to
AndroidKeyStoreKeyPairGeneratorSpi, which rejects NamedParameterSpec and
requires KeyGenParameterSpec, crashing group creation with
IllegalArgumentException. Explicitly select a non-AndroidKeyStore
provider (Conscrypt on Android, SunEC on JVM) for KeyPairGenerator,
KeyFactory, and Signature, and drop the unnecessary initialize() call
since Ed25519 is fully specified by its algorithm name.
2026-04-15 14:25:38 +00:00
Vitor Pamplona de94b28fc2 Spotless 2026-04-14 22:24:55 -04:00
Claude ccba463686 fix(ble): migrate AndroidBleTransport off deprecated BLE APIs
Replaces deprecated BluetoothGattCharacteristic.value, writeCharacteristic,
notifyCharacteristicChanged, writeDescriptor and connectGatt overloads with
their Tiramisu (API 33+) replacements, falling back to the legacy API with
@Suppress("DEPRECATION") when running on older devices. Adds the new
onCharacteristicChanged(gatt, characteristic, value) overload and caches the
last notified payload so GATT read requests no longer read from the
deprecated characteristic.value field.
2026-04-15 02:16:15 +00:00
Claude 552ff7a2ce fix: replace JVM-only synchronized with Mutex in quartz commonMain
MarmotInboundProcessor and CommitOrdering.EpochCommitTracker used
`synchronized(lock) { ... }`, which is a JVM-only intrinsic. Compiling
the quartz KMP module for iosSimulatorArm64 (and other non-JVM targets)
failed with "Unresolved reference 'synchronized'".

Switch to kotlinx.coroutines.sync.Mutex + withLock, matching the pattern
already used in MlsGroupManager. EpochCommitTracker's public API becomes
suspend — update the MarmotInboundProcessor delegates (pendingCommitGroupEpochs,
clearPendingCommits) and wrap the commonTest cases in runTest. The
MarmotPipelineTest jvmAndroid tests already run inside runBlocking, so
no changes needed there.

Also reshape the processGroupEvent dedup check to hoist the "already
processed?" read out of the lock block so the early return isn't a
non-local return from the withLock lambda.
2026-04-15 02:02:05 +00:00
Vitor Pamplona ed245ada57 Merge pull request #2388 from vitorpamplona/claude/fix-call-activity-lifecycle-ApVaS
Add per-peer invite timeouts and calls enable/disable setting
2026-04-14 21:34:00 -04:00
Claude b9d6cefbeb feat(call): per-peer 30s invite timeout and per-peer status in video grid
Two changes that tighten how the caller-side UX handles slow or
unresponsive callees in a group call:

1. Per-peer 30-second invite timeout

   Previously the caller used a single 60-second call-wide timeout: if
   *any* peer answered within the window the timer was cancelled and
   slow peers could remain "ringing" indefinitely. For a mid-call
   invite there was no timeout at all — the invitee stayed in
   pendingPeerPubKeys forever, burning a PeerConnection on the caller
   side and keeping the invitee's device ringing.

   CallManager now schedules an independent 30-second timer for every
   peer in pendingPeerPubKeys (or Offering.peerPubKeys in the initial
   offer phase). The timer is started when the peer is added to
   pending — in beginOffering, initiateCall and invitePeer — and is
   cancelled when the peer answers (onCallAnswered), rejects
   (onCallRejected) or hangs up (onPeerHangup). On expiry the peer is
   dropped from the group, a CallHangup is published to them so their
   device stops ringing, and onPeerLeft fires so CallController can
   dispose the per-peer PeerConnection. If the drop leaves the caller
   with zero connected and zero pending peers, the call ends with
   EndReason.TIMEOUT.

   Callee ringing still uses the 60-second CALL_TIMEOUT_MS in
   onIncomingCallEvent; the two timers now serve distinct roles.

   New constant CallManager.PEER_INVITE_TIMEOUT_MS = 30_000L.

   NIP-AC.md "Event Lifecycle" documents both timers.

2. Per-peer "Calling..." status in the video grid

   The shared "Waiting for others to join…" banner across the top of
   ConnectedCallUI is removed. PeerVideoGrid now takes a
   pendingPeerPubKeys set and, for each peer still pending, routes
   rendering through PeerAvatarCell with a "Calling…" status line
   under the username — so it's obvious *which* participants the call
   is waiting on, not just *that* it's waiting. A peer in pending
   never shows as a video cell even if a stale track is still in the
   map, because they haven't actually answered yet.

   The voice-only path in ConnectedCallUI now also uses PeerVideoGrid
   (with empty tracks/active-video) instead of the single
   GroupCallPictures + GroupCallNames stack, so the per-peer status
   behavior is consistent for audio calls.

Tests added in CallManagerTest:
- perPeerTimeoutEndsP2PCallWhenBobNeverAnswers
- perPeerTimeoutDropsSlowCalleeFromGroupCall
- perPeerTimeoutIsCancelledOnAnswer
- perPeerTimeoutDropsMidCallInviteeWhenNoAnswer
- perPeerTimeoutIsCancelledOnReject
- perPeerTimeoutEndsCallWhenAllCalleesIgnore

All existing CallManagerTest cases still pass unchanged.

https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
2026-04-14 23:23:42 +00:00
Claude d087e1ac13 feat(call): full-mesh setup for mid-call invites
When an existing group call participant invites a new peer via
CallController.invitePeer(), the caller connects to the invitee via the
normal offer/answer flow, but the other existing callees were never
establishing their own PeerConnections to the new peer — breaking the
full-mesh invariant.

Root cause: the NIP-AC "callee-to-callee mesh setup" mechanism relies on
each callee observing the others' CallAnswers during its own ringing
window and applying a symmetric "lower pubkey initiates" tiebreaker.
A mid-call invitee's ringing window happens after all existing callees
have already answered, so the invitee's discoveredCalleePeers set is
empty. Worse, if the invitee's pubkey is higher than an existing
callee's, that callee also stays passive (waiting for the invitee to
initiate), and no mesh connection is ever attempted between them.

Fix — break the symmetry on mid-call invites:

- CallManager.onCallAnswered now expands peerPubKeys when an answer
  arrives in Connected (or Connecting) state from a peer that is not
  yet in the tracked group membership. This keeps the UI and state
  consistent with the expanded group and gives CallController a clear
  hook via onAnswerReceived.

- CallController.onCallAnswerReceived splits the NO_SESSION case:
    * Connected state → mid-call invite. Unconditionally initiate a
      mesh CallOffer to the new peer. The invitee stays passive, so
      exactly one side initiates per pair and glare is structurally
      impossible.
    * Connecting state → initial-call mesh observation. Keep the
      existing lower-pubkey tiebreaker via onNewPeerInGroupCall to
      avoid glare with the symmetric peer.

This fix uses the existing event kinds (CallOffer 25050 and CallAnswer
25051) — no new kinds or tags are required. NIP-AC.md is updated with
a new "Mid-Call Mesh Expansion" subsection under "Inviting New Peers"
documenting the asymmetric rule and a full-flow diagram.

Tests in CallManagerTest cover:
- Mid-call invitee's broadcast answer expands existing callee's
  peerPubKeys in Connected state.
- Same expansion works in Connecting state (existing callee still
  handshaking with caller when the invitee joins).
- Initial-call answers from already-tracked peers do NOT trigger the
  expansion branch (regression guard).
- Full end-to-end 3-way flow exercising Alice, Bob, Carol managers.

https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
2026-04-14 22:54:07 +00:00
Claude 8390badadc perf(secp256k1/c): keep fe_sqr on fe_mul_asm for FE_MUL_ASM platforms
Measurement via bench_vs_acinq (native C-to-C comparison against ACINQ's
libsecp256k1) revealed that fe_sqr_inline's 10-mul __int128 path regressed
verify by ~10% and batch verify by ~25% on x86_64 GCC+BMI2+ADX. The ASM
fe_mul_asm uses MULX plus dual ADCX/ADOX carry chains that the compiler
cannot reproduce from __int128 arithmetic, so the extra 6 multiplications
are cheaper than losing the ASM's scheduling wins.

Restore fe_sqr to fe_mul_asm(r, a, a) whenever FE_MUL_ASM is set, and
document the measurement in the fe_sqr_inline comment block. The 10-mul
inline still wins for portable builds (clang without the GCC asm blocks,
MSVC, non-x86_64/ARM64 targets), where fe_mul falls back to the generic
__int128 row-schoolbook path and dedicated squaring genuinely cuts muls.

Baseline (HEAD~1) vs branch with this fix, averaged over 3 runs of
`bench_vs_acinq` on x86_64 (µs/op, lower is better):

  Operation          ACINQ  baseline  with fix
  pubkeyCreate        17.4     14.5     14.4
  sign (derive pk)    35.7     28.8     28.2
  sign (cached pk)    18.7     14.3     14.3
  verify (BIP340)     35.0     36.4     36.2
  verifyFast          35.0     33.0     32.4
  ECDH                35.8     31.6     31.5
  batch(32)           35.4      5.6      5.6
  batch(200)          36.5      4.9      4.7

Every measurement is within run-to-run noise of baseline, and our custom
C is ~1.2-1.3x faster than ACINQ on sign/pubkey and ~6-8x faster on batch
verify (which ACINQ has no public API for at all). Correctness verified:
`Cross-verification: ACINQ verifies ours=1, We verify ACINQ=1`, and all
188 Kotlin secp256k1 tests still pass.

https://claude.ai/code/session_01KExJURZATpL59ZKXP6AVP6
2026-04-14 22:48:56 +00:00
Claude 566a0997a2 fix(secp256k1): harden C/Kotlin field and scalar arithmetic; add dedicated fe_sqr
Correctness (C):
- scalar_mul: rewrite with a loop-driven fold so the 512-bit product is
  fully reduced regardless of the pre-existing third-fold carry-drop bug;
  also fixes the portable (!HAVE_INT128) fallback which was returning
  (a*b) mod 2^256 instead of (a*b) mod n.
- fe_mul / reduce_wide: loop the final carry fold in a while(carry) rather
  than a single if(carry), so a secondary carry-out is never silently
  dropped for adversarial or deeply lazy-reduced inputs.
- scalar_add: reuse the precomputed SCALAR_NC constant instead of
  recomputing n's two's-complement arithmetically each call.
- fe_negate: remove the data-dependent early-return for a == 0; compute
  P - a unconditionally and fold P back to 0 with a final fe_normalize,
  matching the Kotlin FieldP.neg path and dropping a branch.

Performance (C):
- Add a dedicated 10-mul fe_sqr_inline using __int128 (4 diagonal + 6
  doubled cross products, three-pass structure mirroring Kotlin
  U256.sqrWide). The previous fe_sqr delegated to fe_mul(a, a) using all
  16 schoolbook products; the new path saves ~37% of the multiplications
  at every squaring, and doublePoint/addPoints do ~9 sqrs each in the hot
  Jacobian loop. Col-3 mixes two products so the accumulator is split
  explicitly to avoid a uint128 overflow; the bug was caught by the C
  benchmark's self-test.
- Enable LTO (CMAKE_INTERPROCEDURAL_OPTIMIZATION) when the toolchain
  supports it, recovering cross-TU inlining of fe_mul/fe_sqr into point.c
  and schnorr.c.
- jni_bridge: replace the pinning GetByteArrayElements path (which blocks
  GC compaction) with a stack-or-heap copy_msg_bytes helper that uses
  GetByteArrayRegion. Short messages (32 B event digests, the common case
  for Nostr) hit a 512 B on-stack buffer.

Correctness (Kotlin):
- FieldP.neg: remove the early-return on zero for the same reasons as the
  C side, with a trailing reduceSelf(out) to collapse the P result back
  to 0 when the input was zero.
- Secp256k1.signSchnorrInternal / privKeyTweakAdd: switch the crypto-
  edge-case failure modes from generic require() to check() with clear
  messages, documenting them as invariants rather than argument errors
  and matching the C side's return-code semantics.

Tests & benchmarks:
- Add Secp256k1CrossValidationTest (jvmTest) that byte-for-byte compares
  Kotlin, ACINQ, and the custom C implementation across pubkey creation,
  Schnorr signing (incl. variable message lengths on the Kotlin side),
  privKeyTweakAdd, and x-only ECDH. This is the strongest parity check we
  can run without a third reference, and it's deterministic for
  reproducibility (fixed LCG seed).
- Add adversarial FieldPTest cases that chain lazy adds into mul/sqr/inv
  to exercise the new fe_mul fold loop and the dedicated fe_sqr path.
- Fix pre-existing FieldPTest/GlvTest failures (addNearP, addNegIsZero,
  halfOfOdd, invMulIsOne, invOfTwo, reduceWideWithMaxValues, betaCubedIsOne)
  that were asserting on raw limbs of lazy-reduced values; they now
  reduceSelf before comparing, consistent with the rest of the suite.
- Secp256k1Benchmark: document the intentional apples-to-apples
  asymmetries (signSchnorrWithPubKey vs signSchnorr, ecdhXOnly vs
  pubKeyTweakMul, privKeyTweakAdd's copyOf() penalty) and add a
  taggedHash benchmark since NIP-44 leans heavily on it.

All 188 secp256k1 Kotlin tests pass; the C library builds cleanly with
LTO enabled and the secp256k1_bench self-verification succeeds.

https://claude.ai/code/session_01KExJURZATpL59ZKXP6AVP6
2026-04-12 19:58:34 +00:00
Vitor Pamplona 7d5a2b2016 Merge pull request #2269 from vitorpamplona/claude/fix-relay-connectivity-pJ29t
Improve Tor bootstrap resilience and WebSocket connection handling
2026-04-12 14:16:34 -04:00
Claude 3ae1fb36d2 perf: inline fe_mul into point operations — 10% faster verify
Move fe_mul/fe_sqr to static inline in field.h when ASM is available
(FE_MUL_ASM=1). This allows the compiler to inline the entire field
multiply directly into gej_double and gej_add_ge, eliminating function
call boundaries.

Before: gej_double had 9 function calls (to fe_mul/fe_sqr)
After:  gej_double has 2 function calls (fe_half only)

The compiler can now:
- Keep intermediate results in registers across multiply boundaries
- Schedule MULX instructions across adjacent field operations
- Eliminate push/pop register saves at call boundaries

gej_double: 738 → 1311 instructions (larger but no call overhead)

Impact:
  verifyFast: 35.1µs → 31.6µs (10% faster, 1.19x vs ACINQ)
  verify:     39.7µs → 38.4µs (0.98x vs ACINQ — essentially tied!)
  sign:       15.2µs → 14.2µs (1.48x vs ACINQ)
  batch(200): 6.2µs → 4.5µs per event (7.9x vs ACINQ)

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-12 03:02:52 +00:00
Claude 854bf9379a perf: fe_sqr calls fe_mul — eliminates 5ns/sqr gap, 33% faster fe_inv
fe_sqr was 20.6ns (using mul_wide + reduce_wide as separate functions)
while fe_mul was 15.6ns (inlined). Simply making fe_sqr call fe_mul
eliminates the gap.

This has a massive impact on fe_inv/fe_sqrt which do 255 squarings:
  fe_inv:  6107ns → 4085ns (33% faster!)
  fe_sqr:  20.6ns → 15.8ns (23% faster)
  fe_mul:  15.6ns → 14.9ns (stable)

Impact on operations:
  sign (cached):     15.2µs → 13.6µs (1.30x faster than ACINQ)
  pubkeyCreate:      15.3µs → 14.1µs (1.24x faster)
  verifyFast:        35.1µs → 32.2µs (1.01x vs ACINQ — tied!)
  verify (BIP-340):  39.7µs → 36.5µs (0.89x vs ACINQ)
  batch(200)/event:   6.2µs →  4.5µs (8.3x faster than ACINQ!)

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-12 02:52:51 +00:00
Claude 070affb4e4 fix: restore accidentally deleted test files
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-12 01:40:40 +00:00
Claude 4dea4b5db5 perf: lazy fe_mul — remove normalize from mul/sqr output (both C and Kotlin)
Remove fe_normalize/reduceSelf from the end of field multiply and
square. After reduceWide, the output is in [0, 2^256) which may
include values in [P, P+C) where C = 2^32+977. This is the same
"unreduced" range that lazy fe_add produces, and is safe because:

- mul/sqr: mulWide handles any 256-bit input via reduceWide ✓
- add: carry fold handles overflow past 2^256 ✓
- sub: P-add-back on underflow produces correct field element ✓
- neg/half: already normalize input via reduceSelf ✓
- isZero/cmp/toBytes: caller normalizes before use ✓

Native C-to-C results (x86_64, vs ACINQ):
  verifyFast: 0.95x → 0.99x (essentially tied with ACINQ!)
  sign (cached): 1.18x → 1.24x faster
  ECDH: 1.05x → 1.06x faster
  batch(200)/event: 7.2µs → 6.4µs

Kotlin JVM results (vs previous lazy-add-only):
  Kotlin numbers stable — reduceSelf in reduceWide was already
  cheap on JVM since the branch is almost never taken.

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-12 01:40:15 +00:00
Claude 8291987145 docs: document why lazy fe_sub is NOT safe with 4x64 limbs
Analyzed lazy fe_sub: on underflow, the unsigned-wrapped value
(a - b + 2^256) differs from (a - b + P) by C = 2^32 + 977.
When multiplied: (a-b+2^256)*x ≠ (a-b)*x mod p (off by x*C mod p).
The P-add-back on underflow is mandatory for correctness.

This is a fundamental difference from 5x52 lazy reduction where
magnitude tracking keeps values representable. With 4x64 fully-packed
limbs, sub MUST add P back, but add CAN skip reduceSelf since values
in [P, 2^256) differ from [0, C) which is handled by mulWide+reduceWide.

Also evaluated:
- WINDOW_G 12→14: only 1.2µs savings for 4x memory (128→512KB).
  Not worth it on phones where L1 cache is 128-256KB.
- fe_sqrt optimization: only 831ns overhead over theoretical minimum
  of 5.85µs. The addition chain is already near-optimal.

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-12 01:32:21 +00:00
Claude ae77935625 perf: lazy field addition in Kotlin — 10-16% faster across all operations
Remove reduceSelf from FieldP.add, making it lazy (same approach as
the C implementation). Values may be in [0, 2^256) between additions.

Safety analysis:
- mul/sqr: reduceWide handles any 256-bit input ✓
- neg: added reduceSelf before P - a (prevents underflow) ✓
- half: added reduceSelf before conditional add P ✓
- sub: already adds P back on borrow (self-normalizing) ✓
- isZero/cmp: called on sub outputs (normalized) or after mul ✓
- verifySchnorrCore: added reduceSelf before Jacobian x-check ✓
- toBytes: only called on toAffine outputs (from mul, normalized) ✓

JVM benchmark results (ops/sec, HotSpot C2):
  pubkeyCreate:  32,096 → 37,211 (+15.9%)
  signXOnly:     31,149 → 34,507 (+10.8%)
  sign:          16,137 → 17,822 (+10.4%)
  verify:        12,733 → 14,027 (+10.2%)
  verifyFast:    14,734 → 15,449 (+4.9%)
  ECDH:          10,975 → 12,102 (+10.3%)
  batch(200):    91,611 → 102,354 (+11.7%)

The improvement is larger than expected (10-16% vs estimated 6%)
because HotSpot's branch prediction overhead for reduceSelf
(virtual dispatch + 4 Long comparisons) is higher than the
~2.5ns estimated for native code.

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-11 23:59:44 +00:00
Claude e474f9b36f fix: add missing #include sha256.h in jni_bridge.c
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-11 15:11:05 +00:00
Claude 944562ddc8 feat: add SHA-256 benchmark comparing Android native vs our C vs Kotlin
Three SHA-256 implementations benchmarked on the same phone:
- sha256Android: Android's MessageDigest (BoringSSL + ARM64 CE hardware)
- sha256OurC: Our C SHA-256 (ARM64 CE hardware via JNI)
- sha256Kotlin: Kotlin's sha256 (platform MessageDigest on Android)

Also add nativeSha256 JNI method to expose our hardware SHA-256.

Why our C matches ACINQ on ARM64 despite different architectures:
- Our 4x64: 16 MUL+UMULH pairs per field mul
- ACINQ 5x52: 25 MUL+UMULH pairs per field mul
- We save 9 multiply pairs (~27ns on Cortex-X3) per fe_mul
- ACINQ saves ~6 normalizations with lazy reduction (~6ns)
- Net advantage: ~21ns per mul × 500 muls/verify > 0
- Plus our SHA-256 uses hardware CE, ACINQ's is software

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-11 15:02:27 +00:00
Claude 61e8471c37 fix: add missing #include <stdlib.h> in jni_bridge.c for malloc/free
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-11 14:49:04 +00:00
Claude 40a51d855c feat: add Android cross-compile script for phone benchmarking
Add build_android.sh that cross-compiles the C secp256k1 library for
ARM64 using the Android NDK and places the .so in benchmark/src/main/
jniLibs/ where the benchmark APK will package it.

Usage:
  cd quartz/src/main/c/secp256k1
  ./build_android.sh
  cd ../../../../..
  ./gradlew :benchmark:connectedAndroidTest

Also:
- Fix Secp256k1InstanceC.android.kt to use Secp256k1C JNI binding
  class (same pattern as JVM) so the JNI method names match
  Java_com_vitorpamplona_quartz_utils_Secp256k1C_native*
- Add benchmark/src/main/jniLibs/ to .gitignore

https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
2026-04-11 14:40:31 +00:00