From 2c455709885c541ffdc3490b6374c38a5b6e8b7e Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 09:32:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(order):=20=E4=BA=BA=E9=96=93=E3=81=AE?= =?UTF-8?q?=E3=83=A2=E3=83=90=E3=82=A4=E3=83=AB=E6=B3=A8=E6=96=87=20checko?= =?UTF-8?q?ut=20=E3=81=AE=E6=94=AF=E6=89=95=E3=81=84=E3=82=92=E6=B3=A8?= =?UTF-8?q?=E6=96=87=E3=81=AB=E6=9D=9F=E7=B8=9B=E3=81=97=E5=85=AC=E9=96=8B?= =?UTF-8?q?=20notify=20=E3=81=AE=E6=A8=AA=E5=8F=96=E3=82=8A=E3=82=92?= =?UTF-8?q?=E5=A1=9E=E3=81=90=20(A2c=E3=83=BB=E6=8E=9F=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 6 回レビュー A2 の人間の checkout 側 (P1)。user 承認済みの方針 (relay/recover は intentSalt で束縛・ 標準 ERC-20 は残余として受容) と設計 v0.2。intentSalt を注文 digest とブラウザだけが持つ秘密の hash にし、 notify で秘密を添えて on-chain の Settled/AuthorizationUsed の nonce と照合する。 未送信の注文記録は署名後・broadcast 前に保存し、通知の受理で消す。 flag ENABLE_ORDER_BIND_ENFORCE (server・既定 OFF) は束縛の欠落を注意表示で受け、ON で拒否。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- .env.local.example | 4 + README.md | 1 + app/api/order/notify/route.ts | 162 ++++--- components/CheckoutForm.tsx | 250 +++++++++- components/OrderBindingNotice.tsx | 17 + components/OrderCard.tsx | 2 + components/OrderFeedPanel.tsx | 3 + components/OrderFulfillmentBoard.tsx | 3 + hooks/useJpycEip3009Payment.ts | 303 ++++++++++-- i18n/clientNamespaces.ts | 3 + lib/order/agentOrderReceipt.ts | 32 +- lib/order/orderBindingVerify.ts | 93 ++++ lib/orderBind.ts | 84 ++++ lib/orderDelivery.ts | 162 +++++++ lib/orderDeliveryContext.ts | 31 ++ lib/orderDeliveryRecovery.ts | 78 ++++ lib/orderRelay.ts | 4 + lib/relay/relayIntentRecovery.ts | 9 +- messages/en.json | 14 +- messages/ja.json | 14 +- tests/_helpers/orderBinding.ts | 30 ++ tests/app/api/order-feed.test.ts | 8 + tests/app/api/order-notify.test.ts | 160 ++++++- tests/components/CheckoutForm.test.tsx | 442 +++++++++++++++++- tests/components/OrderFeedPanel.test.tsx | 9 + .../components/OrderFulfillmentBoard.test.tsx | 8 + tests/components/OrderPickupMonitor.test.tsx | 9 + tests/components/PaymentForm.test.tsx | 28 ++ tests/components/TipForm.test.tsx | 25 + tests/hooks/useJpycEip3009Payment.test.tsx | 365 +++++++++++++++ .../lib/order/agentOrderFinalize-lua.test.ts | 6 +- tests/lib/orderBind.test.ts | 57 +++ tests/lib/orderBindingVerify.test.ts | 62 +++ tests/lib/orderDelivery.test.ts | 105 +++++ tests/lib/orderDeliveryContext.test.ts | 32 ++ tests/lib/orderDeliveryRecovery.test.ts | 98 ++++ 36 files changed, 2573 insertions(+), 140 deletions(-) create mode 100644 components/OrderBindingNotice.tsx create mode 100644 lib/order/orderBindingVerify.ts create mode 100644 lib/orderBind.ts create mode 100644 lib/orderDelivery.ts create mode 100644 lib/orderDeliveryContext.ts create mode 100644 lib/orderDeliveryRecovery.ts create mode 100644 tests/_helpers/orderBinding.ts create mode 100644 tests/lib/orderBind.test.ts create mode 100644 tests/lib/orderBindingVerify.test.ts create mode 100644 tests/lib/orderDelivery.test.ts create mode 100644 tests/lib/orderDeliveryContext.test.ts create mode 100644 tests/lib/orderDeliveryRecovery.test.ts diff --git a/.env.local.example b/.env.local.example index d7c728be..90eecc99 100644 --- a/.env.local.example +++ b/.env.local.example @@ -863,3 +863,7 @@ PIMLICO_PAYMASTER_KAIA_V08= ALERT_THRESHOLD_POL_V08= ALERT_THRESHOLD_ETH_V08= ALERT_THRESHOLD_KAIA_V08= + +# Server-only order binding rollout: OFF validates supplied bindings and warns on absent relay bindings. +# ON requires relay/recover binding. Standard ERC-20 and non-relay ERC-4337/7702 gasless payments remain unbound. Operator approval required. +ENABLE_ORDER_BIND_ENFORCE=false diff --git a/README.md b/README.md index fe3a9a4a..37fdce47 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The table below is a **curated subset** (core setup + production feature flags). | `NEXT_PUBLIC_ENABLE_MOBILE_ORDER` | Mobile-order storefront feature (`@handle` shop page / `/order`) — **live on mainnet** (production sets `1`); code default **off**. | optional | | `NEXT_PUBLIC_ENABLE_SHOPS_API` | JPYC Shops API and the explicit shop-owner opt-in that maintains its search index/summary. Default **off**; API routes additionally require the x402 facilitator, order relay, and server-only `ENABLE_AGENT_ORDER` flags so every returned `menuUrl` remains reachable. | optional | | `NEXT_PUBLIC_ENABLE_ORDER_RELAY` | Order relay — delivers the paid order (items + table + settled amount) to the merchant via KV + on-chain verification. Needed for mobile ordering to be usable; requires KV configured. **Live on mainnet** (production sets `1`); code default **off**. | optional | +| `ENABLE_ORDER_BIND_ENFORCE` | Server-only, default OFF. OFF verifies any supplied order binding and rejects malformed bindings; relay orders with no binding carry a manual-verification warning. ON requires relay/recover binding. Standard ERC-20 transfers and non-relay ERC-4337/7702 gasless payments remain unbound (accepted residual). Enable only after explicit operator approval. `bindingConflict` detection covers only orders still in the capped order list; if the unbound winning order has been trimmed, a later verified notification can receive a plain duplicate acknowledgement. | optional | | `NEXT_PUBLIC_ENABLE_ORDER_MEMO` | Optional customer order memo (allergies / requests), passed through same-tab `sessionStorage` to the same-origin order notify only; it is never added to the checkout URL or payment history. Structurally gated by `…ENABLE_ORDER_RELAY`. **Live on mainnet** (production sets `1`); code default **off**. | optional | | `NEXT_PUBLIC_ENABLE_ORDER_CALL` | Dine-in staff call: shown only on an `@handle` page with a table number and this device's matching paid receipt from the last 2 hours. The server binds `orderId` + `txHash` + table to the merchant order list and atomically enforces per-order, per-handle, and table cooldown limits. Merchant boards poll it separately; it is deliberately excluded from Web Push. Structurally gated by `…ENABLE_ORDER_RELAY`; production light-up should also set `IP_HASH_SECRET`. **Live on mainnet** (production sets `1`); code default **off**. | optional | | `NEXT_PUBLIC_ENABLE_SHOP_LIVE` | Live shop status for `@handle` storefronts — per-item sold-out / accepting-orders toggles, recommended items, and a register category filter & image toggle. High-frequency `shop:live:` KV record (advisory; on-chain stays authoritative). Requires KV + `…ENABLE_HANDLES` + `…ENABLE_MOBILE_ORDER`. Default **off**. | optional | diff --git a/app/api/order/notify/route.ts b/app/api/order/notify/route.ts index d6ac3844..94521c01 100644 --- a/app/api/order/notify/route.ts +++ b/app/api/order/notify/route.ts @@ -4,6 +4,7 @@ // (3) on-chain 検証 (from 非依存・to=merchant への実着金)、(4) txHash 冪等 (1 決済 1 注文) で守る。 // 受注は **advisory**: 実着金額を権威保存・商品/テーブルは顧客申告。flag OFF は 404 (本番 inert)。 // 設計: plans/swift-puzzling-sky.md。 +import { receiptHasRelayEvidence, verifyOrderBinding } from '@/lib/order/orderBindingVerify'; import { recordMetric } from '@/lib/metrics'; import { NextResponse, after } from 'next/server'; import { createPublicClient, getAddress, isAddress, type Address, type Hex } from 'viem'; @@ -170,7 +171,8 @@ async function reconcileCollectedStandardFee(args: { // 保存時に server が storefront 設定 + on-chain merchant leg から確定した obligation を使う。 // 後日の mode/feePayer/flag 変更で既存未収が減額・回収不能へ波及するのを断つ。旧/壊れレコードで // expected が無い場合は badge を消さず安全側 no-op(body や現在設定から推測して補完しない)。 - if (!storedOrder.feeExpectedAmount) return; + // Bound relay orders cannot borrow a later standard fee pair; preserve their verified commitment. + if (!storedOrder.feeExpectedAmount || storedOrder.bindingDigest) return; const merchantValue = BigInt(storedOrder.amount); const expectedFee = BigInt(storedOrder.feeExpectedAmount); const alternateFee = storedOrder.feeExpectedAmountAlt @@ -215,7 +217,12 @@ async function reconcileCollectedStandardFee(args: { } return; } - if (args.checkReservation && await receiptHasAgentReservation(args.chainId, args.token, result.receiptLogs) !== 'clear') return; + if (args.checkReservation) { + // Classify inside after(): relay evidence cannot borrow a standard fee pair, and RPC + // latency/failure must not change the historical duplicate/processing response. + if (receiptHasRelayEvidence(args.chainId, args.token, result.receiptLogs)) return; + if (await receiptHasAgentReservation(args.chainId, args.token, result.receiptLogs) !== 'clear') return; + } verifiedObligation = { merchantAmount: storedOrder.amount, feeAmount: storedOrder.feeExpectedAmount, @@ -383,6 +390,7 @@ export async function POST(req: Request): Promise { // ステージ1 = pending クレーム (短 TTL)。検証中に maxDuration タイムアウトで done 昇格前に強制終了しても // pending は ORDER_PENDING_TTL_SEC で自然失効する → 正規注文が最大 72h ロックされ消失する事故 (P1-F) を断つ。 const usedKey = orderUsedKey(chainId, txHash); + let existingMarker: 'done' | 'pending' | null = null; if (isKvConfigured()) { const claim = await kvSet(usedKey, ORDER_MARK_PENDING, { nx: true, @@ -393,18 +401,20 @@ export async function POST(req: Request): Promise { // nx 失敗 = 既存マーカーあり。done (恒久) と pending (検証中) を読み分ける。 const existing = await kvGet(usedKey); if (!existing.ok) return fail('kv_error', 503); - // done = 検証 + 保存済の恒久ブロック → 同一 txHash の無期限リプレイを永久拒否 (P1-E)。1 決済 1 注文。 - if (existing.value === ORDER_MARK_DONE) { - queueFeeReconciliation(false, true); - return NextResponse.json({ ok: true, duplicate: true }); + existingMarker = existing.value === ORDER_MARK_DONE ? 'done' : 'pending'; + // Bind-less duplicates retain their historical immediate response under either rollout flag. + // The after() task verifies reservation and relay evidence before any standard fee claim. + if (!Object.hasOwn(o, 'bind')) { + queueFeeReconciliation(existingMarker === 'pending', true); + return existingMarker === 'done' + ? NextResponse.json({ ok: true, duplicate: true }) : fail('processing', 409); } - // pending 中 (または直前に失効/解放) = **まだ done でない** → 検証成功前の**偽 duplicate を返さない** (P2)。 - // 別 POST が検証中/リトライ可能な「処理中」を表す (client は pending 失効後に同一 txHash を再送可能)。 - queueFeeReconciliation(true, true); - return fail('processing', 409); } } + // A rejected replay must not delete another request's pending/done marker. + const releaseClaim = async () => { if (!existingMarker) await kvDel(usedKey); }; + // 受注保存の確定フラグ (kvLpush 成功で true)。catch の解放判定に使う: 保存確定後は pending/done を // 消さない (done 昇格済を消すと同一 tx が二重注文になり得るため・下記 catch 参照)。 let orderStored = false; @@ -428,63 +438,94 @@ export async function POST(req: Request): Promise { if (!result.ok) { // 検証不成立 → クレーム解放。rpc_error は retryable(503)・それ以外は顧客起因(422)。 - await kvDel(usedKey); + await releaseClaim(); logger.warn('order.notify.verify_failed', { reason: result.reason, chainId, merchant }); return fail(result.reason, result.reason === 'rpc_error' ? 503 : 422); } - // optional な blockNumber の欠落で getBlock が latest を返し、過去の着金が鮮度検査を - // 素通りする波及を断つ。receipt の block が特定できるまで再試行可能な検証失敗にする。 - if (result.blockNumber === undefined) { - await kvDel(usedKey); - logger.warn('order.notify.verify_failed', { reason: 'rpc_error', chainId, merchant }); - return fail('rpc_error', 503); - } + if (!existingMarker) { + // optional な blockNumber の欠落で getBlock が latest を返し、過去の着金が鮮度検査を + // 素通りする波及を断つ。receipt の block が特定できるまで再試行可能な検証失敗にする。 + if (result.blockNumber === undefined) { + await releaseClaim(); + logger.warn('order.notify.verify_failed', { reason: 'rpc_error', chainId, merchant }); + return fail('rpc_error', 503); + } - let blockTimestamp: bigint; - try { - const block = await publicClient.getBlock({ blockNumber: result.blockNumber }); - blockTimestamp = block.timestamp; - } catch { - // block RPC 障害が未検証の受理や pending claim の居座りへ波及しないよう、既存の再試行契約へ戻す。 - await kvDel(usedKey); - logger.warn('order.notify.verify_failed', { reason: 'rpc_error', chainId, merchant }); - return fail('rpc_error', 503); - } - const paymentAgeMs = BigInt(Date.now()) - blockTimestamp * 1000n; - // block が server 時計より大きく未来 = server 側の時計ずれ。本物の着金を恒久拒否 (422) へ - // 波及させないよう、再試行可能な検証失敗に倒す。 - if (paymentAgeMs < -ORDER_PAYMENT_FUTURE_TOLERANCE_MS) { - await kvDel(usedKey); - logger.warn('order.notify.verify_failed', { - reason: 'clock_skew', chainId, merchant, ageSec: Number(paymentAgeMs) / 1000, - }); - return fail('rpc_error', 503); - } - if (paymentAgeMs > ORDER_PAYMENT_MAX_AGE_MS) { - await kvDel(usedKey); - logger.warn('order.notify.verify_failed', { - reason: 'tx_too_old', chainId, merchant, ageSec: Number(paymentAgeMs) / 1000, - }); - return fail('tx_too_old', 422); + let blockTimestamp: bigint; + try { + const block = await publicClient.getBlock({ blockNumber: result.blockNumber }); + blockTimestamp = block.timestamp; + } catch { + // block RPC 障害が未検証の受理や pending claim の居座りへ波及しないよう、既存の再試行契約へ戻す。 + await releaseClaim(); + logger.warn('order.notify.verify_failed', { reason: 'rpc_error', chainId, merchant }); + return fail('rpc_error', 503); + } + const paymentAgeMs = BigInt(Date.now()) - blockTimestamp * 1000n; + // block が server 時計より大きく未来 = server 側の時計ずれ。本物の着金を恒久拒否 (422) へ + // 波及させないよう、再試行可能な検証失敗に倒す。 + if (paymentAgeMs < -ORDER_PAYMENT_FUTURE_TOLERANCE_MS) { + await releaseClaim(); + logger.warn('order.notify.verify_failed', { + reason: 'clock_skew', chainId, merchant, ageSec: Number(paymentAgeMs) / 1000, + }); + return fail('rpc_error', 503); + } + if (paymentAgeMs > ORDER_PAYMENT_MAX_AGE_MS) { + await releaseClaim(); + logger.warn('order.notify.verify_failed', { + reason: 'tx_too_old', chainId, merchant, ageSec: Number(paymentAgeMs) / 1000, + }); + return fail('tx_too_old', 422); + } } // Reuse the successful transfer verification's receipt, after A2a freshness checks. const reservation = await receiptHasAgentReservation(chainId, deployment.address, result.receiptLogs); if (reservation !== 'clear') { - await kvDel(usedKey); + await releaseClaim(); return fail(reservation === 'reserved' ? 'reserved_order' : 'storage_unavailable', reservation === 'reserved' ? 409 : 503); } - const orderId = + const binding = verifyOrderBinding({ + body: o, handle, chainId, token: deployment.address, merchant, logs: result.receiptLogs, + ...(env.feeReceiverConfigured ? { feeReceiver: getAddress(env.feeReceiver) } : {}), + }); + if (!binding.ok) { + await releaseClaim(); + return fail('order_binding_mismatch', 422); + } + if (existingMarker) { + if (existingMarker === 'done' && binding.order) { + // OFF's conflict warning is limited to the capped list; a trimmed unbound winner is undetectable here. + const list = await kvLrange(orderListKey(merchant), 0, ORDER_LIST_MAX - 1); + if (!list.ok) return fail('kv_error', 503); + const conflict = (list.value ?? []).some((raw) => { + const stored = parseStoredOrder(raw); + return stored?.chainId === chainId && stored.txHash.toLowerCase() === txHash.toLowerCase() && stored.bindingMissing; + }); + // OFF is observation only: never acknowledge the victim's opening as a registered order + // when an unbound notification already won the transaction claim. + if (conflict) return NextResponse.json({ ok: true, duplicate: true, bindingConflict: true }); + } + // Only standard merchant/fee pairs are eligible; recover inline fees come from Settled. + if (binding.kind === 'standard') queueFeeReconciliation(existingMarker === 'pending', true); + return existingMarker === 'done' + ? NextResponse.json({ ok: true, duplicate: true }) : fail('processing', 409); + } + const receiptValue = binding.value ?? result.value; + const snapshot = binding.order; + + const orderId = snapshot?.orderId ?? ( typeof o.orderId === 'string' && o.orderId.length > 0 ? o.orderId.slice(0, ORDER_ID_MAX) - : txHash; // orderId 無し時は txHash で代替 (一意) - const items = sanitizeOrderItems(o.items); + : txHash); // orderId 無し時は txHash で代替 (一意) + const items = snapshot?.items ?? sanitizeOrderItems(o.items); const declaredMinor = declaredItemsTotalMinor(items, deployment.decimals); const amountAdvisory = evaluateOrderAmount( declaredMinor, - result.value, + receiptValue, relayGasFeeValue(chainId), AMOUNT_ADVISORY_BPS_CAP, ); @@ -492,8 +533,8 @@ export async function POST(req: Request): Promise { const order: StoredOrder = { orderId, items, - table: sanitizeTable(o.description), // checkout description = テーブル番号ラベル (店内のみ) - amount: result.value.toString(), // **実着金 (権威)** — recover は total−fee, free は total + table: snapshot ? snapshot.description || null : sanitizeTable(o.description), // checkout description = テーブル番号ラベル (店内のみ) + amount: receiptValue.toString(), // **実着金 (権威)** — recover は total−fee, free は total txHash, chainId, // **オンチェーン検証していない顧客申告値** (feeVerify は from を返さない・表示専用)。形式のみ検証。P1-D @@ -501,11 +542,13 @@ export async function POST(req: Request): Promise { ts: Date.now(), fulfilled: false, }; + if (binding.bindingMissing) order.bindingMissing = true; + if (binding.digest) order.bindingDigest = binding.digest; if (amountAdvisory.mismatch) order.amountMismatch = true; if (amountAdvisory.unchecked) order.amountUnchecked = true; const feeObligation = standardFeeObligationFromReceipt({ - receiptValue: result.value, - sameSourceFeeValue: result.sameSourceFeeValue, + receiptValue, + sameSourceFeeValue: binding.sameSourceFeeValue ?? result.sameSourceFeeValue, config: feeConfig, }); // 店舗送金確定後の fee leg 失敗が受注欠落へ波及しないよう未収状態を additive に記録する。 @@ -520,14 +563,15 @@ export async function POST(req: Request): Promise { // 受取予定時刻 (任意・preorder・顧客申告=advisory 表示用・items/table と同じ寛容さ)。正の有限数かつ // **near-future 窓内** (now-1h 〜 now+14d) のみ保存。スロット/lastOrder との厳密照合はしない (advisory) // が、年 9999 等の極端値で受注ボードの表示を汚さないよう sane 窓外は drop する (clock skew に -1h)。 - if (typeof o.pickupAt === 'number' && Number.isFinite(o.pickupAt)) { - const at = Math.floor(o.pickupAt); + const pickupAt = snapshot?.pickupAt ?? o.pickupAt; + if (typeof pickupAt === 'number' && Number.isFinite(pickupAt)) { + const at = Math.floor(pickupAt); const nowMs = Date.now(); if (at > nowMs - 60 * 60 * 1000 && at < nowMs + 14 * 24 * 60 * 60 * 1000) { order.pickupAt = at; } } - const customerMemo = sanitizeOrderMemo(o.customerMemo); + const customerMemo = snapshot?.customerMemo ?? sanitizeOrderMemo(o.customerMemo); if (customerMemo) order.customerMemo = customerMemo; if (isKvConfigured()) { @@ -558,7 +602,7 @@ export async function POST(req: Request): Promise { ) : await kvLpush(key, serializeOrder(order)); if (!save.ok) { - await kvDel(usedKey); // 保存できなければ pending クレームも戻す (リトライで再投入可能に) + await releaseClaim(); // 保存できなければ pending クレームも戻す (リトライで再投入可能に) return fail('kv_error', 503); } if (feeObligation?.collectedInline && save.value !== 1) { @@ -589,7 +633,7 @@ export async function POST(req: Request): Promise { // /api/order/status?t= で **自分の 1 注文の状態だけ** を読めるようにする (token は秘密=列挙不可)。 // 受注本体は上で保存済 = ここは付帯処理。失敗しても受注/決済は成立するため握り (fail-quiet)、 // nx で重複保存を避ける (1 token 1 注文)。flag OFF / token 無し / 不正形式では何もしない (inert)。 - const statusToken = o.statusToken; + const statusToken = snapshot?.statusToken ?? o.statusToken; if (env.enableOrderPickup && isOrderTokenLike(statusToken)) { const ptr = await kvSet( orderStatusPointerKey(statusToken), @@ -625,7 +669,7 @@ export async function POST(req: Request): Promise { // 検証/保存の途中で予期せぬ例外 → pending クレームを解放しリトライ可能に戻す。ただし **保存確定後 // (orderStored) は解放しない**: done 昇格済の恒久ブロックを消すと同一 tx の二重注文を招くため // (掟13: 断つべき波及=検証失敗時のクレーム居座りのみ・保存済の冪等は保つ)。 - if (!orderStored) await kvDel(usedKey); + if (!orderStored) await releaseClaim(); logger.error('order.notify.unexpected', { reason: e instanceof Error ? e.message : String(e), chainId, diff --git a/components/CheckoutForm.tsx b/components/CheckoutForm.tsx index 4869a362..566d1cd7 100644 --- a/components/CheckoutForm.tsx +++ b/components/CheckoutForm.tsx @@ -92,6 +92,8 @@ import { buildJpycRelaySignPreview, buildJpycRecoverSignPreview, } from '@/lib/signPreview'; +import type { CanonicalOrder } from '@/lib/orderBind'; +import { matchesOrderDeliveryContext } from '@/lib/orderDeliveryContext'; import { checkoutIntentContextFingerprint } from '@/lib/checkoutIntentContext'; import { RecoverFeeNotice } from './RecoverFeeNotice'; @@ -136,6 +138,9 @@ async function postCheckoutWebhook( if (!retryOrderProcessing) return response; for (const delayMs of ORDER_NOTIFY_PROCESSING_RETRY_MS) { if (response.status !== 409) return response; + // A non-JSON 409 must not trigger blind retries of reserved/rejected orders. + const error = (await response.clone().json().catch(() => null))?.error; + if (error !== 'processing') return response; // 直前 mount の order claim が処理中でも、fee 成功通知まで 409 で失われ受注/未収状態が // 固着する波及を断つ。既存 route の 409 契約は変えず、同じ byte の notify だけを再試行する。 await new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -144,6 +149,22 @@ async function postCheckoutWebhook( return response; } +function isOwnOrderNotifyUrl(webhook: string, origin: string): boolean { + try { + const url = new URL(webhook); + return url.origin === origin && url.pathname === '/api/order/notify'; + } catch { + // An invalid callback must not make unrelated checkout pages display order-recovery warnings. + return false; + } +} + +function isRetryableOrderNotify(status: number, body: { error?: string; bindingConflict?: boolean } | null): boolean { + if (body?.bindingConflict === true) return false; + return status >= 500 || status === 429 || + (status === 409 && body?.error === 'processing') || (status === 422 && body?.error === 'tx_not_found'); +} + export function CheckoutForm({ params }: { params: CheckoutParams }) { const t = useTranslations('CheckoutForm'); const locale = useLocale(); @@ -152,6 +173,21 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // 使う。flag OFF では生成せず null = payload 無変化・リンク非表示 (byte-identical)。 const [statusToken, setStatusToken] = useState(null); const [orderNotifyTooOld, setOrderNotifyTooOld] = useState(false); + const boundOrderRef = useRef(null); + const boundOrderPreparingRef = useRef(false); + const [bindingStorageError, setBindingStorageError] = useState(false); + const [bindingInputError, setBindingInputError] = useState(false); + const [boundNotifyRetry, setBoundNotifyRetry] = useState(0); + const [boundNotifyRetryable, setBoundNotifyRetryable] = useState(false); + const [boundNotifyAcknowledged, setBoundNotifyAcknowledged] = useState(false); + const boundNotifiedRef = useRef(null); + const previousNotificationsRef = useRef(new Map()); + const previousNotificationMountedRef = useRef(true); + useEffect(() => { + previousNotificationMountedRef.current = true; + return () => { previousNotificationMountedRef.current = false; }; + }, []); + const [previousOrderNotice, setPreviousOrderNotice] = useState<'pending' | 'staff' | null>(null); const statusTokenRef = useRef(null); const router = useRouter(); const [modeOverride, setModeOverride] = useState<'standard' | null>(null); @@ -201,6 +237,24 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // - recover: forwarder 設定済 chain は gas 相当額を JPYC 回収 (gasMode で顧客上乗せ/店主吸収)。 // 未設定は free (OpenPay 負担)。 // - USDC ガスレスが Circle に解決される場合は surcharge 込み quote + permit allowance。 + const totalWei = useMemo( + () => calcCheckoutTotal(params.items, deployment.decimals), + [params.items, deployment.decimals], + ); + const mobileFeeKind: MobileOrderFeeKind | null = + env.enableMobileOrderFee && + (params.feeKind === 'storefront' || params.feeKind === 'preorder') + ? params.feeKind + : null; + const orderTotalValue = mobileFeeKind + ? mobileOrderBreakdown(totalWei, mobileFeeKind, params.feePayer).customerPays : totalWei; + const orderContext = useMemo(() => ({ merchant: params.to, chainId: deployment.chainId, + tokenAddress: deployment.address, webhook: params.webhook, orderId: params.orderId, totalValue: orderTotalValue }), + [params.to, deployment.chainId, deployment.address, params.webhook, params.orderId, orderTotalValue]); + const relay = useJpycEip3009Payment(deployment, { restoreOrderDelivery: true, orderContext }); + const matchingDelivery = relay.orderDelivery && matchesOrderDeliveryContext( + relay.orderDelivery.order, orderContext, typeof window === 'undefined' ? '' : window.location.origin, relay.orderDelivery.intent, + ) ? relay.orderDelivery : null; const resolvedRoute = resolvePaymentRoute({ isStandard: params.mode === 'standard' || @@ -218,11 +272,12 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { }); // submit gesture で選ばれた経路を attempt 単位で固定し、relay POST 中に degraded banner の // standard 切替が競合しても進行中の money-path を差し替えない。 - const route = attemptRouteRef.current ?? resolvedRoute; + const route: PaymentRoute = attemptRouteRef.current ?? + (matchingDelivery && relay.restoredIntent + ? { kind: 'relay', recover: relay.restoredIntent.routeKind === 'recover' } : resolvedRoute); const isStandard = isStandardRoute(route); const useRelay = isRelayRoute(route); const isErc20Paymaster = !isStandard && paymasterMode === 'erc20'; - const relay = useJpycEip3009Payment(deployment); // B1 Layer B: relay 経路でのみ relayer の事前 (preflight) 健全性を polling する。degraded なら // 署名 *前* に「通常決済へ切替」を先回り案内する (Layer A は submit 失敗 *後* の reactive な導線)。 // fail-open (読込中/error は degraded:false) なので advisory に徹し決済実行には影響しない。 @@ -243,11 +298,6 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // customer) は mode/feePayer から確定し effectiveGasMode を上書きする (顧客上乗せ preorder では // recover でも customer が必要)。'register' (レジ) は下記 isRegisterStandardFee で別扱い (standard // のみ)。feeKind 無し・/pay・/tip・通常 checkout は従来動作のまま (一切不変)。 - const mobileFeeKind: MobileOrderFeeKind | null = - env.enableMobileOrderFee && - (params.feeKind === 'storefront' || params.feeKind === 'preorder') - ? params.feeKind - : null; const isMobileFee = mobileFeeKind !== null; // 課金 flag とは独立に、@handle 注文の元 storefront mode を署名前 admission へ渡す。 // feeKind は MobileOrderView が常に付与し、課金自体は上の mobileFeeKind gate が決める。 @@ -279,10 +329,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { const activeQuote = isCircle ? circleQuote : gasQuote; const circlePermitAmount = isCircle ? circleQuote.data?.permitAmount : undefined; - const totalWei = useMemo( - () => calcCheckoutTotal(params.items, deployment.decimals), - [params.items, deployment.decimals], - ); + // レジ (店頭POS) システム利用料: standard 経路 (JPYC) で recover の OpenPay利用料 % を店舗負担で // 課金する (relay 経路は既存 recover が徴収するので register は standard のみ追加)。7月前 @@ -462,6 +509,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { breakdown.customerPays > 0n && !insufficientBalance && !paymentFlowPending && + !relay.orderPaymentHold && gasQuoteReady && !merchantUnderflow && !settledNoRetry && @@ -714,11 +762,13 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { mode: completion.mode, ...completion.hashFields, merchant: params.to, - orderId: params.orderId, + orderId: boundOrderRef.current?.orderId ?? params.orderId, token: params.token, chain: chainSlug, }); + if (useRelay && boundOrderRef.current) return; + // webhook 失敗 (CORS 等) は logger.warn のみ。自社 notify の期限外だけはスタッフへの確認を案内する。 if (params.webhook) { // hash は fetch と並行に開始し、失敗 telemetry が必要な場合だけ await する。 @@ -876,8 +926,104 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { deployment.chainId, deployment.decimals, restoredCheckoutCompletion, + useRelay, + relay.orderDelivery, ]); + // Bare public relay metadata cannot establish an order context. Only a matching delivery + // below may show order-registration assistance; an ordinary /pay recovery is not an order. + useEffect(() => { + const notices = previousNotificationsRef.current; + const updateNotice = () => { + // A second recovered order may change the list while the first POST is outstanding. + // Keep its notice current for this mount without updating an unmounted checkout. + if (!previousNotificationMountedRef.current) return; + const values = [...notices.values()]; + setPreviousOrderNotice(values.includes('staff') ? 'staff' : values.includes('pending') ? 'pending' : null); + }; + for (const record of relay.backgroundOrderDeliveries ?? []) { + // Only validated, already confirmed records may notify in the background. Signed/unknown + // records are resolved read-only by the hook; none of these records becomes page completion. + if (record.state !== 'notify-pending' || !record.notifyBody) continue; + const key = `${record.intent.nonce}:${record.txHash}`; + if (notices.has(key)) continue; + notices.set(key, 'pending'); + const url = new URL('/api/order/notify', window.location.origin); + url.searchParams.set('h', record.order.handle); + void postCheckoutWebhook(url.toString(), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: record.notifyBody, keepalive: true, + }, true).then(async (res) => { + // Missing acknowledgement cannot be treated as registration of a previous order. + const body = await res.json().catch(() => null); + const storage = await import('@/lib/orderDelivery'); + if (res.ok && body?.ok === true && body?.bindingConflict !== true) { + notices.set(key, storage.acknowledgeOrderDelivery(record) ? 'done' : 'pending'); + } else if (!isRetryableOrderNotify(res.status, body)) { + storage.terminateOrderDelivery(record); + notices.set(key, 'staff'); + } + updateNotice(); + }).catch(() => { + // Background network/storage chunk failures retain the opening without blocking this payment. + updateNotice(); + }); + } + updateNotice(); + }, [relay.backgroundOrderDeliveries]); + + // Restored notification is allowed only for the validated saved authorization/order pair. + // The current URL contributes neither order fields nor destination; secrets stay on our origin. + useEffect(() => { + const record = relay.restoredIntent ? matchingDelivery : relay.orderDelivery; + if (!record || record.state !== 'notify-pending' || !record.notifyBody || + !relay.data?.success || relay.data.txHash !== record.txHash) return; + const key = `${record.intent.nonce}:${record.txHash}:${boundNotifyRetry}`; + if (boundNotifiedRef.current === key) return; + boundNotifiedRef.current = key; + setBoundNotifyRetryable(false); + const url = new URL('/api/order/notify', window.location.origin); + url.searchParams.set('h', record.order.handle); + void postCheckoutWebhook(url.toString(), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: record.notifyBody, keepalive: true, + }, true).then(async (res) => { + // A non-JSON response is not an acknowledgement; its HTTP status still determines retryability. + const body = await res.json().catch(() => null); + if (res.ok && body?.ok === true && body?.bindingConflict !== true) { + const { acknowledgeOrderDelivery } = await import('@/lib/orderDelivery'); + if (!acknowledgeOrderDelivery(record)) setBindingStorageError(true); + setBoundNotifyAcknowledged(true); + setOrderNotifyTooOld(false); + statusTokenRef.current = record.order.statusToken || null; + setStatusToken(record.order.statusToken || null); + try { + window.sessionStorage.removeItem(`${ORDER_MEMO_STORAGE_PREFIX}${record.order.orderId}`); + } catch { + // Memo cleanup failure must not roll back an acknowledged order/payment. + } + if (!relay.restoredIntent && params.successUrl) setRedirectIn(SUCCESS_REDIRECT_DELAY_MS / 1000); + return; + } + const terminal = !isRetryableOrderNotify(res.status, body); + if (terminal) { + const { terminateOrderDelivery } = await import('@/lib/orderDelivery'); + if (!terminateOrderDelivery(record)) setBindingStorageError(true); + } + // A terminal delivery shows assistance on this mount only; it must not hijack later payments. + // A notification rejection is never a failed payment or permission to sign again. + setOrderNotifyTooOld(true); + setRedirectIn(null); + setBoundNotifyRetryable(!terminal); + logger.warn('checkout.webhook.non_ok', { status: res.status, statusText: res.statusText }); + }).catch(() => { + // Lost notification response retains the same opening for safe replay, independently of payment. + setOrderNotifyTooOld(true); + setRedirectIn(null); + setBoundNotifyRetryable(true); + logger.warn('checkout.webhook.failed', { reason: 'order_notify_network' }); + }); + }, [relay.orderDelivery, relay.data, relay.restoredIntent, matchingDelivery, boundNotifyRetry, params.successUrl]); + // standard の merchant leg が確定した時点で、独立 fee leg の wallet 操作を待たず受注を届ける。 // 第三者 webhook の成功契約は広げず、OpenPay 自身の same-origin `/api/order/notify` だけを // additive に発火し、後続 fee 成功は従来の通常成功 payload で未収状態を解消する。 @@ -1168,7 +1314,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { }, [redirectIn]); function doRedirect() { - if (!params.successUrl || !completion || restoredCheckoutCompletion || orderNotifyTooOld) return; + if (!params.successUrl || !completion || restoredCheckoutCompletion || orderNotifyTooOld || + (boundOrderRef.current && !boundNotifyAcknowledged)) return; const u = new URL(params.successUrl); for (const [k, v] of Object.entries(completion.redirectQuery)) { u.searchParams.set(k, v); @@ -1236,6 +1383,43 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // admission 待機中に接続・chain・残高等が変わっていれば、stale な submit を止める。 if (!paymentReadyRef.current) return; } + let order: CanonicalOrder | undefined; + boundOrderRef.current = null; + setBindingInputError(false); + setBindingStorageError(false); + setBoundNotifyAcknowledged(false); + if (useRelay && params.webhook) { + // The lazy binding chunk introduces an await before mutate; block repeated clicks during + // that gap so a slow chunk cannot produce multiple authorizations for one submit gesture. + if (boundOrderPreparingRef.current) return; + boundOrderPreparingRef.current = true; + try { + const webhook = new URL(params.webhook); + if (webhook.origin === window.location.origin && webhook.pathname === '/api/order/notify') { + if (webhook.searchParams.getAll('h').length !== 1) throw new Error('invalid_handle'); + const orderId = params.orderId || generateStatusToken().slice(0, 16); + // Unlike advisory standard metadata, a bound memo must be read before signing or stop. + const customerMemo = window.sessionStorage.getItem(`${ORDER_MEMO_STORAGE_PREFIX}${orderId}`) ?? ''; + const { canonicalOrder } = await import('@/lib/orderBind'); + // A slow chunk must not carry a disconnected/switched wallet into signing. + if (!paymentReadyRef.current) return; + order = canonicalOrder({ chainId: deployment.chainId, tokenAddress: deployment.address, + merchant: params.to, handle: webhook.searchParams.get('h'), orderId, items: params.items, + description: params.description, pickupAt: params.pickupAt, customerMemo, + statusToken: env.enableOrderPickup ? generateStatusToken() : '' }); + boundOrderRef.current = order; + } + } catch (error) { + // Storage/invalid own-origin context must not broadcast an order with a missing opening. + if (error instanceof Error && (error.message === 'invalid_handle' || error.message === 'order_binding_mismatch')) { + setBindingInputError(true); + } else setBindingStorageError(true); + return; + } finally { + boundOrderPreparingRef.current = false; + } + } + setBindingStorageError(false); attemptRouteRef.current = route; // webhook/記録は「送金した瞬間の額」を報告する (成功描画時の live breakdown は // gas quote 再取得等で動きうるため、submit 時点の snapshot を真実とする)。 @@ -1272,6 +1456,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { relay.mutate({ merchant: params.to, value: totalWei, + ...(order ? { order } : {}), gasMode: effectiveGas, // モバイル注文 (storefront/preorder) のときだけ feeKind を載せる (server が定数表から % を // 再計算・on-chain 強制)。register(レジ) は relay では既存 recover が徴収するので渡さない。 @@ -1827,6 +2012,38 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { /> )} + {relay.previousPaymentPending && ( +

+ {t(relay.previousPaymentSameMerchant + ? relay.orderPaymentHold ? 'previousPaymentConfirming' : 'previousPaymentUnconfirmed' + : 'previousPaymentConfirmingOther')} +

+ )} + {previousOrderNotice && !(previousOrderNotice === 'pending' && relay.previousPaymentSameMerchant) && ( +

+ {t(previousOrderNotice === 'staff' ? 'previousOrderNotifyStaff' : 'previousOrderNotifyPending')} +

+ )} + {(bindingStorageError || relay.error?.message === 'order_binding_storage_unavailable') && !relay.data?.success && ( +

+ {t('orderBindingStorageUnavailable')} +

+ )} + {bindingInputError && ( +

+ {t('urlInvalidTitle')} +

+ )} + {relay.orderDeliveryLoadError && useRelay && params.webhook && origin && isOwnOrderNotifyUrl(params.webhook, origin) && ( +

+ {t('orderBindingRestoreUnavailable')} +

+ )} + {boundNotifyRetryable && ( + + )} {orderNotifyTooOld && (

{t('orderNotifyTooOld')} @@ -1884,8 +2101,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { /> )} - {!restoredCheckoutCompletion && params.orderId && ( - + {((boundOrderRef.current ?? matchingDelivery?.order)?.orderId || (!restoredCheckoutCompletion && params.orderId)) && ( + )} @@ -1915,7 +2132,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { /> - {!restoredCheckoutCompletion && params.successUrl && !orderNotifyTooOld && ( + {!restoredCheckoutCompletion && params.successUrl && !orderNotifyTooOld && + (!boundOrderRef.current || boundNotifyAcknowledged) && (

{redirectIn !== null && redirectIn > 0 diff --git a/components/OrderBindingNotice.tsx b/components/OrderBindingNotice.tsx new file mode 100644 index 00000000..52d0699f --- /dev/null +++ b/components/OrderBindingNotice.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { StoredOrder } from '@/lib/orderRelay'; + +export function OrderBindingNotice({ order }: { order: StoredOrder }) { + const t = useTranslations('OrderBinding'); + if (order.bindingMissing) { + return

{t('unverified')}

; + } + return null; +} + +export function OrderBindingScope() { + const t = useTranslations('OrderBinding'); + return

{t('scope')}

; +} diff --git a/components/OrderCard.tsx b/components/OrderCard.tsx index cb2ba6f1..4c0f1d4e 100644 --- a/components/OrderCard.tsx +++ b/components/OrderCard.tsx @@ -6,6 +6,7 @@ // ageMin (表示分) を渡す = tick で表示分が変わらない限り再レンダーしない。テーブル訂正の入力ドラフトは // 編集中の当該カードだけ描画に影響する (他カードは draft 変化で再レンダーしない)。 +import { OrderBindingNotice } from '@/components/OrderBindingNotice'; import { memo } from 'react'; import { useTranslations } from 'next-intl'; import { formatUnits } from 'viem'; @@ -100,6 +101,7 @@ function OrderCardInner({ : 'border-slate-200 bg-white' }`} > +

diff --git a/components/OrderFeedPanel.tsx b/components/OrderFeedPanel.tsx index 0d3002e6..fb12c5eb 100644 --- a/components/OrderFeedPanel.tsx +++ b/components/OrderFeedPanel.tsx @@ -7,6 +7,7 @@ // react-query を使うため、親 (create ページ) は env.enableOrderRelay でこのパネルの**マウント自体**を // ゲートする (OFF の単体テストで QueryClient を要求しない)。設計: plans/swift-puzzling-sky.md。 +import { OrderBindingNotice, OrderBindingScope } from '@/components/OrderBindingNotice'; import { useMemo, useState } from 'react'; import { useTranslations, useLocale } from 'next-intl'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -137,6 +138,7 @@ export function OrderFeedPanel() { : 'border-slate-200 bg-white' }`} > +

{/* 受付番号 (受け渡し照合用・客の完了画面と同じコード)。 */} @@ -260,6 +262,7 @@ export function OrderFeedPanel() {

{t('heading')}

{t('subheading')}

+ {/* 完了フローのヒント。飲食 (厨房/ホール) を使う場合は配膳済み=対応済みを案内。 */}

{env.enableOrderFulfillment ? t('completionHintRestaurant') : t('completionHintRetail')} diff --git a/components/OrderFulfillmentBoard.tsx b/components/OrderFulfillmentBoard.tsx index ef743aaa..15f67563 100644 --- a/components/OrderFulfillmentBoard.tsx +++ b/components/OrderFulfillmentBoard.tsx @@ -1,5 +1,7 @@ 'use client'; +import { OrderBindingScope } from '@/components/OrderBindingNotice'; + // 受注フルフィルメントの全画面ボード (Phase 3)。mode で 厨房 (調理) / ホール (配膳) を切替。 // - 厨房: 商品別「調理済み」トグル + 注文「調理済み」(中間 kitchenDone・厨房側だけ折りたたむ)。 // - ホール: 調理済み品は **青** (配膳待ちが一目) + 商品別「配膳済み」トグル + 注文「配膳済み」 @@ -318,6 +320,7 @@ export function OrderFulfillmentBoard({

{t('autoRefresh')}

+ {env.enableOrderCall && mode === 'hall' ? ( boolean; }; type PaymentIntentStorage = typeof import('@/lib/paymentIntentStorage'); @@ -160,7 +166,11 @@ async function postRelayPayload( throw new RelayResponseUnknownError(); } -export function useJpycEip3009Payment(deployment: TokenDeployment) { +export function useJpycEip3009Payment( + deployment: TokenDeployment, + { restoreOrderDelivery = false, orderContext }: { restoreOrderDelivery?: boolean; orderContext?: OrderDeliveryContext } = {}, +) { + const { merchant: contextMerchant, chainId: contextChain, tokenAddress: contextToken, webhook: contextWebhook, orderId: contextOrderId, totalValue: contextTotalValue } = orderContext ?? {}; const { data: walletClient } = useWalletClient(); const { address, chainId } = useAccount(); const publicClient = usePublicClient({ chainId: deployment.chainId }); @@ -170,6 +180,47 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { // 破棄せず、通常 mutate の再呼出でも同じ payload だけを POST して新署名を構造的に封鎖する。 const retainedPayloadRef = useRef(null); const storageRef = useRef(null); + const deliveryStorageRef = useRef(null); + const orderDeliveryRef = useRef(null); + const [orderDelivery, setOrderDelivery] = useState(null); + const [backgroundOrderDeliveries, setBackgroundOrderDeliveries] = useState([]); + const backgroundOrdersRef = useRef([]); + const backgroundLoadedAtRef = useRef(Date.now()); + const [orderDeliveryLoadError, setOrderDeliveryLoadError] = useState(false); + const heldOrdersRef = useRef(new Set()); + const [, updateOrderHold] = useState(0); + const sameMerchantSigned = backgroundOrderDeliveries.filter((r) => r.state === 'signed' && + r.order.merchant.toLowerCase() === contextMerchant?.toLowerCase()); + const orderPaymentHold = sameMerchantSigned.some((r) => heldOrdersRef.current.has(r)); + const isOrderPaymentHeld = useCallback((merchant: Address) => backgroundOrdersRef.current.some((r) => + r.state === 'signed' && r.order.merchant.toLowerCase() === merchant.toLowerCase() && + heldOrdersRef.current.has(r)), []); + const clearSavedRelayIntent = useCallback((intent: RelayIntentMetadata) => { + const saved = storageRef.current?.loadRelayIntent(); + // A bound order's completion must not clear an unrelated ordinary payment's public intent. + if (saved && saved.chainId === intent.chainId && saved.from.toLowerCase() === intent.from.toLowerCase() && + saved.nonce.toLowerCase() === intent.nonce.toLowerCase()) storageRef.current?.clearRelayIntent(); + }, []); + const parkUnbroadcastOrder = useCallback((intent: RelayIntentMetadata) => { + const record = orderDeliveryRef.current; + const storage = deliveryStorageRef.current; + if (record && storage?.sameOrderAuthorization(record, intent)) storage.abandonOrderDelivery(record); + }, []); + const finishOrderPayment = useCallback((intent: RelayIntentMetadata, result: JpycEip3009Result) => { + const storage = deliveryStorageRef.current; + const record = orderDeliveryRef.current; + if (!storage || !record || !storage.sameOrderAuthorization(record, intent) || result.pending) return; + if (result.success && result.txHash) { + const resolved = storage.resolveOrderDelivery(record, result.txHash); + orderDeliveryRef.current = resolved; + setOrderDelivery(resolved); + // A failed disk update must not hide the in-memory opening from notify; ack removes it. + storage.saveOrderDelivery(resolved); + } else if (storage.abandonOrderDelivery(record)) { + orderDeliveryRef.current = null; + setOrderDelivery(null); + } + }, []); const [storageReady, setStorageReady] = useState(false); const [restoredResult, setRestoredResult] = useState(null); @@ -182,7 +233,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { const [recoveryState, setRecoveryState] = useState(null); const mountedRef = useRef(true); - const recoveryPromiseRef = useRef | null>(null); + const recoveryPromiseRef = useRef<{ retained: RetainedRelayPayload; promise: Promise } | null>(null); const recoverySleepRef = useRef | null>(null); const recoveryWakeRef = useRef<(() => void) | null>(null); const recoveryFetchTimeoutRef = useRef | null>( @@ -216,7 +267,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { const resolveAmbiguousPayload = useCallback( (retained: RetainedRelayPayload): Promise => { const existing = recoveryPromiseRef.current; - if (existing) return existing; + if (existing?.retained === retained) return existing.promise; const run = (async (): Promise => { setRecoveryStateIfMounted('auto'); @@ -225,7 +276,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { ); const outcome = await resolveRelayIntent({ intent: retained.intent, - isMounted: () => mountedRef.current, + isMounted: () => mountedRef.current && (retained.isCurrent?.() ?? true), registerSleep: (timer, wake) => { recoverySleepRef.current = timer; recoveryWakeRef.current = wake; @@ -271,9 +322,12 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { ); }, }); + // A receipt can resolve after navigation; it must not clear or complete the new checkout. + if (retained.isCurrent && !retained.isCurrent()) throw new RelayResponseUnknownError(); if (outcome.kind === 'expired') { + finishOrderPayment(retained.intent, { txHash: null, success: false }); retainedPayloadRef.current = null; - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(retained.intent); // 未使用・失効済み intent の復元分類が画面に残り、新規決済の完了処理を // 抑止し続ける波及を断つ。呼出側 catch の有無に依存せず latch と同時に解除する。 setRestoredIntent(null); @@ -282,8 +336,9 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { throw new Error('relay_unused'); } if (outcome.kind === 'settled') { + finishOrderPayment(retained.intent, { txHash: outcome.txHash, success: outcome.success }); retainedPayloadRef.current = null; - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(retained.intent); setRecoveryStateIfMounted(null); void Promise.all([ import('@/lib/history'), @@ -314,28 +369,115 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { throw new RelayResponseUnknownError(); })(); - recoveryPromiseRef.current = run; + recoveryPromiseRef.current = { retained, promise: run }; void run .finally(() => { - if (recoveryPromiseRef.current === run) { + if (recoveryPromiseRef.current?.promise === run) { recoveryPromiseRef.current = null; } }) .catch(() => undefined); return run; }, - [deployment.chainId, publicClient, setRecoveryStateIfMounted], + [deployment.chainId, publicClient, setRecoveryStateIfMounted, finishOrderPayment, clearSavedRelayIntent], ); useEffect(() => { let active = true; + let restoredOrder: RetainedRelayPayload | null = null; + const cancelBackground: (() => void)[] = []; + if (restoreOrderDelivery) { + // Reused CheckoutForm mounts must not carry a previous shop's restored completion forward. + setStorageReady(false); + setRestoredIntent(null); + setRestoredResult(null); + setRestoredError(null); + setRestoredVariables(null); + setRecoveryState(null); + } // /pay・/tip の First Load JS 予算へ storage parser を載せないため mount 後に遅延取得する。 void import('@/lib/paymentIntentStorage') - .then((storage) => { + .then(async (storage) => { + let delivery: OrderDelivery | undefined; + let previous: OrderDelivery[] = []; + let saved = storage.loadRelayIntent(); + if (restoreOrderDelivery) try { + const deliveryStorage = await import('@/lib/orderDelivery'); + if (!active) return; + deliveryStorageRef.current = deliveryStorage; + const loaded = deliveryStorage.loadOrderDeliveries(); + setOrderDeliveryLoadError(loaded.unavailable); + const context = contextMerchant && contextChain && contextToken && contextTotalValue !== undefined + ? { merchant: contextMerchant, chainId: contextChain, tokenAddress: contextToken, webhook: contextWebhook, orderId: contextOrderId, totalValue: contextTotalValue } : null; + delivery = loaded.records.find((r) => context && deliveryStorage.matchesOrderDeliveryContext(r.order, context, window.location.origin, r.intent)); + // A legacy bound intent also lived in the global ordinary-payment slot. Its order slot + // owns recovery now, so that public metadata must not bypass checkout-context matching. + if (saved && loaded.records.some((r) => deliveryStorage.sameOrderAuthorization(r, saved!))) { + storage.clearRelayIntent(); + saved = null; + } + previous = loaded.records.filter((r) => r !== delivery); + } catch { + // An unavailable order chunk must not fabricate a checkout association or opening. + setOrderDeliveryLoadError(true); + } if (!active) return; + orderDeliveryRef.current = delivery ?? null; + setOrderDelivery(delivery ?? null); + backgroundLoadedAtRef.current = Date.now(); + backgroundOrdersRef.current = previous; + heldOrdersRef.current = new Set(previous.filter((r) => r.state === 'signed')); + setBackgroundOrderDeliveries(previous); storageRef.current = storage; - const intent = storage.loadRelayIntent(); + const intent = delivery?.intent ?? saved; setStorageReady(true); + if (previous.some((r) => r.state === 'signed')) { + void import('@/lib/orderDeliveryRecovery').then(({ recoverOrderDelivery }) => { + if (!active) return; + for (const record of previous.filter((r) => r.state === 'signed')) { + cancelBackground.push(recoverOrderDelivery(record, async (hash, timeout) => { + if (publicClient && record.intent.chainId === deployment.chainId) { + const receipt = await publicClient.waitForTransactionReceipt({ hash, confirmations: 1, timeout }); + return { status: receipt.status }; + } + const { waitForStoredRelayReceipt } = await import('@/lib/relay/relayStoredReceipt'); + return waitForStoredRelayReceipt(record.intent.chainId, hash, timeout); + }, (outcome) => { + if (!active) return; + const deliveryStorage = deliveryStorageRef.current!; + let resolved: OrderDelivery | null = null; + if (outcome.kind === 'settled' && outcome.success) { + resolved = deliveryStorage.resolveOrderDelivery(record, outcome.txHash); + // Retain the in-memory opening for notify even if its disk update fails. + deliveryStorage.saveOrderDelivery(resolved); + } else deliveryStorage.abandonOrderDelivery(record); + heldOrdersRef.current.delete(record); + // A background result only advances delivery; it cannot complete the current checkout. + backgroundOrdersRef.current = backgroundOrdersRef.current.flatMap((r) => r === record ? resolved ? [resolved] : [] : [r]); + setBackgroundOrderDeliveries(backgroundOrdersRef.current); + }, { loadedAt: backgroundLoadedAtRef.current, onHoldReleased: () => { + if (!active) return; + heldOrdersRef.current.delete(record); + updateOrderHold((v) => v + 1); + } })); + } + }).catch(() => { + // A failed chunk cannot read status. Keep the opening and expiry hold, then show + // staff advice; the loading failure must not strand payments at another checkout. + if (!active) return; + setOrderDeliveryLoadError(true); + for (const record of previous.filter((r) => r.state === 'signed')) { + const until = Math.min(Number(record.intent.validBefore) * 1000, + record.intent.issuedAt + AUTHORIZATION_VALIDITY_WINDOW_SEC * 1000, + backgroundLoadedAtRef.current + AUTHORIZATION_VALIDITY_WINDOW_SEC * 1000); + const timer = setTimeout(() => { + heldOrdersRef.current.delete(record); + updateOrderHold((v) => v + 1); + }, Math.max(0, until - Date.now())); + cancelBackground.push(() => clearTimeout(timer)); + } + }); + } if (!intent) return; // 保存済み nonce の status 確認より前に届いた submit を、復元終了後の新規署名へ // 波及させない。未解決 intent を優先し、storage 読込中に積まれた操作は破棄する。 @@ -344,7 +486,9 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { payload: null, intent, ambiguous: true, + ...(delivery ? { isCurrent: () => active } : {}), }; + if (delivery) restoredOrder = retained; retainedPayloadRef.current = retained; setRestoredIntent(intent); setRestoredVariables(variablesFromIntent(intent)); @@ -374,8 +518,18 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { }); return () => { active = false; + for (const cancel of cancelBackground) cancel(); + if (restoredOrder && retainedPayloadRef.current === restoredOrder) { + // Cancel only this restored order's readers; its persisted opening stays for its own checkout. + retainedPayloadRef.current = null; + if (recoveryPromiseRef.current?.retained === restoredOrder) { + if (recoverySleepRef.current !== null) clearTimeout(recoverySleepRef.current); + recoveryWakeRef.current?.(); + recoveryAbortRef.current?.abort(); + } + } }; - }, [deployment.chainId, resolveAmbiguousPayload]); + }, [deployment.chainId, publicClient, resolveAmbiguousPayload, restoreOrderDelivery, contextMerchant, contextChain, contextToken, contextWebhook, contextOrderId, contextTotalValue]); const mutation = useMutation({ mutationFn: async ({ @@ -384,7 +538,10 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { gasMode = 'customer', feeKind, tipMessage, + order, }) => { + // A new orderId must not bypass a still-live payment to the same merchant. + if (!retainedPayloadRef.current && isOrderPaymentHeld(merchant)) throw new Error('order_payment_pending'); // 署名待ち中に UI 入力が変わっても wire 値を drift させないため、mutation 開始時の // primitive を固定する。復旧 intent には入れず、同一 mount の retained payload だけが保持する。 const tipMessageSnapshot = @@ -398,15 +555,22 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { if (retained.ambiguous || !retained.payload) { return resolveAmbiguousPayload(retained); } + const record = orderDeliveryRef.current; + const deliveryStorage = deliveryStorageRef.current; + if (record && deliveryStorage?.sameOrderAuthorization(record, retained.intent) && !deliveryStorage.saveOrderDelivery(record)) { + // A retry whose signature never broadcast still needs its original opening persisted first. + throw new Error('order_binding_storage_unavailable'); + } try { const result = await postRelayPayload(retained.payload); + finishOrderPayment(retained.intent, result); // txHash 付き success / reverted は on-chain の確定結果。非 ambiguous な D1 retry の // pending だけは broadcast 済みなので同一 mount でも intent latch を維持する。 retainedPayloadRef.current = result.pending ? { ...retained, ambiguous: true } : null; if (!result.pending) { - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(retained.intent); } setRecoveryState(null); return result; @@ -420,9 +584,13 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { ambiguous: true, }; setRecoveryState('exhausted'); - } else if (!isRelayIpRateLimitedError(error)) { + } else if (isRelayIpRateLimitedError(error)) { + parkUnbroadcastOrder(retained.intent); + clearSavedRelayIntent(retained.intent); + } else { retainedPayloadRef.current = null; - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(retained.intent); + finishOrderPayment(retained.intent, { txHash: null, success: false }); } throw error; } @@ -439,6 +607,26 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { const validBefore = BigInt(nowSec + AUTHORIZATION_VALIDITY_WINDOW_SEC); const forwarder = jpycForwarderFor(chainId); + const bindModule = order ? await import('@/lib/orderBind') : null; + // Canonical order and secret are frozen before wallet waits. Reject a mismatched caller + // context instead of signing an authorization that cannot open its saved order. + const canonical = order && bindModule ? bindModule.canonicalOrder(order) : null; + if (canonical && (canonical.chainId !== chainId || + canonical.tokenAddress.toLowerCase() !== deployment.address.toLowerCase() || + canonical.merchant.toLowerCase() !== merchant.toLowerCase())) throw new Error('order_binding_mismatch'); + const bind = canonical ? { v: 1 as const, secret: randomAuthorizationNonce(), validAfter: '0', validBefore: validBefore.toString() } : null; + const boundSalt = bind && canonical && bindModule + ? bindModule.orderBindSalt(bindModule.orderDigest(canonical), bind.secret) : null; + const persistOrder = async (intent: RelayIntentMetadata) => { + if (!canonical || !bind) return; + const storage = deliveryStorageRef.current ?? await import('@/lib/orderDelivery'); + deliveryStorageRef.current = storage; + const record: OrderDelivery = { version: 1, order: canonical, bind, intent, forwarder, + ...(forwarder ? { feeReceiver: env.feeReceiver as Address } : {}), state: 'signed' }; + if (!storage.saveOrderDelivery(record)) throw new Error('order_binding_storage_unavailable'); + orderDeliveryRef.current = record; + setOrderDelivery(record); + }; // 「署名安心 UX」(plans/sign-reassurance-ux.md §5) の計測: 署名要求の前後と拒否/失敗 // をログ化し、拒否率ファネル (Sentry) を取れるようにする。free/recover の 2 経路で // signTypedData を呼ぶため共通化する (署名内容は各 typed を渡すだけで不変)。catch は @@ -500,7 +688,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { feeValue, validAfter: 0n, validBefore, - intentSalt: randomAuthorizationNonce(), + intentSalt: boundSalt ?? randomAuthorizationNonce(), }; // recover 専用の intent 構築は lazy import (initial /pay バンドルに encodeAbiParameters // 等を載せない・予算節約)。recover 決済が実行された時のみ chunk を読み込む。 @@ -514,6 +702,17 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { deployment.address, forwarder, ); + intent = { + chainId, + from, + merchant, + merchantValue: merchantValue.toString(), + feeValue: feeValue.toString(), + nonce: buildForwarderNonce(params, chainId, forwarder), + validBefore: validBefore.toString(), + routeKind: 'recover', + issuedAt: Date.now(), + }; const signature = await signWithLogging({ account: address, domain: typed.domain, @@ -543,17 +742,6 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { ? { tipMessage: tipMessageSnapshot } : {}), }; - intent = { - chainId, - from, - merchant, - merchantValue: merchantValue.toString(), - feeValue: feeValue.toString(), - nonce: buildForwarderNonce(params, chainId, forwarder), - validBefore: validBefore.toString(), - routeKind: 'recover', - issuedAt: Date.now(), - }; } else { // free: 直接 transferWithAuthorization。OpenPay がガス負担 (回収しない)。 // ⚠️ モバイル注文システム利用料 (feeKind) は free 経路では分割できない (単一 transfer・fee 機構 @@ -568,13 +756,24 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { value, validAfter: 0n, validBefore, - nonce: randomAuthorizationNonce(), + nonce: boundSalt ?? randomAuthorizationNonce(), }; const typed = buildTransferWithAuthorizationTypedData( auth, chainId, deployment.address, ); + intent = { + chainId, + from, + merchant, + merchantValue: value.toString(), + feeValue: '0', + nonce: auth.nonce, + validBefore: validBefore.toString(), + routeKind: 'free', + issuedAt: Date.now(), + }; const signature = await signWithLogging({ account: address, domain: typed.domain, @@ -595,27 +794,26 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { ? { tipMessage: tipMessageSnapshot } : {}), }; - intent = { - chainId, - from, - merchant, - merchantValue: value.toString(), - feeValue: '0', - nonce: auth.nonce, - validBefore: validBefore.toString(), - routeKind: 'free', - issuedAt: Date.now(), - }; } + // Only a resolved signature can leave a restorable order. Storage failure discards it + // before POST, so navigation during a wallet prompt cannot block a subsequent checkout. + if (bind && !mountedRef.current) throw new Error('payment_cancelled'); + if (bind) await persistOrder(intent); + + // Keep the existing post-signing timestamp for ordinary /pay and /tip intents. + if (!bind) intent.issuedAt = Date.now(); + // 署名そのものは保存せず、POST 応答喪失後も read-only status を引ける公開メタデータだけを残す。 - storageRef.current?.saveRelayIntent(intent); + // Bound payments recover from their own authorization slot, never the global /pay intent. + if (!bind) storageRef.current?.saveRelayIntent(intent); try { const result = await postRelayPayload(payload); + finishOrderPayment(intent, result); if (result.pending) { retainedPayloadRef.current = { payload, intent, ambiguous: true }; } else { - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(intent); } return result; } catch (error) { @@ -625,12 +823,14 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { return resolveAmbiguousPayload(retained); } else if (isRelayIpRateLimitedError(error)) { retainedPayloadRef.current = { payload, intent, ambiguous: false }; + parkUnbroadcastOrder(intent); // IP limiter は relay 処理前の確定的拒否。署名 payload は同一 mount の retry 用に // memory へ残すが、未 broadcast intent が reload 後の支払いを塞ぐ波及は断つ。 - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(intent); } else { retainedPayloadRef.current = null; - storageRef.current?.clearRelayIntent(); + clearSavedRelayIntent(intent); + finishOrderPayment(intent, { txHash: null, success: false }); } throw error; } @@ -648,6 +848,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { queuedMutationRef.current = variables; return; } + if (isOrderPaymentHeld(variables.merchant)) return; if ( mutationData?.success || mutationData?.pending || @@ -661,7 +862,7 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { setRestoredVariables(null); mutationMutate(variables); }, - [mutationData, mutationMutate, restoredResult, storageReady], + [mutationData, mutationMutate, restoredResult, storageReady, isOrderPaymentHeld], ); useEffect(() => { @@ -676,8 +877,10 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { const queued = queuedMutationRef.current; if (!queued) return; queuedMutationRef.current = null; + // Discard a queued click blocked by an earlier payment; resolving it must not auto-pay the new cart. + if (isOrderPaymentHeld(queued.merchant)) return; mutationMutate(queued); - }, [mutationIsPending, mutationMutate, restoredResult, storageReady]); + }, [mutationIsPending, mutationMutate, restoredResult, storageReady, isOrderPaymentHeld]); const retryRelay = useCallback(() => { if ( @@ -700,12 +903,12 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { } void resolveAmbiguousPayload(retained) .then((result) => { - if (!mountedRef.current) return; + if (!mountedRef.current || (retained.isCurrent && !retained.isCurrent())) return; setRestoredResult(result); setRestoredError(null); }) .catch((error: unknown) => { - if (!mountedRef.current) return; + if (!mountedRef.current || (retained.isCurrent && !retained.isCurrent())) return; if (error instanceof Error && error.message === 'relay_unused') { // 未使用・失効済み intent の表示分類が残り、新規決済の完了処理を抑止する波及を断つ。 setRestoredIntent(null); @@ -747,5 +950,11 @@ export function useJpycEip3009Payment(deployment: TokenDeployment) { isRestoring: !storageReady, hasActiveIntent: retainedPayloadRef.current !== null, restoredIntent, + orderDelivery, + backgroundOrderDeliveries, + orderPaymentHold, + previousPaymentPending: backgroundOrderDeliveries.some((r) => r.state === 'signed'), + previousPaymentSameMerchant: sameMerchantSigned.length > 0, + orderDeliveryLoadError, }; } diff --git a/i18n/clientNamespaces.ts b/i18n/clientNamespaces.ts index 68abafd5..b1553582 100644 --- a/i18n/clientNamespaces.ts +++ b/i18n/clientNamespaces.ts @@ -101,6 +101,7 @@ export const ROUTE_CLIENT_NAMESPACES = { 'News', 'NonCustodialNotice', 'OfflineLastQr', + 'OrderBinding', 'OrderCall', 'OrderFulfillment', 'OrderRelay', @@ -269,11 +270,13 @@ export const ROUTE_CLIENT_NAMESPACES = { ], 'orders/hall': [ 'LocaleSwitcher', + 'OrderBinding', 'OrderCall', 'OrderFulfillment', ], 'orders/kitchen': [ 'LocaleSwitcher', + 'OrderBinding', 'OrderCall', 'OrderFulfillment', ], diff --git a/lib/order/agentOrderReceipt.ts b/lib/order/agentOrderReceipt.ts index c82c0cf7..135a995a 100644 --- a/lib/order/agentOrderReceipt.ts +++ b/lib/order/agentOrderReceipt.ts @@ -7,6 +7,32 @@ const AUTHORIZATION_ABI = parseAbi([ const SETTLED_ABI = parseAbi([ 'event Settled(address indexed from, bytes32 indexed nonce, address indexed merchant, uint256 merchantValue, address feeReceiver, uint256 feeValue)', ]); +const TRANSFER_ABI = parseAbi(['event Transfer(address indexed from, address indexed to, uint256 value)']); + +// Human checkout and agent finalization share strict event decoding and emitter checks. +export function receiptSettlements(logs: readonly FeeReceiptLog[], forwarder: Address) { + return logs.flatMap((log) => { + if (log.address.toLowerCase() !== forwarder.toLowerCase()) return []; + try { + return [decodeEventLog({ abi: SETTLED_ABI, eventName: 'Settled', topics: log.topics as [Hex, ...Hex[]], data: log.data as Hex, strict: true }).args]; + } catch { + // Unrelated/malformed logs must not become settlement evidence. + return []; + } + }); +} + +export function receiptTokenTransfers(logs: readonly FeeReceiptLog[], token: Address) { + return logs.flatMap((log) => { + if (log.address.toLowerCase() !== token.toLowerCase()) return []; + try { + return [decodeEventLog({ abi: TRANSFER_ABI, eventName: 'Transfer', topics: log.topics as [Hex, ...Hex[]], data: log.data as Hex, strict: true }).args]; + } catch { + // Unrelated/malformed logs must not establish payment attribution. + return []; + } + }); +} export type AgentSettlementTuple = { chainId: number; @@ -36,15 +62,13 @@ export function receiptAuthorizations(logs: readonly FeeReceiptLog[], token: Add } export function matchesAgentSettlement(logs: readonly FeeReceiptLog[], tuple: AgentSettlementTuple): boolean { - return logs.some((log) => { - if (log.address.toLowerCase() !== tuple.forwarder.toLowerCase()) return false; + return receiptSettlements(logs, tuple.forwarder).some((args) => { try { - const { args } = decodeEventLog({ abi: SETTLED_ABI, eventName: 'Settled', topics: log.topics as [Hex, ...Hex[]], data: log.data as Hex, strict: true }); return isAddressEqual(args.from, tuple.authorizer) && args.nonce.toLowerCase() === tuple.nonce.toLowerCase() && isAddressEqual(args.merchant, tuple.merchant) && args.merchantValue === BigInt(tuple.merchantValue) && isAddressEqual(args.feeReceiver, tuple.feeReceiver) && args.feeValue === BigInt(tuple.feeValue); } catch { - // Transfer / unrelated logs must not unlock a reserved order as a Settled event. + // Preserve A2b's fail-closed tuple matching: malformed amounts must not unlock an agent order. return false; } }); diff --git a/lib/order/orderBindingVerify.ts b/lib/order/orderBindingVerify.ts new file mode 100644 index 00000000..50d5c1cf --- /dev/null +++ b/lib/order/orderBindingVerify.ts @@ -0,0 +1,93 @@ +import 'server-only'; +import { keccak256, toHex, type Address } from 'viem'; +import type { FeeReceiptLog } from '@/lib/feeVerify'; +import { canonicalOrder, orderDigest, orderBindSalt, parseOrderBind, type CanonicalOrder } from '@/lib/orderBind'; +import { buildForwarderNonce } from '@/lib/relay/forwarderIntent'; +import { configuredJpycForwarderFor } from '@/lib/relay/forwarderConfig'; +import { receiptAuthorizations, receiptSettlements, receiptTokenTransfers } from '@/lib/order/agentOrderReceipt'; +import { ORDER_DUST_FLOOR_WEI } from '@/lib/orderRelay'; + +export function orderBindEnforced(): boolean { + return process.env.ENABLE_ORDER_BIND_ENFORCE === 'true' || process.env.ENABLE_ORDER_BIND_ENFORCE === '1'; +} +const AUTH_TOPIC = keccak256(toHex('AuthorizationUsed(address,bytes32)')); +const SETTLED_TOPIC = keccak256(toHex('Settled(address,bytes32,address,uint256,address,uint256)')); +const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +// Missing receipt logs must not let a deferred fee follow-up downgrade relay to standard. +export function receiptHasRelayEvidence(chainId: number, token: Address, logs: readonly FeeReceiptLog[] | undefined): boolean { + if (!logs) return true; + const forwarder = configuredJpycForwarderFor(chainId); + const transfers = receiptTokenTransfers(logs, token); + return logs.some((log) => (same(log.address, token) && same(log.topics[0] ?? '', AUTH_TOPIC)) || + (!!forwarder && same(log.address, forwarder) && same(log.topics[0] ?? '', SETTLED_TOPIC))) || + !!forwarder && transfers.some((t) => same(t.from, forwarder) || same(t.to, forwarder)); +} + +type Result = { ok: false } | { + ok: true; + kind: 'standard' | 'relay'; + bindingMissing?: true; + order?: CanonicalOrder; + digest?: string; + value?: bigint; + sameSourceFeeValue?: bigint; +}; +// Caller already verified receipt success and merchant transfer. Never infer the route from +// body.mode, tx sender, a live fee schedule, or an expired authorization's current validity. +export function verifyOrderBinding(args: { + body: Record; handle: string; chainId: number; token: Address; + merchant: Address; logs: readonly FeeReceiptLog[] | undefined; feeReceiver?: Address; +}): Result { + const { body, logs, token, merchant, chainId } = args; + if (!logs) return { ok: false }; // Missing receipt evidence must not downgrade relay to standard. + const present = Object.hasOwn(body, 'bind'); + const bind = present ? parseOrderBind(body.bind) : null; + if (present && !bind) return { ok: false }; + const forwarder = configuredJpycForwarderFor(chainId); + const auths = receiptAuthorizations(logs, token); + const settlements = forwarder ? receiptSettlements(logs, forwarder) : []; + const transfers = receiptTokenTransfers(logs, token); + const relayEvidence = receiptHasRelayEvidence(chainId, token, logs); + if (!relayEvidence) return present ? { ok: false } : { ok: true, kind: 'standard' }; + if (!bind && orderBindEnforced()) return { ok: false }; + let order: CanonicalOrder | undefined; + let digest: ReturnType | undefined; + if (bind) { + try { + order = canonicalOrder({ ...body, handle: args.handle, tokenAddress: token, merchant, chainId }); + digest = orderDigest(order); + } catch { + // Malformed order fields must not become a server error or bypass binding verification. + return { ok: false }; + } + } + const salt = bind && digest ? orderBindSalt(digest, bind.secret) : null; + const verified = (value: bigint, fee: bigint): Result => value < ORDER_DUST_FLOOR_WEI + ? { ok: false } + : { ok: true, kind: 'relay', value, sameSourceFeeValue: fee, + ...(order ? { order, digest } : { bindingMissing: true as const }) }; + if (settlements.length > 0 && forwarder) { + const matches = settlements.filter((s) => same(s.merchant, merchant) && + auths.some((a) => same(a.authorizer, s.from) && same(a.nonce, s.nonce)) && + (!bind || same(buildForwarderNonce({ + ...s, validAfter: BigInt(bind.validAfter), validBefore: BigInt(bind.validBefore), intentSalt: salt!, + }, chainId, forwarder), s.nonce))); + // Multiple matching settlements cannot be assigned to one public order. For a selected + // trusted settlement use ONLY its amount/fee, never other customers' receipt-wide totals. + if (matches.length !== 1) return { ok: false }; + const s = matches[0]; + if (!transfers.some((t) => same(t.from, forwarder) && same(t.to, merchant) && t.value === s.merchantValue)) return { ok: false }; + return verified(s.merchantValue, args.feeReceiver && same(args.feeReceiver, s.feeReceiver) ? s.feeValue : 0n); + } + // Free mode has no recipient in AuthorizationUsed. Reject ambiguous batches rather than + // attributing an unrelated transfer to the nonce. A forwarder leg without Settled is not free. + if (auths.length !== 1 || transfers.length !== 1 || + logs.some((log) => !!forwarder && same(log.address, forwarder) && same(log.topics[0] ?? '', SETTLED_TOPIC))) return { ok: false }; + const auth = auths[0]; + const transfer = transfers[0]; + if (!same(transfer.from, auth.authorizer) || !same(transfer.to, merchant) || + (forwarder && (same(transfer.from, forwarder) || same(transfer.to, forwarder))) || + (salt && !same(auth.nonce, salt))) return { ok: false }; + return verified(transfer.value, 0n); +} diff --git a/lib/orderBind.ts b/lib/orderBind.ts new file mode 100644 index 00000000..ddc0f846 --- /dev/null +++ b/lib/orderBind.ts @@ -0,0 +1,84 @@ +// Shared browser/server v1 schema. Only this module canonicalizes an order for binding. +// The wire snapshot is a fixed point: trimming AFTER truncation/control removal prevents +// a second server pass from changing the signed payload. NFC is explicit, before sanitation. +import { encodeAbiParameters, getAddress, isAddress, keccak256, parseAbiParameters, toHex, type Address, type Hex } from 'viem'; +import { normalizeHandle, isValidHandleFormat } from '@/lib/handle'; +import { isOrderTokenLike } from '@/lib/orderToken'; +import { ORDER_ID_MAX, sanitizeOrderItems, sanitizeOrderMemo, sanitizeTable } from '@/lib/orderRelay'; + +export type CanonicalOrder = { + chainId: number; + tokenAddress: Address; + merchant: Address; + handle: string; + orderId: string; + items: { name: string; qty: number; price: string }[]; + pickupAt: number; + statusToken: string; + customerMemo: string; + description: string; +}; +export type OrderBind = { v: 1; secret: Hex; validAfter: string; validBefore: string }; +export const ORDER_BIND_TAG = keccak256(toHex('openpay.order-bind.v1')); +const SALT_TAG = keccak256(toHex('openpay.order-bind.salt.v1')); +// Fixed absent values: items=[], optional strings='', pickupAt=0. Price stays a string. +const SCHEMA = parseAbiParameters('bytes32,uint256,address,address,string,string,(string name,uint256 qty,string price)[],uint256,string,string,string'); +const nfc = (value: unknown) => typeof value === 'string' ? value.normalize('NFC') : value; +// sanitizeOrderItems/Table truncate UTF-16. Drop a dangling high surrogate so encoding does +// not replace it on one side of the browser→JSON→server boundary only. +const finalText = (value: string) => value.replace(/[\uD800-\uDBFF]$/, '').trim().normalize('NFC'); + +export function canonicalOrder(input: Record): CanonicalOrder { + const handle = normalizeHandle(typeof input.handle === 'string' ? input.handle.normalize('NFC') : ''); + if (!Number.isSafeInteger(input.chainId) || Number(input.chainId) <= 0 || + typeof input.tokenAddress !== 'string' || !isAddress(input.tokenAddress) || + typeof input.merchant !== 'string' || !isAddress(input.merchant) || + !isValidHandleFormat(handle) || typeof input.orderId !== 'string' || !input.orderId.trim()) { + throw new Error('order_binding_mismatch'); + } + const statusToken = input.statusToken ?? ''; + if (statusToken !== '' && !isOrderTokenLike(statusToken)) throw new Error('order_binding_mismatch'); + const pickupAt = input.pickupAt ?? 0; + if (typeof pickupAt !== 'number' || !Number.isSafeInteger(pickupAt) || pickupAt < 0) throw new Error('order_binding_mismatch'); + const orderId = finalText(input.orderId.normalize('NFC').trim().slice(0, ORDER_ID_MAX)); + if (!orderId) throw new Error('order_binding_mismatch'); + const rawItems = Array.isArray(input.items) ? input.items.map((item: unknown) => { + if (!item || typeof item !== 'object') return item; + const o = item as Record; + return { ...o, name: nfc(o.name) }; + }) : input.items; + return { + chainId: Number(input.chainId), + tokenAddress: getAddress(input.tokenAddress).toLowerCase() as Address, + merchant: getAddress(input.merchant).toLowerCase() as Address, + handle, + orderId, + items: sanitizeOrderItems(rawItems).map(({ name, qty, price }) => ({ name: finalText(name), qty, price })).filter((item) => item.name.length > 0), + pickupAt, + statusToken: statusToken as string, + customerMemo: finalText(sanitizeOrderMemo(nfc(input.customerMemo)) ?? ''), + description: finalText(sanitizeTable(nfc(input.description)) ?? ''), + }; +} + +// Input is the canonical snapshot, never a second sanitization pass inside the digest. +export function orderDigest(order: CanonicalOrder): Hex { + return keccak256(encodeAbiParameters(SCHEMA, [ + ORDER_BIND_TAG, BigInt(order.chainId), order.tokenAddress, order.merchant, + order.handle, order.orderId, order.items.map((item) => ({ ...item, qty: BigInt(item.qty) })), + BigInt(order.pickupAt), order.statusToken, order.customerMemo, order.description, + ])); +} +export function orderBindSalt(digest: Hex, secret: Hex): Hex { + return keccak256(encodeAbiParameters(parseAbiParameters('bytes32,bytes32,bytes32'), [SALT_TAG, digest, secret])); +} +export function isUint256Decimal(value: unknown): value is string { + return typeof value === 'string' && /^(0|[1-9][0-9]{0,77})$/.test(value) && BigInt(value) < 2n ** 256n; +} +export function parseOrderBind(value: unknown): OrderBind | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const o = value as Record; + if (o.v !== 1 || typeof o.secret !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(o.secret) || + !isUint256Decimal(o.validAfter) || !isUint256Decimal(o.validBefore) || BigInt(o.validBefore) <= BigInt(o.validAfter)) return null; + return { v: 1, secret: o.secret as Hex, validAfter: o.validAfter, validBefore: o.validBefore }; +} diff --git a/lib/orderDelivery.ts b/lib/orderDelivery.ts new file mode 100644 index 00000000..1585a9e8 --- /dev/null +++ b/lib/orderDelivery.ts @@ -0,0 +1,162 @@ +'use client'; + +import { isAddress, type Address, type Hex } from 'viem'; +import { canonicalOrder, isUint256Decimal, orderBindSalt, orderDigest, parseOrderBind, type CanonicalOrder, type OrderBind } from '@/lib/orderBind'; +import type { RelayIntentMetadata } from '@/lib/paymentIntentStorage'; +import { buildForwarderNonce } from '@/lib/relay/forwarderIntent'; + +export { matchesOrderDeliveryContext } from '@/lib/orderDeliveryContext'; + +export const ORDER_DELIVERY_KEY = 'openpay:undelivered-order:v1'; +export type OrderDelivery = { + version: 1; + order: CanonicalOrder; + bind: OrderBind; + intent: RelayIntentMetadata; + forwarder: Address | null; + feeReceiver?: Address; + state: 'signed' | 'notify-pending' | 'terminal'; + txHash?: Hex; + notifyBody?: string; +}; +const hex32 = (s: unknown): s is Hex => typeof s === 'string' && /^0x[0-9a-fA-F]{64}$/.test(s); +const address = (s: unknown): s is Address => typeof s === 'string' && isAddress(s); +const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); +export function sameOrderAuthorization(record: OrderDelivery, intent: RelayIntentMetadata): boolean { + return record.intent.chainId === intent.chainId && same(record.intent.from, intent.from) && + same(record.intent.nonce, intent.nonce) && same(record.intent.merchant, intent.merchant) && + record.intent.merchantValue === intent.merchantValue && record.intent.feeValue === intent.feeValue && + record.intent.validBefore === intent.validBefore && record.intent.routeKind === intent.routeKind; +} +function bodyFor(record: OrderDelivery, txHash: Hex): string { + const { tokenAddress: _tokenAddress, handle: _handle, ...order } = record.order; + return JSON.stringify({ type: 'openpay.checkout.success', mode: 'relay', token: 'jpyc', + ...order, statusToken: order.statusToken || undefined, from: record.intent.from, txHash, bind: record.bind, ts: record.intent.issuedAt }); +} +export function resolveOrderDelivery(record: OrderDelivery, txHash: Hex): OrderDelivery { + return { ...record, state: 'notify-pending', txHash, notifyBody: bodyFor(record, txHash) }; +} +function parseRecord(value: unknown): OrderDelivery | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const o = value as OrderDelivery; + if (o.version !== 1 || !o.order || !o.intent || !parseOrderBind(o.bind) || + (o.state !== 'signed' && o.state !== 'notify-pending' && o.state !== 'terminal')) return null; + const canonical = canonicalOrder(o.order); + if (JSON.stringify(canonical) !== JSON.stringify(o.order)) return null; + const i = o.intent; + if (i.chainId !== canonical.chainId || !address(i.from) || !address(i.merchant) || + !same(i.merchant, canonical.merchant) || !hex32(i.nonce) || + !isUint256Decimal(i.merchantValue) || BigInt(i.merchantValue) <= 0n || !isUint256Decimal(i.feeValue) || + i.validBefore !== o.bind.validBefore || !Number.isSafeInteger(i.issuedAt) || i.issuedAt <= 0) return null; + const salt = orderBindSalt(orderDigest(canonical), o.bind.secret); + if (i.routeKind === 'free') { + if (o.forwarder !== null || i.feeValue !== '0' || !same(i.nonce, salt)) return null; + } else if (i.routeKind === 'recover' && address(o.forwarder)) { + // The original fee receiver is part of the signed commitment, not today's config. + if (!address(o.feeReceiver)) return null; + const feeReceiver = o.feeReceiver!; + if (!same(i.nonce, buildForwarderNonce({ from: i.from, merchant: i.merchant, + merchantValue: BigInt(i.merchantValue), feeReceiver, feeValue: BigInt(i.feeValue), + validAfter: BigInt(o.bind.validAfter), validBefore: BigInt(o.bind.validBefore), intentSalt: salt, + }, i.chainId, o.forwarder))) return null; + } else return null; + if (o.state !== 'signed') { + if (!hex32(o.txHash) || o.notifyBody !== bodyFor(o, o.txHash)) return null; + } else if (o.txHash !== undefined || o.notifyBody !== undefined) return null; + return o; +} +// Each authorization owns a slot. A failed delivery for another order must not block payment. +export function orderDeliveryKey(record: OrderDelivery): string { + return `${ORDER_DELIVERY_KEY}:${record.order.chainId}:${record.order.tokenAddress.toLowerCase()}:${record.intent.from.toLowerCase()}:${record.intent.nonce.toLowerCase()}`; +} +export function loadOrderDeliveries(): { records: OrderDelivery[]; unavailable: boolean } { + const records = new Map(); + const terminals = new Map(); + let unavailable = false; + try { + const keys = [ORDER_DELIVERY_KEY]; + for (let i = 0; i < window.sessionStorage.length; i++) { + const key = window.sessionStorage.key(i); + if (key?.startsWith(`${ORDER_DELIVERY_KEY}:`)) keys.push(key); + } + for (const key of keys) { + try { + const raw = window.sessionStorage.getItem(key); + if (raw === null) continue; + const value = JSON.parse(raw); + // Legacy prepared records may be unsigned; never recover an abandoned wallet prompt. + if (value?.version === 1 && value.state === 'prepared') continue; + const record = parseRecord(value); + if (!record || (key !== ORDER_DELIVERY_KEY && key !== orderDeliveryKey(record))) throw new Error('invalid_order_delivery'); + records.set(orderDeliveryKey(record), record.state === 'terminal' ? null : record); + if (record.state === 'terminal') terminals.set(key, record); + } catch { + // Corrupt slots are isolated: do not fabricate their opening or hide valid other orders. + // An invalid newer slot must also prevent falling back to its stale legacy record. + records.set(key, null); + unavailable = true; + } + } + for (const [key, record] of terminals) { + if (records.get(orderDeliveryKey(record)) === null) { + // Remove a terminal slot together with its legacy copy so cleanup cannot resurrect it. + if (!acknowledgeOrderDelivery(record)) unavailable = true; + } else { + // A newer live slot supersedes this legacy terminal; remove only the obsolete copy. + window.sessionStorage.removeItem(key); + } + } + } catch { + // Storage denial must be observable without substituting a different checkout's payment. + unavailable = true; + } + return { records: [...records.values()].filter((r): r is OrderDelivery => r !== null) + .sort((a, b) => b.intent.issuedAt - a.intent.issuedAt), unavailable }; +} +export function loadOrderDelivery(intent?: RelayIntentMetadata): { kind: 'empty' } | { kind: 'unavailable' } | { kind: 'ready'; record: OrderDelivery } { + const loaded = loadOrderDeliveries(); + const record = intent ? loaded.records.find((r) => sameOrderAuthorization(r, intent)) : loaded.records[0]; + return record ? { kind: 'ready', record } : loaded.unavailable ? { kind: 'unavailable' } : { kind: 'empty' }; +} +export function saveOrderDelivery(record: OrderDelivery): boolean { + try { + if (!parseRecord(record)) return false; + const key = orderDeliveryKey(record); + const raw = window.sessionStorage.getItem(key); + if (raw !== null) { + const existing = parseRecord(JSON.parse(raw)); + // Updating one nonce cannot overwrite an opening for a different authorization window. + if (!existing || !sameOrderAuthorization(existing, record.intent)) return false; + } + window.sessionStorage.setItem(key, JSON.stringify(record)); + return true; + } catch { + // Storage failure must stop a new bound payment, but never turn a mined payment into failure. + return false; + } +} +export function acknowledgeOrderDelivery(record: OrderDelivery): boolean { + try { + for (const key of [ORDER_DELIVERY_KEY, orderDeliveryKey(record)]) { + const raw = window.sessionStorage.getItem(key); + if (raw === null) continue; + const stored = parseRecord(JSON.parse(raw)); + if (!stored || !sameOrderAuthorization(stored, record.intent)) { + if (key !== ORDER_DELIVERY_KEY) return false; + continue; + } + window.sessionStorage.removeItem(key); + } + return true; + } catch { + // Cleanup failure retains the opening; another order's slot is never removed. + return false; + } +} +// Only call for proven non-broadcast, reverted, or expired-unused authorizations. +export const abandonOrderDelivery = acknowledgeOrderDelivery; + +export function terminateOrderDelivery(record: OrderDelivery): boolean { + // A terminal slot shadows a matching legacy record and leaves every other nonce untouched. + return saveOrderDelivery({ ...record, state: 'terminal' }); +} diff --git a/lib/orderDeliveryContext.ts b/lib/orderDeliveryContext.ts new file mode 100644 index 00000000..cbec679d --- /dev/null +++ b/lib/orderDeliveryContext.ts @@ -0,0 +1,31 @@ +import type { CanonicalOrder } from '@/lib/orderBind'; +import type { RelayIntentMetadata } from '@/lib/paymentIntentStorage'; + +export type OrderDeliveryContext = { + merchant: string; + chainId: number; + tokenAddress: string; + webhook?: string; + orderId?: string; + totalValue: bigint; +}; + +// Match lib/handle's normalization without its storefront dependencies on first render. +const normalizeHandle = (raw: string) => raw.trim().replace(/^@+/, '').toLowerCase(); + +// Keep this matcher free of ABI/storage imports: CheckoutForm uses it on first render. +export function matchesOrderDeliveryContext(order: CanonicalOrder, context: OrderDeliveryContext, origin: string, intent: Pick): boolean { + if (context.chainId !== order.chainId || + context.merchant.toLowerCase() !== order.merchant.toLowerCase() || + context.tokenAddress.toLowerCase() !== order.tokenAddress.toLowerCase() || + (context.orderId !== undefined && context.orderId !== order.orderId) || !context.webhook) return false; + try { + if (BigInt(intent.merchantValue) + BigInt(intent.feeValue) !== context.totalValue) return false; + const url = new URL(context.webhook); + return url.origin === origin && url.pathname === '/api/order/notify' && + url.searchParams.getAll('h').length === 1 && normalizeHandle(url.searchParams.get('h')!) === order.handle; + } catch { + // An invalid current callback cannot attribute a different checkout's saved payment here. + return false; + } +} diff --git a/lib/orderDeliveryRecovery.ts b/lib/orderDeliveryRecovery.ts new file mode 100644 index 00000000..9105d8f2 --- /dev/null +++ b/lib/orderDeliveryRecovery.ts @@ -0,0 +1,78 @@ +'use client'; + +import type { Hex } from 'viem'; +import type { OrderDelivery } from '@/lib/orderDelivery'; +import { resolveRelayIntent, type RelayRecoveryOutcome } from '@/lib/relay/relayIntentRecovery'; +import { AUTHORIZATION_VALIDITY_WINDOW_SEC } from '@/lib/jpycEip3009'; + +export function orderPaymentHoldUntil(record: OrderDelivery, loadedAt: number): number { + // A future browser timestamp must not stretch this checkout's hold beyond five minutes. + return Math.min(Number(record.intent.validBefore) * 1000, + record.intent.issuedAt + AUTHORIZATION_VALIDITY_WINDOW_SEC * 1000, + loadedAt + AUTHORIZATION_VALIDITY_WINDOW_SEC * 1000); +} + +// A background reader owns its timers/abort controller, never the current payment's recovery latch. +export function recoverOrderDelivery( + record: OrderDelivery, + waitForReceipt: (hash: Hex, timeout: number) => Promise<{ status: 'success' | 'reverted' }>, + onResolved: (outcome: Exclude) => void, + { loadedAt, onHoldReleased }: { loadedAt: number; onHoldReleased: () => void }, +): () => void { + let active = true; + let generation = 0; + let cancelRound: (() => void) | undefined; + const holdUntil = orderPaymentHoldUntil(record, loadedAt); + const run = async (singleRead: boolean) => { + const current = ++generation; + const isCurrent = () => active && current === generation; + const sleeps = new Map, () => void>(); + const requests = new Map, AbortController>(); + cancelRound = () => { + for (const [timer, wake] of sleeps) { clearTimeout(timer); wake(); } + for (const [timer, controller] of requests) { clearTimeout(timer); controller.abort(); } + }; + // Retry live authorizations read-only. After the expiry checkpoint, keep one ordinary + // round for unused/revert evidence: a single unused read must not delete the opening. + do { + const outcome = await resolveRelayIntent({ + intent: record.intent, isMounted: isCurrent, + registerSleep: (timer, wake) => { sleeps.set(timer, wake); }, + clearSleep: (timer) => { sleeps.delete(timer); }, + registerFetch: (timer, controller) => { requests.set(timer, controller); }, + clearFetch: (timer) => { requests.delete(timer); }, + waitForReceipt, + }, { singleRead }); + // An earlier round's in-flight receipt must not release or overwrite the expiry check. + if (!isCurrent()) return; + if (outcome.kind !== 'unknown') { + clearTimeout(expiryTimer); + onResolved(outcome); + return; + } + if (singleRead) { + onHoldReleased(); + singleRead = false; + } else if (Date.now() >= holdUntil) return; + } while (isCurrent()); + }; + const start = (singleRead: boolean) => { + void run(singleRead).catch(() => { + // RPC/storage failures retain the opening rather than fabricate payment or abandonment. + // At expiry an unsuccessful read releases only the hold, with staff-assistance wording. + if (active && singleRead) onHoldReleased(); + }); + }; + const expiryTimer = setTimeout(() => { + // Supersede a sleeping round or a pre-expiry request; neither counts as the fresh check. + generation++; + cancelRound?.(); + start(true); + }, Math.max(0, holdUntil - Date.now())); + start(false); + return () => { + active = false; + clearTimeout(expiryTimer); + cancelRound?.(); + }; +} diff --git a/lib/orderRelay.ts b/lib/orderRelay.ts index c87d2ae3..286b5688 100644 --- a/lib/orderRelay.ts +++ b/lib/orderRelay.ts @@ -51,6 +51,8 @@ export type StoredOrderItem = { }; export type StoredOrder = { + bindingMissing?: true; // Migration only: staff must manually verify before handover. + bindingDigest?: string; // Verified v1 commitment, preserved through fulfillment/fee updates. orderId: string; items: StoredOrderItem[]; // 顧客申告 (表示用・突合は店主) table: string | null; // 顧客申告: テーブル番号ラベル (description 由来)。テイクアウトは null。 @@ -380,6 +382,8 @@ export function parseStoredOrder(raw: string): StoredOrder | null { order.readyAt = o.readyAt; } } + if (o.bindingMissing === true) order.bindingMissing = true; + if (typeof o.bindingDigest === 'string' && /^0x[0-9a-fA-F]{64}$/.test(o.bindingDigest)) order.bindingDigest = o.bindingDigest; if (o.amountMismatch === true) order.amountMismatch = true; if (o.amountUnchecked === true) order.amountUnchecked = true; if (o.feeUncollected === true) { diff --git a/lib/relay/relayIntentRecovery.ts b/lib/relay/relayIntentRecovery.ts index 903facb1..a118cac8 100644 --- a/lib/relay/relayIntentRecovery.ts +++ b/lib/relay/relayIntentRecovery.ts @@ -94,12 +94,15 @@ async function readStatus( export async function resolveRelayIntent( runtime: RecoveryRuntime, + { singleRead = false }: { singleRead?: boolean } = {}, ): Promise { - const deadline = Date.now() + DEADLINE_MS; + // Background order holds need a fresh read at expiry without waiting through backoff. + // Its status + receipt work share the fetch timeout; ordinary recovery is unchanged. + const deadline = Date.now() + (singleRead ? FETCH_TIMEOUT_MS : DEADLINE_MS); let consecutiveUnused = 0; - for (const delayMs of BACKOFF_MS) { - if (!(await sleep(delayMs, runtime))) return { kind: 'unknown' }; + for (const delayMs of singleRead ? [0] : BACKOFF_MS) { + if (delayMs ? !(await sleep(delayMs, runtime)) : !runtime.isMounted()) return { kind: 'unknown' }; if (Date.now() > deadline) break; const status = await readStatus(runtime, deadline); diff --git a/messages/en.json b/messages/en.json index 9fb04b7d..8932bad6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1507,7 +1507,15 @@ "returnHomeButton": "Return to OpenPay", "urlInvalidTitle": "Invalid Checkout URL", "urlExample": "Example URL: /checkout?to=0x...&token=usdc&items=T-shirt:1:25", - "poweredBy": "powered by" + "poweredBy": "powered by", + "orderBindingStorageUnavailable": "Payment cannot start because this browser could not save the order recovery information. Check your browser storage settings.", + "retryOrderNotify": "Retry order registration", + "previousPaymentConfirming": "Your previous payment is still being confirmed. Please do not pay again yet.", + "previousPaymentUnconfirmed": "We could not confirm your previous payment. Check with store staff before paying again.", + "previousPaymentConfirmingOther": "A previous payment is still being confirmed. You can still pay this store.", + "orderBindingRestoreUnavailable": "Previous order recovery information could not be read. If you already paid, check with store staff before paying again.", + "previousOrderNotifyPending": "A previous order is awaiting registration. You can still make this payment.", + "previousOrderNotifyStaff": "A previous order could not be registered. Show its payment history or transaction to that store’s staff." }, "CopyableField": { "copy": "Copy", @@ -3003,5 +3011,9 @@ "sellerOperator": "Seller: OpenPay (operator)", "sellerThirdParty": "Seller: Third-party seller ({name})", "providerThirdParty": "Seller: Third-party seller" + }, + "OrderBinding": { + "unverified": "Payment/order association unverified — manual verification required before handover", + "scope": "Standard ERC-20 transfers and non-relay ERC-4337/7702 gasless payments do not cryptographically verify the association between payment and order." } } diff --git a/messages/ja.json b/messages/ja.json index 4c4387a9..5834e895 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1507,7 +1507,15 @@ "returnHomeButton": "OpenPay ホームへ戻る", "urlInvalidTitle": "Checkout URL が不正です", "urlExample": "正しい URL の例: /checkout?to=0x...&token=usdc&items=Tシャツ:1:25", - "poweredBy": "powered by" + "poweredBy": "powered by", + "orderBindingStorageUnavailable": "注文の復元情報をこのブラウザに保存できないため、支払いを開始できません。ブラウザの保存設定を確認してください。", + "retryOrderNotify": "注文登録を再試行", + "previousPaymentConfirming": "前回の支払いを確認中です。まだ支払い直さないでください。", + "previousPaymentUnconfirmed": "前回の支払いの状態を確認できませんでした。支払い直す前にお店のスタッフに確認してください。", + "previousPaymentConfirmingOther": "以前のお支払いを確認中です。このお店へのお支払いは続けられます。", + "orderBindingRestoreUnavailable": "以前の注文の復元情報を読み込めませんでした。支払い済みの場合は、支払い直さずお店のスタッフに確認してください。", + "previousOrderNotifyPending": "以前の注文の登録が保留中です。このお支払いは続けられます。", + "previousOrderNotifyStaff": "以前の注文を登録できませんでした。そのお店のスタッフに支払い履歴またはトランザクションを提示してください。" }, "CopyableField": { "copy": "コピー", @@ -3003,5 +3011,9 @@ "sellerOperator": "販売者: OpenPay (運営)", "sellerThirdParty": "販売者: 第三者出品者 ({name})", "providerThirdParty": "販売者: 第三者出品者" + }, + "OrderBinding": { + "unverified": "支払いと注文の結びつき未確認 — 受け渡し前に手動確認が必要", + "scope": "標準 ERC-20 送金と relay を使わない ERC-4337/7702 ガスレス決済では、支払いと注文の結びつきは暗号学的に検証されません。" } } diff --git a/tests/_helpers/orderBinding.ts b/tests/_helpers/orderBinding.ts new file mode 100644 index 00000000..53bcc5bb --- /dev/null +++ b/tests/_helpers/orderBinding.ts @@ -0,0 +1,30 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbi, type Address, type Hex } from 'viem'; +import { canonicalOrder, orderBindSalt, orderDigest } from '@/lib/orderBind'; +import { buildForwarderNonce } from '@/lib/relay/forwarderIntent'; +import type { FeeReceiptLog } from '@/lib/feeVerify'; +import type { OrderDelivery } from '@/lib/orderDelivery'; +export const BIND_FORWARDER: Address = '0x3333333333333333333333333333333333333333'; +export const BIND_PAYER: Address = '0x2222222222222222222222222222222222222222'; +export const BIND_FEE: Address = '0x1111111111111111111111111111111111111111'; +export const BIND_SECRET = `0x${'12'.repeat(32)}` as Hex; +export const BIND_TX = `0x${'ab'.repeat(32)}` as Hex; +const ABI = parseAbi([ + 'event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)', + 'event Transfer(address indexed from, address indexed to, uint256 value)', + 'event Settled(address indexed from, bytes32 indexed nonce, address indexed merchant, uint256 merchantValue, address feeReceiver, uint256 feeValue)', +]); +export function bindTransfer(token: Address, from: Address, to: Address, value: bigint): FeeReceiptLog { + return { address: token, topics: encodeEventTopics({ abi: ABI, eventName: 'Transfer', args: { from, to } }) as Hex[], data: encodeAbiParameters([{ type: 'uint256' }], [value]) }; +} +export function bindingFixture(mode: 'free' | 'recover', input: Record, value = 1000n * 10n ** 18n, feeValue = 13n * 10n ** 18n) { + const order = canonicalOrder(input); + // Intentionally expired: mined authorizations must use their original window, not wall-clock expiry. + const bind = { v: 1 as const, secret: BIND_SECRET, validAfter: '0', validBefore: '1700000000' }; + const salt = orderBindSalt(orderDigest(order), bind.secret); + const nonce = mode === 'free' ? salt : buildForwarderNonce({ from: BIND_PAYER, merchant: order.merchant, merchantValue: value, feeReceiver: BIND_FEE, feeValue, validAfter: 0n, validBefore: BigInt(bind.validBefore), intentSalt: salt }, order.chainId, BIND_FORWARDER); + const auth: FeeReceiptLog = { address: order.tokenAddress, topics: encodeEventTopics({ abi: ABI, eventName: 'AuthorizationUsed', args: { authorizer: BIND_PAYER, nonce } }) as Hex[], data: '0x' }; + const settled: FeeReceiptLog = { address: BIND_FORWARDER, topics: encodeEventTopics({ abi: ABI, eventName: 'Settled', args: { from: BIND_PAYER, nonce, merchant: order.merchant } }) as Hex[], data: encodeAbiParameters([{ type: 'uint256' }, { type: 'address' }, { type: 'uint256' }], [value, BIND_FEE, feeValue]) }; + const logs = mode === 'free' ? [auth, bindTransfer(order.tokenAddress, BIND_PAYER, order.merchant, value)] : [auth, bindTransfer(order.tokenAddress, BIND_PAYER, BIND_FORWARDER, value + feeValue), bindTransfer(order.tokenAddress, BIND_FORWARDER, order.merchant, value), bindTransfer(order.tokenAddress, BIND_FORWARDER, BIND_FEE, feeValue), settled]; + const record: OrderDelivery = { version: 1, order, bind, state: 'signed', forwarder: mode === 'recover' ? BIND_FORWARDER : null, ...(mode === 'recover' ? { feeReceiver: BIND_FEE } : {}), intent: { chainId: order.chainId, from: BIND_PAYER, merchant: order.merchant, merchantValue: value.toString(), feeValue: mode === 'recover' ? feeValue.toString() : '0', nonce, routeKind: mode, validBefore: bind.validBefore, issuedAt: 1699990000000 } }; + return { order, bind, salt, nonce, logs, record, body: { ...order, token: 'jpyc', txHash: BIND_TX, bind } }; +} diff --git a/tests/app/api/order-feed.test.ts b/tests/app/api/order-feed.test.ts index a3c991e5..f0749d5f 100644 --- a/tests/app/api/order-feed.test.ts +++ b/tests/app/api/order-feed.test.ts @@ -517,3 +517,11 @@ describe('受注閲覧トークン認可 (x-order-token・資金鍵なしの店 expect(evalSpy).not.toHaveBeenCalled(); }); }); + +it('A2c binding marker survives feed read and fulfillment serialization', async () => { + hold.lrange = { ok: true, value: [serializeOrder(order({ bindingMissing: true }))] }; + expect((await (await GET(getReq())).json()).orders[0].bindingMissing).toBe(true); + expect((await POST(postReq({ txHash: TX, fulfilled: true }))).status).toBe(200); + const args = evalSpy.mock.calls[0][2] as string[]; + expect(JSON.parse(args[1])).toMatchObject({ bindingMissing: true, fulfilled: true }); +}); diff --git a/tests/app/api/order-notify.test.ts b/tests/app/api/order-notify.test.ts index f723e95a..233721de 100644 --- a/tests/app/api/order-notify.test.ts +++ b/tests/app/api/order-notify.test.ts @@ -2,6 +2,9 @@ // flag OFF=404 / handle 束縛 / txHash 冪等 / on-chain 検証 (pass/fail/rpc) / KV 必須 (mainnet) / // レート制限。chains/tokens は実値 (chainId=137)・verifyJpycTransferToOnChain と KV は mock。 +import type { FeeReceiptLog } from '@/lib/feeVerify'; +import { bindingFixture, BIND_FORWARDER, BIND_PAYER, bindTransfer } from '../../_helpers/orderBinding'; +import { resolveDeployment } from '@/lib/tokens'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const JPYC = 10n ** 18n; @@ -16,6 +19,7 @@ const STATUS_TOKEN = 'p'.repeat(43); // 顧客生成の status トークン (43 // 注意: vi.hoisted は top-level const より先に走るため、ここで MERCHANT/JPYC を参照すると TDZ。 // 初期値は安全なリテラル/null にし、実値は beforeEach で設定する。 const hold = vi.hoisted(() => ({ + forwarder: null as `0x${string}` | null, enableOrderRelay: true, enableOrderPickup: true, // 顧客向け注文状況の逆引きポインタ保存のゲート enablePushNotify: true, @@ -51,6 +55,7 @@ const hold = vi.hoisted(() => ({ directValue?: bigint; merchantSource?: string; sameSourceFeeValue?: bigint; + receiptLogs?: FeeReceiptLog[]; } | { ok: false; reason: string }, feePairVerify: { @@ -58,16 +63,18 @@ const hold = vi.hoisted(() => ({ value: 10n ** 18n, blockNumber: 2n, } as - | { ok: true; value: bigint; blockNumber: bigint } + | { ok: true; value: bigint; blockNumber: bigint; receiptLogs?: FeeReceiptLog[] } | { ok: false; reason: string }, feePairVerifyQueue: [] as ( - | { ok: true; value: bigint; blockNumber: bigint } + | { ok: true; value: bigint; blockNumber: bigint; receiptLogs?: FeeReceiptLog[] } | { ok: false; reason: string } )[], })); const pushNotify = vi.hoisted(() => ({ after: vi.fn(), + deferAfter: false, + deferred: [] as (() => unknown)[], afterTasks: [] as Promise[], notify: vi.fn(), })); @@ -82,6 +89,7 @@ vi.mock('next/server', () => ({ }, after: (cb: () => unknown) => { pushNotify.after(cb); + if (pushNotify.deferAfter) { pushNotify.deferred.push(cb); return; } try { pushNotify.afterTasks.push(Promise.resolve(cb())); } catch (e) { @@ -141,6 +149,7 @@ vi.mock('@/lib/feeVerify', () => ({ ); }, })); +vi.mock('@/lib/relay/forwarderConfig', async (original) => ({ ...await original(), configuredJpycForwarderFor: () => hold.forwarder })); vi.mock('@/lib/handleStore', () => ({ resolveHandle: vi.fn(async () => hold.handle), })); @@ -313,6 +322,7 @@ function partialOrderRaw( } async function flushAfterTasks() { + for (const cb of pushNotify.deferred.splice(0)) await cb(); await Promise.all(pushNotify.afterTasks); } @@ -325,6 +335,8 @@ beforeEach(() => { vi.setSystemTime(new Date('2026-09-06T12:00:00Z')); getBlockSpy.mockReset(); getBlockSpy.mockResolvedValue({ timestamp: BigInt(Date.now() / 1000) }); + hold.forwarder = null; + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', 'false'); hold.enableOrderRelay = true; hold.enableOrderPickup = true; hold.enablePushNotify = true; @@ -375,6 +387,8 @@ beforeEach(() => { warnSpy.mockClear(); pushNotify.after.mockClear(); pushNotify.afterTasks = []; + pushNotify.deferAfter = false; + pushNotify.deferred = []; pushNotify.notify.mockReset(); pushNotify.notify.mockResolvedValue(undefined); }); @@ -1284,3 +1298,145 @@ describe('POST /api/order/notify', () => { expect(setSpy.mock.calls.some((c) => String(c[0]).startsWith('order:sv:'))).toBe(false); }); }); + +describe('A2c binding rejection precedes side effects', () => { + it.each([false, true])('malformed present binding is rejected even with enforcement=%s', async (enforce) => { + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', enforce ? 'true' : 'false'); + const response = await POST(req(goodBody({ bind: { v: 2 }, statusToken: STATUS_TOKEN, feeTxHash: FEE_TXHASH }))); + expect(response.status).toBe(422); + expect(await response.json()).toEqual({ ok: false, error: 'order_binding_mismatch' }); + expect(lpushSpy).not.toHaveBeenCalled(); + expect(evalSpy).not.toHaveBeenCalled(); + expect(setSpy.mock.calls.some(([key]) => String(key).startsWith('order:sv:'))).toBe(false); + expect(pushNotify.after).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + it.each(['done', 'pending'] as const)('malformed %s replay never queues fee reconciliation', async (marker) => { + hold.claimValue = null; + hold.usedMarker = marker; + hold.listValues = [partialOrderRaw()]; + const response = await POST(req(goodBody({ bind: null, feeTxHash: FEE_TXHASH }))); + expect(response.status).toBe(422); + expect(pushNotify.after).not.toHaveBeenCalled(); + expect(evalSpy).not.toHaveBeenCalled(); + expect(delSpy).not.toHaveBeenCalled(); + }); +}); + +for (const mode of ['free', 'recover'] as const) { + describe(`A2c ${mode} on-chain binding`, () => { + function fixture() { + hold.forwarder = BIND_FORWARDER; + const f = bindingFixture(mode, { ...goodBody({ statusToken: STATUS_TOKEN, customerMemo: 'no ice', pickupAt: Date.now() + 60_000 }), handle: 'alice', tokenAddress: resolveDeployment('jpyc', 80002)!.address }); + hold.verify = { ok: true, value: 5000n * JPYC, receiptLogs: f.logs }; + return f; + } + it('accepts the opening and exact observed POST replay without using the receipt-wide amount', async () => { + const f = fixture(); + expect((await POST(req(f.body))).status).toBe(200); + expect(latestStoredOrder()).toMatchObject({ orderId: f.order.orderId, amount: String(1000n * JPYC), customerMemo: 'no ice', table: f.order.description }); + expect(latestStoredOrder().bindingDigest).toMatch(/^0x[0-9a-f]{64}$/); + hold.claimValue = null; + expect(await (await POST(req(f.body))).json()).toEqual({ ok: true, duplicate: true }); + }); + it.each(['items', 'statusToken', 'description', 'customerMemo', 'orderId', 'pickupAt', 'secret', 'salt'])('rejects substituted %s before all order/fee/push/pointer effects; original still succeeds', async (field) => { + const f = fixture(); + const changed: Record = { ...f.body, feeTxHash: FEE_TXHASH }; + if (field === 'items') changed.items = [{ name: 'stolen', qty: 1, price: '1000' }]; + else if (field === 'statusToken') changed.statusToken = 'q'.repeat(43); + else if (field === 'pickupAt') changed.pickupAt = f.order.pickupAt + 1; + else if (field === 'secret' || field === 'salt') changed.bind = { ...f.bind, secret: field === 'salt' ? f.salt : `0x${'00'.repeat(32)}` }; + else changed[field] = 'stolen'; + expect(await (await POST(req(changed))).json()).toEqual({ ok: false, error: 'order_binding_mismatch' }); + expect(lpushSpy).not.toHaveBeenCalled(); expect(evalSpy).not.toHaveBeenCalled(); expect(pushNotify.after).not.toHaveBeenCalled(); + expect(setSpy.mock.calls.some(([key]) => String(key).startsWith('order:sv:'))).toBe(false); + expect(delSpy).toHaveBeenCalled(); + expect((await POST(req(f.body))).status).toBe(200); + }); + it.each([false, true])('only completely absent binding receives migration treatment (enforce=%s)', async (enforce) => { + const f = fixture(); + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', enforce ? 'true' : 'false'); + const { bind: _bind, ...body } = f.body; + const res = await POST(req(body)); + expect(res.status).toBe(enforce ? 422 : 200); + if (!enforce) expect(latestStoredOrder().bindingMissing).toBe(true); + else expect(lpushSpy).not.toHaveBeenCalled(); + }); + it.each([false, true])('rejects incomplete binding under enforce=%s', async (enforce) => { + const f = fixture(); vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', enforce ? 'true' : 'false'); + expect((await POST(req({ ...f.body, bind: { v: 1 } }))).status).toBe(422); + }); + it.each(['done', 'pending'] as const)('substituted %s replay cannot reconcile fees or release someone else’s claim', async (marker) => { + const f = fixture(); hold.claimValue = null; hold.usedMarker = marker; + expect((await POST(req({ ...f.body, statusToken: 'q'.repeat(43), feeTxHash: FEE_TXHASH }))).status).toBe(422); + expect(pushNotify.after).not.toHaveBeenCalled(); expect(evalSpy).not.toHaveBeenCalled(); expect(delSpy).not.toHaveBeenCalled(); + }); + it('keeps first-notify freshness while acknowledging old completed identical notifications', async () => { + const f = fixture(); getBlockSpy.mockResolvedValue({ timestamp: BigInt(Date.now() / 1000 - 1801) }); + expect(await (await POST(req(f.body))).json()).toEqual({ ok: false, error: 'tx_too_old' }); + hold.claimValue = null; hold.usedMarker = 'done'; + expect(await (await POST(req(f.body))).json()).toEqual({ ok: true, duplicate: true }); + }); + it('rejects forged authorization emitters and missing settlement evidence', async () => { + const f = fixture(); + hold.verify = { ok: true, value: 1000n * JPYC, receiptLogs: f.logs.map((log, i) => i === 0 ? { ...log, address: OTHER } : log) }; + expect((await POST(req(f.body))).status).toBe(422); + hold.verify = { ok: true, value: 1000n * JPYC, receiptLogs: mode === 'recover' ? f.logs.slice(0, -1) : f.logs.slice(1) }; + expect((await POST(req(f.body))).status).toBe(422); + }); + it('rejects ambiguous free batches, but attributes recover only to the selected settlement', async () => { + const f = fixture(); + hold.verify = { ok: true, value: 9000n * JPYC, receiptLogs: [...f.logs, bindTransfer(f.order.tokenAddress, BIND_PAYER, f.order.merchant, 8000n * JPYC)] }; + const res = await POST(req(f.body)); + expect(res.status).toBe(mode === 'free' ? 422 : 200); + if (mode === 'recover') expect(latestStoredOrder().amount).toBe(String(1000n * JPYC)); + }); + }); +} + +it('A2c standard fee follow-up preserves original items, order and binding warning', async () => { + hold.claimValue = null; + hold.listValues = [partialOrderRaw(TXHASH, { bindingMissing: true, orderId: 'original', items: [{ name: 'original', qty: 1, price: '1000' }] })]; + expect(await (await POST(req(goodBody({ feeTxHash: FEE_TXHASH, orderId: 'substituted', statusToken: 'q'.repeat(43) })))).json()).toEqual({ ok: true, duplicate: true }); + await flushAfterTasks(); + expect(latestStoredOrder()).toMatchObject({ orderId: 'original', items: [{ name: 'original', qty: 1, price: '1000' }], bindingMissing: true }); + expect(latestStoredOrder().feeUncollected).toBeUndefined(); + expect(setSpy.mock.calls.some(([key]) => String(key).startsWith('order:sv:'))).toBe(false); +}); + + it.each(['done', 'pending'] as const)('A2c bind-less %s fee follow-up responds before RPC even with enforcement on', async (marker) => { + process.env.ENABLE_ORDER_BIND_ENFORCE = 'true'; + hold.claimValue = null; hold.usedMarker = marker; + hold.verify = { ok: false, reason: 'rpc_error' }; + const res = await POST(req(goodBody({ feeTxHash: FEE_TXHASH }))); + expect(res.status).toBe(marker === 'done' ? 200 : 409); + expect(verifySpy).not.toHaveBeenCalled(); +}); + +it.each(['free', 'recover'] as const)('A2c bind-less %s fee follow-up classifies relay evidence inside after and never claims a fee', async (mode) => { + const f = bindingFixture(mode, { chainId: 80002, tokenAddress: resolveDeployment('jpyc', 80002)!.address, merchant: MERCHANT, handle: 'alice', orderId: 'id', items: [{ name: 'Tea', qty: 1, price: '1000' }] }); + hold.forwarder = mode === 'recover' ? BIND_FORWARDER : null; + pushNotify.deferAfter = true; + hold.claimValue = null; + hold.listValues = [partialOrderRaw(TXHASH)]; + hold.feePairVerify = { ok: true, value: JPYC, blockNumber: 2n, receiptLogs: f.logs }; + const before = [...hold.listValues]; + const res = await POST(req(goodBody({ feeTxHash: FEE_TXHASH }))); + expect(await res.json()).toEqual({ ok: true, duplicate: true }); + expect(verifySpy).not.toHaveBeenCalled(); expect(feePairVerifySpy).not.toHaveBeenCalled(); + await flushAfterTasks(); + expect(feePairVerifySpy).toHaveBeenCalledOnce(); + expect(hold.listValues).toEqual(before); expect(evalSpy).not.toHaveBeenCalled(); +}); +it('A2c verified duplicate reports an OFF migration winner without acknowledging the victim order', async () => { + const f = bindingFixture('free', { chainId: 80002, tokenAddress: resolveDeployment('jpyc', 80002)!.address, merchant: MERCHANT, handle: 'alice', orderId: 'victim', items: [{ name: 'Tea', qty: 1, price: '1000' }], statusToken: STATUS_TOKEN }); + hold.verify = { ok: true, value: 1000n * JPYC, receiptLogs: f.logs }; + hold.claimValue = null; + hold.listValues = [partialOrderRaw(f.body.txHash, { bindingMissing: true })]; + const before = [...hold.listValues]; + expect(await (await POST(req(f.body))).json()).toEqual({ ok: true, duplicate: true, bindingConflict: true }); + expect(hold.listValues).toEqual(before); + expect(lpushSpy).not.toHaveBeenCalled(); expect(evalSpy).not.toHaveBeenCalled(); + expect(delSpy).not.toHaveBeenCalled(); expect(pushNotify.after).not.toHaveBeenCalled(); + expect(setSpy.mock.calls.some(([key]) => String(key).startsWith('order:sv:'))).toBe(false); +}); diff --git a/tests/components/CheckoutForm.test.tsx b/tests/components/CheckoutForm.test.tsx index 87227247..27816825 100644 --- a/tests/components/CheckoutForm.test.tsx +++ b/tests/components/CheckoutForm.test.tsx @@ -1,5 +1,10 @@ +import { logger } from '@/lib/logger'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { bindingFixture } from '../_helpers/orderBinding'; +import { resolveOrderDelivery, saveOrderDelivery, loadOrderDelivery } from '@/lib/orderDelivery'; +import type { OrderDelivery } from '@/lib/orderDelivery'; import type { ReactElement } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { renderWithIntl as render } from '../_helpers/i18n'; import userEvent from '@testing-library/user-event'; @@ -8,6 +13,8 @@ import type { Address } from 'viem'; import { isOrderTokenLike } from '@/lib/orderToken'; vi.mock('wagmi', () => ({ + useWalletClient: vi.fn(() => ({ data: undefined })), + usePublicClient: vi.fn(), useAccount: vi.fn(), useReadContract: vi.fn(), useSwitchChain: vi.fn(), @@ -243,6 +250,7 @@ function setRelayPayment( variables?: NonNullable< ReturnType['variables'] >; + orderDelivery?: OrderDelivery; restoredIntent?: ReturnType< typeof useJpycEip3009Payment >['restoredIntent']; @@ -279,6 +287,7 @@ function setRelayPayment( (state === 'response-unknown' ? 'exhausted' : null), variables: opts?.variables, restoredIntent: opts?.restoredIntent ?? null, + orderDelivery: opts?.orderDelivery ?? null, } as Partial>); } @@ -507,7 +516,10 @@ async function submitRelayThenSucceed( ) { await user.click(screen.getByRole('button', { name: /を支払う/ })); expect(relayMutate).toHaveBeenCalledOnce(); - setRelayPayment('success', opts); + const order = relayMutate.mock.calls[0][0].order; + const record = order ? resolveOrderDelivery(bindingFixture('free', order, opts.variables.value).record, opts.txHash) : undefined; + if (record) saveOrderDelivery(record); + setRelayPayment('success', { ...opts, orderDelivery: record }); rerender(makeUi()); } @@ -2455,7 +2467,7 @@ describe('CheckoutForm — JPYC EIP-3009 relay 経路', () => { )); expect(loggerWarn).not.toHaveBeenCalledWith('checkout.webhook.failed', expect.anything()); expect(screen.getByText('お支払いが完了しました')).toBeInTheDocument(); - expect(screen.queryByText(/支払い履歴またはトランザクションをお店のスタッフに提示/)).toBeNull(); + expect(screen.getByText(/支払い履歴またはトランザクションをお店のスタッフに提示/)).toBeInTheDocument(); expect(relayMutate).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledTimes(1); fetchSpy.mockRestore(); @@ -2941,7 +2953,7 @@ describe('CheckoutForm — モバイル注文 / レジ システム利用料 (fl setupJpycStandard(); const fetchSpy = vi .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(null, { status: 409 })) + .mockResolvedValueOnce(Response.json({ ok: false, error: 'processing' }, { status: 409 })) .mockResolvedValueOnce(new Response(null, { status: 200 })); const params: CheckoutParams = { ...JPYC_PARAMS, @@ -3121,6 +3133,7 @@ describe('CheckoutForm — モバイル注文 / レジ システム利用料 (fl expect(fetchSpy).not.toHaveBeenCalled(); expect(loadHistory()).toHaveLength(0); expect(loadPayerReceipts()).toHaveLength(0); + expect(screen.queryByText(/再度支払わず/)).toBeNull(); fetchSpy.mockRestore(); }); @@ -3492,3 +3505,424 @@ describe('CheckoutForm — F7 off-origin callback 開示', () => { expect(screen.queryByText(/に通知・遷移します/)).toBeNull(); }); }); + +describe('A2c saved-order-only notification', () => { + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + const savedParams = { ...JPYC_PARAMS, orderId: 'saved-id', webhook: `${location.origin}/api/order/notify?h=alice` }; + function setupRelayReady() { + vi.mocked(resolveJpycGaslessProvider).mockReturnValue('eip3009-relay'); + setAccount({ connected: true, chainId: polygonAmoy.id }); + setBalance(10_000n * 10n ** 18n); + } + function savedRecord() { + const dep = deploymentForSlug('jpyc', 'polygon'); + return resolveOrderDelivery(bindingFixture('free', { chainId: dep.chainId, tokenAddress: dep.address, merchant: MERCHANT, handle: 'alice', orderId: 'saved-id', items: [{ name: 'Saved Tea', qty: 1, price: '3000' }], statusToken: 's'.repeat(43), description: 'Saved Table', customerMemo: 'Saved memo' }, 3000n * 10n ** 18n).record, `0x${'ab'.repeat(32)}`); + } + function restored(record: OrderDelivery) { + saveOrderDelivery(record); + setRelayPayment('success', { txHash: record.txHash, orderDelivery: record, restoredIntent: record.intent }); + } + async function realRelay() { + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + } + function unresolvedRecord() { + const { txHash: _hash, notifyBody: _body, ...saved } = savedRecord(); + const validBefore = String(Math.floor(Date.now() / 1000) + 300); + return { ...saved, state: 'signed' as const, bind: { ...saved.bind, validBefore }, intent: { ...saved.intent, validBefore, issuedAt: Date.now() } }; + } + it.each(['relay', 'standard', 'different-shop'] as const)('unresolved old payment scopes the %s checkout hold and notice', async (kind) => { + setupRelayReady(); const record = unresolvedRecord(); saveOrderDelivery(record); + const client = await realRelay(); vi.useFakeTimers(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: true, state: 'indeterminate' })); + const params = { ...savedParams, orderId: 'new', ...(kind === 'standard' ? { mode: 'standard' as const } : {}), ...(kind === 'different-shop' ? { to: '0x9999999999999999999999999999999999999999' as Address } : {}) }; + const page = render(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + }, { timeout: 2000, interval: 10 }); + const button = screen.getByRole('button', { name: /JPYC を支払う/ }); + if (kind === 'different-shop') expect(button).toBeEnabled(); else expect(button).toBeDisabled(); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(300_001)); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeEnabled(); + expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いの状態を確認できませんでした。支払い直す前にお店のスタッフに確認してください。')).toBeInTheDocument(); + expect(loadOrderDelivery().kind).toBe('ready'); + expect(fetchSpy.mock.calls.every(([url]) => url === '/api/relay/jpyc/status')).toBe(true); + page.unmount(); client.clear(); fetchSpy.mockRestore(); vi.useRealTimers(); + }); + // Round 4 regression list: wake exactly at expiry, keep the button/confirming notice + // until the read finishes (including timeout), then show staff advice; a settled read + // delivers only the old order. Other merchants remain payable throughout. + it.each(['unused', 'indeterminate', 'network', 'timeout', 'settled'] as const)('expiry read holds payment until %s completes and updates the notice', async (outcome) => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-24T00:00:00Z')); + setupRelayReady(); const record = unresolvedRecord(); saveOrderDelivery(record); + const expiry = Number(record.intent.validBefore) * 1000; + const { usePublicClient } = await import('wagmi'); + mockHook(usePublicClient, { waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success' }) }); + const client = await realRelay(); + let finishRead!: () => void; + const expiryReads: number[] = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (url !== '/api/relay/jpyc/status') return Response.json({ ok: true }); + if (Date.now() < expiry) return Response.json({ ok: true, state: 'indeterminate' }); + expiryReads.push(Date.now()); + return new Promise((resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true }); + finishRead = () => outcome === 'network' ? reject(new Error('offline')) : resolve(Response.json({ ok: true, state: outcome, ...(outcome === 'settled' ? { txHash: `0x${'ab'.repeat(32)}` } : {}) })); + }); + }); + const page = render(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + }, { timeout: 2000, interval: 10 }); + await act(async () => vi.advanceTimersByTimeAsync(expiry - Date.now())); + expect(expiryReads).toEqual([expiry]); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeDisabled(); + expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(9999)); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeDisabled(); + await act(async () => outcome === 'timeout' ? vi.advanceTimersByTimeAsync(1) : finishRead()); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeEnabled(); + expect(screen.queryByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeNull(); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + if (outcome === 'settled') { + await act(async () => vi.advanceTimersByTimeAsync(0)); + const notify = fetchSpy.mock.calls.find(([url]) => url !== '/api/relay/jpyc/status'); + expect(notify?.[1]?.body).toBe(resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`).notifyBody); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + } else { + expect(screen.getByText('前回の支払いの状態を確認できませんでした。支払い直す前にお店のスタッフに確認してください。')).toBeInTheDocument(); + expect(loadOrderDelivery().kind).toBe('ready'); + } + page.unmount(); client.clear(); vi.mocked(usePublicClient).mockReset(); + }); + it('background signed recovery registers the old order without adopting its completion', async () => { + setupRelayReady(); const record = unresolvedRecord(); saveOrderDelivery(record); + const { usePublicClient } = await import('wagmi'); + mockHook(usePublicClient, { waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success' }) }); + const client = await realRelay(); vi.useFakeTimers(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => Response.json(url === '/api/relay/jpyc/status' ? { ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` } : { ok: true })); + const page = render(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + }, { timeout: 2000, interval: 10 }); + const notify = fetchSpy.mock.calls.find(([url]) => url === `${location.origin}/api/order/notify?h=alice`); + expect(notify?.[1]?.body).toBe(resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`).notifyBody); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeEnabled(); + page.unmount(); client.clear(); fetchSpy.mockRestore(); vi.mocked(usePublicClient).mockReset(); vi.useRealTimers(); + }); + it('finishing a second background recovery cannot strand the first notification notice', async () => { + setupRelayReady(); const first = savedRecord(); saveOrderDelivery(first); + const f = bindingFixture('free', { ...first.order, orderId: 'second-old' }, 3000n * 10n ** 18n); + const validBefore = String(Math.floor(Date.now() / 1000) + 300); + saveOrderDelivery({ ...f.record, bind: { ...f.bind, validBefore }, intent: { ...f.record.intent, validBefore, issuedAt: Date.now() } }); + const { usePublicClient } = await import('wagmi'); + mockHook(usePublicClient, { waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success' }) }); + const client = await realRelay(); vi.useFakeTimers(); + let acknowledgeFirst!: (response: Response) => void; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (url === '/api/relay/jpyc/status') return Response.json({ ok: true, state: 'settled', txHash: `0x${'cd'.repeat(32)}` }); + if (JSON.parse(init!.body as string).orderId === first.order.orderId) return new Promise((resolve) => { acknowledgeFirst = resolve; }); + return Response.json({ ok: true }); + }); + const page = render(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + }, { timeout: 2000, interval: 10 }); + expect(screen.queryByText('以前の注文の登録が保留中です。このお支払いは続けられます。')).toBeNull(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(fetchSpy.mock.calls.filter(([url]) => url !== '/api/relay/jpyc/status')).toHaveLength(2); + }, { timeout: 2000, interval: 10 }); + await act(async () => acknowledgeFirst(Response.json({ ok: true }))); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + expect(screen.queryByText('以前の注文の登録が保留中です。このお支払いは続けられます。')).toBeNull(); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + page.unmount(); client.clear(); fetchSpy.mockRestore(); vi.mocked(usePublicClient).mockReset(); vi.useRealTimers(); + }); + it.each(['', '?h=alice&h=bob'])('invalid notify handle %s shows the existing URL error instead of storage advice', async (query) => { + setupRelayReady(); const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /JPYC を支払う/ })); + expect(screen.getByText('Checkout URL が不正です')).toBeInTheDocument(); + expect(screen.queryByText(/注文の復元情報をこのブラウザに保存できない/)).toBeNull(); + expect(relayMutate).not.toHaveBeenCalled(); + }); + it('a corrupt order slot does not show a cannot-start alert on a third-party checkout', async () => { + const { ORDER_DELIVERY_KEY } = await import('@/lib/orderDelivery'); + setupRelayReady(); sessionStorage.setItem(ORDER_DELIVERY_KEY, 'corrupt'); + const client = await realRelay(); + const page = render(); + await waitFor(() => expect(vi.mocked(useJpycEip3009Payment).mock.results.at(-1)?.value.isRestoring).toBe(false)); + expect(screen.queryByText(/注文の復元情報をこのブラウザに保存できない/)).toBeNull(); + expect(screen.queryByText(/以前の注文の復元情報を読み込めませんでした/)).toBeNull(); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeEnabled(); + page.unmount(); client.clear(); + }); + it.each(['relay', 'standard', 'usdc'])('load warning is scoped for %s own-origin checkout', async (kind) => { + const { ORDER_DELIVERY_KEY } = await import('@/lib/orderDelivery'); + setupRelayReady(); sessionStorage.setItem(ORDER_DELIVERY_KEY, 'corrupt'); + if (kind === 'usdc') { vi.mocked(resolveJpycGaslessProvider).mockReturnValue('pimlico-7702'); setAccount({ connected: true, chainId: baseSepolia.id }); setSmartAccount(true); setGasQuote('ready', 0n); } + const client = await realRelay(); + const params = { ...(kind === 'usdc' ? USDC_PARAMS : savedParams), webhook: `${location.origin}/api/order/notify?h=alice`, ...(kind === 'standard' ? { mode: 'standard' as const } : {}) }; + const page = render(); + await waitFor(() => expect(vi.mocked(useJpycEip3009Payment).mock.results.at(-1)?.value.isRestoring).toBe(false)); + expect(!!screen.queryByText(/以前の注文の復元情報を読み込めませんでした/)).toBe(kind === 'relay'); + expect(screen.queryByText(/注文の復元情報をこのブラウザに保存できない/)).toBeNull(); + page.unmount(); client.clear(); + }); + it('a real pre-broadcast save failure retains the cannot-start wording', () => { + setupRelayReady(); setRelayPayment('error', { errMsg: 'order_binding_storage_unavailable' }); + render(); + expect(screen.getByText(/注文の復元情報をこのブラウザに保存できない/)).toBeInTheDocument(); + expect(screen.queryByText(/以前の注文の復元情報を読み込めませんでした/)).toBeNull(); + }); + it('a changed cart total with the same orderId cannot adopt the previous completion', async () => { + setupRelayReady(); saveOrderDelivery(savedRecord()); const client = await realRelay(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })); + const page = render(); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + expect(screen.getByRole('button', { name: /4000 JPYC を支払う/ })).toBeEnabled(); + expect(fetchSpy.mock.calls.every(([url]) => url === `${location.origin}/api/order/notify?h=alice`)).toBe(true); + page.unmount(); client.clear(); fetchSpy.mockRestore(); + }); + it('restored matching includes customer-paid mobile fees in the total', async () => { + setupRelayReady(); feeFlags.enableMobileOrderFee = true; + const dep = deploymentForSlug('jpyc', 'polygon'); + const f = bindingFixture('recover', { ...savedRecord().order, chainId: dep.chainId, tokenAddress: dep.address }, 3000n * 10n ** 18n, 90n * 10n ** 18n); + const record = resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })); + render(); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(screen.getByText('お支払いが完了しました')).toBeInTheDocument(); fetchSpy.mockRestore(); + }); + it('freezes orderId/status/memo before signing and never attaches them to third-party callbacks', async () => { + const user = userEvent.setup(); setupRelayReady(); + feeFlags.enableOrderPickup = true; + sessionStorage.setItem('openpay:order-memo:fixed', 'before signing'); + const { unmount } = render(); + await user.click(screen.getByRole('button', { name: /を支払う/ })); + const order = relayMutate.mock.calls[0][0].order; + expect(order).toMatchObject({ orderId: 'fixed', customerMemo: 'before signing', handle: 'alice' }); + expect(isOrderTokenLike(order.statusToken)).toBe(true); + unmount(); setupRelayReady(); relayMutate.mockClear(); + render(); + await user.click(screen.getByRole('button', { name: /を支払う/ })); + expect(relayMutate.mock.calls[0][0].order).toBeUndefined(); + }); + it('lazy binding chunk loading cannot turn repeated submit clicks into two payments', async () => { + const actual = await vi.importActual('@/lib/orderBind'); + let release!: () => void; + const loaded = vi.fn(); + const gate = new Promise((resolve) => { release = resolve; }); + vi.doMock('@/lib/orderBind', async () => { loaded(); await gate; return actual; }); + try { + setupRelayReady(); + render(); + const button = screen.getByRole('button', { name: /を支払う/ }); + fireEvent.click(button); fireEvent.click(button); + await waitFor(() => expect(loaded).toHaveBeenCalledOnce()); + expect(relayMutate).not.toHaveBeenCalled(); + await act(async () => release()); + await waitFor(() => expect(relayMutate).toHaveBeenCalledOnce()); + } finally { release(); vi.doUnmock('@/lib/orderBind'); } + }); + it('generates a nonempty orderId before signing when absent', async () => { + const user = userEvent.setup(); setupRelayReady(); + render(); + await user.click(screen.getByRole('button', { name: /を支払う/ })); + expect(relayMutate.mock.calls[0][0].order.orderId.length).toBeGreaterThan(0); + }); + it('a matching saved checkout can recover its original notification after route flags change', async () => { + const record = savedRecord(); setupRelayReady(); restored(record); + vi.mocked(resolveJpycGaslessProvider).mockReturnValue('pimlico-7702'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })); + render(); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(fetchSpy.mock.calls[0][0]).toBe(`${location.origin}/api/order/notify?h=alice`); + expect(fetchSpy.mock.calls[0][1]?.body).toBe(record.notifyBody); + expect(relayMutate).not.toHaveBeenCalled(); fetchSpy.mockRestore(); + }); + it.each(['different-shop', 'third-party'] as const)('a retryable old notify cannot take over a %s checkout', async (kind) => { + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ error: 'rpc_error' }, { status: 503 })); + const first = render(); + expect(await screen.findByRole('button', { name: '注文登録を再試行' })).toBeInTheDocument(); + first.unmount(); + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + vi.mocked(resolveJpycGaslessProvider).mockReturnValue('pimlico-7702'); + setSmartAccount(true); setGasQuote('ready', 0n); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const params = { ...JPYC_PARAMS, orderId: 'new-order', ...(kind === 'different-shop' ? { to: '0x9999999999999999999999999999999999999999' as Address, webhook: `${location.origin}/api/order/notify?h=bob` } : { webhook: 'https://third.example/hook' }) }; + const next = render(); + expect(await screen.findByText('以前の注文の登録が保留中です。このお支払いは続けられます。')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: /3000 JPYC を支払う/ })).toBeEnabled()); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + expect(screen.queryByText('saved-id')).toBeNull(); + expect(screen.queryByText(/再度支払わず/)).toBeNull(); + expect(fetchSpy.mock.calls.every(([url]) => url === `${location.origin}/api/order/notify?h=alice`)).toBe(true); + next.unmount(); client.clear(); fetchSpy.mockRestore(); + }); + it.each([200, 400])('a safe background notification with HTTP %s cannot complete or redirect the current checkout', async (status) => { + const record = savedRecord(); saveOrderDelivery(record); setupRelayReady(); + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json( + status === 200 ? { ok: true } : { error: 'invalid_handle' }, { status }, + )); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const next = render(); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0]).toBe(`${location.origin}/api/order/notify?h=alice`); + expect(fetchSpy.mock.calls[0][1]?.body).toBe(record.notifyBody); + expect(screen.getByRole('button', { name: /3000 JPYC を支払う/ })).toBeEnabled(); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); + expect(screen.queryByRole('button', { name: '今すぐ確認ページへ' })).toBeNull(); + expect(screen.queryByText(/再度支払わず/)).toBeNull(); + expect(!!screen.queryByText('以前の注文を登録できませんでした。そのお店のスタッフに支払い履歴またはトランザクションを提示してください。')).toBe(status === 400); + expect(screen.queryByText('以前の注文の登録が保留中です。このお支払いは続けられます。')).toBeNull(); + next.unmount(); client.clear(); fetchSpy.mockRestore(); + }); + it.each([ + [400, 'invalid_handle', false], [413, 'payload_too_large', false], + [429, 'rate_limited', true], [422, 'no_matching_transfer', false], + [422, 'amount_too_low', false], [422, 'tx_reverted', false], [422, 'tx_not_found', true], + [500, 'internal_error', true], [503, 'rpc_error', true], [409, 'reserved_order', false], + ] as const)('notify %s %s has retryable=%s', async (status, error, retryable) => { + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: false, error }, { status })); + render(); + expect(await screen.findByText(/再度支払わず/)).toBeInTheDocument(); + expect(loadOrderDelivery().kind).toBe(retryable ? 'ready' : 'empty'); + expect(!!screen.queryByRole('button', { name: '注文登録を再試行' })).toBe(retryable); + expect(fetchSpy).toHaveBeenCalledOnce(); expect(relayMutate).not.toHaveBeenCalled(); fetchSpy.mockRestore(); + }); + it('pickup OFF fixes an explicit empty token before signing', async () => { + const user = userEvent.setup(); setupRelayReady(); feeFlags.enableOrderPickup = false; + render(); + await user.click(screen.getByRole('button', { name: /を支払う/ })); + expect(relayMutate.mock.calls[0][0].order.statusToken).toBe(''); + }); + it('lost acknowledgement retains the opening; manual notification retry sends identical bytes and clears only on ok:true', async () => { + const user = userEvent.setup(); const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('lost acknowledgement')).mockResolvedValueOnce(Response.json({ ok: true, duplicate: true })); + render(); + const retry = await screen.findByRole('button', { name: '注文登録を再試行' }); + expect(loadOrderDelivery().kind).toBe('ready'); + expect(screen.getByText(/再度支払わず/)).toBeInTheDocument(); + await user.click(retry); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(fetchSpy.mock.calls.map(([, init]) => init?.body)).toEqual([record.notifyBody, record.notifyBody]); + expect(relayMutate).not.toHaveBeenCalled(); fetchSpy.mockRestore(); + }); + it.each(['order_binding_mismatch', 'tx_too_old', 'merchant_mismatch', 'handle_not_found', 'reserved_order'])('%s terminates delivery without automatic retry or a new authorization', async (error) => { + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: false, error }, { status: error === 'reserved_order' ? 409 : error === 'merchant_mismatch' ? 403 : error === 'handle_not_found' ? 404 : 422 })); + const previous = render(); + expect(await screen.findByText(/再度支払わず/)).toBeInTheDocument(); + expect(loadOrderDelivery().kind).toBe('empty'); expect(fetchSpy).toHaveBeenCalledOnce(); + expect(screen.queryByRole('button', { name: '注文登録を再試行' })).toBeNull(); + expect(relayMutate).not.toHaveBeenCalled(); + previous.unmount(); fetchSpy.mockClear(); + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const next = render(); + await waitFor(() => expect(screen.getByRole('button', { name: /3000 JPYC を支払う/ })).toBeEnabled()); + expect(screen.queryByText(/再度支払わず/)).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(useJpycEip3009Payment).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ restoreOrderDelivery: true, orderContext: expect.objectContaining({ orderId: 'new-checkout' }) })); + next.unmount(); client.clear(); fetchSpy.mockRestore(); + }); + it('bindingConflict ends delivery with staff assistance and no status link or redirect', async () => { + const record = savedRecord(); setupRelayReady(); restored(record); + feeFlags.enableOrderPickup = true; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true, duplicate: true, bindingConflict: true })); + render(); + expect(await screen.findByText(/再度支払わず/)).toBeInTheDocument(); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + expect(screen.queryByRole('button', { name: '今すぐ確認ページへ' })).toBeNull(); + expect(document.querySelector('a[href*="/order/status"]')).toBeNull(); + expect(relayMutate).not.toHaveBeenCalled(); fetchSpy.mockRestore(); + }); + it('bound relay keeps checkout.success telemetry and enables successUrl only after acknowledgement', async () => { + const user = userEvent.setup(); setupRelayReady(); + let acknowledge!: (response: Response) => void; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { acknowledge = resolve; })); + const makeUi = () => ; + const { rerender } = render(makeUi()); + await submitRelayThenSucceed(user, rerender, makeUi, { txHash: `0x${'e'.repeat(64)}`, variables: { merchant: MERCHANT, value: 3000n * 10n ** 18n } }); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce()); + expect(logger.info).toHaveBeenCalledWith('checkout.success', expect.objectContaining({ merchant: MERCHANT, token: 'jpyc' })); + expect(vi.mocked(logger.info).mock.calls.filter(([event]) => event === 'checkout.success')).toHaveLength(1); + const saved = loadOrderDelivery(); + if (saved.kind !== 'ready') throw new Error('missing opening'); + expect(JSON.stringify(vi.mocked(logger.info).mock.calls)).not.toContain(saved.record.bind.secret); + expect(screen.queryByRole('button', { name: '今すぐ確認ページへ' })).toBeNull(); + await act(async () => acknowledge(Response.json({ ok: true }))); + expect(await screen.findByRole('button', { name: '今すぐ確認ページへ' })).toBeInTheDocument(); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); fetchSpy.mockRestore(); + }); + it('a lingering record does not suppress this attempt’s third-party callback or successUrl', async () => { + const user = userEvent.setup(); setupRelayReady(); + const record = savedRecord(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })); + const makeUi = () => ; + const { rerender } = render(makeUi()); + await user.click(screen.getByRole('button', { name: /を支払う/ })); + setRelayPayment('success', { txHash: `0x${'e'.repeat(64)}`, orderDelivery: record }); + rerender(makeUi()); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce()); + expect(fetchSpy.mock.calls[0][0]).toBe('https://third.example/hook'); + expect(fetchSpy.mock.calls[0][1]?.body).not.toContain(record.bind.secret); + expect(screen.getByRole('button', { name: '今すぐ確認ページへ' })).toBeInTheDocument(); + fetchSpy.mockRestore(); + }); + it('409 processing retries only identical persisted bytes', async () => { + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(Response.json({ ok: false, error: 'processing' }, { status: 409 })).mockResolvedValueOnce(Response.json({ ok: true })); + render(); + await waitFor(() => expect(loadOrderDelivery()).toEqual({ kind: 'empty' })); + expect(fetchSpy.mock.calls.map(([, init]) => init?.body)).toEqual([record.notifyBody, record.notifyBody]); + fetchSpy.mockRestore(); + }); + it.each([503, 200])('HTTP %s without an acknowledgement follows the retryable classification', async (status) => { + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: false }, { status })); + render(); + expect(await screen.findByText(/再度支払わず/)).toBeInTheDocument(); + expect(loadOrderDelivery().kind).toBe(status >= 500 ? 'ready' : 'empty'); + expect(fetchSpy).toHaveBeenCalledOnce(); expect(relayMutate).not.toHaveBeenCalled(); fetchSpy.mockRestore(); + }); + it('exhausted processing retries retain the snapshot and never request payment again', async () => { + vi.useFakeTimers(); + const record = savedRecord(); setupRelayReady(); restored(record); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: false, error: 'processing' }, { status: 409 })); + render(); + await act(async () => vi.advanceTimersByTimeAsync(141_000)); + expect(fetchSpy).toHaveBeenCalledTimes(7); + expect(new Set(fetchSpy.mock.calls.map(([, init]) => init?.body))).toEqual(new Set([record.notifyBody])); + expect(loadOrderDelivery().kind).toBe('ready'); expect(relayMutate).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '注文登録を再試行' })).toBeInTheDocument(); + fetchSpy.mockRestore(); vi.useRealTimers(); + }); + it('a mismatched recovered hash never notifies a saved order', async () => { + const record = savedRecord(); setupRelayReady(); restored(record); + setRelayPayment('success', { txHash: `0x${'cd'.repeat(32)}`, orderDelivery: record, restoredIntent: record.intent }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + render(); + await act(async () => {}); + expect(fetchSpy).not.toHaveBeenCalled(); expect(loadOrderDelivery().kind).toBe('ready'); fetchSpy.mockRestore(); + }); +}); diff --git a/tests/components/OrderFeedPanel.test.tsx b/tests/components/OrderFeedPanel.test.tsx index 79e83c0d..f961b34e 100644 --- a/tests/components/OrderFeedPanel.test.tsx +++ b/tests/components/OrderFeedPanel.test.tsx @@ -310,3 +310,12 @@ describe('OrderFeedPanel', () => { expect(screen.queryByText('まだ受注はありません。')).toBeNull(); }); }); + +it('A2c missing binding warning is independent of amount and fee badges', async () => { + envHold.enableOrderRelay = true; + h.orders = [{ ...order, bindingMissing: true }, { ...order, orderId: 'other', txHash: `0x${'cd'.repeat(32)}` }]; + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('支払いと注文の結びつき未確認 — 受け渡し前に手動確認が必要'); + expect(screen.getAllByText(/暗号学的/)).toHaveLength(1); + expect(screen.getByText(/暗号学的/)).toHaveClass('text-slate-800', 'bg-slate-50'); +}); diff --git a/tests/components/OrderFulfillmentBoard.test.tsx b/tests/components/OrderFulfillmentBoard.test.tsx index 9b2aafce..4d874fb6 100644 --- a/tests/components/OrderFulfillmentBoard.test.tsx +++ b/tests/components/OrderFulfillmentBoard.test.tsx @@ -764,3 +764,11 @@ describe('OrderFulfillmentBoard', () => { expect(tokenHold.stored).toBe('e'.repeat(43)); // 保持 (失効ではない) }); }); + +it.each(['kitchen', 'hall'] as const)('A2c %s keeps the manual handover warning visible', (mode) => { + feedHold.data = [order({ bindingMissing: true }), order({ orderId: 'other', txHash: TX2 })]; + renderWithIntl(); + expect(screen.getByRole('alert')).toHaveTextContent('支払いと注文の結びつき未確認 — 受け渡し前に手動確認が必要'); + expect(screen.getAllByText(/暗号学的/)).toHaveLength(1); + expect(screen.getByText(/暗号学的/)).toHaveClass('text-slate-800', 'bg-slate-50'); +}); diff --git a/tests/components/OrderPickupMonitor.test.tsx b/tests/components/OrderPickupMonitor.test.tsx index dc15ece2..ec953000 100644 --- a/tests/components/OrderPickupMonitor.test.tsx +++ b/tests/components/OrderPickupMonitor.test.tsx @@ -183,3 +183,12 @@ describe('OrderPickupMonitor', () => { ).toBeInTheDocument(); }); }); + +it('A2c customer pickup display remains number-only for unverified orders', () => { + feedHold.data = [order('CHECK1', { ready: true, bindingMissing: true })]; + renderWithIntl(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByText(/暗号学的/)).toBeNull(); + expect(screen.getByRole('listitem')).toHaveTextContent(/^CHECK1$/); + expect(screen.getByText('CHECK1')).toBeInTheDocument(); +}); diff --git a/tests/components/PaymentForm.test.tsx b/tests/components/PaymentForm.test.tsx index 1ba38a0f..f1756d8f 100644 --- a/tests/components/PaymentForm.test.tsx +++ b/tests/components/PaymentForm.test.tsx @@ -1,5 +1,6 @@ import { gatewayAttestation, encodedSpec } from '../fixtures/gateway'; import { keccak256 } from 'viem'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act, @@ -19,6 +20,8 @@ vi.mock('next/navigation', () => ({ useSearchParams: vi.fn(), })); vi.mock('wagmi', () => ({ + useWalletClient: vi.fn(() => ({ data: undefined })), + usePublicClient: vi.fn(), useAccount: vi.fn(), useReadContract: vi.fn(), useSwitchChain: vi.fn(), @@ -2580,3 +2583,28 @@ describe('PaymentForm — 署名安心 P2 (standard ヒント / Circle usdc-perm expect(screen.queryByText(/求められるのは「署名」1回だけ/)).toBeNull(); }); }); + +it.each(['notify-pending', 'terminal', 'prepared'] as const)('A2c %s order from an earlier checkout cannot hijack a new PaymentForm mount', async (state) => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { defaultDeploymentForSymbol } = await import('@/lib/tokens'); + const { ORDER_DELIVERY_KEY, resolveOrderDelivery, saveOrderDelivery, terminateOrderDelivery } = await import('@/lib/orderDelivery'); + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + sessionStorage.clear(); + const dep = defaultDeploymentForSymbol('jpyc'); + const record = resolveOrderDelivery(bindingFixture('free', { chainId: dep.chainId, tokenAddress: dep.address, merchant: MERCHANT, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }).record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(record); + if (state === 'terminal') expect(terminateOrderDelivery(record)).toBe(true); + if (state === 'prepared') sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify({ ...record, state: 'prepared', txHash: undefined, notifyBody: undefined })); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + vi.mocked(resolveJpycGaslessProvider).mockReturnValue('eip3009-relay'); + setAccount({ connected: true, chainId: dep.chainId }); + setBalance(10_000n * 10n ** 18n); + setURL(`to=${MERCHANT}&token=jpyc&amount=300`); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const view = render(); + await waitFor(() => expect(screen.getByRole('button', { name: /300 JPYC を支払う/ })).toBeEnabled()); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(vi.mocked(useJpycEip3009Payment).mock.calls.every((args) => args[1]?.restoreOrderDelivery !== true)).toBe(true); + view.unmount(); client.clear(); fetchSpy.mockRestore(); sessionStorage.clear(); +}); diff --git a/tests/components/TipForm.test.tsx b/tests/components/TipForm.test.tsx index e5d70c88..f2a4f5b1 100644 --- a/tests/components/TipForm.test.tsx +++ b/tests/components/TipForm.test.tsx @@ -2382,3 +2382,28 @@ it('Gateway hashless success keeps Tip paid and records a receipt without a tran expect(screen.getAllByText(/チップを送信しました/).length).toBeGreaterThan(0); expect(fetchSpy).not.toHaveBeenCalled(); }); + +it.each(['notify-pending', 'terminal', 'prepared'] as const)('A2c %s order from an earlier checkout cannot hijack a new TipForm mount', async (state) => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { defaultDeploymentForSymbol } = await import('@/lib/tokens'); + const { ORDER_DELIVERY_KEY, resolveOrderDelivery, saveOrderDelivery, terminateOrderDelivery } = await import('@/lib/orderDelivery'); + const { useJpycEip3009Payment: actualHook } = await vi.importActual('@/hooks/useJpycEip3009Payment'); + sessionStorage.clear(); + const dep = defaultDeploymentForSymbol('jpyc'); + const record = resolveOrderDelivery(bindingFixture('free', { chainId: dep.chainId, tokenAddress: dep.address, merchant: CREATOR, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }).record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(record); + if (state === 'terminal') expect(terminateOrderDelivery(record)).toBe(true); + if (state === 'prepared') sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify({ ...record, state: 'prepared', txHash: undefined, notifyBody: undefined })); + vi.mocked(useJpycEip3009Payment).mockImplementation(actualHook); + vi.mocked(resolveJpycGaslessProvider).mockReturnValue('eip3009-relay'); + setAccount({ connected: true, chainId: dep.chainId }); + setBalance(10_000n * 10n ** 18n); + mockHook(useWalletClient, { data: undefined }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const view = render(); + await waitFor(() => expect(screen.getByRole('button', { name: /JPYC.*送る/ })).toBeEnabled()); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(vi.mocked(useJpycEip3009Payment).mock.calls.every((args) => args[1]?.restoreOrderDelivery !== true)).toBe(true); + view.unmount(); client.clear(); fetchSpy.mockRestore(); sessionStorage.clear(); +}); diff --git a/tests/hooks/useJpycEip3009Payment.test.tsx b/tests/hooks/useJpycEip3009Payment.test.tsx index a209577c..6c4e87c9 100644 --- a/tests/hooks/useJpycEip3009Payment.test.tsx +++ b/tests/hooks/useJpycEip3009Payment.test.tsx @@ -1260,3 +1260,368 @@ describe('useJpycEip3009Payment — モバイル注文 feeKind (recover=実 mobi expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +const ORDER_CONTEXT = { merchant: MERCHANT, chainId: jpycDep.chainId, tokenAddress: jpycDep.address, webhook: `${location.origin}/api/order/notify?h=alice`, totalValue: 1000n * 10n ** 18n }; + +describe('A2c browser binding', () => { + it.each(['free', 'recover'] as const)('persists %s opening only after signing and before broadcast; never sends secret to relay; success retains order', async (mode) => { + const { canonicalOrder, orderBindSalt, orderDigest } = await import('@/lib/orderBind'); + const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + const order = canonicalOrder({ chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'before-sign', items: [{ name: 'Tea', qty: 1, price: '300' }], statusToken: 's'.repeat(43), customerMemo: 'no ice' }); + vi.mocked(jpycForwarderFor).mockReturnValue(mode === 'recover' ? '0x3333333333333333333333333333333333333333' : null); + mount(); + signTypedData.mockImplementation(async () => { + expect(loadOrderDelivery().kind).toBe('empty'); + return `0x${'a'.repeat(130)}`; + }); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order }); + await waitFor(() => expect(result.current.data?.success).toBe(true)); + const loaded = loadOrderDelivery(); + expect(loaded.kind).toBe('ready'); + if (loaded.kind !== 'ready') throw new Error('missing order'); + expect(loaded.record.state).toBe('notify-pending'); + const salt = orderBindSalt(orderDigest(order), loaded.record.bind.secret); + const payload = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(payload[mode === 'recover' ? 'intentSalt' : 'nonce']).toBe(salt); + expect(fetchSpy.mock.calls[0][1].body).not.toContain(loaded.record.bind.secret); + expect(sessionStorage.getItem(RELAY_INTENT_STORAGE_KEY)).toBeNull(); + expect(result.current.orderDelivery).toEqual(loaded.record); + }); +}); + +describe('A2c recovery and storage failure transitions', () => { + async function fixture(mode: 'free' | 'recover' = 'free') { + const { bindingFixture } = await import('../_helpers/orderBinding'); + return bindingFixture(mode, { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'saved-order', items: [{ name: 'Tea', qty: 1, price: '300' }], statusToken: 's'.repeat(43) }); + } + it('unavailable order storage discards the signature and prevents broadcast', async () => { + const { ORDER_DELIVERY_KEY } = await import('@/lib/orderDelivery'); + const f = await fixture(); + mount(); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await waitFor(() => expect(result.current.isRestoring).toBe(false)); + const original = Storage.prototype.setItem; + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key.startsWith(ORDER_DELIVERY_KEY)) throw new Error('quota'); + original.call(this, key, value); + }); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await waitFor(() => expect(result.current.error?.message).toBe('order_binding_storage_unavailable')); + expect(signTypedData).toHaveBeenCalledOnce(); expect(fetchSpy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + it.each(['signed', 'notify-pending'] as const)('reload from %s recovers the same authorization even after payment intent was cleared', async (state) => { + const { saveOrderDelivery, resolveOrderDelivery, loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await fixture('recover'); + const firstHash = `0x${'ab'.repeat(32)}` as Hex; + const finalHash = `0x${'cd'.repeat(32)}` as Hex; + saveOrderDelivery(state === 'signed' ? f.record : resolveOrderDelivery(f.record, firstHash)); + vi.useFakeTimers(); + mount(); + fetchSpy.mockImplementation(async () => Response.json({ ok: true, state: 'settled', txHash: finalHash })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, totalValue: BigInt(f.record.intent.merchantValue) + BigInt(f.record.intent.feeValue) } }), { wrapper: makeWrapper() }); + await flushStorageLoad(); + await act(async () => vi.advanceTimersByTimeAsync(3000)); + await flushStorageLoad(); + expect(result.current.data).toEqual({ success: true, txHash: finalHash }); + expect(result.current.orderDelivery?.order).toEqual(f.order); + expect(result.current.orderDelivery?.bind).toEqual(f.bind); + expect(result.current.orderDelivery?.txHash).toBe(finalHash); + expect(loadOrderDelivery().kind).toBe('ready'); + expect(signTypedData).not.toHaveBeenCalled(); + for (const [url, init] of fetchSpy.mock.calls) { + expect(url).toBe('/api/relay/jpyc/status'); expect(init.body).not.toContain(f.bind.secret); + } + vi.useRealTimers(); + }); + it.each(['unused', 'reverted'] as const)('proven %s authorization can discard an unpaid snapshot', async (outcome) => { + const { saveOrderDelivery, loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await fixture(); saveOrderDelivery(f.record); + vi.useFakeTimers(); mount(); + fetchSpy.mockImplementation(async () => Response.json(outcome === 'unused' ? { ok: true, state: 'unused' } : { ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + waitForTransactionReceipt.mockResolvedValue({ status: 'reverted' }); + renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await flushStorageLoad(); + await act(async () => vi.advanceTimersByTimeAsync(9000)); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + expect(signTypedData).not.toHaveBeenCalled(); vi.useRealTimers(); + }); + it('IP retry retains the exact opening and signs once', async () => { + const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await fixture(); vi.mocked(jpycForwarderFor).mockReturnValue(null); mount(); + fetchSpy.mockResolvedValueOnce(Response.json({ error: 'ip_rate_limited' }, { status: 429 })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await waitFor(() => expect(result.current.isError).toBe(true)); + const saved = result.current.orderDelivery; + expect(saved).not.toBeNull(); + expect(loadOrderDelivery().kind).toBe('empty'); + fetchSpy.mockImplementationOnce(async () => { + expect(loadOrderDelivery()).toEqual({ kind: 'ready', record: saved }); + return Response.json({ ok: true, txHash: `0x${'ab'.repeat(32)}` }); + }); + act(() => result.current.retryRelay()); + await waitFor(() => expect(result.current.data?.success).toBe(true)); + expect(signTypedData).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][1].body).toBe(fetchSpy.mock.calls[1][1].body); + expect(result.current.orderDelivery?.bind).toEqual(saved?.bind); + }); + it('pending/unknown retain their saved opening without notification or new authorization', async () => { + const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await fixture(); vi.mocked(jpycForwarderFor).mockReturnValue(null); mount(); + fetchSpy.mockResolvedValue(Response.json({ pending: true, txHash: null }, { status: 202 })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await waitFor(() => expect(result.current.data?.pending).toBe(true)); + expect(loadOrderDelivery().kind).toBe('ready'); + expect(result.current.orderDelivery?.state).toBe('signed'); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + expect(signTypedData).toHaveBeenCalledOnce(); + }); +}); + +it('A2c lost relay response resolves the original opening; a failed resolution write keeps payment successful', async () => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { ORDER_DELIVERY_KEY, loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'unknown', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + vi.mocked(jpycForwarderFor).mockReturnValue(null); + mount(); vi.useFakeTimers(); + const original = Storage.prototype.setItem; + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key.startsWith(ORDER_DELIVERY_KEY) && JSON.parse(value).state === 'notify-pending') throw new Error('quota'); + original.call(this, key, value); + }); + fetchSpy.mockImplementation(async (url) => { + if (url === '/api/relay/jpyc') throw new Error('response lost'); + return Response.json({ ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` }); + }); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await flushStorageLoad(); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await act(async () => vi.advanceTimersByTimeAsync(0)); + const opening = result.current.orderDelivery?.bind; + await act(async () => vi.advanceTimersByTimeAsync(3000)); + await flushStorageLoad(); + expect(result.current.data?.success).toBe(true); + expect(result.current.orderDelivery?.bind).toEqual(opening); + expect(result.current.orderDelivery?.state).toBe('notify-pending'); + expect(loadOrderDelivery()).toMatchObject({ kind: 'ready', record: { state: 'signed', bind: opening } }); + expect(signTypedData).toHaveBeenCalledOnce(); + write.mockRestore(); vi.useRealTimers(); +}); + +it('A2c order chunk failure cannot hide an existing ordinary relay payment intent', async () => { + vi.doMock('@/lib/orderDelivery', () => { throw new Error('chunk unavailable'); }); + try { + vi.useFakeTimers(); + storeRelayIntent(restoredRelayIntent()); mount(); + fetchSpy.mockImplementation(async () => Response.json({ ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + // Module loading uses real microtasks; await the outcome rather than assuming two ticks + // scheduled the fake recovery timer (worker reuse changes module-cache timing). + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(result.current.data?.success).toBe(true); + }, { timeout: 2000, interval: 10 }); + expect(result.current.orderDeliveryLoadError).toBe(true); + expect(signTypedData).not.toHaveBeenCalled(); + } finally { + vi.doUnmock('@/lib/orderDelivery'); vi.useRealTimers(); + } +}); + + it('A2c ordinary payment hooks never restore an undelivered order', async () => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { saveOrderDelivery, resolveOrderDelivery } = await import('@/lib/orderDelivery'); + const f = bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + saveOrderDelivery(resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`)); mount(); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep), { wrapper: makeWrapper() }); + await waitFor(() => expect(result.current.isRestoring).toBe(false)); + expect(result.current.orderDelivery).toBeNull(); + expect(result.current.restoredIntent).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); +}); + +it.each(['prepared', 'terminal'] as const)('A2c %s record cannot block persisting the next signed checkout', async (state) => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { ORDER_DELIVERY_KEY, loadOrderDelivery, resolveOrderDelivery, saveOrderDelivery, terminateOrderDelivery } = await import('@/lib/orderDelivery'); + const f = bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + if (state === 'terminal') { + const record = resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(record); expect(terminateOrderDelivery(record)).toBe(true); + } else sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify({ ...f.record, state: 'prepared' })); + mount(); vi.mocked(jpycForwarderFor).mockReturnValue(null); + fetchSpy.mockImplementation(async () => { + expect(loadOrderDelivery()).toMatchObject({ kind: 'ready', record: { state: 'signed', order: { orderId: 'new' } } }); + return Response.json({ ok: true, txHash: `0x${'cd'.repeat(32)}` }); + }); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await waitFor(() => expect(result.current.isRestoring).toBe(false)); + expect(result.current.restoredIntent).toBeNull(); expect(fetchSpy).not.toHaveBeenCalled(); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: { ...f.order, orderId: 'new' } })); + await waitFor(() => expect(result.current.data?.success).toBe(true)); + expect(signTypedData).toHaveBeenCalledOnce(); + expect(loadOrderDelivery()).toMatchObject({ kind: 'ready', record: { state: 'notify-pending', order: { orderId: 'new' } } }); +}); +it('A2c navigation during a wallet prompt leaves no unsigned record or late broadcast', async () => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'unsigned', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + let sign!: (signature: Hex) => void; + mount({ signImpl: () => new Promise((resolve) => { sign = resolve; }) }); + vi.mocked(jpycForwarderFor).mockReturnValue(null); + const first = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + act(() => first.result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await waitFor(() => expect(signTypedData).toHaveBeenCalledOnce()); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); first.unmount(); + await act(async () => sign(`0x${'ab'.repeat(65)}`)); + const next = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await waitFor(() => expect(next.result.current.isRestoring).toBe(false)); + expect(next.result.current.restoredIntent).toBeNull(); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); expect(fetchSpy).not.toHaveBeenCalled(); +}); + +describe('A2c context and authorization slot isolation', () => { + async function saved() { + const { bindingFixture } = await import('../_helpers/orderBinding'); + return bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + } + it.each(['settled', 'receipt-pending'] as const)('changing the mounted checkout detaches a %s restored order', async (phase) => { + const { saveOrderDelivery, resolveOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await saved(); saveOrderDelivery(resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`)); + mount(); vi.useFakeTimers(); + let receipt!: (result: { status: 'success' }) => void; + if (phase === 'receipt-pending') waitForTransactionReceipt.mockImplementationOnce(() => new Promise((resolve) => { receipt = resolve; })); + fetchSpy.mockResolvedValue(Response.json({ ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + const { result, rerender } = renderHook(({ orderId }) => useJpycEip3009Payment(jpycDep, { + restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, orderId }, + }), { wrapper: makeWrapper(), initialProps: { orderId: 'old' } }); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(waitForTransactionReceipt).toHaveBeenCalledOnce(); + }, { timeout: 2000, interval: 10 }); + if (phase === 'settled') expect(result.current.data?.success).toBe(true); + rerender({ orderId: 'new' }); await flushStorageLoad(); + if (phase === 'receipt-pending') await act(async () => receipt({ status: 'success' })); + expect(result.current.restoredIntent).toBeNull(); expect(result.current.data).toBeUndefined(); + expect(result.current.orderDelivery).toBeNull(); expect(result.current.hasActiveIntent).toBe(false); + expect(result.current.isPending).toBe(false); expect(result.current.recoveryState).toBeNull(); + expect(signTypedData).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it.each([ + { merchant: '0x2222222222222222222222222222222222222222' }, { chainId: 1 }, + { tokenAddress: '0x0000000000000000000000000000000000000def' }, { orderId: 'different' }, + { webhook: 'https://third.example/hook' }, { webhook: `${location.origin}/api/order/notify?h=bob` }, + ])('a mismatched %j context cannot recover completion through either storage slot', async (change) => { + const { saveOrderDelivery, resolveOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await saved(); const record = resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(record); storeRelayIntent(record.intent); mount(); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, ...change } }), { wrapper: makeWrapper() }); + await waitFor(() => expect(result.current.isRestoring).toBe(false)); + expect(result.current.restoredIntent).toBeNull(); expect(result.current.data).toBeUndefined(); + expect(result.current.orderDelivery).toBeNull(); expect(result.current.hasActiveIntent).toBe(false); + expect(result.current.backgroundOrderDeliveries).toEqual([record]); + expect(fetchSpy).not.toHaveBeenCalled(); expect(signTypedData).not.toHaveBeenCalled(); + }); + it('a new mobile order persists and pays without replacing another order’s pending delivery', async () => { + const { saveOrderDelivery, resolveOrderDelivery, loadOrderDeliveries } = await import('@/lib/orderDelivery'); + const { canonicalOrder } = await import('@/lib/orderBind'); + const f = await saved(); const old = resolveOrderDelivery(f.record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(old); mount(); vi.mocked(jpycForwarderFor).mockReturnValue(null); + const nextOrder = canonicalOrder({ ...f.order, orderId: 'new', handle: 'bob' }); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, orderId: 'new', webhook: `${location.origin}/api/order/notify?h=bob` } }), { wrapper: makeWrapper() }); + await waitFor(() => expect(result.current.isRestoring).toBe(false)); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: nextOrder })); + await waitFor(() => expect(result.current.data?.success).toBe(true)); + expect(loadOrderDeliveries().records).toHaveLength(2); + expect(loadOrderDeliveries().records).toContainEqual(old); + expect(result.current.orderDelivery?.order.orderId).toBe('new'); + expect(sessionStorage.getItem(RELAY_INTENT_STORAGE_KEY)).toBeNull(); + expect(signTypedData).toHaveBeenCalledOnce(); + }); + it('IP rejection removes the disk record across reload and a retry save failure never POSTS again', async () => { + const { ORDER_DELIVERY_KEY, loadOrderDelivery } = await import('@/lib/orderDelivery'); + const f = await saved(); mount(); vi.mocked(jpycForwarderFor).mockReturnValue(null); + fetchSpy.mockResolvedValueOnce(Response.json({ error: 'ip_rate_limited' }, { status: 429 })); + const first = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + act(() => first.result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n, order: f.order })); + await waitFor(() => expect(first.result.current.isError).toBe(true)); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + const original = Storage.prototype.setItem; + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key.startsWith(ORDER_DELIVERY_KEY)) throw new Error('quota'); + original.call(this, key, value); + }); + act(() => first.result.current.retryRelay()); + await waitFor(() => expect(first.result.current.error?.message).toBe('order_binding_storage_unavailable')); + expect(fetchSpy).toHaveBeenCalledOnce(); expect(signTypedData).toHaveBeenCalledOnce(); + write.mockRestore(); first.unmount(); + const next = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: ORDER_CONTEXT }), { wrapper: makeWrapper() }); + await waitFor(() => expect(next.result.current.isRestoring).toBe(false)); + expect(next.result.current.restoredIntent).toBeNull(); expect(next.result.current.hasActiveIntent).toBe(false); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); +}); + + +describe('A2c background signed-order recovery', () => { + async function signed() { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { saveOrderDelivery } = await import('@/lib/orderDelivery'); + const f = bindingFixture('free', { chainId: jpycDep.chainId, tokenAddress: jpycDep.address, merchant: MERCHANT, handle: 'alice', orderId: 'old', items: [{ name: 'Tea', qty: 1, price: '300' }] }); + const validBefore = String(Math.floor(Date.now() / 1000) + 300); + const record = { ...f.record, bind: { ...f.bind, validBefore }, intent: { ...f.record.intent, validBefore, issuedAt: Date.now() } }; + expect(saveOrderDelivery(record)).toBe(true); mount(); vi.mocked(jpycForwarderFor).mockReturnValue(null); vi.useFakeTimers(); + return record; + } + it('holds a new same-shop signature and resolves the old payment only into background delivery', async () => { + const record = await signed(); + fetchSpy.mockImplementation(async () => Response.json({ ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, orderId: 'new' } }), { wrapper: makeWrapper() }); + await flushStorageLoad(); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n })); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(signTypedData).not.toHaveBeenCalled(); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(result.current.backgroundOrderDeliveries).toEqual([expect.objectContaining({ state: 'notify-pending', order: record.order })]); + }, { timeout: 2000, interval: 10 }); + expect(result.current.data).toBeUndefined(); expect(result.current.restoredIntent).toBeNull(); + expect(fetchSpy.mock.calls.every(([url]) => url === '/api/relay/jpyc/status')).toBe(true); + expect(signTypedData).not.toHaveBeenCalled(); vi.useRealTimers(); + }); + it.each(['expired', 'reverted'])('%s proof abandons a non-matching signed record', async (outcome) => { + await signed(); const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + if (outcome === 'expired') vi.advanceTimersByTime(300_000); + waitForTransactionReceipt.mockResolvedValue({ status: 'reverted' }); + fetchSpy.mockImplementation(async () => Response.json(outcome === 'expired' ? { ok: true, state: 'unused' } : { ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + const { result } = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, orderId: 'new' } }), { wrapper: makeWrapper() }); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(9000)); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + }, { timeout: 2000, interval: 10 }); + expect(result.current.data).toBeUndefined(); expect(result.current.restoredIntent).toBeNull(); + expect(result.current.orderPaymentHold).toBe(false); + expect(signTypedData).not.toHaveBeenCalled(); + fetchSpy.mockResolvedValue(Response.json({ ok: true, txHash: `0x${'cd'.repeat(32)}` })); + act(() => result.current.mutate({ merchant: MERCHANT, value: 300n * 10n ** 18n })); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(signTypedData).toHaveBeenCalledOnce(); vi.useRealTimers(); + }); + it('unmounting a background receipt reader preserves the unresolved opening', async () => { + const record = await signed(); const { loadOrderDelivery } = await import('@/lib/orderDelivery'); + let receipt!: (value: { status: 'success' }) => void; + waitForTransactionReceipt.mockImplementation(() => new Promise((resolve) => { receipt = resolve; })); + fetchSpy.mockResolvedValue(Response.json({ ok: true, state: 'settled', txHash: `0x${'ab'.repeat(32)}` })); + const hook = renderHook(() => useJpycEip3009Payment(jpycDep, { restoreOrderDelivery: true, orderContext: { ...ORDER_CONTEXT, orderId: 'new' } }), { wrapper: makeWrapper() }); + await vi.waitFor(async () => { + await act(async () => vi.advanceTimersByTimeAsync(3000)); + expect(waitForTransactionReceipt).toHaveBeenCalledOnce(); + }, { timeout: 2000, interval: 10 }); + hook.unmount(); await act(async () => receipt({ status: 'success' })); + expect(loadOrderDelivery()).toEqual({ kind: 'ready', record }); + expect(signTypedData).not.toHaveBeenCalled(); vi.useRealTimers(); + }); +}); diff --git a/tests/lib/order/agentOrderFinalize-lua.test.ts b/tests/lib/order/agentOrderFinalize-lua.test.ts index 916d1780..532a1946 100644 --- a/tests/lib/order/agentOrderFinalize-lua.test.ts +++ b/tests/lib/order/agentOrderFinalize-lua.test.ts @@ -5,14 +5,14 @@ import { h, TOKEN, OTHER, UNIT, pay, notify, nonce, listKey, usedKey, orders, co afterAll(closeRedisLuaEngine); describe('A2b stateful agentOrderFinalize (real Lua)', () => { - it('checks every AuthorizationUsed event, ignores fake emitters, and fails closed on reservation reads', async () => { + it('checks every AuthorizationUsed event; fake emitters cannot hide incomplete forwarder evidence', async () => { await beginPending(); const saved = [...h.logs]; h.logs = [transfer(), authorization(`0x${'ff'.repeat(32)}`), ...saved]; - expect((await notify.POST(publicRequest())).status).toBe(409); expect(orders()).toEqual([]); + expect((await notify.POST(publicRequest({ bind: { v: 2 } }))).status).toBe(409); expect(orders()).toEqual([]); h.fail = (op, keys) => op === 'GET' && keys[0].startsWith('order:agentres:') ? 'before' : undefined; expect((await notify.POST(publicRequest())).status).toBe(503); expect(h.db!.strings.has(usedKey)).toBe(false); h.fail = null; h.logs = [transfer(), authorization(nonce, OTHER)]; - expect((await notify.POST(publicRequest())).status).toBe(200); expect(orders()).toHaveLength(1); + expect((await notify.POST(publicRequest())).status).toBe(422); expect(orders()).toHaveLength(0); }); it('a token log with another ABI event name cannot hide a later reserved authorization', async () => { diff --git a/tests/lib/orderBind.test.ts b/tests/lib/orderBind.test.ts new file mode 100644 index 00000000..c9654a02 --- /dev/null +++ b/tests/lib/orderBind.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalOrder, orderDigest, orderBindSalt, parseOrderBind } from '@/lib/orderBind'; + +export const orderInput = { + chainId: 80002, + tokenAddress: '0x0000000000000000000000000000000000000abc', + merchant: '0x1111111111111111111111111111111111111111', + handle: 'Alice', orderId: 'order-1', + items: [{ name: 'Cafe\u0301', qty: 2, price: '01.00' }], + description: 'Table 1', pickupAt: 1800000000000, + statusToken: 's'.repeat(43), customerMemo: ' No ice ', +}; +const secret = `0x${'12'.repeat(32)}` as const; + +describe('A2c canonical order binding', () => { + it('fixes the versioned ABI golden vector', () => { + expect(orderDigest(canonicalOrder(orderInput))).toBe('0xa604da5b53a869469e68109ae2e4ce47367ba3458370a494ee233e8a366f0e09'); + }); + it('normalizes NFC and handle case explicitly', () => { + expect(canonicalOrder(orderInput)).toEqual(canonicalOrder({ ...orderInput, handle: 'alice', items: [{ name: 'Café', qty: 2, price: '01.00' }] })); + }); + it('survives the actual client JSON server path including non-idempotent sanitizer edges', () => { + const client = canonicalOrder({ ...orderInput, items: [{ name: `${'x'.repeat(79)} y`, qty: '1000', price: '01.00' }], description: `${'t'.repeat(63)} rest`, customerMemo: `\u0000 ${'m'.repeat(118)} rest` }); + const server = canonicalOrder(JSON.parse(JSON.stringify(client))); + expect(server).toEqual(client); + expect(orderDigest(server)).toBe(orderDigest(client)); + }); + it.each([ + { chainId: 137 }, { tokenAddress: '0x2222222222222222222222222222222222222222' }, + { merchant: '0x2222222222222222222222222222222222222222' }, { handle: 'bob' }, + { orderId: 'other' }, { items: [{ name: 'Café', qty: 2, price: '1' }] }, + { pickupAt: 1800000000001 }, { statusToken: 't'.repeat(43) }, + { customerMemo: 'other' }, { description: 'Table 2' }, + ])('commits every fulfillment field: %j', (change) => { + expect(orderDigest(canonicalOrder({ ...orderInput, ...change }))).not.toBe(orderDigest(canonicalOrder(orderInput))); + }); + it('excludes transport fields and keeps absolute pickup milliseconds', () => { + const order = canonicalOrder({ ...orderInput, ts: 1, blockNumber: '2', feeTxHash: 'ignored' }); + expect(order.pickupAt).toBe(1800000000000); + expect(orderDigest(order)).toBe(orderDigest(canonicalOrder(orderInput))); + }); + it('a copied public salt cannot open a different order', () => { + const digest = orderDigest(canonicalOrder(orderInput)); + const salt = orderBindSalt(digest, secret); + expect(orderBindSalt(digest, salt)).not.toBe(salt); + expect(orderBindSalt(orderDigest(canonicalOrder({ ...orderInput, orderId: 'stolen' })), secret)).not.toBe(salt); + }); + it.each([null, {}, { v: 2, secret, validAfter: '0', validBefore: '1' }, { v: 1, secret }, { v: 1, secret, validAfter: '-1', validBefore: '1' }, { v: 1, secret, validAfter: '0', validBefore: String(2n ** 256n) }])('rejects malformed present binding %j', (bind) => { + expect(parseOrderBind(bind)).toBeNull(); + }); +}); + +it('NFC remains stable when removing memo controls creates a combining sequence', () => { + const canonical = canonicalOrder({ ...orderInput, customerMemo: 'e\u0000\u0301' }); + expect(canonical.customerMemo).toBe('é'); + expect(canonicalOrder(JSON.parse(JSON.stringify(canonical)))).toEqual(canonical); +}); diff --git a/tests/lib/orderBindingVerify.test.ts b/tests/lib/orderBindingVerify.test.ts new file mode 100644 index 00000000..98ee1a4c --- /dev/null +++ b/tests/lib/orderBindingVerify.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { verifyOrderBinding, orderBindEnforced } from '@/lib/order/orderBindingVerify'; +import { bindingFixture, BIND_FORWARDER, BIND_FEE, bindTransfer } from '../_helpers/orderBinding'; +vi.mock('@/lib/relay/forwarderConfig', async (original) => ({ ...await original(), configuredJpycForwarderFor: () => '0x3333333333333333333333333333333333333333' })); +const input = { chainId: 80002, tokenAddress: '0x0000000000000000000000000000000000000abc', merchant: '0x9999999999999999999999999999999999999999', handle: 'alice', orderId: 'test', items: [{ name: 'Tea', qty: 1, price: '1000' }], description: 'Table 1', statusToken: 's'.repeat(43) }; +afterEach(() => vi.unstubAllEnvs()); +function check(f: ReturnType, logs = f.logs, body: Record = f.body) { + return verifyOrderBinding({ body, chainId: f.order.chainId, merchant: f.order.merchant, token: f.order.tokenAddress, handle: 'alice', logs, feeReceiver: BIND_FEE }); +} +describe('A2c receipt attribution and downgrade fences', () => { + it('flag is server-only and default OFF; accepts both documented project boolean forms', () => { + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', undefined); expect(orderBindEnforced()).toBe(false); + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', '1'); expect(orderBindEnforced()).toBe(true); + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', 'true'); expect(orderBindEnforced()).toBe(true); + }); + it.each(['free', 'recover'] as const)('%s malformed/missing evidence never falls into standard residual', (mode) => { + const f = bindingFixture(mode, input); + const { bind: _bind, ...absent } = f.body; + expect(check(f, f.logs.map((l, i) => i === 0 ? { ...l, address: BIND_FEE } : l), absent)).toEqual(mode === 'recover' ? { ok: false } : { ok: true, kind: 'standard' }); + expect(check(f, f.logs.map((l, i) => i === 0 ? { ...l, topics: l.topics.slice(0, 2) } : l), absent)).toEqual({ ok: false }); + expect(check(f, f.logs.filter((_, i) => i !== 0), f.body)).toEqual({ ok: false }); + }); + it('a fake forwarder Settled cannot establish a valid recover opening', () => { + const f = bindingFixture('recover', input); + expect(check(f, f.logs.map((log) => log.address === BIND_FORWARDER ? { ...log, address: BIND_FEE } : log))).toEqual({ ok: false }); + }); + it('multiple free authorizations cannot borrow an unrelated merchant transfer', () => { + const f = bindingFixture('free', input); + const other = bindingFixture('free', { ...input, orderId: 'other' }); + expect(check(f, [f.logs[0], other.logs[0], other.logs[1]])).toEqual({ ok: false }); + }); + it('recover selects its own authorizer/nonce/settlement and fee; unrelated money is excluded', () => { + const f = bindingFixture('recover', input, 50n * 10n ** 18n); + const other = bindingFixture('recover', { ...input, orderId: 'other' }, 9000n * 10n ** 18n); + expect(check(f, [...other.logs, ...f.logs])).toMatchObject({ ok: true, value: 50n * 10n ** 18n, sameSourceFeeValue: 13n * 10n ** 18n }); + expect(check(f, [...other.logs, ...f.logs.filter((_, i) => i !== 0)])).toEqual({ ok: false }); + }); + it('a bound dust settlement cannot use another payment to reach the floor', () => { + const f = bindingFixture('recover', input, 1n); + const other = bindingFixture('recover', { ...input, orderId: 'other' }); + expect(check(f, [...other.logs, ...f.logs])).toEqual({ ok: false }); + }); + it('recover missing Settled cannot downgrade even if bind was entirely omitted while OFF', () => { + const f = bindingFixture('recover', input); + const { bind: _bind, ...body } = f.body; + expect(check(f, f.logs.slice(0, -1), body)).toEqual({ ok: false }); + }); + it('standard receipt remains unbound even with enforcement ON, never displays bindingMissing as cryptographic proof', () => { + vi.stubEnv('ENABLE_ORDER_BIND_ENFORCE', 'true'); + const f = bindingFixture('free', input); + const { bind: _bind, ...body } = f.body; + expect(check(f, [bindTransfer(f.order.tokenAddress, BIND_FEE, f.order.merchant, 1000n)], body)).toEqual({ ok: true, kind: 'standard' }); + }); +}); + +it('ignores fake emitters in an otherwise standard transfer without pretending they prove a binding', () => { + const f = bindingFixture('free', input); + const { bind: _bind, ...body } = f.body; + const logs = [{ ...f.logs[0], address: BIND_FEE }, f.logs[1]]; + expect(check(f, logs, body)).toEqual({ ok: true, kind: 'standard' }); + expect(check(f, logs)).toEqual({ ok: false }); +}); diff --git a/tests/lib/orderDelivery.test.ts b/tests/lib/orderDelivery.test.ts new file mode 100644 index 00000000..02faa77d --- /dev/null +++ b/tests/lib/orderDelivery.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { canonicalOrder, orderBindSalt, orderDigest } from '@/lib/orderBind'; +import { loadOrderDelivery, saveOrderDelivery, resolveOrderDelivery, acknowledgeOrderDelivery, ORDER_DELIVERY_KEY, type OrderDelivery } from '@/lib/orderDelivery'; +const order = canonicalOrder({ chainId: 80002, tokenAddress: '0x0000000000000000000000000000000000000abc', merchant: '0x1111111111111111111111111111111111111111', handle: 'alice', orderId: 'id', items: [{ name: 'Tea', qty: 1, price: '100' }], statusToken: 's'.repeat(43) }); +const secret = `0x${'12'.repeat(32)}` as const; +const record: OrderDelivery = { + version: 1, order, bind: { v: 1, secret, validAfter: '0', validBefore: '1800000000' }, + intent: { chainId: 80002, from: '0x2222222222222222222222222222222222222222', merchant: order.merchant, merchantValue: '100000000000000000000', feeValue: '0', nonce: orderBindSalt(orderDigest(order), secret), validBefore: '1800000000', routeKind: 'free', issuedAt: 1700000000000 }, + forwarder: null, state: 'signed', +}; +beforeEach(() => { vi.restoreAllMocks(); sessionStorage.clear(); }); +describe('A2c undelivered order persistence', () => { + it('stores a separate validated snapshot before broadcast and retains it at payment resolution', () => { + expect(saveOrderDelivery(record)).toBe(true); + expect(loadOrderDelivery()).toEqual({ kind: 'ready', record }); + const resolved = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + expect(resolved.state).toBe('notify-pending'); + expect(saveOrderDelivery(resolved)).toBe(true); + const reloaded = loadOrderDelivery(); + expect(reloaded).toEqual({ kind: 'ready', record: resolved }); + expect(JSON.parse(resolved.notifyBody!)).toMatchObject({ orderId: 'id', bind: record.bind, statusToken: order.statusToken }); + expect(acknowledgeOrderDelivery(resolved)).toBe(true); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + }); + it('lost acknowledgement preserves identical bytes and replacement resolution changes only the hash', () => { + const resolved = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + saveOrderDelivery(resolved); + const loaded = loadOrderDelivery(); + if (loaded.kind !== 'ready') throw new Error('missing record'); + expect(resolveOrderDelivery(loaded.record, resolved.txHash!).notifyBody).toBe(resolved.notifyBody); + const replaced = resolveOrderDelivery(loaded.record, `0x${'cd'.repeat(32)}`); + expect(replaced.bind).toEqual(resolved.bind); + expect(replaced.order).toEqual(resolved.order); + expect(JSON.parse(replaced.notifyBody!).txHash).toBe(replaced.txHash); + }); + it('session loss cannot invent a snapshot or opening', () => { + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); + }); + it.each(['order', 'nonce', 'body'])('rejects corrupted %s instead of regenerating an opening', (field) => { + const saved = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + const corrupt = field === 'order' ? { ...saved, order: { ...order, orderId: 'stolen' } } : field === 'nonce' ? { ...saved, intent: { ...saved.intent, nonce: `0x${'00'.repeat(32)}` } } : { ...saved, notifyBody: '{}' }; + sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify(corrupt)); + expect(loadOrderDelivery()).toEqual({ kind: 'unavailable' }); + }); + it('storage read/write/remove failures are explicit and never create a false acknowledgement', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota'); }); + expect(saveOrderDelivery(record)).toBe(false); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied'); }); + expect(loadOrderDelivery()).toEqual({ kind: 'unavailable' }); + expect(acknowledgeOrderDelivery(record)).toBe(false); + }); +}); + +it('failed acknowledgement cleanup retains the record and does not remove a different authorization', () => { + saveOrderDelivery(record); + const remove = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw new Error('denied'); }); + expect(acknowledgeOrderDelivery(record)).toBe(false); + expect(loadOrderDelivery().kind).toBe('ready'); + remove.mockRestore(); + expect(acknowledgeOrderDelivery({ ...record, intent: { ...record.intent, nonce: `0x${'ab'.repeat(32)}` } })).toBe(true); + expect(loadOrderDelivery().kind).toBe('ready'); +}); + + it('legacy unsigned prepared records never restore or block the next order', () => { + sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify({ ...record, state: 'prepared' })); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); +}); + it('terminal delivery is cleaned on load and never restored or treated as an active order', () => { + const resolved = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify({ ...resolved, state: 'terminal' })); + expect(loadOrderDelivery()).toEqual({ kind: 'empty' }); +}); + +it('separate authorization slots preserve another order through acknowledgement and termination', async () => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { loadOrderDeliveries, terminateOrderDelivery } = await import('@/lib/orderDelivery'); + const other = bindingFixture('free', { ...order, orderId: 'other', handle: 'bob' }).record; + const first = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + const second = resolveOrderDelivery(other, `0x${'cd'.repeat(32)}`); + expect(saveOrderDelivery(first)).toBe(true); expect(saveOrderDelivery(second)).toBe(true); + expect(loadOrderDeliveries().records).toHaveLength(2); + expect(acknowledgeOrderDelivery(first)).toBe(true); + expect(loadOrderDeliveries().records).toEqual([second]); + expect(terminateOrderDelivery(second)).toBe(true); + expect(loadOrderDeliveries().records).toEqual([]); +}); +it('an empty canonical statusToken stays bound but is absent from the flag-OFF notify payload', () => { + const empty = { ...order, statusToken: '' }; + const withoutPickup = { ...record, order: empty, intent: { ...record.intent, nonce: orderBindSalt(orderDigest(empty), secret) } }; + const resolved = resolveOrderDelivery(withoutPickup, `0x${'ab'.repeat(32)}`); + expect(JSON.parse(resolved.notifyBody!)).not.toHaveProperty('statusToken'); + expect(saveOrderDelivery(resolved)).toBe(true); +}); + +it('removes terminal slots on the next load without removing another live order', async () => { + const { bindingFixture } = await import('../_helpers/orderBinding'); + const { orderDeliveryKey, terminateOrderDelivery, loadOrderDeliveries } = await import('@/lib/orderDelivery'); + const dead = resolveOrderDelivery(record, `0x${'ab'.repeat(32)}`); + const next = bindingFixture('free', { ...order, orderId: 'next' }).record; + sessionStorage.setItem(ORDER_DELIVERY_KEY, JSON.stringify(dead)); + expect(terminateOrderDelivery(dead)).toBe(true); expect(saveOrderDelivery(next)).toBe(true); + expect(loadOrderDeliveries().records).toEqual([next]); + expect(sessionStorage.getItem(orderDeliveryKey(dead))).toBeNull(); + expect(sessionStorage.getItem(ORDER_DELIVERY_KEY)).toBeNull(); +}); diff --git a/tests/lib/orderDeliveryContext.test.ts b/tests/lib/orderDeliveryContext.test.ts new file mode 100644 index 00000000..52450c68 --- /dev/null +++ b/tests/lib/orderDeliveryContext.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { matchesOrderDeliveryContext, type OrderDeliveryContext } from '@/lib/orderDeliveryContext'; +import { bindingFixture } from '../_helpers/orderBinding'; +const origin = 'https://open-pay.test'; +const order = bindingFixture('free', { chainId: 80002, tokenAddress: '0x0000000000000000000000000000000000000abc', merchant: '0x1111111111111111111111111111111111111111', handle: 'alice', orderId: 'saved', items: [] }).order; +const intent = { merchantValue: '1000', feeValue: '0' }; +const context: OrderDeliveryContext = { merchant: order.merchant, tokenAddress: order.tokenAddress, chainId: order.chainId, orderId: order.orderId, webhook: `${origin}/api/order/notify?h=alice`, totalValue: 1000n }; +describe('checkout delivery context', () => { + it('matches the same checkout, allowing an omitted orderId and address case differences', () => { + expect(matchesOrderDeliveryContext(order, context, origin, intent)).toBe(true); + expect(matchesOrderDeliveryContext(order, { ...context, orderId: undefined, tokenAddress: order.tokenAddress.toUpperCase() }, origin, intent)).toBe(true); + }); + it.each(['Alice', '%40alice'])('normalizes handle %s before matching', (h) => { + expect(matchesOrderDeliveryContext(order, { ...context, webhook: `${origin}/api/order/notify?h=${h}` }, origin, intent)).toBe(true); + }); + it.each([ + { merchant: '0x2222222222222222222222222222222222222222' }, { chainId: 137 }, + { tokenAddress: '0x0000000000000000000000000000000000000def' }, { orderId: 'new' }, + { webhook: undefined }, { webhook: 'invalid' }, { webhook: 'https://third.example/api/order/notify?h=alice' }, + { webhook: `${origin}/api/order/notify/extra?h=alice` }, { webhook: `${origin}/api/order/notify?h=bob` }, + { webhook: `${origin}/api/order/notify?h=alice&h=alice` }, + ])('rejects mismatching checkout context %j', (change) => { + expect(matchesOrderDeliveryContext(order, { ...context, ...change }, origin, intent)).toBe(false); + }); +}); + +it('payment amount must equal merchantValue plus feeValue', () => { + const intent = { merchantValue: '1000', feeValue: '13' }; + const exact = { ...context, totalValue: 1013n }; + expect(matchesOrderDeliveryContext(order, exact, origin, intent)).toBe(true); + expect(matchesOrderDeliveryContext(order, { ...exact, totalValue: 1000n }, origin, intent)).toBe(false); +}); diff --git a/tests/lib/orderDeliveryRecovery.test.ts b/tests/lib/orderDeliveryRecovery.test.ts new file mode 100644 index 00000000..01adb4aa --- /dev/null +++ b/tests/lib/orderDeliveryRecovery.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OrderDelivery } from '@/lib/orderDelivery'; +import { orderPaymentHoldUntil, recoverOrderDelivery } from '@/lib/orderDeliveryRecovery'; +import { bindingFixture } from '../_helpers/orderBinding'; + +const txHash = `0x${'ab'.repeat(32)}`; +function record(validFor = 300): OrderDelivery { + const saved = bindingFixture('free', { chainId: 80002, tokenAddress: '0x0000000000000000000000000000000000000abc', merchant: '0x1111111111111111111111111111111111111111', handle: 'alice', orderId: 'old', items: [] }).record; + const validBefore = String(Math.floor(Date.now() / 1000) + validFor); + return { ...saved, bind: { ...saved.bind, validBefore }, intent: { ...saved.intent, validBefore, issuedAt: Date.now() } }; +} + +describe('background order expiry checkpoint', () => { + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-24T00:00:00Z')); }); + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + + it('caps the hold by expiry, issuance and loading time', () => { + const now = Date.now(); + expect(orderPaymentHoldUntil(record(20), now)).toBe(now + 20_000); + const saved = record(600); + expect(orderPaymentHoldUntil(saved, now + 100_000)).toBe(now + 300_000); + expect(orderPaymentHoldUntil({ ...saved, intent: { ...saved.intent, issuedAt: now + 600_000 } }, now)).toBe(now + 300_000); + }); + + it('aborts a pre-expiry request and waits for a new read, ignoring the old late reply', async () => { + const saved = record(7); + const reads: { at: number; signal: AbortSignal; finish: (r: Response) => void }[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => new Promise((finish) => { + // Deliberately ignore abort to model a late reply already in transit. + reads.push({ at: Date.now(), signal: init!.signal!, finish }); + })); + const receipt = vi.fn().mockResolvedValue({ status: 'success' }); + const resolved = vi.fn(); const released = vi.fn(); + const cancel = recoverOrderDelivery(saved, receipt, resolved, { loadedAt: Date.now(), onHoldReleased: released }); + await vi.advanceTimersByTimeAsync(3000); + expect(reads).toHaveLength(1); + await vi.advanceTimersByTimeAsync(4000); + expect(reads).toHaveLength(2); + expect(reads[1].at).toBe(Number(saved.intent.validBefore) * 1000); + expect(reads[0].signal.aborted).toBe(true); + reads[0].finish(Response.json({ ok: true, state: 'settled', txHash })); + await vi.advanceTimersByTimeAsync(0); + expect(released).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); expect(receipt).not.toHaveBeenCalled(); + reads[1].finish(Response.json({ ok: true, state: 'indeterminate' })); + await vi.advanceTimersByTimeAsync(0); + expect(released).toHaveBeenCalledOnce(); expect(resolved).not.toHaveBeenCalled(); + cancel(); + }); + + it('checks an already expired record immediately, then still requires two unused reads to abandon it', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: true, state: 'unused' })); + const resolved = vi.fn(); const released = vi.fn(); + const cancel = recoverOrderDelivery(record(-1), vi.fn(), resolved, { loadedAt: Date.now(), onHoldReleased: released }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchSpy).toHaveBeenCalledOnce(); expect(released).toHaveBeenCalledOnce(); expect(resolved).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3000); + expect(resolved).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(6000); + expect(resolved).toHaveBeenCalledWith({ kind: 'expired' }); + cancel(); + }); + + it('shares the ten-second checkpoint budget between status and receipt reads', async () => { + const saved = record(1); + vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { + setTimeout(() => resolve(Response.json({ ok: true, state: 'settled', txHash })), 9000); + })); + const receipt = vi.fn((_hash, timeout: number) => new Promise<{ status: 'success' }>((_resolve, reject) => { + setTimeout(() => reject(new Error('receipt unavailable')), timeout); + })); + const resolved = vi.fn(); const released = vi.fn(); + const cancel = recoverOrderDelivery(saved, receipt, resolved, { loadedAt: Date.now(), onHoldReleased: released }); + await vi.advanceTimersByTimeAsync(10_000); + expect(receipt).toHaveBeenCalledWith(txHash, 1000); + expect(released).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(999); + expect(released).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(released).toHaveBeenCalledOnce(); expect(resolved).not.toHaveBeenCalled(); + cancel(); + }); + + it('unmount aborts the expiry read and cannot release or resolve into another checkout', async () => { + let signal: AbortSignal | null = null; + vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => new Promise((_resolve, reject) => { + signal = init!.signal!; + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + })); + const resolved = vi.fn(); const released = vi.fn(); + const cancel = recoverOrderDelivery(record(1), vi.fn(), resolved, { loadedAt: Date.now(), onHoldReleased: released }); + await vi.advanceTimersByTimeAsync(1000); + cancel(); + await vi.advanceTimersByTimeAsync(60_000); + expect(signal!.aborted).toBe(true); + expect(released).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +});