之前和大家分享过很多关于以太坊链开发的内容。因为以太坊的开发者生态运营得比较好,所以网络上的学习资源有很多。而作为老大哥的比特币,由于没有官方的项目方,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 定义了如何从随机数中生成助记词,以及同构助记词转换成一个种子。通过这些助记词可以恢复比特币钱包。
生成钱包
首先我们来看怎么样来生成比特币钱包。
由于比特币有着非常悠久的历史,所以在比特币的发展过程中产生了好几类比特币钱包。它们分别是:
- P2PKH:Pay to PubKey Hash,比特币最早的钱包,1 开头,例子:
1FfmbHfnpaZjKFvyi1okTjJJusN455paPH
。 - P2SH:Pay to Script Hash,多重签名和其他条件脚本的地址,3 开头,例子:
3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy
。 - Bech32:有 3 类。P2WPKH、P2WSH 和 P2TR。SegWit 的地址,支持隔离见证功能,可以减少交易大小。bc1 开头,全小写字母。
- P2WPKH 是指单个公钥哈希的 SegWit 地址,也就是 P2PKH 的 SegWit 版本。例子:
bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq
。 - P2WSH 是指脚本哈希的 SegWit 地址,也就是 P2SH 的 SegWit 版本。例子:
bc1q0ht9tyks4vh93k3n4f2r0z6h0jw82f8swc6c8c
。 - P2TR 是 Pay to Taproot,Taproot 升级后新引入的地址类型。它和 Bech32 钱包的格式是一致的。但是多了一个 p 分隔符,也就是 bc1p 开头,用来和原来的 Bech2 地址区分。例子:
bc1p0ph70s0um3lmskqmdmpr6yynez9a0j5ch6n6j6
。
- P2WPKH 是指单个公钥哈希的 SegWit 地址,也就是 P2PKH 的 SegWit 版本。例子:
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
,邀你进群学习。