比特币钱包库 bitcoinjs-lib 讲解:生成钱包、导入钱包、签名验证、转账交易

之前和大家分享过很多关于以太坊链开发的内容。因为以太坊的开发者生态运营得比较好,所以网络上的学习资源有很多。而作为老大哥的比特币,由于没有官方的项目方,bitcoin core 团队和比特币基金会也比较佛系,再加上比特币的理念就是我告诉你我是什么,你能理解就加入,不理解我也不和你多解释,还有比特币不支持智能合约等种种原因,导致比特币网络的开发者生态比较惨淡,互联网上的学习资源也比较少。

虽然目前没有非常精准的调查数据,但我的评估是,以太坊生态开发者数量大概是比特币生态开发者数量的 10 倍,100 个 Web3 开发者里面,至少有 90 多个都是做以太坊或者以太坊兼容链开发的,虽然每个人可能都知道比特币,但对比特币生态里的技术了解很少。

随着去年比特币生态的崛起,比特币开发者生态也有了一些开发需求。比如有很多网站已经支持比特币钱包的连接,然后用脚本去实现铭文的铭刻。还有几个比较新的比特币 Layer2,交互都是依靠比特币钱包进行的。

这节课我就给你讲一下比特币生态最常用的一些库,以及如何实现比特币钱包一些最常用的操作。

库介绍

我们今天要介绍的库有如下几个:

  • bitcoinjs-lib
  • bitcoinjs-message
  • tiny-secp256k1
  • ecpair
  • bip39
  • bip32

熟悉以太坊生态的开发者朋友应该都知道,以太坊常用的库有 Ethersjs、Web3js 和 Viem 这些。

在比特币生态对应的就是 bitcoinjs-lib。它提供了在 JS 中操作比特币相关的各种方法和数据结构,比如地址、交易等等。还支持创建各种类型的钱包。

bitcoinjs-lib 本身没有在比特币网络进行签名和验证的能力,所以开发者又提供了 bitcoinjs-message 这个库,用来对消息进行签名和验证,扩展了 bitcoinjs-lib 的能力。

tiny-secp256k1 是一个专注于比特币曲线 Secp256k1 的库,主要是用来加密操作,比如生成密钥对、签名等等。

ecpair 是一个用来管理比特币密钥对的库,可以从私钥获取公钥,通常会和 tiny-secp256k1 一起使用。

bip32 是一个比特币提案的实现,BIP32 提案定义了如何使用一颗树状结构来生成和管理密钥,它允许通过一个主密钥来派生很多子密钥。

bip39 是也是一个比特币提案的实现,BIP39 定义了如何从随机数中生成助记词,以及同构助记词转换成一个种子。通过这些助记词可以恢复比特币钱包。

生成钱包

首先我们来看怎么样来生成比特币钱包。

由于比特币有着非常悠久的历史,所以在比特币的发展过程中产生了好几类比特币钱包。它们分别是:

  1. P2PKH:Pay to PubKey Hash,比特币最早的钱包,1 开头,例子:1FfmbHfnpaZjKFvyi1okTjJJusN455paPH
  2. P2SH:Pay to Script Hash,多重签名和其他条件脚本的地址,3 开头,例子:3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy
  3. Bech32:有 3 类。P2WPKH、P2WSH 和 P2TR。SegWit 的地址,支持隔离见证功能,可以减少交易大小。bc1 开头,全小写字母。
    1. P2WPKH 是指单个公钥哈希的 SegWit 地址,也就是 P2PKH 的 SegWit 版本。例子:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq
    2. P2WSH 是指脚本哈希的 SegWit 地址,也就是 P2SH 的 SegWit 版本。例子:bc1q0ht9tyks4vh93k3n4f2r0z6h0jw82f8swc6c8c
    3. P2TR 是 Pay to Taproot,Taproot 升级后新引入的地址类型。它和 Bech32 钱包的格式是一致的。但是多了一个 p 分隔符,也就是 bc1p 开头,用来和原来的 Bech2 地址区分。例子:bc1p0ph70s0um3lmskqmdmpr6yynez9a0j5ch6n6j6

P2PKH 钱包

我们先来生成一个 P2PKH 钱包。

以下是通过 bitcoinjs-lib 生成 P2PKH 钱包的例子:

js 复制代码
import * as bitcoin from 'bitcoinjs-lib';
import ECPairFactory from 'ecpair';
import * as ecc from 'tiny-secp256k1';

// 设置比特币网络
const network = bitcoin.networks.bitcoin;
// 创建一个新的密钥对工厂
const keyPair = ECPairFactory(ecc);
// 通过密钥对工厂创建一个新的密钥对实例
const keyPairInstance = keyPair.makeRandom({ network });
// 通过密钥对实例创建一个新的P2PKH地址
const { address, pubkey } = bitcoin.payments.p2pkh({ pubkey: keyPairInstance.publicKey, network });
// 获取 WIF 格式的私钥(WIF 是 Wallet Import Format 的缩写,即钱包导入格式)
const privateKey = keyPairInstance.toWIF();

console.debug('Address:', address);
console.debug('Public key:', pubkey!.toString('hex'));
console.debug('Private key:', privateKey);

这种方式生成的钱包有个特点,那就是没有办法获取助记词。

P2WPKH 钱包

比特币的助记词是在 bip39 中提到的,要生成带有助记词的 P2WPKH 的钱包,需要使用 bip39 这个库。

以下是生成 P2WPKH 库的例子:

js 复制代码
import * as bitcoin from 'bitcoinjs-lib';
import ECPairFactory from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import * as bip32 from 'bip32';
import * as bip39 from 'bip39';

// 设置比特币网络
const network = bitcoin.networks.bitcoin;
// 生成随机的助记词
const mnemonic = bip39.generateMnemonic();
// 通过助记词生成种子
const seed = bip39.mnemonicToSeedSync(mnemonic);
// 通过种子生成根密钥
const root = bip32.BIP32Factory(ecc).fromSeed(seed, network);

// m/44'/0'/0'/0/0 是 P2WPKH 的 BIP32 派生路径
const path = "m/44'/0'/0'/0/0";
// 根据派生路径生成子密钥
const child = root.derivePath(path);
// 根据子密钥生成密钥对实例
const keyPairInstance = ECPairFactory(ecc).fromPrivateKey(child.privateKey!, { network });
// 通过密钥对实例创建一个新的P2WPKH地址
const { address, pubkey } = bitcoin.payments.p2wpkh({ pubkey: keyPairInstance.publicKey, network });
// 获取 WIF 格式的私钥
const privateKey = keyPairInstance.toWIF();

console.debug('Address:', address);
console.debug('Public key:', pubkey!.toString('hex'));
console.debug('Private key:', privateKey);
console.debug('Mnemonic:', mnemonic);

导入钱包

当我们有了一个私钥或者有了一组助记词后,就可以在很多软件中导入比特币钱包。通常来说导入比特币钱包的方式有两种。分别是通过私钥的方式导入和通过助记词的方式导入。这里我会分别对两种方式进行演示。

通过私钥导入

首先是通过私钥导入,逻辑比较简单。

逻辑如下:

ts 复制代码
import { networks, payments } from 'bitcoinjs-lib';
import ECPairFactory from 'ecpair';
import * as ecc from 'tiny-secp256k1';

// 通过 WIF 格式的私钥可以导入钱包,从而控制这个地址上的比特币。

// WIF 格式的私钥
const privateKey = 'YOUR_PRIVATE_KEY_HERE'
// 通过 WIF 格式的私钥导入钱包
const keyPair = ECPairFactory(ecc).fromWIF(privateKey);
// 公钥
const pubkey = keyPair.publicKey;
// 比特币地址
const { address } = payments.p2pkh({ pubkey, network: networks.bitcoin });
// P2WPKH 地址
const { address: p2wpkhAddress } = payments.p2wpkh({ pubkey, network: networks.bitcoin });

console.debug('Address:', address);
console.debug('P2WPKH Address:', p2wpkhAddress);
console.debug('Public key:', pubkey.toString('hex'));
console.debug('Private key:', privateKey);

通过助记词导入

通过助记词导入是比较麻烦的,需要先通过助记词生成种子,再通过种子生成根密钥,再通过根密钥和派生路径生成子密钥。最后再通过子密钥创建地址。

逻辑如下:

ts 复制代码
import BIP32Factory from 'bip32';
import { networks, payments } from 'bitcoinjs-lib';
import ECPairFactory from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import * as bip39 from 'bip39';

// 助记词
const mnemonic = 'YOUR_MNEMONIC_KEY_HERE';
// 通过助记词生成种子
const seed = bip39.mnemonicToSeedSync(mnemonic);
// 通过种子生成根密钥
const root = BIP32Factory(ecc).fromSeed(seed, networks.bitcoin);
// 派生路径
const path = "m/44'/0'/0'/0/0";
// 通过派生路径生成子密钥
const child = root.derivePath(path);
// 通过子密钥生成密钥对实例
const keyPairInstance = ECPairFactory(ecc).fromPrivateKey(child.privateKey!, { network: networks.bitcoin });
// 通过密钥对实例创建一个新的P2PKH地址
const { address, pubkey } = payments.p2pkh({ pubkey: keyPairInstance.publicKey, network: networks.bitcoin });
// P2WPKH 地址
const { address: p2wpkhAddress } = payments.p2wpkh({ pubkey: keyPairInstance.publicKey, network: networks.bitcoin });

console.debug('Address:', address);
console.debug('P2WPKH Address:', p2wpkhAddress);
console.debug('Public key:', pubkey!.toString('hex'));
console.debug('Private key:', keyPairInstance.toWIF());

签名验证

签名是为了保证你确实拥有这个钱包,在很多 DApp 中签名都是一种非常常见的操作。

比特币钱包签名的逻辑也非常简单,代码如下:

ts 复制代码
import * as bitcoin from 'bitcoinjs-lib';
import { sign, verify } from 'bitcoinjs-message';
import ECPairFactory from 'ecpair';
import * as ecc from 'tiny-secp256k1';

// WIF 格式的私钥
const privateKey = 'YOUR_PRIVATE_KEY_HERE'
// 通过 WIF 格式的私钥导入钱包
const keyPair = ECPairFactory(ecc).fromWIF(privateKey);
// 签名消息
const message = 'Hello, World!';
const signature = sign(message, keyPair.privateKey!, keyPair.compressed);
console.debug('Signature:', signature.toString('base64'));
// 获取地址
const { address } = bitcoin.payments.p2pkh({ pubkey: keyPair.publicKey, network: bitcoin.networks.bitcoin });
// 验证签名
const verified = verify(message, address!, signature);
console.debug('Verified: ', verified);

转账交易

交易是比特币基础的功能,同时也是最复杂的部分。我会分为几个部分来逐步实现完整的交易逻辑。

查询余额

我们要想获取某个比特币钱包地址的余额,必须通过比特币全节点获取。bitcoinjs-lib 本身并不提供直接查询比特币地址余额的 API,因为它不是一个全节点的实现,也不和比特币网络直接交互。

所以通常要么我们自己运行一个比特币全节点,要么通过区块链浏览器提供的 API 来查询。运行一个区块链全节点有一定的成本,这里我们直接演示如何通过区块链浏览器的 API 来查询。

因为我们不会演示在主网进行转账,这样会花费 Gas,我们会在测试网进行演示。

首先生成一个测试网的钱包。

ts 复制代码
function generateTestnetAddress() {
  // 设置比特币网络
  const network = bitcoin.networks.testnet;
  // 生成随机的助记词
  const mnemonic = bip39.generateMnemonic();
  // 通过助记词生成种子
  const seed = bip39.mnemonicToSeedSync(mnemonic);
  // 通过种子生成根密钥
  const root = bip32.BIP32Factory(ecc).fromSeed(seed, network);

  // m/44'/0'/0'/0/0 是 P2WPKH 的 BIP32 派生路径
  const path = "m/44'/0'/0'/0/0";
  // 根据派生路径生成子密钥
  const child = root.derivePath(path);
  // 根据子密钥生成密钥对实例
  const keyPairInstance = ECPairFactory(ecc).fromPrivateKey(child.privateKey!, { network });
  // 通过密钥对实例创建一个新的P2WPKH地址
  const { address, pubkey } = bitcoin.payments.p2wpkh({ pubkey: keyPairInstance.publicKey, network });
  // 获取 WIF 格式的私钥
  const privateKey = keyPairInstance.toWIF();

  console.debug('Address:', address);
  console.debug('Public key:', pubkey!.toString('hex'));
  console.debug('Private key:', privateKey);
  console.debug('Mnemonic:', mnemonic);

  return {
    address,
    pubkey: pubkey!.toString('hex'),
    privateKey,
    mnemonic
  }
}

然后创建一个查询余额的函数。

