EthereumでTwitterっぽいものを作ってみる
作ったもの
dApp を作る練習がてら Twitter っぽいものを Ethereum で作ってみた。ツイートする・グローバルタイムライン・プロフィール・フォロー&アンフォロー・フォロー&フォロワ一覧表示など簡易な機能しかない。
ブロックチェーンにツイートが書き込まれるので削除できない点は注意してほしい。
-> [追記1] EVMのstateは削除できるので保存されたツイート自体は消せる
-> [追記2] ツイートの削除はできるが過去のツイートを過去のnodeから結構力技で掘り起こすこともできる(完璧なツイ消しは出来ない)。
デモページは下記。
使い方
当然 Ethereum の mainnet にデプロイできるほどのお金は持ってないので、testnet(ropsten)にデプロイした。rinkby や kavan など他にも testnet はあるが自分がテスト用の ETH を持っているのが ropsten しかなかったので ropsten を使っている。
準備としてMetamask(他 ethereum に接続できる wallet)のインストールは必須。faucet でテスト用の ETH を取得しておく。
ropsten の faucet としてはRopsten Ethereum (rETH) FaucetやMetaMask Ether Faucetなどがある。テスト ETH が wallet に入金されるまで結構時間がかかるので気長に待つ。
それさえ終わればあとはデモページのホームにあるConnect Wallet
を押して連携を許可するだけで良い。Wallet の接続さえできていれば ↓ こんな感じで Tweet することができる。ツイートは Blockchain に書き込まれるのに時間がかかるので反映されるまで 10~30 秒くらいかかる。一応 10 秒おきに polling で自動読み込みするようにしてあるので待ってれば更新されるはず。
技術的な話
フロントエンド
フロントエンドのフレームワークは Blitz.js を使っている。Blitz.js である理由は自分の遊び場的に使っているフロントエンドサイトのhttps://razokulover.com/が Blitz.js 製なので自動的にここで作ると Blitz.js 製になってしまうから。特別な理由はない。別に Next.js でも生の React.js でもよかった。
Web3 ライブラリはusedappを使っている。Wallet でログインさせたり、そのユーザー情報を保持してページ間で取り回すのに使っている。下記のように Provider で配下のコンポーネントを包むだけであとはどのコンポーネントからもuseEthers()
を使って必要な情報にアクセスできるので楽。
const config: Config = {
readOnlyChainId: ChainId.Hardhat,
readOnlyUrls: {
[ChainId.Hardhat]: "http://localhost:8545",
},
multicallAddresses: {
[ChainId.Hardhat]: "http://localhost:8545",
},
}
<DAppProvider config={config}>
<Main />
</DAppProvider>
Ethereum 上のコントラクトとのやりとりにはethers.jsを使っている。具体的にはコントラクトをコンパイルして生成された ABI の json を ethers.js の Contract に渡す。↓ こんな感じ。
import ABI from "app/resources/abi.json";
const getTweets = async () => {
const inteface = new utils.Interface(ABI.abi);
const contract = new Contract(
`${process.env.CONTRACT_ID}`,
inteface,
library?.getSigner()
);
const tweets = await contract.getTimeline(offset, limit);
return tweets;
};
その他は UI ライブラリに Chakra UI を使っていたりレイアウトに先日読んだEvery Layoutを参考にしてみたりはしたが特筆するほどのことはしてないので割愛。
バックエンド
バックエンドはSolidity + Hardhatを使った。
今回の目的の一つとして Solidity を書くのに慣れるというのがあったが、いい訓練になったと思う。実際に Twitter に必要なデータをコントラクト内でどう保持すれば良いかや文字列や数値の取扱い方などゼロから書いてみるとつまづくとことも多かった。そんなに難しいコードではないので全コード載せておく。
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract Twitter {
struct Tweet {
string content;
address author;
uint timestamp;
}
struct User {
address id;
}
Tweet[] public tweets;
mapping(address => User[]) public followings;
mapping(address => User[]) public followers;
constructor() {
console.log("Hello, Twitter!");
}
function setTweet(string memory _tweet) public {
require(bytes(_tweet).length > 0, "Tweet is too short");
bool isSpaceOnly = true;
for (uint i = 0; i < bytes(_tweet).length; i++) {
bytes1 rune = bytes(_tweet)[i];
if (rune != bytes1(" ")) {
isSpaceOnly = false;
break;
}
}
require(!isSpaceOnly, "Space only tweet is not allowed.");
tweets.push(Tweet({
content: _tweet,
author: msg.sender,
timestamp: block.timestamp
}));
}
function getTimeline(int offset, int limit) public view returns (Tweet[] memory) {
require(offset >= 0, "Offset must be greater than or equal to 0.");
if (uint(offset) > tweets.length) {
return new Tweet[](0);
}
int tweetLength = int(tweets.length);
int length = tweetLength - offset > limit ? limit : tweetLength - offset;
Tweet[] memory result = new Tweet[](uint(length));
uint idx = 0;
for (int i = length - offset - 1; length - offset - limit <= i; i--) {
if (i <= length - offset - 1 && length - offset - limit <= i && i >= 0) {
result[idx] = tweets[uint(i)];
idx++;
}
}
return result;
}
function getUserTweets(address _address) public view returns (Tweet[] memory) {
uint count = 0;
for (uint256 i = 0; i < tweets.length; i++) {
if (tweets[i].author == _address) {
count++;
}
}
Tweet[] memory result = new Tweet[](count);
uint idx = 0;
for (int i = int(tweets.length - 1); 0 <= i; i--) {
if (tweets[uint(i)].author == _address) {
result[idx] = tweets[uint(i)];
idx++;
}
}
return result;
}
function follow(address _address) public {
require(_address != msg.sender, "You cannot follow yourself.");
bool exists = false;
for (uint256 i = 0; i < followings[msg.sender].length; i++) {
if (followings[msg.sender][i].id == _address) {
exists = true;
}
}
if (!exists) {
followings[msg.sender].push(User({id: _address}));
}
exists = false;
for (uint256 i = 0; i < followers[_address].length; i++) {
if (followers[_address][i].id == msg.sender) {
exists = true;
}
}
if (!exists) {
followers[_address].push(User({id: msg.sender}));
}
}
function unfollow(address _address) public {
require(_address != msg.sender, "You cannot unfollow yourself.");
for (uint256 i = 0; i < followings[msg.sender].length; i++) {
if (followings[msg.sender][i].id == _address) {
for (uint j = i; j < followings[msg.sender].length - 1; j++) {
followings[msg.sender][j] = followings[msg.sender][j + 1];
}
followings[msg.sender].pop();
}
}
for (uint256 i = 0; i < followers[_address].length; i++) {
if (followers[_address][i].id == msg.sender) {
for (uint j = i; j < followers[_address].length - 1; j++) {
followers[_address][j] = followers[_address][j + 1];
}
followers[_address].pop();
}
}
}
function getFollowings(address _address) public view returns (User[] memory) {
return followings[_address];
}
function getFollowers(address _address) public view returns (User[] memory) {
return followers[_address];
}
function isFollowing(address _address) public view returns (bool) {
bool _following = false;
for (uint256 i = 0; i < followings[msg.sender].length; i++) {
if (followings[msg.sender][i].id == _address) {
return true;
}
}
return _following;
}
}
永続化するデータは Tweet の構造体を Array にしたものと followings/followers の関係を保持する map。follow/unfollow をする際は followings/followers の map にそれぞれ address を key にして User の Array を保持する形で追加している。冗長な作りだけどドキュメント DB でありがちなやり方にした。
基本的だけど i--の for 文で int を使ってなくて実行時エラーになるやつにちょっとはまった。コンパイルは通るしエラーメッセージはわかりづらいしで迷った。
そのほかにも文字列の連結とかサイズを比較したい場合にいちいち bytes に変換しないといけないとか面倒なこといと多しという感じ。できる限り Solidity で複雑なことはやるなということだと思う。
ちなみにその辺のバリデーションが面倒だったので 140 文字の制限をかけてない。とはいえ文字数が増えれば増えるほど 1 ツイートあたりのガス代が跳ね上がっていくのであまり問題ないと思っている。金があるなら好きに長文投稿してくれというストロングスタイル。
Hardhat に関しては特に難しいことはしてない。hardhat.config.js にデプロイ先の ropsten url を追加しただけ。
const dotenv = require("dotenv");
dotenv.config();
module.exports = {
solidity: "0.8.4",
defaultNetwork: "localhost",
networks: {
ropsten: {
url: process.env.ROPSTEN_URL || "",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
},
};
ゲートウェイとしてはInfuraを使っている。ちょっとはまった点としては夜の 23 時くらいだったかにデプロイを行ったら 3 時間くらい終わらなかったこと。デプロイ自体は成功するんだけど 1 回のデプロイあたりの時間がめちゃくちゃかかるのでうまく出来てるのか不安になったりした。何度か再実行かけた結果、全部デプロイ成功してたので同じコントラクトが複数デプロイされてしまった形に...。testnet でよかった。
まとめ
Ethereum で Twitter っぽいものを作った。Solidity でゼロからコントラクトを書く良い練習になった。
「Ethereum で Twitter」に関して、実際に使ってみるとわかるが、混雑してると1 ツイート数千円、1 フォロー数千円、フォロー外すのにも数千円みたいな予想通り大富豪の遊び場になってしまったので実用性はなさそうである。しかしながら testnet で運用してるので実際の金銭を失うことなく数千円かけた「にゃ〜ん」とか「うんこ」みたいな無駄なつぶやきをすること自体は結構楽しそうではある。
ブロックチェーンなので削除したくてもほぼできないから黒歴史が公開され続ける点も面白い。
またContractは公開されているからAPIは全公開されているのと同じなので独自のTwitterクライアントやBotみたいなものを作ることもできそう。再デプロイができないので機能追加は難しいかもだけど。
そういう意味では他のガス代の安いL1とかL2でコントラクトコードが用意に再デプロイできるチェーン(Solanaとかは良さそう?)があればそこにTwitterクローンみたいなものを立てるのは現実的で面白そうと思った。
そういえばインフラに関して言及してなかったけど、フロントエンドこそ Vercel を使っているがバックエンドはパブリックブロックチェーンのみなのでサーバーを自分で保持したりせずに済んでいる。運用がいらないのは楽。testnet であればタダだし。
ただ Ethereum の mainnet だと 1 コントラクトをデプロイするのに数万円とかかかるのでブロックチェーンである意味が明確にないのであれば AWS なり GCP なりでインスタンスを立てた方がよっぽど安い。アプリを1デプロイするのに数万円が飛んでいく世界、Web エンジニアには受け入れ難いと思う。
あとパフォーマンスに関してはツイート数が増えてみないとわからん。こういうTwitterみたいなアプリをEthereum上に作るとパフォーマンスどうなんだろ。データ量が増えると計算量は増えるからまぁ普通に考えるとパフォーマンスは悪化するはず。mappingからaddressで引くならO(1)だけどツイートのArrayを走査してる処理はデータ量が律速になりそう。
Solidity については CPI とか Oracle とやりとりするコード、他にはtransfer
が絡む処理を書くとかやってないことも多いのでそれは今後の課題にする。
リンク
- 今回作った Twitter っぽいやつ
- Ropsten の faucet
- フロントエンドで活躍した React 用の Web3 ライブラリ
- コントラクトとやりとりするやつ
- 今回のデモのコード。コントラクトとフロントエンドのmonorepoになっている。
その他
- ETH,Polygonアドレス。投げ銭用。
0xfB9AaE55f46F03a2FF53882b432Fbf52Fc6B668F
Discussion