跳至主要內容

NIP-60: Cashu Wallet

在 Nostr 上管理 Cashu ecash 錢包

概述

NIP-60 定義了在 Nostr 上儲存和管理 Cashu ecash 錢包的標準。 Cashu 是一種基於 Chaumian ecash 的隱私支付協議,與閃電網路整合。 通過 NIP-60,用戶可以在不同客戶端之間同步其 ecash 餘額。

什麼是 Cashu?

Cashu 是一種 ecash 協議:

  • 隱私性:使用盲簽名技術,造幣廠無法追蹤交易
  • 閃電網路整合:可與閃電網路相互轉換
  • 離線支付:代幣可以離線傳輸
  • 託管式:資金由造幣廠(Mint)託管

事件類型

Kind 說明 類型
17375 錢包事件 加密可替換
7375 代幣事件 加密一般
7376 交易歷史 加密一般

錢包事件 (Kind 17375)

錢包事件儲存錢包的配置和狀態,內容使用 NIP-44 加密。

標籤

標籤 說明 範例
d 錢包識別符 ["d", "my-wallet"]
mint 造幣廠 URL ["mint", "https://mint.example.com"]
relay 代幣中繼器 ["relay", "wss://relay.example.com"]

加密內容結構

{
  "mints": [
    {
      "url": "https://mint.example.com",
      "balance": 1000,
      "unit": "sat"
    }
  ],
  "proofs": []
}

代幣事件 (Kind 7375)

代幣事件儲存實際的 ecash 代幣,內容使用 NIP-44 加密。

標籤

標籤 說明
a 關聯的錢包事件

加密內容(Cashu Token V4)

{
  "mint": "https://mint.example.com",
  "proofs": [
    {
      "amount": 1,
      "id": "009a1f293253e41e",
      "secret": "...",
      "C": "..."
    }
  ]
}

交易歷史 (Kind 7376)

記錄代幣的交易歷史,包括接收、發送、兌換等操作。

加密內容

{
  "direction": "in",
  "amount": 100,
  "unit": "sat",
  "mint": "https://mint.example.com",
  "created_at": 1700000000,
  "e": ["7375 事件 ID"]
}

TypeScript 實作

錢包類別

import { nip44 } from 'nostr-tools';
import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts';

interface WalletState {
  mints: MintInfo[];
  proofs: Proof[];
}

interface MintInfo {
  url: string;
  balance: number;
  unit: string;
}

interface Proof {
  amount: number;
  id: string;
  secret: string;
  C: string;
}

class NostrCashuWallet {
  private secretKey: Uint8Array;
  private pubkey: string;
  private walletId: string;
  private relays: string[];

  constructor(
    secretKey: Uint8Array,
    pubkey: string,
    walletId: string = 'default',
    relays: string[] = []
  ) {
    this.secretKey = secretKey;
    this.pubkey = pubkey;
    this.walletId = walletId;
    this.relays = relays;
  }

  // 加密錢包資料
  private encrypt(data: any): string {
    const plaintext = JSON.stringify(data);
    return nip44.encrypt(plaintext, this.secretKey, this.pubkey);
  }

  // 解密錢包資料
  private decrypt(ciphertext: string): any {
    const plaintext = nip44.decrypt(ciphertext, this.secretKey, this.pubkey);
    return JSON.parse(plaintext);
  }

  // 建立錢包事件
  createWalletEvent(state: WalletState, mints: string[]) {
    const content = this.encrypt(state);

    const tags: string[][] = [['d', this.walletId]];

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

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

    return {
      kind: 17375,
      content,
      tags,
      created_at: Math.floor(Date.now() / 1000),
    };
  }

  // 建立代幣事件
  createTokenEvent(mint: string, proofs: Proof[]) {
    const token = {
      mint,
      proofs,
    };

    const content = this.encrypt(token);

    return {
      kind: 7375,
      content,
      tags: [
        ['a', `17375:${this.pubkey}:${this.walletId}`],
      ],
      created_at: Math.floor(Date.now() / 1000),
    };
  }

  // 建立歷史事件
  createHistoryEvent(
    direction: 'in' | 'out',
    amount: number,
    mint: string,
    tokenEventId: string
  ) {
    const history = {
      direction,
      amount,
      unit: 'sat',
      mint,
      created_at: Math.floor(Date.now() / 1000),
      e: [tokenEventId],
    };

    const content = this.encrypt(history);

    return {
      kind: 7376,
      content,
      tags: [
        ['a', `17375:${this.pubkey}:${this.walletId}`],
      ],
      created_at: Math.floor(Date.now() / 1000),
    };
  }
}

從 Nostr 載入錢包

import { SimplePool } from 'nostr-tools';

