MoreRSS

site iconsmallyu修改

区块链行业的开发者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

smallyu的 RSS 预览

失业第二个月干了什么(草稿)

2025-07-20 12:12:12

这篇文章的发布日期是 7月20日,是一个未来的日期。我也是尝试一下用未来的时间有没有问题。因为是草稿,内容还会不断变化。

今天是 7月20日,距离 5月20日已经两个月。失业第一个月的时候,简单写了《失业一个月的回顾》复盘第一个月干的事情,其实第一个月的学习节奏很紧张,事不少。现在第二个月了,记录下第二个月干的事情:

(1)从计算机科学班顺利毕业,算是心里的石头落地,学也学完了,接下来好好复习、好好找工作。这个课程还是给我带来了不少信心的,否则我现在一定心态爆炸,我会有很多疑问:“区块链技术到底有啥好学的?”、“我已经接触过很多区块链技术和项目,你让我学啥?”、“面试不通过,你觉得我不会什么?”。这个课程,让我在心态上更加脚踏实地一点,挺不错。

对了,顺便提一下,在区块链的技术领域里,Uniswap 这种东西在技术上,对于我这种 “技术层级” 来说,不值得研究。这话是 ChatGPT 说的,不只是我自己瞎琢磨,我之前就写过 《为什么不要做 DeFi 开发》的文章,里面的道理不全对,但也不是完全没道理,我没给 ChatGPT 看我的这篇文章,但是 ChatGPT 的观点和我一样,而且比我分析的更全面更深入,算是间接验证了我的观点。

(2)给解释器写了简单的 parser,支持变量定义、函数调用等语法,虽然不是纯粹的函数式写法,但仍然有效。不过不打算进一步完善这个东西,简单尝试一下就可以。

(3)用 Java 和 Go 语言分别写了一遍解释器。加上课程中学习的 js 和 Rust 版本,现在解释器已经有 4 个语言的版本了。对于解释器这样的程序,语言其实不是关键,语法差异没有那么大。

(4)学习 Solana 合约开发,包括 Solana 的账户模型、SPL 标准库、SDK 调用等。感觉没有太多值得深入的东西,这些用户层面的技术。如果要深入去理解 Solana 的架构和 BPF 的细节,倒也有进一步的学习空间,接下来可以考虑下这个方向。

(5)学习比特币脚本开发,用 btcdeb 观察脚本执行步骤、使用构建交易、发送交易等命令行。教程里没写多签和 HTLC 之类的内容,因为比特币脚本都是命令行形式,这种复杂的逻辑写起来会很难看,会出现大堆数据,还得找到上下文的对应关系,比如解释解锁的是哪一笔 UTXO,这种过程用文字的形式不太好写。

(6)第二轮复习基础班的内容,第一个月已经第二轮做了 1~7 课的练习题,只剩下第 10 课和 3 个隐藏关。现在这个月完成了全部的第二轮练习题。

  1. 重新做了第 6 课隐藏关的练习题,这个题目难度不大。
  2. 重新看了一遍第 10 课的教学视频,总结起来视频中提到的 Rust 关键特性有两个,Ownership 和 lifetime。
  3. 重新做了第 10 课隐藏关的练习题,用 Rust 写解释器。有意思的是,当第二次按部就班做题目的时候,犯了和第一次完全一样的错误,这个错误分两个阶段,都完美重现。我知道为什么会犯错,以及如何改正,但是说明也许是我的做题思路,或者其他原因,这两个错误是必犯的。当然顺便整理了更完善的题目说明和测试用例,下一次复习做题肯定会更顺手。
  4. (待补充)

(7)学习比特币 Runes 协议。(待补充)

(8)这一个月以来,从 6月20日到 7月20日之间,一共面试 10 家公司,一共参加 14 次面试。

这个频率不算很高,但也需要花不少时间,主要是得耗费心情在里面,一开始以积极的态度去面试,觉得这家也许有合适的机会,但是聊下来却不合适。这样的情况反复经历的多了,心情其实会不是很好,感觉浪费了期待在上面。职位不合适也能理解,但是如果频繁遇到不靠谱的面试官,会逐渐有点越来越受不了。毕竟面试这件事情,唯一的对手方就是面试官,而我最近老是在面试,有时候难免想对面试官发表点意见。

找工作这件事情找的都有点无奈了,到后来感觉面试面的都累了,不断反复自我介绍、反复讲解自己过去做的事情、反复描述我对某些技术问题的看法。这些面试官能问得出的问题就那么些,我都总结出规律了,所以后面的面试我就感觉是在重复。偶尔有面试官提到了新鲜的问题,让我对某些技术有了新的感受,这些面试官我觉得挺难得,也会给他们好评。

那么我现在是否对工作的的事情感到焦虑呢?其实好转很多,失业第一个月的回顾文章里,明确写了当时的烦躁和焦虑(不写出来我现在自己都忘了),现在失业第二个月,情况反而变好了,因为我知道自己在不断努力,学习新技术、沉淀计算机基础技能,我没有理由 “失败”。何况我有很好的女朋友,没工作算什么呢,有一个善解人意又漂亮的女朋友,是很多人 “梦寐以求” 都没有的,我却长时间以来一直在忽略她的重要。为了寻找合适的工作机会,我也有过去上海、香港的计划(不考虑深圳,在币圈“深圳盘”很有名),但是仍然在考虑具体的方式和时机。

有时候我也会想,过去的选择是否错误?只能说塞翁失马,没人知道对错。过去的选择有得有失。我现在不到 30 岁的年龄,去上课学习知识,去对自己未来的职业前景充满信心,去不断自学提高技术能力和见识,这些绝对不是一件错误的事情。我为什么会对自己有比较高的期望呢,也许是因为长期关注区块链行业,这个行业充满了各种故事。我的出身并不好,学历也不好,似乎没有什么理由让我应该 “成功”。不过我在学校的成绩还不错,只比同类优秀就是了。从那个时候开始,我逐渐对自己有了更高的期待。后面在职场中经历各种事情,更是没能明白什么样的人是优秀的,因为我没遇到过。

我期望的生活是什么样的呢,我只不过是希望赚点生活费维持生活而已,这没多少钱,在互联网行业不算多,在币圈更不算多,我经年累月的学习和努力工作,甚至忽略了很多普通生活的元素,如果最后落得找不到工作的下场,也挺奇怪的。人生的意义是什么呢,那些有钱人,他们随便的零花钱都够我一辈子的开销,他们在为什么努力,想要的是什么,他们有没有好好学习技术,有没有积极提升自己,他们又在为什么而发愁,在担心什么事情呢?

比特币脚本开发教程

2025-07-10 00:42:10

比特币脚本有点像房间里的大象,大家都知道这个东西,但是大家都看不见,或者不在乎。这个教程将从最基本的操作开始,理解比特币脚本的原理,学会自己写比特币脚本。因为比特币脚本不是图灵完备的,所以包含很多命令行操作,以及需要观察输出结果。

1. 启动本地节点

运行这个命令安装 bitcoind 的二进制,然后用 bitcoind --help 来测试是否安装成功:

brew install bitcoin

创建一个用于测试使用的目录,比如我的目录名称是 bitcoin-regtest

mkdir ./bitcoin-regtestcd ./bitcoin-regtest

在这个目录下新建一个叫 bitcoin.conf 文件,复制这些配置内容进去:

regtest=1txindex=1fallbackfee=0.0001

这是本地节点的配置文件,后续我们的比特币脚本将基于本地启动的开发节点来测试。这个配置文件中的 regtest=1 比较关键,指明了节点的类型是本地开发网络,不会真的到公网上同步区块数据,本地节点的块高度将从 0 开始。另外两个配置 txindex=1 是指启动本地节点对所有交易的索引,方便我们后续查看交易,fallbackfee=0.0001 则是指明交易手续费的大小。

停留在包含配置文件的当前目录下,执行这个命令来启动节点。这里的命令行,以及后续的命令行,都会带上 -datadir 参数,因为我们希望节点数据是隔离的,每一个工作目录都是一份新的环境,不至于污染电脑的全局环境,而且默认环境的路径比较长,不同操作系统不一致,虽然我们在后续的命令里都需要带上这么一个参数,看起来有点麻烦,但同时也避免了很多其他问题,比如找不到系统默认目录在哪儿之类:

bitcoind -datadir=./ -daemon

命令成功执行会看到 Bitcoin Core starting 的字样。为了测试节点是否真的在运行,可以用这个命令查看节点的状,会得到一个 json 数据:

bitcoin-cli -datadir=./ getblockchaininfo

如果还是对节点的运行状态不放心,可以直接查看节点的日志文件。这就是我们指定了数据目录的好处,日志文件在这个位置:

cat ./regtest/debug.log

如果想要停掉节点,避免后台进程一直在电脑上运行,用这个命令来停止节点:

bitcoin-cli -datadir=./ stop

注意启动节点用的是 bitcoind,停止节点用的是 bitcoin-cli。前者属于 server 端的命令,后者属于 client 端的命令。

另外,如果在停止节点后重启节点,发现钱包(下一小节内容)不能用了,可以用这个命令来导入钱包:

bitcoin-cli -datadir=./ loadwallet learn-script 

2. 创建钱包

运行这个命令来创建一个比特币钱包:

bitcoin-cli -datadir=./ createwallet "learn-script"

我们刚提到命令行中使用 -datadir 参数来指定数据目录,那么钱包的文件其实也会在数据目录下保存,可以看一下 ./regtest/wallets 目录,有一个 learn-script 的文件夹,我们刚才创建的钱包就在这个文件夹内。

查看钱包地址的命令,比如我的地址是 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw

bitcoin-cli -datadir=./ getnewaddress

接着在本地节点上,给钱包地址挖一些钱出来,这里的参数 101 是指挖 101 个区块。为什么是 101 个区块呢?一般我们挖的区块数量会大于 100,因为比特币网络有 100 个区块的成熟期,也就是区块奖励需要在 100 个区块之后,才可以消费。假如我们只挖了 99 个区块,虽然理论上应该得到很多区块奖励,但实际上是不能花费的。

bitcoin-cli -datadir=./ generatetoaddress 101 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw

这个命令运行输出的是每个块的区块哈希。运行结束后,我们就可以查看钱包地址的余额了,余额应该是 50:

bitcoin-cli -datadir=./ getbalance

为什么是 50?因为比特币的区块奖励每 4 年减半,第一次减半之前的块奖励,每个区块都是 50 BTC。为什么挖了 101 个块,但只能查到 50 BTC 的余额?因为后 100 个区块的成熟期,奖励是不到账的。

3. 发送交易

那么现在我们已经有了本地在运行的节点,以及有余额的钱包,接下来可以发起一笔普通的转账交易。先生成一个用于接收转账的新地址,我生成的地址是 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc

bitcoin-cli -datadir=./ getnewaddress

可以查看验证一下,新生成的地址余额为 0。这个命令中的参数 0 意味着查询结果包含未确认的交易。

bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0

接着使用发起交易的命令,来向新生成的地址转账 0.01 BTC:

bitcoin-cli -datadir=./ sendtoaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0.01

这个命令会返回交易哈希,比如我的哈希值是 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26。我们需要用这个交易哈希来查询交易结果和交易详情,像这样:

bitcoin-cli -datadir=./ gettransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26

这笔交易此时就已经提交到链上了,但是也许你会注意到,查询交易详情返回的交易状态中,有一个 "confirmations": 0,意味着交易还没有被确认,而且区块高度还停留在 lastprocessedblock: 101 上。因为比特币不会自动出块,这个时候查询接收地址的余额,能看出差异:

# 查询到余额是 0.01bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0# 查询到余额是 0bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 1

因为我们之前有说明,最后一个参数是 0 代表包含未确认的交易,否则只查询确认的交易。我们刚刚发送的交易就还没有确认。如果想确认下来,就得用之前的 generatetoaddress 命令再挖一个区块出来:

bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw

现在再去查询交易状态,无论是确认数还是钱包余额,就都符合预期了。

4. 查看交易脚本

我们刚才发送的是一笔 P2WPKH 交易,因为现在比特币客户端默认使用原生 SegWit 的地址格式。

先了解一下 P2PKH 是什么,全称是 Pay to Public-Key Hash,我们使用的比特币地址本身就是一个公钥的子集,而 P2PKH 交易以账户地址为接收参数,所以命名为 P2PKH。我们常说的比特币原生地址,就是指 P2PKH 格式,一般以 1 开头,

相比 P2PKH,原生 SegWit 的地址格式叫 P2WPKH,中间多了个字母 W,全称是 Pay to Witness Public-Key Hash,特点是会把签名数据放在 witness 字段里,而不是每一笔 UTXO 的输出里,我们可以具体看一下,首先根据交易哈希,查询得到交易的全部数据:

bitcoin-cli -datadir=./ getrawtransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26

会得到一大段编码后的数据,用这个命令来解码交易数据:

