钱包开发经验分享:BTC篇

<h1>钱包开发经验分享:BTC篇</h1>

<p>[TOC]</p>

<h2>BTC节点搭建</h2>

<p>关于BTC的第一步,自然是搭建节点。由于BTC流行最久最广,网络上关于BTC的节点搭建,或者在同步节点时出现问题的相关文章很多,我这里就不赘述了(主要是没有环境用来搭建节点)。这里推荐一篇文章:<a href="https://lhalcyon.com/blockchain-bitcoin-node/">区块链-Linux下Bitcoin测试节点搭建</a>,没有搭建节点的可以考虑下面几个网站:<a href="https://www.blockcypher.com/dev/bitcoin/#introduction">blockcypher</a>、<a href="https://www.blockchain.com/api/blockchain_api">blockchain</a>、<a href="https://docs.cryptoapis.io/#getting-started">cryptoapis</a>。这篇文章以及后续的关于omniUSDT的文章都是基于这些第三方接口实现的。</p>

<h2>BTC的账户模型——UTXO</h2>

<p>关于UTXO的含义阐述可以参考<a href="https://blog.csdn.net/jfkidear/article/details/90407712">理解比特币的 UTXO、地址和交易</a>,这篇文章对UTXO的阐述我觉得挺全面的。在里面提到:<em>在比特币种,一笔交易的每一条输入和输出实际上都是 UTXO,输入 UTXO 就是以前交易剩下的, 更准确的说是以前交易的输出 UTXO</em>。这句阐述得从JSON数据去理解。</p>

<p>每一笔交易包含了大于等于一个输出,如下图:</p>

<p><img alt="outputs" src="https://tva1.sinaimg.cn/large/006tNbRwgy1gaa9y5cx11j31360ko780.jpg"/></p>

<p>输出列表包含了输出数量(value)、输入脚本(script)、地址(addresses)和脚本类型(script_type),我们主要关注输入数量。</p>

<p>每一笔交易的JSON都包含了大于等于零个输入(挖矿收益没有输入),如下图:</p>

<p><img alt="inputs" src="https://tva1.sinaimg.cn/large/006tNbRwgy1gaa9y47mo7j32460u0tgw.jpg"/></p>

<p>输入列表包含这笔输入对应的上一笔交易的哈希(prev<em>hash)、这笔输入对应的上一笔交易输出的下标(output</em>index),输入脚本(script)、脚本类型(scrip_type)等字段。在输入中最重要的两个字段是上一笔交易的哈希和输出下标,由这两个字段,我们可以轻松找到这笔输入对应上一笔交易的输出,从而从输出中找到这笔输入的数量是多少。</p>

<h2>计算余额</h2>

<p>由上面的账户模型,我们知道了BTC的账户是由UTXO列表组成,每个账户从创建初期到当前的所有交易就是一系列的输入和输出,这些UTXO通过输入输出的规则串联在一起,形成了链式结构,因此要推算账户余额,我们可以通过计算这一系列的UTXO最终获得余额,但是在实际开发上这样做很消耗性能,因此在开发上我们往往考虑直接从第三方区块链浏览器通过开放API获得计算结果。事实上,基本上结合第三方区块链浏览器开发的API,没有搭建节点我们也可以直接完成很多操作:</p>

<p>参考代码:</p>

<p><code>java
/**
* 余额
* @param address
* @param mainNet
* @return
*/
public static String balance(String address, boolean mainNet){
String host = mainNet ? &quot;blockchain.info&quot; : &quot;testnet.blockchain.info&quot;;
String url = &quot;https://&quot; + host + &quot;/balance?active=&quot; + address;
OkHttpClient client = new OkHttpClient();
String response = null;
try {
response = client.newCall(new Request.Builder().url(url).build()).execute().body().string();
} catch (IOException e) {
e.printStackTrace();
}
Map&lt;String, Map&lt;String, Object&gt;&gt; result = new Gson().fromJson(response, Map.class);
Map&lt;String, Object&gt; balanceMap = result.get(address);
BigDecimal finalBalance = BigDecimal.valueOf((double) balanceMap.get(&quot;final_balance&quot;));
BigDecimal balance = finalBalance.divide(new BigDecimal(100000000));
return balance.toPlainString();
}
</code></p>

<p>测试代码:</p>

