Web3フロントエンドTips
この記事は昔に書いた以下の記事の和訳&アップデートになります。
https://dev.to/yujiym/web-30-frontend-stacks-in-2023-1i03
1.5年くらいDeFi(EVM)のフロントエンドを書いてきて、おすすめのスタック・ライブラリについてとWeb3ならではのTipsや工夫点についてまとめました。他に何かおすすめがあったり、質問があれば教えてください!
1. システム構成
2. スタック・ライブラリ
Turborepo
フォルダ構成 :monorepo (multiple apps in one repo)
構成を使うことが多いです。
コードのほとんどは packages
配下の共通フォルダに置き、各アプリにはルーティングのための最小限のページコンポーネント構成とします。
以下のようなケースでアプリの拡張が柔軟に対応できます
- Chain毎に異なるページや関数を持つdApps
- 機能を限定したLite版を別途用意するケース
- ミドルウェア、APIやツールを切り出すケース
.
├── apps
│ ├── dapp
│ │ └── pages (or app) # each app has only pages dir
│ ├── sub-dapp
│ └── ...
├── packages
│ ├── assets
│ │ ├── img
│ │ └── styles
│ ├── config
│ ├── lib
│ │ ├── constants
│ │ │ └── abis # abi json
│ │ ├── queries # thegraph queries
│ │ ├── store # state
│ │ ├── types
│ │ └── utils
│ ├── react-lib
│ │ ├── components # components
│ │ └── hooks # hooks
│ └── tsconfig
└── test
Next.js + TypeScript + Vercel
Frontend Framework & ホスティング :Web3向けのライブラリが充実しているので、Reactが無難な選択肢です。
Next.jsをTurborepoでデプロイする場合、Vercelが最も簡単なのですが、Vercelの便利な機能(Edge関数、ISR...)を使ってしまうと、ベンダーロックされてしまい、後々IPFSにデプロイしてdAppを完全分散化するケースで移行が難しくなってしまいます。
Viteやesbuildも小規模なアプリケーションやツールには適していると思います。
最近はServerless frameworkを使ってもっと小さな構成でアプリを作れないか考えています。
wagmi + ethers.js
ethereum接続 :wagmi
はethers.js
をwrapしたReact Hooksですでした。類似ライブラリは多いですが、機能も多く、テストも充実しているのでおすすめです。 -> wagmiの他のライブラリとの比較
また、wagmiはEthereum ABIのTypeScript型を提供していて、これはzod
スキーマとも連携して動作します。これにより、dAppsの型チェックがより厳密にできるようになります。-> ABIType
今はethers.jsの代替としてtypescript nativeで軽量で、ヒューマンリーダブルなエラーを返す(これ一番大事)、viemを作っています。次のversionでethers.js依存はなくなります。
書込み系処理の例
import { usePrepareContractWrite, useContractWrite, useWaitForTransaction, erc20ABI } from 'wagmi'
....
export default function useApprove(targetAddress: AddressType, owner: AddressType, spender: AddressType) {
....
const args: [AddressType, BigNumber] = useDebounce([spender, BigNumber.from(amount)])
const enabled: boolean = useDebounce(!!targetAddress && !!owner && !!spender)
const prepareFn = usePrepareContractWrite({
address: targetAddress,
abi: erc20ABI,
functionName: 'approve',
args,
enabled
})
const writeFn = useContractWrite({
...prepare.config,
onSuccess(data) {
....
}
})
const waitFn = useWaitForTransaction({
chainId,
hash: write.data?.hash,
wait: write.data?.wait
})
return {
prepareFn,
writeFn,
waitFn
}
}
import useApprove from 'react-lib/hooks/contracts/useApprove'
....
export default function SomeComponent() {
const { prepareFn, writeFn, waitFn } = useApprove(targetAddress, account, spenderAddress)
// handle view with each hook's return value -> https://wagmi.sh/react/hooks/useContractWrite#return-value
return (
<div>
{approve.prepare.isSuccess &&
<button
onClick={() => writeFn?.write}
disabled={writeFn.isLoading || prepareFn.isError}
>
Approve
</button>
)}
{writeFn.isLoading && <p>Loading...</p>}
{writeFn.isSuccess && <p>Tx submitted!</p>}
{waitFn.isSuccess && <p>Approved!</p>}
</div>
)
}
The Graph + urql
インデックス、集計処理 :The Graph is an indexing protocol for querying networks like Ethereum and IPFS. Anyone can build and publish open APIs, called subgraphs, making data easily accessible.
Contract Callは遅く、1度に1つしか呼び出せないため、複数のコントラクトコール結果をインデックスしたり、集計値を計算するためのレイヤーが必要になってきます。(例:liquidity一覧をtableで取得する)
集計とインデックス処理は、Contractのeventをトリガーとするsubgraphでyml形式で指定でき、その結果をthegraphのdbに保存します。
クライアント側では、graphqlでデータを問い合わせることができます。urql
は、軽量なgraphqlクライアントです。
ハマりポイントとしては、
- 同block内で実行順序が保証されないようなケースへ対応しておかないと、エラーになる
- localhost環境で開発・debugするには、Archive Nodeが必要(過去時点のstateに対してcontracl callを実行するため)、HardHat環境ではMock等をかかなければいけない
※subsquidが最近EVM Chainのサポートを追加したので、次はこれを試してみたいと思っています。
// define query
import { gql } from 'urql'
export const GET_MY_LIQUIDITIES = gql`
query getMyLiquidities($account: String) {
${FRAGMENT}
user(id: $account) {
id
liquidities {
id
protocols {
...Fragment
}
}
}
}
`
// call in component
import { useQuery } from 'urql'
....
export function SomeComponent() {
....
const [{ data, fetching }] = useQuery({
query: GET_MY_LIQUIDITIES,
variables: {
account: account.toLowerCase()
},
pause: !account
})
....
}
tailwindcss + PostCSS + Radix UI + UI components by shadcn
UI, CSS :シンプルで&カスタマイズ可能なFrameworkが好みです。
Radix UIはHeadless UIよりも機能が豊富ですが、スタイルを追加するのは難しいです。なので、shadcnによるコンポーネントはほど良い塩梅だと思います。
私はこのCSS variables with Tailwind CSS setupを使っています。
jotai
State管理 :jotai
は、シンプルで軽量な状態管理ライブラリです。useState
+ ContextAPI
のようなシンプルな構成ですが、余計な再レンダリングを防いでくれたり、多くのユーティリティがあります。
類似ライブラリとして、recoil, zustand, valtioがあるので、好きなものを選ぶのが良いと思います。
// packages/store/index.ts
import { atom } from "jotai";
type SessionType = {
chainId?: number;
account?: string;
};
export const sessionsAtom = atom<SessionType>({});
// in component or hook
import { sessionAtom } from 'lib/store'
import { useAtom } from 'jotai'
....
const [session, setSession] = useAtom(sessionAtom)
....
setSession({ chainId: res.chainId, account: res.address })
react-hook-form & zod
フォームライブラリ:前述したwagmi、typescriptと親和性の高いzodでスキーマ管理をして、react-hook-formを使うことが多いです。
3. Web3 frontend tips
トランザクションを非同期で処理する
BlockChainのレスポンス(特にMainnet)は、Web2のそれのレスポンスよりかなり遅いです。
ユーザーがトランザクションを送信した後、ローディング表示やトランザクションのステータスの表示などのハンドリングが必要です。
ユーザーのトランザクションがconfirmされたにもかかわらず、thegraphのデータ更新が遅れるため、ユーザーの操作がブラウザに反映されないケースが発生します。このケースは、最初のレンダリングにはthegraphのデータを使用し、後でcontract callの結果でその値を上書きするようにして対応しています。
また、トランザクションがconfirmする前にユーザーがページやサイトを離れてしまった場合に対応するため、ユーザーの未完了のトランザクションは一旦localStorage
で永続化して、確定するまで保持するようにしています。
// watching uncompleted transaction
import { useEffect } from 'react'
import { useBlockNumber, useProvider } from 'wagmi'
....
export default function useTxHandler() {
const { chainId, account } = useWallet()
const transactions = useTransactions() // get user's transactions from state
const blockNumber = useBlockNumber({
chainId,
scopeKey: 'useTxHandler',
watch: true
})
const provider = useProvider<any>()
useEffect(() => {
transactions.map(async (tx: Transaction) => {
await provider
.getTransactionReceipt(tx.hash)
.then(async (transactionReceipt: any) => {
if (transactionReceipt) {
.... // update user's transaction status here
})
}
})
.catch((e: any) => {
log(`failed to check transaction hash: ${tx.hash}`, e)
})
})
}, [account, chainId, PROVIDER_URL, transactions, blockNumber.data])
}
BigNumberの取り扱いについて
ERC20には decimals
フィールドがあり桁を意識して扱わなければなりません。また、基本Int型の扱える桁数を超えるため、BigNumberを扱うためのライブラリで処理する必要があり、いくつかライブラリが林立していました。(しかもサイズ意外と大きい)
ethers.js v6から、ES2020ビルトインのBigIntが採用されました。(wagmiのviemもBigInt)これを使っていきましょう。 -> Migrating from v5
Uniswap tokenlist format
This package includes a JSON schema for token lists, and TypeScript utilities for working with token lists.
OnChainにあるTokenの情報は限られており、OffChainのどこかにリスト形式で保持する必要があります。Uniswapが公開しているこの形式に従っておくと、取り扱いが便利です。
4. 気になっていること
Permit2
Permit2 introduces a low-overhead, next generation token approval/meta-tx system to make token approvals easier, more secure, and more consistent across applications.
各dAppの各コントラクトを承認する代わりに、一度Permit2
を承認すれば、ERC20 ContractへのApproveをコントロールできるようになります。これにより、UX(walletの毎回のapproveがなくなる!)とセキュリティ(各Dappのコントラクトに対して大量のallowanceが残っていることによる問題)を改善することが期待できます。早くデファクトになってほしい。
Lit Protocol
Lit is distributed key management for encryption, signing, and compute.
Mina
Mina is building the privacy and security layer for web3 with zero knowledge proofs.
5. 所感
- 1.5年前は雑なフロントエンド/ライブラリが多かったけど、ものすごい勢いでクオリティ高くなってきてる。動きも早い(2ヶ月前に書いた記事の和訳に伴う修正ですら結構あった)
- 最近だとERC-4337によるAA実装や、ZKP関係で追えないくらいの新情報が流れてくる
- UXをつきつめていくと、キモの台帳部分のセキュリティ以外については別の手段で代替するのが良いと思うようになった。OffChainで処理したり、Modular Blockchain (Lit Protocol, Mina Protocol, Ceramic Network)と組み合わせたり。そうなると、Web5やのNostrのやろうとしていることも筋は遠っているように思う。
- ようやくDeFi以外のキラーユースケースが出てきそうな雰囲気があり楽しみ
Discussion
めちゃめちゃ参考になりました!モノレポ生成用のフレームワークとかあったんですね・・。yarn init から頑張って作ってました!
あとはNXとかもあるのですが、turboはvercelに買われたのでvercel deployを考えているなら設定が圧倒的に楽です