Skip to main content
Sovran builds a unified contact system from multiple Nostr sources:
  • Direct Message Contacts: Recent DM conversations (NIP-04 and NIP-17)
  • Mint Contacts: Mints with Nostr pubkeys in their info
  • Follow Lists: NIP-02 contact lists (following/followers)
This creates a seamless experience where users can send payments to anyone they’ve messaged or any mint they trust.

Contact Sources

DM Contacts

The primary contact source is recent DM activity:
// app/(drawer)/(tabs)/payments/index.tsx
const recentActivityContacts = useMemo(() => {
  if (!nostrKeys?.pubkey) return [];

  const contactMap = new Map();

  // NIP-04 DMs
  dmEvents?.forEach((event) => {
    const otherPubkey =
      event.pubkey === nostrKeys.pubkey
        ? event.tags.find((tag) => tag[0] === 'p')?.[1]
        : event.pubkey;
    if (!otherPubkey) return;

    const existing = contactMap.get(otherPubkey);
    const ts = event.created_at || 0;
    if (!existing || ts > existing.timestamp) {
      contactMap.set(otherPubkey, { type: 'nip04', event, timestamp: ts });
    }
  });

  // NIP-17 DMs
  unwrappedDMs.forEach((dm) => {
    const otherPubkey =
      dm.senderPubkey === nostrKeys.pubkey ? dm.recipientPubkeys[0] : dm.senderPubkey;
    if (!otherPubkey) return;

    const existing = contactMap.get(otherPubkey);
    if (!existing || dm.created_at > existing.timestamp) {
      contactMap.set(otherPubkey, { type: 'nip17', dm, timestamp: dm.created_at });
    }
  });

  return Array.from(contactMap.entries())
    .map(([pubkey, entry]) => ({
      type: 'contact',
      pubkey,
      dmEvent: entry.type === 'nip04' ? entry.event : null,
      nip17Content: entry.type === 'nip17' ? entry.dm?.content : undefined,
      timestamp: entry.timestamp,
    }))
    .sort((a, b) => b.timestamp - a.timestamp);
}, [dmEvents, unwrappedDMs, nostrKeys?.pubkey]);
Features:
  • Unified NIP-04 and NIP-17: Combines both DM protocols
  • Most recent message: Shows latest message from each contact
  • Automatic decryption: Messages decrypted for preview
  • Sorted by recency: Most recent conversations first

Mint Contacts

Mints that have a Nostr pubkey in their /v1/info endpoint:
const mintsWithMetadata = useMemo(() => {
  const dmMap = new Map();
  dmEvents?.forEach((event) => {
    const otherPubkey =
      event.pubkey === nostrKeys?.pubkey
        ? event.tags.find((tag) => tag[0] === 'p')?.[1]
        : event.pubkey;
    if (!otherPubkey) return;

    const existing = dmMap.get(otherPubkey);
    if (!existing || (event.created_at && event.created_at > existing.created_at)) {
      dmMap.set(otherPubkey, event);
    }
  });

  return mintsWithInfo.map(({ mint, mintInfo }) => {
    let mintPubkey = null;
    const nostrContact = mintInfo.contact?.find((c: any) => c.method === 'nostr');
    if (nostrContact?.info) {
      try {
        mintPubkey = npubToPubkey(nostrContact.info);
      } catch {
        // Ignore decode failure
      }
    }

    return {
      type: 'mint',
      pubkey: mintPubkey,
      mint,
      mintInfo,
      dmEvent: mintPubkey ? dmMap.get(mintPubkey) : undefined,
      timestamp: mintPubkey ? dmMap.get(mintPubkey)?.created_at || 0 : 0,
    };
  });
}, [mintsWithInfo, dmEvents, nostrKeys?.pubkey]);
Use Cases:
  • Support requests: Message mint operators about issues
  • Payment confirmations: Get receipts from mint DMs
  • Mint discovery: Find mints via Nostr contacts

Default Contacts

