【翻译】介绍 CKB Script编程7:Duktape 高级编程(2)

原文来自:
Introduction to CKB Script Programming 7: Advanced Duktape Examples

由于文章过长,超过了字数限制,所以分成2篇来发。

此为第2篇文章,第1篇文章: Duktape 高级编程(1)

Adding new library 添加新库

我想在这里展示的另一件事是,你可以在npm上引入许多库,假设:

  • 该库有一个ES5版本(或者可以实际调整webpack pipeline 以添加polyfill);
  • 它完全是在JavaScript中实现的,没有本地代码

在HTLC脚本中,我将添加crc32,并使用crc32计算密码字符串的哈希值。
我想在这里再次提到,CRC32一直都不是安全的哈希函数。我们选择它是出于简单性,而不是安全性。
在生产环境中,应该使用真正的安全哈希函数,而不是使用JavaScript。
但是现在,crc32对于我们的教程来说是非常合适的 :stuck_out_tongue:

让我们在模板中包含crc32,并编写一些调试代码来测试它:

$ cd $TOP/htlc-template
$ npm install --save crc32
$ cat src/index.js
const { Molecule } = require('molecule-javascript')
const schema = require('../schema/blockchain-combined.json')

const names = schema.declarations.map(declaration => declaration.name)
const scriptTypeIndex = names.indexOf('Script')
const scriptType = new Molecule(schema.declarations[scriptTypeIndex])

// Write your script logic here.
const customSchema = require('./htlc-combined.json')
const customNames = customSchema.declarations.map(d => d.name)

const htlcArgsIndex = customNames.indexOf('HtlcArgs')
const htlcArgsType = new Molecule(customSchema.declarations[htlcArgsIndex])

function bytesToHex(b) {
  return "0x" + Array.prototype.map.call(
    new Uint8Array(b),
    function(x) {
      return ('00' + x.toString(16)).slice(-2)
    }
  ).join('')
}

function hexStringArrayToHexString(a) {
  let s = "0x";
  for (let i = 0; i < a.length; i++) {
    s = s + a[i].substr(2)
  }
  return s
}

const current_script = scriptType.deserialize(bytesToHex(CKB.load_script(0)))
const args = hexStringArrayToHexString(current_script[2][1])
const htlcArgs = htlcArgsType.deserialize(args)

CKB.debug(`c: ${hexStringArrayToHexString(htlcArgs[2][1])}`)

const crc32 = require('crc32')
CKB.debug(crc32('i am a secret'))
$ npm run build
$ cd $TOP/htlc-runner
$ RUST_LOG=debug `./runner.js ../ckb-duktape/build/load0 ../htlc-template/build/duktape.js`
DEBUG:<unknown>: script group: Byte32(0x35ab3d033e66c426573ed4b7ce816e248cb042d908fd8cfe7bba27acb37fb108) DEBUG OUTPUT: c: 0x970dd9a8
DEBUG:<unknown>: script group: Byte32(0x35ab3d033e66c426573ed4b7ce816e248cb042d908fd8cfe7bba27acb37fb108) DEBUG OUTPUT: 970dd9a8
Run result: Ok(0)

你可能会注意到,我们在这里打印的2个值是完全相同的! 这是因为 i am a secret 正是我在准备骨架时选择的密码字符串。

Piecing the Contract Together 把合约合并起来

有了所有的库和所需的知识,我们现在可以完成实现脚本了:

$ cd $TOP/htlc-template
$ cat src/index.js
const { Molecule } = require('molecule-javascript')
const schema = require('../schema/blockchain-combined.json')

const names = schema.declarations.map(declaration => declaration.name)
const scriptTypeIndex = names.indexOf('Script')
const scriptType = new Molecule(schema.declarations[scriptTypeIndex])

// Write your script logic here.
const customSchema = require('./htlc-combined.json')
const customNames = customSchema.declarations.map(d => d.name)

const htlcArgsIndex = customNames.indexOf('HtlcArgs')
const htlcArgsType = new Molecule(customSchema.declarations[htlcArgsIndex])

function bytesToHex(b) {
  return "0x" + Array.prototype.map.call(
    new Uint8Array(b),
    function(x) {
      return ('00' + x.toString(16)).slice(-2)
    }
  ).join('')
}

function hexStringArrayToString(a) {
  let s = "";
  for (let i = 0; i < a.length; i++) {
    s = s + String.fromCharCode(parseInt(a[i]))
  }
  return s
}

function hexStringArrayToHexString(a) {
  let s = "0x";
  for (let i = 0; i < a.length; i++) {
    s = s + a[i].substr(2)
  }
  return s
}

function parseLittleEndianHexStringArray(a) {
  let v = 0
  const l = a.length
  for (let i = 0; i < l; i++) {
    v = (v << 8) | parseInt(a[l - i - 1])
  }
  return v
}

const current_script = scriptType.deserialize(bytesToHex(CKB.load_script(0)))
const args = hexStringArrayToHexString(current_script[2][1])
const htlcArgs = htlcArgsType.deserialize(args)

// Load and parse witness data using the same method as above
const htlcWitnessIndex = customNames.indexOf('HtlcWitness')
const htlcWitnessType = new Molecule(customSchema.declarations[htlcWitnessIndex])

const rawWitness = CKB.load_witness(0, 0, CKB.SOURCE.GROUP_INPUT)
if (typeof rawWitness === 'number') {
  throw new Error(`Invalid response when loading witness: ${rawWitness}`)
}
const htlcWitness = htlcWitnessType.deserialize(bytesToHex(rawWitness))

let lockHashToMatch;
if (htlcWitness[0][1].length > 0) {
  // Test secret string hash
  const crc32 = require('crc32')
  const hash = '0x' + crc32(hexStringArrayToString(htlcWitness[0][1]))
  if (hash !== hexStringArrayToHexString(htlcArgs[2][1])) {
    throw new Error(`Invalid secret string!`)
  }
  lockHashToMatch = hexStringArrayToHexString(htlcArgs[0][1])
} else {
  // Test header block
  const headerTypeIndex = names.indexOf('Header')
  const headerType = new Molecule(schema.declarations[headerTypeIndex])

  // Load header for current input first
  const rawInputHeader = CKB.load_header(0, 0, CKB.SOURCE.GROUP_INPUT)
  if (typeof rawWitness === 'number') {
    throw new Error(`Invalid response when loading input header: ${rawInputHeader}`)
  }
  const inputHeader = headerType.deserialize(bytesToHex(rawInputHeader))
  const inputHeaderNumber = parseLittleEndianHexStringArray(inputHeader[0][1][3][1])

  const targetHeaderIndex = parseLittleEndianHexStringArray(htlcWitness[1][1])
  const rawTargetHeader = CKB.load_header(0, targetHeaderIndex,
                                          CKB.SOURCE.HEADER_DEP)
  if (typeof rawTargetHeader === 'number') {
    throw new Error(`Invalid response when loading target header: ${rawTargetHeader}`)
  }
  const targetHeader = headerType.deserialize(bytesToHex(rawTargetHeader))
  const targetHeaderNumber = parseLittleEndianHexStringArray(targetHeader[0][1][3][1])

  if (targetHeaderNumber < inputHeaderNumber + 100) {
    throw new Error(`Timeout period has not reached!`)
  }
  lockHashToMatch = hexStringArrayToHexString(htlcArgs[1][1])
}

// Now we know which lock hash to test against, we look for an input cell
// with the specified lock hash
let i = 0
while (true) {
  const rawHash = CKB.load_cell_by_field(0, i, CKB.SOURCE.INPUT, CKB.CELL.LOCK_HASH)
  if (rawHash == CKB.CODE.INDEX_OUT_OF_BOUND) {
    throw new Error(`Cannot find input cell using lock hash ${lockHashToMatch}`)
  }
  if (typeof rawHash === 'number') {
    throw new Error(`Invalid response when loading input cell: ${rawHash}`)
  }
  if (bytesToHex(rawHash) == lockHashToMatch) {
    break
  }
  i += 1
}

它使用类似于上面所示的技术来解析见证和块标头,它们也是molecule格式的。

有一个技巧值得一提:在HTLC脚本的设计中,我提到脚本需要对给定的公钥进行签名验证。我们在这里的实际实现对此设计进行了概括:

  1. 我们不是测试给定的公钥,而是测试整个锁定脚本哈希。 虽然这肯定满足了我们的要求,但它提供了更多的可能性:如果每个人都使用默认的secp256k1锁定脚本,则不同的公钥将反映在脚本args部分中,从而导致不同的锁定脚本。 因此,测试锁脚本可以确保使用不同的公钥。 另一方面,并不是每个人都使用默认的secp256k1锁定脚本,因此直接测试锁定脚本哈希,可以提高HTLC脚本使用的灵活性。

  2. 虽然可以肯定地将签名验证逻辑嵌入HTLC脚本中,但是我们在这里选择了另一种更简单的解决方案:我们只测试其中一个输入cell是否具有指定的锁脚本。 根据CKB的验证规则,如果交易被区块链接受,则每个输入cell的锁定脚本必须通过验证,这意味着HTLC脚本中指定的锁定脚本也将通过验证,从而满足HTLC脚本的验证规则。

