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
6 changes: 3 additions & 3 deletions frontend/src/app/markets/[marketId]/dispute/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,7 +47,7 @@ async function fetchDisputeStatus(
}

interface DisputePageProps {
params: { marketId: string };
params: Promise<{ marketId: string }>;
}

/**
Expand All @@ -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 }>();
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/app/markets/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
};
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);
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/markets/PlaceBetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,8 +86,7 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
);
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/lib/__tests__/amountAndAssets.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
92 changes: 69 additions & 23 deletions frontend/src/lib/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<StellarNetwork, SupportedAsset[]> = {
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);
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/lib/wallet/amount.ts
Original file line number Diff line number Diff line change
@@ -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;
}