iOS ネイティブでも Web3 Wallet アプリが作りたい!【前編】

こんにちは!Mobile グループで Coincheck iOS アプリを作っています高橋です。 今回は前々から興味を持って個人で勉強していた Web3 Wallet の作り方についてテックブログで出してみてはどうかとご提案をいただけたので稚拙ながら解説していこうと思います。 オープンソースのライブラリを利用しながら最低限の機能を持った Web3 Wallet を作成しようと思います!

実装前の注意!

今回の記事で利用している実装はあくまでも Web3 Wallet の仕組みや作り方を理解するための実装であり、製品として耐えうるようには設計されていません。 あくまでも参考としてご覧いただき、サンプルコードを製品としてそのまま組み込むことはご遠慮いただけますようお願いいたします。

Web3 Wallet?

Web3 Wallet とは主に Ethereum 等のスマートコントラクトの利用できる経済圏で利用されるアプリケーションであり、Ethereum や Ethereum を利用したアプリケーションを体験する上で必須となるツールです。 今回は大まかに以下のような機能を持っているものを Web3 Wallet と呼びます。

  • 通貨、トークンを紐づけるための秘密鍵とアドレスのペアが作成できる
  • 秘密鍵を端末内で管理できる
  • 紐づいている通貨、トークンの一覧とその残高が確認できる
  • 各種通貨とトークンを任意のアドレスに送金できる
  • Defi やブロックチェーンゲームなどの DApps に接続し利用できる

製品版では上記の機能以外にも多くの機能を持っていますが、説明を簡単にするために上記の基本的な機能に絞っています。

今回作るもの

今回作る Wallet は Simple Wallet という最低限の機能を持ったさっぱりとした Wallet になります。

Simple Wallet では先ほど説明した機能のうち

  • 通貨、トークンを保有するための秘密鍵とアドレスのペアが作成できる
  • 秘密鍵を端末内で管理できる
  • 保有している通貨、トークンの残高が確認できる
  • 各種通貨とトークンを任意のアドレスに送金できる

の四つの機能を実装します。

  • Defi やブロックチェーンゲームなどの DApps に接続し利用できる

についてはそれ単体で記事が書けそうなくらい複雑であるため、今回は割愛したいと思います。

前編となる今回の記事では

  • 通貨、トークンを保有するための秘密鍵とアドレスのペアが作成できる
  • 秘密鍵を端末内で管理できる

の二つの機能を実装していきます!

アプリの構成

タイトルの通り Pure Swift で作っていきます。 UI Framework には SwiftUI を利用し、アプリを構成するアーキテクチャには VIPER を利用しています。 今回のアプリの VIPER とは若干形が違うのですが、 Coincheck iOS でも VIPER を利用しています。 詳しくは弊チームの深澤の記事が参考になるかと思いますので、興味のある方は合わせてご覧ください!

tech.coincheck.blog

また、サンプルコードや完成品はこちらのリポジトリにありますので適宜 pull してください。 完成品は WalletSampleComplete ディレクトリに、この記事と一緒に進めていく初期状態のアプリは WallletSampleStarter ディレクトリに置いています!

github.com

秘密鍵とアドレス(アカウント)の作成

では Wallet の中でも最も基本的な機能である秘密鍵とアドレスの生成から始めていきましょう! 実装に入る前に秘密鍵とアドレスの関係、秘密鍵の生成方法について軽く解説していきます!

秘密鍵とアドレス

Ethereum ではユーザーが保有する資産は全てアドレス(パブリックアドレス)に紐づけられます。 アドレスは事前に生成された秘密鍵から導出されており、秘密鍵と一対一で対応する形になり、この秘密鍵とアドレスのペアを一般的に「アカウント」と呼びます。 アドレスに紐づいた資産を移動させるためには秘密鍵による署名が必要になる性質上、秘密鍵の漏洩や紛失は資産の漏洩、紛失につながるため、秘密鍵の保存先やアプリ内での取り扱いには特に注意が必要になります。

Note ここでいうアドレスは公開鍵のことなのでは?と不思議に思われるかもしれませんが、厳密には「公開鍵をハッシュ関数である Keccak-256関数 に通して得た 32byte のハッシュ値のうち、後半 20byte を取り出し、先頭に 0x をつけたもの」です。 そのため、一般的には「アドレス」と公開鍵とは分けて呼ばれます。

秘密鍵の生成方法

秘密鍵の生成方法によって Wallet の呼び方が変わり、大まかに以下のような種類に分かれます。

1. ランダム Wallet
秘密鍵の生成方法としては一番シンプルな方法で、単に暗号学的に安全な乱数生成器を用いて得た32byte のハッシュ値を秘密鍵として利用する方法です。 非常にシンプルで手軽な方法ですが、複数のアカウントを生成しづらいというデメリットがあります。

