diff --git a/frontend/src/app/account/bets/page.tsx b/frontend/src/app/account/bets/page.tsx index e6b23877..41656e0d 100644 --- a/frontend/src/app/account/bets/page.tsx +++ b/frontend/src/app/account/bets/page.tsx @@ -1,39 +1,9 @@ 'use client'; import React, { useEffect, useState, useCallback } from 'react'; +import { useWallet } from '../../../lib/wallet/WalletProvider'; import './bets.css'; -// TODO: replace with the shared wallet hook from #77 once merged. This local -// stub exposes the same shape (connected address + change subscription) so -// swapping it out later is a one-line change. -function useConnectedWallet(): string | null { - const [address, setAddress] = useState(null); - - useEffect(() => { - const wallet = (typeof window !== 'undefined' ? (window as any).freighterApi : null); - let cancelled = false; - - const loadAddress = async () => { - try { - const result = await wallet?.getAddress?.(); - if (!cancelled) setAddress(result?.address ?? null); - } catch { - if (!cancelled) setAddress(null); - } - }; - - loadAddress(); - wallet?.addEventListener?.('accountChanged', loadAddress); - - return () => { - cancelled = true; - wallet?.removeEventListener?.('accountChanged', loadAddress); - }; - }, []); - - return address; -} - interface Bet { id: string; market_id: string; @@ -47,7 +17,7 @@ const PAGE_SIZE = 20; const BET_NOT_FOUND_CODE = 142; export default function BetHistoryPage() { - const address = useConnectedWallet(); + const { address } = useWallet(); const [bets, setBets] = useState([]); const [page, setPage] = useState(0); const [loading, setLoading] = useState(false); diff --git a/frontend/src/app/markets/create/page.tsx b/frontend/src/app/markets/create/page.tsx index 88391324..277792a7 100644 --- a/frontend/src/app/markets/create/page.tsx +++ b/frontend/src/app/markets/create/page.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useRouter } from 'next/navigation'; import { getEnvConfig } from '@/lib/env'; -import { getConnectedWalletAddress } from '@/lib/wallet'; +import { useWallet } from '@/lib/wallet/WalletProvider'; import { getSupportedAsset } from '@/lib/assets'; import { TokenSelector } from '@/components/markets/TokenSelector'; import { @@ -30,6 +30,7 @@ interface CreateMarketResponse { export default function CreateMarketPage() { const router = useRouter(); + const { address: walletAddress } = useWallet(); const [values, setValues] = React.useState(EMPTY_FORM); const [errors, setErrors] = React.useState({}); const [submitting, setSubmitting] = React.useState(false); @@ -67,7 +68,7 @@ export default function CreateMarketPage() { return; } - const wallet = getConnectedWalletAddress(); + const wallet = walletAddress; if (!wallet) { setErrors({ form: 'Connect a wallet before creating a market.' }); return; diff --git a/frontend/src/components/markets/OraclePriceWidget.tsx b/frontend/src/components/markets/OraclePriceWidget.tsx index 618de9b9..1e73d1bd 100644 --- a/frontend/src/components/markets/OraclePriceWidget.tsx +++ b/frontend/src/components/markets/OraclePriceWidget.tsx @@ -1,3 +1,43 @@ 'use client'; -import { useEffect,useState } from 'react'; -export default function OraclePriceWidget({marketId}:{marketId:string|number}){const [price,setPrice]=useState(null);useEffect(()=>{fetch(`/api/v1/blockchain/oracle/${marketId}`).then(r=>r.json()).then(d=>{const n=Number(d.price??d.value);setPrice(Number.isFinite(n)&&Math.abs(n)setPrice(null))},[marketId]);return {price===null?'Price unavailable':price}} +import { useEffect, useState } from 'react'; +import { api } from '../../lib/api/public-client'; + +type State = { status: 'loading' } | { status: 'error' } | { status: 'ready'; text: string }; + +/** Applies a Pyth-style exponent (price * 10^expo) to an integer price. */ +export function formatOraclePrice(raw: unknown, expo: unknown): string | null { + const price = Number(raw); + if (raw === null || raw === undefined || raw === '' || !Number.isFinite(price)) return null; + const e = Number(expo ?? 0); + if (!Number.isInteger(e)) return null; + const value = price * Math.pow(10, e); + if (!Number.isFinite(value)) return null; + return value.toLocaleString('en-US', { maximumFractionDigits: Math.max(0, Math.min(-e, 20)) }); +} + +export default function OraclePriceWidget({ marketId }: { marketId: string | number }) { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + const controller = new AbortController(); + setState({ status: 'loading' }); + api + .getOracleResult(marketId, controller.signal) + .then((d) => { + const r = d as Record; + const expo = r.expo ?? r.exponent ?? (r.decimals !== undefined ? -Number(r.decimals) : 0); + const text = formatOraclePrice(r.price ?? r.value, expo); + setState(text === null ? { status: 'error' } : { status: 'ready', text }); + }) + .catch(() => { + if (!controller.signal.aborted) setState({ status: 'error' }); + }); + return () => controller.abort(); + }, [marketId]); + + return ( + + {state.status === 'loading' ? 'Loading price…' : state.status === 'error' ? 'Price unavailable' : state.text} + + ); +} diff --git a/frontend/src/components/markets/PlaceBetForm.tsx b/frontend/src/components/markets/PlaceBetForm.tsx index 549cf57d..e306ddf2 100644 --- a/frontend/src/components/markets/PlaceBetForm.tsx +++ b/frontend/src/components/markets/PlaceBetForm.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { api, ApiError } from '../../lib/api/public-client'; import { useWallet, WALLET_NOT_INSTALLED } from '../../lib/wallet/WalletProvider'; -import { fetchNativeBalance } from '../../lib/wallet/balance'; +import { fetchAssetBalance, fetchNativeBalance } from '../../lib/wallet/balance'; +import { SUPPORTED_ASSETS, getSupportedAsset } from '../../lib/assets'; import { INSUFFICIENT_BALANCE_MESSAGE, isInsufficientBalanceError } from '../../lib/wallet/errors'; import { useI18n } from '../../lib/hooks/useI18n'; import './PlaceBetForm.css'; @@ -17,6 +18,8 @@ export interface BetMarket { id: number | string; title: string; outcomes: MarketOutcome[]; + /** Settlement asset address (SAC/classic issuer or 'native'); defaults to XLM. */ + token_address?: string; } interface PlaceBetFormProps { @@ -43,7 +46,10 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted const [insufficientBalance, setInsufficientBalance] = React.useState<{ required: string; current: string | null; + code: string; } | null>(null); + const settlementAsset = + SUPPORTED_ASSETS.find((a) => a.address === market.token_address) ?? getSupportedAsset('native:XLM')!; // Poll the submitted transaction until it leaves the "pending" state. React.useEffect(() => { @@ -122,8 +128,12 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted // Read the balance fresh at the moment of failure — never the value // cached from page load — since the wallet's actual asset state is // what the user needs to see to understand the error (see #79). - const currentBalance = await fetchNativeBalance(wallet.address, wallet.network); - setInsufficientBalance({ required: amount, current: currentBalance }); + const asset = settlementAsset; + const currentBalance = + asset.address === 'native' + ? await fetchNativeBalance(wallet.address, wallet.network) + : await fetchAssetBalance(wallet.address, wallet.network, asset.code, asset.address); + setInsufficientBalance({ required: amount, current: currentBalance, code: asset.code }); } else { const message = err instanceof ApiError ? err.message : 'Failed to submit your bet. Please try again.'; @@ -208,8 +218,8 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted {insufficientBalance && (

