Skip to content

Commit 338aaf4

Browse files
authored
feat(predict): optimistic position updates for buy/sell/claim (#22493)
## **Description** This PR implements optimistic updates for Predict positions to provide immediate user feedback when placing BUY/SELL orders, significantly improving the user experience by showing skeleton loaders instead of stale data while waiting for API confirmation. ### What is the reason for the change? Previously, when users placed orders in the Predict feature, they had to wait for the API to confirm the transaction before seeing their position update. This created a poor UX where: 1. Users saw outdated position values after placing orders 2. No visual feedback indicated that an order was processing 3. Users were unsure if their action succeeded until API confirmed ### What is the improvement/solution? **Implemented a comprehensive optimistic updates system:** #### Core Features - **Optimistic position creation**: When users BUY, create an immediate optimistic position with expected values - **Optimistic position updates**: When users BUY more of an existing position, optimistically update with accumulated values - **Optimistic position removal**: When users SELL or CLAIM, immediately hide the position from the list - **Smart validation**: Compare API responses with expected sizes to know when to remove optimistic updates - **Auto-cleanup**: Remove optimistic updates after 1-minute timeout if API never confirms - **Defensive checks**: Prevent optimistic updates on claimable positions #### Implementation Details 1. **PolymarketProvider.ts** (~450 lines added): - Added `OptimisticUpdateType` enum (CREATE, UPDATE, REMOVE) - Added `OptimisticPositionUpdate` interface with type, position data, and expected size - Added `#optimisticPositionUpdatesByAddress` Map to track updates per user address - Implemented `createOrUpdateOptimisticPosition()` for BUY orders - Implemented `removeOptimisticPosition()` for SELL/CLAIM orders - Implemented `applyOptimisticPositionUpdates()` to merge optimistic data with API responses - Implemented `isApiPositionUpdated()` with 0.1% tolerance for size validation - Added cleanup logic for expired optimistic updates (1-minute timeout) 2. **UI Components**: - **PredictPosition.tsx**: Added skeleton loaders for current value and PnL when `optimistic: true` - **PredictPositionDetail.tsx**: Added skeleton loaders and disabled "Cash out" button for optimistic positions - **PredictPosition types**: Added `optimistic?: boolean` flag to `PredictPosition` type 3. **Controller**: - **PredictController.ts**: Include fees in optimistic balance calculation for accurate display 4. **Comprehensive Test Coverage** (~1,830 lines of tests): - Fixed 8 failing tests from system migration - Added 21 new tests covering CREATE, UPDATE, REMOVE, integration, and UI scenarios - Achieved 91.53% statement coverage and 91.75% line coverage ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [PRED-294](https://consensyssoftware.atlassian.net/browse/PRED-294) ## **Manual testing steps** ```gherkin Feature: Optimistic updates for Predict positions Scenario: User buys a new position Given user is on the Predict Positions screen And user has no position in a market When user places a BUY order for a position Then user immediately sees the new position in their list And the position shows skeleton loaders for value and PnL And the initial value and shares are displayed When the API confirms the order (within 1 minute) Then the skeleton loaders are replaced with actual values And the position shows real-time current value and PnL Scenario: User sells an existing position Given user is on the Predict Positions screen And user has an existing position When user places a SELL order for the position Then the position immediately disappears from the list When the API confirms the sale Then the position remains hidden (optimistic update removed) Scenario: User buys more of an existing position Given user has an existing position with 10 shares When user places another BUY order for the same position Then the position shows skeleton loaders And the expected accumulated values are displayed When the API confirms the order Then actual updated values replace the skeletons Scenario: API timeout cleanup Given user placed a BUY order 1 minute ago And the API has not yet confirmed When 1 minute has passed since the order Then the optimistic update is automatically cleaned up And user sees either the confirmed API position or no position ``` ## **Screenshots/Recordings** ### **Before** After placing an order, users saw stale position data with no indication that an order was processing. ### **After** After placing an order, users immediately see: - New positions appear with skeleton loaders - Existing positions update with skeleton loaders - Sold positions disappear immediately - Skeleton loaders are replaced with real values when API confirms https://github.com/user-attachments/assets/a2b08c94-f223-41a0-b85f-615abd800780 ## **Pre-merge author checklist** - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. --- ## **Technical Implementation Summary** ### Files Changed - `PolymarketProvider.ts`: +450 lines (optimistic update system) - `PredictPosition.tsx`: +29 lines (skeleton loaders) - `PredictPositionDetail.tsx`: +27 lines (skeleton loaders, disabled button) - `PredictController.ts`: +3 lines (fee calculation) - `types/index.ts`: +1 line (`optimistic?: boolean` flag) - Test files: +1,830 lines (comprehensive coverage) ### Optimistic Update Flow **BUY Order:** 1. User places BUY order → `placeOrder()` called 2. Order submitted to API via `submitClobOrder()` 3. On success, `createOrUpdateOptimisticPosition()` called 4. Optimistic position created with `optimistic: true` flag 5. `getPositions()` merges optimistic position with API data 6. UI shows skeleton loaders for value/PnL 7. When API returns position with expected size → optimistic update removed 8. If API doesn't confirm within 1 minute → optimistic update expires **SELL/CLAIM Order:** 1. User places SELL/CLAIM order → `placeOrder()` or `confirmClaim()` called 2. `removeOptimisticPosition()` marks position for removal 3. `getPositions()` filters out the position 4. Position immediately disappears from UI 5. When API confirms (position gone or size reduced) → optimistic update removed ### Test Coverage **Total: 21 new tests added** **PolymarketProvider (14 tests):** - CREATE scenarios: 7 tests (position creation, value calculation, market details) - UPDATE scenarios: 4 tests (accumulation, avgPrice, preservation) - Integration: 3 tests (BUY→confirm, timeout, BUY→SELL) **UI Components (7 tests):** - PredictPosition: 4 tests (skeleton display, value hiding) - PredictPositionDetail: 3 tests (skeleton display, button state) **Coverage Achieved:** - Statements: 91.53% ✅ - Branches: 83.01% ✅ - Functions: 91.6% ✅ - Lines: 91.75% ✅ All metrics exceed the 80% target. [PRED-294]: https://consensyssoftware.atlassian.net/browse/PRED-294?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds full optimistic position create/update/remove flow with UI skeletons, disables actions during optimism, adjusts balance for fees, and expands tests. > > - **Predict Provider (Polymarket)**: > - Implement optimistic updates system (`CREATE`/`UPDATE`/`REMOVE`) via in-memory map keyed by address and `outcomeTokenId`. > - Merge optimistic state in `getPositions` with timeout cleanup (1 min) and API-size validation tolerance. > - Block orders on claimable positions; add targeted fetch by `outcomeId` in `getPositions`. > - Apply optimistic removal on SELL and CLAIM; create/update optimistic positions on BUY using market details when available. > - **UI**: > - `PredictPosition` and `PredictPositionDetail`: show skeletons for value/PnL when `position.optimistic` is true; disable "Cash out" for optimistic positions. > - **Controller**: > - Deduct total fees from balance during optimistic BUY balance update. > - **Types/Providers API**: > - Add `optimistic?: boolean` to `PredictPosition`; add `outcomeId` to `GetPositionsParams` and wire through provider method. > - **Tests**: > - Extensive new tests covering optimistic create/update/remove flows, integration scenarios, and UI behavior. > - **Misc**: > - Minor hook lint suppression in `usePredictPrices`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 144511a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d089aea commit 338aaf4

File tree

12 files changed

+2307
-111
lines changed

12 files changed

+2307
-111
lines changed

app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const styleSheet = () =>
3636
justifyContent: 'flex-end',
3737
alignItems: 'flex-end',
3838
},
39+
skeletonSpacing: {
40+
marginBottom: 4,
41+
},
3942
marketEntry: {
4043
flexDirection: 'column',
4144
justifyContent: 'space-between',

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,33 @@ describe('PredictPosition', () => {
211211
expect(screen.getByText('$100.75')).toBeOnTheScreen();
212212
expect(screen.getByText('+15.75%')).toBeOnTheScreen();
213213
});
214+
215+
describe('optimistic updates UI', () => {
216+
it('hides current value when position is optimistic', () => {
217+
renderComponent({ optimistic: true, currentValue: 2345.67 });
218+
219+
expect(screen.queryByText('$2,345.67')).toBeNull();
220+
});
221+
222+
it('hides percent PnL when position is optimistic', () => {
223+
renderComponent({ optimistic: true, percentPnl: 5.25 });
224+
225+
expect(screen.queryByText('+5.25%')).toBeNull();
226+
});
227+
228+
it('shows actual values when position is not optimistic', () => {
229+
renderComponent({ optimistic: false });
230+
231+
expect(screen.getByText('$2,345.67')).toBeOnTheScreen();
232+
expect(screen.getByText('+5.25%')).toBeOnTheScreen();
233+
});
234+
235+
it('shows initial value line when optimistic', () => {
236+
renderComponent({ optimistic: true, initialValue: 123.45 });
237+
238+
expect(
239+
screen.getByText('$123.45 on Yes · 10 shares at 34¢'),
240+
).toBeOnTheScreen();
241+
});
242+
});
214243
});

app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import styleSheet from './PredictPosition.styles';
1616
import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
1717
import { strings } from '../../../../../../locales/i18n';
18+
import { Skeleton } from '../../../../../component-library/components/Skeleton';
1819

1920
interface PredictPositionProps {
2021
position: PredictPositionType;
@@ -34,6 +35,7 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
3435
avgPrice,
3536
currentValue,
3637
size,
38+
optimistic,
3739
} = position;
3840
const { styles } = useStyles(styleSheet, {});
3941

@@ -71,15 +73,24 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
7173
</Text>
7274
</View>
7375
<View style={styles.positionPnl}>
74-
<Text variant={TextVariant.BodyMDMedium} color={TextColor.Default}>
75-
{formatPrice(currentValue, { maximumDecimals: 2 })}
76-
</Text>
77-
<Text
78-
variant={TextVariant.BodySMMedium}
79-
color={percentPnl > 0 ? TextColor.Success : TextColor.Error}
80-
>
81-
{formatPercentage(percentPnl)}
82-
</Text>
76+
{optimistic ? (
77+
<>
78+
<Skeleton width={60} height={20} style={styles.skeletonSpacing} />
79+
<Skeleton width={50} height={16} />
80+
</>
81+
) : (
82+
<>
83+
<Text variant={TextVariant.BodyMDMedium} color={TextColor.Default}>
84+
{formatPrice(currentValue, { maximumDecimals: 2 })}
85+
</Text>
86+
<Text
87+
variant={TextVariant.BodySMMedium}
88+
color={percentPnl > 0 ? TextColor.Success : TextColor.Error}
89+
>
90+
{formatPercentage(percentPnl)}
91+
</Text>
92+
</>
93+
)}
8394
</View>
8495
</TouchableOpacity>
8596
);

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,26 @@ describe('PredictPositionDetail', () => {
250250
}),
251251
});
252252
});
253+
254+
describe('optimistic updates UI', () => {
255+
it('hides current value when position is optimistic and market is open', () => {
256+
renderComponent({ optimistic: true, currentValue: 500 });
257+
258+
expect(screen.queryByText('$500.00')).toBeNull();
259+
});
260+
261+
it('hides percent PnL when position is optimistic and market is open', () => {
262+
renderComponent({ optimistic: true, percentPnl: 12.34 });
263+
264+
expect(screen.queryByText('+12.34%')).toBeNull();
265+
});
266+
267+
it('shows initial value and outcome when position is optimistic', () => {
268+
renderComponent({ optimistic: true, initialValue: 123.45 });
269+
270+
expect(
271+
screen.getByText('$123.45 on Yes • 34¢', { exact: false }),
272+
).toBeOnTheScreen();
273+
});
274+
});
253275
});

