diff --git a/hooks/useBatchPayment.ts b/hooks/useBatchPayment.ts index 13c54249..efb570d1 100644 --- a/hooks/useBatchPayment.ts +++ b/hooks/useBatchPayment.ts @@ -1121,11 +1121,27 @@ async function runCirclePayment(args: { // - 明示供給 (params.paymentAttemptId) があればそれを素通しし、マッピングは一切触らない。 // - 無ければ callHash 由来の安定マッピング (sessionStorage) で同一タブの再クリック/reload に // 同じ attemptId を割り当て、executeCirclePayment 内の crash-resume 分岐を機能させる。 + // **対応を保存できなかった押下は、新しい支払いを始めない** (stableAttemptId の saved が false = sessionStorage を + // 読めない・書けない・書いても残らない端末。第 10 回レビューの後追い r10-ss)。何の波及を断つか: 保存していない attemptId で + // 新しい支払いを送ると、その支払いが確認待ちで終わった後の押下が別の attemptId (別の key) になり、裏で確定した前の支払い + // (90 秒より前の confirmed) の後に 2 本目の署名を求める。 + // 止めるのは executeCirclePayment の中 = 新しい記録を予約する直前 (mappingSaved: false を渡す・circleSend.ts の冒頭の + // 「対応を保存できていない押下」)。**ここ (入口) では止めない**: 既にある支払いの復旧・結果の受け取り (scan) は、対応を + // 保存できなくても今までどおり通す (入口で止めると、対応を保存した後に sessionStorage を読めなくなった端末で、送った + // 支払いの結果を受け取れなくなる)。saved が false の attemptId は、保存していない、押すたびに変わる id で、scan に使う。 + // 止まった押下は、場面の符号 (circleCode: 'mapping_unavailable') つきの error で終わり、フォームが赤い枠に locale の文を + // 出す (lib/circleErrorKey.ts)。**flag (pendingStoreUnavailable) は立てない・送信を閉じない**: 押せなくすると、後から + // 復旧できるようになった支払い (例: 最後に渡してから 30 分より後の記録で、receipt が後から引けるようになったもの) を、 + // この画面から受け取れなくなる。押すたびに scan が走り、復旧できれば結果を受け取り、できなければ、また新しい支払いの + // 直前で止まる。既にある対応を読めた押下 (saved: true・確認待ちの続き) は、今までどおり。 // sender は executeCirclePayment が computeIdempotencyKey に使う owner (=customer) と同一値、 // chainId は executeCirclePayment が使う bundle.chainId と同一値にする (key 不一致を避ける)。 const usesStableMapping = params.paymentAttemptId === undefined; const attemptArgs = { chainId: bundle.chainId, sender: customer, callHash: computeCallHash(calls) }; - const attemptId = params.paymentAttemptId ?? stableAttemptId(attemptArgs); + const { attemptId, saved } = + params.paymentAttemptId === undefined + ? stableAttemptId(attemptArgs) + : { attemptId: params.paymentAttemptId, saved: true }; // この試みが、マッピングの指す先だと知っている attemptId。入口で読んだ / 作った id で、この試みが付け替えたら // (下の adoptAttempt)、付け替えた先の id になる。マッピングを破棄するのは、マッピングがいまもこの値を指しているときだけ // (下の 2 か所の clearStableAttemptId)。何の波及を断つか: マッピングは同じタブの別の試み (同じ sessionStorage を使う別の @@ -1144,6 +1160,7 @@ async function runCirclePayment(args: { permitAmount, paymentAttemptId: attemptId, isAlive, + mappingSaved: saved, // 対応の付け替え (第 10 回レビュー 穴 B・条件は circleSend.ts の冒頭の「対応の付け替え」)。scan が別の key の記録 // (別のタブ・閉じたタブの支払い) の結果を受け取りに行く押下は、自分の attemptId の記録を作らない。そのまま画面を離れる・ // 確認待ちで終わると、対応は記録の無い attemptId を指し、次の押下が、後から確定したその支払いを「正規のリピート」と @@ -1152,11 +1169,16 @@ async function runCirclePayment(args: { // 書けたときだけ、この試みの「マッピングの指す先」も付け替えた先にする (書けなければマッピングは前の値のまま)。 // 書けたかを返す (第 10 回レビュー PR-39c): 書けなかった付け替えの確認待ちを、circleSend が「このタブでもう一度押すと、 // まず同じ支払いの結果を確かめ直します」の場面 (a) にしないため (対応が前の値のままなので、次の押下はその記録を resume しない)。 + // **最初の対応を保存できていない押下 (saved が false) では、書けても false を返す** (第 10 回レビューの後追い r10-ss)。 + // sessionStorage の getItem だけが throw し setItem は通る端末では、付け替えは「書けた」になるが、次の押下はその対応を + // 読めない (resume にならず、また scan から入る)。(a) の約束は言えないので、circleSend に (e) を出させる。 + // 書き込み自体は今までどおり試す (後で読めるようになった端末では、対応がその記録を指している方がよい)。この試みの + // 「マッピングの指す先」を替えるのも、今までどおり「書けたとき」。 adoptAttempt: usesStableMapping ? (adopted) => { const written = setStableAttemptId(attemptArgs, adopted); if (written) mappedAttemptId = adopted; - return written; + return written && saved; } : undefined, }); @@ -1173,13 +1195,18 @@ async function runCirclePayment(args: { // いた前の支払い (90 秒より前の confirmed) の後に 2 本目が開くこと (第 10 回レビュー PR-39a の Codex P2)。残しても、次の押下は // 同じ記録を resume するだけで、記録が終わっていれば「既に終了」で破棄される。error の文はそのまま出す。 // circleSend・circlePending は静的 import しない (bundle budget・上の動的 import) ので、CirclePendingError と同じく name で判定する。 + // 対応を保存できていない押下が、新しい記録を予約する直前で止まった error (CircleAttemptMappingUnavailableError・上の + // 「冪等試行 ID」・circleSend.ts の冒頭の「対応を保存できていない押下」) でも破棄しない: この押下の attemptId は保存 + // できていない id で、対応に残っている値 (読み戻しの不一致で残った値など) は、この試みのものと言えない。error はそのまま + // 投げる (場面の符号つき・フォームが locale の文を出す)。 const name = (error as Error).name; if ( usesStableMapping && isAlive() && name !== 'CirclePendingError' && name !== 'PendingStoreUnavailableError' && - name !== 'PendingStoreWriteError' + name !== 'PendingStoreWriteError' && + name !== 'CircleAttemptMappingUnavailableError' ) { clearStableAttemptId(attemptArgs, mappedAttemptId); } diff --git a/lib/circleErrorKey.ts b/lib/circleErrorKey.ts index bd88f3ed..cdff86a9 100644 --- a/lib/circleErrorKey.ts +++ b/lib/circleErrorKey.ts @@ -6,15 +6,17 @@ // circleSend を import しない。判定は error の `circleCode` の値だけで、class の同一性 (instanceof) も name も見ない (hook が // error を name で見るのと同じ理由 = chunk の境界を越える)。 // 読むのは `error.circleCode` で、own property かどうかは確かめない (継承した circleCode も読む)。circleCode を持つ error は -// circleSend の 3 つの class だけで、どれも own property に持つ (tests/lib/smartAccount/circleSend.test.ts)。 +// circleSend の 4 つの class だけで、どれも own property に持つ (tests/lib/smartAccount/circleSend.test.ts・ +// tests/lib/smartAccount/circleSend.adoptAttempt.test.ts)。 -/** 場面の符号。error の側 (circleSend.ts の 3 つの class) が `circleCode` に持つ値。 */ +/** 場面の符号。error の側 (circleSend.ts の 4 つの class) が `circleCode` に持つ値。 */ export type CircleErrorCode = | 'unconfirmed' // (a) 送った支払いの結果がまだ分からない (24 時間以内の次の押下は、同じ支払いを確かめ直す) | 'busy_elsewhere' // (b) 別のタブや画面で、同じ支払いが署名の途中 | 'attempt_ended' // (c) この試みは既に終わっていた (何も送っていない) | 'send_rejected' // (d) 送信の受付で断られた (送金されていない) - | 'unconfirmed_earlier'; // (e) 以前に送った記録の結果が分からない (次の押下が同じ支払いを確かめ直すとは限らない) + | 'unconfirmed_earlier' // (e) 以前に送った記録の結果が分からない (次の押下が同じ支払いを確かめ直すとは限らない) + | 'mapping_unavailable'; // (f) この端末はタブの対応を保存できないので、新しい支払いを始めなかった (何も送っていない) /** messages の namespace `CircleErrors` の key。 */ export type CircleErrorKey = @@ -22,7 +24,8 @@ export type CircleErrorKey = | 'busyElsewhere' | 'attemptEnded' | 'sendRejected' - | 'unconfirmedEarlier'; + | 'unconfirmedEarlier' + | 'mappingUnavailable'; const KEY_BY_CODE: Record = { unconfirmed: 'unconfirmed', @@ -30,6 +33,7 @@ const KEY_BY_CODE: Record = { attempt_ended: 'attemptEnded', send_rejected: 'sendRejected', unconfirmed_earlier: 'unconfirmedEarlier', + mapping_unavailable: 'mappingUnavailable', }; // Map で引く (object を直接引くと、'toString' のような継承した名前が key として返る)。 const KEYS = new Map(Object.entries(KEY_BY_CODE)); diff --git a/lib/circlePending.ts b/lib/circlePending.ts index c6f5db2c..d21a4f94 100644 --- a/lib/circlePending.ts +++ b/lib/circlePending.ts @@ -222,26 +222,60 @@ function attemptStorageKey(args: { * 旧 confirmed record (TERMINAL_RETENTION_MS=24h 残存) に誤って resume させない。 * - 別タブ/別デバイスは sessionStorage を共有しないが、その層は findLiveByCallHash の * callHash スキャン (attemptId 非依存) が引き続き担う。 - * - sessionStorage 不可 (SSR / プライベートモード例外) は fresh UUID に fallback - * (= 従来挙動。マッピングは defense-in-depth であり決済可否を左右させない)。 + * - **attemptId と、それが sessionStorage に保存されているか (saved) を返す** (第 10 回レビューの後追い r10-ss)。 + * 直す前は、保存できなくても、保存していない新しい UUID を黙って返していた (呼ぶ側は見分けられない)。 + * 何の波及を断つか: 保存していない attemptId で **新しい支払い** を送ると、その支払いが確認待ちで終わった後の押下が別の + * attemptId (別の key) になり、裏で確定した前の支払い (90 秒より前の confirmed) を「正規のリピート」とみなして 2 本目の + * 署名を求める。H-V・穴 B 以降、この対応は「同じタブの続き」を見分ける本体で、無くても払える付帯のものではない。 + * saved が false のとき、呼ぶ側 (hooks/useBatchPayment.ts の runCirclePayment) はそれを executeCirclePayment に渡し、 + * executeCirclePayment は **新しい記録を予約する直前で** 止める (lib/smartAccount/circleSend.ts の冒頭の「対応を保存できて + * いない押下」・場面 (f) の error)。既にある支払いの復旧・結果の受け取り (scan) は、保存できなくても通す。そのために attemptId は必ず返す。 + * - 既にある値を読めた → その値・saved: true (書かない)。前の押下で保存できていれば、その後に書けなくなった端末でも、 + * 確認待ちの支払いの続き (resume) はそのまま。 + * - 無い → 新しい UUID を書き、読み戻して同じ値のときだけ saved: true。setItem が throw しない端末 (quota の silent + * drop) も見分ける (localStorage の記録の writeRecord・lib/paymentIntentStorage.ts の canStoreRelayIntent と同じ形)。 + * - getItem / setItem が throw・読み戻しが違う → 保存していない新しい UUID と saved: false。throw はしない。この UUID は + * 押すたびに変わるので、その key に記録があることは無い (前の押下の記録は、scan が「別の key の記録」として拾う)。 + * UUID の採番 (crypto.randomUUID) は try の外: storage と関係ない失敗を「保存できない」に化けさせない。 + * saved が false のときは warn を 1 行出す (そういう端末がどれだけあるかを見る)。入れるのは chainId と、どこで失敗したか + * だけ (アドレス・callHash・attemptId・error の文は入れない)。warn の失敗 (throw) は握る (下の理由)。 * - 値は、このタブが採番した attemptId とは限らない: scan が別の key の記録 (別のタブ・閉じたタブの支払い) の結果を * 受け取りに行くとき、hook がその記録の paymentAttemptId に付け替える (下の setStableAttemptId)。 */ export function stableAttemptId(args: { chainId: number; sender: Address; callHash: Hex; -}): string { +}): { attemptId: string; saved: boolean } { const storageKeyName = attemptStorageKey(args); + const unsaved = (cause: 'read' | 'write' | 'readback', attemptId: string) => { + try { + logger.warn('circle.attempt_mapping.unavailable', { chainId: args.chainId, cause }); + } catch { + // 何の波及を断つか: 診断の log の失敗 (console・Sentry の throw) が、この後の、既にある支払いの復旧 (scan) と、 + // 新しい支払いを止める処理 (saved: false を呼ぶ側に返す) を巻き込むこと。warn が出なくても、返す値は同じ。 + } + return { attemptId, saved: false }; + }; + let existing: string | null; + try { + existing = window.sessionStorage.getItem(storageKeyName); + } catch { + return unsaved('read', crypto.randomUUID()); + } + if (existing) return { attemptId: existing, saved: true }; + const fresh = crypto.randomUUID(); try { - const existing = window.sessionStorage.getItem(storageKeyName); - if (existing) return existing; - const fresh = crypto.randomUUID(); window.sessionStorage.setItem(storageKeyName, fresh); - return fresh; } catch { - // SSR / private mode / quota → 保存せず fresh UUID を返す (環境依存の制御フロー)。 - return crypto.randomUUID(); + return unsaved('write', fresh); + } + let stored: string | null; + try { + stored = window.sessionStorage.getItem(storageKeyName); + } catch { + return unsaved('readback', fresh); } + return stored === fresh ? { attemptId: fresh, saved: true } : unsaved('readback', fresh); } /** stableAttemptId のマッピングを破棄する (終端到達時)。次回は新規 attemptId を採番する。 diff --git a/lib/smartAccount/circleSend.ts b/lib/smartAccount/circleSend.ts index f9fbaf40..975de6e3 100644 --- a/lib/smartAccount/circleSend.ts +++ b/lib/smartAccount/circleSend.ts @@ -26,7 +26,8 @@ // (CircleSendRejectedError・第 10 回レビュー H-X。下の「最初の送信の検証拒否で閉じる」) // (4) それ以外すべて → CirclePendingError (確認待ち) // hook (useBatchPayment の runCirclePayment) は、CirclePendingError と storage の失敗 (PendingStoreUnavailableError・ -// PendingStoreWriteError) 以外の error で attemptId の対応を消す。op を渡したかもしれない後にふつうの error で終わると、次の押下が +// PendingStoreWriteError) と、対応を保存できていない押下の停止 (CircleAttemptMappingUnavailableError・下の「対応を保存できて +// いない押下」) 以外の error で attemptId の対応を消す。op を渡したかもしれない後にふつうの error で終わると、次の押下が // 新しい key になり、後から確定した前の支払い (90 秒より前の confirmed) を「正規のリピート」とみなして 2 本目を払う。なので (4) を // 1 か所 (inPostSubmit) で保証する。confirmed の記録の枝 (scan・resume) は包まない: resultFromConfirmed が照会の失敗を全部 // 受けて (1) を返し、投げる処理が無い。(2) は何も渡していない・(3) は bundler がその op の hash を知らないと答えた終わり方で、 @@ -144,6 +145,27 @@ // 残る限界: 30 分より後の記録を復旧する押下 (付け替えない) の途中で画面を離れ、その間に op が確定した場合は、今までどおり // (対応は自分の attemptId のまま。90 秒より後の次の押下は新しい支払い)。 // +// 対応を保存できていない押下 (第 10 回レビューの後追い r10-ss): hook が attemptId の対応 (sessionStorage) を保存できなかった +// 押下 (読めない・書けない・書いても残らない端末) は、args.mappingSaved に false を渡す。その押下の paymentAttemptId は、保存して +// いない、押すたびに変わる id。**そのまま新しい支払いを送ると**、確認待ちで終わった後の押下が別の key になり、裏で確定した +// 前の支払い (90 秒より前の confirmed) を「正規のリピート」とみなして 2 本目の署名を求める。なので、**新しい記録を予約する +// 直前 (下の scan の loop を抜けた後・reserveOrResume の前) で止める** (CircleAttemptMappingUnavailableError)。 +// 止める所より前に、新しい記録の書き込み・署名・送信は無い。scan の中の動きは、対応を保存できていない押下でも変えない: +// 既にある支払いの結果を返す (直近の confirmed)・復旧する (submitting)・確認待ち (確かめられない記録・直近の pre-submit)・ +// 古い pre-submit の abandon・入口の掃除。**入口では止めない**: 既にある支払いの復旧まで止めると、取り出せる結果を失う +// (対応を保存した後に sessionStorage を読めなくなった端末で、送った支払いの結果を受け取れなくなる)。 +// scan の枝の付け替え (adopt) も今までどおり呼ぶ。hook は書き込みを試すが、この押下では、書けても false を返す (getItem だけが +// throw し setItem は通る端末では、書けても次の押下はその対応を読めず、resume ではなく scan から入る)。なので、その復旧の +// 確認待ちは (e) になる ((a) の「このタブでもう一度押すと、まず同じ支払いの結果を確かめ直します」は言えない)。ここ (circleSend) +// の規則は今までどおり「hook の関数が true を返したら (a)・false なら (e)」で、mappingSaved は見ない。 +// 止まった押下に、hook は対応の破棄 (clearStableAttemptId) をせず、error をそのまま投げる (hooks/useBatchPayment.ts の +// runCirclePayment)。error は場面 (f) の符号 (circleCode: 'mapping_unavailable') を持ち、フォームが赤い枠に locale の文を出す。 +// **送信は閉じない** (押せなくする flag を立てない): 止まった後に復旧できるようになる支払いがある。例: 最後に渡してから +// 30 分より後の submitting の記録で、nonce は使用済みだが bundler がまだ receipt を返さない間は、scan は飛ばす (上の「古い +// submitting の記録」の 2) ので、ここで止まる。その後 receipt が引けるようになれば (取り込みが 30 分以内)、次の押下の scan が +// 元の支払いの結果を返す。押せなくすると、その結果を、この画面から受け取れなくなる。 +// 省略・true の呼び出し (hook は、明示の paymentAttemptId の押下に true を渡す) は今までどおり。 +// // useBatchPayment の circle 分岐がこの executeCirclePayment を呼ぶ。permitAmount の // 算定 (gas 実費 + per-chain surcharge) は chunk 5 (useGasQuoteCircle) が担い、ここへは // 確定値が渡る (本 module は fee ポリシーを持たない)。 @@ -297,7 +319,7 @@ export type CirclePaymentResult = { actualGasCost: bigint; }; -// 場面の符号と文 (第 10 回レビュー PR-39c)。下の 3 つの class は、どの場面の error かを own property `circleCode` に持つ。 +// 場面の符号と文 (第 10 回レビュー PR-39c)。下の 4 つの class は、どの場面の error かを own property `circleCode` に持つ。 // 3 つのフォームは class も name も見ず、この符号から locale の文 (messages の CircleErrors) を出す (lib/circleErrorKey.ts)。 // message にも日本語の文を残す: message は i18n を通らない出口にも出る (履歴に保存する error の文・payment log・Sentry)。 // 符号だけにすると、履歴の画面に符号がそのまま出る。同じ文が messages/ja.json の CircleErrors にもあるので、ずれは @@ -323,6 +345,12 @@ const CIRCLE_ERROR_JA = { 'ここでは新しい支払いを始めていません。' + 'もう一度押すと確かめ直しますが、署名を求められた場合は新しい支払いになります。' + '二重に払わないよう、署名の前にウォレットの履歴で以前の支払いを確かめてください。', + // (f) の文は user が承認した文 (2026-11-13)。「次に押すと◯◯します」とは書かない: 次の押下が何をするかは、その時点の + // 記録で変わる (既にある支払いを復旧できれば結果を返し、できなければ、また新しい支払いの直前で止まる)。 + mapping_unavailable: + 'この端末では支払いの記録を保存できないため、USDC ガスレスの新しい支払いを始めていません (この押下では送金していません)。' + + '同じ内容の支払いを以前に送っていた場合は、先にウォレットの履歴で確かめてください。' + + '新しく支払うには、ブラウザの設定 (サイトのデータの保存) を確かめるか、別のブラウザでお試しください。', } as const satisfies Record; /** 確認待ち。**失敗ではない**。場面は 3 つ (reason)。(a) と (e) の違いは、**(a) の文の約束 (24 時間以内にこのタブで同じ内容の @@ -330,12 +358,16 @@ const CIRCLE_ERROR_JA = { * - 'unconfirmed' (a・既定): op を bundler に渡した (渡したかもしれない) が、結果を確かめられなかった。op は後で included * しうる。(a) を名乗ってよい出口は、次の 2 つが両方言える所だけ: * 1. hook の attemptId の対応が、その op の記録の key を指している (自分の key の記録、または scan が付け替えて、hook が - * 対応を **書けた** 記録)。次の押下は、その記録を resume して同じ op の結果を確かめ直す。 - * **確かめていないこと**: 自分の key の記録について、hook の対応そのものが保存されているか。最初の対応の保存 - * (lib/circlePending.ts の stableAttemptId) が sessionStorage の失敗で書けなかった端末では、hook は保存されていない - * attemptId で送る。その新しい送信が確認待ちで終わると (a) を出すが、次の押下は別の attemptId になり、この記録を - * resume しない (その支払いが裏で確定して 90 秒を過ぎていれば、新しい支払いに署名を求める)。この押下は新しい支払いを - * 始めているので、(e) にも当たらない。残る限界 (PR-39c の本文の「残る点」)。 + * 対応を **書けた** 記録。hook は、最初の対応を保存できていない押下では、書けても「書けた」と返さない = 下の続き)。 + * 次の押下は、その記録を resume して同じ op の結果を確かめ直す。 + * 自分の key の記録について、hook の対応そのものが保存されているか: 最初の対応の保存 (lib/circlePending.ts の + * stableAttemptId) が sessionStorage の失敗で保存できなかった押下は、hook が mappingSaved: false を渡し、新しい記録を + * 予約する直前で止まる (冒頭の「対応を保存できていない押下」・第 10 回レビューの後追い r10-ss)。なので、hook が対応を + * 管理する押下が自分の key に作る記録の paymentAttemptId は、その押下の入口で対応に保存されていた値。保存できて + * いない押下が scan で別の key の記録を復旧して確認待ちで終わるときは、(e) になる (下の (e) の 1 つ目の枝): hook の + * adoptAttempt は、その押下では、付け替えを書けても false を返す。getItem だけが throw し setItem は通る端末では、 + * 付け替えは書けるが、次の押下はその対応を読めず、resume ではなく scan から入るので、(a) の約束は言えない。 + * 明示の paymentAttemptId の呼び出し (対応を使わない・本番の 3 フォームには無い) は、今までどおり確かめない。 * 2. その記録が、**確認待ちで出る時点から** 24 時間は残る。確定した記録 (confirmed) は最後の updatedAt から 24 時間で * 掃除され (TERMINAL_RETENTION_MS)、submitting のままの記録は 7 日で掃除される (SUBMITTING_RETENTION_MS)。なので、 * submitting の記録は、最後に書いてから 6 日以内 (= 残りの寿命が 24 時間以上) であること。この押下が自分で op を渡した @@ -345,7 +377,9 @@ const CIRCLE_ERROR_JA = { * 支払いを始めていない (復旧は、保存した同じ op を待つ・送り直すだけ)。出口は executeCirclePayment の 2 つの枝: * - scan の submitting の枝 (別のタブ・閉じたタブが以前に送った支払い) で、**対応をその記録に付け替えられなかった** * (30 分の窓の外・確かめられない記録・記録に paymentAttemptId が無い・hook が対応を管理していない・hook が対応を - * 書けなかった)。対応は自分の attemptId のままで、この押下は自分の記録を作らない (scan は予約より前) ので、次の押下は + * 書けなかった・hook が最初の対応を保存できていない押下 = 付け替えを書けても、次の押下が読めると言えない)。 + * 対応は自分の attemptId のままで (最初の対応を保存できていない押下では、対応が無い・読めない、または付け替えた値を + * 次の押下が読めると言えない)、この押下は自分の記録を作らない (scan は予約より前) ので、次の押下は * また scan から入る。その間に前の支払いが確定して 90 秒を過ぎていれば、次の押下は新しい支払いに署名を求める (冒頭の * 「対応の付け替え」の「残る限界」)。 * - 自分の key の resume の submitting の枝で、**確認待ちで出る時点で、入口で読んだ記録が、最後に書いてから 6 日より後** @@ -386,6 +420,20 @@ export class CircleSendRejectedError extends Error { } } +/** attemptId の対応を保存できていない押下 (args.mappingSaved が false) が、新しい記録を予約する所まで来たので止めた (冒頭の + * 「対応を保存できていない押下」)。場面 (f)。この押下は、新しい記録の書き込み・署名・送信を何も始めていない。 + * **確認待ちではない**: name を 'CirclePendingError' にしない。hook (useBatchPayment の runCirclePayment) は、この name では + * attemptId の対応を消さず、error をそのまま投げる。フォームは符号から locale の文を赤い枠に出し、送信は閉じない + * (次の押下も scan から入り、既にある支払いを復旧できるようになっていれば結果を受け取る)。 */ +export class CircleAttemptMappingUnavailableError extends Error { + /** 場面の符号 (上の「場面の符号と文」)。 */ + readonly circleCode: CircleErrorCode = 'mapping_unavailable'; + constructor() { + super(CIRCLE_ERROR_JA.mapping_unavailable); + this.name = 'CircleAttemptMappingUnavailableError'; + } +} + type Receipt = Awaited>; function toResult(userOpHash: Hex, receipt: Receipt): CirclePaymentResult { @@ -879,8 +927,13 @@ export type ExecuteCirclePaymentArgs = { * いるときだけ渡す。省略時は付け替えない。呼ぶ前に失効は確かめない (呼び出し元は、失効を確かめた同じ tick でこの関数を * 呼ぶこと。冒頭の「失効」)。 * 返す値 = 対応を書けたか (書けたら true)。書けなかった (sessionStorage の失敗) なら、対応は前の値のままで、次の押下は - * その記録を resume しない。使うのは、確認待ちの場面の符号を (a) にするか (e) にするかだけ (復旧の進め方は変えない)。 */ + * その記録を resume しない。hook は、最初の対応を保存できていない押下 (mappingSaved が false) でも false を返す + * (書けても、次の押下がその対応を読めると言えない)。使うのは、確認待ちの場面の符号を (a) にするか (e) にするかだけ (復旧の進め方は変えない)。 */ adoptAttempt?: (paymentAttemptId: string) => boolean; + /** hook が、この押下の paymentAttemptId を attemptId の対応 (sessionStorage) に保存できているか (冒頭の「対応を保存できて + * いない押下」)。false のとき、scan (既にある支払いの復旧・結果の受け取り) は今までどおり行い、新しい記録を予約する直前で + * CircleAttemptMappingUnavailableError を投げる。省略時は true (明示の paymentAttemptId の呼び出しは今までどおり)。 */ + mappingSaved?: boolean; }; // 失効した試みを終える error (冒頭の「失効」)。relay の hook (hooks/useJpycEip3009Payment.ts) と Pimlico の経路 @@ -1071,6 +1124,12 @@ export async function executeCirclePayment( throw new CirclePendingError(rec.userOpHash, 'busy_elsewhere'); } + // --- 対応を保存できていない押下は、新しい記録を予約しない (冒頭の「対応を保存できていない押下」) --- + // ここに来た = 上の scan に、結果を返す記録も、復旧する記録も、待つ記録も無かった (この先は新しい支払い)。何の波及を断つか: + // 保存していない attemptId で新しい支払いを送ると、確認待ちで終わった後の押下が別の key になり、裏で確定した前の支払いの後に + // 2 本目を開ける。ここより前に、新しい記録の書き込み・署名・送信は無い (下の reserveOrResume が最初の書き込み)。 + if (args.mappingSaved === false) throw new CircleAttemptMappingUnavailableError(); + // --- reserve (idempotency gate・自 attempt のクラッシュ resume) ---------- const { record, resumed } = reserveOrResume({ key, diff --git a/messages/en.json b/messages/en.json index 81107a88..604bd312 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3551,7 +3551,8 @@ "busyElsewhere": "A payment with the same details is in progress in another tab or screen, so it wasn't started here. If you complete the payment there, don't press the button here. If you closed it, you can pay from here in a few minutes.", "attemptEnded": "This payment attempt has already ended. Nothing was sent in this attempt. Press the button again to start a new payment.", "sendRejected": "Your gasless USDC payment was declined when it was submitted. This payment was not sent. A possible reason is an insufficient balance. Please try again (you'll sign again).", - "unconfirmedEarlier": "There is a record of an earlier payment with the same details whose result we couldn't confirm yet. No new payment was started here. Pressing the button again re-checks it, but if you're asked to sign, that will be a new payment. To avoid paying twice, check the earlier payment in your wallet history before you sign." + "unconfirmedEarlier": "There is a record of an earlier payment with the same details whose result we couldn't confirm yet. No new payment was started here. Pressing the button again re-checks it, but if you're asked to sign, that will be a new payment. To avoid paying twice, check the earlier payment in your wallet history before you sign.", + "mappingUnavailable": "This device can't save payment records, so a new gasless USDC payment wasn't started (nothing was sent by this press). If you already sent a payment with the same details, check it in your wallet history first. To make a new payment, check your browser settings (site data storage) or try another browser." }, "OrderLinkBuilder": { "noReceiver": "Not set", diff --git a/messages/ja.json b/messages/ja.json index 6c2f526c..5cca2f7e 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -3551,7 +3551,8 @@ "busyElsewhere": "同じ内容の支払いが、別のタブまたは画面で進行中のため、ここでは始めていません。そちらで支払いを終えた場合は、ここでは押さないでください。そちらを閉じた場合は、数分後にここから支払えます。", "attemptEnded": "この支払いの試みは既に終了しています。この試みでは送金していません。もう一度押すと、新しい支払いとして始めます。", "sendRejected": "USDC ガスレスの支払いは、送信の受付で断られました。この支払いは送金されていません。残高が足りないなどの理由が考えられます。もう一度お試しください (署名からやり直します)。", - "unconfirmedEarlier": "同じ内容の支払いを以前に送信した記録があり、その結果をまだ確認できていません。ここでは新しい支払いを始めていません。もう一度押すと確かめ直しますが、署名を求められた場合は新しい支払いになります。二重に払わないよう、署名の前にウォレットの履歴で以前の支払いを確かめてください。" + "unconfirmedEarlier": "同じ内容の支払いを以前に送信した記録があり、その結果をまだ確認できていません。ここでは新しい支払いを始めていません。もう一度押すと確かめ直しますが、署名を求められた場合は新しい支払いになります。二重に払わないよう、署名の前にウォレットの履歴で以前の支払いを確かめてください。", + "mappingUnavailable": "この端末では支払いの記録を保存できないため、USDC ガスレスの新しい支払いを始めていません (この押下では送金していません)。同じ内容の支払いを以前に送っていた場合は、先にウォレットの履歴で確かめてください。新しく支払うには、ブラウザの設定 (サイトのデータの保存) を確かめるか、別のブラウザでお試しください。" }, "OrderLinkBuilder": { "noReceiver": "未設定", diff --git a/tests/_helpers/circleErrorCases.ts b/tests/_helpers/circleErrorCases.ts index 95dc9b65..0de2c5c6 100644 --- a/tests/_helpers/circleErrorCases.ts +++ b/tests/_helpers/circleErrorCases.ts @@ -13,6 +13,7 @@ export const CIRCLE_ERROR_CASES = [ ['attempt_ended', 'attemptEnded'], ['send_rejected', 'sendRejected'], ['unconfirmed_earlier', 'unconfirmedEarlier'], + ['mapping_unavailable', 'mappingUnavailable'], ] as const; export const CIRCLE_ERRORS_JA = jaMessages.CircleErrors; diff --git a/tests/components/PaymentForm.test.tsx b/tests/components/PaymentForm.test.tsx index 66731c3c..74698ca1 100644 --- a/tests/components/PaymentForm.test.tsx +++ b/tests/components/PaymentForm.test.tsx @@ -992,6 +992,98 @@ describe('PaymentForm — 送信フロー', () => { ).toBeDisabled(); }); + // Circle の経路 (USDC ガスレス) でも同じ欄が出る (localStorage を読めない端末で hook が立てる flag。フォームの判定 = + // lib/paymentFlowGuards/pay.ts は経路を見ない)。Circle の経路でこの欄を見る test が無かったので足した (第 10 回レビューの + // 後追い r10-ss)。flag の無い行は、押せないのが flag のせいであること (ほかの準備の不足でない) の対照。 + it.each([ + ['記録を保存できない (flag あり)', 'store-unavailable', true], + ['対照: flag なし', 'idle', false], + ] as const)( + 'Circle の経路・%s → 専用の欄 = %s (赤い error の枠は出ない・欄が出ている間は支払うボタンが押せない)', + async (_label, state, blocked) => { + const user = userEvent.setup(); + vi.mocked(resolvePaymasterMode).mockReturnValue('erc20'); + vi.mocked(resolveUsdcGaslessProvider).mockReturnValue('circle'); + setURL(`to=${MERCHANT}&token=usdc&amount=10`); + setAccount({ connected: true, chainId: baseSepolia.id }); + setBalance(20_000_000n); + setSmartAccount(true); + setCircleQuote('ready', { gasAmount: 0n, permitAmount: 10_050_000n }); + setPayment(state); + render(); + + // Circle の経路の画面であること (有界 permit の署名の案内が出ている。案内の部品は遅延読み込みなので待つ)。 + expect(await screen.findByText(/利用許可 \(Spending cap\).+署名/)).toBeInTheDocument(); + + const title = screen.queryByText('この端末では記録を保存できません'); + const body = screen.queryByText( + 'この端末では支払い記録を保存できないため、ガスレス送信を止めています。通常送信をご利用ください。', + ); + // 赤い error の枠 (題「エラー」) と、止めた error の生の message は、どちらの場合も出ない。 + expect(screen.queryByText('エラー')).toBeNull(); + expect(screen.queryByText(/pending-store-unavailable/)).toBeNull(); + expect(screen.queryByText('送信結果を確認中')).toBeNull(); + + const payButton = screen.getByRole('button', { name: /10 USDC を支払う/ }); + if (blocked) { + expect(title).toBeInTheDocument(); + expect(body).toBeInTheDocument(); + expect(payButton).toBeDisabled(); + await user.click(payButton); + expect(mutate).not.toHaveBeenCalled(); + } else { + expect(title).toBeNull(); + expect(body).toBeNull(); + expect(payButton).not.toBeDisabled(); + await user.click(payButton); + expect(mutate).toHaveBeenCalledOnce(); + } + }, + ); + + // 第 10 回レビューの後追い r10-ss: タブの対応 (sessionStorage) を保存できない端末で、新しい支払いの直前で止まった押下は、 + // 場面 (f) の符号つきの error で終わる (hook は flag を立てない・tests/hooks/useBatchPayment.circleUnmount.test.tsx)。 + // フォームは赤い枠に locale の文を出し、**支払うボタンは押せるまま** (上の「記録を保存できません」の欄は出さない)。 + // 押せなくすると、後から復旧できるようになった支払いを、この画面から受け取れない (押すたびに hook の scan が走る)。 + it.each([ + ['ja', CIRCLE_ERRORS_JA.mappingUnavailable], + ['en', CIRCLE_ERRORS_EN.mappingUnavailable], + ] as const)( + 'Circle の経路・対応を保存できない端末の error (f)・%s → 赤い枠に CircleErrors の文・欄は出ない・支払うボタンは押せる', + async (locale, text) => { + const user = userEvent.setup(); + vi.mocked(resolvePaymasterMode).mockReturnValue('erc20'); + vi.mocked(resolveUsdcGaslessProvider).mockReturnValue('circle'); + setURL(`to=${MERCHANT}&token=usdc&amount=10`); + setAccount({ connected: true, chainId: baseSepolia.id }); + setBalance(20_000_000n); + setSmartAccount(true); + setCircleQuote('ready', { gasAmount: 0n, permitAmount: 10_050_000n }); + // hook が返すのと同じ形: name と場面の符号を持つ error・message は日本語の固定文・flag は立っていない。 + const stopped = Object.assign(codedError('mapping_unavailable', CIRCLE_ERRORS_JA.mappingUnavailable), { + name: 'CircleAttemptMappingUnavailableError', + }); + setPayment('error', stopped); + render(, { locale }); + + // 赤い枠に、その locale の場面 (f) の文が出る (英語の画面に日本語の message を出さない)。 + expect(await screen.findByText(text)).toBeInTheDocument(); + if (locale === 'en') expect(screen.queryByText(CIRCLE_ERRORS_JA.mappingUnavailable)).toBeNull(); + // 「記録を保存できません」の欄 (押せなくする flag の欄)・「確認中」の欄は出ない。 + expect(screen.queryByText('この端末では記録を保存できません')).toBeNull(); + expect(screen.queryByText(/通常送信をご利用ください/)).toBeNull(); + expect(screen.queryByText('送信結果を確認中')).toBeNull(); + + // 支払うボタンは押せるまま: 押すと、hook の mutate を呼ぶ (hook が scan からやり直す)。 + const payButton = screen.getByRole('button', { + name: locale === 'ja' ? /10 USDC を支払う/ : /Pay 10 USDC/, + }); + expect(payButton).not.toBeDisabled(); + await user.click(payButton); + expect(mutate).toHaveBeenCalledOnce(); + }, + ); + it('pending 記録を保存できなくても standard (通常送信) は使える', async () => { const user = userEvent.setup(); setURL(`to=${MERCHANT}&token=usdc&amount=10&mode=standard`); diff --git a/tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx b/tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx index 153a776f..3a164c90 100644 --- a/tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx +++ b/tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx @@ -130,6 +130,8 @@ let userOpRequests: Array>; let landed: Map; // true の間、bundler の receipt の待ちは失敗する (5xx)。 let bundlerDown: boolean; +// true の間、bundler は取り込まれた op の receipt をまだ返さない (索引の遅れ: chain の nonce は使用済み・receipt は not found)。 +let bundlerIndexLagging: boolean; // 最初の画面 (閉じるタブ) の receipt の待ちは、最後まで返らない (タブと一緒に消える)。 let hangNextWait: boolean; // 時計。本物の時刻に足す (executeCirclePayment は Date.now を読む)。 @@ -196,6 +198,7 @@ beforeEach(() => { userOpRequests = []; landed = new Map(); bundlerDown = false; + bundlerIndexLagging = false; hangNextWait = false; clockOffset = 0; const realNow = Date.now.bind(Date); @@ -227,7 +230,9 @@ beforeEach(() => { bundlerClient: { getUserOperationReceipt: (async ({ hash }: { hash: Hex }) => { const found = ALL_OPS.find((o) => o.userOpHash === hash); - if (!found || !landed.has(hash)) throw new UserOperationReceiptNotFoundError({ hash }); + if (!found || !landed.has(hash) || bundlerIndexLagging) { + throw new UserOperationReceiptNotFoundError({ hash }); + } return receiptOf(found); }) as never, }, @@ -370,3 +375,96 @@ describe('H-Z: タブを閉じた Circle の支払いの記録 (hook → 本物 expect(recordStatuses()).toEqual(['confirmed']); }); }); + +// 第 10 回レビューの後追い r10-ss (Codex の 2 回目の順): タブの対応 (sessionStorage) を読めない端末で、新しい支払いの直前で +// 止まった後に、前の支払いが復旧できるようになる筋。 +// 31 分より前の submitting の記録・nonce は使用済みだが、bundler がまだ receipt を返さない → scan は飛ばす → 対応を保存できて +// いない押下は、新しい記録を予約する直前で止まる (場面 (f) の error)。その後 receipt が引けるようになる (取り込みは直近)。 +// main は、もう一度押せば元の支払いの結果を受け取れる (その間の新しい署名の要求を断った後)。この PR の途中の版は、止まった +// 押下で「押せなくする flag」を立てていたので、画面を開き直すまで受け取れなかった。flag をやめたので、同じ画面でもう一度 +// 押すと受け取れる。 +describe('r10-ss: 新しい支払いの直前で止まった後に、復旧できるようになった支払い (hook → 本物の executeCirclePayment)', () => { + // sessionStorage の getItem だけを投げさせる (localStorage は読める)。 + function breakSessionReads() { + const realGetItem = Storage.prototype.getItem; + return vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(function (this: Storage, key: string) { + if (this === window.sessionStorage) throw new Error('SecurityError: storage is not available'); + return realGetItem.call(this, key); + }); + } + const circleCodeOf = (error: unknown) => (error as { circleCode?: string } | null)?.circleCode; + + it('31 分より前の submitting (nonce は使用済み・receipt はまだ引けない) → 止まる (場面 (f)・押せなくならない) → receipt が引けるようになる → 同じ画面でもう一度押すと、元の支払いの結果を受け取る (署名・送信なし)', async () => { + await payAndCloseTab(); + + // 31 分後。前の op はいま取り込まれた (nonce は使用済み) が、bundler の索引がまだ追い付いていない。 + clockOffset += 31 * MIN; + land(OP_A, 100n); + bundlerIndexLagging = true; + const reads = breakSessionReads(); + const screenB = openScreen(); + screenB.result.current.mutate(PARAMS); + await waitFor(() => + expect(screenB.result.current.isError || permitRequests.length > 1).toBe(true), + ); + + // 新しい支払いを始めていない: 署名を求めず、場面 (f) の error で終わる。押せなくする flag は立たない。 + expect(permitRequests).toHaveLength(1); + expect(screenB.result.current.error?.name).toBe('CircleAttemptMappingUnavailableError'); + expect(circleCodeOf(screenB.result.current.error)).toBe('mapping_unavailable'); + expect(screenB.result.current.pendingStoreUnavailable).toBe(false); + expect(readContract).toHaveBeenCalledTimes(1); + // 記録は書き換えていない (submitting のまま・新しい記録も無い)。 + expect(recordStatuses()).toEqual(['submitting']); + + // bundler が receipt を返すようになる。同じ画面でもう一度押す。 + bundlerIndexLagging = false; + clockOffset += 10 * SEC; + screenB.result.current.mutate(PARAMS); + await waitFor(() => + expect(screenB.result.current.isSuccess || permitRequests.length > 1).toBe(true), + ); + reads.mockRestore(); + + expect(screenB.result.current.isSuccess).toBe(true); + expect(screenB.result.current.data).toMatchObject({ + userOpHash: OP_A.userOpHash, + txHash: OP_A.txHash, + success: true, + }); + expect(permitRequests).toHaveLength(1); + expect(userOpRequests).toHaveLength(1); + expect(sentOps()).toEqual([OP_A.signedUserOp]); + expect(recordStatuses()).toEqual(['confirmed']); + }); + + // 止まった後も、前の支払いが復旧できるようにならなければ (取り込みが 30 分より前だった)、押すたびに同じ所で止まる。 + it('31 分より前の submitting・取り込みも 31 分より前 (飛ばす記録) → 押すたびに場面 (f) で止まり、2 本目を始めない', async () => { + await payAndCloseTab(); + land(OP_A, 100n); // 閉じた直後に成立した + + clockOffset += 31 * MIN; + const reads = breakSessionReads(); + const screenB = openScreen(); + let previous: Error | null = null; + for (const press of [1, 2]) { + screenB.result.current.mutate(PARAMS); + await waitFor(() => expect(readContract).toHaveBeenCalledTimes(press)); + // この押下の error が画面に出るまで待つ (前の押下の error とは別の instance)。 + await waitFor(() => { + const shown = screenB.result.current.error; + expect((shown !== null && shown !== previous) || permitRequests.length > 1).toBe(true); + }); + previous = screenB.result.current.error; + expect(permitRequests).toHaveLength(1); + expect(circleCodeOf(previous)).toBe('mapping_unavailable'); + expect(screenB.result.current.pendingStoreUnavailable).toBe(false); + } + reads.mockRestore(); + + expect(sentOps()).toEqual([OP_A.signedUserOp]); + expect(recordStatuses()).toEqual(['submitting']); + }); +}); diff --git a/tests/hooks/useBatchPayment.circleUnmount.test.tsx b/tests/hooks/useBatchPayment.circleUnmount.test.tsx index 8da19fe6..df2bcdaa 100644 --- a/tests/hooks/useBatchPayment.circleUnmount.test.tsx +++ b/tests/hooks/useBatchPayment.circleUnmount.test.tsx @@ -22,6 +22,7 @@ import { baseSepolia } from 'viem/chains'; import type { ReactNode } from 'react'; import { useBatchPayment } from '@/hooks/useBatchPayment'; import { PENDING_KEY_PREFIX } from '@/lib/circlePending'; +import { logger } from '@/lib/logger'; import { defaultDeploymentForSymbol } from '@/lib/tokens'; vi.mock('@/hooks/useSmartAccount', () => ({ @@ -173,7 +174,9 @@ function openScreen() { ), }); const state = () => qc.getMutationCache().getAll()[0].state; - return { ...view, state }; + // 押下ごとの mutation の state (押した順)。同じ画面で 2 回押す test が、2 回目の終わり方を見るのに使う。 + const states = () => qc.getMutationCache().getAll().map((mutation) => mutation.state); + return { ...view, state, states }; } beforeEach(() => { @@ -725,6 +728,8 @@ describe('storage の失敗の押下と attemptId の対応 (hook → 本物の writes.mockRestore(); expect(screen.result.current.error?.name).toBe('PendingStoreWriteError'); + // sessionStorage は正常なので、タブの対応は保存できている。「記録を保存できません」の欄 (flag) にはしない (今までどおり)。 + expect(screen.result.current.pendingStoreUnavailable).toBe(false); expect(recordStatuses()).toEqual([]); expect(signUsdcPermit).not.toHaveBeenCalled(); const mappingAfterFailure = mappings(); @@ -917,3 +922,493 @@ describe('H-X: 最初の送信の検証拒否 (hook → 本物の executeCircleP expect(sentOps()).toEqual([OP_A.signedUserOp]); }); }); + +// 第 10 回レビューの後追い r10-ss (money-path): Circle の経路は「同じタブの続き」を sessionStorage の対応 (chain・sender・内容 → +// attemptId) で見分ける。直す前: 対応を保存できない端末 (sessionStorage を読めない・書けない・書いても残らない) では、 +// stableAttemptId が保存していない新しい UUID を黙って返し、hook はそれで **新しい支払い** まで送っていた。その支払いが確認待ちで +// 終わる (または、前の支払いが裏で確定して 90 秒を過ぎている) と、次の押下は別の attemptId (別の key) になり、2 本目の署名を求める。 +// 直した後: 対応を保存できていない押下は、**新しい記録を予約する直前** で止まり (executeCirclePayment の中・scan の後)、 +// 場面 (f) の符号つきの error (CircleAttemptMappingUnavailableError・circleCode 'mapping_unavailable') で終わる。フォームは +// 赤い枠に locale の文を出す。**押せなくする flag (pendingStoreUnavailable) は立てない**: 押せなくすると、後から復旧できるように +// なった支払いを、この画面から受け取れなくなる (Codex の 2 回目の指摘。その順は +// tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx)。 +// **既にある支払いの復旧・結果の受け取り (scan) は、対応を保存できなくても今までどおり** (入口で止めると、対応を保存した後に +// sessionStorage を読めなくなった端末で、送った支払いの結果を受け取れなくなる = Codex の 1 回目の指摘)。 +describe('r10-ss: タブの対応 (sessionStorage) を保存できていない押下は、新しい支払いを始めない (hook → 本物の executeCirclePayment)', () => { + const UNAVAILABLE = 'circle.attempt_mapping.unavailable'; + // 場面 (f) の文 (messages/ja.json の CircleErrors.mappingUnavailable と同じ文が、error の message に入る。ずれは + // tests/lib/circleErrorKey.test.ts が止める)。 + const MAPPING_UNAVAILABLE_JA_HEAD = 'この端末では支払いの記録を保存できないため、USDC ガスレスの新しい支払いを始めていません'; + const unavailableWarns = () => + vi.mocked(logger.warn).mock.calls.filter(([msg]) => msg === UNAVAILABLE); + const circleCodeOf = (error: unknown) => (error as { circleCode?: string } | null)?.circleCode; + + // setItem を投げさせる。'session' = sessionStorage だけ (localStorage は正常)・'both' = 両方 (getItem は通る)。 + function breakWrites(target: 'session' | 'both') { + const realSetItem = Storage.prototype.setItem; + return vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + if (target === 'both' || this === window.sessionStorage) { + throw new Error('QuotaExceededError: the quota has been exceeded'); + } + return realSetItem.call(this, key, value); + }); + } + + // sessionStorage の setItem が、投げずに黙って落とす (書いたのに残らない)。 + function dropSessionWrites() { + const realSetItem = Storage.prototype.setItem; + return vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + if (this === window.sessionStorage) return; + realSetItem.call(this, key, value); + }); + } + + // sessionStorage の setItem が、渡した値とは別の値を残す (読み戻すと別の値)。 + function garbleSessionWrites() { + const realSetItem = Storage.prototype.setItem; + return vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + realSetItem.call(this, key, this === window.sessionStorage ? 'some-other-value' : value); + }); + } + + // sessionStorage の getItem だけを投げさせる (localStorage は読める = hook の最初の確認は通る)。 + // from = 何回目の読み取りから投げるか (既定 1 = 全部。2 = 最初の読み取りは通り、書いた後の読み戻しだけ投げる)。 + function breakSessionReads(from = 1) { + const realGetItem = Storage.prototype.getItem; + let reads = 0; + return vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(function (this: Storage, key: string) { + if (this === window.sessionStorage) { + reads += 1; + if (reads === from || (from === 1 && reads >= 1)) { + throw new Error('SecurityError: storage is not available'); + } + } + return realGetItem.call(this, key); + }); + } + + // 診断の warn (対応を保存できなかった) だけを投げさせる (console・Sentry の失敗)。ほかの warn は今までどおり。 + function breakUnavailableWarn() { + vi.mocked(logger.warn).mockImplementation((msg: string) => { + if (msg === UNAVAILABLE) throw new Error('test: logger is broken'); + }); + return { mockRestore: () => vi.mocked(logger.warn).mockReset() }; + } + + // 正常な端末で 1 回払い、送信の後の receipt の待ちが失敗して確認待ちで終わる所まで進める (記録は submitting・対応は保存済み・ + // bundler は止まったまま)。 + async function payUntilUnconfirmed(screen: ReturnType) { + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + permitRequests[0].approve(PERMIT); + await waitFor(() => expect(userOpRequests).toHaveLength(1)); + bundlerDown = true; // 送信は通るが、receipt の待ちが失敗する + userOpRequests[0].approve(OP_A); + await waitFor(() => expect(screen.result.current.isError).toBe(true)); + expect(screen.result.current.error?.name).toBe('CirclePendingError'); + expect(circleCodeOf(screen.result.current.error)).toBe('unconfirmed'); + expect(sentOps()).toEqual([OP_A.signedUserOp]); + expect(recordStatuses()).toEqual(['submitting']); + expect(Object.keys(mappings())).toHaveLength(1); + } + + // 正常な端末で 1 回払い、送った後に画面を離れ、その試みが裏で確定する所まで進める (記録は confirmed・成功はどの画面にも + // 出ていない・対応は保存されたまま)。 + async function payAndLeaveBeforeConfirmed() { + const receiptA = deferred(); + receiptGates.set(OP_A.userOpHash, receiptA.promise); + const screenA = openScreen(); + screenA.result.current.mutate(PARAMS); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + permitRequests[0].approve(PERMIT); + await waitFor(() => expect(userOpRequests).toHaveLength(1)); + userOpRequests[0].approve(OP_A); + await waitFor(() => expect(sentOps()).toEqual([OP_A.signedUserOp])); + screenA.unmount(); + receiptA.resolve(); + await waitFor(() => expect(screenA.state().status).toBe('success')); + expect(recordStatuses()).toEqual(['confirmed']); + expect(Object.keys(mappings())).toHaveLength(1); + } + + // この押下が、新しい支払いを何も始めていないこと (既にある分 = before より後に、署名の要求・送信・記録が増えていない)。 + function expectNoNewPayment(before: { signatures: number; sent: number; records: string[] }) { + expect(permitRequests.length + userOpRequests.length).toBe(before.signatures); + expect(vi.mocked(signUsdcPermit).mock.calls.length + vi.mocked(prepareAndSignCircleUserOp).mock.calls.length).toBe( + before.signatures, + ); + expect(sentOps()).toHaveLength(before.sent); + expect(recordStatuses()).toEqual(before.records); + } + const NOTHING = { signatures: 0, sent: 0, records: [] as string[] }; + + // 画面に出る error が、場面 (f) の error であること。押せなくする flag は立っていない。 + function expectMappingUnavailableScene(current: ReturnType['result']['current']) { + expect(current.error?.name).toBe('CircleAttemptMappingUnavailableError'); + expect(circleCodeOf(current.error)).toBe('mapping_unavailable'); + expect(current.error?.message.startsWith(MAPPING_UNAVAILABLE_JA_HEAD)).toBe(true); + expect(current.pendingStoreUnavailable).toBe(false); + expect(current.isUnknown).toBe(false); + expect(current.data).toBeUndefined(); + } + + // ---- 既にある支払いの復旧・結果の受け取りは、対応を保存できなくても今までどおり (Codex の 1 回目の順・main と同じ結果) ---- + + it('Codex の順 1: 送った支払いが確認待ち (記録は submitting・対応は保存済み) → sessionStorage の getItem だけが throw するようになる → もう一度押す → 同じ op の結果を受け取る (署名と送信は 1 回目の分だけ)', async () => { + const screen = openScreen(); + await payUntilUnconfirmed(screen); + + const reads = breakSessionReads(); + bundlerDown = false; + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.result.current.isSuccess).toBe(true)); + reads.mockRestore(); + + expect(screen.result.current.data).toMatchObject({ + userOpHash: OP_A.userOpHash, + txHash: OP_A.txHash, + success: true, + }); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expect(screen.result.current.error).toBeNull(); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + // 対応を読めなかったことは warn に 1 行残る (この押下は、保存していない id で scan から入った)。 + expect(unavailableWarns()).toEqual([[UNAVAILABLE, { chainId: baseSepolia.id, cause: 'read' }]]); + }); + + it('Codex の順 2: 送った支払いが裏で確定 (記録は confirmed・成功はどの画面にも出ていない) → sessionStorage の getItem だけが throw → 90 秒以内に開き直して押す → 既払いの結果を受け取る (署名と送信は 1 回目の分だけ)', async () => { + await payAndLeaveBeforeConfirmed(); + + clockOffset += 30_000; + const reads = breakSessionReads(); + const screenB = openScreen(); + screenB.result.current.mutate(PARAMS); + await waitFor(() => expect(screenB.result.current.isSuccess).toBe(true)); + reads.mockRestore(); + + expect(screenB.result.current.data).toMatchObject({ + userOpHash: OP_A.userOpHash, + txHash: OP_A.txHash, + success: true, + }); + expect(screenB.result.current.pendingStoreUnavailable).toBe(false); + expect(screenB.result.current.error).toBeNull(); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + }); + + // 診断の warn の失敗 (console・Sentry の throw) が、既にある支払いの復旧を巻き込まない。 + it('対応を読めないことの warn が throw しても、既にある支払い (submitting) の結果を受け取れる', async () => { + const screen = openScreen(); + await payUntilUnconfirmed(screen); + + const reads = breakSessionReads(); + const warn = breakUnavailableWarn(); + bundlerDown = false; + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.result.current.isSuccess || screen.states()[1]?.status === 'error').toBe(true)); + const warned = unavailableWarns(); + reads.mockRestore(); + warn.mockRestore(); + + expect(screen.result.current.isSuccess).toBe(true); + expect(screen.result.current.data).toMatchObject({ userOpHash: OP_A.userOpHash, success: true }); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + // warn は呼ばれている (投げた)。 + expect(warned).toHaveLength(1); + }); + + // 最初の対応を保存できていない押下でも、付け替えを書けたら、この試みの「対応の指す先」は今までどおり付け替えた先になる + // (hook が circleSend に返す値だけが false)。結果を受け取った画面のある試みは、付け替えた対応を今までどおり消す。 + it('別のタブの支払いが裏で確定 (90 秒以内) → このタブは、最初の対応の読み戻しだけが throw (saved は false・その後は読める) → 押す → 既払いの結果を受け取り、付け替えた対応は今までどおり消える', async () => { + await payAndLeaveBeforeConfirmed(); + // 別のタブ = このタブの sessionStorage には対応が無い。 + window.sessionStorage.clear(); + + clockOffset += 30_000; + const reads = breakSessionReads(2); + const screenB = openScreen(); + screenB.result.current.mutate(PARAMS); + await waitFor(() => expect(screenB.result.current.isSuccess).toBe(true)); + reads.mockRestore(); + + expect(screenB.result.current.data).toMatchObject({ userOpHash: OP_A.userOpHash, success: true }); + expect(screenB.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + expect(unavailableWarns()).toEqual([[UNAVAILABLE, { chainId: baseSepolia.id, cause: 'readback' }]]); + // 最初に書いた (読み戻せなかった) 値は、付け替えで記録の attemptId に替わり、結果を受け取った所で消える。 + expect(mappings()).toEqual({}); + }); + + // Codex と同じ壊れ方 (getItem だけ throw・setItem は通る) で、復旧が確認待ちで終わる形。付け替えの書き込みは通るが、 + // 次の押下はその対応を読めない (resume にならず、また scan から入る)。(a) の「24 時間以内にこのタブで…もう一度押すと、まず + // 同じ支払いの結果を確かめ直します」は言えないので、hook は付け替えを「書けた」と返さず、場面は (e) になる。 + it('確認待ちの支払いがある端末で sessionStorage の getItem だけが throw するようになり (setItem は通る)、復旧の照会も失敗する → 確認待ち (e) で終わる ((a) にしない・署名を求めない・送り直さない)。次の押下も scan から入り、bundler が戻れば同じ op の結果を受け取る', async () => { + const screen = openScreen(); + await payUntilUnconfirmed(screen); + const savedMapping = mappings(); + + const reads = breakSessionReads(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + screen.result.current.mutate(PARAMS); // bundler は止まったまま + await waitFor(() => expect(screen.states()[1]?.status).toBe('error')); + await waitFor(() => + expect(circleCodeOf(screen.result.current.error)).toBe('unconfirmed_earlier'), + ); + expect(screen.result.current.error?.name).toBe('CirclePendingError'); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['submitting'] }); + // 付け替えの書き込みは今までどおり試している (sessionStorage への setItem が 1 回・値はその記録の attemptId)。 + const sessionWrites = setItem.mock.calls.filter( + (_call, i) => setItem.mock.contexts[i] === window.sessionStorage, + ); + expect(sessionWrites.map(([, value]) => value)).toEqual(Object.values(savedMapping)); + setItem.mockRestore(); + + // 次の押下も、保存していない別の id で scan から入る (warn が押下ごとに 1 行)。bundler が戻れば、同じ op の結果を受け取る。 + bundlerDown = false; + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.result.current.isSuccess).toBe(true)); + reads.mockRestore(); + + expect(screen.result.current.data).toMatchObject({ userOpHash: OP_A.userOpHash, success: true }); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + expect(unavailableWarns()).toEqual([ + [UNAVAILABLE, { chainId: baseSepolia.id, cause: 'read' }], + [UNAVAILABLE, { chainId: baseSepolia.id, cause: 'read' }], + ]); + }); + + // 付け替えを書けないので (e)。次の押下も、保存していない別の id で scan から入り、同じ記録を復旧する。 + it('確認待ちの支払いがある端末で sessionStorage が読めも書けもしなくなり、復旧の照会も失敗する → 確認待ち (e) で終わる (署名を求めない・送り直さない)。次の押下も scan から入り、bundler が戻れば同じ op の結果を受け取る', async () => { + const screen = openScreen(); + await payUntilUnconfirmed(screen); + + const reads = breakSessionReads(); + const writes = breakWrites('session'); + screen.result.current.mutate(PARAMS); // bundler は止まったまま + await waitFor(() => expect(screen.states()[1]?.status).toBe('error')); + await waitFor(() => + expect(circleCodeOf(screen.result.current.error)).toBe('unconfirmed_earlier'), + ); + expect(screen.result.current.error?.name).toBe('CirclePendingError'); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['submitting'] }); + + bundlerDown = false; + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.result.current.isSuccess).toBe(true)); + reads.mockRestore(); + writes.mockRestore(); + + expect(screen.result.current.data).toMatchObject({ userOpHash: OP_A.userOpHash, success: true }); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + }); + + it('別の画面が署名の途中 (直近の pre-submit の記録) → 今までどおり (b) で終わる (この押下は何も始めない)', async () => { + const screenA = openScreen(); + screenA.result.current.mutate(PARAMS); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + expect(recordStatuses()).toEqual(['awaiting_signature']); + + const reads = breakSessionReads(); + const screenB = openScreen(); + screenB.result.current.mutate(PARAMS); + await waitFor(() => expect(screenB.result.current.isError).toBe(true)); + reads.mockRestore(); + + expect(screenB.result.current.error?.name).toBe('CirclePendingError'); + expect(circleCodeOf(screenB.result.current.error)).toBe('busy_elsewhere'); + expect(screenB.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 1, sent: 0, records: ['awaiting_signature'] }); + }); + + // ---- 新しい支払いを作る所まで来た押下は、送らずに場面 (f) の error ---- + + // 2 つ目の「両方の setItem が throw」は、この PR が表示を変える形: 直す前は hook の最初の確認 (getItem だけ) を通り、記録の + // 予約の書き込み (writeRecord) の PendingStoreWriteError がそのまま画面の error に出ていた (日本語の固定文・英語の画面でも)。 + // 直した後は、予約の書き込みの前に止まり、場面 (f) の error になる (送らない点は前も後も同じ)。 + // 最後の列 = 止まった後に sessionStorage に残っている対応の数 (hook は、この押下で対応を消しに行かない)。 + const brokenDevices: Array<[string, () => { mockRestore: () => void }, string, number]> = [ + ['sessionStorage の setItem だけが throw (localStorage は正常)', () => breakWrites('session'), 'write', 0], + [ + 'localStorage と sessionStorage の両方の setItem が throw (getItem は通る)', + () => breakWrites('both'), + 'write', + 0, + ], + ['sessionStorage の setItem が黙って落とす (書いたのに残らない)', dropSessionWrites, 'readback', 0], + ['sessionStorage の setItem が別の値を残す (読み戻すと別の値)', garbleSessionWrites, 'readback', 1], + ['sessionStorage の、書いた後の読み戻しだけが throw (書いた値は残っている)', () => breakSessionReads(2), 'readback', 1], + ['sessionStorage の getItem だけが throw', breakSessionReads, 'read', 0], + ]; + it.each(brokenDevices)( + '記録の無い新しい支払い・%s → 送らずに止まる: 場面 (f) の error・押せなくする flag は立たない・記録も署名も送信も無い', + async (_label, breakStorage, cause, leftMappings) => { + const broken = breakStorage(); + const removeItem = vi.spyOn(Storage.prototype, 'removeItem'); + const screen = openScreen(); + screen.result.current.mutate(PARAMS); + // error は、mutation が終わった後の画面の状態で見る (最初の押下なので、isError が立つのは終わった後だけ)。 + await waitFor(() => expect(screen.state().status).toBe('error')); + await waitFor(() => expect(screen.result.current.isError).toBe(true)); + broken.mockRestore(); + + // hook は error を替えずにそのまま出す (PimlicoPendingStoreUnavailableError にしない・null にしない)。 + expect(screen.result.current.error).toBe(screen.state().error); + expectMappingUnavailableScene(screen.result.current); + expect(getCircleUserOpGasPrice).toHaveBeenCalledOnce(); + expectNoNewPayment(NOTHING); + expect(broadcastCircleUserOp).not.toHaveBeenCalled(); + expect(waitForCircleReceipt).not.toHaveBeenCalled(); + // 止まった押下は、対応を消しに行かない (この押下の attemptId は保存できていない id で、残っている値はこの試みのものと + // 言えない)。読み戻しで見分けた形では、書いた値・別の値が残ったまま。 + expect(removeItem).not.toHaveBeenCalled(); + expect(Object.keys(mappings())).toHaveLength(leftMappings); + // warn は 1 行。入っているのは chainId と、どこで失敗したかだけ (アドレス・hash を入れない)。 + expect(unavailableWarns()).toEqual([[UNAVAILABLE, { chainId: baseSepolia.id, cause }]]); + expect(JSON.stringify(unavailableWarns()).toLowerCase()).not.toContain(CUSTOMER.toLowerCase()); + }, + ); + + // 診断の warn の失敗 (console・Sentry の throw) が、新しい支払いを止める処理を巻き込まない。 + it('対応を保存できないことの warn が throw しても、新しい支払いは場面 (f) で止まる (logger の error をそのまま出さない・送らない)', async () => { + const broken = breakWrites('session'); + const warn = breakUnavailableWarn(); + const screen = openScreen(); + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.state().status).toBe('error')); + await waitFor(() => expect(screen.result.current.isError).toBe(true)); + const warned = unavailableWarns(); + broken.mockRestore(); + warn.mockRestore(); + + expectMappingUnavailableScene(screen.result.current); + expectNoNewPayment(NOTHING); + expect(mappings()).toEqual({}); + expect(warned).toHaveLength(1); + }); + + // この PR が塞ぐ穴 (対応を保存した後に読めなくなった端末の形)。直す前: 保存していない id で scan に入り、90 秒より前の + // confirmed は「正規のリピート」として飛ばし、新しい支払いに署名を求めていた (前の支払いの成功は、どの画面にも出ていない)。 + it('送った支払いが裏で確定 (成功はどの画面にも出ていない) → sessionStorage の getItem が throw → 90 秒より後に開き直して押す → 2 本目を始めない (署名を求めず、場面 (f) の error)', async () => { + await payAndLeaveBeforeConfirmed(); + + clockOffset += 100_000; + const reads = breakSessionReads(); + const screenB = openScreen(); + screenB.result.current.mutate(PARAMS); + await waitFor(() => expect(screenB.state().status).toBe('error')); + await waitFor(() => expect(screenB.result.current.isError).toBe(true)); + reads.mockRestore(); + + expectMappingUnavailableScene(screenB.result.current); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + // 前の支払いの対応は残っている (読めるようになれば、次の押下はその記録を resume して既払いの結果を受け取る)。 + expect(Object.keys(mappings())).toHaveLength(1); + }); + + // 送信は閉じない (flag を立てない) ので、止まった後も押せる。何も変わっていなければ、押すたびに同じ所で止まる + // (記録も署名も送信も増えない)。sessionStorage が戻れば、今までどおり新しい支払いになる。 + it('止まった後にもう一度押す (何も変わっていない) → また場面 (f) で止まる。sessionStorage が戻ってから押すと、今までどおり署名を求める', async () => { + const broken = breakWrites('session'); + const screen = openScreen(); + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.states()[0].status).toBe('error')); + await waitFor(() => expect(screen.result.current.isError).toBe(true)); + expectMappingUnavailableScene(screen.result.current); + + // 2 回目 (試み中の印は 1 回目の終わりで外れている = Circle の入口まで来る)。 + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(getCircleUserOpGasPrice).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.states()[1]?.status).toBe('error')); + await waitFor(() => expect(screen.result.current.error).toBe(screen.states()[1].error)); + expect(screen.states()[1].error).not.toBe(screen.states()[0].error); + expectMappingUnavailableScene(screen.result.current); + expectNoNewPayment(NOTHING); + expect(mappings()).toEqual({}); + expect(unavailableWarns()).toHaveLength(2); + + // sessionStorage が戻ってから押す: 対応を保存して、新しい支払いとして署名を求める。 + broken.mockRestore(); + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expect(Object.keys(mappings())).toHaveLength(1); + expect(recordStatuses()).toEqual(['awaiting_signature']); + expect(sentOps()).toEqual([]); + expect(unavailableWarns()).toHaveLength(2); + }); + + // ---- 対応を保存できている押下・対応を使わない押下は、今までどおり ---- + + it('sessionStorage が正常 → 今までどおり (対応を保存して署名を求める・warn も出ない)', async () => { + const screen = openScreen(); + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expect(screen.result.current.isPending).toBe(true); + const mapping = mappings(); + expect(Object.keys(mapping)).toHaveLength(1); + // 記録は、保存した対応の attemptId で予約されている。 + const recordAttemptIds = Array.from( + { length: window.localStorage.length }, + (_, i) => window.localStorage.key(i)!, + ) + .filter((key) => key.startsWith(PENDING_KEY_PREFIX)) + .map( + (key) => + (JSON.parse(window.localStorage.getItem(key)!) as { paymentAttemptId: string }) + .paymentAttemptId, + ); + expect(recordAttemptIds).toEqual(Object.values(mapping)); + expect(unavailableWarns()).toEqual([]); + }); + + // 既にある対応は読むだけで、書き直さない (saved: true のまま = 自分の key の resume)。 + it('前の押下で対応を保存できた後に、sessionStorage へ書けなくなる (読める) → 確認待ちの続きは今までどおり (署名なしで同じ op の結果を受け取る・対応も消える)', async () => { + const screen = openScreen(); + await payUntilUnconfirmed(screen); + + const broken = breakWrites('session'); + bundlerDown = false; + screen.result.current.mutate(PARAMS); + await waitFor(() => expect(screen.result.current.isSuccess).toBe(true)); + broken.mockRestore(); + + expect(screen.result.current.data).toMatchObject({ + userOpHash: OP_A.userOpHash, + txHash: OP_A.txHash, + success: true, + }); + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expectNoNewPayment({ signatures: 2, sent: 1, records: ['confirmed'] }); + expect(unavailableWarns()).toEqual([]); + // 結果を受け取ったので、対応は今までどおり消える (破棄は removeItem で、書き込みではない)。 + expect(mappings()).toEqual({}); + }); + + it('明示の paymentAttemptId → 対応を使わないので確かめない (sessionStorage に書けなくても、今までどおり署名を求める)', async () => { + const broken = breakWrites('session'); + const screen = openScreen(); + screen.result.current.mutate({ ...PARAMS, paymentAttemptId: 'explicit-attempt-1' }); + await waitFor(() => expect(permitRequests).toHaveLength(1)); + broken.mockRestore(); + + expect(screen.result.current.pendingStoreUnavailable).toBe(false); + expect(screen.result.current.isPending).toBe(true); + expect(recordStatuses()).toEqual(['awaiting_signature']); + expect(mappings()).toEqual({}); + expect(unavailableWarns()).toEqual([]); + }); +}); diff --git a/tests/lib/circleErrorKey.test.ts b/tests/lib/circleErrorKey.test.ts index 69f0ac3c..c2e17494 100644 --- a/tests/lib/circleErrorKey.test.ts +++ b/tests/lib/circleErrorKey.test.ts @@ -3,7 +3,11 @@ import { createTranslator } from 'next-intl'; import jaMessages from '@/messages/ja.json'; import enMessages from '@/messages/en.json'; import { circleErrorKey, type CircleErrorCode, type CircleErrorKey } from '@/lib/circleErrorKey'; -import { CirclePendingError, CircleSendRejectedError } from '@/lib/smartAccount/circleSend'; +import { + CircleAttemptMappingUnavailableError, + CirclePendingError, + CircleSendRejectedError, +} from '@/lib/smartAccount/circleSend'; // Circle の経路 (USDC ガスレス) の error の「場面の符号」→ 案内の文 (messages の CircleErrors) (第 10 回レビュー PR-39c)。 // 3 つのフォームは、error の class も name も見ず、`circleCode` の値だけからこの key を引く @@ -20,10 +24,12 @@ const KEYS: Record = { attempt_ended: 'attemptEnded', send_rejected: 'sendRejected', unconfirmed_earlier: 'unconfirmedEarlier', + mapping_unavailable: 'mappingUnavailable', }; const CODES = Object.keys(KEYS) as CircleErrorCode[]; -// user が承認した文 (2026-11-13・5 つとも)。一字も変えない (同じ文が、ここ・messages・circleSend.ts の 3 か所にある)。 +// user が承認した文 (2026-11-13・(a)〜(f) の 6 つ。(f) = mappingUnavailable は第 10 回レビューの後追い r10-ss で足した)。 +// 一字も変えない (同じ文が、ここ・messages・circleSend.ts の CIRCLE_ERROR_JA の 3 か所にある。替えるときは en も一緒に)。 const APPROVED_JA: Record = { unconfirmed: 'USDC ガスレスの支払いを送信しましたが、結果をまだ確認できていません。24 時間以内にこのタブで同じ内容のままもう一度押すと、まず同じ支払いの結果を確かめ直します。それより後は、新しい支払いとして署名を求める場合があります。確かめられるまで、ほかの方法で払い直さないでください。', @@ -35,6 +41,8 @@ const APPROVED_JA: Record = { 'USDC ガスレスの支払いは、送信の受付で断られました。この支払いは送金されていません。残高が足りないなどの理由が考えられます。もう一度お試しください (署名からやり直します)。', unconfirmedEarlier: '同じ内容の支払いを以前に送信した記録があり、その結果をまだ確認できていません。ここでは新しい支払いを始めていません。もう一度押すと確かめ直しますが、署名を求められた場合は新しい支払いになります。二重に払わないよう、署名の前にウォレットの履歴で以前の支払いを確かめてください。', + mappingUnavailable: + 'この端末では支払いの記録を保存できないため、USDC ガスレスの新しい支払いを始めていません (この押下では送金していません)。同じ内容の支払いを以前に送っていた場合は、先にウォレットの履歴で確かめてください。新しく支払うには、ブラウザの設定 (サイトのデータの保存) を確かめるか、別のブラウザでお試しください。', }; describe('circleErrorKey', () => { @@ -42,12 +50,23 @@ describe('circleErrorKey', () => { expect(circleErrorKey(Object.assign(new Error('x'), { circleCode: code }))).toBe(KEYS[code]); }); - it('本物の class の instance: 確認待ちの既定は (a)・理由つきは (b) (e)・送信の受付の拒否は (d)', () => { + it('本物の class の instance: 確認待ちの既定は (a)・理由つきは (b) (e)・送信の受付の拒否は (d)・対応を保存できない押下の停止は (f)', () => { expect(circleErrorKey(new CirclePendingError(HASH))).toBe('unconfirmed'); expect(circleErrorKey(new CirclePendingError())).toBe('unconfirmed'); expect(circleErrorKey(new CirclePendingError(HASH, 'busy_elsewhere'))).toBe('busyElsewhere'); expect(circleErrorKey(new CirclePendingError(HASH, 'unconfirmed_earlier'))).toBe('unconfirmedEarlier'); expect(circleErrorKey(new CircleSendRejectedError(HASH))).toBe('sendRejected'); + expect(circleErrorKey(new CircleAttemptMappingUnavailableError())).toBe('mappingUnavailable'); + }); + + // (f) は確認待ちではない (hook は name で見る): name は class の名前のままで、符号は own property。 + it('(f) の class: name は CircleAttemptMappingUnavailableError・符号は own property・確認待ち (CirclePendingError) ではない', () => { + const error = new CircleAttemptMappingUnavailableError(); + expect(error.name).toBe('CircleAttemptMappingUnavailableError'); + expect(error.circleCode).toBe('mapping_unavailable'); + expect(Object.hasOwn(error, 'circleCode')).toBe(true); + expect(error).not.toBeInstanceOf(CirclePendingError); + expect('userOpHash' in error).toBe(false); }); it('class の name を見ない (name が同じでも、符号が無ければ null)', () => { @@ -77,12 +96,13 @@ describe('Circle の経路の案内の文 (CircleErrors)', () => { const en = createTranslator({ locale: 'en', messages: enMessages, namespace: 'CircleErrors' }); const ALL_KEYS = Object.values(KEYS); - it('ja と en に 5 つの場面の文があり、余分な鍵が無い', () => { + it('ja と en に 6 つの場面の文があり、余分な鍵が無い', () => { + expect(ALL_KEYS).toHaveLength(6); expect(Object.keys(jaMessages.CircleErrors).sort()).toEqual([...ALL_KEYS].sort()); expect(Object.keys(enMessages.CircleErrors).sort()).toEqual([...ALL_KEYS].sort()); }); - it.each(ALL_KEYS)('%s: ja は承認済みの文と完全に同じ', (key) => { + it.each(ALL_KEYS)('%s: ja は承認済みの文 ((f) は案) と完全に同じ', (key) => { expect(ja(key)).toBe(APPROVED_JA[key]); }); @@ -98,6 +118,15 @@ describe('Circle の経路の案内の文 (CircleErrors)', () => { expect(new CirclePendingError(HASH, 'busy_elsewhere').message).toBe(ja('busyElsewhere')); expect(new CirclePendingError(HASH, 'unconfirmed_earlier').message).toBe(ja('unconfirmedEarlier')); expect(new CircleSendRejectedError(HASH).message).toBe(ja('sendRejected')); + expect(new CircleAttemptMappingUnavailableError().message).toBe(ja('mappingUnavailable')); + }); + + // (f) の文は「次に押すと◯◯します」と約束しない: 次の押下が何をするかは、その時点の記録で変わる。送っていないことは言う。 + it('(f) の文: この押下では送っていないと言い、次の押下の動きを約束しない (ja / en)', () => { + expect(ja('mappingUnavailable')).toContain('この押下では送金していません'); + expect(ja('mappingUnavailable')).not.toMatch(/もう一度押すと|押し直すと|確かめ直します/); + expect(en('mappingUnavailable')).toContain('nothing was sent by this press'); + expect(en('mappingUnavailable')).not.toMatch(/press(ing)? (the button )?again|re-check/i); }); it('「再読み込みしても二重決済にならない」と約束しない (30 分より前の支払いでは成り立たない場合がある)', () => { diff --git a/tests/lib/circlePending.test.ts b/tests/lib/circlePending.test.ts index f52f854c..983f6750 100644 --- a/tests/lib/circlePending.test.ts +++ b/tests/lib/circlePending.test.ts @@ -1035,22 +1035,30 @@ describe('stableAttemptId / clearStableAttemptId (sessionStorage マッピング window.sessionStorage.clear(); }); + // stableAttemptId は { attemptId, saved } を返す (r10-ss)。保存できる前提の test は、saved が true であることを確かめてから + // attemptId を使う (保存できていない id 同士の比較で空振りしない)。 + function savedAttemptId(args: Parameters[0]): string { + const mapping = stableAttemptId(args); + if (!mapping.saved) throw new Error('test: 対応を保存できなかった (stableAttemptId の saved が false)'); + return mapping.attemptId; + } + it('同一 (chainId,sender,callHash) は同じ attemptId を返す (再クリック/reload で resume)', () => { - const a = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); - const b = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); + const a = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); + const b = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); expect(a).toBe(b); }); it('callHash が違えば別 attemptId (別決済 intent)', () => { - const a = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); - const b = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: OTHER_HASH }); + const a = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); + const b = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: OTHER_HASH }); expect(a).not.toBe(b); }); it('clear 後は新しい attemptId を採番する (次の同額購入は別 attempt)', () => { - const a = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); + const a = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); clearStableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }, a); - const b = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); + const b = savedAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); expect(b).not.toBe(a); }); @@ -1060,10 +1068,10 @@ describe('stableAttemptId / clearStableAttemptId (sessionStorage マッピング const ARGS = { chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }; it('別の値に替わっていたら (別の試みが付け替えた) 破棄しない', () => { - const own = stableAttemptId(ARGS); + const own = savedAttemptId(ARGS); setStableAttemptId(ARGS, 'attempt-of-other-tab'); clearStableAttemptId(ARGS, own); - expect(stableAttemptId(ARGS)).toBe('attempt-of-other-tab'); + expect(savedAttemptId(ARGS)).toBe('attempt-of-other-tab'); }); it('付け替えた先の attemptId を渡せば破棄する (付け替えた試み自身の後片付け)', () => { @@ -1079,15 +1087,15 @@ describe('stableAttemptId / clearStableAttemptId (sessionStorage マッピング }); it('別の内容 (callHash) のマッピングには触らない', () => { - const own = stableAttemptId(ARGS); - const other = stableAttemptId({ ...ARGS, callHash: OTHER_HASH }); + const own = savedAttemptId(ARGS); + const other = savedAttemptId({ ...ARGS, callHash: OTHER_HASH }); clearStableAttemptId(ARGS, own); expect(window.sessionStorage.length).toBe(1); - expect(stableAttemptId({ ...ARGS, callHash: OTHER_HASH })).toBe(other); + expect(savedAttemptId({ ...ARGS, callHash: OTHER_HASH })).toBe(other); }); it('sessionStorage を読めない・消せないときは投げない (マッピングはそのまま)', () => { - const own = stableAttemptId(ARGS); + const own = savedAttemptId(ARGS); const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('SecurityError'); }); @@ -1099,23 +1107,224 @@ describe('stableAttemptId / clearStableAttemptId (sessionStorage マッピング expect(() => clearStableAttemptId(ARGS, own)).not.toThrow(); expect(removeItem).toHaveBeenCalledTimes(1); removeItem.mockRestore(); - expect(stableAttemptId(ARGS)).toBe(own); + expect(savedAttemptId(ARGS)).toBe(own); }); }); - it('sessionStorage.setItem が throw する環境では毎回新規 UUID (throw せず fallback)', () => { - const spy = vi - .spyOn(Storage.prototype, 'setItem') - .mockImplementation(() => { - throw new Error('SecurityError: private mode'); + // r10-ss: attemptId と、それを sessionStorage に保存できたか (saved) を返す。保存できなければ、保存していない新しい UUID と + // saved: false (hook はそれを executeCirclePayment に渡し、新しい記録を予約する直前で止まる。scan には、この id を使う)。 + // 直す前は、保存していない新しい UUID を黙って返していた (呼ぶ側は見分けられない): その端末で送った支払いが確認待ちで終わると、 + // 次の押下は別の attemptId になり、裏で確定した前の支払い (90 秒より前の confirmed) の後に 2 本目の署名を求める。 + describe('attemptId と、保存できたか (saved) を返す', () => { + const ARGS = { chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }; + const UNAVAILABLE = 'circle.attempt_mapping.unavailable'; + const UUID = /^[0-9a-f-]{36}$/i; + + // この describe の spy は sessionStorage だけを壊す (localStorage は本物のまま)。 + const realGetItem = Storage.prototype.getItem; + const realSetItem = Storage.prototype.setItem; + function spyWarn() { + return vi.spyOn(logger, 'warn').mockImplementation(() => {}); + } + function breakSessionWrites() { + return vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + if (this === window.sessionStorage) throw new Error('QuotaExceededError: private mode'); + return realSetItem.call(this, key, value); + }); + } + // 保存できなかった結果 (saved: false) であることを確かめ、その (保存していない) attemptId を返す。 + function unsavedAttemptId(): string { + const mapping = stableAttemptId(ARGS); + expect(mapping.saved).toBe(false); + expect(mapping.attemptId).toMatch(UUID); + expect(Object.keys(mapping).sort()).toEqual(['attemptId', 'saved']); + return mapping.attemptId; + } + // sessionStorage に残っている対応の値 (無ければ空)。 + const storedValues = () => + Array.from({ length: window.sessionStorage.length }, (_, i) => + window.sessionStorage.getItem(window.sessionStorage.key(i)!), + ); + + it('対応が無い → 書いて、読み戻せた値と saved: true を返す (書くのは 1 回・2 回目は同じ値を読むだけ)。warn は出さない', () => { + const warn = spyWarn(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const first = stableAttemptId(ARGS); + expect(first).toEqual({ attemptId: expect.stringMatching(UUID), saved: true }); + expect(setItem).toHaveBeenCalledTimes(1); + expect(storedValues()).toEqual([first.attemptId]); + + expect(stableAttemptId(ARGS)).toEqual(first); + expect(setItem).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); + }); + + it('setItem が throw → saved: false と、保存していない UUID (押すたびに別の値)。何も残らない。warn は chainId と cause: write だけ', () => { + const warn = spyWarn(); + const setItem = breakSessionWrites(); + const a = unsavedAttemptId(); + const b = unsavedAttemptId(); + expect(a).not.toBe(b); + expect(setItem).toHaveBeenCalledTimes(2); + setItem.mockRestore(); + expect(storedValues()).toEqual([]); + expect(warn.mock.calls).toEqual([ + [UNAVAILABLE, { chainId: CHAIN_ID, cause: 'write' }], + [UNAVAILABLE, { chainId: CHAIN_ID, cause: 'write' }], + ]); + }); + + it('getItem が throw → saved: false (押すたびに別の UUID)。書きに行かない。warn は cause: read', () => { + const warn = spyWarn(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(function (this: Storage, key: string) { + if (this === window.sessionStorage) throw new Error('SecurityError: storage is not available'); + return realGetItem.call(this, key); + }); + const a = unsavedAttemptId(); + const b = unsavedAttemptId(); + getItem.mockRestore(); + expect(a).not.toBe(b); + expect(setItem).not.toHaveBeenCalled(); + expect(storedValues()).toEqual([]); + expect(warn.mock.calls).toEqual([ + [UNAVAILABLE, { chainId: CHAIN_ID, cause: 'read' }], + [UNAVAILABLE, { chainId: CHAIN_ID, cause: 'read' }], + ]); + }); + + // 保存した後に読めなくなった端末: 既にある対応の値は返さない (読めないので分からない)。保存していない別の UUID を返す。 + it('対応を保存した後に getItem が throw するようになる → saved: false と、保存した値とは別の UUID。保存した値は残る', () => { + const own = savedAttemptId(ARGS); + const warn = spyWarn(); + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(function (this: Storage, key: string) { + if (this === window.sessionStorage) throw new Error('SecurityError: storage is not available'); + return realGetItem.call(this, key); + }); + const unsaved = unsavedAttemptId(); + getItem.mockRestore(); + expect(unsaved).not.toBe(own); + expect(storedValues()).toEqual([own]); + expect(warn.mock.calls).toEqual([[UNAVAILABLE, { chainId: CHAIN_ID, cause: 'read' }]]); + // 読めるようになれば、保存した値に戻る。 + expect(savedAttemptId(ARGS)).toBe(own); + }); + + it('setItem は通るが残らない (silent drop) → saved: false。warn は cause: readback', () => { + const warn = spyWarn(); + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + if (this === window.sessionStorage) return; + realSetItem.call(this, key, value); + }); + unsavedAttemptId(); + expect(setItem).toHaveBeenCalledTimes(1); + setItem.mockRestore(); + expect(storedValues()).toEqual([]); + expect(warn.mock.calls).toEqual([[UNAVAILABLE, { chainId: CHAIN_ID, cause: 'readback' }]]); + }); + + it('読み戻すと別の値 → saved: false。返すのは、残った値ではなく、書こうとした UUID。warn は cause: readback', () => { + const warn = spyWarn(); + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + realSetItem.call(this, key, this === window.sessionStorage ? 'some-other-value' : value); + }); + const unsaved = unsavedAttemptId(); + setItem.mockRestore(); + expect(unsaved).not.toBe('some-other-value'); + expect(storedValues()).toEqual(['some-other-value']); + expect(warn.mock.calls).toEqual([[UNAVAILABLE, { chainId: CHAIN_ID, cause: 'readback' }]]); + }); + + it('書いた後の読み戻しが throw → saved: false。warn は cause: readback', () => { + const warn = spyWarn(); + let reads = 0; + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(function (this: Storage, key: string) { + if (this === window.sessionStorage) { + reads += 1; + if (reads > 1) throw new Error('SecurityError: storage is not available'); + } + return realGetItem.call(this, key); + }); + unsavedAttemptId(); + getItem.mockRestore(); + expect(reads).toBe(2); + expect(warn.mock.calls).toEqual([[UNAVAILABLE, { chainId: CHAIN_ID, cause: 'readback' }]]); + }); + + // 確認待ちの後の押下: 前の押下で保存できた対応は、その後に書けなくなっても読める (saved: true のまま = resume する)。 + it('既に対応がある → その値と saved: true を返し、書かない (setItem が throw する端末でも)', () => { + const own = savedAttemptId(ARGS); + const warn = spyWarn(); + const setItem = breakSessionWrites(); + expect(stableAttemptId(ARGS)).toEqual({ attemptId: own, saved: true }); + expect(setItem).not.toHaveBeenCalled(); + setItem.mockRestore(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('見るのは sessionStorage だけ: localStorage の setItem だけが throw しても、対応は保存できる', () => { + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (this: Storage, key: string, value: string) { + if (this === window.localStorage) throw new Error('QuotaExceededError'); + return realSetItem.call(this, key, value); + }); + const id = savedAttemptId(ARGS); + setItem.mockRestore(); + expect(id).toMatch(UUID); + expect(savedAttemptId(ARGS)).toBe(id); + }); + + it('warn に sender・callHash・attemptId・error の文を入れない', () => { + const warn = spyWarn(); + const setItem = breakSessionWrites(); + const unsaved = unsavedAttemptId(); + setItem.mockRestore(); + expect(warn).toHaveBeenCalledTimes(1); + const logged = JSON.stringify(warn.mock.calls).toLowerCase(); + expect(logged).not.toContain(SENDER.toLowerCase()); + expect(logged).not.toContain(CALL_HASH.toLowerCase()); + expect(logged).not.toContain(unsaved.toLowerCase()); + expect(logged).not.toContain('quotaexceedederror'); + expect(Object.keys(warn.mock.calls[0][1] ?? {}).sort()).toEqual(['cause', 'chainId']); + }); + + // 診断の warn の失敗 (console・Sentry の throw) を、呼ぶ側 (既にある支払いの復旧・新しい支払いを止める処理) に届かせない。 + it('warn が throw しても投げない: 返す値は同じ (saved: false と、保存していない UUID)', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => { + throw new Error('test: logger is broken'); }); - const a = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); - const b = stableAttemptId({ chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }); - // 保存できないので安定しない (毎回新規)。だが throw はしない (決済可否を左右させない)。 - expect(a).toMatch(/^[0-9a-f-]{36}$/i); - expect(b).toMatch(/^[0-9a-f-]{36}$/i); - expect(a).not.toBe(b); - spy.mockRestore(); + const setItem = breakSessionWrites(); + const a = unsavedAttemptId(); + const b = unsavedAttemptId(); + setItem.mockRestore(); + expect(a).not.toBe(b); + expect(warn).toHaveBeenCalledTimes(2); + }); + + // UUID の採番は storage と関係ない。その失敗を「対応を保存できない」(saved: false) に化けさせない (今までも throw だった)。 + it('crypto.randomUUID の失敗は saved: false にしない (そのまま throw・warn は出さない)', () => { + const warn = spyWarn(); + const uuid = vi.spyOn(crypto, 'randomUUID').mockImplementation(() => { + throw new Error('test: randomUUID is not available'); + }); + expect(() => stableAttemptId(ARGS)).toThrow('test: randomUUID is not available'); + uuid.mockRestore(); + expect(storedValues()).toEqual([]); + expect(warn).not.toHaveBeenCalled(); + }); }); it('key は ATTEMPT_KEY_PREFIX で始まり PENDING_KEY_PREFIX と衝突しない', () => { @@ -1135,35 +1344,35 @@ describe('stableAttemptId / clearStableAttemptId (sessionStorage マッピング const ARGS = { chainId: CHAIN_ID, sender: SENDER, callHash: CALL_HASH }; it('付け替えた後、同じ (chainId,sender,callHash) の stableAttemptId は渡した attemptId を返す (key は増えない)。書けたら true', () => { - const own = stableAttemptId(ARGS); + const own = savedAttemptId(ARGS); expect(setStableAttemptId(ARGS, 'attempt-of-other-tab')).toBe(true); - expect(stableAttemptId(ARGS)).toBe('attempt-of-other-tab'); - expect(stableAttemptId(ARGS)).not.toBe(own); + expect(savedAttemptId(ARGS)).toBe('attempt-of-other-tab'); + expect(savedAttemptId(ARGS)).not.toBe(own); expect(window.sessionStorage.length).toBe(1); }); it('sender の大文字・小文字が違っても同じ対応を書く (stableAttemptId と同じ key)', () => { stableAttemptId(ARGS); setStableAttemptId({ ...ARGS, sender: SENDER.toLowerCase() as Address }, 'attempt-of-other-tab'); - expect(stableAttemptId(ARGS)).toBe('attempt-of-other-tab'); + expect(savedAttemptId(ARGS)).toBe('attempt-of-other-tab'); expect(window.sessionStorage.length).toBe(1); }); it('別の内容 (callHash) の対応には触らない', () => { - const other = stableAttemptId({ ...ARGS, callHash: OTHER_HASH }); + const other = savedAttemptId({ ...ARGS, callHash: OTHER_HASH }); setStableAttemptId(ARGS, 'attempt-of-other-tab'); - expect(stableAttemptId({ ...ARGS, callHash: OTHER_HASH })).toBe(other); + expect(savedAttemptId({ ...ARGS, callHash: OTHER_HASH })).toBe(other); }); it('sessionStorage.setItem が throw しても投げず、false を返す。対応は前のまま', () => { - const own = stableAttemptId(ARGS); + const own = savedAttemptId(ARGS); const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); expect(setStableAttemptId(ARGS, 'attempt-of-other-tab')).toBe(false); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); - expect(stableAttemptId(ARGS)).toBe(own); + expect(savedAttemptId(ARGS)).toBe(own); }); }); }); diff --git a/tests/lib/smartAccount/circleSend.adoptAttempt.test.ts b/tests/lib/smartAccount/circleSend.adoptAttempt.test.ts index 079512c7..fefdb0de 100644 --- a/tests/lib/smartAccount/circleSend.adoptAttempt.test.ts +++ b/tests/lib/smartAccount/circleSend.adoptAttempt.test.ts @@ -42,6 +42,7 @@ import { } from '@/lib/smartAccount/circleAccount'; import { executeCirclePayment, + CircleAttemptMappingUnavailableError, CirclePendingError, STALE_SUBMITTING_WINDOW_MS, } from '@/lib/smartAccount/circleSend'; @@ -938,3 +939,259 @@ describe('PR-39c: 自分の key の submitting の記録の resume — 残りの expect(signatureRequests()).toBe(0); }); }); + +// 第 10 回レビューの後追い r10-ss (money-path): hook が attemptId の対応 (sessionStorage) を保存できなかった押下は、 +// mappingSaved: false を渡す (paymentAttemptId は、保存していない、押すたびに変わる id = ここでは tab-2)。 +// 失敗シナリオ (直す前): その id のまま新しい支払いを送ると、確認待ちで終わった後の押下が別の key になり、裏で確定した前の +// 支払い (90 秒より前の confirmed) を「正規のリピート」とみなして 2 本目を払う。 +// 直した後: 新しい記録を予約する直前 (scan の loop の後・reserveOrResume の前) で止める。**scan の中の動きは変えない** +// (既にある支払いの結果を返す・復旧する・確認待ち・古い pre-submit の abandon)。入口で止めると、対応を保存した後に +// sessionStorage を読めなくなった端末で、送った支払いの結果を受け取れなくなる (Codex の指摘)。 +describe('r10-ss: 対応を保存できていない押下 (mappingSaved: false)', () => { + const UNSAVED = { mappingSaved: false } as const; + + // localStorage の Circle の記録 (key → status)。 + function records(): Record { + return Object.fromEntries( + Array.from({ length: window.localStorage.length }, (_, i) => window.localStorage.key(i)!) + .filter((storageKey) => storageKey.startsWith(PENDING_KEY_PREFIX)) + .sort() + .map((storageKey) => [ + storageKey, + (JSON.parse(window.localStorage.getItem(storageKey)!) as { status: string }).status, + ]), + ); + } + + // 押下が「新しい記録を予約する直前」で止まったこと: 専用の error で終わり、新しい支払いを何も始めていない。 + async function expectStopped(attempt: Promise) { + const error = await attempt.catch((e: unknown) => e); + expect(error).toBeInstanceOf(CircleAttemptMappingUnavailableError); + const stopped = error as CircleAttemptMappingUnavailableError; + // hook は name で見る (対応を消さない)。フォームは場面 (f) の符号から locale の文を出す。確認待ちではない。 + // message (i18n を通らない出口 = 履歴・payment log・Sentry に出る) は、ja の同じ場面の文と同じ。 + expect(stopped.name).toBe('CircleAttemptMappingUnavailableError'); + expect(stopped.circleCode).toBe('mapping_unavailable'); + expect(Object.hasOwn(stopped, 'circleCode')).toBe(true); + expect(stopped.message).toBe(jaMessages.CircleErrors.mappingUnavailable); + expect(stopped).not.toBeInstanceOf(CirclePendingError); + // 自分の key の記録を作っていない・署名を求めていない・何も送っていない・待っていない。 + expect(stored('tab-2')).toBeUndefined(); + expect(signatureRequests()).toBe(0); + expect(mockBroadcast).not.toHaveBeenCalled(); + expect(mockWait).not.toHaveBeenCalled(); + } + + describe('scan の枝は今までどおり (既にある支払いの結果を返す・復旧する・確認待ち)', () => { + it('直近の confirmed (30 秒後) → 既払いの結果を返す (付け替えも今までどおり呼ぶ)', async () => { + seed('confirmed'); + include(OLD, { block: 100n, ms: T0 + SEC }); + + const res = await press(T0 + SEC + 30 * SEC, UNSAVED); + + expect(res).toMatchObject({ userOpHash: OLD.userOpHash, txHash: OLD.txHash }); + expect(adopted()).toEqual(['tab-1']); + expect(events).toEqual(['adopt', 'lookup']); + expect(signatureRequests()).toBe(0); + expect(stored('tab-2')).toBeUndefined(); + }); + + it('窓の内の submitting (10 分後) → 同じ op を復旧して結果を返す', async () => { + seed('submitting'); + include(OLD, { block: 100n, ms: T0 + 12 * SEC }); + + const res = await press(T0 + 10 * MIN, UNSAVED); + + expect(res).toMatchObject({ userOpHash: OLD.userOpHash, txHash: OLD.txHash }); + expect(adopted()).toEqual(['tab-1']); + expect(events).toEqual(['adopt', 'wait']); + expect(signatureRequests()).toBe(0); + expect(stored('tab-1')?.status).toBe('confirmed'); + expect(stored('tab-2')).toBeUndefined(); + }); + + it('窓の内の submitting・復旧が確認待ちで終わる・hook が付け替えを書けなかった (false) → (e)。復旧の進め方は同じ', async () => { + seed('submitting'); + adoptAttempt.mockImplementation(() => { + events.push('adopt'); + return false; + }); + + const error = await press(T0 + 10 * MIN, UNSAVED).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CirclePendingError); + expect((error as CirclePendingError).circleCode).toBe('unconfirmed_earlier'); + expect((error as CirclePendingError).userOpHash).toBe(OLD.userOpHash); + expect(takePendingCodes()).toEqual(['unconfirmed_earlier']); + // 待ち → timeout → 同じ op の送り直し → 待ち (新しい op は作らない)。 + expect(events).toEqual(['adopt', 'wait', 'wait']); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + expect(signatureRequests()).toBe(0); + expect(stored('tab-1')?.status).toBe('submitting'); + expect(stored('tab-2')).toBeUndefined(); + }); + + it('窓の内の submitting・復旧が確認待ちで終わる・hook が付け替えを書けた (true) → 今までどおり (a)', async () => { + seed('submitting'); + + const error = await press(T0 + 10 * MIN, UNSAVED).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CirclePendingError); + expect((error as CirclePendingError).circleCode).toBe('unconfirmed'); + expect(adopted()).toEqual(['tab-1']); + expect(stored('tab-2')).toBeUndefined(); + }); + + it('31 分後・使用済みで取り込みは 5 分前 → 今までどおり前の結果を返す', async () => { + seed('submitting'); + const at = T0 + 31 * MIN; + include(OLD, { block: 100n, ms: at - 5 * MIN }); + chain.latestMs = at; + + const res = await press(at, UNSAVED); + + expect(res).toMatchObject({ userOpHash: OLD.userOpHash, txHash: OLD.txHash }); + expect(signatureRequests()).toBe(0); + }); + + it('31 分後・nonce を読めない (確かめられない記録) → 今までどおり確認待ち (e)。止めた error にはしない', async () => { + seed('submitting'); + mockRead.mockRejectedValue(new Error('HTTP request failed. Status: 503')); + + const error = await press(T0 + 31 * MIN, UNSAVED).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CirclePendingError); + expect(takePendingCodes()).toEqual(['unconfirmed_earlier']); + expect(events).toEqual([]); + }); + + it('直近の pre-submit (別の試みが署名中) → 今までどおり確認待ち (b)。止めた error にはしない', async () => { + seed('signed', { handedOffAt: T0 }); + + const error = await press(T0 + MIN, UNSAVED).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CirclePendingError); + expect(takePendingCodes()).toEqual(['busy_elsewhere']); + expect(stored('tab-1')?.status).toBe('signed'); + expect(stored('tab-2')).toBeUndefined(); + }); + }); + + describe('新しい支払いを作る所まで来たら、新しい記録を予約する直前で止まる', () => { + it('記録が何も無い → 止まる。記録は 1 つも増えない', async () => { + walletSigns(NEW, { block: 200n, ms: T0 }); + + await expectStopped(press(T0, UNSAVED)); + + expect(records()).toEqual({}); + expect(adopted()).toEqual([]); + }); + + // この PR が塞ぐ穴: 直す前は、保存していない id で新しい支払い (2 本目) に署名を求めていた。 + it('90 秒より前の confirmed (正規のリピートとして飛ばす記録) だけがある → 2 本目を始めずに止まる', async () => { + seed('confirmed'); + include(OLD, { block: 100n, ms: T0 + SEC }); + const at = T0 + SEC + 90 * SEC + 1; + walletSigns(NEW, { block: 200n, ms: at }); + const before = records(); + + await expectStopped(press(at, UNSAVED)); + + expect(records()).toEqual(before); + expect(adopted()).toEqual([]); + }); + + it('revert した confirmed (dedup しない記録) だけがある → 止まる', async () => { + seed('confirmed', { success: false }); + const at = T0 + SEC + 30 * SEC; + walletSigns(NEW, { block: 200n, ms: at }); + const before = records(); + + await expectStopped(press(at, UNSAVED)); + + expect(records()).toEqual(before); + }); + + it('31 分後・使用済みで取り込みは 31 分前 (飛ばす submitting の記録) だけがある → 止まる。記録は書かない', async () => { + seed('submitting'); + const at = T0 + 31 * MIN; + include(OLD, { block: 100n, ms: T0 + 12 * SEC }); + chain.latestMs = at + 20 * SEC; + walletSigns(NEW, { block: 200n, ms: at }); + const before = records(); + + await expectStopped(press(at, UNSAVED)); + + expect(records()).toEqual(before); + expect(stored('tab-1')?.status).toBe('submitting'); + }); + + it('古い pre-submit (3 分より前) → 今までどおり abandoned にしてから、止まる', async () => { + seed('signed', { handedOffAt: T0 }); + const at = T0 + 4 * MIN; + walletSigns(NEW, { block: 200n, ms: at }); + + await expectStopped(press(at, UNSAVED)); + + expect(stored('tab-1')?.status).toBe('abandoned'); + expect(Object.keys(records())).toHaveLength(1); + }); + + // Codex の 2 回目の順 (止まった後に、復旧できるようになる支払い): 31 分より前の submitting・nonce は使用済みだが、bundler が + // まだ receipt を返さない → scan は飛ばすので止まる。その後 receipt が引けるようになる (取り込みは直近) → 次の押下の scan が + // 元の支払いの結果を返す。止まった押下が記録を書き換えていないので、次の押下が復旧できる。 + it('31 分後・使用済みで receipt がまだ引けない → 止まる → receipt が引けるようになった後の次の押下は、元の支払いの結果を返す (署名なし)', async () => { + seed('submitting'); + const at = T0 + 31 * MIN; + chain.consumed.add(OLD.nonceKey); // 取り込まれたが、bundler の索引がまだ追い付いていない + chain.latestMs = at; + const before = records(); + + await expectStopped(press(at, UNSAVED)); + expect(records()).toEqual(before); + + // bundler が receipt を返すようになる (取り込みは 1 分前)。同じ端末の次の押下 (保存していない別の id)。 + include(OLD, { block: 100n, ms: at - MIN }); + chain.latestMs = at + 10 * SEC; + const res = await press(at + 10 * SEC, { ...UNSAVED, paymentAttemptId: 'tab-2-next-press' }); + + expect(res).toMatchObject({ userOpHash: OLD.userOpHash, txHash: OLD.txHash }); + expect(signatureRequests()).toBe(0); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it('古い記録の照合を待つ間に失効 → 今までどおり payment_cancelled (止めた error より先)', async () => { + seed('submitting'); + const at = T0 + 31 * MIN; + include(OLD, { block: 100n, ms: T0 + 12 * SEC }); + chain.latestMs = at + 20 * SEC; + let alive = true; + mockRead.mockImplementationOnce(async (params: { args: readonly [Address, bigint] }) => { + alive = false; // RPC を待つ間に画面を離れた + return (params.args[1] << 64n) + 1n; // 使用済み + }); + + await expect(press(at, { ...UNSAVED, isAlive: () => alive })).rejects.toThrow('payment_cancelled'); + + expect(stored('tab-2')).toBeUndefined(); + expect(signatureRequests()).toBe(0); + }); + }); + + describe('省略・true の呼び出しは今までどおり (明示の paymentAttemptId を含む)', () => { + it.each([ + ['省略', {}], + ['true', { mappingSaved: true }], + ['undefined', { mappingSaved: undefined }], + ])('mappingSaved が %s・記録が何も無い → 新しい支払い (署名 2 回・送信 1 回)', async (_label, overrides) => { + walletSigns(NEW, { block: 200n, ms: T0 }); + + const res = await press(T0, overrides); + + expect(res).toMatchObject({ userOpHash: NEW.userOpHash, txHash: NEW.txHash }); + expect(signatureRequests()).toBe(2); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + expect(stored('tab-2')?.status).toBe('confirmed'); + }); + }); +});