{INSUFFICIENT_BALANCE_MESSAGE} Your balance is{' '} - {insufficientBalance.current ?? 'unavailable'} XLM, but this bet requires{' '} - {insufficientBalance.required} XLM. + {insufficientBalance.current ?? 'unavailable'} {insufficientBalance.code}, but this bet requires{' '} + {insufficientBalance.required} {insufficientBalance.code}.

)} diff --git a/frontend/src/components/markets/__tests__/OraclePriceWidget.test.tsx b/frontend/src/components/markets/__tests__/OraclePriceWidget.test.tsx new file mode 100644 index 00000000..9a0cc7a3 --- /dev/null +++ b/frontend/src/components/markets/__tests__/OraclePriceWidget.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import OraclePriceWidget, { formatOraclePrice } from '../OraclePriceWidget'; + +const mockGet = jest.fn(); +jest.mock('../../../lib/api/public-client', () => ({ + api: { getOracleResult: (...a: unknown[]) => mockGet(...a) }, +})); + +describe('OraclePriceWidget', () => { + it('applies the exponent', () => { + expect(formatOraclePrice(12345, -2)).toBe('123.45'); + }); + + it('shows loading then the formatted price', async () => { + mockGet.mockResolvedValue({ price: 250000, expo: -4 }); + render(); + expect(screen.getByLabelText('Oracle price')).toHaveTextContent('Loading price…'); + expect(await screen.findByText('25')).toBeInTheDocument(); + }); + + it('shows an error state when the request fails', async () => { + mockGet.mockRejectedValue(new Error('boom')); + render(); + expect(await screen.findByText('Price unavailable')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx index 9d7eba6b..ff54967e 100644 --- a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx +++ b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx @@ -40,8 +40,10 @@ jest.mock('../../../lib/api/public-client', () => { }); const mockFetchNativeBalance = jest.fn(); +const mockFetchAssetBalance = jest.fn(); jest.mock('../../../lib/wallet/balance', () => ({ fetchNativeBalance: (...args: unknown[]) => mockFetchNativeBalance(...args), + fetchAssetBalance: (...args: unknown[]) => mockFetchAssetBalance(...args), })); // --------------------------------------------------------------------------- @@ -330,6 +332,26 @@ describe('PlaceBetForm', () => { }); }); + it("shows the market's settlement asset code, not XLM", async () => { + const usdc = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + mockPlaceBet.mockRejectedValue( + new ApiError('Insufficient balance.', 400, 'CONTRACT_ERROR', { contract_code: 107 }) + ); + mockFetchAssetBalance.mockResolvedValue('3.0000000'); + + render(); + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '10' } }); + fireEvent.click(screen.getByRole('button', { name: /place bet/i })); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent(/3\.0000000 USDC/); + expect(alert).toHaveTextContent(/10 USDC/); + expect(alert).not.toHaveTextContent(/XLM/); + }); + expect(mockFetchAssetBalance).toHaveBeenCalledWith(WALLET_ADDRESS, 'TESTNET', 'USDC', usdc); + }); + it('shows "unavailable" when fetchNativeBalance returns null', async () => { const insufficientBalanceError = new ApiError( 'Insufficient balance to complete this transaction.', diff --git a/frontend/src/lib/wallet.ts b/frontend/src/lib/wallet.ts deleted file mode 100644 index db824b09..00000000 --- a/frontend/src/lib/wallet.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Minimal wallet-connection accessor. - * - * The full wallet-connect flow (Freighter / WalletConnect, tx signing, etc.) - * ships under #77. Until then, forms that must "submit via the wallet" read - * the connected public key from this single seam so that swapping in the - * real implementation later touches one file instead of every caller. - */ - -const WALLET_STORAGE_KEY = 'predictiq_wallet_address'; - -/** Returns the connected Stellar public key, or null if no wallet is connected. */ -export function getConnectedWalletAddress(): string | null { - if (typeof window === 'undefined') return null; - try { - return window.localStorage.getItem(WALLET_STORAGE_KEY); - } catch { - return null; - } -} - -export function isWalletConnected(): boolean { - return getConnectedWalletAddress() !== null; -} diff --git a/frontend/src/lib/wallet/balance.ts b/frontend/src/lib/wallet/balance.ts index 0dfee423..1cb49749 100644 --- a/frontend/src/lib/wallet/balance.ts +++ b/frontend/src/lib/wallet/balance.ts @@ -99,3 +99,32 @@ export async function sendPayment( const result = await server.submitTransaction(transaction); return result.hash; } + +export async function fetchNativeBalance( + publicKey: string, + network: string = 'TESTNET' +): Promise { + return getBalance(publicKey, network); +} + +/** + * Balance of a classic asset for an account (native XLM when `issuer` is + * 'native'). Horizon does not list Soroban-only tokens, so those yield null. + */ +export async function fetchAssetBalance( + publicKey: string, + network: string, + code: string, + issuer: string +): Promise { + if (issuer === 'native') return fetchNativeBalance(publicKey, network); + try { + const account = await getHorizonServer(network).loadAccount(publicKey); + const found = account.balances.find( + (b) => 'asset_code' in b && b.asset_code === code && b.asset_issuer === issuer + ); + return found ? found.balance : '0'; + } catch { + return null; + } +}