nostr-social-graph

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Nostr Social Graph

Nostr社交图谱

Overview

概述

Build and traverse Nostr social graphs: follow lists, relay discovery, the outbox model, and NIP-51 lists. This skill handles the non-obvious parts — correct tag structures for each list kind, the outbox model's read/write relay routing, private list item encryption, and efficient graph traversal patterns.
构建和遍历Nostr社交图谱:关注列表、中继发现、发件箱模型以及NIP-51列表。本技能处理所有易出错的细节——每种列表类型的正确标签结构、发件箱模型的读写中继路由、私有列表条目加密,以及高效的图谱遍历模式。

When to Use

使用场景

  • Building follow/unfollow functionality (kind:3)
  • Implementing a feed that fetches posts from followed users
  • Discovering where a user publishes or reads (kind:10002)
  • Implementing the outbox model for relay-aware fetching
  • Creating mute lists, bookmark lists, or pin lists (NIP-51)
  • Building lists with private encrypted entries
  • Traversing the social graph (follows-of-follows, Web of Trust)
  • Working with follow sets (kind:30000) or relay sets (kind:30002)
Do NOT use when:
  • Building individual events like notes or replies (use nostr-event-builder)
  • Implementing relay WebSocket protocol logic
  • Working with NIP-19 encoding/decoding
  • Building subscription filters (REQ messages)
  • 开发关注/取关功能(类型:3)
  • 实现从关注用户拉取动态的信息流
  • 发现用户发布或读取内容的中继节点(类型:10002)
  • 实现支持中继感知的事件拉取发件箱模型
  • 创建静音列表、书签列表或置顶列表(NIP-51)
  • 构建包含私有加密条目的列表
  • 遍历社交图谱(关注的关注对象、信任网络)
  • 处理关注集合(类型:30000)或中继集合(类型:30002)
请勿在以下场景使用:
  • 构建笔记、回复等独立事件(请使用nostr-event-builder)
  • 实现中继WebSocket协议逻辑
  • 处理NIP-19编码/解码相关需求
  • 构建订阅过滤器(REQ消息)

Workflow

工作流

1. Identify the List Kind

1. 确定列表类型

Ask: "What social graph data is the developer working with?"
IntentKindCategoryNIP
Follow/unfollow users3ReplaceableNIP-02
Mute users, words, hashtags10000ReplaceableNIP-51
Pin notes to profile10001ReplaceableNIP-51
Advertise read/write relays10002ReplaceableNIP-65
Bookmark events10003ReplaceableNIP-51
Set DM relay preferences10050ReplaceableNIP-51
Categorized follow groups30000AddressableNIP-51
User-defined relay groups30002AddressableNIP-51
See references/list-kinds.md for all list kinds with full tag structures.
先明确:"开发者正在处理哪类社交图谱数据?"
用途类型分类NIP
关注/取关用户3可替换NIP-02
静音用户、关键词、话题标签10000可替换NIP-51
笔记置顶到个人主页10001可替换NIP-51
公示读写中继节点10002可替换NIP-65
收藏事件10003可替换NIP-51
设置私信中继偏好10050可替换NIP-51
分类关注分组30000可寻址NIP-51
用户自定义中继分组30002可寻址NIP-51
查看 references/list-kinds.md 可获取所有列表类型的完整标签结构。

2. Build the Event Structure

2. 构建事件结构

Follow List (Kind:3)

关注列表(类型:3)

json
{
  "kind": 3,
  "tags": [
    ["p", "<pubkey-hex>", "<relay-url>", "<petname>"],
    ["p", "<pubkey-hex>", "<relay-url>"],
    ["p", "<pubkey-hex>"]
  ],
  "content": ""
}
Rules:
  • Replaceable: only the latest kind:3 per pubkey is kept
  • Each
    p
    tag = one followed pubkey
  • Relay URL and petname are optional (can be empty string or omitted)
  • content
    is empty (historically held relay prefs, now use kind:10002)
  • Append new follows to the end for chronological ordering
  • Publish the complete list every time — new event replaces the old one
json
{
  "kind": 3,
  "tags": [
    ["p", "<pubkey-hex>", "<relay-url>", "<petname>"],
    ["p", "<pubkey-hex>", "<relay-url>"],
    ["p", "<pubkey-hex>"]
  ],
  "content": ""
}
规则:
  • 可替换:每个公钥仅保留最新的类型:3事件
  • 每个
    p
    标签对应一个关注的公钥
  • 中继URL和备注名是可选的(可留空或省略)
  • content
    为空(历史上用于存储中继偏好,现在请使用类型:10002)
  • 新关注对象追加到末尾保证时间顺序
  • 每次发布完整列表——新事件会完全覆盖旧版本

