Skip to content

Commit aaac811

Browse files
cipherwebllcclaude
andcommitted
fix(payments): 4 core bugs from Codex audit (precision, silent revert, tip webhook, gate)
Codex の core-payment 監査で確認した実バグを修正 (PaymentForm/CheckoutForm/TipForm は OOM で unit-test が動かないため、typecheck + 純関数 unit test + e2e + Codex review で担保)。 B1 (金額の精度ドリフト): DECIMAL_PATTERN が小数桁を無制限に許可し、parseUnits が token の decimals 超過分を黙って丸める (例: USDC 6dp で "0.0000009" → 0.000001) ため表示額と実送金額が 乖離。lib/url.exceedsTokenPrecision を追加、PaymentForm/TipForm は精度超過で amountWei=0n に 倒し送信 block + errorAmountPrecision を案内 (CheckoutForm は item 価格 parser が既に弾く)。 B2 (revert の無反応穴): gasless は data.success===false、standard は phase=*-error だが receipt は 成功 (status=reverted) で Error 無く、success panel も error も出ず無反応。3 form の error 表示に revertedNoFeedback → errorReverted を追加。 B3 (TipForm webhook の live-state 読み): webhook が live の amountStr/breakdown を読み、送信後の額 変更や gasQuote refetch で実送金と異なるチップを通知し得た。onSubmit でスナップショット固定 + 送信中は preset/custom 入力を lock。 B4 (CheckoutForm の弱い zero-merchant gate): canSubmit に merchantReceives>0 明示ガードが無く PaymentForm と非対称。明示ガードを追加して揃える。 i18n: errorAmountPrecision / errorReverted を 3 form 名前空間 (ja/en) に追加。 Verified: tsc 0 · eslint 0 · full suite 2583 passed/0 failed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d33dd19 commit aaac811

7 files changed

Lines changed: 141 additions & 12 deletions

File tree

components/CheckoutForm.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
134134
isConnected &&
135135
!wrongChain &&
136136
(isStandard || !!saData) &&
137+
// PaymentForm と揃える明示ガード。現状は totalWei>0 (有効 items) 不変で merchantUnderflow
138+
// が拾うが、空 batch (merchant 受取 0) 送信を構造的にも塞ぐ defense-in-depth。
139+
breakdown.merchantReceives > 0n &&
137140
breakdown.customerPays > 0n &&
138141
!insufficientBalance &&
139142
!flowPending &&
@@ -142,14 +145,22 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
142145

143146
const flowError = isStandard ? standard.error : gasless.error;
144147
const saFallback = !isStandard && isIncompatibleSmartAccountError(saError);
148+
// 送信は成立したがチェーン上で revert したケース (gasless: data.success===false /
149+
// standard: phase=*-error だが receipt 成功で Error 無し)。無反応穴を明示メッセージで塞ぐ。
150+
const revertedNoFeedback =
151+
(!isStandard && !!gasless.data && !gasless.data.success) ||
152+
(isStandard &&
153+
(standard.isMerchantError || standard.isFeeError) &&
154+
!standard.error);
145155
const error = isGasCongestedError(flowError)
146156
? t('errorGasCongested')
147157
: (flowError?.message ??
148158
(isStandard || saFallback ? undefined : saError?.message) ??
149159
(activeQuote.error ? t('errorGasQuote') : null) ??
150160
(merchantUnderflow
151161
? t('errorMerchantUnderflow', { min: fmt(minimumAmountWei) })
152-
: null));
162+
: null) ??
163+
(revertedNoFeedback ? t('errorReverted') : null));
153164

154165
const [redirectIn, setRedirectIn] = useState<number | null>(null);
155166
// PayPay 風 大型成功 overlay。dismiss 後は inline 成功 panel + redirect countdown を表示。

components/PaymentForm.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ import { logger } from '@/lib/logger';
3030
import { usePaymentHistory } from '@/hooks/usePaymentHistory';
3131
import { resolvePaymasterMode } from '@/lib/pimlico';
3232
import { DEFAULT_CHAIN_FOR_SYMBOL, deploymentForSlug } from '@/lib/tokens';
33-
import { DECIMAL_PATTERN, parsePayParams, type PayParams } from '@/lib/url';
33+
import {
34+
DECIMAL_PATTERN,
35+
exceedsTokenPrecision,
36+
parsePayParams,
37+
type PayParams,
38+
} from '@/lib/url';
3439
import { formatTokenAmount, shortAddress } from '@/lib/format';
3540

