💯

1時間でWeaveDBを使ったTodoアプリを作る

2022/11/22に公開

WeaveDBとは現在絶賛開発中のDecentralized NoSQL データベースです。
Arweave上に構築されており、最速で1秒で読み込みと3秒で書き込むことができます。
そんなわかる人にはすごさがわかるWeaveDBですが、執筆時点でチュートリアルに何か所か詰まるところがあったので、僭越ながらそれらを補完しつつTodoアプリを作成するまでを記事にしました。
WeaveDB自体の解説などに関しては開発者の長澤さんが記事を書かれているので、そちらを参照ください
https://hide.ac/articles/AUtrj_fGV
また、参考元のチュートリアルはこちらです。

開発環境

stack version
node.js v16.18.1
yarn 1.22.19
git ---
補足としては筆者はWindows10とMacOS(M1)で動作確認を行っています。

1.Deploy Contract

まずWeaveDB自体をセットアップします。イメージとしてはFirebaseのプロジェクト作成です。

1.1 Setup Repository

WeaveDBのリポジトリをクローンしたあとパッケージをインストールします

/
git clone https://github.com/weavedb/weavedb.git
cd weavedb
yarn

1.2 ウォレット作成

WeaveDBをデプロイするためのArweaveウォレットを作成します。

/weavedb
node scripts/generate-wallet.js mainnet

成功すると/weavedb/scripts/.wallets/wallet-mainnet.jsonにウォレットのデータが作成されます。

1.3 WeaveDBをデプロイする

いよいよWeaveDBをデプロイします。チュートリアルだとyarn deployを実行するように書かれていますが、これは罠です。
現在のバージョンのリポジトリだとIntmaxやその他もろもろの機能が追加されていて、互換性が失われています。そのため、次のステップでエラーが出ます。
なので/weavedb/scripts/deploy2.jsを作成して以下のスクリプトを貼り付けてください。

/weavedb/scripts/deploy2.js
const fs = require("fs/promises");
const path = require("path");

const Arweave = require("arweave");
const { WarpNodeFactory } = require("warp-contracts");

const srcTxId = "G7aMmk1Fux6Dqw7M7CFoNQf5KZV2J1UCuZjfEn_VFVM";

const deploy = async () => {
  const arweave = Arweave.init({
    host: "arweave.net",
    port: 443,
    protocol: "https",
  });
  const warp = WarpNodeFactory.memCachedBased(arweave).useWarpGateway().build();

  const wallet = JSON.parse(
    await fs.readFile(
      path.join(__dirname, ".wallets/wallet-mainnet.json"),
      "utf-8"
    )
  );
  const walletAddress = await arweave.wallets.jwkToAddress(wallet);

  const stateFromFile = JSON.parse(
    await fs.readFile(
      path.join(__dirname, "../dist/warp/initial-state.json"),
      "utf8"
    )
  );

  const initialState = {
    ...stateFromFile,
    ...{
      owner: walletAddress,
    },
  };

  const res = await warp.createContract.deployFromSourceTx(
    {
      wallet,
      initState: JSON.stringify(initialState),
      srcTxId,
    },
    true
  );
  console.log(res);
};

deploy()
  .catch(console.error)
  .finally(() => process.exit(0));

そしてyarn buildしたあとこのスクリプトを実行してください

/weavedb
yarn build
node scripts/deploy2.js

成功すると以下のような情報が出ると思います。

{
  contractTxId: 'e2n9PMi9oYOfhLRMoEsoZSMHBt681-rdekZnzHd8uc4',
  srcTxId: 'G7aMmk1Fux6Dqw7M7CFoNQf5KZV2J1UCuZjfEn_VFVM'
}

このうちのcontractTxIdプロパティは重要なのでどこかにメモしてください。

1.3.5 コントラクトの確認

