T14 · Web3 フロントエンド
取引の確認を待つ十数秒間、画面には何を表示すべきか?
- 練習する能力
- Builder
- 実践
- 取引の完全な状態機械を実装する:署名待ち、送信済み、承認待ち、成功、失敗、置き換え済み。
- AI Lab
- AI に取引失敗のエラーコードを列挙させ、平易な表示文を作らせて、ユーザーを誤解させる表現がないか確認する。
現実の問題
ユーザーが「交換」を押します。
ウォレットが開き、確認すると閉じます。ページには回転するアイコンと「処理中」が表示されます。
そして十数秒が過ぎます。アプリに慣れた人には長い時間です。止まったかと疑い、別アプリへ移って戻り、もう一度ボタンを押すほど長いのです。
その間、画面は実際には何も分かっていません。
- 署名したのか?ウォレットで拒否したのに、ページは回り続けるかもしれません。
- ブロードキャストした取引はメモリプールへ入ったか?手数料が低く、止まっていないか?
- ブロックへ入ったか?入れば成功なのか?
- ウォレットで「高速化」を押した後、手元のハッシュはまだ有効か?
一つでも答えを誤れば、事実と違う状態を表示します。最も高くつく誤りは、失敗した取引を成功と表示することです。
UI の細部ではありません。Web2 の要求は数百ミリ秒で成否が分かります。チェーンの取引には 6 種類の状態と、十数秒から数分の中間状態があり、中間状態は後戻りもします。Web2 と同じ感覚で画面を作ると誤ります。
思考実験
取引を HTTP 要求ではなく、書留郵便として考えます。
投函した時点から、次のことが起きます。
- 手元に受付番号がある。これがあって初めて照会でき、なければ何もできません。
- 郵便が仕分け場で待つ。郵送料が高ければ前へ進み、低ければ何日も待つかもしれません。
- 郵便がある車へ載る。しかし輸送中で、事故や返送の可能性があります。
- 車が到着する。ようやく結果が分かり、配達完了と配達失敗による返送の 2 つがあります。
最後が鍵です。到着することと、配達に成功することは別です。
チェーンでは、取引がブロックへ入るのは、マイナー/検証者が実行したことを示すだけで、成功したとは限りません。実行中にエラーとなってもブロックへ入り、手数料は消費され、レシートには失敗と記録されます。
T1 でも説明しましたが、この分野で最もよくあるバグなので繰り返します。
成否は、ブロックにあるかではなく、レシートの
statusを確認します。
書留にはない性質もあります。同じ番号で、別の郵便へ置き換えられます。 同じ nonce の取引が最終的にチェーンへ入るのは一つです。ウォレットの高速化やキャンセルは、同じ nonce で新しい取引を送ります。元のハッシュでは、その後レシートを取得できなくなります。
それを待ち続ける画面は、永遠に回転します。
あなたが決める
十数秒の待機画面を、どう設計しますか?
結果を観察する
違いは、最初の 3 つが画面を描き、4 つ目がモデルを作ることです。
画面中心だと、例外ごとに if が増え、5 つ目には誰も状態の総数を説明できなくなります。モデルなら、例外を状態機械のノードとして追加できます。
取引の状態は、6 つに整理できます。
| 状態 | 意味 | 画面の動作 | 終状態か |
|---|---|---|---|
| 署名待ち | ウォレットへ要求し、操作を待つ | ウォレットでの確認を促し、ボタンを無効化 | いいえ |
| ブロードキャスト済み | ハッシュを取得し、メモリプールへ入る | ハッシュとエクスプローラーリンクを表示し、高速化を許可 | いいえ |
| 確認待ち | ブロックへ入り、確認数が不足 | 確認進捗を表示 | いいえ |
| 成功 | レシート status が成功で、確認数も十分 | 結果表示とデータ更新 | はい |
| 失敗 | レシート status が失敗 | 原因と次の行動を説明 | はい |
| 置換済み | 同 nonce の別取引がチェーンへ入る | 新ハッシュを追跡するか、キャンセルを通知 | はい |
見落としがちなルールを 2 つ加えます。
署名拒否は失敗ではありません。 エラーを出さず待機へ戻します。赤いエラーは、単に考えを変えたユーザーに、悪いことをしたと感じさせます。
成功から後戻りすることもあります。 チェーン先端の再編成が起きます(T1 の最後の実例)。確認数が不足したまま成功と表示すると、再編成後に虚偽になります。十分な確認数に達するまで、成功は本当の終状態ではありません。
モデルを作る
主経路は一直線です。
- Idle:待機
- AwaitingSignature:署名待ち
- Broadcast:送信済み
- Pending:確認待ち
- Confirmed:成功
型で状態を記述します。言語より重要なのは、各状態の持つデータが異なることです。ブール値ではなく、ユニオン型を使う理由です。
type TxState =
| { kind: 'idle' }
| { kind: 'awaitingSignature'; intent: TxIntent }
| { kind: 'broadcast'; hash: string; nonce: number; submittedAt: number }
| { kind: 'pending'; hash: string; nonce: number; blockNumber: number; confirmations: number }
| { kind: 'confirmed'; hash: string; blockNumber: number; confirmations: number }
| { kind: 'failed'; hash?: string; reason: FailureReason }
| { kind: 'replaced'; originalHash: string; replacementHash: string; by: 'speedup' | 'cancel' | 'unknown' };
type FailureReason =
| 'userRejected' // ウォレットで拒否。厳密には失敗ではない
| 'insufficientFunds' // 手数料または送金額の残高不足
| 'reverted' // チェーンへ入ったが実行が巻き戻された
| 'slippage' // 見積期限切れやスリッページ超過。reverted の一種
| 'nonceConflict' // nonce を別の取引が使った
| 'dropped' // 長時間ブロックへ入らず、メモリプールから除外された
| 'unknown';isLoading、isSuccess、isError の 3 ブール値では、不可能な isLoading && isSuccess も型として有効で、いつか分岐で現れます。ユニオン型は不正な状態を表現不可能にします。最も実際的な利点です。
遷移表
各遷移は、個別にテストできるケースです。
| 遷移元 | きっかけ | 遷移先 |
|---|---|---|
| 待機 | ユーザーのクリック | 署名待ち |
| 署名待ち | ハッシュ取得 | 送信済み |
| 署名待ち | ユーザーの拒否 | 待機(失敗ではない) |
| 署名待ち | ウォレットのエラー | 失敗 |
| 送信済み | ブロック内で取引を発見 | 確認待ち |
| 送信済み | 時間切れでハッシュも不明 | 失敗(dropped) |
| 送信済み | 同 nonce の別取引が入る | 置換済み |
| 確認待ち | レシート成功と必要確認数 | 成功 |
| 確認待ち | レシート失敗 | 失敗(reverted) |
| 確認待ち | 親ハッシュが不連続で再編成 | 送信済みへ戻る |
| 成功 | 確認数が減り、再編成 | 確認待ちへ戻る |
最後の 2 行が表の価値です。状態機械は後戻りを許す必要があります。 前進だけでは再編成時に誤った終状態に固まり、成功表示が永久に修正されません。
間違えやすい 3 点
第一に、nonce を中心に、ハッシュを補助として追跡します。 高速化やキャンセルでハッシュは変わり、nonce は変わりません。そのアドレスの確定済み nonce が対象 nonce を超えたのに、対象ハッシュのレシートがなければ、置換済みです。実際に入った同 nonce のハッシュを探し、追跡を続けます。
第二に、待機は中断、再開できるようにします。 更新、タブ終了、端末変更が起きます。追跡対象をローカルへ永続化し、読み込み時に未完了の取引の追跡を再開します。メモリだけなら、一度の更新で失われます。
第三に、送信済みではなく、署名待ちでボタンを無効にします。 両者の間の数秒に再度押すと、二つ目の署名要求が出ます。この隙間では重複送信が必然的に起きます。まれな出来事ではありません。
エラーの変換表
低水準のエラーを、説明と行動へ変えます。3 列目が最も重要です。
| 元の状況 | ユーザーへの説明 | できる行動 |
|---|---|---|
| 署名拒否 | 表示せず静かに戻る | 再び押す |
| 手数料残高不足 | 手数料が不足しています。基軸コインを補充してください | 入金入口へ移動 |
| 実行の巻き戻し | 取引は完了せず、手数料は消費されました | 原因候補を見て再試行 |
| スリッページ超過 | 設定した範囲以上に価格が変わりました | 許容幅の変更か再見積もり |
| 長時間未収録 | ネットワークが混雑し、取引は待機中です | 高速化かキャンセル |
| 置換済み | この取引は新しい取引に置き換わりました | 新しい取引を確認 |
文言の必須ルールは 2 つです。
- レシート取得前に成功と伝えません。 提出済み、送信済み、確認中は使えても、成功は使えません。
- 理由なしに「失敗。再試行してください」と書きません。 再び失敗し、もう一度手数料を失う可能性が高いからです。
何と呼ぶか
有限の状態と明確な遷移条件で、取引の寿命全体を表します。
コンポーネントに散らばるブール値群を置き換えます。Crypto のフロントエンドの質を見るなら、これがあるかを確認すればよいのです。
取引確定前に期待される結果を画面へ反映し、即時に感じさせます。
必須の対となる機能はロールバックです。失敗、置換、再編成では元へ戻し、通知します。戻せない更新は、良い体験ではなく善意の嘘です。
ノード、ウォレット、コントラクトの生エラーを、理解できる説明と実行可能な次の行動へ変えます。
各 catch へ散らさず、中央の一つの表にします。そうして初めて、レビュー、テスト、再利用ができます。
チェーン先端が巻き戻ると、成功表示した取引を未確認へ戻し、必要なら楽観的更新も取り消します。
フロントエンドではバックエンドより単純で、状態機械が後戻りを許せばよいのです。本格的な複雑さは T17 で扱います。
同じ nonce と高い手数料で新取引を送り、元の取引を置き換えます。高速化もキャンセルもこれです。
フロントエンドへの意味は、元ハッシュではレシートを取得できず、nonce で実態を発見する必要があることです。
取引のブロックの後に生成されたブロック数です。真偽値ではなく、確率的な安全指標です。
必要数は業務の判断です。アバター変更なら 1 でもよく、大口引き出しの許可なら数十が必要な場合があります。
実践
フォーセットのテストコインだけを使い、テストネットで行います。 わざと失敗や停滞を作るので、メインネットで練習しないでください。
目標は美しい画面ではなく、6 状態をすべて実際に一度通ることです。
コンポーネントより先に型を書きます。
前のユニオン型をコピーし、現在の状態とイベントから新状態を返す、純粋な reducer を書きます。
合格基準は、reducer にネットワーク呼び出しがないことです。純粋なら、通信なしで全遷移をテストできます。
順調な経路を接続し、成功へ進みます。
単純なテストネット送金を行います。ハッシュ取得で送信済みへ進み、レシートをポーリングし、status が成功かつ確認数が十分なら成功へ進みます。
status を必ず検証します。 status が成功でないのに成功状態へ入ったら例外を投げる、一行のアサーションを書きます。本章の中心です。
失敗へ進みます。
必ず巻き戻される取引を作ります。例えば残高を超える送金額など、条件を満たさない引数をコントラクトへ渡します。
取引はチェーンへ入り、手数料を消費し、レシート status は失敗になります。画面が成功ではなく失敗を表示するか確認します。
成功と出たなら、業界で最も多いバグを、自分のコードで再現したことになります。
置換済みへ進みます。
手数料を現在よりはるかに低くして送信し、メモリプールに停滞させます。
ハッシュと nonce を記録し、ウォレットで高速化かキャンセルを押します。同じ nonce の新取引が送られます。
画面は、確定済み nonce が対象を超えたのに元ハッシュのレシートがないと検出し、置換済みへ進み、新ハッシュへ追跡を切り替えるべきです。
回り続けるなら、nonce を追わずハッシュだけを追っています。
署名待ちから待機へ戻ります。
取引を開始し、ウォレットで拒否します。
静かに待機へ戻り、ボタンが使えるようになり、赤いエラーは出さないのが正解です。出るなら直してください。拒否は誤りではありません。
後戻りを作ります。
再編成の発生は難しいので、reducer へ確認数減少イベントを直接送り、成功から確認待ちへ戻り、表示が「再確認中」になるか試します。
数行の純粋なロジックテストですが、設計の最も重要な性質、後戻りできる状態機械を検証します。
エラー表を追加し、更新も試します。
前の表を一つの集中マッピングとして実装します。取引を送り、未確定の間にページを更新します。
消えるのではなく、追跡が再開されるべきです。できなければ、状態がメモリだけにあります。
7 工程を終えれば、どのプロジェクトへも持ち込める状態機械ができます。この段階で最も再利用しやすいコードです。
AI Lab
2 段階で進め、必要な成果は 2 番目です。
第 1 段階:
チェーンの取引を作成してから確定するまでの、全失敗状況を列挙してください。
各状況の条件、ユーザーが見る現象、具体的な判定フィールドを示してください。
署名拒否、手数料不足、巻き戻し、スリッページ超過、nonce 衝突、
メモリプールからの除外、同 nonce の新取引による置換を含めてください。
第 2 段階:
各状況について、一般ユーザー向けの日本語メッセージを作ってください。
- 20 文字以内
- 何が起きたか分かる
- 今できる行動を示す
その後、文言が、取引成功、着金済み、引き落とし済み、取り消し可能という
誤解を生まないか、一つずつ自己点検してください。モデルは自己点検が得意でも、結果は自分で再確認しなければなりません。文言はどれも自然に読めるのが危険です。「完了」と「提出済み」は語感では近くても、意味には status 一つ分の差があります。
ほぼ必ずある偏りは、置換済みを漏らしやすいことです。多くの文書やチュートリアルに登場せず、実ユーザーが高速化したときに初めて現れます。漏れたら、「高速化後、元のハッシュはどうなるか」と直接聞いてください。
AI の回答後、自分で必ず検証すること
- チェーンへ入ったことを成功と同一視していない。最重要項目として、すべての文言を確認した
- レシート前の段階で、成功、完了、着金という言葉を使っていない
- 署名拒否をエラーとして赤字にしていない
- 置換済みの状態を扱っている。多くのモデルが漏らします
- エラーコード、フィールド名、判定条件を、実際のウォレットとノードの戻り値でそれぞれ確認した
- 各文言に、単なる後での再試行ではなく、今実行できる行動が付いている
- 手数料不足と資産残高不足を区別した。解決法がまったく異なります
実例
最もよくあるバグで、多くの新プロジェクトが一度は犯します。
緑の「交換成功」を見たユーザーがウォレットを開くと、トークンは交換されず、手数料だけ減っています。レシート status は失敗ですが、フロントエンドは読まず、ハッシュ取得時点で成功にしていました。
見つけにくいのは、正常経路では完全に正常に見えることです。失敗時にだけ現れ、開発時には失敗取引を専用に作る人が少ないためです。
Lab の第 3 工程で、意図的に作る理由です。
ユーザーが高速化すると、取引はすぐチェーンへ入りましたが、ページは回り続けます。
高速化は同じ nonce の新ハッシュを使います。フロントエンドは永久にレシートのない旧ハッシュをポーリングしていました。
さらに悪い場合は、何回か後に失敗と表示します。実際には成功したのに、画面は失敗を伝えます。ユーザーは再送するかもしれません。
クリック直後に一覧へ交換成功を追加し、失敗後も残ります。
次回訪問時に一覧が更新され、記録が消えます。ユーザーは、元から失敗したとは思わず、製品が記録をなくしたと考えます。
戻せない楽観的更新が生むのは、錯覚ではなく不信です。
ブラウザーからウォレットへ移り、確認後に戻る間に、OS がページを破棄して再読み込みする場合があります。
実際には取引を送ったのに表示がなく、ユーザーは失敗と思って再送します。両方の取引が実行されます。
永続化の直接的な理由です。読み込み時に、進行中の取引をローカルから復元できなければなりません。
変数を一つ変える
状態機械は一文字も変えず、中間状態の滞在時間だけが短くなります。
ただし生成が速いほど一ブロックの重みが軽く、先端の再編成確率が通常高くなることは重要です。同じ 12 確認でも、チェーンにより安全性が大きく異なります。
確認数を全体の定数にせず、チェーンと業務ごとの設定にします。
一クリックが、複数操作を含む取引になり、2 つの変化を生みます。
良い変化:承認と操作を一工程にでき、クリックが減り、承認だけ成功して操作が失敗する中間状態がありません。
難しい変化:レシート status は一つでも、内部には複数操作があり、ログで失敗箇所を判定します。一取引一エラーから、一取引一組の結果へマッピングを拡張します。T27 で詳しく扱います。
基軸コインが不要になり、手数料不足が流れから消え、体験が大きく改善します。
しかし前段階が増えます。代払い側が支払う意思を持つか確認します。拒否、制限、実行途中の失敗があり、新しい失敗要因を表へ入れます。
また取引の発信者がユーザーではなくなる場合があり、nonce の追跡対象も変えます。
ネット未接続の端末で署名し、署名済み取引を接続端末へ運んで送信します。
署名待ちと送信済みの間に、数分から数日の手動移送の空白ができ、状態機械が二段に分かれます。
署名時には正しかった nonce が、移送後には別取引に使われ、取引期限切れがよくある失敗になります。時間が延びると、無視できた競合が日常になります。
持ち帰る問い
ユーザーは誰ですか?チェーンの仕組みを知らず、自分のお金がどうなったかを知りたい人です。16 進数は不要でも、失敗を含む真の状態を知る権利があります。
誰が支払いますか?失敗してもユーザーが手数料を払います。失敗文言に明記しないと、ユーザーは資金を盗んだと疑ってから、別の場所で聞くことになります。
誰がリスクを負いますか?失敗を成功と表示すると、損失はユーザーへ、しかも気付かれずに落ちます。誤認したまま次の操作へ進みます。状態機械は表示ミスを、運任せから網羅的にテストできる問題へ変えます。
章末チェック
実行に失敗しても収録され、手数料が消費され、レシートの status が失敗を示すだけだからです。
レシート status が成功で、確認数が閾値以上という両条件が必要です。一つでも欠ければ成功とは表示できません。
T1 の説明を、ここでは status を読むコードにします。
旧ハッシュには永久にレシートがなく、nonce で実態を調べます。
- アドレスの確定済み nonce を照会します。
- 対象 nonce を超え、元ハッシュのレシートがなければ置換です。
- 実際に入った同 nonce の取引を探して追跡を移し、新取引に置き換わったと通知します。
ハッシュは変わり、nonce は変わりません。nonce を中心に追跡します。
違います。静かに待機へ戻し、ボタンを有効にし、赤い表示は出しません。
体験だけの理由ではありません。エラーにすると、悪いことをしたという負担を与えます。特に理解不能な署名要求では、拒否できる能力を保ってほしいのです。
ユーザーの慎重さを罰しないでください。
ブール値は不正な組み合わせを許します。isLoading と isSuccess の同時 true は型上合法で、いつか再現困難なバグになります。
ユニオン型なら不正状態を表現できず、すべての分岐を扱うよう求められます。送信済みにハッシュがあり待機にはないなど、状態固有のデータを持てるので、任意フィールドを各所に書く必要もありません。
唯一の正解はありません。5 点を確認します。
- 6 状態があるか?特に置換済みは、多くの設計が漏らします。
- 後戻りの遷移があるか?再編成時に成功から確認待ちへ戻れますか?
- 署名拒否の行き先はどこか?失敗なら誤りです。
- 更新後も進行中の取引が残るか?メモリだけでは不合格です。
- 全失敗分岐で、ユーザーが次の行動を分かるか?
第 2 項は最も思い付きにくく、設計の質を分ける項目です。
要点
暗号資産のフロントエンドの中心は取引の状態機械であり、ページの寄せ集めではない。