async function loadWallet(
  pool: SimplePool,
  relays: string[],
  secretKey: Uint8Array,
  pubkey: string,
  walletId: string = 'default'
): Promise<WalletState | null> {
  // 獲取錢包事件
  const walletEvents = await pool.querySync(relays, {
    kinds: [17375],
    authors: [pubkey],
    '#d': [walletId],
  });

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

  // 取最新的錢包事件
  const walletEvent = walletEvents.sort(
    (a, b) => b.created_at - a.created_at
  )[0];

  // 解密錢包狀態
  const wallet = new NostrCashuWallet(secretKey, pubkey, walletId, relays);
  const state = wallet.decrypt(walletEvent.content);

  // 獲取代幣事件
  const tokenEvents = await pool.querySync(relays, {
    kinds: [7375],
    authors: [pubkey],
    '#a': [`17375:${pubkey}:${walletId}`],
  });

  // 解密並合併代幣
  const allProofs: Proof[] = [];
  for (const event of tokenEvents) {
    try {
      const token = wallet.decrypt(event.content);
      allProofs.push(...token.proofs);
    } catch (e) {
      console.error('Failed to decrypt token:', e);
    }
  }

  return {
    ...state,
    proofs: allProofs,
  };
}

接收 Cashu 代幣

import { getDecodedToken } from '@cashu/cashu-ts';

async function receiveToken(
  wallet: NostrCashuWallet,
  pool: SimplePool,
  relays: string[],
  cashuToken: string
) {
  // 解碼 Cashu 代幣
  const decoded = getDecodedToken(cashuToken);

  // 驗證並兌換代幣
  const mint = new CashuMint(decoded.mint);
  const cashuWallet = new CashuWallet(mint);

  const proofs = await cashuWallet.receive(decoded);

  // 計算總額
  const amount = proofs.reduce((sum, p) => sum + p.amount, 0);

  // 建立代幣事件
  const tokenEvent = wallet.createTokenEvent(decoded.mint, proofs);

  // 發布到中繼器
  await Promise.all(
    relays.map((relay) =>
      pool.publish([relay], finalizeEvent(tokenEvent, secretKey))
    )
  );

  // 記錄歷史
  const historyEvent = wallet.createHistoryEvent(
    'in',
    amount,
    decoded.mint,
    tokenEvent.id
  );

  await Promise.all(
    relays.map((relay) =>
      pool.publish([relay], finalizeEvent(historyEvent, secretKey))
    )
  );

  return { amount, proofs };
}

發送 Cashu 代幣

async function sendToken(
  wallet: NostrCashuWallet,
  pool: SimplePool,
  relays: string[],
  mintUrl: string,
  amount: number,
  proofs: Proof[]
): Promise<string> {
  const mint = new CashuMint(mintUrl);
  const cashuWallet = new CashuWallet(mint);

  // 選擇足夠的代幣
  const selectedProofs = selectProofs(proofs, amount);

  // 拆分代幣
  const { send, returnChange } = await cashuWallet.send(
    amount,
    selectedProofs
  );

  // 編碼為 Cashu token
  const token = getEncodedToken({
    mint: mintUrl,
    proofs: send,
  });

  // 更新錢包(移除已使用的代幣,添加找零)
  // ... 發布更新的錢包事件

  // 記錄歷史
  const historyEvent = wallet.createHistoryEvent(
    'out',
    amount,
    mintUrl,
    'token-event-id'
  );

  await Promise.all(
    relays.map((relay) =>
      pool.publish([relay], finalizeEvent(historyEvent, secretKey))
    )
  );

  return token;
}

function selectProofs(proofs: Proof[], amount: number): Proof[] {
  // 簡單的代幣選擇演算法
  const sorted = [...proofs].sort((a, b) => b.amount - a.amount);
  const selected: Proof[] = [];
  let total = 0;

  for (const proof of sorted) {
    if (total >= amount) break;
    selected.push(proof);
    total += proof.amount;
  }

  if (total < amount) {
    throw new Error('Insufficient balance');
  }

  return selected;
}

安全考量

  • 加密儲存:所有代幣使用 NIP-44 加密
  • 造幣廠信任:Cashu 是託管式的,需要信任造幣廠
  • 代幣備份:確保代幣事件正確發布到多個中繼器
  • 雙花防護:已使用的代幣必須及時標記

使用場景

  • 隱私支付:不可追蹤的小額支付
  • 離線轉帳:無需網路即可轉移代幣
  • 跨客戶端同步:在不同應用間共享餘額
  • NutZaps:使用 Cashu 進行 Zaps(NIP-61)
  • NIP-44:加密訊息 v2 - 內容加密
  • NIP-61:NutZaps - Cashu Zaps
  • NIP-57:Zaps - 閃電網路打賞

參考資源

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