Skip to content

Commit 2ec96ff

Browse files
authored
fix(predict): refresh balance/allowance before Polymarket order submission (MetaMask#26954)
## **Description** Polymarket's CLOB infrastructure is intermittently returning 400 errors with "not enough balance / allowance" when placing orders at high request rates. As a temporary workaround communicated by the Polymarket team, we now call `GET /balance-allowance/update` before each order to refresh the balance/allowance state on their end. ### Changes: - **`utils.ts`**: Add `refreshBalanceAllowance()` utility that calls the CLOB balance-allowance/update endpoint with L2 HMAC auth headers - BUY orders → `asset_type=COLLATERAL` (USDC) - SELL orders → `asset_type=CONDITIONAL` with the order's `token_id` - **`PolymarketProvider.ts`**: Call `refreshBalanceAllowance()` in `placeOrder()` right before `submitClobOrder()`, wrapped in try/catch so failures don't block order submission - **`utils.test.ts`**: 5 new tests covering BUY/SELL paths, auth headers, error resilience, and custom signature types - **`PolymarketProvider.test.ts`**: 4 new integration tests verifying call ordering, parameter passing for both sides, and graceful degradation on refresh failure > **Note**: This is a temporary workaround. TODO comments are in place for removal once Polymarket resolves the underlying infrastructure issue. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-726 ## **Manual testing steps** ```gherkin Feature: Polymarket order placement with balance/allowance refresh Scenario: user places a BUY order on a prediction market Given the user has USDC deposited in their Polymarket proxy wallet When user places a BUY order on any market Then the balance/allowance refresh call fires before the order submission And the order completes successfully Scenario: user places a SELL order on a prediction market Given the user holds outcome tokens for a market When user places a SELL order Then the balance/allowance refresh call fires with the token_id before the order And the order completes successfully Scenario: balance/allowance refresh endpoint is down Given the refresh endpoint returns an error When user places any order Then the order submission still proceeds normally ``` ## **Screenshots/Recordings** N/A — no UI changes ## **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] > **Medium Risk** > Adds a new preflight network call in the order placement path; while failures are best-effort and don’t block orders, it changes timing/behavior in a core trading flow and could affect reliability or rate limits. > > **Overview** > Adds a temporary workaround for intermittent Polymarket CLOB `not enough balance / allowance` errors by introducing `refreshBalanceAllowance()` (calls `GET /balance-allowance/update` with L2 HMAC headers) and invoking it in `PolymarketProvider.placeOrder()` immediately before `submitClobOrder()`. > > The refresh is **best-effort** (wrapped in `try/catch` with logging) and varies request params by side: **BUY** refreshes `asset_type=COLLATERAL`, **SELL** refreshes `asset_type=CONDITIONAL` with `token_id`. Updates unit/integration tests to cover parameter selection, call ordering, header usage, and non-blocking behavior on refresh failure. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8a46817. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dbd907a commit 2ec96ff

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
parsePolymarketPositions,
7070
previewOrder,
7171
priceValid,
72+
refreshBalanceAllowance,
7273
submitClobOrder,
7374
} from './utils';
7475

@@ -109,6 +110,7 @@ jest.mock('./utils', () => {
109110
priceValid: jest.fn(),
110111
createApiKey: jest.fn(),
111112
submitClobOrder: jest.fn(),
113+
refreshBalanceAllowance: jest.fn(),
112114
getMarketPositions: jest.fn(),
113115
getBalance: jest.fn(),
114116
previewOrder: jest.fn(),
@@ -212,6 +214,7 @@ const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock;
212214
const mockPriceValid = priceValid as jest.Mock;
213215
const mockCreateApiKey = createApiKey as jest.Mock;
214216
const mockSubmitClobOrder = submitClobOrder as jest.Mock;
217+
const mockRefreshBalanceAllowance = refreshBalanceAllowance as jest.Mock;
215218
const mockEncodeClaim = encodeClaim as jest.Mock;
216219
const mockComputeProxyAddress = computeProxyAddress as jest.Mock;
217220
const mockCreatePermit2FeeAuthorization =
@@ -1603,6 +1606,97 @@ describe('PolymarketProvider', () => {
16031606
});
16041607
});
16051608

1609+
describe('placeOrder balance/allowance refresh workaround', () => {
1610+
beforeEach(() => {
1611+
jest.clearAllMocks();
1612+
mockRefreshBalanceAllowance.mockResolvedValue(undefined);
1613+
});
1614+
1615+
it('calls refreshBalanceAllowance with COLLATERAL before submitting a BUY order', async () => {
1616+
// Arrange
1617+
const { provider, mockSigner } = setupPlaceOrderTest();
1618+
const preview = createMockOrderPreview({ side: Side.BUY });
1619+
1620+
// Act
1621+
await provider.placeOrder({ signer: mockSigner, preview });
1622+
1623+
// Assert
1624+
expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({
1625+
address: mockSigner.address,
1626+
apiKey: expect.objectContaining({ apiKey: 'test-api-key' }),
1627+
side: Side.BUY,
1628+
outcomeTokenId: preview.outcomeTokenId,
1629+
});
1630+
});
1631+
1632+
it('calls refreshBalanceAllowance with CONDITIONAL before submitting a SELL order', async () => {
1633+
// Arrange
1634+
const { provider, mockSigner } = setupPlaceOrderTest();
1635+
const preview = createMockOrderPreview({ side: Side.SELL });
1636+
1637+
// Act
1638+
await provider.placeOrder({ signer: mockSigner, preview });
1639+
1640+
// Assert
1641+
expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({
1642+
address: mockSigner.address,
1643+
apiKey: expect.objectContaining({ apiKey: 'test-api-key' }),
1644+
side: Side.SELL,
1645+
outcomeTokenId: preview.outcomeTokenId,
1646+
});
1647+
});
1648+
1649+
it('calls refreshBalanceAllowance before submitClobOrder', async () => {
1650+
// Arrange
1651+
const { provider, mockSigner } = setupPlaceOrderTest();
1652+
const callOrder: string[] = [];
1653+
mockRefreshBalanceAllowance.mockImplementation(async () => {
1654+
callOrder.push('refresh');
1655+
});
1656+
mockSubmitClobOrder.mockImplementation(async () => {
1657+
callOrder.push('submit');
1658+
return {
1659+
success: true,
1660+
response: {
1661+
success: true,
1662+
makingAmount: '1000000',
1663+
orderID: 'order-123',
1664+
status: 'success',
1665+
takingAmount: '0',
1666+
transactionsHashes: [],
1667+
},
1668+
error: undefined,
1669+
};
1670+
});
1671+
const preview = createMockOrderPreview({ side: Side.BUY });
1672+
1673+
// Act
1674+
await provider.placeOrder({ signer: mockSigner, preview });
1675+
1676+
// Assert
1677+
expect(callOrder).toEqual(['refresh', 'submit']);
1678+
});
1679+
1680+
it('proceeds with order submission when refreshBalanceAllowance fails', async () => {
1681+
// Arrange
1682+
const { provider, mockSigner } = setupPlaceOrderTest();
1683+
mockRefreshBalanceAllowance.mockRejectedValue(
1684+
new Error('Network timeout'),
1685+
);
1686+
const preview = createMockOrderPreview({ side: Side.BUY });
1687+
1688+
// Act
1689+
const result = await provider.placeOrder({
1690+
signer: mockSigner,
1691+
preview,
1692+
});
1693+
1694+
// Assert - order still submitted despite refresh failure
1695+
expect(mockSubmitClobOrder).toHaveBeenCalled();
1696+
expect(result.success).toBe(true);
1697+
});
1698+
});
1699+
16061700
describe('placeOrder with Safe fee authorization', () => {
16071701
it('computes Safe address before creating order', async () => {
16081702
const { provider, mockSigner } = setupPlaceOrderTest();

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
parsePolymarketEvents,
103103
parsePolymarketPositions,
104104
previewOrder,
105+
refreshBalanceAllowance,
105106
roundOrderAmount,
106107
submitClobOrder,
107108
} from './utils';
@@ -1306,6 +1307,23 @@ export class PolymarketProvider implements PredictProvider {
13061307
apiKey: signerApiKey,
13071308
});
13081309

1310+
// TEMPORARY WORKAROUND: Refresh balance/allowance on Polymarket's CLOB
1311+
// before submitting the order. See refreshBalanceAllowance docs for details.
1312+
try {
1313+
await refreshBalanceAllowance({
1314+
address: signer.address,
1315+
apiKey: signerApiKey,
1316+
side,
1317+
outcomeTokenId,
1318+
});
1319+
} catch (refreshError) {
1320+
// Best-effort — don't block order submission if the refresh fails
1321+
DevLogger.log(
1322+
'PolymarketProvider: Pre-order balance/allowance refresh failed',
1323+
refreshError,
1324+
);
1325+
}
1326+
13091327
const { success, response, error } = await submitClobOrder({
13101328
headers,
13111329
clobOrder,

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
parsePolymarketPositions,
6161
parsePolymarketActivity,
6262
priceValid,
63+
refreshBalanceAllowance,
6364
submitClobOrder,
6465
decimalPlaces,
6566
roundNormal,
@@ -701,6 +702,100 @@ describe('polymarket utils', () => {
701702
});
702703
});
703704

705+
describe('refreshBalanceAllowance', () => {
706+
beforeEach(() => {
707+
mockFetch.mockReset();
708+
(global.crypto as any).createHmac.mockReturnValue({
709+
update: jest.fn().mockReturnThis(),
710+
digest: jest.fn().mockReturnValue('mock-digest-base64'),
711+
});
712+
});
713+
714+
it('sends COLLATERAL asset_type for BUY orders', async () => {
715+
mockFetch.mockResolvedValue({ ok: true });
716+
717+
await refreshBalanceAllowance({
718+
address: mockAddress,
719+
apiKey: mockApiKey,
720+
side: Side.BUY,
721+
outcomeTokenId: 'token-123',
722+
});
723+
724+
expect(mockFetch).toHaveBeenCalledWith(
725+
expect.stringContaining('/balance-allowance/update?'),
726+
expect.objectContaining({ method: 'GET' }),
727+
);
728+
const calledUrl = mockFetch.mock.calls[0][0] as string;
729+
expect(calledUrl).toContain('asset_type=COLLATERAL');
730+
expect(calledUrl).toContain('signature_type=2');
731+
expect(calledUrl).not.toContain('token_id=');
732+
});
733+
734+
it('sends CONDITIONAL asset_type with token_id for SELL orders', async () => {
735+
mockFetch.mockResolvedValue({ ok: true });
736+
737+
await refreshBalanceAllowance({
738+
address: mockAddress,
739+
apiKey: mockApiKey,
740+
side: Side.SELL,
741+
outcomeTokenId: 'token-456',
742+
});
743+
744+
const calledUrl = mockFetch.mock.calls[0][0] as string;
745+
expect(calledUrl).toContain('asset_type=CONDITIONAL');
746+
expect(calledUrl).toContain('token_id=token-456');
747+
expect(calledUrl).toContain('signature_type=2');
748+
});
749+
750+
it('calls CLOB_ENDPOINT with L2 auth headers', async () => {
751+
mockFetch.mockResolvedValue({ ok: true });
752+
753+
await refreshBalanceAllowance({
754+
address: mockAddress,
755+
apiKey: mockApiKey,
756+
side: Side.BUY,
757+
outcomeTokenId: 'token-123',
758+
});
759+
760+
const calledUrl = mockFetch.mock.calls[0][0] as string;
761+
expect(calledUrl).toMatch(/^https:\/\/clob\.polymarket\.com/);
762+
const calledOptions = mockFetch.mock.calls[0][1] as RequestInit;
763+
expect(calledOptions.headers).toEqual(
764+
expect.objectContaining({
765+
POLY_ADDRESS: mockAddress,
766+
}),
767+
);
768+
});
769+
770+
it('does not throw when response is not ok', async () => {
771+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
772+
773+
await expect(
774+
refreshBalanceAllowance({
775+
address: mockAddress,
776+
apiKey: mockApiKey,
777+
side: Side.BUY,
778+
outcomeTokenId: 'token-123',
779+
}),
780+
).resolves.toBeUndefined();
781+
});
782+
783+
it('uses custom signatureType when provided', async () => {
784+
mockFetch.mockResolvedValue({ ok: true });
785+
786+
await refreshBalanceAllowance({
787+
address: mockAddress,
788+
apiKey: mockApiKey,
789+
side: Side.BUY,
790+
outcomeTokenId: 'token-123',
791+
signatureType: SignatureType.EOA,
792+
});
793+
794+
const calledUrl = mockFetch.mock.calls[0][0] as string;
795+
expect(calledUrl).toContain('signature_type=0');
796+
});
797+
});
798+
704799
describe('submitClobOrder', () => {
705800
const mockHeaders: ClobHeaders = {
706801
POLY_ADDRESS: mockAddress,

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
PolymarketApiMarket,
6262
PolymarketApiTeam,
6363
PolymarketPosition,
64+
SignatureType,
6465
TickSize,
6566
OrderBook,
6667
} from './types';
@@ -187,6 +188,76 @@ export const getL2Headers = async ({
187188
return headers;
188189
};
189190

191+
/**
192+
* TEMPORARY WORKAROUND for Polymarket infrastructure issue.
193+
*
194+
* Polymarket's CLOB infrastructure intermittently returns 400 errors with
195+
* "not enough balance / allowance" when placing orders at high request rates.
196+
* Calling this endpoint before each order refreshes the balance/allowance state
197+
* on their end and prevents most of these spurious failures.
198+
*
199+
* For BUY orders: refreshes COLLATERAL (USDC) balance/allowance.
200+
* For SELL orders: refreshes CONDITIONAL token balance/allowance.
201+
*
202+
* TODO: Remove this workaround once Polymarket resolves the underlying
203+
* infrastructure issue. Track removal in a follow-up ticket.
204+
*/
205+
export const refreshBalanceAllowance = async ({
206+
address,
207+
apiKey,
208+
side,
209+
outcomeTokenId,
210+
signatureType = SignatureType.POLY_GNOSIS_SAFE,
211+
}: {
212+
address: string;
213+
apiKey: ApiKeyCreds;
214+
side: Side;
215+
outcomeTokenId: string;
216+
signatureType?: SignatureType;
217+
}): Promise<void> => {
218+
const { CLOB_ENDPOINT } = getPolymarketEndpoints();
219+
220+
const queryParams = new URLSearchParams({
221+
signature_type: String(signatureType),
222+
});
223+
224+
if (side === Side.BUY) {
225+
queryParams.set('asset_type', 'COLLATERAL');
226+
} else {
227+
queryParams.set('asset_type', 'CONDITIONAL');
228+
queryParams.set('token_id', outcomeTokenId);
229+
}
230+
231+
const requestPath = `/balance-allowance/update`;
232+
233+
const headers = await getL2Headers({
234+
l2HeaderArgs: {
235+
method: 'GET',
236+
requestPath,
237+
},
238+
address,
239+
apiKey,
240+
});
241+
242+
const response = await fetch(
243+
`${CLOB_ENDPOINT}${requestPath}?${queryParams.toString()}`,
244+
{
245+
method: 'GET',
246+
headers,
247+
},
248+
);
249+
250+
if (!response.ok) {
251+
DevLogger.log(
252+
'refreshBalanceAllowance: Pre-order balance/allowance refresh failed',
253+
{
254+
status: response.status,
255+
side,
256+
},
257+
);
258+
}
259+
};
260+
190261
export const deriveApiKey = async ({ address }: { address: string }) => {
191262
const { CLOB_ENDPOINT } = getPolymarketEndpoints();
192263
const headers = await getL1Headers({ address });

0 commit comments

Comments
 (0)