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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/api/log/payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
Expand Down
70 changes: 65 additions & 5 deletions app/api/log/payment/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,6 +124,8 @@ type BridgeAgg = {
totalFeeWei: bigint;
totalSaleWei: bigint;
totalNetworkFeeWei: bigint;
// cross-chain の bridge fee **上限** 合計 (ceiling・実 charge ではない・unreconciled)。
totalBridgeFeeMaxWei: bigint;
};

type ChainAgg = {
Expand All @@ -135,6 +141,8 @@ type ChainAgg = {
totalSaleWei: bigint;
totalNetworkFeeWei: bigint;
unknownBreakdownCount: number;
// cross-chain bridge fee 上限合計 (ceiling・unreconciled)。
totalBridgeFeeMaxWei: bigint;
byToken: Map<string, TokenAgg>;
byBridge: Map<PaymentBridgeKey, BridgeAgg>;
};
Expand Down Expand Up @@ -181,6 +189,7 @@ function emptyBridgeAgg(bridge: PaymentBridgeKey): BridgeAgg {
totalFeeWei: 0n,
totalSaleWei: 0n,
totalNetworkFeeWei: 0n,
totalBridgeFeeMaxWei: 0n,
};
}

Expand All @@ -205,6 +214,7 @@ function aggregate(entries: LogEntry[]): {
chains: ChainAgg[];
aggregatedCount: number;
invalidEntries: number;
crossChainDeduped: number;
byBridge: BridgeAgg[];
byProvider: ProviderAgg[];
} {
Expand All @@ -213,19 +223,45 @@ function aggregate(entries: LogEntry[]): {
const globalByProvider = new Map<PaymentProviderKey, ProviderAgg>();
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<string>();

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();
const result = e.result;
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。
Expand Down Expand Up @@ -264,6 +300,7 @@ function aggregate(entries: LogEntry[]): {
totalSaleWei: 0n,
totalNetworkFeeWei: 0n,
unknownBreakdownCount: 0,
totalBridgeFeeMaxWei: 0n,
byToken: new Map(),
byBridge: new Map(),
};
Expand Down Expand Up @@ -336,22 +373,28 @@ 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;
chainBridge.totalMerchantWei += merchantWei;
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++;
Expand Down Expand Up @@ -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[]) {
Expand All @@ -404,6 +454,7 @@ function serializeBridges(bridges: BridgeAgg[]) {
totalFeeWei: b.totalFeeWei.toString(),
totalSaleWei: b.totalSaleWei.toString(),
totalNetworkFeeWei: b.totalNetworkFeeWei.toString(),
totalBridgeFeeMaxWei: b.totalBridgeFeeMaxWei.toString(),
}));
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -551,8 +603,14 @@ export async function GET(req: Request): Promise<NextResponse> {
return true;
});

const { chains, aggregatedCount, invalidEntries, byBridge, byProvider } =
aggregate(filtered);
const {
chains,
aggregatedCount,
invalidEntries,
crossChainDeduped,
byBridge,
byProvider,
} = aggregate(filtered);

return NextResponse.json({
ok: true,
Expand All @@ -577,6 +635,8 @@ export async function GET(req: Request): Promise<NextResponse> {
filteredCount: filtered.length,
aggregatedCount,
invalidEntries,
// cross-chain success の (bridge+chainId+txHash) 重複 (resume 由来) を除外した件数。
crossChainDeduped,
filter: {
chainId: parsedChainIdFilter ?? null,
since: sinceFilter,
Expand Down
48 changes: 6 additions & 42 deletions components/CrossChainHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 に反映される (ここでは何もしない)。
}
}

Expand Down
39 changes: 39 additions & 0 deletions hooks/useCrossChainPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -210,6 +247,7 @@ export function useCrossChainPayment(
resume: loadResumeState<GatewayResumeState>(sessionKey),
onStep,
onProgress: reportProgress,
onMerchantMint,
};
const result = await executeGatewayTransfer(gatewayArgs);
clearResumeState(sessionKey);
Expand All @@ -234,6 +272,7 @@ export function useCrossChainPayment(
resume: loadResumeState<CctpResumeState>(sessionKey),
onStep,
onProgress: reportProgress,
onMerchantMint,
};
const result = await executeCctpTransfer(cctpArgs);
clearResumeState(sessionKey);
Expand Down
7 changes: 7 additions & 0 deletions lib/crossChain/cctp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading