錢包開發經驗分享:BTC篇

錢包開發經驗分享:BTC篇

BTC節點搭建

關於BTC的第一步,自然是搭建節點。由於BTC流行最久最廣,網絡上關於BTC的節點搭建,或者在同步節點時出現問題的相關文章很多,我這裏就不贅述了(主要是沒有環境用來搭建節點)。這裏推薦一篇文章:區塊鏈-Linux下Bitcoin測試節點搭建。沒有搭建節點的可以考慮一下兩個網站:blockcypherblockchain

BTC的賬戶模型——UTXO

關於UTXO的含義闡述可以參考理解比特幣的 UTXO、地址和交易,這篇文章對UTXO的闡述我覺得挺全面的。在裏面提到:在比特幣種,一筆交易的每一條輸入和輸出實際上都是 UTXO,輸入 UTXO 就是以前交易剩下的, 更準確的說是以前交易的輸出 UTXO。這句闡述得從JSON數據去理解。

每一筆交易包含了大於等於一個輸出,如下圖:

outputs

輸出列表包含了輸出數量(value)、輸入腳本(script)、地址(addresses)和腳本類型(script_type),我們主要關注輸入數量。

每一筆交易的JSON都包含了大於等於零個輸入(挖礦收益沒有輸入),如下圖:

inputs

輸入列表包含這筆輸入對應的上一筆交易的哈希(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,對比了兩筆交易如下:

優化前:

優化前交易.png

優化後:

優化後交易.png

可以看到,優化前有兩個輸入,優化後只有一個輸入,優化後礦工費比優化前少了一些。

生成錢包地址

參考代碼:

	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是測試網絡,測試網絡和正式網絡的錢包地址是不互通的。

對我的文章感興趣的話,請關注我的公衆號
公衆號二維碼

發佈了34 篇原創文章 · 獲贊 3 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章