Skip to content

fix(pay): Circle の経路で、タブの対応を保存できない端末では新しい支払いを始めない - #1093

Merged
cipherwebllc merged 7 commits into
mainfrom
fix/r10-ss-circle-attempt-mapping-unavailable
Nov 13, 2026
Merged

cipherwebllc merged 7 commits into
mainfrom
fix/r10-ss-circle-attempt-mapping-unavailable

Conversation

@cipherwebllc

@cipherwebllc cipherwebllc commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

第 10 回レビューの後追い「r10-ss」(money-path)。PR 1091 の Codex 4 回目の P2 2 を「残る限界」として受け入れたときの、後続の PR です (user 裁定 2026-11-13「指摘 2 は A+B」)。設計は Fable、計画レビューは Opus (GO-WITH-CHANGES・反映済み)。

merge は user の明示の承認を待ちます (掟 15)。この PR は公開文言を 1 つ足します (場面 (f) の文・ja / en)。文は ja / en とも user が承認済みです (2026-11-13・下の「公開文言」)。

この PR は Codex のレビューを 2 回受けて、止め方を 2 回直しています。下の説明は head ff7e072 (v3) のものです。

  • 1 回目 (head f87c4b6): executeCirclePayment の 前 で止めていた → 既にある支払いの復旧まで止まる (MUST P2)。→ v2: 新しい記録を予約する直前 で止める。
  • 2 回目 (head e186749): 止まった押下で「押せなくする flag」を立てていた → 後から復旧できるようになった支払いを、その画面から受け取れない (MUST P2)。→ v3: flag をやめ、場面の符号つきの error (f) にする。ボタンは押せるまま。

何が穴だったか

  • Circle の経路 (USDC ガスレス) は、「同じタブの続き」を sessionStorage の対応 (chain・sender・内容 → attemptId) で見分ける (H-V・穴 B)。
  • lib/circlePending.ts の stableAttemptId は、sessionStorage の getItem / setItem が throw すると、保存していない新しい UUID を黙って返していた (呼ぶ側は見分けられない)。コメントは「マッピングは defense-in-depth であり決済可否を左右させない」と言っていたが、H-V 以降、この対応は二重払いを防ぐ本体になっている。
  • その id のまま 新しい支払い を送ると、確認待ちで終わった後の押下が別の attemptId (別の key) になる。前の支払いが裏で確定して 90 秒を過ぎていれば、scan はそれを「正規のリピート」とみなし、2 本目の署名を求める。
  • localStorage を読めない端末は、もともと送る前に止まる (hook の最初の確認)。未対策だったのは sessionStorage だけが使えない端末。

変えたこと

file 変更
lib/circlePending.ts stableAttemptId の戻り値を { attemptId: string; saved: boolean } に。既にある値を読めたら、それと saved: true (書かない)。無ければ新しい UUID を書き、読み戻して同じ値のときだけ saved: true。getItem / setItem の throw・読み戻しの不一致は、保存していない新しい UUID と saved: false。そのとき logger.warn('circle.attempt_mapping.unavailable', { chainId, cause }) (cause は 'read' | 'write' | 'readback')。warn は try / catch で包んだ (診断の log の失敗が、既にある支払いの復旧と、新しい支払いを止める処理を巻き込まないため)。UUID の採番は try の外。setStableAttemptId・clearStableAttemptId は変えていない
lib/smartAccount/circleSend.ts executeCirclePayment の引数に mappingSaved?: boolean (省略 = true)。false のとき、scan の loop を抜けた後・reserveOrResume の直前で、新しい class CircleAttemptMappingUnavailableError を投げる (name 同名・circleCode: 'mapping_unavailable'・message は (f) の ja の文)。足したのは、引数 1 つ・class 1 つ・文 1 つ・throw 1 か所。scan の中の動き・ほかの符号・ほかの文は変えていない。冒頭の説明に「対応を保存できていない押下」の段落を足し、(a)・(e) の注記を事実に合わせた
lib/circleErrorKey.ts 符号 mapping_unavailable → key mappingUnavailable を足した (6 つ目の場面)。関数は変えていない
messages/ja.json・messages/en.json namespace CircleErrors に mappingUnavailable を足した (承認済み・2026-11-13)
hooks/useBatchPayment.ts runCirclePayment が saved を mappingSaved として渡す (明示の paymentAttemptId の押下には true を渡す)。adoptAttempt (scan の付け替え) は、saved が false の押下では、書けても false を返す (written && saved。書き込み自体は今までどおり試し、「対応の指す先」を替えるのも今までどおり書けたとき)。catch の「対応を消さない name」の並びに CircleAttemptMappingUnavailableError を足した。flag (pendingStoreUnavailable) は立てない・error を替えない (v1・v2 で足した setter の受け渡しは消した)。hook に logger は足していない

