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: 7 additions & 6 deletions components/CreatorStorePurchaseFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,14 @@ export function CreatorStorePurchaseFlow({
setFlowError(null);
};

// 署名中・送信中は wrong chain になっても確認 UI (isSubmitting で操作不可) を出し続ける。
// start view に落ちると rail/prepare が押せ、進行中の購入を捨てて 2 回目の署名へ進めてしまう。
const showConfirmation =
buyer.quote !== null &&
(buyer.quote.rail === 'usdc' || signPreview !== null) &&
(buyer.phase === 'review' ||
((buyer.phase === 'review' && !buyer.isWrongChain) ||
buyer.phase === 'signing' ||
buyer.phase === 'submitting') &&
!buyer.isWrongChain;
buyer.phase === 'submitting');
const showState =
buyer.phase === 'indeterminate' ||
buyer.phase === 'indeterminate-exhausted' ||
Expand Down Expand Up @@ -418,7 +419,7 @@ export function CreatorStorePurchaseFlow({
name={`store-payment-rail-${product.id}`}
value="jpyc"
checked={selectedPaymentRail === 'jpyc'}
disabled={buyer.phase === 'loading-quote'}
disabled={buyer.isBusy}
onChange={() => {
setPaymentRail('jpyc');
setFlowError(null);
Expand All @@ -441,7 +442,7 @@ export function CreatorStorePurchaseFlow({
name={`store-payment-rail-${product.id}`}
value="usdc"
checked={selectedPaymentRail === 'usdc'}
disabled={buyer.phase === 'loading-quote'}
disabled={buyer.isBusy}
onChange={() => {
setPaymentRail('usdc');
setFlowError(null);
Expand Down Expand Up @@ -520,7 +521,7 @@ export function CreatorStorePurchaseFlow({
) : (
<button
type="button"
disabled={buyer.phase === 'loading-quote'}
disabled={buyer.isBusy}
onClick={prepareReview}
className="mt-5 inline-flex min-h-11 items-center gap-2 rounded-xl bg-brand px-5 py-3 text-sm font-bold text-white hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Expand Down
74 changes: 63 additions & 11 deletions hooks/useHostedStorePurchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export type HostedStorePurchaseErrorCode =
| 'review_required'
| 'wallet_unavailable'
| 'wallet_changed'
| 'product_changed'
| 'purchase_in_progress'
| 'signature_rejected'
| 'signed_payment_unavailable';

Expand Down Expand Up @@ -191,8 +193,12 @@ export function useHostedStorePurchase({
const indeterminateAtRef = useRef<number | null>(null);
const scopeRef = useRef<string | null>(null);
const productScopeRef = useRef<string | null>(null);
// merchant は大文字小文字の差だけでは別商品にしない (checksum/lowercase の揺れで review を捨てない)。
const productScope = `${resourceId}:${title}:${merchant.toLowerCase()}:${priceJpyc}:${rail}`;
const currentProductScopeRef = useRef<string>(productScope);
currentAddressRef.current = address;
currentSessionRef.current = sessionAddress;
currentProductScopeRef.current = productScope;

const clearPurchaseState = useCallback(() => {
signedRequestRef.current = null;
Expand Down Expand Up @@ -224,15 +230,14 @@ export function useHostedStorePurchase({
}, [address, sessionAddress, queryClient, clearPurchaseState]);

useEffect(() => {
const productScope = `${resourceId}:${title}:${merchant.toLowerCase()}:${priceJpyc}:${rail}`;
if (productScopeRef.current === null) {
productScopeRef.current = productScope;
return;
}
if (productScopeRef.current === productScope) return;
productScopeRef.current = productScope;
clearPurchaseState();
}, [resourceId, title, merchant, priceJpyc, rail, clearPurchaseState]);
}, [productScope, clearPurchaseState]);

const prepare = useCallback(async (): Promise<HostedStorePurchaseQuote> => {
if (!enabled) {
Expand All @@ -241,6 +246,21 @@ export function useHostedStorePurchase({
'Creator Store purchase is disabled',
);
}
// 1 件目の購入が未解決 (署名中・送信中・確認中) の間は新しい quote へ進ませない。prepare は
// 署名済み request を捨てるので、進めると 1 件目の retry/status 解決を失い、2 回目の署名が
// できてしまう (二重課金の防止)。条件は reset のガードと同じで、署名中も加える。state は触らない。
if (
phase === 'signing' ||
(signedRequestRef.current &&
(phase === 'submitting' ||
phase === 'indeterminate' ||
phase === 'indeterminate-exhausted'))
) {
throw new HostedStorePurchaseError(
'purchase_in_progress',
'A hosted purchase is still in progress',
);
}
if (!address) {
throw new HostedStorePurchaseError(
'wallet_not_connected',
Expand Down Expand Up @@ -353,13 +373,25 @@ export function useHostedStorePurchase({
setPhase('error');
throw next;
}
// 商品 (product scope) が quote fetch 中に切り替わった場合も、旧商品の quote を
// 切替後の review に出さない (wallet 切替と同じ扱い)。
if (currentProductScopeRef.current !== productScope) {
const next = new HostedStorePurchaseError(
'product_changed',
'Product changed while loading the purchase quote',
);
setError(next);
setPhase('error');
throw next;
}

setSellerRole(env.enableLicenseNftUi && isRecord(raw) &&
(raw.sellerRole === 'operator' || raw.sellerRole === 'third_party') ? raw.sellerRole : undefined);
setQuote(validated);
setPhase('review');
return validated;
}, [
phase,
enabled,
address,
sessionAddress,
Expand All @@ -368,6 +400,7 @@ export function useHostedStorePurchase({
merchant,
priceJpyc,
rail,
productScope,
]);

const markConfirmed = useCallback((settledTxHash?: Hex) => {
Expand All @@ -388,6 +421,20 @@ export function useHostedStorePurchase({
setPhase('indeterminate');
}, []);

// wallet/session・商品の切替 (clearPurchaseState) で破棄した旧 flow の完了が、切替後の画面を
// 旧 flow の成功・確認中・失敗で上書きしないための判定 (quote=null の provisioning で止まる等を防ぐ)。
// 商品の切替と、wallet が A→B→A と戻った場合は、hook が保持する signed request が別 object
// (または null) になることで検出する。retry は保持中の同じ object を送るので捨てない。
// address/session の 2 条件は、切替を render した後・scope effect が clear する前の窓で先に止めるため
// に残す (最終状態は identity だけでも idle で同じ・既存の wallet 判定を変えない)。
const isStaleSignedCompletion = useCallback(
(signed: SignedHostedRequest): boolean =>
!sameAddress(currentAddressRef.current, signed.payer) ||
!sameAddress(currentSessionRef.current, signed.payer) ||
signedRequestRef.current !== signed,
[],
);

const submitSignedRequest = useCallback(
async (signed: SignedHostedRequest): Promise<void> => {
setError(null);
Expand All @@ -404,10 +451,7 @@ export function useHostedStorePurchase({
credentials: 'same-origin',
});
} catch (cause) {
if (
!sameAddress(currentAddressRef.current, signed.payer) ||
!sameAddress(currentSessionRef.current, signed.payer)
) {
if (isStaleSignedCompletion(signed)) {
return;
}
// header が server に届いた可能性を client だけでは否定できない。新しい署名へ倒さず、
Expand All @@ -417,10 +461,7 @@ export function useHostedStorePurchase({
}

const body = await responseJson(response);
if (
!sameAddress(currentAddressRef.current, signed.payer) ||
!sameAddress(currentSessionRef.current, signed.payer)
) {
if (isStaleSignedCompletion(signed)) {
return;
}
if (response.status === 200) {
Expand Down Expand Up @@ -513,7 +554,7 @@ export function useHostedStorePurchase({
);
markIndeterminate();
},
[markConfirmed, markIndeterminate, resourceId],
[isStaleSignedCompletion, markConfirmed, markIndeterminate, resourceId],
);

const purchase = useCallback(async (): Promise<void> => {
Expand Down Expand Up @@ -627,6 +668,16 @@ export function useHostedStorePurchase({
setPhase('error');
throw next;
}
// 署名 modal 中に商品が切り替わった場合も、旧商品の署名を送信しない (wallet 切替と同じ扱い)。
if (currentProductScopeRef.current !== productScope) {
const next = new HostedStorePurchaseError(
'product_changed',
'Product changed before the signed payment was submitted',
);
setError(next);
setPhase('error');
throw next;
}

const header =
prepared.rail === 'usdc'
Expand Down Expand Up @@ -663,6 +714,7 @@ export function useHostedStorePurchase({
chainId,
walletClient,
resourceId,
productScope,
submitSignedRequest,
]);

Expand Down
52 changes: 49 additions & 3 deletions tests/components/CreatorStorePurchaseFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const state = vi.hoisted(() => ({
content: null as Record<string, unknown> | null,
error: null as Error | null,
isWrongChain: false,
isBusy: false,
signPreview: { preview: true } as Record<string, unknown> | null,
canRetrySignedPayment: false,
hookInput: null as Record<string, unknown> | null,
}));
Expand Down Expand Up @@ -83,7 +85,7 @@ vi.mock('@/hooks/useHostedStorePurchase', () => ({
error: state.error,
requiredChainId: state.quote ? Number(state.quote.chainId) : null,
isWrongChain: state.isWrongChain,
isBusy: false,
isBusy: state.isBusy,
canRetrySignedPayment: state.canRetrySignedPayment,
prepare: state.prepare,
purchase: state.purchase,
Expand All @@ -100,7 +102,7 @@ vi.mock('@/lib/x402/hostedPurchaseWire', () => ({
const floor = 10n ** 18n;
return pct > floor ? pct : floor;
},
buildHostedPurchaseSignPreview: () => ({ preview: true }),
buildHostedPurchaseSignPreview: () => state.signPreview,
}));

vi.mock('@/components/CreatorStorePurchaseConfirmation', () => ({
Expand All @@ -112,6 +114,7 @@ vi.mock('@/components/CreatorStorePurchaseConfirmation', () => ({
rail,
paidUsdc,
sellerDisclosureHref,
isSubmitting,
onBack,
onConfirm,
}: {
Expand All @@ -122,6 +125,7 @@ vi.mock('@/components/CreatorStorePurchaseConfirmation', () => ({
rail?: 'jpyc' | 'usdc';
paidUsdc?: string;
sellerDisclosureHref: string;
isSubmitting?: boolean;
onBack: () => void;
onConfirm: () => void;
}) => (
Expand All @@ -135,7 +139,7 @@ vi.mock('@/components/CreatorStorePurchaseConfirmation', () => ({
<button type="button" onClick={onBack}>
back
</button>
<button type="button" onClick={onConfirm}>
<button type="button" disabled={isSubmitting} onClick={onConfirm}>
confirm
</button>
</div>
Expand Down Expand Up @@ -197,6 +201,8 @@ beforeEach(() => {
state.content = null;
state.error = null;
state.isWrongChain = false;
state.isBusy = false;
state.signPreview = { preview: true };
state.canRetrySignedPayment = false;
state.hookInput = null;
state.switchChainAsync.mockResolvedValue(undefined);
Expand Down Expand Up @@ -436,6 +442,46 @@ describe('CreatorStorePurchaseFlow', () => {
expect(state.purchase).not.toHaveBeenCalled();
});

// B-R15d: 署名中・送信中に start view へ落ちると rail/prepare が押せ、進行中の購入を捨てて
// 2 回目の署名へ進めてしまう。wrong chain でも確認 UI を保ち、操作を止める。
it.each(['signing', 'submitting'] as const)(
'%s 中に wallet の chain が変わっても最終確認を出し続け、確定・支払い方法・購入内容の確認は押せない',
(phase) => {
state.phase = phase;
state.isWrongChain = true;
state.isBusy = true;
renderFlow('ja', { ...PRODUCT, usdcEnabled: true });

expect(screen.getByTestId('confirmation')).toHaveTextContent('100/1/101');
expect(screen.getByRole('button', { name: 'confirm' })).toBeDisabled();
expect(screen.queryByRole('radio')).toBeNull();
expect(
screen.queryByRole('button', { name: '購入内容を確認する' }),
).toBeNull();
expect(
screen.queryByRole('button', { name: 'Polygon に切り替える' }),
).toBeNull();
expect(state.prepare).not.toHaveBeenCalled();
},
);

it('署名中に最終確認を描けない場合 (sign preview なし) も、支払い方法と購入内容の確認は押せない', () => {
state.phase = 'signing';
state.isBusy = true;
state.signPreview = null;
renderFlow('ja', { ...PRODUCT, usdcEnabled: true });

expect(screen.queryByTestId('confirmation')).toBeNull();
expect(screen.getByRole('radio', { name: /JPYC で支払う/ })).toBeDisabled();
expect(screen.getByRole('radio', { name: /USDC で支払う/ })).toBeDisabled();
const prepareButton = screen.getByRole('button', {
name: '購入内容を確認する',
});
expect(prepareButton).toBeDisabled();
fireEvent.click(prepareButton);
expect(state.prepare).not.toHaveBeenCalled();
});

it('own content read-back 済みの ready だけ ownershipReadBack=true を渡す', () => {
state.phase = 'ready';
state.paymentStatus = 'confirmed';
Expand Down
Loading
Loading