bitcoin-cli -datadir=./ decoderawtransaction 020000000001018f4e8514038b93d6cc1d4f77b011f4726ba765d338bfdf1e6724d1844bc5d36e0000000000fdffffff0240420f0000000000160014400a517208b473618b98817840328c09a77d6b123eaaf629010000001600147ef4555b42b71e6ebecd687170c92ab64cce35500247304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff10121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc196000000

查询得到的数据结构是这样:

{  // ...  "vin": [    {      // ...      "txinwitness": [        "304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff101",        "020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc1"      ],    }  ],  "vout": [    {      "value": 0.01000000,      "scriptPubKey": {        "asm": "0 400a517208b473618b98817840328c09a77d6b12",        "desc": "addr(bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc)#nry368tt",        "hex": "0014400a517208b473618b98817840328c09a77d6b12",        "address": "bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc",        "type": "witness_v0_keyhash"      }    },    {      "value": 49.98998590,      "scriptPubKey": {        // ...      }    }  ]}

首先关注 txinwitness 这个字段,它是一个数字,有两个部分,第一个部分是签名数据,第二个部分是公钥,这就是我们之前提到的 SegWit,对金额的签名不放在 vout 里,而是放在了 vin 里。

然后再关注 scriptPubKey 里的 asm,ASM 是 RedeemScript 的意思,表示满足什么样的条件就可以消费脚本中锁定的金额。是的我们即使是发起普通转账,实际上也是一种比特币脚本,金额锁定在了脚本中。我们查询到的脚本内容分为两段,第一段是 0,表示比特币脚本中的一个操作码 OP_0,第二段是 400a517208b473618b98817840328c09a77d6b12,其实就是钱包地址,经过 bech32 编码后会变成熟悉的样子 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc

5. 用 btcdeb 调试

刚才提到了 OP_0 这个操作码,它具体是什么呢?操作码是比特币脚本的关键,我们可以用 btcdeb 工具调试和观察一下。btcdeb 没有提供一键式的安装命令,可以按照 官方的教程 先下载源码,然后编译安装。验证安装结果:

btcdeb --version

OP_0 这个操作码本身干的事情很简单,就是把空数据压进栈结构里,尝试运行命令:

btcdeb OP_0

会看到这样的输出:

script  |  stack --------+--------0       | #0000 0

前面的 script 表示有一个操作 0, 也就是 OP_0,这里显示的时候自动隐去了 OP_ 前缀。后面 #0000 0 则表示目前栈里内容为 0(空)。接下来的输入 step 命令,让 btcdeb 真正运行 OP_0 这个步骤,运行结果是这样,可以看到推了一个空数据到栈里,这就是 OP_0 干的事情:

step        <> PUSH stack 

为了增加理解,我们举一个别的操作码例子来观察栈内数据的变化,尝试这个命令:

btcdeb '[OP_2 OP_3 OP_ADD]'

然后输出 step 命令,一直按回车直到脚本结束,输出内容的过程像是这样。默认内容是这样,此时脚本里有 3 个操作码等待执行,分别是 OP_2OP_3OP_ADD

script  |  stack --------+--------2       | 3       | OP_ADD  | #0000 2

第一次回车执行了脚本的第一个步骤 OP_2,对应操作把数字 2 压入栈,执行结束后脚本里剩 2 个操作码了,同时 stack 中有了数字 2:

step        <> PUSH stack 02btcdeb> script  |  stack --------+--------3       |      02OP_ADD  | #0001 3

第二次回车继续执行了 OP_3 操作码,把数字 3 压入栈,此时脚本里只剩 1 个操作码,栈中有数字 2 和数字 3:

        <> PUSH stack 03btcdeb> script  |  stack --------+--------OP_ADD  |      03        |      02#0002 OP_ADD

第三次回车执行 OP_ADD 操作码,这个操作码会从栈里弹出两个数字,计算加法后把结果推回栈内,得到结果 5:

        <> POP  stack        <> POP  stack        <> PUSH stack 05btcdeb> script  |  stack --------+--------        |      05

因为 btcdeb 的命令行输出并不是特别直观,所以这里尽管占用篇幅,也有必要把整个过程的输出都复制过来,还拆分了步骤,方便理解每一步在干什么。可以看到每一个操作码都会对应一些行为,这个行为是比特币程序里定义的,包括加法、减法等各种运算,也有一些行为更复杂的操作,或者对简单的操作码进行排列组合,达到实现更复杂功能的目的。我们还看到比特币脚本的执行是基于栈的,全部行为都发生在栈结构里,栈结构也就意味着完全没有动态内存分配之类的东西。

6. 自己编写比特币脚本 (1)

刚才尝试了在 btcdeb 调试工具里运算加法,现在试着在实际的比特币交易中,写入脚本代码,并且在链上运算。这段是原始的操作码形式的脚本,要注意这个脚本是不安全的,属于自验证的脚本,任何人都可以花费这个脚本中的金额,只是在花费过程中,脚本表示的数字运算会在链上执行:

[OP_2 OP_3 OP_ADD OP_5 OP_EQUAL]

首先需要把操作码转变为十六进制形式,这个编码过程需要手动,或者写代码来操作。我们使用手动的方式,这个 比特币文档 中列出了全部支持的操作码,以及对应的十六进制字符,到我们这个小脚本这里,对应关系就是:

操作码 十六进制
OP_2 52
OP_3 53
OP_ADD 93
OP_5 55
OP_EQUAL 87

因此我们按照依次拼接的顺序,得到了的十六进制脚本:

5253935587

接着生成 P2SH 地址。P2SH 的全称是 Pay to Script Hash,意思是支付到脚本哈希,或者说锁定金额到脚本中,相当于链上脚本的地址:

bitcoin-cli -datadir=./ decodescript 5253935587

命令输出中有一个 p2sh-segwit 字段,值是 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX,把这个 P2SH 地址用作参数生成脚本的校验和,校验和是构造比特币交易必须要的一个参数:

bitcoin-cli -datadir=./ getdescriptorinfo "addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)"

得到 descriptor 的值为 addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e,后续用这个值作为脚本参数构造交易。

不过到这里还有个坑,比特币的 P2SH 脚本,只能用观察模式的钱包导入,所以需要新创建一个没有私钥的钱包:

bitcoin-cli -datadir=./ createwallet "arith-watch" true true "" true

用刚刚创建的新钱包,导入 P2SH 脚本。看到这个命令返回 "success": true,才表示导入成功:

bitcoin-cli -datadir=./ -rpcwallet=arith-watch importdescriptors '[{"desc":"addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e","timestamp":"now","label":"arith-2+3=5"}]'

现在有了 P2SH 的脚本地址,并且已经把脚本导入到钱包,接下来可以给脚本打钱了。这个命令从 learn-script 钱包转账 0.01 BTC 给脚本:

bitcoin-cli -datadir=./ -rpcwallet=learn-script sendtoaddress 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX 0.01

挖一个区块让交易确认:

bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw

现在,这个脚本就上链并且有余额了。

7. 自己编写比特币脚本 (2)

目前这个脚本地址里的钱,任何人都可以消费,消费的同时会运算一下 2+3 这个表达式,并且判断结果是否为 5。接下来构建一笔花费脚本金额的交易,真正花掉刚才存进脚本的钱。准备一个收款地址:

bitcoin-cli -datadir=./ -rpcwallet=learn-script getnewaddress

我新建的地址是 bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r。用这个地址构建一笔交易,注意这里 inputs 中的 txid,是刚才给 P2SH 转账的那一笔交易哈希:

bitcoin-cli -datadir=./ -named createrawtransaction \  inputs='[{"txid":"b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2","vout":0}]' \  outputs='{"bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r":0.009}'

在构建的交易中添加自动找零参数:

bitcoin-cli -datadir=./ -rpcwallet=learn-script \  fundrawtransaction 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff01a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5300000000

关键的一步,用钱包给这笔交易签名,注意这里是给找零之后的交易数据进行签名,如果不找零,节点会把找零金额当作手续费,而节点默认还有手续费的上限值,如果这一步没找零,下一步会触发手续费上限报错:

bitcoin-cli -datadir=./ -rpcwallet=learn-script \  signrawtransactionwithwallet 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da00000000

最后,把签名好的交易数据广播出去就行了:

bitcoin-cli -datadir=./ sendrawtransaction 02000000000101c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000017160014c2d5ade24c1d0b9f27f651a71c3fe49d23d0ae13fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da024730440220406a51d43ade05b240fcf2d14b58c90f31ebc705ab262189949355cac54d0431022051b592c570ef960a35e8509766e903ba836e3bcd1fb3c5cc211f0ff3442283550121021ff283ca8c9ecb45c8e19eacb7e8ae6fcb27d8addd38011d633e396487db44e300000000

记得再挖一个区块让交易确认:

bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw

查看交易状态,验证交易已被花费,如果返回空值,说明已被花费。这里查的交易哈希是当时用钱包给脚本转账 0.01 BTC 那一笔交易的哈希:

bitcoin-cli -datadir=./ gettxout b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2 0

8. Troubshooting

我本地的操作环境以及软件脚本是:

OS: MacOSbitcoind: v29.0.0btcdeb:5.0.24

我从王垠的计算机科学视频班学到了什么

2025-07-08 03:08:42

为了行文方便,这篇文章不使用 “王垠老师” 这样的尊称,直接称呼名字 “王垠”。

大约 2 个月前,我开始报名学习王垠的 计算机科学视频班(基础班),经过 1 个月的学习后毕业,大概用了 120 个小时的学习时长。“120 个小时” 这个数字是经过认真估算的,包含了观看视频的时间和做练习题的时间。因为视频课的学习节奏由自己把控,毕业速度因人而异,所以 “1 个月” 这样的描述不够直观,用小时数更加容易理解。 120 个小时意味着,如果一天学 2 个小时的话,需要 60 天。

王垠并不认识我,但我早就认识王垠;我以前不是王垠的学生,但王垠早就是我的老师了。我关注王垠的博客多年,已经从他的博客文章中受益很多。而这次系统学习了视频课的课程,像是打开了新世界的大门。这并不夸张,我可以负责任地站在学习过课程的立场上说,王垠没有吹牛,他的课程真的有他说的那么好。你可以不赞同王垠的观点,也可以不喜欢王垠的人生态度,但是不可能怀疑王垠在计算机科学,尤其是编程语言领域的研究水平,也完全不需要担心王垠这样对自己和世界都如此较真的人,会拿一些没有含量的课程出来忽悠人。

现在毕业后经过半个月时间的沉淀,我想分享一下 “从课程中能学到什么” 这个话题。没有在毕业当天做总结,是因为怕有点浮燥总结不全,或者掺杂太多个人经历。半个月的时间其实也不够,我没有太多时间复习,课程也远远没来得及发挥出应有的成果,但是现在做总结并不妨碍以后对课程内容有进一步的感悟。王垠曾经有一篇文章《爱因斯坦谈教育》,里面提到爱因斯坦说 “被传授的知识应该被当成宝贵的礼物”,我在学完课程后无意间翻到这篇文章,看到这句话后,突然对这句话有了切实的理解。

因为课程内容是保密的,我不会透露课程具体的学习内容,只基于公开的招生说明中的内容进行解释。对于计算机初学者来说,从课程中能学到的最直接的知识,就是函数、链表、二叉树等基础的编程概念,内容涵盖了大学本科阶段的整个过程。而第 6 课关于解释器的部分,属于课程的 “最终成果”,对应一些美国顶尖大学本科高年级,至硕士低年级的阶段。为什么王垠在招生说明里的描述是 “大学博士阶段才可能学到的内容” 呢?因为中国的大学没有编程语言专业,本科课程没有教解释器的,即使到了研究生阶段,lambda 演算也属于选修课,博士早期才会接触到解释器的实现是正常现象。所以王垠的描述真的没有夸大。

解释器这种内容在中国的教育体系里本来就很稀有,比较高级和精练的教程更是少见。举例来说,当你学完了王垠的课,然后去 bilibili 上搜一些解释器相关的教程,你就会明白这些公开教程里的解释器有多么差劲,不但一眼就能看出它们缺什么要素、存在什么问题,还知道如何改进、如何用最简洁的代码写出最可靠的实现。为什么解释器这个东西重要呢?同样举个例子,以太坊的虚拟机(EVM)就是一个解释器,只不过 EVM 并不是在对编程语言做解释,而是在对以太坊的操作码(Opcodes)做解释,每个操作码都对应在栈结构上的一个动作。所以学过了解释器之后,对 EVM 的原理会有不一样的理解。

对于有计算机经验的学生来说,从课程上可以学到的,就不只是表面上的知识了。比如,课程只用到非常少的编程要素,就表达了第 1 课到第 6 课的全部内容,如果王垠不是对计算机理论有非常深刻的理解,不可能做到这种地步的深入简出。从学习者的角度,一方面可以思考一下为什么课程内容能如此精致,组织这些课程内容的思路是什么,这种高度抽象的思维背后,需要怎样的功底。另一方面由于课程内容自成一体,学习者完全有可能做到自己复刻整个课程内容,就像是手里的一个精致的玩物,随时可以拿出来复习把玩。

王垠最近在微博上评论 AI 编程的时候提到,AI 无法写出 “王垠级别” 的代码。什么是 “王垠级别” 的代码呢?上过课就知道了。我在做练习题的时候,被助教提醒最多次的问题,就是 “代码复杂”,有时候是写法上的复杂,有时候是复杂度上的复杂,但是每一次把代码写到符合课程标准之后,又不禁感叹原来代码可以如此精巧。我已经有多年的编程经验,让代码运行出练习题的结果并不难,但是把代码写的足够漂亮却不容易。假如以前给公司写的都是这种质量的代码,那公司可就太占便宜了。

还不止这些,也许有人看到招生说明会怀疑,一节选修课真能让人学会一种新的编程语言吗?我想提醒的是,不要忘了给你讲课的人是谁,是真正的编程语言专家。

区块链技术面试题(2025年版)

2025-07-06 18:38:03

比起 2023 年版本相对宏观视角的《区块链技术面试题》,这个版本稍微侧重工程实践一点,包含了更多技术细节。这两个版本的内容是互相补充的,不是升级性质的关系。这些题目仅仅只是基于我的个人经历,就像很多面试官在做的那样,自己会什么才问什么,问不出自己不会的东西,所以问出来的问题,无论广度和深度,都是受限于个人水平的,我也是:

  1. 以太坊客户端为什么分为执行层和共识层?
  2. 以太坊的 PoS 运作流程,如何初始化一个 PoS 网络?
  3. 以太坊 PoS 的软分叉和恢复机制?Cardano 的 PoS 和以太坊一样吗?
  4. 以太坊节点有哪些类型,分别适用于什么场景?
  5. EVM 的执行为什么是单线程的?为什么至今全世界的团队都做不出来 “并行EVM” 这种东西?
  6. Solidity 语言有 GC 吗?是如何处理内存动态分配问题的?
  7. Solidity 什么场景下需要内联汇编?
  8. PBFT 共识有了解吗,大体流程是怎么样的?
  9. PBFT 的容错能力公式是怎么来的,为什么是那个数字,而不是其他数字?
  10. PBFT 为什么需要第二次投票?
  11. Solana 的共识机制大体是怎样的?TowerBFT 是在对区块投票吗?
  12. 为什么 Solana 的智能合约可以并行执行,以太坊的不可以?
  13. Cosmos 节点的升级流程是怎样的?和以太坊有什么不同?这种模式有什么风险?
  14. Op Rollup 的大体流程?ZK Rollup 在 Op 模式的基础上,优化了哪个环节?
  15. 以太坊 L2 的资产跨链?与不同网络之间的资产跨链相比,技术上有什么异同?
  16. 以太坊最近有个大版本升级,引入的 EIP-7702 是干什么的?和 AA 钱包是什么关系?
  17. 自己平时思考过哪些区块链相关的、有意思的技术类话题?

这些是我现在能想到的全部问题了。比这些问题更加有深度的工程化的内容,我也只是大概知道点方向,没亲手搞过。这两年的经历还算丰富,对比两个版本的面试题列表能看出不少变化。希望我自己可以再接再厉,不要迷路。

如果你是区块链行业的求职者,尤其是经验尚浅的工程师,千万不要被上面列出来的问题给吓到了。真实的面试过程中,几乎不会出现如此有深度的思考题。更多的问题类似于,“以太坊交易有哪些常用字段?”、“怎么取消一笔已经发送的交易?”、“Solidity 的可重入攻击是什么?”、“Op Stack 有哪几个组件?”、“以太坊合约的 create2 是什么?” 等等。放心大胆的去求职,真正懂技术的人没有那么多。

现在的区块链行业有个问题,就是没有系统化的理论知识,只有一些工业界前沿的、散碎的工程化尝试。比如对比编程语言专业,从丘奇和图灵的计算模型,到函数式编程语言、编译器、类型系统等,经过几十年学术界和工业界的发展,有高度抽象的理论支撑,有实际落地的工业应用,已经比较成熟。而区块链这种东西比较新,2008 年诞生,2013 年开始步入大众视野,短短几年的时间远没有建立起学术体系,行业内的项目方则各自为营,都在搞自己的标准、各自定义术语,账户模型、共识、合约、跨链,每条链都不一样。有人能统一区块链的理论体系吗?Vitalik 来都不行,要是 Satoshi 出山也许有希望。

因此不需要相信什么大学里的 “区块链专业”,没有出过校门的老师和教授,怎么可能有时间把区块链的技术抽象成理论、写成教材、编成课程,然后给学生讲课呢,这个周期得多长?也因此不要太相信已经出版的技术类书籍,书籍的出版需要几年时间,等书发表出来,世界已经变了。今年下半年有个比特币会议,两年前发明了铭文这个概念的项目方,可能又要发布新东西了,难道学校的课程或者书籍能跟得上这种节奏吗?行业最前沿的技术,只能来自各个项目方切实的探索和尝试,也自然就会造成不成体系的现象。

Rust 语言容易让新手困惑的一个“过度优化”

2025-06-30 02:05:25

假如我们现在要写一些代码,随便用 cargo new 一个项目就行,然后写一个函数 append,函数的功能很好理解,就是把两个传入的字符串给拼接起来,第一个参数是字符串(的引用类型),第二个参数也是字符串,假如我们的参数是 Hello, world,函数调用后会返回 Hello, world 给我们。函数具体这样写:

fn append(s1: &String, s2: &String) -> String {    return s1.clone() + s2.clone().as_str();}

不需要关心 return 后面的语句写法,这不是我们关注的重点。在入口函数 main 里调用这个 append,运行一下,输出的内容会和我们预期一样,打印出拼接后的字符串 Hello, world

fn main() {    let s1: String = String::from("Hello");    let s2: String = String::from(", world");    println!("{}", append(&s1, &s2));}

那么现在,保持 append 函数完全不变,在 main 函数里修改两个字符串的定义,整个 main 函数变成这样,猜一下输出结果会是什么?注意 Rust 是静态类型的语言,编译器对于变量类型往往具有严格的定义和判断:

fn main() {    let s1: Box<String> = Box::new(String::from("Hello"));    let s2: Box<String> = Box::new(String::from(", world"));    println!("{}", append(&s1, &s2));}

我们首先的直觉是应该编译报错,因为 s1 的类型是 Box<String>,调用 append 函数的时候,传入的参数为 &s1,对应的类型为 &Box<String>,而显然 append 函数的定义是没有修改的,接收的参数类型仍然是 &String。那么这种情况下,为什么编译器没有报错,而且代码还能正常运行,输出了 Hello, world 的结果?(先别管这里的 Box 是什么,反正是一种类型)

我们接着再修改一下 main 函数的内容,把字符串的定义改为这样:

fn main() {    use std::rc::Rc;    let s1: Rc<String> = Rc::new(String::from("Hello"));    let s2: Rc<String> = Rc::new(String::from(", world"));    println!("{}", append(&s1, &s2));}

代码能通过编译吗?能正常运行吗?append 函数的定义仍然没有变,这里 main 函数中 s1 的类型变成了 Rc<String>,相应的传入 append 函数做参数的时候,类型变为了 &Rc<String>。但是为什么,编译器没有报错,而且还能正常运行出结果,输出 Hello, world?(同样别管 Rc 是什么,也是一种类型)

根据刚才的代码片段,我们观察到一个现象:当函数的参数类型是 &String 的时候,既可以接受 &String 类型的参数,也可以接收 &Box<String> 类型的参数,还可以接收 &Rc<String> 类型的参数。

再疯狂一点,如果把 main 函数改成这样呢?

fn main() {    let s1: Box<Box<Box<Box<String>>>> = Box::new(Box::new(Box::new(Box::new(String::from("Hello")))));    let s2: Box<Box<Box<Box<String>>>> = Box::new(Box::new(Box::new(Box::new(String::from(", world")))));    println!("{}", append(&s1, &s2));}

如果把 main 函数改成这样呢?

fn main() {    use std::rc::Rc;        let s1: Rc<Rc<Rc<Rc<String>>>> = Rc::new(Rc::new(Rc::new(Rc::new(String::from("hello")))));    let s2: Rc<Rc<Rc<Rc<String>>>> = Rc::new(Rc::new(Rc::new(Rc::new(String::from(", world")))));    println!("{}", append(&s1, &s2));}

结果是 main 函数都可以正常运行,输出 Hello, world 的结果。

为了进一步观察关于类型的问题,现在新写两个 append 函数,append2 函数接收的类型是 &Box<String>,而 append3 函数接收的类型是 &Rc<String>

fn append2(s1: &Box<String>, s2: &Box<String>) -> Box<String> {    let mut result = (**s1).clone();    result.push_str(s2);    Box::new(result)}use std::rc::Rc;fn append3(s1: &Rc<String>, s2: &Rc<String>) -> Rc<String> {    let mut result = (**s1).clone();    result.push_str(s2);    Rc::new(result)}

接下来分析一下,对于下面的 main 函数代码,编译器会在哪一行报错?

fn main() {    let s1: Box<Box<Rc<Rc<String>>>> = Box::new(Box::new(Rc::new(Rc::new(String::from("hello")))));    let s2: Box<Box<Rc<Rc<String>>>> = Box::new(Box::new(Rc::new(Rc::new(String::from(", world")))));    println!("{}", append(&s1, &s2));    println!("{}", append2(&s1, &s2));    println!("{}", append3(&s1, &s2));}

这样呢,字符串的类型再扩展一下,编译器还会报错吗,在哪一行?

fn main() {    let s1: Box<Box<Rc<Rc<Box<Box<String>>>>>> = Box::new(Box::new(Rc::new(Rc::new(Box::new(Box::new(String::from("hello")))))));    let s2: Box<Box<Rc<Rc<Box<Box<String>>>>>> = Box::new(Box::new(Rc::new(Rc::new(Box::new(Box::new(String::from(", world")))))));    println!("{}", append(&s1, &s2));    println!("{}", append2(&s1, &s2));    println!("{}", append3(&s1, &s2));}

Rust 把这种语言特性叫做人体工学设计,为了减轻开发人员的负担。但是 Rust 在设计动不动会把变量给 move 掉、不得不使用 ' 单引号写法的时候,却放弃了人体工学,把内存安全放在了更重要的地位……倒是也没什么错,毕竟 Rust 只有内存安全是绝不能放松的。

最后再来个进阶难度的,假如在实际的业务场景中,有一个叫 do_something 的函数,接收泛型类型的参数,我们需要对这个函数基于原有逻辑做一些改动,原本的函数逻辑是这样:

fn do_something<T1, T2>(t1: T1, t2: T2) {    println!("{}", append(&t1, &t2));}

现在新增加一些处理:

fn do_something<T1, T2>(t1: T1, t2: T2) {    // 增加一个函数来处理 t1    handle_t1(&t1);      println!("{}", append(&t1, &t2));}

那么问题来了,参数 t1 的类型是什么?handle_t1 函数的参数类型应该如何定义?在原有逻辑中,t1 作为参数对 append 函数进行了调用,是否意味着 t1 的类型是 &String?如果不是 &Stringt1 的类型可能是什么?

Solana 智能合约开发教程 (3)

2025-06-28 00:01:01

这个一个零基础的系列教程,可以从最基本的操作开始学会 Solana 智能合约的开发。

  • 第一篇》:基础环境安装、HelloWorld 合约部署、链上合约调用
  • 第二篇》:实现 USDT 合约的最小模型,自定义数据结构与方法
  • 第三篇》:使用官方 SPL 库复用合约功能,完成标准化代币的发行

你也许注意到,在编写智能合约的过程中,对于程序逻辑的描述反而是轻量的,比较复杂的部分是不同类型的 #[account] 宏,以及去了解宏接受的参数,比如是否允许自动创建账户、如果创建应该租用多少个字节的空间等,因为 Solana 的全部账户数据需要加载到节点服务器的内存中,价格比较昂贵,所以要求开发者对于空间的占用计算比较精细。而 Solana 的账户体系又有点复杂,需要稍微理解一下。

1. 命令行工具发行代币

对于发行 USDT 这种经典场景,Solana 已经封装好了智能合约的库函数,可以直接调用,甚至封装好了命令行工具,只需要简单的操作,不需要写合约,就可以发行代币。Solana 把这些代币统称为 SPL Token。创建一个 6 位精度的 SPL Token 的命令是这样,注意不需要写代币名字:

spl-token create-token --decimals 6

命令行运行结束后,会输出一个 Address,这个就是 SPL Token 的代币地址,比如我得到的地址是 E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV,可以在 区块链浏览器 上查到。

接下来需要一个操作,来给你本地的账户,在这个 USDT 代币上创建一个关联账户(Associated Token Account,ATA)。这个创建关联账户的动作,相当于在合约上实例化一个数据结构,这个数据结构里保存了你的 USDT 余额等信息,如果没有这个数据,USDT 代币的合约上就找不到你。

用 “账户” 这个词可能有点迷惑,我本地已经有账户了,还能用 solana address 命令看到账户地址,为什么还需要专门调用 USDT 的合约,创建什么 ATA 账户?可以理解为,合约里本来有个空的 map{},创建 ATA 账户就是向 map 里插入了一条数据,key 是你本地的账户地址,value 是 USDT 的余额信息。如果 map 里没有你的信息,你甚至不能接受 USDT 的转账。

那么为什么 Solana 要这么设计,必须先在 map 里开辟空间,才能接受转账呢?因为一开始有提到过,对于 Solana 来说,链上空间是比较珍贵的,map 里开辟一个键值对的空间,也就是创建 ATA 账户,需要占用 165 个字节的内存,这 165 字节不是免费使用的,可以使用命令 solana rent 165 来计算字节数对应的费用,比如这里就会输出 0.00203928 SOL,也就是你创建 ATA 账户的交易,在手续费之外,会多支付这么些租金。所以必须要有创建 ATA 账户这个操作,主要是为了收费。

回到我们的操作,创建 ATA 账户的命令是:

spl-token create-account E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV

这个命令会显示 Creating account,后面是你的 ATA 地址,比如我的是 E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo,同样的,可以在 区块链浏览器 中看得到。

对要注意,ATA 账户是有单独的地址的,比如你本地的账户地址是 a,在 USDT 代币上创建的 ADA 账户地址将是 b,是不一样的。而后续接受 USDT、发送 USDT,将全部通过 ATA 账户来进行,而不是你本地的那个账户。SPL Token 提供了命令来查看本地钱包账户和 ATA 账户的关系:

spl-token address --verbose --token E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV// 输出是这个样子Wallet address: 75sFifxBt7zw1YrDfCdPjDCGDyKEqLWrBarPCLg6PHwbAssociated token address: E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo

那么现在,可以用这个命令,来查询 USDT 的余额,balance 后面的参数是指代币地址,而不是 ATA 地址:

spl-token balance E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV 

当然默认是 0,现在给这个地址挖一些 USDT 上去。这个命令有点长,有 3 个参数,第一个参数是代币地址,第二个参数是代币数量,第三个参数是 ATA 地址,意味着要挖哪个代币、挖多少、挖给谁:

spl-token mint E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV 5 E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo

命令执行成功后,就可以查询到余额,也能直接在浏览器上看到余额了,类似的,转账 USDT 的命令是:

spl-token transfer <MINT> 1 <ATA>

Solana 为了避免用户不记得自己的 ATA 账户地址,也提供了人性化的命令,最后一个参数可以直接用本地的钱包地址,而不需要 ATA 地址,这也就是为什么我们平时使用 Solana 的钱包,并没有感觉到 ATA 账户这种东西存在的原因:

spl-token transfer <MINT> 1 <RECIPIENT_WALLET>

2. 用 spl 标准库写智能合约

我们尝试一下在智能合约里调用 spl 库函数,这种官方提供的、系统级别的库函数是经过严格安全审计的,比我们自己写要安全,所以有了这些库函数,我们可以更加关注自己定制化的业务逻辑,不需要关心太底层的东西,比如 USDT 余额计算是否精度有损失之类的问题。先创建一个新项目:

anchor init usdt_spl

导入 anchor-spl 依赖,这个命令可以把最新版本的库函数导入进来,命令运行后,可以在 programs/usdt_spl/Cargo.toml 文件的 [dependencies] 部分,新增了这样一行 anchor-spl = "0.31.1",说明是成功的:

cargo add anchor-spl

开始写合约代码程序。先在最开始两行导入 spl 的依赖。我们之前有使用过 Anchor 框架自带的账户类型如 AccountSigner,那么这里 spl 也是提供了多种数据类型,比如 TokenAccount 就表示 ATA 账户的数据结构:

use anchor_spl::token::{self, MintTo, Token, TokenAccount, Mint};

接着定义 mint 行为相关的账户规则:

#[derive(Accounts)]pub struct MintToCtx<'info> {    #[account(mut)]    pub mint: Account<'info, Mint>,     #[account(mut)]    pub to:   Account<'info, TokenAccount>,    #[account(mut)]    pub authority: Signer<'info>,    pub token_program: Program<'info, Token>,}

这几行代码中,mut 关键词我们之前用到过,表明账户数据要允许被写入。Account 类型是 anchor 框架自带的,我们也使用过。Mint 类型则是新出现的,是从 spl 框架里导入的,我们之前不是自己定义过一个用 #[Account] 宏标注的 Mint 结构体,然后在 #[derive(Accounts)] 里使用吗。现在有了 spl 库,我们不需要自己定义 Mint 结构体的类型、参数个数,直接使用就好。

同样的,TokenAccountToken 也都是 spl 框架提供的类型。这么看似乎使用 spl 框架比自己写简单了不少?不能高兴的太早,还有一段代码没有写上:

impl<'info> From<&MintToCtx<'info>> for CpiContext<'_, '_, '_, 'info, MintTo<'info>>{    fn from(accts: &MintToCtx<'info>) -> Self {        let cpi_accounts = MintTo {            mint:      accts.mint.to_account_info(),            to:        accts.to.to_account_info(),            authority: accts.authority.to_account_info(),        };        CpiContext::new(accts.token_program.to_account_info(), cpi_accounts)    }}

