跳至主要內容
高級

Silent Payments

BIP-352 靜默支付:可重用的隱私地址協議

20 分鐘

概述

Silent Payments(靜默支付)是 BIP-352 提出的隱私增強協議, 允許接收者公開一個靜態地址,而每筆付款都會發送到唯一的鏈上地址。 這解決了地址重用和隱私洩露的問題。

狀態: BIP-352 是較新的提案(2024年),部分錢包已開始實現。 Bitcoin Core 正在審核相關 PR。

解決的問題

傳統地址的隱私問題

問題 1: 地址重用
- 公開地址(如捐款頁面)
- 所有付款發送到同一地址
- 任何人都能追蹤收入

問題 2: 新地址生成
- 每次交易需要新地址
- 需要發送者和接收者互動
- 不適合非互動場景

現有解決方案的缺陷:

BIP-47 (Reusable Payment Codes):
- 需要鏈上通知交易
- 額外成本和複雜性

Stealth Addresses (舊方案):
- 接收者需掃描所有交易
- 效能問題

Silent Payments 解決方案

Silent Payments 優勢:

1. 靜態地址
   - 一個地址可永久公開
   - 無需互動即可接收付款

2. 唯一輸出
   - 每筆付款都是不同的鏈上地址
   - 無法通過地址關聯付款

3. 無鏈上開銷
   - 不需要通知交易
   - 付款與普通交易無異

4. 基於 Taproot
   - 利用 Taproot 的隱私優勢
   - 輸出看起來與普通 P2TR 相同

工作原理

密鑰結構

Silent Payment 地址組成:

接收者生成兩個密鑰對:
1. Scan Key (B_scan, b_scan)
   - 用於掃描區塊鏈
   - 可以委託給第三方(輕客戶端)

2. Spend Key (B_spend, b_spend)
   - 用於實際花費
   - 必須保密

地址格式:
sp1q<scan_pubkey><spend_pubkey>

範例:
sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq
jhtpp5czqr0sn8svhkwfk22rq0gqgy0rrpnh42cfcrvlqwwz4pxqvnstd

付款流程

發送者 (Alice) 付款給接收者 (Bob):

1. Alice 獲取 Bob 的 SP 地址
   sp1q<B_scan><B_spend>

2. Alice 使用她的 UTXO 輸入
   輸入公鑰: A = a·G

3. Alice 計算共享密鑰
   shared_secret = hash(a · B_scan)

4. Alice 計算輸出公鑰
   P = B_spend + shared_secret · G

5. Alice 發送到 P (Taproot 地址)

Bob 接收:

1. Bob 掃描每個交易的輸入
   提取輸入公鑰 A

2. Bob 計算共享密鑰
   shared_secret = hash(b_scan · A)

3. Bob 計算預期輸出公鑰
   P = B_spend + shared_secret · G

4. 如果交易輸出包含 P
   → 這筆付款是給 Bob 的

5. Bob 可以用私鑰花費
   p = b_spend + shared_secret

數學原理

ECDH 共享密鑰:

發送者計算: a · B_scan = a · b_scan · G
接收者計算: b_scan · A = b_scan · a · G

兩者相等: a · b_scan · G

輸出公鑰推導:
P = B_spend + hash(shared_secret || counter) · G

對應私鑰:
p = b_spend + hash(shared_secret || counter)

驗證:
p · G = (b_spend + hash(...)) · G
     = b_spend · G + hash(...) · G
     = B_spend + hash(...) · G
     = P ✓

TypeScript 實作

地址生成

import * as secp256k1 from '@noble/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { bech32m } from 'bech32';

interface SilentPaymentKeys {
  scanPrivkey: Uint8Array;
  scanPubkey: Uint8Array;
  spendPrivkey: Uint8Array;
  spendPubkey: Uint8Array;
}

// 生成 Silent Payment 密鑰對
function generateSPKeys(): SilentPaymentKeys {
  const scanPrivkey = secp256k1.utils.randomPrivateKey();
  const spendPrivkey = secp256k1.utils.randomPrivateKey();

  return {
    scanPrivkey,
    scanPubkey: secp256k1.getPublicKey(scanPrivkey, true),
    spendPrivkey,
    spendPubkey: secp256k1.getPublicKey(spendPrivkey, true),
  };
}

// 編碼 SP 地址
function encodeSPAddress(
  scanPubkey: Uint8Array,
  spendPubkey: Uint8Array,
  network: 'mainnet' | 'testnet' = 'mainnet'
): string {
  const hrp = network === 'mainnet' ? 'sp' : 'tsp';
  const version = 0;

  // 使用 x-only 公鑰 (32 bytes each)
  const scanX = scanPubkey.slice(1);
  const spendX = spendPubkey.slice(1);

  const data = new Uint8Array([...scanX, ...spendX]);
  const words = bech32m.toWords(data);
  words.unshift(version);

  return bech32m.encode(hrp, words, 118);  // 較長的限制
}

