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
2 changes: 1 addition & 1 deletion .github/workflows/contract-attestation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:

- name: Install Soroban CLI (for optimization)
run: |
cargo install cargo-binstall --locked
cargo install cargo-binstall@1.11.0 --locked
cargo binstall soroban-cli --secure --locked -y

- name: Optimize WASM with soroban contract optimize
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/deploy-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ jobs:
- name: Install System Dependencies
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libudev-dev pkg-config

- name: Install Soroban CLI
# Using cargo-binstall is 10x faster than compiling from source
- name: FIXED Install cargo-binstall
run: |
cargo install cargo-binstall
cargo install cargo-audit@0.21.1 --locked
cargo install cargo-binstall@1.11.0 --locked
cargo binstall soroban-cli --secure --locked -y

- name: Build Smart Contract
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-scanning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ jobs:
cache: true

- name: Install cargo-audit
run: cargo install cargo-audit --locked
run: cargo install cargo-audit@0.21.1 --locked

- name: Run cargo audit
working-directory: ./contracts/prediction_market
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/soroban-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ jobs:
cache: true

- name: Install cargo-audit
run: cargo install cargo-audit --locked
run: cargo install cargo-audit@0.21.1 --locked

- name: Run Security Audit
working-directory: ./contracts/prediction_market
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stress-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ jobs:
override: true

- name: Install cargo-audit
run: cargo install cargo-audit
run: cargo install cargo-audit@0.21.1

- name: Run cargo audit
working-directory: contracts/prediction_market
Expand Down
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fix:
# Install development dependencies
install-deps:
@echo "📦 Installing development dependencies..."
cargo install cargo-audit
cargo install cargo-audit@0.21.1
cargo install cargo-tarpaulin
cargo install just
@echo "✅ Dependencies installed"
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ Response → UI Dashboard

---

## 📊 API Data Format (Pool Depth)

The backend returns a `pool_depth` object representing liquidity concentration per outcome. This powers the **Liquidity Heatmap** UI.

Example response from `/api/markets`:
```json
{
"markets": [
{
"id": 1,
"question": "Will Bitcoin reach $100k before 2027?",
"end_date": "2026-12-31T00:00:00.000Z",
"outcomes": ["Yes", "No"],
"total_pool": "4200",
"pool_depth": {
"0": 1000,
"1": 3200
}
}
]
}
```
*Note: `pool_depth` maps outcome indices to their respective pool liquidity.*

---

## 💡 Key Features

