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

こんにちは!モバイル グループで Coincheck iOS アプリを作っています高橋です。

前回に引き続き今回も Web3 Wallet の作成となります!

まだ前回の記事を読んでいない方は、前編から読んでいただけるとこれ以降の内容もよりスムーズに理解できると思います!

tech.coincheck.blog

実装前の注意!

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

前回のおさらい

では後編の実装に入る前に、軽く前編のおさらいをしておきます。 今回作成する Simple Wallet の次の4つの機能のうち

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

次の2つの機能を実装しました。

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

ここまでで、 HD Wallet が生成されていて、 Wallet の復元に必要な ニーモニックフレーズkeyParams を保存ができ、次の

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

の実装を行う準備ができたところでした。

保有している通貨、トークンの残高を取得する

では実装に入っていきましょう。
Wallet を生成し、秘密鍵を安全な場所に保管できたら、次は Wallet 内の通貨の情報を取得しましょう。 今回は Ethereum 上で流通する通貨である Ether(イーサと読みます) の残高を取得します。

Ethereum との通信について

Ether 残高を取得するためにはブロックチェーンである Ethereum に問い合わせ、データを取得してくる必要があります。
Ethereum は複数のサーバーが相互通信することによって成り立つシステムです。 それぞれのサーバーは Node と呼ばれその一つ一つが Wallet などの外部サービスから送信された Transaction と呼ばれる Ethereum への更新リクエストを収集し、それらをまとめて Ethereum へ刻まれる Block を生成しようとしています。 この TransactionBlock の状態をお互いに伝播させることで全ての Node を最新の状態に保つようになっています。 従って、 Ethereum ネットワークに参加するどれかひとつの Node に問い合わせれば Ethereum 上のデータを取得することができます。

Node は自分でも立てることができ、 InfuraAlchemy, Ginco Web3 Cloud などを利用できます。 今回はサンプルアプリのため、 Ethereum コミュニティによって立てられている公共の Node を利用します。(ありがたや・・・) また Ethereum の本番(メイン)ネットではなく、開発向けに用意されているテストネットを利用します。

テストネットの Ether を取得しておく

一般的な Webサービスに製品となる本番環境と開発用のステージング環境があるように Ethereum にもメインネットと呼ばれる本番環境と、開発向けに公開されているテストネットと呼ばれるステージング環境があります。 メインネットで流通している Ether は実際に金銭的な価値を持っており、開発に使うには多くの危険とコストが伴います。そのため、金銭的な価値を持たない Ether が流通していて開発者が手軽に Ethereum を利用したアプリをテストできるテストネットが用意されています。 これから残高の取得処理を実装していきますが、 0 が返ってくるのは成功しているのかよく分からないですし、送金処理の時にも Ether が必要になるのでテストネットの Ether を受け取っておきましょう。 テストネットの Ether は faucet という形でさまざまな Web サイトで配布されています。

現状手軽に利用できる faucet として AlchemyInfura の提供する faucet があります。 それぞれ Alchemy/Infura へのアカウント登録が必要になってしまいますが、それが終わればアドレスを指定するだけで Sepolia Ether がもらえ、比較的お手軽です。 また MetaMask などの Wallet をすでに利用したことがある場合は QuickNode の提供する faucet も利用できます。 こちらはアカウント登録の代わりに Wallet を接続する形になり、アカウント作成の手間がないためスムーズです。

1. Web3 クラスをインスタンス化する

では実装に入ります。 web3swiftWeb3 という Ethereum に関連する様々なやりとりをするためのクラスが用意されているのでまずはそちらをインスタンス化します。

SimpleWalletApp.swift を開き、instantiateWeb3 メソッドを以下のように実装します。

@Main
struct SimpleWalletApp: App {
        var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // 3. web3 から情報を取得するよう変更
                    let web3 = await instantiateWeb3()
                    let currentAccount = (try? web3?.wallet.getAccounts().first) ?? .zero

                    appState.web3 = web3
                    appState.currentAccount = currentAccount
                }
                .environmentObject(appState)
        }
    }
}