总而言之,我们实际上展示了两个模式,可以方便地在CKB上设计dapps:

  1. 不需要测试公钥的签名验证,可以测试锁定脚本的验证以实现灵活性。

  2. 不需要复制不同的锁定脚本,可以使用相同的锁定 检查输入cell是否存在,并将验证工作委派给输入cell的锁脚本。

从根本上讲,这取决于你的用例,来查看这些模式是否可以适用。
稍后,我们还可以通过动态链接到供应模式2来构建真正的可组合脚本。
但是,当你可以简单地通过它们进行设计时,将这些存储在你的工具库中,可能会很有用。

Always Audit Your Script 始终审计脚本

最后一点需要注意的是,在部署脚本并将真正的token放入脚本之前,应该始终记住审计脚本。上面的HTLC脚本主要用于介绍。我很容易就能发现其中的一些弱点。你不应该直接在CKB主网上使用它。但是,它确实提供了一个非常好的练习,所以如果你感兴趣,可以随意阅读脚本,看自己能否发现漏洞 :stuck_out_tongue:

Running HTLC Script on Chain 在链上运行HTLC脚本

对CKB脚本的测试分为两部分:之前,我们使用了一个off-chain调试器环境来测试脚本,以加快迭代速度。 现在我们有了完整的HTLC脚本,我们还应该将它部署到开发链上,并测试整个工作流。毕竟任何区块链智能合约都不能单独存在,它们必须有一个环境,来帮助准备交易并在链上调用它们。CKB更是如此,因为CKB使用独立的验证器-生成器模型。

为了在链上测试我们的HTLC脚本,我们将再次使用htlc-runner环境,并编写一些可以在chain上部署和测试HTLC脚本的节点可执行程序。 我们将编写的第一个可执行文件,这个可执行文件,可部署duktape二进制以及我们的HTLC脚本链:

$ cd $TOP/htlc-runner
$ cat deploy_scripts.js
#!/usr/bin/env node

const CKB = require("@nervosnetwork/ckb-sdk-core").default
const utils = require("@nervosnetwork/ckb-sdk-utils")
const process = require('process')
const fs = require('fs')

if (process.argv.length !== 6) {
  console.log(`Usage: ${process.argv[1]} <duktape load0 binary> <js script> <private key> <node URL>`)
  process.exit(1)
}

const duktapeBinary = fs.readFileSync(process.argv[2])
const jsScript = fs.readFileSync(process.argv[3])

const privateKey = process.argv[4]
const nodeUrl = process.argv[5]

const run = async () => {
  const ckb = new CKB(nodeUrl)
  const secp256k1Dep = await ckb.loadSecp256k1Dep()

  const publicKey = ckb.utils.privateKeyToPublicKey(privateKey)
  const publicKeyHash = `0x${ckb.utils.blake160(publicKey, 'hex')}`

  const lockScript = {
    hashType: secp256k1Dep.hashType,
    codeHash: secp256k1Dep.codeHash,
    args: publicKeyHash
  }
  const lockHash = ckb.utils.scriptToHash(lockScript)

  const unspentCells = await ckb.loadCells({
    lockHash
  })
  const totalCapacity = unspentCells.reduce((sum, cell) => sum + BigInt(cell.capacity), 0n)

  // For simplicity, we will just use 1 CKB as fee. On a real setup you
  // might not want to do this.
  const fee = 100000000n
  const duktapeBinaryCapacity = BigInt(duktapeBinary.length) * 100000000n + 4100000000n
  const jsScriptCapacity = BigInt(jsScript.length) * 100000000n + 4100000000n

  const outputs = [
    {
      lock: {
        codeHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
        hashType: 'data',
        args: '0x'
      },
      type: null,
      capacity: '0x' + duktapeBinaryCapacity.toString(16)
    },
    {
      lock: {
        codeHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
        hashType: 'data',
        args: '0x'
      },
      type: null,
      capacity: '0x' + jsScriptCapacity.toString(16)
    },
    {
      lock: lockScript,
      type: null,
      capacity: '0x' + (totalCapacity - jsScriptCapacity - duktapeBinaryCapacity - fee).toString(16)
    }
  ]
  const outputsData = [
    utils.bytesToHex(duktapeBinary),
    utils.bytesToHex(jsScript),
    '0x'
  ]

  const transaction = {
    version: '0x0',
    cellDeps: [
      {
        outPoint: secp256k1Dep.outPoint,
        depType: 'depGroup'
      }
    ],
    headerDeps: [],
    inputs: unspentCells.map(cell => ({
      previousOutput: cell.outPoint,
      since: '0x0'
    })),
    outputs,
    witnesses: [
      {
        lock: '',
        inputType: '',
        outputType: ''
      }
    ],
    outputsData
  }
  const signedTransaction = ckb.signTransaction(privateKey)(transaction)

  const txHash = await ckb.rpc.sendTransaction(signedTransaction, 'passthrough')

  console.log(`Transaction hash: ${txHash}`)
  fs.writeFileSync('deploy_scripts_result.txt', txHash)
}