- 📊 **Prediction Markets** — Binary (Yes/No) and multiple choice
Expand Down
22 changes: 19 additions & 3 deletions backend/src/routes/markets.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ const eventBus = require("../bots/eventBus");
router.get("/", async (req, res) => {
try {
const result = await db.query(
"SELECT * FROM markets ORDER BY created_at DESC"
`SELECT m.*,
(SELECT jsonb_object_agg(outcome_index, depth)
FROM (SELECT outcome_index, SUM(amount) as depth
FROM bets b WHERE b.market_id = m.id GROUP BY outcome_index) sub
) AS pool_depth
FROM markets m ORDER BY created_at DESC`
);
logger.debug({ market_count: result.rows.length }, "Markets fetched");
res.json({ markets: result.rows });
// Ensure pool_depth is an object even if null in DB
const markets = result.rows.map(m => ({
...m,
pool_depth: m.pool_depth || {}
}));
res.json({ markets });
} catch (err) {
logger.error({ err }, "Failed to fetch markets");
res.status(500).json({ error: err.message });
Expand Down Expand Up @@ -93,7 +103,13 @@ const { calculateConfidenceScore } = require("../utils/analytics");
// GET /api/markets/:id
router.get("/:id", async (req, res) => {
try {
const market = await db.query("SELECT * FROM markets WHERE id = $1", [req.params.id]);
const market = await db.query(`
SELECT m.*,
(SELECT jsonb_object_agg(outcome_index, depth)
FROM (SELECT outcome_index, SUM(amount) as depth
FROM bets b WHERE b.market_id = m.id GROUP BY outcome_index) sub
) AS pool_depth
FROM markets m WHERE id = $1`, [req.params.id]);
if (!market.rows.length) {
logger.warn({ market_id: req.params.id }, "Market not found");
return res.status(404).json({ error: "Market not found" });
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/components/LiquidityHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';

interface Props {
poolDepth: Record<string, number>;
totalPool: number;
outcomeIndex: number;
}

export default function LiquidityHeatmap({ poolDepth, totalPool, outcomeIndex }: Props) {
// Depth-to-opacity mapping logic:
// 1. Get the pool size for this specific outcome.
// 2. If the total pool is 0, opacity is 0 (empty pool).
// 3. Otherwise, opacity is the percentage of this outcome's pool relative to the total pool.
const outcomePool = poolDepth[outcomeIndex] || 0;
const opacity = totalPool > 0 ? outcomePool / totalPool : 0;

// Blue for YES (index 0), Orange for NO (index 1), fallback to gray for others
let bgColorClass = "bg-gray-500";
if (outcomeIndex === 0) {
bgColorClass = "bg-blue-500";
} else if (outcomeIndex === 1) {
bgColorClass = "bg-orange-500";
}

return (
<div
data-testid={`heatmap-overlay-${outcomeIndex}`}
className={`absolute inset-0 pointer-events-none rounded-xl transition-opacity duration-300 ${bgColorClass}`}
style={{ opacity: opacity * 0.4 }} // Max 40% opacity so text remains readable
/>
);
}
36 changes: 22 additions & 14 deletions frontend/src/components/MarketCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface Market {
asset?: { code: string; issuer: string };
}

import LiquidityHeatmap from "./LiquidityHeatmap";

interface Props {
market: Market;
walletAddress: string | null;
Expand Down Expand Up @@ -164,20 +166,26 @@ export default function MarketCard({ market, walletAddress, onBetPlaced }: Props
{/* Outcomes */}
<div className="flex gap-2 flex-wrap">
{market.outcomes.map((outcome, i) => (
<button
key={i}
onClick={() => setSelectedOutcome(i)}
disabled={market.resolved || isExpired}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
${market.resolved && market.winning_outcome === i
? "bg-green-600 text-white"
: selectedOutcome === i
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
>
{outcome}
</button>
<div key={i} className="relative">
<button
onClick={() => setSelectedOutcome(i)}
disabled={market.resolved || isExpired}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors relative z-10
${market.resolved && market.winning_outcome === i
? "bg-green-600 text-white"
: selectedOutcome === i
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
>
{outcome}
</button>
<LiquidityHeatmap
poolDepth={market.pool_depth || {}}
totalPool={parseFloat(market.total_pool || "0")}
outcomeIndex={i}
/>
</div>
))}
</div>

Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/__tests__/LiquidityHeatmap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { render } from '@testing-library/react';
import LiquidityHeatmap from '../LiquidityHeatmap';

describe('LiquidityHeatmap', () => {
it('renders blue with opacity relative to total pool for YES side (index 0)', () => {
const poolDepth = { "0": 1000, "1": 3000 };
const { getByTestId } = render(
<LiquidityHeatmap poolDepth={poolDepth} totalPool={4000} outcomeIndex={0} />
);
const element = getByTestId('heatmap-overlay-0');
expect(element.className).toContain('bg-blue-500');
// For ratio 1000/4000 = 0.25, max opacity is 0.4 -> 0.25 * 0.4 = 0.1
expect(element.style.opacity).toBe('0.1');
});

it('renders orange with opacity relative to total pool for NO side (index 1)', () => {
const poolDepth = { "0": 1000, "1": 3000 };
const { getByTestId } = render(
<LiquidityHeatmap poolDepth={poolDepth} totalPool={4000} outcomeIndex={1} />
);
const element = getByTestId('heatmap-overlay-1');
expect(element.className).toContain('bg-orange-500');
// For ratio 3000/4000 = 0.75, max opacity is 0.4 -> 0.75 * 0.4 = 0.3
expect(element.style.opacity).toBe('0.3');
});

it('renders zero opacity when pool is empty (edge case)', () => {
const poolDepth = {};
const { getByTestId } = render(
<LiquidityHeatmap poolDepth={poolDepth} totalPool={0} outcomeIndex={0} />
);
const element = getByTestId('heatmap-overlay-0');
expect(element.style.opacity).toBe('0');
});

it('renders 100% relative opacity for a single bettor (edge case)', () => {
const poolDepth = { "0": 500 };
const { getByTestId } = render(
<LiquidityHeatmap poolDepth={poolDepth} totalPool={500} outcomeIndex={0} />
);
const element = getByTestId('heatmap-overlay-0');
// max opacity is 0.4, 100% relative = 0.4
expect(element.style.opacity).toBe('0.4');
});

it('does not block interactions (has pointer-events-none)', () => {
const { getByTestId } = render(
<LiquidityHeatmap poolDepth={{}} totalPool={0} outcomeIndex={0} />
);
const element = getByTestId('heatmap-overlay-0');
expect(element.className).toContain('pointer-events-none');
});
});
32 changes: 21 additions & 11 deletions frontend/src/components/mobile/TradeDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ interface Market {
resolved: boolean;
winning_outcome: number | null;
total_pool: string;
pool_depth?: Record<string, number>;
}

import LiquidityHeatmap from "../LiquidityHeatmap";

interface Props {
market: Market | null;
open: boolean;
Expand Down Expand Up @@ -180,17 +183,24 @@ export default function TradeDrawer({ market, open, onClose, walletAddress, onBe
{/* Outcome buttons */}
<div className="flex gap-3 mb-5">
{market.outcomes.map((outcome, i) => (
<button
key={i}
onClick={() => setSelectedOutcome(i)}
className={`flex-1 py-3 rounded-xl text-sm font-semibold transition-colors
${selectedOutcome === i
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
>
{outcome}
</button>
<div key={i} className="relative flex-1">
<button
onClick={() => setSelectedOutcome(i)}
className={`w-full py-3 rounded-xl text-sm font-semibold transition-colors
${selectedOutcome === i
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
} relative z-10`}
>
{outcome}
</button>
{/* Layered CSS div overlay with opacity tied to pool size */}
<LiquidityHeatmap
poolDepth={market.pool_depth || {}}
totalPool={parseFloat(market.total_pool || "0")}
outcomeIndex={i}
/>
</div>
))}
</div>

Expand Down
1 change: 1 addition & 0 deletions frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading