Skip to content
Open
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules/
.next/
out/
dist/
build/
.env
.env.local
.env.*.local
*.log
.DS_Store
coverage/
189 changes: 187 additions & 2 deletions contracts/treasury/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec};
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, Vec};

// ─── STORAGE KEYS ─────────────────────────────────────────────────────────────
// ADMIN -> Address
Expand All @@ -8,6 +8,18 @@ use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec};
// TOTAL_FEES_EARNED -> i128
// WITHDRAWAL_LOG -> Vec<(Address, i128, u64)>

#[contracttype]
#[derive(Clone, Debug)]
pub struct ProtocolConfig {
pub admin: Address,
pub fee_collector: Address,
pub default_fee_bp: u32,
pub min_bet_amount: i128,
pub max_bet_amount: i128,
pub dispute_window_sec: u64,
pub paused: bool,
}

#[contract]
pub struct Treasury;

Expand Down Expand Up @@ -42,7 +54,42 @@ impl Treasury {
/// Logs the drain. Emits EmergencyDrain event.
/// Returns total amount drained in stroops.
pub fn emergency_drain(env: Env, admin: Address, recipient: Address) -> i128 {
todo!("implement: require_auth(admin), verify protocol is paused, transfer full BALANCE, set BALANCE=0, log, emit event, return drained amount")
// 1. Authentication
admin.require_auth();

// 2. State Check — protocol must be paused
let factory: Address = env.storage().persistent().get(&symbol_short!("FACTORY")).unwrap();
let config: ProtocolConfig = env.invoke_contract(&factory, &symbol_short!("get_config"), soroban_sdk::vec![&env]);
if !config.paused {
panic!("protocol is not paused");
}

// 3. Funds Transfer — drain full balance
let amount: i128 = env.storage().persistent().get(&symbol_short!("BALANCE")).unwrap_or(0);
let token: Address = env.storage().persistent().get(&symbol_short!("TOKEN")).unwrap();
soroban_sdk::token::Client::new(&env, &token).transfer(
&env.current_contract_address(),
&recipient,
&amount,
);
env.storage().persistent().set(&symbol_short!("BALANCE"), &0_i128);

// 4. Logging & Events
let mut log: Vec<(Address, i128, u64)> = env
.storage()
.persistent()
.get(&symbol_short!("WLOG"))
.unwrap_or(soroban_sdk::vec![&env]);
log.push_back((recipient.clone(), amount, env.ledger().timestamp()));
env.storage().persistent().set(&symbol_short!("WLOG"), &log);

env.events().publish(
(symbol_short!("EmrgDrain"), recipient),
amount,
);

// 5. Return drained amount
amount
}

/// Returns current treasury XLM balance in stroops.
Expand All @@ -60,3 +107,141 @@ impl Treasury {
todo!("implement: read WITHDRAWAL_LOG from storage and return")
}
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{
testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation, Events, Ledger},
vec, IntoVal, Symbol,
};

// ── helpers ──────────────────────────────────────────────────────────────