run()

第二个可执行文件使用HTLC脚本创建一个cell作为锁:

$ cd $TOP/htlc-runner
$ cat create_htlc_cell.js
#!/usr/bin/env node

const { Molecule } = require('molecule-javascript')
const crc32 = require('crc32')
const CKB = require("@nervosnetwork/ckb-sdk-core").default
const utils = require("@nervosnetwork/ckb-sdk-utils")
const process = require('process')
const fs = require('fs')

function blake2b(buffer) {
  return utils.blake2b(32, null, null, utils.PERSONAL).update(buffer).digest('binary')
}

if (process.argv.length !== 8) {
  console.log(`Usage: ${process.argv[1]} <duktape load0 binary> <deployed tx hash> <private key> <node URL> <lock hash A> <lock hash B>`)
  process.exit(1)
}

const duktapeBinary = fs.readFileSync(process.argv[2])
const duktapeHash = blake2b(duktapeBinary)

const deployedTxHash = process.argv[3]
const privateKey = process.argv[4]
const nodeUrl = process.argv[5]
const lockHashA = process.argv[6]
const lockHashB = process.argv[7]

function hexStringToHexStringArray(s) {
  let arr = []
  for (let i = 2; i < s.length; i += 2) {
    arr.push('0x' + s.substr(i, 2))
  }
  return arr
}

const run = async () => {
  const ckb = new CKB(nodeUrl)
  const secp256k1Dep = await ckb.loadSecp256k1Dep()

  const publicKey = ckb.utils.privateKeyToPublicKey(privateKey)
  const publicKeyHash = `0x${ckb.utils.blake160(publicKey, 'hex')}`

  const lockScript = {
    hashType: secp256k1Dep.hashType,
    codeHash: secp256k1Dep.codeHash,
    args: publicKeyHash
  }
  const lockHash = ckb.utils.scriptToHash(lockScript)

  const unspentCells = await ckb.loadCells({
    lockHash
  })
  const totalCapacity = unspentCells.reduce((sum, cell) => sum + BigInt(cell.capacity), 0n)

  // For simplicity, we will just use 1 CKB as fee. On a real setup you
  // might not want to do this.
  const fee = 100000000n
  const htlcCellCapacity = 200000000000n

  const customSchema = JSON.parse(fs.readFileSync('../htlc-template/src/htlc-combined.json'))
  const htlcArgsType = new Molecule(
    customSchema.declarations.find(d => d.name == "HtlcArgs"))
  const htlcScriptArgs = htlcArgsType.serialize([
    ['a', hexStringToHexStringArray(lockHashA)],
    ['b', hexStringToHexStringArray(lockHashB)],
    ['hash', hexStringToHexStringArray('0x' + crc32('i am a secret'))]
  ])

  const transaction = {
    version: '0x0',
    cellDeps: [
      {
        outPoint: secp256k1Dep.outPoint,
        depType: 'depGroup'
      }
    ],
    headerDeps: [],
    inputs: unspentCells.map(cell => ({
      previousOutput: cell.outPoint,
      since: '0x0'
    })),
    outputs: [
      {
        lock: {
          codeHash: utils.bytesToHex(duktapeHash),
          hashType: 'data',
          args: htlcScriptArgs
        },
        type: null,
        capacity: '0x' + htlcCellCapacity.toString(16)
      },
      {
        lock: lockScript,
        type: null,
        capacity: '0x' + (totalCapacity - fee - htlcCellCapacity).toString(16)
      }
    ],
    witnesses: [
      {
        lock: '',
        inputType: '',
        outputType: ''
      }
    ],
    outputsData: [
      '0x',
      '0x'
    ]
  }
  const signedTransaction = ckb.signTransaction(privateKey)(transaction)

  const txHash = await ckb.rpc.sendTransaction(signedTransaction, 'passthrough')

  console.log(`Transaction hash: ${txHash}`)
  fs.writeFileSync('create_htlc_cell_result.txt', txHash)
}

run()

值得一提的是,这个可执行文件展示了我们如何序列化一个 molecule格式的数据结构:

// ...

function hexStringToHexStringArray(s) {
  let arr = []
  for (let i = 2; i < s.length; i += 2) {
    arr.push('0x' + s.substr(i, 2))
  }
  return arr
}

