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
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,13 @@ NEXT_PUBLIC_EXPERIMENTAL_CROSS_CHAIN_ENABLED=false
# 用途 (~2-5 min lag 許容)。
# default 空 = enabled (= phase 2 通常運用)。
NEXT_PUBLIC_CROSS_CHAIN_DISABLED=
# CCTP burn の中断再開で「未 broadcast と結論できた」状態 (burn-intent marker 以降に
# nonce が動かず・mempool 空・DepositForBurn log 無し・十分な時間経過) からの **自動**
# 再 burn を許可する。既定 OFF = 同じ状況を manual に落とし、買い手が Explorer で自分の
# USDC が減っていないことを確認して二段確認した場合のみ再送金する (Phase 1)。
# marker 書込 (fail-closed)・revert 検出・log 走査・adopt は本 flag と無関係に常時 ON。
# 点灯は本番の manual 到達率 (Sentry: cross-chain.burn.unresolved) を観測してから。
NEXT_PUBLIC_CROSS_CHAIN_BURN_AUTORESUME=
# Circle attestation API host 上書き (空なら NETWORK_ENV に応じて
# https://gateway-api.circle.com / https://gateway-api-testnet.circle.com を
# 自動選択)。Circle host 障害時に operator が緊急で切替えるための knob。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ The table below is a **curated subset** (core setup + production feature flags).
| `NEXT_PUBLIC_ENABLE_MAV2` | Alchemy Modular Account v2 route. Off ⇒ `pimlico-simple-7702`. Held off until the Pimlico bundler accepts MAv2 senders. Default **off**. | optional |
| `NEXT_PUBLIC_ENABLE_METAMASK_SMART_ACCOUNT` | MetaMask Smart Account (stateless 7702). Disabled because of a viem ↔ delegation-toolkit signing incompatibility (re-verify on real devices before re-enabling). Default **off**. | optional |
| `NEXT_PUBLIC_EXPERIMENTAL_CROSS_CHAIN_ENABLED` | Mounts the experimental cross-chain demo route (`/[locale]/experimental/cross-chain-demo`). Default **off** ⇒ the route 404s. | optional |
| `NEXT_PUBLIC_CROSS_CHAIN_*` | Cross-chain USDC receive knobs: `…_DISABLED` targeted kill-switch (needs a rebuild — prefer Vercel Instant Rollback for a true instant disable), `…_MAX_FEE_BPS` cap on the Gateway BurnIntent max fee (default 10 bps), `…_BLOCK_OFFSET_DEFAULT` attestation block offset. | optional |
| `NEXT_PUBLIC_CROSS_CHAIN_*` | Cross-chain USDC receive knobs: `…_DISABLED` targeted kill-switch (needs a rebuild — prefer Vercel Instant Rollback for a true instant disable), `…_MAX_FEE_BPS` cap on the Gateway BurnIntent max fee (default 10 bps), `…_BLOCK_OFFSET_DEFAULT` attestation block offset, `…_BURN_AUTORESUME` allows an **automatic** CCTP re-burn when a resumed payment proves on-chain that the previous burn was never broadcast (marker nonce unchanged, empty mempool, no `DepositForBurn` log, enough elapsed time). Default **off** ⇒ the same situation asks the buyer to check the explorer and confirm before re-sending; the burn-intent marker, revert detection and log scan stay on either way. | optional |
| `NEXT_PUBLIC_CIRCLE_*_API_URL` | Overrides for Circle's Gateway (`…_GATEWAY_…`) and CCTP V2 iris (`…_IRIS_…`) attestation hosts. Empty ⇒ chosen automatically from `NEXT_PUBLIC_NETWORK_ENV`. An operator knob for Circle-side outages. | optional |
| `NEXT_PUBLIC_JPYC_*_ADDRESS` / `NEXT_PUBLIC_USDC_*_ADDRESS` | Per-chain token contract overrides (JPYC and USDC, mainnet and testnet). Empty ⇒ the built-in deployment table. Only set these to point at a non-canonical deployment. | optional |
| `NEXT_PUBLIC_JPYC_FORWARDER_*` | Per-chain EIP-3009 forwarder address. **Setting one switches that chain to recover mode** (one customer signature splits amount → merchant and gas-equivalent → fee receiver); empty ⇒ free mode (OpenPay pays gas, the merchant receives the full amount). Mutually exclusive with `NEXT_PUBLIC_ENABLE_USAGE_FEE` on the same chain. | optional (fee) |
Expand Down
203 changes: 203 additions & 0 deletions components/CrossChainBurnUnresolvedPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use client';