// 解碼 SP 地址
function decodeSPAddress(address: string): {
  scanPubkey: Uint8Array;
  spendPubkey: Uint8Array;
} {
  const { prefix, words } = bech32m.decode(address, 118);

  if (prefix !== 'sp' && prefix !== 'tsp') {
    throw new Error('Invalid SP address prefix');
  }

  const version = words[0];
  if (version !== 0) {
    throw new Error('Unsupported SP version');
  }

  const data = new Uint8Array(bech32m.fromWords(words.slice(1)));

  // 添加 02 前綴(假設偶數 y 座標)
  return {
    scanPubkey: new Uint8Array([0x02, ...data.slice(0, 32)]),
    spendPubkey: new Uint8Array([0x02, ...data.slice(32, 64)]),
  };
}

發送付款

interface SPInput {
  txid: string;
  vout: number;
  privkey: Uint8Array;
  pubkey: Uint8Array;
}

interface SPOutput {
  outputPubkey: Uint8Array;
  amount: bigint;
}

// 發送者: 計算輸出地址
function createSPOutput(
  inputs: SPInput[],
  recipientScanPubkey: Uint8Array,
  recipientSpendPubkey: Uint8Array,
  amount: bigint
): SPOutput {
  // 聚合輸入私鑰 (簡化版,實際更複雜)
  let aggregatePrivkey = 0n;
  for (const input of inputs) {
    const privkeyBigInt = BigInt('0x' + bytesToHex(input.privkey));
    aggregatePrivkey = (aggregatePrivkey + privkeyBigInt) % secp256k1.CURVE.n;
  }

  // 計算共享密鑰
  // shared_secret = a · B_scan
  const sharedPoint = secp256k1.Point
    .fromHex(bytesToHex(recipientScanPubkey))
    .multiply(aggregatePrivkey);

  // 雜湊共享密鑰
  const sharedSecretHash = taggedHash(
    'BIP0352/SharedSecret',
    sharedPoint.toRawBytes(true)
  );

  // 計算輸出公鑰
  // P = B_spend + hash · G
  const tweakPoint = secp256k1.Point.fromPrivateKey(sharedSecretHash);
  const spendPoint = secp256k1.Point.fromHex(bytesToHex(recipientSpendPubkey));
  const outputPoint = spendPoint.add(tweakPoint);

  // Taproot 輸出 (x-only)
  const outputPubkey = outputPoint.toRawBytes(true).slice(1);

  return {
    outputPubkey,
    amount,
  };
}

function taggedHash(tag: string, data: Uint8Array): Uint8Array {
  const tagHash = sha256(new TextEncoder().encode(tag));
  return sha256(new Uint8Array([...tagHash, ...tagHash, ...data]));
}

接收掃描

interface ScannedOutput {
  txid: string;
  vout: number;
  outputPubkey: Uint8Array;
  amount: bigint;
  spendPrivkey: Uint8Array;
}

// 接收者: 掃描交易
function scanTransaction(
  tx: Transaction,
  scanPrivkey: Uint8Array,
  spendPubkey: Uint8Array,
  spendPrivkey: Uint8Array
): ScannedOutput[] {
  const results: ScannedOutput[] = [];

  // 提取所有輸入公鑰
  const inputPubkeys = extractInputPubkeys(tx);
  if (inputPubkeys.length === 0) return results;

  // 聚合輸入公鑰
  let aggregatePubkey = secp256k1.Point.ZERO;
  for (const pubkey of inputPubkeys) {
    aggregatePubkey = aggregatePubkey.add(
      secp256k1.Point.fromHex(bytesToHex(pubkey))
    );
  }

  // 計算共享密鑰
  // shared_secret = b_scan · A
  const scanPrivkeyBigInt = BigInt('0x' + bytesToHex(scanPrivkey));
  const sharedPoint = aggregatePubkey.multiply(scanPrivkeyBigInt);

  const sharedSecretHash = taggedHash(
    'BIP0352/SharedSecret',
    sharedPoint.toRawBytes(true)
  );

  // 計算預期輸出公鑰
  const tweakPoint = secp256k1.Point.fromPrivateKey(sharedSecretHash);
  const spendPoint = secp256k1.Point.fromHex(bytesToHex(spendPubkey));
  const expectedOutput = spendPoint.add(tweakPoint);
  const expectedX = expectedOutput.toRawBytes(true).slice(1);

  // 檢查每個輸出
  for (let i = 0; i < tx.outputs.length; i++) {
    const output = tx.outputs[i];

    // 檢查是否是 P2TR 輸出
    if (!isP2TR(output.scriptPubKey)) continue;

    const outputX = output.scriptPubKey.slice(2);

    // 比較 x 座標
    if (bytesToHex(outputX) === bytesToHex(expectedX)) {
      // 計算花費私鑰
      // p = b_spend + hash
      const spendPrivkeyBigInt = BigInt('0x' + bytesToHex(spendPrivkey));
      const tweakBigInt = BigInt('0x' + bytesToHex(sharedSecretHash));
      const outputPrivkey = (spendPrivkeyBigInt + tweakBigInt) % secp256k1.CURVE.n;

      results.push({
        txid: tx.txid,
        vout: i,
        outputPubkey: outputX,
        amount: output.value,
        spendPrivkey: hexToBytes(
          outputPrivkey.toString(16).padStart(64, '0')
        ),
      });
    }
  }

  return results;
}

// 輕客戶端掃描 (只用 scan key)
function lightClientScan(
  tx: Transaction,
  scanPrivkey: Uint8Array,
  spendPubkey: Uint8Array
): Uint8Array[] {
  // 只返回可能的輸出公鑰
  // 不計算花費私鑰(需要 spend key)
  const candidates: Uint8Array[] = [];

  // ... 類似上面的掃描邏輯
  // 但只返回匹配的輸出

  return candidates;
}

標籤 (Labels)

Silent Payment Labels:

用途:
- 區分不同來源的付款
- 類似於 HD 錢包的帳戶

實現:
- 對 spend_pubkey 進行調整
- B_spend' = B_spend + hash(b_scan || label) · G

標籤範例:
- label = 0: 默認 (捐款)
- label = 1: 商品銷售
- label = 2: 服務收入

地址格式:
- 默認地址: sp1q<B_scan><B_spend>
- 帶標籤: sp1q<B_scan><B_spend'>

掃描優化

掃描效率

掃描挑戰:
- 需要檢查每個區塊的每筆交易
- 每筆交易需要 ECDH 計算
- 全節點掃描較慢

優化策略:

1. 輕客戶端模式
   - 只委託 scan key
   - 服務器執行掃描
   - 返回匹配的 tweak 值
   - 客戶端驗證並計算私鑰

2. 區塊過濾器
   - 使用 BIP-158 風格的過濾器
   - 快速排除不相關區塊

3. 批量掃描
   - 一次處理多個區塊
   - 利用批量 ECDH 優化

4. 索引服務
   - 公開服務索引所有 SP 輸出
   - 用戶只需查詢自己的 tweak

輕客戶端架構

interface SPLightClient {
  scanPubkey: Uint8Array;  // 公開給服務器
  spendPrivkey: Uint8Array;  // 本地保管
  spendPubkey: Uint8Array;
}

interface SPIndexService {
  // 服務器執行掃描
  scanBlocks(
    scanPubkey: Uint8Array,
    startHeight: number,
    endHeight: number
  ): Promise<SPTweak[]>;
}

interface SPTweak {
  txid: string;
  vout: number;
  tweakData: Uint8Array;
  amount: bigint;
}

// 輕客戶端恢復錢包
async function recoverWallet(
  client: SPLightClient,
  indexService: SPIndexService,
  startHeight: number
): Promise<ScannedOutput[]> {
  const outputs: ScannedOutput[] = [];
  const currentHeight = await getCurrentHeight();

  // 批量掃描
  const batchSize = 10000;
  for (let h = startHeight; h < currentHeight; h += batchSize) {
    const tweaks = await indexService.scanBlocks(
      client.scanPubkey,
      h,
      Math.min(h + batchSize, currentHeight)
    );

    // 本地計算私鑰
    for (const tweak of tweaks) {
      const spendKey = computeSpendKey(
        client.spendPrivkey,
        tweak.tweakData
      );

      outputs.push({
        txid: tweak.txid,
        vout: tweak.vout,
        amount: tweak.amount,
        spendPrivkey: spendKey,
        outputPubkey: secp256k1.getPublicKey(spendKey, true).slice(1),
      });
    }
  }

  return outputs;
}

與其他方案比較

特性 普通地址 BIP-47 Silent Payments
靜態地址
鏈上開銷 通知交易
非互動 否*
掃描需求 已知對手 所有交易
輕客戶端 部分 需索引服務
Taproot 任意

*BIP-47 首次付款需要通知交易

最佳實踐

  • 安全保管 spend key:scan key 可委託,spend key 必須保密
  • 使用標籤:區分不同來源的付款
  • 定期掃描:設置自動掃描新區塊
  • 備份兩個密鑰:恢復需要 scan 和 spend 私鑰
  • 考慮輕客戶端:委託掃描提升效率

相關資源

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