止める所より前に、新しい記録の書き込み・署名・送信は無い (reserveOrResume が最初の書き込み)。saved: false の attemptId は押すたびに変わるので、その key に記録があることは無く、前の押下の記録は scan が「別の key の記録」として拾う。

3 フォーム・lib/paymentFlowGuards は変えていない。フォームは、PR 1091 の経路 (circleErrorKey) で、符号のある error を CircleErrors の文にして赤い枠に出す。

公開文言 (user が承認済み・2026-11-13)

文
ja CircleErrors.mappingUnavailable この端末では支払いの記録を保存できないため、USDC ガスレスの新しい支払いを始めていません (この押下では送金していません)。同じ内容の支払いを以前に送っていた場合は、先にウォレットの履歴で確かめてください。新しく支払うには、ブラウザの設定 (サイトのデータの保存) を確かめるか、別のブラウザでお試しください。
en CircleErrors.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.
  • 「次に押すと◯◯します」とは書いていない (次の押下が何をするかは、その時点の記録で変わる)。
  • 同じ ja の文が 3 か所にある (messages/ja.json・circleSend.ts の CIRCLE_ERROR_JA・tests/lib/circleErrorKey.test.ts)。文が変わったら 3 か所と en を一緒に替える (ずれは test が止める)。
  • 開示 3 点セット (LP・法務・llms.txt) には関係しない (手数料・チェーン・課金条件を変えていない)。

対応を保存できない押下の結果

その時点の記録 main この PR
直近の confirmed (90 秒以内) 既払いの結果を返す 同じ
submitting (最後に渡してから 30 分以内) 同じ op を復旧する 同じ
31 分より前の submitting で、取り込みが 30 分以内 前の結果を返す 同じ
submitting の復旧が確認待ちで終わる 確認待ち (付け替えを書けなければ (e)・書ければ (a)) 確認待ち (e) (付け替えを書けても (a) にしない)
確かめられない記録 / 直近の pre-submit 確認待ち (e) / (b) 同じ
記録なし 署名を求め、保存していない id で送る (localStorage の setItem も失敗する端末は、予約の書き込みの失敗の文で止まる) 署名を求めずに (f) の文
90 秒より前の confirmed だけ / 飛ばす submitting だけ 新しい支払いに署名を求める (2 本目) 署名を求めずに (f) の文
上の 2 行の後、押し直す (署名を断った後) その時点の記録で決まる 同じ (ボタンは押せるまま・押すたびに scan が走る)

