Skip to main content
Sovran uses NIP-17 (Private Direct Messages) for end-to-end encrypted messaging. This provides significantly better metadata privacy than the legacy NIP-04 standard.

Why NIP-17?

NIP-17 improves on NIP-04 by:
  • Metadata Privacy: Random timestamps and throwaway keys hide sender identity
  • No Plaintext Tags: Recipient info is encrypted in the seal layer
  • Self-Copies: Retrieve your own sent messages from relays
  • Multiple Recipients: Support for group messaging (future)

NIP-04 vs NIP-17 Comparison

FeatureNIP-04 (Legacy)NIP-17 (Modern)
Content EncryptionNIP-04 (weak)NIP-44 (strong)
Sender Visible✅ Yes (pubkey in event)❌ No (random throwaway key)
Recipient Visible✅ Yes (p tag)❌ No (encrypted in seal)
Timestamp Privacy❌ No✅ Yes (randomized)
Self-Copy Support❌ No✅ Yes
NIP-04 is deprecated in Sovran. All new messages use NIP-17.

Three-Layer Encryption

NIP-17 uses a “gift wrap” protocol with three nested layers:

1. Rumor (Kind 14)

The innermost layer containing the actual message. It’s unsigned and includes:
{
  kind: 14,
  content: "Hello, this is a private message",
  tags: [["p", recipientPubkey]],
  created_at: 1234567890,
  pubkey: senderPubkey,
  id: "...", // Computed hash for integrity
  // NO SIGNATURE - prevents sender proof
}

2. Seal (Kind 13)

The middle layer that encrypts the rumor with NIP-44 using the sender’s real key:
{
  kind: 13,
  content: "<nip44-encrypted rumor>",
  tags: [], // Always empty per NIP-59
  created_at: randomTimestamp(),
  pubkey: senderPubkey,
  // SIGNED by sender
}

3. Gift Wrap (Kind 1059)

The outermost layer that hides the sender identity:
{
  kind: 1059,
  content: "<nip44-encrypted seal>",
  tags: [["p", recipientPubkey]], // Only recipient visible
  created_at: randomTimestamp(),
  pubkey: throwawayRandomKey, // Can't be linked to sender
  // SIGNED by throwaway key
}
Only the gift wrap (kind 1059) is published to relays. The seal and rumor are nested inside.

Implementation

Building Gift-Wrapped Messages

// utils/nip17.ts
import { buildGiftWrappedDM } from 'utils/nip17';

const giftWrap = buildGiftWrappedDM({
  content: 'Hello!',
  senderPrivateKey: myPrivateKey,
  recipientPublicKey: theirPubkey,
});

// Publish to relays
const wrapEvent = new NDKEvent(ndk);
wrapEvent.kind = giftWrap.kind;
wrapEvent.content = giftWrap.content;
wrapEvent.tags = giftWrap.tags;
wrapEvent.created_at = giftWrap.created_at;
wrapEvent.pubkey = giftWrap.pubkey;
wrapEvent.id = giftWrap.id;
wrapEvent.sig = giftWrap.sig;

await wrapEvent.publish();

Building with Self-Copy

To retrieve your own sent messages, create two gift wraps from the same rumor:
import { buildGiftWrappedDMPair } from 'utils/nip17';

const { recipientWrap, senderWrap } = buildGiftWrappedDMPair({
  content: 'Hello!',
  senderPrivateKey: myPrivateKey,
  recipientPublicKey: theirPubkey,
});

// Publish to recipient
const recipientEvent = new NDKEvent(ndk);
Object.assign(recipientEvent, recipientWrap);
await recipientEvent.publish();

// Publish self-copy
const senderEvent = new NDKEvent(ndk);
Object.assign(senderEvent, senderWrap);
await senderEvent.publish();
Both wraps share the same rumor with the recipient’s pubkey in the p tag. This is per the NIP-17 spec.

Unwrapping Received Messages

import { unwrapGiftWrap } from 'utils/nip17';

const unwrapped = unwrapGiftWrap(
  { content: event.content, pubkey: event.pubkey },
  myPrivateKey
);

if (unwrapped) {
  console.log('Message:', unwrapped.content);
  console.log('From:', unwrapped.senderPubkey);
  console.log('Recipients:', unwrapped.recipientPubkeys);
  console.log('Timestamp:', unwrapped.created_at);
}

UserMessagesScreen

The main DM interface is implemented in UserMessagesScreen.tsx:
// components/screens/UserMessagesScreen.tsx
import { UserMessagesScreen } from 'components/screens/UserMessagesScreen';

// In your route component:
export default function UserMessagesRoute() {
  const { pubkey } = useLocalSearchParams();
  return <UserMessagesScreen pubkey={pubkey} />;
}

Features

  • NIP-17 Subscription: Listens for kind 1059 events addressed to your pubkey
  • Automatic Decryption: Unwraps messages as they arrive
  • Optimistic Updates: Shows sent messages immediately
  • Read Receipts: Checkmarks for sent/delivered status
  • Token Detection: Extracts and displays Cashu tokens inline

Subscribing to DMs

// Subscribe to gift-wrapped events (kind 1059) addressed to us
const giftWrapFilters = useMemo(() => {
  if (!nostrKeys?.pubkey) return null;
  return [{
    kinds: [1059 as number],
    '#p': [nostrKeys.pubkey],
  }];
}, [nostrKeys?.pubkey]);

const { events: giftWrapEvents } = useSubscribe({ filters: giftWrapFilters });

Unwrapping and Filtering

const unwrappedGiftWrapMessages = useMemo(() => {
  if (!giftWrapEvents?.length || !nostrKeys?.privateKey || !nostrKeys?.pubkey) return [];

  return giftWrapEvents
    .map((event) => {
      const unwrapped = unwrapGiftWrap(
        { content: event.content, pubkey: event.pubkey },
        nostrKeys.privateKey
      );
      if (!unwrapped) return null;

      // Filter: only messages in this conversation (between us and pubkey)
      const isFromCounterparty =
        unwrapped.senderPubkey === pubkey &&
        unwrapped.recipientPubkeys.includes(nostrKeys.pubkey);
      const isFromMe =
        unwrapped.senderPubkey === nostrKeys.pubkey &&
        unwrapped.recipientPubkeys.includes(pubkey);

      if (!isFromCounterparty && !isFromMe) return null;

      return {
        wrapId: event.id,
        ...unwrapped,
      };
    })
    .filter((dm): dm is NonNullable<typeof dm> => dm !== null);
}, [giftWrapEvents, nostrKeys?.privateKey, nostrKeys?.pubkey, pubkey]);

Sending Messages

const handleNostrDMSend = async (text: string) => {
  if (!ndk || !nostrKeys?.privateKey || !nostrKeys?.pubkey || !pubkey) {
    console.error('Missing required data for sending DM');
    return;
  }

  const timestamp = Math.floor(Date.now() / 1000);
  const tempMessageId = `temp-${timestamp}`;

  // Optimistic update
  const optimisticMessage = {
    id: tempMessageId,
    content: text,
    sender: 'me' as const,
    timestamp: formatTimestamp(timestamp),
    isRead: false,
    isSending: true,
    created_at: timestamp,
    pubkey: nostrKeys.pubkey,
  };
  setMessages((prev) => [...prev, optimisticMessage]);

  try {
    // Build NIP-17 gift-wrapped DM pair
    const { recipientWrap, senderWrap } = buildGiftWrappedDMPair({
      content: text,
      senderPrivateKey: nostrKeys.privateKey,
      recipientPublicKey: pubkey,
    });

    // Publish to recipient
    const wrapEvent = new NDKEvent(ndk);
    Object.assign(wrapEvent, recipientWrap);
    await wrapEvent.publish();

    // Publish self-copy
    const selfWrapEvent = new NDKEvent(ndk);
    Object.assign(selfWrapEvent, senderWrap);
    await selfWrapEvent.publish();

    // Update UI
    setMessages((prev) =>
      prev.map((msg) =>
        msg.id === tempMessageId
          ? { ...msg, id: wrapEvent.id, isRead: true, isSending: false }
          : msg
      )
    );
  } catch (error) {
    console.error('Failed to send DM:', error);
    setMessages((prev) => prev.filter((msg) => msg.id !== tempMessageId));
  }
};