// ...

const customSchema = JSON.parse(fs.readFileSync('../htlc-template/src/htlc-combined.json'))
const htlcArgsType = new Molecule(
  customSchema.declarations.find(d => d.name == "HtlcArgs"))
const htlcScriptArgs = htlcArgsType.serialize([
  ['a', hexStringToHexStringArray(lockHashA)],
  ['b', hexStringToHexStringArray(lockHashB)],
  ['hash', hexStringToHexStringArray('0x' + crc32('i am a secret'))]
])

// ...

现在一个可执行文件试图解锁HTLC保护cell提供加密字符串:

$ cd $TOP/htlc-runner
$ cat unlock_via_secret_string.js
#!/usr/bin/env node

const { Molecule } = require('molecule-javascript')
const crc32 = require('crc32')
const CKB = require("@nervosnetwork/ckb-sdk-core").default
const utils = require("@nervosnetwork/ckb-sdk-utils")
const process = require('process')
const fs = require('fs')

function blake2b(buffer) {
  return utils.blake2b(32, null, null, utils.PERSONAL).update(buffer).digest('binary')
}

if (process.argv.length !== 8) {
  console.log(`Usage: ${process.argv[1]} <deployed tx hash> <htlc cell tx hash> <private key> <node URL> <secret string> <dry run>`)
  process.exit(1)
}

const deployedTxHash = process.argv[2]
const htlcCellTxHash = process.argv[3]
const privateKey = process.argv[4]
const nodeUrl = process.argv[5]
const secretString = process.argv[6]
const dryrun = process.argv[7] === 'true'

function stringToHexStringArray(s) {
  let a = []
  for (let i = 0; i < s.length; i++) {
    a.push('0x' + ('00' + s.charCodeAt(i).toString(16)).slice(-2))
  }
  return a
}

const run = async () => {
  const ckb = new CKB(nodeUrl)
  const secp256k1Dep = await ckb.loadSecp256k1Dep()

  const publicKey = ckb.utils.privateKeyToPublicKey(privateKey)
  const publicKeyHash = `0x${ckb.utils.blake160(publicKey, 'hex')}`

  const lockScript = {
    hashType: secp256k1Dep.hashType,
    codeHash: secp256k1Dep.codeHash,
    args: publicKeyHash
  }
  const lockHash = ckb.utils.scriptToHash(lockScript)

  const unspentCells = await ckb.loadCells({
    lockHash
  })
  const totalCapacity = unspentCells.reduce((sum, cell) => sum + BigInt(cell.capacity), 0n)

  // For simplicity, we will just use 1 CKB as fee. On a real setup you
  // might not want to do this.
  const fee = 100000000n
  const htlcCellCapacity = 200000000000n

  const customSchema = JSON.parse(fs.readFileSync('../htlc-template/src/htlc-combined.json'))
  const htlcWitnessType = new Molecule(
    customSchema.declarations.find(d => d.name == "HtlcWitness"))
  const htlcWitness = htlcWitnessType.serialize([
    ['s', stringToHexStringArray(secretString)],
    ['i', ['0x0', '0x0', '0x0', '0x0']]
  ])

  const transaction = {
    version: '0x0',
    cellDeps: [
      // Due to the requirement of load0 duktape binary, JavaScript source cell
      // should be the first one in cell deps
      {
        outPoint: {
          txHash: deployedTxHash,
          index: "0x1"
        },
        depType: 'code'
      },
      {
        outPoint: {
          txHash: deployedTxHash,
          index: "0x0"
        },
        depType: 'code'
      },
      {
        outPoint: secp256k1Dep.outPoint,
        depType: 'depGroup'
      }
    ],
    headerDeps: [],
    inputs: unspentCells.map(cell => ({
      previousOutput: cell.outPoint,
      since: '0x0'
    })),
    outputs: [
      {
        lock: lockScript,
        type: null,
        capacity: '0x' + (totalCapacity + htlcCellCapacity - fee).toString(16)
      }
    ],
    witnesses: unspentCells.map(_cell => '0x'),
    outputsData: [
      '0x',
      '0x'
    ]
  }
  transaction.inputs.push({
    previousOutput: {
      txHash: htlcCellTxHash,
      index: "0x0"
    },
    since: '0x0'
  })
  transaction.witnesses[0] = {
    lock: '',
    inputType: '',
    outputType: ''
  }
  const signedTransaction = ckb.signTransaction(privateKey)(transaction)
  signedTransaction.witnesses.push(htlcWitness)

  if (dryrun) {
    try {
      const result = await ckb.rpc.dryRunTransaction(signedTransaction)
      console.log(`Dry run success result: ${JSON.stringify(result, null, 2)}`)
    } catch (e) {
      console.log(`Dry run failure result: ${JSON.stringify(JSON.parse(e.message), null, 2)}`)
    }
  } else {
    const txHash = await ckb.rpc.sendTransaction(signedTransaction, 'passthrough')

    console.log(`Transaction hash: ${txHash}`)
    fs.writeFileSync('unlock_via_secret_string_result.txt', txHash)
  }
}

