Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sovranBitcoin/sovran/llms.txt

Use this file to discover all available pages before exploring further.

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