importmap-railsを使ってMetaMask認証をしてみる。

## あいさつ
こんにちは、[ryosk7](https://x.com/ryosk7)です。

この記事は、[Ruby on Rails Advent Calendar 2023](https://qiita.com/advent-calendar/2023/ruby-on-rails) 16日目の記事です。

## はじめに

技術書典15で出展した「Rubyでつくる NFTマーケットプレイス」を参考に、MetaMask認証機能を作っていきます。

https://techbookfest.org/product/4D0SCAdL9Rk75SttLJ4xsS?productVariantID=mihs8CKjPy3SvwUwF1N3XJ

元々、NFTマーケットプレイスを実装する際に、こちらの記事を参考にしていました。

https://dev.to/q9/finally-authenticating-rails-users-with-metamask-3fj

ただし、こちらの記事ではJavaScript(以下JS)をゴリゴリ書いており、筆者はそこまでJSを書きたくなかったので、なにかいい手はないかと考えたところ、一つの解決策に辿り着きました。
それが、**『importmap-rails』** です。

## importmap-rails
Rails 7.0から標準搭載となった機能です。
こちらの記事がとてもわかりやすいです。

https://zenn.dev/takeyuwebinc/articles/996adfac0d58fb

要約すると、
- JS の Bundling が不要
    - ブラウザが必要なファイルを取得してくれる
- HTTP/2で並列処理
    - 複数ファイルを並列して処理してくれる
- Bundlingが不要になったため、変更のないファイルはキャッシュを利用可能
    - 新たにダウンロードするファイルも最小限に済む

フロントエンドの開発体験がかなりよくなりました。

NFTをはじめとした、Web3界隈の技術は、RubyよりもJSの方が盛んな印象です。
Railsを使って開発する時も、importmap-railsを使えば妥協することなく、JSの技術を利用することができます。

## 実践編

まずはRailsアプリを用意しましょう。importmap-railsはすでに使える状態でインストールされます。

### Railsをセットアップ

```shell
rails new myapp
cd myapp
```

### Userモデルを作成

続いて、Userモデルを作成します。

```shell
rails g model user eth_address:string eth_nonce:string username:string
rails db:migrate
```

それぞれ説明します。

- eth_address: ユーザーのEthereumアドレスです。アプリケーションでの認証に使用されます。
- eth_nonce: 署名のなりすましを防ぐために使用されます。
- username: ユーザー名です。任意の名前が入ります。

作成された`app/models/user.rb`に以下のコードを追加してください。

```ruby:app/models/user.rb
class User < ApplicationRecord
  validates :eth_address, presence: true, uniqueness: true
  validates :eth_nonce, presence: true, uniqueness: true
  validates :username, presence: true, uniqueness: true
end
```

続いて、users_controller.rbを作成しましょう。必要なのはcreateアクションのみです。

```shell
rails g controller users create
```

route.rbを以下のように書き換えます。

```ruby:config/routes.rb
Rails.application.routes.draw do

   resources :users, only: [:create]

   # 省略
```

### importmap-railsを使って、Web3.jsをインストール

MetaMask 認証のような、ウォレットの認証は Web3.js を使うのが便利です。
以下のコマンドでインストールします。

```shell
bin/importmap pin web3
```

モジュールは`config/importmap.rb`に追加されたと思います。確認しましょう。

```ruby:config/importmap.rb
# <--- 省略 --->
pin_all_from "app/javascript/controllers", under: "controllers"
pin "web3", to: "https://..."
pin "@adraffy/ens-normalize", to: "https://..."
pin "@ethereumjs/rlp", to: "https://..."
pin "@noble/curves/secp256k1", to: "https://..."
pin "@noble/hashes/_assert", to: "https://..."
pin "@noble/hashes/crypto", to: "https://..."
pin "@noble/hashes/hmac", to: "https://..."
pin "@noble/hashes/pbkdf2", to: "https://..."
pin "@noble/hashes/scrypt", to: "https://..."
pin "@noble/hashes/sha256", to: "https://..."
# <--- 省略 --->
pin "web3-eth-contract", to: "https://..."
pin "web3-eth-ens", to: "https://..."
pin "web3-eth-iban", to: "https://..."
pin "web3-eth-personal", to: "https://..."
pin "web3-net", to: "https://..."
pin "web3-providers-http", to: "https://..."
pin "web3-providers-ws", to: "https://..."
pin "web3-rpc-methods", to: "https://..."
pin "web3-types", to: "https://..."
pin "web3-utils", to: "https://..."
pin "web3-validator", to: "https://..."
pin "zod", to: "https://..."
```

ファイルの中に、pin_all_from というのがあると思います。これは、app/javascript/controllers 配下に配置した場合に import で読み込めるようにできます。

```ruby:config/importmap.rb
# <--- 省略 --->

pin_all_from "app/javascript/controllers", under: "controllers"

# <--- 省略 --->
```

### Web3.jsと eth gemで認証機能を作る。

ウォレットに接続するための機能を実装していきます。先ほどインストールしてWeb3.jsを利用します。
そのためにはま ず、app/javascript/controllers 配下に新たにコントローラーを追加します。 metamask コントローラーという名前のコントローラーをつくり、ここで接続処理を記述しましょう。以下のコマンドを打ってください。

```shell
bin/rails g stimulus metamask
```

`app/javascript/` 配下に metamask.js が追加されました。

コマンドで利用した『Stimulus』は、 Rails上でJSを扱うためのレールのような存在です。
ファイルを開き、Web3.js を import しましょう。

```js:app/javascript/controllers/metamask_controller.js
import { Controller } from "@hotwired/stimulus"
import Web3 from "web3" // 追加

// Connects to data-controller="metamask"
export default class extends Controller {
    connect() {}
}
```

application.html.erbを編集して、ウォレット接続ボタンを追加します。

```erb:application.html.erb
<div data-controller="metamask">
    <a data-action="click->metamask#signin" href="#">
        ウォレット接続
    </a>
</div>
```

先ほど作成した、`metamask.js`に `signin()`を追加します。

```js:app/javascript/controllers/metamask_controller.js
async signin() {
    const provider = window.ethereum || window.web3?.provider || null

    if (!provider) {
      console.error('!provider')
      return
    }

    const web3 = new Web3(provider)
    const account = await web3.eth.requestAccounts();
    const nonce = account[0]
    const eth_address = account[1]
    const customTitle = "Ethereum on Rails";
    const requestTime = new Date().getTime();
    const eth_message = customTitle + "," + requestTime + "," + nonce;
    const password = ''
    const eth_signature = await web3.eth.personal.sign(
                             eth_message, eth_address, password
                          )

    const csrfToken = document
      .querySelector("meta[name='csrf-token']")
      .getAttribute("content");

    fetch("http://localhost:3000/users/", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": csrfToken,
      },
      body: JSON.stringify({eth_message, eth_address, eth_signature}),
    }).then(response => response.json())
      .then(data => {
        if (data.connected) {
          this.element.style.display = 'none';
        }
      });
  }
```

Web3.js のインスタンスを作成し、MetaMask ウォレットに対してリクエストを投げます。そして、署名を作成してください。これは、サーバー側で認証をさせるために必要になります。
fetch 関数を使い、サーバーに対して、アクセスしています。JSON 形式で渡したいため、JSON.stringify で加工しています。

### eth gemをインストール

サーバー側で、イーサリアムを扱うために、eth gem という gem を使用します。Gemfile に追加し、bundle install してください。

```ruby:Gemfile
gem "eth"
```

```shell
bundle install
```

最後にusers_controller.rbに以下を追加します。これは、元々参考にしていた、[こちらのセクション](https://dev.to/q9/finally-authenticating-rails-users-with-metamask-3fj#:~:text=the%20users%20finally%3F-,User%20Sessions,-The%20previous%20sections)を参考にしています。

```ruby:app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    user = User.find_by(eth_address: params[:eth_address])
    if user.present?
      if params[:eth_signature]
        message = params[:eth_message]
        signature = params[:eth_signature]
        # フォームからではなく、データベースからのユーザー アドレスとノンスを使用する
        user_address = user.eth_address
        user_nonce = user.eth_nonce
        custom_title, request_time, signed_nonce = message.split(",")
        request_time = Time.at(request_time.to_f / 1000.0)
        expiry_time = request_time + 300
        if request_time && Time.current < expiry_time
          # 署名されたノンスが記録にあるものであることを強制する
          if signed_nonce == user_nonce
            # 署名からアドレスを復元する
            signature_pubkey = Eth::Signature.personal_recover message, signature
            signature_address = Eth::Util.public_key_to_address signature_pubkey
            if user_address.downcase == signature_address.to_s.downcase
              session[:user_id] = user.id
              user.eth_nonce = SecureRandom.uuid
              user.save
              redirect_to root_path, notice: "Logged in successfully!"
            end
          end
        end
      end
    end
  end
end
```

これで一通りのMetaMaskログインが完成です。

## おわりに

いかがでしたでしょうか。
元々の記事と比較すると、少ないJSの記述で同じ認証基盤を構築できたことがわかります。
importmap-railsにより、気軽にnpmライブラリを利用できるので、今回の事例で扱ったWeb3.jsに限らず、みなさんもぜひ様々なライブラリで試してみてください！

最後に紹介となりますが、株式会社KINECAで、エンタメマッチングアプリ『pato』の開発を行っています。

https://pato.today/guest/

本サービスもRails7を使っており、常にモダンな環境にするためアップデートメンテナンスを絶やさず行っています。

気になる方、一緒に働きたい方、おしゃべりしたい方はぜひ気軽にXにDMをお願いします🙌

https://x.com/ryosk7