Relay List Metadata (Kind:10002)

中继列表元数据(类型:10002)

json
{
  "kind": 10002,
  "tags": [
    ["r", "wss://relay.example.com"],
    ["r", "wss://write-only.example.com", "write"],
    ["r", "wss://read-only.example.com", "read"]
  ],
  "content": ""
}
Rules:
  • Replaceable: only the latest kind:10002 per pubkey is kept
  • r
    tags with relay URLs
  • No marker = both read AND write
  • "read"
    marker = read only
  • "write"
    marker = write only
  • Keep small: guide users to 2-4 relays per category
  • Spread this event widely for discoverability
json
{
  "kind": 10002,
  "tags": [
    ["r", "wss://relay.example.com"],
    ["r", "wss://write-only.example.com", "write"],
    ["r", "wss://read-only.example.com", "read"]
  ],
  "content": ""
}
规则:
  • 可替换:每个公钥仅保留最新的类型:10002事件
  • r
    标签存储中继URL
  • 无标记 = 同时支持读和写
  • "read"
    标记 = 仅可读
  • "write"
    标记 = 仅可写
  • 保持精简:引导用户每个类别选择2-4个中继即可
  • 尽量广泛广播该事件提升可发现性

Mute List (Kind:10000)

静音列表(类型:10000)

json
{
  "kind": 10000,
  "tags": [
    ["p", "<pubkey-hex>"],
    ["t", "hashtag-to-mute"],
    ["word", "lowercase-word"],
    ["e", "<thread-event-id>"]
  ],
  "content": "<encrypted-private-items-or-empty>"
}
Rules:
  • Public items go in
    tags
  • Private items go in
    content
    as encrypted JSON
  • Supports
    p
    (pubkeys),
    t
    (hashtags),
    word
    (strings),
    e
    (threads)
  • Words should be lowercase for case-insensitive matching
json
{
  "kind": 10000,
  "tags": [
    ["p", "<pubkey-hex>"],
    ["t", "hashtag-to-mute"],
    ["word", "lowercase-word"],
    ["e", "<thread-event-id>"]
  ],
  "content": "<encrypted-private-items-or-empty>"
}
规则:
  • 公开条目放在
    tags
  • 私有条目以加密JSON形式放在
    content
  • 支持
    p
    (公钥)、
    t
    (话题标签)、
    word
    (字符串)、
    e
    (主题帖)类型
  • 关键词需使用小写,方便不区分大小写匹配

3. Handle Private List Items (NIP-51 Encryption)

3. 处理私有列表条目(NIP-51加密)

Any NIP-51 list can have private items. Public items live in
tags
, private items are encrypted in
content
.
Encryption process:
typescript
// Private items use the same tag structure as public items
const privateItems = [
  ["p", "07caba282f76441955b695551c3c5c742e5b9202a3784780f8086fdcdc1da3a9"],
  ["word", "nsfw"],
];

// Encrypt with NIP-44 using the author's own key pair
// The shared key is computed from author's pubkey + privkey
const encrypted = nip44.encrypt(
  JSON.stringify(privateItems),
  conversationKey(authorPrivkey, authorPubkey),
);

event.content = encrypted;
Decryption and backward compatibility:
typescript
function decryptPrivateItems(event, authorPrivkey) {
  if (!event.content || event.content === "") return [];

  // Detect NIP-04 vs NIP-44 by checking for "iv" in ciphertext
  if (event.content.includes("?iv=")) {
    // Legacy NIP-04 format
    return JSON.parse(
      nip04.decrypt(authorPrivkey, authorPubkey, event.content),
    );
  } else {
    // Current NIP-44 format
    return JSON.parse(nip44.decrypt(
      conversationKey(authorPrivkey, authorPubkey),
      event.content,
    ));
  }
}
任意NIP-51列表都可包含私有条目。公开条目存储在
tags
中,私有条目加密后存储在
content
中。
加密流程:
typescript
// Private items use the same tag structure as public items
const privateItems = [
  ["p", "07caba282f76441955b695551c3c5c742e5b9202a3784780f8086fdcdc1da3a9"],
  ["word", "nsfw"],
];

// Encrypt with NIP-44 using the author's own key pair
// The shared key is computed from author's pubkey + privkey
const encrypted = nip44.encrypt(
  JSON.stringify(privateItems),
  conversationKey(authorPrivkey, authorPubkey),
);