Sovran includes default contacts for new users:
const DEFAULT_CONTACTS = [
  {
    npub: 'npub1ref7jqxrh0z74554y900ufajer2lh52lk0wczrdrqcm8fjmjzweqll64x3',
    label: 'Sovran',
  },
  {
    npub: 'npub1ceel7z6ly287kz4mzqqcsgtc6nzc30zw2ru9w9e4gj64gw69f7qscyf0p8',
    label: 'kelbie',
  },
];

const contactsWithDefaults = useMemo(() => {
  const existingPubkeys = new Set(recentActivityContacts.map((c) => c.pubkey));

  const defaultsToAdd = defaultContactPubkeys
    .filter((dc) => !existingPubkeys.has(dc.pubkey))
    .map((dc) => ({
      type: 'contact' as const,
      pubkey: dc.pubkey,
      dmEvent: null,
      nip17Content: undefined,
      timestamp: 0,
      isDefault: true,
    }));

  return [...recentActivityContacts, ...defaultsToAdd];
}, [recentActivityContacts, defaultContactPubkeys]);

DraggableContactsList Component

The main contact list UI:
// components/blocks/payments/DraggableContactsList.tsx
interface DraggableContactsListProps {
  profilesMap: Map<string, any>;
  data: any[];
  isDecrypting: boolean;
  isLoadingProfiles?: boolean;
  emptyMessage: string;
}

export const DraggableContactsList: FC<DraggableContactsListProps> = ({
  profilesMap,
  data,
  isDecrypting,
  isLoadingProfiles = false,
  emptyMessage,
}) => {
  if (isDecrypting) {
    return <Text>Decrypting messages...</Text>;
  }

  if (data.length === 0) {
    return <Text>{emptyMessage}</Text>;
  }

  return (
    <ScrollView>
      {data.map((item) => {
        const profile = item.pubkey ? profilesMap.get(item.pubkey) : undefined;
        return (
          <ContactItem
            key={item.pubkey || item.mint?.mintUrl}
            item={item}
            profile={profile}
            isLoadingProfile={isLoadingProfiles && !profile}
          />
        );
      })}
    </ScrollView>
  );
};

ContactItem

Each contact displays:
  • Avatar: Profile picture or gradient based on pubkey
  • Name: Display name, NIP-05, or truncated npub
  • Last Message: Decrypted preview of most recent DM
  • Timestamp: Relative time (“5m ago”, “Yesterday”, etc.)
// components/blocks/contacts/ContactItem.tsx
function ContactItem({ item, profile, isLoadingProfile }) {
  const displayName = profile?.displayName || profile?.name || truncateMiddle(item.pubkey, 8);
  const lastMessage = item.nip17Content || item.dmEvent?.content || '';

  return (
    <Pressable onPress={() => router.navigate('/userMessages', { pubkey: item.pubkey })}>
      <HStack>
        <Avatar picture={profile?.picture} seed={item.pubkey} loading={isLoadingProfile} />
        <VStack>
          <Text bold>{displayName}</Text>
          <Text size={14} numberOfLines={1}>{lastMessage}</Text>
        </VStack>
        <Spacer />
        <Text size={12}>{formatTimestamp(item.timestamp)}</Text>
      </HStack>
    </Pressable>
  );
}

Profile Metadata Subscription

Contacts are enriched with profile metadata (kind 0 events):
const profileFilters = useMemo(() => {
  const allPubkeys = [
    ...defaultContactPubkeys.map((dc) => dc.pubkey),
    ...decryptedContacts.map((item: any) => item.pubkey),
    ...decryptedMints.map((item: any) => item.pubkey),
  ].filter((pubkey): pubkey is string => !!pubkey);

  const uniquePubkeys = [...new Set(allPubkeys)];
  if (uniquePubkeys.length === 0) return null;
  return [{ kinds: [0], authors: uniquePubkeys }];
}, [decryptedContacts, decryptedMints, defaultContactPubkeys]);

const { events: profileEvents, eose: profilesEose } = useSubscribe({ filters: profileFilters });
const isLoadingProfiles = !profilesEose;

