65.9K
CodeProject 正在变化。 阅读更多。
Home

构建您自己的比特币钱包

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (11投票s)

2016年12月2日

公共领域

9分钟阅读

viewsIcon

116954

.NET Core 中构建基本跨平台比特币钱包的教程和模板

背景

为了能够跟上本文的进度,您需要了解 C# 并熟悉 NBitcoin。最好您在之前已经阅读过 《编程区块链 C# 书籍》

设计选择

我们想要一个跨平台钱包,.NET Core 是我们的首选平台。NBitcoin 是目前最受欢迎的 C# 比特币库,因此我们将使用它。我们目前不需要 GUI,所以它将是一个 CLI 钱包。

与比特币网络通信的方式大致有三种:作为全节点、作为 SPV 节点或通过 HTTP API。本教程将使用 Nicolas Dorier(NBitcoin 的创建者)的 QBitNinja HTTP API,但我计划将其扩展到支持全节点通信。

截至目前(2016.11.29),尚不清楚隔离见证是否会在比特币网络上激活,因此我现在不将其纳入本教程。

我保持概念简单,以便您理解。这当然会带来一些低效率。在本教程之后,您可以查看 HiddenWallet,这是此钱包的后续版本。这样您就可以获得一个已修复错误和提高效率的生产就绪版本。

实现命令行解析

我想实现以下命令:helpgenerate-walletrecover-walletshow-balancesshow-historyreceivesend

help 命令的功能不言自明。在我的应用程序中,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 CLI 应用程序,并使用您喜欢的方法实现命令行参数解析,或者直接查看我的代码。

然后从 NuGet 添加 NBitcoinQBitNinja.Client

创建配置文件

第一次运行我的应用程序时,它会生成带有默认参数的配置文件

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

Config.json 文件存储全局设置。

这些值Network 可以是 MainTestNet。在开发时,您可能希望将其保留在测试网上。此外,您可能希望将 CanSpendUnconfirmed 设置为 True

ConnectionType 可以是 HttpFullNode。如果设置为 FullNode,它会一直抛出异常。

我们还希望轻松访问这些设置,因此我创建了一个 Config

public static class Config
{
    // Initialized with default attributes
    public static string DefaultWalletFileName = @"Wallet.json";
    public static Network Network = Network.Main;
    ....
}

现在您可以选择您喜欢的方法来管理此配置文件,或者直接查看我的代码。

Commands

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);

然后,让我们弄清楚如何正确管理我们的私钥。我正在编写一个名为 HBitcoinGitHubNuGet)的库,其中有一个 Safe 类,可以避免在此操作中出错。我强烈建议您使用此类,除非您知道自己在做什么。如果您尝试手动操作,一个小错误可能导致灾难性后果,您的客户可能会损失资金。

之前,我已在此处 以高层次 和此处 以低层次 详细记录了其用法。

尽管我为了这个项目对其进行了修改。在原始版本中,我将所有 NBitcoin 引用隐藏起来,不让我的 Safe 类的用户接触到,这样他们就不会被细节压倒。在本篇文章中,我的受众更高级。

工作流
  1. 从用户获取密码
  2. 从用户获取密码确认
  3. 创建钱包
  4. 显示助记词

首先,从用户那里获取密码和密码确认。如果您决定自己编写,请在不同系统上进行测试。不同的终端在相同代码上的表现不同。

string pw;
string pwConf;
do
{
    // 1. Get password from user
    WriteLine("Choose a password:");
    pw = PasswordConsole.ReadPassword();
    // 2. Get password confirmation from user
    WriteLine("Confirm password:");
    pwConf = PasswordConsole.ReadPassword();

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

接下来,使用我修改过的 Safe 类创建一个钱包并显示助记词。

// 3. Create wallet
string mnemonic;
Safe safe = Safe.Create(out mnemonic, pw, walletFilePath, Config.Network);
// If no exception thrown the wallet is successfully created.
WriteLine();
WriteLine("Wallet is successfully created.");
WriteLine($"Wallet file: {walletFilePath}");

// 4. Display mnemonic
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);
// If no exception thrown the wallet is successfully recovered.
WriteLine();
WriteLine("Wallet is successfully recovered.");
WriteLine($"Wallet file: {walletFilePath}");

关于安全的附注

要破解钱包,攻击者必须知道(密码助记词密码钱包文件)。这不像大多数其他钱包,知道助记词通常就足够了。

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)
{
    // From now on we'll only work here
}
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 神技查询大量数据

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

上面的语法可能需要一些脑力来理解。不要偷懒,情况只会变得更糟。它基本上做的是:给我们一个字典,其键是我们的安全地址,值是这些地址上的所有操作。一个操作列表的列表。换句话说:操作按地址分组。这些信息足以在不进一步查询区块链的情况下成功实现任何命令。

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;
}

这里发生了许多事情。基本上,它查询我们指定的每个地址的所有操作。

