From e789cb6adbad044ab3a54b3efdfac6c0d46aeec8 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 14 Aug 2026 16:13:46 -0700 Subject: [PATCH 1/3] Add Slipstream Connect prediction markets data layer Typed cleaners and fetch client for the Slipstream Connect API (cross-venue prediction markets over Polymarket, Hyperliquid, and Kalshi), plus a bundled sample dataset used when no API key is configured. The optional key lives at ENV.PLUGIN_API_KEYS.slipstream. --- src/envConfig.ts | 9 +- src/locales/strings/enUS.json | 18 ++ .../prediction-markets/slipstreamApi.ts | 63 +++++ .../slipstreamSampleData.ts | 222 ++++++++++++++++++ .../prediction-markets/slipstreamTypes.ts | 141 +++++++++++ 5 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/plugins/prediction-markets/slipstreamApi.ts create mode 100644 src/plugins/prediction-markets/slipstreamSampleData.ts create mode 100644 src/plugins/prediction-markets/slipstreamTypes.ts 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/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}`) + ) +} From fc317ed8ef63aac2af3e2ca722808e16dd95713e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 14 Aug 2026 16:19:12 -0700 Subject: [PATCH 2/3] Add prediction market scenes and side menu entry Prediction Markets in the side menu opens a new list scene: category tabs (sports, crypto, macro, politics) and market cards comparing each venue's best ask, with the best-priced venue highlighted. Tapping a market opens a details scene showing per-venue bid/ask, the merged order book's top levels, and per-venue market metadata with links out. Scenes show a labeled sample dataset when no API key is configured. --- .../PredictionMarketDetailsScene.test.tsx | 25 + .../scenes/PredictionMarketListScene.test.tsx | 22 + ...PredictionMarketDetailsScene.test.tsx.snap | 1730 +++++++++++++++++ .../PredictionMarketListScene.test.tsx.snap | 773 ++++++++ src/components/Main.tsx | 14 + .../scenes/PredictionMarketDetailsScene.tsx | 290 +++ .../scenes/PredictionMarketListScene.tsx | 291 +++ src/components/themed/SideMenu.tsx | 8 + src/locales/en_US.ts | 25 + src/types/routerTypes.tsx | 3 + 10 files changed, 3181 insertions(+) create mode 100644 src/__tests__/scenes/PredictionMarketDetailsScene.test.tsx create mode 100644 src/__tests__/scenes/PredictionMarketListScene.test.tsx create mode 100644 src/__tests__/scenes/__snapshots__/PredictionMarketDetailsScene.test.tsx.snap create mode 100644 src/__tests__/scenes/__snapshots__/PredictionMarketListScene.test.tsx.snap create mode 100644 src/components/scenes/PredictionMarketDetailsScene.tsx create mode 100644 src/components/scenes/PredictionMarketListScene.tsx 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/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/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 From 0cb837b08504005e3f817bf318c9d80b3302c206 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 14 Aug 2026 16:21:38 -0700 Subject: [PATCH 3/3] Document the prediction markets prototype Technical design document covering the data module, scenes, sample fallback, and the decisions behind the read-only scope, plus the CHANGELOG entry. --- CHANGELOG.md | 1 + src/docs/prediction-markets-prototype.md | 218 +++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/docs/prediction-markets-prototype.md 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/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.