Slide 1

Slide 1 text

ブロックチェーンアプリの 状態管理はなぜ難しいのか Cross-Chain Bridge「TOKI」での事例 2026/2/28 React Tokyoフェス2026 株式会社Datachain 伊藤 耕太

Slide 2

Slide 2 text

伊藤 耕太 株式会社Datachain トークン化預⾦関連事業 フロントエンドセクション 2024年10⽉⼊社からTOKIのフロントエンド開発に従事していました。 現在はトークン化預⾦関連事業にて、フロントエンドセクションの リードをしています。 普段は福岡からフルリモートで働いています。

Slide 3

Slide 3 text

Cross-Chain Bridge「TOKI」 ● 4 ネットワーク対応 ● 異なるネットワーク間でトークンを移す Transfer機能 ● 他、流動性追加‧削除のPool機能や コミュニティ向けの ポイントプログラム機能など ● モバイルファーストなデザイン ● ダークモード対応 ※ TOKIは2026年1⽉をもってクローズしました。

Slide 4

Slide 4 text

フロントエンドの状態管理と⾔えば? 状態の種類 ⽤途 Server State APIデータのキャッシュ‧再検証 URL State フィルター‧ページネーション‧検索クエリなど Client State UIステート(モーダル開閉、選択状態など) Form State フォーム⼊⼒‧バリデーション

Slide 5

Slide 5 text

ブロックチェーンアプリ(DApps)の状態管理は 状態の種類 ⽤途 Server State APIデータのキャッシュ‧再検証 URL State フィルター‧ページネーション‧検索クエリなど Client State UIステート(モーダル開閉、選択状態など) Form State フォーム⼊⼒‧バリデーション Blockchain State on-chainデータ‧トランザクションの状態など

Slide 6

Slide 6 text

トランザクションとは? created ウォレットで署名、トランザクションハッシュが発⾏される pending ブロックチェーンノードの待機エリア(mempool)に保存 contained ブロックに取り込まれる ブロックチェーン上で⾏われる取引の記録のこと。 TOKIでは Transfer や流動性の追加‧削除などを⾏うとトランザクションが⽣成されます。 finality トランザクションが確定する

Slide 7

Slide 7 text

トランザクションとは? created ウォレットで署名、トランザクションハッシュが発⾏される pending ブロックチェーンノードの待機エリア(mempool)に保存 contained ブロックに取り込まれる finality トランザクションが確定する 数秒 ブロックチェーン上で⾏われる取引の記録のこと。 TOKIでは Transfer や流動性の追加‧削除などを⾏うとトランザクションが⽣成されます。 TxHash: 0x123456… TxHash: 0xabcdef… ※Ethereumネットワークを例にしています。約 12秒ごとに1ブロック生成。 時間

Slide 8

Slide 8 text

トランザクションとは? created ウォレットで署名、トランザクションハッシュが発⾏される pending ブロックチェーンノードの待機エリア( mempool)に保存 contained ブロックに取り込まれる ブロックチェーン上で⾏われる取引の記録のこと。 TOKIでは Transfer や流動性の追加‧削除などを⾏うとトランザクションが⽣成されます。 - TxHash: 0x123456… finality トランザクションが確定する 数秒 数⼗秒 ※Ethereumネットワークを例にしています。約 12秒ごとに1ブロック生成。 時間

Slide 9

Slide 9 text

トランザクションとは? created ウォレットで署名、トランザクションハッシュが発⾏される pending ブロックチェーンノードの待機エリア( mempool)に保存 contained ブロックに取り込まれる ブロックチェーン上で⾏われる取引の記録のこと。 TOKIでは Transfer や流動性の追加‧削除などを⾏うとトランザクションが⽣成されます。 finality トランザクションが確定する 数秒 数⼗秒 約12分 ※Ethereumネットワークを例にしています。約 12秒ごとに1ブロック生成。 時間

Slide 10

Slide 10 text

さて、ブラウザでページをリロードしました。 フロントエンドは正しいトランザクションの状態を知ることができるのでしょうか? トランザクション いつ頃終わるんだろう トランザクション 失敗してないかな? ガス代かかったけど 大丈夫?

Slide 11

Slide 11 text

// Response [ { "type": "TRANSFER_POOL", "nonce": 1205, "txs": [ { "status": "SUCCEEDED", "txHash": "0xc0f817b5dd499ba5f0259b3607bc3460afaef41ff48de64728b2828410909b6e", "chainId": 56, "createdTime": "2025-12-08T02:31:37Z", "estimatedTime": "2025-12-08T02:31:37Z", "order": 1, "steps": [ "contractAddress": “0x..”, "event": “SendPacket”, ] }, { …} ], "status": "COMPLETED", "statusDetail": "COMPLETED_DETAIL", "sourceTxHash": "0xc0f817b5dd499ba5f0259b3607bc3460afaef41ff48de64728b2828410909b6e", "sourceChainId": 56, "createdTime": "2025-12-08T02:31:37Z", .. }, API v2/operations トランザクションハッシュを保持していれば、 ブロックチェーンノードに問い合わせたり、バックエンドのAPI、 チェーンのエクスプローラに聞くことで状態を取得できます。 // Response { "blockHash": "0xf850331061196b8f2b67e1f43aaa9e69504c059d3d3fb9547b04f9ed4d141ab7", "blockNumber": "0xcf2420", "from": "0x00192fb10df37c9fb26829eb2cc623cd1bf599e8", "gas": "0x5208", "gasPrice": "0x19f017ef49", "maxFeePerGas": "0x1f6ea08600", "maxPriorityFeePerGas": "0x3b9aca00", "hash": "0xbc78ab8a9e9a0bca7d0321a27b2c03addeae08ba81ea98b03cd3dd237eabed44", "input": "0x", "nonce": "0x33b79d", "to": "0xc67f4e626ee4d3f272c2fb31bad60761ab55ed9f", "transactionIndex": "0x5b", "value": "0x19755d4ce12c00", "type": "0x2", "accessList": [], "chainId": "0x1", "v": "0x0", "r": "0xa681faea68ff81d191169010888bbbe90ec3eb903e31b0572cd34f13dae281b9", "s": "0x3f59b0fa5ce6cf38aff2cfeb68e7a503ceda2a72b4442c7e2844d63544383e3", "yParity": "0x0" } curl \ -X POST \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "eth_getTransactionByHash", "params": ["0xbc78ab8a9e9a0bca7d0321a27b2c03addeae08ba81ea98b03cd3dd237eabed44"], "id": 1}'

