fix(cross-chain): A1 — CCTP burn 再開安全化 (marker fail-closed・決定表・手動照合/hash 貼付け・掟 15 要レビュー) - #440
Merged
Merged
Conversation
…to 再 burn は flag OFF)
全コードベースレビュー第5回 P1-A1。CCTP path の burn だけ「hash の有無」しか見ておらず、
2 つの事故が同時に成立していた:
(i) burn() を broadcast した後、hash を永続化する前に reject / タブ閉じ → 再開時に
`needMerchantBurn = !state.burnTxHash` が true になり **二重 burn (二重支払い)**。
(ii) hash は残ったが tx が revert / 置換された → Iris attestation が永久に出ず **恒久 wedge**。
mint 側は txAlreadySucceeded で landed を検証しているのに burn 側だけ非対称だったのが本質。
## 対処
broadcast の直前に「送るつもり」= burn-intent marker を fail-closed で永続化し、再開時は
marker + on-chain の事実 (receipt / pending vs latest nonce / DepositForBurn log の有界走査)
だけで分岐する。曖昧な状態は自動で再 burn せず wait / manual (人間の二段確認) に倒す。
- marker 書込 (`saveResumeStateStrict`・read-back 検証つき) が失敗したら **burn しない**。
lib/circlePending.ts と同型の fail-closed。書けなければ isCommitted も立てないので、
親フォームの通常決済は使えたまま (掟 13: 何の波及を断つ防御かをコメントで明示)。
- 判定は決定表 21 行 (plans/a1-cctp-burn-resume.md §4) を純粋関数 classifyBurnState に閉じた。
「最古の一致 log を採る」ような選択は一切しない。0 / 1 / 複数 をそのまま扱い、複数一致は manual。
- 過去の同額 burn の誤検出は「候補 tx の nonce >= marker.nonceLatest」で構造的に排除する
(過去 burn は必ず nonce が小さい)。merchant / fee が同額・同宛先の病的ケースも nonce で分離。
- getLogs は chunk 2000 (range error で 1000→500→250→100 に自動縮小)・call 上限 24・chain 別
の span cap。超過は throw せず manual に倒す (provider 上限で恒久 wedge にしない)。
- transport 障害 (RPC ダウン) は握り潰さず throw する。「観測できなかった」を「起きていない」に
潰すと二重 burn になるため (execute.ts の CR-2 と同じ区別)。
## flag / UI (user 決定ぶん)
- `NEXT_PUBLIC_CROSS_CHAIN_BURN_AUTORESUME` 既定 **OFF**。row 9/12/18 の自動再 burn だけを
ゲートし、marker 書込・revert 検出・log 走査・adopt は常時 ON (flag OFF が「二重 burn バグを
残す設定」にならないようにするため)。点灯は manual 到達率を観測してから (user 承認事項)。
- manual パネル (買い手が Explorer で自分の USDC が減っていないことを確認 → 二段確認 → 再送金)
を同梱。CrossChainBurnUnresolvedPanel は next/dynamic で遅延ロードし、/pay 438kB・/tip 436kB
の予算に載せない (予算据え置き・実測とも増減なし)。
- 一致 burn が複数見つかった場合だけは二段確認でも開かない (人間の申告より on-chain を優先)。
## 掟
- 掟 12: money-path の制御フロー変更を含むため掟 15 を適用 (merge 前に user レビュー)。
Gateway path・settleMint・txAlreadySucceeded・calldata 生成は不変。
- 掟 9: .env.local.example / README env テーブルを同時更新。
- 掟 5: messages/ja.json・en.json の CrossChainHint namespace に 13 key を明示追加。
- 掟 6: 共有 hook (useCrossChainPayment) 変更のため full vitest 実施 (470 file / 9281 test green)。
Refs: plans/a1-cctp-burn-resume.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CNMEwurCJNTrzvr2qypSd
…hash 貼付け再開
d146946 (A1) の敵対的レビュー 8 件を修正。最大の穴は「二段確認さえ通れば burn」で、
決定表の row 3/4 は pendingAhead 判定より **前** に返るため、mempool に burn が居ても
再送金できてしまっていた (= 二重支払い)。
## D1 (出荷ブロッカー): manual → burn は「実測が揃っている」ことを条件にする
- classifyBurnState の入力に `pendingAhead: boolean | undefined` (undefined = 未計測) と
`timeGapSatisfied` を追加。canManuallyReburn() が override の可否を一元判定する:
① mempool 空を **実測済み** ② 走査したなら一致 0 件 ('range'/'ratelimited' は「見ていない」
ので不可) ③ marker があるなら MIN_GAP_MS 経過。
- row 3 (旧 state) / row 4 (block・nonce 欠落) / row 20 (cap 超過) はいずれもこの gate を通す。
結果として row 20 は二段確認でも開かない (走査していないため) — 自己解決率より安全側を採る。
- resolveBurnSlot の legacy 分岐 (marker 無し) で pending/latest nonce を実測してから分類する。
marker.block/nonce を欠く場合は log 走査を **行わず** scan=undefined にし、「走査できなかった」
と「走査して 0 件」を型で区別する。
## D3: fee slot の未確定が merchant を人質に取らない (掟 13)
fee が wait/manual でも merchant の attestation → mint はそのまま進め、fee は
`feeBurnUnresolved` として resume state と実行結果に記録するだけにする (自動再 burn しない・
Iris poll もしない)。UI は成功パネルの二次通知で「追加の支払いは不要」を伝える。
## D4: burn の tx hash 貼付けによる再開
「USDC は減ったが hash が残らなかった」(row 4/20) の唯一の自己救済経路。買い手が貼った hash を
verifyBurnTxHash() が on-chain だけで検証し (receipt success + marker と一致する DepositForBurn
log + tx.nonce >= marker.nonceLatest + sender 一致)、通った場合のみ burnTxHash として採用して
続き (Iris poll → mint) に進む。不一致は state を変えず inline error。採用書込も fail-closed。
## その他
- D2: executeOption でも burnUnresolved を reset し、別種のエラーでは畳む (error が
`error && !burnUnresolved` で隠れる問題)。wait パネルに「もう一度確認する」を追加 —
親 Pay ボタンは wait 中 disabled なので、押す先が無いと文言が嘘になる。
- D5: 候補 tx の getTransaction/getTransactionReceipt も MAX_SCAN_CALLS の予算に載せ、
候補単位の notfound (reorg) は走査全体を throw せずその候補だけ飛ばす。
- D6: rate limit (-32005 / 429 / compute units) を範囲超過と分離し、一過性として wait
(row 22) に倒す。QuickNode の "limited to a 10,000 blocks range" と viem の metaMessages に
対応。'limit exceeded' は両方の文面に出るため rate limit 判定を先に置く。
- D7: legacy resume (row 2) が source RPC の receipt 読みに依存するようになったこと (可用性
コストの受容) を execute.ts のコメントに明記。
- D9: mount 時に resume state の marker/hash/attestation から isCommitted を復元する。
再読込しただけで親フォームの直接決済が解禁される穴を塞ぐ。
## テスト
- burnMarker: mempool 不変条件を marker 無し / block=null にも拡張 (allowManualReburn=true でも
burn にならない)、override 条件・row 22・provider 3 文面・候補読み取りの隔離・verifyBurnTxHash。
- execute: fixture の receipt を hash ごとに返すようにし (merchant/fee の取り違えを検出)、
row 3 + 二段確認 + mempool 有り → burn 無し、fee 未確定でも merchant mint 完走を追加。
- CrossChainHint: mount 復元 (D9)、wait → 再確認で再判定 (D2)、hash 貼付けの採用/不一致/書式不正、
errorStorageBlocked の描画。
掟 5: CrossChainHint namespace に 12 key 追加 (ja/en)・burnWaitBody を再確認導線に合わせて更新。
掟 12/15: money-path の制御フロー変更を含むため merge 前に user レビュー。
Refs: plans/a1-cctp-burn-resume.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CNMEwurCJNTrzvr2qypSd
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
第 5 回全コードベースレビュー (plans/full-review-2026-09-02.md) の A1 = CCTP burn の再開安全性。cross-chain USDC (Base 等 → 受取チェーン) の burn 境界に「着弾したか」の再確認が無く、応答喪失で二重 burn・revert で恒久 wedge になり得た。設計 = plans/a1-cctp-burn-resume.md (user 裁定: ①auto 再 burn は flag OFF ②手動照合パネルを出す ③hash 貼付け可 ④CCTP のみ)。
変更 (2 commit)
1. A1 本体 (89441f9)
lib/crossChain/burnMarker.ts(新規): burn-intent marker (block / nonceLatest / noncePending / params) を broadcast 前 に fail-closed で永続化 (saveResumeStateStrict= 読み戻し検証)。決定表classifyBurnState(設計 §4・22 行) を純粋関数で実装。DepositForBurn log 走査は ≤2000 block chunk・MAX_SCAN_CALLS 24・MAX_SCAN_SPAN_BLOCKS 24000。候補受理は amount / destinationDomain / mintRecipient / burnToken / depositor 一致 +tx.nonce ≥ marker.nonceLatest(過去の同額 burn を掴まない)。lib/crossChain/execute.ts: burn 前にresolveBurnSlotで receipt / nonce / log を実測 → wait / manual なら wallet popup を出さずCrossChainBurnUnresolvedErrorで停止。自動再 burn はNEXT_PUBLIC_CROSS_CHAIN_BURN_AUTORESUME(既定 OFF) のときだけ。hooks/useCrossChainPayment.ts+components/CrossChainBurnUnresolvedPanel.tsx(next/dynamic): 手動照合パネル (wait = 数分後に再確認 / manual = explorer で USDC 残高を確認する二段確認)。.env.local.example/ README: flag 追記。i18nCrossChainHint12 キー。2. 敵対検証の 8 指摘への修正 (3445578)
pendingAhead === falseを 実測済み のときだけ開く (undefined= 未計測は拒否・型で区別)。走査をrange/ratelimitedで打ち切った場合も「見ていない」ので開かない。旧 state (row 3) もgetTransactionCount(pending/latest)を実測してから判定。設計からの逸脱: row 20 (走査 cap 超過) は二段確認でも開かない → 自己救済は hash 貼付け (D4) かサポート経由。burnUnresolvedをリセット。wait パネルに「もう一度確認する」ボタン (親の Pay ボタンが disabled で再確認手段が無かった)。feeBurnUnresolvedを resume state / 結果に記録して二次通知 (「追加のお支払いは不要」)。verifyBurnTxHash— receipt success + TokenMessenger の DepositForBurn log が marker と完全一致 +tx.nonce ≥ marker.nonceLatestのときだけ採用 (transport 障害は unavailable・不一致は state 不変)。getTransaction/getTransactionReceiptも call 上限に計上・候補単位の notfound は skip。errorTextは message / details / shortMessage / metaMessages を cause 連鎖で読む。QuickNode のblocks range文言を追加。isCommittedを stored burnIntent / burnTxHash / attestation から復元。errorStorageBlocked描画テスト追加。i18nCrossChainHint+12 キー・burnWaitBody文言更新。検証 (origin/main 9582f46 に rebase 後・Fable 実行)
npm run typecheckexit 0 / eslint 変更ファイル 0 errors (既存 warning 2 = CrossChainHint.tsx:134,140 exhaustive-deps・本 PR 以前から)npm run buildexit 0 / bundle budget 全ルート OK (pay 441/442・tip 438/438・create 388/392・[handle] 478/479・checkout 444/446・shared 230/250)user に判断をお願いしたい点
NEXT_PUBLIC_CROSS_CHAIN_BURN_AUTORESUMEは既定 OFF のまま (点灯は別途)。🤖 Generated with Claude Code
https://claude.ai/code/session_015CNMEwurCJNTrzvr2qypSd