private extension EightBitWalletApp {
    func instantiateWeb3() async -> Web3? {
        let useCase = WalletUseCase()
        // 1. 必要になるデータを準備
        let manager = useCase.loadKeystoreManager()
        let rpcURL = URL(string: "https://rpc.sepolia.org/")!
        let network = Networks.sepolia

        // 2. Web3 をインスタンス化
        guard let provider = try? await Web3HttpProvider(url: rpcURL, network: network, keystoreManager: manager) else {
            print("provider instantiate failure.")
            return nil
        }

        return Web3(provider: provider)
    }
}

コードを順番に見ていきましょう。

1. 必要になるデータを準備
ここではWeb3 のインスタンス化に必要なデータを準備しています。 Networks はどのチェーンに接続するかを表す enum です。 今回は Ethereum のテストネットである Sepolia(セポリアと読むらしい) を利用するので、 Networks.sepolia を指定します。

rpcURL は接続先の Node の URL です。 Ethereum の Node とのやりとりは基本的に JSON RPC を介して行われるため RPC Node などと一般的に呼ばれます。 RPC Node の URL は Networks で指定したネットワークの URL か確認してください。例えば同じ Ethereum コミュニティによって運営されている Node でもメインネットの URL とテストネットの URL は異なります。

KeyStoreManager は生成したアカウントへアクセスするためのクラスです。 Web3 はデータの取得だけでなく、送金などの Transaction を発行し Ethereum の状態を変更するためのメソッドも提供していますが、その際に秘密鍵を使った署名が必要になります。 そのため、秘密鍵などへアクセスできる KeyStoreManager を準備する必要があったんですね。

2. Web3 をインスタンス化
次に Web3 をインスタンス化します。 Web3HttpProvider は実際に Node と通信を行うためのクラスです。ここに先ほど準備した rpcURLnetwork, keyStoreManager を引数に渡して初期化します。

3. web3 から情報を取得するよう変更 最後に WalletUseCase から KeyStoreManager だけロードしていた箇所を削除して、必要なデータを web3 オブジェクトから参照するように変更します。

2. 残高を取得

続いて、先ほどインスタンス化した web3 オブジェクトを利用して残高を取得しましょう。 Ether の残高を取得するには Ethereum の JSON RPC で定められている eth_getBalance メソッドを利用します。

EthereumUseCase.swift を開き、fetchEtherBalance メソッドを以下のように実装します。

// EthereumUseCase.swift

struct EthereumUseCase: EthereumUseCaseInterface {
    // ....

    func fetchEtherBalance(for address: EthereumAddress) async -> BigUInt? {
        do {
            let rawBalance = try await web3.eth.getBalance(for: address)
            return rawBalance
        } catch {
            print("fetch ether balance failed, reason: \(error)")
            return nil
        }
    }
}

これで Ether 残高を取得できるようになりました!
アプリを起動して残高が取得できていれば成功です!

...あれ?よく見ると桁数がとんでもないことになっていますね。
これはどういうことでしょうか?

50京ETHください(切実)

3. 単位を変換する

Ether は 1 ether にも満たないかなり少額の取引が多数あり、そのような少さな金額も表現できるように、いくつか通貨単位が存在します。 最も一般的な ether(ETH) を除き、よく使われる通貨単位は gwei(ギガウェイ)wei(ウェイ) で、大きさは次のとおりです。

  • gwei: 10の-9乗 ether
  • wei: 10の-18乗 ether

数が小さすぎて理解が及びませんが、非常に小さな数であるようです。
gweiTransaction の発行時に必要となるガス代の表記に利用されます。
wei は通貨単位の中で最も小さい単位で、 Node に Ether の残高などを問い合わせた場合には基本的にこの通貨単位で返ってきます。 先ほど残高を取得した際に桁数がとんでもないことになっていたのは wei という単位で値が返ってきていたからですね。
では weiether の単位に直していきましょう。

WalletPresenter.swift を開き、fetchEtherBalance に変換処理を追加します。

// WalletPresenter.swift
final class WalletPresenter: ObservableObject {
    // .....
}

private extension WalletPresenter {
    func fetchEtherBalance() {
        Task { @MainActor in
            if currentAddress != .zero {
                let balance = await interactor.fetchEtherBalance(for: currentAddress) ?? .zero
                // 1. 取得した Ether 残高の単位を wei から Ether へ変換
                etherBalanceString = formatter.string(from: balance, decimals: 18)
            }
        }
    }
}

1. 取得した Ether 残高の単位を wei から Ether へ変換
やることはただの BigInt -> String への変換処理で Wallet の本筋とは少しズレるので、今回は事前に用意した EthereumNumberFormatter を利用してサクッと変換してしまいます。 興味のある方は EthereumNumberFormatter の処理も覗いてみてください!

残高の取得完了!

これで今度こそ残高の取得処理が完了です! 以下のように 0.n ETH と表示されていれば成功です!

単位変換ヨシ!

保有している通貨、トークンを送金する

では最後の機能として、保有している Ether を別のアドレスに送信する機能を追加したいと思います。 Etherの送金を行うためには Transaction と呼ばれるデータを Node に送信する必要があります。

Transaction is 何?

Transaction は Etheruem の状態を更新する際に必要なデータの塊です。 どういうことかというと、Ethereum は自身でも World Computer と称しており、複数の Node が集まり一つの巨大なコンピューターのようになっています。 巨大なコンピューターは一つの大きな状態を持っており、ここでいう 送金アドレスA から アドレスB に n ETH 移動した という Ethereum の状態を更新する操作の一つです。 Transaction が送信され、バリデータと呼ばれる Node が一定のルールに従い複数の Transaction を Block にまとめて繋げていくことで、 Ethereum の状態が更新されていきます。 例えば、送金時に作成される Transaction には以下の情報が含まれます。

  • from: 送金元となるアドレスを指します
  • to: 送金先のアドレスを指します
  • nonce: Transaction の発行ごとに付与されるカウンタで、1ずつインクリメントされていきます
  • value: 送金元から送金先へ送金する Ether の数量で、16進数の形式で渡します
  • gasLimit: Transaction で消費するガスの最大量。一般的に、送金に必要なガスの量は 21000 です
  • gasPrice: 1 gas あたりに支払っても良い gas の価格です
  • maxPriorityFeePerGas: バリデータへのチップとして含まれるガスの最大金額です
  • maxFeePerGas: トランザクションに支払うガス代の最大金額です
  • data: 任意のデータを含めるオプションのフィールドで、スマートコントラクトを利用する際に使うフィールドです

ガス is 何?

説明が長くなるのでざっくりした説明に留めますが、ガスは平たく言ってしまえば、 Ethereum を利用するために必要な手数料です。 支払ったガス代の一部ブロックを作成して Transaction を確定させるバリデーターへの報酬として支払われます。 残りのガス代は誰もアクセスできないゼロアドレスへ送られることになっており、これを一般的に Burnする と言います。 またガスは、Ethereum ネットワークに無意味な Transaction を溢れさせてパンクさせるといった攻撃を防ぐセキュリティとしての役割も果たします。 詳しくは Ethereum Foundation の提供する ガスとフィー(手数料)という記事を参照してください。

1. Transaction の作成

ではここから実装に入ります。 まずは Transaction を作成しましょう! web3swift に Transaction を表現している CodableTransaction というクラスがあるのでそちらを利用していきます。

TransactionBuilder.swift を開き、buildTransferTransaction メソッドを以下のように実装します。

struct TransactionBuilder {

    // .....

