diff --git a/.env.local.example b/.env.local.example index b5f632dd..51b57cc7 100644 --- a/.env.local.example +++ b/.env.local.example @@ -634,8 +634,18 @@ RELAY_MAX_JPYC= # 注: mainnet (Polygon 137) を self-host で relay するには KV (KV_REST_API_URL/TOKEN) 必須。 # 未設定だと idempotency が fail-open になり二重 submit リスク → mainnet は 503 kv_required で拒否。 # B4 circuit breaker: chain 日次の relay 件数上限 (Sybil による POL 枯渇 griefing を止める)。 -# 空 = 既定 500。KV (Upstash) 設定時のみ機能・fail-open (KV 障害は通す)。UTC 日付でカウント。 +# 空/不正/巨大値 = 既定 500。非負の有限 safe integer のみ有効。KV (Upstash) 設定時のみ機能・ +# fail-open (KV 障害は通す)。UTC 日付でカウント。 RELAY_DAILY_TX_CAP= +# forwarder の回収額が chain 別ガスフロア未満になる settle 専用の別枠 (UTC 日次・chain 単位)。 +# 共有 RELAY_DAILY_TX_CAP より先に評価し、低回収 settle が通常の gasless 枠を枯らすのを防ぐ。 +# 空/不正/巨大値 = 実効共有枠の 20% (整数切捨て、共有枠 500 なら 100)。共有枠が正なら明示値も +# 共有枠未満へ clamp (共有枠 1 なら 0)。0 にすると sub-floor settle を停止。 +RELAY_SUBFLOOR_DAILY_TX_CAP= +# 同じ署名済み払い元が上の専用枠を単独で枯らさないための UTC 日次上限 (chain + payer 単位)。 +# 空/不正/巨大値 = 実効専用枠の 20% (整数切捨て、専用枠 100 なら 20)。専用枠 > 1 なら明示値も +# 専用枠未満へ clamp。専用枠 1 なら最大 1、専用枠 0 なら 0。Sybil は上の chain 単位枠で止める。 +RELAY_SUBFLOOR_PAYER_DAILY_TX_CAP= # B5 赤字防止: 1 tx の native (POL) gas コスト上限 (wei)。gas見積×gasPrice がこれを超える高騰時は # relay せず standard モードへ倒す。空/0 = 無効 (testnet 既定)。mainnet は回収 fee 相当額に設定。 # 例: 0.01 POL = 10000000000000000 (回収 fee 2 JPYC が賄える範囲で worst-case を弾く値にする)。 diff --git a/README.md b/README.md index 519e7fdb..240b28e0 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ The table below is a **curated subset** (core setup + production feature flags). | `NEXT_PUBLIC_*_RPC_URL` | Custom RPC per chain | recommended on prod | | `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_AUTH_TOKEN` | Sentry client + source-map upload | recommended on prod | | `KV_REST_API_URL` / `KV_REST_API_TOKEN` | Vercel KV — payment log **and** SIWE sessions / **encrypted** freee tokens / entitlements | optional (required for SIWE + freee sync) | +| `RELAY_DAILY_TX_CAP` / `RELAY_SUBFLOOR_DAILY_TX_CAP` / `RELAY_SUBFLOOR_PAYER_DAILY_TX_CAP` | Server-only UTC-day circuit breakers for all relay traffic, sub-floor forwarder settlements, and each signed sub-floor payer. Only non-negative finite safe integers are accepted; invalid or oversized values use safe defaults. The shared default is **500**. An unset sub-floor cap is 20% of the effective shared cap (**100** by default), and an unset payer cap is 20% of the effective sub-floor cap (**20** by default), rounded down. When the shared cap is positive, the effective sub-floor cap is clamped below it (shared `1` ⇒ sub-floor `0`); when the sub-floor cap is above `1`, the payer cap is clamped below it (sub-floor `1` allows at most payer `1`). Sub-floor `0` keeps both sub-floor caps stopped. Requires KV; storage failures follow the existing fail-open policy. | optional (recommended on prod) | | `IP_HASH_SECRET` | Server-only HMAC secret for best-effort IP rate limits on auth and resource-write endpoints. Generate with `openssl rand -base64 32`; unset or shorter than 32 bytes makes the IP limiter inert. Self-hosted deployments must use a trusted proxy that overwrites client-supplied `X-Forwarded-For`. | optional (recommended on prod) | | `PAYMENT_LOG_ADMIN_TOKEN` | Bearer for `/api/log/payment/export` + `/stats` | optional | | `ADMIN_WALLETS` | Comma-separated wallet addresses allowed to read `/api/admin/billing/revenue` (requires a valid SIWE session **and** the wallet in this allowlist). Empty/unset ⇒ admin endpoints are closed (fail-closed). | optional | diff --git a/app/api/agent-order/pay/route.ts b/app/api/agent-order/pay/route.ts index 5c8682f1..fab53f42 100644 --- a/app/api/agent-order/pay/route.ts +++ b/app/api/agent-order/pay/route.ts @@ -2,7 +2,7 @@ // // 402 → X-PAYMENT / PAYMENT-SIGNATURE → facilitator verify/settle という既存 x402 レール // (app/api/paid/_shared.ts) を **踏襲** する。ただし amount / payTo / resource が注文ごとに動的なため -// _shared の first-party helper は使えず、同形のフローをここに閉じて組む (_shared.ts は無改変・掟12)。 +// _shared の first-party 固定価格 helper は使えないため、同形のフローをこの route に閉じて組む。 // - amount = computeAgentOrder が menu から確定した合計 (顧客申告額は信じない) // - payTo = record.config.to (@handle 権威・project_mobileorder_receiver_config_to) // - resource = 正規順 (h, cart, table, pickupAt) で組んだ自 URL (MCP の accepts.resource 照合用) @@ -24,8 +24,31 @@ import { readShopLive } from '@/lib/shopLiveStore'; import { isBeforeOpen, isPastLastOrder, pickupSlots } from '@/lib/shopTime'; import { createJpycPaymentRequirements } from '@/lib/x402/requirements'; import { x402FacilitatorConfig } from '@/lib/x402/facilitatorConfig'; +import { resolveFacilitatorPaymentStatus } from '@/lib/x402/facilitatorStatus'; import { OPENPAY_CANONICAL_ORIGIN } from '@/lib/x402/firstParty'; +import { caip2ForChainId } from '@/lib/x402/network'; import { decodeAgentCart, computeAgentOrder } from '@/lib/agentOrder'; +import { sanitizeTable } from '@/lib/orderRelay'; +import { + createAgentOrderSnapshot, + parseAgentOrderSettlement, + parseBoundAgentOrderSnapshot, + pickupAtForAgentOrderSnapshot, + type AgentOrderSettlement, + type AgentOrderSnapshot, +} from '@/lib/x402/agentOrderRecovery'; +import { + claimPaymentRedelivery, + isFacilitatorPreBroadcastRejection, + lookupPaymentRedelivery, + paymentRedeliveryIdentity, + promotePaymentRedelivery, + releasePaymentRedelivery, + type PaymentRedeliveryBinding, + type PaymentRedeliveryIdentity, + type PaymentRedeliveryRecord, +} from '@/lib/x402/paymentRedelivery'; +import { checkFacilitatorStatusRateLimit } from '@/lib/x402/facilitatorStatusRateLimit'; import { buildPaymentRequiredV2, decodePaymentSignatureHeaderValue, @@ -49,8 +72,9 @@ type SettleBody = { success?: boolean; errorReason?: string; payer?: string; - transaction?: string; -}; + transaction?: string | null; + network?: string; +} & Record; function agentOrderEnabled(): boolean { return ( @@ -92,6 +116,145 @@ function canonicalResourceUrl( return `${OPENPAY_CANONICAL_ORIGIN}/api/agent-order/pay?${params.toString()}`; } +function paymentInvalidResponse(): NextResponse { + return NextResponse.json( + { x402Version: 1, error: 'payment_invalid' }, + { status: 402 }, + ); +} + +function pendingRecoveryResponse(snapshot: AgentOrderSnapshot): NextResponse { + return NextResponse.json( + { + success: false, + errorReason: 'pending', + transaction: null, + network: caip2ForChainId(snapshot.chainId), + payer: snapshot.payer, + }, + { status: 202 }, + ); +} + +async function settledOrderResponse(input: { + req: Request; + snapshot: AgentOrderSnapshot; + settlement: AgentOrderSettlement; +}): Promise { + const { req, snapshot, settlement } = input; + const txHash = settlement.transaction; + const orderId = `agent-${txHash.slice(0, 18)}`; + + // 受注登録 (付帯処理) を既存の受注リレーへ委譲する。掟13 の隔離: **受注登録の失敗 (KV 障害 / + // on-chain 検証遅延 / notify の想定外 throw) が「決済成功」という本体を巻き込まない** ための防御。 + // 支払いは既に settle 済 (不可逆) なので、notify がこけても 200 + orderRegistered:false + txHash を + // 返し、店主は履歴/txHash から追える。ここで throw を握るのはこの波及を断つためであり、他意はない。 + let orderRegistered = false; + try { + const notifyReq = new Request( + new URL( + `/api/order/notify?h=${encodeURIComponent(snapshot.handle)}`, + req.url, + ), + { + method: 'POST', + headers: cloneForwardHeaders(req), + body: JSON.stringify({ + token: 'jpyc', + txHash, + chainId: snapshot.chainId, + merchant: snapshot.merchant, + orderId, + items: snapshot.items, + description: snapshot.table ?? undefined, + pickupAt: snapshot.pickupAt ?? undefined, + from: settlement.payer, + }), + }, + ); + const notifyRes = await notifyOrder(notifyReq); + const notifyBody = (await notifyRes.json()) as { ok?: boolean }; + orderRegistered = notifyRes.status === 200 && notifyBody.ok === true; + } catch { + orderRegistered = false; + } + + const res = NextResponse.json({ + ok: true, + orderId, + txHash, + amountJpyc: formatUnits( + BigInt(snapshot.totalMinor), + snapshot.decimals, + ), + orderRegistered, + }); + res.headers.set('X-PAYMENT-RESPONSE', encodeJsonBase64(settlement)); + res.headers.set( + 'PAYMENT-RESPONSE', + encodePaymentResponseHeaderValue(settlement), + ); + return res; +} + +async function recoverMatchedPayment(input: { + req: Request; + identity: PaymentRedeliveryIdentity; + binding: PaymentRedeliveryBinding; + record: PaymentRedeliveryRecord; +}): Promise { + const { req, identity, binding, record } = input; + const snapshot = parseBoundAgentOrderSnapshot({ + context: record.context, + facilitatorBody: record.facilitatorBody, + resource: binding.resource, + identity, + }); + if (snapshot === null) return paymentInvalidResponse(); + + if (record.state === 'settled') { + const settlement = parseAgentOrderSettlement( + record.settlement, + snapshot, + ); + return settlement === null + ? paymentInvalidResponse() + : settledOrderResponse({ req, snapshot, settlement }); + } + + if (!(await checkFacilitatorStatusRateLimit(req))) { + return pendingRecoveryResponse(snapshot); + } + const status = await resolveFacilitatorPaymentStatus( + record.facilitatorBody, + ); + if ( + !status.ok || + status.state !== 'settled' || + status.txHash === null + ) { + return pendingRecoveryResponse(snapshot); + } + const settlement = parseAgentOrderSettlement( + { + success: true, + transaction: status.txHash, + network: caip2ForChainId(status.chainId), + payer: status.payer, + }, + snapshot, + ); + if (settlement === null) return paymentInvalidResponse(); + + const promotion = await promotePaymentRedelivery({ + identity, + binding, + settlement, + }); + if (promotion.kind === 'conflict') return paymentInvalidResponse(); + return settledOrderResponse({ req, snapshot, settlement }); +} + function paymentRequired( res: NextResponse, resourceUrl: string, @@ -142,6 +305,62 @@ export async function GET(req: Request): Promise { const cartParam = url.searchParams.get('cart') ?? ''; const tableParam = url.searchParams.get('table'); // 生値 (resource echo 用・notify が sanitize) const pickupAtParam = url.searchParams.get('pickupAt'); + const resourceUrl = canonicalResourceUrl( + handle, + cartParam, + tableParam, + pickupAtParam, + ); + const binding: PaymentRedeliveryBinding = { + scope: 'agent-order', + resource: resourceUrl, + }; + + const paymentSignatureHeader = req.headers.get('PAYMENT-SIGNATURE'); + const paymentHeader = req.headers.get('x-payment'); + let decodedPaymentPayload: unknown; + let decodedPaymentPayloadReady = false; + if (paymentSignatureHeader) { + try { + decodedPaymentPayload = decodePaymentSignatureHeaderValue( + paymentSignatureHeader, + ); + decodedPaymentPayloadReady = true; + } catch { + // 既存 challenge 形を保つため、current requirements 生成後の payload error へ委ねる。 + } + } else if (paymentHeader) { + try { + decodedPaymentPayload = decodePaymentHeader(paymentHeader); + decodedPaymentPayloadReady = true; + } catch { + // 既存 challenge 形を保つため、current requirements 生成後の payload error へ委ねる。 + } + } + + const paymentIdentity = decodedPaymentPayloadReady + ? paymentRedeliveryIdentity(decodedPaymentPayload) + : null; + let paymentScopeConflict = false; + if (paymentIdentity) { + const delivery = await lookupPaymentRedelivery( + paymentIdentity, + binding, + ); + if (delivery.kind === 'match') { + // 支払い済み retry は current menu/live 状態を再評価せず、初回 verify 時の immutable + // server snapshot だけを使う。決済後の売切/閉店/価格変更が受注消失へ波及するのを断つ。 + return recoverMatchedPayment({ + req, + identity: paymentIdentity, + binding, + record: delivery.record, + }); + } + // missing/unavailable では cache を解錠せず、従来の current verify/settle へ進む。 + // 復旧 KV の障害が新規の正当な注文決済を停止する波及を断つ。 + paymentScopeConflict = delivery.kind === 'conflict'; + } const cartItems = decodeAgentCart(cartParam); if (cartItems === null) { @@ -228,12 +447,6 @@ export async function GET(req: Request): Promise { const shopName = record.storefront.shopName || record.config.name?.trim() || `@${handle}`; const description = `${shopName} — ${order.summary}`.slice(0, 240); - const resourceUrl = canonicalResourceUrl( - handle, - cartParam, - tableParam, - pickupAtParam, - ); let accepts: Accepts; try { @@ -257,18 +470,29 @@ export async function GET(req: Request): Promise { ); } - const paymentSignatureHeader = req.headers.get('PAYMENT-SIGNATURE'); - const paymentHeader = req.headers.get('x-payment'); if (!paymentSignatureHeader && !paymentHeader) { return challenge(resourceUrl, description, accepts, 'payment_required'); } + if (paymentScopeConflict) { + return challenge(resourceUrl, description, accepts, 'payment_invalid'); + } + if (decodedPaymentPayloadReady && paymentIdentity === null) { + return challenge( + resourceUrl, + description, + accepts, + 'invalid_payment_payload', + ); + } // payload (v2 PAYMENT-SIGNATURE / v1 X-PAYMENT) → facilitator v1 body に正規化 (_shared と同形)。 - let facilitatorBody: unknown; + let facilitatorBody: Record; if (paymentSignatureHeader) { let payloadV2: unknown; try { - payloadV2 = decodePaymentSignatureHeaderValue(paymentSignatureHeader); + payloadV2 = decodedPaymentPayloadReady + ? decodedPaymentPayload + : decodePaymentSignatureHeaderValue(paymentSignatureHeader); } catch { return challenge( resourceUrl, @@ -286,11 +510,13 @@ export async function GET(req: Request): Promise { 'invalid_payment_payload', ); } - facilitatorBody = v1Body; + facilitatorBody = { ...v1Body }; } else { let payload: unknown; try { - payload = decodePaymentHeader(paymentHeader!); + payload = decodedPaymentPayloadReady + ? decodedPaymentPayload + : decodePaymentHeader(paymentHeader!); } catch { return challenge( resourceUrl, @@ -315,10 +541,12 @@ export async function GET(req: Request): Promise { }), ); const verifyBody = (await verifyRes.json()) as VerifyBody; - if (verifyRes.status !== 200) { - return NextResponse.json(verifyBody, { status: verifyRes.status }); - } - if (verifyBody.isValid !== true) { + if (verifyRes.status !== 200 || verifyBody.isValid !== true) { + // status recovery は verify 前に exact payment→resource record が一致した経路だけで行う。 + // 任意の expired/invalid payload を current cart の受注へ付け替える波及を断つ。 + if (verifyRes.status !== 200) { + return NextResponse.json(verifyBody, { status: verifyRes.status }); + } return challenge( resourceUrl, description, @@ -327,6 +555,63 @@ export async function GET(req: Request): Promise { ); } + if (paymentIdentity === null) { + return challenge(resourceUrl, description, accepts, 'payment_invalid'); + } + let payer: Address; + try { + payer = getAddress(verifyBody.payer ?? ''); + } catch { + return challenge(resourceUrl, description, accepts, 'payment_invalid'); + } + + const snapshot = createAgentOrderSnapshot({ + handle, + merchant: configTo, + payer, + chainId, + decimals: deployment.decimals, + items: order.items, + totalMinor: order.totalMinor, + resource: resourceUrl, + table: sanitizeTable(tableParam), + pickupAt: pickupAtForAgentOrderSnapshot(pickupAtParam), + }); + if ( + snapshot === null || + parseBoundAgentOrderSnapshot({ + context: snapshot, + facilitatorBody, + resource: resourceUrl, + identity: paymentIdentity, + }) === null + ) { + return challenge(resourceUrl, description, accepts, 'payment_invalid'); + } + + const claim = await claimPaymentRedelivery({ + identity: paymentIdentity, + binding, + facilitatorBody, + context: snapshot, + }); + if (claim.kind === 'conflict') { + return challenge(resourceUrl, description, accepts, 'payment_invalid'); + } + if (claim.kind === 'match') { + return recoverMatchedPayment({ + req, + identity: paymentIdentity, + binding, + record: claim.record, + }); + } + // unavailable は従来 settle を許す一方、record を確認できない request から status 回復は + // 行わない。補助 KV 障害を決済停止へ波及させず、未束縛 status の注文解錠も増やさない。 + const recoveryClaimed = claim.kind === 'claimed'; + const recoveryOwnerToken = + claim.kind === 'claimed' ? claim.record.ownerToken : null; + const settleRes = await settlePayment( new Request(new URL('/api/facilitator/settle', req.url), { method: 'POST', @@ -335,7 +620,56 @@ export async function GET(req: Request): Promise { }), ); const settleBody = (await settleRes.json()) as SettleBody; + if ( + recoveryOwnerToken !== null && + isFacilitatorPreBroadcastRejection(settleRes.status, settleBody) + ) { + await releasePaymentRedelivery({ + identity: paymentIdentity, + binding, + ownerToken: recoveryOwnerToken, + }); + } if (settleRes.status !== 200 || settleBody.success !== true) { + if ( + recoveryClaimed && + settleBody.errorReason === 'pending' && + (await checkFacilitatorStatusRateLimit(req)) + ) { + const status = await resolveFacilitatorPaymentStatus( + facilitatorBody, + ); + if ( + status.ok && + status.state === 'settled' && + status.txHash !== null + ) { + const recovered = parseAgentOrderSettlement( + { + success: true, + transaction: status.txHash, + network: caip2ForChainId(status.chainId), + payer: status.payer, + }, + snapshot, + ); + if (recovered) { + const promotion = await promotePaymentRedelivery({ + identity: paymentIdentity, + binding, + settlement: recovered, + }); + if (promotion.kind === 'conflict') { + return paymentInvalidResponse(); + } + return settledOrderResponse({ + req, + snapshot, + settlement: recovered, + }); + } + } + } if (settleRes.status === 200) { return challenge( resourceUrl, @@ -347,52 +681,30 @@ export async function GET(req: Request): Promise { return NextResponse.json(settleBody, { status: settleRes.status }); } - const txHash = settleBody.transaction ?? ''; - const payer = settleBody.payer ?? verifyBody.payer; - const orderId = `agent-${txHash.slice(0, 18)}`; - - // 受注登録 (付帯処理) を既存の受注リレーへ委譲する。掟13 の隔離: **受注登録の失敗 (KV 障害 / - // on-chain 検証遅延 / notify の想定外 throw) が「決済成功」という本体を巻き込まない** ための防御。 - // 支払いは既に settle 済 (不可逆) なので、notify がこけても 200 + orderRegistered:false + txHash を - // 返し、店主は履歴/txHash から追える。ここで throw を握るのはこの波及を断つためであり、他意はない。 - let orderRegistered = false; - try { - const notifyReq = new Request( - new URL(`/api/order/notify?h=${encodeURIComponent(handle)}`, req.url), - { - method: 'POST', - headers: cloneForwardHeaders(req), - body: JSON.stringify({ - token: 'jpyc', - txHash, - chainId, - merchant: configTo, - orderId, - items: order.items, // サーバー検証済み StoredOrderItem[] - description: tableParam ?? undefined, // テーブル番号ラベル (notify が sanitize) - pickupAt: - pickupAtParam !== null && /^\d+$/.test(pickupAtParam) - ? Number(pickupAtParam) - : undefined, - from: payer, - }), - }, + const settlement = parseAgentOrderSettlement( + { + ...settleBody, + success: true, + network: settleBody.network ?? caip2ForChainId(snapshot.chainId), + payer: settleBody.payer ?? snapshot.payer, + }, + snapshot, + ); + if (settlement === null) { + // malformed な内部 success が空 txHash の受注登録へ波及するのを断つ。facilitator の + // 正常 success 契約は transaction/network/payer を常に返すため、通常応答は不変。 + return NextResponse.json( + { success: false, errorReason: 'settlement_invalid' }, + { status: 502 }, ); - const notifyRes = await notifyOrder(notifyReq); - const notifyBody = (await notifyRes.json()) as { ok?: boolean }; - orderRegistered = notifyRes.status === 200 && notifyBody.ok === true; - } catch { - orderRegistered = false; } - - const res = NextResponse.json({ - ok: true, - orderId, - txHash, - amountJpyc: formatUnits(order.totalMinor, deployment.decimals), - orderRegistered, - }); - res.headers.set('X-PAYMENT-RESPONSE', encodeJsonBase64(settleBody)); - res.headers.set('PAYMENT-RESPONSE', encodePaymentResponseHeaderValue(settleBody)); - return res; + if (recoveryClaimed) { + const promotion = await promotePaymentRedelivery({ + identity: paymentIdentity, + binding, + settlement, + }); + if (promotion.kind === 'conflict') return paymentInvalidResponse(); + } + return settledOrderResponse({ req, snapshot, settlement }); } diff --git a/app/api/billing/settle/route.ts b/app/api/billing/settle/route.ts index 41cd7053..709f5fde 100644 --- a/app/api/billing/settle/route.ts +++ b/app/api/billing/settle/route.ts @@ -4,7 +4,7 @@ // インボイス額をメーター (S1) + 料率 (S2) から算出し、(2) on-chain で「セッション wallet → 受領アドレス・ // JPYC・インボイス額以上」を照合し、成立すれば fee-current (S3) を延長する。二重付与は txHash // idempotency (KV nx・lock→result 昇格) で防止。billing flag OFF では無効。設計: docs/plans/merchant-gasless-fee-a1.md (S4)。 -import { NextResponse } from 'next/server'; +import { NextResponse, after } from 'next/server'; import { createPublicClient, isHex, type Hex } from 'viem'; import { requireSession } from '../../auth/siwe/_session'; import { env } from '@/lib/env'; @@ -20,8 +20,14 @@ import { usageFeeConfig } from '@/lib/usageFee'; import { verifyJpycFeeOnChain } from '@/lib/feeVerify'; import { chainObjectForId, transportForChain } from '@/lib/chains'; import { resolveDeployment } from '@/lib/tokens'; -import { kvSet, kvGet, kvDel } from '@/lib/kv'; +import { kvSet, kvGet, kvDel, kvEval } from '@/lib/kv'; import { logger } from '@/lib/logger'; +import { + legacyBillingPaymentKey, + paymentClaimKey, + paymentClaimResultValue, + RELEASE_PAYMENT_CLAIM_IF_OWNED, +} from '@/lib/paymentClaim'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -35,6 +41,17 @@ const RESULT_PREFIX = 'r:'; type SettleResult = { period: string; expiresAt: number }; +function billingPaymentClaimValue( + wallet: string, + period: string, +): string { + return `${paymentClaimResultValue('billing')}:${wallet.toLowerCase()}:${period}`; +} + +function legacyBillingPaymentClaimValue(period: string): string { + return `${paymentClaimResultValue('billing')}:legacy:${period}`; +} + function parseSettleResult(value: string | null): SettleResult | null { if (!value || !value.startsWith(RESULT_PREFIX)) return null; try { @@ -159,7 +176,7 @@ export async function POST(req: Request): Promise { } // 処理ロックを nx で取得 (txHash 単位)。取れなければ確定結果 (replay) か処理中 (409)。 - const usedKey = `billing:settled:${chainId}:${txHash.toLowerCase()}`; + const usedKey = legacyBillingPaymentKey(chainId, txHash); const claim = await kvSet(usedKey, LOCK_MARKER, { nx: true, ttlSec: SETTLE_LOCK_TTL_SEC, @@ -174,6 +191,35 @@ export async function POST(req: Request): Promise { const existing = await kvGet(usedKey); const prior = existing.ok ? parseSettleResult(existing.value) : null; if (prior) { + after(async () => { + // 旧 result に存在しない payer を current session から補い、別 wallet の支払いとして + // global claim へ誤帰属させる波及を断つ。legacy replay は period だけの marker に固定する。 + const claimValue = legacyBillingPaymentClaimValue(prior.period); + const backfill = await kvSet( + paymentClaimKey(chainId, txHash), + claimValue, + { nx: true }, + ); + // legacy settled tx の global backfill 障害を既存 replay 応答へ波及させず、移行漏れは observable にする。 + if (!backfill.ok) { + logger.warn('billing.settle.global-claim-backfill-failed', { + chainId, + reason: backfill.reason, + }); + } else if (backfill.value === null) { + const existingGlobal = await kvGet( + paymentClaimKey(chainId, txHash), + ); + if (!existingGlobal.ok || existingGlobal.value !== claimValue) { + logger.warn('billing.settle.global-claim-backfill-failed', { + chainId, + reason: existingGlobal.ok + ? 'claim_conflict' + : existingGlobal.reason, + }); + } + } + }); return NextResponse.json({ ok: true, period: prior.period, @@ -187,6 +233,31 @@ export async function POST(req: Request): Promise { ); } + const globalClaimKey = paymentClaimKey(chainId, txHash); + const globalClaimValue = billingPaymentClaimValue( + session.address, + period, + ); + let globalClaimedThisRequest = false; + let billingGrantMade = false; + + async function releaseGlobalClaim(cause: string): Promise { + if (!globalClaimedThisRequest || billingGrantMade) return; + const del = await kvEval( + RELEASE_PAYMENT_CLAIM_IF_OWNED, + [globalClaimKey], + [globalClaimValue], + ); + if (!del.ok) { + logger.error('billing.settle.global-claim-release-failed', { + globalClaimKey, + cause, + reason: del.reason, + }); + } + globalClaimedThisRequest = false; + } + try { const publicClient = createPublicClient({ chain, @@ -228,6 +299,42 @@ export async function POST(req: Request): Promise { ); } + // on-chain 検証後、付与前に Pro/CSV/order と共有する恒久 claim を NX 取得する。 + // 同じ billing wallet+period の値だけは crash/retry として続行し、他用途・他請求への二重利用を断つ。 + const globalClaim = await kvSet(globalClaimKey, globalClaimValue, { + nx: true, + }); + if (!globalClaim.ok) { + await releaseClaim(usedKey, 'global-claim-unavailable'); + logger.error('billing.settle.global-claim-failed', { + globalClaimKey, + reason: globalClaim.reason, + }); + return NextResponse.json( + { ok: false, error: 'kv_unavailable' }, + { status: 503 }, + ); + } + if (globalClaim.value === null) { + const existing = await kvGet(globalClaimKey); + if (!existing.ok) { + await releaseClaim(usedKey, 'global-claim-read-failed'); + return NextResponse.json( + { ok: false, error: 'kv_unavailable' }, + { status: 503 }, + ); + } + if (existing.value !== globalClaimValue) { + await releaseClaim(usedKey, 'global-claim-conflict'); + return NextResponse.json( + { ok: false, error: 'already_processed' }, + { status: 409 }, + ); + } + } else { + globalClaimedThisRequest = true; + } + const granted = await grantFeeCurrent(session.address, { period, txHash, @@ -235,6 +342,7 @@ export async function POST(req: Request): Promise { }); if (!granted.ok) { await releaseClaim(usedKey, 'grant-write-failed'); + await releaseGlobalClaim('grant-write-failed'); logger.error('billing.settle.grant-failed', { wallet: session.address, chainId, @@ -246,6 +354,7 @@ export async function POST(req: Request): Promise { { status: 503 }, ); } + billingGrantMade = true; // 期間別「支払い済み」マーカーを記録する。古い期間の清算では expiresAt が過去で fee-current は // 付かない (= 現行カバレッジは買えない・これが正しい挙動) ため、関所ゲートが「この期間はもう @@ -254,6 +363,8 @@ export async function POST(req: Request): Promise { const marked = await markPeriodPaid(session.address, period, txHash); if (!marked.ok) { await releaseClaim(usedKey, 'paid-marker-write-failed'); + // fee-current は既に付与済みなので global claim は恒久維持する。同じ billing + // wallet+period の retry だけを許し、Pro/CSV/order への二重利用波及を断つ。 logger.error('billing.settle.paid-marker-failed', { wallet: session.address, chainId, @@ -302,6 +413,7 @@ export async function POST(req: Request): Promise { }); } catch (e) { await releaseClaim(usedKey, 'unexpected-error'); + await releaseGlobalClaim('unexpected-error'); logger.error('billing.settle.unexpected', { wallet: session.address, chainId, diff --git a/app/api/facilitator/settle/route.ts b/app/api/facilitator/settle/route.ts index 0e4871e1..de576355 100644 --- a/app/api/facilitator/settle/route.ts +++ b/app/api/facilitator/settle/route.ts @@ -23,7 +23,10 @@ import { buildForwarderNonce } from '@/lib/relay/forwarderIntent'; import { clientIp, hashIp } from '@/lib/net/ipHash'; import { checkIpRateLimit } from '@/lib/relay/relayGuards'; import { MAX_BODY_BYTES } from '@/lib/relay/relayRoute'; -import { x402FacilitatorReady } from '@/lib/x402/facilitatorConfig'; +import { + x402FacilitatorConfig, + x402FacilitatorReady, +} from '@/lib/x402/facilitatorConfig'; import { parseFacilitatorRequest } from '@/lib/x402/facilitatorSettle'; import { caip2ForChainId } from '@/lib/x402/network'; import { @@ -158,13 +161,15 @@ export async function POST(req: Request): Promise { } // 検証 (server 権威 feeValue 照合 / 署名 / 残高 / nonce 未使用 / 冪等 / 日次予算) + forwarder 健全性 + - // submit/poll は共有 settleViaForwarder。forwarderFor は raw (a1 非依存)・idempotency は専用名前空間。 + // submit/poll は共有 settleViaForwarder。sub-floor は x402 自身の fee floor、forwarderFor は raw + // (a1 非依存)・idempotency は専用名前空間。 const result = await settleViaForwarder({ chainId, params, signature, rateLimitKeys: [params.from], expectedFeeValue, + callerFeeFloorValue: x402FacilitatorConfig.feeFloorWei, forwarderFor: configuredJpycForwarderFor, idemPrefix: 'x402fac:idem:', }); diff --git a/app/api/facilitator/status/route.ts b/app/api/facilitator/status/route.ts new file mode 100644 index 00000000..76252e2b --- /dev/null +++ b/app/api/facilitator/status/route.ts @@ -0,0 +1,67 @@ +// x402 facilitator payment の read-only status endpoint。 +// 署名済み facilitator body を既存の署名 recover + KV/on-chain truth で照合し、 +// relay status と同じ settled / unused / indeterminate 語彙を返す。settle/broadcast は行わない。 + +import { NextResponse } from 'next/server'; +import { env } from '@/lib/env'; +import { MAX_BODY_BYTES } from '@/lib/relay/relayRoute'; +import { resolveFacilitatorPaymentStatus } from '@/lib/x402/facilitatorStatus'; +import { checkFacilitatorStatusRateLimit } from '@/lib/x402/facilitatorStatusRateLimit'; + +export const runtime = 'nodejs'; +export const maxDuration = 15; + +export async function POST(req: Request): Promise { + if (!env.enableX402Facilitator) { + return NextResponse.json({ ok: false, error: 'not_found' }, { status: 404 }); + } + + if (!(await checkFacilitatorStatusRateLimit(req))) { + return NextResponse.json( + { ok: false, error: 'ip_rate_limited' }, + { status: 429, headers: { 'Retry-After': '60' } }, + ); + } + + let bodyText: string; + try { + bodyText = await req.text(); + } catch { + return NextResponse.json( + { ok: false, error: 'invalid_payload' }, + { status: 400 }, + ); + } + if (Buffer.byteLength(bodyText, 'utf8') > MAX_BODY_BYTES) { + return NextResponse.json( + { ok: false, error: 'payload_too_large' }, + { status: 413 }, + ); + } + + let raw: unknown; + try { + raw = JSON.parse(bodyText); + } catch { + return NextResponse.json( + { ok: false, error: 'invalid_payload' }, + { status: 400 }, + ); + } + + const result = await resolveFacilitatorPaymentStatus(raw); + if (!result.ok) { + return NextResponse.json( + { ok: false, error: result.error }, + { status: 400 }, + ); + } + if (result.state === 'settled') { + return NextResponse.json({ + ok: true, + state: result.state, + txHash: result.txHash, + }); + } + return NextResponse.json({ ok: true, state: result.state }); +} diff --git a/app/api/order/notify/route.ts b/app/api/order/notify/route.ts index fa68b466..a128a5c9 100644 --- a/app/api/order/notify/route.ts +++ b/app/api/order/notify/route.ts @@ -7,16 +7,25 @@ import { NextResponse, after } from 'next/server'; import { createPublicClient, getAddress, isAddress, type Address, type Hex } from 'viem'; import { env, isMainnet } from '@/lib/env'; -import { chainObjectForId, transportForChain } from '@/lib/chains'; +import { + chainObjectForId, + slugForChain, + transportForChain, +} from '@/lib/chains'; import { resolveDeployment } from '@/lib/tokens'; -import { verifyJpycTransferToOnChain } from '@/lib/feeVerify'; +import { + verifyJpycStandardFeePairOnChain, + verifyJpycTransferToOnChain, +} from '@/lib/feeVerify'; import { kvSet, kvGet, kvDel, kvLpush, + kvLrange, kvLtrim, kvExpire, + kvEval, isKvConfigured, } from '@/lib/kv'; import { checkRateLimit } from '@/lib/relay/relayGuards'; @@ -28,6 +37,7 @@ import { orderListKey, orderUsedKey, orderStatusPointerKey, + parseStoredOrder, serializeOrder, sanitizeOrderItems, sanitizeOrderMemo, @@ -44,6 +54,17 @@ import { ORDER_ID_MAX, type StoredOrder, } from '@/lib/orderRelay'; +import { + mobileOrderFeeValue, + standardMobileOrderFeeMinimum, + type FeePayer, + type MobileOrderFeeKind, +} from '@/lib/mobileOrderFee'; +import { + legacyBillingPaymentKey, + paymentClaimKey, + paymentClaimResultValue, +} from '@/lib/paymentClaim'; import { isOrderTokenLike } from '@/lib/orderToken'; import { logger } from '@/lib/logger'; import { notifyPaymentReceived } from '@/lib/push/notify'; @@ -53,11 +74,258 @@ export const dynamic = 'force-dynamic'; export const maxDuration = 20; const AMOUNT_ADVISORY_BPS_CAP = 300; +const FEE_RECONCILE_RETRY_MS = [0, 1_000, 4_000, 10_000] as const; + +// 既存 raw が残っているときだけ同じ位置へ置換し、Pro/CSV/billing と共有する fee tx の恒久 +// claim も同一 Redis transaction 内で確定する。受注ボードの同時 fulfill 更新を失わず、1 本の +// fee tx が別注文・別商品へ波及する replay を断つ。list の位置と TTL は LPOS + LSET なので不変。 +const RECONCILE_FEE = + "local idx=redis.call('LPOS',KEYS[1],ARGV[1]); " + + 'if not idx then return 0 end; ' + + "if redis.call('EXISTS',KEYS[2])==1 or redis.call('EXISTS',KEYS[3])==1 then return -1 end; " + + "redis.call('SET',KEYS[2],ARGV[3]); " + + "redis.call('LSET',KEYS[1],idx,ARGV[2]); return 1"; + +// 同一 receipt 内の fee を徴収済みとして保存する瞬間に、その txHash を用途横断 claim する。 +// claim 確認と注文 LPUSH の間へ別注文 reconciliation が割り込んで同じ fee を二重充当する波及を +// Redis 1 transaction で断つ。既存 global/legacy claim があれば未収版を保存し badge を残す。 +// Lua の runtime error は先行 write を rollback しないため、未収版 LPUSH→SET claim→徴収済み版 +// LSET の順にする。どこで止まっても「未保存」または「未収表示が安全側に残る」だけにし、 +// claim 無しの徴収済み注文や、claim だけ残る受注喪失へ波及させない。 +const STORE_ORDER_WITH_INLINE_FEE_CLAIM = + 'local claimed=0; ' + + "if redis.call('EXISTS',KEYS[2])==0 and redis.call('EXISTS',KEYS[3])==0 then " + + "redis.call('LPUSH',KEYS[1],ARGV[1]); redis.call('SET',KEYS[2],ARGV[3]); " + + "redis.call('LSET',KEYS[1],0,ARGV[2]); claimed=1; " + + "else redis.call('LPUSH',KEYS[1],ARGV[1]); end; return claimed"; function fail(error: string, status: number) { return NextResponse.json({ ok: false, error }, { status }); } +type StandardFeeConfig = { + kind: MobileOrderFeeKind; + feePayer: FeePayer; +}; + +function resolveStandardFeeConfig( + storefront: { + chain: string; + chains?: string[]; + mode: MobileOrderFeeKind; + feePayer: FeePayer; + } | undefined, + chainId: number, +): StandardFeeConfig | null { + if (!env.enableMobileOrderFee || !storefront) return null; + const chainSlug = slugForChain(chainId); + const configuredChains = storefront.chains ?? [storefront.chain]; + // 別 chain の storefront 設定を流用して fee obligation を作る波及を断つ。公開済み受取 chain のみ対象。 + if (!chainSlug || !configuredChains.includes(chainSlug)) return null; + return { kind: storefront.mode, feePayer: storefront.feePayer }; +} + +type StandardFeeObligation = { + expected: bigint; + alternate?: bigint; + collectedInline: boolean; +}; + +function standardFeeObligationFromReceipt(args: { + receiptValue: bigint; + sameSourceFeeValue?: bigint; + config: StandardFeeConfig | null; +}): StandardFeeObligation | null { + if (!args.config) return null; + const fee = standardMobileOrderFeeMinimum( + args.receiptValue, + args.config.kind, + args.config.feePayer, + ); + if (fee <= 0n) return null; + const merchantBorne = + args.config.kind !== 'preorder' || + args.config.feePayer !== 'customer'; + const alternate = fee + 1n; + const alternateGross = args.receiptValue + alternate; + const hasAlternate = + merchantBorne && + mobileOrderFeeValue(alternateGross, args.config.kind) === alternate && + alternateGross - alternate === args.receiptValue; + // merchant 着金全額と同じ Transfer source が同一 receipt 内で feeReceiver に期待額以上を + // 払った atomic relay/batch だけを徴収済みとする。receipt.from 不一致の helper/4337 支払いで + // standard 判定を避け、fee 未払いを通常受注へ落とす迂回の波及を断つ。 + return { + expected: fee, + ...(hasAlternate ? { alternate } : {}), + collectedInline: (args.sameSourceFeeValue ?? 0n) >= fee, + }; +} + +async function reconcileCollectedStandardFee(args: { + merchant: Address; + merchantTxHash: Hex; + feeTxHash: Hex; + chainId: number; + token: Address; + feeReceiver: Address; + waitForOrder: boolean; + publicClient: Parameters< + typeof verifyJpycStandardFeePairOnChain + >[0]['publicClient']; +}): Promise { + const listKey = orderListKey(args.merchant); + let verifiedObligation: { + merchantAmount: string; + feeAmount: string; + feeAlternateAmount?: string; + } | null = null; + let sawTarget = false; + + for (let attempt = 0; attempt < FEE_RECONCILE_RETRY_MS.length; attempt++) { + const delayMs = FEE_RECONCILE_RETRY_MS[attempt]; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + const list = await kvLrange(listKey, 0, ORDER_LIST_MAX - 1); + if (!list.ok) { + logger.warn('order.notify.fee_reconcile_kv_error', { + reason: list.reason, + op: 'read', + chainId: args.chainId, + merchant: args.merchant, + }); + continue; + } + + let oldRaw: string | null = null; + let storedOrder: StoredOrder | null = null; + for (const raw of list.value ?? []) { + const parsed = parseStoredOrder(raw); + if ( + parsed?.txHash.toLowerCase() === args.merchantTxHash.toLowerCase() && + parsed.feeUncollected === true + ) { + oldRaw = raw; + storedOrder = parsed; + break; + } + } + // fresh/pending POST と保存処理の短い race は bounded retry し、既に解消済み・72h 失効は no-op。 + if (!oldRaw || !storedOrder) { + if (!args.waitForOrder) return; + continue; + } + sawTarget = true; + + // 保存時に server が storefront 設定 + on-chain merchant leg から確定した obligation を使う。 + // 後日の mode/feePayer/flag 変更で既存未収が減額・回収不能へ波及するのを断つ。旧/壊れレコードで + // expected が無い場合は badge を消さず安全側 no-op(body や現在設定から推測して補完しない)。 + if (!storedOrder.feeExpectedAmount) return; + const merchantValue = BigInt(storedOrder.amount); + const expectedFee = BigInt(storedOrder.feeExpectedAmount); + const alternateFee = storedOrder.feeExpectedAmountAlt + ? BigInt(storedOrder.feeExpectedAmountAlt) + : undefined; + + if ( + verifiedObligation === null || + storedOrder.amount !== verifiedObligation.merchantAmount || + storedOrder.feeExpectedAmount !== verifiedObligation.feeAmount || + storedOrder.feeExpectedAmountAlt !== + verifiedObligation.feeAlternateAmount + ) { + const result = await verifyJpycStandardFeePairOnChain({ + publicClient: args.publicClient, + merchantTxHash: args.merchantTxHash, + feeTxHash: args.feeTxHash, + expected: { + token: args.token, + merchant: args.merchant, + merchantValue, + feeReceiver: args.feeReceiver, + feeMinValue: expectedFee, + ...(alternateFee !== undefined + ? { feeAlternateValue: alternateFee } + : {}), + }, + }); + if (!result.ok) { + logger.warn('order.notify.fee_reconcile_verify_failed', { + reason: result.reason, + chainId: args.chainId, + merchant: args.merchant, + }); + if ( + (result.reason === 'rpc_error' || + result.reason === 'tx_not_found') && + attempt + 1 < FEE_RECONCILE_RETRY_MS.length + ) { + continue; + } + return; + } + verifiedObligation = { + merchantAmount: storedOrder.amount, + feeAmount: storedOrder.feeExpectedAmount, + ...(storedOrder.feeExpectedAmountAlt + ? { feeAlternateAmount: storedOrder.feeExpectedAmountAlt } + : {}), + }; + } + + const reconciled: StoredOrder = { ...storedOrder }; + delete reconciled.feeUncollected; + delete reconciled.feeExpectedAmount; + delete reconciled.feeExpectedAmountAlt; + const cas = await kvEval( + RECONCILE_FEE, + [ + listKey, + paymentClaimKey(args.chainId, args.feeTxHash), + legacyBillingPaymentKey(args.chainId, args.feeTxHash), + ], + [ + oldRaw, + serializeOrder(reconciled), + paymentClaimResultValue('order'), + ], + ); + if (!cas.ok) { + logger.warn('order.notify.fee_reconcile_kv_error', { + reason: cas.reason, + op: 'cas', + chainId: args.chainId, + merchant: args.merchant, + }); + continue; + } + if (cas.value === 1) { + logger.info('order.notify.fee_reconciled', { + chainId: args.chainId, + merchant: args.merchant, + }); + return; + } + if (cas.value === -1) { + logger.warn('order.notify.fee_reconcile_replay', { + chainId: args.chainId, + merchant: args.merchant, + }); + return; + } + // cas.value === 0: 受注ボードの同時更新で oldRaw が消えた。再読込して最新状態を位置維持で更新する。 + } + + if (sawTarget) { + logger.warn('order.notify.fee_reconcile_conflict', { + reason: 'retry_exhausted', + chainId: args.chainId, + merchant: args.merchant, + }); + } +} + export async function POST(req: Request): Promise { if (!env.enableOrderRelay) return fail('not_found', 404); @@ -109,6 +377,40 @@ export async function POST(req: Request): Promise { const chain = chainObjectForId(chainId); const deployment = resolveDeployment('jpyc', chainId); if (!chain || !deployment) return fail('unsupported_chain', 400); + const feeConfig = resolveStandardFeeConfig(record.storefront, chainId); + const feeTxHash = isTxHashLike(o.feeTxHash) + ? (o.feeTxHash as Hex) + : null; + + const queueFeeReconciliation = (waitForOrder = false) => { + if (!feeTxHash || !env.feeReceiverConfigured) return; + after(async () => { + try { + const publicClient = createPublicClient({ + chain, + transport: transportForChain(chainId), + }); + await reconcileCollectedStandardFee({ + merchant, + merchantTxHash: txHash, + feeTxHash, + chainId, + token: deployment.address, + feeReceiver: getAddress(env.feeReceiver), + waitForOrder, + publicClient, + }); + } catch (e) { + // 手数料表示の付帯 reconciliation 障害を受注 webhook の既存応答へ波及させない。 + // 未収フラグを残す安全側に倒し、偽成功にせず observable にする。 + logger.warn('order.notify.fee_reconcile_unexpected', { + reason: e instanceof Error ? e.message : String(e), + chainId, + merchant, + }); + } + }); + }; // mainnet は KV 必須 (fail-open で未検証/未保存のまま素通りさせない)。 if (isMainnet && !isKvConfigured()) return fail('kv_required', 503); @@ -129,10 +431,12 @@ export async function POST(req: Request): Promise { if (!existing.ok) return fail('kv_error', 503); // done = 検証 + 保存済の恒久ブロック → 同一 txHash の無期限リプレイを永久拒否 (P1-E)。1 決済 1 注文。 if (existing.value === ORDER_MARK_DONE) { + queueFeeReconciliation(); return NextResponse.json({ ok: true, duplicate: true }); } // pending 中 (または直前に失効/解放) = **まだ done でない** → 検証成功前の**偽 duplicate を返さない** (P2)。 // 別 POST が検証中/リトライ可能な「処理中」を表す (client は pending 失効後に同一 txHash を再送可能)。 + queueFeeReconciliation(true); return fail('processing', 409); } } @@ -147,7 +451,14 @@ export async function POST(req: Request): Promise { const result = await verifyJpycTransferToOnChain({ publicClient, txHash, - expected: { token: deployment.address, to: merchant, minValue: ORDER_DUST_FLOOR_WEI }, + expected: { + token: deployment.address, + to: merchant, + minValue: ORDER_DUST_FLOOR_WEI, + ...(env.feeReceiverConfigured + ? { feeReceiver: getAddress(env.feeReceiver) } + : {}), + }, }); if (!result.ok) { @@ -184,6 +495,20 @@ export async function POST(req: Request): Promise { }; if (amountAdvisory.mismatch) order.amountMismatch = true; if (amountAdvisory.unchecked) order.amountUnchecked = true; + const feeObligation = standardFeeObligationFromReceipt({ + receiptValue: result.value, + sameSourceFeeValue: result.sameSourceFeeValue, + config: feeConfig, + }); + // 店舗送金確定後の fee leg 失敗が受注欠落へ波及しないよう未収状態を additive に記録する。 + // 判定/額は body でなく公開 storefront 設定 + on-chain direct merchant leg のみから導く。 + if (feeObligation && !feeObligation.collectedInline) { + order.feeUncollected = true; + order.feeExpectedAmount = feeObligation.expected.toString(); + if (feeObligation.alternate !== undefined) { + order.feeExpectedAmountAlt = feeObligation.alternate.toString(); + } + } // 受取予定時刻 (任意・preorder・顧客申告=advisory 表示用・items/table と同じ寛容さ)。正の有限数かつ // **near-future 窓内** (now-1h 〜 now+14d) のみ保存。スロット/lastOrder との厳密照合はしない (advisory) // が、年 9999 等の極端値で受注ボードの表示を汚さないよう sane 窓外は drop する (clock skew に -1h)。 @@ -199,11 +524,43 @@ export async function POST(req: Request): Promise { if (isKvConfigured()) { const key = orderListKey(merchant); - const push = await kvLpush(key, serializeOrder(order)); - if (!push.ok) { + const save = feeObligation?.collectedInline + ? await kvEval( + STORE_ORDER_WITH_INLINE_FEE_CLAIM, + [ + key, + paymentClaimKey(chainId, txHash), + legacyBillingPaymentKey(chainId, txHash), + ], + [ + serializeOrder({ + ...order, + feeUncollected: true, + feeExpectedAmount: feeObligation.expected.toString(), + ...(feeObligation.alternate !== undefined + ? { + feeExpectedAmountAlt: + feeObligation.alternate.toString(), + } + : {}), + }), + serializeOrder(order), + paymentClaimResultValue('order'), + ], + ) + : await kvLpush(key, serializeOrder(order)); + if (!save.ok) { await kvDel(usedKey); // 保存できなければ pending クレームも戻す (リトライで再投入可能に) return fail('kv_error', 503); } + if (feeObligation?.collectedInline && save.value !== 1) { + order.feeUncollected = true; + order.feeExpectedAmount = feeObligation.expected.toString(); + if (feeObligation.alternate !== undefined) { + order.feeExpectedAmountAlt = + feeObligation.alternate.toString(); + } + } orderStored = true; // 受注は KV に確定。以降 pending クレームは消さない (下記 catch 参照)。 // ステージ2 = pending → done 昇格 (恒久・TTL 上書きで EX を落とす)。**保存確定の後**に昇格するのが肝: // 逆順 (昇格→保存) だと昇格後に保存失敗した tx が恒久ブロックのまま永久喪失する (P1-F を悪化)。 @@ -241,6 +598,16 @@ export async function POST(req: Request): Promise { if (orderStored && env.enablePushNotify) { after(() => notifyPaymentReceived(merchant, 'order')); } + if ( + orderStored && + feeObligation && + (!feeObligation.collectedInline || + // inline claim が既存用途と衝突した場合は未収版を保存したため、同梱 feeTxHash があれば + // 通常 reconciliation を試す(同じ claim なら badge は安全側に残る)。 + order.feeUncollected === true) + ) { + queueFeeReconciliation(); + } logger.info('order.notify.stored', { chainId, merchant, amount: order.amount }); return NextResponse.json({ ok: true, orderId }); diff --git a/app/api/paid/_shared.ts b/app/api/paid/_shared.ts index a995ddf4..eecd4528 100644 --- a/app/api/paid/_shared.ts +++ b/app/api/paid/_shared.ts @@ -10,6 +10,19 @@ import { } from '@/lib/x402/firstParty'; import { POST as verifyPayment } from '@/app/api/facilitator/verify/route'; import { POST as settlePayment } from '@/app/api/facilitator/settle/route'; +import { resolveFacilitatorPaymentStatus } from '@/lib/x402/facilitatorStatus'; +import { + claimPaymentRedelivery, + isFacilitatorPreBroadcastRejection, + lookupPaymentRedelivery, + paymentRedeliveryIdentity, + promotePaymentRedelivery, + releasePaymentRedelivery, + type PaymentRedeliveryBinding, + type PaymentSettlement, +} from '@/lib/x402/paymentRedelivery'; +import { checkFacilitatorStatusRateLimit } from '@/lib/x402/facilitatorStatusRateLimit'; +import { caip2ForChainId, chainIdFromCaip2 } from '@/lib/x402/network'; import { buildPaymentRequiredV2, decodePaymentSignatureHeaderValue, @@ -29,11 +42,13 @@ type SettleBody = { success?: boolean; errorReason?: string; payer?: string; -}; + transaction?: string | null; + network?: string; +} & Record; type PaidContent = (ctx: { payer?: string }) => Promise | NextResponse; type PreparedPayment = { - body: unknown; + body: Record; accepts: ReturnType; }; @@ -55,7 +70,10 @@ function cloneForwardHeaders(req: Request): Headers { return h; } -function paymentBody(resource: FirstPartyResource, paymentPayload: unknown) { +function paymentBody( + resource: FirstPartyResource, + paymentPayload: unknown, +): PreparedPayment { const accepts = createJpycPaymentRequirements({ amount: firstPartyAmount(resource), payTo: firstPartyPayTo(), @@ -74,6 +92,77 @@ function paymentBody(resource: FirstPartyResource, paymentPayload: unknown) { }; } +async function paidContentResponse( + content: PaidContent, + settlement: PaymentSettlement, + payer?: string, +): Promise { + const res = await content({ payer }); + res.headers.set('X-PAYMENT-RESPONSE', encodeJsonBase64(settlement)); + res.headers.set( + 'PAYMENT-RESPONSE', + encodePaymentResponseHeaderValue(settlement), + ); + return res; +} + +function pendingRecoveryResponse(input: { + chainId: number; + payer: string; +}): NextResponse { + return NextResponse.json( + { + success: false, + errorReason: 'pending', + transaction: null, + network: caip2ForChainId(input.chainId), + payer: input.payer, + }, + { status: 202 }, + ); +} + +function pendingRecoveryDetails( + facilitatorBody: Record, +): { chainId: number; payer: string } | null { + const paymentPayload = facilitatorBody.paymentPayload; + if ( + typeof paymentPayload !== 'object' || + paymentPayload === null || + Array.isArray(paymentPayload) + ) { + return null; + } + const network = (paymentPayload as Record).network; + const payload = (paymentPayload as Record).payload; + if ( + typeof network !== 'string' || + typeof payload !== 'object' || + payload === null || + Array.isArray(payload) + ) { + return null; + } + const authorization = (payload as Record).authorization; + if ( + typeof authorization !== 'object' || + authorization === null || + Array.isArray(authorization) + ) { + return null; + } + const payer = (authorization as Record).from; + const chainId = chainIdFromCaip2(network); + if ( + chainId === null || + typeof payer !== 'string' || + !/^0x[0-9a-fA-F]{40}$/.test(payer) + ) { + return null; + } + return { chainId, payer }; +} + function setPaymentRequiredV2Header( res: NextResponse, resource: FirstPartyResource, @@ -149,53 +238,136 @@ export async function handleFirstPartyPaidGet( return paymentChallenge(resource, 'payment_required'); } - let prepared: PreparedPayment; + let paymentPayload: unknown; + const usesV2Header = paymentSignatureHeader !== null; if (paymentSignatureHeader) { - let paymentPayloadV2: unknown; try { - paymentPayloadV2 = decodePaymentSignatureHeaderValue(paymentSignatureHeader); + paymentPayload = decodePaymentSignatureHeaderValue(paymentSignatureHeader); } catch { return paymentChallenge(resource, 'invalid_payment_payload'); } - try { - prepared = paymentBody(resource, paymentPayloadV2); - } catch (e) { - // Misconfigured requirements must not pass an unverifiable body into verify/settle. - return NextResponse.json( - { - x402Version: 1, - error: 'payment_facility_unavailable', - message: e instanceof Error ? e.message : String(e), - }, - { status: 503 }, - ); - } - const v1Body = v2PayloadToV1Body(paymentPayloadV2, prepared.accepts); - if (!v1Body) { - return paymentChallenge(resource, 'invalid_payment_payload'); - } - prepared = { ...prepared, body: v1Body }; } else { - let paymentPayload: unknown; try { paymentPayload = decodePaymentHeader(paymentHeader!); } catch { return paymentChallenge(resource, 'invalid_payment_payload'); } + } + const redeliveryIdentity = paymentRedeliveryIdentity(paymentPayload); + if (!redeliveryIdentity) { + return paymentChallenge(resource, 'invalid_payment_payload'); + } - try { - prepared = paymentBody(resource, paymentPayload); - } catch (e) { - // Misconfigured requirements must not pass an unverifiable body into verify/settle. + // 検索 API の query が違う別コンテンツまで同じ支払いで解錠される波及を断つため、 + // payment identity の原子的 claim を resource path + 実リクエスト query へ束縛する。 + const requestUrl = new URL(req.url); + const redeliveryBinding: PaymentRedeliveryBinding = { + scope: 'first-party', + resource: `${firstPartyResourceUrl(resource)}${requestUrl.search}`, + }; + const deliveryLookup = await lookupPaymentRedelivery( + redeliveryIdentity, + redeliveryBinding, + ); + if (deliveryLookup.kind === 'conflict') { + return paymentChallenge(resource, 'payment_invalid'); + } + const delivery = + deliveryLookup.kind === 'match' ? deliveryLookup.record : null; + if (delivery?.state === 'settled') { + // settled cache は facilitator success または下の on-chain 完全照合からのみ昇格する。 + // 同一 payment の応答喪失を再課金せず配信へ戻し、通常の verify/settle は一切再実行しない。 + return paidContentResponse( + content, + delivery.settlement, + delivery.settlement.payer, + ); + } + if (delivery?.state === 'pending') { + if (!(await checkFacilitatorStatusRateLimit(req))) { return NextResponse.json( - { - x402Version: 1, - error: 'payment_facility_unavailable', - message: e instanceof Error ? e.message : String(e), - }, - { status: 503 }, + { ok: false, error: 'ip_rate_limited' }, + { status: 429, headers: { 'Retry-After': '60' } }, + ); + } + let status: Awaited>; + try { + status = await resolveFacilitatorPaymentStatus( + delivery.facilitatorBody, + ); + } catch { + const pendingDetails = pendingRecoveryDetails( + delivery.facilitatorBody, + ); + if (!pendingDetails) { + return paymentChallenge(resource, 'payment_invalid'); + } + // read-only status の想定外障害を pending payment の再 settle へ波及させず、 + // 二重 broadcast を避けるため同じ 202 回復待ちへ閉じる。 + return pendingRecoveryResponse(pendingDetails); + } + if ( + status.ok && + status.state === 'settled' && + status.txHash !== null + ) { + const recoveredSettlement: PaymentSettlement = { + success: true, + transaction: status.txHash, + network: caip2ForChainId(status.chainId), + payer: status.payer, + }; + const promotion = await promotePaymentRedelivery({ + identity: redeliveryIdentity, + binding: redeliveryBinding, + settlement: recoveredSettlement, + }); + if (promotion.kind === 'conflict') { + return paymentChallenge(resource, 'payment_invalid'); + } + const settlement = + promotion.kind === 'promoted' || + promotion.kind === 'already-settled' + ? promotion.record.settlement + : recoveredSettlement; + return paidContentResponse( + content, + settlement, + settlement.payer, ); } + // pending marker が存在する payment を unused/error 時に settle へ戻すと、同時 request や + // status の一時的不整合が二重 broadcast / relayer gas 消費へ波及するため、settled の完全照合 + // 以外はすべて既存 pending 語彙で待たせる。 + const pendingDetails = status.ok + ? status + : pendingRecoveryDetails(delivery.facilitatorBody); + if (!pendingDetails) { + return paymentChallenge(resource, 'payment_invalid'); + } + return pendingRecoveryResponse(pendingDetails); + } + + let prepared: PreparedPayment; + try { + prepared = paymentBody(resource, paymentPayload); + } catch (e) { + // Misconfigured requirements must not pass an unverifiable body into verify/settle. + return NextResponse.json( + { + x402Version: 1, + error: 'payment_facility_unavailable', + message: e instanceof Error ? e.message : String(e), + }, + { status: 503 }, + ); + } + if (usesV2Header) { + const v1Body = v2PayloadToV1Body(paymentPayload, prepared.accepts); + if (!v1Body) { + return paymentChallenge(resource, 'invalid_payment_payload'); + } + prepared = { ...prepared, body: { ...v1Body } }; } const bodyText = JSON.stringify(prepared.body); @@ -227,6 +399,37 @@ export async function handleFirstPartyPaidGet( ); } + // verify 後・settle 前の原子的 claim により、同じ payment identity を競合 resource が同時に + // settle して共有 gas と別コンテンツ配信へ波及するのを断つ。KV 障害時だけ既存経路へ fail-open。 + const claim = await claimPaymentRedelivery({ + identity: redeliveryIdentity, + binding: redeliveryBinding, + facilitatorBody: prepared.body, + }); + if (claim.kind === 'conflict') { + return paymentChallenge(resource, 'payment_invalid'); + } + if (claim.kind === 'match' && claim.record.state === 'settled') { + return paidContentResponse( + content, + claim.record.settlement, + claim.record.settlement.payer, + ); + } + if (claim.kind === 'match') { + const pendingDetails = pendingRecoveryDetails( + claim.record.facilitatorBody, + ); + if (!pendingDetails) { + return paymentChallenge(resource, 'payment_invalid'); + } + // lookup miss 同士の race で先行 request が claim 済みなら、後続を再 settle へ進めず + // 先行 broadcast の完了待ちに閉じ、二重 broadcast と gas 消費への波及を断つ。 + return pendingRecoveryResponse(pendingDetails); + } + const recoveryOwnerToken = + claim.kind === 'claimed' ? claim.record.ownerToken : null; + const settleRes = await settlePayment( new Request(new URL('/api/facilitator/settle', req.url), { method: 'POST', @@ -235,6 +438,16 @@ export async function handleFirstPartyPaidGet( }), ); const settleBody = (await settleRes.json()) as SettleBody; + if ( + recoveryOwnerToken !== null && + isFacilitatorPreBroadcastRejection(settleRes.status, settleBody) + ) { + await releasePaymentRedelivery({ + identity: redeliveryIdentity, + binding: redeliveryBinding, + ownerToken: recoveryOwnerToken, + }); + } if (settleRes.status !== 200 || settleBody.success !== true) { if (settleRes.status === 200) { const error = settleBody.errorReason ?? 'settlement_failed'; @@ -255,8 +468,23 @@ export async function handleFirstPartyPaidGet( return NextResponse.json(settleBody, { status: settleRes.status }); } - const res = await content({ payer: settleBody.payer ?? verifyBody.payer }); - res.headers.set('X-PAYMENT-RESPONSE', encodeJsonBase64(settleBody)); - res.headers.set('PAYMENT-RESPONSE', encodePaymentResponseHeaderValue(settleBody)); - return res; + const successfulSettlement = settleBody as PaymentSettlement; + const promotion = await promotePaymentRedelivery({ + identity: redeliveryIdentity, + binding: redeliveryBinding, + settlement: successfulSettlement, + }); + if (promotion.kind === 'conflict') { + return paymentChallenge(resource, 'payment_invalid'); + } + const deliveredSettlement = + promotion.kind === 'promoted' || + promotion.kind === 'already-settled' + ? promotion.record.settlement + : successfulSettlement; + return paidContentResponse( + content, + deliveredSettlement, + deliveredSettlement.payer ?? verifyBody.payer, + ); } diff --git a/app/api/register/claim/route.ts b/app/api/register/claim/route.ts new file mode 100644 index 00000000..91e0a76f --- /dev/null +++ b/app/api/register/claim/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import { isAddress, isHex, type Address, type Hex } from 'viem'; +import { env } from '@/lib/env'; +import { claimRegisterFeePayment } from '@/lib/registerFeeClaim'; +import { clientIp, hashIp } from '@/lib/net/ipHash'; +import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { MAX_BODY_BYTES } from '@/lib/relay/relayRoute'; + +export const runtime = 'nodejs'; +export const maxDuration = 15; + +function txHash(value: unknown): value is Hex { + return typeof value === 'string' && isHex(value) && value.length === 66; +} + +export async function POST(req: Request): Promise { + if (!env.enableRegisterFee) { + return NextResponse.json({ ok: false, error: 'not_found' }, { status: 404 }); + } + if ( + !(await checkIpRateLimit( + 'register-claim', + hashIp(clientIp(req)), + 60, + 60, + )) + ) { + // 公開 claim endpoint の flood を receipt RPC/KV まで波及させない。 + return NextResponse.json( + { ok: false, error: 'rate_limited' }, + { status: 429, headers: { 'Retry-After': '60' } }, + ); + } + let bodyText: string; + try { + bodyText = await req.text(); + } catch { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } + if (Buffer.byteLength(bodyText, 'utf8') > MAX_BODY_BYTES) { + return NextResponse.json( + { ok: false, error: 'payload_too_large' }, + { status: 413 }, + ); + } + let raw: unknown; + try { + raw = JSON.parse(bodyText); + } catch { + return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 }); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return NextResponse.json({ ok: false, error: 'invalid_body' }, { status: 400 }); + } + const body = raw as Record; + if ( + typeof body.chainId !== 'number' || + !Number.isInteger(body.chainId) || + body.chainId <= 0 || + typeof body.tokenAddress !== 'string' || + !isAddress(body.tokenAddress, { strict: false }) || + typeof body.merchant !== 'string' || + !isAddress(body.merchant, { strict: false }) || + typeof body.saleAmount !== 'string' || + !/^[1-9]\d*$/.test(body.saleAmount) || + !txHash(body.merchantTxHash) || + !txHash(body.feeTxHash) + ) { + return NextResponse.json({ ok: false, error: 'invalid_body' }, { status: 400 }); + } + + const result = await claimRegisterFeePayment({ + chainId: body.chainId, + tokenAddress: body.tokenAddress as Address, + merchant: body.merchant as Address, + saleAmount: BigInt(body.saleAmount), + merchantTxHash: body.merchantTxHash, + feeTxHash: body.feeTxHash, + }); + if (result === 'claimed' || result === 'replay') { + return NextResponse.json({ ok: true, status: result }); + } + const status = + result === 'invalid' + ? 400 + : result === 'conflict' + ? 409 + : result === 'verify_failed' + ? 422 + : 503; + return NextResponse.json({ ok: false, error: result }, { status }); +} diff --git a/app/api/relay/jpyc/route.ts b/app/api/relay/jpyc/route.ts index a8017bd0..a17e5106 100644 --- a/app/api/relay/jpyc/route.ts +++ b/app/api/relay/jpyc/route.ts @@ -31,6 +31,7 @@ import { jpycForwarderFor, configuredJpycForwarderFor, isRecoverRequiredChain, + relayGasFeeValue, } from '@/lib/relay/forwarderConfig'; import { recoverFeeValue } from '@/lib/relay/recoverFee'; import { @@ -400,8 +401,10 @@ async function handleRecover( // server 権威の per-tx 手数料。 // モバイル注文 (feeKind present + flag ON): 経路非依存のシステム利用料 = 一律 % を **定数表から // 再計算** する (mobileOrderFeeValue)。client 申告の率は信用せず server が定数から導くため率を - // 下げられない (残余は「安い feeKind を申告」=過少徴収のみ・資金毀損なし)。flag OFF では - // feeKind を無視 (= 従来の gas-recovery に倒す)。 + // 下げられない。ただし feeKind 自体は未署名 hint でモバイル注文との束縛が無いため、これを + // 別用途へ付けてガスフロア未満へ切り替える余地は残る。価格を変えずにその波及を断つため、 + // settleViaForwarder が実 expectedFee と chain 別フロアを比較し、sub-floor 専用の払い元制限 + + // 日次予算を共有予算より先に適用する。flag OFF では feeKind を無視 (= 従来の gas-recovery)。 // それ以外 (/pay・/checkout・チップ): 従来の gas-recovery (recoverFeeValue・gasMode 選択): // merchant=max(ガスフロア, billAmount × bps/10000)・customer=フロアのみ。 // いずれも client と **同式** で算出し、forwarderRecover が feeValue === expectedFeeValue を強制 @@ -416,7 +419,8 @@ async function handleRecover( : recoverFeeValue(billAmount, gasMode, chainId); // 検証 (server 権威 feeValue 照合 / 署名 recover / 残高 / nonce 未使用 / 冪等 / 日次予算) + // forwarder 健全性 + submit/poll は共有 settleViaForwarder に集約 (x402 facilitator と同一コア)。 - // recover 固有値だけ渡す: expectedFeeValue = 上で算出した recover 料率 / forwarderFor = a1-aware / + // recover 固有値だけ渡す: expectedFeeValue = 上で算出した recover 料率 / + // callerFeeFloorValue = recover/mobile 共通の relay gas floor / forwarderFor = a1-aware / // idemPrefix = 'relay:idem:' (従来の冪等名前空間を維持)。 const result = await settleViaForwarder({ chainId, @@ -424,6 +428,7 @@ async function handleRecover( signature: raw.signature as Hex, rateLimitKeys: [params.from], expectedFeeValue: expectedFee, + callerFeeFloorValue: relayGasFeeValue(chainId), forwarderFor, idemPrefix: 'relay:idem:', }); diff --git a/app/api/relay/jpyc/status/route.ts b/app/api/relay/jpyc/status/route.ts index 892c99b0..d5df3e0d 100644 --- a/app/api/relay/jpyc/status/route.ts +++ b/app/api/relay/jpyc/status/route.ts @@ -147,6 +147,27 @@ async function readBody(req: Request): Promise | null> { } function parseIntent(raw: Record): ParsedIntent | null { + if (raw.lookup === 'nonce') { + if ( + typeof raw.chainId !== 'number' || + !Number.isInteger(raw.chainId) || + !(raw.chainId in SUPPORTED_CHAINS) || + !isAddress(raw.from as string) || + typeof raw.nonce !== 'string' || + !/^0x[0-9a-fA-F]{64}$/.test(raw.nonce) + ) { + return null; + } + const from = getAddress(raw.from as string); + return { + chainId: raw.chainId, + from, + nonce: raw.nonce as Hex, + // nonce は 32-byte random/forwarder commitment で列挙不能、route は rate-limit 済みかつ + // read-only。リロード後に署名を再保存せず結果照会できるよう signer は from に固定する。 + verifySignature: async () => from, + }; + } if ( typeof raw.chainId !== 'number' || !Number.isInteger(raw.chainId) || diff --git a/components/CheckoutForm.tsx b/components/CheckoutForm.tsx index b5e664e1..2870be2b 100644 --- a/components/CheckoutForm.tsx +++ b/components/CheckoutForm.tsx @@ -22,7 +22,7 @@ import { PaymentSuccessOverlay, type PaymentSuccessOverlayPayload, } from './PaymentSuccessOverlay'; -import { SignReassurance, type SignReassuranceProps } from './SignReassurance'; +import type { SignReassuranceProps } from './SignReassurance'; import { PayerReceiptCompletion } from './PayerReceiptCompletion'; import { useBatchPayment } from '@/hooks/useBatchPayment'; import { useStandardPayment } from '@/hooks/useStandardPayment'; @@ -55,6 +55,7 @@ import { isRelayResponseUnknownError, } from '@/lib/relay/relayResponseError'; import { generateStatusToken } from '@/lib/orderStatusToken'; +import { isOrderTokenLike } from '@/lib/orderToken'; import { useErc20BalanceAndChain } from '@/hooks/useErc20BalanceAndChain'; import { type GasMode } from '@/lib/fee'; import { @@ -91,6 +92,7 @@ import { buildJpycRelaySignPreview, buildJpycRecoverSignPreview, } from '@/lib/signPreview'; +import { checkoutIntentContextFingerprint } from '@/lib/checkoutIntentContext'; import { RecoverFeeNotice } from './RecoverFeeNotice'; // 送信後の pending / unknown と同一 payload の復旧時だけ必要な UI を First Load JS @@ -100,9 +102,47 @@ const PaymentStatusPanel = dynamic( import('./PaymentStatusPanel').then((m) => m.PaymentStatusPanel), { ssr: false }, ); +const SignReassurance = dynamic( + () => import('./SignReassurance').then((m) => m.SignReassurance), +); const SUCCESS_REDIRECT_DELAY_MS = 3000; const ORDER_MEMO_STORAGE_PREFIX = 'openpay:order-memo:'; +const ORDER_STATUS_TOKEN_STORAGE_PREFIX = 'openpay:order-status-token:'; +const ORDER_NOTIFY_PROCESSING_RETRY_MS = [ + 250, 1_000, 4_000, 15_000, 60_000, 60_000, +] as const; + +function orderStatusTokenForMerchantTx(merchantTxHash: string): string { + const key = `${ORDER_STATUS_TOKEN_STORAGE_PREFIX}${merchantTxHash.toLowerCase()}`; + try { + const stored = window.sessionStorage.getItem(key); + if (isOrderTokenLike(stored)) return stored; + const generated = generateStatusToken(); + window.sessionStorage.setItem(key, generated); + return generated; + } catch { + // status token の sessionStorage 障害を受注通知へ波及させない。同一 mount は呼出側の ref で固定する。 + return generateStatusToken(); + } +} + +async function postCheckoutWebhook( + url: string, + init: RequestInit, + retryOrderProcessing: boolean, +): Promise { + let response = await fetch(url, init); + if (!retryOrderProcessing) return response; + for (const delayMs of ORDER_NOTIFY_PROCESSING_RETRY_MS) { + if (response.status !== 409) return response; + // 直前 mount の order claim が処理中でも、fee 成功通知まで 409 で失われ受注/未収状態が + // 固着する波及を断つ。既存 route の 409 契約は変えず、同じ byte の notify だけを再試行する。 + await new Promise((resolve) => setTimeout(resolve, delayMs)); + response = await fetch(url, init); + } + return response; +} export function CheckoutForm({ params }: { params: CheckoutParams }) { const t = useTranslations('CheckoutForm'); @@ -111,6 +151,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // 1 度だけ生成し、webhook payload (notify がポインタ保存) と完了画面の「注文状況を見る」リンクで // 使う。flag OFF では生成せず null = payload 無変化・リンク非表示 (byte-identical)。 const [statusToken, setStatusToken] = useState(null); + const statusTokenRef = useRef(null); const router = useRouter(); const [modeOverride, setModeOverride] = useState<'standard' | null>(null); const [orderAdmissionPending, setOrderAdmissionPending] = useState(false); @@ -128,6 +169,9 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { const { address, isConnected, chainId } = useAccount(); const { switchChain, isPending: isSwitching } = useSwitchChain(); + // sessionStorage に broadcast 済み standard intent が残る場合、元 URL が gasless でも + // standard の receipt 復元を本線に固定する。storage 読込中は下の readiness で全経路を止める。 + const standard = useStandardPayment(); // F7: webhook / success_url / cancel_url のうち、現在の origin と host が異なる第三者ホスト。 // これらは決済者データの POST 先 / 決済後の遷移先になり得るため、支払い前に payer へ明示開示する @@ -157,7 +201,10 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // 未設定は free (OpenPay 負担)。 // - USDC ガスレスが Circle に解決される場合は surcharge 込み quote + permit allowance。 const resolvedRoute = resolvePaymentRoute({ - isStandard: params.mode === 'standard' || modeOverride === 'standard', + isStandard: + params.mode === 'standard' || + modeOverride === 'standard' || + standard.hasAttempt, jpycGaslessProvider: resolveJpycGaslessProvider( deployment, chainId ?? deployment.chainId, @@ -224,7 +271,6 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { !isStandard && !useRelay, ); const gasless = useBatchPayment(deployment, !isStandard && !useRelay); - const standard = useStandardPayment(); const gasQuote = useGasQuote(deployment, !isStandard && !useRelay); // USDC ガスレスが Circle に解決される場合は surcharge 込み quote + permit allowance (route 由来)。 const isCircle = isCircleRoute(route); @@ -349,7 +395,9 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { const relayIpRateLimited = isRelayIpRateLimitedError(relay.error) ? relay.error : null; - const paymentFlowPending = relayAmbiguous || gaslessAmbiguous + const paymentFlowPending = standard.isRestoring || relay.isRestoring + ? true + : relayAmbiguous || gaslessAmbiguous ? true : isStandard ? standard.isPending @@ -388,6 +436,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { (useRelay && !!relay.data && (relay.data.success || !!relay.data.pending)) || + relay.hasActiveIntent || + standard.hasActiveIntent || (isStandard && (!!standard.data || standard.isFeeError || standard.isUnknown)); const standardUnknownTxHash = standard.isFeeUnknown ? standard.feeTxHash @@ -484,6 +534,11 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // R: gasQuote refetch (30s) で breakdown が再計算 → notification effect が再実行 // される。同一 tx hash の重複 webhook を防ぐため key 単位の dedup gate を使う。 const notifiedKeyRef = useRef(null); + const partialOrderNotifiedKeyRef = useRef(null); + const partialOrderNotifyPromiseRef = useRef<{ + key: string; + promise: Promise; + } | null>(null); // webhook/記録は「送金した瞬間の額」を報告する (成功描画時の live breakdown は // gas quote 再取得等で動きうるため、submit 時点の snapshot を真実とする)。 @@ -495,6 +550,70 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { customerPays: bigint; } | null>(null); + // relay 成功/失敗/pending を既存の gasless 履歴経路に流す合成 snapshot。 + const relayHistoryGasless = useRelayGaslessSnapshot( + relay, + useRecover, + deployment.chainId, + ); + const checkoutContextKey = useMemo( + () => + checkoutIntentContextFingerprint({ + params, + chainId: deployment.chainId, + tokenAddress: deployment.address, + totalAtomic: totalWei.toString(), + }), + [ + deployment.address, + deployment.chainId, + params, + totalWei, + ], + ); + const restoredStandardOrderAttempt = useMemo(() => { + const submitted = standard.lastSubmittedParams; + const sameAddress = (left: string, right: string) => + left.toLowerCase() === right.toLowerCase(); + if ( + !standard.restoredFromStorage || + submitted?.contextKey !== checkoutContextKey || + submitted.chainId !== deployment.chainId || + !sameAddress(submitted.tokenAddress, deployment.address) || + !sameAddress(submitted.merchant, params.to) || + !sameAddress(submitted.feeReceiver, env.feeReceiver) || + submitted.merchantAmount !== breakdown.merchantReceives || + submitted.feeAmount !== breakdown.feeAmount || + submitted.saleAmount !== totalWei + ) { + return null; + } + return { + snapshot: { + totalWei: submitted.saleAmount, + merchantReceives: submitted.merchantAmount, + feeAmount: submitted.feeAmount, + customerPays: + submitted.merchantAmount + submitted.feeAmount, + }, + from: standard.lastSubmittedFrom, + }; + }, [ + breakdown.feeAmount, + breakdown.merchantReceives, + checkoutContextKey, + deployment.address, + deployment.chainId, + params.to, + standard.lastSubmittedFrom, + standard.lastSubmittedParams, + standard.restoredFromStorage, + totalWei, + ]); + const restoredCheckoutCompletion = + (isStandard && standard.restoredFromStorage) || + (useRelay && relay.restoredIntent != null); + useEffect(() => { if (gasless.error) logger.error('checkout.failed', { error: gasless.error }); }, [gasless.error]); @@ -576,11 +695,10 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { useEffect(() => { if (!completion) return; - // completion は同一セッションの submit 後 (mutation の in-memory data 由来) にのみ発火する - // ため snapshot は必ず存在する。両ガードで型を自然に絞る (live breakdown への silent - // fallback は乖離バグの再発口になるため書かない)。 const snapshot = submitSnapshotRef.current; - if (!snapshot) return; + // 許可済みの on-chain intent metadata だけでは元の items/order/callback を再構成できない。 + // reload 復元を現在 URL の受注通知・redirect へ誤帰属させる波及を断ち、same-mount だけを通知する。 + if (!snapshot || restoredCheckoutCompletion) return; if (notifiedKeyRef.current === completion.key) return; notifiedKeyRef.current = completion.key; @@ -612,8 +730,16 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { isOrderNotifyWebhook = false; // 不正 URL = notify ではない } const statusTokenForOrder = - env.enableOrderPickup && isOrderNotifyWebhook ? generateStatusToken() : null; - if (statusTokenForOrder) setStatusToken(statusTokenForOrder); + env.enableOrderPickup && isOrderNotifyWebhook + ? (statusTokenRef.current ?? + (completion.mode === 'standard' + ? orderStatusTokenForMerchantTx(completion.key) + : generateStatusToken())) + : null; + if (statusTokenForOrder) { + statusTokenRef.current = statusTokenForOrder; + setStatusToken(statusTokenForOrder); + } let customerMemo: string | undefined; if (isOrderNotifyWebhook && params.orderId) { try { @@ -659,32 +785,49 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { : {}), ts: Date.now(), }; - fetch(params.webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - mode: 'cors', - keepalive: true, - }) - .then(async (res) => { - if (!res.ok) { + const sendWebhook = () => + postCheckoutWebhook( + params.webhook!, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + mode: 'cors', + keepalive: true, + }, + isOrderNotifyWebhook, + ) + .then(async (res) => { + if (!res.ok) { + const redacted = await webhookTelemetry; + logger.warn('checkout.webhook.non_ok', { + status: res.status, + statusText: res.statusText, + webhookOrigin: redacted.origin, + webhookHash: redacted.hash, + }); + } + }) + .catch(async (err) => { const redacted = await webhookTelemetry; - logger.warn('checkout.webhook.non_ok', { - status: res.status, - statusText: res.statusText, + logger.warn('checkout.webhook.failed', { + error: err, webhookOrigin: redacted.origin, webhookHash: redacted.hash, }); - } - }) - .catch(async (err) => { - const redacted = await webhookTelemetry; - logger.warn('checkout.webhook.failed', { - error: err, - webhookOrigin: redacted.origin, - webhookHash: redacted.hash, }); - }); + const partial = + completion.mode === 'standard' && + partialOrderNotifyPromiseRef.current?.key === completion.key + ? partialOrderNotifyPromiseRef.current.promise + : null; + // merchant 確定通知の KV claim が処理中のまま通常成功通知と競合し、後者が 409 で失われる + // 波及を断つ。部分通知の成否にかかわらず完了後に従来 payload を同じ byte で送る。 + if (partial) { + void partial.then(sendWebhook, sendWebhook); + } else { + void sendWebhook(); + } if (customerMemo && params.orderId) { try { window.sessionStorage.removeItem( @@ -715,6 +858,177 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { address, deployment.chainId, deployment.decimals, + restoredCheckoutCompletion, + ]); + + // standard の merchant leg が確定した時点で、独立 fee leg の wallet 操作を待たず受注を届ける。 + // 第三者 webhook の成功契約は広げず、OpenPay 自身の same-origin `/api/order/notify` だけを + // additive に発火し、後続 fee 成功は従来の通常成功 payload で未収状態を解消する。 + useEffect(() => { + const submitted = standard.lastSubmittedParams; + const submittedFeeAmount = + submitted?.feeAmount ?? submitSnapshotRef.current?.feeAmount; + if ( + !isStandard || + submittedFeeAmount === undefined || + submittedFeeAmount <= 0n || + (!!standard.data && !standard.restoredFromStorage) || + !standard.merchantTxHash || + standard.merchantBlockNumber === undefined || + !params.webhook + ) { + return; + } + const mountedSnapshot = submitSnapshotRef.current; + const attempt = mountedSnapshot + ? { snapshot: mountedSnapshot, from: address } + : restoredStandardOrderAttempt; + // fingerprint が一致しない復元 hash を現在 URL の items/orderId へ通知する波及を断つ。 + if (!attempt) return; + const { snapshot } = attempt; + + let isOrderNotifyWebhook = false; + try { + const webhook = new URL(params.webhook); + isOrderNotifyWebhook = + webhook.origin === window.location.origin && + webhook.pathname === '/api/order/notify' && + (mountedSnapshot !== null || + (!!params.storeHandle && + webhook.searchParams.getAll('h').length === 1 && + webhook.searchParams.get('h')?.toLowerCase() === + params.storeHandle.toLowerCase())); + } catch { + isOrderNotifyWebhook = false; + } + if (!isOrderNotifyWebhook) return; + const partialNotifyKey = + `${standard.merchantTxHash}:${standard.feeTxHash ?? 'awaiting'}`; + if (partialOrderNotifiedKeyRef.current === partialNotifyKey) return; + // fee hash の broadcast 後にも同じ受注を additive に通知し、直後の close/reload で + // server reconciliation が hash を知らないまま未収へ固着する波及を断つ。 + partialOrderNotifiedKeyRef.current = partialNotifyKey; + + const statusTokenForOrder = env.enableOrderPickup + ? (statusTokenRef.current ?? + orderStatusTokenForMerchantTx(standard.merchantTxHash)) + : null; + if (statusTokenForOrder) { + statusTokenRef.current = statusTokenForOrder; + setStatusToken(statusTokenForOrder); + } + + let customerMemo: string | undefined; + if (params.orderId) { + try { + customerMemo = + window.sessionStorage.getItem( + `${ORDER_MEMO_STORAGE_PREFIX}${params.orderId}`, + ) || undefined; + } catch { + // sessionStorage 障害を受注通知へ波及させない (メモは advisory)。 + } + } + + logger.info('checkout.order.delivered_fee_uncollected', { + merchantTxHash: standard.merchantTxHash, + feeTxHash: standard.feeTxHash, + merchant: params.to, + orderId: params.orderId, + token: params.token, + chain: chainSlug, + }); + + const webhookTelemetry = redactUrlForTelemetry(params.webhook); + const payload = { + type: 'openpay.checkout.success', + mode: 'standard', + merchant: params.to, + from: attempt.from, + token: params.token, + chain: chainSlug, + chainId: deployment.chainId, + amount: formatUnits(snapshot.totalWei, deployment.decimals), + items: params.items, + merchantAmount: snapshot.merchantReceives.toString(), + feeAmount: snapshot.feeAmount.toString(), + customerPays: snapshot.customerPays.toString(), + orderId: params.orderId, + description: params.description, + ...(statusTokenForOrder ? { statusToken: statusTokenForOrder } : {}), + ...(params.pickupAt !== undefined ? { pickupAt: params.pickupAt } : {}), + ...(customerMemo ? { customerMemo } : {}), + merchantTxHash: standard.merchantTxHash, + ...(standard.feeTxHash ? { feeTxHash: standard.feeTxHash } : {}), + blockNumber: standard.merchantBlockNumber.toString(), + feeUncollected: true, + ts: Date.now(), + }; + const request = postCheckoutWebhook( + params.webhook, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + mode: 'cors', + keepalive: true, + }, + true, + ) + .then(async (res) => { + if (!res.ok) { + const redacted = await webhookTelemetry; + logger.warn('checkout.webhook.non_ok', { + status: res.status, + statusText: res.statusText, + webhookOrigin: redacted.origin, + webhookHash: redacted.hash, + }); + } + }) + .catch(async (err) => { + const redacted = await webhookTelemetry; + logger.warn('checkout.webhook.failed', { + error: err, + webhookOrigin: redacted.origin, + webhookHash: redacted.hash, + }); + }); + partialOrderNotifyPromiseRef.current = { + key: standard.merchantTxHash, + promise: request, + }; + + if (customerMemo && params.orderId) { + try { + window.sessionStorage.removeItem( + `${ORDER_MEMO_STORAGE_PREFIX}${params.orderId}`, + ); + } catch { + // 送信済みメモの後片付け失敗を受注通知へ波及させない。 + } + } + }, [ + address, + chainSlug, + deployment.chainId, + deployment.decimals, + isStandard, + params.description, + params.items, + params.orderId, + params.pickupAt, + params.storeHandle, + params.to, + params.token, + params.webhook, + restoredStandardOrderAttempt, + standard.feeTxHash, + standard.data, + standard.lastSubmittedParams, + standard.merchantBlockNumber, + standard.merchantTxHash, + standard.restoredFromStorage, ]); // ローカル履歴 (Phase 2) — gasless / standard 全 5 transition を hook で集約。 @@ -798,20 +1112,22 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { locale, ], ); - // relay 成功/失敗/pending を既存の gasless 履歴経路に流す合成 snapshot。relay は userOp/receipt - // block を持たないため両者 null。amount は mutate() の variables で固定し drift を避ける。 - // recover は hook と同一式で split を再計算: feeAmount(=サービス料) は常に 0、ネットワーク手数料 - // 相当額 = 回収した gas (feeValue)、merchantAmount は customer 上乗せなら満額・merchant 吸収なら - // 満額−fee、saleAmount は請求額 (value)。free は fee=0・netFee=0。pending は status='pending'。 - const relayHistoryGasless = useRelayGaslessSnapshot( - relay, - useRecover, - deployment.chainId, - ); + // 永続 metadata に items/order/callback を追加せず、reload 復元の結果は既存 pending 記録の + // status 昇格だけに限定する。現在 URL の会計 context から新しい履歴・控えを作る波及を断つ。 + const gaslessForHistory = + restoredCheckoutCompletion && useRelay + ? { error: null } + : useRelay + ? relayHistoryGasless + : gasless; + const standardForHistory = + restoredCheckoutCompletion && isStandard + ? { phase: 'idle', error: null } + : standard; usePaymentHistory( historyCtx, - useRelay ? relayHistoryGasless : gasless, - standard, + gaslessForHistory, + standardForHistory, ); useEffect(() => { @@ -826,7 +1142,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { }, [redirectIn]); function doRedirect() { - if (!params.successUrl || !completion) return; + if (!params.successUrl || !completion || restoredCheckoutCompletion) return; const u = new URL(params.successUrl); for (const [k, v] of Object.entries(completion.redirectQuery)) { u.searchParams.set(k, v); @@ -912,6 +1228,10 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // 売上総額 = 商品小計 (totalWei)。顧客上乗せ時に merchant+fee で gross を over しないよう、 // 履歴 snapshot に正しい gross を運ぶ (非モバイルは fee=0 で merchantAmount に一致)。 saleAmount: totalWei, + contextKey: checkoutContextKey, + // レジ standard fee であることの印。送金自体は従来どおり plain transfer のままで、 + // 2 tx 確定後に fee txHash を server 通知して用途束縛 claim を作らせるだけ (付帯処理)。 + ...(isRegisterStandardFee ? { registerFee: true as const } : {}), }); } else if (useRelay) { // JPYC EIP-3009 relay: 顧客が transferWithAuthorization に署名 → 自前 relayer が gas 負担で @@ -975,7 +1295,9 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { // 同じ。amountDisplay=fmt(totalCustomerOutflow)・merchantAddress=params.to・explorerBase は全経路 // 共通で従来どおり。 const successOverlayPayload: PaymentSuccessOverlayPayload | null = - !isStandard && !useRelay && gasless.data && gasless.data.success + restoredCheckoutCompletion + ? null + : !isStandard && !useRelay && gasless.data && gasless.data.success ? { amountDisplay: fmt(totalCustomerOutflow), txHash: gasless.data.txHash, @@ -1512,7 +1834,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { /> )} - {params.orderId && ( + {!restoredCheckoutCompletion && params.orderId && ( )} @@ -1543,7 +1865,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { /> - {params.successUrl && ( + {!restoredCheckoutCompletion && params.successUrl && (

{redirectIn !== null && redirectIn > 0 @@ -1559,7 +1881,7 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {

)} - {!params.successUrl && ( + {(restoredCheckoutCompletion || !params.successUrl) && (