/// Registers a Treasury contract and pre-seeds its storage so that
/// `emergency_drain` has the data it needs without calling `initialize`
/// (which is still a `todo!`).
fn setup(
env: &Env,
paused: bool,
balance: i128,
) -> (TreasuryClient, Address, Address, Address) {
let admin = Address::generate(env);
let recipient = Address::generate(env);

// Deploy a mock token
let token_admin = Address::generate(env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address();
let token = soroban_sdk::token::StellarAssetClient::new(env, &token_id);

// Deploy a mock factory whose `get_config` returns a ProtocolConfig
let factory_id = env.register(MockFactory, (admin.clone(), paused));

// Deploy Treasury and seed its storage
let treasury_id = env.register(Treasury, ());
env.as_contract(&treasury_id, || {
env.storage().persistent().set(&symbol_short!("FACTORY"), &factory_id);
env.storage().persistent().set(&symbol_short!("TOKEN"), &token_id);
env.storage().persistent().set(&symbol_short!("ADMIN"), &admin);
env.storage().persistent().set(&symbol_short!("BALANCE"), &balance);
});

// Mint `balance` tokens into the treasury contract
token.mint(&treasury_id, &balance);

let client = TreasuryClient::new(env, &treasury_id);
(client, admin, recipient, token_id)
}

// ── mock factory ─────────────────────────────────────────────────────────

#[contract]
struct MockFactory;

#[contractimpl]
impl MockFactory {
pub fn __constructor(env: Env, admin: Address, paused: bool) {
env.storage().persistent().set(&symbol_short!("admin"), &admin);
env.storage().persistent().set(&symbol_short!("paused"), &paused);
}

pub fn get_config(env: Env) -> ProtocolConfig {
let admin: Address = env.storage().persistent().get(&symbol_short!("admin")).unwrap();
let paused: bool = env.storage().persistent().get(&symbol_short!("paused")).unwrap();
ProtocolConfig {
admin: admin.clone(),
fee_collector: admin,
default_fee_bp: 200,
min_bet_amount: 1_000_000,
max_bet_amount: 100_000_000,
dispute_window_sec: 86_400,
paused,
}
}
}

// ── tests ─────────────────────────────────────────────────────────────────

#[test]
fn test_emergency_drain_success() {
let env = Env::default();
env.mock_all_auths();

let balance = 50_000_000_i128;
let (client, admin, recipient, token_id) = setup(&env, true, balance);

let drained = client.emergency_drain(&admin, &recipient);

// Return value
assert_eq!(drained, balance);

// BALANCE set to 0
let stored_balance: i128 = env.as_contract(&client.address, || {
env.storage().persistent().get(&symbol_short!("BALANCE")).unwrap()
});
assert_eq!(stored_balance, 0);

// Token actually transferred
let token = soroban_sdk::token::Client::new(&env, &token_id);
assert_eq!(token.balance(&recipient), balance);
assert_eq!(token.balance(&client.address), 0);

// Withdrawal log updated
let log: Vec<(Address, i128, u64)> = env.as_contract(&client.address, || {
env.storage().persistent().get(&symbol_short!("WLOG")).unwrap()
});
assert_eq!(log.len(), 1);
let (log_recipient, log_amount, _) = log.get(0).unwrap();
assert_eq!(log_recipient, recipient);
assert_eq!(log_amount, balance);

// EmergencyDrain event emitted
let events = env.events().all();
let found = events.iter().any(|(_, topics, data)| {
topics.contains(&symbol_short!("EmrgDrain").into_val(&env))
&& data == balance.into_val(&env)
});
assert!(found, "EmergencyDrain event not found");
}

#[test]
#[should_panic(expected = "protocol is not paused")]
fn test_emergency_drain_fails_when_not_paused() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, recipient, _) = setup(&env, false, 10_000_000);
client.emergency_drain(&admin, &recipient);
}

#[test]
#[should_panic]
fn test_emergency_drain_fails_when_unauthorized() {
let env = Env::default();
// Do NOT mock auths — a non-admin call must fail auth check.

let (client, _admin, recipient, _) = setup(&env, true, 10_000_000);
let attacker = Address::generate(&env);
client.emergency_drain(&attacker, &recipient);
}
}
19 changes: 19 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body {
overflow-x: hidden;
max-width: 100vw;
}

/* Ensure all interactive elements meet 44px touch target minimum */
@layer base {
button,
a,
[role="button"] {
min-height: 44px;
min-width: 44px;
}
}
24 changes: 24 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Navbar } from "@/components/Navbar";

export const metadata: Metadata = {
title: "BOXMEOUT — Boxing Prediction Market",
description: "Decentralized boxing prediction market on Stellar",
};

export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-950 text-white min-h-screen overflow-x-hidden">
<Navbar />
<main className="max-w-7xl mx-auto px-4 py-6">{children}</main>
</body>
</html>
);
}
69 changes: 63 additions & 6 deletions frontend/app/markets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,73 @@ import { MarketOddsBar } from "@/components/MarketOddsBar";
import { MarketOddsChart } from "@/components/MarketOddsChart";
import { MarketStatusBadge } from "@/components/MarketStatusBadge";
import { CountdownTimer } from "@/components/CountdownTimer";
import { Market } from "@/lib/api";

