Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions hooks/useBatchPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 を使う別の
Expand All @@ -1144,6 +1160,7 @@ async function runCirclePayment(args: {
permitAmount,
paymentAttemptId: attemptId,
isAlive,
mappingSaved: saved,
// 対応の付け替え (第 10 回レビュー 穴 B・条件は circleSend.ts の冒頭の「対応の付け替え」)。scan が別の key の記録
// (別のタブ・閉じたタブの支払い) の結果を受け取りに行く押下は、自分の attemptId の記録を作らない。そのまま画面を離れる・
// 確認待ちで終わると、対応は記録の無い attemptId を指し、次の押下が、後から確定したその支払いを「正規のリピート」と
Expand All @@ -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,
});
Expand All @@ -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);
}
Expand Down
12 changes: 8 additions & 4 deletions lib/circleErrorKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,34 @@
// 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 =
| 'unconfirmed'
| 'busyElsewhere'
| 'attemptEnded'
| 'sendRejected'
| 'unconfirmedEarlier';
| 'unconfirmedEarlier'
| 'mappingUnavailable';

const KEY_BY_CODE: Record<CircleErrorCode, CircleErrorKey> = {
unconfirmed: 'unconfirmed',
busy_elsewhere: 'busyElsewhere',
attempt_ended: 'attemptEnded',
send_rejected: 'sendRejected',
unconfirmed_earlier: 'unconfirmedEarlier',
mapping_unavailable: 'mappingUnavailable',
};
// Map で引く (object を直接引くと、'toString' のような継承した名前が key として返る)。
const KEYS = new Map<unknown, CircleErrorKey>(Object.entries(KEY_BY_CODE));
Expand Down
52 changes: 43 additions & 9 deletions lib/circlePending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 を採番する。
Expand Down
Loading
Loading