run()

最后一个可执行文件试图解锁HTLC保护的cell,假设等待期已经过去:

$ cd $TOP/htlc-runner
$ cat unlock_via_timeout.js
#!/usr/bin/env node

const { Molecule } = require('molecule-javascript')
const crc32 = require('crc32')
const CKB = require("@nervosnetwork/ckb-sdk-core").default
const utils = require("@nervosnetwork/ckb-sdk-utils")
const process = require('process')
const fs = require('fs')

function blake2b(buffer) {
  return utils.blake2b(32, null, null, utils.PERSONAL).update(buffer).digest('binary')
}

if (process.argv.length !== 8) {
  console.log(`Usage: ${process.argv[1]} <deployed tx hash> <htlc cell tx hash> <private key> <node URL> <header hash> <dry run>`)
  process.exit(1)
}

const deployedTxHash = process.argv[2]
const htlcCellTxHash = process.argv[3]
const privateKey = process.argv[4]
const nodeUrl = process.argv[5]
const headerHash = process.argv[6]
const dryrun = process.argv[7] === 'true'

const run = async () => {
  const ckb = new CKB(nodeUrl)
  const secp256k1Dep = await ckb.loadSecp256k1Dep()

  const htlcCellTx = await ckb.rpc.getTransaction(htlcCellTxHash)
  const htlcCellHeaderHash = htlcCellTx.txStatus.blockHash

  const publicKey = ckb.utils.privateKeyToPublicKey(privateKey)
  const publicKeyHash = `0x${ckb.utils.blake160(publicKey, 'hex')}`

  const lockScript = {
    hashType: secp256k1Dep.hashType,
    codeHash: secp256k1Dep.codeHash,
    args: publicKeyHash
  }
  const lockHash = ckb.utils.scriptToHash(lockScript)

  const unspentCells = await ckb.loadCells({
    lockHash
  })
  const totalCapacity = unspentCells.reduce((sum, cell) => sum + BigInt(cell.capacity), 0n)

  // For simplicity, we will just use 1 CKB as fee. On a real setup you
  // might not want to do this.
  const fee = 100000000n
  const htlcCellCapacity = 200000000000n

  const customSchema = JSON.parse(fs.readFileSync('../htlc-template/src/htlc-combined.json'))
  const htlcWitnessType = new Molecule(
    customSchema.declarations.find(d => d.name == "HtlcWitness"))
  const htlcWitness = htlcWitnessType.serialize([
    ['s', []],
    ['i', ['0x1', '0x0', '0x0', '0x0']]
  ])

  const transaction = {
    version: '0x0',
    cellDeps: [
      // Due to the requirement of load0 duktape binary, JavaScript source cell
      // should be the first one in cell deps
      {
        outPoint: {
          txHash: deployedTxHash,
          index: "0x1"
        },
        depType: 'code'
      },
      {
        outPoint: {
          txHash: deployedTxHash,
          index: "0x0"
        },
        depType: 'code'
      },
      {
        outPoint: secp256k1Dep.outPoint,
        depType: 'depGroup'
      }
    ],
    headerDeps: [
      htlcCellHeaderHash,
      headerHash,
    ],
    inputs: unspentCells.map(cell => ({
      previousOutput: cell.outPoint,
      since: '0x0'
    })),
    outputs: [
      {
        lock: lockScript,
        type: null,
        capacity: '0x' + (totalCapacity + htlcCellCapacity - fee).toString(16)
      }
    ],
    witnesses: unspentCells.map(_cell => '0x'),
    outputsData: [
      '0x',
      '0x'
    ]
  }
  transaction.inputs.push({
    previousOutput: {
      txHash: htlcCellTxHash,
      index: "0x0"
    },
    since: '0x0'
  })
  transaction.witnesses[0] = {
    lock: '',
    inputType: '',
    outputType: ''
  }
  const signedTransaction = ckb.signTransaction(privateKey)(transaction)
  signedTransaction.witnesses.push(htlcWitness)

  if (dryrun) {
    try {
      const result = await ckb.rpc.dryRunTransaction(signedTransaction)
      console.log(`Dry run success result: ${JSON.stringify(result, null, 2)}`)
    } catch (e) {
      console.log(`Dry run failure result: ${JSON.stringify(JSON.parse(e.message), null, 2)}`)
    }
  } else {
    const txHash = await ckb.rpc.sendTransaction(signedTransaction, 'passthrough')

    console.log(`Transaction hash: ${txHash}`)
    fs.writeFileSync('unlock_via_timeout_result.txt', txHash)
  }
}

