本文最近一次更新于 8 个月前,其中的内容很可能已经有所发展或是发生改变。
最近这段时间一直在研究 Web3 相关的技术,而迈向 Web3 数字世界的遇到的第一个门槛就是拥有一个数字钱包。数字钱包地址可以理解成现实世界的银行卡号,不少人会去追求号码有好的寓意(比如尾号尽可能多的 6 或 8,或者是对自己有独特含义的号),不同的是,银行卡号的靓号往往需要去银行花钱办理;钱包地址则只需要你花时间去碰撞生成即可。这种去不断碰撞得到的地址一般称为虚荣地址(Vanity Address),也就是满足自己的虚荣心而生成的,Solana 也形象地将这种行为称作 Grinding(磨),虚荣地址其本质以及功能性与其他的地址对普通用户来说没有太大区别。
在运行生成地址的算法时,显卡相比 CPU 有很大的优势:显卡的流处理器相比 CPU 有数量级别的优势,因此使用 GPU 来生成虚荣地址会更快速。网上找了一圈,虽然 Solana 也有 solanity这款使用 CUDA 编写的工具,但是我使用 RTX 3080 运行的生成其实和我用 CPU 跑不出什么多大的区别(issue 也有人反馈根本达不到预期的性能),而我在使用 profanity2这款 OpenCL 编写的 ETH 虚荣地址生成工具运行速度是相比 CPU 有数量级的差距。因此我便开始研究生成地址用到的加密算法,打算自己写一个出来。
钱包地址生成算法的实现
对于不同的 Web3 钱包种类而言,其实生成地址的步骤都是大同小异的,最大的区别在于选择的加密算法不同:
- 生成私钥,一般来说是生成 32 个随机字节;
- 将随机字节转化成大数然后乘以椭圆曲线上的 G 点,得到公钥的坐标点,这个过程称为派生;
- 将这个坐标点重新转化字节,对字节进行某些编码或者哈希处理作为钱包地址。
对于 ETH 地址选择的椭圆曲线是 secp256k1,Sol 地址选择的椭圆曲线是 ed25519;找到对应坐标点后,ETH 会对公钥进行 keccak 哈希,然后取最后 20 个字节转 hex 后作为地址;而 Sol 则是直接对公钥进行 base58 编码作为地址。
也就是说,用 OpenCL 实现 ed25519 和 base58 这两个算法就大功告成了。
因为 OpenCL 本身的语法是基于 C99 的扩展,因此从 0 开始实现加密算法并不是首选的方案。找一个 C 语言的实现,验证没问题后移植到 OpenCL 是更安全、方便的做法。
网上有很多选择,我选择的是:ed25519和 base58。
显卡是如何能加速计算的
之前在不太了解 OpenCL 的时候,我在网上 copy 了一份使用 OpenCL 计算哈希的代码作为入手代码,但是在批量运行 10w 次时,甚至相比单纯 CPU 算慢了非常多。因此托朋友问了一个游戏渲染方面的大佬「显卡加速哈希运算应该怎么做,为什么我批量计算比 CPU 还慢」,然而得到的回答并没有解答我的疑惑,他认为哈希运算并不能分块计算,因此使用 GPU 并没有优势可言,而是像图像那样可以细粒度计算才能体现 GPU 的优势。他的回答某种程度上没问题,对于单次的哈希运算而言,GPU 不会比 CPU 更快,而我其实需要的是批量输入的并行计算,在后来我明白了,其实是我输入或者说调用方式有问题,我要运行 10w 次哈希,不应该是调用 10w 次 OpenCL 内核代码,而应该是调用 1 次 OpenCL 内核代码,然后设置 10w 个 worker,然后在内核代码里计算 10w 次。
worker 可以理解成 thread,其数量是通过调用内核时传入的 global worker size 参数来指定的。
具体到地址生成算法,我的做法是:随机生成 32 个字节作为 seed,然后将 global worker size 设置为 256 ** 4
,每一个 OpenCL 线程分别去获取当前 thread id,然后转化成字节的大端序格式,然后覆盖的末尾四个字节,每个线程计算一次。如果找到符合条件的地址,就记录在输出中。然后每一轮 OpenCL 调用,都会把 seed 倒数第五个字节 + 1(满则进位),这样来做迭代计算,直到找到满足条件的地址为止。
迁移到 OpenCL
OpenCL 程序并不像 C 程序一样,代码编译后直接运行,而是分成了两部分:
- 运行在计算设备(也就是 GPU)上的内核代码;
- 管理内核编译、执行以及传输数据的主机端代码。
其中 ed25519 算法以及 base58 算法都放在内核代码里,然后暴露一个入口函数,由主机端代码调用,并对结果进行后续处理。
我使用的 mac 系统自带的 OpenCL 版本是 1.2,在迁移的过程遇到的最大阻力就是入口函数的问题,OpenCL 相比标准 C 多了地址空间的概念,尤其是在内核函数的参数里,需要指定空间为 global、private、local 三者其一。
而我在把程序迁移到 Nvidia 设备的生活,遇到的问题就比较麻烦了,因为自 OpenCL 2.0 开始函数的参数未指定地址空间默认为 generic,如果调用这个函数传入的是 private 地址空间的变量就会编译错误,而默认声明的变量都是 private 地址空间的,因此需要逐个把调用函数的参数都改成 generic 的空间,一共一百多个地方(掀桌子…
与 profanity2 的区别
profanity2 本身其实是修复了 profanity 私钥 seed 生成不够随机的漏洞,但是此外它还有一个很大的改进,就是它不生成私钥 seed,由用户提供公钥,然后利用对公钥的偏移量来计算不同的虚荣地址,就算公钥被泄漏,但因为椭圆曲线的特性,也无法从公钥逆推出私钥,最大化地确保私钥的安全性。
我初次了解这种设计时,感觉真的很酷,于是深入了解了一下这样做的技术原理,并且也想让 Solana 地址也采用这种方式生成:
- 假设原始私钥为
k
,偏移量为delta
,那么新的私钥可以表示为k' = k + delta
。由于椭圆曲线的性质,这种加法操作对应于曲线上点的加法; - 新的私钥
k'
将生成一个新的公钥P'
。由于公钥是私钥乘以 G 点,因此P' = (k + delta) * G = k * G + delta * G = P + (delta * G)
,其中P
是原始公钥,P'
是新公钥,delta * G
是偏移量乘以 G 点,表示在椭圆曲线上移动的效果。
因此,当使用 profanity2 找到满足目标的 delta 时,将 delta 加上原本的 seed 就是目标的私钥了。
不过 Solana 的地址并不能使用公钥 + 偏移量这种方式生产,因为 ed25519 的公钥生成略有不同:需要对原始私钥 seed 进行一次 SHA512 运算得到结果 H,然后用 H 前 32 字节转化成大数去乘以 G 点,才得到公钥。这也就意味着,即使我对公钥偏移了 delta 得到了目标的地址,但是得到的输入其实是 H + delta,我们并不知道对原始 seed 进行什么样的变化才能让 SHA512 之后会得到刚好偏移 delta 的值,SHA512 同样是不可逆的。
结果对比
我使用的是 M1 版本的 MacBook Air,使用官方的 solana-keygen grind
命令计算的哈希速度是 0.3 Mh/s。
GPU | Memory Clock | TFLOPS | Hash Speed |
---|---|---|---|
Apple Silicon M1 | - | 2.3 TFLOPS | 1.2 MH/s |
RTX 3080 | 1188 MHz | 29.2 TFLOPS | 23.1 Mh/s |
RTX 4090 | 1313 MHz | 81.9 TFLOPS | 67.8 Mh/s |
另外说一点,这种 Vanity Address 的工具吃的是 GPU 的计算性能(而非图形性能、显存),预估不同 GPU 的速度,使用 TFLOPS 指标会更接近真实情况。
项目的代码已开源,欢迎自行对比。
一点感想
两年前的我,自认为本身的技术已经达到了一个比较高的水平,再加上对搜索引擎使用的也比较熟练,我想我对软件开发领域的大部分技术已经信手拈来,就算真的遇到了没有接触过的领域或者技术(比如:计算机图形学),上手也无非是需要一个熟悉的过程罢了,而在过去工作的几年时间,我也没有遇到什么无法解决的难题。而这段时间学习 OpenCL 的编程其实是给我上了一课。
虽然本文写的过程很轻松,但主要原因在于这不是我第一个接触的 OpenCL 的项目了,加上地址生成算法比较简单,只是涉及到椭圆曲线的点乘运算,完成这个项目也就花了十来个小时。回想起一个多月前,首次接触 OpenCL 项目时,遭遇的困难远比我想象的多得多,途中我也曾数次想过放弃——因为如果完全不了解一个领域时,你甚至都不知道如何下手,对 ChatGPT 提问也问不到点子上,那么自然也得不到想要的回答,但我终究还是坚持过来了。
只是经过这次之后,我不再会那么「狂妄」的认为我已经熟悉了大部分技术了,计算机终究还是一个非常大的领域,我所了解的技术大部分也只不过是限定在 Web 开发领域而已。