<p><code>java
/**
* 获取余额
* @throws Exception
*/
@Test
public void testGetBTCBalance() throws Exception{
String address = &quot;17A16QmavnUfCW11DAApiJxp7ARnxN5pGX&quot;;
String balance = BtcUtil.balance(address, true);
logger.warn(&quot;balance: {}&quot;, balance);
}
</code></p>

<p>若上面例子中,获取测试币的余额的接口<code>https://testnet.blockchain.info/balance?active=mtESbeXpf5qYkbYnphhKEJ7FU3UyQKYQzy</code>失效,可以改用<code>https://api.blockcypher.com/v1/btc/test3/addrs/mtESbeXpf5qYkbYnphhKEJ7FU3UyQKYQzy/balance</code>。</p>

<h2>计算矿工费</h2>

<p>由上面的结论可知,每一笔交易都由零个、一个或多个输入和一个或多个输出组成,每一个输入都指向上一笔交易的输出,这样每一笔交易都由这些输入输出(UTXO)串行而成。一般而言,一笔交易会有一个或多个多个输入,这些输入的数量总和刚好或者大于这次交易的数量,会有一个或多个输出,输出主要有这次交易的收款地址和数量,以及找零地址和找零数量,找零地址通常是原地址,输入的数量总和和输出的数量总和总是不相等的,因为每一笔交易中间包含了矿工费,由此我们可以推断出矿工费的计算方式,即每一笔的输入总和减去输出总和:</p>

<p>参考代码:</p>

<p><code></code>`java
/**
<em> 计算矿工费
</em> @param txid
<em> @param mainNet
</em> @return
*/
public static String fee(String txid, boolean mainNet){
String host = mainNet ? &quot;blockchain.info&quot; : &quot;testnet.blockchain.info&quot;;
String url = &quot;https://&quot; + host + &quot;/rawtx/&quot; + txid;
OkHttpClient client = new OkHttpClient();
String response = null;
try {
response = client.newCall(new Request.Builder().url(url).build()).execute().body().string();
} catch (IOException e) {
e.printStackTrace();
}
JSONObject jsonObject = JSONObject.parseObject(response);</p>

<pre><code> // 统计输入总和
JSONArray inputs = jsonObject.getJSONArray(&quot;inputs&quot;);
BigDecimal totalIn = BigDecimal.ZERO;
for (int i = 0; i &lt; inputs.size(); i++) {
JSONObject inputsData = inputs.getJSONObject(0);
JSONObject prevOut = inputsData.getJSONObject(&quot;prev_out&quot;);
totalIn = totalIn.add(prevOut.getBigDecimal(&quot;value&quot;));
}

// 统计输出总和
JSONArray outs = jsonObject.getJSONArray(&quot;out&quot;);
BigDecimal totalOut = BigDecimal.ZERO;
for (int i = 0; i &lt; outs.size(); i++) {
JSONObject outData = outs.getJSONObject(i);
totalOut = totalOut.add(outData.getBigDecimal(&quot;value&quot;));
}

return totalIn.subtract(totalOut).divide(new BigDecimal(100000000)).toPlainString();
}</code></pre>

<p><code></code>`</p>

<p>测试代码:</p>

<p><code>java
/**
* 计算矿工费
* https://blockchain.info/rawtx/$tx_hash
*/
@Test
public void testGetMinerFee(){
String txid = &quot;b8df97b51f54df1c1f831e0e9e5561c03822f6c5a5a59e0118b15836657a4970&quot;;
logger.warn(&quot;Fee: {}&quot;, BtcUtil.fee(txid, true));
}
</code></p>

<p>通过第三方区块链浏览器开放的API获取的交易数据和自己搭建节点获取的交易数据有些许不同,如果是自己搭建节点,我推荐使用<a href="https://bitbucket.org/azazar/bitcoin-json-rpc-client/wiki/Home">azazar/bitcoin-json-rpc-client</a>或者其他的封装了bitcoinRPC接口的SDK去实现,这样是最简单,最省事的实现方式,他封装了很多对象,不用我们手动从JSONObject对象去获取需要的数据,而且通过这些SDK我们可以真正像调用方法一样调用bitcoin节点的接口。</p>

<h2>获取未花费列表</h2>

<p>参考代码:</p>