見える動きの変化 (main との差・1 つずつ)

  1. sessionStorage を使えない端末で、新しい支払いを始めようとしたとき (getItem が throw・setItem が throw・setItem が黙って落とす・読み戻すと別の値。localStorage は正常)
    • 前: 署名を求め、保存していない attemptId で送っていた。
    • 後: 署名を求めずに止まり、赤い枠に (f) の文が出る。支払うボタンは押せるまま。既にある支払いの復旧・結果の受け取りは今までどおり。
  2. localStorage と sessionStorage の両方で setItem が失敗し、getItem は通る端末 (例: 古い Safari のプライベートモード)
    • 前: 記録の予約の書き込みの失敗 (PendingStoreWriteError) の文「pending record 書込失敗 (送信を中止): …」が赤い枠に出ていた (英語の画面でも日本語)。
    • 後: 予約の書き込みの前に止まり、(f) の文が出る (英語の画面では英語)。送らない点は前も後も同じ。
  3. 90 秒より前に確定した支払いの記録だけがある押下 (対応を保存した後に sessionStorage を読めなくなった端末で、送った後に画面を離れ、裏で確定した支払い。成功はどの画面にも出ていない)
    • 前: 新しい支払い (2 本目) に署名を求めていた。
    • 後: 署名を求めずに (f) の文。
  4. 対応を保存できていない押下が、既にある支払いを復旧して確認待ちで終わるとき (sessionStorage の getItem だけが throw し、setItem は通る端末)
    • 前: 付け替えの書き込みが通るので、場面 (a)「…24 時間以内にこのタブで同じ内容のままもう一度押すと、まず同じ支払いの結果を確かめ直します。…」が出ていた。その端末の次の押下は対応を読めないので、付け替えた記録の resume にならず、また scan から入る。
    • 後: 場面 (e) の既存の文が出る。復旧の進め方 (待つ・同じ op を送り直す) は変わらない。
  5. 履歴・log (ほかの Circle の error と同じ扱い・フォームは変えていない):
    • (f) で止まった押下は、ほかの Circle の error と同じ扱いで記録が残る (フォームを変えないので、そのまま)。payment log は押すたびに error が 1 件。フォームの error の log (Sentry の error) は押すたびに 1 件で、event の名前はフォームごとに違う (/pay = payment.failed・/checkout = checkout.failed・チップ = tip.failed)。この端末の履歴の error の行は、/pay と /checkout だけが書く (チップは履歴の hook を通らないので書かない)。同じ error が同じ秒に重なった分は 1 行にまとまり (履歴の id が同じになる)、localStorage に書けない端末では行は残らない。ボタンが押せるままなので、押した回数に応じて増える。 履歴の行の文は ja の固定文。
    • 対応を保存できなかった押下ごとに warn が 1 行出る (circle.attempt_mapping.unavailable・Sentry の warning)。入るのは chainId と cause だけ。止まらなかった押下 (scan が結果を返した・復旧した) でも出る。
  6. 型: stableAttemptId の戻り値 (string → { attemptId, saved }・本番の呼び出し元は hook の 1 か所)。ExecuteCirclePaymentArgs に mappingSaved (省略可)。CircleErrorCode・CircleErrorKey に 1 つずつ。export の class が 1 つ増える。

「この端末では記録を保存できません」の欄 (押せなくする flag) は、この PR では出さない。localStorage を読めない端末の既存の欄は今までどおり。

変わらないこと・触っていないこと

  • sessionStorage が正常な端末の動き (対応を保存して署名を求める)。
  • 既にある対応を読めた押下 (saved: true・確認待ちの続き)。その後に書けなくなった端末でも、読めれば自分の key を resume する (書き直さない)。
  • scan の中の動き: 直近の confirmed の結果を返す・submitting を復旧する・古い submitting の記録の照合・確かめられない記録と直近の pre-submit の確認待ち・古い pre-submit の abandon・入口の掃除。止まる押下でも、掃除・abandon・scan は走る (main と同じ)。
  • 対応の付け替え (adoptAttempt → setStableAttemptId) を呼ぶ条件・順番・書き込みと、書けなかったときの扱い。executeCirclePayment の規則 (hook の関数が true を返したら (a)・false なら (e)) も同じ。
  • 明示の paymentAttemptId の呼び出し (本番の 3 フォームには無い)。hook は mappingSaved に true を渡すので、止まらない (動きは今までどおり)。
  • sessionStorage は正常で、localStorage の書き込みだけが失敗する端末 (今までどおり PendingStoreWriteError の文が赤い枠に出る・test で残した)。
  • hook の最初の確認 (localStorage の読み取り) と、その flag・欄。Pimlico の経路・relay の経路・通常決済。
  • 3 フォーム・lib/paymentFlowGuards。(a)〜(e) の符号と文。

