1. 程式人生 > >打造屬於自己的比特幣錢包

打造屬於自己的比特幣錢包

背景

為了能夠順利地讀懂本文,您需要有一點C#程式設計經驗並且熟悉NBitcoin。當然如果你研究過Bitcoin C# book就更好了。

設計選擇

我們希望打造一個跨平臺的錢包,所以.NET Core是我們的首選。我們將使用NBitcoin比特幣庫,因為它是目前為止最流行的庫。這個錢包沒有使用圖形介面的必要,因此使用命令列介面就夠了。

大體上有三種方式可以和比特幣網路進行通訊:用一個完整節點,SPV節點或通過HTTP API。本教程將使用來自NBitcoin的創造者Nicolas Dorier的QBitNinja HTTP API,但我計劃把它擴充套件成一個完整的通訊節點。

下面我會盡量說的通俗易懂,因此可能效率不會那麼高。在閱讀完本教程之後,您可以去看看這個錢包的應用版本

HiddenWallet。這是個修復了BUG,效能也比較高,可以真正拿來用的比特幣錢包。

命令列實現解析

這個錢包得具備以下命令:helpgenerate-walletrecover-walletshow-balancesshow-historyreceivesend

help命令是沒有其他引數的。generate-walletrecover-walletshow-balancesshow-historyreceive命令後面可以加引數--指定錢包的檔名。例如wallet-file=wallet.dat。如果wallet-file=未指定引數的話,則應用程式將使用預設配置檔案中指定的錢包檔案。

send命令後面同樣可以附加錢包檔名和一些其他引數,如:

  • btc=3.2
  • address=1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4xqX

幾個例子:

  • dotnet run generate-wallet wallet-file=wallet.dat
  • dotnet run receive wallet-file=wallet.dat
  • dotnet run show-balances wallet-file=wallet.dat
  • dotnet run send wallet-file=wallet.dat btc=3.2 address=1F1tAaz5x1HUXrCNLbtMDqcw6o5GNn4x
  • dotnet run show-history wallet-file = wallet.dat

現在我們繼續建立一個新的.NET Core命令列程式,你可以自己隨喜好去實現這些命令,或者跟著我的程式碼來也行。

然後從NuGet管理器中新增NBitcoin和QBitNinja.Client。

建立配置檔案

第一次執行程式時,它會生成帶預設引數的配置檔案:

{
  "DefaultWalletFileName": "Wallet.json",
  "Network": "Main",
  "ConnectionType": "Http",
  "CanSpendUnconfirmed": "False"
}

Config.json檔案儲存全域性設定。

Network的值的可以是MainTestNet。當你在處於開發階段時你可以把它設定為測試模式(TestNet)。CanSpendUnconfirmed也可以設定為TrueConnectionType可以是HttpFullNode,但如果設定為FullNode的話,程式會丟擲異常

為了方便的設定配置檔案,我建立了一個類:Config

複製程式碼

public static class Config
{
    // 使用預設屬性初始化
    public static string DefaultWalletFileName = @"Wallet.json";
    public static Network Network = Network.Main;
    ....
}

複製程式碼

你可以用你喜歡的方式來管理這個配置檔案,或者跟著我的程式碼來。

命令

generate-wallet

輸出示例

複製程式碼

Choose a password:

Confirm password:

Wallet is successfully created.
Wallet file: Wallets/Wallet.json

Write down the following mnemonic words.
With the mnemonic words AND your password you can recover this wallet by using the recover-wallet command.

-------
renew frog endless nature mango farm dash sing frog trip ritual voyage
-------

複製程式碼

程式碼

首先要確定指定名字的錢包檔案不存在,以免意外覆蓋一個已經存在的錢包檔案。

var walletFilePath =  GetWalletFilePath ( args ); 
AssertWalletNotExists ( walletFilePath );

那麼要怎樣怎樣妥當地管理我們的錢包私鑰呢?我寫了一個HBitcoin(GitHubNuGet)的庫,裡面有一個類叫Safe類,我強烈建議你使用這個類,這樣能確保你不會出什麼差錯。如果你想自己手動去實現金鑰管理類的話,你得有十足的把握。不然一個小錯誤就可能會導致災難性的後果,您的客戶可能會損失掉錢包裡的資金。

之前我很全面地寫了一些關於這個類的使用方法:這個連結是高階版這個連結是簡單版

在原始版本中,為了讓那些Safe類的使用者們不被那些NBitcoin 的複雜引用搞的頭暈,我把很多細節都隱藏起來了。但對於這篇文章,我對Safe做了稍許修改,因為本文章的讀者應該水平更高一點。

工作流程

  1. 使用者輸入密碼
  2. 使用者確認密碼
  3. 建立錢包
  4. 顯示助記符

首先使用者輸入密碼並確認密碼。如果您決定自己寫,請在不同的系統上進行測試。相同的程式碼在不同的終端可能有不同的結果。

複製程式碼

string pw;
string pwConf;
do
{
    // 1. 使用者輸入密碼
    WriteLine("Choose a password:");
    pw = PasswordConsole.ReadPassword();
    // 2. 使用者確認密碼
    WriteLine("Confirm password:");
    pwConf = PasswordConsole.ReadPassword();

    if (pw != pwConf) WriteLine("Passwords do not match. Try again!");
} while (pw != pwConf);

複製程式碼

接下來用我的修改後的Safe類建立一個錢包並顯示助記符。

複製程式碼

// 3. 建立錢包
string mnemonic;
Safe safe = Safe.Create(out mnemonic, pw, walletFilePath, Config.Network);
// 如果沒有異常丟擲的話,此時就會建立一個錢包
WriteLine();
WriteLine("Wallet is successfully created.");
WriteLine($"Wallet file: {walletFilePath}");

// 4. 顯示助記符
WriteLine();
WriteLine("Write down the following mnemonic words.");
WriteLine("With the mnemonic words AND your password you can recover this wallet by using the recover-wallet command.");
WriteLine();
WriteLine("-------");
WriteLine(mnemonic);
WriteLine("-------");

複製程式碼

recover-wallet

輸出示例

複製程式碼

Your software is configured using the Bitcoin TestNet network.
Provide your mnemonic words, separated by spaces:
renew frog endless nature mango farm dash sing frog trip ritual voyage
Provide your password. Please note the wallet cannot check if your password is correct or not. If you provide a wrong password a wallet will be recovered with your provided mnemonic AND password pair:

Wallet is successfully recovered.
Wallet file: Wallets/jojdsaoijds.json

複製程式碼

程式碼

無需多解釋,程式碼很簡單,很容易理解

複製程式碼

var walletFilePath = GetWalletFilePath(args);
AssertWalletNotExists(walletFilePath);

WriteLine($"Your software is configured using the Bitcoin {Config.Network} network.");
WriteLine("Provide your mnemonic words, separated by spaces:");
var mnemonic = ReadLine();
AssertCorrectMnemonicFormat(mnemonic);

WriteLine("Provide your password. Please note the wallet cannot check if your password is correct or not. If you provide a wrong password a wallet will be recovered with your provided mnemonic AND password pair:");
var password = PasswordConsole.ReadPassword();

Safe safe = Safe.Recover(mnemonic, password, walletFilePath, Config.Network);
// 如果沒有異常丟擲,錢包會被順利恢復
WriteLine();
WriteLine("Wallet is successfully recovered.");
WriteLine($"Wallet file: {walletFilePath}");

複製程式碼

安全提示

攻擊者如果想破解一個比特幣錢包,他必須知道(passwordmnemonic)或(password和錢包檔案)。而其他錢包只要知道助記符就夠了。

receive

輸出示例

複製程式碼

Type your password:

Wallets/Wallet.json wallet is decrypted.
7 Receive keys are processed.

---------------------------------------------------------------------------
Unused Receive Addresses
---------------------------------------------------------------------------
mxqP39byCjTtNYaJUFVZMRx6zebbY3QKYx
mzDPgvzs2Tbz5w3xdXn12hkSE46uMK2F8j
mnd9h6458WsoFxJEfxcgq4k3a2NuiuSxyV
n3SiVKs8fVBEecSZFP518mxbwSCnGNkw5s
mq95Cs3dpL2tW8YBt41Su4vXRK6xh39aGe
n39JHXvsUATXU5YEVQaLR3rLwuiNWBAp5d
mjHWeQa63GPmaMNExt14VnjJTKMWMPd7yZ

複製程式碼

程式碼

到目前為止,我們都不必與比特幣網路進行通訊。下面就來了,正如我之前提到的,這個錢包有兩種方法可以與比特幣網路進行通訊。通過HTTP API和使用完整節點。(稍後我會解釋為什麼我不實現完整節點的通訊方式)。

我們現在有兩種方式可以分別實現其餘的命令,好讓它們都能與區塊鏈進行通訊。當然這些命令也需要訪問Safe類:

複製程式碼

var walletFilePath = GetWalletFilePath(args);
Safe safe = DecryptWalletByAskingForPassword(walletFilePath);

if (Config.ConnectionType == ConnectionType.Http)
{
    // 從現在開始,我們下面的工作都在這裡進行
}
else if (Config.ConnectionType == ConnectionType.FullNode)
{
    throw new NotImplementedException();
}
else
{
    Exit("Invalid connection type.");
}

複製程式碼

我們將使用QBitNinja.Client作為我們的HTTP API,您可以在NuGet中找到它。對於完整節點通訊,我的想法是在本地執行QBitNinja.Server和bitcoind客戶端。這樣Client(客戶端)就可以連上了,並且程式碼也會比較統一規整。只是有個問題,QBitNinja.Server目前還不能在.NET Core上執行。

receive命令是最直接的。我們只需向用戶展示7個未使用的地址就行了,這樣它就可以開始接收比特幣了。

下面我們該做的就是用QBitNinja jutsu(QBit忍術)來查詢一堆資料:

Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerReceiveAddresses = QueryOperationsPerSafeAddresses(safe, 7, HdPathType.Receive);

上面的語句可能有點難懂。不要逃避,那樣你會什麼都不懂得。它的基本功能是:給我們一個字典,其中鍵是我們的safe類(錢包)的地址,值是這些地址上的所有操作。操作列表的列表,換句話說就是:這些操作按地址就行分組。這樣我們就有足夠的資訊來實現所有命令而不需要再去進一步查詢區塊鏈了。

複製程式碼

public static Dictionary<BitcoinAddress, List<BalanceOperation>> QueryOperationsPerSafeAddresses(Safe safe, int minUnusedKeys = 7, HdPathType? hdPathType = null)
{
    if (hdPathType == null)
    {
        Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerReceiveAddresses = QueryOperationsPerSafeAddresses(safe, 7, HdPathType.Receive);
        Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerChangeAddresses = QueryOperationsPerSafeAddresses(safe, 7, HdPathType.Change);

        var operationsPerAllAddresses = new Dictionary<BitcoinAddress, List<BalanceOperation>>();
        foreach (var elem in operationsPerReceiveAddresses)
            operationsPerAllAddresses.Add(elem.Key, elem.Value);
        foreach (var elem in operationsPerChangeAddresses)
            operationsPerAllAddresses.Add(elem.Key, elem.Value);
        return operationsPerAllAddresses;
    }

    var addresses = safe.GetFirstNAddresses(minUnusedKeys, hdPathType.GetValueOrDefault());
    //var addresses = FakeData.FakeSafe.GetFirstNAddresses(minUnusedKeys);

    var operationsPerAddresses = new Dictionary<BitcoinAddress, List<BalanceOperation>>();
    var unusedKeyCount = 0;
    foreach (var elem in QueryOperationsPerAddresses(addresses))
    {
        operationsPerAddresses.Add(elem.Key, elem.Value);
        if (elem.Value.Count == 0) unusedKeyCount++;
    }
    WriteLine($"{operationsPerAddresses.Count} {hdPathType} keys are processed.");

    var startIndex = minUnusedKeys;
    while (unusedKeyCount < minUnusedKeys)
    {
        addresses = new HashSet<BitcoinAddress>();
        for (int i = startIndex; i < startIndex + minUnusedKeys; i++)
        {
            addresses.Add(safe.GetAddress(i, hdPathType.GetValueOrDefault()));
            //addresses.Add(FakeData.FakeSafe.GetAddress(i));
        }
        foreach (var elem in QueryOperationsPerAddresses(addresses))
        {
            operationsPerAddresses.Add(elem.Key, elem.Value);
            if (elem.Value.Count == 0) unusedKeyCount++;
        }
        WriteLine($"{operationsPerAddresses.Count} {hdPathType} keys are processed.");
        startIndex += minUnusedKeys;
    }

    return operationsPerAddresses;
}

複製程式碼

這些程式碼做了很多事。基本上它所做的是查詢我們指定的每個地址的所有操作。首先,如果safe類中的前7個地址不是全部未使用的,我們就進行查詢,然後繼續查詢後面7個地址。如果在組合列表中,仍然沒有找到7個未使用的地址,我們再查詢7個,以此次類推完成查詢。在if ConnectionType.Http的結尾,我們完成了任何有關我們的錢包金鑰的所有操作。而且,這些操作在與區塊鏈溝通的其他命令中都是必不可少的,這樣我們後面就輕鬆了。現在我們來學習如何用operationsPerAddresses來向用戶輸出相關資訊。

receive命令是最簡單的一個。它只是向向用戶展示了所有未使用和正處於監控中的地址:

複製程式碼

Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerReceiveAddresses = QueryOperationsPerSafeAddresses(safe, 7, HdPathType.Receive);

WriteLine("---------------------------------------------------------------------------");
WriteLine("Unused Receive Addresses");
WriteLine("---------------------------------------------------------------------------");
foreach (var elem in operationsPerReceiveAddresses)
    if (elem.Value.Count == 0)
        WriteLine($"{elem.Key.ToWif()}");

複製程式碼

請注意elem.Key是比特幣地址。

show-history

輸出示例

複製程式碼

Type your password:

Wallets/Wallet.json wallet is decrypted.
7 Receive keys are processed.
14 Receive keys are processed.
21 Receive keys are processed.
7 Change keys are processed.
14 Change keys are processed.
21 Change keys are processed.

---------------------------------------------------------------------------
Date            Amount        Confirmed    Transaction Id
---------------------------------------------------------------------------
12/2/16 10:39:59 AM    0.04100000    True        1a5d0e6ba8e57a02e9fe5162b0dc8190dc91857b7ace065e89a0f588ac2e7316
12/2/16 10:39:59 AM    -0.00025000    True        56d2073b712f12267dde533e828f554807e84fc7453e4a7e44e78e039267ff30
12/2/16 10:39:59 AM    0.04100000    True        3287896029429735dbedbac92712283000388b220483f96d73189e7370201043
12/2/16 10:39:59 AM    0.04100000    True        a20521c75a5960fcf82df8740f0bb67ee4f5da8bd074b248920b40d3cc1dba9f
12/2/16 10:39:59 AM    0.04000000    True        60da73a9903dbc94ca854e7b022ce7595ab706aca8ca43cb160f02dd36ece02f
12/2/16 10:39:59 AM    -0.00125000    True 

複製程式碼

程式碼

跟著我來:

複製程式碼

AssertArgumentsLenght(args.Length, 1, 2);
var walletFilePath = GetWalletFilePath(args);
Safe safe = DecryptWalletByAskingForPassword(walletFilePath);

if (Config.ConnectionType == ConnectionType.Http)
{
// 0.查詢所有操作,把使用過的Safe地址(錢包地址)按組分類
Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerAddresses = QueryOperationsPerSafeAddresses(safe);

WriteLine();
WriteLine("---------------------------------------------------------------------------");
WriteLine("Date\t\t\tAmount\t\tConfirmed\tTransaction Id");
WriteLine("---------------------------------------------------------------------------");

Dictionary<uint256, List<BalanceOperation>> operationsPerTransactions = GetOperationsPerTransactions(operationsPerAddresses);

// 3. 記錄交易歷史
// 向用戶展示歷史記錄資訊這個功能是可選的
var txHistoryRecords = new List<Tuple<DateTimeOffset, Money, int, uint256>>();
foreach (var elem in operationsPerTransactions)
{
    var amount = Money.Zero;
    foreach (var op in elem.Value)
        amount += op.Amount;
    var firstOp = elem.Value.First();

    txHistoryRecords
        .Add(new Tuple<DateTimeOffset, Money, int, uint256>(
            firstOp.FirstSeen,
            amount,
            firstOp.Confirmations,
            elem.Key));
}

// 4. 把記錄按時間或確認順序排序(按時間排序是無效的, 因為QBitNinja有這麼個bug)
var orderedTxHistoryRecords = txHistoryRecords
    .OrderByDescending(x => x.Item3) // 時間排序
    .ThenBy(x => x.Item1); // 首項
foreach (var record in orderedTxHistoryRecords)
{
    // Item2是總額
    if (record.Item2 > 0) ForegroundColor = ConsoleColor.Green;
    else if (record.Item2 < 0) ForegroundColor = ConsoleColor.Red;
    WriteLine($"{record.Item1.DateTime}\t{record.Item2}\t{record.Item3 > 0}\t\t{record.Item4}");
    ResetColor();
}

複製程式碼

show-balances

輸出示例

複製程式碼

Type your password:

Wallets/test wallet is decrypted.
7 Receive keys are processed.
14 Receive keys are processed.
7 Change keys are processed.
14 Change keys are processed.

---------------------------------------------------------------------------
Address                    Confirmed    Unconfirmed
---------------------------------------------------------------------------
mk212H3T5Hm11rBpPAhfNcrg8ioL15zhYQ    0.0655        0
mpj1orB2HDp88shsotjsec2gdARnwmabug    0.09975        0

---------------------------------------------------------------------------
Confirmed Wallet Balance: 0.16525btc
Unconfirmed Wallet Balance: 0btc<code>
---------------------------------------------------------------------------</code>

複製程式碼

程式碼

它與前一個類似,有點難懂。跟著我來:

複製程式碼

// 0.查詢所有操作,按地址分組 
Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerAddresses = QueryOperationsPerSafeAddresses(safe, 7);

//1.通過wrapper類取得所有地址歷史記錄
var addressHistoryRecords = new List<AddressHistoryRecord>();
foreach (var elem in operationsPerAddresses)
{
    foreach (var op in elem.Value)
    {
        addressHistoryRecords.Add(new AddressHistoryRecord(elem.Key, op));
    }
}

// 2. 計算錢包餘額
Money confirmedWalletBalance;
Money unconfirmedWalletBalance;
GetBalances(addressHistoryRecords, out confirmedWalletBalance, out unconfirmedWalletBalance);

// 3. 把所有地址歷史記錄按地址分組
var addressHistoryRecordsPerAddresses = new Dictionary<BitcoinAddress, HashSet<AddressHistoryRecord>>();
foreach (var address in operationsPerAddresses.Keys)
{
    var recs = new HashSet<AddressHistoryRecord>();
    foreach(var record in addressHistoryRecords)
    {
        if (record.Address == address)
            recs.Add(record);
    }
    addressHistoryRecordsPerAddresses.Add(address, recs);
}

// 4. 計算地址的餘額
WriteLine();
WriteLine("---------------------------------------------------------------------------");
WriteLine("Address\t\t\t\t\tConfirmed\tUnconfirmed");
WriteLine("---------------------------------------------------------------------------");
foreach (var elem in addressHistoryRecordsPerAddresses)
{
    Money confirmedBalance;
    Money unconfirmedBalance;
    GetBalances(elem.Value, out confirmedBalance, out unconfirmedBalance);
    if (confirmedBalance != Money.Zero || unconfirmedBalance != Money.Zero)
        WriteLine($"{elem.Key.ToWif()}\t{confirmedBalance.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}\t\t{unconfirmedBalance.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}");
}
WriteLine("---------------------------------------------------------------------------");
WriteLine($"Confirmed Wallet Balance: {confirmedWalletBalance.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}btc");
WriteLine($"Unconfirmed Wallet Balance: {unconfirmedWalletBalance.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}btc");
WriteLine("---------------------------------------------------------------------------");

複製程式碼

send

輸出示例

複製程式碼

Type your password:

Wallets/test wallet is decrypted.
7 Receive keys are processed.
14 Receive keys are processed.
7 Change keys are processed.
14 Change keys are processed.
Finding not empty private keys...
Select change address...
1 Change keys are processed.
2 Change keys are processed.
3 Change keys are processed.
4 Change keys are processed.
5 Change keys are processed.
6 Change keys are processed.
Gathering unspent coins...
Calculating transaction fee...
Fee: 0.00025btc

The transaction fee is 2% of your transaction amount.
Sending:     0.01btc
Fee:         0.00025btc
Are you sure you want to proceed? (y/n)
y
Selecting coins...
Signing transaction...
Transaction Id: ad29443fee2e22460586ed0855799e32d6a3804d2df059c102877cc8cf1df2ad
Try broadcasting transaction... (1)

Transaction is successfully propagated on the network.

複製程式碼

程式碼

從使用者處獲取指定的特位元金額和比特幣地址。將他們解析成NBitcoin.MoneyNBitcoin.BitcoinAddress

我們先找到所有非空的私鑰,這樣我們就知道有多少錢能花。

複製程式碼

Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerAddresses = QueryOperationsPerSafeAddresses(safe, 7);

// 1. 收集所有非空的私鑰
WriteLine("Finding not empty private keys...");
var operationsPerNotEmptyPrivateKeys = new Dictionary<BitcoinExtKey, List<BalanceOperation>>();
foreach (var elem in operationsPerAddresses)
{
    var balance = Money.Zero;
    foreach (var op in elem.Value) balance += op.Amount;
    if (balance > Money.Zero)
    {
        var secret = safe.FindPrivateKey(elem.Key);
        operationsPerNotEmptyPrivateKeys.Add(secret, elem.Value);
    }
}

複製程式碼

下面我們得找個地方把更改傳送出去。首先我們先得到changeScriptPubKey。這是第一個未使用的changeScriptPubKey,我使用了一種效率比較低的方式來完成它,因為突然間我不知道該怎麼做才不會讓我的程式碼變得亂七八糟:

複製程式碼

// 2. 得到所有ScriptPubkey的變化
WriteLine("Select change address...");
Script changeScriptPubKey = null;
Dictionary<BitcoinAddress, List<BalanceOperation>> operationsPerChangeAddresses = QueryOperationsPerSafeAddresses(safe, minUnusedKeys: 1, hdPathType: HdPathType.Change);
foreach (var elem in operationsPerChangeAddresses)
{
    if (elem.Value.Count == 0)
        changeScriptPubKey = safe.FindPrivateKey(elem.Key).ScriptPubKey;
}
if (changeScriptPubKey == null)
    throw new ArgumentNullException();

複製程式碼

一切搞定。現在讓我們以同樣低效的方式來收集未使用的比特幣:

// 3. 獲得花掉的比特幣數目
WriteLine("Gathering unspent coins...");
Dictionary<Coin, bool> unspentCoins = GetUnspentCoins(operationsPerNotEmptyPrivateKeys.Keys);

還有功能:

複製程式碼

/// <summary>
/// 
/// </summary>
/// <param name="secrets"></param>
/// <returns>dictionary with coins and if confirmed</returns>
public static Dictionary<Coin, bool> GetUnspentCoins(IEnumerable<ISecret> secrets)
{
    var unspentCoins = new Dictionary<Coin, bool>();
    foreach (var secret in secrets)
    {
        var destination = secret.PrivateKey.ScriptPubKey.GetDestinationAddress(Config.Network);

        var client = new QBitNinjaClient(Config.Network);
        var balanceModel = client.GetBalance(destination, unspentOnly: true).Result;
        foreach (var operation in balanceModel.Operations)
        {
            foreach (var elem in operation.ReceivedCoins.Select(coin => coin as Coin))
            {
                unspentCoins.Add(elem, operation.Confirmations > 0);
            }
        }
    }

    return unspentCoins;
}

複製程式碼

下面我們來計算一下手續費。在比特幣圈裡這可是一個熱門話題,裡面有很多疑惑和錯誤資訊。其實很簡單,一筆交易只要是確定的,不是異世界裡的,那麼使用動態計算算出來的費用就99%是對的。但是當API出現問題時,我將使用HTTP API來查詢費用並妥當的處理。這一點很重要,即使你用比特幣核心中最可靠的方式來計算費用,你也不能指望它100%不出錯。還記得 Mycelium 的16美元交易費用嗎?這其實也不是錢包的錯。

有一件事要注意:交易的資料包大小決定了交易費用。而交易資料包的大小又取決於輸入和輸出的資料大小。一筆常規交易大概有1-2個輸入和2個輸出,資料白大小為250位元組左右,這個大小應該夠用了,因為交易的資料包大小變化不大。但是也有一些例外,例如當你有很多小的輸入時。我在這個連結裡說明了如何處理,但是我不會寫在本教程中,因為它會使費用估計變得很複雜。

複製程式碼

// 4. 取得手續費
WriteLine("Calculating transaction fee...");
Money fee;
try
{
    var txSizeInBytes = 250;
    using (var client = new HttpClient())
    {

        const string request = @"https://bitcoinfees.21.co/api/v1/fees/recommended";
        var result = client.GetAsync(request, HttpCompletionOption.ResponseContentRead).Result;
        var json = JObject.Parse(result.Content.ReadAsStringAsync().Result);
        var fastestSatoshiPerByteFee = json.Value<decimal>("fastestFee");
        fee = new Money(fastestSatoshiPerByteFee * txSizeInBytes, MoneyUnit.Satoshi);
    }
}
catch
{
    Exit("Couldn't calculate transaction fee, try it again later.");
    throw new Exception("Can't get tx fee");
}
WriteLine($"Fee: {fee.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}btc");

複製程式碼

如你所見,我只發起了最快的交易請求。此外,我們希望檢查費用是否高於了使用者想要傳送的資金的1%,如果超過了就要求客戶親自確認,但是這些將會在晚些時候完成。

現在我們來算算我們一共有多少錢可以花。儘管禁止使用者花費未經確認的硬幣是一個不錯的主意,但由於我經常希望這樣做,所以我會將它作為非預設選項新增到錢包中。

請注意,我們還會計算未確認的金額,這樣就人性化多了:

複製程式碼

// 5. 我們有多少錢能花?
Money availableAmount = Money.Zero;
Money unconfirmedAvailableAmount = Money.Zero;
foreach (var elem in unspentCoins)
{
    // 如果未確定的比特幣可以使用,則全部加起來
    if (Config.CanSpendUnconfirmed)
    {
        availableAmount += elem.Key.Amount;
        if (!elem.Value)
            unconfirmedAvailableAmount += elem.Key.Amount;
    }
    //否則只相加已經確定的
    else
    {
        if (elem.Value)
        {
            availableAmount += elem.Key.Amount;
        }
    }
}

複製程式碼

接下來我們要弄清楚有多少錢能用來發送。我可以很容易地通過引數來得到它,例如:

var amountToSend = new Money(GetAmountToSend(args), MoneyUnit.BTC);

但我想做得更好,能讓使用者指定一個特殊金額來發送錢包中的所有資金。這種需求總會有的。所以,使用者可以直接輸入btc=all而不是btc=2.918112來實現這個功能。經過一些重構,上面的程式碼變成了這樣:

複製程式碼

// 6. 能花多少?
Money amountToSend = null;
string amountString = GetArgumentValue(args, argName: "btc", required: true);
if (string.Equals(amountString, "all", StringComparison.OrdinalIgnoreCase))
{
    amountToSend = availableAmount;
    amountToSend -= fee;
}
else
{
    amountToSend = ParseBtcString(amountString);
}

複製程式碼

然後檢查下程式碼:

複製程式碼

// 7. 做一些檢查
if (amountToSend < Money.Zero || availableAmount < amountToSend + fee)
    Exit("Not enough coins.");

decimal feePc = Math.Round((100 * fee.ToDecimal(MoneyUnit.BTC)) / amountToSend.ToDecimal(MoneyUnit.BTC));
if (feePc > 1)
{
    WriteLine();
    WriteLine($"The transaction fee is {feePc.ToString("0.#")}% of your transaction amount.");
    WriteLine($"Sending:\t {amountToSend.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}btc");
    WriteLine($"Fee:\t\t {fee.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")}btc");
    ConsoleKey response = GetYesNoAnswerFromUser();
    if (response == ConsoleKey.N)
    {
        Exit("User interruption.");
    }
}

var confirmedAvailableAmount = availableAmount - unconfirmedAvailableAmount;
var totalOutAmount = amountToSend + fee;
if (confirmedAvailableAmount < totalOutAmount)
{
    var unconfirmedToSend = totalOutAmount - confirmedAvailableAmount;
    WriteLine();
    WriteLine($"In order to complete this transaction you have to spend {unconfirmedToSend.ToDecimal(MoneyUnit.BTC).ToString("0.#############################")} unconfirmed btc.");
    ConsoleKey response = GetYesNoAnswerFromUser();
    if (response == ConsoleKey.N)
    {
        Exit("User interruption.");
    }
}

複製程式碼

下面離建立交易只差最後一步了:選擇要花的比特幣。後面我會做一個面向隱私的比特幣選擇。現在只就用一個簡單就行了的:

複製程式碼

// 8. 選擇比特幣
WriteLine("Selecting coins...");
var coinsToSpend = new HashSet<Coin>();
var unspentConfirmedCoins = new List<Coin>();
var unspentUnconfirmedCoins = new List<Coin>();
foreach (var elem in unspentCoins)
    if (elem.Value) unspentConfirmedCoins.Add(elem.Key);
    else unspentUnconfirmedCoins.Add(elem.Key);

bool haveEnough = SelectCoins(ref coinsToSpend, totalOutAmount, unspentConfirmedCoins);
if (!haveEnough)
    haveEnough = SelectCoins(ref coinsToSpend, totalOutAmount, unspentUnconfirmedCoins);
if (!haveEnough)
    throw new Exception("Not enough funds.");

複製程式碼

還有SelectCoins功能:

複製程式碼

public static bool SelectCoins(ref HashSet<Coin> coinsToSpend, Money totalOutAmount, List<Coin> unspentCoins)
{
    var haveEnough = false;
    foreach (var coin in unspentCoins.OrderByDescending(x => x.Amount))
    {
        coinsToSpend.Add(coin);
        // if doesn't reach amount, continue adding next coin
        if (coinsToSpend.Sum(x => x.Amount) < totalOutAmount) continue;
        else
        {
            haveEnough = true;
            break;
        }
    }

    return haveEnough;
}

複製程式碼

接下來獲取簽名金鑰:

複製程式碼

// 9. 獲取簽名私鑰
var signingKeys = new HashSet<ISecret>();
foreach (var coin in coinsToSpend)
{
    foreach (var elem in operationsPerNotEmptyPrivateKeys)
    {
        if (elem.Key.ScriptPubKey == coin.ScriptPubKey)
            signingKeys.Add(elem.Key);
    }
}

複製程式碼

建立交易:

複製程式碼

// 10.建立交易
WriteLine("Signing transaction...");
var builder = new TransactionBuilder();
var tx = builder
    .AddCoins(coinsToSpend)
    .AddKeys(signingKeys.ToArray())
    .Send(addressToSend, amountToSend)
    .SetChange(changeScriptPubKey)
    .SendFees(fee)
    .BuildTransaction(true);

複製程式碼

最後把它廣播出去!注意這比理想的情況要多了些程式碼,因為QBitNinja的響應是錯誤的,所以我們做一些手動檢查:

複製程式碼

if (!builder.Verify(tx))
    Exit("Couldn't build the transaction.");

WriteLine($"Transaction Id: {tx.GetHash()}");

var qBitClient = new QBitNinjaClient(Config.Network);

// QBit's 的成功提示有點BUG,所以我們得手動檢查一下結果
BroadcastResponse broadcastResponse;
var success = false;
var tried = 0;
var maxTry = 7;
do
{
    tried++;
    WriteLine($"Try broadcasting transaction... ({tried})");
    broadcastResponse = qBitClient.Broadcast(tx).Result;
    var getTxResp = qBitClient.GetTransaction(tx.GetHash()).Result;
    if (getTxResp == null)
    {
        Thread.Sleep(3000);
        continue;
    }
    else
    {
        success = true;
        break;
    }
} while (tried <= maxTry);
if (!success)
{
    if (broadcastResponse.Error != null)
    {
        WriteLine($"Error code: {broadcastResponse.Error.ErrorCode} Reason: {broadcastResponse.Error.Reason}");
    }
    Exit($"The transaction might not have been successfully broadcasted. Please check the Transaction ID in a block explorer.", ConsoleColor.Blue);
}
Exit("Transaction is successfully propagated on the network.", ConsoleColor.Green);

複製程式碼

最後的話

恭喜你,你剛剛完成了你的第一個比特幣錢包。你可能也會像我一樣遇到一些難題,並且可能會更好地解決它們,即使你現在可能不太理解。此外,如果你已經略有小成了,我會歡迎你來修復我在這個比特幣錢包中可能產生的數百萬個錯誤。