基于Java语言构建区块链(五)—— 地址(钱包)

wallet

文章的主要思想和内容均来自 https://jeiwan.cc/posts/building-blockchain-in-go-part-5/

引言

上一篇 文章当中,我们开始了交易机制的实现。你已经了解到交易的一些非个人特征:没有用户账户,您的个人数据(例如:姓名、护照号码以及SSN(美国社会安全卡(Social Security Card)上的9 位数字))不是必需的,并且不存储在比特币的任何地方。但仍然必须有一些东西能够识别你是这些交易输出的所有者(例如:锁定在这些输出上的币的所有者)。这就是比特币地址的作用所在。到目前为止,我们只是使用了任意的用户定义的字符串当做地址,现在是时候来实现真正的地址了,就像它们在比特币中实现的一样。

比特币地址

这里有一个比特币地址的示例:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa。这是一个非常早期的比特币地址,据称是属于中本聪的比特币地址。比特币地址是公开的。如果你想要给某人发送比特币,你需要知道对方的比特币地址。但是地址(尽管它是唯一的)并不能作为你是一个钱包所有者的凭证。事实上,这样的地址是公钥的一种可读性更好的表示 。在比特币中,你的身份是存储在你计算机上(或存储在你有权访问的其他位置)的一对(或多对)私钥和公钥。比特币依靠加密算法的组合来创建这些密钥,并保证世界上没有其他人任何人可以在没有物理访问密钥的情况下访问您的比特币。

比特币地址与公钥不同。比特币地址是由公钥经过单向的哈希函数生成的

PubKey to bitcoin address

接下来,让我们来讨论一下这些加密算法。

注意:不要向本篇文章中的代码所生成的任何比特币地址发送真实的比特币来进行测试,否则后果自负……

公钥密码学

公钥加密算法(public-key cryptography)使用的是密钥对:公钥和私钥。公钥属于非敏感信息,可以向任何人透露。相比之下,私钥不能公开披露:除了所有者之外,任何人都不能拥有私钥的权限,因为它是用作所有者标识的私钥。你的私钥代表就是你(当然是在加密货币世界里的)。

本质上,比特币钱包就是一对这样的密钥。当你安装一个钱包应用程序或者使用比特币客户端去生成一个新的地址时,它们就为你创建好了一个密钥对。在比特币种,谁控制了私钥,谁就掌握了所有发往对应公钥地址上所有比特币的控制权。

私钥和公钥只是随机的字节序列,因此它们不能被打印在屏幕上供人读取。这就是为什么比特币会用一种算法将公钥的字节序列转化为人类可读的字符串形式。

如果你曾今使用过比特币钱包的应用程序,它可能会为你生成助记词密码短语。这些助记词可以用来替代私钥,并且能够生成私钥。这种机制是通过 BIP-039 来实现的。

好了,现在我们已经知道在比特币中由什么来决定用户的标识了。但是,比特币是如何校验交易输出(和它里面存储的一些币)的所有权的呢?

数字签名

在数学和密码学中,有个数字签名的概念,这套算法保证了以下几点:

  1. 保证数据从发送端传递到接收端的过程中不会被篡改;
  2. 数据由某个发送者创建;
  3. 发送者不能否认发送的数据;

通过对数据应用签名算法(即签署数据),可以得到一个签名,以后可以对其进行验证。数字签名需要使用私钥,而验证则需要公钥。

为了能够签署数据我们需要:

  1. 用于被签名的数据;
  2. 私钥。

签名操作会产生一个存储在交易输入中的签名。为了能够验证一个签名,我们需要:

  1. 签名之后的数据;
  2. 签名;
  3. 公钥。

简单来讲,这个验证的过程可以被描述为:检查签名是由被签名数据加上私钥得来,并且这个公钥也是由该私钥生成。

数字签名并不是一种加密方法,你无法从签名反向构造出源数据。这个和我们 前面 提到过的Hash算法有点类似:通过对一个数据使用Hash算法,你可以得到该数据的唯一表示。它们两者的不同之处在于,签名算法多了一个密钥对:它让数字签名得以验证成为可能。

但是密钥对也能够用于去加密数据:私钥用于加密数据,公钥用于解密数据。不过比特币并没有使用加密算法。