テスト

  • tests/lib/circlePending.test.ts (stableAttemptId): 無い → 書いて saved: true・setItem が throw / getItem が throw → saved: false と、押すたびに別の UUID・保存した後に getItem が throw → 保存した値とは別の UUID (保存した値は残る)・silent drop・読み戻すと別の値・読み戻しが throw・既にある値 → saved: true (setItem を呼ばない)・localStorage だけ壊れていても保存できる・warn の中身 (chainId と cause だけ)・warn が throw しても投げない (返す値は同じ)・crypto.randomUUID の失敗は saved: false にしない。
  • tests/lib/circleErrorKey.test.ts: 符号 (f) → key・本物の class (name・own property の符号・確認待ちではない)・CircleErrors の鍵が ja / en とも 6 つちょうど・ja の文が期待の文と完全に同じ・en に日本語が混ざらない・class の message = ja.json の文・(f) の文が次の押下の動きを約束しない。
  • tests/lib/smartAccount/circleSend.adoptAttempt.test.ts (executor の単体・mappingSaved: false): scan の枝は今までどおり (結果・復旧・(a)/(e)・(b))。予約の直前で止まる (記録なし・90 秒より前の confirmed・revert した confirmed・飛ばす submitting・古い pre-submit) → (f) の error・自分の key の記録なし・署名 0・送信 0・記録が増えない。止まった後に receipt が引けるようになると、次の押下が元の支払いの結果を返す。失効は payment_cancelled が先。省略・true・undefined → 今までどおり。
  • tests/hooks/useBatchPayment.circleStaleSubmitting.test.tsx (本物の executor・本物の storage):
    • Codex の 2 回目の順: 31 分より前の submitting (nonce 使用済み・receipt はまだ引けない)・sessionStorage の getItem が throw → 止まる ((f)・flag は false・署名なし・記録は書き換えない) → receipt が引けるようになる → 同じ画面でもう一度押すと、元の支払いの結果を受け取る (署名・送信なし)。
    • 取り込みも 31 分より前 (飛ばす記録) → 押すたびに (f) で止まり、2 本目を始めない。
  • tests/hooks/useBatchPayment.circleUnmount.test.tsx (本物の executor・本物の storage):
    • Codex の 1 回目の順 2 つ (submitting / 90 秒以内の confirmed → getItem だけ throw → 押す → 結果)。
    • 確認待ちで終わる形 2 つ (getItem だけ throw = (e)・読めも書けもしない = (e))。次の押下も scan から入り、bundler が戻れば結果。
    • 直近の pre-submit → (b)。
    • 記録の無い新しい支払い × 6 つの壊れ方 → error は CircleAttemptMappingUnavailableError (符号 mapping_unavailable・message は ja の文)・flag は false・hook は error を替えない・記録 0・署名 0・送信 0・removeItem 0 回・warn 1 行 (アドレスなし)。
    • 90 秒より前の confirmed だけがある (この PR が塞ぐ穴) → 2 本目を始めない。
    • 止まった後にもう一度押す (何も変わっていない) → また (f)。sessionStorage が戻ってから押すと、今までどおり署名を求める。
    • warn が throw しても、既にある支払いの結果を受け取れる・新しい支払いは (f) で止まる。
    • sessionStorage が正常 / 対応を保存できた後に書けなくなる / 明示の paymentAttemptId → 今までどおり。
  • フォーム: tests/_helpers/circleErrorCases.ts に (f) を足したので、3 フォーム (PaymentForm・CheckoutForm・TipForm) の「符号 → その場面の文 (ja)・英語の画面では英語の文」の表の test が (f) も見る。tests/components/PaymentForm.test.tsx に「Circle の経路・(f) の error → 赤い枠に CircleErrors の文 (ja / en)・「記録を保存できません」の欄は出ない・支払うボタンは押せる (押すと mutate を呼ぶ)」を足した。Circle の経路で既存の欄 (localStorage の flag) を見る test も足した (これまで無かった)。

既存の test を変えた所 (main との差)

所 変更 理由
tests/lib/circlePending.test.ts の「sessionStorage.setItem が throw する環境では毎回新規 UUID (throw せず fallback)」 「saved: false を返す」の test 群に置き換えた この PR が変える動き (保存できなかったことを呼ぶ側に返す)。返す id が毎回新しい UUID である点は同じで、新しい test でも確かめている
同 file の stableAttemptId( の呼び出し test 用の helper (savedAttemptId) に替えた 戻り値の型が変わった。期待は同じ
tests/lib/circleErrorKey.test.ts の「ja と en に 5 つの場面の文があり、余分な鍵が無い」 6 つに この PR が足す場面 (f)
同 file の符号・key・期待の文の表 (f) を 1 行ずつ足した 同上 ((f) の文は案と明記)
tests/_helpers/circleErrorCases.ts (f) を 1 行足した 3 フォームの表の test に (f) を通す
tests/hooks/useBatchPayment.circleUnmount.test.tsx の「新しい支払いで、記録の予約を書けない」 assert を 1 行足した (flag は false) localStorage の書き込みだけの失敗は今までどおり、を固定する
同 file の openScreen・circleStaleSubmitting の bundler の偽物 states を足した・「receipt をまだ返さない」の切り替えを足した 新しい test のため (既存の test の動きは同じ)

tests/lib/smartAccount/circleSend.*.test.ts・tests/hooks/useBatchPayment.circleAdopt.test.tsx・CheckoutForm / TipForm の test は、期待を変えていない。

変異 (commit の後に 1 つずつ入れ、関係する test を流し、git checkout で戻した・head ff7e072)

変異 結果
Q01 hook: 押せなくする flag を立てる形に戻す (v2) 落ちた (11 failed・Codex の 2 回目の順を含む)
Q02 hook: この name で対応を消す 落ちた (1 failed・removeItem が呼ばれる)
Q03 send: (f) の error から符号を外す 落ちた (19 failed)
Q04 lib: warn の try / catch を外す 落ちた (3 failed)
Q05 send: (f) の error の message を内部向けの固定文に戻す 落ちた (16 failed)
Q06 key: 符号 mapping_unavailable を key の表から外す (フォームが生の message を出す) 落ちた (9 failed・3 フォームの英語の画面の test を含む)
Q07 send: (f) の error の name を CirclePendingError にする 落ちた (16 failed)
N01 send: 止める所を scan の前に戻す 落ちた (19 failed・Codex の 1 回目の順を含む)
N02 send: 止める所を予約 (reserveOrResume) の後に動かす 落ちた (17 failed)
N03 send: mappingSaved を無視する (直す前の動き) 落ちた (17 failed)
N06 hook: mappingSaved を渡さない 落ちた (11 failed)
N10 hook: 止める所を hook の入口に戻す (v1) 落ちた (18 failed)
N13 lib: 保存できなくても saved: true を返す (直す前の動き) 落ちた (20 failed)
P01 hook: adoptAttempt が saved を見ずに written だけ返す 落ちた (1 failed)

変異の後、作業ツリーは clean (dirty=0)。この head で流し直していない変異 (lib の読み戻しを外す・既にある値でも書く・UUID の採番を try の中へ・warn に sender を入れる・warn を出さない・mappingSaved の省略を false として扱う・付け替えの書き込みを試さない など) は、head 7f3e0c5・e1867490 で落ちることを確かめてあり、その部分のコードは変えていない (warn を try / catch で包んだ以外)。

Q01 について: hook の test は mutate を直接呼ぶので (フォームの「押せない」を通らない)、flag の版でも 2 回目の押下自体は走る。Q01 が落ちるのは「止まった押下の error が (f) であること・flag が立っていないこと」の assert。「flag が立っていなければ・error が (f) なら、ボタンは押せる」は PaymentForm の test が見ている。

検証 (head ff7e072)

  • tsc --noEmit: exit 0
  • eslint (変えた 11 file・json を除く): exit 0
  • full vitest: Test Files 926 passed (926)・Tests 25621 passed (25621)・exit 0
  • node scripts/check-bundle-budget.mjs --build (next build を含む・本番 flag): exit 0・全 route が予算内

bundle (First Load JS・手元の本番 flag build・gzip の byte)

route main 5252859 v1 f87c4b6 v2 7f3e0c5 fix2 e186749 v3 ff7e072 main との差 Route 表 (main → v3) 予算
/pay 408,607 408,642 408,677 408,677 408,667 +60 409 → 409 kB 409
/checkout 432,770 432,805 432,840 432,840 432,830 +60 433 → 433 kB 433
/[handle] 447,596 447,631 447,666 447,666 447,656 +60 448 → 448 kB 448
/tip/[address] 400,881 400,916 400,951 400,951 400,941 +60 401 → 401 kB 402

Route 表は 4 行とも変わらない。予算の数字は変えていない。

残る点 (この PR で直さないもの)

  • (f) で止まった押下の記録: (f) で止まった押下は、ほかの Circle の error と同じ扱いで記録が残る (フォームを変えないので、そのまま)。payment log は押すたびに error が 1 件。フォームの error の log (Sentry の error) は押すたびに 1 件で、event の名前はフォームごとに違う (/pay = payment.failed・/checkout = checkout.failed・チップ = tip.failed)。この端末の履歴の error の行は、/pay と /checkout だけが書く (チップは履歴の hook を通らないので書かない)。同じ error が同じ秒に重なった分は 1 行にまとまり (履歴の id が同じになる)、localStorage に書けない端末では行は残らない。ボタンが押せるままなので、押した回数に応じて増える。
  • logger 自体は投げうるまま (Codex の SHOULD): この PR で包んだのは stableAttemptId の warn だけ。logger は支払いの経路のあちこちで、包まずに呼んでいる (例 circle.broadcast.response-lost)。直すなら logger 自体を投げない形にする (別件)。
  • 対応を保存できていない端末で、確認待ち (e) の後の押下: resume ではなく scan から入って同じ記録を復旧する。最後に渡してから 30 分以内は今までどおり復旧する。30 分より後は「古い submitting の記録」の照合を通り、取り込みから 30 分より後の記録は飛ばして、(f) になる (2 本目は始まらない)。(e) の文の「署名を求められた場合は新しい支払いになります」は、その端末では起きない (対応を保存できるようになった後の押下では起きうる)。
  • (f) の文は、前の支払いがあったかを見分けない: 記録なしでも、90 秒より前に確定した支払いの記録だけがあっても、同じ文。文は「以前に送っていた場合は、先にウォレットの履歴で確かめてください」と言う。
  • 読み戻しで見分けた形では、書いた値・別の値が sessionStorage に残る (hook は消しに行かない)。読めるようになった次の押下は、その値を保存済みの対応として使う (その key に記録は無いので、新しい支払いになる)。
  • 明示の paymentAttemptId の呼び出しは確かめない (本番の 3 フォームには無い)。
  • 埋め込み (iframe) のチップ: relay の経路だけにある「保存の確かめを飛ばす」例外は、この経路には無い。storage を拒む埋め込みは、もともと localStorage の確認で止まる。
  • 止まる前に、動的 import・ガス価格の RPC・(古い submitting の記録があれば) 照合の RPC はかかる (読むだけ・押すたび)。

確かめられなかったこと

  • 主要なブラウザで、sessionStorage の setItem が黙って落ちる (throw しない) ことが実際にあるか。sessionStorage だけを拒む・途中から読めなくなる設定があるか (ブラウザの挙動で、リポからは確かめられない)。読み戻しは、リポの既存の形 (writeRecord・canStoreRelayIntent) に合わせた。
  • 実機での表示 (jsdom の test だけ・実機のスクショは撮っていない)。フォームは変えておらず、(f) の文は既存の赤い枠 (PR 1091 の 5 場面と同じ出し方) に出る。
  • (f) の文の「ブラウザの設定 (サイトのデータの保存)」が、実際の端末で直せる案内になっているか。

user に見てほしい点

  1. (f) の文 (ja / en) の承認 (上の「公開文言」)。
  2. 止める位置 (lib/smartAccount/circleSend.ts の executeCirclePayment・scan の loop の後・reserveOrResume の前) と、止めた後の扱い (押せなくしない・符号つきの error・hook は対応を消さない)。
  3. 見える動きの変化の 2 (両方の setItem が失敗する端末)・3 (90 秒より前の confirmed だけがある押下)・4 (確認待ちが (a) から (e) へ)。
  4. 見える動きの変化の 5 (止まった押下が、payment log とフォームの error の log に押すたびに残り、/pay と /checkout では履歴にも残ること)。
  5. 新しい warn (circle.attempt_mapping.unavailable) が Sentry に warning として出ること (止まらなかった押下でも出る)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc

cipherwebllc and others added 2 commits November 13, 2026 21:28
Circle の経路 (USDC ガスレス) は、同じタブの続きを sessionStorage の対応 (chain・sender・内容から attemptId) で見分ける。
これまで lib/circlePending.ts の stableAttemptId は、sessionStorage の getItem や setItem が throw すると、
保存していない新しい UUID を黙って返し、hook はそれで送っていた。その端末で送った支払いが確認待ちで終わると、
次の押下は別の attemptId になり、裏で確定した前の支払い (90 秒より前の confirmed) の後に 2 本目の署名を求める。
確認待ちの文の「このタブでもう一度押すと、まず同じ支払いの結果を確かめ直します」も成り立たなかった。

stableAttemptId は、保存できた attemptId だけを返すようにした (戻り値は string か null)。
既にある値を読めたらそれを返し、書かない。無ければ新しい UUID を書き、読み戻して同じ値のときだけ返す。
getItem か setItem の throw、読み戻しの不一致は null で、warn を 1 行出す (chainId と、どこで失敗したかだけ)。
UUID の採番は try の外に出した (storage と関係ない失敗を null にしない)。

hooks/useBatchPayment.ts の runCirclePayment は、null なら executeCirclePayment を呼ぶ前に止める。
古い記録の掃除、scan、記録の予約、署名、送信のどれも始まっていない。既存の flag (pendingStoreUnavailable) を立て、
PimlicoPendingStoreUnavailableError を投げる。画面には、localStorage を読めない端末と同じ
「この端末では記録を保存できません」の欄が出る。フォーム、lib/paymentFlowGuards、messages は変えていない。

見える動きが変わるのは 2 つ。sessionStorage だけが使えない端末は、署名を求められずに上の欄が出る。
localStorage と sessionStorage の両方で setItem が失敗し getItem は通る端末は、記録の予約の書き込みの失敗の文
(赤い枠) の代わりに上の欄が出る。どちらも送らない。

既にある対応を読めた押下 (確認待ちの続き) と、明示の paymentAttemptId の呼び出しは、今までどおり。
対応の付け替えの書き込みの失敗の扱いも変えていない。lib/smartAccount/circleSend.ts はコメントだけを事実に合わせた。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
止める所は executeCirclePayment を包む try の外にある (try の catch は対応を破棄しに行く)。
止まった押下の間に sessionStorage と localStorage の removeItem が 1 回も呼ばれないことを、hook の test で見る。
止める所を try の中へ動かす変異が、これで落ちる。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
openpay Ignored Ignored Preview Nov 13, 2026 2:32pm UTC

Request Review

cipherwebllc and others added 3 commits November 13, 2026 22:03
前の commit は、タブの対応 (sessionStorage) を保存できない押下を executeCirclePayment の前で止めていた。
それだと、既にある支払いの復旧まで止まる。送った支払いが確認待ち (記録は submitting) や、裏で確定した直後
(confirmed・90 秒以内) のときに sessionStorage の getItem が throw するようになると、main は scan が記録を拾って
結果を返すのに、前の commit は欄を出して止まり、取り出せる結果を失っていた。

止める所を、executeCirclePayment の中の「scan の loop を抜けた後、reserveOrResume の前」に移した。
stableAttemptId は attemptId と saved を返す。保存できなければ、保存していない新しい UUID と saved の false を返し、
warn を 1 行出す (前の commit と同じ見分け方)。hook は saved を mappingSaved として executeCirclePayment に渡す。
false のとき executeCirclePayment は、新しい記録を予約する直前で CircleAttemptMappingUnavailableError を投げる。
hook はその name を受けて既存の flag を立て、PimlicoPendingStoreUnavailableError を投げる。対応は破棄しない。

scan の中の動きは変えていない。直近の confirmed の結果を返す、submitting を復旧する、確かめられない記録と
直近の pre-submit の確認待ち、古い pre-submit の abandon、入口の掃除は、対応を保存できていない押下でも今までどおり。
付け替えも今までどおり呼び、hook が書けなければ確認待ちは (e) になる。
mappingSaved を省略した呼び出しと true の呼び出し (明示の paymentAttemptId を含む) は今までどおり。

新しい支払いを作る所まで来た押下は、署名を求めずに「この端末では記録を保存できません」の欄になる。
90 秒より前に確定した支払いの記録だけがある押下も同じ (これまでは 2 本目の署名を求めていた)。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
sessionStorage の getItem だけが throw し setItem は通る端末では、scan の付け替えの書き込みは通る。
前の commit は、その復旧が確認待ちで終わると場面 (a) を出していた。(a) は「24 時間以内にこのタブで同じ内容のまま
もう一度押すと、まず同じ支払いの結果を確かめ直します」と言うが、その端末の次の押下は対応を読めないので、
付け替えた記録の resume にならず、また scan から入る。最後に渡してから 30 分より後は、この約束が成り立たない。

hook の adoptAttempt は、最初の対応を保存できていない押下 (saved が false) では、書けても false を返すようにした。
executeCirclePayment は今までの規則どおり (e) を出す。書き込み自体は今までどおり試す
(後で読めるようになった端末では、対応がその記録を指している方がよい)。この試みの対応の指す先を替えるのも、
今までどおり書けたときだけ。executeCirclePayment のコードは変えていない (コメントだけ)。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
最初の対応の読み戻しだけが throw する押下 (saved は false・その後は読める) が、別のタブの確定した支払いの結果を
受け取る筋を hook の test に足した。付け替えを書けたら、この試みの対応の指す先は付け替えた先になり、
結果を受け取った所で対応が消える (今までどおり)。hook が circleSend に返す値だけが false になる。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
@cipherwebllc cipherwebllc changed the title fix(pay): Circle の経路で、タブの対応を保存できない端末では送る前に止める fix(pay): Circle の経路で、タブの対応を保存できない端末では新しい支払いを始めない Nov 13, 2026
cipherwebllc and others added 2 commits November 13, 2026 22:56
前の commit は、新しい記録を予約する直前で止まった押下で pendingStoreUnavailable の flag を立てていた。
flag が立つとフォームは送信を閉じるので、止まった後に復旧できるようになった支払いを、その画面から受け取れなかった。
例は、最後に渡してから 30 分より後の submitting の記録で、nonce は使用済みだが bundler がまだ receipt を返さないもの。
scan はその記録を飛ばすので止まるが、receipt が引けるようになれば、次の押下の scan が元の支払いの結果を返す。
main は押せば受け取れるので、flag の版は main より悪かった。

flag と PimlicoPendingStoreUnavailableError への変換をやめた (setter の受け渡しも消した)。
CircleAttemptMappingUnavailableError に場面の符号 mapping_unavailable を持たせ、6 つ目の場面 (f) として、
3 つのフォームが赤い枠に CircleErrors の文を出す (lib/circleErrorKey.ts に符号と key を足した。フォームは変えていない)。
送信は閉じない。押すたびに scan が走り、復旧できれば結果を受け取り、できなければ、また新しい支払いの直前で止まる。
hook は、この name では対応を消さない (CirclePendingError などと同じ並びに足した)。
mappingSaved の受け渡しと、付け替えの戻り値 (書けて、かつ保存できているときだけ true) は前の commit のまま。

(f) の文 (messages の CircleErrors.mappingUnavailable の ja と en、circleSend.ts の同じ文) は案で、承認待ち。

stableAttemptId の中の logger.warn を try と catch で包んだ。診断の log の失敗が、既にある支払いの復旧と、
新しい支払いを止める処理を巻き込まないようにするため。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
場面 (f) の文 (ja と en) は user が承認した (2026-11-13)。
circleSend.ts と circleErrorKey.test.ts のコメントの「案・承認待ち」を直した。コードと test の期待は変えていない。

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc
@cipherwebllc
cipherwebllc merged commit 8c1a317 into main Nov 13, 2026
8 checks passed
@cipherwebllc
cipherwebllc deleted the fix/r10-ss-circle-attempt-mapping-unavailable branch November 13, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant