跳至主要內容

NIP-61: Nutzaps

使用 Cashu ecash 進行 Zaps 打賞

概述

NIP-61 定義了 Nutzaps - 使用 Cashu ecash 代幣進行打賞的機制。 與傳統的閃電網路 Zaps(NIP-57)不同,Nutzaps 提供更強的隱私性, 因為 Cashu 使用盲簽名技術,造幣廠無法追蹤代幣流向。

Nutzaps 優勢

  • 隱私性:無法追蹤支付來源
  • 無需 LNURL:不依賴閃電網路服務
  • 即時確認:無需等待閃電網路確認
  • 低手續費:Cashu 交易通常免費

事件類型

Kind 說明 類型
9321 Nutzap 事件 一般事件
10019 Nutzap 資訊 可替換事件

Nutzap 資訊 (Kind 10019)

用戶發布此事件來宣告他們接受 Nutzaps 以及偏好的造幣廠。

標籤

標籤 說明 範例
mint 接受的造幣廠 URL ["mint", "https://mint.example.com", "sat"]
relay 接收 Nutzaps 的中繼器 ["relay", "wss://relay.example.com"]
pubkey P2PK 鎖定公鑰 ["pubkey", "hex公鑰"]

範例

{
  "kind": 10019,
  "pubkey": "接收者公鑰",
  "tags": [
    ["mint", "https://mint.minibits.cash", "sat"],
    ["mint", "https://mint.coinos.io", "sat"],
    ["relay", "wss://relay.damus.io"],
    ["relay", "wss://nos.lol"],
    ["pubkey", "用於 P2PK 鎖定的公鑰"]
  ],
  "content": "",
  "created_at": 1700000000
}

Nutzap 事件 (Kind 9321)

實際的打賞事件,包含 Cashu 代幣。

標籤

標籤 說明 必要
amount 金額(最小單位)
unit 單位(sat, msat, usd)
proof Cashu proof JSON
u 造幣廠 URL
e 被打賞的事件 ID
p 被打賞者公鑰

範例

{
  "kind": 9321,
  "pubkey": "打賞者公鑰",
  "tags": [
    ["amount", "100"],
    ["unit", "sat"],
    ["u", "https://mint.minibits.cash"],
    ["proof", "{\"amount\":64,\"id\":\"009a1f293253e41e\",\"secret\":\"...\",\"C\":\"...\"}"],
    ["proof", "{\"amount\":32,\"id\":\"009a1f293253e41e\",\"secret\":\"...\",\"C\":\"...\"}"],
    ["proof", "{\"amount\":4,\"id\":\"009a1f293253e41e\",\"secret\":\"...\",\"C\":\"...\"}"],
    ["e", "被打賞的事件ID", "wss://relay.example.com"],
    ["p", "被打賞者公鑰"]
  ],
  "content": "讚!很棒的內容 🎉",
  "created_at": 1700000000
}

P2PK 鎖定

為了確保只有接收者能夠使用代幣,可以使用 P2PK(Pay to Public Key)鎖定:

  • 打賞者使用接收者的公鑰鎖定 Cashu 代幣
  • 只有擁有對應私鑰的人才能解鎖並使用代幣
  • 即使代幣被截獲,也無法被盜用

TypeScript 實作

發布 Nutzap 資訊

import { finalizeEvent } from 'nostr-tools';

interface NutzapConfig {
  mints: { url: string; unit: string }[];
  relays: string[];
  p2pkPubkey?: string;
}

function createNutzapInfo(
  config: NutzapConfig,
  secretKey: Uint8Array
) {
  const tags: string[][] = [];

  config.mints.forEach((mint) => {
    tags.push(['mint', mint.url, mint.unit]);
  });

  config.relays.forEach((relay) => {
    tags.push(['relay', relay]);
  });

  if (config.p2pkPubkey) {
    tags.push(['pubkey', config.p2pkPubkey]);
  }

  return finalizeEvent(
    {
      kind: 10019,
      content: '',
      tags,
      created_at: Math.floor(Date.now() / 1000),
    },
    secretKey
  );
}

// 使用範例
const nutzapInfo = createNutzapInfo(
  {
    mints: [
      { url: 'https://mint.minibits.cash', unit: 'sat' },
      { url: 'https://mint.coinos.io', unit: 'sat' },
    ],
    relays: ['wss://relay.damus.io', 'wss://nos.lol'],
    p2pkPubkey: 'hex公鑰',
  },
  secretKey
);

發送 Nutzap

import { CashuMint, CashuWallet, getEncodedToken, Proof } from '@cashu/cashu-ts';

interface NutzapTarget {
  pubkey: string;
  eventId?: string;
  relayHint?: string;
}