// A1: cross-chain (CCTP) の再開時に「前回 burn を broadcast したか」を自動判定できなかった
// ときの説明パネル。CrossChainHint から next/dynamic で遅延読込する (/pay・/tip の First Load
// JS 予算に載せないため — この UI は中断からの再開という稀なケースでしか描画されない)。
//
// 2 種類:
// wait … mempool に自分の tx が居る等、時間を置けば自動で解決する。ボタンは無効。
// manual … 自動判定不能。買い手が explorer で **自分の USDC が減っていないこと** を確認し、
// 二段確認 (チェック + ボタン) を通した場合だけ再送金を許可する。
// 「もう一度払う」ことを勧める UI ではないので、文面で明確に「二重に払わない」
// ことを最優先に伝える。

import { useId, useState, type FormEvent } from 'react';
import { useTranslations } from 'next-intl';
import type { Address, Hex } from 'viem';
import { blockExplorerUrl } from '@/lib/chains';
import type { AdoptBurnTxHashResult } from '@/hooks/useCrossChainPayment';

type AdoptResult = AdoptBurnTxHashResult;

export interface CrossChainBurnUnresolvedPanelProps {
kind: 'wait' | 'manual';
/** D4: 買い手が explorer で見つけた burn tx hash を貼って続きから再開する。
* 検証は on-chain (receipt + DepositForBurn log) で行われ、一致しなければ
* reason が返り、state は変わらない。 */
onAdoptHash?: (hash: string) => Promise<AdoptResult>;
/** wait パネルの「もう一度確認する」。判定をやり直す (送金はしない)。 */
onRetry?: () => void;
/** 送金元 chain (explorer link の解決に使う) */
sourceChainId: number;
/** 買い手のアドレス (burn tx が特定できないときの確認先) */
depositor: Address;
/** 判っている場合の burn tx hash */
burnTxHash?: Hex;
/** 二段確認を通してよい状態か (false = 一致する burn が複数見つかっている等) */
reburnable: boolean;
/** 二段確認済み (再 Pay 待ち) */
armed: boolean;
onArm: () => void;
}

export function CrossChainBurnUnresolvedPanel(
props: CrossChainBurnUnresolvedPanelProps,
) {
const t = useTranslations('CrossChainHint');
const [checked, setChecked] = useState(false);
const explorerBase = blockExplorerUrl(props.sourceChainId);
const explorerHref = explorerBase
? props.burnTxHash
? `${explorerBase}/tx/${props.burnTxHash}`
: `${explorerBase}/address/${props.depositor}`
: undefined;

if (props.kind === 'wait') {
return (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-900">{t('burnWaitTitle')}</p>
<p className="text-xs text-amber-800">{t('burnWaitBody')}</p>
{/* wait の間は親の Pay ボタンを無効にしているので、再確認の導線はここに置く。
置かないと「数分おいてからもう一度」と書いてあるのに押す先が無い (D2)。 */}
{props.onRetry && (
<button
type="button"
onClick={props.onRetry}
className="w-full rounded-lg border border-amber-400 bg-white px-3 py-2 text-xs font-semibold text-amber-900"
>
{t('burnWaitRetry')}
</button>
)}
</div>
);
}

return (
<div className="space-y-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-900">{t('burnManualTitle')}</p>
<p className="text-xs text-amber-800">{t('burnManualBody')}</p>
{explorerHref && (
<a
href={explorerHref}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-xs text-amber-900 underline"
>
{t('burnManualCheckExplorer')}
</a>
)}
{props.reburnable ? (
props.armed ? (
<p className="text-xs font-semibold text-amber-900">
{t('burnManualArmedHint')}
</p>
) : (
<>
<label className="flex items-start gap-2 text-xs text-amber-900">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="mt-0.5 h-4 w-4"
/>
<span>{t('burnManualConfirmLabel')}</span>
</label>
<button
type="button"
disabled={!checked}
onClick={props.onArm}
className="w-full rounded-lg border border-amber-400 bg-white px-3 py-2 text-xs font-semibold text-amber-900 disabled:opacity-50"
>
{t('burnManualReburn')}
</button>
</>
)
) : (
<p className="text-xs text-amber-900">{t('burnManualBlocked')}</p>
)}
{props.onAdoptHash && <AdoptHashForm onAdoptHash={props.onAdoptHash} />}
</div>
);
}

/** 「USDC は減っている (= burn は着弾した) が hash が判らない」買い手の自己救済入力。
* 再送金 (二段確認) とは逆向きの出口 — こちらは **送らずに** 続きから進める。 */
function AdoptHashForm({
onAdoptHash,
}: {
onAdoptHash: (hash: string) => Promise<AdoptResult>;
}) {
const t = useTranslations('CrossChainHint');
const inputId = useId();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AdoptErrorKey | null>(null);

async function submit(e: FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const res = await onAdoptHash(value);
if (!res.ok) setError(adoptErrorKey(res.reason));
} finally {
setBusy(false);
}
}

return (
<form onSubmit={submit} className="space-y-1 border-t border-amber-200 pt-2">
<label
htmlFor={inputId}
className="block text-xs font-semibold text-amber-900"
>
{t('burnAdoptLabel')}
</label>
<p className="text-xs text-amber-800">{t('burnAdoptHint')}</p>
<input
id={inputId}
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0x…"
className="w-full rounded-lg border border-amber-300 bg-white px-2 py-1.5 font-mono text-xs text-slate-800"
/>
<button
type="submit"
disabled={busy || value.trim() === ''}
className="w-full rounded-lg border border-amber-400 bg-white px-3 py-2 text-xs font-semibold text-amber-900 disabled:opacity-50"
>
{busy ? t('burnAdoptChecking') : t('burnAdoptSubmit')}
</button>
{error && <p className="text-xs text-red-700">{t(error)}</p>}
</form>
);
}

type AdoptErrorKey =
| 'burnAdoptErrorFormat'
| 'burnAdoptErrorNotFound'
| 'burnAdoptErrorReverted'
| 'burnAdoptErrorMismatch'
| 'burnAdoptErrorUnavailable';

function adoptErrorKey(
reason: Extract<AdoptResult, { ok: false }>['reason'],
): AdoptErrorKey {
switch (reason) {
case 'format':
return 'burnAdoptErrorFormat';
case 'notfound':
return 'burnAdoptErrorNotFound';
case 'reverted':
return 'burnAdoptErrorReverted';
case 'mismatch':
return 'burnAdoptErrorMismatch';
case 'unavailable':
return 'burnAdoptErrorUnavailable';
}
}
65 changes: 62 additions & 3 deletions components/CrossChainHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,30 @@
// に委譲するため execute は no-op (= 既存 Pay button が処理する)。

import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { formatUnits, type Address } from 'viem';
import { useTranslations } from 'next-intl';
import { useCrossChainPayment } from '@/hooks/useCrossChainPayment';
import type { ExecuteResult } from '@/hooks/useCrossChainPayment';
import type { CrossChainProgress } from '@/lib/crossChain/execute';
import type { PathOption } from '@/lib/crossChain/pathEnumerator';
import { CROSS_CHAIN_DISABLED } from '@/lib/crossChain/config';
import { ResumeStoreWriteError } from '@/lib/crossChain/resumeStore';
import { blockExplorerUrl } from '@/lib/chains';
import { CrossChainSourceChooser } from './CrossChainSourceChooser';
import { shortAddress } from '@/lib/format';
import { logger } from '@/lib/logger';

// 中断再開でしか描画されない説明パネルなので、/pay・/tip の First Load JS には載せない
// (予算は既に上限張り付き — 掟: 増えたら予算を上げる前にまず code-split)。
const CrossChainBurnUnresolvedPanel = dynamic(
() =>
import('./CrossChainBurnUnresolvedPanel').then(
(m) => m.CrossChainBurnUnresolvedPanel,
),
{ ssr: false },
);

export interface CrossChainHintProps {
/** PaymentForm の token (token !== 'usdc' なら hint を出さない) */
token: 'usdc' | 'jpyc';
Expand Down Expand Up @@ -119,13 +131,13 @@
props.onSuccess?.(result);
}
}
}, [result, props.recipient, props.requiredAtomic, props.onSuccess]);

Check warning on line 134 in components/CrossChainHint.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'props'. Either include it or remove the dependency array. However, 'props' will change when *any* prop changes, so the preferred fix is to destructure the 'props' object outside of the useEffect call and refer to those specific props inside useEffect

useEffect(() => {
// 成功は onSuccess 側の settled lock に引き継ぐ。失敗時は不可逆境界前だけ false に
// 戻し、burn / attestation 後は親の通常 Pay を同一 mount 中ずっと封鎖する。
props.onExecutingChange?.(result ? false : isExecuting || isCommitted);
}, [isCommitted, isExecuting, result, props.onExecutingChange]);

Check warning on line 140 in components/CrossChainHint.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'props'. Either include it or remove the dependency array. However, 'props' will change when *any* prop changes, so the preferred fix is to destructure the 'props' object outside of the useEffect call and refer to those specific props inside useEffect

useEffect(() => {
if (error) {
Expand Down Expand Up @@ -168,6 +180,11 @@
destChainId={result.destChainId}
mintTxHash={result.mintTxHash}
explorerBase={explorer}
// D3: 利用料 (付帯) だけが未確定でも決済は成立している。買い手には「追加の支払いは
// 不要」を二次通知として伝える (本送金の成功表示を濁さない)。
feeUnresolved={
result.path === 'cctp-v2' && result.feeBurnUnresolved !== undefined
}
/>
);
}
Expand Down Expand Up @@ -216,16 +233,29 @@
}
}

// D4: 買い手が貼った burn tx hash が on-chain 検証を通ったら、そのまま続き
// (Iris poll → mint) を走らせる。検証に落ちた場合は panel が inline error を出すだけで
// 実行しない (state も変えない)。
async function onAdoptHash(hash: string) {
const res = await hook.adoptBurnTxHash(hash);
if (res.ok) void onPay();
return res;
}

const isDirectSelected = selectedOption?.kind === 'direct';
// 中断再開: 選択中 option に保存済みの途中 state があれば、再 Pay で続きから
// 再開できる (送金済みは再送しない)。UI で明示して二重支払いの不安を消す。
const resumable =
!isDirectSelected &&
selectedOption !== null &&
hook.isOptionResumable(selectedOption);
const burnUnresolved = hook.burnUnresolved;
const payButtonDisabled =
isExecuting ||
!!props.executionDisabled ||
// 前回 burn が mempool に居る可能性がある間は、再 Pay 自体を押させない (押しても
// 同じ wait に落ちるだけで、買い手には「二重に払うのでは」という不安だけが残る)。
burnUnresolved?.kind === 'wait' ||
// 同一 mount で committed を観測したのに resume 保存が無い場合、再 execute は
// 二重 burn/debit になり得る。D4b は行わず、この mount の子ボタンだけ fail-closed。
(isCommitted && !resumable) ||
Expand All @@ -247,11 +277,24 @@
{t('directSelectedHint')}
</p>
)}
{resumable && !isExecuting && (
{resumable && !isExecuting && !burnUnresolved && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
{t('resumeHint')}
</p>
)}
{burnUnresolved && (
<CrossChainBurnUnresolvedPanel
kind={burnUnresolved.kind}
sourceChainId={burnUnresolved.sourceChainId}
depositor={burnUnresolved.depositor}
burnTxHash={burnUnresolved.burnTxHash}
reburnable={burnUnresolved.reburnable}
armed={hook.isManualReburnArmed}
onArm={hook.armManualReburn}
onAdoptHash={onAdoptHash}
onRetry={() => void onPay()}
/>
)}
{!isDirectSelected && (
<button
type="button"
Expand All @@ -266,9 +309,14 @@
: t('payWithSelected')}
</button>
)}
{error && (
{error && !burnUnresolved && (
<p className="text-xs text-red-700">
{t('errorPrefix')}: {error.message}
{t('errorPrefix')}:{' '}
{error instanceof ResumeStoreWriteError
? // marker を書けない = 二重 burn を防げないので送金しなかった、という
// 「安全側に倒した」旨を専用文言で伝える (生の例外文は買い手に無意味)。
t('errorStorageBlocked')
: error.message}
</p>
)}
</div>
Expand Down Expand Up @@ -302,6 +350,12 @@
return t('progressFeeSourceTxPending');
case 'fee_dest_tx_pending':
return t('progressFeeDestTxPending');
case 'burn_probe':
return t('progressBurnProbe');
case 'burn_unconfirmed':
return t('progressBurnUnconfirmed');
case 'fee_burn_unconfirmed':
return t('progressFeeBurnUnconfirmed');
}
}

Expand All @@ -313,6 +367,7 @@
destChainId,
mintTxHash,
explorerBase,
feeUnresolved,
}: {
bridge: 'gateway' | 'cctp-v2';
recipient: Address;
Expand All @@ -321,6 +376,7 @@
destChainId: number;
mintTxHash: `0x${string}`;
explorerBase: string | undefined;
feeUnresolved?: boolean;
}) {
const t = useTranslations('CrossChainHint');
return (
Expand All @@ -337,6 +393,9 @@
chainId: destChainId,
})}
</p>
{feeUnresolved && (
<p className="text-xs text-emerald-800">{t('feeUnresolvedNotice')}</p>
)}
{explorerBase && (
<a
href={`${explorerBase}/tx/${mintTxHash}`}
Expand Down
Loading
Loading