Slide 12

Slide 12 text

トランザクションハッシュを保持していれば、 ブロックチェーンノードに問い合わせたり、バックエンドのAPI、 チェーンのエクスプローラに聞くことで状態を取得できます。 しかし、pendingの状態ではブロックチェーンノードからはnullと返ってきたり、 バックエンドAPIにはそのトランザクションの情報が含まれていない. . . ってことが起こり得ます。

Slide 13

Slide 13 text

ブロックに取り込まれる前、フロントエンドが接続しているノードによって 結果が変わってしまいます。 RPCを複数候補から選ぶ設計でも、ノード間でmempoolは⼀致しません。 また、バックエンドはそもそもブロックに取り込まれた後にしか検知しません。 しかし、pendingの状態ではブロックチェーンノードからはnullと返ってきたり、 バックエンドAPIにはそのトランザクションの情報が含まれていない. . . ってことが起こり得ます。

Slide 14

Slide 14 text

つまり、pendingの状態はフロントエンドで 保持しておく必要があります。 ブロックに取り込まれる前、フロントエンドが接続しているノードによって 結果が変わってしまいます。 RPCを複数候補から選ぶ設計でも、ノード間でmempoolは⼀致しません。 また、バックエンドはそもそもブロックに取り込まれた後にしか検知しません。

Slide 15

Slide 15 text

  pendingをどこに保持すべきか?主な要件は、 ● トランザクション数によってデータが増えても安⼼な設計にしたい ● [address + createdTime]によって絞り込み、ソートしたい ● UIを⾮同期に更新したい つまり、pendingの状態はフロントエンドで 保持しておく必要があります。

Slide 16

Slide 16 text

IndexedDBを採⽤(Dexie + Live Query)   どこに保持するか?主な要件は、 ● トランザクション数によってデータが増えても安⼼な設計にしたい ● [address + createdTime]によって絞り込み、ソートしたい ● UIを⾮同期に更新したい localStorage IndexedDB 容量/履歴 △(厳しめ) ◎ 検索/ソート ✕(⾃前) ◎ 更新とUI △ ◎

Slide 17

Slide 17 text

トランザクションを送ったら、IndexedDBに pending を保存して、UI表⽰を⾏います。 同時にバックエンドAPIから取得した情報で IndexedDBを更新して、UIを追従させます。 こうすることで、リロードしてもトランザク ションの追跡が失われることはありません。 そして確定情報が届くたびに、最新の状態へ 更新されていきます。

Slide 18

Slide 18 text

まとめ ● トランザクションは⾮同期に進み、段階的に状態が変わっていきます。 ● pending状態にある時、ノードやAPIでは正しい状態を取得できる保証がありま せん。 ● リロードしても状態を失わないように、ローカルに保持する仕組みにしました。 ● IndexedDBを⼀時的なSingle Source of Truthとして扱う。 ● フロントエンドが責任を持てる設計にすることが、ユーザ体験を守る

Slide 19

Slide 19 text

ご清聴ありがとうございました。 We are hiring! ブース出展してますので、ぜひお越しください!

Slide 20

Slide 20 text

補⾜資料

Slide 21

Slide 21 text

in-memoryを加えた⽐較 in-memory localStorage IndexedDB 容量/履歴 △ △(厳しめ) ◎ 検索/ソート △ ✕(⾃前) ◎ 更新とUI ◎ △ ◎ リロード耐性 ✕ ◎ ◎ in-memoryはシンプルですが、リロードで消えます。 localStorageは永続化できますが、検索やソートが弱い。

Slide 22

Slide 22 text

useLiveQueryの仕組み export const useOperations = (sender?: string, offset?: number, limit?: number) => { const operations = useLiveQuery (() => { if (!sender) return [] let query = dbOperations. operations .where('[sender+createdTime]' ) .between([sender, Dexie. minKey], [sender, Dexie. maxKey]) .reverse() if (!isUndefined (offset)) { query = query. offset(offset) } if (!isUndefined (limit)) { query = query. limit(limit) } return query.toArray() }, [sender, offset, limit]) ?? [] return { operations } }

Slide 23

Slide 23 text

エッジケース対応 ● WebSocket切断 → 再接続時にBackendから最新を取得して同期 ● tx失敗 → RESUMABLE / UNRECOVERABLE の状態遷移で対応 ● タイムアウト → ウォレット側で speed up / cancel、サポートへの導線