3641
export function PaymentForm() {
@@ -98,8 +103,15 @@ function PaymentDetails({ params }: { params: PayParams }) {
98103

99104
const amountWei = useMemo(() => {
100105
if (!amountStr || !DECIMAL_PATTERN.test(amountStr)) return 0n;
106+
// 精度超過は parseUnits が黙って丸めて表示額と実送金額が乖離するため弾く (0n=送信 block)。
107+
if (exceedsTokenPrecision(amountStr, deployment.decimals)) return 0n;
101108
return parseUnits(amountStr, deployment.decimals);
102109
}, [amountStr, deployment.decimals]);
110+
// 精度超過を UI で明示するためのフラグ (amountWei は 0n になるが理由を伝える)。
111+
const amountPrecisionError =
112+
!!amountStr &&
113+
DECIMAL_PATTERN.test(amountStr) &&
114+
exceedsTokenPrecision(amountStr, deployment.decimals);
103115

104116
const fmt = (wei: bigint) => formatTokenAmount(wei, deployment);
105117

@@ -207,14 +219,26 @@ function PaymentDetails({ params }: { params: PayParams }) {
207219
// 漏れるため、i18n 化した friendly メッセージに置き換える (詳細は logger 経由で Sentry へ)。
208220
const flowError = isStandard ? standard.error : gasless.error;
209221
const saFallback = !isStandard && isIncompatibleSmartAccountError(saError);
222+
// 送信は成立したがチェーン上で revert したケース。gasless は data.success===false、standard は
223+
// phase=*-error だが receipt 自体は成功 (status=reverted) なので Error オブジェクトが無い。
224+
// どちらも success panel も error も出ず「無反応」に見える穴を、明示メッセージで塞ぐ。
225+
const revertedNoFeedback =
226+
(!isStandard && !!gasless.data && !gasless.data.success) ||
227+
(isStandard &&
228+
(standard.isMerchantError || standard.isFeeError) &&
229+
!standard.error);
210230
const error = isGasCongestedError(flowError)
211231
? t('errorGasCongested')
212232
: (flowError?.message ??
213233
(isStandard || saFallback ? undefined : saError?.message) ??
214234
(activeQuote.error ? t('errorGasQuote') : null) ??
235+
(amountPrecisionError
236+
? t('errorAmountPrecision', { decimals: deployment.decimals })
237+
: null) ??
215238
(merchantUnderflow
216239
? t('errorMerchantUnderflow', { min: fmt(minimumAmountWei) })
217-
: null));
240+
: null) ??
241+
(revertedNoFeedback ? t('errorReverted') : null));
218242

219243
useEffect(() => {
220244
if (gasless.error) logger.error('payment.failed', { error: gasless.error });

components/TipForm.tsx

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ import { isIncompatibleSmartAccountError } from '@/lib/accountDetection';
2424
import { logger } from '@/lib/logger';
2525
import { resolvePaymasterMode } from '@/lib/pimlico';
2626
import { DEFAULT_CHAIN_FOR_SYMBOL, deploymentForSlug } from '@/lib/tokens';
27-
import { DECIMAL_PATTERN, DEFAULT_TIP_PRESETS, type TipParams } from '@/lib/url';
27+
import {
28+
DECIMAL_PATTERN,
29+
DEFAULT_TIP_PRESETS,
30+
exceedsTokenPrecision,
31+
type TipParams,
32+
} from '@/lib/url';
2833
import { formatTokenAmount } from '@/lib/format';
2934

3035
const DEFAULT_THEME_COLOR = '#2563eb';
@@ -72,8 +77,14 @@ export function TipForm({ params }: { params: TipParams }) {
7277
const amountStr = customSelected ? customAmount : (selectedPreset ?? '');
7378
const amountWei = useMemo(() => {
7479
if (!amountStr || !DECIMAL_PATTERN.test(amountStr)) return 0n;
80+
// 精度超過は parseUnits が黙って丸めて表示額と実送金額が乖離するため弾く。
81+
if (exceedsTokenPrecision(amountStr, deployment.decimals)) return 0n;
7582
return parseUnits(amountStr, deployment.decimals);
7683
}, [amountStr, deployment.decimals]);
84+
const amountPrecisionError =
85+
!!amountStr &&
86+
DECIMAL_PATTERN.test(amountStr) &&
87+
exceedsTokenPrecision(amountStr, deployment.decimals);
7788

7889
// Tip widget は gasless / gas=customer 固定 (preset セマンティクス維持):
7990
// creator は preset - fee を受け取り、ファンは preset + gas を支払う。
@@ -121,11 +132,18 @@ export function TipForm({ params }: { params: TipParams }) {
121132
// (デバッグ向け詳細) ではなく i18n された案内文に差し替える。
122133
// gasQuote の失敗も同様に i18n 化 (詳細は logger 経由で Sentry へ)。
123134
const saFallback = isIncompatibleSmartAccountError(saError);
135+
// 送信は成立したがチェーン上で revert したケース (gasless: data.success===false)。success
136+
// overlay も error も出ず無反応に見える穴を明示メッセージで塞ぐ。
137+
const revertedNoFeedback = !!gasless.data && !gasless.data.success;
124138
const error = isGasCongestedError(gasless.error)
125139
? t('errorGasCongested')
126140
: (gasless.error?.message ??
127141
(saFallback ? undefined : saError?.message) ??
128-
(gasQuote.error ? t('errorGasQuote') : null));
142+
(gasQuote.error ? t('errorGasQuote') : null) ??
143+
(amountPrecisionError
144+
? t('errorAmountPrecision', { decimals: deployment.decimals })
145+
: null) ??
146+
(revertedNoFeedback ? t('errorReverted') : null));
129147

130148
useEffect(() => {
131149
if (gasless.error) logger.error('tip.failed', { error: gasless.error });
@@ -142,16 +160,32 @@ export function TipForm({ params }: { params: TipParams }) {
142160
// userOpHash ごとに 1 回限りの webhook 発火。gasQuote の refetchInterval (30s)
143161
// で breakdown が再計算 → effect 再実行 → 二重発火を防ぐ gate。
144162
const notifiedUserOpHashRef = useRef<string | null>(null);
163+
// 送信時点の確定スナップショット。webhook が live state (amountStr / breakdown) を読むと、
164+
// 送信後にユーザが額を変えたり gasQuote が refetch されたとき、実際に送ったチップと異なる
165+
// 値を creator へ通知してしまう。onSubmit でここに固定し、webhook はこちらを参照する。
166+
const submittedRef = useRef<{
167+
amount: string;
168+
merchantAmount: string;
169+
feeAmount: string;
170+
customerPays: string;
171+
} | null>(null);
145172

146173
useEffect(() => {
147174
if (!gasless.data || !gasless.data.success) return;
148175
if (notifiedUserOpHashRef.current === gasless.data.userOpHash) return;
149176
notifiedUserOpHashRef.current = gasless.data.userOpHash;
177+
// 送信時スナップショット優先 (live state drift を排除)。万一未設定なら live に fallback。
178+
const sent = submittedRef.current ?? {
179+
amount: amountStr,
180+
merchantAmount: breakdown.merchantReceives.toString(),
181+
feeAmount: breakdown.feeAmount.toString(),
182+
customerPays: breakdown.customerPays.toString(),
183+
};
150184
logger.info('tip.success', {
151185
userOpHash: gasless.data.userOpHash,
152186
txHash: gasless.data.txHash,
153187
creator: params.to,
154-
amount: amountStr,
188+
amount: sent.amount,
155189
token: params.token,
156190
});
157191
// webhook 失敗 (CORS / non-2xx) は logger.warn のみ。tip は成立しているため UI には出さない。
@@ -163,10 +197,10 @@ export function TipForm({ params }: { params: TipParams }) {
163197
from: address,
164198
token: params.token,
165199
chain: chainSlug,
166-
amount: amountStr,
167-
merchantAmount: breakdown.merchantReceives.toString(),
168-
feeAmount: breakdown.feeAmount.toString(),
169-
customerPays: breakdown.customerPays.toString(),
200+
amount: sent.amount,
201+
merchantAmount: sent.merchantAmount,
202+
feeAmount: sent.feeAmount,
203+
customerPays: sent.customerPays,
170204
message: params.message,
171205
txHash: gasless.data.txHash,
172206
userOpHash: gasless.data.userOpHash,
@@ -223,6 +257,14 @@ export function TipForm({ params }: { params: TipParams }) {
223257

224258
function onSubmit() {
225259
if (!canSubmit) return;
260+
// 送信時点の値を固定 (webhook はこのスナップショットを使い、後続の編集 / gasQuote
261+
// refetch による drift を排除する)。
262+
submittedRef.current = {
263+
amount: amountStr,
264+
merchantAmount: breakdown.merchantReceives.toString(),
265+
feeAmount: breakdown.feeAmount.toString(),
266+
customerPays: breakdown.customerPays.toString(),
267+
};
226268
gasless.mutate({
227269
tokenAddress: deployment.address,
228270
merchant: params.to,
@@ -279,7 +321,8 @@ export function TipForm({ params }: { params: TipParams }) {
279321
key={p}
280322
type="button"
281323
onClick={() => selectPreset(p)}
282-
className={`rounded-xl border px-2 py-3 text-center text-sm font-semibold transition ${
324+
disabled={gasless.isPending}
325+
className={`rounded-xl border px-2 py-3 text-center text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${
283326
active
284327
? 'border-transparent text-white shadow-sm'
285328
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
@@ -300,6 +343,7 @@ export function TipForm({ params }: { params: TipParams }) {
300343
type="text"
301344
inputMode="decimal"
302345
value={customAmount}
346+
disabled={gasless.isPending}
303347
onFocus={selectCustom}
304348
onChange={(e) => {
305349
setSelectedPreset(null);
@@ -310,7 +354,7 @@ export function TipForm({ params }: { params: TipParams }) {
310354
? t('amountCustomPlaceholderJpyc')
311355
: t('amountCustomPlaceholderUsdc')
312356
}
313-
className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-lg font-semibold focus:outline-none"
357+
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"
314358
style={{ borderColor: customSelected ? themeColor : undefined }}
315359
/>
316360
</label>

lib/url.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,19 @@ const TIP_THANKS_MAX = 200;
432432
export const TIP_PRESET_MAX = 6;
433433
export const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
434434
export const DECIMAL_PATTERN = /^\d+(\.\d+)?$/;
435+
436+
// amountStr の小数桁が token の decimals を超えると viem の parseUnits は **黙って丸める**
437+
// (例: USDC=6dp で "0.0000009" → 0.000001)。結果、画面表示額と実送金額が乖離する。これを
438+
// 防ぐため精度超過を検出し、呼出側は invalid 扱い (送信 block + 案内) にする。DECIMAL_PATTERN
439+
// 通過を前提 (小数点が高々 1 個)。
440+
export function exceedsTokenPrecision(
441+
amountStr: string,
442+
decimals: number,
443+
): boolean {
444+
const dot = amountStr.indexOf('.');
445+
if (dot === -1) return false;
446+
return amountStr.length - dot - 1 > decimals;
447+
}
435448
// http/https のみ許可。URL.canParse を使うので try/catch 不要。
436449
// localhost / 127.0.0.1 は webhook テスト用途で許可するが、本番では
437450
// クリエイターが制御していない URL を貼ると意図しない POST 先になり得る点に注意。

messages/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@
322322
"successBlock": "Block",
323323
"urlInvalidTitle": "Invalid payment URL",
324324
"loading": "Loading payment details…",
325+
"errorAmountPrecision": "Amount can have at most {decimals} decimal places. Trim the digits so the displayed amount matches what is actually sent.",
326+
"errorReverted": "The transaction reverted on-chain (no funds were transferred). Check your balance and network, then try again.",
325327
"emptyLandingTitle": "This is the customer payment page",
326328
"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.",
327329
"emptyLandingToHome": "Open OpenPay (merchant)",
@@ -368,6 +370,8 @@
368370
"errorGasQuote": "Failed to fetch the gas estimate. Check your network connection and try again.",
369371
"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.).",
370372
"errorMav2Disabled": "Tip sending from wallets backed by Alchemy Modular Account v2 (such as HashPort) is being rolled out gradually. Please try again later.",
373+
"errorAmountPrecision": "Amount can have at most {decimals} decimal places. Trim the digits so the displayed amount matches what is actually sent.",
374+
"errorReverted": "The tip reverted on-chain (no funds were transferred). Check your balance and network, then try again.",
371375
"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.",
372376
"errorMetaMaskKaia": "MetaMask Smart Account is not supported on Kaia yet. Toggle off \"Smart account\" in MetaMask Account details, or try a different wallet.",
373377
"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 @@
485489
"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.).",
486490
"errorMav2Disabled": "Payments from wallets backed by Alchemy Modular Account v2 (such as HashPort) are being rolled out gradually. Please try again later.",
487491
"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.",
492+
"errorReverted": "The transaction reverted on-chain (no funds were transferred). Check your balance and network, then try again.",
488493
"errorMetaMaskKaia": "MetaMask Smart Account is not supported on Kaia yet. Toggle off \"Smart account\" in MetaMask Account details, or try a different wallet.",
489494
"errorPristineNoBootstrap": "This wallet isn't set up for gasless payments yet. You can pay now in standard mode (you pay network gas).",
490495
"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).",

0 commit comments

Comments
 (0)