useNostrDirectMessage Hook

For sending DMs from anywhere in the app:
import { useNostrDirectMessage } from 'hooks/useNostrDirectMessage';

function SendPaymentRequest() {
  const { sendDirectMessage, isSending, error } = useNostrDirectMessage();

  const handleSend = async () => {
    const payload = { type: 'payment-request', amount: 1000 };
    await sendDirectMessage(
      nprofile,
      JSON.stringify(payload),
      { additionalRelays: ['wss://relay.example.com'] }
    );
  };

  return (
    <Button onPress={handleSend} disabled={isSending}>
      Send Payment Request
    </Button>
  );
}

Hook API

interface UseNostrDirectMessageReturn {
  sendDirectMessage: (
    nprofile: string,
    message: string,
    options?: { additionalRelays?: string[] }
  ) => Promise<void>;
  isSending: boolean;
  error: Error | null;
}

Relay Selection

The hook publishes to multiple relays for reliability:
const targetRelays = [
  ...(profileRelays || FALLBACK_PAYMENT_RELAYS),
  ...(options?.additionalRelays || FALLBACK_PAYMENT_RELAYS),
  DEFAULT_PAYMENT_RELAY,
].filter((relay, index, self) => self.indexOf(relay) === index); // Deduplicate
Default relays:
const DEFAULT_PAYMENT_RELAY = 'wss://relay.vertexlab.io';

const FALLBACK_PAYMENT_RELAYS = [
  'wss://relay.damus.io',
  'wss://relay.8333.space/',
  'wss://nos.lol',
  'wss://relay.primal.net',
];

Message Components

Text Messages

// app/message/components/TextMessage.tsx
function MessageBubble({ message, isMe, userPicture, userName }) {
  return (
    <VStack align={isMe ? 'flex-end' : 'flex-start'}>
      <HStack>
        {!isMe && <Avatar picture={userPicture} seed={message.pubkey} />}
        <View style={{ backgroundColor: isMe ? accentColor : surfaceColor }}>
          <Text>{message.content}</Text>
        </View>
        {isMe && <Avatar seed={message.pubkey} />}
      </HStack>
      <HStack>
        <Text size={12}>{message.timestamp}</Text>
        {isMe && (
          message.isSending ? (
            <Icon name="svg-spinners:90-ring-with-bg" />
          ) : (
            <Icon name={message.isRead ? 'ion:checkmark-done' : 'simple-line-icons:check'} />
          )
        )}
      </HStack>
    </VStack>
  );
}

Cashu Token Messages

Sovran automatically detects and displays Cashu tokens in messages:
// Extract token from message content
function extractCashuToken(content: string): string | null {
  const lowerContent = content.toLowerCase();
  const cashuAIndex = lowerContent.indexOf('cashua');
  const cashuBIndex = lowerContent.indexOf('cashub');

  let tokenStartIndex = -1;
  if (cashuAIndex !== -1 && (cashuBIndex === -1 || cashuAIndex < cashuBIndex)) {
    tokenStartIndex = cashuAIndex;
  } else if (cashuBIndex !== -1) {
    tokenStartIndex = cashuBIndex;
  }

  if (tokenStartIndex === -1) return null;

  const remainingText = content.slice(tokenStartIndex);
  let token = '';
  const maxTokenLength = 5000;

  for (let i = 6; i <= Math.min(remainingText.length, maxTokenLength); i++) {
    const candidate = remainingText.slice(0, i);
    if (isValidEcashToken(candidate)) {
      token = candidate;
    } else if (token) {
      break;
    }
  }

  return token || null;
}
// Display token as a card
function CashuTokenBubble({ token, isMe }) {
  const decoded = getDecodedToken(token);
  const amount = decoded.proofs.reduce((sum, proof) => sum + proof.amount, 0);

  return (
    <Pressable onPress={() => router.navigate('/receiveToken', { token })}>
      <VStack>
        <Text>{decoded.mint}</Text>
        <AmountFormatter amount={amount} unit={decoded.unit} />
        <Button>{isMe ? 'Cancel' : 'Redeem'}</Button>
      </VStack>
    </Pressable>
  );
}

