Skip to content

Commit 721177c

Browse files
authored
feat(predict): relay sell orders (MetaMask#22290)
## **Description** This PR enables relaying of SELL orders through the MetaMask Predict API relay, similar to how BUY orders are already relayed. Previously, only BUY orders were sent through the relay to collect fees, while SELL orders went directly to the Polymarket CLOB endpoint. **Reason for change:** - SELL orders should also be relayed to enable consistent fee collection and monitoring across all order types - Provides better control and observability for all trading activity **Improvements:** - All orders (BUY and SELL) now route through the `CLOB_RELAYER` endpoint - Rate limiting now applies to both BUY and SELL orders based on the last BUY order timestamp - Simplified order submission logic by removing conditional endpoint selection - Updated `OrderResponse` type fields to be optional for better error handling - Renamed `BUY_ORDER_RATE_LIMIT_MS` to `ORDER_RATE_LIMIT_MS` to reflect that rate limiting applies to all orders ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: Polymarket SELL order relaying Scenario: user places a SELL order Given user has an open position in a Polymarket market And user is connected to Polygon mainnet And the Predict feature is enabled When user navigates to the position details And user initiates a SELL order And user confirms the transaction Then the order should be relayed through the MetaMask API endpoint And the order should be successfully submitted to Polymarket And fees should be collected appropriately Scenario: user is rate limited after placing orders Given user has placed a BUY order within the last 5 seconds When user attempts to preview a new order (BUY or SELL) Then the preview should show rateLimited: true And user should be prevented from placing another order too quickly ``` ## **Screenshots/Recordings** N/A - Backend/API changes only, no UI changes ### **Before** - BUY orders → CLOB_RELAYER endpoint (with fees) - SELL orders → Direct to Polymarket CLOB endpoint (no fees) - Rate limiting only applied to BUY order previews ### **After** - BUY orders → CLOB_RELAYER endpoint (with fees) - SELL orders → CLOB_RELAYER endpoint (no fees) - Rate limiting applies to all order previews after a BUY order ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Routes both BUY and SELL orders through the MetaMask Predict relayer, applies unified rate limiting, and updates response handling/types with corresponding tests. > > - **Order routing (utils/submitClobOrder)**: > - Always post to `CLOB_RELAYER` (`/order`), removing conditional CLOB endpoint usage. > - Send both underscore and dash variants of `POLY_*` headers; include `feeAuthorization` when provided. > - **Rate limiting**: > - Rename `BUY_ORDER_RATE_LIMIT_MS` to `ORDER_RATE_LIMIT_MS` and apply in `PolymarketProvider.isRateLimited`. > - `previewOrder` now rate-limits when a `signer` is present (affects SELL after a recent BUY). > - **Order handling**: > - `OrderResponse` fields (`errorMsg`, `makingAmount`, `orderID`, `status`, `takingAmount`, `transactionsHashes`) made optional. > - `placeOrder` checks top-level `success` and nested `response.success`; returns `errorMsg` when present. > - **Tests**: > - Update submit order tests to expect relayer URL, dual-format headers, and serialized `feeAuthorization`. > - Adjust expectations for `response` shape (includes `success: true`). > - Update rate limit test to expect SELL previews to be rate-limited after a BUY. > - Remove unused `errorCode` from 403 path expectations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33a2d3e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 461614b commit 721177c

6 files changed

Lines changed: 100 additions & 48 deletions

File tree

app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,7 @@ describe('PolymarketProvider', () => {
823823
mockSubmitClobOrder.mockResolvedValue({
824824
success: true,
825825
response: {
826+
success: true,
826827
makingAmount: '1000000',
827828
orderID: 'order-123',
828829
status: 'success',
@@ -1131,7 +1132,7 @@ describe('PolymarketProvider', () => {
11311132
});
11321133
mockSubmitClobOrder.mockResolvedValue({
11331134
success: true,
1134-
response: { orderId: 'test-order' },
1135+
response: { success: true, orderId: 'test-order' },
11351136
error: undefined,
11361137
});
11371138
mockCreateApiKey.mockResolvedValue({
@@ -2599,7 +2600,7 @@ describe('PolymarketProvider', () => {
25992600
});
26002601
};
26012602

2602-
it('does not set rateLimited for SELL orders', async () => {
2603+
it('sets rateLimited for SELL orders after BUY order', async () => {
26032604
setupPreviewOrderMock();
26042605
const { provider, mockSigner } = setupPlaceOrderTest();
26052606

@@ -2610,7 +2611,7 @@ describe('PolymarketProvider', () => {
26102611
preview,
26112612
});
26122613

2613-
// Now try to preview a SELL order - should NOT be rate limited
2614+
// Now try to preview a SELL order - should also be rate limited
26142615
const sellPreview = await provider.previewOrder({
26152616
marketId: 'market-1',
26162617
outcomeId: 'outcome-1',
@@ -2620,7 +2621,7 @@ describe('PolymarketProvider', () => {
26202621
signer: mockSigner,
26212622
});
26222623

2623-
expect(sellPreview.rateLimited).toBeUndefined();
2624+
expect(sellPreview.rateLimited).toBe(true);
26242625
});
26252626

26262627
it('does not set rateLimited when signer is not provided', async () => {

app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from '../types';
4545
import { PREDICT_CONSTANTS } from '../../constants/errors';
4646
import {
47-
BUY_ORDER_RATE_LIMIT_MS,
47+
ORDER_RATE_LIMIT_MS,
4848
FEE_COLLECTOR_ADDRESS,
4949
MATIC_CONTRACTS,
5050
POLYGON_MAINNET_CHAIN_ID,
@@ -203,7 +203,7 @@ export class PolymarketProvider implements PredictProvider {
203203
return false;
204204
}
205205
const elapsed = Date.now() - lastTimestamp;
206-
return elapsed < BUY_ORDER_RATE_LIMIT_MS;
206+
return elapsed < ORDER_RATE_LIMIT_MS;
207207
}
208208

209209
public async getMarkets(params?: GetMarketsParams): Promise<PredictMarket[]> {
@@ -463,7 +463,7 @@ export class PolymarketProvider implements PredictProvider {
463463
): Promise<OrderPreview> {
464464
const basePreview = await previewOrder(params);
465465

466-
if (params.side === Side.BUY && params.signer) {
466+
if (params.signer) {
467467
if (this.isRateLimited(params.signer.address)) {
468468
return {
469469
...basePreview,
@@ -603,13 +603,20 @@ export class PolymarketProvider implements PredictProvider {
603603
feeAuthorization,
604604
});
605605

606-
if (!response) {
606+
if (!success) {
607607
return {
608608
success,
609609
error,
610610
} as OrderResult;
611611
}
612612

613+
if (!response.success) {
614+
return {
615+
success: false,
616+
error: response.errorMsg,
617+
} as OrderResult;
618+
}
619+
613620
if (side === Side.BUY) {
614621
this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now());
615622
} else if (positionId) {

app/components/UI/Predict/providers/polymarket/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const FEE_COLLECTOR_ADDRESS =
1313
*/
1414
export const SLIPPAGE = 0.015; // 1.5%
1515

16-
export const BUY_ORDER_RATE_LIMIT_MS = 5000;
16+
export const ORDER_RATE_LIMIT_MS = 5000;
1717

1818
export const POLYGON_MAINNET_CHAIN_ID = 137;
1919
export const POLYGON_MAINNET_CAIP_CHAIN_ID =

app/components/UI/Predict/providers/polymarket/types.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,13 +302,13 @@ export interface L2HeaderArgs {
302302

303303
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
304304
export type OrderResponse = {
305-
errorMsg: string;
306-
makingAmount: string;
307-
orderID: string;
308-
status: string;
305+
errorMsg?: string;
306+
makingAmount?: string;
307+
orderID?: string;
308+
status?: string;
309309
success: boolean;
310-
takingAmount: string;
311-
transactionsHashes: string[];
310+
takingAmount?: string;
311+
transactionsHashes?: string[];
312312
};
313313

314314
export interface TickSizeResponse {

app/components/UI/Predict/providers/polymarket/utils.test.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -712,11 +712,25 @@ describe('polymarket utils', () => {
712712
response: mockOrderResponse,
713713
});
714714
expect(mockFetch).toHaveBeenCalledWith(
715-
'https://clob.polymarket.com/order',
715+
'https://predict.api.cx.metamask.io/order',
716716
{
717717
method: 'POST',
718-
headers: mockHeaders,
719-
body: JSON.stringify(mockClobOrder),
718+
headers: {
719+
POLY_ADDRESS: mockAddress,
720+
POLY_SIGNATURE: 'test-signature_',
721+
POLY_TIMESTAMP: '1704067200',
722+
POLY_API_KEY: 'test-api-key',
723+
POLY_PASSPHRASE: 'test-passphrase',
724+
'POLY-ADDRESS': mockAddress,
725+
'POLY-SIGNATURE': 'test-signature_',
726+
'POLY-TIMESTAMP': '1704067200',
727+
'POLY-API-KEY': 'test-api-key',
728+
'POLY-PASSPHRASE': 'test-passphrase',
729+
},
730+
body: JSON.stringify({
731+
...mockClobOrder,
732+
feeAuthorization: undefined,
733+
}),
720734
},
721735
);
722736
});
@@ -781,12 +795,24 @@ describe('polymarket utils', () => {
781795
});
782796

783797
expect(mockFetch).toHaveBeenCalledWith(
784-
'https://clob.polymarket.com/order',
798+
'https://predict.api.cx.metamask.io/order',
785799
{
786800
method: 'POST',
787-
headers: mockHeaders,
801+
headers: {
802+
POLY_ADDRESS: mockAddress,
803+
POLY_SIGNATURE: 'test-signature_',
804+
POLY_TIMESTAMP: '1704067200',
805+
POLY_API_KEY: 'test-api-key',
806+
POLY_PASSPHRASE: 'test-passphrase',
807+
'POLY-ADDRESS': mockAddress,
808+
'POLY-SIGNATURE': 'test-signature_',
809+
'POLY-TIMESTAMP': '1704067200',
810+
'POLY-API-KEY': 'test-api-key',
811+
'POLY-PASSPHRASE': 'test-passphrase',
812+
},
788813
body: JSON.stringify({
789814
...mockClobOrder,
815+
feeAuthorization: undefined,
790816
}),
791817
},
792818
);
@@ -820,25 +846,37 @@ describe('polymarket utils', () => {
820846
expect(parsedBody.feeAuthorization).toEqual(feeAuthorization);
821847
});
822848

823-
it('uses CLOB endpoint when feeAuthorization is not provided for BUY orders', async () => {
849+
it('uses CLOB_RELAYER endpoint when feeAuthorization is not provided for BUY orders', async () => {
824850
await submitClobOrder({
825851
headers: mockHeaders,
826852
clobOrder: mockClobOrder,
827853
});
828854

829855
expect(mockFetch).toHaveBeenCalledWith(
830-
'https://clob.polymarket.com/order',
856+
'https://predict.api.cx.metamask.io/order',
831857
{
832858
method: 'POST',
833-
headers: mockHeaders,
859+
headers: {
860+
POLY_ADDRESS: mockAddress,
861+
POLY_SIGNATURE: 'test-signature_',
862+
POLY_TIMESTAMP: '1704067200',
863+
POLY_API_KEY: 'test-api-key',
864+
POLY_PASSPHRASE: 'test-passphrase',
865+
'POLY-ADDRESS': mockAddress,
866+
'POLY-SIGNATURE': 'test-signature_',
867+
'POLY-TIMESTAMP': '1704067200',
868+
'POLY-API-KEY': 'test-api-key',
869+
'POLY-PASSPHRASE': 'test-passphrase',
870+
},
834871
body: JSON.stringify({
835872
...mockClobOrder,
873+
feeAuthorization: undefined,
836874
}),
837875
},
838876
);
839877
});
840878

841-
it('uses CLOB endpoint for SELL orders even with feeAuthorization', async () => {
879+
it('uses CLOB_RELAYER endpoint for SELL orders with feeAuthorization', async () => {
842880
const sellClobOrder: ClobOrderObject = {
843881
...mockClobOrder,
844882
order: {
@@ -867,12 +905,24 @@ describe('polymarket utils', () => {
867905
});
868906

869907
expect(mockFetch).toHaveBeenCalledWith(
870-
'https://clob.polymarket.com/order',
908+
'https://predict.api.cx.metamask.io/order',
871909
{
872910
method: 'POST',
873-
headers: mockHeaders,
911+
headers: {
912+
POLY_ADDRESS: mockAddress,
913+
POLY_SIGNATURE: 'test-signature_',
914+
POLY_TIMESTAMP: '1704067200',
915+
POLY_API_KEY: 'test-api-key',
916+
POLY_PASSPHRASE: 'test-passphrase',
917+
'POLY-ADDRESS': mockAddress,
918+
'POLY-SIGNATURE': 'test-signature_',
919+
'POLY-TIMESTAMP': '1704067200',
920+
'POLY-API-KEY': 'test-api-key',
921+
'POLY-PASSPHRASE': 'test-passphrase',
922+
},
874923
body: JSON.stringify({
875924
...sellClobOrder,
925+
feeAuthorization,
876926
}),
877927
},
878928
);
@@ -1894,7 +1944,6 @@ describe('polymarket utils', () => {
18941944
expect(result).toEqual({
18951945
success: false,
18961946
error: 'You are unable to access this provider.',
1897-
errorCode: 403,
18981947
});
18991948
});
19001949

app/components/UI/Predict/providers/polymarket/utils.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type PredictMarket,
1616
type PredictPosition,
1717
PredictActivity,
18+
Result,
1819
} from '../../types';
1920
import { getRecurrence } from '../../utils/format';
2021
import type {
@@ -324,29 +325,24 @@ export const submitClobOrder = async ({
324325
headers: ClobHeaders;
325326
clobOrder: ClobOrderObject;
326327
feeAuthorization?: SafeFeeAuthorization;
327-
}) => {
328-
const { CLOB_ENDPOINT, CLOB_RELAYER } = getPolymarketEndpoints();
329-
let url = `${CLOB_ENDPOINT}/order`;
330-
let body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = {
328+
}): Promise<Result<OrderResponse>> => {
329+
const { CLOB_RELAYER } = getPolymarketEndpoints();
330+
const url = `${CLOB_RELAYER}/order`;
331+
const body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = {
331332
...clobOrder,
333+
feeAuthorization,
332334
};
333335

334-
// If a feeAuthorization is provided, we need to use our clob
335-
// relayer to submit the order and collect the fee.
336-
if (clobOrder.order.side === Side.BUY && feeAuthorization) {
337-
url = `${CLOB_RELAYER}/order`;
338-
body = { ...body, feeAuthorization };
339-
// For our relayer, we need to replace the underscores with dashes
340-
// since underscores are not standardly allowed in headers
341-
headers = {
342-
...headers,
343-
...Object.entries(headers)
344-
.map(([key, value]) => ({
345-
[key.replace(/_/g, '-')]: value,
346-
}))
347-
.reduce((acc, curr) => ({ ...acc, ...curr }), {}),
348-
};
349-
}
336+
// For our relayer, we need to replace the underscores with dashes
337+
// since underscores are not standardly allowed in headers
338+
headers = {
339+
...headers,
340+
...Object.entries(headers)
341+
.map(([key, value]) => ({
342+
[key.replace(/_/g, '-')]: value,
343+
}))
344+
.reduce((acc, curr) => ({ ...acc, ...curr }), {}),
345+
};
350346

351347
const response = await fetch(url, {
352348
method: 'POST',
@@ -359,7 +355,6 @@ export const submitClobOrder = async ({
359355
return {
360356
success: false,
361357
error: 'You are unable to access this provider.',
362-
errorCode: response.status,
363358
};
364359
}
365360
const responseData = await response.json();

0 commit comments

Comments
 (0)