錢包開發經驗分享:BTC篇
BTC節點搭建
關於BTC的第一步,自然是搭建節點。由於BTC流行最久最廣,網絡上關於BTC的節點搭建,或者在同步節點時出現問題的相關文章很多,我這裏就不贅述了(主要是沒有環境用來搭建節點)。這裏推薦一篇文章:區塊鏈-Linux下Bitcoin測試節點搭建。沒有搭建節點的可以考慮一下兩個網站:blockcypher、blockchain。
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);
}
計算礦工費:
由上面的結論可知,每一筆交易都由零個、一個或多個輸入和一個或多個輸出組成,每一個輸入都指向上一筆交易的輸出,這樣每一筆交易都由這些輸入輸出(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);
}
離線簽名
參考代碼:
/**
* 離線簽名
* @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是測試網絡,測試網絡和正式網絡的錢包地址是不互通的。
對我的文章感興趣的話,請關注我的公衆號