# DETT 技術手記

DETT.cc 是一個基於 DEXON 的區塊鏈去中心化論壇,由 Pelith 團隊開發。
Pelith 致力於利用區塊鏈「匿名」、「不可編輯」等特性,開發一個「真・去中心化」系統。之所以強調去中心化,是因為整個 DETT 系統完全不依賴後端伺服器,而全部執行/儲存於 DEXON 區塊鏈及智慧合約上,同時充分利用 Github Page 和 Circle CI 進行部署。
本篇文章將由淺入深的介紹 DETT 的技術細節,並說明我們未來的發展方向。
## DETT 緣起
Pelith 團隊在 2019.5.6 傍晚時,注意到 DEXON 區塊鏈上出現一個非常特別的[合約](https://dexonscan.app/address/0x663002C4E41E5d04860a76955A7B9B8234475952)
```solidity
pragma solidity ^0.5.0;
contract BBS {
event Posted(string content);
function Post(string memory content) public {
emit Posted(content);
}
}
```
該合約產生後,隨即伴隨著一系列非常有趣的 Internal Tx,其共通點都是 0 DXN,而且 `data` 欄位都不是同普通交易的 `0x`
* https://dexonscan.app/transaction/0x7525962be7e8b7bca1bd4497c7a0783509a6ec32dbf8f1584869e7c7c8c58873
* https://dexonscan.app/transaction/0x1e2f2abfcc4a5e9a63c18f54ad3d1d75e9388a2da3a15c1ff5acd7464777233b
* https://dexonscan.app/transaction/0xfc3030c3c1cc55dee5ccd94755a41f68c6cbee679f9dea0bbe7115787337add4
在當時流言滿天飛的情況下,很多人很快的把這些 TXID 作為可靠的資訊管道。就在此時,Pelith 團隊萌生一個大膽的想法:我們能不能做一個 DEXON BBS?
## 初期技術目標
最初的 DETT 架構其實非常簡單,由於 DEXON 的架構和 Ethereum 幾乎完全相同,故我們直接使用 web3,也就是 metamask 擴充套件內,用以連接 ETH 網路套件,直接對 DEXON HTTP RPC 查詢過去的 Event (`getPastEvents`)
`getPastEvents` 可以指定取得某個區段開始的 block,故我們從 1170000 開始往後抓取。由於 DEXON Mainnet 大約 1 秒鐘就會出一個 block,所以我們無法知道含有特定文章的交易在哪個 block 裡面,所以只能一次抓出全部的 transaction,然後將 `data` 欄位由 hex 轉成 utf8 顯示於網站上。
## 顯示文章
列出所有 Event 後,我們同時也獲得了 Event 的 TXID,故我們就可以實作單頁的文章顯示,用 GET 參數帶入 TXID,每次使用者開啟頁面都會自動向 DEXON 查詢該 TXID 帶有的 data
```
this.BBSEvents = await this.BBS.getPastEvents('Posted', {fromBlock : _fromBlock, toBlock: _toBlock})
```
## 無伺服器,快速部署
在短時間密集的開發下,我們在一個晚上的就把 prototype 寫好,甚至套上了 PTT web 的前端風格,部署於 github page
對於 DETT 來說,我們根本不需要後端伺服器,區塊鏈就是我們最好的後端,我們網頁完全架設在 Github pages 上,每次使用者開啟網頁,都會由 Client side JS 連線到 DEXON RPC 獲得最新文章後,顯示於網頁上。
## 技術瓶頸
乍看之下,DETT 就是一個簡單,一個晚上就能寫好的小專案。確實滿足了當時大家需要管道抒發心情,也確實利用了區塊鏈不可竄改的特性來發布一些勁爆的消息。但是,我們很快地發現,還有很多問題需要解決:
1. 有沒有更簡易的介面能和合約互動?
2. 如何實作推文功能?
3. 如何實作編輯、版主管理功能?
4. 使用者能在不安裝 DEXON wallet 的情況下發文/回文嗎?
5. 由於所有內容都是由 JS 直接向 DEXON RPC 即時抓取,如何印出 `<meta>` 讓分享至 FB 時效果更好?
6. 每次使用者都要透過 web3 連線到 DEXON RPC 掃過全部 block 找到過去 event,如果文章越來越多怎麼辦?
7. 文章網址包含整段 TXID,能否縮短網址?
8. 我們不知道交易會分佈在哪些區塊上,如何達成分段 loading?
## (1) 有沒有更簡易的介面能和合約互動?
我們了解,不是所有使用者都能自己發送交易至合約上,故我們實作了一個發文介面,使用者按下確認發文後,會自動將文章內容編碼,然後透過瀏覽器的 DEXON Wallet 彈出確認交易視窗,告知交易手續費 (gas)。按下確認後,大約一秒內 (DEXON 平均產生區塊速度) 即可確認交易,看到 TXID。

## (2) 如何實作推、噓文功能?
我們無法更改原本的[發文合約](0x663002C4E41E5d04860a76955A7B9B8234475952),故我們重新 deploy 了一個合約 [BBS_Extension](https://dexonscan.app/address/0xec368ba43010056abb3e5afd01957ea1fdbd3d8f),來延伸原本合約的功能。
```solidity
Source
pragma solidity ^0.5.0;
contract BBS {
event Posted(string content);
function Post(string memory content) public {
emit Posted(content);
}
}
contract BBS_Extension {
BBS DEXON_BBS = BBS(0x663002C4E41E5d04860a76955A7B9B8234475952);
mapping(bytes32 => uint256) public upvotes;
mapping(bytes32 => uint256) public downvotes;
mapping(address => mapping(bytes32 => bool)) public voted;
event Replied(bytes32 origin, uint256 vote, string content);
function upvote(bytes32 post) internal {
require(!voted[msg.sender][post]);
voted[msg.sender][post] = true;
upvotes[post] += 1;
}
function downvote(bytes32 post) internal {
require(!voted[msg.sender][post]);
voted[msg.sender][post] = true;
downvotes[post] += 1;
}
function Post(string memory content) public {
DEXON_BBS.Post(content);
}
function Reply(bytes32 origin, uint256 vote, string memory content) public {
if(vote == 1)
upvote(origin);
else if(vote == 2)
downvote(origin);
else
vote = 0;
emit Replied(origin, vote, content);
}
}
```
注意到 `BBS_Extension` 中,宣告了原始合約的位置,故當使用者透過 DETT 發文時,其實他是改對我們的新合約發文 (呼叫函數 `Post()`),我們再幫他 Proxy 到原本合約的 `Post()` 函數
新合約中,我們定義了以下變數
* upvotes: bytes32 => uint256
* downvotes: bytes32 => uint256
* voted: address => mapping(bytes32 => bool)
當使用者嘗試推/噓/註解一個特定的文章時(根據 origin 即 TXID 判斷),我們會根據他進行推文 (vote == 1) 或噓文 (vote == 2) 互叫 `upvote` / `downvote` 函數
這些函數會首先檢查該地址是否已經推噓過文(一帳號只限一次),然後修改對應的變數記錄該推噓結果
如果是註解,則不另外呼叫函數,僅送出 Reply Event,供我們顯示文章相關推噓文的時候獲取

## (3) 如何實作編輯、版主管理功能?
```solidity
contract BBS_Edit {
event Edited(bytes32 origin, string content);
function edit(bytes32 origin, string memory content) public {
emit Edited(origin, content);
}
}
```
編輯合約的原理跟推文合約相同,我們無法修改以前的文章,所以我們只能另外對該文章 TXID 發布一個新的 Edited Event,顯示文章時則向 DEXON 查詢所有該 TXID 所屬的 Edited Event

### 編輯權限問題
我們可能會注意到,編輯文章並沒有權限控管,故任意使用者都可以對合約直接呼叫編輯 `edit()` 函數編輯別人文章,但我們在前端會檢查來源帳號阻擋該惡意編輯
```js
async getArticle(tx, checkEdited){
const transaction = await web3.eth.getTransaction(tx)
// check transaction to address is bbs contract
if (!this.isDettTx(transaction.to)) return null
const article = new Article(transaction)
await article.init()
if (checkEdited) {
const edits = this.BBSEditEvents.filter(event => event.returnValues.origin === tx )
if (edits.length >0) await article.initEdits(edits)
}
return article
}
```
### 版主管理功能
```solidity
contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev The Ownable constructor sets the original `owner` of the contract to the sender
* account.
*/
constructor() public {
owner = msg.sender;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0));
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
contract Admin is Ownable{
mapping(bytes32 => bool) public banned;
mapping(address => bool) public isAdmin;
address public category;
event Ban(bytes32 indexed origin, bool banned, address admin, string reason);
constructor(address _category) public {
category = _category;
}
function ban(bytes32 origin, bool _banned) public {
require(isAdmin[msg.sender]);
banned[origin] = _banned;
emit Ban(origin, _banned, msg.sender);
}
function setAdmin(address who, bool _isAdmin) public onlyOwner {
isAdmin[who] = _isAdmin;
}
}
```
我們實作了 Ownable 合約,讓我們能設定管理權限,進而達成 Ban 以及 setAdmin 轉移管理權限等功能。
為了我們預計採用 Dao 來做社群管理
## (4) 使用者能在不安裝 DEXON wallet 的情況下發文/回文嗎?

我們實作了一個瀏覽器錢包,使用者可以選擇由註記詞控制的錢包與 DApp 互動
而不是傳統透過 metamask (DEXON wallet) 注入的 web3 後進行操作
不過 web3 1.0 beta 37 以後就無法穩定在瀏覽器內執行,關於這個我們將於下次專文討論
## Caching 與短連結機制
本段落將回答下列提問:
* 由於所有內容都是由 JS 直接向 DEXON RPC 即時抓取,如何印出 `<meta>` 讓分享至 FB 時效果更好?
* 每次使用者都要透過 web3 連線到 DEXON RPC 掃過全部 block 找到過去 event,如果文章越來越多怎麼辦?
* 文章網址包含整段 TXID,能否縮短網址?
* 我們不知道交易會分佈在哪些區塊上,如何達成分段 loading?
DETT 堅持去中心化,所以我們不考慮用後端伺服器連接 DEXON RPC 獲取文章後,印出個別文章頁面的 meta,而是採用混合 DEXON 合約 / CI 伺服器 / Caching Server 的方式解決。
### 短連結快取合約
我們首先發佈了一個合約,用以紀錄
* TXID -> 短連結ID對應表
* Milestones (即每 20 篇文章的 blocknumber)
```solidity
contract shortURLandMilestone is Ownable {
mapping(bytes32 => bytes32) public links;
bytes32[] milestones;
event Link(bytes32 long, bytes32 short);
function link(bytes32 long, bytes32 short) public onlyOwner {
links[long] = short;
links[short] = long;
emit Link(long, short);
}
function addMilestone(bytes32 milestone) public onlyOwner {
milestones.push(milestone);
}
function clearMilestone() public onlyOwner {
delete milestones;
}
function getMilestones() public view returns(bytes32[]) {
return milestones;
}
}
```
這個合約會被我們的 Caching Server 每 30 秒跑一次時存取。並有下列兩種程序
1. 比對 DEXON 與本地 Cache 檔案,檢查是否有新文章
- 有:產生新的短連結 ID 後,送到合約內儲存
2. 檢查上一個 Milestone 到最新文章間,是否滿足 20 篇新文章
- 有:將最新文章所在之 block number 送到合約內儲存後,更新文章快照列表
依據文章快照列表,我們會繼續產生快取檔案,並在此時對 `<head>` 插入 `<meta>` Tag 讓 Facebook 分享能抓到正確的 Meta,如下檔案所示:
https://github.com/pelith/DEXON_BBS/blob/gh-pages/s/bsRHbG.html
透過合約儲存 Milestone 以及短連結網址,讓我們不再需要依賴中心化後端伺服器,而後即使 DEXON RPC 連線失敗,DETT 所有文章都還能繼續儲存於 github
### 運作流程圖
為了詳細說明 Caching 機制的運作原理,我們製作了下列流程圖供大家參考

## 結語
DETT.cc 是 Pelith 對去中心化服務的一次嘗試,我們盡力抗拒使用中心化服務的誘惑,嘗試大量使用智慧合約解決後端需求
我們期望能在去中心化的基礎上,改進 PTT 使用上的許多問題
最後,解答一個問題

https://drive.google.com/open?id=1krWVlFN7qQaUtlnJZmhIcGxSPvCBqT_w
https://ipfs.io/ipfs/QmexWTNp2kTgdRMEwhhPNFz84Aaz6skWWfHYG1C582mjzw