const profilesMap = useMemo(() => {
  const map = new Map();
  profileEvents?.forEach((event) => {
    try {
      map.set(event.pubkey, JSON.parse(event.content));
    } catch {
      // Skip invalid profile JSON
    }
  });
  return map;
}, [profileEvents]);
Profile fields used:
  • name: Short handle
  • displayName: Full name
  • picture: Avatar image URL
  • about: Bio/description
  • nip05: Verified identifier
  • lud16: Lightning address

Message Decryption

Contacts show a preview of the last message, which requires decryption:
async function decryptNip04Events<T extends { pubkey: string | null; dmEvent?: any }>(
  items: T[],
  privateKey: Uint8Array
): Promise<T[]> {
  const signer = new NDKPrivateKeySigner(privateKey);
  const results: T[] = [];

  for (const item of items) {
    try {
      if (item.nip17Content !== undefined) {
        // Already decrypted during unwrapping
        results.push({ ...item, dmEvent: { content: item.nip17Content } });
        continue;
      }
      if (!item.dmEvent || !item.pubkey) {
        results.push(item);
        continue;
      }
      if (item.dmEvent instanceof NDKEvent) {
        const counterparty = new NDKUser({ pubkey: item.pubkey });
        await item.dmEvent.decrypt(counterparty, signer);
        results.push({ ...item, dmEvent: { ...item.dmEvent, content: item.dmEvent.content } });
      } else {
        results.push(item);
      }
    } catch {
      // Decryption failed - show placeholder
      results.push({ ...item, dmEvent: { ...item.dmEvent, content: '[Encrypted message]' } });
    }
  }
  return results;
}
Decryption states:
  • NIP-17: Already decrypted during unwrapGiftWrap
  • NIP-04: Decrypt using NDKEvent.decrypt()
  • Failed: Show “[Encrypted message]” placeholder
The payments screen includes search for finding new contacts:
const searchUsers = useCallback(async (query: string) => {
  if (!query.trim()) return;
  setSearchLoading(true);
  setHasSearched(true);

  try {
    const result = await apiSearchUsers({ query, limit: 10 });
    if (result.isOk()) {
      const data = result.value;
      if (data.results && Array.isArray(data.results)) {
        const formatted: SearchResultData[] = data.results.map((res) => ({
          pubkey: res.pubkey,
          profile: { ...res },
        }));
        setSearchResults(formatted);
        if (formatted.length > 0) addSearchToHistory(query, 'payments');
      } else {
        setSearchResults([]);
      }
    } else {
      setSearchResults([]);
    }
  } catch {
    setSearchResults([]);
  } finally {
    setSearchLoading(false);
  }
}, [addSearchToHistory]);
Search features:
  • Debounced input: 500ms delay to avoid excessive API calls
  • Skeleton states: Show placeholders while searching
  • Search history: Track recent searches
  • API-based: Uses Sovran API for user search

SearchResult Component

function SearchResult({ result, loading, onPress }) {
  if (loading) {
    return (
      <HStack>
        <Skeleton style={{ width: 48, height: 48, borderRadius: 24 }} />
        <VStack>
          <Skeleton style={{ width: 120, height: 16 }} />
          <Skeleton style={{ width: 180, height: 14 }} />
        </VStack>
      </HStack>
    );
  }

  const displayName = result.profile?.displayName || result.profile?.name || truncateMiddle(result.pubkey, 8);

  return (
    <Pressable onPress={() => onPress(result)}>
      <HStack>
        <Avatar picture={result.profile?.picture} seed={result.pubkey} />
        <VStack>
          <Text bold>{displayName}</Text>
          {result.profile?.nip05 && (
            <HStack>
              <Icon name="mdi:check-decagram" size={14} />
              <Text size={14}>{result.profile.nip05}</Text>
            </HStack>
          )}
        </VStack>
      </HStack>
    </Pressable>
  );
}

Tabs: Recent Activity vs Mints

The payments screen has two tabs:
const TABS = ['Recent activity', 'Mints'];

