BSV 上也能写出"有状态"的合约?很多人第一反应是:UTXO 模型不是天然无状态的吗?每笔交易都是"过去的既定事实",合约像一张张写了规则的小纸条,怎么谈得上状态?这个想法没错,但只说对了一半。
UTXO 本身确实是不可变的、无状态的,但合约可以用一种很巧妙的方式把状态"焊"在链上:让合约的输出重新指向它自己,把当前状态序列化进锁定脚本,下一次花费时再解锁并生成新的状态。这就是 BSV 上实现有状态合约的核心思路,而 sCrypt 恰好把这套流程封装得非常优雅。
这篇东西我会从原理讲到实战,手把手带你写一个真正能在 BSV 上跑起来的有状态合约示例,然后说说我踩过的坑。内容会比较长,但每一步都有它的道理,看完你就能自己动手去改、去扩展了。
1. 为什么说 BSV 的 UTXO 天然"无状态",有状态又是怎么绕出来的
要理解有状态合约,得先想清楚无状态到底是什么。比特币系的 UTXO 模型里,账本不是"账户余额表",而是一堆未花费输出的集合。每个 UTXO 都由一把锁定脚本看管着,只有满足脚本条件的交易才能花费它。合约写进链上之后就不会再变,这既是优点也是限制。
1.1 无状态合约的"一次性"困境
最常见的无状态合约就是:你锁一笔资金,设置一个条件,等某个人满足条件后拿走。典型例子是简易的托管或时间锁——script 里写死"谁能花、什么时候能花",花完了,合约的生命就结束了。
这带来一个很尴尬的问题:如果你想做需要"记住过去"的东西,比如一个计数器、一个逐步解锁的金库,或者一个需要按批次执行的代付协议,无状态合约做不了。每次执行都是"从零开始",没有地方存"上一轮执行到了哪"。
有人会想到把数据存在链下、再用签名授权,但那已经不算是"合约状态"了,而且链上没有任何约束力,信任模型完全变了。
1.2 有状态的本质:把状态塞回锁定脚本
sCrypt 解决这个问题的思路极其直白:让合约锁定的 UTXO 在花费之后,再生成一个新的 UTXO,新 UTXO 的锁定脚本里包含更新后的状态。这样每一轮合约执行都从旧 UTXO 中读取状态,再把新状态写入新 UTXO,状态就顺着 UTXO 的流转一代一代传下去了。
关键点在于:你没法修改链上已有的 UTXO,但你可以在交易输出里创建一个新的锁定脚本,而这个脚本恰恰还是同一份合约代码,只是状态参数变了。所谓的"有状态",其实是一种协议层面的递归——合约代码不变,状态字段在每轮输出中升级。
听起来有点像函数式编程里的不可变数据结构:你永远不修改旧对象,而是创建带着新值的新对象。合约也一样,它不"变更"自己,它"繁衍"自己。
1.3 sCrypt 在这个机制里扮演什么角色
sCrypt 本身是一门高级语言,编译成 BSV 的 Script 字节码。它最大的贡献是让你不用直接面对 Script 那套栈式低级指令,而是用类似 TypeScript 的语法写合约逻辑,同时提供了状态属性的序列化、反序列化、状态轮转的辅助工具。
我举个例子帮你建立直觉。如果你要裸写 Script 实现一个计数器,你需要手动处理数据入栈、OP_ADD、再次入栈、哈希、检查签名等一系列繁琐的字节操作,稍不留神脚本就会超长或逻辑出错。而用 sCrypt,你只需要在合约里声明:
@state() counter: bigint剩下的序列化和恢复,框架帮你搞定。这就是我推荐用 sCrypt 而不是裸写 Script 的原因——不是不能裸写,而是没必要。
2. 环境准备与工具链:先把脚手架搭稳
开始写合约之前,先把工具链捋清楚。sCrypt 的官方工具链这几年迭代很快,我今天用的这套组合是实测过比较稳的。
2.1 你需要装的东西
我的环境是 Node.js 16+ 和 npm 8+,用 sCrypt 官方的 CLI 工具初始化项目,再用 scryptlib 库来做合约编译和链下调用。如果你打算跑本地调试,还需要一个本地节点环境,我用的是 sCrypt 的测试沙箱,它能在不消耗真实测试币的情况下模拟合约调用。
安装步骤简要如下:
npm install -g scrypt-cli scrypt new counterByDemo cd counterByDemo npm install这个模板会帮你把目录结构、编译配置、测试框架都搭好。有一点要提醒:sCrypt 合约文件的后缀是 .scrypt,不是 .ts,虽然语法接近 TypeScript,但后缀必须用对,否则 CLI 不认。
2.2 项目目录结构怎么理解
初始化之后你会看到几个关键目录:
contracts/:放 .scrypt 合约源码,主要工作区tests/:放 TypeScript 写的测试用例,用于模拟合约调用artifacts/:编译产物,包括合约的 ABI 描述文件和字节码,部署时要用deployments/:存放部署脚本的输出,我习惯自己建一个
这种结构比较接近传统开发者的习惯——源码、测试、产物分开。不要小看这个布局,BSV 合约调试本身就比普通程序繁琐,目录清楚能省不少事。
2.3 第一次编译和跑通测试
模板自带一个示例合约,你什么都不改,先跑一遍:
npm run build npm test如果 build 和 test 都通过,恭喜,工具链 OK 了。我第一次搭的时候在 build 阶段卡了很久,后来发现是 Node 版本太老,scryptlib 的某些依赖已经不支持 14 以下的版本。建议先确认 node -v 至少是 16,不然你会在各种奇怪的依赖报错里浪费一下午。
3. 写一个可递增计数器:有状态合约的最小实现
为了把机制讲透,我用一个经典例子——链上计数器。它功能上没什么用,但麻雀虽小五脏俱全,把有状态合约的所有关键环节都跑了一遍。
3.1 合约代码逐行拆解
直接上代码。这是一个最简单的 Counter 合约,每次调用递增计数,同时锁定一笔 BSV(satoshis 数额可以自己定),只有知道正确私钥的人才能推进下一轮。
import "scryptlib"; contract Counter { @state() counter: bigint; constructor(counter: bigint) { this.counter = counter; } public function increment(newCounter: bigint, pubKey: PubKey, sig: Sig) { // 验证新状态比旧状态大 1 require(newCounter == this.counter + 1n); // 验证花费者确实拥有对应的私钥 require(this.checkSig(sig, pubKey)); // 关键:把新状态传给下一轮合约 this.counter = newCounter; // 生成新的状态输出 StateOutput stateOutput = this.getStateScript(); // 构造交易输出,把币转给"下一轮自己" bytes outputScript = stateOutput.script; // 部署方可以通过 change 输出指定钱去哪 require(this.amount() >= 1); } }这段代码虽然短,但信息量很大,我拆开讲。
3.2 @state 注解和状态序列化机制
@state()修饰的成员变量counter会被 sCrypt 编译进锁定脚本。具体做法是:合约实例会把自己的状态属性序列化成一段字节,拼在合约代码后面,形成一个完整的锁定脚本。
为什么要拼在后面?因为这段字节是需要被"解锁条件"检查的。下一轮调用合约时,this.counter的值就是从解锁脚本(即旧 UTXO 的锁定脚本)里反序列化出来的。也就是说,状态不是存在内存里,而是存在链上,由旧输出带着走。
我自己早期有个误解:以为this.counter = newCounter是"改了一个对象的属性"。实际上它做的是:构造一个新的合约实例,新实例的 counter 是新值,然后生成新的锁定脚本。旧合约对象还在链上原地不动,直到它被花费才消亡。
3.3 getStateScript 的意义
this.getStateScript()是 sCrypt 提供的关键 API。它会读取当前合约实例的所有@state变量,把它们序列化,然后连同合约模板代码一起生成一个新的锁定脚本。
你可以理解为:getStateScript 是"合约的自我复制函数"。它复制了合约的代码逻辑,但用新状态替换旧状态。没有这个方法,你要手动把状态拼进脚本,极其容易出错——顺序、长度前缀、字节对齐,每一项都能让你怀疑人生。
3.4 为什么需要签名和 checkSig
你可能会问:既然新状态已经写在输出里了,合约里还要checkSig干嘛?这其实是在做授权校验。任何状态的推进都必须由拥有某把私钥的人发起,否则任何人都能调用合约、把状态改成他想改的值。
比如有人恶意调用,把 counter 从 5 改成 9999,表面上看合约逻辑没损坏,但整个协议就乱套了。签名校验保证了只有合法的操作者才能推进状态变迁。这在真实业务里很重要——状态合约不只是"数据在链上",更是"数据变迁有门槛"。
4. 部署与状态流转:从合约编译到三轮更新的完整链路
代码写完只是第一步,真正让它"活"起来需要部署和连续调用。这部分我按实际操作顺序讲,你可以直接照着敲。
4.1 编译与 ABI 理解
跑完npm run build之后,在artifacts/目录下会生成合约的.json描述文件。这个文件里最重要的是 ABI(Application Binary Interface),它定义了合约的构造函数参数、public 函数的参数类型和结构。
你可以用cat打开看,会看到一个类似这样的结构:
{ "name": "Counter", "params": [ { "name": "counter", "type": "bigint" } ], "publicFunctions": [ { "name": "increment", "params": [ { "name": "newCounter", "type": "bigint" }, { "name": "pubKey", "type": "PubKey" }, { "name": "sig", "type": "Sig" } ] } ] }这个 ABI 决定了你在链下 JavaScript/TypeScript 代码里如何实例化合约、如何构造调用。不熟悉的读者注意:这里的PubKey和Sig是 sCrypt 封装好的类型,链下传数据时需要转成对应的十六进制字符串或签名对象。
4.2 部署第一轮:用一个交易把合约发上链
用 scryptlib 的 API 在 TypeScript 里创建合约实例:
import { Scrypt, bsv } from "scrypt-ts"; const privateKey = bsv.PrivateKey.fromRandom("testnet"); const pubKey = privateKey.publicKey; const counter = new Counter(0n); // 初始状态为 0 // 测试环境部署,会自动处理 UTXO 选择 await counter.connect({ network: bsv.Networks.testnet }); await counter.deploy(1000); // 锁定 1000 satoshis这一笔交易会把包含 "counter = 0" 的锁定脚本写到链上。此时这个 UTXO 就像一个"尚未激活的状态机",等待有人用increment去推进。
4.3 调用第二轮:解锁旧状态,写入新状态
部署完之后,新的 UTXO 在链上,我们需要发起一笔新的交易来花费它。这笔交易的输入是旧 UTXO,输出是新的合约 UTXO(状态变为 1)。
const nextCounter = counter.counter + 1n; await counter.methods.increment( nextCounter, pubKey.toHex(), signTx(privateKey, counter) );这里signTx是一个自定义辅助函数,它会对当前交易上下文做签名。签名必须覆盖交易的核心字段,否则 BSV 的checkSig校验会失败。我一般用一个工具函数处理:
function signTx(privateKey: bsv.PrivateKey, tx: bsv.Transaction): bsv.Signature { return bsv.Transaction.Signing.sign( tx, privateKey, bsv.crypto.Signature.SIGHASH_ALL | bsv.crypto.Signature.SIGHASH_FORKID ); }注意SIGHASH_FORKID——这是 BSV 特有的标志位,忘了加它会导致所有签名验证都失败。我见过不少新手在这里栽跟头,排序和哈希方式都不对,最后查半天发现是忘了这个 flag。
4.4 状态流转的交易结构解剖
有一件事很多人写博客都不讲,但实战中非常重要——有状态合约每一轮调用,交易结构必须满足严格的形状要求。
从输入输出层面看,一轮完整的流转包含:
- 输入:旧合约 UTXO(锁定脚本携带旧状态)
- 输出 1:新合约 UTXO(锁定脚本携带新状态)
- 输出 2:找零输出(如果合约金额有多余,会回到你的地址)
sCrypt 在编译时会对合约脚本做约束检查,如果你的交易输出结构不满足合约构造时的预期(比如你把新合约输出放在第二个位置而不是第一个),脚本执行就会失败。
最直观的表现是:你觉得代码逻辑完全没问题,但链下执行报Script execution failed。我花了很长时间才意识到是输出排序问题。解决方案是在构造交易时明确指定输出顺序,别依赖默认排序。scryptlib 的methods调用会自动处理大部分情况,但如果你手动拼装交易,务必仔细检查。
5. 资源消耗与成本优化:状态合约不是想怎么写就怎么写
BSV 上合约执行是按字节收费的,状态越长、脚本越复杂,每次调用消耗的费用就越高。这一节讲怎么控制成本,以及如何在不牺牲安全性的前提下压缩状态。
5.1 费用模型速览
锁定脚本越长,交易体积越大,矿工费越高。一个大致的参考:在测试网上锁定 1000 satoshis 的 Counter 合约,每次调用大概消耗几百 satoshis 的手续费。如果你把状态做成复杂结构体(比如存一长串字符串、数组),费用会上涨得非常快。
这里有一条朴素的规则:状态体积是费用的大头,设计合约时先想状态里到底需要存什么。
5.2 用 ByteString 压缩和序列化技巧
如果你的状态里有多个字段,不要直接用多个bigint,可以考虑把多个小字段拼成一个ByteString,然后在合约里做位运算解包。比如一个状态包含 type(3 bit)、version(5 bit)、value(24 bit),完全可以用一个 32 bit 的数字合在一起。
sCrypt 合约里也支持ByteString类型,配合len、split等方法可以灵活地做状态编码。我自己的经验是:能用整数位运算解决的,就不要用字符串拼接,前者体积更小、gas 费用更低,而且不容易出边界问题。
5.3 状态更新频率和内存池压力
另一个调优思路是控制状态更新的频率。有些业务场景其实不需要每笔交易都上链——比如你需要的是"最终一致性"而不是"实时一致性",那就可以把多次链下状态攒在一起,最后在链上做一次批量结算。这种情况下,状态合约为你提供了一个链上"锚点",链下状态变化通过签名互相认可,只有关键节点才触发链上更新。
不过要注意:链下攒批意味着信任模型的改变。链下状态如果由单一方控制,另一方必须依赖它的诚实性。如果要保持去中心化,还是得老老实实每轮都上链。
6. 踩坑记录:我在开发有状态合约时遇到的3类问题
工具链再好用,实际开发中该踩的坑一个都少不了。我把自己印象最深的三类问题整理出来,都是能让你少走很多弯路的经验。
6.1 状态在锁定脚本中的序列化顺序问题
早期我写了一个带多个状态字段的合约,调试时发现每次调用都会报"state mismatch",后来定位到是字段声明顺序和反序列化顺序必须严格一致。
sCrypt 在序列化状态时是严格按照合约中变量的声明顺序来的。如果你在合约里调整了状态字段的声明位置,但链下代码还是用旧顺序去读取和构造新状态,就会出现"旧状态读出来是 3,写进去变成 100"这种荒谬的错误。
解决方案很简单:每次改合约后,重新编译,然后在链下代码里重新生成合约实例,别复用旧的 ABI 描述文件。这一点看似常识,但是当你有多个分支、多个版本合约在同时跑的时候,很容易漏掉某个构建步骤。
6.2 交易输入输出顺序与找零地址的恩怨
这是我最想吐槽的一个坑。BSV 的签名机制对交易的输入输出顺序非常敏感,但 sCrypt 的链下 API 在某些情况下会帮你自动排序,有时又不会。如果你混合使用了methods调用和手动添加输入输出,排序逻辑就可能和合约里硬编码的检查不一致。
具体表现是:合约执行时提示checkSig失败,或者getStateScript生成的输出脚本与交易实际输出不匹配。
我的建议是:一个交易里尽量只通过 sCrypt 的 API 来构造合约相关的输入和输出,不要让合约 UTXO 和其他普通 UTXO 混在一个交易里。如果业务上必须混,那就要手动控制输入输出数组的排列顺序,并且每改一次交易结构就重新签名。
6.3 合约金额不足导致的状态更新失败
有状态合约里,每一轮的新 UTXO 需要锁定足够的 satoshis,否则输出脚本会因金额不足被网络拒绝。这个金额必须大于等于"下下轮"还要调用合约时的最小成本。
比如你的合约每次调用消耗 200 satoshis,第一轮锁了 1000,看起来很够用。但如果连续调用 5 次,到第 6 次时 UTXO 金额只剩 0,合约就"死"了——没法再推进状态,资金也永远锁死在链上了。
这个问题在设计时就要想清楚:你的状态机会被执行多少次?是无限次还是有上限?如果无限次,每一轮都必须额外注入资金;如果有上限,你得计算好金额消耗曲线。我见过的一些 NFT 合约就因为没算清楚这个,导致资产卡死在合约里无法流转。
提示:如果你设计的是"每轮消耗固定费用"的合约,建议在合约里写一个金额下限检查,比如
require(this.amount() >= MIN_AMOUNT),防止状态机意外自毁。
结尾
写到这里,有状态 sCrypt 合约的核心机制、最小实现、部署流转、成本优化和典型坑基本都覆盖了。最后分享一点我个人的体会:BSV 上的状态合约并不是要把链变成数据库,它更像是在链上刻了一串"会自己长大"的印记——每一轮变化都客观存在,任何时候回看历史都能看到状态的完整轨迹。
如果你是在做需要防篡改的计数、分步执行的资金流、或者跨多轮授权的协议,这套方案真的值得一试。工具链虽然还在快速迭代,但核心思想非常稳定:状态不要存在内存里,存在链上;更新不要去修改,去重新创建。想通这两点,剩下的只是熟练度的问题了。