    func buildTransferTransaction(to: EthereumAddress, from: EthereumAddress, value: BigUInt) async -> CodableTransaction {
        // 1. CodableTransaction をインスタンス化
        var transaction = CodableTransaction(type: .eip1559, to: to, chainID: Networks.sepolia.chainID, value: value)
        transaction.from = from

        do {
            // 2. nonce を計算
            let nonce = try await policyResolver.resolveNonce(for: transaction, with: .latest)
            transaction.nonce = nonce

            // 3. 必要なガスの量と価格を計算
            let gasLimit = try await policyResolver.resolveGasEstimate(for: transaction, with: .automatic)
            let baseFee = await policyResolver.resolveGasBaseFee(for: .automatic)
            let maxPriorityFeePerGas = await policyResolver.resolveGasPriorityFee(for: .automatic)
            let maxFeePerGas = (baseFee * 2) + maxPriorityFeePerGas

            transaction.gasLimit = gasLimit
            transaction.maxPriorityFeePerGas = maxPriorityFeePerGas
            transaction.maxFeePerGas = maxFeePerGas
        } catch {
            print("error: \(error)")
        }

        return transaction
    }
}

コードを順番に見ていきましょう。

1. CodableTransaction をインスタンス化
まずは大元となる CodableTransaction をインスタンス化します ここで全てのデータをセットするわけではなく、計算が必要ない値のみセットしていきます。

  • type はガスの計算方法をセットします。おおまかに legacyeip1559 の方法があり、最新の方法である eip1559 を利用するのが主流で、Transaction も Block に取り込まれやすいです
  • to は送金先のアドレスをセットします
  • chainID は利用するネットワークの ID をセットします。
  • value は送金する ether の量をセットします。単位は wei です
  • from は送金元である自分のアドレスをセットします

2. nonce を計算
続いて nonce を計算していきます。 基本的に nonce は Transaction を発行するごとに1ずつ増えていきます。 ということは、最新の nonce を得るには アドレスの Transaction発行数 + 1 の計算で得られることになります。 アドレスの Transaction発行数 は Ethereum の JSON RPC で定められている eth_getTransactionCount というメソッドを利用して取得できます。 今回は web3swift の用意している policyResolver.resolveNonce() を利用していますが、内部実装は上記の方法で計算しています。

3. 必要なガスの量と価格を計算
最後にガスについての計算です。 今回はガスの計算に比較的新しく主流の方法である eip1559 という仕様を利用しています。 eip1559 の仕様は少々複雑で、ここで扱うと説明が長くなってしまうのでここでは以下の三つの情報を計算する必要があるということだけ意識していただければと思います。

  • gasLimit
  • maxPriorityFeePerGas
  • maxFeePerGas

gasLimit, maxPriorityFeePerGas についてはそれぞれpolicyResolver.resolveGasEstimate,policyResolver.getMaxPrioirtyFee を利用することで概ね妥当なガス価格を計算することができます。 maxFeePerGas については (baseFee * 2) + maxPriorityFeePerGas の式で求めるのが妥当な方法の一つとされています。
英語になりますが、こちら の記事が EIP 1559 についてわかりやすく書かれていますので合わせてご覧ください。

2. Transactionに署名し、送信する

あとは作成した Transaction を送信するだけですが、その前に Transaction に署名する必要があります。 Ethereum は誰でも参加できるパブリックネットワークで Transaction も誰でも自由に作成し、送信することができます。 しかし、第三者が私のアドレスに紐づいた資産を勝手に送金するような Transaction を作成できてしまっては困ります。 そこでアドレスを発行するために利用した秘密鍵を用いて Transaction に署名を行います。 秘密鍵での署名をもって、 Transaction の送信者が from に設定されたアドレスの保有者であることを証明します。

Transaction を Node に送信するには Ethereum の JSON RPC で定められている eth_sendRawTransaction メソッドを利用します。

EthereumUseCase.swift を開き、 sendEther メソッドを以下のように実装します。

// EthereumUseCase.swift

struct EthereumUseCase: EthereumUseCaseInterface {
    // ....

    func fetchEtherBalance(for address: EthereumAddress) async -> BigUInt? { ... }