首先,我们查询我们安全钱包的第一个 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 是比特币地址,elem.Value 是该地址上的操作。

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      bcef7265f92f8b40dba0a40b706735daf9f05bde480b609adb96f4087442bbe8

代码

关注我的评论

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

if (Config.ConnectionType == ConnectionType.Http)
{
// 0. Query all operations, grouped our used safe addresses
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. Create history records from the transactions
// History records is arbitrary data we want to show to the user
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. Order the records by confirmations and time 
// (Simply time does not work, because of a QBitNinja bug)
var orderedTxHistoryRecords = txHistoryRecords
    .OrderByDescending(x => x.Item3) // Confirmations
    .ThenBy(x => x.Item1); // FirstSeen
foreach (var record in orderedTxHistoryRecords)
{
    // Item2 is the Amount
    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>
---------------------------------------------------------------------------

代码

与上一个类似,同样令人困惑。关注我的评论

// 0. Query all operations, grouped by addresses
Dictionary<BitcoinAddress, 
 List<BalanceOperation>> operationsPerAddresses = QueryOperationsPerSafeAddresses(safe, 7);

// 1. Get all address history record with a wrapper class
var addressHistoryRecords = new List<AddressHistoryRecord>();
foreach (var elem in operationsPerAddresses)
{
    foreach (var op in elem.Value)
    {
        addressHistoryRecords.Add(new AddressHistoryRecord(elem.Key, op));
    }
}

// 2. Calculate wallet balances
Money confirmedWalletBalance;
Money unconfirmedWalletBalance;
GetBalances(addressHistoryRecords, out confirmedWalletBalance, out unconfirmedWalletBalance);

// 3. Group all address history records by addresses
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. Calculate address balances
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.

代码

从用户那里获取指定的 btc 金额和比特币地址。将它们解析为 NBitcoin.MoneyNBitcoin.BitcoinAddress

首先,让我们找出我们所有非空的私钥,这样我们就知道我们可以花什么。

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

// 1. Gather all the not empty private keys
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. Get the script pubkey of the change.
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. Gather coins can be spend
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;
}

接下来,让我们计算我们的费用。这是目前比特币界的一个热门话题,存在大量的 FUD 和错误信息。事实是,对于非奇特的已确认交易,简单的动态费用计算几乎总是有效的。我将使用 HTTP API 来查询应该使用什么费用,并妥善处理 API 出现问题的情况。这一点很重要,即使您使用最可靠的方式(比特币核心)计算费用,也应该始终预期它会出问题。还记得 Mycelium 的 16 美元交易费吗? 那不是钱包的错。

需要注意的一点是:正确的费用取决于交易大小。交易大小取决于输入和输出的数量。在此处了解更多信息。 带有 1-2 个输入和 2 个输出的常规交易大约为 250 字节。使用此常量应该足够了,因为交易大小的变化不大。

但是,也存在一些边缘情况,例如当您有很多小额输入时,我在 此处 处理了它们,但在此教程中不包含,因为它会大大增加费用估算的复杂性。

// 4. Get the fee
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. How much money we can spend?
Money availableAmount = Money.Zero;
Money unconfirmedAvailableAmount = Money.Zero;
foreach (var elem in unspentCoins)
{
    // If can spend unconfirmed add all
    if (Config.CanSpendUnconfirmed)
    {
        availableAmount += elem.Key.Amount;
        if (!elem.Value)
            unconfirmedAvailableAmount += elem.Key.Amount;
    }
    // else only add confirmed ones
    else
    {
        if (elem.Value)
        {
            availableAmount += elem.Key.Amount;
        }
    }
}

接下来,我们需要弄清楚要发送多少钱。我可以轻松地从参数中获取,如下所示

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

但我希望做得更好,让用户指定一个特殊的金额来发送钱包中的所有资金。这是可以实现的。因此,用户可以用 btc=all 代替 btc=2.918112。经过少量重构,上面的代码变成了这样

// 6. How much to spend?
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. Do some checks
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. Select coins
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. Get signing keys
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. Build the transaction
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 的响应存在 bug,所以我们进行一些手动检查。

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

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

var qBitClient = new QBitNinjaClient(Config.Network);

// QBit's success response is buggy so let's check manually, too        
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);

结束语

恭喜您,您已经构建了您的第一个比特币钱包。即使您没有完全理解,您也会面临与我相同的决策,并且可能会比我更好地解决它们。另外,如果您走到这一步,我很欢迎您的 PR 来修复我在此实现中可能犯的数百万个 bug 之一。

更新

  • 2017 年 2 月 21 日
    • 添加 HBitcoin NuGet 选项以获取 Safe
    • 添加后续版本 HiddenWallet,用于 bug 修复和性能改进
  • 2016 年 12 月 19 日
    • 澄清交易费用计算部分
    • 修复一些格式错误
© . All rights reserved.