ts 复制代码
// 通过比特币地址查询余额
async function getBalance(address: string) {
  const url = `https://api.blockcypher.com/v1/btc/test3/addrs/${address}/balance`;
  const res = await fetch(url)
  return await res.json();
}

// 查询余额
getBalance(
  'tb1qkdl3ecltxnd3nmgq32lmm56vtarasf94epeka0'
).then(console.debug);

可以看到我们的 balance 是 0。

json 复制代码
{
  address: 'tb1qkdl3ecltxnd3nmgq32lmm56vtarasf94epeka0',
  total_received: 0,
  total_sent: 0,
  balance: 0,
  unconfirmed_balance: 0,
  final_balance: 0,
  n_tx: 0,
  unconfirmed_n_tx: 0,
  final_n_tx: 0
}

接下来我们要去比特币测试网络水龙头网站去领取一些测试网的比特币。

进入网站: coinfaucet.eu/en/btc-test...

输入钱包地址:

![[Pasted image 20240229112238.png]]

然后收到了将近 0.0006 个比特币。

![[Pasted image 20240229112308.png]]

然后再去查询,发现我们有一笔未确认的交易和未确认的余额。

json 复制代码
{
  address: 'tb1qkdl3ecltxnd3nmgq32lmm56vtarasf94epeka0',
  total_received: 0,
  total_sent: 0,
  balance: 0,
  unconfirmed_balance: 59792,
  final_balance: 59792,
  n_tx: 0,
  unconfirmed_n_tx: 1,
  final_n_tx: 1
}

因为我们需要矿工来确认交易,这个时间是不确定的。最快大概是 10 分钟左右,最慢的话可能要半个小时或更久。

我们可以在区块链浏览器中查看这币交易的详细信息: blockstream.info/testnet/

当看到有确认后,意味着领取成功了。

![[Pasted image 20240229112949.png]]

再次调用接口来查询余额,可以看到 balance 已经有数字了。

json 复制代码
{
  address: 'tb1qkdl3ecltxnd3nmgq32lmm56vtarasf94epeka0',
  total_received: 59792,
  total_sent: 0,
  balance: 59792,
  unconfirmed_balance: 0,
  final_balance: 59792,
  n_tx: 1,
  unconfirmed_n_tx: 0,
  final_n_tx: 1
}

在正式编写转账逻辑之前,我们需要再实现三个辅助函数,分别是查询 UTXO、查询交易详情和广播交易。

查询 UTXO

UTXO 是 Unspent Transaction Outputs 的缩写,表示未花费交易输出,比特币网络使用这种账户模型来追踪某个账户未花费的余额。

逻辑如下:

ts 复制代码
// 查询 UTXO
async function getUTXO(address: string) {
  const url = `https://api.blockcypher.com/v1/btc/test3/addrs/${address}?unspentOnly=true`;
  const res = await fetch(url)
  return await res.json();
}

获取交易详情

交易详情中包含了 UTXO 的输出脚本,这会作为交易的输入。

逻辑如下:

ts 复制代码
// 查询交易详情
async function getTxDetail(txHash: string) {
  const url = `https://api.blockcypher.com/v1/btc/test3/txs/${txHash}`;
  const res = await fetch(url)
  return await res.json();
}

广播交易

当我们对交易完成签名后,还需要将交易广播到区块链网络,等待其他节点确认。

逻辑如下:

ts 复制代码
// 广播交易
async function broadcastTx(tx: string) {
  const res = await fetch(
    `https://api.blockcypher.com/v1/btc/test3/txs/push`,
    {
      method: 'POST',
      body: JSON.stringify({
        tx,
      }),
      headers: {
        'Content-Type': 'application/json'
      }
    }
  );
  return await res.json();
}

交易逻辑

现在我们可以进行转账了。以下是转账逻辑,详细的代码作用已经在代码中进行注释:

