diff --git a/components/CheckoutForm.tsx b/components/CheckoutForm.tsx index a2b22c3..7ecea2c 100644 --- a/components/CheckoutForm.tsx +++ b/components/CheckoutForm.tsx @@ -134,6 +134,9 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { isConnected && !wrongChain && (isStandard || !!saData) && + // PaymentForm と揃える明示ガード。現状は totalWei>0 (有効 items) 不変で merchantUnderflow + // が拾うが、空 batch (merchant 受取 0) 送信を構造的にも塞ぐ defense-in-depth。 + breakdown.merchantReceives > 0n && breakdown.customerPays > 0n && !insufficientBalance && !flowPending && @@ -142,6 +145,13 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { const flowError = isStandard ? standard.error : gasless.error; const saFallback = !isStandard && isIncompatibleSmartAccountError(saError); + // 送信は成立したがチェーン上で revert したケース (gasless: data.success===false / + // standard: phase=*-error だが receipt 成功で Error 無し)。無反応穴を明示メッセージで塞ぐ。 + const revertedNoFeedback = + (!isStandard && !!gasless.data && !gasless.data.success) || + (isStandard && + (standard.isMerchantError || standard.isFeeError) && + !standard.error); const error = isGasCongestedError(flowError) ? t('errorGasCongested') : (flowError?.message ?? @@ -149,7 +159,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) { (activeQuote.error ? t('errorGasQuote') : null) ?? (merchantUnderflow ? t('errorMerchantUnderflow', { min: fmt(minimumAmountWei) }) - : null)); + : null) ?? + (revertedNoFeedback ? t('errorReverted') : null)); const [redirectIn, setRedirectIn] = useState(null); // PayPay 風 大型成功 overlay。dismiss 後は inline 成功 panel + redirect countdown を表示。 diff --git a/components/PaymentForm.tsx b/components/PaymentForm.tsx index 85346f2..6e8067d 100644 --- a/components/PaymentForm.tsx +++ b/components/PaymentForm.tsx @@ -30,7 +30,12 @@ import { logger } from '@/lib/logger'; import { usePaymentHistory } from '@/hooks/usePaymentHistory'; import { resolvePaymasterMode } from '@/lib/pimlico'; import { DEFAULT_CHAIN_FOR_SYMBOL, deploymentForSlug } from '@/lib/tokens'; -import { DECIMAL_PATTERN, parsePayParams, type PayParams } from '@/lib/url'; +import { + DECIMAL_PATTERN, + exceedsTokenPrecision, + parsePayParams, + type PayParams, +} from '@/lib/url'; import { formatTokenAmount, shortAddress } from '@/lib/format'; export function PaymentForm() { @@ -98,8 +103,15 @@ function PaymentDetails({ params }: { params: PayParams }) { const amountWei = useMemo(() => { if (!amountStr || !DECIMAL_PATTERN.test(amountStr)) return 0n; + // 精度超過は parseUnits が黙って丸めて表示額と実送金額が乖離するため弾く (0n=送信 block)。 + if (exceedsTokenPrecision(amountStr, deployment.decimals)) return 0n; return parseUnits(amountStr, deployment.decimals); }, [amountStr, deployment.decimals]); + // 精度超過を UI で明示するためのフラグ (amountWei は 0n になるが理由を伝える)。 + const amountPrecisionError = + !!amountStr && + DECIMAL_PATTERN.test(amountStr) && + exceedsTokenPrecision(amountStr, deployment.decimals); const fmt = (wei: bigint) => formatTokenAmount(wei, deployment); @@ -207,14 +219,26 @@ function PaymentDetails({ params }: { params: PayParams }) { // 漏れるため、i18n 化した friendly メッセージに置き換える (詳細は logger 経由で Sentry へ)。 const flowError = isStandard ? standard.error : gasless.error; const saFallback = !isStandard && isIncompatibleSmartAccountError(saError); + // 送信は成立したがチェーン上で revert したケース。gasless は data.success===false、standard は + // phase=*-error だが receipt 自体は成功 (status=reverted) なので Error オブジェクトが無い。 + // どちらも success panel も error も出ず「無反応」に見える穴を、明示メッセージで塞ぐ。 + const revertedNoFeedback = + (!isStandard && !!gasless.data && !gasless.data.success) || + (isStandard && + (standard.isMerchantError || standard.isFeeError) && + !standard.error); const error = isGasCongestedError(flowError) ? t('errorGasCongested') : (flowError?.message ?? (isStandard || saFallback ? undefined : saError?.message) ?? (activeQuote.error ? t('errorGasQuote') : null) ?? + (amountPrecisionError + ? t('errorAmountPrecision', { decimals: deployment.decimals }) + : null) ?? (merchantUnderflow ? t('errorMerchantUnderflow', { min: fmt(minimumAmountWei) }) - : null)); + : null) ?? + (revertedNoFeedback ? t('errorReverted') : null)); useEffect(() => { if (gasless.error) logger.error('payment.failed', { error: gasless.error }); diff --git a/components/TipForm.tsx b/components/TipForm.tsx index b4d523e..ae277ab 100644 --- a/components/TipForm.tsx +++ b/components/TipForm.tsx @@ -24,7 +24,12 @@ import { isIncompatibleSmartAccountError } from '@/lib/accountDetection'; import { logger } from '@/lib/logger'; import { resolvePaymasterMode } from '@/lib/pimlico'; import { DEFAULT_CHAIN_FOR_SYMBOL, deploymentForSlug } from '@/lib/tokens'; -import { DECIMAL_PATTERN, DEFAULT_TIP_PRESETS, type TipParams } from '@/lib/url'; +import { + DECIMAL_PATTERN, + DEFAULT_TIP_PRESETS, + exceedsTokenPrecision, + type TipParams, +} from '@/lib/url'; import { formatTokenAmount } from '@/lib/format'; const DEFAULT_THEME_COLOR = '#2563eb'; @@ -72,8 +77,14 @@ export function TipForm({ params }: { params: TipParams }) { const amountStr = customSelected ? customAmount : (selectedPreset ?? ''); const amountWei = useMemo(() => { if (!amountStr || !DECIMAL_PATTERN.test(amountStr)) return 0n; + // 精度超過は parseUnits が黙って丸めて表示額と実送金額が乖離するため弾く。 + if (exceedsTokenPrecision(amountStr, deployment.decimals)) return 0n; return parseUnits(amountStr, deployment.decimals); }, [amountStr, deployment.decimals]); + const amountPrecisionError = + !!amountStr && + DECIMAL_PATTERN.test(amountStr) && + exceedsTokenPrecision(amountStr, deployment.decimals); // Tip widget は gasless / gas=customer 固定 (preset セマンティクス維持): // creator は preset - fee を受け取り、ファンは preset + gas を支払う。 @@ -121,11 +132,18 @@ export function TipForm({ params }: { params: TipParams }) { // (デバッグ向け詳細) ではなく i18n された案内文に差し替える。 // gasQuote の失敗も同様に i18n 化 (詳細は logger 経由で Sentry へ)。 const saFallback = isIncompatibleSmartAccountError(saError); + // 送信は成立したがチェーン上で revert したケース (gasless: data.success===false)。success + // overlay も error も出ず無反応に見える穴を明示メッセージで塞ぐ。 + const revertedNoFeedback = !!gasless.data && !gasless.data.success; const error = isGasCongestedError(gasless.error) ? t('errorGasCongested') : (gasless.error?.message ?? (saFallback ? undefined : saError?.message) ?? - (gasQuote.error ? t('errorGasQuote') : null)); + (gasQuote.error ? t('errorGasQuote') : null) ?? + (amountPrecisionError + ? t('errorAmountPrecision', { decimals: deployment.decimals }) + : null) ?? + (revertedNoFeedback ? t('errorReverted') : null)); useEffect(() => { if (gasless.error) logger.error('tip.failed', { error: gasless.error }); @@ -142,16 +160,32 @@ export function TipForm({ params }: { params: TipParams }) { // userOpHash ごとに 1 回限りの webhook 発火。gasQuote の refetchInterval (30s) // で breakdown が再計算 → effect 再実行 → 二重発火を防ぐ gate。 const notifiedUserOpHashRef = useRef(null); + // 送信時点の確定スナップショット。webhook が live state (amountStr / breakdown) を読むと、 + // 送信後にユーザが額を変えたり gasQuote が refetch されたとき、実際に送ったチップと異なる + // 値を creator へ通知してしまう。onSubmit でここに固定し、webhook はこちらを参照する。 + const submittedRef = useRef<{ + amount: string; + merchantAmount: string; + feeAmount: string; + customerPays: string; + } | null>(null); useEffect(() => { if (!gasless.data || !gasless.data.success) return; if (notifiedUserOpHashRef.current === gasless.data.userOpHash) return; notifiedUserOpHashRef.current = gasless.data.userOpHash; + // 送信時スナップショット優先 (live state drift を排除)。万一未設定なら live に fallback。 + const sent = submittedRef.current ?? { + amount: amountStr, + merchantAmount: breakdown.merchantReceives.toString(), + feeAmount: breakdown.feeAmount.toString(), + customerPays: breakdown.customerPays.toString(), + }; logger.info('tip.success', { userOpHash: gasless.data.userOpHash, txHash: gasless.data.txHash, creator: params.to, - amount: amountStr, + amount: sent.amount, token: params.token, }); // webhook 失敗 (CORS / non-2xx) は logger.warn のみ。tip は成立しているため UI には出さない。 @@ -163,10 +197,10 @@ export function TipForm({ params }: { params: TipParams }) { from: address, token: params.token, chain: chainSlug, - amount: amountStr, - merchantAmount: breakdown.merchantReceives.toString(), - feeAmount: breakdown.feeAmount.toString(), - customerPays: breakdown.customerPays.toString(), + amount: sent.amount, + merchantAmount: sent.merchantAmount, + feeAmount: sent.feeAmount, + customerPays: sent.customerPays, message: params.message, txHash: gasless.data.txHash, userOpHash: gasless.data.userOpHash, @@ -223,6 +257,14 @@ export function TipForm({ params }: { params: TipParams }) { function onSubmit() { if (!canSubmit) return; + // 送信時点の値を固定 (webhook はこのスナップショットを使い、後続の編集 / gasQuote + // refetch による drift を排除する)。 + submittedRef.current = { + amount: amountStr, + merchantAmount: breakdown.merchantReceives.toString(), + feeAmount: breakdown.feeAmount.toString(), + customerPays: breakdown.customerPays.toString(), + }; gasless.mutate({ tokenAddress: deployment.address, merchant: params.to, @@ -279,7 +321,8 @@ export function TipForm({ params }: { params: TipParams }) { key={p} type="button" onClick={() => selectPreset(p)} - className={`rounded-xl border px-2 py-3 text-center text-sm font-semibold transition ${ + disabled={gasless.isPending} + className={`rounded-xl border px-2 py-3 text-center text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${ active ? 'border-transparent text-white shadow-sm' : 'border-slate-200 bg-white text-slate-700 hover:border-slate-300' @@ -300,6 +343,7 @@ export function TipForm({ params }: { params: TipParams }) { type="text" inputMode="decimal" value={customAmount} + disabled={gasless.isPending} onFocus={selectCustom} onChange={(e) => { setSelectedPreset(null); @@ -310,7 +354,7 @@ export function TipForm({ params }: { params: TipParams }) { ? t('amountCustomPlaceholderJpyc') : t('amountCustomPlaceholderUsdc') } - className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-lg font-semibold focus:outline-none" + className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-lg font-semibold focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" style={{ borderColor: customSelected ? themeColor : undefined }} /> diff --git a/lib/url.ts b/lib/url.ts index 9181f56..6d1fd0c 100644 --- a/lib/url.ts +++ b/lib/url.ts @@ -432,6 +432,19 @@ const TIP_THANKS_MAX = 200; export const TIP_PRESET_MAX = 6; export const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; export const DECIMAL_PATTERN = /^\d+(\.\d+)?$/; + +// amountStr の小数桁が token の decimals を超えると viem の parseUnits は **黙って丸める** +// (例: USDC=6dp で "0.0000009" → 0.000001)。結果、画面表示額と実送金額が乖離する。これを +// 防ぐため精度超過を検出し、呼出側は invalid 扱い (送信 block + 案内) にする。DECIMAL_PATTERN +// 通過を前提 (小数点が高々 1 個)。 +export function exceedsTokenPrecision( + amountStr: string, + decimals: number, +): boolean { + const dot = amountStr.indexOf('.'); + if (dot === -1) return false; + return amountStr.length - dot - 1 > decimals; +} // http/https のみ許可。URL.canParse を使うので try/catch 不要。 // localhost / 127.0.0.1 は webhook テスト用途で許可するが、本番では // クリエイターが制御していない URL を貼ると意図しない POST 先になり得る点に注意。 diff --git a/messages/en.json b/messages/en.json index a3873b3..4aac988 100644 --- a/messages/en.json +++ b/messages/en.json @@ -322,6 +322,8 @@ "successBlock": "Block", "urlInvalidTitle": "Invalid payment URL", "loading": "Loading payment details…", + "errorAmountPrecision": "Amount can have at most {decimals} decimal places. Trim the digits so the displayed amount matches what is actually sent.", + "errorReverted": "The transaction reverted on-chain (no funds were transferred). Check your balance and network, then try again.", "emptyLandingTitle": "This is the customer payment page", "emptyLandingBody": "Scan a merchant's OpenPay QR or open a payment URL shared by a merchant to make a payment here. There is nothing to pay on this page alone.", "emptyLandingToHome": "Open OpenPay (merchant)", @@ -368,6 +370,8 @@ "errorGasQuote": "Failed to fetch the gas estimate. Check your network connection and try again.", "errorIncompatibleSmartAccount": "Your wallet is delegated to a Smart Account implementation that OpenPay does not support yet (delegate: {address}). If you are using MetaMask, toggle off \"Smart account\" in Account details to revert to a plain EOA. Or try a different wallet (Rabby / Frame, etc.).", "errorMav2Disabled": "Tip sending from wallets backed by Alchemy Modular Account v2 (such as HashPort) is being rolled out gradually. Please try again later.", + "errorAmountPrecision": "Amount can have at most {decimals} decimal places. Trim the digits so the displayed amount matches what is actually sent.", + "errorReverted": "The tip reverted on-chain (no funds were transferred). Check your balance and network, then try again.", "errorMav2KaiaPolygon": "Sending tips on Kaia is not supported for wallets delegated to Alchemy Modular Account v2. Please use a Polygon / Base / Arbitrum / Optimism Tip link, or try a different wallet.", "errorMetaMaskKaia": "MetaMask Smart Account is not supported on Kaia yet. Toggle off \"Smart account\" in MetaMask Account details, or try a different wallet.", "errorPristineNoBootstrap": "This wallet isn't set up for gasless payments yet, and tips require gasless mode. Please try a wallet that's already set up for gasless.", @@ -485,6 +489,7 @@ "errorIncompatibleSmartAccount": "Your wallet is delegated to a Smart Account implementation that OpenPay does not support yet (delegate: {address}). If you are using MetaMask, toggle off \"Smart account\" in Account details to revert to a plain EOA. Or try a different wallet (Rabby / Frame, etc.).", "errorMav2Disabled": "Payments from wallets backed by Alchemy Modular Account v2 (such as HashPort) are being rolled out gradually. Please try again later.", "errorMav2KaiaPolygon": "Checkout on Kaia is not supported for wallets delegated to Alchemy Modular Account v2. Please use a Polygon / Base / Arbitrum / Optimism checkout URL, or try a different wallet.", + "errorReverted": "The transaction reverted on-chain (no funds were transferred). Check your balance and network, then try again.", "errorMetaMaskKaia": "MetaMask Smart Account is not supported on Kaia yet. Toggle off \"Smart account\" in MetaMask Account details, or try a different wallet.", "errorPristineNoBootstrap": "This wallet isn't set up for gasless payments yet. You can pay now in standard mode (you pay network gas).", "errorChainNo7702": "Gasless USDC payments aren't available on this chain yet (its EIP-7702 implementation differs from the standard). You can pay now in standard mode (you pay network gas).", diff --git a/messages/ja.json b/messages/ja.json index 511ee09..9c2bec2 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -322,6 +322,8 @@ "successBlock": "ブロック", "urlInvalidTitle": "決済 URL が不正です", "loading": "決済情報を読み込み中…", + "errorAmountPrecision": "金額の小数点以下は最大 {decimals} 桁までです。表示額と実際の送金額がずれないよう、桁を減らしてください。", + "errorReverted": "取引はチェーン上で revert しました (送金は成立していません)。残高やネットワーク状況を確認のうえ、再度お試しください。", "emptyLandingTitle": "ここは「顧客向け」決済ページです", "emptyLandingBody": "店舗の OpenPay QR を読み取るか、店舗から共有された決済 URL を開くとここで決済できます。決済情報がないため、このページ単体ではご利用いただけません。", "emptyLandingToHome": "OpenPay (店舗向け) を開く", @@ -368,6 +370,8 @@ "errorGasQuote": "ガス代見積の取得に失敗しました。ネットワーク接続を確認してから再度お試しください。", "errorIncompatibleSmartAccount": "お使いのウォレットは OpenPay 未対応の Smart Account 実装に委任されています (delegate: {address})。MetaMask の場合は「アカウント詳細 → Smart account」をオフにすると元の EOA に戻せます。または別のウォレット (Rabby / Frame 等) でお試しください。", "errorMav2Disabled": "現在 HashPort 等の一部ウォレット (Alchemy Modular Account v2) でのチップ送信は段階公開中です。少々お待ちください。", + "errorAmountPrecision": "金額の小数点以下は最大 {decimals} 桁までです。表示額と実際の送金額がずれないよう、桁を減らしてください。", + "errorReverted": "チップ送信はチェーン上で revert しました (送金は成立していません)。残高やネットワーク状況を確認のうえ、再度お試しください。", "errorMav2KaiaPolygon": "Kaia チェーン上では Alchemy Modular Account v2 に委任されたウォレットからチップを送れません。お手数ですが Polygon / Base / Arbitrum / Optimism 等の他チェーン版 Tip リンクをご利用いただくか、別のウォレットでお試しください。", "errorMetaMaskKaia": "Kaia チェーン上では MetaMask Smart Account には現在対応していません。MetaMask の「アカウント詳細 → Smart account」をオフにするか、別のウォレットでお試しください。", "errorPristineNoBootstrap": "このウォレットはまだガスレス決済の準備ができていません。チップにはガスレスモードが必要です。ガスレス対応済みのウォレットでお試しください。", @@ -485,6 +489,7 @@ "errorIncompatibleSmartAccount": "お使いのウォレットは OpenPay 未対応の Smart Account 実装に委任されています (delegate: {address})。MetaMask の場合は「アカウント詳細 → Smart account」をオフにすると元の EOA に戻せます。または別のウォレット (Rabby / Frame 等) でお試しください。", "errorMav2Disabled": "現在 HashPort 等の一部ウォレット (Alchemy Modular Account v2) での決済は段階公開中です。少々お待ちください。", "errorMav2KaiaPolygon": "Kaia チェーン上では Alchemy Modular Account v2 に委任されたウォレットでチェックアウトできません。お手数ですが Polygon / Base / Arbitrum / Optimism 等の他チェーン版のチェックアウト URL をご利用いただくか、別のウォレットでお試しください。", + "errorReverted": "取引はチェーン上で revert しました (送金は成立していません)。残高やネットワーク状況を確認のうえ、再度お試しください。", "errorMetaMaskKaia": "Kaia チェーン上では MetaMask Smart Account には現在対応していません。MetaMask の「アカウント詳細 → Smart account」をオフにするか、別のウォレットでお試しください。", "errorPristineNoBootstrap": "このウォレットはまだガスレス決済の準備ができていません。通常決済 (ガス代自己負担) で今すぐ支払えます。", "errorChainNo7702": "このチェーンでは現在 USDC ガスレス決済に対応していません (このチェーンの EIP-7702 実装が標準と異なるため)。通常決済 (ガス代自己負担) で今すぐ支払えます。", diff --git a/tests/lib/url.test.ts b/tests/lib/url.test.ts index 03066a7..5299249 100644 --- a/tests/lib/url.test.ts +++ b/tests/lib/url.test.ts @@ -10,6 +10,7 @@ import { parseSplitDrafts, DEFAULT_TIP_PRESETS, searchParamsFromNext, + exceedsTokenPrecision, } from '@/lib/url'; // USDC (Base) のアドレスは checksum 既知のため、テストの roundtrip が安定する。 @@ -1697,3 +1698,29 @@ describe('PayParams: crossChain (cross-chain receive)', () => { if (r.ok) expect(r.params.crossChain).toBe(false); }); }); + +describe('exceedsTokenPrecision', () => { + it('小数なしは常に false', () => { + expect(exceedsTokenPrecision('1000', 18)).toBe(false); + expect(exceedsTokenPrecision('0', 6)).toBe(false); + expect(exceedsTokenPrecision('100', 0)).toBe(false); + }); + + it('小数桁が decimals 以内なら false', () => { + expect(exceedsTokenPrecision('1.5', 18)).toBe(false); + expect(exceedsTokenPrecision('0.000001', 6)).toBe(false); // 6 桁 = USDC 上限 + expect(exceedsTokenPrecision('1.123456', 6)).toBe(false); + }); + + it('小数桁が decimals を超えると true (parseUnits の黙る丸めを弾く)', () => { + // USDC 6dp: "0.0000009" は parseUnits で 0.000001 に丸まる → 表示額と乖離 + expect(exceedsTokenPrecision('0.0000009', 6)).toBe(true); + expect(exceedsTokenPrecision('1.1234567', 6)).toBe(true); // 7 > 6 + expect(exceedsTokenPrecision('1.0', 0)).toBe(true); // 整数 token に小数 + }); + + it('JPYC 18dp の上限ちょうど / 超過', () => { + expect(exceedsTokenPrecision(`0.${'1'.repeat(18)}`, 18)).toBe(false); + expect(exceedsTokenPrecision(`0.${'1'.repeat(19)}`, 18)).toBe(true); + }); +});