app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PredictEventValues } from '../../constants/eventNames';
2424
import { strings } from '../../../../../../locales/i18n';
2525
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
2626
import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
27+
import { Skeleton } from '../../../../../component-library/components/Skeleton';
2728

2829
interface PredictPositionProps {
2930
position: PredictPositionType;
@@ -46,6 +47,7 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
4647
avgPrice,
4748
currentValue,
4849
title,
50+
optimistic,
4951
} = position;
5052
const navigation =
5153
useNavigation<NavigationProp<PredictNavigationParamList>>();
@@ -80,6 +82,11 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
8082
};
8183

8284
const renderValueText = () => {
85+
// Show skeleton for optimistic positions
86+
if (optimistic) {
87+
return <Skeleton width={70} height={20} />;
88+
}
89+
8390
if (marketStatus === PredictMarketStatus.OPEN) {
8491
return (
8592
<Text variant={TextVariant.BodyMDMedium} color={TextColor.Default}>
@@ -135,14 +142,17 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
135142
</Box>
136143
<Box twClassName="items-end justify-end ml-auto shrink-0">
137144
{renderValueText()}
138-
{marketStatus === PredictMarketStatus.OPEN && (
139-
<Text
140-
variant={TextVariant.BodySMMedium}
141-
color={percentPnl > 0 ? TextColor.Success : TextColor.Error}
142-
>
143-
{formatPercentage(percentPnl)}
144-
</Text>
145-
)}
145+
{marketStatus === PredictMarketStatus.OPEN &&
146+
(optimistic ? (
147+
<Skeleton width={55} height={16} style={tw.style('mt-1')} />
148+
) : (
149+
<Text
150+
variant={TextVariant.BodySMMedium}
151+
color={percentPnl > 0 ? TextColor.Success : TextColor.Error}
152+
>
153+
{formatPercentage(percentPnl)}
154+
</Text>
155+
))}
146156
</Box>
147157
</Box>
148158
{marketStatus === PredictMarketStatus.OPEN && (
@@ -156,6 +166,7 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
156166
width={ButtonWidthTypes.Full}
157167
label={strings('predict.cash_out')}
158168
onPress={onCashOut}
169+
isDisabled={optimistic}
159170
/>
160171
</Box>
161172
)}

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1168,14 +1168,15 @@ export class PredictController extends BaseController<
11681168
let realSharePrice = sharePrice;
11691169
try {
11701170
if (preview.side === Side.BUY) {
1171+
const totalFee = params.preview.fees?.totalFee ?? 0;
11711172
realAmountUsd = parseFloat(spentAmount);
11721173
realSharePrice = parseFloat(spentAmount) / parseFloat(receivedAmount);
11731174

11741175
// Optimistically update balance
11751176
this.update((state) => {
11761177
state.balances[providerId] = state.balances[providerId] || {};
11771178
state.balances[providerId][signer.address] = {
1178-
balance: cachedBalance - realAmountUsd,
1179+
balance: cachedBalance - (realAmountUsd + totalFee),
11791180
// valid for 5 seconds (since it takes some time to reflect balance on-chain)
11801181
validUntil: Date.now() + 5000,
11811182
};

app/components/UI/Predict/hooks/usePredictPrices.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const usePredictPrices = (
147147
setIsFetching(false);
148148
}
149149
}
150+
// eslint-disable-next-line react-compiler/react-compiler
150151
// eslint-disable-next-line react-hooks/exhaustive-deps
151152
}, [enabled, queriesKey, providerId, pollingInterval]);
152153

0 commit comments

Comments
 (0)