2. HD Wallet
おそらく現在最もオーソドックスであると思われる秘密鍵の生成方法です。 HD は Hierarchical Deterministic(階層決定的)の頭文字で、名前の通り生成された鍵が階層構造になる生成方法です。 シード値からルートとなる master key からはじまり、親の鍵から子の鍵を、さらにその子の鍵を生成したり、一つの親の鍵から複数の子の鍵を作成できるためツリー構造のような形になります。 また、シード値は人間が覚えやすくするため、単語リストからランダムに選ばれたニーモニックフレーズという単語群から導出されます。

3. MPC Wallet
現在先進的とされている鍵の管理方法で、秘密鍵のセキュリティの向上という点に重きを置いている方法です。 大まかな概要を説明すると、生成された一つの秘密鍵を複数の欠片に分割し、 Wallet を提供する事業者と Wallet の利用者でそれぞれ保持します。 署名が必要な際には分割した秘密鍵を秘密計算関数にそれぞれ通し必要な数の秘密鍵が得られた場合のみ有効な署名を作成するようになります。 この技術を用いることでハッキングが難しくなったり、秘密鍵の欠片を紛失しても事業者の秘密鍵から再度復旧することができるようになるようです。 ユーザー体験の向上が期待できる一方で、技術的にかなり実装が難しいという特徴があります。

今回は一番オーソドックスな HD Wallet の方式を利用して秘密鍵を生成していきます。

1. 関数を作る

お待たせしました!ようやく本題です。 HD Walletを一から実装していくのは骨が折れるので、今回は web3swift というライブラリを利用して実装していきます。

github.com

Wallet はいくつかのステップを踏んで作られるのでまずはそれらのステップをまとめるメソッドを作ります。 WalletUseCase.swift を開いて、createInitialAccount メソッドを以下のコードで置き換えます。

// WalletUseCase.swift

struct WalletUseCase: WalletUseCaseInterface {
    func createInitialAccount() -> KeystoreManager? {    
        // 1. Password を取得
        guard let password = getPassword() else { return nil }

        // 2. ニーモニックフレーズを生成
        guard let mnemonics = generateMnemonic() else { return nil }
    
        // 3. いくつかの情報から Wallet を生成
        let initialPath = ""
        guard let keystore = createKeystore(from: mnemonics, prefixPath: initialPath, password: password) else { return nil }

        return KeystoreManager([keystore])
    }
}

1. パスワードを取得
まずはユーザーに入力してもらったパスワードを設定します。 このパスワードは Wallet の秘密鍵にアクセスするためのパスワードで、署名のためアプリが秘密鍵にアクセスするときに利用したり、ユーザーがバックアップのためニーモニックフレーズを表示する際に入力を求めることがあります。

2. ニーモニックフレーズを生成
続いて HD Wallet のシード値の大元となるニーモニックフレーズを取得します。 この次に generateMnemonic メソッドの中身を実装していくので、詳細はその時に説明します。

3. いくつかの情報から Wallet を生成
最後に ニーモニックフレーズ、パス、パスワードから Wallet を生成します。 ここもあとで createKeystore メソッドの中身を実装していくので、詳細はその時に説明します。

2. ニーモニックフレーズを取得する

続いて Wallet を作るために必要なメソッドを実装していきましょう! パスワードを取得してくる箇所はすでに実装済みなので、まずはニーモニックフレーズを取得する generateMnemonic メソッドを実装します。

// WalletUseCase.swift

struct WalletUseCase: WalletUseCaseInterface {
    //...
}

private extension WalletUseCase {
    func generateMnemonic() -> String? {
        do {
            // 1. ニーモニックフレーズの生成
            let mnemonics = try BIP39.generateMnemonics(bitsOfEntropy: 128)
            print("mnemonics: \(String(describing: mnemonics))")
            return mnemonics
        } catch {
            return nil
        }
    }
}

1. ニーモニックフレーズの生成
BIP39.generateMnemonics メソッドを利用して12個の単語で構成されたニーモニックフレーズを取得します。引数に渡している bitsOfEntropy の値は暗号の強度で、 128bit か 256bit であれば一般的に十分な強度であるとされています。 エントロピーによって単語数も変化し、128bit では単語数が12, 256bit では単語数が24となります。 ニーモニックフレーズは端末の買い替えや紛失に伴う Wallet のバックアップにも利用されるため、単語数が多すぎない 128bit が用いられることが多いようです。

3. Wallet を生成する

生成したニーモニックフレーズを元に Wallet を生成します。 web3swift では HD Wallet のツリー構造を表現するクラスが BIP32Keystore となるので、このクラスをインスタンス化することで HD Wallet が生成できます。

