NIP-33: Parameterized Replaceable Events
帶參數的可替換事件,允許同一作者發布多個可替換事件
概述
NIP-33 定義了「帶參數的可替換事件」(Parameterized Replaceable Events), 擴展了 NIP-01
中的可替換事件概念。普通可替換事件每個 kind 只能有一個, 而帶參數的可替換事件通過 d 標籤允許同一
kind 存在多個實例。
事件類型範圍
帶參數的可替換事件使用特定的 kind 範圍:
| Kind 範圍 | 類型 | 說明 |
|---|---|---|
30000-39999 | 帶參數可替換事件 | 使用 d 標籤區分不同實例 |
d 標籤
d 標籤是區分同一 kind 不同實例的關鍵:
["d", "<識別符>"] 識別規則
- 每個事件必須有且只有一個
d標籤 - 識別符可以是任何字串(包括空字串)
- 相同
pubkey+kind+d值的新事件會替換舊事件 - 中繼器只保留最新版本(以
created_at判斷)
naddr 地址
帶參數的可替換事件使用 naddr(NIP-19)來引用:
naddr1...(包含 kind、pubkey、d 值和推薦中繼器) 範例
長文文章(kind 30023)
{
"kind": 30023,
"pubkey": "作者公鑰",
"created_at": 1700000000,
"tags": [
["d", "my-first-article"],
["title", "我的第一篇文章"],
["published_at", "1700000000"]
],
"content": "# 文章內容\n\n這是 Markdown 格式的長文...",
"sig": "..."
} 更新同一篇文章
{
"kind": 30023,
"pubkey": "同一作者公鑰",
"created_at": 1700100000,
"tags": [
["d", "my-first-article"],
["title", "我的第一篇文章(更新版)"],
["published_at", "1700000000"]
],
"content": "# 更新後的內容\n\n這是修改後的文章...",
"sig": "..."
} 用戶個人資料(kind 30078)
{
"kind": 30078,
"pubkey": "用戶公鑰",
"created_at": 1700000000,
"tags": [
["d", "app-settings/theme"]
],
"content": "{\"theme\": \"dark\", \"fontSize\": 14}",
"sig": "..."
} 常見的帶參數可替換事件
| Kind | 說明 | d 標籤用途 |
|---|---|---|
30000 | 關注集合 | 集合名稱 |
30001 | 書籤集合 | 集合名稱 |
30008 | 個人資料徽章 | 徽章識別 |
30009 | 徽章定義 | 徽章唯一 ID |
30017 | 商店攤位 | 攤位 ID |
30018 | 商品 | 商品 ID |
30023 | 長文文章 | 文章 slug |
30024 | 草稿文章 | 草稿 ID |
30078 | 應用資料 | 資料鍵 |
30311 | 直播活動 | 活動 ID |
30402 | 分類廣告 | 列表 ID |
30617 | Git 倉庫 | 倉庫識別 |
30818 | Wiki 文章 | 主題名稱 |
TypeScript 實作
建立帶參數可替換事件
import { finalizeEvent } from 'nostr-tools';
interface ParameterizedReplaceableEvent {
kind: number;
dTag: string;
content: string;
tags?: string[][];
}
function createParameterizedReplaceableEvent(
event: ParameterizedReplaceableEvent,
secretKey: Uint8Array
) {
// 確保 kind 在正確範圍
if (event.kind < 30000 || event.kind > 39999) {
throw new Error('Kind must be between 30000-39999');
}
const tags: string[][] = [['d', event.dTag]];
// 添加其他標籤
if (event.tags) {
tags.push(...event.tags);
}
return finalizeEvent(
{
kind: event.kind,
content: event.content,
tags,
created_at: Math.floor(Date.now() / 1000),
},
secretKey
);
}
// 使用範例:建立長文文章
const article = createParameterizedReplaceableEvent(
{
kind: 30023,
dTag: 'bitcoin-basics',
content: '# 比特幣基礎\n\n比特幣是...',
tags: [
['title', '比特幣基礎入門'],
['summary', '一篇關於比特幣基礎的文章'],
['published_at', Math.floor(Date.now() / 1000).toString()],
['t', 'bitcoin'],
['t', '入門'],
],
},
secretKey
); 查詢帶參數可替換事件
import { SimplePool } from 'nostr-tools';
interface NaddrPointer {
kind: number;
pubkey: string;
identifier: string;
}
async function fetchParameterizedReplaceableEvent(
pool: SimplePool,
relays: string[],
pointer: NaddrPointer
) {
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier],
};
const events = await pool.querySync(relays, filter);
// 返回最新的一個
return events.sort((a, b) => b.created_at - a.created_at)[0] || null;
}
// 使用範例
const article = await fetchParameterizedReplaceableEvent(
pool,
relays,
{
kind: 30023,
pubkey: '作者公鑰',
identifier: 'bitcoin-basics',
}
); NIP-19 naddr 編解碼
import { nip19 } from 'nostr-tools';
// 編碼為 naddr
function encodeNaddr(
kind: number,
pubkey: string,
identifier: string,
relays: string[] = []
): string {
return nip19.naddrEncode({
kind,
pubkey,
identifier,
relays,
});
}
// 解碼 naddr
function decodeNaddr(naddr: string): NaddrPointer | null {
try {
const { type, data } = nip19.decode(naddr);
if (type === 'naddr') {
return {
kind: data.kind,
pubkey: data.pubkey,
identifier: data.identifier,
};
}
} catch {
return null;
}
return null;
}
// 使用範例
const naddr = encodeNaddr(
30023,
'作者公鑰',
'bitcoin-basics',
['wss://relay.example.com']
);
console.log(naddr); // naddr1...
const decoded = decodeNaddr(naddr);
console.log(decoded);
// { kind: 30023, pubkey: '...', identifier: 'bitcoin-basics' } 列出用戶的所有文章
interface Article {
id: string;
pubkey: string;
identifier: string;
title: string;
summary?: string;
content: string;
publishedAt: number;
tags: string[];
naddr: string;
}
async function listUserArticles(
pool: SimplePool,
relays: string[],
pubkey: string
): Promise<Article[]> {
const events = await pool.querySync(relays, {
kinds: [30023],
authors: [pubkey],
});
// 按 d 標籤去重,只保留最新版本
const latestByD = new Map<string, any>();
for (const event of events) {
const dTag = event.tags.find((t: string[]) => t[0] === 'd');
const identifier = dTag?.[1] || '';
const existing = latestByD.get(identifier);
if (!existing || event.created_at > existing.created_at) {
latestByD.set(identifier, event);
}
}
return Array.from(latestByD.values()).map((event) => {
const getTag = (name: string) =>
event.tags.find((t: string[]) => t[0] === name)?.[1];
const identifier = getTag('d') || '';
return {
id: event.id,
pubkey: event.pubkey,
identifier,
title: getTag('title') || '無標題',
summary: getTag('summary'),
content: event.content,
publishedAt: parseInt(getTag('published_at') || event.created_at),
tags: event.tags
.filter((t: string[]) => t[0] === 't')
.map((t: string[]) => t[1]),
naddr: encodeNaddr(30023, event.pubkey, identifier, relays.slice(0, 2)),
};
});
} 中繼器行為
對於帶參數的可替換事件,中繼器應該:
- 同一
pubkey+kind+d只保留一個事件 - 收到較新事件時,替換舊事件
- 拒絕時間戳較舊的事件
- 支援
#d過濾器查詢
與普通可替換事件的比較
| 特性 | 可替換事件 (10000-19999) | 帶參數可替換事件 (30000-39999) |
|---|---|---|
| 每個 kind 實例數 | 每用戶 1 個 | 每用戶多個(由 d 標籤區分) |
| 唯一性 | pubkey + kind | pubkey + kind + d |
| 引用方式 | 直接用 kind 查詢 | 使用 naddr 或 #d 過濾 |
| 使用場景 | 個人資料、設定 | 文章、商品、列表 |
最佳實踐
- 有意義的 d 值:使用可讀的 slug 而非隨機 ID
- 穩定的 d 值:d 值一旦設定不應更改
- 包含中繼器提示:在 naddr 中包含推薦的中繼器
- 版本追蹤:可在標籤中記錄版本號或更新時間
相關 NIPs
參考資源
已複製連結