event.content = encrypted;
解密与向下兼容:
typescript
function decryptPrivateItems(event, authorPrivkey) {
  if (!event.content || event.content === "") return [];

  // Detect NIP-04 vs NIP-44 by checking for "iv" in ciphertext
  if (event.content.includes("?iv=")) {
    // Legacy NIP-04 format
    return JSON.parse(
      nip04.decrypt(authorPrivkey, authorPubkey, event.content),
    );
  } else {
    // Current NIP-44 format
    return JSON.parse(nip44.decrypt(
      conversationKey(authorPrivkey, authorPubkey),
      event.content,
    ));
  }
}

4. Implement the Outbox Model

4. 实现发件箱模型

The outbox model is the correct way to route event fetching across relays. See references/outbox-model.md for the full explanation.
Core rules:
ActionWhich relays to use
Fetch events FROM a userThat user's WRITE relays
Fetch events ABOUT a user (mentions)That user's READ relays
Publish your own eventYour WRITE relays
Notify tagged usersEach tagged user's READ relays
Spread your kind:10002As many relays as viable
Implementation pattern:
typescript
async function buildFeedSubscriptions(myFollows: string[]) {
  // Step 1: Fetch kind:10002 for each followed user
  const relayLists = await fetchRelayLists(myFollows);

  // Step 2: Group follows by their WRITE relays
  const relayToUsers = new Map<string, string[]>();
  for (const [pubkey, relays] of relayLists) {
    const writeRelays = getWriteRelays(relays); // "write" or no marker
    for (const relay of writeRelays) {
      if (!relayToUsers.has(relay)) relayToUsers.set(relay, []);
      relayToUsers.get(relay)!.push(pubkey);
    }
  }

  // Step 3: Subscribe to each relay for its relevant users
  for (const [relay, users] of relayToUsers) {
    subscribe(relay, { kinds: [1], authors: users });
  }
}

function getWriteRelays(tags: string[][]): string[] {
  return tags
    .filter((t) => t[0] === "r" && (t.length === 2 || t[2] === "write"))
    .map((t) => t[1]);
}

function getReadRelays(tags: string[][]): string[] {
  return tags
    .filter((t) => t[0] === "r" && (t.length === 2 || t[2] === "read"))
    .map((t) => t[1]);
}
发件箱模型是跨中继路由拉取事件的标准实现方案。查看 references/outbox-model.md 可获取完整说明。
核心规则:
操作应使用的中继
拉取某个用户发布的事件该用户的WRITE中继
拉取提及某个用户的事件该用户的READ中继
发布你自己的事件你的WRITE中继
通知被@的用户每个被@用户的READ中继
广播你的类型:10002事件尽可能多的可用中继
实现示例:
typescript
async function buildFeedSubscriptions(myFollows: string[]) {
  // Step 1: Fetch kind:10002 for each followed user
  const relayLists = await fetchRelayLists(myFollows);

  // Step 2: Group follows by their WRITE relays
  const relayToUsers = new Map<string, string[]>();
  for (const [pubkey, relays] of relayLists) {
    const writeRelays = getWriteRelays(relays); // "write" or no marker
    for (const relay of writeRelays) {
      if (!relayToUsers.has(relay)) relayToUsers.set(relay, []);
      relayToUsers.get(relay)!.push(pubkey);
    }
  }

  // Step 3: Subscribe to each relay for its relevant users
  for (const [relay, users] of relayToUsers) {
    subscribe(relay, { kinds: [1], authors: users });
  }
}

function getWriteRelays(tags: string[][]): string[] {
  return tags
    .filter((t) => t[0] === "r" && (t.length === 2 || t[2] === "write"))
    .map((t) => t[1]);
}

function getReadRelays(tags: string[][]): string[] {
  return tags
    .filter((t) => t[0] === "r" && (t.length === 2 || t[2] === "read"))
    .map((t) => t[1]);
}

5. Traverse the Social Graph

5. 遍历社交图谱

