Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 2 additions & 32 deletions frontend/src/app/account/bets/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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;
Expand All @@ -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<Bet[]>([]);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/markets/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +30,7 @@ interface CreateMarketResponse {

export default function CreateMarketPage() {
const router = useRouter();
const { address: walletAddress } = useWallet();
const [values, setValues] = React.useState<MarketFormValues>(EMPTY_FORM);
const [errors, setErrors] = React.useState<MarketFormErrors>({});
const [submitting, setSubmitting] = React.useState(false);
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 42 additions & 2 deletions frontend/src/components/markets/OraclePriceWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
'use client';
import { useEffect,useState } from 'react';
export default function OraclePriceWidget({marketId}:{marketId:string|number}){const [price,setPrice]=useState<number|null>(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)<Number.MAX_SAFE_INTEGER?n:null)}).catch(()=>setPrice(null))},[marketId]);return <output aria-label="Oracle price">{price===null?'Price unavailable':price}</output>}
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<State>({ status: 'loading' });

useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
api
.getOracleResult(marketId, controller.signal)
.then((d) => {
const r = d as Record<string, unknown>;
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 (
<output aria-label="Oracle price">
{state.status === 'loading' ? 'Loading price…' : state.status === 'error' ? 'Price unavailable' : state.text}
</output>
);
}
20 changes: 15 additions & 5 deletions frontend/src/components/markets/PlaceBetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -43,7 +46,10 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ 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(() => {
Expand Down Expand Up @@ -122,8 +128,12 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ 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.';
Expand Down Expand Up @@ -208,8 +218,8 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ market, onSubmitted
{insufficientBalance && (
<p className="place-bet-form__error" role="alert">
{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}.
</p>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<OraclePriceWidget marketId={1} />);
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(<OraclePriceWidget marketId={1} />);
expect(await screen.findByText('Price unavailable')).toBeInTheDocument();
});
});
22 changes: 22 additions & 0 deletions frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(<PlaceBetForm market={{ ...MARKET, token_address: usdc }} />);
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.',
Expand Down
24 changes: 0 additions & 24 deletions frontend/src/lib/wallet.ts

This file was deleted.

29 changes: 29 additions & 0 deletions frontend/src/lib/wallet/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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<string | null> {
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;
}
}