run()

我们将HTLC输入cell的区块头dep放在索引0处,将测试当前时间戳的头放置在索引1处,因此,当我们准备 witness数据时,我们将0x01000000用于i,这是1的小字端表示。

这也提供了不同的启发。要在CKB中证明某个时间已经过去,可以使用Nervos DAO validator脚本中所示的since字段,也可以在链上包括一个区块头,并依靠区块头的区块高度或时间戳来证明已经达到了某个时间。这取决于你的用例,才能确定哪一个是更好的选择。

在这里准备好所有4个可执行文件之后,我们就可以开始使用HTLC脚本了。 但首先,让我们先运行一个新的CKB开发链。

$ cd $TOP
$ export CKB="<path to your ckb binary>"
$ $CKB --version
ckb 0.28.0 (728eff2 2020-02-04)
# Block assembler args configured here correspond to the following private key:
# 0x0a14c6fd7af6a3f13c9e2aacad80d78968de5d068a342828080650084bf20104
$ $CKB init -c dev -C ckb-data --ba-arg 0x5a7487f529b8b8fd4d4a57c12dc0c70f7958a196
$ $CKB run -C ckb-data

在另一个终端,我们启动一个挖矿实例:

$ cd $TOP
$ $CKB miner -C ckb-data

我们使用CKB的开发链,因为已经有两个带有余额的方便的地址,在测试前我们不需要挖矿。此外,使用开发链,可以自定义出块速度。只要你愿意,也可以使用testnet,需要记住的是不要使用mainnet进行测试。

随着CKB实例的运行,可以对HTLC脚本进行部署和测试。

# Make sure the HTLC script is successfully built first
$ cd $TOP/htlc-template
$ npm run build
# Ensure all scripts are runnable
$ cd $TOP/htlc-runner
$ chmod +x deploy_scripts.js
$ chmod +x create_htlc_cell.js
$ chmod +x unlock_via_secret_string.js
$ chmod +x unlock_via_timeout.js

# Let's first deploy duktape binary and JS scripts
$ ./deploy_scripts.js \
    ../ckb-duktape/build/load0 \
    ../htlc-template/build/duktape.js \
    0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc \
    "http://127.0.0.1:8114/"
This method is only for demo, don't use it in production
Transaction hash: 0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4

# Let's create a HTLC cell
$ ./create_htlc_cell.js \
    ../ckb-duktape/build/load0 \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc \
    "http://127.0.0.1:8114/" \
    0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947 \
    0xc219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd
This method is only for demo, don't use it in production
Transaction hash: 0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd

# To save us the hassle of recreating cells, both unlock executables support
# a dry run mode, where we only does full transaction verification, but do not
# commit the success ones on chain.
# First let's show that we can unlock a HTLC cell given the right secret string
# and lock script
$ ./unlock_via_secret_string.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc \
    "http://127.0.0.1:8114/" \
    "i am a secret" \
    true
This method is only for demo, don't use it in production
Dry run success result: {
  "cycles": "0xb1acc38"
}

# Given an invalid secret string, the transaction would fail the validation.
# If you have enabled debug output in CKB's configuration like mentioned here:
# https://docs.nervos.org/dev-guide/debugging-ckb-script.html#debug-syscall
# you can notice the failure lines in CKB's debug logs.
$ ./unlock_via_secret_string.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc \
    "http://127.0.0.1:8114/" \
    "invalid secret" \
    true
Dry run failure result: {
  "code": -3,
  "message": "Error { kind: ValidationFailure(-2) ...}"
}

# Given the correct secret string but an invalid public key, this would still
# fail the validation:
$ ./unlock_via_secret_string.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0x63d86723e08f0f813a36ce6aa123bb2289d90680ae1e99d4de8cdb334553f24d \
    "http://127.0.0.1:8114/" \
    "i am a secret" \
    true
Dry run failure result: {
  "code": -3,
  "message": "Error { kind: ValidationFailure(-2) ...}"
}

# Now we've tested unlocking by providing secret string, let's try unlocking
# via waiting enough time. In my setup, I have the following values:
# HTLC cell is packed in transaction:
# 0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4
# which is commited in block:
# 0x04539cff3e1a106773bc1ec35804340c0981804093ce8d7a17e9ebc37a3268ff
# whose block number is 399.
#
# I'm gonna test it with block:
# 0xe93ebb311d156847fbcdc159d1fa3c38f12613121e51582272d909379c4d1a60
# whose block number is 409, and block:
# 0x665ccfab2d854afa035f4697a2301f2bad9d4aa86506090b104f8ed18772ca01
# whose block number is 510.
# Let's first try block 510 to verify that we can unlock the HTLC cell this way:
$ ./unlock_via_timeout.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0x63d86723e08f0f813a36ce6aa123bb2289d90680ae1e99d4de8cdb334553f24d \
    "http://127.0.0.1:8114/" \
    0x665ccfab2d854afa035f4697a2301f2bad9d4aa86506090b104f8ed18772ca01 \
    true
This method is only for demo, don't use it in production
Dry run success result: {
  "cycles": "0x16c500ba"
  }
# Notice here we are unlocking using lock script hash:
# 0x63d86723e08f0f813a36ce6aa123bb2289d90680ae1e99d4de8cdb334553f24d
# which is different from unlocking by providing secret string.

# Now let's try block 409 here:
$ ./unlock_via_timeout.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0x63d86723e08f0f813a36ce6aa123bb2289d90680ae1e99d4de8cdb334553f24d \
    "http://127.0.0.1:8114/" \
    0xe93ebb311d156847fbcdc159d1fa3c38f12613121e51582272d909379c4d1a60 \
    true
Dry run failure result: {
  "code": -3,
  "message": "Error { kind: ValidationFailure(-2) ...}"
}
# As expected, this fails validatin, and if we check CKB's debug log(if you
# have enabled it), we can find log lines containing "Timeout period has not
# reached!", proving our script works as expected.

# One final step would checking unlocking with enough waiting, but using the
# wrong public key.
$ ./unlock_via_timeout.js \
    0xf30e1e8989fc3a4cb1e52dacc85090f8ff74b05e008d636b8c9154f5c296e1f4 \
    0x7de8ea6b0d6cb9941e76976d1d55edf844c4fa81485e00fb8eba2d161b5830cd \
    0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc \
    "http://127.0.0.1:8114/" \
    0x665ccfab2d854afa035f4697a2301f2bad9d4aa86506090b104f8ed18772ca01 \
    true
Dry run failure result: {
  "code": -3,
  "message": "Error { kind: ValidationFailure(-2) ...}"
}
# As expected, this also fails validation.

请注意,在每次不同的运行过程中,生成的交易哈希可能不同。所以一定要根据需要调整cell的参数。

到此,我们的HTLC脚本将按预期运行(当然不包括那些易糟糕的情况),万岁!

Compute Intensive Code in JavaScript JavaScript中的计算密集型代码

让我们往回跳一下。我一直避免在HTLC脚本中使用JavaScript编写签名验证代码。你可能会注意到,我们还使用了非常简单的CRC32哈希算法,而不是像blake2b这样更安全的哈希算法。 虽然我这么做的一个主要原因是为了这篇文章的简单(如果你读到这里,你会发现这篇文章已经很长了!),但仍不建议在JavaScript中进行这些操作,因为:

  • 加密算法需要精确的实现,虽然我并不是说你不能做到这一点,但它确实需要在更高级的语言(如JavaScript)中要更小心地构建加密算法。最好利用现有的以C或Rust编写的经过测试的库。

  • 密码算法是典型的计算密集型代码,因为我们在duktape中运行JavaScript代码,它可以很容易地将代码速度降低10倍甚至100倍。本地实现可以更快,并且可以节省大量CKB周期。

现在这里使用的duktape发行版只包含duktape,没有外部库。在将来,我可能会添加某些正式版本的 加密算法,如secp256k1和blake2b。通过这种方式,你将能够在JavaScript中使用运行快速并且安全的加密算法。但是请记住,有时上面提到的委托模式可能更适合你的用例。

Recap 回顾

我真诚地希望你已经读到这里,没有跳过文章。这是一个可笑的长帖子,但它包含了很多有用的信息,当在CKB构建脚本时:

  • 如何准备一个调试环境,已帮助编写脚本
  • 如何建立molecule格式的自定义数据结构
  • 如何序列化/反序列化molecule数据结构
  • 如何在npm上包含外部库并打包单个JavaScript以供CKB使用

如果我发现有有趣的东西可以写,我可能还会在这个系列中增加更多的帖子,但我确信这个系列中现有的7篇帖子,加上同事们发表的许多其他很棒帖子,已经为你在CKB上创建精彩的东西做好了充分的准备。我们期待你在CKB上开发出让大家惊叹的作品 :slight_smile:

2 Likes

Shooter :heart_eyes: :heart_eyes: :heart_eyes: