diff --git a/app/components/UI/PredictNext/README.md b/app/components/UI/PredictNext/README.md new file mode 100644 index 000000000000..c5bb4bcb464b --- /dev/null +++ b/app/components/UI/PredictNext/README.md @@ -0,0 +1,126 @@ +# PredictNext + +Predict integrates prediction market platforms like Polymarket and future Kalshi into MetaMask Mobile. Users browse events, place bets on outcomes, and manage positions. The feature also supports depositing and withdrawing funds. + +## Architecture Overview + +The system uses a four layer architecture. Components sit at the top, followed by hooks. Controllers and services handle the logic. Adapters connect to external protocols. + +### Services + +Six deep services manage the domain logic. TradingService handles orders. MarketDataService and PortfolioService extend BaseDataService for data fetching. TransactionService tracks on chain activity. LiveDataService provides real time updates. AnalyticsService records user interactions. + +### Orchestration + +A thin PredictController orchestrates write operations. It delegates tasks to the underlying services. + +### Adapters + +Protocol adapters like PolymarketAdapter and the future KalshiAdapter handle external communication. + +### Hooks + +Hooks are organized by domain in co-located folders with barrel exports. Data hooks are granular — each triggers exactly one query so components only fetch what they need. Imperative hooks (useTrading, useTransactions, useLiveData) remain deep since they manage complex stateful workflows. Domains include events, portfolio, trading, transactions, live-data, navigation, and guard. + +### Components + +Components follow a three tier structure. Primitives like EventCard and OutcomeButton form the base. Widgets like EventFeed and OrderForm combine primitives. Views like PredictHome and EventDetails represent full screens. + +## Directory Structure + +``` +PredictNext/ +├── README.md +├── UBIQUITOUS_LANGUAGE.md +├── index.ts # Public API +├── docs/ +│ ├── architecture.md +│ ├── services.md +│ ├── adapters.md +│ ├── components.md +│ ├── hooks.md +│ ├── testing.md +│ ├── state-management.md +│ ├── error-handling.md +│ └── migration/ +│ ├── README.md +│ └── phase-1-*.md +│ └── phase-2-*.md +│ └── phase-3-*.md +│ └── phase-4-*.md +│ └── phase-5-*.md +│ └── phase-6-*.md +│ └── phase-7-*.md +├── compat/ # Temporary translation layer (deleted in Phase 7) +│ ├── mappers.ts +│ ├── types.ts +│ └── index.ts +├── types/ +├── controller/ +├── services/ +│ ├── trading/ +│ ├── market-data/ +│ ├── portfolio/ +│ ├── transactions/ +│ ├── live-data/ +│ └── analytics/ +├── adapters/ +│ ├── types.ts +│ ├── polymarket/ +│ └── kalshi/ (future) +├── hooks/ +│ ├── events/ +│ ├── portfolio/ +│ ├── trading/ +│ ├── transactions/ +│ ├── live-data/ +│ ├── navigation/ +│ └── guard/ +├── components/ +│ ├── EventCard/ +│ ├── OutcomeButton/ +│ ├── PositionCard/ +│ ├── PriceDisplay/ +│ ├── Scoreboard/ +│ ├── Chart/ +│ └── Skeleton/ +├── widgets/ +│ ├── EventFeed/ +│ ├── FeaturedCarousel/ +│ ├── PortfolioSection/ +│ ├── OrderForm/ +│ └── ActivityList/ +├── views/ +│ ├── PredictHome/ +│ ├── EventDetails/ +│ ├── OrderScreen/ +│ └── TransactionsView/ +├── routes/ +├── selectors/ +├── constants/ +└── utils/ +``` + +## Public API + +The index.ts file defines the public API. It exports views for navigation and components for embedding. Hooks for data access, types, and selectors are also available. Internal modules like services and adapters remain private. + +## Design Principles + +Modules are deep with slim interfaces. We use compound components similar to the Vercel style. Read services extend BaseDataService. We define errors out of existence. The team uses DDD ubiquitous language for consistency. + +## Documentation Index + +- [Architecture](docs/architecture.md) +- [Services](docs/services.md) +- [Adapters](docs/adapters.md) +- [Components](docs/components.md) +- [Hooks](docs/hooks.md) +- [Testing](docs/testing.md) +- [State Management](docs/state-management.md) +- [Error Handling](docs/error-handling.md) +- [Migration](docs/migration/README.md) + +## Migration Status + +This feature is being built using an inside-out migration from the original Predict directory. The new adapter and services replace internals first while the old UI stays unchanged, then UI migrates as vertical slices. Check the [migration documentation](docs/migration/README.md) for details. diff --git a/app/components/UI/PredictNext/UBIQUITOUS_LANGUAGE.md b/app/components/UI/PredictNext/UBIQUITOUS_LANGUAGE.md new file mode 100644 index 000000000000..b4fb7f09050f --- /dev/null +++ b/app/components/UI/PredictNext/UBIQUITOUS_LANGUAGE.md @@ -0,0 +1,95 @@ +# Ubiquitous Language + +## Core Data Model + +| Term | Definition | Aliases to avoid | +| :----------- | :------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------- | +| **Event** | A group of related binary markets on a single topic. For example, "2026 NBA Finals" or "Will ETH hit $5k?" | Market (old codebase term), PredictMarket (old type name) | +| **Market** | A single binary question within an event, resolved as Yes or No. For example, "Lakers to win Game 7" | Outcome (old codebase term), PredictOutcome (old type name), condition | +| **Outcome** | One side of a binary market, representing a tradeable position. Usually labeled "Yes" or "No" but may have custom labels. | OutcomeToken (old codebase term), token, share | +| **Position** | A user's holdings in a specific outcome, measured in shares. | Bet, wager, stake | +| **Order** | A request to buy or sell outcome shares at a specified price. | Trade, transaction | + +## Order Lifecycle + +| Term | Definition | Aliases to avoid | +| :---------------- | :--------------------------------------------------------------------------------------------------------------------------- | :----------------------------- | +| **Active Order** | An order currently being processed through the placement pipeline. This includes preview, deposit, place, and confirm steps. | Pending order, in-flight order | +| **Order Preview** | A price quote showing estimated cost, fees, and potential return before placement. | Quote, estimate | +| **Cash Out** | Selling an existing position before event resolution. | Sell, exit | +| **Claim** | Collecting winnings from a resolved market where the user held the winning outcome. | Redeem, collect | + +## Financial Terms + +| Term | Definition | Aliases to avoid | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------- | +| **Deposit** | Transferring USDC from the user's wallet to their prediction market account. This usually goes to a Polymarket proxy wallet. | Fund, top up | +| **Withdraw** | Transferring USDC from the prediction market account back to the user's wallet. | Cash out (ambiguous, also means selling positions) | +| **Balance** | The user's available USDC in their prediction market account, ready for placing orders. | Funds, wallet balance (ambiguous, could mean main wallet) | +| **Volume** | Total USDC traded on a market or event across all users. | Liquidity (different concept) | +| **Liquidity** | The depth of available orders in a market's order book. Higher liquidity means less price slippage. | Volume (different concept) | + +## Platform Terms + +| Term | Definition | Aliases to avoid | +| :--------------- | :---------------------------------------------------------------------------------------------------------- | :--------------------------------------- | +| **Provider** | An external prediction market platform integrated as a data source. Examples include Polymarket and Kalshi. | Platform, exchange, source | +| **Adapter** | The code module that translates between a provider's native API and Predict's canonical data model. | Provider (overloaded), bridge, connector | +| **Proxy Wallet** | A smart contract wallet created on the provider's platform to hold user funds and execute trades. | Account, sub-wallet | + +## UI Terms + +| Term | Definition | Aliases to avoid | +| :----------------- | :--------------------------------------------------------------------------------- | :----------------------------------- | +| **Event Card** | A card component displaying an event with its markets and outcomes. | Market card (old term) | +| **Outcome Button** | A tappable button representing one outcome of a market, showing the current price. | Bet button (old term), action button | +| **Position Card** | A card displaying a user's position in a specific market, including P&L. | Position row, position detail | + +## Architecture Terms + +| Term | Definition | Aliases to avoid | +| :------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------- | +| **Service** | A deep module encapsulating a specific domain with a slim public interface. Examples include trading and market data. | Controller (different role), manager, handler | +| **Controller** | A thin orchestrator that delegates to services. It serves as the entry point for write operations from the UI. | Service (different role) | + +## Relationships + +- Each **Event** contains one or more **Markets**. +- A **Market** contains exactly two **Outcomes**, typically Yes and No. +- Every **Position** is tied to exactly one **Outcome**. +- An **Order** targets exactly one **Outcome**. +- The **Provider** is accessed through exactly one **Adapter**. +- An **Event** originates from exactly one **Provider**. + +## Example Dialogue + +> **Dev:** "When a user taps a market card and sees the details..." +> +> **Domain expert:** "That's an **Event Card**. This component shows an Event. For example, 'Will ETH hit $5k?' One Event may contain one or more Markets. In this case there's just one Market, the binary Yes/No question." +> +> **Dev:** "So when they tap the Yes button at $0.65 to place a bet..." +> +> **Domain expert:** "They're tapping an **Outcome Button** for the Yes Outcome. Such an action creates an Order to buy shares of that Outcome. We don't call it a 'bet' in code, it's an **Order**." +> +> **Dev:** "And after the order goes through, their holdings show up as a position?" +> +> **Domain expert:** "Exactly. The Order creates a Position. Users now hold shares of the Yes Outcome in that Market. If the Market resolves Yes, they can Claim their winnings." + +## Flagged Ambiguities + +- "market" was used in the old codebase to mean what is now an **Event**. In the new codebase, **Market** specifically means a single binary question within an **Event**. +- "outcome" was used in the old codebase to mean what is now a **Market**. In the new codebase, **Outcome** specifically means one side of a **Market**. +- "cash out" is ambiguous. It could mean **Withdraw**, moving USDC back to the wallet, or selling a **Position**. Use the specific term. +- "balance" is ambiguous without context. Always qualify as "prediction market **Balance**", funds in the proxy wallet, versus "wallet balance", the main MetaMask wallet. +- "provider" can mean the platform or the code module. Use **Provider** for the platform and **Adapter** for the code module. + +## Provider Terminology Mapping + +| Canonical Term | Polymarket Term | Kalshi Term | +| :------------- | :---------------------- | :------------------- | +| Event | Event | Event | +| Market | Market / Condition | Market / Contract | +| Outcome | Outcome token | Yes/No contract | +| Position | Position | Position | +| Order | Order | Order | +| Proxy Wallet | Polymarket proxy (Safe) | N/A (direct trading) | diff --git a/app/components/UI/PredictNext/docs/adapters.md b/app/components/UI/PredictNext/docs/adapters.md new file mode 100644 index 000000000000..b1c9bb4ee4e9 --- /dev/null +++ b/app/components/UI/PredictNext/docs/adapters.md @@ -0,0 +1,481 @@ +# PredictNext Adapter Layer + +This document describes the adapter layer for PredictNext. Adapters are the boundary between external prediction-market providers and the canonical Predict domain model used by services, hooks, and components. + +Related documents: + +- [architecture.md](./architecture.md) +- [services.md](./services.md) +- [error-handling.md](./error-handling.md) +- [testing.md](./testing.md) + +## 1. Adapter Pattern Overview + +Adapters translate provider-specific APIs into Predict's canonical model: + +- `PredictEvent` +- `PredictMarket` +- `PredictOutcome` +- `PredictPosition` +- `ActivityItem` +- `OrderPreview` +- `TransactionBatch` + +The adapter layer exists so the rest of the feature never needs to depend on: + +- provider-specific DTOs +- endpoint naming +- authentication details +- socket transport details +- provider-specific account models + +### Target shape + +Each adapter should expose roughly fifteen methods. Those methods should be fetch-and-transform only. + +Adapters do: + +- call remote APIs or SDKs +- transform remote payloads +- build provider-specific order or transaction payloads +- create live data subscriptions + +Adapters do not: + +- cache +- retry +- orchestrate multi-step workflows +- manage UI-facing state +- emit analytics +- decide product behavior + +The rest of PredictNext treats adapters as swappable protocol implementations behind a stable domain contract. + +```text + [ Service ] [ Adapter ] [ Provider API ] + | | | + |--- call method ---->| | + | |--- request --------->| + | | | + | |<-- provider DTO -----| + | | | + | | [ Transform DTO ] | + | | [ to PredictType ] | + | | | + |<-- PredictType -----| | +``` + +Adding a new provider should primarily mean implementing one interface. + +## 2. PredictAdapter Interface + +The `PredictAdapter` contract defines the full provider boundary for the redesigned feature. + +```typescript +export type Unsubscribe = () => void; + +export type TimePeriod = '1H' | '1D' | '1W' | '1M' | 'ALL'; + +export interface PaginatedResult { + items: T[]; + nextCursor?: string; +} + +export interface PredictOutcome { + id: string; + label: string; + price: string; + probability: number; + volume?: string; +} + +export interface PredictMarket { + id: string; + eventId: string; + question: string; + status: 'open' | 'closed' | 'resolved'; + closesAt?: string; + resolvesAt?: string; + outcomes: PredictOutcome[]; +} + +export interface PredictEvent { + id: string; + title: string; + subtitle?: string; + category?: string; + status: 'upcoming' | 'live' | 'resolved'; + startsAt?: string; + resolvesAt?: string; + markets: PredictMarket[]; +} + +export interface PricePoint { + timestamp: string; + value: string; +} + +export interface MarketPrices { + marketId: string; + bestBid?: string; + bestAsk?: string; + lastTradedPrice?: string; + updatedAt: string; +} + +export interface PredictPosition { + id: string; + accountId: string; + marketId: string; + outcomeId: string; + shares: string; + averageEntryPrice: string; + currentValue: string; + unrealizedPnL: string; +} + +export interface ActivityItem { + id: string; + type: 'order' | 'deposit' | 'withdrawal' | 'claim'; + status: 'pending' | 'confirmed' | 'failed'; + timestamp: string; + description: string; + txHash?: string; +} + +export interface Balance { + tokenAddress: string; + symbol: string; + amount: string; + decimals: number; +} + +export interface AccountState { + accountId: string; + availableBalances: Balance[]; + selectedPaymentTokenAddress?: string; + canTrade: boolean; + requiresWalletSetup: boolean; +} + +export interface FetchEventsParams { + cursor?: string; + league?: string; + status?: 'upcoming' | 'live' | 'resolved'; + sort?: 'featured' | 'volume' | 'endingSoon'; + limit?: number; +} + +export interface PreviewParams { + accountId: string; + marketId: string; + outcomeId: string; + side: 'buy' | 'sell'; + amount: string; + paymentTokenAddress?: string; +} + +export interface SubmitOrderParams extends PreviewParams { + slippageBps?: number; +} + +export interface OrderPreview { + marketId: string; + outcomeId: string; + estimatedShares: string; + averagePrice: string; + fee: string; + requiresDeposit: boolean; + totalCost: string; +} + +export interface OrderReceipt { + orderId: string; + status: 'submitted' | 'filled' | 'partially_filled'; + providerOrderId?: string; + txHashes: string[]; +} + +export interface DepositParams { + accountId: string; + tokenAddress: string; + amount: string; +} + +export interface WithdrawParams { + accountId: string; + tokenAddress: string; + amount: string; +} + +export interface ClaimParams { + accountId: string; + eventId: string; + marketIds?: string[]; +} + +export interface TransactionRequest { + to: string; + data: string; + value?: string; +} + +export interface TransactionBatch { + chainId: string; + requests: TransactionRequest[]; + requiresSignature: boolean; +} + +export interface PredictAdapter { + fetchEvents( + params: FetchEventsParams, + ): Promise>; + fetchEvent(eventId: string): Promise; + fetchCarouselEvents(): Promise; + searchEvents(query: string): Promise; + fetchPriceHistory( + marketId: string, + period: TimePeriod, + ): Promise; + fetchPrices(marketIds: string[]): Promise>; + + fetchPositions(accountId: string): Promise; + fetchActivity( + accountId: string, + cursor?: string, + ): Promise>; + fetchBalance(accountId: string): Promise; + fetchAccountState(accountId: string): Promise; + + submitOrder(params: SubmitOrderParams): Promise; + getOrderPreview(params: PreviewParams): Promise; + + buildDepositTx(params: DepositParams): Promise; + buildWithdrawTx(params: WithdrawParams): Promise; + buildClaimTx(params: ClaimParams): Promise; + + createSubscription( + channel: string, + params: unknown, + callback: (data: unknown) => void, + ): Unsubscribe; +} +``` + +### Why this interface is intentionally broad but shallow + +The adapter interface spans the provider boundary for three reasons: + +1. services need one place to get provider capabilities +2. the rest of the system should not depend on provider SDKs +3. adding a provider should not force new abstractions into higher layers + +Even so, the interface remains shallow. It describes capabilities, not orchestration. For example, `submitOrder()` exists, but `depositThenSubmitOrder()` does not. That workflow belongs in `TradingService`, not in the adapter. + +## 3. PolymarketAdapter Implementation + +`PolymarketAdapter` is the initial concrete adapter for PredictNext. + +### Provider surfaces used + +Polymarket requires multiple underlying APIs and transports: + +- Gamma API for market and event discovery +- CLOB API for order placement and previewing +- Polymarket account endpoints for balances, positions, and activity +- WebSocket feeds for live price and status updates + +```text + PredictAdapter (Interface) + ^ + | + +-------------------+ + | PolymarketAdapter | + +-------------------+ + / | \ \ + v v v v + Gamma API CLOB API Account WebSockets + (Events) (Orders) Endpoints (Live) +``` + +The adapter unifies those sources into the Predict domain model. + +### Transformation responsibility + +The core job of `PolymarketAdapter` is transformation. The rest of the application should never depend on Gamma event DTOs or CLOB-specific terminology. + +Examples of translation: + +- Polymarket event payloads become `PredictEvent` +- Polymarket markets become `PredictMarket` +- Polymarket outcomes become `PredictOutcome` +- account holdings become `PredictPosition` +- fills, deposits, and claims become `ActivityItem` + +### Authentication responsibility + +Authentication details also stay inside the adapter boundary. + +Examples: + +- API key management for Polymarket endpoints +- CLOB signing requirements +- account-specific headers or session configuration + +Services can request capabilities like `getOrderPreview()` or `submitOrder()` without knowing how Polymarket authenticates those calls. + +### Example transformation + +The following sketch shows the shape of a provider-to-domain transform for event reads. + +```typescript +interface PolymarketGammaEventDto { + id: string; + title: string; + description?: string; + active: boolean; + closed: boolean; + archived: boolean; + startDate?: string; + endDate?: string; + markets: Array<{ + id: string; + question: string; + active: boolean; + closed: boolean; + outcomes: Array<{ + id: string; + label: string; + price: string; + probability?: number; + }>; + }>; +} + +function mapPolymarketEvent(dto: PolymarketGammaEventDto): PredictEvent { + const status: PredictEvent['status'] = + dto.archived || dto.closed ? 'resolved' : dto.active ? 'live' : 'upcoming'; + + return { + id: dto.id, + title: dto.title, + subtitle: dto.description, + status, + startsAt: dto.startDate, + resolvesAt: dto.endDate, + markets: dto.markets.map((market) => ({ + id: market.id, + eventId: dto.id, + question: market.question, + status: market.closed ? 'closed' : market.active ? 'open' : 'resolved', + outcomes: market.outcomes.map((outcome) => ({ + id: outcome.id, + label: outcome.label, + price: outcome.price, + probability: outcome.probability ?? Number(outcome.price), + })), + })), + }; +} +``` + +The specific mapping details will evolve, but the architectural rule does not: transformation belongs here, not in services or hooks. + +## 4. Future KalshiAdapter + +`KalshiAdapter` is the expected next provider implementation. The existing `PredictAdapter` interface is designed to support it without changing higher layers. + +### Same contract, different transport and semantics + +Kalshi is likely to differ from Polymarket in several important ways: + +- direct trading rather than a proxy wallet flow +- different order types and preview semantics +- different account and position models +- SSE or another streaming mechanism instead of the same WebSocket contract + +Those differences should remain inside `KalshiAdapter`. + +### How the interface accommodates provider differences + +The contract is intentionally phrased in product capabilities, not provider implementation details. + +Examples: + +- `submitOrder()` does not require callers to know how the provider executes the order +- `buildDepositTx()` may return a trivial or empty batch if the provider does not need the same funding flow +- `createSubscription()` abstracts whether the provider uses WebSocket, SSE, or another push channel +- `fetchAccountState()` normalizes eligibility and setup conditions into a Predict-friendly shape + +This means the service layer can remain stable even when providers differ substantially. + +### Provider-specific freedom inside the boundary + +The adapter interface does not force identical internal implementations. A Kalshi adapter may: + +- omit proxy-wallet mechanics internally +- translate provider-specific order states into the canonical order result shape +- use different auth or signing models +- compose multiple APIs differently than Polymarket does + +The only requirement is that callers continue to receive canonical Predict entities and capability-level methods. + +## 5. Adding a New Provider + +Adding a provider should be a bounded infrastructure change, not a feature-wide rewrite. + +### Step 1: implement `PredictAdapter` + +Create a concrete adapter that fulfills the full `PredictAdapter` interface. Every returned value must be canonical Predict domain data, not provider DTOs. + +### Step 2: add provider configuration + +Define adapter-specific configuration such as: + +- base URLs +- auth settings +- chain and token defaults +- supported live-data channels +- provider capability flags if needed for internal adapter decisions + +### Step 3: register in the adapter factory + +Use a provider key to resolve the correct adapter implementation. + +Illustrative shape: + +```typescript +export type PredictProvider = 'polymarket' | 'kalshi'; + +export interface PredictAdapterFactory { + create(provider: PredictProvider): PredictAdapter; +} +``` + +The factory is the seam where environment, feature flags, or account-specific provider selection can be resolved. + +### Step 4: verify service compatibility + +Run service integration tests against the new adapter contract. If service code needs provider-specific branching, that is a design smell. Prefer pushing that difference downward into the adapter. + +### Step 5: add adapter integration tests + +Test the new adapter at the provider boundary: + +- HTTP payload mapping +- account mapping +- order preview mapping +- transaction batch construction +- live data subscription translation + +### Acceptance rule for new providers + +A new provider integration is architecturally successful when: + +- hooks do not change +- components do not change +- controller shape does not change +- service public interfaces do not change +- only adapter implementation and provider configuration need significant work + +That is the payoff of keeping adapters thin, services deep, and the public Predict model canonical. diff --git a/app/components/UI/PredictNext/docs/architecture.md b/app/components/UI/PredictNext/docs/architecture.md new file mode 100644 index 000000000000..5c397731ed91 --- /dev/null +++ b/app/components/UI/PredictNext/docs/architecture.md @@ -0,0 +1,634 @@ +# PredictNext Architecture + +This document is the entry point for the PredictNext redesign. It describes the target architecture for prediction markets in MetaMask Mobile, the responsibilities of each layer, and the boundaries that keep the system small at the surface and deep underneath. + +PredictNext is designed around a canonical prediction-market model: + +- `PredictEvent` +- `PredictMarket[]` +- `PredictOutcome[]` + +Provider-specific complexity lives below that model. Views, hooks, and most service APIs should speak only in Predict terminology, not in Polymarket or future provider terminology. + +Related documents: + +- [services.md](./services.md) +- [adapters.md](./adapters.md) +- [hooks.md](./hooks.md) +- [components.md](./components.md) +- [state-management.md](./state-management.md) +- [error-handling.md](./error-handling.md) +- [testing.md](./testing.md) +- [../UBIQUITOUS_LANGUAGE.md](../UBIQUITOUS_LANGUAGE.md) + +## 1. Design Principles + +### Deep modules, slim interfaces + +PredictNext follows the core idea from John Ousterhout's _A Philosophy of Software Design_: modules should be deep, not wide. A good module hides a large amount of complexity behind a small, stable public API. + +In the current implementation, the opposite happened: + +- the controller owns too many responsibilities +- provider logic leaks upward +- UI hooks duplicate orchestration logic +- complexity is spread across many files instead of buried in a few strong modules + +The redesign reverses that. + +- Adapters are narrow translation boundaries. +- Services are deep modules that own orchestration. +- Hooks are mostly thin integration seams. +- Components focus on rendering and user interaction. + +The result should be fewer public methods, fewer cross-layer dependencies, and fewer states that UI code must understand. + +### Pull complexity downward + +PredictNext explicitly pushes operational complexity into the service layer. + +Services absorb: + +- retry policies +- cache invalidation +- concurrency control +- request deduplication +- optimistic overlays +- subscription lifecycle +- order state machines +- provider-specific fallbacks +- transaction orchestration + +That means higher layers do not coordinate retries, reconcile partial state, or interpret low-level failures. They ask for intent-level operations and receive intent-level results. + +### Define errors out of existence + +The preferred design is not to expose more errors with better naming. It is to make many errors impossible for callers to experience. + +Examples: + +- transient HTTP failures are retried inside data services +- repeated WebSocket disconnects are handled by reconnection policy in `LiveDataService` +- deposit-before-order sequencing is hidden inside `TradingService` +- provider-specific transaction failures are normalized into a single Predict error model + +The UI should rarely need to reason about raw transport failures. It should primarily render user-meaningful states: + +- empty state +- unavailable +- action failed +- degraded + +### Different layer, different abstraction + +Each layer owns a distinct abstraction and should not borrow another layer's language. + +| Layer | Primary abstraction | Should not expose | +| ---------- | ---------------------------------- | ------------------------------------------- | +| Adapters | Provider translation | UI concepts, caching, orchestration | +| Services | Product capabilities and workflows | Provider DTOs, raw transport details | +| Hooks | React integration | Business workflows duplicated from services | +| Components | Presentation and interaction | Provider protocols, transaction plumbing | + +If a component or hook needs to know too much about provider formats, order transitions, cache policy, or transaction building, complexity has leaked upward and the boundary is wrong. + +### DDD ubiquitous language + +PredictNext uses a shared domain vocabulary documented in [../UBIQUITOUS_LANGUAGE.md](../UBIQUITOUS_LANGUAGE.md). All public APIs should prefer Predict terminology over provider terminology. + +Core terms include: + +- Event +- Market +- Outcome +- Position +- Activity +- Order Preview +- Order Result +- Account State +- Price History + +This keeps interfaces stable even as providers change. Polymarket and Kalshi may model their APIs differently, but adapters translate those differences into the same domain language before the rest of the stack sees them. + +## 2. Architecture Layers + +PredictNext is organized into four layers, bottom-up. + +4-Layer Architecture Overview: + +```text + (Responses) + (Requests) ▲ + │ │ + ▼ │ +┌──────────────────────────────────────────────────────────┐ +│ Layer 4: Components (Views → Widgets → Primitives) │ +└───────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Layer 3: Hooks (events/, portfolio/, trading/, etc.) │ +└───────────┬───────────────────────────┬──────────────────┘ + │ │ + │ (Read Path) │ + ▼ ┌─────────────┘ +┌─────────────────────────┤ ▼ +│ Layer 2: Controller │ ┌────────────────────────────┐ +│ (PredictController) │ │ Layer 2: Services │ +└───────────┬─────────────┘ │ (MarketDataService, etc.) │ + │ └─────────┬──────────────────┘ + ▼ │ +┌─────────────────────────┐ │ +│ Layer 2: Services │ │ +│ (TradingService, etc.) │ │ +└───────────┬─────────────┘ │ + └─────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Layer 1: Adapters (PolymarketAdapter, KalshiAdapter) │ +└───────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ External APIs (Polymarket APIs, Kalshi APIs) │ +└──────────────────────────────────────────────────────────┘ +``` + +### Layer 1 — Adapters + +Adapters are thin protocol boundaries that translate provider APIs into the canonical Predict model. + +Responsibilities: + +- fetch provider data +- transform provider DTOs into canonical domain entities +- build provider-specific transactions or order payloads +- open provider-specific live data connections + +Non-responsibilities: + +- caching +- retries +- rate limiting +- orchestration across multiple operations +- UI state +- analytics + +Target shape: + +- around 15 methods per adapter +- fetch-and-transform only +- stateless except for lightweight auth/session primitives required by the provider SDK + +Primary implementations: + +- `PolymarketAdapter` +- future `KalshiAdapter` + +The adapter contract is defined in `adapters/types.ts`. See [adapters.md](./adapters.md) for detail. + +### Layer 2 — Services + Controller + +The service layer is the center of the redesign. + +PredictNext uses six deep services: + +1. `MarketDataService` +2. `PortfolioService` +3. `TradingService` +4. `TransactionService` +5. `LiveDataService` +6. `AnalyticsService` + +#### BaseDataService-backed read services + +`MarketDataService` and `PortfolioService` extend `@metamask/base-data-service`. + +These services are built on TanStack Query at the service level and provide: + +- shared cache +- request deduplication +- retry via Cockatiel policy +- circuit breaker behavior +- messenger-based access from React hooks + +These services register directly with Engine via messenger. Reads do not flow through a controller intermediary. + +#### Plain services for orchestration and writes + +`TradingService`, `TransactionService`, `LiveDataService`, and `AnalyticsService` are plain services with deliberately small public APIs and deep internals. + +They own: + +- write workflows +- transaction orchestration +- order state transitions +- realtime subscription multiplexing +- analytics event formatting and batching + +#### PredictController as thin orchestrator + +`PredictController` becomes a narrow facade with roughly ten public methods, down from the current 60+ method surface. + +Its role is to: + +- expose write operations into Engine context +- coordinate lifecycle setup and teardown +- delegate immediately to services + +Its role is not to: + +- implement business logic directly +- serve as the read path for queries +- manage custom caches +- know provider-specific rules in detail + +See [services.md](./services.md) for detail. + +### Layer 3 — Hooks + +Hooks provide React-friendly access to the service layer while preserving service ownership of business complexity. + +Hooks are organized by domain in co-located folders with barrel exports: + +- `hooks/events/` — `useFeaturedEvents`, `useEventList`, `useEventSearch`, `useEventDetail`, `usePriceHistory`, `usePrices` +- `hooks/portfolio/` — `usePositions`, `useBalance`, `useActivity`, `usePnL` +- `hooks/trading/` — `useTrading` +- `hooks/transactions/` — `useTransactions` +- `hooks/live-data/` — `useLiveData` +- `hooks/navigation/` — `usePredictNavigation` +- `hooks/guard/` — `usePredictGuard` + +#### Granular query hooks + +Event and portfolio hooks are granular — each hook triggers exactly one `useQuery` or `useInfiniteQuery` call from `@metamask/react-data-query`. This means a component that only needs the balance does not trigger position, activity, or P&L queries. The actual read logic lives in BaseDataService-backed services via messenger. + +#### Deep imperative hooks + +`useTrading`, `useTransactions`, and `useLiveData` remain deep because they wrap imperative service operations and lifecycle concerns (order state machines, transaction orchestration, WebSocket subscriptions). + +#### Navigation and guard hooks + +`usePredictNavigation` and `usePredictGuard` isolate routing and eligibility concerns from views. + +#### View-local hooks + +Any view-specific derived state should live in thin local hooks colocated with the view. These hooks may combine service data with presentation needs, but they must not recreate service orchestration. + +See [hooks.md](./hooks.md) for detail. + +### Layer 4 — Components + +Components are organized into three tiers. + +#### Tier 1: Predict design system primitives + +Roughly seven reusable, compound primitives form the UI vocabulary of PredictNext: + +- `EventCard` +- `OutcomeButton` +- `PositionCard` +- `PriceDisplay` +- `Scoreboard` +- `Chart` +- `Skeleton` + +These should feel like product-specific design system building blocks: composable, visually consistent, and free of provider logic. + +#### Tier 2: Composed widgets + +Widgets assemble primitives into reusable product blocks: + +- `EventFeed` +- `PortfolioSection` +- `FeaturedCarousel` +- `OrderForm` +- `ActivityList` + +#### Tier 3: Views and screens + +Views compose widgets and hooks into complete product surfaces: + +- `PredictHome` +- `EventDetails` +- `OrderScreen` +- `TransactionsView` + +See [components.md](./components.md) for detail. + +## 3. Data Flow Diagrams + +### Reading data: events list + +```text +PredictHome → useEventList(params) → useInfiniteQuery({ queryKey: ['PredictMarketData:getEvents', params] }) + ↕ (messenger bridge) + MarketDataService.getEvents() → this.fetchQuery() → PolymarketAdapter.fetchEvents() → Polymarket Gamma API +``` + +Key properties of this flow: + +- the UI does not know which provider is serving data +- query keys are stable, explicit contracts +- caching and retries happen below React +- read operations never route through `PredictController` + +### Writing data: place order + +```text +OrderScreen → useTrading.placeOrder(params) + → Engine.context.PredictController.placeOrder(params) + → TradingService.placeOrder(params) + → [state machine: PREVIEW → DEPOSITING → PLACING → SUCCESS] + → TransactionService.deposit() (if needed) + → PolymarketAdapter.submitOrder() +``` + +Key properties of this flow: + +- the view expresses intent, not protocol steps +- order sequencing is buried in `TradingService` +- funding requirements are hidden from the caller +- provider-specific order payloads are hidden in the adapter + +### Real-time data: live prices + +```text +EventDetails → useLiveData.subscribe('marketPrices', { marketId }) + → LiveDataService.subscribe() + → PolymarketAdapter.createSubscription() + → WebSocket connection + → callback → React state update +``` + +Key properties of this flow: + +- channel subscription is generic at the hook boundary +- socket ownership lives entirely in `LiveDataService` +- reconnection, multiplexing, and channel fan-out are internal service concerns + +## 4. State Management Overview + +PredictNext intentionally uses different state containers for different lifetimes and concerns. + +### BaseDataService shared cache + +`MarketDataService` and `PortfolioService` hold server-state reads using BaseDataService and TanStack Query semantics. + +Use this for: + +- events +- event details +- prices +- positions +- account balances +- activity history + +Why it belongs here: + +- shared across views +- benefits from cache and stale-time control +- naturally query-shaped +- should be deduplicated across consumers + +### Redux via PredictController state + +Controller-managed Redux state is reserved for session state that is not just remote read data. + +Use this for: + +- active orders in progress +- selected payment tokens +- pending deposits +- session-scoped trading context + +Why it belongs here: + +- needs to survive navigation +- may combine user intent with service progress +- is not a straightforward cache of remote data + +### Service internals + +Services own transient operational state that should not leak outward. + +Examples: + +- rate limit windows +- optimistic overlays +- request in-flight maps +- circuit breaker status +- socket connection lifecycle +- subscription registry + +Why it belongs here: + +- callers should not coordinate it +- it is implementation detail +- exposing it would widen the public surface unnecessarily + +### React local state + +Views own purely local presentation state. + +Examples: + +- keypad input +- scroll position +- tab selection +- search text +- inline form focus + +Why it belongs here: + +- no other layer benefits from owning it +- it should be destroyed with the view + +Reference [state-management.md](./state-management.md). + +## 5. Error Handling Overview + +The architecture is designed so that most low-level failures are never directly rendered. + +### Internal absorption + +Services absorb transient failures through: + +- retries +- circuit breakers +- reconnection loops +- fallback fetches +- normalized provider errors + +### UI-visible categories + +Only four categories should commonly surface to the UI: + +1. `empty state` — there is simply no relevant content yet +2. `unavailable` — the feature or data source is currently inaccessible +3. `action failed` — a user-initiated operation did not complete +4. `degraded` — partial functionality is still available + +### Unified error model + +All service-facing failures should normalize to a single `PredictError` model with: + +- `code` +- `message` +- `recoverable` +- optional structured metadata + +This keeps hooks and components from branching on provider exceptions or transport-specific failures. + +Reference [error-handling.md](./error-handling.md). + +## 6. Testing Strategy Overview + +The redesign reduces test volume by moving complexity into fewer, deeper modules. + +### Primary test surfaces + +#### Component view tests + +Component view tests are the primary surface because they validate meaningful user behavior with real Redux and minimal mocking. + +#### Service integration tests + +Services should be tested by mocking only the adapter boundary and verifying behavior of the deep module: + +- retries +- state machine transitions +- cache invalidation +- transaction sequencing +- subscription fan-out + +#### Adapter integration tests + +Adapters should be tested with HTTP interception such as `nock`, validating transformation from provider payloads into canonical Predict entities. + +#### Minimal unit tests + +Standalone unit tests should be limited to pure utilities with real branching value. + +### Outcome target + +By concentrating complexity in deep modules and shrinking API surface area, the test suite should need far less scaffolding than the current system. The target is roughly an 85% to 90% reduction from the current 87K lines of test code while keeping or improving confidence. + +Reference [testing.md](./testing.md). + +## 7. Module Boundaries + +PredictNext should present a deliberate public surface. + +Module Boundary: + +```text +┌────────────────────────────────────────────────────────────────┐ +│ PredictNext Module Boundary │ +├───────────────────────────────┬────────────────────────────────┤ +│ PUBLIC (index.ts) │ INTERNAL │ +├───────────────────────────────┼────────────────────────────────┤ +│ • Views │ • Services │ +│ • Components │ • Adapters │ +│ • Hooks │ • Widgets │ +│ • Types │ • Utils │ +│ • Selectors │ • Constants │ +│ │ • Provider DTOs │ +└───────────────────────────────┴────────────────────────────────┘ +``` + +### Public API + +The package-level `index.ts` should export only the stable product surface: + +- views +- key components +- public hooks +- public types +- selectors + +Illustrative boundary: + +```typescript +export type { + PredictEvent, + PredictMarket, + PredictOutcome, + PredictPosition, + OrderPreview, + OrderResult, +} from './types'; + +export { + PredictHome, + EventDetails, + OrderScreen, + TransactionsView, +} from './views'; +export { EventCard, PositionCard, OutcomeButton } from './components'; +// Event query hooks +export { + useFeaturedEvents, + useEventList, + useEventSearch, + useEventDetail, + usePriceHistory, + usePrices, +} from './hooks/events'; +// Portfolio query hooks +export { + usePositions, + useBalance, + useActivity, + usePnL, +} from './hooks/portfolio'; +// Imperative hooks +export { useTrading } from './hooks/trading'; +export { useTransactions } from './hooks/transactions'; +export { useLiveData } from './hooks/live-data'; +export { usePredictNavigation } from './hooks/navigation'; +export { usePredictGuard } from './hooks/guard'; +export { + selectPredictActiveOrder, + selectPredictSelectedPaymentToken, +} from './selectors'; +``` + +### Internal modules + +The following stay internal and are not exported from the feature root: + +- services +- adapters +- widgets +- utils +- constants +- provider DTOs +- adapter factories + +### Enforcement model + +Boundary enforcement is convention-based first: + +- only import from explicitly public entrypoints +- avoid relative imports into service internals from UI layers +- keep provider types local to adapters + +An ESLint rule can later formalize the boundary, but the architecture should not rely on tooling to make the design understandable. + +Terminology should remain aligned with [../UBIQUITOUS_LANGUAGE.md](../UBIQUITOUS_LANGUAGE.md). + +## 8. Documentation Index + +This directory is intended to describe the whole PredictNext feature architecture in layers. + +- [architecture.md](./architecture.md) — master architecture overview, layering, state, errors, and boundaries. +- [services.md](./services.md) — service layer design, controller surface, and service interaction patterns. +- [adapters.md](./adapters.md) — provider adapter contract, provider implementations, and extension model. +- [hooks.md](./hooks.md) — React integration layer, query hooks, imperative hooks, and local derived-state guidance. +- [components.md](./components.md) — UI composition model, primitive/component tiers, and rendering boundaries. +- [state-management.md](./state-management.md) — where each category of state lives and why. +- [error-handling.md](./error-handling.md) — Predict error model, recovery behavior, and UI error states. +- [testing.md](./testing.md) — recommended testing pyramid and scope boundaries for adapters, services, and views. +- [../UBIQUITOUS_LANGUAGE.md](../UBIQUITOUS_LANGUAGE.md) — domain vocabulary for Events, Markets, Outcomes, Positions, Orders, and account state. diff --git a/app/components/UI/PredictNext/docs/components.md b/app/components/UI/PredictNext/docs/components.md new file mode 100644 index 000000000000..7377516a2817 --- /dev/null +++ b/app/components/UI/PredictNext/docs/components.md @@ -0,0 +1,722 @@ +# PredictNext Component Architecture + +## Design Philosophy + +PredictNext uses a 3-tier component taxonomy: + +1. Primitives: reusable building blocks with no screen awareness +2. Widgets: composed sections of a screen +3. Views: route-level layout and wiring + +The redesign follows deep modules and slim interfaces. A small number of components own the complexity of rendering prediction-market data instead of spreading variant logic across many shallow files. This keeps view code small and gives teams one place to evolve behavior. + +Core rules: + +- Prefer one deep component over many variant-specific wrappers +- Keep layout flexibility through composition, not prop explosion +- Use the MetaMask Mobile design system first: `useTailwind`, `Box`, and `Text` +- Use compound components where a parent can provide shared context to related children +- Keep domain formatting and rendering logic inside primitives when it improves reuse +- Primitives are pure (no hooks) — widgets wire data hooks to primitives — views compose widgets + +```text +TIER 3: Views (route-level) +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────────┐ +│ PredictHome │ │ EventDetails │ │ OrderScreen │ │ TransactionsView│ +└──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └────────┬────────┘ + │ │ │ │ + v v v v +TIER 2: Widgets (composed sections) +┌───────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ EventFeed │ │ FeaturedCarousel │ │ PortfolioSection│ │ OrderForm │ +└─────┬─────┘ └────────┬─────────┘ └────────┬────────┘ └──────┬───────┘ + │ │ │ │ + v v v v +TIER 1: Primitives (reusable building blocks) +┌───────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ +│ EventCard │ │ OutcomeButton │ │ PositionCard │ │ PriceDisplay │ +└───────────┘ └───────────────┘ └──────────────┘ └──────────────┘ +``` + +Legend: + +- Primitives: Pure (no hooks), receive data via props +- Widgets: Wire data hooks to primitives +- Views: Compose widgets with imperative/guard hooks + +Related docs: + +- [hooks](./hooks.md) +- [state management](./state-management.md) +- [testing](./testing.md) +- [error handling](./error-handling.md) +- [services](./services.md) + +## Tier 1: Predict Design System Primitives + +Tier 1 primitives are used across feeds, detail screens, portfolio surfaces, and order flows. They know about Predict domain entities, but not about specific route composition. + +### EventCard + +`EventCard` is the core compound component for event presentation. It replaces multiple card and row variants by internalizing layout, market-count, and sport-specific logic. + +```tsx + + + + + + +``` + +Why this shape works: + +- `EventCard` provides event context once +- sub-components can be reordered or omitted per screen +- sport, crypto, binary, and multi-market rendering differences remain internal +- compact row and full card layouts can share the same public API + +```text + ← provides context + ├── ← reads from context + ├── ← reads from context + ├── ← reads from context + └── ← reads from context (optional) +``` + +Suggested file structure: + +```text +components/ + primitives/ + EventCard/ + EventCard.tsx + EventCardHeader.tsx + EventCardMarkets.tsx + EventCardFooter.tsx + EventCardScoreboard.tsx + EventCardContext.tsx + index.ts +``` + +Example implementation sketch: + +```tsx +// components/primitives/EventCard/EventCardContext.tsx +import React, { createContext, useContext } from 'react'; +import { Box, Text } from '@metamask/design-system-react-native'; +import type { PredictEvent } from '../../types'; + +export interface EventCardContextValue { + event: PredictEvent; + variant: 'card' | 'row' | 'detail'; + density: 'compact' | 'comfortable'; +} + +const EventCardContext = createContext(null); + +export function EventCardProvider({ + value, + children, +}: { + value: EventCardContextValue; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useEventCardContext() { + const context = useContext(EventCardContext); + + if (!context) { + throw new Error( + 'EventCard sub-components must be rendered within EventCard', + ); + } + + return context; +} + +export function EventCardHeader() { + const { event } = useEventCardContext(); + return ( + + {event.title} + + ); +} +``` + +```tsx +// components/primitives/EventCard/EventCard.tsx +import React from 'react'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box } from '@metamask/design-system-react-native'; +import type { PredictEvent } from '../../types'; +import { EventCardProvider } from './EventCardContext'; +import { EventCardHeader } from './EventCardHeader'; +import { EventCardMarkets } from './EventCardMarkets'; +import { EventCardFooter } from './EventCardFooter'; +import { EventCardScoreboard } from './EventCardScoreboard'; + +interface EventCardProps { + event: PredictEvent; + variant?: 'card' | 'row' | 'detail'; + density?: 'compact' | 'comfortable'; + children: React.ReactNode; +} + +type EventCardCompound = React.FC & { + Header: typeof EventCardHeader; + Markets: typeof EventCardMarkets; + Footer: typeof EventCardFooter; + Scoreboard: typeof EventCardScoreboard; +}; + +const EventCardBase: React.FC = ({ + event, + variant = 'card', + density = 'comfortable', + children, +}) => { + const tw = useTailwind(); + + return ( + + + {children} + + + ); +}; + +export const EventCard = EventCardBase as EventCardCompound; +EventCard.Header = EventCardHeader; +EventCard.Markets = EventCardMarkets; +EventCard.Footer = EventCardFooter; +EventCard.Scoreboard = EventCardScoreboard; +``` + +### OutcomeButton + +`OutcomeButton` replaces specialized buy, claim, and cash-out buttons with a single stateful action surface. + +Public contract: + +- `outcome` +- `price` +- `variant: 'buy' | 'claim' | 'cashout'` +- `loading` +- `disabled` + +It owns label selection, loading state, price display, and disabled styling. + +```tsx +import React from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box, Text } from '@metamask/design-system-react-native'; +import { PriceDisplay } from '../PriceDisplay'; +import type { PredictOutcome } from '../../types'; + +interface OutcomeButtonProps { + outcome: PredictOutcome; + price?: number; + variant: 'buy' | 'claim' | 'cashout'; + loading?: boolean; + disabled?: boolean; + onPress: () => void; +} + +export function OutcomeButton({ + outcome, + price, + variant, + loading = false, + disabled = false, + onPress, +}: OutcomeButtonProps) { + const tw = useTailwind(); + + const label = + variant === 'buy' + ? `Buy ${outcome.label}` + : variant === 'claim' + ? 'Claim winnings' + : 'Cash out'; + + return ( + + + + {loading ? 'Processing…' : label} + + {typeof price === 'number' ? ( + + ) : null} + + + ); +} +``` + +### PositionCard + +`PositionCard` handles portfolio states through position status rather than specialized components. Open, won, lost, and claimable states remain internal to the component. + +```tsx +import React from 'react'; +import { Box, Text } from '@metamask/design-system-react-native'; +import type { PredictPosition } from '../../types'; +import { OutcomeButton } from '../OutcomeButton'; +import { PriceDisplay } from '../PriceDisplay'; + +interface PositionCardProps { + position: PredictPosition; + onClaim?: (positionId: string) => void; +} + +export function PositionCard({ position, onClaim }: PositionCardProps) { + const canClaim = position.status === 'claimable'; + + return ( + + {position.outcomeLabel} + {position.shares} shares + + + + {canClaim && onClaim ? ( + onClaim(position.id)} + /> + ) : null} + + ); +} +``` + +### PriceDisplay + +`PriceDisplay` centralizes formatting rules for cents, dollars, percentages, and shares. It prevents view code from duplicating display logic and lets formatting evolve in one place. + +```tsx +import React from 'react'; +import { Text } from '@metamask/design-system-react-native'; + +interface PriceDisplayProps { + value: number; + format: 'cents' | 'dollars' | 'percentage' | 'shares'; + emphasize?: 'gain' | 'loss' | 'neutral'; +} + +export function PriceDisplay({ + value, + format, + emphasize = 'neutral', +}: PriceDisplayProps) { + const color = + emphasize === 'gain' + ? 'successDefault' + : emphasize === 'loss' + ? 'errorDefault' + : 'textDefault'; + + const formatted = + format === 'cents' + ? `${Math.round(value)}¢` + : format === 'dollars' + ? `$${value.toFixed(2)}` + : format === 'percentage' + ? `${(value * 100).toFixed(1)}%` + : `${value.toFixed(2)} shares`; + + return {formatted}; +} +``` + +### Scoreboard + +`Scoreboard` is a standalone sports presentation primitive with `compact` and `full` modes. + +```tsx +import React from 'react'; +import { Box, Text } from '@metamask/design-system-react-native'; +import type { PredictGame } from '../../types'; + +interface ScoreboardProps { + game: PredictGame; + variant: 'compact' | 'full'; +} + +export function Scoreboard({ game, variant }: ScoreboardProps) { + return ( + + + {game.awayTeam.name} {game.awayTeam.score} + + + {game.homeTeam.name} {game.homeTeam.score} + + {game.periodLabel} + + ); +} +``` + +### Chart + +`Chart` provides one Predict chart API for both price history and game progression. + +```tsx +import React, { useMemo, useState } from 'react'; +import { Box, Text } from '@metamask/design-system-react-native'; + +interface ChartPoint { + timestamp: number; + value: number; +} + +interface ChartProps { + data: ChartPoint[]; + variant: 'price' | 'game'; +} + +export function Chart({ data, variant }: ChartProps) { + const [range, setRange] = useState<'1D' | '1W' | '1M'>('1D'); + + const visibleData = useMemo(() => { + return data; + }, [data, range]); + + return ( + + {variant === 'price' ? 'Price history' : 'Game movement'} + {`Points: ${visibleData.length}`} + setRange('1W')}>{range} + + ); +} +``` + +### Skeleton + +`Skeleton` acts as a layout factory for loading states. Screens request a semantic layout, not a custom loading component. + +```tsx +import React from 'react'; +import { Box } from '@metamask/design-system-react-native'; + +interface SkeletonProps { + layout: 'eventCard' | 'detailsHeader' | 'positionCard' | 'feed'; +} + +export function Skeleton({ layout }: SkeletonProps) { + if (layout === 'feed') { + return ( + + + + + ); + } + + return ( + + ); +} +``` + +## Tier 2: Composed Widgets + +Widgets are the integration layer between data and presentation. They call data query hooks internally and render Tier 1 primitives with the results. Each widget maps to a major screen section and owns the section-level state needed to operate. + +See [hooks — Hook Usage by Component Tier](./hooks.md#hook-usage-by-component-tier) for the full tier/hook relationship. + +### EventFeed + +Purpose: + +- Render a searchable, filterable, infinitely scrolling list of events + +Composes: + +- `EventCard` +- `Skeleton` + +Hooks (called internally by the widget): + +- `useEventList` from `hooks/events` — paginated event feed +- `useEventSearch` from `hooks/events` — search results +- optional local filter/tab state hook co-located with the widget + +Typical responsibilities: + +- search box input +- category tab state +- pagination trigger via `fetchMore` +- empty and loading states + +### FeaturedCarousel + +Purpose: + +- Render highlighted events in a horizontal carousel layout + +Composes: + +- `EventCard` + +Hooks (called internally by the widget): + +- `useFeaturedEvents` from `hooks/events` — carousel events +- `usePredictNavigation` from `hooks/navigation` — tap-to-details navigation + +Typical responsibilities: + +- horizontal snapping behavior +- card width calculation +- tap-to-details navigation + +### PortfolioSection + +Purpose: + +- Render account balance, aggregate P&L, and open or resolved positions + +Composes: + +- `PositionCard` +- `PriceDisplay` +- `Skeleton` + +Hooks (called internally by the widget): + +- `usePositions` from `hooks/portfolio` — open and resolved positions +- `useBalance` from `hooks/portfolio` — prediction market balance +- `usePnL` from `hooks/portfolio` — unrealized P&L + +Typical responsibilities: + +- section tabs for open, resolved, and claimable positions +- summary header for balance and unrealized P&L +- empty state for users with no exposure + +### OrderForm + +Purpose: + +- Collect order amount, payment token, and outcome selection before placing a trade + +Composes: + +- `OutcomeButton` +- `PriceDisplay` + +Hooks (called internally by the widget): + +- `useTrading` from `hooks/trading` — order preview and placement +- `usePredictGuard` from `hooks/guard` — eligibility check +- local keypad state hook co-located with the widget + +Typical responsibilities: + +- amount keypad input +- payment token selector +- fee and slippage summary +- primary action enablement + +### ActivityList + +Purpose: + +- Render transaction history and a detail sheet for a selected activity row + +Composes: + +- `PriceDisplay` +- `Skeleton` + +Hooks (called internally by the widget): + +- `useActivity` from `hooks/portfolio` — transaction history +- `usePredictNavigation` from `hooks/navigation` — detail sheet navigation + +Typical responsibilities: + +- grouping by date +- pending-state badges +- opening a transaction detail sheet + +## Tier 3: Views + +Views remain thin. They arrange widgets, connect route params, and handle cross-cutting concerns (eligibility guards, imperative actions). Views do not fetch data directly — widgets handle that internally. + +### PredictHome + +Composition: + +- `FeaturedCarousel` (fetches featured events internally) +- `EventFeed` (fetches event list and search internally) +- `PortfolioSection` (fetches positions, balance, P&L internally) + +Hooks wired at view level: + +- `usePredictGuard` — gate access for geo-blocked or ineligible users +- `usePredictNavigation` — tab state, scroll management + +Route params: + +- none required + +```tsx +export function PredictHome() { + const { isEligible } = usePredictGuard(); + if (!isEligible) return ; + + return ( + + + + + + ); +} +``` + +### EventDetails + +Composition: + +- `EventCard` in `detail` mode +- `Chart` +- `OutcomeButton` +- `PositionCard` + +Hooks wired at view level: + +- `useEventDetail` from `hooks/events` — single event by ID +- `usePositions` from `hooks/portfolio` — user positions for this event +- `useLiveData` from `hooks/live-data` — real-time price updates +- `usePriceHistory` from `hooks/events` — chart data + +Note: EventDetails is a view that directly renders primitives rather than composing widgets, because its layout is unique and not reusable elsewhere. This is fine — not every view needs to delegate to widgets. + +Route params: + +- `eventId: string` + +### OrderScreen + +Composition: + +- `OrderForm` (handles trading hooks internally) + +Hooks wired at view level: + +- `usePredictGuard` — final eligibility check before order entry + +Route params: + +- `marketId: string` +- `outcomeId: string` + +### TransactionsView + +Composition: + +- `ActivityList` (fetches activity internally) + +Hooks wired at view level: + +- `useTransactions` from `hooks/transactions` — pending transaction state + +Route params: + +- `accountId?: string` + +## Component Directory Structure + +Recommended structure under `app/components/UI/PredictNext/components`: + +```text +components/ + primitives/ + EventCard/ + EventCard.tsx + EventCardContext.tsx + EventCardFooter.tsx + EventCardHeader.tsx + EventCardMarkets.tsx + EventCardScoreboard.tsx + index.ts + OutcomeButton/ + OutcomeButton.tsx + index.ts + PositionCard/ + PositionCard.tsx + index.ts + PriceDisplay/ + PriceDisplay.tsx + index.ts + Scoreboard/ + Scoreboard.tsx + index.ts + Chart/ + Chart.tsx + index.ts + Skeleton/ + Skeleton.tsx + index.ts + widgets/ + EventFeed/ + EventFeed.tsx + useEventFeedState.ts + index.ts + FeaturedCarousel/ + FeaturedCarousel.tsx + index.ts + PortfolioSection/ + PortfolioSection.tsx + index.ts + OrderForm/ + OrderForm.tsx + useOrderFormState.ts + index.ts + ActivityList/ + ActivityList.tsx + index.ts + views/ + PredictHome/ + PredictHome.tsx + index.ts + EventDetails/ + EventDetails.tsx + index.ts + OrderScreen/ + OrderScreen.tsx + useBuyViewState.ts + index.ts + TransactionsView/ + TransactionsView.tsx + index.ts +``` + +This structure keeps the public surface area small while preserving high internal cohesion. Primitive complexity stays centralized, widgets compose behavior predictably, and views remain easy to read and test. diff --git a/app/components/UI/PredictNext/docs/error-handling.md b/app/components/UI/PredictNext/docs/error-handling.md new file mode 100644 index 000000000000..3849bb3e3b70 --- /dev/null +++ b/app/components/UI/PredictNext/docs/error-handling.md @@ -0,0 +1,473 @@ +# PredictNext Error Handling + +## Design Principle: Define Errors Out of Existence + +PredictNext follows the principle from _A Philosophy of Software Design_: the best error is the one users never need to experience. Services should absorb transient failures whenever possible. + +That means: + +- retry temporary transport failures automatically +- reconnect live channels without surfacing modal errors +- fall back to cached data when it remains useful +- surface only errors that require a user decision or action + +Related docs: + +- [state management](./state-management.md) +- [hooks](./hooks.md) +- [components](./components.md) +- [testing](./testing.md) + +## PredictError Class + +User-actionable failures are represented by a single structured error type. + +```typescript +export enum PredictErrorCode { + GEO_BLOCKED = 'GEO_BLOCKED', + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + ORDER_REJECTED = 'ORDER_REJECTED', + NETWORK_UNAVAILABLE = 'NETWORK_UNAVAILABLE', + SERVICE_DEGRADED = 'SERVICE_DEGRADED', + FEATURE_DISABLED = 'FEATURE_DISABLED', + UNKNOWN = 'UNKNOWN', +} + +export class PredictError extends Error { + constructor( + message: string, + public readonly code: PredictErrorCode, + public readonly recoverable: boolean, + ) { + super(message); + this.name = 'PredictError'; + } +} +``` + +Why a single error type helps: + +- UI code branches on stable categories instead of raw transport errors +- logging and analytics get structured codes +- service layers can transform diverse backend failures into a small action model + +## Four UI Error Categories + +| Category | Trigger | UI Treatment | Example | +| ------------- | -------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------ | +| Empty state | Query returns no data | Empty state component with illustration and guidance | No events found, no positions | +| Unavailable | Geo-block, feature disabled, wrong network | Redirect or present `UnavailableModal` | User in restricted region | +| Action failed | Order, claim, or deposit fails after user action | Inline error with retry affordance | Exchange rejects an order | +| Degraded | Live data or freshness is reduced but app remains usable | Subtle banner or status pill | WebSocket disconnected, stale prices | + +The category model keeps UI behavior predictable and limits bespoke handling. + +## Error Handling by Layer + +```text +Layer: ADAPTER SERVICE HOOK COMPONENT + ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ + │ │ │ │ │ │ │ │ +Error: │ Raw │────────>│Absorb│────────>│Expose│─────>│Render│ + │throw │ catch │retry │ throw │state │ props │ UI │ + │ │ │wrap │ Predict │ │ │state │ + └──────┘ │cache │ Error └──────┘ └──────┘ + │fall │ + │back │ + └──────┘ + + Transient failures: ABSORBED (retry, cache fallback, reconnect) + User-actionable: SURFACED as PredictError with code + recoverable + UI renders: empty | unavailable | action failed | degraded +``` + +### Adapter Layer + +Responsibilities: + +- perform HTTP or transport calls +- parse responses +- throw raw errors when requests or parsing fail + +Rules: + +- adapters never throw `PredictError` +- adapters should preserve response context where possible + +Example: + +```typescript +export class PolymarketAdapter { + async fetchEvents(params: Record) { + const response = await this.httpClient.get('/events', { params }); + + if (!Array.isArray(response.data)) { + throw new Error('Invalid Gamma API response'); + } + + return response.data.map(this.toPredictEvent); + } +} +``` + +### Service Layer + +Responsibilities: + +- absorb transient failures when possible +- retry with policy controls +- use cached data when safe +- transform user-actionable failures into `PredictError` + +Example: + +```typescript +import { Logger } from '../../../util/Logger'; +import { PredictError, PredictErrorCode } from '../errors/PredictError'; + +export class TradingService { + async placeOrder(params: PlaceOrderParams) { + try { + return await this.adapter.placeOrder(params); + } catch (error) { + Logger.error(error as Error, 'Predict order placement failed'); + + if (this.isInsufficientFundsError(error)) { + throw new PredictError( + 'Not enough balance to place this order.', + PredictErrorCode.INSUFFICIENT_FUNDS, + true, + ); + } + + if (this.isExchangeRejection(error)) { + throw new PredictError( + 'The exchange rejected this order.', + PredictErrorCode.ORDER_REJECTED, + true, + ); + } + + throw new PredictError( + 'Predict trading is temporarily unavailable.', + PredictErrorCode.UNKNOWN, + true, + ); + } + } +} +``` + +### Hook Layer + +Responsibilities: + +- expose query error states and user-actionable `PredictError` +- translate service state into view-friendly booleans +- avoid low-level error branching in components + +Example: + +```typescript +import { useMemo } from 'react'; +import { useTrading } from './useTrading'; +import { PredictErrorCode } from '../errors/PredictError'; + +export function useOrderErrorState() { + const trading = useTrading(); + + return useMemo(() => { + return { + isUnavailable: + trading.orderError?.code === PredictErrorCode.FEATURE_DISABLED, + isInsufficientFunds: + trading.orderError?.code === PredictErrorCode.INSUFFICIENT_FUNDS, + canRetry: Boolean(trading.orderError?.recoverable), + message: trading.orderError?.message ?? null, + }; + }, [trading.orderError]); +} +``` + +### Component Layer + +Responsibilities: + +- render the right UI state for the error category +- provide retry or redirect affordances +- avoid `try/catch` in render or event code when hooks already model the state + +Example: + +```tsx +import React from 'react'; +import { Box, Text } from '../../../component-library'; +import { OutcomeButton } from '../components/primitives/OutcomeButton'; + +interface OrderErrorBannerProps { + message: string; + canRetry: boolean; + onRetry: () => void; +} + +export function OrderErrorBanner({ + message, + canRetry, + onRetry, +}: OrderErrorBannerProps) { + return ( + + {message} + {canRetry ? ( + + ) : null} + + ); +} +``` + +## Error Flow Examples + +### 1. Transient API Error: absorbed by the service + +Flow: + +1. `MarketDataService` requests event data +2. adapter request fails with a temporary network timeout +3. service retries according to its policy +4. if cached data exists, service returns cached data and logs degradation +5. user continues without a blocking error + +Example: + +```typescript +async getEvents(params: EventsParams) { + try { + return await this.fetchQuery({ + queryKey: ['PredictMarketData:getEvents', params], + queryFn: async () => await this.adapter.fetchEvents(params), + policyOptions: { + retry: 2, + }, + }); + } catch (error) { + this.logger.warn('Serving cached Predict events after fetch failure'); + const cached = this.getCachedData(['PredictMarketData:getEvents', params]); + + if (cached) { + return cached; + } + + throw error; + } +} +``` + +User outcome: + +- no blocking error if useful cached data exists +- optional degraded banner if live freshness matters + +### 2. Geo-block Error: surfaces as unavailable + +Flow: + +1. guard service evaluates account eligibility and region +2. guard service identifies restricted geography +3. it throws or returns `PredictErrorCode.GEO_BLOCKED` +4. hook exposes unavailable state +5. view shows `UnavailableModal` + +Example: + +```typescript +import { PredictError, PredictErrorCode } from '../errors/PredictError'; + +export class PredictGuardService { + ensureEligible(regionCode: string) { + if (this.restrictedRegions.has(regionCode)) { + throw new PredictError( + 'Predict is not available in your region.', + PredictErrorCode.GEO_BLOCKED, + false, + ); + } + } +} +``` + +```tsx +import React from 'react'; +import { UnavailableModal } from '../components/UnavailableModal'; +import { usePredictGuard } from '../hooks/usePredictGuard'; + +export function PredictHomeGate() { + const guard = usePredictGuard(); + + if (!guard.isEligible) { + return ( + + ); + } + + return null; +} +``` + +User outcome: + +- clear unavailable messaging +- no confusing retry loop + +### 3. Order Placement Failure: inline actionable error + +Flow: + +1. user taps buy in `OrderScreen` +2. `useTrading` calls `TradingService.placeOrder` +3. service maps rejection to `PredictErrorCode.ORDER_REJECTED` +4. hook transitions to `orderState = 'error'` +5. view renders inline error with retry action + +Example: + +```tsx +import React, { useCallback, useState } from 'react'; +import { Box } from '../../../component-library'; +import { OrderErrorBanner } from './OrderErrorBanner'; +import { OutcomeButton } from '../components/primitives/OutcomeButton'; +import { useTrading } from '../hooks/useTrading'; + +export function OrderScreenActions({ + marketId, + outcomeId, +}: { + marketId: string; + outcomeId: string; +}) { + const trading = useTrading(); + const [amount] = useState('25'); + + const submit = useCallback(async () => { + await trading.placeOrder({ + marketId, + outcomeId, + amount, + paymentToken: trading.selectedPayment, + }); + }, [amount, marketId, outcomeId, trading]); + + return ( + + {trading.orderError ? ( + + ) : null} + { + void submit(); + }} + /> + + ); +} +``` + +User outcome: + +- failure is visible and local to the action +- retry is possible when safe + +## Degraded States + +Degraded states are not blocking errors. They indicate reduced fidelity while preserving utility. + +Typical triggers: + +- live price socket disconnected +- stale sports score feed +- delayed activity refresh + +Recommended treatment: + +- non-blocking banner +- subtle status pill on live components +- continue rendering last known good data + +Example: + +```tsx +import React from 'react'; +import { Box, Text } from '../../../component-library'; + +export function LiveDataStatusBanner({ + status, +}: { + status: 'connected' | 'reconnecting' | 'disconnected'; +}) { + if (status === 'connected') { + return null; + } + + return ( + + + {status === 'reconnecting' + ? 'Refreshing live prices…' + : 'Live prices temporarily unavailable'} + + + ); +} +``` + +## Logging and Monitoring + +All meaningful failures should be logged before being absorbed or surfaced. + +Rules: + +- log raw errors close to the failing boundary +- include `PredictError.code` in structured logs and analytics +- track counts and rates, not only stack traces +- distinguish absorbed degradation from user-visible failure + +Example: + +```typescript +import Logger from '../../../util/Logger'; + +function logPredictError( + error: PredictError, + context: Record, +) { + Logger.error(error, 'PredictError', { + ...context, + code: error.code, + recoverable: error.recoverable, + }); + + void Engine.context.Analytics.trackEvent({ + event: 'Predict Error Encountered', + properties: { + code: error.code, + recoverable: error.recoverable, + ...context, + }, + }); +} +``` + +## Summary + +PredictNext error handling should make transient failures disappear, user-actionable failures consistent, and degraded states calm rather than disruptive. `PredictError` provides the stable contract, services absorb complexity, hooks expose decision-ready state, and components render category-based UX without low-level exception handling. diff --git a/app/components/UI/PredictNext/docs/hooks.md b/app/components/UI/PredictNext/docs/hooks.md new file mode 100644 index 000000000000..790bddc89da5 --- /dev/null +++ b/app/components/UI/PredictNext/docs/hooks.md @@ -0,0 +1,730 @@ +# PredictNext Hook Architecture + +## Philosophy + +PredictNext organizes hooks by domain using co-located folders with barrel exports. Data-fetching hooks are granular — each hook triggers exactly one query, so components only pay for the data they actually need. Imperative hooks (trading, transactions, live data) remain deep since they manage complex stateful workflows. + +The old Predict codebase had 37 hooks, many 100-300 lines each with duplicated caching, error handling, and state management. With BaseDataService handling the heavy lifting at the service level, data hooks shrink to 3-5 lines each. Having 12-15 granular hooks is not the same problem as 37 complex ones. + +Guiding rules: + +- Each data hook triggers exactly one query — no wasted API calls +- Imperative hooks are deep and own async workflows +- Related hooks are co-located in domain folders with barrel exports +- View-specific derivation stays local to the view +- Components never import services directly + +Related docs: + +- [components](./components.md) +- [state management](./state-management.md) +- [error handling](./error-handling.md) +- [testing](./testing.md) + +## Hook Directory Structure + +``` +hooks/ +├── events/ +│ ├── useFeaturedEvents.ts # carousel/featured events +│ ├── useEventList.ts # paginated event feed +│ ├── useEventSearch.ts # search results +│ ├── useEventDetail.ts # single event by ID +│ ├── usePriceHistory.ts # price history for a market +│ ├── usePrices.ts # current prices for markets +│ └── index.ts # barrel export +├── portfolio/ +│ ├── usePositions.ts # user positions +│ ├── useBalance.ts # prediction market balance +│ ├── useActivity.ts # transaction history +│ ├── usePnL.ts # unrealized P&L +│ └── index.ts # barrel export +├── trading/ +│ ├── useTrading.ts # deep — order state machine +│ └── index.ts +├── transactions/ +│ ├── useTransactions.ts # deep — deposit/withdraw/claim +│ └── index.ts +├── live-data/ +│ ├── useLiveData.ts # deep — WebSocket lifecycle +│ └── index.ts +├── navigation/ +│ ├── usePredictNavigation.ts +│ └── index.ts +├── guard/ +│ ├── usePredictGuard.ts +│ └── index.ts +└── index.ts # top-level barrel +``` + +Components import from the domain barrel or the top-level barrel: + +```typescript +import { useFeaturedEvents } from '../hooks/events'; +import { useBalance } from '../hooks/portfolio'; + +// or from the top-level barrel +import { useFeaturedEvents, useBalance } from '../hooks'; +``` + +## Hook Catalog — Event Queries + +All event hooks map to `MarketDataService` (BaseDataService). Each triggers exactly one query. + +### useFeaturedEvents + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PredictEvent } from '../../types'; + +export function useFeaturedEvents() { + return useQuery({ + queryKey: ['PredictMarketData:getCarouselEvents'], + }); +} +``` + +### useEventList + +```typescript +import { useCallback, useMemo } from 'react'; +import { useInfiniteQuery } from '@metamask/react-data-query'; +import type { PredictEvent, FetchEventsParams } from '../../types'; + +export function useEventList(params: FetchEventsParams) { + const query = useInfiniteQuery<{ + items: PredictEvent[]; + nextCursor?: string; + }>({ + queryKey: ['PredictMarketData:getEvents', params], + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); + + const events = useMemo( + () => query.data?.pages.flatMap((page) => page.items) ?? [], + [query.data], + ); + + const fetchMore = useCallback(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }, [query]); + + return { + events, + fetchMore, + isLoading: query.isLoading, + isError: query.isError, + }; +} +``` + +### useEventSearch + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PredictEvent } from '../../types'; + +export function useEventSearch(query: string) { + return useQuery({ + queryKey: ['PredictMarketData:searchEvents', query], + enabled: query.length > 0, + }); +} +``` + +### useEventDetail + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PredictEvent } from '../../types'; + +export function useEventDetail(eventId: string) { + return useQuery({ + queryKey: ['PredictMarketData:getEvent', eventId], + }); +} +``` + +### usePriceHistory + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PricePoint, TimePeriod } from '../../types'; + +export function usePriceHistory(marketId: string, period: TimePeriod) { + return useQuery({ + queryKey: ['PredictMarketData:getPriceHistory', marketId, period], + }); +} +``` + +### usePrices + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { MarketPrices } from '../../types'; + +export function usePrices(marketIds: string[]) { + return useQuery>({ + queryKey: ['PredictMarketData:getPrices', marketIds], + enabled: marketIds.length > 0, + }); +} +``` + +Notes: + +- Query keys are the contract between UI and service cache. +- No `queryFn` is supplied — the messenger-backed query client resolves the data source. +- Each hook can be imported independently. A component needing only featured events does not trigger the event list or search queries. + +## Hook Catalog — Portfolio Queries + +All portfolio hooks map to `PortfolioService` (BaseDataService). Same pattern — one query per hook. + +### usePositions + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PredictPosition } from '../../types'; + +export function usePositions(accountId: string) { + return useQuery({ + queryKey: ['PredictPortfolio:getPositions', accountId], + }); +} +``` + +### useBalance + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { Balance } from '../../types'; + +export function useBalance(accountId: string) { + return useQuery({ + queryKey: ['PredictPortfolio:getBalance', accountId], + }); +} +``` + +### useActivity + +```typescript +import { useInfiniteQuery } from '@metamask/react-data-query'; +import type { ActivityItem } from '../../types'; + +export function useActivity(accountId: string) { + return useInfiniteQuery<{ items: ActivityItem[]; nextCursor?: string }>({ + queryKey: ['PredictPortfolio:getActivity', accountId], + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); +} +``` + +### usePnL + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { UnrealizedPnL } from '../../types'; + +export function usePnL(accountId: string) { + return useQuery({ + queryKey: ['PredictPortfolio:getUnrealizedPnl', accountId], + }); +} +``` + +### useTrading + +Purpose: + +- Drive preview, payment selection, placement, and reset flows for order entry + +Maps to: + +- `TradingService` +- write operations eventually call `Engine.context.PredictController.placeOrder()` + +Return contract: + +```typescript +function useTrading(): { + preview: (params: PreviewParams) => Promise; + placeOrder: (params: PlaceOrderParams) => Promise; + orderState: OrderState; + orderError: PredictError | null; + selectedPayment: PaymentToken; + selectPayment: (token: PaymentToken) => void; + reset: () => void; +}; +``` + +Implementation sketch: + +```typescript +import { useCallback, useMemo, useState } from 'react'; +import Engine from '../../../core/Engine'; +import { PredictError } from '../errors/PredictError'; +import type { + OrderPreview, + OrderState, + PaymentToken, + PlaceOrderParams, + PreviewParams, +} from '../types'; + +export function useTrading() { + const [orderState, setOrderState] = useState('idle'); + const [orderError, setOrderError] = useState(null); + const [selectedPayment, setSelectedPayment] = useState('USDC'); + + const tradingService = useMemo( + () => Engine.context.PredictTradingService, + [], + ); + + const preview = useCallback( + async (params: PreviewParams) => { + setOrderState('previewing'); + setOrderError(null); + + try { + const result: OrderPreview = await tradingService.previewOrder({ + ...params, + paymentToken: selectedPayment, + }); + setOrderState('idle'); + return result; + } catch (error) { + const predictError = tradingService.toPredictError(error); + setOrderState('error'); + setOrderError(predictError); + throw predictError; + } + }, + [selectedPayment, tradingService], + ); + + const placeOrder = useCallback( + async (params: PlaceOrderParams) => { + setOrderState('placing'); + setOrderError(null); + + try { + await Engine.context.PredictController.placeOrder({ + ...params, + paymentToken: selectedPayment, + }); + setOrderState('success'); + } catch (error) { + const predictError = tradingService.toPredictError(error); + setOrderState('error'); + setOrderError(predictError); + throw predictError; + } + }, + [selectedPayment, tradingService], + ); + + const reset = useCallback(() => { + setOrderState('idle'); + setOrderError(null); + }, []); + + return { + preview, + placeOrder, + orderState, + orderError, + selectedPayment, + selectPayment: setSelectedPayment, + reset, + }; +} +``` + +`useTrading` is intentionally deep. It exposes a slim public contract while hiding the order state machine, service translation, and controller handoff. + +### useTransactions + +Purpose: + +- Handle deposit, withdraw, and claim side effects plus pending transaction state + +Maps to: + +- `TransactionService` + +Return contract: + +```typescript +function useTransactions(): { + deposit: (params: DepositParams) => Promise; + withdraw: (params: WithdrawParams) => Promise; + claim: (params: ClaimParams) => Promise; + pendingTx: PendingTransaction | null; +}; +``` + +Implementation sketch: + +```typescript +import { useCallback, useMemo, useState } from 'react'; +import Engine from '../../../core/Engine'; +import type { + ClaimParams, + DepositParams, + PendingTransaction, + WithdrawParams, +} from '../types'; + +export function useTransactions() { + const transactionService = useMemo( + () => Engine.context.PredictTransactionService, + [], + ); + const [pendingTx, setPendingTx] = useState(null); + + const wrap = useCallback( + async ( + kind: PendingTransaction['kind'], + params: T, + task: () => Promise, + ) => { + setPendingTx({ kind, createdAt: Date.now(), params }); + try { + await task(); + } finally { + setPendingTx(null); + } + }, + [], + ); + + return { + deposit: (params: DepositParams) => + wrap('deposit', params, () => transactionService.deposit(params)), + withdraw: (params: WithdrawParams) => + wrap('withdraw', params, () => transactionService.withdraw(params)), + claim: (params: ClaimParams) => + wrap('claim', params, () => transactionService.claim(params)), + pendingTx, + }; +} +``` + +### useLiveData + +Purpose: + +- Subscribe to live channels for prices, scores, and status updates + +Maps to: + +- `LiveDataService` + +Return contract: + +```typescript +function useLiveData( + channel: string, + params: unknown, +): { + data: unknown; + status: 'connected' | 'reconnecting' | 'disconnected'; +}; +``` + +Implementation sketch: + +```typescript +import { useEffect, useMemo, useState } from 'react'; +import Engine from '../../../core/Engine'; + +export function useLiveData(channel: string, params: unknown) { + const liveDataService = useMemo( + () => Engine.context.PredictLiveDataService, + [], + ); + const [data, setData] = useState(null); + const [status, setStatus] = useState< + 'connected' | 'reconnecting' | 'disconnected' + >('disconnected'); + + useEffect(() => { + setStatus('reconnecting'); + + const unsubscribe = liveDataService.subscribe(channel, params, { + onOpen: () => setStatus('connected'), + onMessage: (nextData: unknown) => setData(nextData), + onClose: () => setStatus('disconnected'), + onReconnect: () => setStatus('reconnecting'), + }); + + return unsubscribe; + }, [channel, liveDataService, params]); + + return { data, status }; +} +``` + +### usePredictNavigation + +Purpose: + +- Centralize route helpers, tabs, and navigation-specific screen state + +Maps to: + +- Predict navigation stack definitions + +Return contract: + +```typescript +function usePredictNavigation(): { + navigateToEvent: (eventId: string) => void; + navigateToOrder: (marketId: string, outcomeId: string) => void; + navigateBack: () => void; + tabs: TabConfig; + scrollState: ScrollState; +}; +``` + +Implementation sketch: + +```typescript +import { useMemo, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; + +export function usePredictNavigation() { + const navigation = useNavigation(); + const [scrollY, setScrollY] = useState(0); + + return { + navigateToEvent: (eventId: string) => + navigation.navigate('PredictEventDetails', { eventId }), + navigateToOrder: (marketId: string, outcomeId: string) => + navigation.navigate('PredictOrderScreen', { marketId, outcomeId }), + navigateBack: () => navigation.goBack(), + tabs: useMemo( + () => ({ + home: { key: 'home', label: 'Home' }, + portfolio: { key: 'portfolio', label: 'Portfolio' }, + activity: { key: 'activity', label: 'Activity' }, + }), + [], + ), + scrollState: { + y: scrollY, + setY: setScrollY, + isScrolled: scrollY > 0, + }, + }; +} +``` + +### usePredictGuard + +Purpose: + +- Gate access based on eligibility, network, feature availability, and account restrictions + +Maps to: + +- guard and eligibility services coordinated by the controller layer + +Return contract: + +```typescript +function usePredictGuard(): { + isEligible: boolean; + canTrade: boolean; + ensureNetwork: () => Promise; + blockReason: string | null; +}; +``` + +Implementation sketch: + +```typescript +import { useCallback, useMemo } from 'react'; +import Engine from '../../../core/Engine'; + +export function usePredictGuard() { + const guardService = useMemo(() => Engine.context.PredictGuardService, []); + const state = guardService.getCurrentState(); + + const ensureNetwork = useCallback(async () => { + if (state.canTrade) { + return true; + } + + return await guardService.ensureSupportedNetwork(); + }, [guardService, state.canTrade]); + + return { + isEligible: state.isEligible, + canTrade: state.canTrade, + ensureNetwork, + blockReason: state.blockReason, + }; +} +``` + +## View-Local Hooks Pattern + +Deep hooks should not absorb every derived boolean needed by every route. View-local hooks remain thin and compute state specific to one screen. + +Example: + +```typescript +// app/components/UI/PredictNext/components/views/OrderScreen/useBuyViewState.ts +import { useMemo } from 'react'; +import type { useTrading } from '../../../hooks/useTrading'; + +interface UseBuyViewStateParams { + amount: number; + balance: number; + trading: ReturnType; +} + +export function useBuyViewState({ + amount, + balance, + trading, +}: UseBuyViewStateParams) { + return useMemo(() => { + const canPlaceBet = trading.orderState === 'idle' && amount > 0; + const isInsufficientBalance = amount > balance; + const isBusy = + trading.orderState === 'previewing' || trading.orderState === 'placing'; + const shouldShowInlineError = + trading.orderState === 'error' && Boolean(trading.orderError); + + return { + canPlaceBet, + isInsufficientBalance, + isBusy, + shouldShowInlineError, + }; + }, [amount, balance, trading.orderError, trading.orderState]); +} +``` + +This pattern keeps deep hooks stable and reusable while allowing view code to stay explicit. + +## Hook Usage by Component Tier + +Not every tier uses hooks. The rule is: primitives are pure, widgets wire data, views orchestrate. + +```text +Views (PredictHome, EventDetails, OrderScreen) + │ + ├── Guard hooks: usePredictGuard + ├── Nav hooks: usePredictNavigation + ├── Imperative: useTrading, useTransactions + │ + └── Widgets (EventFeed, PortfolioSection, OrderForm) + │ + ├── Data hooks: useEventList, useFeaturedEvents, usePositions, useBalance + │ │ + │ v + │ BaseDataService (MarketDataService, PortfolioService) + │ │ + │ v + │ PredictAdapter + │ + └── Primitives (EventCard, OutcomeButton, PositionCard) + │ + └── No hooks. Pure props only. +``` + +| Tier | Uses hooks? | Uses services directly? | Receives props? | +| ------------------------------------------- | ------------------------------ | ----------------------- | ------------------------------ | +| Primitives (EventCard, OutcomeButton, etc.) | No | No | Yes — data + callbacks | +| Widgets (EventFeed, PortfolioSection, etc.) | Yes — data query hooks | No | Yes — config/params from views | +| Views (PredictHome, EventDetails, etc.) | Yes — imperative + guard hooks | No | Yes — route params | + +**Primitives** are pure render components. They receive domain entities via props and render them. No hooks, no side effects, no data fetching. This is what makes them reusable across feeds, detail screens, and external embed points. + +**Widgets** are the integration layer between data and presentation. An `EventFeed` calls `useEventList` and `useEventSearch` internally, then renders `EventCard` primitives. A `PortfolioSection` calls `usePositions`, `useBalance`, and `usePnL`, then renders `PositionCard` and `PriceDisplay` primitives. Widgets own the data wiring so views stay thin. + +**Views** compose widgets and handle cross-cutting concerns: route params, eligibility guards, imperative actions (trading, transactions). A view like `PredictHome` mostly arranges widgets — it does not fetch event lists or positions directly. + +This split means: + +- Changing how events are fetched only touches widget code, not view or primitive code. +- Primitives can be tested with plain props (no mock hooks needed). +- Views are easy to test with the component view framework since they mostly compose widgets. + +## Hook Composition Rules + +```text +Read path: + Widget → useEventList → useQuery(queryKey) → messenger → MarketDataService → adapter → API + +Write path: + View → useTrading → Engine.context.PredictController → TradingService → adapter → API +``` + +1. Imperative hooks compose services, not each other. +2. Widgets compose data query hooks with primitives. +3. Views compose widgets and imperative/guard hooks. +4. Primitives never use hooks — data arrives via props. +5. No tier imports services directly — always go through hooks. +6. Query hooks use stable query keys and avoid inline cache semantics. +7. Imperative hooks return a small state machine instead of leaking service internals. +8. Error translation happens in services or imperative hooks, never in primitives. + +## Example View Composition + +```tsx +import React from 'react'; +import { ScrollView } from 'react-native'; +import { EventCard } from '../components/EventCard'; +import { Chart } from '../components/Chart'; +import { PositionCard } from '../components/PositionCard'; +import { useEventDetail } from '../hooks/events'; +import { usePositions } from '../hooks/portfolio'; +import { useLiveData } from '../hooks/live-data'; + +export function EventDetails({ + route, +}: { + route: { params: { eventId: string; accountId: string } }; +}) { + const { data: event } = useEventDetail(route.params.eventId); + const { data: positions } = usePositions(route.params.accountId); + const { data: livePrices } = useLiveData('event-prices', { + eventId: route.params.eventId, + }); + + if (!event) { + return null; + } + + return ( + + + + + + + + {(positions ?? []).map((position) => ( + + ))} + + ); +} +``` + +The view imports exactly the hooks it needs — `useEventDetail` and `usePositions` — triggering only two queries instead of the full event and portfolio query sets. The service layer remains hidden behind hook APIs that are stable enough for broad reuse and deep enough to absorb complexity. diff --git a/app/components/UI/PredictNext/docs/migration/README.md b/app/components/UI/PredictNext/docs/migration/README.md new file mode 100644 index 000000000000..7b9dcae08de4 --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/README.md @@ -0,0 +1,248 @@ +# PredictNext Migration Plan + +## 1. Motivation + +The Predict feature shipped fast. When it started, the product shape was unclear, the integration surface was unknown, and designs evolved as new designers joined. That speed was the right call at the time, but the codebase now carries the cost. + +**What the current architecture looks like:** + +- A **PredictController** with 2,600+ lines and 60+ public methods that grows with every feature. +- A **PolymarketProvider** with 2,000+ lines that mixes API calls, transaction building, caching, and data transformation in one file. +- **37 hooks** where many are thin wrappers around a single controller call. The buy flow alone requires importing 7 separate hooks that all talk about the same thing. +- **45 component directories** with multiple near-identical variants: 7 market card components, 6 bet button components, 6 skeleton components. +- **87,000 lines of test code** (2.38:1 test-to-source ratio) where 78% of test files duplicate the same mock setup. The controller test alone is 9,400 lines. +- **Inconsistent naming** that no longer matches the domain. What Polymarket and Kalshi both call an "event," our code calls a "market." What they call a "market," we call an "outcome." + +**What this migration delivers:** + +- **Deep modules with slim interfaces.** A PredictController with ~10 methods instead of 60+. Six focused services that each hide real complexity (state machines, caching, retry, WebSocket lifecycle) behind 3-5 method interfaces. Inspired by "A Philosophy of Software Design" by John Ousterhout. +- **A canonical data model** aligned with the industry (Event, Market, Outcome) and documented in a DDD ubiquitous language glossary that the whole team uses. +- **Composable UI.** Seven compound components replace 45 directories. One `EventCard` handles every variant internally. One `OutcomeButton` is the single place you bet in the entire app. +- **Modern data services.** Read-heavy services extend `BaseDataService` from `@metamask/base-data-service`, gaining built-in request deduplication, retry with circuit breaker, and shared cache between service layer and UI via TanStack Query. Custom caches like `GameCache` and `TeamsCache` are eliminated. +- **~85-90% test code reduction.** Component view tests (integration-level, real Redux, minimal mocking) replace hundreds of isolated unit tests. Service integration tests with a mock adapter replace thousands of lines of mock-heavy controller tests. +- **Clear module boundaries.** A single `index.ts` defines the public API. Internal modules (services, adapters, utils) are not exported. Other teams know exactly what they can import. +- **Provider-agnostic architecture.** Adding Kalshi (or any future provider) means implementing a ~15-method adapter interface. No service, hook, or component changes required. + +The goal is not novelty. It is a codebase where a new team member can understand the Predict feature in a day, where adding a new provider takes a week instead of a quarter, and where the test suite runs fast and catches real bugs instead of breaking on every refactor. + +## 2. Strategy + +Inside-out migration: replace the internals while keeping the external interface unchanged, then replace the interface once the internals are proven. + +- **Bottom-up through the stack.** The new adapter grows first. The old PolymarketProvider progressively delegates API calls to it. Then new services grow, and the old PredictController progressively delegates to them. By the time UI migration starts, the entire data stack is battle-tested with real production traffic. +- **Translation layer as the seam.** A `compat/` module in PredictNext handles bidirectional mapping between canonical types (`PredictEvent`, `PredictMarket`, `PredictOutcome`) and legacy types (`Market`, `Outcome`, `OutcomeToken`). The data shapes are structurally identical — only the naming differs. Old code delegates down to new code, new code returns canonical types, the translation layer renames fields back to old shapes for old consumers. +- **Zero UI disruption during data migration.** Phases 2 through 5 touch only the data stack. Old hooks, components, and views continue working unchanged because the old controller's public interface and state shape remain stable throughout. +- **Vertical UI slices after data is proven.** Phase 6 replaces UI one screen at a time. Each slice includes new hooks, new components, and a new view for that screen. By then, the entire data layer is already in production. +- **Every PR is shippable.** Users see zero behavior change during Phases 1 through 5. UI changes appear gradually during Phase 6 as screens switch one by one. +- **No shim or re-export layer.** Old code delegates directly to new code via imports. New code never imports old code. The translation layer is the only bridge, and it gets deleted in Phase 7. + +### How it works at each level + +``` +Phase 2 — Adapter replaces provider internals: + Old Provider method → calls New Adapter → gets canonical types → translates back to old types → returns + +Phase 3-4 — Services replace controller internals: + Old Controller method → calls New Service → gets canonical types → translates back to old state shape → publishes + +Phase 5 — New Controller replaces old controller internals: + Old Controller method → forwards to New Controller → translation at the boundary + +Phase 6 — New UI replaces old UI: + New View → New Hooks → New Controller → New Services → New Adapter + (translation layer no longer needed for migrated screens) +``` + +Inside-Out Migration Order: + +```text +┌──────────────────────────────────────────────────────────┐ +│ Inside-Out Migration Order │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Phase 6: UI (Hooks, Components, Views) │ │ +│ │ ┌──────────────────────────────────┐ │ │ +│ │ │ Phase 5: PredictController │ │ │ +│ │ │ ┌────────────────────────┐ │ │ │ +│ │ │ │ Phase 3-4: Services │ │ │ │ +│ │ │ │ ┌──────────────┐ │ │ │ │ +│ │ │ │ │ Phase 2: │ │ │ │ │ +│ │ │ │ │ Adapter │ │ │ │ │ +│ │ │ │ └──────────────┘ │ │ │ │ +│ │ │ └────────────────────────┘ │ │ │ +│ │ └──────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +## 3. PredictNext/ Approach + +- New architecture lives in `app/components/UI/PredictNext/`. +- Old code in `app/components/UI/Predict/` progressively delegates to PredictNext internals rather than being replaced all at once. +- A `PredictNext/compat/` module provides bidirectional type mappers between canonical and legacy types. This module is intentionally temporary and will be deleted in Phase 7. +- During the data migration (Phases 2 through 5), old UI code is completely untouched. Old hooks subscribe to the same old controller messenger events, receive the same old state shapes, and render the same old components. +- During the UI migration (Phase 6), each screen is rebuilt as a vertical slice using new hooks, components, and views that talk directly to the new controller. No screen ever mixes old and new hooks. +- External consumers outside the Predict feature switch imports during Phase 6 as each screen or widget becomes available in PredictNext. The main switch points are expected to include: + - `app/core/Engine/controllers/predict-controller/index.ts` + - `app/core/Engine/messengers/predict-controller-messenger/index.ts` + - `app/core/Engine/types.ts` + - `app/core/NavigationService/types.ts` + - `app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts` + - `app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx` + - `app/components/Views/Homepage/Sections/Predictions/components/PredictMarketCard.tsx` + - `app/components/Views/TrendingView/sections.config.tsx` + - `app/components/Views/Wallet/index.tsx` + - `app/components/Views/WalletActions/WalletActions.tsx` + - `app/components/Views/TradeWalletActions/TradeWalletActions.tsx` + - `app/components/Views/BrowserTab/BrowserTab.tsx` + - `tests/component-view/presets/predict.ts` + - `tests/component-view/renderers/predict.tsx` + - `tests/component-view/renderers/predictMarketDetails.tsx` +- Final cutover sequence: + 1. verify no production imports from old `Predict/` remain, + 2. delete `app/components/UI/Predict/`, + 3. `git mv app/components/UI/PredictNext/ app/components/UI/Predict/`, + 4. update the remaining external imports from `PredictNext` to `Predict`. + +## 4. Phase Summary + +| Phase | Name | Goal | Est. PRs | Dependencies | +| ----- | ------------------------------ | ------------------------------------------------------------------------ | -------- | ------------ | +| 1 | Foundation | Types, adapter interface, error model, translation layer | 2-3 | None | +| 2 | Adapter & Provider Migration | New PolymarketAdapter, old provider delegates API calls to it | 3-5 | Phase 1 | +| 3 | Read Services | MarketDataService, PortfolioService, old controller delegates reads | 3-4 | Phase 2 | +| 4 | Write Services | TradingService, TransactionService, LiveDataService, AnalyticsService | 4-5 | Phase 2 | +| 5 | New Controller | New PredictController, old controller becomes pure translation shim | 1-2 | Phases 3, 4 | +| 6 | UI Migration (Vertical Slices) | Hooks + components + views, one screen at a time | 8-12 | Phase 5 | +| 7 | Cleanup | Delete old code, rename PredictNext to Predict, remove translation layer | 1-2 | Phase 6 | + +Note: Phases 3 and 4 can run in parallel because read services and write services are independent. Both depend on the adapter from Phase 2. + +## 5. Parallel Work Streams + +Parallel Work Streams: + +```text +┌──────────────────────────────────────────────────────────┐ +│ Parallel Work Streams │ +│ │ +│ Phase 1 (Foundation) │ +│ │ │ +│ ▼ │ +│ Split Streams ──────────────────────────┐ │ +│ │ │ │ +│ Stream A (Read Path) Stream B (Write Path) │ +│ Phase 2 → Phase 3 Phase 2 → Phase 4 │ +│ │ │ │ +│ └────────────────┬─────────────────┘ │ +│ ▼ │ +│ Phase 5 (Merge Point) │ +│ │ │ +│ ▼ │ +│ Phase 6 (UI Migration) │ +│ │ │ +│ ▼ │ +│ Phase 7 (Cleanup) │ +└──────────────────────────────────────────────────────────┘ +``` + +### Stream A: Read path + +`Phase 1 → Phase 2 → Phase 3` + +- Phase 1 defines the vocabulary and contracts. +- Phase 2 builds the adapter and hollows out the old provider. +- Phase 3 builds read services and hollows out the old controller's read methods. + +### Stream B: Write path + +`Phase 1 → Phase 2 → Phase 4` + +- Phase 4 can start as soon as the adapter from Phase 2 is stable. +- Write services (trading, transactions, live data, analytics) are independent of read services. + +### Merge point + +`Phase 5` + +- The new controller composes all six services from both streams. +- The old controller becomes a pure translation shim. + +### UI stream + +`Phase 5 → Phase 6` + +- UI migration starts only after the full data stack is proven in production. +- Within Phase 6, different screens can be migrated in parallel by different developers. + +### Final stream + +`Phase 7` + +- Cleanup starts after every routed screen and every external embed point has switched to PredictNext. + +## 6. Risk Mitigation + +- **Zero UI disruption during data migration.** Phases 2 through 5 do not touch any view, hook, or component file in old code. If a service extraction causes a regression, the failure is isolated to the data path and the old code can stop delegating with a one-line revert. +- **Translation layer is structurally trivial.** The canonical types and legacy types are isomorphic — same nesting, different names. The translation layer is field renames, not structural transformation, so the risk of data loss is minimal. +- **Incremental provider delegation.** Each adapter method is wired one at a time in the old provider. If one method causes issues, only that method reverts. The rest of the provider continues delegating. +- **Incremental controller delegation.** Same pattern: each controller method delegates to new services one at a time. Partial delegation is a stable intermediate state. +- **Feature work goes in old code.** During Phases 2 through 5, all new features are built in old Predict code. They automatically benefit from new internals because the old code delegates underneath. No confusion about where new code goes. +- **UI migration is per-screen.** Phase 6 migrates one screen at a time. Each screen switch is independently revertable. If the event details screen has issues, the event feed screen is unaffected. +- **Rollback at any phase.** Phases 1 through 5: stop delegating and revert PredictNext PRs. Phase 6: revert route switch for the affected screen. Phase 7: do not start until all screens are stable. + +## 7. Definition of Done + +- All routed Predict screens render from `app/components/UI/PredictNext/views/`. +- All external consumers switch away from `app/components/UI/Predict/`. +- All component view tests for Predict pass against the migrated views. +- Engine registration points instantiate the new Predict controller. +- No runtime imports from old `Predict/` remain. +- Translation layer (`PredictNext/compat/`) is deleted. +- `UBIQUITOUS_LANGUAGE.md` is complete and reflected in code symbols. +- `PredictNext/README.md` and `PredictNext/docs/*` describe the shipped architecture rather than the transitional one. + +## 8. Naming Conversion Rules + +The migration is not a file move only; it also corrects the domain model: + +| Old term in `Predict/` | Canonical term in `PredictNext/` | Migration note | +| ---------------------- | -------------------------------- | ------------------------------------------------------------ | +| `Market` | `Event` | Old `PredictMarket` often represented a top-level event card | +| `Outcome` | `Market` | Old outcome collections frequently map to binary markets | +| `OutcomeToken` | `Outcome` | Tradeable yes/no token becomes the new outcome concept | + +These conversions are handled by the `PredictNext/compat/` translation layer during Phases 2 through 5. They must be applied consistently in: + +- `PredictNext/types/index.ts` +- service method names +- adapter method names +- hook return shapes +- component prop names +- navigation params +- analytics event payloads + +## 9. Recommended PR Order + +1. Phase 1 contracts, error model, and translation layer +2. Phase 2 adapter implementation and initial provider delegation +3. Phase 2 continued provider delegation (method by method, as many PRs as needed) +4. Phase 3 read services plus old controller read delegation +5. Phase 4 write services plus old controller write delegation (can parallel with 4) +6. Phase 5 new controller plus old controller becomes shim +7. Phase 6 vertical slice: event feed (home screen) +8. Phase 6 vertical slice: event details +9. Phase 6 vertical slice: portfolio sections +10. Phase 6 vertical slice: order flow +11. Phase 6 vertical slice: modals and remaining screens +12. Phase 6 external consumer import switches +13. Phase 7 deletion, rename, and final import cleanup + +## 10. Review Expectations + +- Review contracts first: `types/`, `adapters/types.ts`, `errors/`, `compat/`. +- Review delegation PRs by verifying that old behavior is preserved: same state shape, same messenger events, same hook return values. +- Review service extraction PRs by bounded context, not by file count. +- Review UI vertical slices as complete screen replacements: hooks, components, view, and component view tests in one reviewable unit. +- Prefer explicit temporary delegation comments in old code over hidden coupling. +- Do not merge a phase PR without the acceptance criteria from its phase document being met. diff --git a/app/components/UI/PredictNext/docs/migration/phase-1-foundation.md b/app/components/UI/PredictNext/docs/migration/phase-1-foundation.md new file mode 100644 index 000000000000..05dc27b10c9f --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-1-foundation.md @@ -0,0 +1,157 @@ +# Phase 1: Foundation + +## Goal + +Establish the canonical data model, ubiquitous language, adapter contract, shared error primitives, and bidirectional translation layer that every later PredictNext module depends on. + +## Prerequisites + +- None. + +## Deliverables + +- Canonical domain types in `app/components/UI/PredictNext/types/index.ts` +- PredictNext glossary in `app/components/UI/PredictNext/UBIQUITOUS_LANGUAGE.md` +- PredictNext package overview in `app/components/UI/PredictNext/README.md` +- Adapter contract in `app/components/UI/PredictNext/adapters/types.ts` +- Shared error class in `app/components/UI/PredictNext/errors/PredictError.ts` +- Translation layer in `app/components/UI/PredictNext/compat/` +- PredictNext public barrel exports in `app/components/UI/PredictNext/index.ts` and related subdirectory barrels + +## Step-by-Step Tasks + +1. Create the canonical domain type module at `app/components/UI/PredictNext/types/index.ts`. + - Define and document at minimum: + - `PredictEvent` + - `PredictMarket` + - `PredictOutcome` + - `PredictPosition` + - `PredictOrder` + - `ActivityItem` + - `Balance` + - `OrderPreview` + - `OrderResult` + - `TransactionState` + - `LivePricePoint` + - `PriceHistoryPoint` + - `PredictAccount` + - `PredictEligibility` + - Add JSDoc for every exported type explaining the canonical meaning and the old-code equivalent where relevant. + - Explicitly encode the naming conversion from old `Market/Outcome/OutcomeToken` to new `Event/Market/Outcome` in comments so future migrations do not reintroduce old terminology. + +2. Split navigation and feature-flag types into dedicated modules under `PredictNext/types/`. + - Create `app/components/UI/PredictNext/types/navigation.ts`. + - Create `app/components/UI/PredictNext/types/flags.ts` if feature flags remain feature-owned. + - Mirror the useful parts of: + - `app/components/UI/Predict/types/navigation.ts` + - `app/components/UI/Predict/types/flags.ts` + - Rename route params to canonical nouns where that improves clarity, for example `eventId`, `marketId`, `outcomeId`. + +3. Define the adapter seam in `app/components/UI/PredictNext/adapters/types.ts`. + - Export a `PredictAdapter` interface with roughly 15 methods grouped by concern: + - event reads, + - portfolio reads, + - order preview and placement, + - deposits and withdrawals, + - live subscriptions, + - analytics metadata helpers. + - Include explicit method return types using the new canonical domain types. + - Add a `ProviderCapabilities` or similar type so later adapters can describe support for deposits, live prices, claims, withdrawals, and proxy-wallet semantics. + - Keep the interface provider-agnostic so `PolymarketAdapter` and later `KalshiAdapter` can both implement it. + +4. Create the shared error model in `app/components/UI/PredictNext/errors/PredictError.ts`. + - Export `PredictErrorCode` enum values for domain-safe failure categories such as: + - `EligibilityBlocked` + - `InsufficientBalance` + - `OrderPreviewExpired` + - `OrderPlacementFailed` + - `DepositFailed` + - `WithdrawalFailed` + - `ClaimFailed` + - `NetworkMismatch` + - `ProviderUnavailable` + - `LiveDataDisconnected` + - Export `PredictError` class with fields such as `code`, `cause`, `recoverable`, `context`, and `displayMessage`. + - Add constructors/helpers that make downstream code prefer typed errors over string matching. + +5. Create the translation layer in `app/components/UI/PredictNext/compat/`. + - Create `app/components/UI/PredictNext/compat/mappers.ts` with bidirectional mapping functions: + - **Canonical to legacy** (used when old controller/provider needs to return old-shaped data to old consumers): + - `toOldMarket(event: PredictEvent): LegacyMarket` + - `toOldOutcome(market: PredictMarket): LegacyOutcome` + - `toOldOutcomeToken(outcome: PredictOutcome): LegacyOutcomeToken` + - Additional mappers for positions, orders, activity items as needed. + - **Legacy to canonical** (used when old code passes commands to new services): + - `toCanonicalEvent(market: LegacyMarket): PredictEvent` + - `toCanonicalMarket(outcome: LegacyOutcome): PredictMarket` + - `toCanonicalOutcome(token: LegacyOutcomeToken): PredictOutcome` + - Additional mappers for order params, navigation params as needed. + - Create `app/components/UI/PredictNext/compat/types.ts` to re-export or alias the legacy types that the mappers depend on. Import these from the old `Predict/types/` module rather than redefining them. + - The data shapes are structurally identical — the mappers are field renames, not structural transformations. + - This module is intentionally temporary. It will be deleted in Phase 7. + +6. Create barrel exports so later phases import from a stable surface. + - Update or create: + - `app/components/UI/PredictNext/index.ts` + - `app/components/UI/PredictNext/types/index.ts` if split into multiple files + - `app/components/UI/PredictNext/adapters/index.ts` + - `app/components/UI/PredictNext/errors/index.ts` + - `app/components/UI/PredictNext/compat/index.ts` + - Export only contracts and safe primitives; do not expose service internals yet. + +7. Cross-check the glossary against the old codebase. + - Read `app/components/UI/Predict/README.md` and `app/components/UI/Predict/types/index.ts`. + - Confirm every ambiguous term used there has one canonical replacement in `UBIQUITOUS_LANGUAGE.md`. + - Ensure the same term is used in type names, comments, and folder names. + +8. Add foundational tests. + - Write `app/components/UI/PredictNext/errors/PredictError.test.ts` if the error class has logic. + - Write `app/components/UI/PredictNext/compat/mappers.test.ts` to verify bidirectional translation correctness. These mappers are pure functions and benefit from thorough unit tests since every later phase depends on them. + - Write `app/components/UI/PredictNext/adapters/types.test-d.ts` only if the repo already uses type assertion tests; otherwise keep contract verification through compile-time usage in later service tests. + +9. Freeze the contract before delegation work starts. + - Review this phase with owners of controller, hooks, and UI migration work. + - Do not begin Phase 2 until the canonical names, adapter methods, and translation mappers are agreed. + +## Files Created + +| File path | Description | Estimated lines | +| ------------------------------------------------------ | --------------------------------------------------- | --------------: | +| `app/components/UI/PredictNext/types/index.ts` | Canonical domain types and shared value objects | 220-320 | +| `app/components/UI/PredictNext/types/navigation.ts` | PredictNext route param types | 60-120 | +| `app/components/UI/PredictNext/types/flags.ts` | Feature-flag types if feature-owned | 20-40 | +| `app/components/UI/PredictNext/adapters/types.ts` | `PredictAdapter` interface and capability types | 120-180 | +| `app/components/UI/PredictNext/adapters/index.ts` | Adapter barrel exports | 5-15 | +| `app/components/UI/PredictNext/errors/PredictError.ts` | Shared error enum and class | 80-140 | +| `app/components/UI/PredictNext/errors/index.ts` | Error barrel exports | 5-10 | +| `app/components/UI/PredictNext/compat/mappers.ts` | Bidirectional canonical-to-legacy type mappers | 80-140 | +| `app/components/UI/PredictNext/compat/types.ts` | Legacy type aliases imported from old Predict | 20-40 | +| `app/components/UI/PredictNext/compat/index.ts` | Compat barrel exports | 5-10 | +| `app/components/UI/PredictNext/compat/mappers.test.ts` | Translation mapper unit tests | 100-180 | +| `app/components/UI/PredictNext/index.ts` | Public package entry point for foundational exports | 20-40 | + +## Files Affected in Old Code + +| File path | Expected change | +| ----------------------------------------------- | --------------------------------------------------------------- | +| `app/components/UI/Predict/types/index.ts` | None; reference only while mapping old names to canonical names | +| `app/components/UI/Predict/types/navigation.ts` | None; reference only while defining new params | +| `app/components/UI/Predict/README.md` | None; reference only | +| `app/components/UI/Predict/providers/types.ts` | None during Phase 1 | + +## Acceptance Criteria + +- Every core domain concept has exactly one canonical exported type in `PredictNext/types/`. +- `PredictAdapter` can describe the full scope needed by later read, write, and live-data services. +- `PredictError` eliminates stringly typed error handling for new code. +- Translation mappers in `PredictNext/compat/` correctly convert between canonical and legacy types in both directions, verified by unit tests. +- `PredictNext/index.ts` exposes a stable foundational API without leaking implementation internals. +- `UBIQUITOUS_LANGUAGE.md` and the exported types use the same terminology. +- No production files outside `PredictNext/` are switched yet. + +## Estimated PRs + +- 2-3 PRs total. + 1. Types, navigation contracts, and translation layer with tests. + 2. Adapter interface and error model. + 3. Optional cleanup PR for barrels and doc alignment if review scope needs to stay small. diff --git a/app/components/UI/PredictNext/docs/migration/phase-2-adapter.md b/app/components/UI/PredictNext/docs/migration/phase-2-adapter.md new file mode 100644 index 000000000000..4b38be917ec2 --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-2-adapter.md @@ -0,0 +1,77 @@ +# Phase 2: Adapter and Provider Migration + +## Goal + +Build the new PolymarketAdapter implementing the PredictAdapter interface. Incrementally redirect old PolymarketProvider API call sites to delegate to the new adapter. The old provider progressively becomes a thin shell that calls the adapter and translates results back to legacy types via compat mappers. + +## Prerequisites + +- Phase 1 complete (Canonical types, PredictAdapter interface, and compat mappers established). + +## Deliverables + +- `PolymarketAdapter.ts` fully implementing the `PredictAdapter` interface. +- Refactored `PolymarketProvider.ts` that delegates all core logic to the new adapter. +- Comprehensive unit tests for `PolymarketAdapter` using mocked API responses. + +## Step-by-Step Tasks + +### 1. Implement Read Methods in PolymarketAdapter + +Create `app/components/UI/PredictNext/adapters/polymarket/PolymarketAdapter.ts`. Move the data fetching logic from `app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts` into the adapter. + +- Implement `getEvents`, `getFeaturedEvents`, `getEvent`, `search`, and `getPriceHistory`. +- Ensure these methods return canonical types (`PredictEvent`, `PredictMarket`, `PredictOutcome`). +- Move any private helper methods for URL construction or response parsing from the old provider to the adapter. + +### 2. Wire Read Methods in PolymarketProvider + +Update `PolymarketProvider.ts` to use the new adapter for read operations. + +- Inject `PolymarketAdapter` into the provider. +- Replace the internal logic of `getEvents`, `getEvent`, etc., with calls to the adapter. +- Use `toLegacyEvent` and `toLegacyMarket` from `app/components/UI/PredictNext/compat/mappers.ts` to convert canonical results back to legacy shapes. +- This ensures existing consumers in the old controller and hooks remain functional. + +### 3. Implement Write and Transaction Methods + +Move stateful write operations and transaction preparation logic to the adapter. + +- Implement `getOrderPreview` and `placeOrder` in `PolymarketAdapter`. +- Move `prepareDeposit`, `prepareWithdrawal`, and `prepareClaim` logic. +- Update `PolymarketProvider` to delegate these calls, mapping legacy inputs to canonical types before calling the adapter. + +### 4. Implement Live Data Subscriptions + +Integrate the existing WebSocket logic into the adapter interface. + +- Implement `subscribe` and `unsubscribe` in `PolymarketAdapter`. +- These methods should delegate to `app/components/UI/Predict/providers/polymarket/WebSocketManager.ts`. +- Ensure the adapter handles the mapping of incoming WebSocket messages to canonical types. + +## Files Created + +| File Path | Description | Estimated Lines | +| ----------------------------------------------------------------------------- | --------------------------------------------------- | --------------- | +| `app/components/UI/PredictNext/adapters/polymarket/PolymarketAdapter.ts` | Implementation of PredictAdapter for Polymarket API | 600-800 | +| `app/components/UI/PredictNext/adapters/polymarket/PolymarketAdapter.test.ts` | Unit tests for the new adapter | 300-400 | +| `app/components/UI/PredictNext/adapters/polymarket/index.ts` | Polymarket adapter barrel export | 5-10 | + +## Files Affected in Old Code + +| File Path | Expected Change | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts` | Logic removed and replaced with adapter delegation and type mapping. | + +## Acceptance Criteria + +- `PolymarketAdapter` passes all unit tests with 100% coverage of migrated methods. +- `PolymarketProvider` methods return the exact same legacy data shapes as before. +- No changes are required in `PredictController.ts` or any files in `app/components/UI/Predict/hooks/`. +- The app remains fully functional with the Predict feature enabled. + +## Estimated PRs + +- **PR 1**: Read methods implementation and provider wiring. +- **PR 2**: Write and transaction methods implementation. +- **PR 3**: Live data integration and final provider cleanup. diff --git a/app/components/UI/PredictNext/docs/migration/phase-3-read-services.md b/app/components/UI/PredictNext/docs/migration/phase-3-read-services.md new file mode 100644 index 000000000000..2dcaf4cb614c --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-3-read-services.md @@ -0,0 +1,78 @@ +# Phase 3: Read Services + +## Goal + +Build MarketDataService and PortfolioService using BaseDataService patterns. Hook old PredictController read methods to delegate to these new services. The old controller translates new service responses back to old state shapes via compat mappers before publishing to old hooks. + +## Prerequisites + +- Phase 2 complete (PolymarketAdapter is functional and wired to the old provider). + +## Deliverables + +- `MarketDataService.ts` handling all market-related data fetching and caching. +- `PortfolioService.ts` managing user-specific data like balances and positions. +- Refactored `PredictController.ts` where read-only methods delegate to the new services. + +## Step-by-Step Tasks + +### 1. Build MarketDataService + +Create `app/components/UI/PredictNext/services/market-data/MarketDataService.ts`. This service will be the primary source for market information. + +- Implement `getFeaturedEvents`, `getEvents` (feed), `getEventDetails`, `searchEvents`, `getPriceHistory`, and `getSeries`. +- Use `PolymarketAdapter` as the data source. +- Implement caching logic using the `BaseDataService` pattern to reduce redundant API calls. +- Ensure all methods return canonical types. + +### 2. Build PortfolioService + +Create `app/components/UI/PredictNext/services/portfolio/PortfolioService.ts`. This service manages the user's personal state within the Predict feature. + +- Implement `getBalances`, `getPositions` (open, resolved, and claimable), `getActivityFeed`, `getUnrealizedPnL`, `getRewards`, and `getAccountState`. +- Consume the adapter for raw data. +- Handle the logic for calculating aggregate values like total portfolio value or total unrealized PnL. + +### 3. Update PredictController Read Methods + +Modify `app/components/UI/Predict/controllers/PredictController.ts` to delegate to the new services. + +- Identify methods like `fetchMarketData`, `fetchEventDetails`, `fetchPortfolio`, and `refreshBalances`. +- Replace their internal logic with calls to `MarketDataService` or `PortfolioService`. +- Use compat mappers from `app/components/UI/PredictNext/compat/mappers.ts` to translate canonical service responses back to the legacy state shapes used by the controller. +- Update the controller's internal state and trigger updates to old hooks. + +### 4. Messenger and Cache Invalidation + +Ensure the new services are properly integrated with the app's messaging system. + +- Register the services with the controller messenger. +- Implement cache invalidation logic. For example, when a network change occurs, the services should clear their caches and trigger a refresh. + +## Files Created + +| File Path | Description | Estimated Lines | +| ------------------------------------------------------------------------------ | -------------------------------------------------- | --------------- | +| `app/components/UI/PredictNext/services/market-data/MarketDataService.ts` | Service for market data, events, and search | 350-500 | +| `app/components/UI/PredictNext/services/market-data/MarketDataService.test.ts` | Unit tests for MarketDataService | 200-300 | +| `app/components/UI/PredictNext/services/portfolio/PortfolioService.ts` | Service for user balances, positions, and activity | 400-600 | +| `app/components/UI/PredictNext/services/portfolio/PortfolioService.test.ts` | Unit tests for PortfolioService | 250-350 | + +## Files Affected in Old Code + +| File Path | Expected Change | +| ------------------------------------------------------------ | ---------------------------------------------------- | +| `app/components/UI/Predict/controllers/PredictController.ts` | Read methods refactored to delegate to new services. | + +## Acceptance Criteria + +- `MarketDataService` and `PortfolioService` pass all unit tests. +- `PredictController` state remains consistent with previous versions. +- Old hooks like `usePredictMarket` and `usePredictPositions` continue to receive data in the expected legacy format. +- Data fetching performance is maintained or improved through service-level caching. + +## Estimated PRs + +- **PR 1**: MarketDataService implementation and controller wiring. +- **PR 2**: PortfolioService implementation and controller wiring. +- **PR 3**: Messenger integration and cache invalidation logic. diff --git a/app/components/UI/PredictNext/docs/migration/phase-4-write-services.md b/app/components/UI/PredictNext/docs/migration/phase-4-write-services.md new file mode 100644 index 000000000000..be3e5e7dac81 --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-4-write-services.md @@ -0,0 +1,98 @@ +# Phase 4: Write Services + +## Goal + +Extract stateful and write-heavy business logic from the old controller into focused PredictNext services: TradingService, TransactionService, LiveDataService, AnalyticsService. Hook old PredictController write methods to delegate to these new services. + +## Prerequisites + +- Phase 2 complete (PolymarketAdapter is functional). +- Phase 3 can run in parallel with this phase. + +## Deliverables + +- `TradingService.ts` managing the order lifecycle and trading state. +- `TransactionService.ts` handling on-chain operations and Safe orchestration. +- `LiveDataService.ts` providing a unified interface for real-time updates. +- `AnalyticsService.ts` centralizing all feature-specific tracking. +- Refactored `PredictController.ts` where write methods delegate to these services. + +## Step-by-Step Tasks + +### 1. Build TradingService + +Create `app/components/UI/PredictNext/services/trading/TradingService.ts`. This service handles the complexity of placing and managing orders. + +- Move the active-order state machine from `PredictController.ts`. +- Extract logic from trading hooks such as `usePredictTrading`, `usePredictPlaceOrder`, and `usePredictOrderPreview`. +- Implement `placeOrder`, `cancelOrder`, and `retryOrder` methods. +- Manage payment token selection and order validation logic. + +### 2. Build TransactionService + +Create `app/components/UI/PredictNext/services/transactions/TransactionService.ts`. This service orchestrates complex on-chain interactions. + +- Move Safe and Permit2 logic from `app/components/UI/Predict/providers/polymarket/safe/*` and the old controller. +- Implement `deposit`, `withdraw`, and `claim` operations. +- Add a robust pending transaction tracking system to monitor the status of submitted transactions. +- Ensure proper error handling for gas estimation and execution failures. + +### 3. Build LiveDataService + +Create `app/components/UI/PredictNext/services/live-data/LiveDataService.ts`. This service unifies real-time data streams. + +- Consolidate logic from parallel live hooks like `useLiveGameUpdates`, `useLiveMarketPrices`, and `usePredictLivePositions`. +- Use the adapter's `subscribe` and `unsubscribe` methods to manage WebSocket connections. +- Provide a single point of entry for components to listen for market and portfolio updates. + +### 4. Build AnalyticsService + +Create `app/components/UI/PredictNext/services/analytics/AnalyticsService.ts`. This service centralizes all tracking logic. + +- Move the event API from `app/components/UI/Predict/controllers/PredictAnalytics.ts`. +- Extract embedded analytics calls from the old controller and buy flow hooks. +- Provide a clean interface for logging user actions, errors, and performance metrics. + +### 5. Update PredictController Write Methods + +Modify `app/components/UI/Predict/controllers/PredictController.ts` to delegate to the new write services. + +- Identify methods like `placeOrder`, `depositFunds`, `withdrawFunds`, and `claimRewards`. +- Replace their internal logic with calls to the appropriate new service. +- Translate legacy command parameters to canonical types at the controller boundary. +- Map service responses back to legacy state shapes to keep old hooks and UI functional. + +## Files Created + +| File Path | Description | Estimated Lines | +| -------------------------------------------------------------------------------- | -------------------------------------------------- | --------------- | +| `app/components/UI/PredictNext/services/trading/TradingService.ts` | Service for order management and trading logic | 400-700 | +| `app/components/UI/PredictNext/services/trading/TradingService.test.ts` | Trading service tests | 250-400 | +| `app/components/UI/PredictNext/services/transactions/TransactionService.ts` | Service for Safe/Permit2 and on-chain transactions | 500-800 | +| `app/components/UI/PredictNext/services/transactions/TransactionService.test.ts` | Transaction service tests | 300-450 | +| `app/components/UI/PredictNext/services/live-data/LiveDataService.ts` | Service for unified real-time data subscriptions | 200-400 | +| `app/components/UI/PredictNext/services/live-data/LiveDataService.test.ts` | Live data service tests | 150-250 | +| `app/components/UI/PredictNext/services/analytics/AnalyticsService.ts` | Service for centralized feature analytics | 150-300 | +| `app/components/UI/PredictNext/services/analytics/AnalyticsService.test.ts` | Analytics service tests | 100-180 | + +## Files Affected in Old Code + +| File Path | Expected Change | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| `app/components/UI/Predict/controllers/PredictController.ts` | Write methods refactored to delegate to new services. | +| `app/components/UI/Predict/controllers/PredictAnalytics.ts` | Logic moved to AnalyticsService; file eventually deprecated. | + +## Acceptance Criteria + +- All new services pass unit tests with mocked dependencies. +- The trading flow remains functional from the user's perspective. +- Transactions (deposit, withdraw, claim) are processed correctly through the new service layer. +- Real-time updates continue to flow to the UI without interruption. +- Analytics events are still correctly reported to the backend. + +## Estimated PRs + +- **PR 1**: TradingService implementation and controller wiring. +- **PR 2**: TransactionService implementation and Safe logic migration. +- **PR 3**: LiveDataService and AnalyticsService implementation. +- **PR 4**: Final controller cleanup and write method delegation. diff --git a/app/components/UI/PredictNext/docs/migration/phase-5-controller.md b/app/components/UI/PredictNext/docs/migration/phase-5-controller.md new file mode 100644 index 000000000000..575d9d466138 --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-5-controller.md @@ -0,0 +1,85 @@ +# Phase 5: New Controller + +## Goal + +Build the new `PredictController` as a thin orchestrator that composes the six services from Phases 3 and 4. Make the old `PredictController` a pure translation shim that forwards all operations to the new controller. At the end of this phase, the old controller is an empty shell — it translates old types to canonical types, calls the new controller, and translates back. + +## Prerequisites + +- Phase 3 (Read Services) and Phase 4 (Write Services) complete. +- All six services (`MarketDataService`, `PortfolioService`, `TradingService`, `TransactionService`, `LiveDataService`, `AnalyticsService`) are fully implemented and tested. + +## Deliverables + +- New PredictController in `app/components/UI/PredictNext/controller/PredictController.ts`. +- New controller messenger types in `app/components/UI/PredictNext/controller/types.ts`. +- Updated old `PredictController` in `app/components/UI/Predict/controllers/PredictController.ts` acting as a delegation shim. +- Controller unit tests in `app/components/UI/PredictNext/controller/PredictController.test.ts`. + +## Step-by-Step Tasks + +1. Define the new controller messenger and state types in `app/components/UI/PredictNext/controller/types.ts`. + - Narrow the action and event surface to only what the new architecture requires. + - Use canonical types for all state and event payloads. + +2. Implement the new `PredictController` in `app/components/UI/PredictNext/controller/PredictController.ts`. + - Inject the six services via the constructor. + - Implement the core orchestration methods (~10 methods): + - `getEvents()`: Delegates to `MarketDataService`. + - `getEvent(eventId)`: Delegates to `MarketDataService`. + - `getPortfolio()`: Delegates to `PortfolioService`. + - `previewOrder(params)`: Delegates to `TradingService`. + - `placeOrder(params)`: Delegates to `TradingService`. + - `deposit(amount)`: Delegates to `TransactionService`. + - `withdraw(amount)`: Delegates to `TransactionService`. + - `claim(marketId)`: Delegates to `TransactionService`. + - `subscribeLiveData(topic)`: Delegates to `LiveDataService`. + - `unsubscribeLiveData(topic)`: Delegates to `LiveDataService`. + - Ensure the controller remains a thin coordinator with minimal logic of its own. + +3. Update the old `PredictController` to delegate to the new controller. + - Import the new controller and the translation mappers from `PredictNext/compat/mappers.ts`. + - For every public method in the old controller: + - Map incoming legacy arguments to canonical types. + - Call the corresponding method on the new controller. + - Map the canonical result back to the legacy type. + - Return the legacy result to the caller. + - Update the old controller's state by subscribing to the new controller's events and mapping the payloads back to the old state shape. + +4. Update Engine initialization in `app/core/Engine/index.ts` (or the relevant controller setup file). + - Ensure both controllers are instantiated correctly if they need to coexist, or switch the Engine to use the new controller if the messenger interface is compatible. + - Typically, keep the old controller as the primary Engine-registered controller to avoid breaking external consumers until Phase 6. + +5. Write unit tests for the new controller. + - Focus on verifying that the controller correctly calls the underlying services. + - Do not duplicate service logic tests; use mocks for all services. + +## Files Created + +| File path | Description | Estimated lines | +| -------------------------------------------------------------------- | ------------------------------------------------ | --------------: | +| `app/components/UI/PredictNext/controller/PredictController.ts` | New thin orchestrator controller | 150-250 | +| `app/components/UI/PredictNext/controller/types.ts` | Messenger and state types for the new controller | 60-100 | +| `app/components/UI/PredictNext/controller/index.ts` | Controller barrel export | 5-10 | +| `app/components/UI/PredictNext/controller/PredictController.test.ts` | Unit tests for the new controller | 120-200 | + +## Files Affected in Old Code + +| File path | Expected change | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `app/components/UI/Predict/controllers/PredictController.ts` | Replace all internal logic with delegation to the new PredictNext controller. | +| `app/core/Engine/controllers/predict-controller/index.ts` | Update instantiation logic if necessary. | + +## Acceptance Criteria + +- The new `PredictController` implements the ~10 core methods using the six injected services. +- The old `PredictController` is a pure shim with zero internal business logic, delegating all calls to the new controller via the translation layer. +- All existing Predict features continue to work perfectly in the app using the old UI and hooks. +- No regressions in data fetching, trading, or portfolio management. +- Controller tests verify delegation and orchestration only. + +## Estimated PRs + +- 1-2 PRs total. + 1. New controller implementation and tests. + 2. Old controller delegation switch. diff --git a/app/components/UI/PredictNext/docs/migration/phase-6-ui-migration.md b/app/components/UI/PredictNext/docs/migration/phase-6-ui-migration.md new file mode 100644 index 000000000000..b747edb0f0a8 --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-6-ui-migration.md @@ -0,0 +1,123 @@ +# Phase 6: UI Migration (Vertical Slices) + +## Goal + +Replace the old Predict UI one screen at a time. Each vertical slice includes new hooks, new components/widgets, and a new view for that screen. The data stack is already fully proven in production from Phases 2-5, so UI migration is purely a presentation concern. + +## Prerequisites + +- Phase 5 (New Controller) complete. +- Design system components (@metamask/design-system-react-native) and Tailwind preset are available. +- Component view test framework is ready in `tests/component-view/`. + +## Deliverables + +- New granular hooks in `app/components/UI/PredictNext/hooks/`. +- New primitive components in `app/components/UI/PredictNext/components/`. +- New widgets in `app/components/UI/PredictNext/widgets/`. +- New views in `app/components/UI/PredictNext/views/`. +- Component view tests for every migrated view. +- Updated route registration in `app/components/UI/PredictNext/routes/`. + +## Step-by-Step Tasks + +### 1. Hook Organization and Implementation + +- Implement granular data hooks in domain folders: + - `hooks/events/`: `useEventFeed`, `useFeaturedEvents`, `useEvent`, `usePriceHistory`. + - `hooks/portfolio/`: `usePositions`, `useBalance`, `useActivity`. + - `hooks/trading/`: `useOrderPreview`, `useTrading`. + - `hooks/transactions/`: `useTransactions`, `useClaim`. + - `hooks/live-data/`: `useLiveData`. + - `hooks/navigation/`: `usePredictNavigation`. + - `hooks/guard/`: `useEligibilityGuard`. +- **Rule**: Each data hook triggers exactly one query or subscription. +- **Rule**: Use barrel exports in each folder for clean imports. +- **Rule**: Deep imperative hooks (trading, transactions) manage complex stateful workflows. + +### 2. Component Tier Implementation + +- Follow the 3-tier component architecture: + - **Primitives**: Pure components with no hooks. Use design system primitives (`Box`, `Text`, `ButtonBase`). + - Examples: `EventCard`, `OutcomeButton`, `PositionCard`, `PriceDisplay`, `Scoreboard`, `Chart`, `Skeleton`. + - **Widgets**: Wire data hooks to primitives. + - Examples: `EventFeed`, `FeaturedCarousel`, `PortfolioSection`, `OrderForm`, `ActivityList`. + - **Views**: Compose widgets and orchestrate with imperative/guard hooks. + - Examples: `PredictHome`, `EventDetails`, `OrderScreen`, `TransactionsView`. + +### 3. Vertical Slice Migration + +Migrate screens in the following order (simplest to most complex): + +1. **Event Feed Slice**: + - Hooks: `useEventFeed`, `useFeaturedEvents`. + - Components: `EventCard`, `Skeleton`. + - Widgets: `EventFeed`, `FeaturedCarousel`. + - View: `PredictHome` (replaces `PredictFeed/`). +2. **Event Details Slice**: + - Hooks: `useEvent`, `usePriceHistory`. + - Components: `Scoreboard`, `Chart`, `PriceDisplay`. + - View: `EventDetails` (replaces `PredictMarketDetails/`). +3. **Portfolio Slice**: + - Hooks: `usePositions`, `useBalance`, `useActivity`. + - Components: `PositionCard`. + - Widgets: `PortfolioSection`, `ActivityList`. + - View: `PortfolioView` (replaces `PredictTransactionsView/` and portfolio sections). +4. **Order Flow Slice**: + - Hooks: `useOrderPreview`, `useTrading`, `useTransactions`. + - Components: `OutcomeButton`, `PredictKeypad`. + - Widgets: `OrderForm`. + - View: `OrderScreen` (replaces `PredictBuyWithAnyToken`, `PredictBuyPreview`, `PredictSellPreview`). +5. **Modals and Guards**: + - Migrate `AddFundsModal`, `UnavailableModal`, `GTMModal`. + - Implement `useEligibilityGuard` and wire into views. + +### 4. Verification and Testing + +- For each migrated view, create: + - A preset in `tests/component-view/presets/predict.ts`. + - A renderer in `tests/component-view/renderers/`. + - A view test file `*.view.test.tsx`. +- **Rule**: Component view tests are the primary verification surface. No standalone hook or component unit tests are required unless they contain complex non-UI logic. + +### 5. External Consumer Switch + +- Once all internal views are migrated, switch external import points: + - Homepage sections, Wallet actions, Browser tab, and Deeplink handlers. + - Update `app/core/NavigationService/types.ts` to point to new routes. + +## Files Created + +| File path | Description | Estimated lines | +| --------------------------------------------------- | ------------------------------------ | --------------: | +| `app/components/UI/PredictNext/hooks/**/*.ts` | Granular domain hooks | 500-1,000 | +| `app/components/UI/PredictNext/components/**/*.tsx` | Tier 1: Pure primitive components | 800-1,500 | +| `app/components/UI/PredictNext/widgets/**/*.tsx` | Tier 2: Data-wired widgets | 600-1,200 | +| `app/components/UI/PredictNext/views/**/*.tsx` | Tier 3: Orchestrated screen views | 400-800 | +| `tests/component-view/**/*.view.test.tsx` | Integration tests for migrated views | 600-1,200 | + +## Files Affected in Old Code + +| File path | Expected change | +| -------------------------------------------- | --------------------------------------------------------------- | +| `app/components/UI/Predict/routes/index.tsx` | Switch route components to PredictNext views as they are ready. | +| `app/components/Views/Homepage/...` | Switch imports to PredictNext components. | +| `app/components/Views/Wallet/...` | Switch imports to PredictNext components. | +| `app/core/NavigationService/types.ts` | Update route param types to canonical versions. | + +## Acceptance Criteria + +- All Predict screens are rendered using `PredictNext` views, hooks, and components. +- UI uses `@metamask/design-system-react-native` and Tailwind CSS exclusively. +- No screen mixes old and new hooks or components. +- Every migrated view has a passing component view test suite. +- External consumers (Homepage, Wallet) are successfully switched to new components. +- Performance is equal to or better than the legacy implementation. + +## Estimated PRs + +- 8-12 PRs total. + - 1 PR per vertical slice (Feed, Details, Portfolio, Order Flow). + - 1-2 PRs for shared hooks and primitives. + - 1-2 PRs for modals and guards. + - 1 PR for external consumer switches. diff --git a/app/components/UI/PredictNext/docs/migration/phase-7-cleanup.md b/app/components/UI/PredictNext/docs/migration/phase-7-cleanup.md new file mode 100644 index 000000000000..c69cff3fb2db --- /dev/null +++ b/app/components/UI/PredictNext/docs/migration/phase-7-cleanup.md @@ -0,0 +1,82 @@ +# Phase 7: Cleanup + +## Goal + +Remove the legacy Predict implementation, delete the translation layer, rename `PredictNext/` to `Predict/`, and finish the migration with zero old-code dependencies remaining. + +## Prerequisites + +- Phase 6 (UI Migration) complete. +- All routed screens and external embed points are using `PredictNext`. +- Zero runtime imports from the old `app/components/UI/Predict/` directory remain in the codebase. + +## Deliverables + +- Deleted `app/components/UI/Predict/` directory. +- Deleted `app/components/UI/PredictNext/compat/` directory. +- Renamed `app/components/UI/PredictNext/` to `app/components/UI/Predict/`. +- Updated import paths across the entire repository. +- Updated `CODEOWNERS` and documentation. + +## Step-by-Step Tasks + +1. **Final Verification**: + - Run a global grep for `from '.*UI/Predict/'` and `from ".*UI/Predict/"` to ensure no production code still imports from the old directory. + - Verify that all tests (unit, component view, E2E) pass. + +2. **Delete Legacy Code**: + - Delete the entire `app/components/UI/Predict/` tree. + - Delete the `app/components/UI/PredictNext/compat/` translation layer. It is no longer needed since all consumers now use canonical types. + +3. **Rename Directory**: + - Execute: `git mv app/components/UI/PredictNext/ app/components/UI/Predict/`. + - This preserves git history for the new implementation while restoring the original directory name. + +4. **Update Import Paths**: + - Update all import paths that were pointing to `PredictNext` to point to `Predict`. + - Affected areas include: + - `app/core/Engine/` + - `app/core/NavigationService/` + - `app/core/DeeplinkManager/` + - `app/components/Views/Homepage/` + - `app/components/Views/Wallet/` + - `tests/component-view/` + - `tests/smoke/` and `tests/regression/` (E2E tests) + +5. **Update Documentation and Metadata**: + - Update `CODEOWNERS` if the team structure has changed. + - Rewrite `app/components/UI/Predict/README.md` and other docs to describe the final shipped architecture, removing references to the migration process where they are no longer relevant. + - Update any scripts or CI configurations that referenced `PredictNext`. + +6. **Final Test Run**: + - Run the full suite of component view tests. + - Run all Predict-related E2E tests (smoke and regression). + - Perform a final manual smoke test on both iOS and Android. + +## Files Created + +| File path | Description | Estimated lines | +| ------------------------------------- | ---------------------------------------- | --------------: | +| `app/components/UI/Predict/README.md` | Updated final architecture documentation | 100-200 | + +## Files Affected in Old Code + +| File path | Expected change | +| ---------------------------------- | ----------------------------------------------------- | +| `app/components/UI/Predict/` (old) | Deleted. | +| `app/components/UI/PredictNext/` | Renamed to `app/components/UI/Predict/`. | +| `~15 external files` | Import paths updated from `PredictNext` to `Predict`. | + +## Acceptance Criteria + +- The `app/components/UI/PredictNext/` directory no longer exists. +- The `app/components/UI/Predict/` directory contains the new architecture. +- The `compat/` translation layer is completely removed. +- All tests pass, and the app functions perfectly. +- No "PredictNext" terminology remains in production code or import paths. + +## Estimated PRs + +- 1-2 PRs total. + 1. Deletion of old code and translation layer. + 2. Directory rename and global import update. diff --git a/app/components/UI/PredictNext/docs/services.md b/app/components/UI/PredictNext/docs/services.md new file mode 100644 index 000000000000..2574ab4eb737 --- /dev/null +++ b/app/components/UI/PredictNext/docs/services.md @@ -0,0 +1,756 @@ +# PredictNext Service Architecture + +This document describes the service layer for the PredictNext redesign. The service layer is where PredictNext becomes deep: reads, writes, orchestration, retries, state machines, transaction composition, and realtime coordination all live here rather than in components, hooks, or controllers. + +Related documents: + +- [architecture.md](./architecture.md) +- [adapters.md](./adapters.md) +- [state-management.md](./state-management.md) +- [error-handling.md](./error-handling.md) +- [testing.md](./testing.md) + +## 1. Service Overview + +PredictNext uses six services. Two are read-oriented `BaseDataService` implementations and four are plain services. + +| Service | Extends BaseDataService? | Approximate public interface size | What it hides | +| -------------------- | ------------------------ | --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `MarketDataService` | Yes | 6 methods | query key definitions, cache strategy, retries, stale-time policy, provider pagination normalization | +| `PortfolioService` | Yes | 5 methods | account read aggregation, cache policy, pagination normalization, background refresh behavior | +| `TradingService` | No | 5 methods + small readonly state | order state machine, rate limiting, optimistic overlays, deposit-before-order chaining | +| `TransactionService` | No | 3 methods | Permit2, EIP-712 signing, Safe proxy wallet flows, batch transaction building and submission | +| `LiveDataService` | No | 2 methods + connection status | socket lifecycle, reconnection, multiplexing, channel fan-out, provider transport differences | +| `AnalyticsService` | No | 1 method | event normalization, session context injection, batching, provider-independent analytics vocabulary | + +The design intent is that each service exposes only the capabilities other modules must actually use. Internal helper methods, transport concerns, and workflow states remain private. + +## 2. PredictController (Thin Orchestrator) + +`PredictController` becomes a facade for write operations and lifecycle only. It is intentionally thin and should settle near ten public methods, not dozens. + +### Controller responsibilities + +- expose write operations through `Engine.context` +- initialize service graph and shared dependencies +- own feature lifecycle entrypoints +- hold session-scoped controller state that is not query-shaped + +### Controller non-responsibilities + +- serving read queries +- transforming provider payloads +- managing bespoke caches +- implementing retry loops +- owning transaction details +- directly implementing order state transitions + +### Read path rule + +Read operations do not go through `PredictController`. They go through `BaseDataService`-backed services registered directly with Engine and consumed by `useQuery` through messenger. + +### Public controller interface + +```typescript +export type Unsubscribe = () => void; + +export type SubscriptionChannel = + | 'marketPrices' + | 'cryptoPrices' + | 'gameUpdates'; + +export interface PreviewOrderParams { + accountId: string; + marketId: string; + outcomeId: string; + side: 'buy' | 'sell'; + amount: string; + paymentTokenAddress?: string; +} + +export interface PlaceOrderParams extends PreviewOrderParams { + slippageBps?: number; + requireDepositIfNeeded?: boolean; +} + +export interface DepositParams { + accountId: string; + tokenAddress: string; + amount: string; +} + +export interface WithdrawParams { + accountId: string; + tokenAddress: string; + amount: string; +} + +export interface ClaimParams { + accountId: string; + eventId: string; + marketIds?: string[]; +} + +export interface OrderPreview { + marketId: string; + outcomeId: string; + estimatedShares: string; + averagePrice: string; + fee: string; + requiresDeposit: boolean; + totalCost: string; +} + +export interface OrderResult { + orderId: string; + status: 'submitted' | 'filled' | 'partially_filled'; + txHashes: string[]; +} + +export interface TransactionResult { + txHash: string; + status: 'submitted' | 'confirmed'; +} + +export interface SubscriptionParams { + marketId?: string; + marketIds?: string[]; + eventId?: string; +} + +export interface PredictController { + previewOrder(params: PreviewOrderParams): Promise; + placeOrder(params: PlaceOrderParams): Promise; + cancelOrder(orderId: string): Promise; + + deposit(params: DepositParams): Promise; + withdraw(params: WithdrawParams): Promise; + claim(params: ClaimParams): Promise; + + subscribe( + channel: SubscriptionChannel, + params: SubscriptionParams, + ): Unsubscribe; + + initialize(): Promise; + destroy(): void; +} +``` + +The controller surface stays intentionally small even if internal services are sophisticated. That is the point of the design. + +```text +Hooks (read path) Hooks (write path) + | | + | [bypasses controller] v + | PredictController + | (~10 methods) + | / | \ + v v v v +MarketData Portfolio Trading Transaction LiveData Analytics +Service Service Service Service Service Service + \ \ | | / / + \_________ \________|_______|________/ ______/ + | + PredictAdapter +``` + +## 3. MarketDataService (BaseDataService) + +`MarketDataService` is the read model for market and discovery data. + +### Why BaseDataService + +Market data is shared server state: + +- many screens consume it +- cache behavior matters +- stale/fresh semantics matter +- identical requests should dedupe automatically + +`@metamask/base-data-service` gives PredictNext the correct shape for this problem: + +- TanStack Query semantics at the service layer +- shared cache via messenger +- retries through Cockatiel policy +- circuit breaker behavior +- query-key-centric reads + +### Registration model + +`MarketDataService` registers with Engine messenger and exposes query methods that can be invoked by React hooks without passing through `PredictController`. + +### Policy defaults + +- `maxRetries: 2` +- `maxConsecutiveFailures: 3` + +### Stale-time strategy + +- prices: `1 minute` +- active event metadata: `5 minutes` +- resolved events: `1 hour` + +### Replaces legacy complexity + +`MarketDataService` replaces scattered mechanisms such as: + +- `GameCache` +- `TeamsCache` +- custom pagination trackers +- view-owned fetch coordination + +### Public interface + +```typescript +export interface FetchEventsParams { + cursor?: string; + league?: string; + status?: 'upcoming' | 'live' | 'resolved'; + sort?: 'featured' | 'volume' | 'endingSoon'; + limit?: number; +} + +export interface SearchEventsParams { + query: string; + limit?: number; +} + +export type TimePeriod = '1H' | '1D' | '1W' | '1M' | 'ALL'; + +export interface PredictOutcome { + id: string; + label: string; + price: string; + probability: number; +} + +export interface PredictMarket { + id: string; + eventId: string; + question: string; + status: 'open' | 'closed' | 'resolved'; + outcomes: PredictOutcome[]; +} + +export interface PredictEvent { + id: string; + title: string; + subtitle?: string; + status: 'upcoming' | 'live' | 'resolved'; + markets: PredictMarket[]; + startsAt?: string; + resolvesAt?: string; +} + +export interface PricePoint { + timestamp: string; + value: string; +} + +export interface MarketPrices { + marketId: string; + bestBid?: string; + bestAsk?: string; + lastTradedPrice?: string; + updatedAt: string; +} + +export interface PaginatedResult { + items: T[]; + nextCursor?: string; +} + +export interface PredictMarketDataQueryKeys { + getEvents( + params: FetchEventsParams, + ): ['PredictMarketData:getEvents', FetchEventsParams]; + getEvent(eventId: string): ['PredictMarketData:getEvent', string]; + getCarouselEvents(): ['PredictMarketData:getCarouselEvents']; + searchEvents( + params: SearchEventsParams, + ): ['PredictMarketData:searchEvents', SearchEventsParams]; + getPriceHistory( + marketId: string, + period: TimePeriod, + ): ['PredictMarketData:getPriceHistory', string, TimePeriod]; + getPrices(marketIds: string[]): ['PredictMarketData:getPrices', string[]]; +} + +export interface MarketDataService { + getEvents(params: FetchEventsParams): Promise>; + getEvent(eventId: string): Promise; + getCarouselEvents(): Promise; + searchEvents(params: SearchEventsParams): Promise; + getPriceHistory(marketId: string, period: TimePeriod): Promise; + getPrices(marketIds: string[]): Promise>; +} +``` + +### Query key contract + +These keys are part of the public read contract between hooks and data services: + +```typescript +const PredictMarketDataQueryKeys: PredictMarketDataQueryKeys = { + getEvents: (params) => ['PredictMarketData:getEvents', params], + getEvent: (eventId) => ['PredictMarketData:getEvent', eventId], + getCarouselEvents: () => ['PredictMarketData:getCarouselEvents'], + searchEvents: (params) => ['PredictMarketData:searchEvents', params], + getPriceHistory: (marketId, period) => [ + 'PredictMarketData:getPriceHistory', + marketId, + period, + ], + getPrices: (marketIds) => ['PredictMarketData:getPrices', marketIds], +}; +``` + +The hook layer should never invent alternate keys for these reads. + +## 4. PortfolioService (BaseDataService) + +`PortfolioService` is the read model for account-specific prediction-market data. + +### Responsibilities + +- positions +- activity history +- balances +- unrealized profit and loss +- normalized account state for eligibility and funding UI + +### Cache strategy + +- positions: `1 minute` +- activity: `5 minutes` +- balance and account state: typically `1 minute` + +Positions are relatively volatile during active trading, while activity is more append-only and tolerates a slightly longer stale window. + +### Public interface + +```typescript +export interface PredictPosition { + id: string; + accountId: string; + marketId: string; + outcomeId: string; + shares: string; + averageEntryPrice: string; + currentValue: string; + unrealizedPnL: string; +} + +export interface ActivityItem { + id: string; + type: 'order' | 'deposit' | 'withdrawal' | 'claim'; + status: 'pending' | 'confirmed' | 'failed'; + timestamp: string; + txHash?: string; + description: string; +} + +export interface Balance { + tokenAddress: string; + symbol: string; + amount: string; + decimals: number; +} + +export interface AccountState { + accountId: string; + availableBalances: Balance[]; + selectedPaymentTokenAddress?: string; + canTrade: boolean; + requiresWalletSetup: boolean; +} + +export interface PredictPortfolioQueryKeys { + getPositions(accountId: string): ['PredictPortfolio:getPositions', string]; + getActivity( + accountId: string, + cursor?: string, + ): ['PredictPortfolio:getActivity', string, string?]; + getBalance(accountId: string): ['PredictPortfolio:getBalance', string]; + getUnrealizedPnL( + accountId: string, + ): ['PredictPortfolio:getUnrealizedPnL', string]; + getAccountState( + accountId: string, + ): ['PredictPortfolio:getAccountState', string]; +} + +export interface PortfolioService { + getPositions(accountId: string): Promise; + getActivity( + accountId: string, + cursor?: string, + ): Promise>; + getBalance(accountId: string): Promise; + getUnrealizedPnL(accountId: string): Promise; + getAccountState(accountId: string): Promise; +} +``` + +### Query key contract + +```typescript +const PredictPortfolioQueryKeys: PredictPortfolioQueryKeys = { + getPositions: (accountId) => ['PredictPortfolio:getPositions', accountId], + getActivity: (accountId, cursor) => [ + 'PredictPortfolio:getActivity', + accountId, + cursor, + ], + getBalance: (accountId) => ['PredictPortfolio:getBalance', accountId], + getUnrealizedPnL: (accountId) => [ + 'PredictPortfolio:getUnrealizedPnL', + accountId, + ], + getAccountState: (accountId) => [ + 'PredictPortfolio:getAccountState', + accountId, + ], +}; +``` + +## 5. TradingService (Plain Service) + +`TradingService` owns the entire active-order workflow. This is one of the deepest modules in the system. + +### State machine ownership + +The order lifecycle is modeled inside `TradingService`, not in hooks or screens: + +- `IDLE` +- `PREVIEWING` +- `DEPOSITING` +- `PLACING_ORDER` +- `SUCCESS` +- `ERROR` + +The UI can render the current state, but it should not be responsible for deciding transitions. + +```text + +-------+ +------------+ + | ERROR | <------- | ANY STATE | + +-------+ +------------+ + | + | (reset) + v + +-------+ +------------+ + +----> | IDLE | <------> | PREVIEWING | + | +-------+ +------------+ + | | | + | v | + | +------------+ | + | | DEPOSITING | <---------+ + | +------------+ + | | + | v + | +---------------+ + | | PLACING_ORDER | + | +---------------+ + | | + | v + | +---------+ + +----- | SUCCESS | + +---------+ +``` + +### Internal responsibilities + +`TradingService` hides substantial complexity: + +- rate limiting to at most one order every three seconds +- order preview lifecycle +- automatic deposit-and-order chaining when funding is insufficient +- optimistic position overlays before read cache catches up +- invalidation of market and portfolio query data after write completion +- analytics emission at preview, submit, success, and failure boundaries + +### Public interface + +```typescript +export type TradingStateStatus = + | 'IDLE' + | 'PREVIEWING' + | 'DEPOSITING' + | 'PLACING_ORDER' + | 'SUCCESS' + | 'ERROR'; + +export interface SelectedPaymentToken { + tokenAddress: string; + symbol: string; +} + +export interface TradingState { + status: TradingStateStatus; + activePreview?: OrderPreview; + lastOrderResult?: OrderResult; + lastErrorCode?: PredictErrorCode; +} + +export interface TradingService { + readonly orderState: TradingState; + readonly selectedPayment?: SelectedPaymentToken; + + previewOrder(params: PreviewOrderParams): Promise; + placeOrder(params: PlaceOrderParams): Promise; + cancelOrder(orderId: string): Promise; + selectPaymentToken(token: SelectedPaymentToken): void; + reset(): void; +} +``` + +### Hidden internals by design + +The service may internally require many helpers: + +- preview validator +- quote freshness checker +- funding evaluator +- rate limit gate +- order transition reducer +- optimistic overlay manager +- post-write invalidation planner + +Those helpers should remain private because callers do not benefit from depending on them. The public API stays small even if the implementation is sophisticated. + +## 6. TransactionService (Plain Service) + +`TransactionService` owns blockchain transaction construction and execution for PredictNext account operations. + +### Responsibilities + +- deposits +- withdrawals +- claims +- transaction batching where supported +- signing and submission coordination +- provider-independent error normalization + +### Internal complexity absorbed here + +The UI and controller should never need to know whether a transaction requires: + +- Safe proxy wallet interaction +- Permit2 approval +- EIP-712 signing +- multiple batched calls +- provider-specific calldata shape + +That is exactly the complexity this module exists to hide. + +### Public interface + +```typescript +export enum PredictErrorCode { + UNAVAILABLE = 'UNAVAILABLE', + RATE_LIMITED = 'RATE_LIMITED', + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + QUOTE_EXPIRED = 'QUOTE_EXPIRED', + TRANSACTION_REJECTED = 'TRANSACTION_REJECTED', + TRANSACTION_FAILED = 'TRANSACTION_FAILED', + PROVIDER_ERROR = 'PROVIDER_ERROR', +} + +export class PredictError extends Error { + constructor( + public readonly code: PredictErrorCode, + message: string, + public readonly recoverable: boolean, + public readonly metadata?: Record, + ) { + super(message); + this.name = 'PredictError'; + } +} + +export interface TransactionService { + deposit(params: DepositParams): Promise; + withdraw(params: WithdrawParams): Promise; + claim(params: ClaimParams): Promise; +} +``` + +Every thrown error exposed from this service should be a `PredictError`. Lower-level exceptions should not escape the boundary. + +## 7. LiveDataService (Plain Service) + +`LiveDataService` owns realtime delivery for prediction-market updates. + +### Responsibilities + +- manage socket or stream connection lifecycle +- fan provider streams into stable channel abstractions +- multiplex multiple subscribers onto shared underlying connections +- reconnect with backoff +- expose a small, generic subscription API + +### Public interface + +```typescript +export type LiveDataConnectionStatus = + | 'connected' + | 'reconnecting' + | 'disconnected'; + +export interface MarketPriceUpdate { + marketId: string; + bestBid?: string; + bestAsk?: string; + lastTradedPrice?: string; + updatedAt: string; +} + +export interface CryptoPriceUpdate { + symbol: string; + price: string; + updatedAt: string; +} + +export interface GameUpdate { + eventId: string; + status: 'upcoming' | 'live' | 'resolved'; + headline?: string; + updatedAt: string; +} + +export interface SubscriptionHandle { + readonly status: LiveDataConnectionStatus; + readonly data?: TData; + unsubscribe(): void; +} + +export interface LiveDataService { + readonly connectionStatus: LiveDataConnectionStatus; + + subscribe( + channel: SubscriptionChannel, + params: SubscriptionParams, + onData: (data: TData) => void, + ): SubscriptionHandle; + + disconnect(): void; +} +``` + +### Channel set + +Supported channels: + +- `'marketPrices'` +- `'cryptoPrices'` +- `'gameUpdates'` + +The channel abstraction is product-level. Whether a provider implements it through WebSocket, SSE, or polling fallback is an internal concern. + +## 8. AnalyticsService (Plain Service) + +`AnalyticsService` is a cross-cutting dependency used by other services. + +### Responsibilities + +- track Predict product events +- inject stable session and account context +- normalize naming across providers +- batch emissions when appropriate + +### Public interface + +```typescript +export type PredictAnalyticsEvent = + | 'Predict Viewed Home' + | 'Predict Viewed Event' + | 'Predict Previewed Order' + | 'Predict Placed Order' + | 'Predict Order Failed' + | 'Predict Deposited Funds' + | 'Predict Withdrew Funds' + | 'Predict Claimed Winnings' + | 'Predict Live Data Reconnected'; + +export interface AnalyticsService { + track( + event: PredictAnalyticsEvent, + properties: Record, + ): void; +} +``` + +Other services should depend on `AnalyticsService` through constructor injection and call `track()` at meaningful workflow boundaries. Views should rarely emit analytics directly. + +## 9. Service Interaction Patterns + +Services cooperate, but dependency directions stay disciplined. + +### Dependency graph + +Typical dependencies: + +- `TradingService` → `TransactionService` +- `TradingService` → `AnalyticsService` +- `TradingService` → `MarketDataService` invalidation hooks +- `TradingService` → `PortfolioService` invalidation hooks +- `LiveDataService` → `AnalyticsService` +- `MarketDataService` → `PredictAdapter` +- `PortfolioService` → `PredictAdapter` +- `TransactionService` → `PredictAdapter` +- `LiveDataService` → `PredictAdapter` + +```text + TradingService LiveDataService + / | | \ / \ + v v v v v v + Transac Market Portfol Analytics PredictAdapter + Service Data folio Service ^ + | \ / | + +--------+----+---------------------+ +``` + +`AnalyticsService` should not depend back on feature services. `PredictAdapter` should not depend upward on services. + +### Constructor injection + +Dependencies are provided explicitly. + +```typescript +export interface TradingServiceDeps { + transactionService: TransactionService; + analyticsService: AnalyticsService; + marketDataInvalidator: { + invalidatePrices(marketIds: string[]): Promise; + }; + portfolioInvalidator: { + invalidateAccount(accountId: string): Promise; + }; + adapter: { + getOrderPreview(params: PreviewOrderParams): Promise; + submitOrder(params: PlaceOrderParams): Promise; + }; +} +``` + +Constructor injection keeps testing direct and makes boundaries explicit. + +### BaseDataService registration + +The read services register with Engine under a `DATA_SERVICES` convention. Hooks use those registrations through `@metamask/react-data-query` and messenger-driven query resolution. + +Illustrative shape: + +```typescript +export interface PredictDataServicesRegistry { + PredictMarketData: MarketDataService; + PredictPortfolio: PortfolioService; +} +``` + +This pattern gives PredictNext a shared data plane for reads while preserving a thin controller for writes. + +### Guiding rule + +If a new requirement introduces orchestration, retries, branching workflow state, or provider coordination, it belongs in a service. If it only translates a provider payload, it belongs in an adapter. If it only presents data, it belongs above the service layer. diff --git a/app/components/UI/PredictNext/docs/state-management.md b/app/components/UI/PredictNext/docs/state-management.md new file mode 100644 index 000000000000..bd4bdb2f5cb2 --- /dev/null +++ b/app/components/UI/PredictNext/docs/state-management.md @@ -0,0 +1,320 @@ +# PredictNext State Management + +## State Categories + +PredictNext uses four state categories, each chosen for a specific kind of responsibility. + +| Category | Where | Why | Examples | +| ----------------------- | ---------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Server cache | BaseDataService and UI query cache | Fetched-and-cached data with staleness, refetching, and deduplication | Events, markets, positions, activity, balance, prices | +| Session state | Redux in `PredictController` | Persists across navigation and backgrounding | Active order, selected payment token, pending deposits and claims | +| Transient service state | Service internals | Implementation detail not read directly by UI | Rate limiting timestamps, optimistic overlays, WebSocket connection state | +| View-local state | React `useState` and local hooks | Dies with the view and stays close to interaction logic | Keypad input, scroll position, search query, bottom sheet visibility | + +```text +┌─────────────────────────────────────────────────────────────┐ +│ PredictNext State Map │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ React Local State ──────────────────────────────┐ │ +│ │ useState / local hooks │ │ +│ │ keypad input, scroll, search, tabs │ │ +│ │ Dies with the view │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Redux (PredictController) ──────────────────────┐ │ +│ │ Session state │ │ +│ │ active order, payment token, pending deposits │ │ +│ │ Survives navigation │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ BaseDataService Cache ──────────────────────────┐ │ +│ │ Server cache (TanStack Query) │ │ +│ │ events, markets, positions, balance, activity │ │ +│ │ Shared, deduplicated, stale-time controlled │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Service Internals ──────────────────────────────┐ │ +│ │ Transient operational state │ │ +│ │ rate limits, overlays, socket state, circuits │ │ +│ │ Never exposed to UI │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +This division keeps the persistent state footprint small and places each concern where it can be managed most naturally. + +Related docs: + +- [hooks](./hooks.md) +- [components](./components.md) +- [error handling](./error-handling.md) +- [testing](./testing.md) + +## BaseDataService Integration + +PredictNext read services should use `@metamask/base-data-service` so fetched data is cache-aware, deduplicated, and naturally aligned with the UI query layer. + +Primary read services: + +- `MarketDataService` +- `PortfolioService` + +Key rules: + +- each service owns an internal query client +- service methods call `this.fetchQuery()` with canonical query keys +- UI reads with `useQuery` and `useInfiniteQuery` from `@metamask/react-data-query` +- UI does not define `queryFn` for service-backed reads +- cache synchronization flows through messenger events + +Registration requirement: + +- add service names to `app/constants/data-services.ts` in `DATA_SERVICES` +- `app/core/ReactQueryService/` creates the UI query client via `createUIQueryClient` + +### Full read data flow + +```text +UI: useQuery({ queryKey: ['PredictMarketData:getEvents', params] }) + → ReactQueryService.queryClient (UI QueryClient) + → createUIQueryClient intercepts, calls messenger adapter + → Engine.controllerMessenger.call('PredictMarketData:getEvents', params) + → MarketDataService.getEvents(params) + → this.fetchQuery({ queryKey, queryFn: () => adapter.fetchEvents(params) }) + → PolymarketAdapter.fetchEvents(params) → HTTP → Polymarket Gamma API + → result cached in service internal QueryClient + → service publishes 'PredictMarketData:cacheUpdated:hash' event + → UI QueryClient updates cache + → component re-renders +``` + +### Example BaseDataService method + +```typescript +import { BaseDataService } from '@metamask/base-data-service'; +import type { EventsParams, PredictEvent } from '../types'; + +export class MarketDataService extends BaseDataService { + readonly name = 'PredictMarketData'; + + async getEvents(params: EventsParams = {}) { + return await this.fetchQuery({ + queryKey: ['PredictMarketData:getEvents', params], + staleTime: 5 * 60 * 1000, + queryFn: async () => await this.adapter.fetchEvents(params), + }); + } + + async getEvent(eventId: string) { + return await this.fetchQuery({ + queryKey: ['PredictMarketData:getEvent', eventId], + staleTime: 60 * 1000, + queryFn: async () => await this.adapter.fetchEvent(eventId), + }); + } +} +``` + +### Example UI query usage + +```typescript +import { useQuery } from '@metamask/react-data-query'; +import type { PredictEvent } from '../types'; + +export function useEvent(eventId: string) { + return useQuery({ + queryKey: ['PredictMarketData:getEvent', eventId], + }); +} +``` + +This arrangement makes read state declarative and minimizes Redux involvement for fetched data. + +## Redux State Shape + +Redux stores only session state that must survive navigation and backgrounding. Market data, portfolio lists, and balances should not live in Redux when they can live in the query cache. + +Minimal `PredictController` state: + +```typescript +interface ActiveOrderState { + marketId: string; + outcomeId: string; + amount: string; + side: 'buy' | 'sell'; + startedAt: number; +} + +interface PendingDeposit { + transactionId: string; + amount: string; + createdAt: number; +} + +interface PendingClaim { + positionId: string; + transactionId: string; + createdAt: number; +} + +interface AccountMeta { + lastViewedEventId?: string; + hasSeenPredictEducation?: boolean; +} + +interface PaymentToken { + symbol: string; + address: string; + decimals: number; +} + +interface PredictControllerState { + activeOrder: ActiveOrderState | null; + selectedPaymentToken: PaymentToken | null; + pendingDeposits: Record; + pendingClaims: Record; + accountMeta: Record; +} +``` + +Benefits of the reduced shape: + +- smaller persistence payloads +- less stale duplication +- less reducer branching +- fewer state synchronization bugs between Redux and server cache + +## Query Key Convention + +All query keys follow the same convention: + +```typescript +['ServiceName:methodName', ...params]; +``` + +Examples: + +```typescript +['PredictMarketData:getEvents', { category: 'sports', cursor: 'next-page' }][ + ('PredictMarketData:getEvent', 'event-42') +]['PredictMarketData:getCarouselEvents'][ + ('PredictPortfolio:getPositions', 'account-1') +][('PredictPortfolio:getBalance', 'account-1')][ + ('PredictPortfolio:getActivity', 'account-1') +]; +``` + +Why this matters: + +- cache keys are readable in tooling and logs +- query invalidation becomes consistent +- UI and services share one stable contract + +## Stale Time Strategy + +Different data types need different freshness policies. + +| Data Type | Stale Time | Rationale | +| --------------- | ---------- | ------------------------------------------------- | +| Event metadata | 5 min | Titles, images, and descriptions change rarely | +| Market prices | 1 min | Prices move often and must stay useful | +| Resolved events | 1 hour | Post-resolution data is mostly static | +| Positions | 1 min | Portfolio views should feel current | +| Balance | 30 sec | Order entry requires fresher values | +| Activity | 5 min | Historical records rarely need sub-minute refresh | +| Carousel | 5 min | Curated content changes slowly | + +Example service configuration: + +```typescript +await this.fetchQuery({ + queryKey: ['PredictPortfolio:getBalance', accountId], + staleTime: 30 * 1000, + queryFn: async () => await this.adapter.fetchBalance(accountId), +}); +``` + +## Cache Invalidation + +Mutation flows should invalidate only the queries affected by the write. + +Rules: + +- after placing order, invalidate positions and balance +- after deposit or withdraw, invalidate balance +- after claim, invalidate positions and balance +- manual refresh invalidates all current-account Predict queries + +Example invalidation helper: + +```typescript +import { QueryClient } from '@metamask/react-data-query'; + +export async function invalidateAfterOrder( + queryClient: QueryClient, + accountId: string, +) { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getPositions', accountId], + }), + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getBalance', accountId], + }), + ]); +} +``` + +Manual refresh example: + +```typescript +export async function refreshPredictAccount( + queryClient: QueryClient, + accountId: string, +) { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getPositions', accountId], + }), + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getActivity', accountId], + }), + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getBalance', accountId], + }), + queryClient.invalidateQueries({ + queryKey: ['PredictPortfolio:getUnrealizedPnl', accountId], + }), + ]); +} +``` + +## Session State vs View State + +A useful decision rule: + +- if the state must survive route changes or backgrounding, put it in Redux +- if the state is fetched, cache it in services and query clients +- if the state is implementation-only, keep it inside the service +- if the state exists only for one rendered route, keep it local + +Examples: + +```typescript +// Good local state: keypad input for the active OrderScreen session +const [typedAmount, setTypedAmount] = useState('0'); + +// Good Redux state: selected payment token reused across Predict flows +dispatch(setSelectedPaymentToken(token)); + +// Good query cache state: positions fetched from portfolio service +const { data: positions } = useQuery({ + queryKey: ['PredictPortfolio:getPositions', accountId], +}); +``` + +## Summary + +PredictNext state management works best when read data is query-backed, session intent lives in Redux, and ephemeral UI logic stays local. This removes redundant state ownership and supports the redesign goal of deep modules with slim interfaces. diff --git a/app/components/UI/PredictNext/docs/testing.md b/app/components/UI/PredictNext/docs/testing.md new file mode 100644 index 000000000000..17479b92503d --- /dev/null +++ b/app/components/UI/PredictNext/docs/testing.md @@ -0,0 +1,400 @@ +# PredictNext Testing Strategy + +## Testing Philosophy + +PredictNext should test behavior at the level where users experience it. The redesign favors fewer, deeper tests over large numbers of shallow unit tests. + +Principles: + +- test real behavior, not implementation details +- test deep modules at their real seam boundaries +- rely on component view tests as the main UI confidence layer +- keep pure unit tests only where logic is truly isolated and stable + +Related docs: + +- [components](./components.md) +- [hooks](./hooks.md) +- [state management](./state-management.md) +- [error handling](./error-handling.md) + +## Testing Pyramid for Predict + +| Level | What | Count (est.) | Framework | +| ------------------------- | --------------------------- | ------------ | ------------------------------- | +| Component View Tests | Views with real Redux state | ~8-10 files | tests/component-view/ framework | +| Service Integration Tests | Services with mock adapter | ~6 files | Jest + mock adapter | +| Adapter Integration Tests | Adapter with mock HTTP | ~1-2 files | Jest + nock | +| Unit Tests | Pure utility functions | ~3-5 files | Jest | +| E2E Tests | Full user flows | ~10 files | Detox | + +```text + /\ + / \ + / E2E \ ~10 files + / Detox \ Full user flows + /──────────\ + / Component \ ~8-10 files + / View Tests \ Views + real Redux + /────────────────\ + / Service Integration\ ~6 files + / Mock adapter boundary\ State machines, retries + /────────────────────────\ + / Adapter Integration \ ~1-2 files + / Mock HTTP (nock) \ DTO transformation + /──────────────────────────────\ + / Pure Unit Tests \ ~3-5 files + / formatPrice, parseOutcome \ Isolated logic + /────────────────────────────────────\ + + More integration ────> More isolation + Fewer files ────> More files (in old arch) + Higher signal ────> Lower signal +``` + +This pyramid concentrates coverage where behavior is most meaningful while preserving fast feedback. + +## Component View Tests: Primary Surface + +Component view tests are the primary UI safety net. + +Why they are the right default: + +- they use the existing `tests/component-view/` framework +- they exercise screens with realistic state +- they avoid brittle mocking of hook internals +- they validate composition between views, hooks, Redux, and the engine boundary + +Rules: + +- mock only Engine edges and allowed native modules +- drive scenarios with presets and fixture overrides +- assert visible behavior and interaction outcomes +- avoid testing individual primitives in isolation + +Existing infrastructure to build on: + +- `initialStatePredict` preset +- `renderPredictFeedView` renderer +- `MOCK_PREDICT_MARKET` fixture + +Infrastructure to expand: + +- presets for positions, active orders, balance states, sports games, resolved events, and geo-blocked users +- renderers for `PredictHome`, `EventDetails`, `OrderScreen`, and `TransactionsView` +- fixtures for canonical event, market, outcome, and position models +- `nock` API mocks in `tests/component-view/api-mocking/predict.ts` + +Example view test: + +```typescript +import '../../../tests/component-view/mocks'; +import Engine from '../../../app/core/Engine'; +import { renderPredictHomeView } from '../../../tests/component-view/renderers/predictHome'; +import { MOCK_PREDICT_EVENT } from '../../../tests/component-view/fixtures/predict'; + +describe('PredictHome', () => { + it('shows featured carousel when events are available', async () => { + jest + .spyOn(Engine.context.PredictMarketDataService, 'getCarouselEvents') + .mockResolvedValue([MOCK_PREDICT_EVENT]); + + const { findByTestId } = renderPredictHomeView(); + + expect(await findByTestId('featured-carousel')).toBeOnTheScreen(); + }); +}); +``` + +Example renderer shape: + +```typescript +import React from 'react'; +import { renderScreen } from '../helpers/renderScreen'; +import { PredictHome } from '../../../app/components/UI/PredictNext/components/views/PredictHome'; +import { initialStatePredict } from '../presets/predict'; + +export function renderPredictHomeView(overrides = {}) { + return renderScreen(, { + state: { + ...initialStatePredict(), + ...overrides, + }, + }); +} +``` + +Recommended view test scenarios: + +- featured carousel visible when curated events exist +- event feed empty state when search returns zero matches +- order form disables primary action for insufficient balance +- geo-blocked user sees unavailable flow instead of trading UI +- resolved event shows claimable positions and hidden buy actions +- transactions screen groups pending and completed activity correctly + +## Service Integration Tests + +Service integration tests exercise service logic with a mocked adapter boundary. The service should be treated as a deep module with one public interface and internal workflow that deserves direct verification. + +What to test here: + +- state-machine transitions +- retries and fallback behavior +- cache invalidation requests +- mapping of raw adapter failures to `PredictError` + +Example: `TradingService` integration test + +```typescript +import { TradingService } from '../TradingService'; +import { PredictErrorCode } from '../../errors/PredictError'; + +describe('TradingService', () => { + it('completes preview to place flow with deposit when balance is insufficient', async () => { + const adapter = { + previewOrder: jest + .fn() + .mockResolvedValue({ total: '25.00', fee: '0.50' }), + ensureFunding: jest.fn().mockResolvedValue({ deposited: true }), + placeOrder: jest.fn().mockResolvedValue({ orderId: 'order-1' }), + }; + + const service = new TradingService({ + adapter, + logger: console as never, + analytics: { trackEvent: jest.fn() } as never, + }); + + const preview = await service.previewOrder({ + marketId: 'market-1', + outcomeId: 'yes', + amount: '25', + }); + + expect(preview.total).toBe('25.00'); + + await service.placeOrder({ + marketId: 'market-1', + outcomeId: 'yes', + amount: '25', + paymentToken: 'USDC', + }); + + expect(adapter.ensureFunding).toHaveBeenCalledTimes(1); + expect(adapter.placeOrder).toHaveBeenCalledTimes(1); + }); + + it('maps adapter rejection to PredictError for user-facing handling', async () => { + const adapter = { + previewOrder: jest.fn(), + ensureFunding: jest.fn(), + placeOrder: jest + .fn() + .mockRejectedValue(new Error('exchange rejected order')), + }; + + const service = new TradingService({ + adapter, + logger: console as never, + analytics: { trackEvent: jest.fn() } as never, + }); + + await expect( + service.placeOrder({ + marketId: 'market-1', + outcomeId: 'yes', + amount: '25', + paymentToken: 'USDC', + }), + ).rejects.toMatchObject({ code: PredictErrorCode.ORDER_REJECTED }); + }); +}); +``` + +One test file per service is a good default: + +- `MarketDataService.test.ts` +- `PortfolioService.test.ts` +- `TradingService.test.ts` +- `TransactionService.test.ts` +- `LiveDataService.test.ts` +- `GuardService.test.ts` + +## Adapter Integration Tests + +Adapter integration tests verify the boundary between third-party APIs and Predict canonical types. They use `nock` to mock HTTP responses and confirm data transformation. + +What to test: + +- response parsing +- field normalization +- missing-field tolerance +- transformation into canonical `PredictEvent`, `PredictMarket`, and `PredictOutcome` + +Example: + +```typescript +import nock from 'nock'; +import { PolymarketAdapter } from '../PolymarketAdapter'; + +describe('PolymarketAdapter', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('maps Gamma API events into PredictEvent', async () => { + nock('https://gamma-api.polymarket.com') + .get('/events') + .query(true) + .reply(200, [ + { + id: 'event-1', + title: 'Will ETH close above $4,000 on Friday?', + markets: [ + { + id: 'market-1', + outcomes: [ + { id: 'yes', name: 'Yes', price: 0.63 }, + { id: 'no', name: 'No', price: 0.37 }, + ], + }, + ], + }, + ]); + + const adapter = new PolymarketAdapter({ + baseUrl: 'https://gamma-api.polymarket.com', + }); + const [event] = await adapter.fetchEvents({ category: 'crypto' }); + + expect(event.id).toBe('event-1'); + expect(event.markets).toHaveLength(1); + expect(event.markets[0].outcomes[0]).toMatchObject({ + id: 'yes', + label: 'Yes', + price: 0.63, + }); + }); +}); +``` + +## Unit Tests: Minimal and Focused + +Unit tests remain valuable for pure functions with no environmental dependencies. + +Good candidates: + +- price formatting +- date label formatting +- validation helpers +- market parsing utilities + +Example: + +```typescript +import { formatPrice } from '../formatPrice'; + +describe('formatPrice', () => { + it('formats cents without losing intent', () => { + expect(formatPrice(63, 'cents')).toBe('63¢'); + }); + + it('formats dollars with two decimals', () => { + expect(formatPrice(12.5, 'dollars')).toBe('$12.50'); + }); +}); +``` + +Avoid unit tests for hooks and presentational components when the same behavior is already covered more effectively through view tests. + +## What Not to Test + +Do not spend test budget on these surfaces: + +- individual hooks in isolation +- individual components in isolation +- controller delegation that only forwards a call +- private service methods +- styles or internal prop plumbing + +These tests usually duplicate stronger coverage from view tests and service integration tests. + +## Migration Impact + +Current state: + +- 169 unit tests +- 87K lines of test code +- 2.38:1 test-to-source ratio + +Target state: + +- ~25-30 test files +- ~8-12K lines of test code +- ~0.3-0.5:1 test-to-source ratio + +Expected reduction: + +- roughly 85-90% less test code + +The goal is not less confidence. The goal is fewer, higher-value tests. + +## Test Infrastructure Checklist + +| Item | Type | Status | Notes | +| --------------------------------------------- | -------- | -------- | ----------------------------------------------- | +| `initialStatePredict` | Preset | Existing | Base state for Predict flows | +| `predictPositionsOpen` | Preset | New | Open positions for portfolio and detail screens | +| `predictPositionsResolved` | Preset | New | Won, lost, and claimable states | +| `predictActiveOrder` | Preset | New | Pending order session in Redux | +| `predictLowBalance` | Preset | New | Order disabled and deposit path scenarios | +| `predictSportsGame` | Preset | New | Scoreboard and live sports cards | +| `predictGeoBlocked` | Preset | New | Guard and unavailable routing | +| `renderPredictFeedView` | Renderer | Existing | Existing feed surface | +| `renderPredictHomeView` | Renderer | New | Home route integration | +| `renderEventDetailsView` | Renderer | New | Details route integration | +| `renderOrderScreenView` | Renderer | New | Order workflow integration | +| `renderTransactionsView` | Renderer | New | Activity route integration | +| `MOCK_PREDICT_EVENT` | Fixture | New | Canonical event fixture | +| `MOCK_PREDICT_MARKET` | Fixture | Existing | Binary market fixture | +| `MOCK_PREDICT_OUTCOME` | Fixture | New | Outcome fixture | +| `MOCK_PREDICT_POSITION` | Fixture | New | Position fixture | +| `tests/component-view/api-mocking/predict.ts` | API mock | New | Shared mock HTTP responses | + +## Recommended File Layout + +```text +tests/ + component-view/ + fixtures/ + predict.ts + presets/ + predict.ts + renderers/ + predictHome.tsx + eventDetails.tsx + orderScreen.tsx + transactionsView.tsx + api-mocking/ + predict.ts +app/ + components/ + UI/ + PredictNext/ + services/ + MarketDataService.test.ts + PortfolioService.test.ts + TradingService.test.ts + TransactionService.test.ts + LiveDataService.test.ts + GuardService.test.ts + adapters/ + PolymarketAdapter.test.ts + KalshiAdapter.test.ts + utils/ + formatPrice.test.ts + parseOutcome.test.ts +``` + +This strategy keeps testing aligned with the architecture: deep modules, realistic seams, and a small number of high-signal tests.