在比特币中,每一笔交易输入都会被该笔交易的创建者进行签名。比特币中的每一笔交易在放入区块之前都必须得到验证。验证的意思就是:

  • 检查交易输入是否拥有引用前一笔交易中交易输出的权限
  • 检查交易的签名是否正确

数据签名以及签名验证的过程如下图所示:

signing-scheme

让我们来回顾一下交易的完整生命周期:

  1. 最开始,会有一个包含了Coinbase交易的创世区块。由于在Coinbase交易中没有真正的交易输入,所以它不需要签名。Coinbase交易的交易输出会包含一个Hashing之后的公钥(使用的算法为 RIPEMD16(SHA256(PubKey))
  2. 当一个人发送比特币时,会创建一笔交易。这笔交易的交易输入会引用前一笔或多笔交易的交易输出。每一个交易输入将会存储未经Hashing处理的公钥以及整个交易的签名信息。
  3. 当比特币网络中的其他节点收到其他节点广播的交易数据之后将,将会对其进行验证。其他的事情除外,他们将会验证:
    • 检查交易输入中公钥的Hash值是否与它所引用的交易输出的Hash值想匹配,这是确保发送方只能发送属于他们自己的比特币。
    • 检查签名是否正确,这是为了确保这笔交易是由比特币的真正所有者创建的。
  4. 当一个矿工准备开始开采一个新的区块时,他会将交易信息放入区块中,然后开始挖矿。
  5. 当一个区块完成挖矿之后,网络中的其他节点将会收到一条区块已挖矿完毕的消息,并且他们会把这个区块添加到区块链中去。
  6. 当一个区块被添加到区块链之后,就标志着这笔交易已经完成,它所产生的交易输出将会在新的交易中被引用。

椭圆曲线密码学

正如前面所提到的那样,公钥和私钥是一串随机的字符序列。由于私钥是用来识别比特币所有者身份的缘故,因此有一个必要的条件:这个随机算法必须产生真正的随机序列。我们不希望意外地生成其他人所拥有的私钥。也就是要保证随机序列的绝对唯一性。

比特币是使用的椭圆曲线来生成的私钥。椭圆曲线是一个非常复杂的数学概念,这里我们不做详细的介绍(如果你对此非常好奇,可以点击 this gentle introduction to elliptic curves 进行详细的 了解,警告:数学公式)。我们需要知道的是,这些曲线可以用来生成真正大而随机的数字。比特币所采用的曲线算法能够随机生成一个介于0到 2^2^56之间的数字(这是一个非常大的数字,用十进制表示的话,大约是10^77, 而整个可见的宇宙中,原子数在 10^78 到 10^82 之间) 。这么巨大的上限意味着产生两个一样的私钥是几乎不可能的事情。

另外,我们将会使用比特币中所使用的 ECDSA (椭圆曲线数字签名算法)去签署交易信息。

Base58和Base58Check编码

现在让我们回到上面提到的比特币地址:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa . 现在我们知道这个地址其实是公钥的一种可读高的表示方式。如果我们对他进行解码,我们会看到公钥看起来是这样子的(字节序列的十六进制的表示方式):

1
0062E907B15CBF27D5425399EBF6F0FB50EBB88F18C29B7D93

Base58

Base64使用了26个小写字母、26个大写字母、10个数字以及两个符号(例如“+”和“/”),用于在电子邮件这样的基于文本的媒介中传输二进制数据。Base64通常用于编码邮件中的附件。Base58是一种基于文本的二进制编码格式,用在比特币和其它的加密货币中。这种编码格式不仅实现了数据压缩,保持了易读性,还具有错误诊断功能。Base58是Base64编码格式的子集,同样使用大小写字母和10个数字,但舍弃了一些容易错读和在特定字体中容易混淆的字符。具体地,Base58不含Base64中的0(数字0)、O(大写字母o)、l(小写字母L)、I(大写字母i),以及“+”和“/”两个字符。简而言之,Base58就是由不包括(0,O,l,I)的大小写字母和数字组成。

比特币的Base58字母表:

123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

Base58Check

Base58Check是一种常用在比特币中的Base58编码格式,增加了错误校验码来检查数据在转录中出现的错误。校验码长4个字节,添加到需要编码的数据之后。校验码是从需要编码的数据的哈希值中得到的,所以可以用来检测并避免转录和输入中产生的错误。使用Base58check编码格式时,编码软件会计算原始数据的校验码并和结果数据中自带的校验码进行对比。二者不匹配则表明有错误产生,那么这个Base58Check格式的数据就是无效的。例如,一个错误比特币地址就不会被钱包认为是有效的地址,否则这种错误会造成资金的丢失。

为了使用Base58Check编码格式对数据(数字)进行编码,首先我们要对数据添加一个称作“版本字节”的前缀,这个前缀用来明确需要编码的数据的类型。例如,比特币地址的前缀是0(十六进制是0x00),而对私钥编码时前缀是128(十六进制是0x80)。

让我们以示意图的形式展示一下从公钥得到地址的过程:

Base58Check Encoding

因此,上述解码的公钥由三部分组成:

1
2
Version  Public key hash                           Checksum
00 62E907B15CBF27D5425399EBF6F0FB50EBB88F18 C29B7D93

由于哈希函数是单向的(也就说无法逆转回去),所以不可能从一个哈希中提取公钥。不过通过执行哈希函数并进行哈希比较,我们可以检查一个公钥是否被用于哈希的生成。

OK,现在我们有了所有的东西,让我们来编写一些代码。 当一些概念被写成代码时,我们会对此理解的更加清晰和深刻。

地址实现

让我们从 Wallet 的构成开始,这里我们需要先引入一个maven包:

1
2
3
4
5
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.59</version>
</dependency>

钱包结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 钱包
*
* @author wangwei
* @date 2018/03/14
*/
@Data
@AllArgsConstructor
public class Wallet {

// 校验码长度
private static final int ADDRESS_CHECKSUM_LEN = 4;
/**
* 私钥
*/
private BCECPrivateKey privateKey;
/**
* 公钥
*/
private byte[] publicKey;

public Wallet() {
initWallet();
}

/**
* 初始化钱包
*/
private void initWallet() {
try {
KeyPair keyPair = newECKeyPair();
BCECPrivateKey privateKey = (BCECPrivateKey) keyPair.getPrivate();
BCECPublicKey publicKey = (BCECPublicKey) keyPair.getPublic();

byte[] publicKeyBytes = publicKey.getQ().getEncoded(false);

this.setPrivateKey(privateKey);
this.setPublicKey(publicKeyBytes);
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 创建新的密钥对
*
* @return
* @throws Exception
*/
private KeyPair newKeyPair() throws Exception {
// 注册 BC Provider
Security.addProvider(new BouncyCastleProvider());
// 创建椭圆曲线算法的密钥对生成器,算法为 ECDSA
KeyPairGenerator g = KeyPairGenerator.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
// 椭圆曲线(EC)域参数设定
// bitcoin 为什么会选择 secp256k1,详见:https://bitcointalk.org/index.php?topic=151120.0
ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1");
g.initialize(ecSpec, new SecureRandom());
return g.generateKeyPair();
}

}

所谓的钱包,其实本质上就是一个密钥对。这里我们需要借助 KeyPairGenerator 生成密钥对。

接着,我们来生成比特币的钱包地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Wallet {

...

/**
* 获取钱包地址
*
* @return
*/
public String getAddress() throws Exception {
// 1. 获取 ripemdHashedKey
byte[] ripemdHashedKey = BtcAddressUtils.ripeMD160Hash(this.getPublicKey();

// 2. 添加版本 0x00
ByteArrayOutputStream addrStream = new ByteArrayOutputStream();
addrStream.write((byte) 0);
addrStream.write(ripemdHashedKey);
byte[] versionedPayload = addrStream.toByteArray();

// 3. 计算校验码
byte[] checksum = BtcAddressUtils.checksum(versionedPayload);

// 4. 得到 version + paylod + checksum 的组合
addrStream.write(checksum);
byte[] binaryAddress = addrStream.toByteArray();

// 5. 执行Base58转换处理
return Base58Check.rawBytesToBase58(binaryAddress);
}

...
}

这个时候,你就可以得到 真实的比特币地址 了,并且你可以到 blockchain.info 上去检查这个地址的余额。

例如,通过 getAddress 方法,得到了一个比特币地址为:1rZ9SjXMRwnbW3Pu8itC1HtNBVHERSQhaACbL16

我敢保证,无论你生成多少次比特币地址,它的余额始终为0.这就是为什么选择适当的公钥密码算法如此重要:考虑到私钥是随机数字,产生相同数字的机会必须尽可能低。 理想情况下,它必须低至“永不”。

另外,需要注意的是你不需要连接到比特币的节点上去获取比特币的地址。有关地址生成的开源算法工具包已经有很多编程语言和库实现了。

现在,我们需要去修改交易输入与输出,让他们开始使用真实的地址:

交易输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 交易输入
*
* @author wangwei
* @date 2017/03/04
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TXInput {

/**
* 交易Id的hash值
*/
private byte[] txId;
/**
* 交易输出索引
*/
private int txOutputIndex;
/**
* 签名
*/
private byte[] signature;
/**
* 公钥
*/
private byte[] pubKey;


/**
* 检查公钥hash是否用于交易输入
*
* @param pubKeyHash
* @return
*/
public boolean usesKey(byte[] pubKeyHash) {
byte[] lockingHash = BtcAddressUtils.ripeMD160Hash(this.getPubKey());
return Arrays.equals(lockingHash, pubKeyHash);
}

}

交易输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 交易输出
*
* @author wangwei
* @date 2017/03/04
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TXOutput {

/**
* 数值
*/
private int value;
/**
* 公钥Hash
*/
private byte[] pubKeyHash;

/**
* 创建交易输出
*
* @param value
* @param address
* @return
*/
public static TXOutput newTXOutput(int value, String address) {
// 反向转化为 byte 数组
byte[] versionedPayload = Base58Check.base58ToBytes(address);
byte[] pubKeyHash = Arrays.copyOfRange(versionedPayload, 1, versionedPayload.length);
return new TXOutput(value, pubKeyHash);
}

/**
* 检查交易输出是否能够使用指定的公钥
*
* @param pubKeyHash
* @return
*/
public boolean isLockedWithKey(byte[] pubKeyHash) {
return Arrays.equals(this.getPubKeyHash(), pubKeyHash);
}

}

代码中还有很多其他的地方需要变动,这里不一一指出,详见文末的源码连接。

注意,由于我们不会去实现脚本语言特性,所以我们不再使用 scriptPubKeyscriptSig 字段。取而代之的是,我们将 scriptSig 拆分为了 signaturepubKey 字段,scriptPubKey 重命名为了 pubKeyHash 。我们将会实现类似于比特币中的交易输出锁定/解锁逻辑和交易输入的签名逻辑,但是我们会在方法中执行此操作。

usesKey 用于检查交易输入中的公钥是否能够解锁交易输出。需要注意的是,交易输入中存储的是未经hash过的公钥,但是方法实现中对它做了一步 ripeMD160Hash 转化。

isLockedWithKey 用于检查提供的公钥Hash是否能够用于解锁交易输出,这个方法是 usesKey 的补充。usesKey 被用于 getAllSpentTXOs 方法中,isLockedWithKey 被用于 findUnspentTransactions 方法中,这样使得在前后两笔交易之间建立起了连接。

newTXOutput 方法中,将 value 锁定到了 address 上。当我们向别人发送比特币时,我们只知道他们的地址,因此函数将地址作为唯一的参数。然后解码地址,并从中提取公钥哈希并保存在PubKeyHash字段中。

现在,让我们一起来检查一下是否能够正常运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ ./blochchain.sh  createwallet
wallet address : 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh

$ ./blochchain.sh createwallet
wallet address : 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e

$ ./blochchain.sh createwallet
wallet address : 19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1

$ ./blochchain.sh createblockchain -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh

Elapsed Time: 6.77 seconds
correct hash Hex: 00000e44be0c94c39a4fef24c67d85c428e8bfbd227e292d75c0f4d398e2e81c

Done !

$ ./blochchain.sh getbalance -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh
Balance of '13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh': 10

$ ./blochchain.sh send -from 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e -to 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVd -amount 5
java.lang.Exception: ERROR: Not enough funds

$ ./blochchain.sh send -from 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh -to 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e-amount 5
Elapsed Time: 4.477 seconds
correct hash Hex: 00000da41dfacc8032a553ed5b1aa5e24318d5d89ca14a16c4f70129609c8365

Success!

$ ./blochchain.sh getbalance -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh
Balance of '13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh': 5

$ ./blochchain.sh getbalance -address 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e
Balance of '1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e': 5

$ ./blochchain.sh getbalance -address 19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1
Balance of '19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1': 0

Nice! 现在让我们一起来实现交易签名部分的内容。

签名实现

交易数据必须被签名,因为这是比特币中能够保证不能花费属于他人比特币的唯一方法。如果一个签名是无效的,那么这笔交易也是无效的,这样的话,这笔交易就不能被添加到区块链中去。

我们已经有了实现交易签名的所有片段,还有一个事情除外:用于签名的数据。交易数据中哪一部分是真正用于签名的呢?难道是全部数据?选择用于签名的数据相当的重要。用于签名的数据必须包含以独特且唯一的方式标识数据的信息。例如,仅对交易输出签名是没有意义的,因为此签名不会考虑发送发与接收方。

考虑到交易数据要解锁前面的交易输出,重新分配交易输出中的 value 值,并且锁定新的交易输出,因此下面这些数据是必须被签名的:

  1. 存储在解锁了的交易输出中的公钥Hash。它标识了交易的发送方。
  2. 存储在新的、锁定的交易输出中的公钥Hash。它标识了交易的接收方。
  3. 新的交易输出中包含的 value 值。

在比特币中,锁定/解锁逻辑存储在脚本中,解锁脚本存储在交易输入的 ScriptSig 字段中,而锁定脚本存储在交易输出的 ScriptPubKey 的字段中。 由于比特币允许不同类型的脚本,因此它会对ScriptPubKey的全部内容进行签名。

如你所见,我们不需要去对存储在交易输入中的公钥进行签名。正因为如此,在比特币中,所签名的并不是一个交易,而是一个去除部分内容的交易输入副本,交易输入里面存储了被引用交易输出的 ScriptPubKey

获取修剪后的交易副本的详细过程在这里. 虽然它可能已经过时了,但是我并没有找到另一个更可靠的来源。

OK,它看起来有点复杂,因此让我们来开始coding吧。我们将从 Sign 方法开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Transaction {

...

/**
* 签名
*
* @param privateKey 私钥
* @param prevTxMap 前面多笔交易集合
*/
public void sign(BCECPrivateKey privateKey, Map<String, Transaction> prevTxMap) throws Exception {
// coinbase 交易信息不需要签名,因为它不存在交易输入信息
if (this.isCoinbase()) {
return;
}
// 再次验证一下交易信息中的交易输入是否正确,也就是能否查找对应的交易数据
for (TXInput txInput : this.getInputs()) {
if (prevTxMap.get(Hex.encodeHexString(txInput.getTxId())) == null) {
throw new Exception("ERROR: Previous transaction is not correct");
}
}

// 创建用于签名的交易信息的副本
Transaction txCopy = this.trimmedCopy();

Security.addProvider(new BouncyCastleProvider());
Signature ecdsaSign = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
ecdsaSign.initSign(privateKey);

for (int i = 0; i < txCopy.getInputs().length; i++) {
TXInput txInputCopy = txCopy.getInputs()[i];
// 获取交易输入TxID对应的交易数据
Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInputCopy.getTxId()));
// 获取交易输入所对应的上一笔交易中的交易输出
TXOutput prevTxOutput = prevTx.getOutputs()[txInputCopy.getTxOutputIndex()];
txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
txInputCopy.setSignature(null);
// 得到要签名的数据,即交易ID
txCopy.setTxId(txCopy.hash());
txInputCopy.setPubKey(null);

// 对整个交易信息仅进行签名,即对交易ID进行签名
ecdsaSign.update(txCopy.getTxId());
byte[] signature = ecdsaSign.sign();

// 将整个交易数据的签名赋值给交易输入,因为交易输入需要包含整个交易信息的签名
// 注意是将得到的签名赋值给原交易信息中的交易输入
this.getInputs()[i].setSignature(signature);
}
}

...

}

这个方法需要私钥和前面多笔交易集合作为参数。正如前面所提到的那样,为了能够对交易信息进行签名,我们需要能够访问到被交易数据中的交易输入所引用的交易输出,因此我们需要得到存储这些交易输出的交易信息。

让我们来一步一步review这个方法:

1
2
3
if (this.isCoinbase()) {
return;
}

由于 coinbase 交易信息不存在交易输入信息,因此它不需要签名,直接return.

1
Transaction txCopy = this.trimmedCopy();

创建交易的副本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Transaction {

...

/**
* 创建用于签名的交易数据副本
*
* @return
*/
public Transaction trimmedCopy() {
TXInput[] tmpTXInputs = new TXInput[this.getInputs().length];
for (int i = 0; i < this.getInputs().length; i++) {
TXInput txInput = this.getInputs()[i];
tmpTXInputs[i] = new TXInput(txInput.getTxId(), txInput.getTxOutputIndex(), null, null);
}

TXOutput[] tmpTXOutputs = new TXOutput[this.getOutputs().length];
for (int i = 0; i < this.getOutputs().length; i++) {
TXOutput txOutput = this.getOutputs()[i];
tmpTXOutputs[i] = new TXOutput(txOutput.getValue(), txOutput.getPubKeyHash());
}

return new Transaction(this.getTxId(), tmpTXInputs, tmpTXOutputs);
}

...

}

这个交易数据的副本包含了交易输入与交易输出,但是交易输入的 SignaturePubKey 需要设置为null。

使用私钥初始化 SHA256withECDSA 签名算法:

1
2
3
Security.addProvider(new BouncyCastleProvider());
Signature ecdsaSign = Signature.getInstance("SHA256withECDSA",BouncyCastleProvider.PROVIDER_NAME);
ecdsaSign.initSign(privateKey);

接下来,我们迭代交易副本中的交易输入:

1
2
3
4
5
6
7
for (TXInput txInput : txCopy.getInputs()) {
// 获取交易输入TxID对应的交易数据
Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInputCopy.getTxId()));
// 获取交易输入所对应的上一笔交易中的交易输出
TXOutput prevTxOutput = prevTx.getOutputs()[txInputCopy.getTxOutputIndex()];
txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
txInputCopy.setSignature(null);

在每一个 txInput中,signature 都需要设置为null(仅仅是为了二次确认检查),并且 pubKey 设置为它所引用的交易输出的 pubKeyHash 字段。在此刻,除了当前的正在循环的交易输入(txInput)外,其他所有的交易输入都是”空的”,也就是说他们的 SignaturePubKey 字段被设置为 null。因此,交易输入是被分开签名的,尽管这对于我们的应用并不十分紧要,但是比特币允许交易包含引用了不同地址的输入。

Hash 方法对交易进行序列化,并使用 SHA-256 算法进行哈希。哈希后的结果就是我们要签名的数据。在获取完哈希,我们应该重置 PubKey 字段,以便于它不会影响后面的迭代。

1
2
3
// 得到要签名的数据,即交易ID
txCopy.setTxId(txCopy.hash());
txInput.setPubKey(null);

现在,最关键的部分来了:

1
2
3
4
5
6
7
8
9
10
// 对整个交易信息仅进行签名,即对交易ID进行签名
Security.addProvider(new BouncyCastleProvider());
Signature ecdsaSign = Signature.getInstance("SHA256withECDSA",BouncyCastleProvider.PROVIDER_NAME);
ecdsaSign.initSign(privateKey);
ecdsaSign.update(txCopy.getTxId());
byte[] signature = ecdsaSign.sign();

// 将整个交易数据的签名赋值给交易输入,因为交易输入需要包含整个交易信息的签名
// 注意是将得到的签名赋值给原交易信息中的交易输入
this.getInputs()[i].setSignature(signature);

使用 SHA256withECDSA 签名算法加上私钥,来对交易ID进行签名,从而得到了交易输入所要设置的交易签名。

现在,让我们来实现交易的验证功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class Transaction {

...

/**
* 验证交易信息
*
* @param prevTxMap 前面多笔交易集合
* @return
*/
public boolean verify(Map<String, Transaction> prevTxMap) throws Exception {
// coinbase 交易信息不需要签名,也就无需验证
if (this.isCoinbase()) {
return true;
}

// 再次验证一下交易信息中的交易输入是否正确,也就是能否查找对应的交易数据
for (TXInput txInput : this.getInputs()) {
if (prevTxMap.get(Hex.encodeHexString(txInput.getTxId())) == null) {
throw new Exception("ERROR: Previous transaction is not correct");
}
}

// 创建用于签名验证的交易信息的副本
Transaction txCopy = this.trimmedCopy();

Security.addProvider(new BouncyCastleProvider());
ECParameterSpec ecParameters = ECNamedCurveTable.getParameterSpec("secp256k1");
KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
Signature ecdsaVerify = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);

for (int i = 0; i < this.getInputs().length; i++) {
TXInput txInput = this.getInputs()[i];
// 获取交易输入TxID对应的交易数据
Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInput.getTxId()));
// 获取交易输入所对应的上一笔交易中的交易输出
TXOutput prevTxOutput = prevTx.getOutputs()[txInput.getTxOutputIndex()];

TXInput txInputCopy = txCopy.getInputs()[i];
txInputCopy.setSignature(null);
txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
// 得到要签名的数据,即交易ID
txCopy.setTxId(txCopy.hash());
txInputCopy.setPubKey(null);

// 使用椭圆曲线 x,y 点去生成公钥Key
BigInteger x = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 33, 65));
ECPoint ecPoint = ecParameters.getCurve().createPoint(x, y);

