Skip to content

Commit b063652

Browse files
authored
fix(predict): prevent duplicate Predict claim requests during pending state (MetaMask#27133)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** ## Summary - Add `isClaimPending` state to `usePredictClaim` to prevent concurrent claim executions from repeated taps. - Early-return from `claim()` when a claim is already in flight, and always reset pending state in a `finally` block. - Keep retry behavior intact while making retry invocation explicit with `void claim()`. ## Tests - Extend `usePredictClaim` tests to validate pending-state behavior: - initializes with `isClaimPending = false` - sets pending while claim starts - ignores a second `claim()` call while pending - resets pending after success - resets pending after failure - Wrap async claim actions with `act(...)` to ensure stable React state assertions in hook tests. ## Why Repeated taps on Predict claim could trigger multiple confirmation flows. This change enforces a single in-flight claim request and avoids duplicate claim confirmations. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-739 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches claim/transaction flow by adding per-account pending-claim tracking in `PredictController` and wiring it through UI/hook logic; bugs here could block or duplicate user claim attempts despite added tests. > > **Overview** > **Prevents duplicate claim submissions** by adding non-persisted `pendingClaims` state to `PredictController`, short-circuiting `claimWithConfirmation` when a claim is already in flight, and clearing pending state on errors and on terminal transaction status updates. > > **Propagates claim-pending state to the UI** via a new `selectPredictPendingClaimByAddress` selector and `usePredictClaim.isClaimPending`, showing an “in progress” toast on repeat taps and rendering claim buttons in a disabled loading state (spinner + new `predict.claiming_text`), including privacy-mode hiding of amounts. > > Refactors market/positions claim CTAs to reuse `PredictClaimButton` and threads `isClaimPending` through `PredictActionButtons`, game details, sport card footer, and market details actions, with updated/added tests covering the new behavior and edge cases. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5678560. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e038f30 commit b063652

24 files changed

Lines changed: 1038 additions & 77 deletions

File tree

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { fireEvent, screen } from '@testing-library/react-native';
33
import PredictActionButtons from './PredictActionButtons';
4+
import PredictBetButton from './PredictBetButton';
45
import renderWithProvider from '../../../../../util/test/renderWithProvider';
56
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
67
import {
@@ -237,6 +238,19 @@ describe('PredictActionButtons', () => {
237238
expect(screen.queryByText('YES · 65¢')).not.toBeOnTheScreen();
238239
expect(screen.queryByText('NO · 35¢')).not.toBeOnTheScreen();
239240
});
241+
242+
it('passes carousel mode to bet buttons', () => {
243+
const props = createDefaultProps({ isCarousel: true });
244+
245+
const { UNSAFE_getAllByType } = renderWithProvider(
246+
<PredictActionButtons {...props} />,
247+
);
248+
249+
const betButtons = UNSAFE_getAllByType(PredictBetButton);
250+
251+
expect(betButtons[0].props.size).toBe('md');
252+
expect(betButtons[1].props.size).toBe('md');
253+
});
240254
});
241255

242256
describe('bet buttons for game markets', () => {
@@ -312,6 +326,17 @@ describe('PredictActionButtons', () => {
312326
});
313327

314328
describe('edge cases', () => {
329+
it('uses default testID when testID is not provided', () => {
330+
const props = createDefaultProps();
331+
delete (props as Partial<typeof props>).testID;
332+
333+
renderWithProvider(<PredictActionButtons {...props} />);
334+
335+
expect(
336+
screen.getByTestId('predict-action-buttons-bet-yes'),
337+
).toBeOnTheScreen();
338+
});
339+
315340
it('renders nothing when outcome has less than 2 tokens', () => {
316341
const outcomeWithOneToken = createMockOutcome({
317342
tokens: [{ id: 'token-1', title: 'Yes', price: 0.65 }],

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
1414
onClaimPress,
1515
claimableAmount = 0,
1616
isLoading = false,
17+
isClaimPending = false,
1718
testID = 'predict-action-buttons',
1819
isCarousel,
1920
}) => {
@@ -76,6 +77,7 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
7677
<PredictClaimButton
7778
amount={market.game ? undefined : claimableAmount}
7879
onPress={onClaimPress}
80+
isLoading={isClaimPending}
7981
testID={`${testID}-claim`}
8082
/>
8183
</Box>

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface PredictClaimButtonProps {
3636
amount?: number;
3737
onPress: () => void;
3838
disabled?: boolean;
39+
isLoading?: boolean;
40+
isHidden?: boolean;
3941
testID?: string;
4042
}
4143

@@ -46,6 +48,7 @@ export interface PredictActionButtonsProps {
4648
onClaimPress?: () => void;
4749
claimableAmount?: number;
4850
isLoading?: boolean;
51+
isClaimPending?: boolean;
4952
testID?: string;
5053
isCarousel?: boolean;
5154
}

app/components/UI/Predict/components/PredictActionButtons/PredictClaimButton.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ jest.mock('../../../../../../locales/i18n', () => ({
1111
if (key === 'predict.claim_winnings_text') {
1212
return 'Claim winnings';
1313
}
14+
if (key === 'predict.claiming_text') {
15+
return 'Claiming...';
16+
}
1417
return key;
1518
}),
1619
}));
@@ -75,6 +78,30 @@ describe('PredictClaimButton', () => {
7578

7679
expect(screen.getByText('Claim $11.00')).toBeOnTheScreen();
7780
});
81+
82+
it('renders loading state with claiming text when isLoading is true', () => {
83+
// Arrange
84+
const props = createDefaultProps({ amount: 25.5, isLoading: true });
85+
86+
// Act
87+
renderWithProvider(<PredictClaimButton {...props} />);
88+
89+
// Assert
90+
expect(screen.getByText('Claiming...')).toBeOnTheScreen();
91+
expect(screen.queryByText('Claim $25.50')).not.toBeOnTheScreen();
92+
});
93+
94+
it('renders SensitiveText when isHidden is true and amount is provided', () => {
95+
// Arrange
96+
const props = createDefaultProps({ amount: 25.5, isHidden: true });
97+
98+
// Act
99+
renderWithProvider(<PredictClaimButton {...props} />);
100+
101+
// Assert
102+
expect(screen.getByText('•••••••••')).toBeOnTheScreen();
103+
expect(screen.queryByText('Claim $25.50')).not.toBeOnTheScreen();
104+
});
78105
});
79106

80107
describe('without amount (Button Secondary variant)', () => {
@@ -123,6 +150,18 @@ describe('PredictClaimButton', () => {
123150

124151
expect(mockOnPress).not.toHaveBeenCalled();
125152
});
153+
154+
it('renders loading state with claiming text when isLoading is true', () => {
155+
// Arrange
156+
const props = createDefaultProps({ amount: undefined, isLoading: true });
157+
158+
// Act
159+
renderWithProvider(<PredictClaimButton {...props} />);
160+
161+
// Assert
162+
expect(screen.getByText('Claiming...')).toBeOnTheScreen();
163+
expect(screen.queryByText('Claim winnings')).not.toBeOnTheScreen();
164+
});
126165
});
127166

128167
describe('common behavior', () => {
@@ -165,5 +204,21 @@ describe('PredictClaimButton', () => {
165204

166205
expect(mockOnPress).not.toHaveBeenCalled();
167206
});
207+
208+
it('disables button when isLoading is true', () => {
209+
// Arrange
210+
const mockOnPress = jest.fn();
211+
const props = createDefaultProps({
212+
onPress: mockOnPress,
213+
isLoading: true,
214+
});
215+
216+
// Act
217+
renderWithProvider(<PredictClaimButton {...props} />);
218+
fireEvent.press(screen.getByTestId('claim-button'));
219+
220+
// Assert
221+
expect(mockOnPress).not.toHaveBeenCalled();
222+
});
168223
});
169224
});

app/components/UI/Predict/components/PredictActionButtons/PredictClaimButton.tsx

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,102 @@
11
import React from 'react';
2+
import { ActivityIndicator } from 'react-native';
23
import {
3-
Button,
44
ButtonSize,
55
Text,
66
TextVariant,
77
TextColor,
8+
Box,
89
} from '@metamask/design-system-react-native';
910
import { useTailwind } from '@metamask/design-system-twrnc-preset';
1011
import { strings } from '../../../../../../locales/i18n';
12+
import SensitiveText, {
13+
SensitiveTextLength,
14+
} from '../../../../../component-library/components/Texts/SensitiveText';
15+
import { TextVariant as ComponentTextVariant } from '../../../../../component-library/components/Texts/Text/Text.types';
1116
import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero';
1217
import { PredictClaimButtonProps } from './PredictActionButtons.types';
1318

19+
const LoadingContent = () => (
20+
<Box twClassName="flex-row items-center gap-1">
21+
<ActivityIndicator color="white" size="small" />
22+
<Text variant={TextVariant.BodyMd} color={TextColor.PrimaryInverse}>
23+
{strings('predict.claiming_text')}
24+
</Text>
25+
</Box>
26+
);
27+
28+
const AmountLabel = ({
29+
label,
30+
isHidden,
31+
}: {
32+
label: string;
33+
isHidden: boolean;
34+
}) => {
35+
if (isHidden) {
36+
return (
37+
<SensitiveText
38+
variant={ComponentTextVariant.BodyMD}
39+
color="white"
40+
isHidden={isHidden}
41+
length={SensitiveTextLength.Medium}
42+
>
43+
{label}
44+
</SensitiveText>
45+
);
46+
}
47+
48+
return (
49+
<Text variant={TextVariant.BodyMd} color={TextColor.PrimaryInverse}>
50+
{label}
51+
</Text>
52+
);
53+
};
54+
1455
const PredictClaimButton: React.FC<PredictClaimButtonProps> = ({
1556
amount,
1657
onPress,
1758
disabled = false,
59+
isLoading = false,
60+
isHidden = false,
1861
testID = 'predict-claim-button',
1962
}) => {
2063
const tw = useTailwind();
2164

2265
if (amount === undefined) {
2366
return (
24-
<Button
67+
<ButtonHero
2568
size={ButtonSize.Lg}
2669
onPress={onPress}
27-
isDisabled={disabled}
70+
isDisabled={disabled || isLoading}
2871
testID={testID}
2972
style={tw.style('w-full')}
3073
>
31-
{strings('predict.claim_winnings_text')}
32-
</Button>
74+
{isLoading ? (
75+
<LoadingContent />
76+
) : (
77+
strings('predict.claim_winnings_text')
78+
)}
79+
</ButtonHero>
3380
);
3481
}
3582

83+
const amountLabel = strings('predict.claim_amount_text', {
84+
amount: amount.toFixed(2),
85+
});
86+
3687
return (
3788
<ButtonHero
3889
size={ButtonSize.Lg}
3990
onPress={onPress}
40-
isDisabled={disabled}
91+
isDisabled={disabled || isLoading}
4192
testID={testID}
4293
style={tw.style('w-full')}
4394
>
44-
<Text variant={TextVariant.BodyMd} color={TextColor.PrimaryInverse}>
45-
{strings('predict.claim_amount_text', {
46-
amount: amount.toFixed(2),
47-
})}
48-
</Text>
95+
{isLoading ? (
96+
<LoadingContent />
97+
) : (
98+
<AmountLabel label={amountLabel} isHidden={isHidden} />
99+
)}
49100
</ButtonHero>
50101
);
51102
};

app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const PredictGameDetailsContent: React.FC<PredictGameDetailsContentProps> = ({
3939
onClaimPress,
4040
claimableAmount = 0,
4141
isLoading = false,
42+
isClaimPending = false,
4243
}) => {
4344
const tw = useTailwind();
4445
const { colors } = useTheme();
@@ -133,6 +134,7 @@ const PredictGameDetailsContent: React.FC<PredictGameDetailsContentProps> = ({
133134
onInfoPress={handleInfoPress}
134135
claimableAmount={claimableAmount}
135136
isLoading={isLoading}
137+
isClaimPending={isClaimPending}
136138
/>
137139

138140
{isVisible && (

app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export interface PredictGameDetailsContentProps {
99
onClaimPress?: () => void;
1010
claimableAmount?: number;
1111
isLoading?: boolean;
12+
isClaimPending?: boolean;
1213
}

app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const PredictGameDetailsFooter: React.FC<PredictGameDetailsFooterProps> = ({
2626
onInfoPress,
2727
claimableAmount = 0,
2828
isLoading = false,
29+
isClaimPending = false,
2930
testID = 'predict-game-details-footer',
3031
}) => {
3132
const insets = useSafeAreaInsets();
@@ -95,6 +96,7 @@ const PredictGameDetailsFooter: React.FC<PredictGameDetailsFooterProps> = ({
9596
onClaimPress={onClaimPress}
9697
claimableAmount={claimableAmount}
9798
isLoading={isLoading}
99+
isClaimPending={isClaimPending}
98100
testID={`${testID}-action-buttons`}
99101
/>
100102
</Box>

app/components/UI/Predict/components/PredictGameDetailsFooter/PredictGameDetailsFooter.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface PredictGameDetailsFooterProps {
1212
onInfoPress: () => void;
1313
claimableAmount?: number;
1414
isLoading?: boolean;
15+
isClaimPending?: boolean;
1516
testID?: string;
1617
}
1718

app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
BoxJustifyContent,
66
Text,
77
TextVariant,
8-
ButtonSize as ButtonSizeHero,
98
} from '@metamask/design-system-react-native';
109
import { useTailwind } from '@metamask/design-system-twrnc-preset';
1110
import {
@@ -51,8 +50,8 @@ import { selectPredictWonPositions } from '../../selectors/predictController';
5150
import { PredictPosition } from '../../types';
5251
import { PredictNavigationParamList } from '../../types/navigation';
5352
import { formatPercentage, formatPrice } from '../../utils/format';
54-
import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero';
5553
import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton';
54+
import PredictClaimButton from '../PredictActionButtons/PredictClaimButton';
5655
import { PredictEventValues } from '../../constants/eventNames';
5756
import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accounts';
5857

@@ -73,7 +72,7 @@ const PredictPositionsHeader = forwardRef<
7372
>((props, ref) => {
7473
const { onError } = props;
7574
const privacyMode = useSelector(selectPrivacyMode);
76-
const { claim } = usePredictClaim();
75+
const { claim, isClaimPending } = usePredictClaim();
7776
const navigation =
7877
useNavigation<NavigationProp<PredictNavigationParamList>>();
7978
const tw = useTailwind();
@@ -202,23 +201,13 @@ const PredictPositionsHeader = forwardRef<
202201
return (
203202
<Box twClassName="gap-4 pb-4 pt-2">
204203
{hasClaimableAmount && (
205-
<ButtonHero
206-
size={ButtonSizeHero.Lg}
207-
testID={PredictPositionsHeaderSelectorsIDs.CLAIM_BUTTON}
204+
<PredictClaimButton
205+
amount={totalClaimableAmount}
208206
onPress={handleClaim}
209-
style={tw.style('w-full')}
210-
>
211-
<SensitiveText
212-
variant={ComponentTextVariant.BodyMD}
213-
color="white"
214-
isHidden={privacyMode}
215-
length={SensitiveTextLength.Medium}
216-
>
217-
{strings('predict.claim_amount_text', {
218-
amount: totalClaimableAmount.toFixed(2),
219-
})}
220-
</SensitiveText>
221-
</ButtonHero>
207+
isLoading={isClaimPending}
208+
isHidden={privacyMode}
209+
testID={PredictPositionsHeaderSelectorsIDs.CLAIM_BUTTON}
210+
/>
222211
)}
223212

224213
{(hasAvailableBalance ||

0 commit comments

Comments
 (0)