先ほどデプロイされたコントラクトが本当にデプロイされたか確認しましょう。
SonARというサイトにアクセスしてください。
https://sonar.warp.cc/?#/app/contracts
このサイトは他のチェーンでいうBlockChain Exploerで、デプロイされたコントラクトなどを詳しく調べることができます。
サイト上側の検索に先ほどのcontractTxIdを貼って下側に出てきたContractの検索候補をクリックしてください。
するとページが遷移してコントラクトの詳細などを見ることができます。

1.4 WeaveDBをマイグレートする

WeaveDBは使用する前にあらかじめデータ構造とセキュリティルールを設定する必要があります。
SQLデータベースでいうマイングレートです。
weavedb/scripts/todo-setup.jsを開いてください。ここでは今回作成するTodoアプリのスキーマとセキュリティルールが定義されており、それらを設定する処理が書かれています。
以下のコマンドで設定できます。CONTRACT_TX_IDは先ほどのものと置き換えてください。

/weavedb
node scripts/todo-setup.js mainnet CONTRACT_TX_ID

先ほどのSonARのTransactionsタブで無事設定できているか確認できると思います。
これで難関のWeaveDBのセットアップは完了です!!

2. Todoアプリのフロントエンドを作成

2.1 Next.jsのプロジェクトを作成

今回の記事用に最低限の構成のNextJSのテンプレートを作成しました。
下のリンクからテンプレートをコピーして、それをパソコンにcloneして開いてください。
https://github.com/inaridiy/next_simple_templete
今後、レポぢ鳥ををweavedb-todoとして作成した場合の手順をか行きますが、適時置き換えてください。

/
git clone REPO_URL
cd weavedb-todo
yarn

2.2 パッケージインストール

WeaveDBのアクセスに必要なパッケージをインストールします。

/weavedb-todo
yarn add localforage weavedb-sdk buffer ethers

2.3 SDKの型を作成

weavedb-sdkはTypescriptの型が付属していないので、TypescriptからSDKを扱いづらいです。
そこで、簡易かつ私の推測ですがdtsファイルを作成したので、/weavedb-todo/src/types/weavedb-sdk.d.tsに下記のコードを貼り付けてください。

/weavedb-todo/src/types/weavedb-sdk.d.ts
interface Window {
  ethereum: any | undefined;
}
declare var window: Window;

declare module "weavedb-sdk" {
  export interface ArweaveConfig {
    host?: string;
    protocol?: string;
    port?: string | number;
    timeout?: number;
    logging?: boolean;
    logger?: Function;
    network?: string;
  }

  export type SigningFunction = (tx: Transaction) => Promise<void>;

  export interface ArWallet {
    kty: string;
    e: string;
    n: string;
    d?: string;
    p?: string;
    q?: string;
    dp?: string;
    dq?: string;
    qi?: string;
  }

  export type WarpSigner = SigningFunction | ArweaveWallet | "use_wallet";

  export interface EthWallet {
    wallet: string;
    privateKey: string;
  }

  export interface WeaveDBConfig {
    arweave?: ArweaveConfig;
    contractTxId: string; // maybe
    wallet: WarpSigner;
    name: string;
    version: string;
    EthWallet?: string | EthWallet; //maybe
    web3?: any;
  }

  export type OP = {
    __op: string;
  } & any;

  export default class SDK {
    constructor(config: WeaveDBConfig);
    cget<T = any>(path: string, ...query: string[][]): Promise<T>;
    add<T = any>(data: T, path: string, user: EthWallet): Promise<void>;
    update<T = any>(
      data: T,
      path: string,
      id: string,
      user: EthWallet
    ): Promise<void>;
    delete(path: string, id: string, user: EthWallet): Promise<void>;
    ts(): OP;
    signer(): OP;
    createTempAddress(
      address: string
    ): Promise<{ tx: any; identity: any; err: any }>;
  }
}

2.4 ページを作成

/weavedb-todo/src/pages/index.tsxに下記のコードを貼り付けてください。
ログイン処理に関しては後で解説しますが、それ以外はFirestoreなどと大きく変わらないので省きます。

/weavedb-todo/src/pages/index.tsx
import { useState, useEffect, useCallback } from "react";
import lf from "localforage";
import SDK, { EthWallet } from "weavedb-sdk";
import { Buffer } from "buffer";
import { ethers } from "ethers";
import clsx from "clsx";

