diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2066fd45ab0..5f0447dee43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased (develop)
- added: App/device attestation for gated info-server requests
+- added: Prediction Markets side menu entry with market browsing scenes (prototype): category tabs, per-venue price comparison, and a merged order book view, backed by the Slipstream Connect API with a bundled sample dataset when no API key is configured
- added: "-m" tag on the version number in the Help scene for Maestro test builds
- added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message.
- added: `edge://buy` and `edge://sell` deep links (and their `https://deep.edge.app` equivalents) that open the buy/sell flow, optionally pinning a provider and payment method to the top of the quote options for that visit.
diff --git a/src/__tests__/scenes/PredictionMarketDetailsScene.test.tsx b/src/__tests__/scenes/PredictionMarketDetailsScene.test.tsx
new file mode 100644
index 00000000000..a0a5feddcaf
--- /dev/null
+++ b/src/__tests__/scenes/PredictionMarketDetailsScene.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, it } from '@jest/globals'
+import { render } from '@testing-library/react-native'
+import * as React from 'react'
+
+import { PredictionMarketDetailsScene } from '../../components/scenes/PredictionMarketDetailsScene'
+import { predictionMarketSampleData } from '../../plugins/prediction-markets/slipstreamSampleData'
+import { FakeProviders } from '../../util/fake/FakeProviders'
+import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps'
+
+describe('PredictionMarketDetailsScene', () => {
+ it('should render', () => {
+ const rendered = render(
+
+
+
+ )
+
+ expect(rendered.toJSON()).toMatchSnapshot()
+ rendered.unmount()
+ })
+})
diff --git a/src/__tests__/scenes/PredictionMarketListScene.test.tsx b/src/__tests__/scenes/PredictionMarketListScene.test.tsx
new file mode 100644
index 00000000000..d23295630ab
--- /dev/null
+++ b/src/__tests__/scenes/PredictionMarketListScene.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, it } from '@jest/globals'
+import { render } from '@testing-library/react-native'
+import * as React from 'react'
+
+import { PredictionMarketListScene } from '../../components/scenes/PredictionMarketListScene'
+import { FakeProviders } from '../../util/fake/FakeProviders'
+import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps'
+
+describe('PredictionMarketListScene', () => {
+ it('should render', () => {
+ const rendered = render(
+
+
+
+ )
+
+ expect(rendered.toJSON()).toMatchSnapshot()
+ rendered.unmount()
+ })
+})
diff --git a/src/__tests__/scenes/__snapshots__/PredictionMarketDetailsScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/PredictionMarketDetailsScene.test.tsx.snap
new file mode 100644
index 00000000000..8b51d9001e4
--- /dev/null
+++ b/src/__tests__/scenes/__snapshots__/PredictionMarketDetailsScene.test.tsx.snap
@@ -0,0 +1,1730 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`PredictionMarketDetailsScene should render 1`] = `
+[
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+
+
+
+ Prediction Markets
+
+
+
+
+
+
+ NBA
+
+
+ Lakers vs Celtics: Lakers win
+
+
+
+
+ Venue Prices
+
+
+
+
+
+
+
+
+
+
+ hyperliquid
+
+
+
+
+ Bid
+
+
+ 62¢
+
+
+ Ask
+
+
+ 64¢
+
+
+
+
+
+
+
+ polymarket
+
+
+ Best Price
+
+
+
+
+ Bid
+
+
+ 60¢
+
+
+ Ask
+
+
+ 62¢
+
+
+
+
+
+
+
+
+ Order Book
+
+
+
+
+
+
+
+
+
+ Bids
+
+
+
+ 62¢
+
+
+ 1,200 · hyperliquid
+
+
+
+
+ 60¢
+
+
+ 2,400 · polymarket
+
+
+
+
+ 59¢
+
+
+ 900 · polymarket
+
+
+
+
+
+ Asks
+
+
+
+ 62¢
+
+
+ 800 · polymarket
+
+
+
+
+ 63¢
+
+
+ 1,500 · polymarket
+
+
+
+
+ 64¢
+
+
+ 650 · hyperliquid
+
+
+
+
+
+
+
+
+ Market Details
+
+
+
+
+
+
+
+
+
+
+ polymarket
+
+
+ 24h volume: 184,200
+Resolves: August 20th, 2026
+
+
+
+
+
+
+
+
+
+
+
+
+ hyperliquid
+
+
+
+
+
+
+
+ ,
+]
+`;
diff --git a/src/__tests__/scenes/__snapshots__/PredictionMarketListScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/PredictionMarketListScene.test.tsx.snap
new file mode 100644
index 00000000000..1fca17405e2
--- /dev/null
+++ b/src/__tests__/scenes/__snapshots__/PredictionMarketListScene.test.tsx.snap
@@ -0,0 +1,773 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`PredictionMarketListScene should render 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Prediction Markets
+
+
+
+
+
+
+
+
+
+
+ Sports
+
+
+
+
+
+
+ Crypto
+
+
+
+
+
+
+ Macro
+
+
+
+
+
+
+ Politics
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/Main.tsx b/src/components/Main.tsx
index 90b6c02013b..1cfc8859c21 100644
--- a/src/components/Main.tsx
+++ b/src/components/Main.tsx
@@ -123,6 +123,8 @@ import { NotificationScene as NotificationSceneComponent } from './scenes/Notifi
import { OtpRepairScene as OtpRepairSceneComponent } from './scenes/OtpRepairScene'
import { OtpSettingsScene as OtpSettingsSceneComponent } from './scenes/OtpSettingsScene'
import { ChangeRecoveryScene as ChangeRecoverySceneComponent } from './scenes/PasswordRecoveryScene'
+import { PredictionMarketDetailsScene as PredictionMarketDetailsSceneComponent } from './scenes/PredictionMarketDetailsScene'
+import { PredictionMarketListScene as PredictionMarketListSceneComponent } from './scenes/PredictionMarketListScene'
import { PrivacySettingsScene as PrivacySettingsSceneComponent } from './scenes/PrivacySettingsScene'
import { PromotionSettingsScene as PromotionSettingsSceneComponent } from './scenes/PromotionSettingsScene'
import { RampBankFormScene as RampBankFormSceneComponent } from './scenes/RampBankFormScene'
@@ -269,6 +271,10 @@ const MigrateWalletSelectCryptoScene = ifLoggedIn(
const NotificationCenterScene = ifLoggedIn(NotificationCenterSceneComponent)
const NotificationScene = ifLoggedIn(NotificationSceneComponent)
const OtpRepairScene = ifLoggedIn(OtpRepairSceneComponent)
+const PredictionMarketDetailsScene = ifLoggedIn(
+ PredictionMarketDetailsSceneComponent
+)
+const PredictionMarketListScene = ifLoggedIn(PredictionMarketListSceneComponent)
const OtpSettingsScene = ifLoggedIn(OtpSettingsSceneComponent)
const PromotionSettingsScene = ifLoggedIn(PromotionSettingsSceneComponent)
const RampBankFormScene = ifLoggedIn(RampBankFormSceneComponent)
@@ -728,6 +734,14 @@ const EdgeAppStack: React.FC = () => {
name="coinRankingDetails"
component={CoinRankingDetailsScene}
/>
+
+
{}
+
+const BOOK_LEVELS_SHOWN = 3
+
+const PredictionMarketDetailsSceneComponent: React.FC = props => {
+ const { route } = props
+ const { market } = route.params
+ const theme = useTheme()
+ const styles = getStyles(theme)
+
+ const bestAsk = getBestAskPrice(market)
+
+ return (
+
+
+ {market.league != null ? (
+
+ {market.league.toUpperCase()}
+
+ ) : null}
+
+ {market.title}
+
+
+
+
+ {market.venue_prices.map(venuePrice => {
+ const isBest =
+ venuePrice.best_ask != null &&
+ bestAsk != null &&
+ eq(venuePrice.best_ask, bestAsk)
+ return (
+
+
+
+ {venuePrice.venue}
+
+ {isBest ? (
+
+ {lstrings.prediction_markets_best_price}
+
+ ) : null}
+
+
+
+ {lstrings.prediction_markets_bid}
+
+
+ {formatCentsPrice(venuePrice.best_bid)}
+
+
+ {lstrings.prediction_markets_ask}
+
+
+ {formatCentsPrice(venuePrice.best_ask)}
+
+
+
+ )
+ })}
+
+
+ {market.book != null ? (
+ <>
+
+
+
+
+
+ {lstrings.prediction_markets_bids}
+
+ {market.book.bids
+ .slice(0, BOOK_LEVELS_SHOWN)
+ .map((level, index) => (
+
+ ))}
+
+
+
+ {lstrings.prediction_markets_asks}
+
+ {market.book.asks
+ .slice(0, BOOK_LEVELS_SHOWN)
+ .map((level, index) => (
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+
+
+ {market.legs.map(leg => (
+
+ ))}
+
+
+
+ )
+}
+
+export const PredictionMarketDetailsScene = React.memo(
+ PredictionMarketDetailsSceneComponent
+)
+
+interface BookLevelRowProps {
+ level: BookLevel
+ isBid: boolean
+}
+
+const BookLevelRow: React.FC = props => {
+ const { level, isBid } = props
+ const theme = useTheme()
+ const styles = getStyles(theme)
+
+ return (
+
+
+ {formatCentsPrice(level.price)}
+
+
+ {`${formatNumber(level.size, { toFixed: 0 })} · ${level.venue}`}
+
+
+ )
+}
+
+interface MarketLegRowProps {
+ leg: MarketLeg
+}
+
+const MarketLegRow: React.FC = props => {
+ const { leg } = props
+
+ const bodyLines: string[] = []
+ if (leg.volume_24h != null) {
+ bodyLines.push(
+ sprintf(
+ lstrings.prediction_markets_volume_24h_1s,
+ formatNumber(leg.volume_24h, { toFixed: 0 })
+ )
+ )
+ }
+ if (leg.resolution_date != null) {
+ const resolutionDate = new Date(leg.resolution_date)
+ if (!isNaN(resolutionDate.valueOf())) {
+ bodyLines.push(
+ sprintf(
+ lstrings.prediction_markets_resolves_1s,
+ formatDate(resolutionDate)
+ )
+ )
+ }
+ }
+
+ // Live API responses are untrusted; only open https URLs on known venue
+ // hosts, never deep-link schemes or the app's own claimed App Link hosts:
+ const legUrl =
+ leg.url != null && isSafeVenueUrl(leg.url) ? leg.url : undefined
+
+ const handlePress = useHandler(() => {
+ if (legUrl == null) return
+ openBrowserUri(legUrl).catch((error: unknown) => {
+ showError(error)
+ })
+ })
+
+ return (
+ 0 ? bodyLines.join('\n') : undefined}
+ onPress={legUrl != null ? handlePress : undefined}
+ />
+ )
+}
+
+const getStyles = cacheStyles((theme: Theme) => ({
+ leagueChip: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.6)
+ },
+ title: {
+ fontFamily: theme.fontFaceMedium,
+ fontSize: theme.rem(1.1),
+ marginBottom: theme.rem(0.5)
+ },
+ venuePriceRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ margin: theme.rem(0.5)
+ },
+ venueNameContainer: {
+ flexDirection: 'row',
+ alignItems: 'center'
+ },
+ venueName: {
+ fontSize: theme.rem(0.9)
+ },
+ bestPriceTag: {
+ color: theme.iconTappable,
+ fontSize: theme.rem(0.6),
+ marginLeft: theme.rem(0.5)
+ },
+ bidAskContainer: {
+ flexDirection: 'row',
+ alignItems: 'center'
+ },
+ bidAskLabel: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.65),
+ marginRight: theme.rem(0.25)
+ },
+ bidAskValue: {
+ fontSize: theme.rem(0.9),
+ marginRight: theme.rem(0.75)
+ },
+ bookColumns: {
+ flexDirection: 'row',
+ margin: theme.rem(0.25)
+ },
+ bookColumn: {
+ flex: 1
+ },
+ bookColumnTitle: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.75),
+ marginBottom: theme.rem(0.25)
+ },
+ bookLevelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: theme.rem(0.25)
+ },
+ bookPriceBid: {
+ color: theme.positiveText,
+ fontSize: theme.rem(0.85),
+ marginRight: theme.rem(0.5)
+ },
+ bookPriceAsk: {
+ color: theme.negativeDeltaText,
+ fontSize: theme.rem(0.85),
+ marginRight: theme.rem(0.5)
+ },
+ bookLevelDetail: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.65)
+ }
+}))
diff --git a/src/components/scenes/PredictionMarketListScene.tsx b/src/components/scenes/PredictionMarketListScene.tsx
new file mode 100644
index 00000000000..fe547164099
--- /dev/null
+++ b/src/components/scenes/PredictionMarketListScene.tsx
@@ -0,0 +1,291 @@
+import { useQuery } from '@tanstack/react-query'
+import { eq } from 'biggystring'
+import * as React from 'react'
+import type { ListRenderItem } from 'react-native'
+import { ScrollView, View } from 'react-native'
+import Animated from 'react-native-reanimated'
+
+import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings'
+import { useHandler } from '../../hooks/useHandler'
+import { lstrings } from '../../locales/strings'
+import { fetchPredictionMarkets } from '../../plugins/prediction-markets/slipstreamApi'
+import {
+ formatCentsPrice,
+ getBestAskPrice,
+ PREDICTION_MARKET_CATEGORIES,
+ type PredictionMarket,
+ type PredictionMarketCategory
+} from '../../plugins/prediction-markets/slipstreamTypes'
+import { useSceneScrollHandler } from '../../state/SceneScrollState'
+import type { EdgeAppSceneProps } from '../../types/routerTypes'
+import { AlertCardUi4 } from '../cards/AlertCard'
+import { EdgeCard } from '../cards/EdgeCard'
+import { EdgeAnim } from '../common/EdgeAnim'
+import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
+import { SceneWrapper } from '../common/SceneWrapper'
+import { SceneContainer } from '../layout/SceneContainer'
+import { FillLoader } from '../progress-indicators/FillLoader'
+import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
+import { EdgeText } from '../themed/EdgeText'
+
+const CATEGORY_LABELS: Record = {
+ sports: lstrings.prediction_markets_category_sports,
+ crypto: lstrings.prediction_markets_category_crypto,
+ macro: lstrings.prediction_markets_category_macro,
+ politics: lstrings.prediction_markets_category_politics
+}
+
+interface Props extends EdgeAppSceneProps<'predictionMarkets'> {}
+
+const PredictionMarketListSceneComponent: React.FC = props => {
+ const { navigation } = props
+ const theme = useTheme()
+ const styles = getStyles(theme)
+
+ const [category, setCategory] =
+ React.useState('sports')
+
+ const handleScroll = useSceneScrollHandler()
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['predictionMarkets', category],
+ queryFn: async () => await fetchPredictionMarkets(category)
+ })
+
+ const handleCategoryPress = useHandler(
+ (newCategory: PredictionMarketCategory) => {
+ setCategory(newCategory)
+ }
+ )
+
+ const renderItem: ListRenderItem = React.useCallback(
+ ({ item }) => {
+ const handlePress = (): void => {
+ navigation.navigate('predictionMarketDetails', { market: item })
+ }
+ const bestAsk = getBestAskPrice(item)
+ return (
+
+
+ {item.league != null ? (
+
+ {item.league.toUpperCase()}
+
+ ) : null}
+
+ {item.title}
+
+
+
+ {item.venue_prices.map(venuePrice => {
+ const isBest =
+ venuePrice.best_ask != null &&
+ bestAsk != null &&
+ eq(venuePrice.best_ask, bestAsk)
+ return (
+
+
+ {venuePrice.venue}
+
+
+ {formatCentsPrice(venuePrice.best_ask)}
+
+
+ )
+ })}
+
+
+ )
+ },
+ [navigation, styles]
+ )
+
+ const keyExtractor = React.useCallback(
+ (item: PredictionMarket): string => item.id,
+ []
+ )
+
+ return (
+
+ {({ insetStyle, undoInsetStyle }) => (
+
+
+ {PREDICTION_MARKET_CATEGORIES.map((categoryOption, index) => {
+ const isSelected = category === categoryOption
+ return (
+
+ {
+ handleCategoryPress(categoryOption)
+ }}
+ >
+
+ {CATEGORY_LABELS[categoryOption]}
+
+
+
+ )
+ })}
+
+ {isLoading ? (
+
+ ) : data == null ? (
+
+ ) : (
+ <>
+ {data.isSampleData ? (
+
+ ) : null}
+ {data.markets.length === 0 ? (
+
+ {lstrings.prediction_markets_empty}
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ )}
+
+ )
+}
+
+export const PredictionMarketListScene = React.memo(
+ PredictionMarketListSceneComponent
+)
+
+const getStyles = cacheStyles((theme: Theme) => ({
+ categoryScrollView: {
+ flexGrow: 0,
+ flexShrink: 0,
+ marginBottom: theme.rem(0.5)
+ },
+ categoryContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingRight: theme.rem(0.5)
+ },
+ categoryButton: {
+ paddingHorizontal: theme.rem(0.5),
+ paddingVertical: theme.rem(0.25)
+ },
+ categoryText: {
+ color: theme.deactivatedText,
+ fontSize: theme.rem(0.85)
+ },
+ categoryTextSelected: {
+ color: theme.primaryText,
+ fontFamily: theme.fontFaceMedium,
+ fontSize: theme.rem(0.85)
+ },
+ cardHeader: {
+ margin: theme.rem(0.25)
+ },
+ leagueChip: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.6),
+ marginBottom: theme.rem(0.25)
+ },
+ cardTitle: {
+ fontFamily: theme.fontFaceMedium,
+ fontSize: theme.rem(0.9)
+ },
+ venueRow: {
+ flexDirection: 'row',
+ margin: theme.rem(0.25),
+ marginTop: theme.rem(0.5)
+ },
+ venueCell: {
+ borderColor: theme.lineDivider,
+ borderRadius: theme.rem(0.5),
+ borderWidth: theme.thinLineWidth,
+ marginRight: theme.rem(0.5),
+ paddingHorizontal: theme.rem(0.5),
+ paddingVertical: theme.rem(0.25),
+ alignItems: 'center'
+ },
+ venueCellBest: {
+ borderColor: theme.iconTappable,
+ borderRadius: theme.rem(0.5),
+ borderWidth: theme.thinLineWidth,
+ marginRight: theme.rem(0.5),
+ paddingHorizontal: theme.rem(0.5),
+ paddingVertical: theme.rem(0.25),
+ alignItems: 'center'
+ },
+ venueName: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.65)
+ },
+ venueNameBest: {
+ color: theme.iconTappable,
+ fontSize: theme.rem(0.65)
+ },
+ venuePrice: {
+ fontSize: theme.rem(0.85)
+ },
+ venuePriceBest: {
+ fontFamily: theme.fontFaceMedium,
+ fontSize: theme.rem(0.85)
+ },
+ emptyText: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.85),
+ margin: theme.rem(1),
+ textAlign: 'center'
+ }
+}))
diff --git a/src/components/themed/SideMenu.tsx b/src/components/themed/SideMenu.tsx
index 0dece84236f..6137da4f9e6 100644
--- a/src/components/themed/SideMenu.tsx
+++ b/src/components/themed/SideMenu.tsx
@@ -315,6 +315,14 @@ export function SideMenuComponent(props: Props): React.ReactElement {
iconName: 'chart',
title: lstrings.title_markets
},
+ {
+ handlePress: () => {
+ navigation.navigate('edgeAppStack', { screen: 'predictionMarkets' })
+ navigation.dispatch(DrawerActions.closeDrawer())
+ },
+ iconNameFontAwesome: 'poll',
+ title: lstrings.title_prediction_markets
+ },
// Only show gift card menu option if Phaze API key is configured
...(ENV.PLUGIN_API_KEYS?.phaze?.apiKey != null
? [
diff --git a/src/docs/prediction-markets-prototype.md b/src/docs/prediction-markets-prototype.md
new file mode 100644
index 00000000000..45bd4928380
--- /dev/null
+++ b/src/docs/prediction-markets-prototype.md
@@ -0,0 +1,218 @@
+# Prediction markets prototype: browse cross-venue markets from the side menu
+
+| | |
+|---|---|
+| Status | Implemented (read-only prototype) |
+| Author | Jon Tzeng (agent run) |
+| Reviewer | - |
+| Last updated | 2026-08-14 |
+| Repos | [edge-react-gui](https://github.com/EdgeApp/edge-react-gui) |
+| Implementation | [edge-react-gui#6158](https://github.com/EdgeApp/edge-react-gui/pull/6158) |
+| Supersedes | - |
+| Related | [slipstream-example](https://github.com/tylerthebuildor/slipstream-example) |
+
+File references point at the `jon/prediction-markets-prototype` branch. Direction came from Asana task 1217498026846463: add a prediction markets side menu entry with a new set of scenes, design at the implementer's discretion, preferring UI component reuse.
+
+## Contents
+
+1. [Problem](#1-problem)
+2. [Prior art](#2-prior-art)
+3. [Goals and non-goals](#3-goals-and-non-goals)
+4. [Design overview](#4-design-overview)
+5. [Detailed design: edge-react-gui](#5-detailed-design-edge-react-gui)
+6. [Testing](#6-testing)
+7. [Phase history](#7-phase-history)
+8. [Decisions](#8-decisions)
+9. [Glossary](#9-glossary)
+10. [References](#10-references)
+11. [Post-implementation retrospective](#11-post-implementation-retrospective)
+
+## 1. Problem
+
+Edge has no surface for prediction markets. The [Slipstream Connect](#slipstream-connect) API aggregates the same real-world event across Polymarket, Hyperliquid, and Kalshi into one [cluster](#cluster) with per-[venue](#venue) prices and a merged order book, and its non-custodial trade flow (the client signs, the API never sees a key) fits Edge's custody model. Before committing to a trading integration, the app needs a prototype that proves out navigation, scene design, and the data shapes.
+
+## 2. Prior art
+
+Two in-app features already solve the "browse an external REST dataset" problem and set the conventions this prototype reuses:
+
+- CoinRanking (`src/components/scenes/CoinRankingScene.tsx`): list plus details scenes over the rates server, cleaner-typed responses in `src/types/coinrankTypes.ts`.
+- Gift cards (`src/plugins/gift-cards/`, `src/components/scenes/GiftCardMarketScene.tsx`): a plugin directory holding the API client and [cleaners](#cleaners), TanStack Query for fetching, category chips over an `Animated.FlatList`, an optional API key under `ENV.PLUGIN_API_KEYS` gating provider behavior.
+
+Neither talks to a prediction markets [venue](#venue), and neither pattern needed changes; the prototype is an application of both.
+
+## 3. Goals and non-goals
+
+Goals:
+
+- A Prediction Markets side menu row visible to every logged-in user.
+- A list scene: category tabs (sports, crypto, macro, politics), one card per market [cluster](#cluster) comparing each [venue](#venue)'s best ask, best-priced venue highlighted.
+- A details scene: per-venue bid/ask, merged order book top levels, per-venue metadata (24h volume, resolution date, link out to the venue page).
+- A typed client for `GET /connect/markets/{category}` with cleaner-validated responses.
+- Fully browsable without an API key, via a clearly labeled bundled sample dataset.
+
+Non-goals (each deferred, see [decision 1](#d1-read-only-scope-no-trade-flow)):
+
+- Trading (quote, order build, signing, submit), balances, positions, and venue setup.
+- A server-side key proxy. The prototype reads the key from `env.json` on the client; production hardening is deferred with the trade flow.
+- New icon assets. The row reuses an existing FontAwesome5 glyph.
+
+## 4. Design overview
+
+One repo, three layers: a data module under `src/plugins/prediction-markets/`, two scenes, and wiring (router types, `Main.tsx` registration, side menu row, localized strings).
+
+```mermaid
+flowchart TD
+ menu[SideMenu row: Prediction Markets] --> list[PredictionMarketListScene]
+ list -->|useQuery per category| api[fetchPredictionMarkets]
+ api --> keyed{ENV.PLUGIN_API_KEYS.slipstream set?}
+ keyed -->|yes| live[GET baseUrl/connect/markets/category with X-API-Key]
+ keyed -->|no| sample[Bundled sample dataset, banner shown in UI]
+ live --> clean[asPredictionMarkets cleaner]
+ sample --> clean
+ clean --> list
+ list -->|tap market card| details[PredictionMarketDetailsScene]
+ details -->|tap venue row| browser[openBrowserUri to venue page]
+```
+
+## 5. Detailed design: edge-react-gui
+
+### Data module
+
+`src/plugins/prediction-markets/` follows the gift-card plugin layout:
+
+- `slipstreamTypes.ts`: [cleaners](#cleaners) mirroring the Connect API's market [cluster](#cluster) shape (`asPredictionMarket`, `asMarketBook`, `asMarketLeg`, `asVenuePrice`), the category list, and `formatCentsPrice`, which renders the API's `0`-`1` decimal-string prices as whole cents using biggystring math.
+- `slipstreamApi.ts`: the fetch client, whose exported surface is:
+
+[`src/plugins/prediction-markets/slipstreamApi.ts`](https://github.com/EdgeApp/edge-react-gui/blob/eeab4cc1085368d86b5f894ba47a8a84705fa277/src/plugins/prediction-markets/slipstreamApi.ts)
+```ts
+export const fetchPredictionMarkets = async (
+ category: PredictionMarketCategory
+): Promise => {
+```
+
+ `PredictionMarketsResult` is `{ markets: PredictionMarket[], isSampleData: boolean }`. With no key configured it returns the sample dataset and `isSampleData: true`; with a key it fetches `GET {baseUrl}/connect/markets/{category}` with the `X-API-Key` header and throws on a non-OK status so the scene's error state renders. Responses parse through `asJSON(asPredictionMarkets)`.
+- `slipstreamSampleData.ts`: two markets per category shaped like live responses. The literals run through `asPredictionMarkets` at module load, so the sample path exercises the same cleaners as the live path.
+
+The optional key lives at `ENV.PLUGIN_API_KEYS.slipstream` (`apiKey`, optional `baseUrl` defaulting to `https://api.papi.market`), following the phaze entry in `src/envConfig.ts`.
+
+### Scenes
+
+`PredictionMarketListScene` (route `predictionMarkets`): `SceneWrapper` and `SceneContainer` with the scene title, a horizontal category chip row (the GiftCardMarketScene pattern), and an `Animated.FlatList` of `EdgeCard` rows. Each card shows the league tag, title, and one bordered cell per [venue](#venue) with its best ask; the cell matching the merged book's best ask (falling back to the lowest venue ask) is highlighted. Data comes from `useQuery` keyed on the category. Sample mode renders an `AlertCardUi4` banner above the list; fetch errors render the same card with an error string; an empty category renders a centered empty message.
+
+`PredictionMarketDetailsScene` (route `predictionMarketDetails`, params `{ market: PredictionMarket }`): the market travels in the route params, so the scene does no fetching. Three sections: venue prices (one row per venue with bid and ask, best ask tagged via the shared `getBestAskPrice` helper the list scene also uses), order book (top three bid and ask levels side by side, each level showing price, size, and source venue), and market details (one `EdgeRow` per [leg](#leg) with 24h volume and resolution date). A leg row is tappable only when its URL passes `isSafeVenueUrl`: https on an allowlisted venue host (polymarket.com, hyperliquid.xyz, kalshi.com, with subdomains), no userinfo. Live responses are untrusted, so a scheme check alone would still let a hostile payload point at the app's own claimed App Link hosts; numeric fields likewise clean through `asBiggystring` so a malformed decimal string fails the fetch instead of throwing in render.
+
+### Wiring
+
+- `src/types/routerTypes.tsx`: `predictionMarkets: undefined` and `predictionMarketDetails: PredictionMarketDetailsParams` in `EdgeAppStackParamList`, params imported from the details scene per repo convention.
+- `src/components/Main.tsx`: both scenes wrapped in `ifLoggedIn` and registered on the app stack next to the CoinRanking screens.
+- `src/components/themed/SideMenu.tsx`: a row after Markets navigating to `predictionMarkets`, icon `iconNameFontAwesome: 'poll'` (see [decision 4](#d4-icon-reuse-fontawesome5-poll)).
+- `src/locales/en_US.ts`: a PredictionMarkets region; all scene text is localized.
+
+## 6. Testing
+
+1. `PredictionMarketListScene.test.tsx`: snapshot render under `FakeProviders` (which supplies the `QueryClientProvider`).
+2. `PredictionMarketDetailsScene.test.tsx`: snapshot render with the first sports sample market as params, covering the [venue](#venue) price, order book, and [leg](#leg) sections.
+3. Sim drive (run evidence in the task's run report): side menu shows the row; list scene renders sample data with the banner; category tabs switch datasets; tapping a card opens details with prices, book, and legs.
+4. Type and cleaner conformance: the sample dataset passes `asPredictionMarkets` at module load, so a shape drift fails every jest suite importing it.
+
+## 7. Phase history
+
+### Phase 1: read-only prototype (2026-08-14)
+
+Sketch and shipped implementation match: data module, two scenes, side menu row, sample fallback. Nothing diverged mid-build except the sample dataset's typing, which moved from hand-written `PredictionMarket[]` literals to cleaner-validated literals when tsc rejected the optional-field shapes. Deferred: the trade flow and everything key-gated (see [goals and non-goals](#3-goals-and-non-goals)).
+
+Review hardening, same day (from six Cursor Bugbot and Security Reviewer findings on [edge-react-gui#6158](https://github.com/EdgeApp/edge-react-gui/pull/6158), all accepted):
+
+| Shipped as | Replacing |
+|---|---|
+| Shared `getBestAskPrice` used by both scenes | Details scene read only `book.best_ask`, so its highlight could disagree with the list |
+| `isSafeVenueUrl` allowlist (https, [venue](#venue) hosts, userinfo rejected) | Untrusted [leg](#leg) URLs went to `openBrowserUri` with no scheme or host check |
+| `asBiggystring` on price, size, and volume fields | `asString` let malformed decimals throw inside render-time biggystring math |
+| Order-book asks in `theme.negativeDeltaText` (red) | `theme.negativeText` is blue-gray, so asks read as muted body text |
+| Error card only when no data exists | A failed background refetch replaced a loaded list with the error card |
+
+## 8. Decisions
+
+### D1: read-only scope, no trade flow
+
+Chosen: browse-only (markets list and details). The trade flow needs a `trade`-scope API key, funded [venue](#venue) wallets, and [EIP-712](#eip-712) signing wired through the wallet layer; none of those exist in this environment (live API probes returned 404/502 without a key, and `env.json` has no Slipstream entry). Rejected: full trade flow (unbuildable and unverifiable here); quote-only trading UI (a quote the user cannot execute is a dead-end control, worse than omitting it). Reopen when a key with `trade` scope and a signing design for `signing_request.kind` exist.
+
+### D2: sample-data fallback instead of key-gating the feature
+
+Chosen: the row always shows; with no key the scenes run on bundled sample data behind a visible banner. Rejected: hiding the row without a key like the phaze gift-card row (the task commissions a browsable prototype, and a hidden row demos nothing on any build without secrets); treating no-key as an error state (same problem, an error screen is not a prototype). The banner keeps the provenance honest. Reopen at productization, when the row should probably gate on a real key.
+
+### D3: client-side key from env.json
+
+Chosen: `ENV.PLUGIN_API_KEYS.slipstream`, fetched directly from the app. The reference integration holds the key server-side behind a proxy, and that remains the right production shape. Rejected for the prototype: standing up a proxy or info-server relay for a read-only demo that usually runs keyless. This mirrors how other `PLUGIN_API_KEYS` entries already work in the app. Reopen with D1.
+
+### D4: icon, reuse FontAwesome5 'poll'
+
+Chosen: `iconNameFontAwesome: 'poll'` on the side menu row, matching the gift-card row's use of the FontAwesome5 escape hatch. Rejected: a new [Fontello](#fontello) glyph (requires regenerating `src/assets/vector/config.json` and the font binary, churn a prototype does not justify); reusing the Fontello `chart` glyph (already the Markets row icon, and duplicate icons in adjacent rows read as a bug).
+
+### D5: data module under src/plugins/prediction-markets/
+
+Chosen: the gift-card plugin layout (`Api.ts`, `Types.ts` equivalents). Rejected: the older CoinRanking layout (types in `src/types/`, fetch helpers in `src/util/network.ts`), which scatters one feature across three directories; new features in the repo have moved to the plugin-directory shape.
+
+## 9. Glossary
+
+### Slipstream Connect
+
+The aggregation API at `api.papi.market`. It matches the same real-world event across prediction market venues, returns per-venue prices and a merged book, and builds venue order payloads for the client to sign locally. Source: [slipstream-example README](https://github.com/tylerthebuildor/slipstream-example).
+
+### Cluster
+
+One real-world event matched across venues: a title, one leg per venue, per-venue prices, and a merged book. A cluster's `id` is the venue:market_id pairs joined over the sorted legs. Source: [slipstream-example README, markets endpoint](https://github.com/tylerthebuildor/slipstream-example#get-connectmarketscategory).
+
+### Leg
+
+A cluster's listing on one specific venue: the venue's market id, YES outcome id, page URL, 24h volume, and resolution date. Defined by `asMarketLeg` in [slipstreamTypes.ts](https://github.com/EdgeApp/edge-react-gui/blob/jon/prediction-markets-prototype/src/plugins/prediction-markets/slipstreamTypes.ts).
+
+### YES frame
+
+The API's price normalization: every leg is quoted as the probability of the YES outcome, priced `0`-`1`, so asks are always the cost to buy and bids the proceeds to sell regardless of a venue's native convention. Source: [slipstream-example README](https://github.com/tylerthebuildor/slipstream-example#get-connectmarketscategory).
+
+### Venue
+
+An underlying prediction market exchange reachable through Slipstream Connect: [Polymarket](https://polymarket.com), [Hyperliquid](https://hyperliquid.xyz), or [Kalshi](https://kalshi.com). Kalshi is discovery-only in the API.
+
+### Cleaners
+
+Edge's runtime validation library ([cleaners](https://www.npmjs.com/package/cleaners)): composable functions that assert a JSON shape and produce the matching TypeScript type. All Connect responses and the sample dataset pass through them.
+
+### Fontello
+
+The app's generated icon font (`src/assets/vector/`), built with the [Fontello](https://fontello.com) font generator, and the default icon source for side menu rows. Adding a glyph means regenerating the font, which is why this row uses the FontAwesome5 fallback instead.
+
+### EIP-712
+
+Ethereum's typed structured data signing standard ([EIP-712](https://eips.ethereum.org/EIPS/eip-712)). Slipstream Connect returns order payloads in this format for the client wallet to sign locally; nothing in this prototype signs, which is part of why trading is out of scope.
+
+## 10. References
+
+- [slipstream-example](https://github.com/tylerthebuildor/slipstream-example): API documentation and reference integration.
+- Asana task 1217498026846463 (Prediction Markets - Prototype).
+- In-repo precedents: `src/components/scenes/GiftCardMarketScene.tsx`, `src/components/scenes/CoinRankingScene.tsx`, `src/plugins/gift-cards/`.
+
+## 11. Post-implementation retrospective
+
+### Estimate vs. actuals
+
+| Item | Planned | Actual |
+|---|---|---|
+| Scenes | list + details | list + details, as planned |
+| Data source | live fetch with sample fallback | same; live path unexercised (no API key exists in any environment yet) |
+| Review rounds | none budgeted | 3 rounds, 6 automated findings, all accepted and fixed same day |
+
+### Where this document was wrong or silent
+
+1. [Detailed design](#5-detailed-design-edge-react-gui) originally opened leg URLs on any `openBrowserUri`-accepted value; review showed untrusted-URL handling needed the allowlist now described there.
+2. [Testing](#6-testing) was silent on refetch-failure behavior; the shipped list scene keeps last-good data on a failed background refetch.
+
+### What held
+
+The reuse bets: GiftCardMarketScene's chip-row and query patterns, `SceneContainer`/`EdgeCard`/`EdgeRow`, the phaze-style env key, and the cleaner-validated sample dataset (it caught every data-shape tightening for free as the [cleaners](#cleaners) hardened).
+
+### Verification highlights
+
+- Maestro drive on the iOS sim, first attempt pass: side menu -> list (sample banner) -> category switch -> details; four proof frames plus one after-fix frame attached to [edge-react-gui#6158](https://github.com/EdgeApp/edge-react-gui/pull/6158).
+- Full verify-repo pass (eslint, tsc, jest incl. the two new snapshot tests) on every commit via the pre-commit hook.
diff --git a/src/envConfig.ts b/src/envConfig.ts
index f0181d68c3d..3fb18e3e3be 100644
--- a/src/envConfig.ts
+++ b/src/envConfig.ts
@@ -167,6 +167,12 @@ export const asEnvConfig = asObject({
apiKey: asString,
baseUrl: asString
})
+ ),
+ slipstream: asOptional(
+ asObject({
+ apiKey: asString,
+ baseUrl: asOptional(asString, 'https://api.papi.market')
+ })
)
}).withRest,
() => ({
@@ -180,7 +186,8 @@ export const asEnvConfig = asObject({
revolut: undefined,
simplex: undefined,
ionia: undefined,
- phaze: undefined
+ phaze: undefined,
+ slipstream: undefined
})
),
RAMP_PLUGIN_INITS: asOptional(
diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
index 51f82a5058b..96107573937 100644
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -1932,6 +1932,31 @@ const strings = {
// #endregion CoinRanking
+ // #region PredictionMarkets
+
+ title_prediction_markets: 'Prediction Markets',
+ prediction_markets_category_sports: 'Sports',
+ prediction_markets_category_crypto: 'Crypto',
+ prediction_markets_category_macro: 'Macro',
+ prediction_markets_category_politics: 'Politics',
+ prediction_markets_sample_data:
+ 'Showing sample markets. Add a Slipstream Connect API key to load live data.',
+ prediction_markets_error:
+ 'Unable to load prediction markets. Please try again later.',
+ prediction_markets_empty: 'No matched markets in this category right now.',
+ prediction_markets_venue_prices: 'Venue Prices',
+ prediction_markets_best_price: 'Best Price',
+ prediction_markets_bid: 'Bid',
+ prediction_markets_ask: 'Ask',
+ prediction_markets_order_book: 'Order Book',
+ prediction_markets_bids: 'Bids',
+ prediction_markets_asks: 'Asks',
+ prediction_markets_market_details: 'Market Details',
+ prediction_markets_volume_24h_1s: '24h volume: %1$s',
+ prediction_markets_resolves_1s: 'Resolves: %1$s',
+
+ // #endregion PredictionMarkets
+
// #region GuiPlugins
form_field_mailing_address_title: 'Mailing Address',
diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json
index ecfa739a196..98e9a444e98 100644
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -1500,6 +1500,24 @@
"coin_rank_see_more": "See More",
"coin_rank_currency_rates_warning_title": "Currency Rates",
"coin_rank_currency_rates_warning_message_2s": "The rates shown are quoted in %1$s as a fallback. Market data for your default currency setting (%2$s) is not supported.",
+ "title_prediction_markets": "Prediction Markets",
+ "prediction_markets_category_sports": "Sports",
+ "prediction_markets_category_crypto": "Crypto",
+ "prediction_markets_category_macro": "Macro",
+ "prediction_markets_category_politics": "Politics",
+ "prediction_markets_sample_data": "Showing sample markets. Add a Slipstream Connect API key to load live data.",
+ "prediction_markets_error": "Unable to load prediction markets. Please try again later.",
+ "prediction_markets_empty": "No matched markets in this category right now.",
+ "prediction_markets_venue_prices": "Venue Prices",
+ "prediction_markets_best_price": "Best Price",
+ "prediction_markets_bid": "Bid",
+ "prediction_markets_ask": "Ask",
+ "prediction_markets_order_book": "Order Book",
+ "prediction_markets_bids": "Bids",
+ "prediction_markets_asks": "Asks",
+ "prediction_markets_market_details": "Market Details",
+ "prediction_markets_volume_24h_1s": "24h volume: %1$s",
+ "prediction_markets_resolves_1s": "Resolves: %1$s",
"form_field_mailing_address_title": "Mailing Address",
"form_field_personal_information_title": "Personal Information",
"form_field_title_account_owner": "Account Owner",
diff --git a/src/plugins/prediction-markets/slipstreamApi.ts b/src/plugins/prediction-markets/slipstreamApi.ts
new file mode 100644
index 00000000000..6fcd497c82b
--- /dev/null
+++ b/src/plugins/prediction-markets/slipstreamApi.ts
@@ -0,0 +1,63 @@
+import { asJSON } from 'cleaners'
+
+import { ENV } from '../../env'
+import { debugLog } from '../../util/logger'
+import { predictionMarketSampleData } from './slipstreamSampleData'
+import {
+ asPredictionMarkets,
+ type PredictionMarket,
+ type PredictionMarketCategory
+} from './slipstreamTypes'
+
+const DEFAULT_BASE_URL = 'https://api.papi.market'
+
+export interface PredictionMarketsResult {
+ markets: PredictionMarket[]
+ /** True when the bundled sample dataset is shown (no API key configured). */
+ isSampleData: boolean
+}
+
+interface SlipstreamConfig {
+ apiKey: string
+ baseUrl: string
+}
+
+const getSlipstreamConfig = (): SlipstreamConfig | undefined => {
+ const config = ENV.PLUGIN_API_KEYS?.slipstream
+ if (config?.apiKey == null || config.apiKey === '') return undefined
+ return { apiKey: config.apiKey, baseUrl: config.baseUrl ?? DEFAULT_BASE_URL }
+}
+
+/**
+ * Fetches the matched markets for one category from the Slipstream Connect
+ * API. Falls back to the bundled sample dataset when no API key is
+ * configured; live fetch errors are thrown for the caller's error state.
+ */
+export const fetchPredictionMarkets = async (
+ category: PredictionMarketCategory
+): Promise => {
+ const config = getSlipstreamConfig()
+ if (config == null) {
+ debugLog(
+ 'predictionMarkets',
+ 'No Slipstream Connect API key configured; using sample data'
+ )
+ return {
+ markets: predictionMarketSampleData[category],
+ isSampleData: true
+ }
+ }
+
+ const uri = `${config.baseUrl}/connect/markets/${category}`
+ debugLog('predictionMarkets', 'Fetching', uri)
+ const response = await fetch(uri, {
+ headers: { 'X-API-Key': config.apiKey }
+ })
+ if (!response.ok) {
+ throw new Error(
+ `Slipstream markets/${category} failed: HTTP ${response.status}`
+ )
+ }
+ const text = await response.text()
+ return { markets: asJSON(asPredictionMarkets)(text), isSampleData: false }
+}
diff --git a/src/plugins/prediction-markets/slipstreamSampleData.ts b/src/plugins/prediction-markets/slipstreamSampleData.ts
new file mode 100644
index 00000000000..1428e00a354
--- /dev/null
+++ b/src/plugins/prediction-markets/slipstreamSampleData.ts
@@ -0,0 +1,222 @@
+import {
+ asPredictionMarkets,
+ type PredictionMarket,
+ type PredictionMarketCategory
+} from './slipstreamTypes'
+
+/**
+ * Bundled sample markets, shaped exactly like `GET /connect/markets/{category}`
+ * responses. Shown (with an in-UI notice) when no Slipstream Connect API key
+ * is configured, so the prediction market scenes stay fully browsable.
+ *
+ * The literals run through `asPredictionMarkets` so they are guaranteed to
+ * match what a live API response would produce.
+ */
+const sampleData = {
+ sports: [
+ {
+ id: 'hyperliquid:@107|polymarket:0x2a3f9c41',
+ title: 'Lakers vs Celtics: Lakers win',
+ category: 'sports',
+ league: 'nba',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0x2a3f9c41',
+ outcome_id: '71943382',
+ url: 'https://polymarket.com/event/lakers-vs-celtics',
+ volume_24h: '184200.5',
+ resolution_date: '2026-08-21T02:30:00Z'
+ },
+ { venue: 'hyperliquid', market_id: '@107', outcome_id: '#107' }
+ ],
+ venue_prices: [
+ { venue: 'hyperliquid', best_ask: '0.64', best_bid: '0.62' },
+ { venue: 'polymarket', best_ask: '0.62', best_bid: '0.60' }
+ ],
+ book: {
+ bids: [
+ { price: '0.62', size: '1200', venue: 'hyperliquid' },
+ { price: '0.60', size: '2400', venue: 'polymarket' },
+ { price: '0.59', size: '900', venue: 'polymarket' }
+ ],
+ asks: [
+ { price: '0.62', size: '800', venue: 'polymarket' },
+ { price: '0.63', size: '1500', venue: 'polymarket' },
+ { price: '0.64', size: '650', venue: 'hyperliquid' }
+ ],
+ best_bid: '0.62',
+ best_ask: '0.62'
+ }
+ },
+ {
+ id: 'kalshi:KXNFLGAME|polymarket:0x8b1d2e77',
+ title: 'Chiefs win Super Bowl LXI',
+ category: 'sports',
+ league: 'nfl',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0x8b1d2e77',
+ outcome_id: '55018221',
+ url: 'https://polymarket.com/event/super-bowl-lxi',
+ volume_24h: '96411.0',
+ resolution_date: '2027-02-08T04:00:00Z'
+ },
+ { venue: 'kalshi', market_id: 'KXNFLGAME', outcome_id: 'KXNFLGAME-YES' }
+ ],
+ venue_prices: [
+ { venue: 'polymarket', best_ask: '0.18', best_bid: '0.17' },
+ { venue: 'kalshi', best_ask: '0.19', best_bid: '0.16' }
+ ],
+ book: {
+ bids: [
+ { price: '0.17', size: '5200', venue: 'polymarket' },
+ { price: '0.16', size: '3100', venue: 'kalshi' }
+ ],
+ asks: [
+ { price: '0.18', size: '4400', venue: 'polymarket' },
+ { price: '0.19', size: '2800', venue: 'kalshi' }
+ ],
+ best_bid: '0.17',
+ best_ask: '0.18'
+ }
+ }
+ ],
+ crypto: [
+ {
+ id: 'hyperliquid:@212|polymarket:0x91c4aa08',
+ title: 'Bitcoin above $150k on Dec 31',
+ category: 'crypto',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0x91c4aa08',
+ outcome_id: '83726190',
+ url: 'https://polymarket.com/event/bitcoin-150k-2026',
+ volume_24h: '412876.2',
+ resolution_date: '2027-01-01T00:00:00Z'
+ },
+ { venue: 'hyperliquid', market_id: '@212', outcome_id: '#212' }
+ ],
+ venue_prices: [
+ { venue: 'polymarket', best_ask: '0.41', best_bid: '0.40' },
+ { venue: 'hyperliquid', best_ask: '0.43', best_bid: '0.39' }
+ ],
+ book: {
+ bids: [
+ { price: '0.40', size: '8800', venue: 'polymarket' },
+ { price: '0.39', size: '4100', venue: 'hyperliquid' }
+ ],
+ asks: [
+ { price: '0.41', size: '6200', venue: 'polymarket' },
+ { price: '0.43', size: '2900', venue: 'hyperliquid' }
+ ],
+ best_bid: '0.40',
+ best_ask: '0.41'
+ }
+ },
+ {
+ id: 'polymarket:0x5e77b3c2',
+ title: 'ETH flips BTC market cap this decade',
+ category: 'crypto',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0x5e77b3c2',
+ outcome_id: '61054433',
+ url: 'https://polymarket.com/event/eth-flips-btc',
+ volume_24h: '15320.8',
+ resolution_date: '2030-01-01T00:00:00Z'
+ }
+ ],
+ venue_prices: [
+ { venue: 'polymarket', best_ask: '0.07', best_bid: '0.06' }
+ ],
+ book: {
+ bids: [{ price: '0.06', size: '12000', venue: 'polymarket' }],
+ asks: [{ price: '0.07', size: '9500', venue: 'polymarket' }],
+ best_bid: '0.06',
+ best_ask: '0.07'
+ }
+ }
+ ],
+ macro: [
+ {
+ id: 'kalshi:KXFEDCUT|polymarket:0x33d90f15',
+ title: 'Fed cuts rates at the next FOMC meeting',
+ category: 'macro',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0x33d90f15',
+ outcome_id: '90211675',
+ url: 'https://polymarket.com/event/fed-cut-next-fomc',
+ volume_24h: '287554.1',
+ resolution_date: '2026-09-17T18:00:00Z'
+ },
+ { venue: 'kalshi', market_id: 'KXFEDCUT', outcome_id: 'KXFEDCUT-YES' }
+ ],
+ venue_prices: [
+ { venue: 'polymarket', best_ask: '0.72', best_bid: '0.71' },
+ { venue: 'kalshi', best_ask: '0.74', best_bid: '0.70' }
+ ],
+ book: {
+ bids: [
+ { price: '0.71', size: '10400', venue: 'polymarket' },
+ { price: '0.70', size: '5600', venue: 'kalshi' }
+ ],
+ asks: [
+ { price: '0.72', size: '7700', venue: 'polymarket' },
+ { price: '0.74', size: '3900', venue: 'kalshi' }
+ ],
+ best_bid: '0.71',
+ best_ask: '0.72'
+ }
+ }
+ ],
+ politics: [
+ {
+ id: 'kalshi:KXPRES28|polymarket:0xa10c44d9',
+ title: 'Incumbent party wins 2028 US election',
+ category: 'politics',
+ legs: [
+ {
+ venue: 'polymarket',
+ market_id: '0xa10c44d9',
+ outcome_id: '47700912',
+ url: 'https://polymarket.com/event/2028-us-election',
+ volume_24h: '731209.9',
+ resolution_date: '2028-11-08T05:00:00Z'
+ },
+ { venue: 'kalshi', market_id: 'KXPRES28', outcome_id: 'KXPRES28-YES' }
+ ],
+ venue_prices: [
+ { venue: 'polymarket', best_ask: '0.52', best_bid: '0.51' },
+ { venue: 'kalshi', best_ask: '0.53', best_bid: '0.50' }
+ ],
+ book: {
+ bids: [
+ { price: '0.51', size: '22000', venue: 'polymarket' },
+ { price: '0.50', size: '15000', venue: 'kalshi' }
+ ],
+ asks: [
+ { price: '0.52', size: '18000', venue: 'polymarket' },
+ { price: '0.53', size: '9000', venue: 'kalshi' }
+ ],
+ best_bid: '0.51',
+ best_ask: '0.52'
+ }
+ }
+ ]
+}
+
+export const predictionMarketSampleData: Record<
+ PredictionMarketCategory,
+ PredictionMarket[]
+> = {
+ sports: asPredictionMarkets(sampleData.sports),
+ crypto: asPredictionMarkets(sampleData.crypto),
+ macro: asPredictionMarkets(sampleData.macro),
+ politics: asPredictionMarkets(sampleData.politics)
+}
diff --git a/src/plugins/prediction-markets/slipstreamTypes.ts b/src/plugins/prediction-markets/slipstreamTypes.ts
new file mode 100644
index 00000000000..a1edb056d92
--- /dev/null
+++ b/src/plugins/prediction-markets/slipstreamTypes.ts
@@ -0,0 +1,141 @@
+import { lt, mul, toFixed } from 'biggystring'
+import {
+ asArray,
+ asObject,
+ asOptional,
+ asString,
+ asValue,
+ type Cleaner
+} from 'cleaners'
+
+import { asBiggystring } from '../../util/cleaners'
+
+/**
+ * Types and cleaners for the Slipstream Connect API (https://api.papi.market),
+ * a cross-venue prediction markets aggregator over Polymarket, Hyperliquid,
+ * and Kalshi. See https://github.com/tylerthebuildor/slipstream-example
+ *
+ * API conventions: numbers are decimal strings (never floats), prices are
+ * `0`-`1` probabilities, sizes are in contracts, and times are RFC3339.
+ */
+
+export const PREDICTION_MARKET_CATEGORIES = [
+ 'sports',
+ 'crypto',
+ 'macro',
+ 'politics'
+] as const
+
+export type PredictionMarketCategory =
+ (typeof PREDICTION_MARKET_CATEGORIES)[number]
+
+export const asPredictionMarketCategory: Cleaner =
+ asValue('sports', 'crypto', 'macro', 'politics')
+
+/** Best bid/ask a single venue shows for the market's YES outcome. */
+export const asVenuePrice = asObject({
+ venue: asString,
+ best_ask: asOptional(asBiggystring),
+ best_bid: asOptional(asBiggystring)
+})
+export type VenuePrice = ReturnType
+
+/** One price level of the merged book, tagged with its source venue. */
+export const asBookLevel = asObject({
+ price: asBiggystring,
+ size: asBiggystring,
+ venue: asString
+})
+export type BookLevel = ReturnType
+
+/**
+ * Merged order book across venues, normalized to the YES frame: `asks` is
+ * always what it costs to buy, `bids` what you get to sell.
+ */
+export const asMarketBook = asObject({
+ bids: asOptional(asArray(asBookLevel), () => []),
+ asks: asOptional(asArray(asBookLevel), () => []),
+ best_bid: asOptional(asBiggystring),
+ best_ask: asOptional(asBiggystring)
+})
+export type MarketBook = ReturnType
+
+/** The market's listing on one specific venue. */
+export const asMarketLeg = asObject({
+ venue: asString,
+ market_id: asString,
+ outcome_id: asOptional(asString),
+ url: asOptional(asString),
+ volume_24h: asOptional(asBiggystring),
+ resolution_date: asOptional(asString)
+})
+export type MarketLeg = ReturnType
+
+/**
+ * One real-world event matched across venues (a "cluster"), with a leg per
+ * venue and per-venue inside prices.
+ */
+export const asPredictionMarket = asObject({
+ id: asString,
+ title: asString,
+ category: asOptional(asString),
+ league: asOptional(asString),
+ image: asOptional(asString),
+ legs: asOptional(asArray(asMarketLeg), () => []),
+ venue_prices: asOptional(asArray(asVenuePrice), () => []),
+ book: asOptional(asMarketBook)
+})
+export type PredictionMarket = ReturnType
+
+export const asPredictionMarkets = asArray(asPredictionMarket)
+
+/**
+ * Formats a `0`-`1` decimal-string probability price as whole cents
+ * (`'0.62'` becomes `'62¢'`). Missing prices render as `'-'`.
+ */
+export const formatCentsPrice = (price?: string): string => {
+ if (price == null || price === '') return '-'
+ return `${toFixed(mul(price, '100'), 0, 0)}¢`
+}
+
+/**
+ * The market's best (lowest) ask: the merged book's `best_ask` when present,
+ * falling back to the lowest per-venue best ask. Both scenes highlight the
+ * best-priced venue with this value.
+ */
+export const getBestAskPrice = (
+ market: PredictionMarket
+): string | undefined => {
+ if (market.book?.best_ask != null) return market.book.best_ask
+ let best: string | undefined
+ for (const venuePrice of market.venue_prices) {
+ const ask = venuePrice.best_ask
+ if (ask == null) continue
+ if (best == null || lt(ask, best)) best = ask
+ }
+ return best
+}
+
+/** Hosts a market leg's `url` may open, matched with subdomains. */
+const VENUE_LINK_HOSTS = ['polymarket.com', 'hyperliquid.xyz', 'kalshi.com']
+
+/**
+ * True only for https URLs on a known venue site. Live API responses are
+ * untrusted: a scheme check alone still lets a hostile response point at
+ * hosts the app claims as App Links (deep.edge.app and friends), which the
+ * deep-link parser rewrites into in-app handlers, so venue links open only
+ * on this allowlist.
+ */
+export const isSafeVenueUrl = (url: string): boolean => {
+ if (!/^https:\/\//i.test(url)) return false
+ const authority = url.replace(/^https:\/\//i, '').split(/[/?#]/)[0]
+ // Userinfo makes everything before the "@" cosmetic
+ // (https://polymarket.com:443@evil.example/ opens evil.example), and venue
+ // URLs never carry credentials, so reject it outright:
+ if (authority.includes('@')) return false
+ const hostname = authority.split(':')[0].toLowerCase()
+ return VENUE_LINK_HOSTS.some(
+ allowedHost =>
+ hostname === allowedHost || hostname.endsWith(`.${allowedHost}`)
+ )
+}
diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx
index 0656673799e..37a4ead530d 100644
--- a/src/types/routerTypes.tsx
+++ b/src/types/routerTypes.tsx
@@ -49,6 +49,7 @@ import type { MigrateWalletCalculateFeeParams } from '../components/scenes/Migra
import type { MigrateWalletCompletionParams } from '../components/scenes/MigrateWalletCompletionScene'
import type { MigrateWalletSelectCryptoParams } from '../components/scenes/MigrateWalletSelectCryptoScene'
import type { OtpRepairParams } from '../components/scenes/OtpRepairScene'
+import type { PredictionMarketDetailsParams } from '../components/scenes/PredictionMarketDetailsScene'
import type { RampBankFormParams } from '../components/scenes/RampBankFormScene'
import type { RampBankRoutingDetailsParams } from '../components/scenes/RampBankRoutingDetailsScene'
import type { RampConfirmationParams } from '../components/scenes/RampConfirmationScene'
@@ -226,6 +227,8 @@ export type EdgeAppStackParamList = {} & {
otpSetup: undefined
passwordRecovery: undefined
pluginView: PluginViewParams
+ predictionMarketDetails: PredictionMarketDetailsParams
+ predictionMarkets: undefined
promotionSettings: undefined
rampBankForm: RampBankFormParams
rampBankRoutingDetails: RampBankRoutingDetailsParams