😊

ブロックチェーン上にWEBアプリをホストする その2(機能紹介編) Internet Computer Protocol(ICP)

2024/09/24に公開

1.はじめに

前回の記事では環境構築して、簡単な疎通確認をしました。
今回も、2章 dapp開発を参考に Internet Computer Protocol(ICP) における開発で必要となるであろう主要機能を動かしてみたので、前回同様に備忘録を兼ねて記事にしてみます。

次の ICP 独自機能(2024/08時点)に注目して見ていきたい思いますが、今回は○が付いた機能を中心に動かしてみます。内容に誤りがあればご指摘いただけますと幸いです。

機能
Stable Memory
Inetrnet Identity
HTTP Outcalls
Chain Key ECDSA
Bitcoin Integration
Ethereum Integration

https://techbookfest.org/product/99rzkWcsBYAuZq91sCWQEU?productVariantID=kY29JjsML3QcZ0b3dhVfdc

2.ICP 機能の紹介

環境構築と起動確認については前回確認できているので、さっそく実装して動かしてみる。
まずは、Stable memory から順次、ICPの機能を紹介・確認していく。

2.1. スマートコントラクトのアップグレード時にメモリを引き継ぐ

Canister には、Heap memory と Stable memory という 2種類のメモリ領域がある。
Heap memory は聞き馴染みある人も多いと思うが、Stable memory に関しては聞き馴染みなかったので筆者は Static memory もしくは Stack memory の誤記だろうと勘違いした。
それもそのはず、公式ドキュメントを見てみると、ICP独自機能だった。

[公式ドキュメント引用]
Stable memory is a unique feature of the Internet Computer that defines a data store separate from the canister's regular Wasm memory data store, which is known as heap memory

Heap memory は、Canister の Wasmメモリとして機能し、Canister が停止・アップグレードされたらクリアされるメモリ(最大4Gi)である。
一方で、Stable memoryは、Canister が停止・アップグレードしても値を保持する不揮発性の特徴を持ったメモリ(最大400GiB)であり、Canisterのアップグレード後もデータを保持させたい場合に使用されるらしい

Rustでの実装ワークフロー

Motoko では stable 修飾子を付与すれば Stable memory を定義することが可能だが、Rust で Stable memory を扱うには癖がある。

Rust ではアップグレードの前後で pre_upgrade アトリビュートと post_upgrade アトリビュートが付与された関数が呼び出されるので、その関数内で、 stable_save() 関数 と stable_restore() 関数を用いて Stable memory へ書き込む、といったワークフローが必要になる。

ソースコード(Rust)

まず、変数ENTRIES を Stable memory 、変数STOREを Heap memory として定義する。
Stable memory として定義した ENTRIES は、#[ic_cdk::pre_upgrade] アトリビュートが付与された pre_upgrade() 内で stable_save() を呼び出し、Heap memory の値を Stable memory へ格納する。
その後、#[ic_cdk::post_upgrade] アトリビュートを付与された post_upgrade() 内で、stable_restore() を呼び出すことで Stable memory から値を復元させる といった前述のワークフローを実装する。

ソースコード
lib.rs
type Entries = HashMap<String, Nat>;
// Stable Memory
thread_local! {
	static ENTRIES: RefCell<Entries> = RefCell::default();
}

// Heap Memory
thread_local! {
    static STORE: RefCell<String> = RefCell::default();
}

#[query(name = "getMessage")]
fn get_message()-> String {
    STORE.with(|store| {
        store.borrow().clone()
    })
}

#[update(name = "setMessage")]
fn set_message(text: String) {
    STORE.with(|store| {
        *store.borrow_mut() = text;
    });
}

#[ic_cdk::update]
fn register(name: String) {
	ENTRIES.with(|entries| {
		let value = Nat::from(entries.borrow().len());
		entries.borrow_mut().insert(name, value);
	});
}

#[ic_cdk::query]
fn lookup(name: String) -> Option<Nat> {
    ENTRIES.with(|entries| entries.borrow().get(&name).cloned())
}

#[ic_cdk::pre_upgrade]
fn pre_upgrade() {
	ENTRIES.with(|entries|
		ic_cdk::storage::stable_save((entries,)).unwrap());
}

#[ic_cdk::post_upgrade]
fn post_upgrade() {
    let (old_entries,): (HashMap<String,Nat>,) =
        ic_cdk::storage::stable_restore().unwrap();
    ENTRIES.with(|entries| *entries.borrow_mut() = old_entries);
}

