Skip to content

Latest commit

 

History

History
621 lines (500 loc) · 20.1 KB

2.4 实战:CCTime.md

File metadata and controls

621 lines (500 loc) · 20.1 KB

这一小节我们将会从零开始写一个类 Hacker News 的新闻聚合智能合约——CCTime。

2.4.1 功能设计与激励机制

首先,我们得设计一下 CCTime 智能合约有哪些功能。Hacker News 是一个独立搭建的服务,而 CCTime 是一个运行在区块链上的智能合约,有一定的局限性,所以我们只实现 Hacker News 的部分功能。

功能设计

  1. 账号(Account)
    1. 注册账号:包含用户名(username)、个人简介(bio)
    2. 更新账号信息:username、bio
  2. 文章(Article)
    1. 创建文章:包含标题(title)、链接(url)
    2. 赞赏一篇文章
    3. 举报一篇文章(文章被举报超过 3 次不会再展示)
    4. 获取所有文章(按创建时间降序,可翻页)
    5. 获取所有文章(按热度降序,可翻页)
  3. 留言(Comment)
    1. 创建一个留言:包括文章id(articleId)、留言内容(content)
    2. 获取一个文章下所有留言(按创建时间降序,可翻页)
    3. 举报一个留言(留言被举报超过 3 次不会再展示)
  4. 充值(onPay)
  5. 提现(onPayout)

激励机制

CCTime 合约内写死一个 genesisAddress(ASCH 公链地址),在合约注册到 ASCH 链上后,用此 genesisAddress 地址往合约内转入一定量的 XCT 作为奖池(CCTime 在 ASCH 公链发布的资产),即 CCTime 合约内的流通资产是 XCT。XCT 流通场景如下:

  1. 用户转入 XCT(充值)
  2. 注册账号或修改账号信息消耗 XCT
  3. 用户创建文章会得到 XCT 奖励(奖池给),每 8 小时可以发布一篇文章
  4. 赞赏文章消耗赞赏人的 XCT,文章的作者获得 90% 赞赏的 XCT,10% 的 XCT 进入奖池
  5. 用户转出 XCT(提现)

文章热度计算

文章热度是一个简单的算法,基于 Hacker News 的文章热度算法修改而成,如下:

Math.sqrt(文章赞赏数 + 文章留言数 + 1)
———————————————————————————————————
Math.pow(文章发表到当前区块经过的小时数 + 文章的举报次数 * 2 + 2, 1.8)

即:文章赞赏数、留言数越高得分越高,文章发表越久、举报次数越多得分越低。

2.4.2 本地环境搭建

我们已经开发了搭建 ASCH 智能合约的脚手架工具,在安装使用之前,我们先把脚手架所需依赖安装上。

Windows

确保已安装 VS2015,运行以下命令:

$ npm config set msvs_version 2015 --global
$ npm i node-gyp -g

Linux

$ [sudo] apt-get update
$ [sudo] apt-get upgrade
$ [sudo] apt-get install libtool-bin
$ npm i node-gyp -g

Mac

$ brew update
$ brew upgrade
$ brew install libtool autoconf automake
$ npm i node-gyp -g

使用 npm 全局安装脚手架:

$ npm i create-asch-contract -g
$ create-asch-contract init
$ cd [my-asch-contract] && npm test

生成的 my-asch-contract 目录如下:

.
├── __tests__
│   └── SimpleContract.test.ts
├── contract
│   └── SimpleContract.ts
├── mock.js
├── package.json
├── tsconfig.json
└── tslint.json

各目录及文件作用如下:

  • __tests__: 存放测试文件目录
  • contract: 存放智能合约的目录
  • mock.js: 因为智能合约在执行时会注入一些全局变量,为了模拟这种环境,mock.js 注入了一些全局变量
  • tsconfig.json: TypeScript 编译配置
  • tslint: 为 ASCH 智能合约定制的 TSLint 规则
  • package.json: 合约相关信息

脚手架用到的相关模块

create-asch-contract 里用到了 3 个模块:

  1. asch-contract-core: ASCH 智能合约核心实现
  2. asch-contract-types: 为 ASCH 智能合约定制的 .d.ts 头文件
  3. asch-contract-tslint: 为 ASCH 智能合约定制的 TSLint 规范

2.4.3 搭建基本框架

现在我们正式开始编写 CCTime 合约,在 contract 目录下创建 CCTime.ts,添加如下代码:

/// <reference types="asch-contract-types" />

class Account {
  username: string = ''
  bio: string = ''
  createdAt: number = 0
}

class Article {
  id: string = '' // sha256(${transactionId}${timestamp}${authorId}${title}${url})
  transactionId: string = ''
  authorId: string = ''
  timestamp: number = 0
  title: string = ''
  url: string = ''
  rewardCount: number = 0
  commentCount: number = 0
  reportCount: number = 0
  score: number = 0
}

class Comment {
  id: string = '' // sha256(${transactionId}${timestamp}${authorId}${articleId})
  transactionId: string = ''
  timestamp: number = 0
  authorId: string = ''
  articleId: string = ''
  content: string = ''
  reportCount: number = 0
}

export default class CCTimeContract extends AschContract {
  genesisAddress: string
  holding: Mapping<bigint>

  reportThreshold: number
  accounts: Mapping<Account>// { accountId: account }
  usernames: Mapping<string>// { username: accountId }
  articles: Vector<Article>// [article, ...], 按时间升序
  articleIndexes: Mapping<number>// { articleId: 0(文章对应articles中的下标) }
  comments: Mapping<Vector<Comment>>// { articleId: [comment, ...] }, comment 时间升序
  articleReports: Mapping<Mapping<number>>// { articleId: { reporterId: 1 } }, report 时间升序
  commentReports: Mapping<Mapping<number>>// { commentId: { reporterId: 1 } }, report 时间升序

  constructor () {
    super()

    this.genesisAddress = 'GENESIS_ADDRESS'
    this.holding = new Mapping<bigint>()

    this.reportThreshold = 3
    this.accounts = new Mapping<Account>()
    this.usernames = new Mapping<string>()
    this.articles = new Vector<Article>()
    this.articleIndexes = new Mapping<number>()
    this.comments = new Mapping<Vector<Comment>>()
    this.articleReports = new Mapping<Mapping<number>>()
    this.commentReports = new Mapping<Mapping<number>>()
  }
}

简单解释一下:

  1. 上述代码创建了 Account、Article、Comment 三个类,并且都添加了初始化默认值,或者使用 ? 表示字段是可选的
  2. 在 CCTimeContract 的构造函数里,用 Mapping/Vector 定义了用来存储账号、文章和留言的字段

**注意:**目前由于 ASCH 智能合约底层存储系统用的 key-value 型的 LevelDB,所以暂时不支持复杂的查询,只能在设计智能合约的时候多加用心。

2.4.4 实现充值与提现

在 CCTimeContract 添加如下方法:

  // 充值
  @payable({ isDefault: true })
  onPay (amount: bigint, currency: string): void {
    assert(amount > 0, 'Amount must great than 0')
    assert(currency === 'XCT', 'Support XCT only')

    const senderAddress = this.context.senderAddress

    this.increaseHolding(senderAddress, amount)
  }

  // 提现
  onPayout (amount: bigint): void {
    assert(amount > 0, 'amount must greater than 0')
    const senderAddress = this.context.senderAddress
    const holding = this.getHolding(senderAddress)
    assert((holding > 0) && (amount <= holding), 'XCT not enough for payout')

    // 1. 减去用户 holding
    this.increaseHolding(senderAddress, -amount)

    // 2. 转账
    this.transfer(senderAddress, amount, 'XCT')
  }

  private increaseHolding (address: string, value: bigint | number): void {
    const holdingValue = this.holding[address] || BigInt(0)
    this.holding[address] = holdingValue + BigInt(value)
  }

  @constant
  getHolding (senderAddress: string): bigint {
    return this.holding[senderAddress] || BigInt(0)
  }

需要解释以下几点:

  1. 合约对外可调用的函数(这里是 onPay 和 onPayout)一定要添加严格的参数验证
  2. 调用 this.transfer 使当前合约的地址转账到指定的地址一定数量的资产
  3. 声明为 private 的函数只能在内部调用,这里的 increaseHolding 即是内部的一个工具函数
  4. 添加 @constant 修饰器的函数为只读函数,既可以内部调用,也可以通过 HTTP 方式调用,但有个限制:
    1. 不能修改合约的状态,即 this 上的属性
    2. 不能使用 this.transfer,即不能转账
    3. 不能使用 this.context,因为没有区块信息

2.4.5 实现账号功能

在 CCTimeContract 添加如下方法:

  /*************** Account ***************/
  // 创建或者更新账号
  createOrUpdateAccount (username: string, bio: string): void {
    assert(username && (username.length < 50), 'Please set your username with 50 characters')
    assert(bio && (bio.length < 256), 'Please set your bio with 256 characters')

    const senderAddress = this.context.senderAddress
    const _accountId = this.usernames[username]
    // 1. 用户名已存在,且不是自己,则拒绝更新
    if (_accountId) {
      assert(_accountId === senderAddress, 'This username already exists')
    }

    // 2. 扣费 XCT
    const holding = this.getHolding(senderAddress)
    const fee = BigInt(100)
    assert(holding > fee, 'XCT not enough for create or update account')
    this.increaseHolding(senderAddress, -fee)

    // 3. 创建or更新用户信息
    const account = this.accounts[senderAddress]
    if (account) {
      // 删除老的username映射
      this.usernames[account.username] = undefined
      account.username = username
      account.bio = bio
      // 新的username映射
      this.usernames[username] = senderAddress
    } else {
      // 创建用户
      this.accounts[senderAddress] = {
        username,
        bio,
        createdAt: this.context.transaction.timestamp
      }
      this.usernames[username] = senderAddress
    }
  }

2.4.6 实现文章功能

在 CCTime.ts 最外层定义 3 个 interface,用来做公开方法的返回值类型:

interface AccountInfo {
  username: string
  bio: string
  createdAt: number
}

interface ArticleInfo {
  id: string
  transactionId: string
  author?: AccountInfo
  timestamp: number
  title: string
  url: string
  rewardCount: number
  commentCount: number
  reportCount: number
  score: number
}

interface CommentInfo {
  id: string
  transactionId: string
  timestamp: number
  author?: AccountInfo
  articleId: string
  content: string
  reportCount: number
}

在 CCTimeContract 添加如下方法:

  /*************** Article ***************/
  // 计算文章热度
  private calcScore (article: Article | ArticleInfo): number {
    let elapsedHours = (this.context.transaction.timestamp - article.timestamp) / 3600000
    return Math.sqrt(article.rewardCount + article.commentCount + 1) /
      Math.pow(elapsedHours + article.reportCount * 2 + 2, 1.8)
  }

  // 创建一篇文章
  createArticle (title: string, url: string): void {
    const senderAddress = this.context.senderAddress
    const account = this.accounts[senderAddress]
    assert(account, 'Please create an account first')
    assert(title, 'Missing title')
    assert(title.length < 256, 'Title must less or equal than 256 characters')
    assert(url, 'Missing url')
    assert(url.length < 256, 'Url must less or equal than 256 characters')

    // 1. 检查距上篇文章发表大于8小时
    const lastArticle = this.getAccountLastArticle(senderAddress)
    if (lastArticle) {
      assert(this.context.transaction.timestamp - lastArticle.timestamp > 28800000, 'create article too frequently')
    }

    const transactionId = this.context.transaction.id
    const authorId = this.context.senderAddress
    const timestamp = this.context.transaction.timestamp
    const article = {
      id: Crypto.sha256.hash(`${transactionId}${timestamp}${authorId}${title}${url}`),
      transactionId,
      authorId,
      timestamp,
      title,
      url,
      rewardCount: 0,
      commentCount: 0,
      reportCount: 0,
      score: 0
    }

    // 2. 先写入全部 articles
    this.articles.push(article)

    // 3. 存储索引
    this.articleIndexes[article.id] = this.articles.size() - 1

    // 4. 奖励作者(记账)
    const reward = BigInt(10)
    const genesisAddressHolding = this.getHolding(this.genesisAddress)
    assert(genesisAddressHolding > reward, 'XCT pool not enough')

    this.increaseHolding(senderAddress, reward)
    this.increaseHolding(this.genesisAddress, -reward)
  }

  // 获取一篇文章
  private getOneArticle (articleId: string): Article {
    const index = this.articleIndexes[articleId]!
    assert(index !== undefined, 'Cannot find this article')

    const article = this.articles[index]
    assert(article, 'Cannot find this article')

    return article!
  }

  // 获取账号最后创建的一篇文章
  private getAccountLastArticle (accountId: string): Article | undefined {
    let count = this.articles.size()
    let article: Article
    for (let i = count - 1; i >= 0; i--) {
      const _article = this.articles[i]!
      if (_article.authorId === accountId) {
        article = _article
        break
      }
    }

    return article!
  }

  // 赞赏文章
  rewardArticle (articleId: string, amount: number): void {
    const senderAddress = this.context.senderAddress
    const account = this.accounts[senderAddress]
    assert(account, 'Please create an account first')
    assert(articleId, 'Missing articleId')
    const article = this.getOneArticle(articleId)
    // assert(article, 'Cannot find this article')
    assert(amount > 10, 'Amount must greater than 10')
    const remainingAmount = this.getHolding(senderAddress)
    assert(remainingAmount > amount, 'XCT not enough for reward this article')

    // 1. 先减去用户 XCT
    this.increaseHolding(senderAddress, -amount)

    // 2. 增加文章 rewardCount
    article!.rewardCount += amount

    // 3. 打赏作者,10%手续费,记账
    const reward = BigInt(amount)
    const fee = BigInt(Math.floor(amount / 10))
    this.increaseHolding(senderAddress, -reward)
    this.increaseHolding(article!.authorId, reward - fee)
    this.increaseHolding(this.genesisAddress, fee)
  }

  // 举报文章
  reportArticle (articleId: string): void {
    const senderAddress = this.context.senderAddress
    const account = this.accounts[senderAddress]
    assert(account, 'Please create an account first')
    assert(articleId, 'Missing articleId')
    const article = this.getOneArticle(articleId)
    assert(article, 'Cannot find this article')

    // 1. 创建 report 记录
    this.articleReports[articleId] = this.articleReports[articleId] || new Mapping()

    assert(this.articleReports[articleId]![senderAddress] !== 1, 'You already reported this article')
    this.articleReports[articleId]![senderAddress] = 1

    // 2. 文章reportCount+1
    article!.reportCount += 1
  }

  // 获取所有文章(按创建时间降序,可翻页)
  @constant
  getArticlesByTime (limit: number, offset: number): ArticleInfo[] {
    assert(limit > 0 && limit <= 100, 'limit must greater than 0 and less or equal to 100')
    assert(offset >= 0, 'offset must greater or equal than 0')

    let count = this.articles.size()
    const articles = []
    for (let i = Math.min(offset + limit, count) - 1; i >= offset; i--) {
      const article: ArticleInfo = { ...this.articles[i]! }
      if (article.reportCount >= this.reportThreshold) {
        continue
      }
      article.author = this.accounts[this.articles[i]!.authorId]!
      articles.push(article)
    }

    return articles
  }

  // 获取所有文章(按热度降序,可翻页)
  @constant
  getArticlesByScore(limit: number, offset: number): ArticleInfo[] {
    assert(limit > 0 && limit <= 100, 'limit must greater than 0 and less or equal to 100')
    assert(offset >= 0, 'offset must greater or equal than 0')

    let count = Math.min(this.articles.size(), 500)
    // 1. 先取 500 条最新的
    let articles = []
    for (let i = Math.min(offset + limit, count) - 1; i >= offset; i--) {
      const article: ArticleInfo = { ...this.articles[i]! }
      if (article.reportCount >= this.reportThreshold) {
        continue
      }
      article.author = this.accounts[this.articles[i]!.authorId]!
      article.score = this.calcScore(article)

      articles.push(article)
    }

    // 2. score 降序
    articles = articles.sort((prev, next) => {
      return next.score - prev.score
    })

    // 3. 截取
    articles = articles.slice(offset, offset + limit)

    return articles
  }

2.4.7 实现留言功能

在 CCTimeContract 添加如下方法:

  /*************** Comment ***************/
  // 创建一个留言
  createComment (articleId: string, content: string): void {
    const senderAddress = this.context.senderAddress
    const account = this.accounts[this.context.senderAddress]
    assert(account, 'Please create an account first')
    assert(articleId, 'Missing articleId')
    const article = this.getOneArticle(articleId)
    assert(article, 'Cannot find this article')
    assert(content, 'Missing content')
    assert(content.length < 1024, 'Content must less or equal 1024 characters')

    const transactionId = this.context.transaction.id
    const timestamp = this.context.transaction.timestamp
    const comment = {
      id: Crypto.sha256.hash(`${transactionId}${timestamp}${senderAddress}${articleId}`),
      transactionId,
      timestamp,
      authorId: senderAddress,
      articleId,
      content,
      reportCount: 0
    }

    this.comments[articleId] = this.comments[articleId] || new Vector()
    this.comments[articleId]!.push(comment)
    article.commentCount += 1
  }

  // 获取一个文章下所有留言(按创建时间降序,可翻页)
  @constant
  getOneArticleComments (articleId: string, limit: number, offset: number): CommentInfo[] {
    assert(limit > 0 && limit <= 100, 'limit must greater than 0 and less or equal to 100')
    assert(offset >= 0, 'offset must greater or equal than 0')
    assert(articleId, 'Missing articleId')
    const article = this.getOneArticle(articleId)
    assert(article, 'Cannot find this article')

    let articleComments = this.comments[articleId] || (new Vector())
    let count = articleComments.size()

    const comments = []
    for (let i = Math.min(count, offset + limit) - 1; i >= offset; i--) {
      const comment: CommentInfo = { ...articleComments[i]! }
      if (comment.reportCount >= this.reportThreshold) {
        continue
      }
      comment.author = this.accounts[articleComments[i]!.authorId]!
      comments.push(comment)
    }

    return comments
  }

  // 获取一个留言
  private getOneComment (articleId: string, commentId: string): Comment | undefined {
    assert(articleId, 'Missing articleId')
    const article = this.getOneArticle(articleId)
    assert(article, 'Cannot find this article')

    let comments = this.comments[articleId] || new Vector()
    let count = comments.size()
    let comment: Comment
    for (let i = count - 1; i >= 0; i--) {
      if (comments[i]!.id === commentId) {
        comment = comments[i]!
        break
      }
    }
    return comment!
  }

  // 举报一个留言
  reportComment (articleId: string, commentId: string): void {
    const senderAddress = this.context.senderAddress
    const account = this.accounts[senderAddress]
    assert(account, 'Please create an account first')
    assert(articleId, 'Missing articleId')
    assert(commentId, 'Missing commentId')
    const comment = this.getOneComment(articleId, commentId)
    assert(comment, 'Cannot find this comment')

    // 1. 创建 report 记录
    this.commentReports[commentId] = this.commentReports[commentId] || new Mapping()

    assert(this.commentReports[commentId]![senderAddress] !== 1, 'You already reported this comment')
    this.commentReports[commentId]![senderAddress] = 1

    // 2. 留言reportCount+1
    comment!.reportCount += 1
  }

2.4.8 验证合约代码与估算 gas

在合约根目录下运行 create-asch-contract verify 命令验证合约代码是否有效:

$ create-asch-contract verify contract/CCTime.ts
// Verify success! consumed 28926 gas.

结果显示:合约代码通过检查,并且估算出注册该合约代码需要消耗 28926 gas。

我们来看下验证失败的情况,在 CCTimeContract 的 constructor 里添加 debugger 关键字,再运行上述命令,结果如下:

Cannot use: debugger, but got: "{ ... debugger; ... }"

2.4.9 总结

至此,一个 CCTime 智能合约的所有功能已经全部实现。可以看出,写 ASCH 智能合约就是用 TypeScript 写一个类,使用 VS Code + create-asch-contract 脚手架可以快速的开发一个智能合约。