“打点创新营”主题分享:SV 开发中的那些坑

大家下午好!我是 Aaron。感谢哲明和打点钱包的邀请,让我分享一些开发过程中遇到的问题。

我想从“192”这个神奇的数字开始今天的内容:普通用户能发出来的、最小的一笔交易的大小,是 192 字节。

作为开发者,其实就是通过代码来操作交易与比特币系统交互。交易是比特币系统中最重要的部分,对交易细节的把握可以让你在写代码的过程中避免掉坑。所以我先讲些交易的细节。

从程序员的角度来说,一个交易长这个样子:开头是一个 4 字节的版本号,标记这笔交易的结构是什么版本;随后是一个 1 ~ 9 字节的可变长整数,标识这笔交易中输入的个数;后面跟着一个数组,多个交易输入按顺序排列;接着是一个可变长整数,标识交易有多少输出,后面跟着具体的交易输出;最后是一个 4 字节的 nLocktime,标记交易的时间锁。

对于交易输入的结构:开头是一个 32 字节的交易哈希和一个 4 字节的序号,表达交易链条,指示这笔交易输入来自之前哪笔交易的第几个输出;接着是一个可变长整数,表示解锁脚本的长度,后面跟着具体的解锁脚本;最后是 4 字节的 nSequence。对于交易输出的结构:开头是一个 8 字节整数,表示这笔交易输出(UTXO)的金额;接着是一个可变长整数,表示锁定脚本的长度,后面跟着具体的锁定脚本。这就是一笔交易的结构,很简单,比特币系统中所有的交易都是这个样子。

普通用户能接触到的交易基本都是 P2PKH(付款到公钥哈希)交易,作为开发者,操作的交易也基本全是 P2PKH。

所以我们可以细化一下交易的结构图,因为确定了交易类型,就等于确定了解锁脚本和锁定脚本的结构。对于 P2PKH 交易,解锁脚本分两部分,前面是 DER 格式的签名,后面跟着公钥,第一个 OP_72 表示签名的长度,后面的 OP_33 表示公钥有 33 字节;锁定脚本的结构也是确定的,有 25 字节,5 个操作码加上 20 字节的收款方公钥哈希。

所以普通用户能发出来的最小的交易,是只有一个输入和一个输出的 P2PKH 交易,自下而上,计算出来交易的大小是 192 字节。

为了验证,我发了一笔交易,有一个输入,有一个输出,交易的大小是 192 字节,手续费 1 聪每字节,最后我支付了 192 聪的费用,截图里你能看到实际情况确实如此。

介绍 192 这个数字的目的是什么呢?因为很多开发者在写代码时忽略了另一个数字,这个数字是“546”:交易输出的金额,不能小于 546 聪。我说一下它是怎么来的。我们都知道,节点在收到交易之后,要验证这笔交易,如果交易合法且交易是标准交易,节点才会把它放入自己的内存池(mempool),并向相邻的节点(peers)传播(relay)这笔交易。最早在做压测工具的时候,为了尽可能的省钱,我需要将每笔交易输出设置的尽可能小,那就 1 聪好了,但是交易被拒绝了。

翻源代码找原因,看到一个函数IsStandardTx,最后有一步函数调用,判断交易输出是不是 dust,就是我用红线标记的那行。

看下这个IsDust函数做了什么,一共就三四行代码却有很多注释,拿出注释细看一下。

我把比较重要的内容标记成了三部分。

  1. 如果你在花费一笔 UTXO 时,需要支付的手续费超过了这个 UTXO 面值的 1/3,那么这个 UTXO 就是 dust;
  2. 交易输出有 34 字节,把它作为交易输入花费掉需要 148 字节;
  3. 得出来交易输出最小金额的公式是 546 乘以最小的手续费单位。目前最少的手续费是 1 聪每字节,所以说最小的 UTXO 面值是 546 聪。

之前的交易结构图里有说到注释里的 148 和 34 都是怎么来的,列个式子计算一下即可。

很多开发者在构造交易时注意不到 546 的限制,一些底层库在处理交易找零时也没有特别处理。比如说我现在有一个 1000 聪的 UTXO,支付了 800 聪,找零 200 聪。这笔交易是合法的交易,但广播后节点会拒绝,也不会 relay 它,这会给开发者开来困扰。这张图是 BitSV 库内部的处理方法,你能看到,当交易剩余的找零小于 546 聪时,它会把这部分输出直接当成矿工费,以避开 dust 限制。当你在使用其他语言库写代码时,务必要注意这些细节。

接下来我想说说 UTXO 这种数据结构。UTXO 是构成交易的基本元素,作为开发者,这种数据结构很友好,但普通用户却难以理解它。基于余额设计财务系统是非常直观的,转账发生时更新余额字段的值即可,但要注意,更新共享变量需要加锁,否则可能会读到脏数据或计算出负余额,而加锁就意味着对并发操作并不友好,系统难以水平扩展。UTXO 结构是高并发友好的,多个 UTXO 之间毫无关系且彼此独立。老刘 Edward 之前有篇文章讲到还可以将 UTXO 看成一种文件共享锁,感兴趣的可以去读一读。

作为普通用户,当你在玩 BSVRUN 的时候一定见过这样的画面,玩着玩着你就不能继续了。

