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 displays rich user profiles with social metadata from the Nostr network and Sovran’s reputation API.
Profiles are stored as kind 0 events with JSON content:
{
kind : 0 ,
content : JSON . stringify ({
name: "alice" ,
display_name: "Alice Smith" ,
about: "Bitcoin maximalist and Nostr enthusiast" ,
picture: "https://example.com/avatar.jpg" ,
banner: "https://example.com/banner.jpg" ,
website: "https://alice.com" ,
nip05: "alice@example.com" ,
lud16: "alice@getalby.com" ,
}),
created_at : 1234567890 ,
pubkey : "..." ,
}
Standard Fields
Field Description nameShort handle (e.g., “alice”) display_nameFull name (e.g., “Alice Smith”) aboutBio/description pictureAvatar image URL bannerBanner/header image URL websitePersonal website nip05NIP-05 identifier (verified DNS-based identity) lud16Lightning address (LNURL-pay) lud06Legacy LNURL-pay
Profile Screen
The main profile UI is in app/(user-flow)/profile.tsx:
// app/(user-flow)/profile.tsx
import { router , useLocalSearchParams } from 'expo-router' ;
import { npubToPubkey } from 'helper/nostrClient' ;
export default function UserProfileScreen () {
const { npub , pubkey : pubkeyParam } = useLocalSearchParams ();
const pubkey = useMemo (() => {
if ( pubkeyParam ) return pubkeyParam ;
if ( npub ) return npubToPubkey ( npub );
return '' ;
}, [ npub , pubkeyParam ]);
// Subscribe to profile metadata
const metadataFilters = useMemo (
() => ( pubkey ? [{ authors: [ pubkey ], kinds: [ Metadata ], limit: 1 }] : null ),
[ pubkey ]
);
const { events : metadataEvents , eose : metadataEose } = useSubscribe ({ filters: metadataFilters });
const userInfo = useMemo (() => {
if ( ! metadataEvents ?.[ 0 ]) return null ;
try {
return JSON . parse ( metadataEvents [ 0 ]. content );
} catch {
return null ;
}
}, [ metadataEvents ]);
return (
< UserFeed
pubkey = { pubkey }
authorName = { displayName }
authorPicture = {userInfo?. picture }
ListHeaderComponent = {
<View>
< BannerWithAvatar
bannerUrl = {userInfo?. banner }
pictureUrl = {userInfo?. picture }
pubkey = { pubkey }
displayName = { displayName }
nip05 = {userInfo?. nip05 }
/>
< ProfileStatsGrid
followingCount = { followingCount }
followerCount = { followerCount }
reputationScore = { reputationScore }
joinedDate = { joinedDate }
/>
<TopFollowers topFollowers = {profileData?.topFollowers || []} />
{userInfo?.about && <Card variant="info" message={userInfo.about} />}
</View>
}
/>
);
}
Banner & Avatar
The profile header displays:
Banner image : Full-width background (150px height)
Overlapping avatar : 90px circle with 4px border
Display name : From display_name or name
NIP-05 : Verified identifier with checkmark
Follow button : For non-own profiles
function BannerWithAvatar ({
bannerUrl ,
pictureUrl ,
pubkey ,
displayName ,
nip05 ,
isLoading ,
showFollowButton ,
isFollowing ,
onToggleFollow ,
}) {
const bannerGradientTheme = useMemo (
() => generateSeededGradient ( ` ${ pubkey } :person` , 'person' ),
[ pubkey ]
);
return (
< View >
{ /* Banner with gradient fallback */ }
< View style = {{ height : BANNER_HEIGHT }} >
< LinearGradient colors = {bannerGradientTheme. primaryColors } />
{ bannerUrl && < ExpoImage source = {{ uri : bannerUrl }} /> }
</ View >
{ /* Overlapping avatar */ }
< View style = {{ marginTop : - ( AVATAR_SIZE - AVATAR_OVERLAP ) }} >
< View style = {{ borderWidth : 4 , borderColor : background }} >
< Avatar picture = { pictureUrl } seed = { pubkey } size = { AVATAR_SIZE } />
</ View >
</ View >
{ /* Name & NIP-05 */ }
< VStack align = "center" >
< Text bold size = { 22 } > { displayName } </ Text >
{ nip05 && (
< HStack >
< Icon name = "mdi:check-decagram" size = { 16 } />
< Text size = { 14 } > { nip05 } </ Text >
</ HStack >
)}
{ showFollowButton && (
< TouchableOpacity onPress = { onToggleFollow } >
< Text >{ isFollowing ? 'Following' : 'Follow' }</ Text >
</ TouchableOpacity >
)}
</ VStack >
</ View >
);
}
Profile Stats Grid
Displays a 2x2 grid of key metrics:
function ProfileStatsGrid ({
followingCount ,
followerCount ,
reputationScore ,
joinedDate ,
isLoading ,
}) {
const stats = [
{
label: 'Following' ,
description: 'Users followed' ,
value: followingCount ?. toString () ?? '0' ,
},
{
label: 'Followers' ,
description: 'Total count' ,
value: followerCount ?. toString () ?? '0' ,
},
{
label: 'Reputation' ,
description: 'Network score' ,
value: reputationScore !== undefined ? ` ${ Math . round ( reputationScore ) } / 100` : 'N/A' ,
},
{
label: 'Joined' ,
description: 'Account created' ,
value: joinedDate || 'Unknown' ,
},
];
return (
< View style = {styles. statsGrid } >
< View style = {styles. statsRow } >
{ stats . slice (0, 2). map (( stat ) => (
< View key = {stat. label } style = {styles. statCard } >
< Text bold size = { 12 } > {stat.label.toUpperCase()} </ Text >
< Text bold size = { 20 } > {stat. value } </ Text >
< Text size = { 12 } > {stat. description } </ Text >
</ View >
))}
</ View >
< View style = {styles. statsRow } >
{ stats . slice (2, 4). map (( stat ) => (
< View key = {stat. label } style = {styles. statCard } >
< Text bold size = { 12 } > {stat.label.toUpperCase()} </ Text >
< Text bold size = { 16 } > {stat. value } </ Text >
< Text size = { 12 } > {stat. description } </ Text >
</ View >
))}
</ View >
</ View >
);
}
Reputation System
Sovran uses the Sovran API to fetch reputation scores:
// helper/apiClient.ts
export interface NostrProfileResponse {
pubkey : string ;
npub : string ;
rank : number ;
followers : number ;
follows : number ;
score : number ; // 0-100 reputation score
topFollowers : TopFollower [];
created_at : number ;
fromCache : boolean ;
mintUrl ?: string ; // If user operates a mint
}
export const fetchNostrProfile = ( pubkey : string ) =>
safeFetch < NostrProfileResponse >( ` ${ BASE_URL } /nostr/profile?pubkey= ${ pubkey } ` );
useNostrProfile Hook
// hooks/useNostrProfile.ts
import { fetchNostrProfile , type NostrProfileResponse } from '@/helper/apiClient' ;
export function useNostrProfile ( pubkey : string | null ) : UseNostrProfileResult {
const [ data , setData ] = useState < NostrProfileResponse | null >( null );
const [ isLoading , setIsLoading ] = useState ( false );
const [ error , setError ] = useState < Error | null >( null );
const fetchProfile = useCallback ( async () => {
if ( ! pubkey ) {
setData ( null );
setIsLoading ( false );
return ;
}
setIsLoading ( true );
setError ( null );
const result = await fetchNostrProfile ( pubkey );
if ( result . isOk ()) {
setData ( result . value );
} else {
setError ( result . error );
setData ( null );
}
setIsLoading ( false );
}, [ pubkey ]);
useEffect (() => {
fetchProfile ();
}, [ fetchProfile ]);
return { data , isLoading , error , refetch: fetchProfile };
}
Reputation Score Calculation
The API computes reputation based on:
Follower count : Number of followers
Follower quality : Weighted by follower reputation
Network position : Centrality in follow graph
Content engagement : Likes, replies, reposts
Account age : Older accounts score higher
The exact algorithm is proprietary but follows standard social graph analysis.
Top Followers
Displays the 6 most influential followers:
export interface TopFollower {
pubkey : string ;
npub : string ;
rank : number ;
name ?: string ;
displayName ?: string ;
picture ?: string ;
image ?: string ;
banner ?: string ;
about ?: string ;
nip05 ?: string ;
nip05Valid ?: boolean ;
website ?: string ;
lud16 ?: string ;
}
function TopFollowers ({ topFollowers , isLoading }) {
const followersWithProfiles = useMemo (
() => getFollowersWithProfiles ( topFollowers ). slice ( 0 , 6 ),
[ topFollowers ]
);
return (
< View >
< Text bold size = { 12 } > TOP FOLLOWERS </ Text >
< View style = {styles. topFollowersGrid } >
{ followersWithProfiles . map (( follower ) => (
< TouchableOpacity
key = {follower. pubkey }
onPress = {() => router.navigate( '/profile' , { npub : follower . npub })} >
< Avatar
picture = { getFollowerPicture ( follower )}
seed = {follower. pubkey }
size = { avatarSize }
/>
< Text size = { 11 } numberOfLines = { 1 } >
{ getFollowerDisplayName ( follower )}
</ Text >
</ TouchableOpacity >
))}
</ View >
</ View >
);
}
Helper Functions
// hooks/useNostrProfile.ts
export function getFollowersWithProfiles ( topFollowers : TopFollower []) : TopFollower [] {
return topFollowers . filter (( f ) => f . name || f . displayName || f . picture || f . image );
}
export function getFollowerDisplayName ( follower : TopFollower ) : string {
return follower . displayName || follower . name || follower . npub . slice ( 0 , 12 ) + '...' ;
}
export function getFollowerPicture ( follower : TopFollower ) : string | undefined {
return follower . picture || follower . image ;
}
Follow/Unfollow
Following is implemented via NIP-02 contact lists (kind 3):
const handleToggleFollow = useCallback ( async () => {
if ( ! pubkey || ! nostrKeys ?. pubkey || ! ndk ) {
engagementUpdateFailedPopup ( 'follow' );
return ;
}
if ( nostrKeys . pubkey === pubkey || followInFlight ) return ;
const shouldFollow = ! isFollowingProfile ;
setFollowOptimistic ( pubkey , shouldFollow , true );
const nextTags = buildUpdatedContactTags ( contactsTags , pubkey , shouldFollow );
const createdAt = Math . floor ( Date . now () / 1000 );
try {
const contactEvent = new NDKEvent ( ndk );
contactEvent . kind = Contacts ;
contactEvent . tags = nextTags ;
contactEvent . content = contactsContent ;
contactEvent . created_at = createdAt ;
await contactEvent . publish ();
setContactsFromRelay ({ tags: nextTags , content: contactsContent , createdAt });
clearFollowOptimistic ( pubkey );
} catch {
clearFollowOptimistic ( pubkey );
engagementUpdateFailedPopup ( 'follow' );
}
}, [ pubkey , nostrKeys ?. pubkey , ndk , isFollowingProfile , contactsTags , contactsContent ]);
function buildUpdatedContactTags (
existingTags : string [][],
targetPubkey : string ,
shouldFollow : boolean
) : string [][] {
// Remove existing entry for this pubkey
const nextTags = existingTags . filter (( tag ) => ! ( tag [ 0 ] === 'p' && tag [ 1 ] === targetPubkey ));
// Add back if following
if ( shouldFollow ) {
nextTags . push ([ 'p' , targetPubkey ]);
}
// Deduplicate
const seenP = new Set < string >();
const deduped : string [][] = [];
for ( const tag of nextTags ) {
if ( tag [ 0 ] !== 'p' ) {
deduped . push ( tag );
continue ;
}
const pk = tag [ 1 ];
if ( ! pk || seenP . has ( pk )) continue ;
seenP . add ( pk );
deduped . push ( tag );
}
return deduped ;
}
Optimistic Updates
Follow/unfollow uses optimistic UI updates:
// stores/nostrSocialStore.ts
interface NostrSocialState {
followingPubkeys : Record < string , boolean >;
optimisticFollowsByPubkey : Record < string , { value : boolean ; pending : boolean }>;
setFollowOptimistic : ( pubkey : string , value : boolean , pending : boolean ) => void ;
clearFollowOptimistic : ( pubkey : string ) => void ;
}
export const useNostrSocialStore = create < NostrSocialState >(( set ) => ({
followingPubkeys: {},
optimisticFollowsByPubkey: {},
setFollowOptimistic : ( pubkey , value , pending ) =>
set (( state ) => ({
optimisticFollowsByPubkey: {
... state . optimisticFollowsByPubkey ,
[pubkey]: { value , pending },
},
})),
clearFollowOptimistic : ( pubkey ) =>
set (( state ) => {
const next = { ... state . optimisticFollowsByPubkey };
delete next [ pubkey ];
return { optimisticFollowsByPubkey: next };
}),
}));
Profile Info Section
Displays copyable/clickable profile fields:
const profileInfoItems = useMemo (() => {
const items : {
key : string ;
prefix : React . ReactNode ;
title : string ;
suffixIcon : string ;
onPress : () => void ;
}[] = [
{
key: 'npub' ,
prefix: < CurrencyIcon colors ={[ iconColor ]} width ={ 20 } currency = "nostr" />,
title: truncateMiddle ( npub , 10 ),
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( npub , 'npub' ),
},
];
if ( userInfo ?. nip05 ) {
items . push ({
key: 'nip05' ,
prefix: < Icon name = "mdi:check-decagram" size ={ 20 } />,
title: userInfo . nip05 ,
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( userInfo . nip05 , 'nip05' ),
});
}
if ( userInfo ?. lud16 ) {
items . push ({
key: 'lud16' ,
prefix: < Icon name = "mdi:lightning-bolt" size ={ 20 } />,
title: userInfo . lud16 ,
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( userInfo . lud16 , 'lud16' ),
});
}
if ( userInfo ?. website ) {
items . push ({
key: 'website' ,
prefix: < Icon name = "mdi:web" size ={ 20 } />,
title: userInfo . website ,
suffixIcon: 'mdi:open-in-new' ,
onPress : () => handleOpenLink ( userInfo . website ),
});
}
return items ;
}, [ npub , userInfo , handleCopy , handleOpenLink ]);
return (
< ListGroup variant = "secondary" >
{ profileInfoItems . map (( item ) => (
< PressableFeedback key = {item. key } onPress = {item. onPress } >
< ListGroup . Item >
< ListGroup . ItemPrefix > {item. prefix } </ ListGroup . ItemPrefix >
< ListGroup . ItemContent >
< ListGroup . ItemTitle > {item. title } </ ListGroup . ItemTitle >
</ ListGroup . ItemContent >
< ListGroup . ItemSuffix >
< Icon name = {item. suffixIcon } size = { 20 } />
</ ListGroup . ItemSuffix >
</ ListGroup . Item >
</ PressableFeedback >
))}
</ ListGroup >
);
QR Code Sharing
Profiles can be shared via QR code:
// Header right button
< Link
href = {{
pathname : '/(user-flow)/share' ,
params : {
type : 'npub' ,
data : npub ,
... ( userInfo ?. lud16 && { lud16: userInfo . lud16 }),
},
}}
asChild >
< TouchableOpacity >
< Icon name = "mdi:qrcode" size = { 24 } />
</ TouchableOpacity >
</ Link >
The share screen displays:
QR code : Encoded npub or nprofile
Lightning address : If available
Profile card : Name, picture, NIP-05
Share button : Export to system share sheet
User Feed
Profiles include a feed of recent notes (kind 1 events):
import { UserFeed } from 'components/blocks/UserFeed' ;
< UserFeed
pubkey = { pubkey }
authorName = { displayName }
authorPicture = {userInfo?. picture }
isOwnProfile = { isOwnProfile }
onVideoPostsReady = { handleVideoPostsReady }
ListHeaderComponent = {<ProfileHeader />}
/>
The feed includes:
Text notes : Standard short-form posts
Replies : Threaded conversations
Reposts : Quoted or boosted notes
Reactions : Likes and custom emoji reactions
Video posts are used to populate the Stories feature.
Best Practices
Fallback for missing profile data
const displayName = userInfo ?. display_name || userInfo ?. name || truncateMiddle ( npub , 8 );
const picture = userInfo ?. picture || undefined ; // Triggers gradient
const banner = userInfo ?. banner || undefined ; // Triggers gradient
import { prefetchImages } from '@/helper/imageCache' ;
useEffect (() => {
if ( userInfo ?. picture ) prefetchImages ([ userInfo . picture ]);
if ( userInfo ?. banner ) prefetchImages ([ userInfo . banner ]);
}, [ userInfo ]);
Handle NIP-05 verification
const nip05Verified = userInfo ?. nip05 && ! userInfo ?. hasNip05Conflict ;
{ nip05Verified && (
< HStack >
< Icon name = "mdi:check-decagram" color = { successColor } />
< Text >{userInfo. nip05 } </ Text >
</ HStack >
)}
Safely parse profile JSON
const userInfo = useMemo (() => {
if ( ! metadataEvents ?.[ 0 ]) return null ;
try {
return JSON . parse ( metadataEvents [ 0 ]. content );
} catch {
console . warn ( 'Invalid profile metadata JSON' );
return null ;
}
}, [ metadataEvents ]);
Identity & Keys NIP-06 key derivation for profile signing
Contacts Contact lists and follow management
Direct Messages Send DMs from profile screen
Lightning Address Claim Lightning addresses for your profile