用sCrypt在比特币UTXO链上发行Token:合约设计与落地实践

📅 发布时间:2026/10/15 15:04:08
用sCrypt在比特币UTXO链上发行Token:合约设计与落地实践
一直有朋友问我在比特币生态上做资产发行该怎么入手问我能不能用最少的花架子讲清楚 Token 合约到底是怎么写出来的。这篇文章我就拿 sCrypt 直接跑一个最简单的同质化 Token把合约的设计思路、代码结构、测试部署流程一次捋清楚。sCrypt 是面向比特币类 UTXO 链的智能合约语言语法上接近 TypeScript编译之后会落到链上脚本里执行。用它写 Token核心不是“记账”而是把“谁拥有多少 Token”这件事编码进 UTXO 的状态里每一次转账就是一次状态迁移。这个思维转换对从以太坊 Solidity 过来的人冲击最大但理解了以后会发现它比账户模型更直观也更接近“真的在转钱”。本文适合三类人一是刚接触智能合约、想找一个能立刻落地的练习项目的新手二是对 UTXO 模型感兴趣、想看看合约状态怎么做转移的开发者三是已经在以太坊上写过 ERC-20、想横向对比两种模型差异的从业者。我会尽量把每一步的“为什么这样写”讲透代码给的是教学精简版但测试和部署流程是按实际项目跑的。1. 项目整体认知与设计思路1.1 为什么选 sCrypt而不是直接搓脚本第一次听到 sCrypt 是在某个技术群里有人说“用 TypeScript 写比特币合约”我当时第一反应是这玩意儿八成是个半成品。后来实际跑通了一个多签钱包 Demo 才发现sCrypt 的设计思路确实聪明它不重新发明一套执行环境而是把 TypeScript 源码编译成比特币原生的脚本字节码链上验证逻辑仍然在 UTXO 模型里跑。这意味着几件事。一是合约的安全性最终落在比特币脚本执行器上不需要维护一个独立的虚拟机共识链本身不会为了合约而分叉。二是开发体验比直接写脚本好太多脚本语言没有函数、没有类型、没有作用域概念写几十行就开始怀疑人生而 sCrypt 保留了 TypeScript 的语法糖和类型检查心智负担小一个量级。三是它天然适合 UTXO 模型的资产类合约因为 UTXO 本身就是“硬币”的抽象Token 无非是在这枚硬币上附加自定义属性。用 sCrypt 做 Token 合约还有个实际好处生态里有scrypt-ts这样的开发包提供了合约编译、测试、部署的工具链包括sCrypt插件和scrypt-cli命令行工具配合测试网可以快速反复验证。你可以把它当作一套面向比特币类链的 Hardhat项目初始化和调试路径都是通的不需要自己从零拼轮子。1.2 Token 在 UTXO 世界里的核心逻辑状态迁移在开始写代码前必须先建立一个关键认知UTXO 模型下没有“余额表”这种东西。以太坊的 ERC-20 合约里有一张mapping(address uint256)余额是合约存储里的数据而在 UTXO 链上每个 UTXO 本身就是一笔资产它被一个锁定脚本控制。花的不是“余额”花的是“某个具体的 UTXO”。所以Token 合约要做的不是记录“谁有多少”而是规定“什么样的 UTXO 是合法 Token”。我的简化方案是把 Token 的状态直接放进合约实例的prop()字段里owner当前这笔 Token UTXO 的持有者公钥tokenId代币的唯一标识用来区分这是哪一期发行的 Tokenamount这个 UTXO 上携带的 Token 数量。转账时逻辑上做什么把当前 UTXO 花掉然后创建两个新 UTXO一个给接收者携带转出数量一个给原持有者携带剩余数量。这跟比特币 UTXO 转账的思路一模一样只不过锁定脚本从标准 P2PKH 换成了 Token 合约脚本。合约的职责就是在脚本执行时验证事情符合预期比如数量没有凭空变多、接收者确实是脚本里写的那个人。提示理解状态迁移是写 UTXO 合约的分水岭。如果你一直用“改数据库里的某个字段”的思维写合约第一步就会卡死你需要的思维是“销毁旧状态创建新状态”。1.3 定义这次项目的功能范围为了避免项目失控我把“简单 Token”限定成最小可用功能支持发行一笔固定总量的 Token支持一笔 UTXO 向任意地址转账指定数量支持找零逻辑保证原持有者的剩余 Token 不丢失不允许增发不允许凭空销毁转账数量必须小于等于持有量。代码里会省略部分签名校验细节这一点我放在后面“安全红线”里专门说明。如果你是想做一个可以直接发到主网生产环境的合约建议在读完本文后对比官方仓库的完整实现补齐安全层本文重点是状态流转和 UTXO 契约思想先把主干跑通比一次性追求完美更重要。2. 合约设计数据结构与状态方法2.1 状态字段的选择与约束合约的状态设计决定了它能表达什么。我最终选了三段式字段这是为了同时满足“标识唯一性”和“数量可分割性”字段类型作用ownerPubKey当前 UTXO 的控制者后续转账身份校验的基准tokenIdByteString代币身份标识可以取发行交易的某个哈希amountbigint当前 UTXO 携带的 Token 数量bigint类型在 sCrypt 里对应链上的整数运算合约内数量加减都用它避免浮点误差。很多人刚上手会踩一个坑用number类型存数量一编译就报类型错误这不是框架限制而是链上脚本本来就不支持传统浮点数所有资产类数值都应该走整数。tokenId的设计也需要想清楚。如果只做单一种类 Token这个字段可以固定为常量不需要放进状态里但为了以后复用同一份合约代码发多期资产我把tokenId放进了prop()让每期代币用一个独立标识区分。这相当于在合约层面加深了一层身份隔离后期扩展 NFT 时也可以直接用类似字段承载元数据引用。owner 字段我选择直接存公钥的PubKey类型在验证时通过哈希比对来确认身份。有些方案会直接存hash160(公钥)这样可以缩短脚本体积、提升执行效率但可读性会差一些。教学版里为了直观我先把公钥放进去同时注释里标明了生产版本建议用哈希。2.2 核心方法transfer 与找零逻辑整个合约只有一个关键方法transfer但它承担了 Token 流转的所有核心逻辑。方法签名我设计成method() transfer(to: PubKey, amount: bigint, change: bigint)传入的amount是转给接收者的数量change是找零数量。这两个参数由调用者构造交易时算好合约内部校验它们的正确性。第一步是校验数量关系this.amount amount change。这一步如果写成也是安全的但会导致一种麻烦多余的部分留在合约里但不知道归属谁。因为每个 UTXO 花掉后必须完整消费多余的 Token 会变成一个没人能动的死 UTXO。所以我在设计上强制要求等式成立把余额拆分的责任完全交给转账方合约只负责“没有凭空变多或减少”。第二步是构造两个新 Token 输出并检查交易输出。这里借用了 sCrypt 对当前交易上下文的访问能力合约在执行时可以拿到tx.outputs也就是这笔交易产生的所有输出。我用getStateScript()方法根据新状态序列化出新的锁定脚本然后用Utils.buildOutput构造出期望的输出逐个比较。这一步比较是 UTXO 合约的灵魂。它保证了转账结果和你调用合约时看到的参数是一致的。如果你允许合约执行完却生成别的输出那合约约束就等于没有。第三步是校验输出顺序和数量。实际交易里一般会先把转给接收者的输出放前面找零放后面顺序错了比较逻辑会失败。这是一个实现细节但它节省了大量的代码因为不需要按地址匹配输出。下面是教学版伪代码我特意保留了步骤注释便于对照上面的说明import { SmartContract, prop, method, PubKey, ByteString, bigint, assert, Utils } from scrypt-ts export class SimpleToken extends SmartContract { prop() owner: PubKey prop() tokenId: ByteString prop() amount: bigint constructor(owner: PubKey, tokenId: ByteString, amount: bigint) { super(...arguments) this.owner owner this.tokenId tokenId this.amount amount } method() transfer(to: PubKey, amount: bigint, change: bigint) { // 1. 数量守恒转出 找零必须等于当前持有量 assert(this.amount amount change, amount mismatch) // 2. 构造接收者对应的新合约状态 const receiveScript this.getStateScript(to, this.tokenId, amount) // 3. 构造原持有者的找零合约状态 const changeScript this.getStateScript(this.owner, this.tokenId, change) // 4. 按约定顺序检查两个输出 const receiveSatoshi 1n const changeSatoshi 1n assert( Utils.buildOutput(receiveScript, receiveSatoshi) this.ctx.outputs[0], receiver output mismatch ) assert( Utils.buildOutput(changeScript, changeSatoshi) this.ctx.outputs[1], change output mismatch ) } }注意这版代码为了教学内容清晰省去了签名校验和前序交易输出的严格匹配。真实部署时必须在第一步前增加签名验证并在第四步后校验至少一个输入是当前的 Token 合约否则合约可能被绕过。这一点我会在第四章安全红线段落展开。2.3 为什么输出脚本校验是整个合约的命门在账户模型的合约里你直接操作存储字段写完balance[to] amount就算完成转账但在 UTXO 模型里合约方法执行完毕后真正影响链上状态的是“这笔交易生成了哪些输出”。如果合约逻辑没有对输出进行约束恶意调用者完全可以构造一笔交易让合约成功执行但实际输出却是一个把全部 Token 转给别人的脚本。这个类比可以用一个生活场景理解你在一家银行网点签了一张转账授权书员工核对完签名后应该把钱汇到指定账户但如果银行员工拿着授权书跑出门把钱塞进了自己口袋那前面所有的核对都白做了。UTXO 合约里的“输出检查”就是那个监督员工动作的摄像头它强制要求资金只能流向脚本认可的地方。所以本文合约的assert(Utils.buildOutput(...) this.ctx.outputs[0])不是可有可无的锦上添花它意味着合约在“验证转账结果”。哪一步缺失哪一步就是可被攻击的缝隙。我在给新人 review 合约代码时第一眼永远先看方法末尾有没有对outputs的断言而不是开头的余额检查。3. 实操过程环境搭建与核心代码实现3.1 环境准备初始化项目与安装工具链实际动手的第一步是准备开发环境。我把完整流程写在这里包括我踩过的版本坑。我用的方案是 Node.js TypeScript 的工程结构配合scrypt-ts包。初始化命令npm init -y npm install scrypt-ts npm install -D typescript ts-node types/node如果是新项目scrypt-ts会要求你复制一份标准配置文件。常见操作是运行npx scrypt-cli project new my-token这个命令会生成一个带有contracts、tests、deployments目录的脚手架里面已经配好了tsconfig.json和测试链环境变量。手工从零搭也不是不行但脚手架能帮你避开很多版本兼容问题我建议第一次直接用脚手架。有一个容易踩的坑是 Node 版本。某些 sCrypt 版本对 Node 的 bigint 处理和编译器的兼容性在旧版 Node 上会报BigInt literals are not available的错误。我用 Node 18 以上的版本全程无碍如果你还在 Node 14 以下先把运行时升级上来再往下走。环境验证方式很简单在项目根目录跑一次合约编译能生成.scrypt字节码文件就说明链路通了npx scrypt-cli compile3.2 合约代码逐段讲解把上一节的教学版代码补充完整后对应到实际项目还需要提前做两件事。第一件事是引入Signature机制。虽然教学版省略了签名但在我实际跑的测试版本里第一步就是校验当前调用者拥有owner对应的私钥。这里给一个更接近真实代码但依然可读的写法method() transfer(sig: Sig, to: PubKey, amount: bigint, change: bigint) { assert( hash160(this.owner) hash160(PubKey(this.ctx.unsignedScript)), script not from owner ) // 真正的签名校验在 sCrypt 中通常用 checkSig 原语 assert(this.ctx.checkSig(sig, this.owner), signature check failed) ... }这行checkSig的作用是链上验证调用者的数字签名和比特币标准的解锁脚本逻辑一致。有了这一步即使有人拿到了合约的 scriptCode也没法替真正的 owner 发起转账因为签名过不了。第二件事是构造输出。getStateScript是 sCrypt 合约基类提供的能力它会把当前合约的构造参数序列化成新的锁定脚本。它的原理是新合约实例的prop()字段变更后脚本内容也随之变化而 UTXO 锁定脚本正是这个状态序列化结果。这正是“没有数据库也能存储状态”的机制。完整项目代码我建议按这样的文件排布组织my-token/ ├── contracts/ │ └── simpleToken.ts ├── tests/ │ └── simpleToken.test.ts ├── deployments/ │ └── (生成的部署文件) ├── package.json └── tsconfig.jsoncontracts/simpleToken.ts里放合约定义tests/simpleToken.test.ts里放部署和转账测试。逻辑上分开后面扩展多签或者 NFT 时每个合约一个文件互不干扰。3.3 编译、测试与本地验证代码写完第一件事是编译。编译通过后我习惯先跑一遍合约的单测框架sCrypt 的测试框架会部署到本地模拟环境不消耗测试网币迭代很快。一个典型测试流程是用SimpleToken构造一个初始 UTXOowner 设为本地生成的一对密钥amount 设为 1000调用transfer方法构造一笔交易把 100 转到接收者地址900 找零回原地址断言交易成功再查询接收者地址的 UTXO验证数量是 100再跑一个反例尝试转账 1000找零 100预期断言失败。这里有个细节必须提醒找零金额和输出顺序要保持稳定。我第一次写测试时因为输出顺序是随机的断言时好时坏排查半天才意识到 UTXO 模型对输出顺序敏感后来把所有输出顺序固定为“接收者在前找零在后”问题立刻消失。小范围测试通过后再上测试网部署。部署的本质是构造一笔携带合约字节码和初始状态的交易把它广播到链上然后拿到一个可以作为tokenId的标识。这一步可以用 sCrypt 提供的部署脚本简化它会自动处理手续费和找零。3.4 前端/客户端如何调用合约合约本身只是链上验证逻辑实际使用还需要一个前端或者后端服务来构造交易、签名并广播。这一步通常被新手忽略但恰恰是“能不能用起来”的关键。调用方需要做三件事读取持有者的 Token UTXO计算转出和找零数量填入transfer参数用持有者私钥签名整个交易然后广播。构造交易时要注意Token UTXO 上除了amountToken 数量还有一个比特币面额satoshi。在我的设计里每个 Token 输出都只有 1 个 satoshi因为 Token 本身的价值由链下协议定义合约只关心amount字段但比特币网络规定 UTXO 必须带最小面额所以这个 1 satoshi 不能省略否则交易会被拒。这个设计选择在测试网跑通很容易主网上如果有多类型资产扩展可以考虑把最小面额提上去避免粉尘攻击。4. 常见问题与排查技巧实录4.1 编译期报错与调试经验在项目从零到跑通的过程中我遇到的最典型报错是类型不匹配。所有prop()字段的类型必须是 sCrypt 支持的类型集合比如bigint、ByteString、PubKey、Sig等。把number类型直接放进prop()会报编译错连续踩了两次以后我养成了习惯合约内一律用bigint一旦涉及数值操作就加n后缀。还有一个容易忽略的问题是prop()字段必须在构造函数里被赋值编译器不允许状态字段留空。这跟 TypeScript 的严格属性初始化规则一致但从 Solidity 转过来的人容易漏因为 Solidity 的状态变量可以有默认值。调试方面我建议多用assert带上错误信息。sCrypt 的assert(条件, 消息)在测试环境能直接把错误消息抛出来定位问题比看堆栈高效得多。我在合约里加的每一条assert都写了明确的消息比如receiver output mismatch、amount mismatch这样测试挂了能一眼看出是哪一步失败。4.2 交易构造中的坑小数、找零与状态延续小数是资产合约里永恒的话题。智能合约里没有任何一种原生float可以用因为脚本执行必须确定性和精确。如果你想让用户能转账 0.5 个 Token正确的做法是定义最小单位为 0.01 或 0.0001然后所有数量都用整数的最小单位表示。我在项目里直接把初始供应量设为 1000000代表 10000 个最小单位 Token这样可以支持到小数点后两位足够演示。找零问题也值得展开说。刚才的设计里change必须满足this.amount amount change。但如果用户转账 100持有 1000系统里同时存在两笔 Token UTXO各 500 和 500转账时需要选择合适的输入或者在交易里把两个输入合并成一个输出这其实就是 UTXO 模型里的“选币”问题。我在项目里写了一个简单的 UTXO 选择器优先选出数量刚好等于转出额的 UTXO找不到就选最小的大于转出额的 UTXO然后计算找零。这个策略对教学足够但生产环境需要更精细的算法来避免粉尘和手续费浪费。状态延续性也要特别检查转账生成的接收者和找零输出必须都是同一个SimpleToken合约的延续不能跑成普通 P2PKH 输出。这已经内置在getStateScript()里了但如果你手写输出脚本极容易漏掉tokenId的传递导致 Token 换了一种锁定脚本后续就无法再被合约识别和转移。4.3 安全红线伪造数量、重放攻击与假币先说不定量增发。我的合约末尾检查了输出必须带来amount change this.amount所以转出和找零的总量被锁死没法凭空增加。但如果你在设计自己的合约时把这一步去掉攻击者可以构造交易让输出携带任意大的 amountToken 瞬间变成无限增发。这是最基础也最致命的安全边界。再说绕过校验。很多新手以为只要合约代码写了transfer函数别人就不能乱动你的 Token。但实际上如果签名校验缺失任何人都能以“你的合约参数”发起一笔交易把 owner 替换成自己再把全部 Token 转走。这正是我在第二章里特意强调“输出检查 签名检查”缺一不可的原因。签名保证“只有你授权的调用能成功”输出检查保证“授权调用也严格按照你的预期产生结果”两个安全边界配合起来才完整。重放攻击也是 UTXO 类链特有需要注意的点。一笔签名过的交易广播后所有人都可以重新广播它。对于简单 Token 转账这问题还不致命但如果你实现了空投或者兑换类逻辑重放可能导致资产被多次领取。解决思路是在合约状态里加入随机数或者时间锁约束这类扩展放在后续文章里讲但意识要从第一个 Token 合约就开始建立。最后还有假币问题。由于同一份合约可以被不同交易实例化合约本身并不知道“你是官方发行方”。攻击者可以复制你的tokenId逻辑发行一个同样名为某 Token 的资产引导用户误以为是官方资产。对抗手段是在合约里校验tokenId必须等于创世交易的某个哈希并且引导客户端只接受通过官方 Indexer 查询到的 UTXO。这里面的教训是合约负责数学正确但“哪个合约实例是真的”需要链外基础设施把关不要把一个简单教学合约直接当生产协议用。5. 写在最后从简单 Token 到更复杂的扩展这个简单 Token 合约跑通之后我对 UTXO 智能合约的理解上了一个大台阶也想清楚了不少之前想不通的设计。最直接的体会是在 UTXO 模型里合约的开发范式不是“功能优先”而是“状态转换优先”。每写一个方法前先问自己这个方法会把哪个旧状态销毁会创建哪些新状态新状态的约束是什么。把这个想清楚哪怕不用 sCrypt换成别的 UTXO 合约语言思路也能平滑迁移。尝到甜头以后我个人建议下一步朝三个方向扩展一是加入 mint/burn 能力用权威输入控制供应量二是给合约增加多签门槛把 owner 从单公钥升级为多把公钥的门限组合三是实现一个简单的 NFT 版本把amount固定为 1新增 metadata 字段承载链下信息。这三个方向已经把同质化资产到非标资产的主干都覆盖了足够做出一套有实用价值的资产发行协议原型。如果你也是第一次在 UTXO 链上写资产类合约我的建议是不要一上来就追求复杂功能先把我这个极简 Token 编译、部署、转账完整跑一遍再对照官方示例补签名校验和生产级安全细节。只有跑通了“销毁旧状态、创建新状态”的循环你才算真正跨过了 UTXO 合约的门槛。