// WalletUseCase.swift

struct WalletUseCase: WalletUseCaseInterface {
    func createInitialAccount() -> KeystoreManager? {
        // 1. アカウントのパスを決定
        let initialPath = "m/44'/60'/0'/0/0"

        // ...
    }

    // ....
}

private extension WalletUseCase {
    func createKeystore(from mnemonics: String, prefixPath: String, password: String) {
        do {
            // 2. ニーモニック、パスワード、パスを指定して Wallet を生成
            let wallet = try BIP32Keystore(mnemonics: mnemonics, password: password, prefixPath: prefixPath)
            return wallet
        } catch {
            return nil
        }
    }
}

1. アカウントのパスを決定
いきなり出てきたパスってなに?となっていると思いますのでこちらの説明から。 パスは Wallet のどの位置にアカウントを作成するかを指示するために必要な引数で、PCにディレクトリを掘っていくのと似た形で、アカウントを作成します。 パスの形は基本的には自由なのですが、今回はオーソドックスな方法として BIP44 という仕様に準拠した形でパスを定義します。 BIP44 に準拠すると以下の図のように m/ から始まるパスになります。

/ で区切られた各階層ごとに、パスの仕様を表す層、保有する通貨の種類、etc... といったように役割が定められていて、ある程度決まった形になります。

今回は以下のパスをベースとし、アカウントを作成します。
m/44'/60'/0'/0

上記のベースパスにもう一つ階層を増やし、そこにアカウントを作成していきます。 m/44'/60'/0'/0/0

複数のアカウントを作成する場合は、パスの最後尾の数字 (address_index) をインクリメントしていきます。

BIP44 については以下の解説記事が大変わかりやすいので、詳しく知りたいよという方はこちらも合わせてご覧ください!

techmedia-think.hatenablog.com

2. ニーモニックフレーズ、パス、パスワードを指定して Wallet を生成
まず最初の引数はニーモニックフレーズです。これは先ほど生成したものを渡します。 次の引数はパスワードです。パスワードは Wallet にアクセスするためのパスワードで、一度ユーザーが入力し、設定した後は基本的にシステム内部でしか利用しません。ただし、バックアップのためニーモニックフレーズを表示する際には利用します。 最後の引数は先ほど作成したパスで、どの位置にアカウントを作成するか決定します。

Wallet 生成完了!

これで Wallet の生成は完了です! コードを実行して、パスワード入力後に以下の画面に遷移すれば成功です!

Wallet の生成完了!

ちなみに、 Wallet に作られたアドレスを参照するには以下のようにします。 まだアカウントが一つしかないので first で取得できます。

print("My first address: \(keyStore.addresses.first.address)")

秘密鍵を管理する

さて、続いては生成した秘密鍵の保存についてです。 このままではアプリを起動するたびに新しいアカウントを生成することになってしまうので、生成した秘密鍵を安全な場所に保存して、起動の際に読み込むようにしたいと思います。 保存場所は皆さん大好き Keychain です。

秘密鍵の保存

今回保存するのはニーモニックフレーズと、keyParams と呼ばれる情報を保存します。 基本的にはニーモニックフレーズだけで Wallet の復元は可能なのですが、アカウントを複数作成した場合に、どの位置に、いくつアカウントが存在するのか?ということまでは分からず、アカウント復元の処理が複雑になってしまいます。 そこで keyParams が必要になってくるのですが、 keyParams は以下のような json で、ルートの鍵の情報とどの位置にどれだけアカウントがあるのかというパスの情報も一緒に持っています。

{
    "crypto": {
        "cipher": "aes-128-cbc",
        "cipherparams": { ... },
        "ciphertext": "...",
        "kdf": "scrypt",
        "kdfparams": { ... },
        "mac": "..."
    },
    "id": "...",
    "isHDWallet": 1,
    "pathAddressPairs": [
        {
            "address": "0xa7e680756994dB4349c7e9Bcfb670CC5d9534dfa",
            "path": "m/44'/60'/0'/0/0/0"
        }
    ],
    "rootPath": "m/44'/60'/0'/0/0",
    "version": 4
}

そのため、起動時にはこちらを読み込みユーザーが作成したアカウントを全て復元できるよう実装します。

Note 一応ニーモニックフレーズも保存していますが、基本的にニーモニックフレーズはアカウントのバックアップの際に使う時に読み出します。
しかし Simple Wallet ではバックアップ機能は実装していないので、今回のケースではほぼデバッグ用の実装となります。

1. keyParams を取得する

保存処理は先ほどの createInitialAccount に追加していきますが、 まずは keyParams を取得するメソッドを実装しましょう。 WalletUseCase.swift を開いて、 getEncodedKeyParams を以下のように置き換えます。

// WalletUseCase.swift

struct WalletUseCase: WalletUseCaseInterface {
    //...
}

private extension WalletUseCase {
    func getEncodedKeyParams(from keystore: BIP32Keystore) -> Data? {
        do {
            // 1. keyParams を Data 型にエンコードしつつ取得する
            return try JSONEncoder().encode(keystore.keystoreParams)
        } catch {
            print("keyParams encode failed: \(error)")
            return nil
        }
    }
}

1. keyParams を Data 型にエンコードしつつ取得する
先ほど生成した HD Wallet のツリー構造を管理している BIP32Keystore から取得します。 また、Keychain には json をそのまま保存することはできないということと、結局 Wallet を復元する際には Data型 を求められるため、今のうちに Data型 にエンコードしておきます。

2. 保存処理を追加する

準備ができたため、保存処理を追加していきます。

// WalletUseCase.swift

struct WalletUseCase: WalletUseCaseInterface {
    func createInitialAccount() -> KeystoreManager? {
        let initialPath = "m/44'/60'/0'/0/0"
        guard let mnemonics = generateMnemonic() else { return nil }
        guard let password = getPassword() else { return nil }
        guard let keystore = createKeystore(from: mnemonics, prefixPath: initialPath, password: password) else { return nil }
    
        // ------- ここから追加分 -------
        // 1. keyParams を取得する
        guard let keyParams = getEncodedKeyParams(from: keystore) else { return nil }

        // 2. ニーモニックフレーズと keyParams を保存
        _ = KeychainManager.shared.save(mnemonics, key: Constants.Keychain.mnemonics, itemClass: .genericPassword)
        _ = KeychainManager.shared.save(keyParams, key: Constants.Keychain.privateKey, itemClass: .genericPassword)

        // ------- ここまで追加分 -------

        return KeystoreManager([keystore])
    }
}

順番に解説していきます。

1. keyParams を取得する
先ほど実装した getEncodedKeyParams を利用して取得します。

2. ニーモニックフレーズと keyParams を保存
取得したニーモニックフレーズと keyParams を保存します。 今回は Keychain の薄いラッパークラスを作り、チョットダケ使いやすいようにしました。

3. Wallet の復元

次に、Keychain に保存した内容から、 Wallet を復元します。

WalletUseCase.swift を開き、 loadKeyStoreManager を以下のように実装します。

struct WalletUseCase: WalletUseCaseInterface {
    func createInitialAccount() -> KeystoreManager? {
        //....
    }

    func loadKeyStoreManager() {
        // 1. Keychain に保存した keyParams を取得
        guard let keyParams = KeychainManager.shared.readData(key: Constants.Keychain.privateKey, itemClass: .genericPassword) else {
            print("keyParams is empty.")
            return nil
        }

        // 2. keyParams から Wallet(Keystore)を復元
        guard let keystore = BIP32Keystore(keyParams) else {
            print("keystore is nil.")
            return nil
        }

        return KeystoreManager([keystore])
    }
} 

順番に解説していきます。

1. Keychain に保存した keyParams を取得
先ほど保存した keyParams を取得します。また、 Keystore に渡す際には Data型 になっている必要があるため、JSON へのデコードは行わず、そのまま渡します。

2. keyParams から Wallet(Keysytore)を復元
HD Wallet は BIP32 という仕様で定められている方法のため、そのための Keystore である BIP32Keystore を利用して、 Wallet を復元します。

保存&復元処理完了!

これで保存&復元処理の実装完了です。 アプリ起動後に Wallet の作成を求められることなく、以下の画面が表示されていれば成功です!

読み込み成功!

まとめ

いかがだったでしょうか? 今回は前編ということで Wallet や秘密鍵の生成と保存を行いました。 次回となる後編では

  • 紐づいている通貨、トークンの一覧とその残高が確認できる
  • 各種通貨とトークンを任意のアドレスに送金できる

といった実際に Ethereum と通信を行う機能を実装していきます。 また、今回の Simple Wallet のコードの後編も含めた完成形は SimpleWalletComplete ディレクトリに入っていますので、興味のある方はそちらも覗いてみて下さい!

tech.coincheck.blog

最後に、コインチェックではエンジニアを積極募集中です。今話題のWeb3、ブロックチェーンの領域で勝負したいエンジニアの方などご興味ある方はお気軽にお申し込みください!

engineer-recruit.coincheck.com

また、コインチェックに興味がある方や転職を検討している方に向けて、社内の情報をゆるく発信していくCoincheck FMもやっておりますので、よろしければ聞いてみていただけると幸いです。

open.spotify.com