    func sendEther(to: EthereumAddress, from: EthereumAddress, amount: BigUInt) async -> String {
        // 1. Transaction を生成する
        var transaction = await transactionBuilder.buildTransferTransaction(to: to, from: from, value: amount)

        // 2. 秘密鍵にアクセスするため、 Wallet 作成時に設定した password を取得する
        guard let password = KeychainManager.shared.read(key: Constants.Keychain.appPassword, itemClass: .genericPassword) else {
            return ""
        }

        do {
            // 3. Transaction に署名する
            _ = try web3.wallet.signTX(transaction: &transaction, account: from, password: password)

            // 4. Transaction を Data型 にエンコード
            guard let encodedTransaction = transaction.encode() else {
                print("encode failed: \(transaction)")
                return ""
            }

            // 5. Transaction を Node へ送信
            let result = try await web3.eth.send(raw: encodedTransaction)
            return result.hash
        } catch {
            print("error: \(error)")
            return ""
        }

        return ""
    }
}

前半 1, 2 までが Transaction の署名について、後半 3, 4 が Transaction の送信部分です。 順番に見ていきます。

1. Transaction を生成する
先ほど実装した TransactionBuilder を利用して送金の Transaction を生成します。

2. Wallet 作成時に設定した password を取得する
秘密鍵にアクセスするために、 Wallet 作成時に設定したパスワードが必要なため、 Keychain から取得します。

3. Transaction に署名する
web3.wallet.signTX(transaction:, account:, password:) メソッドを利用して第一引数に渡した transaction に署名を行います。 account には Transaction の発行者となる from にセットしたアドレスを渡します。 内部処理では

  1. web3 が保持している KeyStore から引数の account を利用して該当のアカウントを特定する
  2. アカウント(秘密鍵とアドレスのペア)が得られるまで master key から順番にパスに沿って計算を行う
  3. 計算して得られた秘密鍵を利用して transaction に署名する

という順番になっています。 秘密鍵は KeyStore に常に保持されているわけではなく必要に応じて計算され、利用が終わったあとは速やかにメモリ上から破棄されます。

4. Transaction を Data型 にエンコード
RPC Node に Transaction などのデータを送信する場合には RLP と呼ばれる Ethereum 独自の形式のバイト列にエンコードされる必要があります。 RLP へのエンコードはライブラリに任せますが、その下準備として Transaction を Data型へエンコードします。

5. Transaction を Node へ送信
web3.eth.send(raw:) メソッドを利用して Node へトランザクションを送信します。 Transaction 送信の送信に成功すると transactionHash が返ってきます。 これは自身の発行した Transaction が成功したかどうかを知るために必要な情報になるので print して表示しておきます。

Transaction が送信されたか確認する

さてこれで Transaction の送信まで完了しました! Transaction が Node に送信され、 Block に取り込まれたかどうかは etherscan で確認できます。 これは Block explorer というもので、 Ethereum 上でのアドレスに紐づいている通貨やアドレスが発行した Transaction などを閲覧できるツールです。 トップページの検索窓に先ほど print した transactionHash をコピーして検索をします。 Block に取り込まれていれば以下のように Transaction の詳細が表示され、 Status の部分が Success となっていれば成功です! お疲れ様でした!

送信成功!

まとめ

実装お疲れ様でした!
前編の冒頭でもお話した通り、今回実装した機能は Wallet の持つ機能のほんの一部で、今回実装した機能以外にも

  • ニーモニックフレーズのバックアップ
  • トランザクション履歴
  • トランザクションの成功確認
  • DApps との接続
  • ネットワークの切り替え etc...

など、製品としての Wallet には必要な機能がまだまだあります。果てしないですね。 自分で Wallet の仕組みに興味を持ち、どうやって実装されているのか調べていきましたが、体系的に Wallet の実装について(しかも Swift で)解説している記事もほとんどなく、何度も挫折しながら学んでいました。 自分でそんな経験をしていたことや、最近国産の Web3 Wallet が増えてきていて、 Wallet の仕組みを解説する記事は需要があるのではないかと思い書いてみました。 少しでも皆さんの参考になれば幸いです。

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

engineer-recruit.coincheck.com

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

open.spotify.com