async function sendNutzap(
  mintUrl: string,
  proofs: Proof[],
  amount: number,
  target: NutzapTarget,
  message: string,
  secretKey: Uint8Array
) {
  const mint = new CashuMint(mintUrl);
  const wallet = new CashuWallet(mint);

  // 選擇並拆分代幣
  const { send } = await wallet.send(amount, proofs);

  // 建立 Nutzap 事件
  const tags: string[][] = [
    ['amount', amount.toString()],
    ['unit', 'sat'],
    ['u', mintUrl],
    ['p', target.pubkey],
  ];

  // 添加每個 proof 作為單獨的標籤
  send.forEach((proof) => {
    tags.push(['proof', JSON.stringify(proof)]);
  });

  if (target.eventId) {
    const eventTag = ['e', target.eventId];
    if (target.relayHint) {
      eventTag.push(target.relayHint);
    }
    tags.push(eventTag);
  }

  return finalizeEvent(
    {
      kind: 9321,
      content: message,
      tags,
      created_at: Math.floor(Date.now() / 1000),
    },
    secretKey
  );
}

// 使用範例
const nutzap = await sendNutzap(
  'https://mint.minibits.cash',
  myProofs,
  100,
  {
    pubkey: '被打賞者公鑰',
    eventId: '事件ID',
    relayHint: 'wss://relay.example.com',
  },
  '很棒的內容!',
  secretKey
);

接收 Nutzaps

import { SimplePool } from 'nostr-tools';

interface ReceivedNutzap {
  id: string;
  from: string;
  amount: number;
  unit: string;
  mint: string;
  proofs: Proof[];
  message: string;
  eventId?: string;
  createdAt: number;
}

async function getReceivedNutzaps(
  pool: SimplePool,
  relays: string[],
  pubkey: string,
  since?: number
): Promise<ReceivedNutzap[]> {
  const filter: any = {
    kinds: [9321],
    '#p': [pubkey],
  };

  if (since) {
    filter.since = since;
  }

  const events = await pool.querySync(relays, filter);

  return events.map((event) => {
    const getTag = (name: string) =>
      event.tags.find((t: string[]) => t[0] === name)?.[1];

    const proofs = event.tags
      .filter((t: string[]) => t[0] === 'proof')
      .map((t: string[]) => JSON.parse(t[1]));

    return {
      id: event.id,
      from: event.pubkey,
      amount: parseInt(getTag('amount') || '0'),
      unit: getTag('unit') || 'sat',
      mint: getTag('u') || '',
      proofs,
      message: event.content,
      eventId: getTag('e'),
      createdAt: event.created_at,
    };
  });
}

// 兌換收到的 Nutzaps
async function redeemNutzap(
  nutzap: ReceivedNutzap,
  secretKey: Uint8Array
): Promise<Proof[]> {
  const mint = new CashuMint(nutzap.mint);
  const wallet = new CashuWallet(mint);

  // 如果是 P2PK 鎖定的,需要提供私鑰
  const newProofs = await wallet.receive(nutzap.proofs, {
    privkey: bytesToHex(secretKey),
  });

  return newProofs;
}

查詢用戶的 Nutzap 配置

interface UserNutzapConfig {
  mints: { url: string; unit: string }[];
  relays: string[];
  p2pkPubkey?: string;
}

async function getUserNutzapConfig(
  pool: SimplePool,
  relays: string[],
  pubkey: string
): Promise<UserNutzapConfig | null> {
  const events = await pool.querySync(relays, {
    kinds: [10019],
    authors: [pubkey],
  });

  if (events.length === 0) {
    return null;
  }

  // 取最新的
  const event = events.sort((a, b) => b.created_at - a.created_at)[0];

  const mints = event.tags
    .filter((t: string[]) => t[0] === 'mint')
    .map((t: string[]) => ({ url: t[1], unit: t[2] || 'sat' }));

  const configRelays = event.tags
    .filter((t: string[]) => t[0] === 'relay')
    .map((t: string[]) => t[1]);

  const pubkeyTag = event.tags.find((t: string[]) => t[0] === 'pubkey');

  return {
    mints,
    relays: configRelays,
    p2pkPubkey: pubkeyTag?.[1],
  };
}

// 檢查用戶是否支援 Nutzaps
async function supportsNutzaps(
  pool: SimplePool,
  relays: string[],
  pubkey: string
): Promise<boolean> {
  const config = await getUserNutzapConfig(pool, relays, pubkey);
  return config !== null && config.mints.length > 0;
}

Nutzaps vs 傳統 Zaps

特性 Zaps (NIP-57) Nutzaps (NIP-61)
支付網路 閃電網路 Cashu ecash
隱私性 可追蹤 不可追蹤
設置要求 LNURL 服務 僅需發布 kind 10019
確認時間 需等待閃電網路 即時
託管風險 無(非託管) 有(造幣廠託管)
互操作性 需 LNURL 支援 只需 Cashu 支援

安全考量

  • 造幣廠信任:Cashu 是託管式的,選擇可信的造幣廠
  • P2PK 鎖定:建議使用 P2PK 防止代幣被截獲
  • 及時兌換:收到 Nutzap 後應及時兌換,防止造幣廠倒閉
  • 多造幣廠:分散使用多個造幣廠降低風險
  • NIP-57:Zaps - 閃電網路打賞
  • NIP-60:Cashu Wallet - 錢包管理
  • NIP-44:加密訊息 v2 - 內容加密

參考資源

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