interface Props {
params: { id: string };
}

/**
* Market detail page — server component.
* Renders fighter cards, live odds bar, betting interface, and bet history.
* Passes market_id down to client components for real-time polling.
*/
/** Placeholder market for layout rendering until API is wired up. */
function placeholderMarket(id: string): Market {
return {
id,
contractAddress: "",
fighterA: { name: "Fighter A", record: "20-0", nationality: "USA", weightClass: "Heavyweight" },
fighterB: { name: "Fighter B", record: "18-2", nationality: "UK", weightClass: "Heavyweight" },
scheduledAt: new Date(Date.now() + 86400000).toISOString(),
bettingEndsAt: new Date(Date.now() + 43200000).toISOString(),
status: "Open",
outcome: null,
poolA: "0",
poolB: "0",
totalPool: "0",
oracleAddress: "",
createdBy: "",
};
}

export default async function MarketDetailPage({ params }: Props): Promise<JSX.Element> {
throw new Error("Not implemented");
const market = placeholderMarket(params.id);
const poolA = BigInt(market.poolA);
const poolB = BigInt(market.poolB);
const total = poolA + poolB;
const oddsA = total === BigInt(0) ? 50 : Number((poolA * BigInt(100)) / total);
const oddsB = 100 - oddsA;

return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-bold text-white">
{market.fighterA.name} vs {market.fighterB.name}
</h1>
<MarketStatusBadge status={market.status} />
</div>

<CountdownTimer
targetTimestamp={Math.floor(new Date(market.bettingEndsAt).getTime() / 1000)}
label="Betting closes in"
/>

{/* Fighter cards — stack on mobile, side-by-side on md+ */}
<div className="flex flex-col md:flex-row gap-4">
<FighterCard fighter={market.fighterA} side="A" poolAmount={poolA} impliedOdds={oddsA} />
<FighterCard fighter={market.fighterB} side="B" poolAmount={poolB} impliedOdds={oddsB} />
</div>

{/* Odds bar */}
<MarketOddsBar
poolA={poolA}
poolB={poolB}
fighterAName={market.fighterA.name}
fighterBName={market.fighterB.name}
/>

{/* Chart — full width */}
<MarketOddsChart marketId={market.id} historicalOdds={[]} />

{/* Betting interface — full width below chart */}
<BettingInterface market={market} onBetPlaced={() => {}} />
</div>
);
}
46 changes: 39 additions & 7 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
/**
* Home page — server component.
* Fetches active markets on the server and passes them to MarketList.
* Shows three tabs: Active, Upcoming, Resolved.
*/
export default async function HomePage(): Promise<JSX.Element> {
throw new Error("Not implemented");
"use client";
import { useState } from "react";
import { MarketList } from "@/components/MarketList";
import { MarketStatus } from "@/lib/api";

const TABS: { label: string; status: MarketStatus }[] = [
{ label: "Active", status: "Open" },
{ label: "Upcoming", status: "Locked" },
{ label: "Resolved", status: "Resolved" },
];

export default function HomePage(): JSX.Element {
const [tab, setTab] = useState<MarketStatus>("Open");

return (
<div>
<h1 className="text-2xl font-bold text-white mb-6">Boxing Markets</h1>

{/* Tabs — full-width on mobile, auto-width on desktop */}
<div className="flex border-b border-gray-700 mb-6 overflow-x-auto">
{TABS.map(({ label, status }) => (
<button
key={status}
onClick={() => setTab(status)}
className={`flex-1 md:flex-none px-4 min-h-[44px] text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
tab === status
? "border-amber-400 text-amber-400"
: "border-transparent text-gray-400 hover:text-white"
}`}
>
{label}
</button>
))}
</div>

{/* MarketList handles the responsive grid (1-col mobile, 2-col md, 3-col lg) */}
<MarketList markets={[]} isLoading={false} filter={tab} />
</div>
);
}
Loading