const contractTxId = WEAVEDB_CONTRACT_TX_ID
const arweave_wallet = ARWEAVE_WALLET_JSON

type Task = {
  block: { height: number; timestamp: number };
  data: {
    date: number;
    done: boolean;
    task: string;
    user_address: string;
  };
  id: string;
  setter: string;
};

export default function Home() {
  const [db, setDb] = useState<SDK | null>();
  const [tasks, setTasks] = useState<Task[]>([]);
  const [loading, setLoading] = useState("");
  const [input, setInput] = useState("");
  const [user, setUser] = useState<EthWallet | null>(null);
  const [tab, setTab] = useState<"ALL" | "MY">("ALL");

  const getTasks = useCallback(async () => {
    if (!db) return;
    try {
      setLoading("getTasks");
      setTasks(await db.cget("tasks", ["date", "desc"]));
    } catch (e) {
      console.error(e);
    } finally {
      setLoading("");
    }
  }, [db]);

  const getMyTasks = useCallback(async () => {
    if (!db || !user) return;
    try {
      setLoading("getTasks");
      setTasks(
        await db.cget(
          "tasks",
          ["user_address", "=", user.wallet.toLowerCase()],
          ["date", "desc"]
        )
      );
    } catch (e) {
      console.error(e);
    } finally {
      setLoading("");
    }
  }, [db, user]);

  const addTask = async (task: string) => {
    if (!db || !user) return;
    setLoading("addTask");
    try {
      await db.add(
        {
          task,
          date: db.ts(),
          user_address: db.signer(),
          done: false,
        },
        "tasks",
        user
      );
      await getTasks();
    } catch (e) {
      console.error(e);
    } finally {
      setLoading("");
    }
  };

  const deleteTask = async (id: string) => {
    if (!db || !user) return;
    await db.delete("tasks", id, user);
    await getTasks();
  };

  const login = async () => {
    if (!window.ethereum || !db) return;
    setLoading("login");
    try {
      const provider = new ethers.providers.Web3Provider(window.ethereum);
      await provider.send("eth_requestAccounts", []);
      const wallet_address = await provider.getSigner().getAddress();
      const identityCache: any = await lf.getItem(
        `temp_address:${contractTxId}:${wallet_address}`
      );
      if (identityCache)
        return setUser({
          wallet: wallet_address,
          privateKey: identityCache.privateKey,
        });

      const { tx, identity } = await db.createTempAddress(wallet_address);
      if (!tx || tx.err) throw new Error("Failed to relate address TX");
      if (tx && !tx.err) {
        await lf.setItem("temp_address:current", wallet_address);
        await lf.setItem(`temp_address:${contractTxId}:${wallet_address}`, {
          wallet: wallet_address,
          privateKey: identity.privateKey,
        });
        setUser({
          wallet: wallet_address,
          privateKey: identity.privateKey,
        });
      }
    } catch (e) {
      console.error(e);
    } finally {
      setLoading("");
    }
  };

  const checkUser = async () => {
    const wallet_address = (await lf.getItem(`temp_address:current`)) as string;
    if (!wallet_address) return;

    const identity = (await lf.getItem(
      `temp_address:${contractTxId}:${wallet_address}`
    )) as any;

    setUser({
      wallet: wallet_address,
      privateKey: identity.privateKey,
    });
  };

  const submit = () => {
    if (input) {
      addTask(input);
      setInput("");
    }
  };

  useEffect(() => {
    if (tab === "ALL") {
      getTasks();
    } else {
      getMyTasks();
    }
  }, [tab, getTasks, getMyTasks]);

  useEffect(() => {
    if (typeof window === "undefined") return;
    window.Buffer = Buffer;
    checkUser();
    const db = new SDK({
      wallet: arweave_wallet,
      name: "weavedb",
      version: "1",
      contractTxId,
      arweave: {
        host: "arweave.net",
        port: 443,
        protocol: "https",
      },
    });
    setDb(db);
  }, []);

  return (
    <div className="h-screen bg-base-200">
      <div className="mx-auto w-full max-w-xl p-2 gap-2 flex flex-col">
        <div className="flex w-full gap-2">
          <input
            className="input flex-1 input-bordered"
            placeholder="Create New Era"
            disabled={!user}
            onChange={(e) => setInput(e.target.value)}
          />
          {loading ? (
            <button disabled className="btn btn-disabled loading"></button>
          ) : user ? (
            <button className="btn" onClick={submit}>
              Submit
            </button>
          ) : (
            <button className="btn btn-primary" onClick={login}>
              Login
            </button>
          )}
        </div>
        <div className="flex gap-2">
          <button
            className={clsx(
              "btn flex-1",
              tab === "ALL" ? "btn-primary" : "btn-ghost"
            )}
            onClick={() => setTab("ALL")}
          >
            All Tasks
          </button>
          <button
            className={clsx(
              "btn flex-1",
              tab === "MY" ? "btn-secondary" : "btn-ghost"
            )}
            onClick={() => setTab("MY")}
          >
            My Tasks
          </button>
        </div>
        {tasks.map((task) => (
          <div key={task.id} className="card flex-row bg-base-100 p-2 pl-6">
            <div className="flex flex-col flex-1">
              <div className="text-lg font-bold">{task.data.task}</div>
              <div className="flex gap-2">
                <div>{task.data.done.toString()}</div>
                <div>{new Date(task.data.date * 1000).toLocaleString()}</div>
                <div>
                  {task.data.user_address.slice(0, 6) +
                    "..." +
                    task.data.user_address.slice(-4)}{" "}
                </div>
              </div>
            </div>
            {task.data.user_address === user?.wallet.toLowerCase() && (
              <button
                className="btn btn-error btn-square text-xl"
                onClick={() => deleteTask(task.id)}
              >
                🗑️
              </button>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

WEAVEDB_CONTRACT_TX_IDARWEAVE_WALLET_JSONはそれぞれデプロイしたcontractTxIdとArweaveのウォレットの中身を貼り付けてください。

2.5 起動

/weavedb-todo
yarn dev

成功すればTodoアプリが立ち上がるはずです。

2.6 ログイン処理の解説

ログイン処理のコアの部分は次の通りです

const provider = new ethers.providers.Web3Provider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const wallet_address = await provider.getSigner().getAddress();
const identityCache: any = await lf.getItem(
  `temp_address:${contractTxId}:${wallet_address}`
);
if (identityCache)
  return setUser({
    wallet: wallet_address,
    privateKey: identityCache.privateKey,
  });

const { tx, identity } = await db.createTempAddress(wallet_address);
if (!tx || tx.err) throw new Error("Failed to relate address TX");
if (tx && !tx.err) {
  await lf.setItem("temp_address:current", wallet_address);
  await lf.setItem(`temp_address:${contractTxId}:${wallet_address}`, {
    wallet: wallet_address,
    privateKey: identity.privateKey,
  });
  setUser({
    wallet: wallet_address,
    privateKey: identity.privateKey,
  });
}

WeaveDBでは署名によって仮のウォレットを作成してトランザクションを行います。
下記のコードでは以前に生成した仮ウォレットのキャッシュを読み込んでキャッシュがあるならそれでログインします。

const identityCache: any = await lf.getItem(
  `temp_address:${contractTxId}:${wallet_address}`
);
if (identityCache)
  return setUser({
    wallet: wallet_address,
    privateKey: identityCache.privateKey,
  });

キャッシュがない場合、SDKのcreateTempAddressメソッドを読みだして仮ウォレットを生成し、メインのアドレスと紐づける処理を行います。
そしてできたウォレットでログインします。

const { tx, identity } = await db.createTempAddress(wallet_address);

ログインの大まかな流れはこんな感じです。

終わりに

WeaveDBの簡単な使い方をまとめてみました。
もしなにかわからないことなどがあれば、Twitterで連絡ください。

Discussion