Skip to content

feat: NIP-17 private DMs + nostrdb submodule conversion#3667

Draft
alltheseas wants to merge 24 commits intodamus-io:masterfrom
alltheseas:feat/nip17-private-dms
Draft

feat: NIP-17 private DMs + nostrdb submodule conversion#3667
alltheseas wants to merge 24 commits intodamus-io:masterfrom
alltheseas:feat/nip17-private-dms

Conversation

@alltheseas
Copy link
Copy Markdown
Collaborator

@alltheseas alltheseas commented Mar 6, 2026

Summary

NIP-17 encrypted private direct messages with NIP-44 encryption and NIP-59 gift wraps, nostrdb submodule conversion using SPM local package (inspired by jb55's compact branch), and database compaction UI.

NIP-17 DMs:

  • Kind 14 chat messages via kind 13 seals + kind 1059 gift wraps
  • DM Relays settings tab for kind:10050 relay list management
  • NIP-04/NIP-17 encryption indicator in DM UI
  • Outbox model for sending via recipient's kind:10050 relays
  • Duplicate send prevention with button disable and spinner

nostrdb submodule + SPM local package (Closes #3666):

  • Replaced vendored C files with git submodule pointing to alltheseas/nostrdb@spm (based on nostrdb compact branch tip 4e5af99 + Package.swift)
  • Removed ~88 individual C file build entries from pbxproj, replaced with a single XCLocalSwiftPackageReference to the nostrdb SPM package
  • Package.swift created for nostrdb (not yet upstream) — compiles all C sources as one SPM target with proper header search paths and preprocessor defines
  • Moved Swift bindings from damus/Bindings/nostrdb/damus/Nostrdb/
  • Swift-side TLV kind extraction workaround for nostrdb#126

Database compaction (from jb55's compact branch):

  • Ndb.compact() calls ndb_compact() to create a compacted database keeping all profiles and the user's own notes
  • Ndb.try_swap_compacted_db() atomically swaps the compacted DB into place at app startup
  • Compact Database button in Settings > Storage with confirmation dialog
  • Approach based on jb55's compact branch (origin/compact, commit a4afb16), adapted to work with the SPM package approach

Checklist

Standard PR Checklist

Test report

Device: iPhone 17 Simulator

iOS: 26.2

Damus: 3f0d9e93 (HEAD of this PR)

Build verification:

  • All 4 targets (damus, ShareExtension, HighlighterActionExtension, DamusNotificationService) compile with zero errors
  • nostrdb SPM local package compiles all C sources correctly

Still needs testing:

  • NIP-17 DM send/receive across clients
  • Database compaction UI in Settings > Storage
  • Compacted DB swap at app restart
  • Bech32 TLV kind extraction

Note: Unit tests (xcodebuild test) are blocked by a pre-existing SPM conflict (duplicate module.modulemap output from nostr-sdk-swift vs swift-sodium). This exists on current master and is unrelated to this PR.

Other notes

  • The Package.swift in nostrdb should be upstreamed to damus-io/nostrdb. Once that happens, .gitmodules URL can switch back to damus-io/nostrdb.
  • The bech32 TLV kind workaround (extractTLVKind(fromBech32:)) is documented with references to nostrdb#126 and can be removed once nostrdb adds kind to bech32_nevent/bech32_naddr structs upstream.
  • bech32_util.c and node_id.c are excluded from the SPM package — they have unresolvable ccan header dependencies (talstr.h, array_size.h) and are not in nostrdb's Makefile SRCS. error.c is included (needed by wasm.c).
  • .beads/ added to .gitignore and metadata.json untracked to prevent local tooling artifacts in repo.

alltheseas and others added 21 commits February 4, 2026 00:53
Update nostrdb to include gift wrap (NIP-59) processing support:
- Add NIP-44 encryption/decryption (ChaCha20 + HMAC-SHA256)
- Add gift wrap unwrapping with key management
- Add base64 encoding/decoding utilities

Co-Authored-By: Claude Opus 4.5 <[email protected]>

nostrdb: Add Swift bindings for gift wrap APIs

Add Swift bindings for nostrdb's gift wrap processing:
- ndb_add_key(): Register decryption key for gift wrap processing
- ndb_process_giftwraps(): Trigger processing of stored gift wraps
- NdbNote extensions for rumor detection and metadata

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Implement NIP-17 Private Direct Messages (rumor→seal→gift_wrap):
- createMessage(): Create gift wraps for recipient and sender (self-wrap)
- unwrap(): Decrypt gift wrap → seal → rumor with signature verification
- parseRumorJson(): Handle rumors without id field (calculate from hash)
- createDMRelayList()/parseDMRelayList(): Kind 10050 support
- Add kind definitions: dm_chat (14), seal (13), gift_wrap (1059), dm_relay_list (10050)

Security:
- Verify seal signature to prevent spoofed sender pubkeys
- Use ephemeral keypairs for gift wrap signing
- Randomize timestamps for temporal privacy (up to 2 days)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Register the user's private key with nostrdb on app startup to enable
automatic gift wrap decryption:
- Add initializeNip17KeysIfNeeded() to DamusState
- Call from ContentView after DamusState creation
- Trigger processGiftWraps() after adding key
- Key initialization is idempotent and safe for multiple accounts

Co-Authored-By: Claude Opus 4.5 <[email protected]>

feat(nip17): Subscribe to kind 1059 gift wraps and process DM rumors

Add NIP-17 event handling in HomeModel:
- Subscribe to kind 1059 (gift wrap) where p-tag matches our pubkey
- Process incoming gift wraps with Swift-side NIP17.unwrap()
- Handle unwrapped kind 14 rumors via handle_dm()
- Add debug logging for gift wrap reception and processing
- Trigger nostrdb processGiftWraps() when new gift wraps arrive

Co-Authored-By: Claude Opus 4.5 <[email protected]>

refactor(nip17): Remove Swift-side gift wrap unwrapping

Remove redundant Swift NIP17.unwrap() from HomeModel - nostrdb handles
gift wrap processing natively:

1. Gift wraps (kind 1059) are ingested by nostrdb
2. nostrdb unwraps to rumors (kind 14) using registered keys
3. Unwrapped rumors are stored in nostrdb and streamed to app
4. App subscribes to kind 14 events which includes nostrdb-unwrapped rumors

The processGiftWraps() call in dmsStream triggers nostrdb to process
any pending gift wraps when new ones arrive.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
… model

Implement NIP-17 message sending with DM outbox model:
- Create gift-wrapped messages using NIP17.createMessage()
- Fetch recipient's DM relay list (kind 10050)
- Connect on-demand to recipient's DM relays (ensureConnected)
- Send recipient wrap to their DM relays
- Send sender self-wrap to own relays for cross-device recovery
- Show warning if recipient has no DM relay list or connection fails
- Fall back to NIP-04 if no private key available
- Uses ephemeral relays that auto-cleanup (not persisted)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

fix(nip17): Address code review issues

1. [High] Remove main-thread DB work during app init
   - Remove synchronous ndb.addKey/processGiftWraps from convenience init
   - Keep only the background init in initializeNip17KeysIfNeeded()

2. [Medium] Fix ephemeral relay leak / pool growth
   - Add sendToEphemeralRelays() that handles acquire/release lifecycle
   - Properly release leases after delayed timeout

3. [Medium] Fix stale relay list fetch
   - fetchDMRelayList now returns the latest event by created_at
   - Correctly handles kind 10050 as a replaceable event

Co-Authored-By: Claude Opus 4.5 <[email protected]>

fix(nip17): Restore warning on connection failure and use PostBox for reliability

- Route ephemeral relay sends through PostBox for retry/backoff
- Release ephemeral relay leases via OnFlush.once callback after ACK
- Return Bool from send_nip17_message to indicate success/failure
- Show fallback warning dialog when all DM relay connections fail
- Move draft clearing to after successful send

Co-Authored-By: Claude Opus 4.5 <[email protected]>

fix(nip17): Improve ephemeral relay lease lifecycle and add DEBUG fallback

Ephemeral relay lease lifecycle:
- Release leases after first ACK only if remaining relays are disconnected
- Keep leases alive while PostBox has connected relays to retry
- Add 20-second safety timeout to prevent indefinite lease leaks

DEBUG safety net:
- Add Swift-side unwrap fallback in DEBUG builds only
- Helps diagnose nostrdb gift wrap processing issues during development
- Production builds rely solely on nostrdb for unwrapping

Co-Authored-By: Claude Opus 4.5 <[email protected]>

fix(nip17): Release ephemeral leases only after PostBox drains all relays

- Switch from OnFlush.once to OnFlush.all for proper completion detection
- Release leases only when remaining relays list is empty
- Cancel PostBox retries after timeout to prevent "relay not found" errors
- Removes unnecessary MainActor.run hop for relay connection checks

Co-Authored-By: Claude Opus 4.5 <[email protected]>

fix(nip17): Add force_cancel_send to stop PostBox retries after timeout

- Add PostBox.force_cancel_send() that removes events regardless of delay
- Timeout cleanup now properly stops retries when leases are released
- Add debug logging when no pending entry exists to cancel

This fixes the issue where cancel_send only worked for delayed events,
leaving immediate sends retrying to removed relays indefinitely.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
UI improvements for NIP-17 DMs:
- Add encryption protocol indicator in DM toolbar
- Shield icon for NIP-17 (private), lock icon for NIP-04 (legacy)
- Fix empty DM bubble when render_blocks returns empty
- Update Xcode project for new NIP17 files

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Added: Added NIP-04/NIP-17 encryption indicator in DM UI
Add unit tests for NIP-17 implementation:
- testCreateMessageReturnsTwoWraps: Verify recipient + sender wraps
- testGiftWrapUsesEphemeralKey: Verify ephemeral key usage
- testTimestampRandomization: Verify temporal privacy (0-2 days)
- testWrapUnwrapRoundTrip: Full encryption/decryption cycle
- testSenderCanUnwrapSelfWrap: Cross-device recovery
- testWrongRecipientCannotUnwrap: Security validation
- testUnwrapRejectsNonGiftWrap: Input validation
- Additional edge case and security tests

Co-Authored-By: Claude Opus 4.5 <[email protected]>

test(nip17): Add DM relay list and error path tests

Add additional tests for NIP-17:
- testCreateDMRelayList: Verify kind 10050 event creation
- testParseDMRelayList: Verify relay URL extraction
- testParseDMRelayListEmpty: Handle empty relay list
- testParseDMRelayListWrongKind: Reject wrong event kind
- testUnwrapEmptyContentFails: Handle empty gift wrap content
- testUnwrapMalformedContentFails: Handle invalid encryption
- testCreateMessageEmptyContent: Allow empty message content

Note: Integration tests for ensureConnected() require network mocking
infrastructure and are documented but not implemented.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
- Move keypair generation off main thread in NIP17.swift using Task.detached
- Gate debug prints with #if DEBUG in DamusState.swift, truncate pubkey
- Wrap addKey with withNdb for thread safety in Ndb.swift
- Update NIP17Tests to async for new createMessage signature

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
P1 Memory Bugs:
- nostrdb.c:6357: Check malloc result, free on strdup failure
- nostrdb.c:6196: Check malloc result, free on strdup failure

P2 Bugs:
- nostrdb.c:2416: Fix duplicate quotes check, add total_reactions
- metadata.c:99: Fix pointer arithmetic to use bytes not struct units
- metadata.c:99: Assign size output parameter
- metadata.c:342: Use sizeof(struct) not sizeof(pointer)
- threadpool.h:85: Always dispatch to all threads, avoid short-circuit

P3 Bugs:
- nip44.h: Add missing stddef.h, stdint.h, sys/types.h includes
- binmoji_table.h: Add EmojiHashEntry typedef fallback
- base64.c: Use unsigned char to avoid sign-extension

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
HIGH PRIORITY:
1. Disable since optimization for giftwrap filters
   - NIP-17 randomizes timestamps up to 2 days past
   - Since optimization was filtering out new messages
   - SubscriptionManager.swift now skips optimization for kind:1059

2. Subscribe to own 10050 relays on login
   - Added subscribeToOwnDMRelays() in DamusState.swift
   - Fetches user's kind:10050, connects to those relays
   - Streams giftwraps indefinitely with no since optimization
   - Called after network connects in ContentView.swift

MEDIUM PRIORITY:
3. Enforce seal/rumor pubkey match (security)
   - NIP17.swift now rejects messages where rumor.pubkey != seal.pubkey
   - Prevents sender spoofing attacks

4. Send self-wrap to sender's 10050 relays
   - DMChatView.swift now sends self-wrap to own DM relays
   - Enables cross-device message recovery

TESTS:
- Complete outbound DM flow test
- Complete inbound DM flow test
- DM relay list roundtrip tests
- Unhappy path tests (wrong recipient, replay attacks)
- Bidirectional conversation test
- Security test for pubkey verification

Closes damus-jps

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Fixed: Fixed inbound NIP-17 DM reception
Technical problem statement for external review of why jb55's DM
sent via notedeck isn't being received in Damus iOS.

Key findings:
- Relay overlap EXISTS (nos.lol, nostr.wine, relay.damus.io)
- Other NIP-17 DMs work (0xchat, nospeak)
- Most likely cause: `since` optimization + timestamp randomization
- AUTH requirements ruled out (nos.lol, relay.damus.io don't require)

Includes focused tests and questions for notedeck team.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Adds a "DM Relays" tab to Settings -> Relays allowing users to:
- View their current DM relay list (kind:10050)
- Add/remove DM relays
- Set up default relays for new users
- Publish updated 10050 to network

This enables NIP-17 interoperability by letting users configure where
they receive encrypted DMs from other NIP-17 compatible apps.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Added: Added DM Relays settings tab for kind:10050 relay management
Adds isSending state to prevent rapid double-taps during NIP-17 message
creation. The send button now:
- Shows a spinner while sending
- Is disabled during the async operation
- Clears draft immediately for responsive feel

Note: We don't create a local display event for immediate UI feedback
because the self-wrap returns from DM relays quickly with the correct
rumor ID. Creating a separate display event caused duplicate messages.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Fixed: Fixed duplicate DM sends
Adds tests for:
- Seal signature validation documentation
- Special character content handling (newlines, tabs, quotes, null bytes)
- p-tag recipient identification in rumor
- Self-to-self message edge case
- Cross-client interop test vector template (TODO: add real vectors)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Signed-off-by: alltheseas <[email protected]>
Changelog-Changed: Scaled event fonts for Dynamic Type
Implement NIP-17 Private Direct Messages with end-to-end encryption
using gift wrap protocol (NIP-59).

Code cleanup:
- Wrap all debug prints in #if DEBUG guards
- Consolidate duplicate fetchLatestDMRelayListEvent and
  fetchLatestNIP65RelayListEvent into single fetchLatestEvent helper
- Flatten fetchDMRelayList nesting using guard/early returns
- Remove redundant kindName variables in production builds

Tested successfully with send & receive across:
- Damus
- Notedeck
- 0xchat
- Nospeak
- Pollerama

Closes damus-io#1737
Closes: damus-io#2815

Changelog-Added: NIP-17 Private Direct Messages with end-to-end encryption
Changelog-Added: DM Relays settings for managing kind 10050 relay list

Signed-off-by: alltheseas <[email protected]>
Co-Authored-By: Claude Opus 4.5 <[email protected]>
Per lead dev feedback, nostrdb must use submodule instead of vendored code.

Changes:
- Remove vendored nostrdb C code (231 files)
- Add nostrdb as git submodule from https://github.com/damus-io/nostrdb
- Move Swift bindings to damus/Bindings/nostrdb/
- Update Xcode project to reference:
  - Swift bindings from damus/Bindings/nostrdb/
  - C code from nostrdb submodule (using SOURCE_ROOT)

This maintains clean separation: Swift wrappers in damus repo, upstream C code in submodule.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Changed: Converted nostrdb from vendored code to git submodule
Closes: damus-io#3666
The prior commit (98bbeca) converted nostrdb from vendored code to a
git submodule but left several path references and API mismatches that
prevented compilation. This fixes all of them:

- Update pbxproj paths for secp256k1, flatcc, and lmdb files to match
  the submodule's directory layout (deps/secp256k1/include/, etc.)
- Update HEADER_SEARCH_PATHS to mirror nostrdb Makefile's -I flags
- Add local copies of NdbProfile.swift and NdbMeta.swift without the
  import FlatBuffers line (FlatBuffers is compiled as source, not module)
- Fix OpaquePointer → UnsafeMutablePointer<ndb_blocks> in NdbBlock.swift
- Fix nostr_bech32_t → nostr_bech32 type name in Bech32Object.swift
- Remove has_kind/kind field references not present in nostrdb 4a14400
- Remove dead bech32_util.c from build phases (not in nostrdb Makefile)
- Restore damus-c/parser.h (cursor parser needed by wasm.c)
- Fix #if DEBUG / else preprocessor split in HomeModel.swift

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
The nostrdb C parser defines TLV_KIND but never parses it — the
bech32_nevent and bech32_naddr structs lack a kind field across all
commits and branches. This caused decoded nevent/naddr to silently
drop the kind, breaking round-trip encoding and naddr resolution
(e.g. kind 30023 long-form articles would resolve as kind 0).

Add a Swift-side TLV kind extractor that re-decodes the bech32 data
and scans for TLV type 3 after the C parse completes. This is a
workaround until nostrdb adds kind support upstream.

Upstream issue: damus-io/nostrdb#126

Also untrack .beads/metadata.json (local tooling artifact).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
Changelog-Fixed: Fixed bech32 kind decoding for nevent/naddr entities
The init(block:) path for inline bech32 mentions (used by
NoteContent for rendered mentions) was still hardcoding kind to
nil/0. This could cause naddr resolution to fail for addressable
events like kind 30023 long-form articles.

Extract the raw bech32 string from block.str and apply the same
Swift-side TLV kind fallback used in parse().

Also add .beads/ to .gitignore to prevent accidental re-tracking
of local tooling artifacts.

Ref: damus-io/nostrdb#126

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Signed-off-by: alltheseas <[email protected]>
@alltheseas alltheseas marked this pull request as draft March 6, 2026 06:23
# Conflicts:
#	damus.xcodeproj/project.pbxproj
#	damus/Bindings/nostrdb/NdbDatabase+UI.swift
#	nostrdb/src/nostrdb.c
#	nostrdb~HEAD
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 6, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a1e798f-ffac-412a-a53e-79fc1d171a74

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alltheseas
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 6, 2026

@alltheseas Sure! I'll start reviewing the changes in this PR now.


🧠 Learnings used
Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Code should be human reviewable

Learnt from: CR
Repo: damus-io/damus PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-08T01:12:51.605Z
Learning: Review and follow pull_request_template.md when creating PRs for iOS Damus
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…d database compaction

Replace ~88 individual C file build entries in the Xcode project with a single
SPM local package reference to nostrdb. This simplifies the build configuration
and makes it easier to update the nostrdb dependency. Move Swift bindings from
damus/Bindings/nostrdb/ to damus/Nostrdb/.

Add database compaction feature: Ndb.compact() calls ndb_compact() to create a
compacted copy keeping all profiles and the user's own notes. The compacted DB
is swapped in atomically at next app launch. UI added in Storage settings.

Signed-off-by: alltheseas <[email protected]>
Changelog-Changed: Switched nostrdb build from Xcode pbxproj to SPM local package
Changelog-Added: Added database compaction UI in storage settings
Nostrdb's nip44.c uses libsodium (crypto_stream_chacha20_ietf_xor_ic).
The Clibsodium xcframework should be declared as a dependency of the
Nostrdb SPM package rather than linked separately by each app target.

Note: The pre-existing modulemap conflict between nostr_sdkFFI.xcframework
and Clibsodium.xcframework (both output include/module.modulemap) still
prevents xcodebuild test from succeeding. This is an upstream issue
requiring unique modulemap paths in one of the xcframeworks.

Signed-off-by: alltheseas <[email protected]>
@rabble
Copy link
Copy Markdown

rabble commented Mar 24, 2026

Great to see NIP-17 support being worked on! We're building divine.video and planning to focus on NIP-17 for DMs — so we're really hoping to see all the major clients adopt it.

The NIP-04 → NIP-17 migration is one of the most important interoperability gaps in Nostr right now. Primal still only supports NIP-04 with no NIP-17 work in progress, and their open issues (android #470, iOS #190) show users hitting DM delivery failures with clients like Amethyst and 0xchat that default to NIP-17. Damus shipping NIP-17 would put more pressure on the remaining holdouts and improve the DM experience across the whole ecosystem.

It's also great that Notedeck already has NIP-17 natively — strong signal of where things are headed.

A few thoughts on the PR itself:

  • Bundling three features (NIP-17, nostrdb submodule, DB compaction) in one PR makes it harder to review and land incrementally. Would it be feasible to split them so NIP-17 can land independently?
  • The cross-client testing from the earlier PR feat(nip17): Add NIP-17 Private Direct Messages support #3590 (0xchat, Nospeak, Pollerama) was valuable — would be great to repeat that before merge.

Looking forward to seeing this ship! 🤙

@alltheseas
Copy link
Copy Markdown
Collaborator Author

@rabble interop DM thread is here: nostrability/nostrability#169

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert nostrdb from vendored directory to git submodule nip-17 DM

2 participants