アップグレードして、メモリの中身を確認する

アップグレード後に Heap memory はクリアされるが、Stable memory はクリアされずにアップデートした Canister に引き継がれるいった動きを確認してみる。

手順はRustのStable memory ワークフローを参考にして、1.Canister を停止、2.コード改修、3.アップグレード実行、4.Canister を起動するという流れを実行してみる。

  1. Canisterを停止して、pre_upgradeフックが定義された関数が実行される。
      (このとき、Heap memory は破棄され Stable memory は保持される)
  2. Canisterコード 改修(Canisterのアップグレード内容を実装する)
  3. 次に --mode upgradeを使用して改修した Canisterコード をインストールする。
  4. その後 Canister を起動すると、新しくアップグレードされたコードが実行され、post_upgradeフックが定義された関数が実行される

具体的には、次のコマンドを順に実行する。

terminal
// 起動・デプロイ
$ dfx start --clean --background
$ dfx deploy
// 停止
$ dfx canister stop hellorust_backend
// コード修正したら、ビルド・アップグレード
$ dfx build
$ dfx canister install hellorust_backend --mode upgrade
// 起動・デプロイ
$ dfx canister start hellorust_backend
$ dfx deploy hellorust_backend

動作確認

以下、動かして各memory の動きを確認していく

手順1 Canister起動後、各memoryに値をセットする

まずは、Canister を起動したら Heap memory に"heap"、Stable memory に"stable"をセットする。
(ここで、register() は Stable memory へ登録するAPIで、setMessage() は Heap memory へ登録するAPI)

手順2 セットされた値を確認する

上記で登録した値が Heap memory と Stable memory に格納されていることを確認
(ここで、lookup() は Stable memory を参照するAPIで、getMessage() は heap memory を参照するAPI)

手順3 Canisterを停止後、ソースコードを改修する

Canister を停止して、ソースコード改修する。動作確認が出来ればよいので、println!() を追加するだけの改修で試してみる

lib.rs
#[query(name = "getMessage")]
fn get_message()-> String {
    println!("upgrade"); // 追記
    STORE.with(|store| {
        store.borrow().clone()
    })
}

手順4 アップグレードして起動する

ビルド・アップグレードして、Canister を起動後に lookup() および getMessage() を実行すると、Heap memory である変数 STORE はクリアされていて何も取得できないが、変数 ENTRIES はクリアされておらずアップグレード前の値が保持されていることが確認できた。

2.2.principal(プリンシパル)でユーザを識別しよう

Internet Identityに触れていく前に、まずは principal に触れていくのがよいだろう。
principal は、公開鍵から決定論的に生成されるユーザまたは Canister を示す一意の識別子であり、principal ID と同義と理解できる。
この principal は Canister をデプロイ・管理する場合やquery call/update callする際に、ユーザまたは Canister を識別するために使用される。ユーザを特定することができるため、アクセス可能なリソースを制限することもできるようになる。

[column] principal を取得するには?

Terminal から次のコマンドを実行すれば principal をテキスト表現で取得できる

terminal
% dfx identity get-principal
hu3zm-5iqg5-sv3c4-qtmur-jukm5-afwyp-zf5cu-asucd-lbapd-4gbif-6ae

Identity

principal は非対称暗号鍵ペアの公開鍵のテキスト表現であり、各principalには紐付く秘密鍵(.pemファイル)が存在する。この秘密鍵が「Identity」と呼ばれていて、新しいマシンで初めてdfxを実行した際に下記ユーザーディレクトリにデフォルトで作成・保存される(開発者Identityとも呼ばれ、後述のInternet Identityで作成されるIdentityとは別物)。principal(ID) はこの Identity から導出された公開鍵を元に導出される。
また「Identity」の公開鍵ファイルはマシン上に生成されないので、注意が必要。
.pemファイルの中身は、次の通り(*でマスク済)

terminal
$ find ~/ -name identity.pem
/home/user/.config/dfx/identity/default/identity.pem
$ cat /home/user/.config/dfx/identity/default/identity.pem
-----BEGIN EC PRIVATE KEY-----
****************************************************************
****************************************************************
********************************
-----END EC PRIVATE KEY-----

ちなみに同一マシンに複数の Identity をインストールして、各 Identity に名前を付けることもできるそう。

Account ID

補足になるので読み飛ばして構わないが、Ledger Canister と呼ばれる Canister では、Acconut ID を用いてトランザクションとアカウントを関連付けて管理している。
この Account ID も principal ID と同様に Identity から導出される。より詳細にいえば、principal ID とサブアカウント識別子(同じ所有者(プリンシパル)が複数のアカウントを持つことを可能にするための識別子)から導出される。
サブアカウント識別子は、ユーザーまたは開発者が自由に生成できる任意な32バイト(Blob)の値。ただ、識別可能とするために一意な値である必要がある。

Identityから導出される情報

.pemファイル(Identity), Principal ID, Account ID を図示すると、下図のようになる。

Identityから導出される情報 概要図

principal(ID) の取得処理を実装する

Rust では、ic_cdk::caller() 関数で呼び出し元の principal を取得可能で、
ic_cdk::id() 関数で呼び出し先(Canister)の principal(Canister ID) を取得できる。

呼び出し元の principal を取得する whoami() 関数、呼び出し先の principal
を取得するwhatid()関数を Rust で実装してみる

ソースコード
lib.rs
#[ic_cdk::query]
fn whoami()-> String {
    ic_cdk::caller().to_string()
}

#[ic_cdk::query]
fn whatid()-> String {
    ic_cdk::id().to_string()
}

Rust なので .did ファイルにも上述の関数インターフェースを追記する

hellorust_backend.did
service : {
(省略)
    "whoami": ()->(text) query;
    "whatid": ()->(text) query;
}

principal(ID) を取得してみる

フロントを作るのも手間なので Candid UI で確認してみると、それぞれ principal を取得できた

2.3.Internet Identity(II)で認証する

Internet Identity(II)とは IC 上の"匿名"の認証システムである。
アカウント作成時にユーザーのデバイス上のセキュリティチップで秘密鍵が生成・保存され、同時にIdentity anchor を作成し II に保存する。Identity anchor には互換性のある暗号化対応デバイス (指紋センサー、Face ID、YubiKey や Ledger ウォレットなどのポータブル HSM など) を複数台割り当てることができる。すると、Identity anchor に割り当てた任意のデバイスを使用して、IC上で実行されている dapp にサインアップおよび認証することができるようになる。

Identity anchor

前述の Identity anchor とは、Identity に対して1つだけ存在する一意な番号であり、システムによって自動生成され Internet Identity に保存される。

同一 Identity anchor には複数のデバイス(スマートフォン・ノートPCなど)を登録することが可能であり、Internet Identity と統合された dapps では Identity anchor を使用して認証するため、異なるデバイス間でシームレスに dapp を利用することを可能となる。

Internet Identity では dapp 毎に異なるウォレットを作成する

Internet Identity では、セキュリティ強化のため dapp 毎に異なる principal を作成している。

もし principal を複数の dapp で使用すると、それらの dapp はユーザーの権限を共有するので、ユーザーが認証した dapp が、ユーザーのすべての資産を制御できるリスクを生じてしまう。

例えば、メッセージボードのフロントエンドが悪意を持ってショッピングサイトの Canister を呼び出し、ユーザーの名前で注文を行うことも可能になってしまうので、IIはユーザーがログインする各フロントエンドに対して異なる principal を生成している。

dapp 毎に異なる principal を持つ

Internet Identity(II)は、ユーザーが認証を行う各 dapp ごとに異なるセッションキーペアを生成することで、dapp 毎に異なる principal を持たせている。このセッションキーペアは、公開鍵と秘密鍵の組み合わせで、ブラウザのストレージに保存される。

ic-identity

この ic-identity が、II(II Canister)により作成されたセッションキーペアの秘密鍵と公開鍵であり、公開鍵、秘密鍵の順で格納される。
ic-identity : ["302a300...367", "21b2e...b367"](public key, private key)

ic-delegation

ic-delegation は、認証権限を他のキー(IIでの認証においてはセッションキー)に委任するためのデリゲーション。このデリゲーションがあることでセッションキーが root Identity を代表して署名できるようになる。

Internet Identity (II) 認証フロー

Internet Identity (II) 認証フローには登場する要素が多く整理しづらく感じたので図示してみた。
II認証フロー
II認証フロー 概要図

II Canister をローカル環境にデプロイする

前段が長くなってしまったが、実装をしていこうと思う。
ローカル環境での開発時では、II Canister をローカルにデプロイする必要があるので、まずは II Canister をデプロイして、IIと連携したdappで認証してみようと思う。

II Canister をローカルプロジェクトへ追加

次のコードブロックを dfx.json ファイル内の canisters セクションに追加する。