ECPublicKeySpec keySpec = new ECPublicKeySpec(ecPoint, ecParameters);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
ecdsaVerify.initVerify(publicKey);
ecdsaVerify.update(txCopy.getTxId());
if (!ecdsaVerify.verify(txInput.getSignature())) {
return false;
}
}
return true;
}

...
}

首选,同前面签名一样,我们先获取交易的拷贝数据:

1
Transaction txCopy = this.trimmedCopy();

获取椭圆曲线参数和签名类:

1
2
3
4
Security.addProvider(new BouncyCastleProvider());
ECParameterSpec ecParameters = ECNamedCurveTable.getParameterSpec("secp256k1");
KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
Signature ecdsaVerify = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);

接下来,我们来检查每一个交易输入的签名是否正确:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (int i = 0; i < this.getInputs().length; i++) {
TXInput txInput = this.getInputs()[i];
// 获取交易输入TxID对应的交易数据
Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInput.getTxId()));
// 获取交易输入所对应的上一笔交易中的交易输出
TXOutput prevTxOutput = prevTx.getOutputs()[txInput.getTxOutputIndex()];

TXInput txInputCopy = txCopy.getInputs()[i];
txInputCopy.setSignature(null);
txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
// 得到要签名的数据,即交易ID
txCopy.setTxId(txCopy.hash());
txInputCopy.setPubKey(null);
}

