From 04b362cdffdbd61f6a85c65e8edd0f39bb1b6de7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 11:04:50 +0100 Subject: [PATCH 01/43] Add market category helper --- scripts/utils/market-categories.ts | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 scripts/utils/market-categories.ts diff --git a/scripts/utils/market-categories.ts b/scripts/utils/market-categories.ts new file mode 100644 index 0000000..fe368f1 --- /dev/null +++ b/scripts/utils/market-categories.ts @@ -0,0 +1,102 @@ +export const CONTRACT_MARKET_CATEGORIES = { + CRYPTO: 1, + SPORTS: 2, + POLITICS: 3, + ECONOMICS: 4, + OTHER: 5, +} as const; + +const TOPIC_CATEGORY_MAP: Record = { + crypto: CONTRACT_MARKET_CATEGORIES.CRYPTO, + sports: CONTRACT_MARKET_CATEGORIES.SPORTS, + politics: CONTRACT_MARKET_CATEGORIES.POLITICS, + economics: CONTRACT_MARKET_CATEGORIES.ECONOMICS, + business: CONTRACT_MARKET_CATEGORIES.ECONOMICS, + other: CONTRACT_MARKET_CATEGORIES.OTHER, + tech: CONTRACT_MARKET_CATEGORIES.OTHER, + entertainment: CONTRACT_MARKET_CATEGORIES.OTHER, + science: CONTRACT_MARKET_CATEGORIES.OTHER, + defi: CONTRACT_MARKET_CATEGORIES.CRYPTO, +}; + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +export function categoryFromTopic(topic: string): number { + return TOPIC_CATEGORY_MAP[normalize(topic)] ?? CONTRACT_MARKET_CATEGORIES.OTHER; +} + +export function categoryFromQuestion(question: string): number { + const q = normalize(question); + + if ( + q.includes('bitcoin') || + q.includes('btc') || + q.includes('ethereum') || + q.includes('eth') || + q.includes('crypto') || + q.includes('stx') || + q.includes('stacks') || + q.includes('token') || + q.includes('coin') + ) { + return CONTRACT_MARKET_CATEGORIES.CRYPTO; + } + + if ( + q.includes('super bowl') || + q.includes('world cup') || + q.includes('nba') || + q.includes('nfl') || + q.includes('football') || + q.includes('basketball') || + q.includes('soccer') || + q.includes('championship') || + q.includes('match') || + q.includes('finals') || + q.includes('playoff') || + q.includes('sports') || + q.includes('tennis') || + q.includes('golf') || + q.includes('olympic') + ) { + return CONTRACT_MARKET_CATEGORIES.SPORTS; + } + + if ( + q.includes('election') || + q.includes('president') || + q.includes('vote') || + q.includes('congress') || + q.includes('government') || + q.includes('policy') || + q.includes('senate') || + q.includes('republican') || + q.includes('democrat') + ) { + return CONTRACT_MARKET_CATEGORIES.POLITICS; + } + + if ( + q.includes('stock') || + q.includes('company') || + q.includes('ipo') || + q.includes('merger') || + q.includes('earnings') || + q.includes('revenue') || + q.includes('market cap') || + q.includes('ceo') || + q.includes('acquisition') || + q.includes('inflation') || + q.includes('unemployment') || + q.includes('gdp') || + q.includes('oil') || + q.includes('gold') || + q.includes('bank') + ) { + return CONTRACT_MARKET_CATEGORIES.ECONOMICS; + } + + return CONTRACT_MARKET_CATEGORIES.OTHER; +} From 598637e857f03d32b9ab4d8a160cb4634d20884f Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 11:05:06 +0100 Subject: [PATCH 02/43] Pass categories to market scripts --- scripts/bulk-create-markets.ts | 8 ++++++-- scripts/interact-contract.ts | 6 ++++-- scripts/market-lifecycle.ts | 13 +++++++++++-- scripts/stress-test.ts | 21 ++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/scripts/bulk-create-markets.ts b/scripts/bulk-create-markets.ts index 02c159f..8010091 100644 --- a/scripts/bulk-create-markets.ts +++ b/scripts/bulk-create-markets.ts @@ -22,6 +22,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromTopic } from './utils/market-categories.js'; import { getRandomQuestions, getCategories } from './config/market-questions.js'; // Load environment variables @@ -41,7 +42,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise { const txOptions = { contractAddress: CONTRACT_ADDRESS, @@ -51,6 +53,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -193,7 +196,8 @@ async function main() { privateKey, question.question, endBlock, - resolutionBlock + resolutionBlock, + categoryFromTopic(question.category) ); tracker.add('Market Creation', txid, { diff --git a/scripts/interact-contract.ts b/scripts/interact-contract.ts index e60a656..1c7bcb3 100644 --- a/scripts/interact-contract.ts +++ b/scripts/interact-contract.ts @@ -12,6 +12,7 @@ import { STACKS_MAINNET } from '@stacks/network'; import { generateWallet } from '@stacks/wallet-sdk'; import prompts from 'prompts'; import * as dotenv from 'dotenv'; +import { categoryFromQuestion } from './utils/market-categories.js'; import fs from 'fs'; import toml from 'toml'; import path from 'path'; @@ -106,7 +107,7 @@ async function getPrivateKey(): Promise { /** * Create a new prediction market */ -async function createMarket(privateKey: string): Promise { +async function createMarket(privateKey: string, category: number): Promise { console.log('\n📊 Creating market...'); console.log(`Question: "${MARKET_QUESTION}"`); console.log(`End block: ${END_BLOCK_HEIGHT}`); @@ -120,6 +121,7 @@ async function createMarket(privateKey: string): Promise { stringAsciiCV(MARKET_QUESTION), uintCV(END_BLOCK_HEIGHT), uintCV(RESOLUTION_BLOCK_HEIGHT), + uintCV(category), ], senderKey: privateKey, network, @@ -267,7 +269,7 @@ async function main() { } // Step 1: Create market - const createMarketTxId = await createMarket(privateKey); + const createMarketTxId = await createMarket(privateKey, categoryFromQuestion(MARKET_QUESTION)); // Wait for user to confirm market creation before proceeding console.log('\n⏳ Please wait for the market creation transaction to confirm...'); diff --git a/scripts/market-lifecycle.ts b/scripts/market-lifecycle.ts index 88ca25a..a0b8024 100644 --- a/scripts/market-lifecycle.ts +++ b/scripts/market-lifecycle.ts @@ -23,6 +23,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromQuestion } from './utils/market-categories.js'; import { getRandomQuestions } from './config/market-questions.js'; // Load environment variables @@ -42,7 +43,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise { const txOptions = { contractAddress: CONTRACT_ADDRESS, @@ -52,6 +54,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -206,7 +209,13 @@ async function main() { console.log(` End block: ${formatBlockHeight(endBlock)}`); console.log(` Resolution block: ${formatBlockHeight(resolutionBlock)}\n`); - const createTxid = await createMarket(privateKey, marketQuestion.question, endBlock, resolutionBlock); + const createTxid = await createMarket( + privateKey, + marketQuestion.question, + endBlock, + resolutionBlock, + categoryFromQuestion(marketQuestion.question) + ); tracker.add('Market Creation', createTxid, { question: marketQuestion.question }); logTransaction('Market creation', createTxid, 'mainnet'); diff --git a/scripts/stress-test.ts b/scripts/stress-test.ts index 1eb8a1d..d398903 100644 --- a/scripts/stress-test.ts +++ b/scripts/stress-test.ts @@ -23,6 +23,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromQuestion } from './utils/market-categories.js'; import { getRandomQuestions } from './config/market-questions.js'; // Load environment variables @@ -112,7 +113,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise<{ txid: string; responseTime: number }> { const startTime = Date.now(); @@ -124,6 +126,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -279,7 +282,13 @@ async function main() { if (testType === 'markets') { // Market creation stress test const question = getRandomQuestions(1)[0]; - result = await createMarket(privateKey, question.question, endBlock, resolutionBlock); + result = await createMarket( + privateKey, + question.question, + endBlock, + resolutionBlock, + categoryFromQuestion(question.question) + ); txType = 'Market Creation'; tracker.add(txType, result.txid, { question: question.question }); @@ -315,7 +324,13 @@ async function main() { } else { // 30% market creation const question = getRandomQuestions(1)[0]; - result = await createMarket(privateKey, question.question, endBlock, resolutionBlock); + result = await createMarket( + privateKey, + question.question, + endBlock, + resolutionBlock, + categoryFromQuestion(question.question) + ); txType = 'Market Creation'; tracker.add(txType, result.txid, { question: question.question }); } From c5c16bb8a03e66b09dfa2efd94468325d4d5d87f Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 11:20:50 +0100 Subject: [PATCH 03/43] Use deployer as market owner --- contracts/market-core.clar | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/market-core.clar b/contracts/market-core.clar index 4454ea8..d513355 100644 --- a/contracts/market-core.clar +++ b/contracts/market-core.clar @@ -59,8 +59,8 @@ ;; Emergency pause switch (owner-controlled) (define-data-var contract-paused bool false) -;; Contract owner (deployer principal) -(define-constant CONTRACT-OWNER 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM) +;; Contract owner (set to the deploying principal) +(define-constant CONTRACT-OWNER tx-sender) ;; ============================================ ;; Data Variables From 7e140297d6b09a3efc08164ee4302d5d793f3c1d Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 20:14:48 +0100 Subject: [PATCH 04/43] Use active network in pages --- frontend/src/pages/PortfolioPage.tsx | 22 +++++++++++----------- frontend/src/pages/TradePage.tsx | 15 ++++++++------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx index 6ba5865..9bddf1a 100644 --- a/frontend/src/pages/PortfolioPage.tsx +++ b/frontend/src/pages/PortfolioPage.tsx @@ -1,14 +1,13 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { cvToJSON, fetchCallReadOnlyFunction, uintCV, principalCV } from '@stacks/transactions'; -import { STACKS_MAINNET } from '@stacks/network'; import { useWallet } from '../components/WalletProvider'; +import { useNetwork } from '../contexts/NetworkContext'; import { useMarkets } from '../hooks/useMarkets'; import { useContract } from '../hooks/useContract'; import type { Market, Position } from '../types/market'; import { MarketStatus, MarketOutcome } from '../types/market'; import { parsePosition, formatStx, calculateOdds } from '../utils/helpers'; -import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../constants'; import { validateMarketId } from '../utils/validation'; interface PositionWithMarket extends Position { @@ -17,6 +16,7 @@ interface PositionWithMarket extends Position { export function PortfolioPage() { const { isConnected, connect, address } = useWallet(); + const { stacksNetwork, contractAddress, contractName } = useNetwork(); const { markets, isLoading: marketsLoading } = useMarkets(); const { claimWinnings } = useContract(); const [positions, setPositions] = useState([]); @@ -38,12 +38,12 @@ export function PortfolioPage() { for (const market of markets) { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-user-position', functionArgs: [uintCV(market.id), principalCV(address)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); @@ -63,7 +63,7 @@ export function PortfolioPage() { } fetchPositions(); - }, [address, markets]); + }, [address, markets, stacksNetwork, contractAddress, contractName]); const refetchPositions = async () => { if (!address || markets.length === 0) return; @@ -72,12 +72,12 @@ export function PortfolioPage() { for (const market of markets) { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-user-position', functionArgs: [uintCV(market.id), principalCV(address)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); diff --git a/frontend/src/pages/TradePage.tsx b/frontend/src/pages/TradePage.tsx index 92b6b9e..9806bae 100644 --- a/frontend/src/pages/TradePage.tsx +++ b/frontend/src/pages/TradePage.tsx @@ -1,12 +1,12 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { cvToJSON, fetchCallReadOnlyFunction, uintCV } from '@stacks/transactions'; -import { STACKS_MAINNET } from '@stacks/network'; import type { Market } from '../types/market'; import { MarketStatus, MarketOutcome } from '../types/market'; import { parseMarketData, calculateOdds, formatStx, getStatusLabel, formatAddress } from '../utils/helpers'; -import { CONTRACT_ADDRESS, CONTRACT_NAME, MIN_STAKE, MAX_STAKE } from '../constants'; +import { MIN_STAKE, MAX_STAKE } from '../constants'; import { useWallet } from '../components/WalletProvider'; +import { useNetwork } from '../contexts/NetworkContext'; import { useStake } from '../hooks/useStake'; import { useRealtimeSignal } from '../hooks/useRealtimeSignal'; import { validateAmount, validateMarketId } from '../utils/validation'; @@ -34,6 +34,7 @@ export function TradePage() { }, [marketId]); const { isConnected, connect, address } = useWallet(); + const { stacksNetwork, contractAddress, contractName } = useNetwork(); const userAddress = isConnected ? address : null; const { placeYesStake, placeNoStake, isLoading: isStaking, error: stakeError, txId, isContractPaused } = useStake(); const { signal, source, isSocketConnected } = useRealtimeSignal({ enabled: true }); @@ -64,12 +65,12 @@ export function TradePage() { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-market', functionArgs: [uintCV(marketId)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); @@ -83,7 +84,7 @@ export function TradePage() { } finally { setIsLoading(false); } - }, [marketId]); + }, [marketId, stacksNetwork, contractAddress, contractName]); // Initial market data fetch on mount useEffect(() => { From 51b970bd6c485618792a96e59555996097a19a07 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 20:28:24 +0100 Subject: [PATCH 05/43] Add frontend regression tests --- .../components/__tests__/ShareModal.test.ts | 36 ++++++++++ .../__tests__/SocialButtons.test.ts | 22 ++++++ frontend/src/hooks/useMarketFiltering.test.ts | 72 +++++++++++++++++++ .../pages/__tests__/LeaderboardPage.test.ts | 37 ++++++++++ 4 files changed, 167 insertions(+) create mode 100644 frontend/src/components/__tests__/ShareModal.test.ts create mode 100644 frontend/src/components/__tests__/SocialButtons.test.ts create mode 100644 frontend/src/hooks/useMarketFiltering.test.ts create mode 100644 frontend/src/pages/__tests__/LeaderboardPage.test.ts diff --git a/frontend/src/components/__tests__/ShareModal.test.ts b/frontend/src/components/__tests__/ShareModal.test.ts new file mode 100644 index 0000000..06ff305 --- /dev/null +++ b/frontend/src/components/__tests__/ShareModal.test.ts @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShareModal } from '../ShareModal'; + +describe('ShareModal', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + it('renders share details and copies the market link', () => { + render( + React.createElement(ShareModal, { + isOpen: true, + onClose: vi.fn(), + marketId: 42, + marketQuestion: 'Will STX reach $5?', + yesPercentage: 61, + noPercentage: 39, + poolSize: 12500000, + }) + ); + + expect(screen.getByText('Share Market')).toBeInTheDocument(); + const shareUrl = `${window.location.origin}/trade/42`; + expect(screen.getByDisplayValue(shareUrl)).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[0]); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(shareUrl); + }); +}); diff --git a/frontend/src/components/__tests__/SocialButtons.test.ts b/frontend/src/components/__tests__/SocialButtons.test.ts new file mode 100644 index 0000000..11f845b --- /dev/null +++ b/frontend/src/components/__tests__/SocialButtons.test.ts @@ -0,0 +1,22 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SocialButtons } from '../SocialButtons'; + +describe('SocialButtons', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + it('opens the share modal from the primary action', () => { + render(React.createElement(SocialButtons, { marketId: 7, variant: 'button' })); + + fireEvent.click(screen.getByRole('button', { name: /share market/i })); + expect(screen.getByRole('heading', { name: 'Share Market' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useMarketFiltering.test.ts b/frontend/src/hooks/useMarketFiltering.test.ts new file mode 100644 index 0000000..add584f --- /dev/null +++ b/frontend/src/hooks/useMarketFiltering.test.ts @@ -0,0 +1,72 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderHook, act } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useMarketFiltering } from './useMarketFiltering'; +import { MarketCategory, SortOption } from '../utils/marketCategories'; +import type { Market } from '../types/market'; +import { MarketStatus, MarketOutcome } from '../types/market'; + +const markets: Market[] = [ + { + id: 1, + question: 'Will Bitcoin hit $100k?', + creator: 'SP1', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 500, + totalNoStake: 300, + status: MarketStatus.ACTIVE, + outcome: MarketOutcome.NONE, + createdAt: 3, + }, + { + id: 2, + question: 'Will the Super Bowl draw record viewers?', + creator: 'SP2', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 100, + totalNoStake: 200, + status: MarketStatus.RESOLVED, + outcome: MarketOutcome.YES, + createdAt: 1, + }, + { + id: 3, + question: 'Will a new movie win best picture?', + creator: 'SP3', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 900, + totalNoStake: 100, + status: MarketStatus.ACTIVE, + outcome: MarketOutcome.NONE, + createdAt: 2, + }, +]; + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(MemoryRouter, {}, children); + +describe('useMarketFiltering', () => { + it('filters and sorts markets', () => { + const { result } = renderHook( + () => useMarketFiltering({ markets }), + { wrapper } + ); + + expect(result.current.counts.all).toBe(3); + expect(result.current.filteredMarkets[0].id).toBe(1); + + act(() => { + result.current.setStatusFilter('active'); + result.current.setCategory(MarketCategory.CRYPTO); + result.current.setSortOption(SortOption.OLDEST); + result.current.setSearchQuery('bitcoin'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].id).toBe(1); + }); +}); diff --git a/frontend/src/pages/__tests__/LeaderboardPage.test.ts b/frontend/src/pages/__tests__/LeaderboardPage.test.ts new file mode 100644 index 0000000..d27fcb0 --- /dev/null +++ b/frontend/src/pages/__tests__/LeaderboardPage.test.ts @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LeaderboardPage } from '../LeaderboardPage'; + +vi.mock('../../hooks/useLeaderboard', () => ({ + useLeaderboard: () => ({ + byWinRate: [ + { + rank: 1, + address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + displayName: 'TradeKing', + winRate: 78.5, + totalVolume: BigInt(125000), + wins: 157, + losses: 43, + lastUpdated: new Date('2026-04-15T00:00:00Z'), + }, + ], + byVolume: [], + weeklyRanking: [], + monthlyRanking: [], + isLoading: false, + error: null, + lastRefresh: new Date('2026-04-15T00:00:00Z'), + }), +})); + +describe('LeaderboardPage', () => { + it('renders leaderboard entries', () => { + render(React.createElement(LeaderboardPage)); + + expect(screen.getByText('Leaderboard')).toBeInTheDocument(); + expect(screen.getByText('TradeKing')).toBeInTheDocument(); + expect(screen.getByText('78.5%')).toBeInTheDocument(); + }); +}); From 58d6709e3288d32caac00901bf94708c23d0d9e2 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 23:43:01 +0100 Subject: [PATCH 06/43] Add trade page network test --- .../src/pages/__tests__/TradePage.test.tsx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 frontend/src/pages/__tests__/TradePage.test.tsx diff --git a/frontend/src/pages/__tests__/TradePage.test.tsx b/frontend/src/pages/__tests__/TradePage.test.tsx new file mode 100644 index 0000000..b46b587 --- /dev/null +++ b/frontend/src/pages/__tests__/TradePage.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { TradePage } from '../TradePage'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parseMarketDataMock = vi.fn(); + +const networkContext = { + stacksNetwork: { network: 'testnet' }, + contractAddress: 'STTESTCONTRACT', + contractName: 'market-core', +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkContext, +})); + +vi.mock('../../components/WalletProvider', () => ({ + useWallet: () => ({ + isConnected: false, + connect: vi.fn(), + address: null, + }), +})); + +vi.mock('../../hooks/useStake', () => ({ + useStake: () => ({ + placeYesStake: vi.fn(), + placeNoStake: vi.fn(), + isLoading: false, + error: null, + txId: null, + isContractPaused: false, + }), +})); + +vi.mock('../../hooks/useRealtimeSignal', () => ({ + useRealtimeSignal: () => ({ + signal: 0, + source: null, + isSocketConnected: false, + }), +})); + +vi.mock('../../utils/helpers', async () => { + const actual = await vi.importActual('../../utils/helpers'); + return { + ...actual, + parseMarketData: (...args: unknown[]) => parseMarketDataMock(...args), + }; +}); + +vi.mock('../../components/SocialButtons', () => ({ + SocialButtons: () => React.createElement('div', { 'data-testid': 'social-buttons' }), +})); + +describe('TradePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the active network for market reads', async () => { + parseMarketDataMock.mockReturnValue({ + id: 1, + question: 'Will STX reach $5?', + creator: 'SPTEST', + endDate: 100, + resolutionDate: 200, + totalYesStake: 100, + totalNoStake: 50, + status: 0, + outcome: 0, + createdAt: 10, + }); + cvToJSONMock.mockReturnValue({ + type: 'some', + value: { market: 'ok' }, + }); + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + + render( + React.createElement( + MemoryRouter, + { initialEntries: ['/trade/1'] }, + React.createElement( + Routes, + null, + React.createElement(Route, { path: '/trade/:id', element: React.createElement(TradePage) }) + ) + ) + ); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalled(); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkContext.stacksNetwork, + contractAddress: networkContext.contractAddress, + contractName: networkContext.contractName, + functionName: 'get-market', + }); + }); +}); From afad53a3a8f23dbb49a9bc09877c95a8b7bf0c18 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Wed, 15 Apr 2026 23:47:57 +0100 Subject: [PATCH 07/43] Add portfolio page network test --- .../pages/__tests__/PortfolioPage.test.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 frontend/src/pages/__tests__/PortfolioPage.test.tsx diff --git a/frontend/src/pages/__tests__/PortfolioPage.test.tsx b/frontend/src/pages/__tests__/PortfolioPage.test.tsx new file mode 100644 index 0000000..1c6f165 --- /dev/null +++ b/frontend/src/pages/__tests__/PortfolioPage.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { PortfolioPage } from '../PortfolioPage'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parsePositionMock = vi.fn(); + +const networkContext = { + stacksNetwork: { network: 'testnet' }, + contractAddress: 'STTESTCONTRACT', + contractName: 'market-core', +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), + principalCV: (value: string) => ({ type: 'principal', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkContext, +})); + +vi.mock('../../components/WalletProvider', () => ({ + useWallet: () => ({ + isConnected: true, + connect: vi.fn(), + address: 'SPTESTUSER', + }), +})); + +vi.mock('../../hooks/useMarkets', () => ({ + useMarkets: () => ({ + markets: [ + { + id: 1, + question: 'Will STX reach $5?', + creator: 'SPCREATOR', + endDate: 100, + resolutionDate: 200, + totalYesStake: 100, + totalNoStake: 50, + status: 0, + outcome: 0, + createdAt: 10, + }, + ], + isLoading: false, + }), +})); + +vi.mock('../../hooks/useContract', () => ({ + useContract: () => ({ + claimWinnings: vi.fn(), + }), +})); + +vi.mock('../../utils/helpers', async () => { + const actual = await vi.importActual('../../utils/helpers'); + return { + ...actual, + parsePosition: (...args: unknown[]) => parsePositionMock(...args), + }; +}); + +describe('PortfolioPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the active network for position reads', async () => { + parsePositionMock.mockReturnValue({ + marketId: 1, + user: 'SPTESTUSER', + yesStake: 100, + noStake: 0, + claimed: false, + }); + cvToJSONMock.mockReturnValue({ + type: 'some', + value: { position: 'ok' }, + }); + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + + render(React.createElement(MemoryRouter, null, React.createElement(PortfolioPage))); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalled(); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkContext.stacksNetwork, + contractAddress: networkContext.contractAddress, + contractName: networkContext.contractName, + functionName: 'get-user-position', + }); + }); +}); From d4ad911664e2483a452714e7a5b70b320309e1ea Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 16 Apr 2026 00:02:41 +0100 Subject: [PATCH 08/43] Add market loading network test --- .../src/hooks/__tests__/useMarkets.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useMarkets.test.ts diff --git a/frontend/src/hooks/__tests__/useMarkets.test.ts b/frontend/src/hooks/__tests__/useMarkets.test.ts new file mode 100644 index 0000000..095cfbf --- /dev/null +++ b/frontend/src/hooks/__tests__/useMarkets.test.ts @@ -0,0 +1,90 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useMarkets } from '../useMarkets'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parseMarketDataMock = vi.fn(); + +const networkState = { + network: 'mainnet', + stacksNetwork: { network: 'mainnet', id: 'mainnet-net' }, +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkState, +})); + +vi.mock('../../config/contracts', () => ({ + MARKET_CONTRACT: { + address: 'STTESTADDRESS', + name: 'market-core', + }, +})); + +vi.mock('../../utils/helpers', () => ({ + parseMarketData: (...args: unknown[]) => parseMarketDataMock(...args), +})); + +describe('useMarkets', () => { + beforeEach(() => { + vi.clearAllMocks(); + networkState.network = 'mainnet'; + networkState.stacksNetwork = { network: 'mainnet', id: 'mainnet-net' }; + }); + + it('uses the active network when fetching markets and refetches on network change', async () => { + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + cvToJSONMock + .mockReturnValueOnce({ value: 1 }) + .mockReturnValueOnce({ type: 'some', value: { market: 'initial' } }) + .mockReturnValueOnce({ value: 1 }) + .mockReturnValueOnce({ type: 'some', value: { market: 'updated' } }); + + parseMarketDataMock.mockImplementation((id: number, value: { market: string }) => ({ + id, + question: value.market, + creator: 'SPTEST', + endDate: 100, + resolutionDate: 200, + totalYesStake: 0, + totalNoStake: 0, + status: 0, + outcome: 0, + createdAt: 1, + })); + + const { result, rerender } = renderHook(() => useMarkets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkState.stacksNetwork, + contractAddress: 'STTESTADDRESS', + contractName: 'market-core', + functionName: 'get-market-counter', + }); + + networkState.network = 'testnet'; + networkState.stacksNetwork = { network: 'testnet', id: 'testnet-net' }; + rerender(); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalledTimes(4); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[2][0]).toMatchObject({ + network: networkState.stacksNetwork, + functionName: 'get-market-counter', + }); + expect(result.current.currentNetwork).toBe('testnet'); + }); +}); From 2e5856a330904e093ab975b7e1b13f67199cf012 Mon Sep 17 00:00:00 2001 From: Adebayo Muhammed Olosasa <95596908+Mosas2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:27:54 +0100 Subject: [PATCH 09/43] network readonly fix (#65) * Add market category helper * Pass categories to market scripts * Use deployer as market owner * Use active network in pages * Add frontend regression tests * Add trade page network test * Add portfolio page network test * Add market loading network test --- contracts/market-core.clar | 4 +- .../components/__tests__/ShareModal.test.ts | 36 ++++++ .../__tests__/SocialButtons.test.ts | 22 ++++ .../src/hooks/__tests__/useMarkets.test.ts | 90 ++++++++++++++ frontend/src/hooks/useMarketFiltering.test.ts | 72 +++++++++++ frontend/src/pages/PortfolioPage.tsx | 22 ++-- frontend/src/pages/TradePage.tsx | 15 +-- .../pages/__tests__/LeaderboardPage.test.ts | 37 ++++++ .../pages/__tests__/PortfolioPage.test.tsx | 102 ++++++++++++++++ .../src/pages/__tests__/TradePage.test.tsx | 113 ++++++++++++++++++ scripts/bulk-create-markets.ts | 8 +- scripts/interact-contract.ts | 6 +- scripts/market-lifecycle.ts | 13 +- scripts/stress-test.ts | 21 +++- scripts/utils/market-categories.ts | 102 ++++++++++++++++ 15 files changed, 634 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/__tests__/ShareModal.test.ts create mode 100644 frontend/src/components/__tests__/SocialButtons.test.ts create mode 100644 frontend/src/hooks/__tests__/useMarkets.test.ts create mode 100644 frontend/src/hooks/useMarketFiltering.test.ts create mode 100644 frontend/src/pages/__tests__/LeaderboardPage.test.ts create mode 100644 frontend/src/pages/__tests__/PortfolioPage.test.tsx create mode 100644 frontend/src/pages/__tests__/TradePage.test.tsx create mode 100644 scripts/utils/market-categories.ts diff --git a/contracts/market-core.clar b/contracts/market-core.clar index 4454ea8..d513355 100644 --- a/contracts/market-core.clar +++ b/contracts/market-core.clar @@ -59,8 +59,8 @@ ;; Emergency pause switch (owner-controlled) (define-data-var contract-paused bool false) -;; Contract owner (deployer principal) -(define-constant CONTRACT-OWNER 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM) +;; Contract owner (set to the deploying principal) +(define-constant CONTRACT-OWNER tx-sender) ;; ============================================ ;; Data Variables diff --git a/frontend/src/components/__tests__/ShareModal.test.ts b/frontend/src/components/__tests__/ShareModal.test.ts new file mode 100644 index 0000000..06ff305 --- /dev/null +++ b/frontend/src/components/__tests__/ShareModal.test.ts @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShareModal } from '../ShareModal'; + +describe('ShareModal', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + it('renders share details and copies the market link', () => { + render( + React.createElement(ShareModal, { + isOpen: true, + onClose: vi.fn(), + marketId: 42, + marketQuestion: 'Will STX reach $5?', + yesPercentage: 61, + noPercentage: 39, + poolSize: 12500000, + }) + ); + + expect(screen.getByText('Share Market')).toBeInTheDocument(); + const shareUrl = `${window.location.origin}/trade/42`; + expect(screen.getByDisplayValue(shareUrl)).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[0]); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(shareUrl); + }); +}); diff --git a/frontend/src/components/__tests__/SocialButtons.test.ts b/frontend/src/components/__tests__/SocialButtons.test.ts new file mode 100644 index 0000000..11f845b --- /dev/null +++ b/frontend/src/components/__tests__/SocialButtons.test.ts @@ -0,0 +1,22 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SocialButtons } from '../SocialButtons'; + +describe('SocialButtons', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + it('opens the share modal from the primary action', () => { + render(React.createElement(SocialButtons, { marketId: 7, variant: 'button' })); + + fireEvent.click(screen.getByRole('button', { name: /share market/i })); + expect(screen.getByRole('heading', { name: 'Share Market' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useMarkets.test.ts b/frontend/src/hooks/__tests__/useMarkets.test.ts new file mode 100644 index 0000000..095cfbf --- /dev/null +++ b/frontend/src/hooks/__tests__/useMarkets.test.ts @@ -0,0 +1,90 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useMarkets } from '../useMarkets'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parseMarketDataMock = vi.fn(); + +const networkState = { + network: 'mainnet', + stacksNetwork: { network: 'mainnet', id: 'mainnet-net' }, +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkState, +})); + +vi.mock('../../config/contracts', () => ({ + MARKET_CONTRACT: { + address: 'STTESTADDRESS', + name: 'market-core', + }, +})); + +vi.mock('../../utils/helpers', () => ({ + parseMarketData: (...args: unknown[]) => parseMarketDataMock(...args), +})); + +describe('useMarkets', () => { + beforeEach(() => { + vi.clearAllMocks(); + networkState.network = 'mainnet'; + networkState.stacksNetwork = { network: 'mainnet', id: 'mainnet-net' }; + }); + + it('uses the active network when fetching markets and refetches on network change', async () => { + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + cvToJSONMock + .mockReturnValueOnce({ value: 1 }) + .mockReturnValueOnce({ type: 'some', value: { market: 'initial' } }) + .mockReturnValueOnce({ value: 1 }) + .mockReturnValueOnce({ type: 'some', value: { market: 'updated' } }); + + parseMarketDataMock.mockImplementation((id: number, value: { market: string }) => ({ + id, + question: value.market, + creator: 'SPTEST', + endDate: 100, + resolutionDate: 200, + totalYesStake: 0, + totalNoStake: 0, + status: 0, + outcome: 0, + createdAt: 1, + })); + + const { result, rerender } = renderHook(() => useMarkets()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkState.stacksNetwork, + contractAddress: 'STTESTADDRESS', + contractName: 'market-core', + functionName: 'get-market-counter', + }); + + networkState.network = 'testnet'; + networkState.stacksNetwork = { network: 'testnet', id: 'testnet-net' }; + rerender(); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalledTimes(4); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[2][0]).toMatchObject({ + network: networkState.stacksNetwork, + functionName: 'get-market-counter', + }); + expect(result.current.currentNetwork).toBe('testnet'); + }); +}); diff --git a/frontend/src/hooks/useMarketFiltering.test.ts b/frontend/src/hooks/useMarketFiltering.test.ts new file mode 100644 index 0000000..add584f --- /dev/null +++ b/frontend/src/hooks/useMarketFiltering.test.ts @@ -0,0 +1,72 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderHook, act } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useMarketFiltering } from './useMarketFiltering'; +import { MarketCategory, SortOption } from '../utils/marketCategories'; +import type { Market } from '../types/market'; +import { MarketStatus, MarketOutcome } from '../types/market'; + +const markets: Market[] = [ + { + id: 1, + question: 'Will Bitcoin hit $100k?', + creator: 'SP1', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 500, + totalNoStake: 300, + status: MarketStatus.ACTIVE, + outcome: MarketOutcome.NONE, + createdAt: 3, + }, + { + id: 2, + question: 'Will the Super Bowl draw record viewers?', + creator: 'SP2', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 100, + totalNoStake: 200, + status: MarketStatus.RESOLVED, + outcome: MarketOutcome.YES, + createdAt: 1, + }, + { + id: 3, + question: 'Will a new movie win best picture?', + creator: 'SP3', + endDate: 1000, + resolutionDate: 1100, + totalYesStake: 900, + totalNoStake: 100, + status: MarketStatus.ACTIVE, + outcome: MarketOutcome.NONE, + createdAt: 2, + }, +]; + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(MemoryRouter, {}, children); + +describe('useMarketFiltering', () => { + it('filters and sorts markets', () => { + const { result } = renderHook( + () => useMarketFiltering({ markets }), + { wrapper } + ); + + expect(result.current.counts.all).toBe(3); + expect(result.current.filteredMarkets[0].id).toBe(1); + + act(() => { + result.current.setStatusFilter('active'); + result.current.setCategory(MarketCategory.CRYPTO); + result.current.setSortOption(SortOption.OLDEST); + result.current.setSearchQuery('bitcoin'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].id).toBe(1); + }); +}); diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx index 6ba5865..9bddf1a 100644 --- a/frontend/src/pages/PortfolioPage.tsx +++ b/frontend/src/pages/PortfolioPage.tsx @@ -1,14 +1,13 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { cvToJSON, fetchCallReadOnlyFunction, uintCV, principalCV } from '@stacks/transactions'; -import { STACKS_MAINNET } from '@stacks/network'; import { useWallet } from '../components/WalletProvider'; +import { useNetwork } from '../contexts/NetworkContext'; import { useMarkets } from '../hooks/useMarkets'; import { useContract } from '../hooks/useContract'; import type { Market, Position } from '../types/market'; import { MarketStatus, MarketOutcome } from '../types/market'; import { parsePosition, formatStx, calculateOdds } from '../utils/helpers'; -import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../constants'; import { validateMarketId } from '../utils/validation'; interface PositionWithMarket extends Position { @@ -17,6 +16,7 @@ interface PositionWithMarket extends Position { export function PortfolioPage() { const { isConnected, connect, address } = useWallet(); + const { stacksNetwork, contractAddress, contractName } = useNetwork(); const { markets, isLoading: marketsLoading } = useMarkets(); const { claimWinnings } = useContract(); const [positions, setPositions] = useState([]); @@ -38,12 +38,12 @@ export function PortfolioPage() { for (const market of markets) { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-user-position', functionArgs: [uintCV(market.id), principalCV(address)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); @@ -63,7 +63,7 @@ export function PortfolioPage() { } fetchPositions(); - }, [address, markets]); + }, [address, markets, stacksNetwork, contractAddress, contractName]); const refetchPositions = async () => { if (!address || markets.length === 0) return; @@ -72,12 +72,12 @@ export function PortfolioPage() { for (const market of markets) { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-user-position', functionArgs: [uintCV(market.id), principalCV(address)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); diff --git a/frontend/src/pages/TradePage.tsx b/frontend/src/pages/TradePage.tsx index 92b6b9e..9806bae 100644 --- a/frontend/src/pages/TradePage.tsx +++ b/frontend/src/pages/TradePage.tsx @@ -1,12 +1,12 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { cvToJSON, fetchCallReadOnlyFunction, uintCV } from '@stacks/transactions'; -import { STACKS_MAINNET } from '@stacks/network'; import type { Market } from '../types/market'; import { MarketStatus, MarketOutcome } from '../types/market'; import { parseMarketData, calculateOdds, formatStx, getStatusLabel, formatAddress } from '../utils/helpers'; -import { CONTRACT_ADDRESS, CONTRACT_NAME, MIN_STAKE, MAX_STAKE } from '../constants'; +import { MIN_STAKE, MAX_STAKE } from '../constants'; import { useWallet } from '../components/WalletProvider'; +import { useNetwork } from '../contexts/NetworkContext'; import { useStake } from '../hooks/useStake'; import { useRealtimeSignal } from '../hooks/useRealtimeSignal'; import { validateAmount, validateMarketId } from '../utils/validation'; @@ -34,6 +34,7 @@ export function TradePage() { }, [marketId]); const { isConnected, connect, address } = useWallet(); + const { stacksNetwork, contractAddress, contractName } = useNetwork(); const userAddress = isConnected ? address : null; const { placeYesStake, placeNoStake, isLoading: isStaking, error: stakeError, txId, isContractPaused } = useStake(); const { signal, source, isSocketConnected } = useRealtimeSignal({ enabled: true }); @@ -64,12 +65,12 @@ export function TradePage() { try { const result = await fetchCallReadOnlyFunction({ - network: STACKS_MAINNET, - contractAddress: CONTRACT_ADDRESS, - contractName: CONTRACT_NAME, + network: stacksNetwork, + contractAddress, + contractName, functionName: 'get-market', functionArgs: [uintCV(marketId)], - senderAddress: CONTRACT_ADDRESS, + senderAddress: contractAddress, }); const jsonResult = cvToJSON(result); @@ -83,7 +84,7 @@ export function TradePage() { } finally { setIsLoading(false); } - }, [marketId]); + }, [marketId, stacksNetwork, contractAddress, contractName]); // Initial market data fetch on mount useEffect(() => { diff --git a/frontend/src/pages/__tests__/LeaderboardPage.test.ts b/frontend/src/pages/__tests__/LeaderboardPage.test.ts new file mode 100644 index 0000000..d27fcb0 --- /dev/null +++ b/frontend/src/pages/__tests__/LeaderboardPage.test.ts @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LeaderboardPage } from '../LeaderboardPage'; + +vi.mock('../../hooks/useLeaderboard', () => ({ + useLeaderboard: () => ({ + byWinRate: [ + { + rank: 1, + address: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + displayName: 'TradeKing', + winRate: 78.5, + totalVolume: BigInt(125000), + wins: 157, + losses: 43, + lastUpdated: new Date('2026-04-15T00:00:00Z'), + }, + ], + byVolume: [], + weeklyRanking: [], + monthlyRanking: [], + isLoading: false, + error: null, + lastRefresh: new Date('2026-04-15T00:00:00Z'), + }), +})); + +describe('LeaderboardPage', () => { + it('renders leaderboard entries', () => { + render(React.createElement(LeaderboardPage)); + + expect(screen.getByText('Leaderboard')).toBeInTheDocument(); + expect(screen.getByText('TradeKing')).toBeInTheDocument(); + expect(screen.getByText('78.5%')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/__tests__/PortfolioPage.test.tsx b/frontend/src/pages/__tests__/PortfolioPage.test.tsx new file mode 100644 index 0000000..1c6f165 --- /dev/null +++ b/frontend/src/pages/__tests__/PortfolioPage.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { PortfolioPage } from '../PortfolioPage'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parsePositionMock = vi.fn(); + +const networkContext = { + stacksNetwork: { network: 'testnet' }, + contractAddress: 'STTESTCONTRACT', + contractName: 'market-core', +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), + principalCV: (value: string) => ({ type: 'principal', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkContext, +})); + +vi.mock('../../components/WalletProvider', () => ({ + useWallet: () => ({ + isConnected: true, + connect: vi.fn(), + address: 'SPTESTUSER', + }), +})); + +vi.mock('../../hooks/useMarkets', () => ({ + useMarkets: () => ({ + markets: [ + { + id: 1, + question: 'Will STX reach $5?', + creator: 'SPCREATOR', + endDate: 100, + resolutionDate: 200, + totalYesStake: 100, + totalNoStake: 50, + status: 0, + outcome: 0, + createdAt: 10, + }, + ], + isLoading: false, + }), +})); + +vi.mock('../../hooks/useContract', () => ({ + useContract: () => ({ + claimWinnings: vi.fn(), + }), +})); + +vi.mock('../../utils/helpers', async () => { + const actual = await vi.importActual('../../utils/helpers'); + return { + ...actual, + parsePosition: (...args: unknown[]) => parsePositionMock(...args), + }; +}); + +describe('PortfolioPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the active network for position reads', async () => { + parsePositionMock.mockReturnValue({ + marketId: 1, + user: 'SPTESTUSER', + yesStake: 100, + noStake: 0, + claimed: false, + }); + cvToJSONMock.mockReturnValue({ + type: 'some', + value: { position: 'ok' }, + }); + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + + render(React.createElement(MemoryRouter, null, React.createElement(PortfolioPage))); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalled(); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkContext.stacksNetwork, + contractAddress: networkContext.contractAddress, + contractName: networkContext.contractName, + functionName: 'get-user-position', + }); + }); +}); diff --git a/frontend/src/pages/__tests__/TradePage.test.tsx b/frontend/src/pages/__tests__/TradePage.test.tsx new file mode 100644 index 0000000..b46b587 --- /dev/null +++ b/frontend/src/pages/__tests__/TradePage.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { TradePage } from '../TradePage'; + +const fetchCallReadOnlyFunctionMock = vi.fn(); +const cvToJSONMock = vi.fn(); +const parseMarketDataMock = vi.fn(); + +const networkContext = { + stacksNetwork: { network: 'testnet' }, + contractAddress: 'STTESTCONTRACT', + contractName: 'market-core', +}; + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args: unknown[]) => fetchCallReadOnlyFunctionMock(...args), + cvToJSON: (...args: unknown[]) => cvToJSONMock(...args), + uintCV: (value: number) => ({ type: 'uint', value }), +})); + +vi.mock('../../contexts/NetworkContext', () => ({ + useNetwork: () => networkContext, +})); + +vi.mock('../../components/WalletProvider', () => ({ + useWallet: () => ({ + isConnected: false, + connect: vi.fn(), + address: null, + }), +})); + +vi.mock('../../hooks/useStake', () => ({ + useStake: () => ({ + placeYesStake: vi.fn(), + placeNoStake: vi.fn(), + isLoading: false, + error: null, + txId: null, + isContractPaused: false, + }), +})); + +vi.mock('../../hooks/useRealtimeSignal', () => ({ + useRealtimeSignal: () => ({ + signal: 0, + source: null, + isSocketConnected: false, + }), +})); + +vi.mock('../../utils/helpers', async () => { + const actual = await vi.importActual('../../utils/helpers'); + return { + ...actual, + parseMarketData: (...args: unknown[]) => parseMarketDataMock(...args), + }; +}); + +vi.mock('../../components/SocialButtons', () => ({ + SocialButtons: () => React.createElement('div', { 'data-testid': 'social-buttons' }), +})); + +describe('TradePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the active network for market reads', async () => { + parseMarketDataMock.mockReturnValue({ + id: 1, + question: 'Will STX reach $5?', + creator: 'SPTEST', + endDate: 100, + resolutionDate: 200, + totalYesStake: 100, + totalNoStake: 50, + status: 0, + outcome: 0, + createdAt: 10, + }); + cvToJSONMock.mockReturnValue({ + type: 'some', + value: { market: 'ok' }, + }); + fetchCallReadOnlyFunctionMock.mockResolvedValue({}); + + render( + React.createElement( + MemoryRouter, + { initialEntries: ['/trade/1'] }, + React.createElement( + Routes, + null, + React.createElement(Route, { path: '/trade/:id', element: React.createElement(TradePage) }) + ) + ) + ); + + await waitFor(() => { + expect(fetchCallReadOnlyFunctionMock).toHaveBeenCalled(); + }); + + expect(fetchCallReadOnlyFunctionMock.mock.calls[0][0]).toMatchObject({ + network: networkContext.stacksNetwork, + contractAddress: networkContext.contractAddress, + contractName: networkContext.contractName, + functionName: 'get-market', + }); + }); +}); diff --git a/scripts/bulk-create-markets.ts b/scripts/bulk-create-markets.ts index 02c159f..8010091 100644 --- a/scripts/bulk-create-markets.ts +++ b/scripts/bulk-create-markets.ts @@ -22,6 +22,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromTopic } from './utils/market-categories.js'; import { getRandomQuestions, getCategories } from './config/market-questions.js'; // Load environment variables @@ -41,7 +42,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise { const txOptions = { contractAddress: CONTRACT_ADDRESS, @@ -51,6 +53,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -193,7 +196,8 @@ async function main() { privateKey, question.question, endBlock, - resolutionBlock + resolutionBlock, + categoryFromTopic(question.category) ); tracker.add('Market Creation', txid, { diff --git a/scripts/interact-contract.ts b/scripts/interact-contract.ts index e60a656..1c7bcb3 100644 --- a/scripts/interact-contract.ts +++ b/scripts/interact-contract.ts @@ -12,6 +12,7 @@ import { STACKS_MAINNET } from '@stacks/network'; import { generateWallet } from '@stacks/wallet-sdk'; import prompts from 'prompts'; import * as dotenv from 'dotenv'; +import { categoryFromQuestion } from './utils/market-categories.js'; import fs from 'fs'; import toml from 'toml'; import path from 'path'; @@ -106,7 +107,7 @@ async function getPrivateKey(): Promise { /** * Create a new prediction market */ -async function createMarket(privateKey: string): Promise { +async function createMarket(privateKey: string, category: number): Promise { console.log('\n📊 Creating market...'); console.log(`Question: "${MARKET_QUESTION}"`); console.log(`End block: ${END_BLOCK_HEIGHT}`); @@ -120,6 +121,7 @@ async function createMarket(privateKey: string): Promise { stringAsciiCV(MARKET_QUESTION), uintCV(END_BLOCK_HEIGHT), uintCV(RESOLUTION_BLOCK_HEIGHT), + uintCV(category), ], senderKey: privateKey, network, @@ -267,7 +269,7 @@ async function main() { } // Step 1: Create market - const createMarketTxId = await createMarket(privateKey); + const createMarketTxId = await createMarket(privateKey, categoryFromQuestion(MARKET_QUESTION)); // Wait for user to confirm market creation before proceeding console.log('\n⏳ Please wait for the market creation transaction to confirm...'); diff --git a/scripts/market-lifecycle.ts b/scripts/market-lifecycle.ts index 88ca25a..a0b8024 100644 --- a/scripts/market-lifecycle.ts +++ b/scripts/market-lifecycle.ts @@ -23,6 +23,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromQuestion } from './utils/market-categories.js'; import { getRandomQuestions } from './config/market-questions.js'; // Load environment variables @@ -42,7 +43,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise { const txOptions = { contractAddress: CONTRACT_ADDRESS, @@ -52,6 +54,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -206,7 +209,13 @@ async function main() { console.log(` End block: ${formatBlockHeight(endBlock)}`); console.log(` Resolution block: ${formatBlockHeight(resolutionBlock)}\n`); - const createTxid = await createMarket(privateKey, marketQuestion.question, endBlock, resolutionBlock); + const createTxid = await createMarket( + privateKey, + marketQuestion.question, + endBlock, + resolutionBlock, + categoryFromQuestion(marketQuestion.question) + ); tracker.add('Market Creation', createTxid, { question: marketQuestion.question }); logTransaction('Market creation', createTxid, 'mainnet'); diff --git a/scripts/stress-test.ts b/scripts/stress-test.ts index 1eb8a1d..d398903 100644 --- a/scripts/stress-test.ts +++ b/scripts/stress-test.ts @@ -23,6 +23,7 @@ import { validateBlockHeights, TransactionTracker, } from './utils/transaction-helpers.js'; +import { categoryFromQuestion } from './utils/market-categories.js'; import { getRandomQuestions } from './config/market-questions.js'; // Load environment variables @@ -112,7 +113,8 @@ async function createMarket( privateKey: string, question: string, endBlock: number, - resolutionBlock: number + resolutionBlock: number, + category: number ): Promise<{ txid: string; responseTime: number }> { const startTime = Date.now(); @@ -124,6 +126,7 @@ async function createMarket( stringAsciiCV(question), uintCV(endBlock), uintCV(resolutionBlock), + uintCV(category), ], senderKey: privateKey, network, @@ -279,7 +282,13 @@ async function main() { if (testType === 'markets') { // Market creation stress test const question = getRandomQuestions(1)[0]; - result = await createMarket(privateKey, question.question, endBlock, resolutionBlock); + result = await createMarket( + privateKey, + question.question, + endBlock, + resolutionBlock, + categoryFromQuestion(question.question) + ); txType = 'Market Creation'; tracker.add(txType, result.txid, { question: question.question }); @@ -315,7 +324,13 @@ async function main() { } else { // 30% market creation const question = getRandomQuestions(1)[0]; - result = await createMarket(privateKey, question.question, endBlock, resolutionBlock); + result = await createMarket( + privateKey, + question.question, + endBlock, + resolutionBlock, + categoryFromQuestion(question.question) + ); txType = 'Market Creation'; tracker.add(txType, result.txid, { question: question.question }); } diff --git a/scripts/utils/market-categories.ts b/scripts/utils/market-categories.ts new file mode 100644 index 0000000..fe368f1 --- /dev/null +++ b/scripts/utils/market-categories.ts @@ -0,0 +1,102 @@ +export const CONTRACT_MARKET_CATEGORIES = { + CRYPTO: 1, + SPORTS: 2, + POLITICS: 3, + ECONOMICS: 4, + OTHER: 5, +} as const; + +const TOPIC_CATEGORY_MAP: Record = { + crypto: CONTRACT_MARKET_CATEGORIES.CRYPTO, + sports: CONTRACT_MARKET_CATEGORIES.SPORTS, + politics: CONTRACT_MARKET_CATEGORIES.POLITICS, + economics: CONTRACT_MARKET_CATEGORIES.ECONOMICS, + business: CONTRACT_MARKET_CATEGORIES.ECONOMICS, + other: CONTRACT_MARKET_CATEGORIES.OTHER, + tech: CONTRACT_MARKET_CATEGORIES.OTHER, + entertainment: CONTRACT_MARKET_CATEGORIES.OTHER, + science: CONTRACT_MARKET_CATEGORIES.OTHER, + defi: CONTRACT_MARKET_CATEGORIES.CRYPTO, +}; + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +export function categoryFromTopic(topic: string): number { + return TOPIC_CATEGORY_MAP[normalize(topic)] ?? CONTRACT_MARKET_CATEGORIES.OTHER; +} + +export function categoryFromQuestion(question: string): number { + const q = normalize(question); + + if ( + q.includes('bitcoin') || + q.includes('btc') || + q.includes('ethereum') || + q.includes('eth') || + q.includes('crypto') || + q.includes('stx') || + q.includes('stacks') || + q.includes('token') || + q.includes('coin') + ) { + return CONTRACT_MARKET_CATEGORIES.CRYPTO; + } + + if ( + q.includes('super bowl') || + q.includes('world cup') || + q.includes('nba') || + q.includes('nfl') || + q.includes('football') || + q.includes('basketball') || + q.includes('soccer') || + q.includes('championship') || + q.includes('match') || + q.includes('finals') || + q.includes('playoff') || + q.includes('sports') || + q.includes('tennis') || + q.includes('golf') || + q.includes('olympic') + ) { + return CONTRACT_MARKET_CATEGORIES.SPORTS; + } + + if ( + q.includes('election') || + q.includes('president') || + q.includes('vote') || + q.includes('congress') || + q.includes('government') || + q.includes('policy') || + q.includes('senate') || + q.includes('republican') || + q.includes('democrat') + ) { + return CONTRACT_MARKET_CATEGORIES.POLITICS; + } + + if ( + q.includes('stock') || + q.includes('company') || + q.includes('ipo') || + q.includes('merger') || + q.includes('earnings') || + q.includes('revenue') || + q.includes('market cap') || + q.includes('ceo') || + q.includes('acquisition') || + q.includes('inflation') || + q.includes('unemployment') || + q.includes('gdp') || + q.includes('oil') || + q.includes('gold') || + q.includes('bank') + ) { + return CONTRACT_MARKET_CATEGORIES.ECONOMICS; + } + + return CONTRACT_MARKET_CATEGORIES.OTHER; +} From 259ae011bef272b22f01a4b0ea5caa2def1fa388 Mon Sep 17 00:00:00 2001 From: Adebayo Muhammed Olosasa <95596908+Mosas2000@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:30:19 +0100 Subject: [PATCH 10/43] Issue 58 market category fix (#66) * Add market category helper * Pass categories to market scripts * Use deployer as market owner From 431b4ad6d1b4f8d360fdf7f41cd0183d83356357 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 16 Apr 2026 11:21:53 +0100 Subject: [PATCH 11/43] Make explorer links network aware --- frontend/src/components/Footer.tsx | 6 +++++- frontend/src/pages/LandingPage.tsx | 13 ++++++++----- frontend/src/pages/TradePage.tsx | 17 +++++++++-------- frontend/src/utils/transactions.ts | 16 +++++++++++++--- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index a9e61a2..d1f447a 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,7 +1,11 @@ import { Link } from 'react-router-dom'; import { Logo } from './Logo'; +import { useNetwork } from '../contexts/NetworkContext'; +import { getExplorerAddressUrl } from '../utils/transactions'; export function Footer() { + const { network, contractAddress } = useNetwork(); + return (