dfx.json
"internet_identity": {
  "type": "custom",
  "candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did",
  "wasm": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_dev.wasm.gz",
  "remote": {
    "id": {
      "ic": "rdmx6-jaaaa-aaaaa-aaadq-cai"
    }
  },
  "frontend": {}
}

II Canister をローカル環境へデプロイする

dfx deplo でデプロイ実行する

terminal
$ dfx deploy

実行してみると、internet_identity がデプロイされたことを確認できた。

terminal
Deployed canisters.
URLs:
  Frontend canister via browser
    hellorust_frontend:
      - http://127.0.0.1:4943/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai
      - http://bd3sg-teaaa-aaaaa-qaaba-cai.localhost:4943/
  Backend canister via Candid interface:
    hellorust_backend: http://127.0.0.1:4943/?canisterId=br5f7-7uaaa-aaaaa-qaaca-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai
    internet_identity: http://127.0.0.1:4943/?canisterId=br5f7-7uaaa-aaaaa-qaaca-cai&id=be2us-64aaa-aaaaa-qaabq-cai

auth-client ライブラリをフロントエンドにインストール

auth-clientは、Internet Identity との統合を容易にするためにDFINITYが提供するライブラリ

$ npm install @dfinity/auth-client @dfinity/identity --save-dev

Candid declarations の生成

dfx generateコマンドでプロジェクトのCandidファイルを自動生成する。自動生成されたファイルは、src/declarationsディレクトリに格納される。

terminal
$ dfx generate
実行結果
Generating type declarations for canister internet_identity:
  /home/user/hellorust/src/declarations/internet_identity/internet_identity.did.d.ts
  /home/user/hellorust/src/declarations/internet_identity/internet_identity.did.js
  /home/user/hellorust/src/declarations/internet_identity/internet_identity.did
Generating type declarations for canister hellorust_frontend:
  /home/user/hellorust/src/declarations/hellorust_frontend/hellorust_frontend.did.d.ts
  /home/user/hellorust/src/declarations/hellorust_frontend/hellorust_frontend.did.js
  /home/user/hellorust/src/declarations/hellorust_frontend/hellorust_frontend.did
Generating type declarations for canister hellorust_backend:
  /home/user/hellorust/src/declarations/hellorust_backend/hellorust_backend.did.d.ts
  /home/user/hellorust/src/declarations/hellorust_backend/hellorust_backend.did.js
  /home/user/hellorust/src/declarations/hellorust_backend/hellorust_backend.did

フロントエンドにログイン処理を実装する

II 認証後にログイン後ページ(/Contents)に遷移させ、II ログアウト時にログイン画面へ遷移させる

ソースコード
main.tsx
import React from 'react';
import * as ReactDOM from 'react-dom/client';
import AppRoutes from './AppRoutes'
import './index.scss';

const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');

ReactDOM.createRoot(rootElement).render(
  <React.StrictMode>
    <AppRoutes/>
  </React.StrictMode>,
);
auth.tsx
import { AuthClient } from "@dfinity/auth-client";

let authClient:AuthClient;

async function getAuthClient() {
  if (!authClient) {
    authClient = await AuthClient.create();
  }
  return authClient;
}

export async function isAuthenticated() {
  if (overrideAuthenticated) {
    return true; // in the middle of login.onSuccess, before authClient.isAuthenticated will return true
  }
  const authClient = await getAuthClient();
  return await authClient.isAuthenticated();
}

let overrideAuthenticated: boolean;

export async function authenticate(onSuccess:Function) {
  const authClient = await getAuthClient();
  await authClient.login({
    onSuccess: async () => {
      overrideAuthenticated = true;
      try {
        onSuccess();
      } finally {
        overrideAuthenticated = false;
      }
    },
    onError: async (e) => {
      throw e;
    },
    identityProvider: isMainnet()
      ? "https://identity.ic0.app/#authorize"
      : `http://${process.env.CANISTER_ID_INTERNET_IDENTITY}.localhost:4943/#authorize`,
  });
}

export async function logout() {
  if (authClient) {
    await authClient.logout();
  }
}

export async function getPrincipal() {
  const authClient = await getAuthClient();
  return authClient.getIdentity().getPrincipal();
}

export function isMainnet() {
  return process.env.DFX_NETWORK === "ic";
}
App.tsx
import * as React from 'react'
import Login from "./utils/Login"

const App: React.FC = () => {
  return (
    <main>
      <img src="/logo2.svg" alt="DFINITY logo" />
      <Login/>
    </main>
  );
}

