Symbol にはスマートコントラクトがない。だから DEX もできない。なにか仮想機械の一つでもあればなぁ。……という貴重なご意見の存在は認識しているけど、どう考えてもハードフォークが要る話なので、そう気軽には試せない。
今回は、Symbol をハードフォークせずに、その上で Ethereum Virtual Machine を動かした。cast(Foundry のコマンド)からふつうに送金もできるし、Solidity のコントラクトもデプロイできる。しかも、ノードを何台立てても、状態のルートがぴったり一致した。いいじゃないこれぇ🎉 この状態のルートが一致するってとこがまたうれしい✨
試作なので、ロールアップの中の ETH は、最初に配った分しかないただの数字だ。ブリッジもないので、外から何かを持ち込むこともできない。いつもと違って、今度はわりと安心して遊んでもらえそう💦
Symbol は、あとから間に差し込めない掲示板
Symbol のトランザクションには、メッセージを付けられる。1 つ 1 KiB まで、アグリゲートにまとめれば 100 個まで載る。そして、一度ブロックに入って確定したものは、順番も中身も変わらない。
つまり Symbol は、誰でも書き込めて、あとから順番も内容も変えられない掲示板として使える。ここに Ethereum Virtual Machine のトランザクションを順に貼っていき、貼られた順に全員が同じように実行すれば、全員が同じ状態にたどり着く。
こういうものを Sovereign Rollup と呼ぶ。
- データを置いておくこと、並び順、最終性は、下のチェーン(ここでは Symbol)に任せる
- どの状態が正しいかは、ロールアップのノードが自分の規則で決める。下のチェーンにコントラクトはいらない
Sovereign Rollup と言えばやはり Celestia の上のものが有名だ。自分で合意を取るのがチェーンで、合意を借りるのがロールアップ、くらいに覚えておけばいい。
規則を変えたくなったら、ロールアップのノードのソフトウェアを変えるだけでいい。Symbol の許しはいらない。これが Sovereign(主権がある)と呼ばれるゆえんで、ハードフォークがいらない理由でもある。
もう 1 つ、並べる係(シーケンサー)がいない。並び順は、Symbol のブロックの中の順そのものだ。これを based ordering と呼ぶ。シーケンサーがいないので、「シーケンサーに締め出される」こともない。誰かが載せてくれなくても、手数料を払って自分で Symbol に載せればいい。
載せ方
やることは単純だ。
- EVM のトランザクション(署名済みのバイト列)を、Symbol の転送のメッセージに入れる。先頭に
\0evmの 4 バイトを付ける - 宛先は、受け箱のアドレス。チェーン ID のハッシュを公開鍵の代わりにして作ったアドレスなので、誰も秘密鍵を持っていない
- 1 KiB に収まらなければ、アグリゲートの中の転送に分ける。最大で約 100 KiB まで載る
EVM のトランザクションの大きさは、ふつうの送金で 110 バイトくらい、コントラクトのデプロイでも上限は 5 万バイトくらいだ。ほとんどは 1 KiB に収まるし、デプロイも 100 KiB には余裕で収まる。
アグリゲートは全部入るか全部入らないかなので、途中の欠片だけが載ることはない。この性質のおかげで、組み立て直す側はとても楽ができた。
誰が載せてもかまわない。EVM のトランザクションには、もう送り主の署名が入っているからだ。この例では、JSON-RPC の eth_sendRawTransaction で受け取ったものを、ノードが代わりに Symbol に載せる(リレー)。手数料の XYM はリレーが払う。
手数料は、Symbol のメインネットの倍率 100 なら、ふつうの送金で 0.03 XYM くらい、8 KB のデプロイで 1 XYM くらいの計算になる(Symbol の転送そのものの大きさも込み)。
実行は revm、外から見ればふつうの EVM
実行するのは revm だ。reth や Foundry の中で動いている、Rust の EVM である。こんなときになんて丁度いいライブラリなんでしょうと感動の嵐である✨ EVM の版(Ethereum のハードフォークの名前)は、revm の既定の Osaka にした。
- ロールアップのブロックは、Symbol のブロックと 1 対 1 で、高さも同じ
- Symbol のブロックの中の順に、1 件ずつ実行する
- 読めない、署名が合わない、チェーン ID が違う、nonce が合わないものは、なかったことにする
外からは、ふつうの Ethereum の JSON-RPC に見えるようにした。なので、Foundry の cast がそのまま使える。
cast send --rpc-url http://127.0.0.1:8545 --private-key $KEY 0x000000000000000000000000000000000000dEaD --value 1ether
確定まで待つのはつらいので、状態を 2 つ持つ
Symbol の確定(ファイナライズ)には、メインネットだと数分から 10 分以上かかる。wallet でボタンを押して 10 分待たされたら、さすがにつらい。日は暮れるし🌇 動物は化石になっちゃう🦴
そこで、ノードは状態を 2 つ持つことにした。
-
finalized:確定したブロックまで。巻き戻らない -
latest:Symbol の先頭のブロックまで。確定した状態の写しに、確定していないブロックを毎回実行し直して作る
Ethereum の JSON-RPC にも、もともと latest と finalized というブロックの指定がある。それにそのまま合わせただけだ。送ったものは Symbol のブロックに入ればすぐ latest に見えて、大事な大事な重いことをするときだけ finalized を待てばいい。
Symbol の先頭のほうが巻き戻っても、次に作り直すときに勝手に直る。毎回作り直すのは富豪的だけど、試作なのでまあ……よしとする😇 私が許す🫠
ルートが合わない!と思ったら anvil の癖だった
いちばん確かめたかったのは、実行の結果が本物の EVM と同じかだ。そこで、anvil と同じ最初の状態から同じ送金を実行して、状態のルート(全口座の状態をまとめたハッシュ)を突き合わせた。
最初のルートは一致した。トランザクションのハッシュも一致した。なのに、送金したあとのルートだけが合わない。はいー?なんでー😱
調べていくと、原因はこっちではなく anvil の側にあった。
- anvil は、Prague 以降のシステムのコントラクトを勝手に置いている(古いハードフォークで動かせば消える)
- anvil は、渡した genesis に、自分の開発用の口座を 10 個と、CREATE2 のデプロイヤーを足している。なのに、ブロック 0 のルートだけは、渡した genesis のとおりに出していた。最初のルートが一致したのは、そのせいだった
- anvil は、手数料 0 で触った空の coinbase を、ルートに残す。Ethereum の決まり(EIP-161)では、触られて空になった口座は消える
3 つ目は、こっちの実装のほうが決まりどおりだった。anvil の coinbase を中身のある口座にして条件を揃えたら、ルートがぴったり一致した。
anvil の本当の最初の状態をそのまま genesis にして、同じ送金を実行し、同じルートになることを、テストに入れてある。テストで anvil の癖まで記録することになるとは思わなかった。heika のリポジトリなのに……。
ガス代 0 は、よくなかった
最初は、ガス代を 0 にしていた。スパムは、Symbol に載せるときの XYM の手数料で防げるだろうしもういいじゃん面倒だしと思ったからだ。
ところが、Symbol の手数料は大きさで決まる。計算の重さとは関係ない。100 バイトの、無限ループ寸前の重いトランザクションを、安い XYM でいくらでも並べられてしまう。
それで、Symbol のブロック 1 つで使えるガスの合計に上限を置いた。超えたものは入らない。……のだけど、これは使う側からするとかなり微妙だった。
- Symbol の手数料は払って、トランザクションも Symbol のブロックに入っている
- なのにロールアップでは入らなかったことになり、何も起きない
- しかも原因は、同じブロックに入ったほかの人の重いトランザクション
いくらなんでも理不尽、まさに汚客様が神様である🫠
そこで、Ethereum の EIP-1559 と同じく、ガス代を basefee で払ってもらうことにした🔥
- basefee は、トランザクション 1 件ごとに決める。1 秒あたり 100 万ガスの目標より多く使われると、指数で上がっていき、時間がたつと下がる。式は EIP-4844 の blob の手数料と同じものを借りた
- 時間は Symbol のブロックの時刻で測るので、誰が計算しても同じになる
- 払った basefee は燃える🔥
- 払えなければ失敗🔥 署名と nonce が合っていれば必ずレシートを出して、払えなかったものは失敗(status 0)にする。nonce は使ったことにする
ブロックのガスの合計の上限は、なくした。混んでいたら値段が上がるだけで、払える人は必ず実行されるし、払えない人は自分の分が失敗するだけだ。ほかの人のせいで弾かれることはない。
cast は JSON-RPC で今の basefee を聞いて、自分で値段を付けてくれる。値段 0 で無理やり送ると、すぐに失敗のレシートが返ってくる。ちゃんと失敗してくれるのは、ありがたいことだ。
ちなみに、燃やすのはロールアップの中の ETH で、XYM ではない。ロールアップの中の ETH は最初に配った分しかないので、燃やせばちゃんと重さの値段になる🔥🔥🔥
動かしてみた
heika のノード 4 台のプライベートネットで動かした。ロールアップのノードは、リレーの鍵を持つものと、読むだけのものの 2 台だ(basefee にする前は、あとから起動したものを足して 3 台)。
-
cast sendの送金が Symbol のブロックに入り、数秒でlatestに見えた(※プライベートネットはブロックが速い設定)。値段は cast が basefee を見て付けてくれた - 値段 0 で送ると、すぐに失敗のレシートが返り、nonce が進んだ
- 確定したあとの状態のルートは、2 台とも同じだった
ここから下は、ガス代を basefee にする前(ガス代 0 のころ)に確かめたものだ。
- 前の記事のブリッジで使った wXYM のコントラクト(8 KB あまり)を、そのままデプロイできた。9 つのメッセージのアグリゲートに分かれて載り、1 回で通った。できたアドレスの決まり方も Ethereum と同じ(送り主と nonce から決まる)だ
- 残高のない
burnは、cast estimateでは revert の理由付きで断られ、無理やり送ると失敗として入った - 確定したあとの状態のルートは、3 台とも同じだった。あとから起動したノードも、最初から読み直して同じルートになった
Solidity のコントラクトが、手を入れずにそのまま動く。Symbol の上で。と言っても Sovereign Rollup だけど🫠 でも割と気軽に動いたのは大きいかも✨
信じているもの
- Symbol: データがあること、並び順、最終性。確定したブロックまでで状態を決めるので、Symbol のファイナライズの投票者を信じることになる。これは、もともと Symbol を使う人がみんな置いている前提だ
- 読んでいる Symbol のノード: ロールアップのノードは、Symbol のノードの REST を読んでいる。自分で動かしているノードなら、自分で確かめたものを読んでいるだけだ
- ハーベスター: ブロックの中の順を決めるのはハーベスターなので、MEV(順番で得をすること)もハーベスターのものになる。based ordering の性質である
逆に言うと、これ以外は何も信じなくていい。別のライトクライアントも、オラクルも、委員会もいない。Symbol のノードを 1 台動かしていれば、その横に置くだけでロールアップのノードになる。
やっていないこと
-
MetaMask などの wallet では試していない。 JSON-RPC は、cast で送金、デプロイ、呼び出しをするのに要るものだけだ。
eth_getLogsもまだ作ってない。困る - 状態を保存しない。 起動のたびに、最初から読み直す
- 古いブロックがいる。 最初から読み直すので、そこからのブロックを持っている Symbol のノードが要る。古いブロックを捨てるライトノードでは足りない。……ライトノードで古いブロックを捨てられるようにしたの、私なんだけど🫠
- 未承認のトランザクションを持たない。 続けて送るときは、前のものがブロックに入るのを待つ必要がある
- ブリッジがない。 XYM をロールアップに持ち込むことも、出すこともできない
- 依存に、直っていない 🚨 勧告 🚨 が残っている。 revm と alloy が使う古い楕円曲線のライブラリの下に、「秘密の値で計算の時間が変わることがある」という勧告(RUSTSEC-2026-0387)が出ている。ロールアップのノードがその計算に使うのは、署名から送り主を割り出すことと precompile の計算だけで、どちらも公開されたデータだ。時間を測られても漏れるものはないので、CI でもこの例だけ見逃してあげている🫠
ブリッジをかけるなら
ブリッジはまだないけど、作るとしたら形はだいたい見えている。
- 入れるとき: ロールアップは Symbol を直接読んでいる。なので、「金庫のアドレスに XYM が送られてきて、メッセージに EVM のアドレスが書いてあったら、その分を発行する」という規則を書くだけで済む。ここに委員会はいらない
- 出すとき: Symbol の金庫から XYM を送るには、誰かの署名がいる。ここだけは、閾値署名の委員会に頼むことになる
前の記事のブリッジでは、EVM の側で発行するのにも見張り役の署名が必要だった。
ロールアップにすると、Symbol から EVM に入れる側から委員会が不要になる。しかも EVM から Symbol に出す側は Symbol の Ed25519 の署名だけでいいので、EVM 用の secp256k1 の鍵もいらなくなる。秘密鍵で secp256k1 を使う場面がなくなるので、ちょうど依存ライブラリのそのまた依存ライブラリの脆弱性対応の関係で微妙な感じになっているところを多少クリアできる。
もうひとつ、地味だけど効いてくるのが、確かめるコンセンサスが Symbol の 1 つだけになることだ。ふつう、2 つのチェーンをつなぐには、片方の中にもう片方のライトクライアント(相手のブロックが確定したかを確かめるしくみ)を置くか、それができないので見張り役を信じるかになる。前の記事の見張り役も、Symbol と EVM の両方を見ていた。
ロールアップの状態は、Symbol の確定したブロックから決まる。入れるときは、ロールアップが Symbol を直接読むので、EVM の中に Symbol のライトクライアントはいらない。出すときも、委員会が確かめるのは「Symbol のこの高さまで確定していて、そこまで実行するとこの引き出しがある」ということだけで、それは Symbol のノードとロールアップのノードを 1 つずつ動かせばわかる。信じるものは、Symbol のコンセンサスとロールアップの規則(状態の遷移)だけになる。片方だけ巻き戻って食い違う、という心配もない。確定は 1 つしかないからだ。
とはいえ、Symbol の側は EVM を実行できないので、出すときに委員会を信じることは残る。そこは何もお得にはならない🫠
さらに欲張るなら、XYM だけでなく Symbol のモザイクを全部持ってきて、1 つの ERC-6909(ERC-1155 から、受け取る側のコントラクトを呼び返すしくみを抜いた、軽い複数トークンの規格)にでもまとめてしまえばいい。Symbol とつながるのはその ERC-6909 だけなので、そこだけを precompile にして、ほかは全部ふつうの EVM のままにできる。……と、夢だけはなんぼでも広がるものの着手しなければいつまでも夢のままである😇
おわりに
Symbol を掲示板にして、EVM を載せた。ハードフォークもいらないし、Symbol のノードの横に置くだけで動く。「Symbol には VM がない」は、「Symbol の上で VM を動かせない」という意味ではなかったようだ。
今回作ったのは EVM だったけど、実行する部分を差し替えれば、別の規則のロールアップも作れる。親の顔より見た Vue のデモのようなカウンタでも、碁でも、先着順の何かでも、プライバシーのあるコインでも。こういう掲示板の上でなら、わりとなんだって動く。というか同じものを同じ規則で動かしてもらったらそりゃ同じ結果になるのでそりゃそうではあるが……。