这部分与Sign方法中的相同,因为在验证过程中我们需要签署相同的数据。

1
2
3
4
5
6
7
8
9
10
11
12
// 使用椭圆曲线 x,y 点去生成公钥Key
BigInteger x = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 33, 65));
ECPoint ecPoint = ecParameters.getCurve().createPoint(x, y);

ECPublicKeySpec keySpec = new ECPublicKeySpec(ecPoint, ecParameters);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
ecdsaVerify.initVerify(publicKey);
ecdsaVerify.update(txCopy.getTxId());
if (!ecdsaVerify.verify(txInput.getSignature())) {
return false;
}

由于交易输入中存储的 pubkey ,实际上是椭圆曲线上的一对 x,y 坐标,所以我们可以从 pubKey 得到公钥PublicKey,然后在用公钥去签名进行验证。如果验证成功,则返回true,否则,返回false。

现在,我们需要一个方法来获取以前的交易。 由于这需要与区块链互动,我们将使其成为 blockchain 的一种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Blockchain {

...

/**
* 依据交易ID查询交易信息
*
* @param txId 交易ID
* @return
*/
private Transaction findTransaction(byte[] txId) throws Exception {
for (BlockchainIterator iterator = this.getBlockchainIterator(); iterator.hashNext(); ) {
Block block = iterator.next();
for (Transaction tx : block.getTransactions()) {
if (Arrays.equals(tx.getTxId(), txId)) {
return tx;
}
}
}
throw new Exception("ERROR: Can not found tx by txId ! ");
}


/**
* 进行交易签名
*
* @param tx 交易数据
* @param privateKey 私钥
*/
public void signTransaction(Transaction tx, BCECPrivateKey privateKey) throws Exception {
// 先来找到这笔新的交易中,交易输入所引用的前面的多笔交易的数据
Map<String, Transaction> prevTxMap = new HashMap<>();
for (TXInput txInput : tx.getInputs()) {
Transaction prevTx = this.findTransaction(txInput.getTxId());
prevTxMap.put(Hex.encodeHexString(txInput.getTxId()), prevTx);
}
tx.sign(privateKey, prevTxMap);
}

/**
* 交易签名验证
*
* @param tx
*/
private boolean verifyTransactions(Transaction tx) throws Exception {
Map<String, Transaction> prevTx = new HashMap<>();
for (TXInput txInput : tx.getInputs()) {
Transaction transaction = this.findTransaction(txInput.getTxId());
prevTx.put(Hex.encodeHexString(txInput.getTxId()), transaction);
}
return tx.verify(prevTx);
}

}

现在,我们需要对我们的交易进行真正的签名和验证了,交易的签名发生在 newUTXOTransaction 中:

1
2
3
4
5
6
7
8
9
10
11
12
 public static Transaction newUTXOTransaction(String from, String to, int amount, Blockchain blockchain) throws Exception {

...

Transaction newTx = new Transaction(null, txInputs, txOutput);
newTx.setTxId(newTx.hash());

// 进行交易签名
blockchain.signTransaction(newTx, senderWallet.getPrivateKey());

return newTx;
}

交易的验证发生在一笔交易被放入区块之前:

1
2
3
4
5
6
7
8
9
10
public void mineBlock(Transaction[] transactions) throws Exception {
// 挖矿前,先验证交易记录
for (Transaction tx : transactions) {
if (!this.verifyTransactions(tx)) {
throw new Exception("ERROR: Fail to mine block ! Invalid transaction ! ");
}
}

...
}

OK,让我们再一次对整个工程的代码做一个测试,测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ ./blochchain.sh  createwallet
wallet address : 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6

$ ./blochchain.sh createwallet
wallet address : 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB

$ ./blochchain.sh createwallet
wallet address : 13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f

$ ./blochchain.sh createblockchain -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6

Elapsed Time: 164.961 seconds
correct hash Hex: 00000524231ae1832c49957848d2d1871cc35ff4d113c23be1937c6dff5cdf2a