这段代码乍一看眼花缭乱,可能要晕了,为什么那么多尖括号,为什么那么多单引号和下划线。这就是 Rust,为了迎合独特的内存管理设计,不得不让语言在语法形式上变得复杂。

impl ... From<...> for ... 是 Rust 的语法规则,大意是让一种类型变为另一种类型,我们这里就是让 From<&MintToCtx<'info>> 类型变为 CpiContext<'_, '_, '_, 'info, MintTo<'info>>。其中 MintToCtx 是我们上面自己用 #[derive(Accounts)] 宏定义的类型,然后作为泛型参数传递给了 From,而这个 From,是 Rust 标准库提供的一个包装类型,用来接受我们传入的参数。

至于后面的 CpiContext 部分,Cpi 的全称是跨程序调用 Cross-Program Invocation,用于把要调用的外部程序,以及账户类型,都打包到一个统一的数据结构中。前三个参数不用管,最后的 MintTo 是我们真正传入的类型,这个类型是 spl 库提供的。

那么也许这里有疑问,为什么还涉及到调用外部程序?CpiContext 又是如何知道要调用哪个外部程序的?这个和 Solana 智能合约的设计有关,SPL Token 不止是一些类型定义,而且是实际已经部署在 Solana 网络上的程序。我们在使用 spl 依赖库的过程,实际上就是去调用那些已经预先在 Solana 网络上部署的 spl 合约。智能合约在运行的时候,发现你要调用 spl,就去找 spl 的合约地址,执行一些操作,然后返回结果。相当于整个网络上的智能合约都在复用同一套 spl 合约。