export default App;
Contents.tsx
import * as React from 'react'
import * as Agent from './utils/auth';
import { useNavigate } from 'react-router-dom';
import { Button, Box } from '@mui/material'
import LogoutSharpIcon from '@mui/icons-material/LogoutSharp';

let _isAuthenticated;

const App: React.FC = () => {
  const [principal, setprincipal] = React.useState('');
  const navigate = useNavigate();

  const logout = async() => {
    await Agent.logout();
    _isAuthenticated = await Agent.isAuthenticated();
    navigate('/');
  }

  const _getPrincipal = async() => {
    const tmp_Principal = await Agent.getPrincipal();
    setprincipal(tmp_Principal.toString());
  }

  React.useEffect(() => {
    _getPrincipal();
    return () => {
    };
  }, []);


  return (
    <main>
      <img src="/logo2.svg" alt="DFINITY logo" />
      <Box>principal :{principal}</Box>
      <Button variant="contained" onClick={logout} endIcon={<LogoutSharpIcon/>}> II Logout </Button>
    </main>
  );
}

export default App;
Login.tsx
import * as React from 'react'
import { useNavigate } from 'react-router-dom';
import { Button, Stack, Box } from '@mui/material'
import LoginSharpIcon from '@mui/icons-material/LoginSharp';
import * as Agent from './auth';

let _isAuthenticated;

const Login: React.FC = () => {
  const [principal, setprincipal] = React.useState('');
  const navigate = useNavigate();

  const handleLoginComplete = () => {
    navigate('/Contents');
  };

  const _getPrincipal = async() => {
    const tmp_Principal = await Agent.getPrincipal();
    setprincipal(tmp_Principal.toString());
  }
  
  const login = async() => {
    Agent.authenticate(() => {
        _isAuthenticated = Agent.isAuthenticated();
        handleLoginComplete();
    });
  }

  React.useEffect(() => {
    _getPrincipal();
    return () => {
    };
  }, []);

  return (
    <main>
      <Box>principal :{principal}</Box>
      <Stack direction="row" spacing={2}>
        <Button variant="contained" onClick={login} endIcon={<LoginSharpIcon/>}>II Login</Button>
      </Stack>
    </main>
  );
}

export default Login;

環境変数に II Canister のIDが定義されている。

.env
# DFX CANISTER ENVIRONMENT VARIABLES
DFX_VERSION='0.20.1'
DFX_NETWORK='local'
CANISTER_CANDID_PATH_HELLORUST_BACKEND='/home/user/hellorust/src/hellorust_backend/hellorust_backend.did'
CANISTER_ID_INTERNET_IDENTITY='xxxxx-xxxxx-xxxxx-xxxxx-xxx'
CANISTER_ID_HELLORUST_FRONTEND='xxxxx-xxxxx-xxxxx-xxxxx-xxx'
CANISTER_ID_HELLORUST_BACKEND='xxxxx-xxxxx-xxxxx-xxxxx-xxx'

II 認証(ローカル環境)してみる

Canister を起動させ、ログイン画面を表示する。Principal ID は匿名プリンシパルを示す「2vxsx-fae」であり、ログイン状態でないことが分かる。

1.ログインボタン押下する

「II LOGIN」ボタンを押下して authClient.login() をコールし、IIのユーザー認証画面を表示する。

2.Internet Identity を作成する

「Create Internet Identity」を押下して、Identity Anchor を作成する。

3.Passkeyを生成する

「Create Passkey」を押下して、パスキー認証できるようにする。
ローカルIIの場合は、デバイスの登録は出来ないっぽい

4.Human 認証する

Botかどうか確認する。よくあるやつ
ローカルIIの場合、「a」固定っぽい

5.保存して、ログインする

「I saved it, continue」を押下して、ログインする

6.ログアウトする

ログインに成功すると onSuccess で指定したコールバック関数が呼び出され、ログイン後画面に遷移する。ログイン後、匿名ユーザではなくログインユーザの Principal ID が割り当てられたことが確認できる。

7.ログアウト後

匿名プリンシパルに戻っており、ログアウトしたことが分かる

さいごに

ここまでで筆者は息切れしてしまったのとボリュームが多くなりすぎてしまったこともあり、HTTP Outcalls や Chain key ECDSA は次回に紹介しようと思います。ICPにおけるWEBアプリケーションのテンプレートを作るまでは記事を書いていく予定です。

Discussion