Done !

$ ./blochchain.sh getbalance -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6
Balance of '1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6': 10

$ ./blochchain.sh send -from 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB -to 13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f -amount 5
java.lang.Exception: ERROR: Not enough funds

$ ./blochchain.sh send -from 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6 -to 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB -amount 5
Elapsed Time: 54.92 seconds
correct hash Hex: 00000354f86cde369d4c39d2b3016ac9a74956425f1348b4c26b2cddb98c100b

Success!

$ ./blochchain.sh getbalance -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6
Balance of '1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6': 5

$ ./blochchain.sh getbalance -address 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB
Balance of '1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB': 5

$ ./blochchain.sh getbalance -address 13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f
Balance of '13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f': 0

Good!没有任何错误!

让我们注释掉 NewUTXOTransaction 方法中的一行代码,确保未被签名的交易不能被添加到区块中:

1
2
3
4
5
...

// blockchain.signTransaction(newTx, senderWallet.getPrivateKey());

...

测试结果:

1
2
3
4
5
6
java.lang.Exception: Fail to verify transaction ! transaction invalid ! 
at one.wangwei.blockchain.block.Blockchain.verifyTransactions(Blockchain.java:334)
at one.wangwei.blockchain.block.Blockchain.mineBlock(Blockchain.java:76)
at one.wangwei.blockchain.cli.CLI.send(CLI.java:202)
at one.wangwei.blockchain.cli.CLI.parse(CLI.java:79)
at one.wangwei.blockchain.BlockchainTest.main(BlockchainTest.java:23)

说明

WalletUtils 如若抛出异常:Illegal key size or default parameters,请按以下方法进行解决:

https://stackoverflow.com/questions/6481627/java-security-illegal-key-size-or-default-parameters

总结

这一节,我们学到了:

  1. 使用椭圆曲线加密算法,如何去创建钱包;
  2. 了解到了如何去生成比特币地址;
  3. 如何去对交易信息进行签名并对签名进行验证;

到目前为止,我们已经实现了比特币的许多关键特性! 我们已经实现了除外网络外的几乎所有功能,并且在下一篇文章中,我们将继续完善交易这一环节机制。

资料

https://press.one/file/v?s=e314e0662f84fc732e37faa7abfa5615ce04675d70c3fad51d534ac60c7f38a41e5f468d43d209823e008299b24bde00ffcbd172896c1be5a1655c37f332e1a71&h=cf27d733b01c15c2dff665f44233f12735f93bf3a4529166506fce3caff2872a&a=23fe9bfd7ceef4b44c2ce44dcac8e4a49caf8026&f=P1&v=2

王维 / Michael  Wang wechat
欢迎交流学习
请我喝杯咖啡吧~