所以要留意 Solana 智能合约依赖库的实现方式,和其他网络是有不同的。Solana 在设计上让程序和数据分离,以致于可以实现程序共享的模式。为什么我们不自己部署一套 spl 合约,或者每个人都各自部署一套 spl 合约,然后自己使用呢?一方面是需要付出额外的手续费成本,另一方面是 Solana 的智能合约本来就允许程序共享,你要是自己部署一套,用户都不知道你有没有偷偷修改标准库的代码,反而不安全了。

还有最后一部分 #[program] 里的程序逻辑要补齐:

pub fn mint_to(ctx: Context<MintToCtx>, amount: u64) -> Result<()> {    token::mint_to((&*ctx.accounts).into(), amount)}

3. 编译合约

现在代码没问题,但是如果现在编译合约项目,会遇到报错。需要修改下 programs/usdt_spl/Cargo.toml 文件,把这两行的特性打开:

[features]idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"][dependencies]anchor-spl  = { version = "0.31.1", features = ["token", "idl-build"] }

因为静态编译的时候,命令行默认没有把 spl 标准库给带上,在配置文件里指明就可以了。现在项目可以编译成功:

anchor build

4. 写单元测试

安装 spl 相关的 nodejs 依赖,注意单元测试用的是 ts 语言,不是 Rust 语言:

