Skip to content

Commit dddee92

Browse files
kevinbluermatallui
andauthored
feat(predict): Adds CLOB pricing refresh to details view (MetaMask#22261)
<!-- 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** - Adds a look to the CLOB `/prices` [endpoint](https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request) to get the latest prices for all the outcomes displayed within market details. - This price should then pass through to the buy preview screen so it's consistent when the additional lookup occurs there. - See example of the pricing update in the recording below <!-- 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: [PRED-263](https://consensyssoftware.atlassian.net/browse/PRED-263) ## **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** https://github.com/user-attachments/assets/48e865d1-1cf0-447f-a5fe-5a02417de580 <!-- [screenshots/recordings] --> ## **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. [PRED-263]: https://consensyssoftware.atlassian.net/browse/PRED-263?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces provider/controller API and React hook to fetch current BUY/SELL prices from Polymarket CLOB and applies real-time prices in market details, with comprehensive tests. > > - **Backend/Controller**: > - Add `PredictController.getPrices` with error/state handling, defaulting to `polymarket` (`app/components/UI/Predict/controllers/PredictController.ts`). > - Extend types with `GetPriceParams`, `GetPriceResponse`, `PriceQuery`, `PriceResult`, `PriceEntry` (`types/index.ts`). > - **Provider (Polymarket)**: > - Implement `getPrices` calling CLOB `POST /prices`, returning structured BUY/SELL entries; robust error handling and logging (`providers/polymarket/PolymarketProvider.ts`). > - Expose `getPrices` in `PredictProvider` interface (`providers/types.ts`). > - **Hook**: > - New `usePredictPrices` for fetching/pruning/polling prices with error handling and manual `refetch` (`hooks/usePredictPrices.tsx`). > - **UI**: > - `PredictMarketDetails` uses `usePredictPrices` to update open outcomes and action button prices in real time; falls back gracefully; integrates with existing tabs and chart (`views/PredictMarketDetails/PredictMarketDetails.tsx`). > - **Tests**: > - Add thorough tests for controller `getPrices`, provider `getPrices`, hook behavior (polling, errors, reactivity), and view integration (`*.test.ts/tsx`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d22efd5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 7df31cf commit dddee92

10 files changed

Lines changed: 2115 additions & 27 deletions

File tree

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

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,249 @@ describe('PredictController', () => {
770770
});
771771
});
772772

773+
describe('getPrices', () => {
774+
const mockPrices = {
775+
'token-1': { BUY: 0.65, SELL: 0.64 },
776+
'token-2': { BUY: 0.35, SELL: 0.34 },
777+
};
778+
779+
it('get prices successfully with single provider', async () => {
780+
await withController(async ({ controller }) => {
781+
mockPolymarketProvider.getPrices = jest
782+
.fn()
783+
.mockResolvedValue(mockPrices);
784+
785+
const result = await controller.getPrices({
786+
queries: [
787+
{
788+
marketId: 'market-1',
789+
outcomeId: 'outcome-1',
790+
outcomeTokenId: 'token-1',
791+
},
792+
{
793+
marketId: 'market-2',
794+
outcomeId: 'outcome-2',
795+
outcomeTokenId: 'token-2',
796+
},
797+
],
798+
providerId: 'polymarket',
799+
});
800+
801+
expect(result).toEqual(mockPrices);
802+
expect(controller.state.lastError).toBeNull();
803+
expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0);
804+
expect(mockPolymarketProvider.getPrices).toHaveBeenCalledWith({
805+
queries: [
806+
{
807+
marketId: 'market-1',
808+
outcomeId: 'outcome-1',
809+
outcomeTokenId: 'token-1',
810+
},
811+
{
812+
marketId: 'market-2',
813+
outcomeId: 'outcome-2',
814+
outcomeTokenId: 'token-2',
815+
},
816+
],
817+
});
818+
});
819+
});
820+
821+
it('default to polymarket provider when providerId is not specified', async () => {
822+
await withController(async ({ controller }) => {
823+
mockPolymarketProvider.getPrices = jest
824+
.fn()
825+
.mockResolvedValue(mockPrices);
826+
827+
const result = await controller.getPrices({
828+
queries: [
829+
{
830+
marketId: 'market-1',
831+
outcomeId: 'outcome-1',
832+
outcomeTokenId: 'token-1',
833+
},
834+
],
835+
providerId: 'polymarket',
836+
});
837+
838+
expect(result).toEqual(mockPrices);
839+
expect(mockPolymarketProvider.getPrices).toHaveBeenCalled();
840+
});
841+
});
842+
843+
it('handle empty bookParams array', async () => {
844+
await withController(async ({ controller }) => {
845+
mockPolymarketProvider.getPrices = jest
846+
.fn()
847+
.mockResolvedValue({ providerId: 'polymarket', results: [] });
848+
849+
const result = await controller.getPrices({
850+
queries: [],
851+
providerId: 'polymarket',
852+
});
853+
854+
expect(result).toEqual({ providerId: 'polymarket', results: [] });
855+
expect(controller.state.lastError).toBeNull();
856+
});
857+
});
858+
859+
it('handle prices with only BUY side', async () => {
860+
const mockBuyPrices = {
861+
'token-1': { BUY: 0.65 },
862+
'token-2': { BUY: 0.35 },
863+
};
864+
865+
await withController(async ({ controller }) => {
866+
mockPolymarketProvider.getPrices = jest
867+
.fn()
868+
.mockResolvedValue(mockBuyPrices);
869+
870+
const result = await controller.getPrices({
871+
queries: [
872+
{
873+
marketId: 'market-1',
874+
outcomeId: 'outcome-1',
875+
outcomeTokenId: 'token-1',
876+
},
877+
{
878+
marketId: 'market-2',
879+
outcomeId: 'outcome-2',
880+
outcomeTokenId: 'token-2',
881+
},
882+
],
883+
providerId: 'polymarket',
884+
});
885+
886+
expect(result).toEqual(mockBuyPrices);
887+
});
888+
});
889+
890+
it('handle prices with only SELL side', async () => {
891+
const mockSellPrices = {
892+
'token-1': { SELL: 0.64 },
893+
'token-2': { SELL: 0.34 },
894+
};
895+
896+
await withController(async ({ controller }) => {
897+
mockPolymarketProvider.getPrices = jest
898+
.fn()
899+
.mockResolvedValue(mockSellPrices);
900+
901+
const result = await controller.getPrices({
902+
queries: [
903+
{
904+
marketId: 'market-1',
905+
outcomeId: 'outcome-1',
906+
outcomeTokenId: 'token-1',
907+
},
908+
{
909+
marketId: 'market-2',
910+
outcomeId: 'outcome-2',
911+
outcomeTokenId: 'token-2',
912+
},
913+
],
914+
providerId: 'polymarket',
915+
});
916+
917+
expect(result).toEqual(mockSellPrices);
918+
});
919+
});
920+
921+
it('throw error when provider is not available', async () => {
922+
await withController(async ({ controller }) => {
923+
await expect(
924+
controller.getPrices({
925+
queries: [
926+
{
927+
marketId: 'market-1',
928+
outcomeId: 'outcome-1',
929+
outcomeTokenId: 'token-1',
930+
},
931+
],
932+
providerId: 'nonexistent',
933+
}),
934+
).rejects.toThrow('Provider not available');
935+
936+
expect(controller.state.lastError).toBe('Provider not available');
937+
expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0);
938+
});
939+
});
940+
941+
it('handle error when getPrices throws', async () => {
942+
await withController(async ({ controller }) => {
943+
const errorMessage = 'Failed to fetch prices';
944+
mockPolymarketProvider.getPrices = jest
945+
.fn()
946+
.mockRejectedValue(new Error(errorMessage));
947+
948+
await expect(
949+
controller.getPrices({
950+
queries: [
951+
{
952+
marketId: 'market-1',
953+
outcomeId: 'outcome-1',
954+
outcomeTokenId: 'token-1',
955+
},
956+
],
957+
providerId: 'polymarket',
958+
}),
959+
).rejects.toThrow(errorMessage);
960+
961+
expect(controller.state.lastError).toBe(errorMessage);
962+
expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0);
963+
});
964+
});
965+
966+
it('handle non-Error objects thrown by getPrices', async () => {
967+
await withController(async ({ controller }) => {
968+
mockPolymarketProvider.getPrices = jest
969+
.fn()
970+
.mockRejectedValue('String error');
971+
972+
await expect(
973+
controller.getPrices({
974+
queries: [
975+
{
976+
marketId: 'market-1',
977+
outcomeId: 'outcome-1',
978+
outcomeTokenId: 'token-1',
979+
},
980+
],
981+
providerId: 'polymarket',
982+
}),
983+
).rejects.toBe('String error');
984+
985+
expect(controller.state.lastError).toBe('PREDICT_UNKNOWN_ERROR');
986+
});
987+
});
988+
989+
it('clear previous errors on successful call', async () => {
990+
await withController(async ({ controller }) => {
991+
// First, set an error state
992+
controller.updateStateForTesting((state) => {
993+
state.lastError = 'Previous error';
994+
});
995+
996+
mockPolymarketProvider.getPrices = jest
997+
.fn()
998+
.mockResolvedValue(mockPrices);
999+
1000+
await controller.getPrices({
1001+
queries: [
1002+
{
1003+
marketId: 'market-1',
1004+
outcomeId: 'outcome-1',
1005+
outcomeTokenId: 'token-1',
1006+
},
1007+
],
1008+
providerId: 'polymarket',
1009+
});
1010+
1011+
expect(controller.state.lastError).toBeNull();
1012+
});
1013+
});
1014+
});
1015+
7731016
describe('placeOrder', () => {
7741017
it('place order successfully via provider', async () => {
7751018
const mockResult = {

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
AcceptAgreementParams,
5555
ClaimParams,
5656
GetPriceHistoryParams,
57+
GetPriceParams,
58+
GetPriceResponse,
5759
PredictAccountMeta,
5860
PredictActivity,
5961
PredictBalance,
@@ -601,6 +603,54 @@ export class PredictController extends BaseController<
601603
}
602604
}
603605

606+
/**
607+
* Get current prices for multiple tokens
608+
*
609+
* Fetches BUY (best ask) and SELL (best bid) prices from the provider.
610+
* BUY = what you'd pay to buy
611+
* SELL = what you'd receive to sell
612+
*/
613+
async getPrices(params: GetPriceParams): Promise<GetPriceResponse> {
614+
try {
615+
const providerId = params.providerId ?? 'polymarket';
616+
const provider = this.providers.get(providerId);
617+
618+
if (!provider) {
619+
throw new Error('Provider not available');
620+
}
621+
622+
const response = await provider.getPrices({ queries: params.queries });
623+
624+
this.update((state) => {
625+
state.lastError = null;
626+
state.lastUpdateTimestamp = Date.now();
627+
});
628+
629+
return response;
630+
} catch (error) {
631+
const errorMessage =
632+
error instanceof Error
633+
? error.message
634+
: PREDICT_ERROR_CODES.UNKNOWN_ERROR;
635+
636+
this.update((state) => {
637+
state.lastError = errorMessage;
638+
state.lastUpdateTimestamp = Date.now();
639+
});
640+
641+
// Log to Sentry with prices context
642+
Logger.error(
643+
ensureError(error),
644+
this.getErrorContext('getPrices', {
645+
providerId: params.providerId,
646+
queriesCount: params.queries?.length,
647+
}),
648+
);
649+
650+
throw error;
651+
}
652+
}
653+
604654
/**
605655
* Get user positions
606656
*/

0 commit comments

Comments
 (0)