跳至主要內容
高級

Coin Selection

UTXO 選擇算法:優化交易手續費和隱私的策略

20 分鐘

概述

Coin Selection(幣選擇)是決定使用哪些 UTXO 來支付交易的過程。 好的選擇算法可以最小化手續費、保護隱私、並維護錢包的長期健康。 這是錢包開發中最複雜的問題之一。

核心權衡: 使用較少的 UTXO 可以降低當前交易的手續費,但會產生較大的找零, 可能增加未來交易的成本。

選擇目標

  • 最小化手續費:選擇最少的輸入數量
  • 避免找零:找零會增加輸出大小和未來成本
  • 維護隱私:避免關聯不同的地址
  • UTXO 健康:避免積累大量小額 UTXO(灰塵)
  • 確認時間:優先使用已確認的 UTXO

選擇算法

Branch and Bound (BnB)

Bitcoin Core 的主要算法,嘗試找到精確匹配(無找零):

目標:找到 UTXO 組合使得
sum(inputs) = target + fee

優點:
- 避免產生找零
- 長期降低 UTXO 集合大小
- 隱私更好(輸出數量少)

缺點:
- 不總是能找到精確匹配
- 計算複雜度較高

Knapsack (背包算法)

當 BnB 失敗時的備選方案:

策略:
1. 隨機選擇 UTXO
2. 嘗試多次組合
3. 選擇最接近目標的組合

優點:
- 總能找到解決方案
- 相對快速

缺點:
- 通常產生找零
- 非確定性(結果可能不同)

Single Random Draw (SRD)

簡單的隨機選擇:

策略:
1. 隨機打亂 UTXO 列表
2. 依序添加直到足夠
3. 產生找零

用途:
- 最後的備選方案
- 簡單實現

Lowest Larger

選擇最小的足夠 UTXO:

策略:
找到最小的 UTXO 使得 value >= target + fee

優點:
- 最簡單的實現
- 保留大額 UTXO

缺點:
- 可能產生大量小額找零
- 長期累積灰塵

Bitcoin Core 實作

選擇流程:

1. 過濾候選 UTXO
   - 移除未確認的(可選)
   - 移除鎖定的
   - 移除低於灰塵閾值的

2. 嘗試 Branch and Bound
   - 搜索精確匹配
   - 考慮費率範圍

3. 如果 BnB 失敗,嘗試 Knapsack
   - 1000 次隨機迭代
   - 選擇最佳組合

4. 如果 Knapsack 失敗,使用 SRD

5. 計算找零輸出(如需要)

TypeScript 實作

UTXO 類型定義

interface UTXO {
  txid: string;
  vout: number;
  value: number;        // satoshis
  scriptPubKey: Buffer;
  confirmations: number;
  addressType: 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr';
}

interface SelectionResult {
  inputs: UTXO[];
  fee: number;
  change: number;
  effectiveFeeRate: number;
}

// 輸入大小(vBytes)
const INPUT_SIZES: Record<string, number> = {
  p2pkh: 148,
  p2sh: 91,      // P2SH-P2WPKH
  p2wpkh: 68,
  p2wsh: 104,    // 2-of-3 多簽
  p2tr: 57.5,
};

// 輸出大小(vBytes)
const OUTPUT_SIZES: Record<string, number> = {
  p2pkh: 34,
  p2sh: 32,
  p2wpkh: 31,
  p2wsh: 43,
  p2tr: 43,
};

費用計算

function calculateFee(
  inputs: UTXO[],
  outputs: number,
  feeRate: number,  // sat/vB
  changeType: string = 'p2wpkh'
): { fee: number; vsize: number } {
  // 基礎交易開銷
  let vsize = 10.5;  // 版本 + locktime + 輸入/輸出計數

  // 輸入大小
  for (const input of inputs) {
    vsize += INPUT_SIZES[input.addressType] || 68;
  }

  // 輸出大小(不含找零)
  vsize += outputs * OUTPUT_SIZES.p2wpkh;

  // 找零輸出
  vsize += OUTPUT_SIZES[changeType];

  const fee = Math.ceil(vsize * feeRate);

  return { fee, vsize };
}

function effectiveValue(utxo: UTXO, feeRate: number): number {
  const inputCost = (INPUT_SIZES[utxo.addressType] || 68) * feeRate;
  return utxo.value - inputCost;
}

Branch and Bound

interface BnBResult {
  success: boolean;
  selection?: UTXO[];
}

function branchAndBound(
  utxos: UTXO[],
  target: number,
  feeRate: number,
  costOfChange: number = 50  // 創建找零的額外成本
): BnBResult {
  // 按有效價值排序
  const sorted = [...utxos]
    .map((u) => ({ utxo: u, effective: effectiveValue(u, feeRate) }))
    .filter((u) => u.effective > 0)
    .sort((a, b) => b.effective - a.effective);

  const targetRange = {
    min: target,
    max: target + costOfChange,
  };

  let bestSelection: UTXO[] | undefined;
  let bestWaste = Infinity;

  function search(
    index: number,
    currentValue: number,
    currentSelection: UTXO[]
  ): void {
    // 剪枝:如果當前值已超過最大目標太多
    if (currentValue > targetRange.max + sorted[sorted.length - 1]?.effective) {
      return;
    }

    // 找到有效解
    if (currentValue >= targetRange.min && currentValue <= targetRange.max) {
      const waste = currentValue - target;
      if (waste < bestWaste) {
        bestWaste = waste;
        bestSelection = [...currentSelection];
      }
      return;
    }

    // 超出搜索範圍
    if (index >= sorted.length) {
      return;
    }

    // 剪枝:剩餘 UTXO 不足以達到目標
    const remaining = sorted
      .slice(index)
      .reduce((sum, u) => sum + u.effective, 0);
    if (currentValue + remaining < targetRange.min) {
      return;
    }

    // 包含當前 UTXO
    search(
      index + 1,
      currentValue + sorted[index].effective,
      [...currentSelection, sorted[index].utxo]
    );

    // 不包含當前 UTXO
    search(index + 1, currentValue, currentSelection);
  }

  search(0, 0, []);

  return bestSelection
    ? { success: true, selection: bestSelection }
    : { success: false };
}

Knapsack 算法

function knapsackSelection(
  utxos: UTXO[],
  target: number,
  feeRate: number,
  iterations: number = 1000
): UTXO[] {
  let bestSelection: UTXO[] = [];
  let bestExcess = Infinity;

  for (let i = 0; i < iterations; i++) {
    // 隨機打亂
    const shuffled = [...utxos].sort(() => Math.random() - 0.5);

    const selection: UTXO[] = [];
    let total = 0;

    for (const utxo of shuffled) {
      selection.push(utxo);
      total += effectiveValue(utxo, feeRate);

      if (total >= target) {
        const excess = total - target;
        if (excess < bestExcess) {
          bestExcess = excess;
          bestSelection = [...selection];
        }
        break;
      }
    }
  }

  return bestSelection;
}

完整選擇器

interface CoinSelectionOptions {
  target: number;           // 目標金額(satoshis)
  feeRate: number;          // sat/vB
  outputCount: number;      // 輸出數量
  changeType?: string;      // 找零地址類型
  minConfirmations?: number;
}

function selectCoins(
  utxos: UTXO[],
  options: CoinSelectionOptions
): SelectionResult | null {
  const {
    target,
    feeRate,
    outputCount,
    changeType = 'p2wpkh',
    minConfirmations = 0,
  } = options;

  // 過濾 UTXO
  const candidates = utxos.filter((u) => {
    if (u.confirmations < minConfirmations) return false;
    if (effectiveValue(u, feeRate) <= 0) return false;
    return true;
  });

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

  // 計算基本費用(不含輸入)
  const baseFee = (10.5 + outputCount * OUTPUT_SIZES.p2wpkh) * feeRate;

  // 嘗試 Branch and Bound
  const bnbResult = branchAndBound(
    candidates,
    target + baseFee,
    feeRate
  );

  let selectedInputs: UTXO[];

  if (bnbResult.success && bnbResult.selection) {
    selectedInputs = bnbResult.selection;
  } else {
    // 回退到 Knapsack
    selectedInputs = knapsackSelection(
      candidates,
      target + baseFee,
      feeRate
    );
  }

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

  // 計算最終費用和找零
  const totalInput = selectedInputs.reduce((sum, u) => sum + u.value, 0);
  const { fee, vsize } = calculateFee(
    selectedInputs,
    outputCount,
    feeRate,
    changeType
  );

  const change = totalInput - target - fee;

  // 檢查找零是否為灰塵
  const dustThreshold = 546;
  const finalChange = change > dustThreshold ? change : 0;
  const finalFee = fee + (change > dustThreshold ? 0 : change);

  return {
    inputs: selectedInputs,
    fee: finalFee,
    change: finalChange,
    effectiveFeeRate: finalFee / vsize,
  };
}

灰塵處理

灰塵 (Dust) 定義:
花費 UTXO 的成本 > UTXO 的價值

灰塵閾值計算:
dustThreshold = (inputSize + 34) × 3 × minRelayFee

標準閾值:
- P2PKH: 546 satoshis
- P2WPKH: 294 satoshis
- P2TR: 330 satoshis

處理策略:
1. 避免創建低於閾值的找零
2. 將小額找零加入手續費
3. 考慮未來合併灰塵的成本

隱私考量

輸入合併問題

問題:
使用多個 UTXO 作為輸入會暴露它們屬於同一所有者

緩解策略:
1. 優先使用單個 UTXO
2. 只合併來自同一地址的 UTXO
3. 使用 CoinJoin

找零識別

分析者可以識別找零:
- 找零通常是較小的輸出
- 找零地址類型可能與輸入匹配

緩解策略:
1. 使用相同類型的地址
2. 使用 Paynym/靜態支付碼
3. 無找零交易(BnB)

批次支付

// 批次支付可以顯著降低每筆交易的手續費
function createBatchPayment(
  recipients: Array<{ address: string; amount: number }>,
  utxos: UTXO[],
  feeRate: number
): SelectionResult | null {
  const totalAmount = recipients.reduce((sum, r) => sum + r.amount, 0);

  return selectCoins(utxos, {
    target: totalAmount,
    feeRate,
    outputCount: recipients.length,
  });
}

// 範例:10 筆支付
// 單獨交易: 10 × (10.5 + 68 + 31) vB = 1095 vB
// 批次交易: 10.5 + 68 + (10 × 31) vB = 388.5 vB
// 節省: 65%

RPC 命令

# 列出可用 UTXO
bitcoin-cli listunspent

# 手動選擇 UTXO
bitcoin-cli createrawtransaction \
  '[{"txid":"...", "vout":0}]' \
  '[{"bc1q...": 0.001}]'

# 自動選擇(使用錢包)
bitcoin-cli walletcreatefundedpsbt \
  '[]' \
  '[{"bc1q...": 0.001}]'

# 設置手續費策略
bitcoin-cli settxfee 0.0001  # BTC/kB

最佳實踐

  • 優先使用 BnB:盡量避免產生找零
  • 定期整合 UTXO:在低手續費時期合併小額 UTXO
  • 使用批次支付:多筆支付合併為一筆交易
  • 考慮隱私:避免不必要的輸入合併
  • 監控 UTXO 健康:追蹤 UTXO 數量和金額分佈

相關資源

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