npm i @coral-xyz/anchor@^0.31 @solana/spl-token chai

把单元测试代码复制到 tests/usdt_spl.ts 文件中:

import anchor from "@coral-xyz/anchor";import { Program } from "@coral-xyz/anchor";import {  createMint,  createAssociatedTokenAccount,  getAccount,  TOKEN_PROGRAM_ID,} from "@solana/spl-token";import { assert } from "chai";const { AnchorProvider, BN } = anchor;describe("usdt_spl / mint_to", () => {  const provider = AnchorProvider.env();  anchor.setProvider(provider);  const program = anchor.workspace.UsdtSpl as Program;  let mintPubkey: anchor.web3.PublicKey;  let ata: anchor.web3.PublicKey;  it("creates mint, mints 1 USDT into ATA", async () => {    mintPubkey = await createMint(      provider.connection,      provider.wallet.payer,          // fee-payer      provider.wallet.publicKey,      // mint authority      null,                           // freeze authority      6                               // decimals    );    ata = await createAssociatedTokenAccount(      provider.connection,      provider.wallet.payer,          // fee-payer      mintPubkey,      provider.wallet.publicKey       // owner    );    await program.methods      .mintTo(new BN(1_000_000))      // 1 USDT      .accounts({        mint: mintPubkey,        to: ata,        authority: provider.wallet.publicKey,        tokenProgram: TOKEN_PROGRAM_ID,      })      .rpc();    const accInfo = await getAccount(provider.connection, ata);    assert.equal(accInfo.amount.toString(), "1000000");  });});

运行单元测试,会看到成功的输出:

anchor test

5. 部署合约到 devnet

确保账户里余额足够,然后用 anchor 来部署合约:

anchor deploy --provider.cluster devnet 

这个命令偶尔会因为网络问题执行失败,抛出 Operation timed out 错误。可以直接把 provider 的参数改为自己的 rpc 地址,如果网址比较长,可以用双引号括一下:

anchor deploy --provider.cluster "<your-rpc-url>"

因为网络问题带来的麻烦有可能还不止如此,比如本地存在写入了一部分但是为完成的 buffer、链上存在 buffer 但是本地不存在导致状态不一致等问题,为了直接跳过那些问题,可以直接这种这样的命令:

solana program deploy \  target/deploy/usdt_spl.so \  --program-id target/deploy/usdt_spl-keypair.json \  --url "<your-rpc-url>"

这个命令更加好用。如果没有带 --program-id 参数,这个命令会自动新生成 keypair,也就意味着会把合约部署的新的地址,这个根据自己的需求来选择。部署成功后,就可以去 区块链浏览器 上查看了。

6. 使用 SDK 调用链上合约

我们之前使用过 SDK,现在再来使用和复习一下,编辑 app/app.js 文件,把代码复制进去:

// scripts/mint_to.js   (CommonJS)const anchor = require("@coral-xyz/anchor");const {  createMint,  createAssociatedTokenAccount,  getAccount,  TOKEN_PROGRAM_ID,} = require("@solana/spl-token");const fs   = require("fs");const os   = require("os");const path = require("path");const { Keypair, Connection, PublicKey } = anchor.web3;const RPC_URL = process.env.RPC_URL || "https://api.devnet.solana.com";const connection = new Connection(RPC_URL, { commitment: "confirmed" });const secret = Uint8Array.from(  JSON.parse(fs.readFileSync(path.join(os.homedir(), ".config/solana/id.json"))));const wallet = new anchor.Wallet(Keypair.fromSecretKey(secret));const provider = new anchor.AnchorProvider(connection, wallet, {  preflightCommitment: 'confirmed',});anchor.setProvider(provider);const idl  = JSON.parse(fs.readFileSync(path.resolve("target/idl/usdt_spl.json")));const prog = new anchor.Program(idl, provider);(async () => {  const mint = await createMint(connection, wallet.payer, wallet.publicKey, null, 6);  const ata  = await createAssociatedTokenAccount(connection, wallet.payer, mint, wallet.publicKey);  const sig = await prog.methods    .mintTo(new anchor.BN(1_000_000))    .accounts({ mint, to: ata, authority: wallet.publicKey, tokenProgram: TOKEN_PROGRAM_ID })    .rpc();  console.log("tx:", sig);  console.log(`explorer: https://explorer.solana.com/tx/${sig}?cluster=devnet`);  const bal = await getAccount(connection, ata);  console.log("balance:", bal.amount.toString());})();

如果一切顺利,可以看到这样的运行结果:

~/work/github/sol_contract/usdt_spl main ❯ node app/app.jstx: 3MgHxsfnJp68mrrABvCh9iwNm6MSXp1SEvk7vDYHoW7KhTEHfVNyMWsbfbEAXTC9gLzcmWu5xbkzia8hgZrcZ18iexplorer: https://explorer.solana.com/tx/3MgHxsfnJp68mrrABvCh9iwNm6MSXp1SEvk7vDYHoW7KhTEHfVNyMWsbfbEAXTC9gLzcmWu5xbkzia8hgZrcZ18i?cluster=devnetbalance: 1000000