ts 复制代码
// 转账
async function transfer(privateKey: string, toAddress: string, amount: number) {
  try {
    // 定义验证函数,用于校验签名是否有效
    const validator = (
      pubkey: Buffer,
      msghash: Buffer,
      signature: Buffer,
    ): boolean => ECPair.fromPublicKey(pubkey).verify(msghash, signature);

    // 创建一个新的密钥对工厂
    const ECPair = ECPairFactory(ecc);
    // 设置发送方私钥
    const alice = ECPair.fromWIF(privateKey, bitcoin.networks.testnet);
    // 发送方地址
    const aliacAddress = bitcoin.payments.p2wpkh({ pubkey: alice.publicKey, network: bitcoin.networks.testnet }).address;
    // 动态查询 UTXO
    const utxo = await getUTXO(aliacAddress!);
    // 如果没有 UTXO,则无法进行转账,返回错误信息
    if (utxo.txrefs === null) {
      return 'No UTXO';
    }
    // 选择最后一个 UTXO 作为输入
    const utxoTarget = utxo.txrefs[utxo.txrefs.length - 1];
    // UTXO 的交易哈希
    const utxoHash = utxoTarget.tx_hash;
    // 查询 UTXO 对应的交易详情
    const txDetail = await getTxDetail(utxoHash);
    // 获取输出脚本的十六进制表示
    const scriptPubKeyHex = txDetail.outputs[0].script;
    // 创建一个新的 Psbt 实例 (Partially Signed Bitcoin Transaction)
    // 一个部分签名的比特币交易,被创建出来但还没有被完全签名的交易
    const psbt = new bitcoin.Psbt({
      network: bitcoin.networks.testnet,
    });
    // 设置 gas
    const fee = 1000;
    // 添加输入
    psbt.addInput({
      // UTXO 的交易哈希
      hash: utxoHash,
      // UTXO 的输出索引
      index: utxoTarget.tx_output_n,
      witnessUtxo: {
        // UTXO 的输出脚本
        script: Buffer.from(scriptPubKeyHex, 'hex'),
        // UTXO 的金额
        value: utxoTarget.value,
      }
    });
    // 添加输出
    psbt.addOutput({
      // 接收方地址
      address: toAddress,
      // 金额
      value: amount,
    });
    // 计算找零
    const change = utxoTarget.value - amount - fee;
    // 添加找零
    psbt.addOutput({
      // 找零地址
      address: aliacAddress!,
      // 金额
      value: change,
    });
    // 签名输入
    psbt.signInput(0, alice);
    // 验证输入签名
    psbt.validateSignaturesOfInput(0, validator);
    // 终结所有输入,表示签名完成
    psbt.finalizeAllInputs();
    // 提取交易事务
    const tx = psbt.extractTransaction().toHex();
    // 广播交易到比特币网络,等待确认
    const res = await broadcastTx(tx);
    return res;
  }
  catch (e) {
    console.error('transfer error: ', e);
  }
}

然后开始交易:

ts 复制代码
transfer(
  'privateKey',
  'address',
  10000
).then(console.debug);

最终我们会得到一个交易的 Hash。

txt 复制代码
526835d70aae0dae1524a0cc8fceeb102c66fd83b818fc0bf6ba0db0140b6e2c

然后就可以通过比特币测试浏览器查看交易的信息: blockstream.info/testnet/tx/...

大概经过 10 分钟左右就可以得到确认。

比特币测试网水龙头网址: coinfaucet.eu/en/btc-test...

比特币测试网浏览器网址: blockstream.info/testnet/

文中代码托管在 GitHub,欢迎 Star: github.com/luzhenqian/...


如果你对本文内容感兴趣,或者对 Web3 感兴趣。欢迎添加作者微信:LZQ20130415,邀你进群学习。

相关推荐
谢尔登6 分钟前
Webpack 和 Vite 的区别
前端·webpack·node.js
谢尔登6 分钟前
【Webpack】Tree Shaking
前端·webpack·node.js
过期的H2O222 分钟前
【H2O2|全栈】关于CSS(4)CSS基础(四)
前端·css
纳尼亚awsl36 分钟前
无限滚动组件封装(vue+vant)
前端·javascript·vue.js
八了个戒41 分钟前
【TypeScript入坑】TypeScript 的复杂类型「Interface 接口、class类、Enum枚举、Generics泛型、类型断言」
开发语言·前端·javascript·面试·typescript
西瓜本瓜@43 分钟前
React + React Image支持图像的各种转换,如圆形、模糊等效果吗?
前端·react.js·前端框架
黄毛火烧雪下44 分钟前
React 的 useEffect 钩子,执行一些异步操作来加载基本信息
前端·chrome·react.js
蓝莓味柯基1 小时前
React——点击事件函数调用问题
前端·javascript·react.js
资深前端之路1 小时前
react jsx
前端·react.js·前端框架
cc蒲公英1 小时前
vue2中使用vue-office库预览pdf /docx/excel文件
前端·vue.js