跳至主要內容

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 中包含推薦的中繼器
  • 版本追蹤:可在標籤中記錄版本號或更新時間
  • NIP-01:基本協議 - 事件類型
  • NIP-19:bech32 編碼 - naddr 格式
  • NIP-23:長文內容 - kind 30023
  • NIP-78:應用程式資料 - kind 30078

參考資源

已複製連結
已複製到剪貼簿