<p><code></code>`java
/**<em>
</em> 获取未消费列表
<em> @param address :地址
</em> @return
*/
public static List&lt;UTXO&gt; getUnspent(String address, boolean mainNet) {
List&lt;UTXO&gt; utxos = Lists.newArrayList();
String host = mainNet ? &quot;blockchain.info&quot; : &quot;testnet.blockchain.info&quot;;
String url = &quot;https://&quot; + host + &quot;/zh-cn/unspent?active=&quot; + address;
try {</p>

<pre><code> OkHttpClient client = new OkHttpClient();
String response = client.newCall(new Request.Builder().url(url).build()).execute().body().string();
if (StringUtils.equals(&quot;No free outputs to spend&quot;, response)) {
return utxos;
}
JSONObject jsonObject = JSON.parseObject(response);
JSONArray unspentOutputs = jsonObject.getJSONArray(&quot;unspent_outputs&quot;);
List&lt;Map&gt; outputs = JSONObject.parseArray(unspentOutputs.toJSONString(), Map.class);
if (outputs == null || outputs.size() == 0) {
System.out.println(&quot;交易异常,余额不足&quot;);
}
for (int i = 0; i &lt; outputs.size(); i++) {
Map outputsMap = outputs.get(i);
String tx_hash = outputsMap.get(&quot;tx_hash&quot;).toString();
String tx_hash_big_endian = outputsMap.get(&quot;tx_hash_big_endian&quot;).toString();
String tx_index = outputsMap.get(&quot;tx_index&quot;).toString();
String tx_output_n = outputsMap.get(&quot;tx_output_n&quot;).toString();
String script = outputsMap.get(&quot;script&quot;).toString();
String value = outputsMap.get(&quot;value&quot;).toString();
String value_hex = outputsMap.get(&quot;value_hex&quot;).toString();
String confirmations = outputsMap.get(&quot;confirmations&quot;).toString();
UTXO utxo = new UTXO(Sha256Hash.wrap(tx_hash_big_endian), Long.valueOf(tx_output_n), Coin.valueOf(Long.valueOf(value)),
0, false, new Script(Hex.decode(script)));
utxos.add(utxo);
}
return utxos;
} catch (Exception e) {
return null;
}
}</code></pre>

<p><code></code>`</p>

<p>测试代码:</p>

<p><code>java
/**
* 获取未花费列表
*/
@Test
public void testGetUnSpentUtxo(){
String address = &quot;17A16QmavnUfCW11DAApiJxp7ARnxN5pGX&quot;;
List&lt;UTXO&gt; unspent = BtcUtil.getUnspent(address, true);
logger.warn(&quot;unspent: {}&quot;, unspent);
}
</code></p>

<p>上面例子中通过<code>testnet.blockchain.info</code>获取测试钱包未花费列表不可用的话,参考下面的例子。</p>

<p>参考代码:</p>

<p><code>java
public static List&lt;UTXO&gt; getUnspentFromTestNet(String address) {
List&lt;UTXO&gt; utxos = Lists.newArrayList();
String url = String.format(&quot;https://test.bitgo.com/api/v1/address/%s/unspents&quot;, address);
OkHttpClient client = new OkHttpClient();
String response = null;
try {
response = client.newCall(new Request.Builder().url(url).build()).execute().body().string();
} catch (IOException e) {
e.printStackTrace();
}
if (StringUtils.equals(&quot;No free outputs to spend&quot;, response)) {
return utxos;
}
JSONObject jsonObject = JSON.parseObject(response);
JSONArray unspentOutputs = jsonObject.getJSONArray(&quot;unspents&quot;);
List&lt;Map&gt; outputs = JSONObject.parseArray(unspentOutputs.toJSONString(), Map.class);
if (outputs == null || outputs.size() == 0) {
System.out.println(&quot;交易异常,余额不足&quot;);
}
for (int i = 0; i &lt; outputs.size(); i++) {
Map outputsMap = outputs.get(i);
String tx_hash = outputsMap.get(&quot;tx_hash&quot;).toString();
String tx_output_n = outputsMap.get(&quot;tx_output_n&quot;).toString();
String script = outputsMap.get(&quot;script&quot;).toString();
String value = outputsMap.get(&quot;value&quot;).toString();
String confirmations = outputsMap.get(&quot;confirmations&quot;).toString();
String tx_hash_big_endian = ByteUtil.bytesToHex(ByteUtil.changeBytes(ByteUtil.toBytes(tx_hash)));
UTXO utxo = new UTXO(Sha256Hash.wrap(tx_hash), Long.valueOf(tx_output_n), Coin.valueOf(Long.valueOf(value)),
0, false, new Script(Hex.decode(script)));
utxos.add(utxo);
}
return utxos;
}
</code></p>

<h2>离线签名</h2>

<p>参考代码:</p>

<p><code></code>`java
/**
<em> 离线签名
</em> @param unSpentBTCList
<em> @param from
</em> @param to
<em> @param privateKey
</em> @param value
<em> @param fee
</em> @param mainNet
<em> @return
</em> @throws Exception
*/
public static String signBTCTransactionData(List&lt;UTXO&gt; unSpentBTCList, String from, String to, String privateKey, long value, long fee, boolean mainNet) throws Exception {
NetworkParameters networkParameters = null;
if (!mainNet)
networkParameters = MainNetParams.get();
else
networkParameters = TestNet3Params.get();</p>

<pre><code> Transaction transaction = new Transaction(networkParameters);
DumpedPrivateKey dumpedPrivateKey = DumpedPrivateKey.fromBase58(networkParameters, privateKey);

ECKey ecKey = dumpedPrivateKey.getKey();

long totalMoney = 0;
List&lt;UTXO&gt; utxos = new ArrayList&lt;&gt;();
//遍历未花费列表,组装合适的item
for (UTXO us : unSpentBTCList) {
if (totalMoney &gt;= (value + fee))
break;
UTXO utxo = new UTXO(us.getHash(), us.getIndex(), us.getValue(), us.getHeight(), us.isCoinbase(), us.getScript());
utxos.add(utxo);
totalMoney += us.getValue().value;
}

transaction.addOutput(Coin.valueOf(value), Address.fromBase58(networkParameters, to));
// transaction.

//消费列表总金额 - 已经转账的金额 - 手续费 就等于需要返回给自己的金额了
long balance = totalMoney - value - fee;
//输出-转给自己
if (balance &gt; 0) {
transaction.addOutput(Coin.valueOf(balance), Address.fromBase58(networkParameters, from));
}
//输入未消费列表项
for (UTXO utxo : utxos) {
TransactionOutPoint outPoint = new TransactionOutPoint(networkParameters, utxo.getIndex(), utxo.getHash());
transaction.addSignedInput(outPoint, utxo.getScript(), ecKey, Transaction.SigHash.ALL, true);
}

return Hex.toHexString(transaction.bitcoinSerialize());
}</code></pre>

<p><code></code>`</p>

<p>签名之后的结果就可以拿去广播了,没有自己搭建节点的可以使用<a href="https://www.blockcypher.com/dev/bitcoin/#customizing-transaction-requests">blockcypher/send</a>广播自己的交易。在上面的交易中,找零是自己本身,当然,也可以设置为其他钱包地址。其次,在这个交易中,交易手续费是在前置步骤计算得到的,其计算方式下面会提到。</p>

<h2>广播交易</h2>

<p>如果是自己搭建了节点,可以直接调用接口广播交易,这里是针对没有搭建节点,但是想要完成整个交易流程的同学们。我们可以在搜索引擎上找到很多可以为我们广播交易的API,我这里使用的是上文提到的<a href="https://www.blockcypher.com/dev/bitcoin/#customizing-transaction-requests">blockcypher/send</a>,你也可以选择直接通过<a href="https://blockchair.com/zh/broadcast">交易广播</a>来广播你的交易。</p>

<p>参考代码:</p>

<p><code>java
/**
* 全网广播交易
* @param tx
* @param mainNet
* @return
*/
public static String sendTx(String tx, boolean mainNet){
String url = &quot;&quot;;
if(mainNet) {
url = &quot;https://api.blockcypher.com/v1/btc/main/txs/push&quot;;
}else {
url = &quot;https://api.blockcypher.com/v1/btc/test3/txs/push&quot;;
}
OkHttpClient client = new OkHttpClient();
JSONObject jsonObject = new JSONObject();
jsonObject.put(&quot;tx&quot;, tx);
String response = null;
try {
response = client.newCall(new Request.Builder().url(url).post(RequestBody.create(MediaType.parse(&quot;application/json&quot;), jsonObject.toJSONString())).build()).execute().body().string();
} catch (IOException e) {
e.printStackTrace();
}
return response;
}
</code></p>

<h2>估算矿工费</h2>

<p>关于矿工费计算公式的解释,可以参考<a href="https://blog.csdn.net/servletcome/article/details/81941334">BTC手续费计算,如何设置手续费</a>。通过文章指导,要计算矿工费首先我们需要得到费率,即每字节等于多少聪。</p>

<p>参考代码:</p>

<p><code>java
/**
* 获取费率
* @param level 3 fastestFee 2 halfHourFee 1 hourFee default fastestFee
* @return
*/
public static String feeRate(int level){
OkHttpClient client = new OkHttpClient();
String url = &quot;https://bitcoinfees.earn.com/api/v1/fees/recommended&quot;;
String response = null;
try {
response = client.newCall(new Request.Builder().url(url).build()).execute().body().string();
} catch (IOException e) {
e.printStackTrace();
}
JSONObject jsonObject = JSONObject.parseObject(response);
switch (level){
case 1:
return jsonObject.getBigDecimal(&quot;hourFee&quot;).toPlainString();
case 2:
return jsonObject.getBigDecimal(&quot;halfHourFee&quot;).toPlainString();
default:
return jsonObject.getBigDecimal(&quot;fastestFee&quot;).toPlainString();
}
}
</code></p>

<p>测试代码:</p>

<p><code>java
@Test
public void testGetFeeRate(){
logger.warn(&quot;feeRate: {}&quot;, BtcUtil.feeRate(3));
}
</code></p>

<p>获得费率之后就可以计算矿工费了。一般而言,一笔交易包含了若干个输入,这些输入的数量总和刚好能支付这笔交易的数量的时候,输出的体积是最小的,仅一个接收地址的输出,当这些输入的数量总和大于这笔交易的数量时,输出的数量包含了一个接收地址的输出和一个找零的输出,通过上面离线签名的代码也能很容易理解这点。</p>

<p>参考代码:</p>

<p><code>java
/**
* 获取矿工费用
* @param amount
* @param utxos
* @return
*/
public static Long getFee(long amount, List&lt;UTXO&gt; utxos) {
Long feeRate = Long.valueOf(feeRate(3));//获取费率
Long utxoAmount = 0L;
Long fee = 0L;
Long utxoSize = 0L;
for (UTXO us : utxos) {
utxoSize++;
if (utxoAmount &gt;= (amount + fee)) {
break;
} else {
utxoAmount += us.getValue().value;
fee = (utxoSize * 148 + 34 * 2 + 10) * feeRate;
}
}
return fee;
}
</code></p>

<p>测试代码:</p>

<p><code>java
@Test
public void testGetFee(){
String address = &quot;17A16QmavnUfCW11DAApiJxp7ARnxN5pGX&quot;;
List&lt;UTXO&gt; unspent = BtcUtil.getUnspent(address, true);
Long fee = BtcUtil.getFee(100 * 100000000, unspent);
logger.warn(&quot;fee: {}&quot;, BigDecimal.valueOf(fee / 100000000.0).toPlainString());
}
</code></p>

<h2>优化矿工费</h2>

<p>通过矿工费的计算公式<code>(input*148+34*out+10)*rate</code>,我们很容易想到减少矿工费的手段,主要有两个方面:其一选择较低的矿工费率,这样能明显减低矿工费,因为公式上能明显反映rate和输入输出的体积是倍数关系,所以减小rate是能够最有效减少矿工费的,但是相对的这种方式带来的负面影响也是直接的,它会影响打包的效率。其二是减小输入输出的体积,我们在组装一个能够支付本次交易的列表是,往往是直接遍历未花费列表,累加判断,但是其实我们可以通过一些算法,使得支付当前交易的未花费列表最小化,这个算法计算翻译过来其实是<strong>使用尽可能少的列表项,使得交易等式两边成立</strong>,根据这个结论,最简单的实现方式就是在使用未花费列表前,先对未花费列表进行倒序排序:</p>

<p>测试代码:</p>

<p><code></code>`java
@Test
public void testGetFee(){
String address = &quot;17A16QmavnUfCW11DAApiJxp7ARnxN5pGX&quot;;
List&lt;UTXO&gt; unspents = BtcUtil.getUnspent(address, true);
Long fee1 = BtcUtil.getFee(100 <em> 100000000, unspents);
Collections.sort(unspents, (o1, o2) -&gt; BigInteger.valueOf(o2.getValue().value).compareTo(BigInteger.valueOf(o1.getValue().value)));
Long fee2 = BtcUtil.getFee(100 </em> 100000000, unspents);</p>

<pre><code> logger.warn(&quot;排序前矿工费: {}, 排序后矿工费: {}&quot;, BigDecimal.valueOf(fee1 / 100000000.0).toPlainString(), BigDecimal.valueOf(fee2 / 100000000.0).toPlainString());
}</code></pre>

<p><code></code>`</p>

<p>对比结果:</p>

<p><code>xml
排序前矿工费: 0.00137968, 排序后矿工费: 0.00001808
</code></p>

<p>根据这个想法,我在测试链发起了两笔交易,交易额都是0.012BTC,对比了两笔交易如下:</p>

<p>优化前:</p>

<p><img alt="优化前交易.png" src="https://i.loli.net/2020/01/06/pc5rFxgVPqj9TBu.png"/></p>

<p>优化后:</p>

<p><img alt="优化后交易.png" src="https://i.loli.net/2020/01/06/HyrG8l16ghPEIeq.png"/></p>

<p>可以看到,优化前有两个输入,优化后只有一个输入,优化后矿工费比优化前少了一些。</p>

<h2>生成钱包地址</h2>

<p>参考代码:</p>

<p><code></code>`java
public static final Map&lt;String, String&gt; btcGenerateBip39Wallet(String mnemonic, String mnemonicPath) {</p>

<pre><code> if (null == mnemonic || &quot;&quot;.equals(mnemonic)) {
byte[] initialEntropy = new byte[16];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(initialEntropy);
mnemonic = generateMnemonic(initialEntropy);
}

String[] pathArray = mnemonicPath.split(&quot;/&quot;);
List&lt;ChildNumber&gt; pathList = new ArrayList&lt;ChildNumber&gt;();
for (int i = 1; i &lt; pathArray.length; i++) {
int number;
if (pathArray[i].endsWith(&quot;&#39;&quot;)) {
number = Integer.parseInt(pathArray[i].substring(0, pathArray[i].length() - 1));
} else {
number = Integer.parseInt(pathArray[i]);
}
pathList.add(new ChildNumber(number, pathArray[i].endsWith(&quot;&#39;&quot;)));
}

DeterministicSeed deterministicSeed = null;
try {
deterministicSeed = new DeterministicSeed(mnemonic, null, &quot;&quot;, 0);
} catch (UnreadableWalletException e) {
throw new RuntimeException(e.getMessage());
}
DeterministicKeyChain deterministicKeyChain = DeterministicKeyChain.builder().seed(deterministicSeed).build();
BigInteger privKey = deterministicKeyChain.getKeyByPath(pathList, true).getPrivKey();
ECKey ecKey = ECKey.fromPrivate(privKey);
String publickey = Numeric.toHexStringNoPrefixZeroPadded(new BigInteger(ecKey.getPubKey()), 66);

// 正式
String mainNetPrivateKey = ecKey.getPrivateKeyEncoded(MainNetParams.get()).toString();
Map&lt;String, String&gt; map = Maps.newHashMap();
map.put(&quot;mnemonic&quot;, mnemonic);
map.put(&quot;mainNetPrivateKey&quot;, mainNetPrivateKey);
map.put(&quot;publickey&quot;, publickey);
map.put(&quot;address&quot;, ecKey.toAddress(MainNetParams.get()).toString());
return map;
}</code></pre>

<p><code></code>`</p>

<p>测试代码:</p>

<p><code>java
@Test
public void testGenerateBtcWallet(){
Map&lt;String, String&gt; map = AddrUtil.btcGenerateBip39Wallet(null, Constants.BTC_MNEMONIC_PATH);
String mnemonic = map.get(&quot;mnemonic&quot;);
String privateKey = map.get(&quot;mainNetPrivateKey&quot;);
String publicKey = map.get(&quot;publicKey&quot;);
String address = map.get(&quot;address&quot;);
logger.warn(&quot;address: {}, mnemonic: {}, privateKey: {}, publicKey: {}&quot;, address, mnemonic, privateKey, publicKey);
}
</code></p>

<p>比特币的钱包地址有一个特征可以区分正式网络还是测试网络,一般比特币钱包地址开头是数字1或3是正式网络,开头是m是测试网络,测试网络和正式网络的钱包地址是不互通的。</p>

<h2>获取测试币</h2>

<p>这里我主要罗列几个获取BTC测试币的网站:</p>

<ul><li><p><a href="https://coinfaucet.eu/en/btc-testnet/">coinfaucet</a></p></li><li><p><a href="https://testnet-faucet.mempool.co/">testnet-faucet</a></p></li></ul>

猜你喜欢

转载自www.cnblogs.com/ezsyncxz/p/12196470.html
今日推荐