chore(predict): Block prediction orders on post-active markets#30403
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
MarioAslau
left a comment
There was a problem hiding this comment.
Hey @caieu awesome work! I couldn't find any high severity issues
These are the medium ones:
Medium severity
M1 — Confirm-time API round-trip with no caching adds latency to every order
try {
await validateMarketBettable({
provider: this.provider,
preview: params.preview,
});
} catch (error) {validateMarketBettable calls provider.getMarketDetails({ marketId: preview.marketId }) on every placeOrder invocation. getMarketDetails hits the Gamma API (getMarketDetailsFromGammaApi) — a fresh fetch with no in-memory cache, no dedupe, no retry, no timeout override.
For a user that just sat on the buy preview for 10–60 seconds and then taps Confirm, the data was fetched seconds ago by the same screen. We're adding a serial network round-trip in the critical path of the order — typically 100–500 ms on Wi-Fi, 1–3 s on degraded mobile connectivity — before the order is even forwarded. On a transient failure this becomes a hard block via MARKET_BETTABLE_CHECK_FAILED.
Suggested improvements:
- Add a short TTL cache keyed by
marketId(5–10 s is enough to cover preview→confirm taps and double-taps without weakening the guard against stale feed-card entries). - Single retry on network error before failing closed — a single 5xx/timeout shouldn't permanently block a valid order.
- Optional: time-box the call (e.g.
Promise.racewith a 2 s timeout) so users on slow connections see the error promptly instead of the spinner hanging.
Failing closed on the validation API is the right security default; the issue is the latency tax on the happy path.
M2 — Outcome-not-found leaks across two distinct failure modes
export const getNonBettableMarketErrorCode = (
market: PredictMarket,
preview: OrderPreview,
): string | undefined => {
const outcome = market.outcomes.find(({ id }) => id === preview.outcomeId);
if (!outcome) {
return PREDICT_ERROR_CODES.MARKET_NOT_ACCEPTING_BETS;
}There are two reasons the outcome can be missing from market.outcomes:
- The outcome really doesn't exist (probably impossible in practice if the preview was valid).
- The outcome was filtered out at parse time. In
parsePolymarketEvents, line 1138:Polymarket markets with.filter((market: PolymarketApiMarket) => market?.active !== false);active === falseare dropped before being mapped toPredictOutcome[].
Because the filter runs before validateMarketBettable sees the data, the outcome.active === false branch on line 54 of marketState.ts is effectively dead code, and a market whose only outcome is active === false + resolutionStatus === 'proposed_resolution' will surface as MARKET_NOT_ACCEPTING_BETS ("This market is no longer accepting bets.") even though the more accurate copy is "This market is pending resolution.".
Suggested fixes (pick one):
- A: Bypass the
active !== falsefilter whenparsePolymarketEventsis called fromgetMarketDetails(single-market context), so the bettability util can read the full outcome state. The filter still makes sense in the markets-list rendering path. - B: In
validateMarketBettable, when the outcome is missing, fetch the rawPolymarketApiMarket(or look it up viaumaResolutionStatusfrom a sibling source) so we can distinguish the two cases. - C (lowest-risk): Treat outcome-not-found as
MARKET_BETTABLE_CHECK_FAILEDinstead ofMARKET_NOT_ACCEPTING_BETS, since the data state is ambiguous and the user-facing copy "We couldn't confirm this market is accepting bets. Try again." is more honest.
M3 — selectedPaymentToken is wiped on a transient network error
this.update((state) => {
state.lastError = errorMessage;
state.lastUpdateTimestamp = Date.now();
if (isBuyWithAnyToken && state.activeBuyOrders[activeOrderAddress]) {
state.activeBuyOrders[activeOrderAddress].state =
ActiveOrderState.PREVIEW;
state.activeBuyOrders[activeOrderAddress].error = errorMessage;
}
if (isBuyWithAnyToken) {
state.selectedPaymentToken = null;
}
});For the pay-with-any-token flow, the user picked a payment token (USDC on Linea, ETH on Arbitrum, etc.). When the guard returns MARKET_BETTABLE_CHECK_FAILED — a retryable network error, not a final state — we still null out selectedPaymentToken. The user has to:
- Read the "We couldn't confirm…" banner
- Reopen the token picker
- Re-select the same token
- Tap Confirm again
The existing post-createOrder-failure path does the same reset, which makes sense there (the deposit batch is no longer reusable). For a pre-flight check failure where no deposit has been initiated, this is over-reactive.
Recommended: keep selectedPaymentToken intact on MARKET_BETTABLE_CHECK_FAILED. On MARKET_PENDING_RESOLUTION / MARKET_NOT_ACCEPTING_BETS (terminal states for this market), keeping the token is also fine — the user typically switches markets, not tokens.
M4 — No analytics signal when the guard fires
The guard throws before trackPredictOrderEvent({ status: PredictTradeStatus.SWAP_INITIATED, ... }) runs (around line 1196). We don't currently emit any analytics event when an order is blocked by the bettability check.
Consequences:
- We lose product visibility into how often feed cards / deeplinks / cached details screens are surfacing stale (post-active) markets to the order screen.
- We can't measure whether the upstream fixes (e.g. PR #30342's
isMarketUnavailablebranch, or feed-card filtering) reduce the rate of guard hits over time. - QA can't easily verify "the guard fires when expected" in a production-like environment.
Recommended: emit a predict_order_blocked analytics event in the catch block, with properties { reason: errorMessage, marketId, outcomeId, side, entryPoint }. One call site, one event — but it unlocks dashboards and regression detection.
d04961b to
4975b55
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4975b55. Configure here.
…ets-in-proposed-resolution-or-dispute-state
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
SmokePredictions is the primary tag - these changes directly affect the position lifecycle (opening positions, error handling) which is the core of SmokePredictions tests. SmokeWalletPlatform is required per SmokePredictions tag description: "Predictions is also a section inside the Trending tab (SmokeWalletPlatform); changes to Predictions views affect Trending. When selecting SmokePredictions, also select SmokeWalletPlatform." SmokeConfirmations is required per SmokePredictions tag description: "opening/closing positions are on-chain transactions. When selecting SmokePredictions, also select SmokeConfirmations." The changes are medium risk - they add a new validation step in the critical order placement path that could potentially block valid orders if the market state check fails unexpectedly, but the logic is well-tested with unit tests and the fallback behavior is conservative (fail closed). Performance Test Selection: |
|




Description
This PR adds a confirm-time Predict market state guard so orders cannot be submitted after a Polymarket market has entered a post-active state, even if the user reached the market through a stale feed card, deeplink, search result, or cached details screen.
The change:
active,acceptingOrders, resolved, and UMA resolution status fields into Predict market/outcome models.PredictController.placeOrderbefore forwarding the order to the provider.Scope note: this is the client/controller guard only. No server-side forwarding guard is included.
Changelog
CHANGELOG entry: Fixed a bug that could allow prediction orders on markets that are no longer accepting bets.
Related issues
Fixes: PRED-828
Related: PRED-747
Manual testing steps
Automated testing
yarn jest app/components/UI/Predict/controllers/PredictController.test.ts app/components/UI/Predict/providers/polymarket/utils.test.ts app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.tsgit diff --checkScreenshots/Recordings
Before
N/A
After
N/A
Pre-merge author checklist
Performance checks (if applicable)
For performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Adds a confirm-time live market lookup that can block order placement and alters pay-with-any-token state/cleanup behavior; risk is mainly around false negatives or new failure paths that could prevent valid orders.
Overview
Adds a confirm-time market bettability guard before
PredictController.placeOrdersubmits to the provider, rejecting orders when the live market/outcome is inactive, closed/resolved, in dispute/pending resolution, or not accepting orders (and failing closed when the check can’t be performed).Propagates Polymarket
active/acceptingOrdersfields into Predict market/outcome models, introduces new Predict error codes + localized messages for these blocked states, and updates buy-sheet hooks to avoid stuck confirming state and to display the specific parsed guard error (with logging and generic fallback).Reviewed by Cursor Bugbot for commit 3cbeec2. Bugbot is set up for automated code reviews on this repo. Configure here.