这一小节我们将会从零开始写一个类 Hacker News 的新闻聚合智能合约——CCTime。
首先,我们得设计一下 CCTime 智能合约有哪些功能。Hacker News 是一个独立搭建的服务,而 CCTime 是一个运行在区块链上的智能合约,有一定的局限性,所以我们只实现 Hacker News 的部分功能。
功能设计
- 账号(Account)
- 注册账号:包含用户名(username)、个人简介(bio)
- 更新账号信息:username、bio
- 文章(Article)
- 创建文章:包含标题(title)、链接(url)
- 赞赏一篇文章
- 举报一篇文章(文章被举报超过 3 次不会再展示)
- 获取所有文章(按创建时间降序,可翻页)
- 获取所有文章(按热度降序,可翻页)
- 留言(Comment)
- 创建一个留言:包括文章id(articleId)、留言内容(content)
- 获取一个文章下所有留言(按创建时间降序,可翻页)
- 举报一个留言(留言被举报超过 3 次不会再展示)
- 充值(onPay)
- 提现(onPayout)
激励机制
CCTime 合约内写死一个 genesisAddress(ASCH 公链地址),在合约注册到 ASCH 链上后,用此 genesisAddress 地址往合约内转入一定量的 XCT 作为奖池(CCTime 在 ASCH 公链发布的资产),即 CCTime 合约内的流通资产是 XCT。XCT 流通场景如下:
- 用户转入 XCT(充值)
- 注册账号或修改账号信息消耗 XCT
- 用户创建文章会得到 XCT 奖励(奖池给),每 8 小时可以发布一篇文章
- 赞赏文章消耗赞赏人的 XCT,文章的作者获得 90% 赞赏的 XCT,10% 的 XCT 进入奖池
- 用户转出 XCT(提现)
文章热度计算
文章热度是一个简单的算法,基于 Hacker News 的文章热度算法修改而成,如下:
Math.sqrt(文章赞赏数 + 文章留言数 + 1)
———————————————————————————————————
Math.pow(文章发表到当前区块经过的小时数 + 文章的举报次数 * 2 + 2, 1.8)
即:文章赞赏数、留言数越高得分越高,文章发表越久、举报次数越多得分越低。
我们已经开发了搭建 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 个模块:
- asch-contract-core: ASCH 智能合约核心实现
- asch-contract-types: 为 ASCH 智能合约定制的 .d.ts 头文件
- asch-contract-tslint: 为 ASCH 智能合约定制的 TSLint 规范
现在我们正式开始编写 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>>()
}
}
简单解释一下:
- 上述代码创建了 Account、Article、Comment 三个类,并且都添加了初始化默认值,或者使用
?
表示字段是可选的 - 在 CCTimeContract 的构造函数里,用 Mapping/Vector 定义了用来存储账号、文章和留言的字段
**注意:**目前由于 ASCH 智能合约底层存储系统用的 key-value 型的 LevelDB,所以暂时不支持复杂的查询,只能在设计智能合约的时候多加用心。
在 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)
}
需要解释以下几点:
- 合约对外可调用的函数(这里是 onPay 和 onPayout)一定要添加严格的参数验证
- 调用 this.transfer 使当前合约的地址转账到指定的地址一定数量的资产
- 声明为 private 的函数只能在内部调用,这里的 increaseHolding 即是内部的一个工具函数
- 添加 @constant 修饰器的函数为只读函数,既可以内部调用,也可以通过 HTTP 方式调用,但有个限制:
- 不能修改合约的状态,即 this 上的属性
- 不能使用 this.transfer,即不能转账
- 不能使用 this.context,因为没有区块信息
在 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
}
}
在 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
}
在 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
}
在合约根目录下运行 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; ... }"
至此,一个 CCTime 智能合约的所有功能已经全部实现。可以看出,写 ASCH 智能合约就是用 TypeScript 写一个类,使用 VS Code + create-asch-contract 脚手架可以快速的开发一个智能合约。