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
110 changes: 110 additions & 0 deletions frontend/lib/stellar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// ============================================================
// BOXMEOUT — Soroban Transaction Utilities
// Low-level helpers for building, simulating, and submitting
// Soroban contract invocations.
// ============================================================

import {
Contract,
Networks,
SorobanRpc,
TransactionBuilder,
BASE_FEE,
xdr,
} from '@stellar/stellar-sdk';

const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet';
const HORIZON_URL =
process.env.NEXT_PUBLIC_HORIZON_URL ?? 'https://horizon-testnet.stellar.org';
const SOROBAN_RPC_URL =
NETWORK === 'mainnet'
? 'https://soroban-rpc.stellar.org'
: 'https://soroban-testnet.stellar.org';

export const NETWORK_PASSPHRASE =
NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET;

/**
* Assembles a Soroban contract invocation transaction, then calls
* simulateTransaction on the RPC to obtain the fee estimate and
* resource footprint. Returns the prepared (simulation-enriched) XDR.
*/
export async function buildContractTransaction(
sourceAddress: string,
contractAddress: string,
method: string,
args: xdr.ScVal[],
): Promise<string> {
const server = new SorobanRpc.Server(SOROBAN_RPC_URL);
const account = await server.getAccount(sourceAddress);
const contract = new Contract(contractAddress);

const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(30)
.build();

// simulateTransaction fills in the resource footprint and fee estimate
const simResult = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(simResult)) {
throw new Error(`Simulation failed: ${simResult.error}`);
}

const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build();
return preparedTx.toXDR();
}

/**
* Submits a signed transaction XDR to the network and polls until
* it reaches SUCCESS or a terminal failure state.
* Returns the transaction hash on success.
*/
export async function submitTransaction(signedXdr: string): Promise<string> {
const server = new SorobanRpc.Server(SOROBAN_RPC_URL);
const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE);

const sendRes = await server.sendTransaction(tx);
if (sendRes.status === 'ERROR') {
throw new Error(
`Network rejected transaction: ${sendRes.errorResult?.toString() ?? 'unknown error'}`,
);
}

// Poll for confirmation (max 30 s)
let getRes = await server.getTransaction(sendRes.hash);
for (let i = 0; i < 20 && getRes.status === 'NOT_FOUND'; i++) {
await new Promise((r) => setTimeout(r, 1500));
getRes = await server.getTransaction(sendRes.hash);
}

if (getRes.status !== 'SUCCESS') {
throw new Error(`Transaction failed with status: ${getRes.status}`);
}

return sendRes.hash;
}

/**
* Converts a stroops value (7 decimal places) to a human-readable XLM string.
* e.g. 12345678n → "1.2345678"
*/
export function formatTokenAmount(stroops: bigint | string, decimals = 7): string {
const n = BigInt(stroops);
const divisor = BigInt(10 ** decimals);
const whole = n / divisor;
const frac = (n % divisor).toString().padStart(decimals, '0').replace(/0+$/, '');
return frac.length > 0 ? `${whole}.${frac}` : `${whole}`;
}

/**
* Truncates a Stellar address to "GABC...1234" format.
*/
export function truncateAddress(address: string, leading = 4, trailing = 4): string {
if (address.length <= leading + trailing) return address;
return `${address.slice(0, leading)}...${address.slice(-trailing)}`;
}

export { HORIZON_URL, SOROBAN_RPC_URL };
29 changes: 29 additions & 0 deletions frontend/src/components/bet/BetHistoryTableSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function BetHistoryTableSkeleton({ rows = 5 }: { rows?: number }): JSX.Element {
return (
<div className="overflow-x-auto -mx-4 px-4 animate-pulse">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
{['Market', 'Side', 'Amount (XLM)', 'Status', 'Payout (XLM)', 'Action'].map((col) => (
<th key={col} className="pb-2 pr-4 whitespace-nowrap">
<div className="h-3 w-16 bg-gray-700 rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i} className="border-b border-gray-800/50">
<td className="py-3 pr-4"><div className="h-4 w-20 bg-gray-700 rounded" /></td>
<td className="py-3 pr-4"><div className="h-4 w-16 bg-gray-700 rounded" /></td>
<td className="py-3 pr-4"><div className="h-4 w-16 bg-gray-700 rounded" /></td>
<td className="py-3 pr-4"><div className="h-4 w-14 bg-gray-700 rounded" /></td>
<td className="py-3 pr-4"><div className="h-4 w-16 bg-gray-700 rounded" /></td>
<td className="py-3"><div className="h-8 w-16 bg-gray-700 rounded-lg" /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
54 changes: 54 additions & 0 deletions frontend/src/components/market/MarketDetailSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export function MarketDetailSkeleton(): JSX.Element {
return (
<main className="max-w-4xl mx-auto px-4 py-6 space-y-6 animate-pulse">
{/* Badges + title */}
<div className="space-y-2">
<div className="flex gap-2">
<div className="h-5 w-16 bg-gray-700 rounded-full" />
<div className="h-5 w-24 bg-gray-700 rounded-full" />
</div>
<div className="h-7 w-3/4 bg-gray-700 rounded" />
<div className="h-4 w-40 bg-gray-800 rounded" />
<div className="h-4 w-28 bg-gray-800 rounded" />
</div>

{/* Odds bar */}
<div className="space-y-2">
<div className="h-8 w-full bg-gray-700 rounded" />
<div className="flex justify-between">
<div className="h-3 w-28 bg-gray-800 rounded" />
<div className="h-3 w-20 bg-gray-800 rounded" />
<div className="h-3 w-28 bg-gray-800 rounded" />
</div>
</div>

{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* BetPanel skeleton */}
<div className="lg:col-start-3 bg-gray-900 rounded-xl p-6 space-y-4">
<div className="flex gap-2">
<div className="flex-1 h-11 bg-gray-700 rounded-lg" />
<div className="flex-1 h-11 bg-gray-700 rounded-lg" />
<div className="flex-1 h-11 bg-gray-700 rounded-lg" />
</div>
<div className="h-9 w-full bg-gray-700 rounded-lg" />
<div className="h-16 w-full bg-gray-800 rounded-lg" />
<div className="h-11 w-full bg-gray-700 rounded-lg" />
</div>

{/* Recent bets skeleton */}
<div className="lg:col-span-2 lg:row-start-1 space-y-3">
<div className="h-5 w-28 bg-gray-700 rounded" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 border-b border-gray-800/50 pb-3">
<div className="h-4 w-20 bg-gray-700 rounded" />
<div className="h-4 w-16 bg-gray-700 rounded" />
<div className="h-4 w-16 bg-gray-700 rounded" />
<div className="h-4 w-12 bg-gray-800 rounded" />
</div>
))}
</div>
</div>
</main>
);
}
46 changes: 46 additions & 0 deletions frontend/src/components/ui/PlatformStatsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';

import { usePlatformStats } from '../../hooks/usePlatformStats';

function StatCell({ label, value }: { label: string; value: string }) {
return (
<div className="bg-gray-900 rounded-xl p-4 text-center">
<p className="text-xs text-gray-400">{label}</p>
<p className="text-lg font-bold text-white mt-1">{value}</p>
</div>
);
}

function StatCellSkeleton() {
return (
<div className="bg-gray-900 rounded-xl p-4 text-center space-y-2 animate-pulse">
<div className="h-3 w-24 bg-gray-700 rounded mx-auto" />
<div className="h-6 w-20 bg-gray-700 rounded mx-auto" />
</div>
);
}

export function PlatformStatsBanner(): JSX.Element {
const { stats, isLoading } = usePlatformStats();

if (isLoading || !stats) {
return (
<div className="grid grid-cols-3 gap-3">
<StatCellSkeleton />
<StatCellSkeleton />
<StatCellSkeleton />
</div>
);
}

return (
<div className="grid grid-cols-3 gap-3">
<StatCell label="Active Markets" value={stats.activeMarkets.toLocaleString()} />
<StatCell
label="Total Volume"
value={`${stats.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })} XLM`}
/>
<StatCell label="Total Bets Placed" value={stats.totalBets.toLocaleString()} />
</div>
);
}
116 changes: 116 additions & 0 deletions frontend/src/hooks/useCreateMarket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { nativeToScVal, Address, xdr } from '@stellar/stellar-sdk';
import type { TxStatus } from '../types';
import type { CreateMarketParams } from '../services/wallet';
import { buildContractTransaction, submitTransaction, NETWORK_PASSPHRASE } from '../../lib/stellar';
import { getConnectedAddress } from '../services/wallet';

export interface UseCreateMarketResult {
createMarket: (params: CreateMarketParams) => Promise<void>;
txStatus: TxStatus['status'];
txHash: string | null;
error: string | null;
}

function xlmToStroops(xlm: number): bigint {
const [whole, frac = ''] = xlm.toString().split('.');
return BigInt(whole) * BigInt(10_000_000) + BigInt(frac.slice(0, 7).padEnd(7, '0'));
}

function buildArgs(params: CreateMarketParams): xdr.ScVal[] {
return [
nativeToScVal(params.matchId, { type: 'string' }),
nativeToScVal(params.fighterA, { type: 'string' }),
nativeToScVal(params.fighterB, { type: 'string' }),
nativeToScVal(params.weightClass, { type: 'string' }),
nativeToScVal(params.venue, { type: 'string' }),
nativeToScVal(params.titleFight, { type: 'bool' }),
nativeToScVal(BigInt(new Date(params.scheduledAt).getTime()), { type: 'u64' }),
nativeToScVal(xlmToStroops(params.minBetXlm), { type: 'i128' }),
nativeToScVal(xlmToStroops(params.maxBetXlm), { type: 'i128' }),
nativeToScVal(params.feeBps, { type: 'u32' }),
nativeToScVal(params.lockBeforeMinutes, { type: 'u32' }),
];
}

/** Extracts the new market ID from the transaction result ScVal. */
function parseMarketId(resultXdr: string): string {
try {
const val = xdr.ScVal.fromXDR(resultXdr, 'base64');
// Contract returns the market address as a string ScVal
if (val.switch() === xdr.ScValType.scvString()) {
return val.str().toString();
}
if (val.switch() === xdr.ScValType.scvAddress()) {
return Address.fromScVal(val).toString();
}
} catch {
// fall through
}
throw new Error('Could not parse market ID from transaction result');
}

export function useCreateMarket(): UseCreateMarketResult {
const router = useRouter();
const [txStatus, setTxStatus] = useState<TxStatus['status']>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const createMarket = useCallback(async (params: CreateMarketParams) => {
const factoryAddress = process.env.NEXT_PUBLIC_MARKET_FACTORY_ADDRESS;
if (!factoryAddress) throw new Error('NEXT_PUBLIC_MARKET_FACTORY_ADDRESS not set');

const address = getConnectedAddress();
if (!address) throw new Error('Wallet not connected');

setTxStatus('pending');
setTxHash(null);
setError(null);

try {
// 1. Build + simulate
const preparedXdr = await buildContractTransaction(
address,
factoryAddress,
'create_market',
buildArgs(params),
);

// 2. Sign with Freighter
const freighter = (window as any).freighter;
if (!freighter) throw new Error('Freighter not installed');

const { signedTxXdr } = await freighter.signTransaction(preparedXdr, {
networkPassphrase: NETWORK_PASSPHRASE,
});

// 3. Submit and poll
const hash = await submitTransaction(signedTxXdr);
setTxHash(hash);

// 4. Parse market ID from result
const { SorobanRpc } = await import('@stellar/stellar-sdk');
const server = new SorobanRpc.Server(
process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet'
? 'https://soroban-rpc.stellar.org'
: 'https://soroban-testnet.stellar.org',
);
const txResult = await server.getTransaction(hash);
if (txResult.status !== 'SUCCESS') throw new Error('Transaction did not succeed');

const resultXdr = (txResult as any).returnValue
? (txResult as any).returnValue.toXDR('base64')
: '';
const marketId = parseMarketId(resultXdr);

setTxStatus('success');
router.push(`/markets/${marketId}`);
} catch (e: any) {
setTxStatus('error');
setError(e?.message ?? String(e));
}
}, [router]);

return { createMarket, txStatus, txHash, error };
}
Loading
Loading