diff --git a/frontend/src/app/markets/[marketId]/dispute/page.tsx b/frontend/src/app/markets/[marketId]/dispute/page.tsx index 9ea6a80a..86ca87e4 100644 --- a/frontend/src/app/markets/[marketId]/dispute/page.tsx +++ b/frontend/src/app/markets/[marketId]/dispute/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { use, useCallback, useEffect, useState } from 'react'; import { useAsync } from '@/lib/hooks/useAsync'; import { useTransaction } from '@/lib/hooks/useTransaction'; import { describeWriteError } from '@/lib/api/contractErrors'; @@ -47,7 +47,7 @@ async function fetchDisputeStatus( } interface DisputePageProps { - params: { marketId: string }; + params: Promise<{ marketId: string }>; } /** @@ -62,7 +62,7 @@ interface DisputePageProps { * to a nonexistent backend route. */ export default function DisputePage({ params }: DisputePageProps) { - const { marketId } = params; + const { marketId } = use(params); const [reason, setReason] = useState(''); const wallet = useWallet(); const tx = useTransaction<{ marketId: string }>(); diff --git a/frontend/src/app/markets/create/page.tsx b/frontend/src/app/markets/create/page.tsx index ce5b8cad..bf6dd68a 100644 --- a/frontend/src/app/markets/create/page.tsx +++ b/frontend/src/app/markets/create/page.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useRouter } from 'next/navigation'; +import { ApiError } from '@/lib/api/public-client'; +import { describeWriteError } from '@/lib/api/contractErrors'; import { getEnvConfig } from '@/lib/env'; import { useWallet } from '@/lib/wallet/WalletProvider'; import { getSupportedAsset } from '@/lib/assets'; @@ -116,14 +118,24 @@ export default function CreateMarketPage() { ); if (!res.ok) { - throw new Error(`Failed to create market (${res.status})`); + const body = (await res.json().catch(() => ({}))) as { + message?: string; + code?: string; + details?: Record; + }; + throw new ApiError( + body.message ?? `Failed to create market (${res.status})`, + res.status, + body.code, + body.details + ); } const data: CreateMarketResponse = await res.json(); setSuccess(true); router.push(`/markets/${data.market_id}`); } catch (err) { - setErrors({ form: err instanceof Error ? err.message : 'Failed to create market.' }); + setErrors({ form: describeWriteError(err, 'create') }); } finally { setSubmitting(false); } diff --git a/frontend/src/components/markets/PlaceBetForm.tsx b/frontend/src/components/markets/PlaceBetForm.tsx index feef858b..d9858676 100644 --- a/frontend/src/components/markets/PlaceBetForm.tsx +++ b/frontend/src/components/markets/PlaceBetForm.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { api, ApiError } from '../../lib/api/public-client'; import { useWallet, WALLET_NOT_INSTALLED } from '../../lib/wallet/WalletProvider'; +import { parseBetAmount } from '../../lib/wallet/amount'; import { placeBetOnChain } from '../../lib/wallet/soroban'; import { fetchAssetBalance, fetchNativeBalance } from '../../lib/wallet/balance'; import { SUPPORTED_ASSETS, getSupportedAsset } from '../../lib/assets'; @@ -85,8 +86,7 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted const validate = (): string | null => { if (selectedOutcome === null) return t('placeBetForm.validationOutcome'); - const parsed = Number(amount); - if (!amount || Number.isNaN(parsed) || parsed <= 0) { + if (parseBetAmount(amount) === null) { return t('placeBetForm.validationAmount'); } return null; diff --git a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx index e19d1962..e40dd038 100644 --- a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx +++ b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx @@ -342,7 +342,7 @@ describe('PlaceBetForm', () => { }); it("shows the market's settlement asset code, not XLM", async () => { - const usdc = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const usdc = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; mockPlaceBet.mockRejectedValue( new ApiError('Insufficient balance.', 400, 'CONTRACT_ERROR', { contract_code: 107 }) ); diff --git a/frontend/src/lib/__tests__/amountAndAssets.test.ts b/frontend/src/lib/__tests__/amountAndAssets.test.ts new file mode 100644 index 00000000..f15e76ac --- /dev/null +++ b/frontend/src/lib/__tests__/amountAndAssets.test.ts @@ -0,0 +1,28 @@ +import { parseBetAmount } from '../wallet/amount'; +import { getSupportedAssets } from '../assets'; + +describe('parseBetAmount', () => { + it.each(['1e-9', '0x10', '1.123456789', '', '0', '0.0000000', '-1', '.5', '1.'])( + 'rejects %p', + (v) => expect(parseBetAmount(v)).toBeNull() + ); + it('converts exactly to stroops without float rounding', () => { + expect(parseBetAmount('1.1234567')).toBe(11234567n); + expect(parseBetAmount('9007199254740993')).toBe(90071992547409930000000n); + expect(parseBetAmount('0.0000001')).toBe(1n); + }); +}); + +describe('getSupportedAssets', () => { + it('keys issuers by network and validates addresses', () => { + const usdc = (n: 'PUBLIC' | 'TESTNET') => + getSupportedAssets(n).find((a) => a.code === 'USDC')?.address; + expect(usdc('PUBLIC')).not.toBe(usdc('TESTNET')); + expect(getSupportedAssets('FUTURENET').some((a) => a.code === 'USDC')).toBe(false); + }); + it('throws on an invalid PIQ address', () => { + process.env.NEXT_PUBLIC_PIQ_TOKEN_ADDRESS = 'nope'; + expect(() => getSupportedAssets('TESTNET')).toThrow(/Invalid address/); + delete process.env.NEXT_PUBLIC_PIQ_TOKEN_ADDRESS; + }); +}); diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts index 9ef27449..15cf592f 100644 --- a/frontend/src/lib/assets.ts +++ b/frontend/src/lib/assets.ts @@ -10,6 +10,8 @@ * rejected client-side rather than left for the contract call to fail. */ +import { StrKey } from '@stellar/stellar-sdk'; + export type AssetKind = 'soroban_token' | 'classic_asset'; export interface SupportedAsset { @@ -23,29 +25,73 @@ export interface SupportedAsset { label: string; } -export const SUPPORTED_ASSETS: SupportedAsset[] = [ - { - id: 'native:XLM', - kind: 'classic_asset', - code: 'XLM', - address: 'native', - label: 'XLM (Stellar Lumens)', - }, - { - id: 'classic:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - kind: 'classic_asset', - code: 'USDC', - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - label: 'USDC (Circle, classic asset)', - }, - { - id: 'soroban:PIQ:CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K', - kind: 'soroban_token', - code: 'PIQ', - address: 'CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K', - label: 'PIQ (PredictIQ token)', - }, -]; +export type StellarNetwork = 'PUBLIC' | 'TESTNET' | 'FUTURENET'; + +const XLM: SupportedAsset = { + id: 'native:XLM', + kind: 'classic_asset', + code: 'XLM', + address: 'native', + label: 'XLM (Stellar Lumens)', +}; + +function usdc(issuer: string, label: string): SupportedAsset { + return { id: `classic:USDC:${issuer}`, kind: 'classic_asset', code: 'USDC', address: issuer, label }; +} + +/** Per-network issuer/contract addresses; ids on one network never match another's. */ +const ASSETS_BY_NETWORK: Record = { + PUBLIC: [ + XLM, + usdc('GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', 'USDC (Circle, classic asset)'), + ], + TESTNET: [ + XLM, + usdc('GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', 'USDC (Circle testnet, classic asset)'), + ], + FUTURENET: [XLM], +}; + +export function getStellarNetwork(): StellarNetwork { + const raw = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'TESTNET').trim().toUpperCase(); + return raw in ASSETS_BY_NETWORK ? (raw as StellarNetwork) : 'TESTNET'; +} + +/** True when `address` is `native`, a valid G... issuer, or a valid C... contract id. */ +export function isValidAssetAddress(address: string): boolean { + return ( + address === 'native' || + StrKey.isValidEd25519PublicKey(address) || + StrKey.isValidContract(address) + ); +} + +/** + * Registry for `network`. The PIQ token contract is deployment-specific, so it + * comes from NEXT_PUBLIC_PIQ_TOKEN_ADDRESS. Every address is validated; an + * invalid one throws at startup instead of failing later on-chain. + */ +export function getSupportedAssets(network: StellarNetwork = getStellarNetwork()): SupportedAsset[] { + const assets = [...ASSETS_BY_NETWORK[network]]; + const piq = process.env.NEXT_PUBLIC_PIQ_TOKEN_ADDRESS?.trim(); + if (piq) { + assets.push({ + id: `soroban:PIQ:${piq}`, + kind: 'soroban_token', + code: 'PIQ', + address: piq, + label: 'PIQ (PredictIQ token)', + }); + } + for (const asset of assets) { + if (!isValidAssetAddress(asset.address)) { + throw new Error(`Invalid address for ${asset.code} on ${network}: ${asset.address}`); + } + } + return assets; +} + +export const SUPPORTED_ASSETS: SupportedAsset[] = getSupportedAssets(); export function isSupportedAsset(id: string): boolean { return SUPPORTED_ASSETS.some((asset) => asset.id === id); diff --git a/frontend/src/lib/wallet/amount.ts b/frontend/src/lib/wallet/amount.ts new file mode 100644 index 00000000..3beeef4a --- /dev/null +++ b/frontend/src/lib/wallet/amount.ts @@ -0,0 +1,13 @@ +/** Stellar amounts: non-negative decimal, at most 7 fractional digits. */ +const AMOUNT_RE = /^\d+(\.\d{1,7})?$/; + +/** Minimum bet in stroops; raise once the contract exposes its minimum. */ +export const MIN_BET_STROOPS = 1n; + +/** Parse a user-entered amount to stroops, or null if malformed or below the minimum. */ +export function parseBetAmount(amount: string): bigint | null { + if (!AMOUNT_RE.test(amount)) return null; + const [whole, frac = ''] = amount.split('.'); + const stroops = BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, '0')); + return stroops >= MIN_BET_STROOPS ? stroops : null; +}