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,