nostr-social-graph
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNostr 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?"
| Intent | Kind | Category | NIP |
|---|---|---|---|
| Follow/unfollow users | 3 | Replaceable | NIP-02 |
| Mute users, words, hashtags | 10000 | Replaceable | NIP-51 |
| Pin notes to profile | 10001 | Replaceable | NIP-51 |
| Advertise read/write relays | 10002 | Replaceable | NIP-65 |
| Bookmark events | 10003 | Replaceable | NIP-51 |
| Set DM relay preferences | 10050 | Replaceable | NIP-51 |
| Categorized follow groups | 30000 | Addressable | NIP-51 |
| User-defined relay groups | 30002 | Addressable | NIP-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 tag = one followed pubkey
p - Relay URL and petname are optional (can be empty string or omitted)
- is empty (historically held relay prefs, now use kind:10002)
content - 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和备注名是可选的(可留空或省略)
- 为空(历史上用于存储中继偏好,现在请使用类型:10002)
content - 新关注对象追加到末尾保证时间顺序
- 每次发布完整列表——新事件会完全覆盖旧版本
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
- tags with relay URLs
r - No marker = both read AND write
- marker = read only
"read" - marker = write only
"write" - 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事件
- 标签存储中继URL
r - 无标记 = 同时支持读和写
- 标记 = 仅可读
"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 as encrypted JSON
content - Supports (pubkeys),
p(hashtags),t(strings),word(threads)e - 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 , private
items are encrypted in .
tagscontentEncryption 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列表都可包含私有条目。公开条目存储在中,私有条目加密后存储在中。
tagscontent加密流程:
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:
| Action | Which relays to use |
|---|---|
| Fetch events FROM a user | That user's WRITE relays |
| Fetch events ABOUT a user (mentions) | That user's READ relays |
| Publish your own event | Your WRITE relays |
| Notify tagged users | Each tagged user's READ relays |
| Spread your kind:10002 | As 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 tag with valid 32-byte hex pubkey
p - For kind:10002: every entry is an tag with valid
rURLwss:// - For kind:10002: markers are only or
"read"(or omitted)"write" - For kind:10000: tags use lowercase strings
word - Private items are encrypted with NIP-44 (not NIP-04 for new events)
- The complete list is published (not just additions/removals)
- is empty string when no private items exist
content - is Unix timestamp in seconds
created_at
- 列表类型对应的kind值正确
- 所有标签类型对应该kind都是合法的(参考列表类型文档)
- 类型:3:每个条目都是标签,包含合法的32字节十六进制公钥
p - 类型:10002:每个条目都是标签,包含合法的
rURLwss:// - 类型:10002:标记只能是、
"read"或省略"write" - 类型:10000:标签使用小写字符串
word - 私有条目使用NIP-44加密(新事件不要使用NIP-04)
- 发布的是完整列表(不是仅新增/删除的部分)
- 无私有条目时为空字符串
content - 是秒级Unix时间戳
created_at
Common Mistakes
常见错误
| Mistake | Why It Breaks | Fix |
|---|---|---|
| Publishing only new follows instead of the full list | Kind:3 is replaceable — new event replaces old, so partial list = lost follows | Always publish the complete follow list |
| Using kind:3 content for relay preferences | Deprecated; clients ignore it | Use kind:10002 for relay list metadata |
Treating unmarked | No marker means BOTH read and write | |
| Fetching from a user's READ relays for their posts | READ relays are where they expect mentions, not where they publish | Use WRITE relays to fetch a user's own events |
| Broadcasting to all known relays | Wastes bandwidth, defeats the outbox model | Use targeted relay routing per the outbox model |
| Using NIP-04 for new private list items | NIP-04 is deprecated for this purpose | Use NIP-44 encryption for new events |
| Forgetting to spread kind:10002 widely | Others can't discover your relays | Publish kind:10002 to well-known indexer relays |
Uppercase words in mute list | Matching should be case-insensitive | Always store words as lowercase |
| Missing relay URL normalization | Duplicate relay entries with different formatting | Normalize URLs (lowercase host, remove default port, trailing slash) |
| 错误 | 影响 | 修复方案 |
|---|---|---|
| 仅发布新增的关注对象而不是完整列表 | 类型:3是可替换事件,新事件会覆盖旧事件,部分列表会导致已关注对象丢失 | 每次都发布完整的关注列表 |
| 使用类型:3的content字段存储中继偏好 | 该用法已废弃,客户端会忽略该字段 | 使用类型:10002存储中继列表元数据 |
将无标记的 | 无标记代表同时支持读写 | |
| 从用户的READ中继拉取其发布的帖子 | READ中继是用户接收提及内容的节点,不是发布内容的节点 | 使用WRITE中继拉取用户自己发布的事件 |
| 向所有已知中继广播事件 | 浪费带宽,违背发件箱模型设计 | 按照发件箱模型规则进行定向中继路由 |
| 新的私有列表条目使用NIP-04加密 | NIP-04在该场景下已被废弃 | 新事件使用NIP-44加密 |
| 忘记广泛广播类型:10002事件 | 其他用户无法发现你的中继节点 | 将类型:10002发布到知名的索引中继节点 |
静音列表的 | 匹配逻辑应为不区分大小写 | 始终存储小写形式的关键词 |
| 缺失中继URL标准化处理 | 格式不同的同一个中继会被识别为重复条目 | 标准化URL(小写主机名、移除默认端口、末尾斜杠) |
Quick Reference
快速参考
| Operation | Kind | Key Tags | Content |
|---|---|---|---|
| Follow user | 3 | | |
| Set relays | 10002 | | |
| Mute user/word | 10000 | | encrypted private items |
| Pin note | 10001 | | |
| Bookmark | 10003 | | encrypted private items |
| DM relays | 10050 | | |
| Follow set | 30000 | | encrypted private items |
| Relay set | 30002 | | encrypted private items |
| 操作 | 类型 | 关键标签 | 内容 |
|---|---|---|---|
| 关注用户 | 3 | | |
| 设置中继 | 10002 | | |
| 静音用户/关键词 | 10000 | | 加密的私有条目 |
| 置顶笔记 | 10001 | | |
| 收藏 | 10003 | | 加密的私有条目 |
| 私信中继 | 10050 | | |
| 关注集合 | 30000 | | 加密的私有条目 |
| 中继集合 | 30002 | | 加密的私有条目 |
Key Principles
核心原则
-
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.
-
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.
-
Private items use NIP-44 — New encrypted list content MUST use NIP-44. When reading, detect NIP-04 legacy format by checking forin the ciphertext and handle both.
?iv= -
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.
-
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.
-
可替换事件需包含完整内容 —— 类型:3、10000、10001、10002、10003都是可替换事件。每次发布必须包含完整列表,而不是仅修改的部分。新事件会完全覆盖旧版本。
-
发件箱模型是强制要求 —— 拉取事件时不参考类型:10002中继列表会导致内容缺失和无效连接。拉取用户事件前必须先解析其WRITE中继。
-
私有条目使用NIP-44加密 —— 新的加密列表内容必须使用NIP-44。读取时可通过密文中是否包含识别NIP-04旧格式,同时兼容两种格式。
?iv= -
中继列表需保持精简 —— 引导用户每个类别(读/写)选择2-4个中继即可。过大的中继列表会违背定向路由的设计目的,增加网络负载。
-
图谱遍历需感知中继 —— 你不能只从单个中继拉取类型:3事件。使用发件箱模型在每个用户的WRITE中继上查找其类型:3事件,再通过这些关注列表发现下一跳节点。