From 57ef6362ff5f9200283384587ae4565fcd0f0eed Mon Sep 17 00:00:00 2001 From: dwebxr Date: Mon, 1 Jun 2026 05:46:05 +0900 Subject: [PATCH 1/3] feat(crosschain): centralize cross-chain accounting log in the hook + v3 fields (Step 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 調査の結果、cross-chain 決済は「記録なし」ではなく CrossChainHint が executeOption 成功後に logPaymentEvent していた。ただし (1) 全 execute 完了後に発火するため fee mint 失敗で merchant 着金ログを取りこぼす (Gateway/CCTP は merchant を fee より先に mint)、(2) saleAmount/bridge fee を持たず merchantAmount=bridgedAmount を settled income として記録、(3) CrossChainSource Chooser 経路は未ログ、という課題があった。会計ログを hook の onMerchantMint (merchant mint 確定時・fee mint より前) に集約して是正する。 - execute.ts: OnMerchantMint callback を追加し、Gateway/CCTP とも merchant mint 確定直後 (fee mint より前・resume 経路含む) に発火。fee mint 失敗でも merchant 着金を取りこぼさない。 - useCrossChainPayment: onMerchantMint で logPaymentEvent を発火 (全実行経路を集約)。 flow:'direct' / bridge / sourceChainId / saleAmount(gross) / merchantAmount(=bridged intent) / bridgeFeeMax(ceiling) / burnTxHash / feeAmount(0)。 - CrossChainHint: 重複する明示ログを削除 (hook に集約)。 - paymentLog + route: bridgedAmount / bridgeFeeMax / burnTxHash を追加・検証 (全て unreconciled)。 - cctp.ts / gateway.ts: estimateCctp/GatewayMaxFee を export (calldata と同一計算)。 - stats: cross-chain success を (bridge+chainId+txHash) で dedup、bridged intent を settled totalMerchantWei から除外 (byBridge には残す)、totalBridgeFeeMaxWei / crossChainDeduped を追加、 saleAmount を GMV に計上。 - 実着金 (minted) と実 bridge fee は mint receipt 照合 (B-3) で verified、本記録は reported。 Verified: tsc 0 · eslint 0 · full suite 2564 passed/0 failed · /pay bundle 421kB (予算内)。 Co-Authored-By: Claude Opus 4.8 --- app/api/log/payment/route.ts | 13 +++++ app/api/log/payment/stats/route.ts | 70 +++++++++++++++++++++++-- components/CrossChainHint.tsx | 48 +++-------------- hooks/useCrossChainPayment.ts | 39 ++++++++++++++ lib/crossChain/cctp.ts | 7 +++ lib/crossChain/execute.ts | 28 ++++++++++ lib/crossChain/gateway.ts | 7 +++ lib/paymentLog.ts | 16 ++++++ tests/app/api/log-payment-stats.test.ts | 43 +++++++++++++++ tests/app/api/log-payment.test.ts | 27 ++++++++++ tests/lib/crossChain/execute.test.ts | 12 +++++ 11 files changed, 263 insertions(+), 47 deletions(-) diff --git a/app/api/log/payment/route.ts b/app/api/log/payment/route.ts index bf63c41..ea8f206 100644 --- a/app/api/log/payment/route.ts +++ b/app/api/log/payment/route.ts @@ -47,6 +47,11 @@ type Payload = { // direct (同一 chain) では undefined、Gateway/CCTP V2 経由なら値が入る。 bridge?: 'gateway' | 'cctp-v2'; sourceChainId?: number; + // cross-chain 会計フィールド (unreconciled・reported)。bridgedAmount / bridgeFeeMax は raw + // decimal、burnTxHash は CCTP source burn tx。 + bridgedAmount?: string; + bridgeFeeMax?: string; + burnTxHash?: Hex; // Circle Paymaster 監査 (gasless circle 経路のみ・Phase1 C2/C3)。 provider?: 'pimlico' | 'circle'; circlePaymasterAddress?: Address; @@ -119,6 +124,11 @@ function validate(raw: unknown): Payload | null { r.sourceChainId <= 0) ) return null; + if (r.bridgedAmount !== undefined && !isDecimalString(r.bridgedAmount)) + return null; + if (r.bridgeFeeMax !== undefined && !isDecimalString(r.bridgeFeeMax)) + return null; + if (r.burnTxHash !== undefined && !validHex(r.burnTxHash)) return null; if (r.provider !== undefined && r.provider !== 'pimlico' && r.provider !== 'circle') return null; if ( @@ -165,6 +175,9 @@ function validate(raw: unknown): Payload | null { if (r.errorMessage !== undefined) clean.errorMessage = r.errorMessage; if (r.bridge !== undefined) clean.bridge = r.bridge; if (r.sourceChainId !== undefined) clean.sourceChainId = r.sourceChainId; + if (r.bridgedAmount !== undefined) clean.bridgedAmount = r.bridgedAmount; + if (r.bridgeFeeMax !== undefined) clean.bridgeFeeMax = r.bridgeFeeMax; + if (r.burnTxHash !== undefined) clean.burnTxHash = r.burnTxHash; if (r.provider !== undefined) clean.provider = r.provider; if (r.circlePaymasterAddress !== undefined) clean.circlePaymasterAddress = r.circlePaymasterAddress; diff --git a/app/api/log/payment/stats/route.ts b/app/api/log/payment/stats/route.ts index bc7ee10..59e0ca6 100644 --- a/app/api/log/payment/stats/route.ts +++ b/app/api/log/payment/stats/route.ts @@ -75,6 +75,10 @@ type LogEntry = { // 送金は両者とも undefined、Gateway / CCTP V2 経由なら値が入る。 bridge?: string; sourceChainId?: number; + // cross-chain (Step 3) の dest mint tx (= idempotency key) と bridge fee 上限 (ceiling)。 + // cross-chain success は resume で複数回 log され得るため (bridge+chainId+txHash) で dedup する。 + txHash?: string; + bridgeFeeMax?: string; // Phase1 Circle Paymaster 監査フィールド (gasless circle 経路のみ)。 provider?: string; circlePaymasterNetUsdc?: string; @@ -120,6 +124,8 @@ type BridgeAgg = { totalFeeWei: bigint; totalSaleWei: bigint; totalNetworkFeeWei: bigint; + // cross-chain の bridge fee **上限** 合計 (ceiling・実 charge ではない・unreconciled)。 + totalBridgeFeeMaxWei: bigint; }; type ChainAgg = { @@ -135,6 +141,8 @@ type ChainAgg = { totalSaleWei: bigint; totalNetworkFeeWei: bigint; unknownBreakdownCount: number; + // cross-chain bridge fee 上限合計 (ceiling・unreconciled)。 + totalBridgeFeeMaxWei: bigint; byToken: Map; byBridge: Map; }; @@ -181,6 +189,7 @@ function emptyBridgeAgg(bridge: PaymentBridgeKey): BridgeAgg { totalFeeWei: 0n, totalSaleWei: 0n, totalNetworkFeeWei: 0n, + totalBridgeFeeMaxWei: 0n, }; } @@ -205,6 +214,7 @@ function aggregate(entries: LogEntry[]): { chains: ChainAgg[]; aggregatedCount: number; invalidEntries: number; + crossChainDeduped: number; byBridge: BridgeAgg[]; byProvider: ProviderAgg[]; } { @@ -213,12 +223,32 @@ function aggregate(entries: LogEntry[]): { const globalByProvider = new Map(); let aggregatedCount = 0; let invalidEntries = 0; + // cross-chain success は executor の onMerchantMint が resume で複数回発火し得る + // (route は POST を無条件 append)。同一着金の二重計上を防ぐため (bridge+chainId+txHash) + // で dedup する。dedup 件数は透明性のため response に出す。 + let crossChainDeduped = 0; + const seenCrossChain = new Set(); for (const e of entries) { if (!isAggregable(e)) { invalidEntries++; continue; } + // cross-chain success の冪等 dedup (mint tx hash 単位)。 + const bridgeKeyForDedup = normalizeBridge(e.bridge); + if ( + bridgeKeyForDedup !== 'direct' && + e.result === 'success' && + typeof e.txHash === 'string' && + e.txHash.length > 0 + ) { + const key = `${bridgeKeyForDedup}:${e.chainId}:${e.txHash}`; + if (seenCrossChain.has(key)) { + crossChainDeduped++; + continue; + } + seenCrossChain.add(key); + } aggregatedCount++; const chainId = e.chainId; const tokenAddress = e.tokenAddress.toLowerCase(); @@ -226,6 +256,12 @@ function aggregate(entries: LogEntry[]): { const merchantWei = parseWei(e.merchantAmount); const feeWei = parseWei(e.feeAmount); const bridgeKey = normalizeBridge(e.bridge); + // cross-chain (bridge !== direct) の merchantAmount は bridged intent (実着金 = minted は + // bridge fee 控除後で未確定・B-3 で receipt 照合)。settled income ではないため chain/token の + // totalMerchantWei には計上せず、byBridge 側にのみ intent として残す。GMV (totalSaleWei) と + // bridge fee 上限 (totalBridgeFeeMaxWei) は計上する。 + const isCrossChain = bridgeKey !== 'direct'; + const bridgeFeeMaxWei = parseWei(e.bridgeFeeMax); // v3: 売上総額 (gross) は saleAmount を優先し、無い旧 log は着金額で代替 (gas=customer の // 大多数は両者一致するため GMV は概ね保たれる)。ネットワーク手数料相当額は非 circle の // networkFeeEquivalent と circle の circlePaymasterNetUsdc を coalesce。 @@ -264,6 +300,7 @@ function aggregate(entries: LogEntry[]): { totalSaleWei: 0n, totalNetworkFeeWei: 0n, unknownBreakdownCount: 0, + totalBridgeFeeMaxWei: 0n, byToken: new Map(), byBridge: new Map(), }; @@ -336,11 +373,15 @@ function aggregate(entries: LogEntry[]): { // 除外する (conflated feeAmount が利用手数料収益として誤計上されるのを防ぐ)。 const serviceFeeWei = hasSeparatedBreakdown ? feeWei : 0n; if (!hasSeparatedBreakdown) chain.unknownBreakdownCount++; - chain.totalMerchantWei += merchantWei; + // settled income (totalMerchantWei) は同一 chain 決済のみ。cross-chain は bridged intent + // (実着金未確定) のため chain/token からは除外し、byBridge 側に intent として残す。 + const settledMerchantWei = isCrossChain ? 0n : merchantWei; + chain.totalMerchantWei += settledMerchantWei; chain.totalFeeWei += serviceFeeWei; chain.totalSaleWei += saleWei; chain.totalNetworkFeeWei += netFeeWei; - token.totalMerchantWei += merchantWei; + chain.totalBridgeFeeMaxWei += bridgeFeeMaxWei; + token.totalMerchantWei += settledMerchantWei; token.totalFeeWei += serviceFeeWei; token.totalSaleWei += saleWei; token.totalNetworkFeeWei += netFeeWei; @@ -348,10 +389,12 @@ function aggregate(entries: LogEntry[]): { chainBridge.totalFeeWei += serviceFeeWei; chainBridge.totalSaleWei += saleWei; chainBridge.totalNetworkFeeWei += netFeeWei; + chainBridge.totalBridgeFeeMaxWei += bridgeFeeMaxWei; globalBridge.totalMerchantWei += merchantWei; globalBridge.totalFeeWei += serviceFeeWei; globalBridge.totalSaleWei += saleWei; globalBridge.totalNetworkFeeWei += netFeeWei; + globalBridge.totalBridgeFeeMaxWei += bridgeFeeMaxWei; } else if (result === 'reverted') { chain.revertedCount++; token.revertedCount++; @@ -391,7 +434,14 @@ function aggregate(entries: LogEntry[]): { .map((k) => globalByProvider.get(k)) .filter((p): p is ProviderAgg => p !== undefined); - return { chains, aggregatedCount, invalidEntries, byBridge, byProvider }; + return { + chains, + aggregatedCount, + invalidEntries, + crossChainDeduped, + byBridge, + byProvider, + }; } function serializeBridges(bridges: BridgeAgg[]) { @@ -404,6 +454,7 @@ function serializeBridges(bridges: BridgeAgg[]) { totalFeeWei: b.totalFeeWei.toString(), totalSaleWei: b.totalSaleWei.toString(), totalNetworkFeeWei: b.totalNetworkFeeWei.toString(), + totalBridgeFeeMaxWei: b.totalBridgeFeeMaxWei.toString(), })); } @@ -432,6 +483,7 @@ function serialize(chains: ChainAgg[]) { totalFeeWei: c.totalFeeWei.toString(), totalSaleWei: c.totalSaleWei.toString(), totalNetworkFeeWei: c.totalNetworkFeeWei.toString(), + totalBridgeFeeMaxWei: c.totalBridgeFeeMaxWei.toString(), unknownBreakdownCount: c.unknownBreakdownCount, byToken: Array.from(c.byToken.values()) .sort((a, b) => b.successCount - a.successCount) @@ -551,8 +603,14 @@ export async function GET(req: Request): Promise { return true; }); - const { chains, aggregatedCount, invalidEntries, byBridge, byProvider } = - aggregate(filtered); + const { + chains, + aggregatedCount, + invalidEntries, + crossChainDeduped, + byBridge, + byProvider, + } = aggregate(filtered); return NextResponse.json({ ok: true, @@ -577,6 +635,8 @@ export async function GET(req: Request): Promise { filteredCount: filtered.length, aggregatedCount, invalidEntries, + // cross-chain success の (bridge+chainId+txHash) 重複 (resume 由来) を除外した件数。 + crossChainDeduped, filter: { chainId: parsedChainIdFilter ?? null, since: sinceFilter, diff --git a/components/CrossChainHint.tsx b/components/CrossChainHint.tsx index 6538136..e143982 100644 --- a/components/CrossChainHint.tsx +++ b/components/CrossChainHint.tsx @@ -15,14 +15,8 @@ import { useEffect, useState } from 'react'; import { formatUnits, type Address } from 'viem'; import { useTranslations } from 'next-intl'; import { useCrossChainPayment } from '@/hooks/useCrossChainPayment'; -import { - buildPaymentLogEvent, - logPaymentEvent, - type PaymentBridge, -} from '@/lib/paymentLog'; import type { CrossChainProgress } from '@/lib/crossChain/execute'; import type { PathOption } from '@/lib/crossChain/pathEnumerator'; -import { computeCrossChainFeeSplit } from '@/lib/crossChain/feeSplit'; import { CROSS_CHAIN_DISABLED } from '@/lib/crossChain/config'; import { blockExplorerUrl } from '@/lib/chains'; import { CrossChainSourceChooser } from './CrossChainSourceChooser'; @@ -178,44 +172,14 @@ export function CrossChainHint(props: CrossChainHintProps) { // ここでは何もしない (= no-op、panel 残る)。 return; } - let executeResult; try { - executeResult = await hook.executeOption(selectedOption); + // 会計ログ (KV) は useCrossChainPayment の onMerchantMint が merchant mint 確定時 + // (fee mint より前) に発火する。fee mint 失敗でも merchant 着金を取りこぼさず、売上総額 + // (saleAmount) / bridgeFeeMax / burnTxHash など v3 フィールドも含めて記録する。ここでは + // 実行のみ (success panel は hook.result state が駆動)。 + await hook.executeOption(selectedOption); } catch { - return; - } - if (executeResult) { - const bridge: PaymentBridge = - selectedOption.kind === 'gateway' ? 'gateway' : 'cctp-v2'; - const sourceChainIdForLog = - selectedOption.kind === 'cctp-v2' - ? selectedOption.sourceChainId - : undefined; - // fee=0 (Phase 1) では merchant 宛 1 本のみブリッジ。fee>0 で operator 宛 2 本目が - // 自動復活する (execute 側の bridgeFee guard 経由)。 - const { feeAmount, bridgedAmount } = computeCrossChainFeeSplit( - props.requiredAtomic, - 'usdc', - 'standard', - ); - const evt = buildPaymentLogEvent( - { - flow: 'direct', - chainId: props.targetChainId, - tokenAddress: props.tokenAddress, - merchant: props.recipient, - merchantAmount: bridgedAmount, - feeReceiver: props.feeReceiver, - feeAmount, - // 利用料は merchant mint とは別 tx で着金する。監査用に fee mint hash を - // 記録 (txHash は merchant mint なので、これが無いと fee 送金を辿れない)。 - feeTxHash: executeResult.feeMintTxHash, - bridge, - sourceChainId: sourceChainIdForLog, - }, - { result: 'success', txHash: executeResult.mintTxHash }, - ); - void logPaymentEvent(evt); + // 実行エラーは hook.error に反映される (ここでは何もしない)。 } } diff --git a/hooks/useCrossChainPayment.ts b/hooks/useCrossChainPayment.ts index cd5cea8..7db9eb4 100644 --- a/hooks/useCrossChainPayment.ts +++ b/hooks/useCrossChainPayment.ts @@ -20,7 +20,11 @@ import { type ExecuteGatewayTransferArgs, type ExecuteGatewayTransferResult, type GatewayResumeState, + type OnMerchantMint, } from '@/lib/crossChain/execute'; +import { buildPaymentLogEvent, logPaymentEvent } from '@/lib/paymentLog'; +import { estimateCctpMaxFee } from '@/lib/crossChain/cctp'; +import { estimateGatewayMaxFee } from '@/lib/crossChain/gateway'; import { selectPath, type PathDecision } from '@/lib/crossChain/router'; import { enumeratePathOptions, @@ -190,6 +194,39 @@ export function useCrossChainPayment( }; const onStep = (s: ResumeState) => saveResumeState(sessionKey, s); + // merchant mint 確定時に会計ログ (KV) を発火する。cross-chain は買い手の端末で実行され + // localStorage は買い手の控えにしかならないため、店舗向けの会計記録は KV ログが本筋。 + // 値は全て unreconciled (reported): merchantAmount=bridgedAmount は bridge intent (実着金 + // = minted は bridge fee 控除後で B-3 の receipt 照合で確定)、bridgeFeeMax は ceiling。 + // resume で複数回発火し得るので、集計層が (bridge+chainId+mintTxHash) で dedup する。 + const onMerchantMint: OnMerchantMint = (info) => { + const bridgeFeeMax = + core.kind === 'cctp-v2' + ? estimateCctpMaxFee(bridgedAmount) + : estimateGatewayMaxFee(bridgedAmount); + void logPaymentEvent( + buildPaymentLogEvent( + { + flow: 'direct', + chainId: args.targetChainId, + tokenAddress: destDeployment.address, + merchant: args.recipient, + merchantAmount: bridgedAmount, + customer: account, + feeReceiver: args.feeReceiver, + feeAmount, // OpenPay cross-chain 利用料 (Phase1 alpha = 0) + saleAmount: args.requiredAtomic, // 請求総額 (gross) + bridge: core.kind, + sourceChainId: core.sourceChainId, + bridgedAmount, + bridgeFeeMax, + burnTxHash: info.burnTxHash, + }, + { result: 'success', txHash: info.mintTxHash }, + ), + ); + }; + if (core.kind === 'gateway') { const gatewayArgs: ExecuteGatewayTransferArgs = { walletClient, @@ -210,6 +247,7 @@ export function useCrossChainPayment( resume: loadResumeState(sessionKey), onStep, onProgress: reportProgress, + onMerchantMint, }; const result = await executeGatewayTransfer(gatewayArgs); clearResumeState(sessionKey); @@ -234,6 +272,7 @@ export function useCrossChainPayment( resume: loadResumeState(sessionKey), onStep, onProgress: reportProgress, + onMerchantMint, }; const result = await executeCctpTransfer(cctpArgs); clearResumeState(sessionKey); diff --git a/lib/crossChain/cctp.ts b/lib/crossChain/cctp.ts index f5ddf04..8f7ce90 100644 --- a/lib/crossChain/cctp.ts +++ b/lib/crossChain/cctp.ts @@ -142,6 +142,13 @@ function computeCctpMaxFee( : computed; } +// 会計ログ用の bridge fee **上限** 見積 (実 charge ではない・実 fee ≤ これ)。calldata と +// 同じ既定 (overrides 無し) で算出し、ログ値が calldata と drift しないようにする。記録は +// reported/unreconciled 扱い、実 charge は mint receipt 照合 (B-3) で確定する。 +export function estimateCctpMaxFee(value: bigint): bigint { + return computeCctpMaxFee(value, {}); +} + // 事前に erc20.approve(TOKEN_MESSENGER_ADDRESS, value) が必要。 export function encodeDepositForBurnCalldata( args: BuildDepositForBurnArgs, diff --git a/lib/crossChain/execute.ts b/lib/crossChain/execute.ts index 4868655..cb104ca 100644 --- a/lib/crossChain/execute.ts +++ b/lib/crossChain/execute.ts @@ -63,6 +63,16 @@ export type CrossChainProgress = export type ProgressCallback = (p: CrossChainProgress) => void; +// merchant mint が **確定** した時点で発火するコールバック (fee mint より前)。Gateway / CCTP は +// merchant を fee より先に mint するため、fee mint が失敗しても merchant 着金を会計ログに +// 取りこぼさないよう、このタイミングで呼ぶ。冪等ではなく resume で複数回呼ばれ得るので、 +// 呼出側 (会計ログの集計層) が (bridge + chainId + mintTxHash) で dedup する前提。 +export type OnMerchantMint = (info: { + mintTxHash: Hex; + /** CCTP の source burn tx (照合用)。Gateway は burn-intent モデルで undefined。 */ + burnTxHash?: Hex; +}) => void; + // wagmi useSwitchChain.switchChainAsync の signature と互換。 export type SwitchChainFn = (args: { chainId: number }) => Promise; @@ -213,6 +223,8 @@ export interface ExecuteGatewayTransferArgs { fetch?: FetchLike; attestationBaseUrl?: string; onProgress?: ProgressCallback; + /** merchant mint 確定時に発火 (fee mint より前)。会計ログ用。詳細は OnMerchantMint。 */ + onMerchantMint?: OnMerchantMint; } export interface ExecuteGatewayTransferResult { @@ -359,6 +371,11 @@ export async function executeGatewayTransfer( }, label: 'gateway mint', }); + // merchant mint 確定 → 会計ログ発火 (fee mint より前)。settleMint は resume / fresh / + // 確認待ちを内部で吸収するので、ここに来た時点で merchant 着金は確定している。 + if (state.mintTxHash) { + args.onMerchantMint?.({ mintTxHash: state.mintTxHash }); + } if (bridgeFee && state.feeAttestation) { await settleMint({ client: args.destPublicClient, @@ -431,6 +448,8 @@ export interface ExecuteCctpTransferArgs { 'intervalMs' | 'timeoutMs' | 'sleep' | 'now' >; onProgress?: ProgressCallback; + /** merchant mint 確定時に発火 (fee mint より前)。会計ログ用。詳細は OnMerchantMint。 */ + onMerchantMint?: OnMerchantMint; } export interface ExecuteCctpTransferResult { @@ -565,6 +584,13 @@ export async function executeCctpTransfer( let attestationMessage: Hex | undefined; let attestationSignature: Hex | undefined; + // resume で merchant mint が既に landed している場合、この run では再 mint しない + // (merchantIris は !merchantMintLanded のときだけ取得される)。確定済の merchant 着金を + // fee mint より前にここで会計ログ発火する (fee mint 失敗でも取りこぼさない・dedup は集計層)。 + if (merchantMintLanded && state.mintTxHash) { + args.onMerchantMint?.({ mintTxHash: state.mintTxHash, burnTxHash: burnHash }); + } + if (!merchantMintLanded || !feeMintLanded) { onProgress({ kind: 'poll_attestation' }); // burn hash から attestation を再取得 (Iris は永続なので resume でも取得可能)。 @@ -620,6 +646,8 @@ export async function executeCctpTransfer( persist({ mintTxHash: mintHash }); onProgress({ kind: 'dest_tx_pending', hash: mintHash }); await waitForReceiptOrThrow(args.destPublicClient, mintHash, 'cctp mint'); + // fresh merchant mint 確定 → 会計ログ発火 (下の fee mint より前)。 + args.onMerchantMint?.({ mintTxHash: mintHash, burnTxHash: burnHash }); } if (feeIris) { const feeMintData = encodeReceiveMessageCalldata( diff --git a/lib/crossChain/gateway.ts b/lib/crossChain/gateway.ts index 4c60319..0f052bd 100644 --- a/lib/crossChain/gateway.ts +++ b/lib/crossChain/gateway.ts @@ -241,6 +241,13 @@ function computeMaxFee(value: bigint, ov: BuildBurnIntentOverrides): bigint { return computed < MIN_MAX_FEE_ATOMIC ? MIN_MAX_FEE_ATOMIC : computed; } +// 会計ログ用の bridge fee **上限** 見積 (実 charge ではない・実 fee ≤ これ)。burn intent と +// 同じ既定 (overrides 無し) で算出し、ログ値が calldata と drift しないようにする。記録は +// reported/unreconciled 扱い、実 charge は mint receipt 照合 (B-3) で確定する。 +export function estimateGatewayMaxFee(value: bigint): bigint { + return computeMaxFee(value, {}); +} + // EIP712Domain は viem が domain object から自動推論するため types には含めない。 export function getBurnIntentTypedData( intent: BurnIntent, diff --git a/lib/paymentLog.ts b/lib/paymentLog.ts index 08ac948..11f376e 100644 --- a/lib/paymentLog.ts +++ b/lib/paymentLog.ts @@ -75,6 +75,15 @@ export type PaymentLogEvent = { // bridge 経由時の source chain ID (本 chainId は destination)。 // direct の場合は undefined。 sourceChainId?: number; + // --- cross-chain (CCTP V2 / Gateway) の会計フィールド。全て **unreconciled** (reported)。 --- + // bridge に渡す intent (= gross - OpenPay 利用料)。bridge が fee を deduct する前の値で、 + // 実着金 (minted) は未確定 (B-3 で mint receipt 照合)。settled income ではないため + // stats は totalMerchantWei に計上しない (bridge marker で除外)。 + bridgedAmount?: string; + // bridge fee の **上限** (maxFee ceiling・実 charge ではない・実 fee ≤ これ)。 + bridgeFeeMax?: string; + // cross-chain (CCTP) の source burn tx hash (照合用)。Gateway は burn-intent モデルで undefined。 + burnTxHash?: Hex; // --- Circle Paymaster 監査 (gasless circle 経路のみ・C2/C3) --- // paymaster 系統。gasless で 'pimlico' | 'circle'、standard/cross-chain は undefined。 provider?: PaymentProvider; @@ -106,6 +115,10 @@ export type PaymentLogContext = { // cross-chain bridge 経由の場合のみ指定。direct (= 既存単一 chain) では undefined。 bridge?: PaymentBridge; sourceChainId?: number; + // cross-chain 会計フィールド (unreconciled・reported)。詳細は PaymentLogEvent 参照。 + bridgedAmount?: bigint; + bridgeFeeMax?: bigint; + burnTxHash?: Hex; // Circle Paymaster 経路の監査フィールド (gasless circle のみ)。 provider?: PaymentProvider; circlePaymasterAddress?: Address; @@ -140,6 +153,9 @@ export function buildPaymentLogEvent( feeTxHash: ctx.feeTxHash, bridge: ctx.bridge, sourceChainId: ctx.sourceChainId, + bridgedAmount: ctx.bridgedAmount?.toString(), + bridgeFeeMax: ctx.bridgeFeeMax?.toString(), + burnTxHash: ctx.burnTxHash, provider: ctx.provider, circlePaymasterAddress: ctx.circlePaymasterAddress, circlePaymasterNetUsdc: ctx.circlePaymasterNetUsdc, diff --git a/tests/app/api/log-payment-stats.test.ts b/tests/app/api/log-payment-stats.test.ts index 3cde47f..b9fca5c 100644 --- a/tests/app/api/log-payment-stats.test.ts +++ b/tests/app/api/log-payment-stats.test.ts @@ -442,6 +442,49 @@ describe('stats: aggregate — chain / token / count / GMV', () => { expect(chain.totalNetworkFeeWei).toBe('5000000000000000'); }); + it('Step3: cross-chain success は (bridge+chainId+txHash) で dedup・merchantAmount は settled income 除外・saleAmount/bridgeFeeMax を集計', async () => { + const cc = { + flow: 'direct', + result: 'success', + chainId: 8453, + tokenAddress: USDC_BASE, + merchant: MERCHANT, + merchantAmount: '1000000', // bridged intent (1 USDC) + saleAmount: '1000000', // gross + bridge: 'cctp-v2', + sourceChainId: 137, + txHash: '0xmint1', + bridgeFeeMax: '1000', // ceiling + feeAmount: '0', + feeBreakdownVersion: 1, + }; + vi.mocked(kvLrange).mockResolvedValue({ + ok: true, + // 同一 mint の重複 (resume で onMerchantMint が複数回発火したケース)。 + value: [makeEntry(cc), makeEntry(cc)], + }); + vi.mocked(kvLlen).mockResolvedValue({ ok: true, value: 2 }); + + const res = await GET(makeReq({ auth: `Bearer ${TOKEN}` })); + const body = await res.json(); + expect(body.crossChainDeduped).toBe(1); // 2 件中 1 件は重複除外 + const chain = body.byChain.find( + (c: { chainId: number }) => c.chainId === 8453, + ); + expect(chain.successCount).toBe(1); // dedup 後 1 件 + // cross-chain は bridged intent (実着金未確定) なので settled income に計上しない + expect(chain.totalMerchantWei).toBe('0'); + // GMV (gross) と bridge fee 上限は計上する + expect(chain.totalSaleWei).toBe('1000000'); + expect(chain.totalBridgeFeeMaxWei).toBe('1000'); + // byBridge には intent として残る + const cctp = body.byBridge.find( + (b: { bridge: string }) => b.bridge === 'cctp-v2', + ); + expect(cctp.totalMerchantWei).toBe('1000000'); + expect(cctp.totalBridgeFeeMaxWei).toBe('1000'); + }); + it('Phase 1: feeAmount=0 / undefined の batch・direct・standard-merchant で GMV 計上・totalFeeWei=0', async () => { // Phase 1 (alpha) では決済手数料 0% なので、新規 entry は全て feeAmount=0 // (batch / direct は '0'、standard-merchant は undefined) で記録される。 diff --git a/tests/app/api/log-payment.test.ts b/tests/app/api/log-payment.test.ts index 0fd112d..d28198d 100644 --- a/tests/app/api/log-payment.test.ts +++ b/tests/app/api/log-payment.test.ts @@ -72,6 +72,33 @@ describe('POST /api/log/payment', () => { expect(entry.serverTs).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); + it('Step3: cross-chain 会計フィールド (bridgedAmount / bridgeFeeMax / burnTxHash) を受理', async () => { + const res = await POST( + req({ + ...validBody, + flow: 'direct', + bridge: 'cctp-v2', + sourceChainId: 8453, + saleAmount: '1000000', + bridgedAmount: '1000000', + bridgeFeeMax: '1000', + burnTxHash: '0x' + 'c'.repeat(64), + feeBreakdownVersion: 1, + }), + ); + expect(res.status).toBe(200); + }); + + it('Step3: bridgeFeeMax が数字以外なら 400', async () => { + const res = await POST(req({ ...validBody, bridgeFeeMax: '1.5' })); + expect(res.status).toBe(400); + }); + + it('Step3: burnTxHash が hex でないと 400', async () => { + const res = await POST(req({ ...validBody, burnTxHash: 'notahex' })); + expect(res.status).toBe(400); + }); + it('flow が不正値だと 400', async () => { const res = await POST(req({ ...validBody, flow: 'invalid' })); expect(res.status).toBe(400); diff --git a/tests/lib/crossChain/execute.test.ts b/tests/lib/crossChain/execute.test.ts index fdc3675..a2f0b26 100644 --- a/tests/lib/crossChain/execute.test.ts +++ b/tests/lib/crossChain/execute.test.ts @@ -97,6 +97,7 @@ describe('lib/crossChain/execute.executeGatewayTransfer', () => { ), ); const progress: CrossChainProgress[] = []; + const merchantMints: Array<{ mintTxHash: string; burnTxHash?: string }> = []; const result = await executeGatewayTransfer({ walletClient: walletClient as unknown as Parameters< @@ -120,6 +121,7 @@ describe('lib/crossChain/execute.executeGatewayTransfer', () => { valueAtomic: 1_000_000n, fetch: mockFetch as unknown as typeof fetch, onProgress: (p) => progress.push(p), + onMerchantMint: (i) => merchantMints.push(i), }); // call sequence @@ -144,6 +146,9 @@ describe('lib/crossChain/execute.executeGatewayTransfer', () => { hash: '0xminthash01', }); + // merchant mint 確定で onMerchantMint が発火 (会計ログ用・Gateway は burnTxHash 無し) + expect(merchantMints).toEqual([{ mintTxHash: '0xminthash01' }]); + // result expect(result.path).toBe('gateway'); expect(result.mintTxHash).toBe('0xminthash01'); @@ -272,6 +277,7 @@ describe('lib/crossChain/execute.executeCctpTransfer', () => { ), ); const progress: CrossChainProgress[] = []; + const merchantMints: Array<{ mintTxHash: string; burnTxHash?: string }> = []; const result = await executeCctpTransfer({ walletClient: walletClient as never, @@ -289,6 +295,7 @@ describe('lib/crossChain/execute.executeCctpTransfer', () => { fetch: mockFetch as unknown as typeof fetch, pollOptions: { sleep: vi.fn(async (_ms: number) => undefined), now: () => 0 }, onProgress: (p) => progress.push(p), + onMerchantMint: (i) => merchantMints.push(i), }); // approve は writeContract、burn と receive は sendTransaction @@ -321,6 +328,11 @@ describe('lib/crossChain/execute.executeCctpTransfer', () => { expect(sourcePublic.waitForTransactionReceipt).toHaveBeenCalledTimes(2); expect(destPublic.waitForTransactionReceipt).toHaveBeenCalledTimes(1); + // merchant mint 確定で onMerchantMint が発火 (会計ログ用・CCTP は burnTxHash 付き) + expect(merchantMints).toEqual([ + { mintTxHash: '0xreceive01', burnTxHash: '0xburn01' }, + ]); + // result expect(result.path).toBe('cctp-v2'); expect(result.approveTxHash).toBe('0xapprove01'); From abd2b188864fc9dc777beeced0387884544a7d07 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Mon, 1 Jun 2026 05:57:52 +0900 Subject: [PATCH 2/3] fix(crosschain): isolate onMerchantMint callback + resume/fee-failure test coverage (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex code-review (approve-with-changes) の minor 2 点: - 隔離: fireMerchantMint helper で onMerchantMint を try/catch 包み、会計ログ (best-effort) の例外が確定済 merchant mint 後の決済 flow を中断させないようにした (3 fire site 全て)。 - テスト: (a) CCTP resume-merchant-landed が fee mint より前に callback を 1 度発火、 (b) CCTP fee mint 失敗でも merchant callback は発火済 (income 取りこぼし無し)、 (c) onMerchantMint throw が executor に伝播しない (隔離の検証)。 bridgeFeeMax の override 乖離 (Codex minor #4) は現状 override 経路の caller が無く latent。 将来 override 対応 caller を足す際は executor から実 ceiling を callback に渡す follow-up が必要 (estimator は default overrides 前提)。 Verified: tsc 0 · eslint 0 · full suite 2566 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 --- lib/crossChain/execute.ts | 27 +++++++- tests/lib/crossChain/execute.test.ts | 96 ++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/lib/crossChain/execute.ts b/lib/crossChain/execute.ts index cb104ca..0cddc58 100644 --- a/lib/crossChain/execute.ts +++ b/lib/crossChain/execute.ts @@ -73,6 +73,21 @@ export type OnMerchantMint = (info: { burnTxHash?: Hex; }) => void; +// onMerchantMint を隔離して呼ぶ。会計ログ (best-effort) の例外で、確定済の merchant mint +// 後の決済 flow を中断させないため (callee は通常 void logPaymentEvent で fire-and-forget だが、 +// 契約として呼出側でも throw を握り潰す)。 +function fireMerchantMint( + cb: OnMerchantMint | undefined, + info: { mintTxHash: Hex; burnTxHash?: Hex }, +): void { + if (!cb) return; + try { + cb(info); + } catch { + /* audit ログ失敗は決済確定に影響させない */ + } +} + // wagmi useSwitchChain.switchChainAsync の signature と互換。 export type SwitchChainFn = (args: { chainId: number }) => Promise; @@ -374,7 +389,7 @@ export async function executeGatewayTransfer( // merchant mint 確定 → 会計ログ発火 (fee mint より前)。settleMint は resume / fresh / // 確認待ちを内部で吸収するので、ここに来た時点で merchant 着金は確定している。 if (state.mintTxHash) { - args.onMerchantMint?.({ mintTxHash: state.mintTxHash }); + fireMerchantMint(args.onMerchantMint, { mintTxHash: state.mintTxHash }); } if (bridgeFee && state.feeAttestation) { await settleMint({ @@ -588,7 +603,10 @@ export async function executeCctpTransfer( // (merchantIris は !merchantMintLanded のときだけ取得される)。確定済の merchant 着金を // fee mint より前にここで会計ログ発火する (fee mint 失敗でも取りこぼさない・dedup は集計層)。 if (merchantMintLanded && state.mintTxHash) { - args.onMerchantMint?.({ mintTxHash: state.mintTxHash, burnTxHash: burnHash }); + fireMerchantMint(args.onMerchantMint, { + mintTxHash: state.mintTxHash, + burnTxHash: burnHash, + }); } if (!merchantMintLanded || !feeMintLanded) { @@ -647,7 +665,10 @@ export async function executeCctpTransfer( onProgress({ kind: 'dest_tx_pending', hash: mintHash }); await waitForReceiptOrThrow(args.destPublicClient, mintHash, 'cctp mint'); // fresh merchant mint 確定 → 会計ログ発火 (下の fee mint より前)。 - args.onMerchantMint?.({ mintTxHash: mintHash, burnTxHash: burnHash }); + fireMerchantMint(args.onMerchantMint, { + mintTxHash: mintHash, + burnTxHash: burnHash, + }); } if (feeIris) { const feeMintData = encodeReceiveMessageCalldata( diff --git a/tests/lib/crossChain/execute.test.ts b/tests/lib/crossChain/execute.test.ts index a2f0b26..75b0c32 100644 --- a/tests/lib/crossChain/execute.test.ts +++ b/tests/lib/crossChain/execute.test.ts @@ -1155,6 +1155,8 @@ describe('lib/crossChain/execute: OpenPay 利用料ブリッジ (案A′)', () = ), ); + const merchantMints: Array<{ mintTxHash: string; burnTxHash?: string }> = []; + const result = await executeCctpTransfer({ walletClient: walletClient as never, sourcePublicClient: sourcePublic as never, @@ -1178,6 +1180,7 @@ describe('lib/crossChain/execute: OpenPay 利用料ブリッジ (案A′)', () = }, fetch: mockFetch as unknown as typeof fetch, pollOptions: { sleep: vi.fn(async () => undefined), now: () => 0 }, + onMerchantMint: (i) => merchantMints.push(i), }); // merchant mint 済なので fee poll のみ (1 回)、fee mint のみ (1 回) @@ -1187,6 +1190,99 @@ describe('lib/crossChain/execute: OpenPay 利用料ブリッジ (案A′)', () = expect(result.feeMintTxHash).toBe('0xmint_f'); // merchant mint を再実行していないので attestation は再取得されない expect(result.attestationMessage).toBeUndefined(); + // resume で merchant mint が landed 済でも、会計ログ callback は (fee mint より前に) + // 確定済 merchant 着金を 1 度発火する (income を取りこぼさない)。 + expect(merchantMints).toEqual([ + { mintTxHash: '0xmint_m_prev', burnTxHash: '0xburn_m_prev' }, + ]); + }); + + it('CCTP: fee mint が失敗しても merchant mint の会計 callback は発火済 (income を取りこぼさない)', async () => { + const walletClient = makeWalletClient({ + signature: '0x', + // approve, burn_m, burn_f, mint_m まで成功、fee mint (mint_f) は hash 切れで throw。 + txHashes: ['0xapprove', '0xburn_m', '0xburn_f', '0xmint_m'], + }); + const sourcePublic = makePublicClient(); + const destPublic = makePublicClient(); + const mockFetch = vi.fn( + async () => + new Response( + JSON.stringify({ + messages: [ + { status: 'complete', message: '0xmsg', attestation: '0xatt' }, + ], + }), + { status: 200 }, + ), + ); + const merchantMints: Array<{ mintTxHash: string; burnTxHash?: string }> = []; + + await expect( + executeCctpTransfer({ + walletClient: walletClient as never, + sourcePublicClient: sourcePublic as never, + destPublicClient: destPublic as never, + switchChainAsync: trackSwitch(), + account: ACCOUNT, + sourceChainId: 84532, + destChainId: 80002, + destDomain: CIRCLE_DOMAIN_POLYGON, + sourceDomain: CIRCLE_DOMAIN_BASE, + sourceToken: SOURCE_TOKEN, + recipient: RECIPIENT, + valueAtomic: 9_900_000n, + feeReceiver: FEE_RECEIVER, + feeAmount: 100_000n, + fetch: mockFetch as unknown as typeof fetch, + pollOptions: { sleep: vi.fn(async () => undefined), now: () => 0 }, + onMerchantMint: (i) => merchantMints.push(i), + }), + ).rejects.toThrow(); + + // fee mint で throw する前に merchant mint の会計 callback は発火済。 + expect(merchantMints).toEqual([ + { mintTxHash: '0xmint_m', burnTxHash: '0xburn_m' }, + ]); + }); + + it('Gateway: onMerchantMint が throw しても決済 flow は中断しない (audit は隔離)', async () => { + const walletClient = makeWalletClient({ + signature: '0xsig', + txHashes: ['0xminthash01'], + }); + const sourcePublic = makePublicClient({ blockNumber: 500n }); + const destPublic = makePublicClient(); + const mockFetch = vi.fn( + async () => + new Response( + JSON.stringify({ attestation: '0xatt', signature: '0xattsig' }), + { status: 200 }, + ), + ); + + const result = await executeGatewayTransfer({ + walletClient: walletClient as never, + sourcePublicClient: sourcePublic as never, + destPublicClient: destPublic as never, + switchChainAsync: trackSwitch(), + account: ACCOUNT, + sourceChainId: 84532, + destChainId: 80002, + sourceDomain: CIRCLE_DOMAIN_BASE, + destDomain: CIRCLE_DOMAIN_POLYGON, + sourceToken: SOURCE_TOKEN, + destToken: DEST_TOKEN, + recipient: RECIPIENT, + valueAtomic: 1_000_000n, + fetch: mockFetch as unknown as typeof fetch, + onMerchantMint: () => { + throw new Error('audit log boom'); + }, + }); + + // callback の例外は fireMerchantMint で握り潰され、merchant mint は確定して result が返る。 + expect(result.mintTxHash).toBe('0xminthash01'); }); it('Gateway resume: attestation 済なら再 sign せず mint だけ実行', async () => { From 13005dd735fb39ab73aa138661cb2220969b338e Mon Sep 17 00:00:00 2001 From: dwebxr Date: Mon, 1 Jun 2026 06:14:06 +0900 Subject: [PATCH 3/3] test(crosschain): add resume-landed + fee-mint-failure ordering guard for onMerchantMint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review の非ブロッキング推奨: resume で merchant mint が landed 済かつ fee mint が 失敗するケースで、merchant callback が fee mint の「前」に発火することを fence する。callback を fee mint の後ろに動かすとこの test が落ちる (ordering の退行検知)。 --- tests/lib/crossChain/execute.test.ts | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/lib/crossChain/execute.test.ts b/tests/lib/crossChain/execute.test.ts index 75b0c32..3c69424 100644 --- a/tests/lib/crossChain/execute.test.ts +++ b/tests/lib/crossChain/execute.test.ts @@ -1285,6 +1285,62 @@ describe('lib/crossChain/execute: OpenPay 利用料ブリッジ (案A′)', () = expect(result.mintTxHash).toBe('0xminthash01'); }); + it('CCTP resume-landed + fee mint 失敗: merchant callback は fee mint より前に発火 (ordering guard)', async () => { + // merchant mint は landed 済 (resume.mintTxHash 在り)、fee mint 未了。fee mint の + // sendTransaction を hash 切れで throw させ、merchant callback が fee mint より「前」に + // 発火していることを fence する (callback を fee mint の後ろに動かすとこの test は落ちる)。 + const walletClient = makeWalletClient({ + signature: '0x', + txHashes: [], // 最初の sendTransaction (= fee mint) で throw + }); + const sourcePublic = makePublicClient(); + const destPublic = makePublicClient(); + const mockFetch = vi.fn( + async () => + new Response( + JSON.stringify({ + messages: [ + { status: 'complete', message: '0xmsg', attestation: '0xatt' }, + ], + }), + { status: 200 }, + ), + ); + const merchantMints: Array<{ mintTxHash: string; burnTxHash?: string }> = []; + + await expect( + executeCctpTransfer({ + walletClient: walletClient as never, + sourcePublicClient: sourcePublic as never, + destPublicClient: destPublic as never, + switchChainAsync: trackSwitch(), + account: ACCOUNT, + sourceChainId: 84532, + destChainId: 80002, + destDomain: CIRCLE_DOMAIN_POLYGON, + sourceDomain: CIRCLE_DOMAIN_BASE, + sourceToken: SOURCE_TOKEN, + recipient: RECIPIENT, + valueAtomic: 9_900_000n, + feeReceiver: FEE_RECEIVER, + feeAmount: 100_000n, + resume: { + approveTxHash: '0xapprove_prev', + burnTxHash: '0xburn_m_prev', + feeBurnTxHash: '0xburn_f_prev', + mintTxHash: '0xmint_m_prev', + }, + fetch: mockFetch as unknown as typeof fetch, + pollOptions: { sleep: vi.fn(async () => undefined), now: () => 0 }, + onMerchantMint: (i) => merchantMints.push(i), + }), + ).rejects.toThrow(); + + expect(merchantMints).toEqual([ + { mintTxHash: '0xmint_m_prev', burnTxHash: '0xburn_m_prev' }, + ]); + }); + it('Gateway resume: attestation 済なら再 sign せず mint だけ実行', async () => { const walletClient = makeWalletClient({ signature: '0xshould_not_be_used',