Follows-of-follows (2-hop):
typescript
async function getFollowsOfFollows(pubkey: string) {
  // 1. Get direct follows
  const myFollowList = await fetchKind3(pubkey);
  const directFollows = myFollowList.tags
    .filter((t) => t[0] === "p")
    .map((t) => t[1]);

  // 2. Fetch kind:3 for each direct follow
  const secondHop = await Promise.all(
    directFollows.map((pk) => fetchKind3(pk)),
  );

  // 3. Aggregate and rank by frequency
  const scores = new Map<string, number>();
  for (const followList of secondHop) {
    for (const tag of followList.tags.filter((t) => t[0] === "p")) {
      const pk = tag[1];
      if (pk !== pubkey && !directFollows.includes(pk)) {
        scores.set(pk, (scores.get(pk) || 0) + 1);
      }
    }
  }

  return [...scores.entries()].sort((a, b) => b[1] - a[1]);
}
Web of Trust scoring:
typescript
function wotScore(
  target: string,
  myFollows: string[],
  followLists: Map<string, string[]>,
): number {
  let score = 0;
  for (const follow of myFollows) {
    const theirFollows = followLists.get(follow) || [];
    if (theirFollows.includes(target)) score++;
  }
  return score; // Higher = more trusted
}
关注的关注对象(2跳关系):
typescript
async function getFollowsOfFollows(pubkey: string) {
  // 1. Get direct follows
  const myFollowList = await fetchKind3(pubkey);
  const directFollows = myFollowList.tags
    .filter((t) => t[0] === "p")
    .map((t) => t[1]);

  // 2. Fetch kind:3 for each direct follow
  const secondHop = await Promise.all(
    directFollows.map((pk) => fetchKind3(pk)),
  );

  // 3. Aggregate and rank by frequency
  const scores = new Map<string, number>();
  for (const followList of secondHop) {
    for (const tag of followList.tags.filter((t) => t[0] === "p")) {
      const pk = tag[1];
      if (pk !== pubkey && !directFollows.includes(pk)) {
        scores.set(pk, (scores.get(pk) || 0) + 1);
      }
    }
  }

  return [...scores.entries()].sort((a, b) => b[1] - a[1]);
}
信任网络评分:
typescript
function wotScore(
  target: string,
  myFollows: string[],
  followLists: Map<string, string[]>,
): number {
  let score = 0;
  for (const follow of myFollows) {
    const theirFollows = followLists.get(follow) || [];
    if (theirFollows.includes(target)) score++;
  }
  return score; // Higher = more trusted
}

6. Validate Before Publishing

6. 发布前校验

  • Kind is correct for the list type
  • All tag types are valid for this kind (see list-kinds reference)
  • For kind:3: every entry is a
    p
    tag with valid 32-byte hex pubkey
  • For kind:10002: every entry is an
    r
    tag with valid
    wss://
    URL
  • For kind:10002: markers are only
    "read"
    or
    "write"
    (or omitted)
  • For kind:10000:
    word
    tags use lowercase strings
  • Private items are encrypted with NIP-44 (not NIP-04 for new events)
  • The complete list is published (not just additions/removals)
  • content
    is empty string when no private items exist
  • created_at
    is Unix timestamp in seconds
  • 列表类型对应的kind值正确
  • 所有标签类型对应该kind都是合法的(参考列表类型文档)
  • 类型:3:每个条目都是
    p
    标签,包含合法的32字节十六进制公钥
  • 类型:10002:每个条目都是
    r
    标签,包含合法的
    wss://
    URL
  • 类型:10002:标记只能是
    "read"
    "write"
    或省略
  • 类型:10000:
    word
    标签使用小写字符串
  • 私有条目使用NIP-44加密(新事件不要使用NIP-04)
  • 发布的是完整列表(不是仅新增/删除的部分)
  • 无私有条目时
    content
    为空字符串
  • created_at
    是秒级Unix时间戳

Common Mistakes

常见错误