<Tabs
  tabs={TABS}
  selectedTab={selectedTab}
  handleTabPress={handleTabPress}
  amounts={[String(decryptedContacts.length), String(decryptedMints.length)]}
/>

<PagerView onPageSelected={onPageSelected}>
  <View key="1">
    <DraggableContactsList
      data={decryptedContacts}
      profilesMap={profilesMap}
      isDecrypting={isDecrypting}
      isLoadingProfiles={isLoadingProfiles}
      emptyMessage="No recent conversations found"
    />
  </View>
  <View key="2">
    <DraggableContactsList
      data={decryptedMints}
      profilesMap={profilesMap}
      isDecrypting={mintInfoLoading || isDecryptingMints}
      isLoadingProfiles={isLoadingProfiles}
      emptyMessage="No mints with nostr contacts found"
    />
  </View>
</PagerView>

NIP-02 Contact Lists

Contact lists (kind 3 events) track who you follow:
const latestContactListEvent = useMemo(() => {
  if (!nostrKeys?.pubkey) return null;
  const candidates = (contactListEvents || []).filter(
    (event) => event.kind === Contacts && event.pubkey === nostrKeys.pubkey
  );
  if (candidates.length === 0) return null;
  return [...candidates].sort((a, b) => {
    const byCreatedAt = (b.created_at || 0) - (a.created_at || 0);
    if (byCreatedAt !== 0) return byCreatedAt;
    return (b.id || '').localeCompare(a.id || '');
  })[0];
}, [contactListEvents, nostrKeys?.pubkey]);

useEffect(() => {
  if (!latestContactListEvent) return;
  const tags = Array.isArray(latestContactListEvent.tags)
    ? (latestContactListEvent.tags as string[][])
    : [];
  const content =
    typeof latestContactListEvent.content === 'string' ? latestContactListEvent.content : '';
  setContactsFromRelay({ tags, content, createdAt: latestContactListEvent.created_at || 0 });
}, [latestContactListEvent, setContactsFromRelay]);
Contact list structure:
{
  kind: 3,
  content: "", // Optional JSON relay list
  tags: [
    ["p", "<pubkey1>", "<relay1>", "<petname1>"],
    ["p", "<pubkey2>", "<relay2>", "<petname2>"],
    // ...
  ],
  created_at: 1234567890,
}

Contact Management

Follow/Unfollow

See User Profiles for implementation.

Blocking

Blocking is not yet implemented in Sovran but can use NIP-51 mute lists.

Pet Names

The third parameter in p tags can store custom nicknames:
["p", "<pubkey>", "wss://relay.example.com", "Alice"]
These are displayed instead of display names when set.

Best Practices

const allContacts = useMemo(() => {
  const map = new Map();
  
  // DM contacts (highest priority)
  dmContacts.forEach(c => map.set(c.pubkey, { ...c, source: 'dm' }));
  
  // Mint contacts
  mintContacts.forEach(c => {
    if (!map.has(c.pubkey)) map.set(c.pubkey, { ...c, source: 'mint' });
  });
  
  // Follow list
  followContacts.forEach(c => {
    if (!map.has(c.pubkey)) map.set(c.pubkey, { ...c, source: 'follow' });
  });
  
  return Array.from(map.values());
}, [dmContacts, mintContacts, followContacts]);
import { prefetchImages } from '@/helper/imageCache';

useEffect(() => {
  prefetchImages(Array.from(profilesMap.values()).map((p: any) => p?.picture));
}, [profilesMap]);
const displayName = profile?.displayName || profile?.name || truncateMiddle(npub, 8);
const picture = profile?.picture || undefined; // Triggers gradient avatar
const decryptedContacts = await decryptNip04Events(contactsWithDefaults, privateKey);

Direct Messages

NIP-17 encrypted messaging for contact DMs

User Profiles

Profile metadata and follow lists

Identity & Keys

Key management for contact encryption

Mint Discovery

Finding mints with Nostr contacts

Build docs developers (and LLMs) love