relay-membership.service.ts raw

   1  import { Pubkey } from '@/domain'
   2  import { sortEventsDesc } from '@/lib/event'
   3  import client from '@/services/client.service'
   4  import DataLoader from 'dataloader'
   5  import { Filter } from 'nostr-tools'
   6  
   7  /**
   8   * NIP-43: Relay Access Metadata and Requests
   9   * https://github.com/nostr-protocol/nips/blob/master/43.md
  10   */
  11  class RelayMembershipService {
  12    private static instance: RelayMembershipService
  13    private membershipListCache: Map<string, Promise<Set<string>>> = new Map()
  14    private membershipListDataLoader = new DataLoader<
  15      { url: string; pubkey: string },
  16      Set<string>,
  17      string
  18    >(
  19      async (params) => {
  20        return Promise.all(params.map(({ url, pubkey }) => this.fetchMembershipList(url, pubkey)))
  21      },
  22      { cacheKeyFn: (key) => key.url, cacheMap: this.membershipListCache }
  23    )
  24  
  25    public static getInstance(): RelayMembershipService {
  26      if (!RelayMembershipService.instance) {
  27        RelayMembershipService.instance = new RelayMembershipService()
  28      }
  29      return RelayMembershipService.instance
  30    }
  31  
  32    /**
  33     * Check if a user is a member of a relay that supports NIP-43
  34     * @param relayUrl The relay URL
  35     * @param userPubkey The user's public key
  36     * @param relayPubkey The relay's public key from NIP-11
  37     * @returns Membership status
  38     */
  39    async checkMembership(
  40      relayUrl: string,
  41      userPubkey: string,
  42      relayPubkey?: string
  43    ): Promise<boolean> {
  44      if (!relayPubkey) {
  45        return false
  46      }
  47  
  48      const memberSet = await this.membershipListDataLoader.load({
  49        url: relayUrl,
  50        pubkey: relayPubkey
  51      })
  52  
  53      return memberSet.has(userPubkey)
  54    }
  55  
  56    private async fetchMembershipList(relayUrl: string, relayPubkey: string): Promise<Set<string>> {
  57      try {
  58        const filter: Filter = {
  59          kinds: [13534],
  60          authors: [relayPubkey],
  61          limit: 1
  62        }
  63  
  64        const events = await client.fetchEvents([relayUrl], filter)
  65  
  66        if (events.length === 0) {
  67          return new Set()
  68        }
  69  
  70        const membershipEvent = sortEventsDesc(events)[0]
  71        const members = membershipEvent.tags
  72          .filter((tag) => tag[0] === 'member' && Pubkey.isValidHex(tag[1]))
  73          .map((tag) => tag[1])
  74  
  75        return new Set(members)
  76      } catch (error) {
  77        console.error('Error checking relay membership:', error)
  78        return new Set()
  79      }
  80    }
  81  
  82    /**
  83     * Request an invite code from a relay (kind 28935)
  84     * @param relayUrl The relay URL
  85     * @param relayPubkey The relay's public key from NIP-11
  86     * @returns Invite code or null
  87     */
  88    async requestInviteCode(relayUrl: string, relayPubkey: string): Promise<string | null> {
  89      try {
  90        const filter: Filter = {
  91          kinds: [28935],
  92          authors: [relayPubkey],
  93          limit: 1
  94        }
  95  
  96        const events = await client.fetchEvents([relayUrl], filter)
  97  
  98        if (events.length === 0) {
  99          return null
 100        }
 101  
 102        const inviteEvent = events[0]
 103        const claimTag = inviteEvent.tags.find((tag) => tag[0] === 'claim')
 104        return claimTag?.[1] ?? null
 105      } catch (error) {
 106        console.error('Error requesting invite code:', error)
 107        return null
 108      }
 109    }
 110  
 111    async addNewMember(relayUrl: string, newMemberPubkey: string) {
 112      const cache = await this.membershipListCache.get(relayUrl)
 113      if (cache) {
 114        cache.add(newMemberPubkey)
 115      }
 116    }
 117  
 118    async removeMember(relayUrl: string, memberPubkey: string) {
 119      const cache = await this.membershipListCache.get(relayUrl)
 120      if (cache) {
 121        cache.delete(memberPubkey)
 122      }
 123    }
 124  }
 125  
 126  const instance = RelayMembershipService.getInstance()
 127  export default instance
 128