Skip to content

feat(nostr): add NIP-59 Gift Wrap support for private DMs#763

Merged
penso merged 2 commits intomainfrom
shade-nasturtium
Apr 17, 2026
Merged

feat(nostr): add NIP-59 Gift Wrap support for private DMs#763
penso merged 2 commits intomainfrom
shade-nasturtium

Conversation

@penso
Copy link
Copy Markdown
Collaborator

@penso penso commented Apr 17, 2026

Summary

  • Replace NIP-04 (kind:4) outbound DMs with NIP-59 gift wraps (kind:1059), hiding sender/receiver metadata via ephemeral keys
  • Accept both kind:4 (legacy) and kind:1059 inbound for backward compatibility
  • New gift_wrap module with shared send_gift_wrapped_dm() and unwrap_gift_wrap() helpers
  • OTP challenge/verification messages also sent via gift wrap

Validation

Completed

  • cargo check — feature resolution OK
  • cargo test -p moltis-nostr — 28 unit tests + 1 integration test pass
  • just test — all 367 workspace tests pass
  • just lint — clippy clean
  • just format-check — formatting clean

Remaining

  • ./scripts/local-validate.sh with PR number
  • Manual test with real Nostr relay (run ignored integration tests)

Manual QA

  1. Start moltis with a Nostr account configured
  2. Send a DM from a Nostr client (e.g. Damus, Amethyst) — verify inbound works for both NIP-04 and NIP-59
  3. Verify bot replies arrive as gift wraps (kind:1059) in the client
  4. Test OTP flow — non-allowlisted sender should receive gift-wrapped challenge

Closes #759

🤖 Generated with Claude Code

Replace NIP-04 (kind:4) outbound DMs with NIP-59 gift wraps (kind:1059)
which hide sender/receiver metadata using ephemeral keys. Inbound accepts
both kind:4 (legacy backward compat) and kind:1059.

The bus subscription now filters for both kinds with a 2-day wider window
to account for gift wrap timestamp tweaking. handle_event extracts sender,
plaintext, and timestamp early (decrypt for kind:4, unwrap for kind:1059)
then runs a unified pipeline. OTP messages also send via gift wrap.

New gift_wrap module provides shared send_gift_wrapped_dm() and
unwrap_gift_wrap() helpers used by both bus.rs and outbound.rs.

Closes #759

Entire-Checkpoint: 23f41fb4db47
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 17, 2026

Greptile Summary

This PR replaces NIP-04 (kind:4) outbound DMs with NIP-59 gift wraps (kind:1059), adds a new gift_wrap module with shared send/unwrap helpers, and updates inbound handling to accept both formats for backward compatibility. OTP challenge/response messages are also migrated to gift wraps.

The implementation is structurally sound with good test coverage. Two style issues worth addressing: the 172_800 magic constant violates the project's no-magic-epoch-math rule from CLAUDE.md, and the single combined relay subscription filter for both kinds unnecessarily replays 2 days of old kind:4 messages on every startup.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style suggestions that don't affect correctness or security.

The NIP-59 implementation is correct: round-trip crypto is right, error paths are handled, self-message and staleness checks are preserved, and the OTP flow works for both legacy and gift-wrapped replies. The two open issues (magic constant and combined filter) are style/efficiency concerns with no impact on data integrity or correctness.

crates/nostr/src/bus.rs — magic constant and single combined filter; both are P2.

Important Files Changed

Filename Overview
crates/nostr/src/gift_wrap.rs New NIP-59 gift wrap helpers — correct use of nostr-sdk's private_msg / extract_rumor, good error mapping, three unit tests covering round-trip, wrong-recipient, and non-gift-wrap cases.
crates/nostr/src/bus.rs Inbound pipeline updated to accept kind:4 and kind:1059; single relay filter uses 2-day lookback for both kinds, causing needless replay of old kind:4 events on startup. Magic constant 172_800 violates CLAUDE.md no-magic-epoch-math rule.
crates/nostr/src/outbound.rs Outbound send path cleanly replaced: NIP-04 encrypt+kind:4 builder dropped in favour of send_gift_wrapped_dm; metrics label updated to gift_wrap.
crates/nostr/tests/nostr_integration.rs Adds local round-trip test and #[ignore] relay integration test; magic constant 172_800 repeated in test filter setup.
Cargo.toml Adds nip59 feature flag to nostr-sdk workspace dependency; minimal, correct change.
crates/nostr/src/lib.rs Adds pub mod gift_wrap export; needed for integration tests that call moltis_nostr::gift_wrap::unwrap_gift_wrap directly.

Sequence Diagram

sequenceDiagram
    participant Sender as Nostr Client (Sender)
    participant Relay
    participant Bot as Moltis Bot

    Note over Sender,Bot: Outbound (bot → user) — NIP-59 gift wrap
    Bot->>Bot: send_gift_wrapped_dm()<br/>EventBuilder::private_msg()
    Bot->>Relay: publish kind:1059 (outer ephemeral key, #p=recipient)
    Relay-->>Sender: deliver kind:1059
    Sender->>Sender: extract_rumor() → kind:14 plaintext

    Note over Sender,Bot: Inbound (user → bot) — dual kind support
    Sender->>Relay: kind:4 OR kind:1059
    Relay-->>Bot: relay subscription (kind:4 + kind:1059, since=now-2d)
    
    alt kind:1059 gift wrap
        Bot->>Bot: unwrap_gift_wrap()<br/>→ (sender_pubkey, plaintext, rumor.created_at)
    else kind:4 NIP-04
        Bot->>Bot: try_decrypt()<br/>→ (event.pubkey, plaintext, event.created_at)
    end

    Bot->>Bot: dedup / self-check / staleness
    Bot->>Bot: OTP or access-control gate

    opt OTP challenge (new sender)
        Bot->>Relay: send_gift_wrapped_dm() — OTP challenge
        Relay-->>Sender: kind:1059 challenge
        Sender->>Relay: OTP reply (kind:4 or kind:1059)
        Relay-->>Bot: deliver reply
        Bot->>Relay: send_gift_wrapped_dm() — Access granted
    end

    Bot->>Bot: dispatch_to_chat()
Loading

Reviews (1): Last reviewed commit: "feat(nostr): add NIP-59 Gift Wrap suppor..." | Re-trigger Greptile

Comment thread crates/nostr/src/bus.rs Outdated
Comment thread crates/nostr/src/bus.rs Outdated
Comment thread crates/nostr/tests/nostr_integration.rs
Extract TIMESTAMP_WINDOW_SECS constant using time::Duration::days(2)
instead of magic 172_800. Split single combined filter into two separate
subscribe calls so kind:4 uses `since=now` and kind:1059 uses the wider
2-day window, avoiding wasted decryption of stale kind:4 messages on
startup.

Entire-Checkpoint: b3a2714fd875
@penso penso merged commit 9251cf8 into main Apr 17, 2026
19 of 29 checks passed
@penso penso deleted the shade-nasturtium branch April 17, 2026 09:23
@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented Apr 17, 2026

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing shade-nasturtium (54ecd15) with main (c733c91)2

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (5c19899) during the generation of this report, so c733c91 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 17, 2026

Codecov Report

❌ Patch coverage is 40.15152% with 79 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/nostr/src/bus.rs 0.00% 58 Missing ⚠️
crates/nostr/src/gift_wrap.rs 76.81% 16 Missing ⚠️
crates/nostr/src/outbound.rs 0.00% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

[Feature]: Support NIP-59 (Gift Wraps) / NIP-17 for secure Nostr DMs

1 participant