NIP-44 Encryption

NIP-17 uses NIP-44 for encryption (not NIP-04):
import { nip44 } from 'nostr-tools';

const conversationKey = nip44.v2.utils.getConversationKey(
  senderPrivateKey,
  recipientPublicKey
);

const encrypted = nip44.v2.encrypt(plaintext, conversationKey);
const decrypted = nip44.v2.decrypt(encrypted, conversationKey);
Benefits of NIP-44 over NIP-04:
  • Authenticated encryption: Prevents tampering
  • Nonce handling: Better randomness management
  • Standard compliance: Uses established crypto primitives

Metadata Privacy

Random Timestamps

Timestamps are randomized within a 2-day window to prevent timing analysis:
const TWO_DAYS = 2 * 24 * 60 * 60;
const randomNow = (): number => Math.round(Date.now() / 1000 - Math.random() * TWO_DAYS);

Throwaway Keys

Each gift wrap uses a fresh random keypair:
import { generateSecretKey, getPublicKey } from 'nostr-tools';

const randomKey = generateSecretKey();
const randomPubkey = getPublicKey(randomKey);

// Use randomKey to sign the gift wrap
const giftWrap = finalizeEvent(
  { kind: 1059, content: encryptedSeal, tags: [['p', recipientPubkey]] },
  randomKey
);
Relays and observers see:
  • ✅ Recipient pubkey (from p tag)
  • ❌ Sender pubkey (hidden by throwaway key)
  • ❌ Message content (encrypted)
  • ❌ Timestamp (randomized)

Best Practices

// ✅ Use buildGiftWrappedDMPair for self-copy support
const { recipientWrap, senderWrap } = buildGiftWrappedDMPair(...);
await recipientWrap.publish();
await senderWrap.publish();

// ❌ Don't use buildGiftWrappedDM for sent messages
const wrap = buildGiftWrappedDM(...); // No self-copy
const unwrapped = unwrapGiftWrap(event, privateKey);
if (!unwrapped) {
  console.warn('Failed to decrypt message');
  return; // Skip this message
}
const isFromCounterparty =
  unwrapped.senderPubkey === theirPubkey &&
  unwrapped.recipientPubkeys.includes(myPubkey);

const isFromMe =
  unwrapped.senderPubkey === myPubkey &&
  unwrapped.recipientPubkeys.includes(theirPubkey);

if (!isFromCounterparty && !isFromMe) return null;
const existingIds = new Set(messages.map((m) => m.id));
const uniqueNewMessages = newMessages.filter((m) => !existingIds.has(m.id));

Migration from NIP-04

Sovran still reads legacy NIP-04 DMs but never sends them:
// NIP-04 subscription (read-only for backward compatibility)
const dmFilters = useMemo(() => {
  if (!nostrKeys?.pubkey) return null;
  return [
    { kinds: [EncryptedDirectMessage], authors: [nostrKeys.pubkey], '#p': [pubkey] },
    { kinds: [EncryptedDirectMessage], '#p': [nostrKeys.pubkey], authors: [pubkey] },
  ];
}, [pubkey, nostrKeys?.pubkey]);

const { events: dmEvents } = useSubscribe({ filters: dmFilters });

// Decrypt NIP-04 events
await Promise.all(dmEvents.map(async (event) => {
  const counterparty = new NDKUser({ pubkey });
  const signer = new NDKPrivateKeySigner(nostrKeys.privateKey);
  await event.decrypt(counterparty, signer);
  return { content: event.content, ... };
}));
NIP-04 reveals sender/recipient pubkeys and timestamps to relays. Only use for reading old messages.

Identity & Keys

NIP-06 key derivation for message signing

Contacts

Contact lists populated from DM activity

User Profiles

Profile metadata for message senders

Cashu Tokens

Sending ecash via direct messages

Build docs developers (and LLMs) love