去年春节,当你使用打点钱包在群里给朋友发红包时,还会见过这样的错误,红包发着发着就发不出来了。这是现在比特币系统中的另一限制:未确认的交易祖先不能超过 25 个。举个例子,如果不考虑手续费,我向哲明支付了 1 BSV,这笔交易产生了一个 1 BSV 的 UTXO,现在是未确认的状态;哲明不需要等待交易确认,他可以立即将这 1 BSV 发送给晓峰;晓峰同样不需要等待,可以立即将收到的 1 BSV 支付给 AusLiu。现在,AusLiu 有了一个 1 BSV 的还未确认的 UTXO,而这个 UTXO 依赖的交易祖先,也都是未确认的。比特币系统允许用户花费未确认的 UTXO,但对这些 UTXO 未确认的交易祖先的个数有限制。如果你的用户会在应用里互相高频的发送交易,那你一定会碰到这个问题。如果我需要在 1 秒内发出 100 笔交易,不对 UTXO 做预处理就无法实现这个需求。

绕过这个限制的方法很直接,你需要预先拆分出足够多的 UTXO。

因为出块就意味着交易确认,所以拆分过程可以把出块当做触发器。起始是 1 个黄色的 UTXO,程序监听到网络出块,黄色的 UTXO 变成了已确认的状态,构造一笔交易,将它拆分成 400 个绿色的UTXO,然后等待,再次出块后,绿色的 UTXO 都变成了已确认,再构造交易,把每个绿色的 UTXO 拆成 400 个红色的 UTXO,这样一直拆下去,直到得到足够多的 UTXO。拆分得到的 UTXO 数量呈指数增长,效率够用。回到上面的问题,我只需要预先将 UTXO 拆成 100 份并等待 1 个区块确认即可。很多 BSV 应用都需要做频繁的小额支付,提前想到解决方案,用户体验会好很多。

前几天,一个朋友联系我说他的 Money Button 不能发送交易了,他没法支付放在账户里面的 BSV。

我说那你在别的钱包软件里恢复一下助记词,看看到底发生了什么。他恢复了之后说自己有一个地址里被加特林灌了几百个 700 聪的 UTXO。

我说那没事,合并一下就行了。在 ElectrumSV 里全选这些小额 UTXO,支付到一个新地址,把它们它合并成一个输出就可以了。

但他广播交易时遇到了错误,ElectrumSV 的服务器做了限制,会拒绝比较大的交易。最后我给他发了段代码解决了这个问题。

对于几百个 UTXO 的合并非常简单,但对于这样一个有三百多万次历史交易、三万个 UTXO 的地址来说,想使用现有钱包导入私钥做输出合并没任何可能性。如果你想花这个地址里的 BSV,只有写代码。前面我讲了拆分 UTXO,现在讲讲合并零碎 UTXO 的方案。

如果你还记得之前的这张图,这是一输入一输出 P2PKH 交易的结构。在合并时,我们可以将每 100 个 UTXO 合并成 1 个新的 UTXO,计算一下,每笔交易的手续费是 14844 聪。验证一下,新建一个交易,左边是签名前,有 100 个输入,1 个输出,显示交易大小确实是 14844 字节,需要 14844 聪的手续费。看起来不错,计算是正确的。然后对交易签名,签名后的交易是右边截图的样子。很奇怪,交易的大小发生了改变,签名之前是 14844 字节,签名之后变成 14793 字节,为什么?

难道说之前的计算有什么问题吗?不太确定,我又构建了一个新的一输入一输出的交易,发现它在签名前是 192 字节,签名后变成了 191 字节。这个又勾起我的好奇心,为什么会发生这样的情况。

所以我搜了些文章看,原因这里不展开说了。很明显交易大小的改变是因为签名的长度会变,未确认的交易没有实际的签名数据,只是会预留空间,所以其大小与理论计算值一样,而签名后因为实际的签名数据会变小,所以存在差值。这页里我放了两个参考链接,感兴趣的朋友可以之后看。

让我们回到之前 MB 的错误上来,顺着这个思路往下想,你会找到一个有趣的攻击点。

Money Button 的用户可以使用 paymail 地址收款。这本不是什么问题,但 MB 的 paymail 支持“用户ID号@moneybutton.com”格式。这意味着,我可以不需要事先知道 MB 用户具体的收款地址或 paymail 而向他们支付 BSV,也就是说,我可以按顺序依次向 ID 是 1、ID 是 2 … ID 是 10000 的 MB 用户支付大量的小额 UTXO,从而让他们无法再通过 MB 的网站操作自己的账户。整个攻击的成本非常低。

最后我想说说广播交易这块的坑,FastPayButton 的邱总和我在这里都曾遇到过问题。FPB 之前使用 BitIndex API,他们上线城市交易的那天下午,BitIndex 广播交易的 API 有问题,调用后会返回正确,与往常没任何区别,但实际上你的交易并没有真正被广播出去,网络中并不存在这些交易。所以他们最后发现交易了很多城市,却都没收用户的钱。

后来我们讨论了一下,觉得比较好的做法是不要直接认可服务商返回的广播结果,在一些非常关键的业务场景,多花一点时间去访问其他不同服务商的接口,确认交易是不是也进入了他们的内存池。如果是,再去做接下来的逻辑,让用户等待两三秒的时间,避免意外。

最后一点时间,说说我这一年来的感受。SV 的开发社区非常活跃,在座有一半以上的熟面孔,我们经常在全国各地的活动中碰到。当我遇到开发难题时,有经验的朋友能毫无保留的分享解决方案和思路,他们热情、可爱、平易近人,虽然我们之前从未谋面不认识彼此。下个月的 Genesis 升级会解锁被限制的脚本操作码,基于 BSV 也可以做出更多更好玩的应用。生态的建设,需要在座每一位的参与,需要更多的开发者加入进来。如果你有兴趣基于 BSV 做一些尝试,不要犹豫。

以上就是我今天的分享,谢谢大家!

aaron67-dotcamp-20200104.pdf