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: 12 additions & 1 deletion components/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -142,14 +145,22 @@ 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 ??
(isStandard || saFallback ? undefined : saError?.message) ??
(activeQuote.error ? t('errorGasQuote') : null) ??
(merchantUnderflow
? t('errorMerchantUnderflow', { min: fmt(minimumAmountWei) })
: null));
: null) ??
(revertedNoFeedback ? t('errorReverted') : null));

const [redirectIn, setRedirectIn] = useState<number | null>(null);
// PayPay 風 大型成功 overlay。dismiss 後は inline 成功 panel + redirect countdown を表示。
Expand Down
28 changes: 26 additions & 2 deletions components/PaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 });
Expand Down
62 changes: 53 additions & 9 deletions components/TipForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 を支払う。
Expand Down Expand Up @@ -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 });
Expand All @@ -142,16 +160,32 @@ export function TipForm({ params }: { params: TipParams }) {
// userOpHash ごとに 1 回限りの webhook 発火。gasQuote の refetchInterval (30s)
// で breakdown が再計算 → effect 再実行 → 二重発火を防ぐ gate。
const notifiedUserOpHashRef = useRef<string | null>(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 には出さない。
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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);
Expand All @@ -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 }}
/>
</label>
Expand Down
13 changes: 13 additions & 0 deletions lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 先になり得る点に注意。
Expand Down
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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).",
Expand Down
Loading
Loading