錢包開發經驗分享:BTC篇
BTC節點搭建
關於BTC的第一步,自然是搭建節點。由於BTC流行最久最廣,網絡上關於BTC的節點搭建,或者在同步節點時出現問題的相關文章很多,我這里就不贅述了(主要是沒有環境用來搭建節點)。這里推薦一篇文章:區塊鏈-Linux下Bitcoin測試節點搭建,沒有搭建節點的可以考慮下面幾個網站:blockcypher、blockchain、cryptoapis。這篇文章以及后續的關於omniUSDT的文章都是基於這些第三方接口實現的。
BTC的賬戶模型——UTXO
關於UTXO的含義闡述可以參考理解比特幣的 UTXO、地址和交易,這篇文章對UTXO的闡述我覺得挺全面的。在里面提到:在比特幣種,一筆交易的每一條輸入和輸出實際上都是 UTXO,輸入 UTXO 就是以前交易剩下的, 更准確的說是以前交易的輸出 UTXO。這句闡述得從JSON數據去理解。
每一筆交易包含了大於等於一個輸出,如下圖:
輸出列表包含了輸出數量(value)、輸入腳本(script)、地址(addresses)和腳本類型(script_type),我們主要關注輸入數量。
每一筆交易的JSON都包含了大於等於零個輸入(挖礦收益沒有輸入),如下圖:
輸入列表包含這筆輸入對應的上一筆交易的哈希(prev_hash)、這筆輸入對應的上一筆交易輸出的下標(output_index),輸入腳本(script)、腳本類型(scrip_type)等字段。在輸入中最重要的兩個字段是上一筆交易的哈希和輸出下標,由這兩個字段,我們可以輕松找到這筆輸入對應上一筆交易的輸出,從而從輸出中找到這筆輸入的數量是多少。
計算余額
由上面的賬戶模型,我們知道了BTC的賬戶是由UTXO列表組成,每個賬戶從創建初期到當前的所有交易就是一系列的輸入和輸出,這些UTXO通過輸入輸出的規則串聯在一起,形成了鏈式結構,因此要推算賬戶余額,我們可以通過計算這一系列的UTXO最終獲得余額,但是在實際開發上這樣做很消耗性能,因此在開發上我們往往考慮直接從第三方區塊鏈瀏覽器通過開放API獲得計算結果。事實上,基本上結合第三方區塊鏈瀏覽器開發的API,沒有搭建節點我們也可以直接完成很多操作:
參考代碼:
/** * 余額 * @param address * @param mainNet * @return */ public static String balance(String address, boolean mainNet){ String host = mainNet ? "blockchain.info" : "testnet.blockchain.info"; String url = "https://" + host + "/balance?active=" + 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<String, Map<String, Object>> result = new Gson().fromJson(response, Map.class); Map<String, Object> balanceMap = result.get(address); BigDecimal finalBalance = BigDecimal.valueOf((double) balanceMap.get("final_balance")); BigDecimal balance = finalBalance.divide(new BigDecimal(100000000)); return balance.toPlainString(); }
測試代碼:
/** * 獲取余額 * @throws Exception */ @Test public void testGetBTCBalance() throws Exception{ String address = "17A16QmavnUfCW11DAApiJxp7ARnxN5pGX"; String balance = BtcUtil.balance(address, true); logger.warn("balance: {}", balance); }
若上面例子中,獲取測試幣的余額的接口https://testnet.blockchain.info/balance?active=mtESbeXpf5qYkbYnphhKEJ7FU3UyQKYQzy
失效,可以改用https://api.blockcypher.com/v1/btc/test3/addrs/mtESbeXpf5qYkbYnphhKEJ7FU3UyQKYQzy/balance
。
計算礦工費
由上面的結論可知,每一筆交易都由零個、一個或多個輸入和一個或多個輸出組成,每一個輸入都指向上一筆交易的輸出,這樣每一筆交易都由這些輸入輸出(UTXO)串行而成。一般而言,一筆交易會有一個或多個多個輸入,這些輸入的數量總和剛好或者大於這次交易的數量,會有一個或多個輸出,輸出主要有這次交易的收款地址和數量,以及找零地址和找零數量,找零地址通常是原地址,輸入的數量總和和輸出的數量總和總是不相等的,因為每一筆交易中間包含了礦工費,由此我們可以推斷出礦工費的計算方式,即每一筆的輸入總和減去輸出總和:
參考代碼:
/** * 計算礦工費 * @param txid * @param mainNet * @return */ public static String fee(String txid, boolean mainNet){ String host = mainNet ? "blockchain.info" : "testnet.blockchain.info"; String url = "https://" + host + "/rawtx/" + 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); // 統計輸入總和 JSONArray inputs = jsonObject.getJSONArray("inputs"); BigDecimal totalIn = BigDecimal.ZERO; for (int i = 0; i < inputs.size(); i++) { JSONObject inputsData = inputs.getJSONObject(0); JSONObject prevOut = inputsData.getJSONObject("prev_out"); totalIn = totalIn.add(prevOut.getBigDecimal("value")); } // 統計輸出總和 JSONArray outs = jsonObject.getJSONArray("out"); BigDecimal totalOut = BigDecimal.ZERO; for (int i = 0; i < outs.size(); i++) { JSONObject outData = outs.getJSONObject(i); totalOut = totalOut.add(outData.getBigDecimal("value")); } return totalIn.subtract(totalOut).divide(new BigDecimal(100000000)).toPlainString(); }
測試代碼:
/** * 計算礦工費 * https://blockchain.info/rawtx/$tx_hash */ @Test public void testGetMinerFee(){ String txid = "b8df97b51f54df1c1f831e0e9e5561c03822f6c5a5a59e0118b15836657a4970"; logger.warn("Fee: {}", BtcUtil.fee(txid, true)); }
通過第三方區塊鏈瀏覽器開放的API獲取的交易數據和自己搭建節點獲取的交易數據有些許不同,如果是自己搭建節點,我推薦使用azazar/bitcoin-json-rpc-client或者其他的封裝了bitcoinRPC接口的SDK去實現,這樣是最簡單,最省事的實現方式,他封裝了很多對象,不用我們手動從JSONObject對象去獲取需要的數據,而且通過這些SDK我們可以真正像調用方法一樣調用bitcoin節點的接口。
獲取未花費列表
參考代碼:
/*** * 獲取未消費列表 * @param address :地址 * @return */ public static List<UTXO> getUnspent(String address, boolean mainNet) { List<UTXO> utxos = Lists.newArrayList(); String host = mainNet ? "blockchain.info" : "testnet.blockchain.info"; String url = "https://" + host + "/zh-cn/unspent?active=" + address; try { OkHttpClient client = new OkHttpClient(); String response = client.newCall(new Request.Builder().url(url).build()).execute().body().string(); if (StringUtils.equals("No free outputs to spend", response)) { return utxos; } JSONObject jsonObject = JSON.parseObject(response); JSONArray unspentOutputs = jsonObject.getJSONArray("unspent_outputs"); List<Map> outputs = JSONObject.parseArray(unspentOutputs.toJSONString(), Map.class); if (outputs == null || outputs.size() == 0) { System.out.println("交易異常,余額不足"); } for (int i = 0; i < outputs.size(); i++) { Map outputsMap = outputs.get(i); String tx_hash = outputsMap.get("tx_hash").toString(); String tx_hash_big_endian = outputsMap.get("tx_hash_big_endian").toString(); String tx_index = outputsMap.get("tx_index").toString(); String tx_output_n = outputsMap.get("tx_output_n").toString(); String script = outputsMap.get("script").toString(); String value = outputsMap.get("value").toString(); String value_hex = outputsMap.get("value_hex").toString(); String confirmations = outputsMap.get("confirmations").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; } }
測試代碼:
/** * 獲取未花費列表 */ @Test public void testGetUnSpentUtxo(){ String address = "17A16QmavnUfCW11DAApiJxp7ARnxN5pGX"; List<UTXO> unspent = BtcUtil.getUnspent(address, true); logger.warn("unspent: {}", unspent); }
上面例子中通過testnet.blockchain.info
獲取測試錢包未花費列表不可用的話,參考下面的例子。
參考代碼:
public static List<UTXO> getUnspentFromTestNet(String address) { List<UTXO> utxos = Lists.newArrayList(); String url = String.format("https://test.bitgo.com/api/v1/address/%s/unspents", 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("No free outputs to spend", response)) { return utxos; } JSONObject jsonObject = JSON.parseObject(response); JSONArray unspentOutputs = jsonObject.getJSONArray("unspents"); List<Map> outputs = JSONObject.parseArray(unspentOutputs.toJSONString(), Map.class); if (outputs == null || outputs.size() == 0) { System.out.println("交易異常,余額不足"); } for (int i = 0; i < outputs.size(); i++) { Map outputsMap = outputs.get(i); String tx_hash = outputsMap.get("tx_hash").toString(); String tx_output_n = outputsMap.get("tx_output_n").toString(); String script = outputsMap.get("script").toString(); String value = outputsMap.get("value").toString(); String confirmations = outputsMap.get("confirmations").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; }
離線簽名
參考代碼:
/** * 離線簽名 * @param unSpentBTCList * @param from * @param to * @param privateKey * @param value * @param fee * @param mainNet * @return * @throws Exception */ public static String signBTCTransactionData(List<UTXO> 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(); Transaction transaction = new Transaction(networkParameters); DumpedPrivateKey dumpedPrivateKey = DumpedPrivateKey.fromBase58(networkParameters, privateKey); ECKey ecKey = dumpedPrivateKey.getKey(); long totalMoney = 0; List<UTXO> utxos = new ArrayList<>(); //遍歷未花費列表,組裝合適的item for (UTXO us : unSpentBTCList) { if (totalMoney >= (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 > 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()); }
簽名之后的結果就可以拿去廣播了,沒有自己搭建節點的可以使用blockcypher/send廣播自己的交易。在上面的交易中,找零是自己本身,當然,也可以設置為其他錢包地址。其次,在這個交易中,交易手續費是在前置步驟計算得到的,其計算方式下面會提到。
廣播交易
如果是自己搭建了節點,可以直接調用接口廣播交易,這里是針對沒有搭建節點,但是想要完成整個交易流程的同學們。我們可以在搜索引擎上找到很多可以為我們廣播交易的API,我這里使用的是上文提到的blockcypher/send,你也可以選擇直接通過交易廣播來廣播你的交易。
參考代碼:
/** * 全網廣播交易 * @param tx * @param mainNet * @return */ public static String sendTx(String tx, boolean mainNet){ String url = ""; if(mainNet) { url = "https://api.blockcypher.com/v1/btc/main/txs/push"; }else { url = "https://api.blockcypher.com/v1/btc/test3/txs/push"; } OkHttpClient client = new OkHttpClient(); JSONObject jsonObject = new JSONObject(); jsonObject.put("tx", tx); String response = null; try { response = client.newCall(new Request.Builder().url(url).post(RequestBody.create(MediaType.parse("application/json"), jsonObject.toJSONString())).build()).execute().body().string(); } catch (IOException e) { e.printStackTrace(); } return response; }
估算礦工費
關於礦工費計算公式的解釋,可以參考BTC手續費計算,如何設置手續費。通過文章指導,要計算礦工費首先我們需要得到費率,即每字節等於多少聰。
參考代碼:
/** * 獲取費率 * @param level 3 fastestFee 2 halfHourFee 1 hourFee default fastestFee * @return */ public static String feeRate(int level){ OkHttpClient client = new OkHttpClient(); String url = "https://bitcoinfees.earn.com/api/v1/fees/recommended"; 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("hourFee").toPlainString(); case 2: return jsonObject.getBigDecimal("halfHourFee").toPlainString(); default: return jsonObject.getBigDecimal("fastestFee").toPlainString(); } }
測試代碼:
@Test public void testGetFeeRate(){ logger.warn("feeRate: {}", BtcUtil.feeRate(3)); }
獲得費率之后就可以計算礦工費了。一般而言,一筆交易包含了若干個輸入,這些輸入的數量總和剛好能支付這筆交易的數量的時候,輸出的體積是最小的,僅一個接收地址的輸出,當這些輸入的數量總和大於這筆交易的數量時,輸出的數量包含了一個接收地址的輸出和一個找零的輸出,通過上面離線簽名的代碼也能很容易理解這點。
參考代碼:
/** * 獲取礦工費用 * @param amount * @param utxos * @return */ public static Long getFee(long amount, List<UTXO> utxos) { Long feeRate = Long.valueOf(feeRate(3));//獲取費率 Long utxoAmount = 0L; Long fee = 0L; Long utxoSize = 0L; for (UTXO us : utxos) { utxoSize++; if (utxoAmount >= (amount + fee)) { break; } else { utxoAmount += us.getValue().value; fee = (utxoSize * 148 + 34 * 2 + 10) * feeRate; } } return fee; }
測試代碼:
@Test public void testGetFee(){ String address = "17A16QmavnUfCW11DAApiJxp7ARnxN5pGX"; List<UTXO> unspent = BtcUtil.getUnspent(address, true); Long fee = BtcUtil.getFee(100 * 100000000, unspent); logger.warn("fee: {}", BigDecimal.valueOf(fee / 100000000.0).toPlainString()); }
優化礦工費
通過礦工費的計算公式(input*148+34*out+10)*rate
,我們很容易想到減少礦工費的手段,主要有兩個方面:其一選擇較低的礦工費率,這樣能明顯減低礦工費,因為公式上能明顯反映rate和輸入輸出的體積是倍數關系,所以減小rate是能夠最有效減少礦工費的,但是相對的這種方式帶來的負面影響也是直接的,它會影響打包的效率。其二是減小輸入輸出的體積,我們在組裝一個能夠支付本次交易的列表是,往往是直接遍歷未花費列表,累加判斷,但是其實我們可以通過一些算法,使得支付當前交易的未花費列表最小化,這個算法計算翻譯過來其實是使用盡可能少的列表項,使得交易等式兩邊成立,根據這個結論,最簡單的實現方式就是在使用未花費列表前,先對未花費列表進行倒序排序:
測試代碼:
@Test public void testGetFee(){ String address = "17A16QmavnUfCW11DAApiJxp7ARnxN5pGX"; List<UTXO> unspents = BtcUtil.getUnspent(address, true); Long fee1 = BtcUtil.getFee(100 * 100000000, unspents); Collections.sort(unspents, (o1, o2) -> BigInteger.valueOf(o2.getValue().value).compareTo(BigInteger.valueOf(o1.getValue().value))); Long fee2 = BtcUtil.getFee(100 * 100000000, unspents); logger.warn("排序前礦工費: {}, 排序后礦工費: {}", BigDecimal.valueOf(fee1 / 100000000.0).toPlainString(), BigDecimal.valueOf(fee2 / 100000000.0).toPlainString()); }
對比結果:
排序前礦工費: 0.00137968, 排序后礦工費: 0.00001808
根據這個想法,我在測試鏈發起了兩筆交易,交易額都是0.012BTC,對比了兩筆交易如下:
優化前:
優化后:
可以看到,優化前有兩個輸入,優化后只有一個輸入,優化后礦工費比優化前少了一些。
生成錢包地址
參考代碼:
public static final Map<String, String> btcGenerateBip39Wallet(String mnemonic, String mnemonicPath) { if (null == mnemonic || "".equals(mnemonic)) { byte[] initialEntropy = new byte[16]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(initialEntropy); mnemonic = generateMnemonic(initialEntropy); } String[] pathArray = mnemonicPath.split("/"); List<ChildNumber> pathList = new ArrayList<ChildNumber>(); for (int i = 1; i < pathArray.length; i++) { int number; if (pathArray[i].endsWith("'")) { 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("'"))); } DeterministicSeed deterministicSeed = null; try { deterministicSeed = new DeterministicSeed(mnemonic, null, "", 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<String, String> map = Maps.newHashMap(); map.put("mnemonic", mnemonic); map.put("mainNetPrivateKey", mainNetPrivateKey); map.put("publickey", publickey); map.put("address", ecKey.toAddress(MainNetParams.get()).toString()); return map; }
測試代碼:
@Test public void testGenerateBtcWallet(){ Map<String, String> map = AddrUtil.btcGenerateBip39Wallet(null, Constants.BTC_MNEMONIC_PATH); String mnemonic = map.get("mnemonic"); String privateKey = map.get("mainNetPrivateKey"); String publicKey = map.get("publicKey"); String address = map.get("address"); logger.warn("address: {}, mnemonic: {}, privateKey: {}, publicKey: {}", address, mnemonic, privateKey, publicKey); }
比特幣的錢包地址有一個特征可以區分正式網絡還是測試網絡,一般比特幣錢包地址開頭是數字1或3是正式網絡,開頭是m是測試網絡,測試網絡和正式網絡的錢包地址是不互通的。
獲取測試幣
這里我主要羅列幾個獲取BTC測試幣的網站: