上一篇文章主要介绍了 sCrypt 语言开发利器 sCrypt Visual Studio Code 插件 的相关功能。现在我们就要实操起来,体验一下 sCrypt 合约完整的设计、开发、测试、部署、及调用全流程。
构建任何一个智能合约的第一步都是从想法出发完成一个设计,这里我们选择将一种比特币网络中常见的交易类型(P2PKH)进行 sCrypt 合约化。把这个过程作为示例的主要原因有两个:
P2PKH 的全称是 Pay To Public Key Hash,是比特币网络中最常见的交易类型,用于实现转账功能。
它的锁定脚本为:
OP_DUP OP_HASH160
它的解锁脚本为:
咱们还是拿本系列第一篇文章中讲的那个例子来说说它的原理和实现。
如果有人要给我转比特币,首先我需要把自己的公钥哈希值(即通常说的比特币地址,相当于我的银行卡号)告诉他,然后对方使用这个值构造 P2PKH 锁定脚本(这里记为 LS-1)并将交易发送给矿工,矿工验证无误后将交易记录到链上。
现在,当我想花费这个比特币的时候,需要提供两个信息才能构造解锁脚本:
构造出解锁脚本后,再使用收款人的公钥哈希值构建新的锁定脚本,最后把交易广播出去。
当矿工收到我这笔新交易时,需要验证其合法性,主要涉及两个步骤:
将解锁脚本与 UTXO 中的锁定脚本(即前述 LS-1)连接起来形成完整的验证脚本:
使用虚拟机执行这个验证脚本,检查执行结果是否有效。实际上,验证过程中最关键的检查也有两个:
2.1. 验证解锁脚本中提供的公钥信息能否计算出锁定脚本中的公钥哈希值。如果通过则说明这个公钥确实是之前交易的接收方地址(相当于验证了之前转账的接收地址是我的银行卡号);
2.2. 验证解锁脚本中提供的签名与公钥信息是否吻合。如果通过则说明我确实掌握与这个公钥所对应的私钥控制权(相当于验证了我有这个银行卡号的密码);
合法性验证通过,证明我确实拥有并可以支配这个比特币,那么矿工就会把这笔新的花费交易记录到链上。这就是 P2PKH 类型交易的主要过程和原理。
综上,我们进行合约设计的目标也非常明确:实现一个和 P2PKH 功能完全等价的 sCrypt 合约。
有了设计思路和目标我们就可以搞起来,首先当然是在 VS Code 里装上 sCrypt 插件(如上篇文章所述)。
sCrypt 提供了一个样板项目方便大家快速学习开发测试合约。这是一个不错的起点,我们也从这里开始,首先克隆项目到本地,使用命令:
git clone [email protected]:scrypt-sv/boilerplate.git
实际上该项目中已经包含了我们想要的 P2PKH 合约,所以直接来看代码(文件为 contracts/p2pkh.scrypt
):
contract DemoP2PKH {
Ripemd160 pubKeyHash;
constructor(Ripemd160 pubKeyHash) {
this.pubKeyHash = pubKeyHash;
}
public function unlock(Sig sig, PubKey pubKey) {
require(hash160(pubKey) == this.pubKeyHash);
require(checkSig(sig, pubKey));
}
}
合约也很简单,主体包括:
Ripemd160
的属性变量 pubKeyHash
。对应于之前 P2PKH 锁定脚本中的
;constructor
。用于完成属性变量的初始化;unlock
的公共函数。参数类型分别为 Sig
及 PubKey
,对应于之前 P2PKH 解锁脚本中的
及
;实现逻辑也对应着前面讲的 P2PKH 验证。对比之前 Script 形式的验证脚本,相信大部分朋友都会认同 sCrypt 的代码更容易学习和编写。而且合约逻辑功能越复杂,sCrypt 的优势就能体现的越明显。
有了代码接下来就要验证其功能实现是否正确,这时候常规的方法是增加一些单元测试。针对上述合约的测试文件为 tests/js/p2pkh.scrypttest.js
,代码如下:
const path = require('path');
const { expect } = require('chai');
const { buildContractClass, bsv } = require('scrypttest');
/**
* an example test for contract containing signature verification
*/
const { inputIndex, inputSatoshis, tx, signTx, toHex } = require('../testHelper');
const privateKey = new bsv.PrivateKey.fromRandom('testnet')
const publicKey = privateKey.publicKey
const pkh = bsv.crypto.Hash.sha256ripemd160(publicKey.toBuffer())
const privateKey2 = new bsv.PrivateKey.fromRandom('testnet')
describe('Test sCrypt contract DemoP2PKH In Javascript', () => {
let demo
let sig
before(() => {
const DemoP2PKH = buildContractClass(path.join(__dirname, '../../contracts/p2pkh.scrypt'), tx, inputIndex, inputSatoshis)
demo = new DemoP2PKH(toHex(pkh))
});
it('signature check should succeed when right private key signs', () => {
sig = signTx(tx, privateKey, demo.getLockingScript())
expect(demo.unlock(toHex(sig), toHex(publicKey))).to.equal(true);
/*
* print out parameters used in debugger, see ""../.vscode/launch.json" for an example
console.log(toHex(pkh))
console.log(toHex(sig))
console.log(toHex(publicKey))
console.log(tx.uncheckedSerialize())
*/
});
it('signature check should fail when wrong private key signs', () => {
sig = signTx(tx, privateKey2, demo.getLockingScript())
expect(demo.unlock(toHex(sig), toHex(publicKey))).to.equal(false);
});
});
熟悉 Javascript 的朋友可能一下就辨识出这是一个基于 mocha + chai
框架的纯 JS 测试文件。让我们再进一步看看这个测试用例。
首先导入 sCrypt 的 Javascript / Typescript 测试库 scrypttest 函数:
const { buildContractClass, bsv } = require('scrypttest');
使用工具函数 buildContractClass
得到合约 DemoP2PKH
在 Javascript 中反射的类对象:
const DemoP2PKH = buildContractClass(path.join(__dirname, '../../contracts/p2pkh.scrypt'), tx, inputIndex, inputSatoshis)
使用初始化参数(即公钥哈希的 hex 格式)实例化合约类:
demo = new DemoP2PKH(toHex(pkh))
测试合约实例的公共方法,其应当成功时:
sig = signTx(tx, privateKey, demo.getLockingScript())
expect(demo.unlock(toHex(sig), toHex(publicKey))).to.equal(true);
或其应当失败时(因为使用错误的私钥导致签名无法通过验证):
sig = signTx(tx, privateKey2, demo.getLockingScript())
expect(demo.unlock(toHex(sig), toHex(publicKey))).to.equal(false);
在运行测试之前,我们需要在项目的根目录中运行 npm install
确保测试依赖都已成功安装;之后在 VS Code 的编辑器中右键这个测试文件,选择 “Run sCrypt Test”;运行结果在 “OUTPUT” 视图中查看。
仅有上述单元测试也还是不够的,因为当单测出错时我们只能得到最终结果,而没有其内部更多信息帮助我们解决合约本身的代码问题。这个时候就需要使用 sCrypt 插件的 Debug 功能了。
在 .vscode/launch.json
文件中可以找到针对 DemoP2PKH 合约的 Debug 配置项:
{
"type": "scrypt",
"request": "launch",
"name": "Debug P2PKH",
"program": "${workspaceFolder}/contracts/p2pkh.scrypt",
"constructorParams": "Ripemd160(b'2bc7163e0085b0bcd4e0efd1c537537053aa13f2')",
"entryMethod": "unlock",
"entryMethodParams": "Sig(b'30440220729d3935d496e5a708a6a1d4c61dcdd1bebae6f0e0b63b9b9eb1b7616cdbbc2b02203b58cdde0133a6e90d921ecee6ecafca7000a13a3e38673810b4c6badd8d952041'), PubKey(b'03613fa845ad3fe1ef4fe9bbf0b50a1cb5219dd30a0c4e3e4e46fb218313af9220')",
"txContext": {
"hex": "01000000015884e5db9de218238671572340b207ee85b628074e7e467096c267266baf77a40000000000ffffffff0000000000",
"inputIndex": 0,
"inputSatoshis": 100000
}
}
解释下其中的关键参数:
program
: 指定该配置具体执行的合约文件;constructorParams
: 指定合约的构造函数参数列表,如果有多个时使用逗号连接;另外,如果合约没有显示的构造函数时,编译器会自动生成一个默认构造函数,所以也需要将其属性按顺序当做构造函数参数列表传入。entryMethod
:指定要调试的公共函数名;entryMethodParams
:指定要调试公共函数的实参列表,同样如果有多个需用逗号连接;txContext
:指定调试时当前交易的相关上下文信息,其中:
hex
:交易的 hex 格式表示,可以是签名过的(signed transaction),也可以是未签名的(unsigned transaction);inputIndex
:要花费的、被合约锁定的 UTXO 所对应的 input 序号;inputSatoshis
: 要花费的、被合约锁定的 UTXO 中比特币数量,单位 satoshis;注意: constructorParams
和 entryMethodParams
中的参数都必须与合约中对应的参数保持(子)类型一致,且必须为 sCrypt 语法。否则,启动调试时会报错提示参数问题。
那么上述参数一般是如何得到的呢?回看一下之前的测试文件,可以发现其中有若干项被注释的命令行输出:
/*
* print out parameters used in debugger, see ""../.vscode/launch.json" for an example
console.log(toHex(pkh))
console.log(toHex(sig))
console.log(toHex(publicKey))
console.log(tx.uncheckedSerialize())
*/
这些输出也正是 Debug 配置时需要的参数,同理其他的合约也可以用类似的方法得到所需。
配置妥当之后,就可以使用 “F5” 快捷键启动代码调试了。调试器的具体功能和使用方法也可以参见上一篇文章 和 VS Code 官方文档。
在生产环境中使用合约之前,开发者应当在测试网(Testnet )上进行必要的测试以保证合约代码符合预期。针对本文的例子,可以在项目根目录中使用命令 node tests/testnet/p2pkh.js
来运行。
当我们首次运行该文件时,会看到类似这样的输出结果:
New privKey generated for testnet: cMtFUvwk43MwBoWs15fU15jWmQEk27yJJjEkWotmPjHHRuXU9qGq
With address: moJnB7AND5TW8suRmdHPbY6knpfE1uJ15n
You could fund the address on testnet & use the privKey to complete the test
因为正常运行代码有两个前提条件:
如果你已经有这样的私钥,可以找到并修改下面这行代码(使用 WIF 格式的私钥替代空字符):
const privKey = ''
当然,你也可以直接使用上面输出结果中的私钥,但需要先为输出结果中的地址获取测试币(比如在这个网站上领取)。
做好前述准备工作后,就可以再次运行这个用例了。正常情况下可以看到以下输出:
Contract Deployed Successfully! TxId: bc929f1dddc6652896c7c162314e2651fbcd26495bd1ccf9568219e22fea2fb8
Contract Method Called Successfully! TxId: ce2dba497065d33c1e07bf710ad94e9600c6413e053b4abec2bd8562aea3dc20
上述结果显示合约部署和调用都已经成功,可以去这个BSV 区块链浏览器中查看对应的交易详情(使用输出结果里的 TxId 进行查询)。
在 tests/testnet/p2pkh.js
文件中可以查看完整的代码:
const path = require('path')
const { exit } = require('process')
const {
buildContractClass,
showError,
bsv
} = require('scrypttest')
const {
toHex,
createLockingTx,
createUnlockingTx,
signTx,
sendTx
} = require('../testHelper')
function getUnlockingScript(method, sig, publicKey) {
if (method === 'unlock') {
return toHex(sig) + ' ' + toHex(publicKey)
}
}
async function main() {
try {
// private key on testnet in WIF
const privKey = 'cVWvTt4tVqCHgSchQpUHch7EHcDbfXeYZnYbuqXYxpPbXQWPtrxV'
if (!privKey) {
const newPrivKey = new bsv.PrivateKey.fromRandom('testnet')
console.log('New privKey generated for testnet: ' + newPrivKey.toWIF())
console.log('With address: ' + newPrivKey.toAddress())
console.log('You could fund the address on testnet & use the privKey to complete the test') // for example get bsv from: https://faucet.bitcoincloud.net/
exit(1)
}
const privateKey = new bsv.PrivateKey.fromWIF(privKey)
const publicKey = privateKey.publicKey
// Initialize contract
const P2PKH = buildContractClass(path.join(__dirname, '../../contracts/p2pkh.scrypt'))
const publicKeyHash = bsv.crypto.Hash.sha256ripemd160(publicKey.toBuffer())
const p2pkh = new P2PKH(toHex(publicKeyHash))
// deploy contract on testnet
const amountInContract = 10000
const deployTx = await createLockingTx(privateKey.toAddress(), amountInContract)
const lockingScript = p2pkh.getLockingScript()
deployTx.outputs[0].setScript(bsv.Script.fromASM(lockingScript))
deployTx.sign(privateKey)
const deployTxId = await sendTx(deployTx)
console.log('Contract Deployed Successfully! TxId: ', deployTxId)
// call contract method on testnet
const spendAmount = amountInContract / 10
const methodCallTx = createUnlockingTx(deployTxId, amountInContract, lockingScript, spendAmount, privateKey.toAddress())
const sig = signTx(methodCallTx, privateKey, lockingScript, amountInContract)
const unlockingScript = getUnlockingScript('unlock', sig, publicKey)
methodCallTx.inputs[0].setScript(bsv.Script.fromASM(unlockingScript))
const methodCallTxId = await sendTx(methodCallTx)
console.log('Contract Method Called Successfully! TxId: ', methodCallTxId)
} catch (error) {
console.log('Failed on testnet')
showError(error)
}
}
main()
为了方便大家理解,我们一起来看看合约部署和调用的具体实现。
创建一个新的锁定交易:
const deployTx = await createLockingTx(privateKey.toAddress(), amountInContract)
获取合约对应的锁定脚本:
const lockingScript = p2pkh.getLockingScript()
设置对应 output 的脚本为上述锁定脚本:
deployTx.outputs[0].setScript(bsv.Script.fromASM(lockingScript))
交易签名:
deployTx.sign(privateKey)
发送交易到服务节点:
const deployTxId = await sendTx(deployTx)
创建新的解锁交易:
const methodCallTx = createUnlockingTx(deployTxId, amountInContract, lockingScript, spendAmount, privateKey.toAddress())
获取对此交易的签名:
const sig = signTx(methodCallTx, privateKey, lockingScript, amountInContract)
获取合约方法调用所对应的解锁脚本:
const unlockingScript = getUnlockingScript('unlock', sig, publicKey)
设置对应 input 的脚本为上述解锁脚本;
methodCallTx.inputs[0].setScript(bsv.Script.fromASM(unlockingScript))
发送交易到服务节点:
const methodCallTxId = await sendTx(methodCallTx)
注意:不同合约的部署和调用实现会有差异,但大致流程与此例类似。
说到这里,比特币智能合约入门这个系列也结束了。我期望能够通过这样的方式,让感兴趣的朋友更多地了解和参与到智能合约的开发中,用区块链的技术创造更多的可能性。也请大家继续保持关注,谢谢:)
公钥哈希计算方式:先计算公钥的 SHA256 哈希值,再计算前述结果的 RIPEMD160 哈希值, 得到 20字节的公钥哈希值。 ↩︎
交易签名(Signature)更为详细的介绍可参考这个文档。 ↩︎