MistakeWhy It BreaksFix
Publishing only new follows instead of the full listKind:3 is replaceable — new event replaces old, so partial list = lost followsAlways publish the complete follow list
Using kind:3 content for relay preferencesDeprecated; clients ignore itUse kind:10002 for relay list metadata
Treating unmarked
r
tags as write-only
No marker means BOTH read and write
["r", "wss://..."]
= read + write
Fetching from a user's READ relays for their postsREAD relays are where they expect mentions, not where they publishUse WRITE relays to fetch a user's own events
Broadcasting to all known relaysWastes bandwidth, defeats the outbox modelUse targeted relay routing per the outbox model
Using NIP-04 for new private list itemsNIP-04 is deprecated for this purposeUse NIP-44 encryption for new events
Forgetting to spread kind:10002 widelyOthers can't discover your relaysPublish kind:10002 to well-known indexer relays
Uppercase words in mute list
word
tags
Matching should be case-insensitiveAlways store words as lowercase
Missing relay URL normalizationDuplicate relay entries with different formattingNormalize URLs (lowercase host, remove default port, trailing slash)
错误影响修复方案
仅发布新增的关注对象而不是完整列表类型:3是可替换事件,新事件会覆盖旧事件,部分列表会导致已关注对象丢失每次都发布完整的关注列表
使用类型:3的content字段存储中继偏好该用法已废弃,客户端会忽略该字段使用类型:10002存储中继列表元数据
将无标记的
r
标签视为仅可写
无标记代表同时支持读写
["r", "wss://..."]
= 读 + 写
从用户的READ中继拉取其发布的帖子READ中继是用户接收提及内容的节点,不是发布内容的节点使用WRITE中继拉取用户自己发布的事件
向所有已知中继广播事件浪费带宽,违背发件箱模型设计按照发件箱模型规则进行定向中继路由
新的私有列表条目使用NIP-04加密NIP-04在该场景下已被废弃新事件使用NIP-44加密
忘记广泛广播类型:10002事件其他用户无法发现你的中继节点将类型:10002发布到知名的索引中继节点
静音列表的
word
标签使用大写单词
匹配逻辑应为不区分大小写始终存储小写形式的关键词
缺失中继URL标准化处理格式不同的同一个中继会被识别为重复条目标准化URL(小写主机名、移除默认端口、末尾斜杠)

Quick Reference

快速参考

OperationKindKey TagsContent
Follow user3
["p", "<hex>", "<relay>", "<petname>"]
""
Set relays10002
["r", "<url>", "<read|write>"]
""
Mute user/word10000
["p", "<hex>"]
,
["word", "<str>"]
encrypted private items
Pin note10001
["e", "<event-id>"]
""
Bookmark10003
["e", "<id>"]
,
["a", "<coord>"]
encrypted private items
DM relays10050
["relay", "<url>"]
""
Follow set30000
["d", "<id>"]
,
["p", "<hex>"]
encrypted private items
Relay set30002
["d", "<id>"]
,
["relay", "<url>"]
encrypted private items
操作类型关键标签内容
关注用户3
["p", "<hex>", "<relay>", "<petname>"]
""
设置中继10002
["r", "<url>", "<read|write>"]
""
静音用户/关键词10000
["p", "<hex>"]
,
["word", "<str>"]
加密的私有条目
置顶笔记10001
["e", "<event-id>"]
""
收藏10003
["e", "<id>"]
,
["a", "<coord>"]
加密的私有条目
私信中继10050
["relay", "<url>"]
""
关注集合30000
["d", "<id>"]
,
["p", "<hex>"]
加密的私有条目
中继集合30002
["d", "<id>"]
,
["relay", "<url>"]
加密的私有条目

Key Principles

核心原则

  1. Replaceable means complete — Kind:3, 10000, 10001, 10002, 10003 are replaceable events. Every publish must contain the FULL list, not just changes. The new event completely replaces the old one.
  2. Outbox model is not optional — Fetching events without consulting kind:10002 relay lists leads to missed content and wasted connections. Always resolve a user's write relays before fetching their events.
  3. Private items use NIP-44 — New encrypted list content MUST use NIP-44. When reading, detect NIP-04 legacy format by checking for
    ?iv=
    in the ciphertext and handle both.
  4. Relay lists should be small — Guide users to 2-4 relays per category (read/write). Large relay lists defeat the purpose of targeted routing and increase load on the network.
  5. Graph traversal requires relay awareness — You can't just fetch kind:3 from one relay. Use the outbox model to find each user's kind:3 on their write relays, then use those follow lists to discover the next hop.
  1. 可替换事件需包含完整内容 —— 类型:3、10000、10001、10002、10003都是可替换事件。每次发布必须包含完整列表,而不是仅修改的部分。新事件会完全覆盖旧版本。
  2. 发件箱模型是强制要求 —— 拉取事件时不参考类型:10002中继列表会导致内容缺失和无效连接。拉取用户事件前必须先解析其WRITE中继。
  3. 私有条目使用NIP-44加密 —— 新的加密列表内容必须使用NIP-44。读取时可通过密文中是否包含
    ?iv=
    识别NIP-04旧格式,同时兼容两种格式。
  4. 中继列表需保持精简 —— 引导用户每个类别(读/写)选择2-4个中继即可。过大的中继列表会违背定向路由的设计目的,增加网络负载。
  5. 图谱遍历需感知中继 —— 你不能只从单个中继拉取类型:3事件。使用发件箱模型在每个用户的WRITE中继上查找其类型:3事件,再通过这些关注列表发现下一跳节点。