diff --git a/ui/lib/db/trading-activities.ts b/ui/lib/db/trading-activities.ts
new file mode 100644
index 0000000..792bc83
--- /dev/null
+++ b/ui/lib/db/trading-activities.ts
@@ -0,0 +1,368 @@
+/*
+ * Combinator - Futarchy infrastructure for your project.
+ * Copyright (C) 2026 Spice Finance Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * Questions or feature requests? Reach out:
+ * - Telegram Group: https://t.me/+Ao05jBnpEE0yZGVh
+ * - Direct: https://t.me/handsdiff
+ */
+
+import { Pool } from 'pg';
+
+/**
+ * Trading Activity Queries
+ *
+ * Read-only functions for querying wallet participation on futarchy proposal markets.
+ * Data is populated by external indexer into cmb_trade_history table.
+ */
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface TradingActivity {
+ id: number;
+ trader: string;
+ proposal_pda: string;
+ market: number;
+ is_base_to_quote: boolean;
+ amount_in: string;
+ amount_out: string;
+ tx_signature: string | null;
+ timestamp: Date;
+ price: string | null;
+}
+
+export interface WalletStats {
+ totalVolume: string;
+ totalTransactions: number;
+ uniqueProposals: number;
+}
+
+export interface LeaderboardEntry {
+ trader: string;
+ total_volume: string;
+ transaction_count: number;
+}
+
+export interface ProposalStats {
+ totalVolume: string;
+ totalTransactions: number;
+ uniqueTraders: number;
+}
+
+// ============================================================================
+// Wallet Query Functions
+// ============================================================================
+
+/**
+ * Get paginated trading activities for a wallet.
+ * Optionally filter by DAO name.
+ */
+export async function getWalletActivities(
+ pool: Pool,
+ walletAddress: string,
+ options?: { limit?: number; offset?: number; daoName?: string }
+): Promise {
+ const limit = options?.limit || 50;
+ const offset = options?.offset || 0;
+ const daoName = options?.daoName;
+
+ let query: string;
+ let params: (string | number)[];
+
+ if (daoName) {
+ // Filter by DAO name via join
+ query = `
+ SELECT
+ t.id,
+ t.trader,
+ t.proposal_pda,
+ t.market,
+ t.is_base_to_quote,
+ t.amount_in::TEXT AS amount_in,
+ t.amount_out::TEXT AS amount_out,
+ t.tx_signature,
+ t.timestamp,
+ t.price::TEXT AS price
+ FROM cmb_trade_history t
+ JOIN cmb_proposal_dao_mapping m ON t.proposal_pda = m.proposal_pda
+ JOIN cmb_daos d ON m.dao_pda = d.dao_pda
+ WHERE t.trader = $1 AND d.dao_name = $2
+ ORDER BY t.timestamp DESC
+ LIMIT $3 OFFSET $4
+ `;
+ params = [walletAddress, daoName, limit, offset];
+ } else {
+ query = `
+ SELECT
+ id,
+ trader,
+ proposal_pda,
+ market,
+ is_base_to_quote,
+ amount_in::TEXT AS amount_in,
+ amount_out::TEXT AS amount_out,
+ tx_signature,
+ timestamp,
+ price::TEXT AS price
+ FROM cmb_trade_history
+ WHERE trader = $1
+ ORDER BY timestamp DESC
+ LIMIT $2 OFFSET $3
+ `;
+ params = [walletAddress, limit, offset];
+ }
+
+ try {
+ const result = await pool.query(query, params);
+ return result.rows;
+ } catch (error) {
+ console.error('Error fetching wallet activities:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get aggregate stats for a wallet.
+ * Optionally filter by DAO name.
+ */
+export async function getWalletStats(
+ pool: Pool,
+ walletAddress: string,
+ options?: { daoName?: string }
+): Promise {
+ const daoName = options?.daoName;
+
+ let query: string;
+ let params: string[];
+
+ if (daoName) {
+ query = `
+ SELECT
+ COALESCE(SUM(t.amount_in), 0)::TEXT AS total_volume,
+ COUNT(*) AS total_transactions,
+ COUNT(DISTINCT t.proposal_pda) AS unique_proposals
+ FROM cmb_trade_history t
+ JOIN cmb_proposal_dao_mapping m ON t.proposal_pda = m.proposal_pda
+ JOIN cmb_daos d ON m.dao_pda = d.dao_pda
+ WHERE t.trader = $1 AND d.dao_name = $2
+ `;
+ params = [walletAddress, daoName];
+ } else {
+ query = `
+ SELECT
+ COALESCE(SUM(amount_in), 0)::TEXT AS total_volume,
+ COUNT(*) AS total_transactions,
+ COUNT(DISTINCT proposal_pda) AS unique_proposals
+ FROM cmb_trade_history
+ WHERE trader = $1
+ `;
+ params = [walletAddress];
+ }
+
+ try {
+ const result = await pool.query(query, params);
+ const row = result.rows[0];
+
+ return {
+ totalVolume: row.total_volume,
+ totalTransactions: parseInt(row.total_transactions),
+ uniqueProposals: parseInt(row.unique_proposals),
+ };
+ } catch (error) {
+ console.error('Error fetching wallet stats:', error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// Leaderboard Functions
+// ============================================================================
+
+/**
+ * Get top traders by volume.
+ * Optionally filter by DAO name or specific proposal.
+ */
+export async function getVolumeLeaderboard(
+ pool: Pool,
+ options?: { limit?: number; offset?: number; daoName?: string; proposalPda?: string }
+): Promise {
+ const limit = options?.limit || 50;
+ const offset = options?.offset || 0;
+ const daoName = options?.daoName;
+ const proposalPda = options?.proposalPda;
+
+ let query: string;
+ let params: (string | number)[];
+
+ if (proposalPda) {
+ // Filter by specific proposal
+ query = `
+ SELECT
+ trader,
+ SUM(amount_in)::TEXT AS total_volume,
+ COUNT(*) AS transaction_count
+ FROM cmb_trade_history
+ WHERE proposal_pda = $1
+ GROUP BY trader
+ ORDER BY SUM(amount_in) DESC
+ LIMIT $2 OFFSET $3
+ `;
+ params = [proposalPda, limit, offset];
+ } else if (daoName) {
+ // Filter by DAO name
+ query = `
+ SELECT
+ t.trader,
+ SUM(t.amount_in)::TEXT AS total_volume,
+ COUNT(*) AS transaction_count
+ FROM cmb_trade_history t
+ JOIN cmb_proposal_dao_mapping m ON t.proposal_pda = m.proposal_pda
+ JOIN cmb_daos d ON m.dao_pda = d.dao_pda
+ WHERE d.dao_name = $1
+ GROUP BY t.trader
+ ORDER BY SUM(t.amount_in) DESC
+ LIMIT $2 OFFSET $3
+ `;
+ params = [daoName, limit, offset];
+ } else {
+ // No filter - all trades
+ query = `
+ SELECT
+ trader,
+ SUM(amount_in)::TEXT AS total_volume,
+ COUNT(*) AS transaction_count
+ FROM cmb_trade_history
+ GROUP BY trader
+ ORDER BY SUM(amount_in) DESC
+ LIMIT $1 OFFSET $2
+ `;
+ params = [limit, offset];
+ }
+
+ try {
+ const result = await pool.query(query, params);
+ return result.rows.map(row => ({
+ trader: row.trader,
+ total_volume: row.total_volume,
+ transaction_count: parseInt(row.transaction_count),
+ }));
+ } catch (error) {
+ console.error('Error fetching volume leaderboard:', error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// Proposal Query Functions
+// ============================================================================
+
+/**
+ * Get all trading activities for a proposal.
+ */
+export async function getProposalActivities(
+ pool: Pool,
+ proposalPda: string,
+ options?: { limit?: number; offset?: number }
+): Promise {
+ const limit = options?.limit || 50;
+ const offset = options?.offset || 0;
+
+ const query = `
+ SELECT
+ id,
+ trader,
+ proposal_pda,
+ market,
+ is_base_to_quote,
+ amount_in::TEXT AS amount_in,
+ amount_out::TEXT AS amount_out,
+ tx_signature,
+ timestamp,
+ price::TEXT AS price
+ FROM cmb_trade_history
+ WHERE proposal_pda = $1
+ ORDER BY timestamp DESC
+ LIMIT $2 OFFSET $3
+ `;
+
+ try {
+ const result = await pool.query(query, [proposalPda, limit, offset]);
+ return result.rows;
+ } catch (error) {
+ console.error('Error fetching proposal activities:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get aggregate stats for a proposal.
+ */
+export async function getProposalStats(
+ pool: Pool,
+ proposalPda: string
+): Promise {
+ const query = `
+ SELECT
+ COALESCE(SUM(amount_in), 0)::TEXT AS total_volume,
+ COUNT(*) AS total_transactions,
+ COUNT(DISTINCT trader) AS unique_traders
+ FROM cmb_trade_history
+ WHERE proposal_pda = $1
+ `;
+
+ try {
+ const result = await pool.query(query, [proposalPda]);
+ const row = result.rows[0];
+
+ return {
+ totalVolume: row.total_volume,
+ totalTransactions: parseInt(row.total_transactions),
+ uniqueTraders: parseInt(row.unique_traders),
+ };
+ } catch (error) {
+ console.error('Error fetching proposal stats:', error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// Proposal-to-DAO Mapping Functions
+// ============================================================================
+
+/**
+ * Upsert a proposal-to-DAO mapping.
+ * Called when a proposal is created or fetched.
+ */
+export async function upsertProposalDaoMapping(
+ pool: Pool,
+ proposalPda: string,
+ daoPda: string
+): Promise {
+ const query = `
+ INSERT INTO cmb_proposal_dao_mapping (proposal_pda, dao_pda)
+ VALUES ($1, $2)
+ ON CONFLICT (proposal_pda) DO NOTHING
+ `;
+
+ try {
+ await pool.query(query, [proposalPda, daoPda]);
+ } catch (error) {
+ console.error('Error upserting proposal-dao mapping:', error);
+ throw error;
+ }
+}
diff --git a/ui/routes/dao/activity.ts b/ui/routes/dao/activity.ts
new file mode 100644
index 0000000..87d84ec
--- /dev/null
+++ b/ui/routes/dao/activity.ts
@@ -0,0 +1,200 @@
+/*
+ * Combinator - Futarchy infrastructure for your project.
+ * Copyright (C) 2026 Spice Finance Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * Questions or feature requests? Reach out:
+ * - Telegram Group: https://t.me/+Ao05jBnpEE0yZGVh
+ * - Direct: https://t.me/handsdiff
+ */
+
+/**
+ * Activity Tracking API
+ * Track wallet participation and volume on futarchy proposal markets
+ */
+
+import { Router, Request, Response } from 'express';
+
+import { getPool } from '../../lib/db';
+import { isValidSolanaAddress, isValidTokenMintAddress } from '../../lib/validation';
+import {
+ getWalletActivities,
+ getWalletStats,
+ getVolumeLeaderboard,
+ getProposalActivities,
+ getProposalStats,
+} from '../../lib/db/trading-activities';
+
+const router = Router();
+
+// ============================================================================
+// GET /dao/activity/leaderboard - Top traders by volume
+// NOTE: Must be defined before /:wallet to prevent route interception
+// ============================================================================
+
+router.get('/leaderboard', async (req: Request, res: Response) => {
+ try {
+ const { limit, offset, dao, proposal } = req.query;
+
+ const pool = getPool();
+ const parsedLimit = limit ? parseInt(limit as string, 10) : 50;
+ const parsedOffset = offset ? parseInt(offset as string, 10) : 0;
+ const daoName = dao && typeof dao === 'string' && dao.trim() ? dao.trim() : undefined;
+ const proposalPda = proposal && typeof proposal === 'string' && isValidTokenMintAddress(proposal)
+ ? proposal : undefined;
+
+ // Validate pagination params
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
+ return res.status(400).json({ error: 'limit must be between 1 and 100' });
+ }
+ if (isNaN(parsedOffset) || parsedOffset < 0) {
+ return res.status(400).json({ error: 'offset must be non-negative' });
+ }
+
+ // Validate proposal param if provided but invalid
+ if (proposal && !proposalPda) {
+ return res.status(400).json({ error: 'Invalid proposal PDA' });
+ }
+
+ const leaderboard = await getVolumeLeaderboard(pool, {
+ limit: parsedLimit,
+ offset: parsedOffset,
+ daoName,
+ proposalPda,
+ });
+
+ const response: Record = {
+ leaderboard,
+ pagination: {
+ limit: parsedLimit,
+ offset: parsedOffset,
+ hasMore: leaderboard.length === parsedLimit,
+ },
+ };
+
+ if (proposalPda) {
+ response.proposal = proposalPda;
+ } else if (daoName) {
+ response.dao = daoName;
+ }
+
+ res.json(response);
+ } catch (error) {
+ console.error('Error fetching leaderboard:', error);
+ res.status(500).json({ error: 'Failed to fetch leaderboard', details: String(error) });
+ }
+});
+
+// ============================================================================
+// GET /dao/activity/proposal/:pda - All activity on a proposal
+// NOTE: Must be defined before /:wallet to prevent route interception
+// ============================================================================
+
+router.get('/proposal/:pda', async (req: Request, res: Response) => {
+ try {
+ const { pda } = req.params;
+ const { limit, offset } = req.query;
+
+ if (!isValidTokenMintAddress(pda)) {
+ return res.status(400).json({ error: 'Invalid proposal PDA' });
+ }
+
+ const pool = getPool();
+ const parsedLimit = limit ? parseInt(limit as string, 10) : 50;
+ const parsedOffset = offset ? parseInt(offset as string, 10) : 0;
+
+ // Validate pagination params
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
+ return res.status(400).json({ error: 'limit must be between 1 and 100' });
+ }
+ if (isNaN(parsedOffset) || parsedOffset < 0) {
+ return res.status(400).json({ error: 'offset must be non-negative' });
+ }
+
+ const [stats, activities] = await Promise.all([
+ getProposalStats(pool, pda),
+ getProposalActivities(pool, pda, { limit: parsedLimit, offset: parsedOffset }),
+ ]);
+
+ res.json({
+ proposalPda: pda,
+ stats,
+ activities,
+ pagination: {
+ limit: parsedLimit,
+ offset: parsedOffset,
+ hasMore: activities.length === parsedLimit,
+ },
+ });
+ } catch (error) {
+ console.error('Error fetching proposal activity:', error);
+ res.status(500).json({ error: 'Failed to fetch proposal activity', details: String(error) });
+ }
+});
+
+// ============================================================================
+// GET /dao/activity/:wallet - Wallet's trading history + stats
+// ============================================================================
+
+router.get('/:wallet', async (req: Request, res: Response) => {
+ try {
+ const { wallet } = req.params;
+ const { limit, offset, dao } = req.query;
+
+ if (!isValidSolanaAddress(wallet)) {
+ return res.status(400).json({ error: 'Invalid wallet address' });
+ }
+
+ const pool = getPool();
+ const parsedLimit = limit ? parseInt(limit as string, 10) : 50;
+ const parsedOffset = offset ? parseInt(offset as string, 10) : 0;
+ const daoName = dao && typeof dao === 'string' && dao.trim() ? dao.trim() : undefined;
+
+ // Validate pagination params
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
+ return res.status(400).json({ error: 'limit must be between 1 and 100' });
+ }
+ if (isNaN(parsedOffset) || parsedOffset < 0) {
+ return res.status(400).json({ error: 'offset must be non-negative' });
+ }
+
+ const [stats, activities] = await Promise.all([
+ getWalletStats(pool, wallet, { daoName }),
+ getWalletActivities(pool, wallet, { limit: parsedLimit, offset: parsedOffset, daoName }),
+ ]);
+
+ const response: Record = {
+ wallet,
+ stats,
+ activities,
+ pagination: {
+ limit: parsedLimit,
+ offset: parsedOffset,
+ hasMore: activities.length === parsedLimit,
+ },
+ };
+
+ if (daoName) {
+ response.dao = daoName;
+ }
+
+ res.json(response);
+ } catch (error) {
+ console.error('Error fetching wallet activity:', error);
+ res.status(500).json({ error: 'Failed to fetch wallet activity', details: String(error) });
+ }
+});
+
+export default router;
diff --git a/ui/routes/dao/index.ts b/ui/routes/dao/index.ts
index ad95bfb..3927cb4 100644
--- a/ui/routes/dao/index.ts
+++ b/ui/routes/dao/index.ts
@@ -31,6 +31,7 @@ import DLMM from '@meteora-ag/dlmm';
import { getPool } from '../../lib/db';
import { getDaoByPda, getDaoByModeratorPda } from '../../lib/db/daos';
+import { upsertProposalDaoMapping } from '../../lib/db/trading-activities';
import { fetchAdminKeypair, AdminKeyError } from '../../lib/keyService';
import { isValidTokenMintAddress } from '../../lib/validation';
import { uploadProposalMetadata } from '../../lib/ipfs';
@@ -55,6 +56,7 @@ import queriesRouter from './queries';
import creationRouter from './creation';
import proposersRouter from './proposers';
import tradingRouter from './trading';
+import activityRouter from './activity';
import { daoLimiter, getConnection, createProvider } from './shared';
const router = Router();
@@ -67,7 +69,7 @@ router.use(daoLimiter);
router.use('/', queriesRouter);
router.use('/', creationRouter);
router.use('/', proposersRouter);
-
+router.use('/activity', activityRouter);
// NOTE: tradingRouter is mounted AFTER POST /proposal to prevent route interception
// See end of POST /proposal handler
@@ -749,6 +751,14 @@ router.post('/proposal', requireSignedHash, async (req: Request, res: Response)
console.log(`Created proposal ${proposalPda} for DAO ${dao_pda}`);
+ // Insert proposal-to-DAO mapping for activity filtering
+ try {
+ await upsertProposalDaoMapping(pool, proposalPda, dao_pda);
+ } catch (mappingError) {
+ console.warn('Failed to upsert proposal-dao mapping:', mappingError);
+ // Non-fatal - continue with response
+ }
+
// Update the proposal count cache
incrementProposalCount(dao_pda);
diff --git a/ui/routes/dao/trading.ts b/ui/routes/dao/trading.ts
index c218f4e..df28e72 100644
--- a/ui/routes/dao/trading.ts
+++ b/ui/routes/dao/trading.ts
@@ -65,6 +65,7 @@ interface TradingRequestData {
wallet: string;
operation: 'swap' | 'deposit' | 'withdraw' | 'redeem';
vaultType?: 'base' | 'quote';
+ inputAmount?: string; // Amount for activity tracking
}
// Request storage for build/execute pattern (15 min TTL)
@@ -382,6 +383,7 @@ router.post('/:proposalPda/swap/build', async (req: Request, res: Response) => {
poolAddress: poolPda.toBase58(),
wallet,
operation: 'swap',
+ inputAmount: quote.inputAmount.toString(),
});
// Serialize transaction (without signatures)
@@ -626,6 +628,7 @@ router.post('/:proposalPda/deposit/build', async (req: Request, res: Response) =
wallet,
operation: 'deposit',
vaultType,
+ inputAmount: amountBN.toString(),
});
const serializedTx = tx.serialize({ requireAllSignatures: false });
@@ -741,6 +744,7 @@ router.post('/:proposalPda/deposit/execute', async (req: Request, res: Response)
console.log(`Deposit executed for proposal ${proposalPda}: ${signature}`);
+
res.json({
success: true,
signature,
@@ -852,6 +856,7 @@ router.post('/:proposalPda/withdraw/build', async (req: Request, res: Response)
wallet,
operation: 'withdraw',
vaultType,
+ inputAmount: amountBN.toString(),
});
const serializedTx = tx.serialize({ requireAllSignatures: false });
@@ -967,6 +972,7 @@ router.post('/:proposalPda/withdraw/execute', async (req: Request, res: Response
console.log(`Withdraw executed for proposal ${proposalPda}: ${signature}`);
+
res.json({
success: true,
signature,