From 097eabe335d6b47eaf54b72367a25d840c324f65 Mon Sep 17 00:00:00 2001 From: dylanvu Date: Tue, 20 Jan 2026 17:02:47 -0500 Subject: [PATCH 1/3] fairscale requested endpoints --- ui/lib/db/trading-activities.ts | 368 +++++++++ ui/routes/dao/activity.ts | 200 +++++ ui/routes/dao/index.ts | 13 + ui/routes/dao/trading.ts | 1268 +++++++++++++++++++++++++++++++ 4 files changed, 1849 insertions(+) create mode 100644 ui/lib/db/trading-activities.ts create mode 100644 ui/routes/dao/activity.ts create mode 100644 ui/routes/dao/trading.ts 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 91ea6cf..4d4f41e 100644 --- a/ui/routes/dao/index.ts +++ b/ui/routes/dao/index.ts @@ -27,6 +27,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'; @@ -49,6 +50,8 @@ import { 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(); @@ -61,6 +64,8 @@ router.use(daoLimiter); router.use('/', queriesRouter); router.use('/', creationRouter); router.use('/', proposersRouter); +router.use('/activity', activityRouter); +router.use('/trading', tradingRouter); // ============================================================================ // Proposal Lifecycle Routes @@ -736,6 +741,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 new file mode 100644 index 0000000..df28e72 --- /dev/null +++ b/ui/routes/dao/trading.ts @@ -0,0 +1,1268 @@ +/* + * 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 + */ + +/** + * Trading API routes for third-party integration + * Enables programmatic trading on futarchy proposal markets + */ + +import { Router, Request, Response } from 'express'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { AnchorProvider, BN } from '@coral-xyz/anchor'; +import { futarchy, VaultType } from '@zcomb/programs-sdk'; +import { + getAssociatedTokenAddress, + getAccount, + createAssociatedTokenAccountInstruction, + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import bs58 from 'bs58'; + +import { isValidSolanaAddress } from '../../lib/validation'; +import { getConnection } from './shared'; +import { RequestStorage, REQUEST_EXPIRY } from '../liquidity/shared/request-storage'; +import { + deserializeTransaction, + computeTransactionHash, + verifyWalletSignature, + verifyTransactionIntegrity, + verifyBlockhash, + verifyFeePayer, +} from '../liquidity/shared/tx-verification'; + +const router = Router(); + +// ============================================================================ +// Types +// ============================================================================ + +interface TradingRequestData { + timestamp: number; + poolAddress: string; + hash: string; + proposalPda: string; + 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) +const tradingStorage = new RequestStorage(); + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function createReadOnlyClient(connection: ReturnType) { + const readProvider = new AnchorProvider( + connection, + { + publicKey: PublicKey.default, + signTransaction: async (tx: Transaction) => tx, + signAllTransactions: async (txs: Transaction[]) => txs, + } as any, + { commitment: 'confirmed' } + ); + return new futarchy.FutarchyClient(readProvider); +} + +function getVaultType(type: string): VaultType { + if (type === 'base') return VaultType.Base; + if (type === 'quote') return VaultType.Quote; + throw new Error(`Invalid vault type: ${type}. Must be 'base' or 'quote'`); +} + +async function getTokenProgramForMint( + connection: ReturnType, + mint: PublicKey +): Promise { + const accountInfo = await connection.getAccountInfo(mint); + if (!accountInfo) { + throw new Error(`Mint account not found: ${mint.toBase58()}`); + } + if (accountInfo.owner.equals(TOKEN_2022_PROGRAM_ID)) { + return TOKEN_2022_PROGRAM_ID; + } + return TOKEN_PROGRAM_ID; +} + +// ============================================================================ +// GET /dao/proposal/:proposalPda/market-status +// Returns TWAP values, spot prices, and leading option +// ============================================================================ + +router.get('/:proposalPda/market-status', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + // Fetch proposal from chain + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + // Parse proposal state + const { state, winningIdx } = futarchy.parseProposalState(proposal.state); + + // Get all valid pools from proposal + const numOptions = proposal.numOptions; + const validPools = proposal.pools + .slice(0, numOptions) + .filter((pool: PublicKey) => !pool.equals(PublicKey.default)); + + // Fetch spot prices and TWAP for each pool + const poolData = await Promise.all( + validPools.map(async (poolPda: PublicKey, index: number) => { + try { + const poolAccount = await client.amm.fetchPool(poolPda); + const spotPrice = await client.amm.fetchSpotPrice(poolPda); + const twap = await client.amm.fetchTwap(poolPda); + + return { + index, + poolPda: poolPda.toBase58(), + spotPrice: spotPrice.toString(), + twap: twap ? twap.toString() : null, + oracle: { + createdAt: Number(poolAccount.oracle.createdAtUnixTime), + warmupDuration: Number(poolAccount.oracle.warmupDuration), + lastUpdate: Number(poolAccount.oracle.lastUpdateUnixTime), + }, + }; + } catch (err) { + return { + index, + poolPda: poolPda.toBase58(), + error: String(err), + }; + } + }) + ); + + // Calculate leading option based on TWAP values + let leadingOption: number | null = null; + let highestTwap: BN | null = null; + + for (const pool of poolData) { + if ('twap' in pool && pool.twap) { + const twapBN = new BN(pool.twap); + if (!highestTwap || twapBN.gt(highestTwap)) { + highestTwap = twapBN; + leadingOption = pool.index; + } + } + } + + // Calculate time remaining + const now = Math.floor(Date.now() / 1000); + const createdAt = Number(proposal.createdAt?.toString() || 0); + const length = Number(proposal.config?.length || 0); + const endTime = createdAt + length; + const timeRemaining = Math.max(0, endTime - now); + + res.json({ + proposalPda, + state, + winningIndex: state === 'resolved' ? winningIdx : undefined, + numOptions, + pools: poolData, + leadingOption, + timing: { + createdAt, + length, + endTime, + timeRemaining, + hasEnded: timeRemaining === 0, + }, + }); + } catch (error) { + console.error('Error fetching market status:', error); + res.status(500).json({ error: 'Failed to fetch market status', details: String(error) }); + } +}); + +// ============================================================================ +// GET /dao/proposal/:proposalPda/quote +// Get swap quote for a specific pool +// ============================================================================ + +router.get('/:proposalPda/quote', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { poolIndex, swapAToB, inputAmount } = req.query; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (poolIndex === undefined || swapAToB === undefined || !inputAmount) { + return res.status(400).json({ + error: 'Missing required query params: poolIndex, swapAToB, inputAmount', + }); + } + + const poolIndexNum = parseInt(poolIndex as string); + const swapAToBBool = swapAToB === 'true'; + const inputAmountBN = new BN(inputAmount as string); + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + // Fetch proposal to get pool address + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + if (poolIndexNum >= proposal.numOptions) { + return res.status(400).json({ + error: `Invalid pool index: ${poolIndexNum}. Proposal has ${proposal.numOptions} options.`, + }); + } + + const poolPda = proposal.pools[poolIndexNum]; + if (poolPda.equals(PublicKey.default)) { + return res.status(400).json({ error: `Pool ${poolIndexNum} is not initialized` }); + } + + // Get quote from AMM + const quote = await client.amm.quote(poolPda, swapAToBBool, inputAmountBN); + + res.json({ + proposalPda, + poolIndex: poolIndexNum, + poolPda: poolPda.toBase58(), + swapAToB: swapAToBBool, + inputAmount: quote.inputAmount.toString(), + outputAmount: quote.outputAmount.toString(), + minOutputAmount: quote.minOutputAmount.toString(), + feeAmount: quote.feeAmount.toString(), + priceImpact: quote.priceImpact, + spotPriceBefore: quote.spotPriceBefore.toString(), + spotPriceAfter: quote.spotPriceAfter.toString(), + }); + } catch (error) { + console.error('Error getting swap quote:', error); + res.status(500).json({ error: 'Failed to get swap quote', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/swap/build +// Build a swap transaction for user to sign +// ============================================================================ + +router.post('/:proposalPda/swap/build', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { wallet, poolIndex, swapAToB, inputAmount, slippageBps } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!wallet || !isValidSolanaAddress(wallet)) { + return res.status(400).json({ error: 'Invalid or missing wallet' }); + } + + if (poolIndex === undefined || swapAToB === undefined || !inputAmount) { + return res.status(400).json({ + error: 'Missing required fields: poolIndex, swapAToB, inputAmount', + }); + } + + const poolIndexNum = parseInt(poolIndex); + const slippageBpsNum = slippageBps ? parseInt(slippageBps) : 200; // Default 2% + const inputAmountBN = new BN(inputAmount.toString()); + const userPublicKey = new PublicKey(wallet); + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + // Fetch proposal to get pool address + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + // Check proposal is in pending state + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + return res.status(400).json({ + error: 'Proposal is not active', + state, + message: 'Trading is only available when proposal is in pending state', + }); + } + + if (poolIndexNum >= proposal.numOptions) { + return res.status(400).json({ + error: `Invalid pool index: ${poolIndexNum}. Proposal has ${proposal.numOptions} options.`, + }); + } + + const poolPda = proposal.pools[poolIndexNum]; + if (poolPda.equals(PublicKey.default)) { + return res.status(400).json({ error: `Pool ${poolIndexNum} is not initialized` }); + } + + // Convert basis points to percent for SDK + const slippagePercent = slippageBpsNum / 100; + + // Build swap transaction using SDK + const { builder, quote } = await client.amm.swapWithSlippage( + userPublicKey, + poolPda, + swapAToB, + inputAmountBN, + slippagePercent, + { autoCreateTokenAccounts: true } + ); + + // Build transaction + const tx = await builder.transaction(); + const { blockhash } = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = userPublicKey; + + // Compute hash for integrity verification + const hash = computeTransactionHash(tx); + + // Store request data + const requestId = tradingStorage.generateRequestId(); + tradingStorage.set(requestId, { + hash, + proposalPda, + poolAddress: poolPda.toBase58(), + wallet, + operation: 'swap', + inputAmount: quote.inputAmount.toString(), + }); + + // Serialize transaction (without signatures) + const serializedTx = tx.serialize({ requireAllSignatures: false }); + + res.json({ + requestId, + transaction: bs58.encode(serializedTx), + expiresAt: Date.now() + REQUEST_EXPIRY.BUILD, + quote: { + inputAmount: quote.inputAmount.toString(), + outputAmount: quote.outputAmount.toString(), + minOutputAmount: quote.minOutputAmount.toString(), + priceImpact: quote.priceImpact, + }, + }); + } catch (error) { + console.error('Error building swap transaction:', error); + res.status(500).json({ error: 'Failed to build swap transaction', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/swap/execute +// Execute a signed swap transaction +// ============================================================================ + +router.post('/:proposalPda/swap/execute', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { requestId, signedTransaction } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!requestId || !signedTransaction) { + return res.status(400).json({ error: 'Missing required fields: requestId, signedTransaction' }); + } + + // Retrieve stored request + const storedData = tradingStorage.get(requestId); + if (!storedData) { + return res.status(400).json({ error: 'Request not found or expired' }); + } + + if (tradingStorage.isExpired(requestId, REQUEST_EXPIRY.CONFIRM)) { + tradingStorage.delete(requestId); + return res.status(400).json({ error: 'Request has expired' }); + } + + if (storedData.operation !== 'swap') { + return res.status(400).json({ error: 'Invalid request type' }); + } + + if (storedData.proposalPda !== proposalPda) { + return res.status(400).json({ error: 'Proposal PDA mismatch' }); + } + + const connection = getConnection(); + + // Verify proposal is still pending before executing swap + const readClient = createReadOnlyClient(connection); + const proposal = await readClient.fetchProposal(new PublicKey(storedData.proposalPda)); + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + tradingStorage.delete(requestId); + return res.status(400).json({ + error: 'Proposal is no longer pending', + currentState: state + }); + } + + const userPublicKey = new PublicKey(storedData.wallet); + + // Deserialize and verify transaction + let transaction: Transaction; + try { + transaction = deserializeTransaction(signedTransaction); + } catch (err) { + return res.status(400).json({ error: 'Failed to deserialize transaction' }); + } + + // Verify fee payer + const feePayerResult = verifyFeePayer(transaction, userPublicKey); + if (!feePayerResult.success) { + return res.status(400).json({ error: feePayerResult.error }); + } + + // Verify user signature + const signatureResult = verifyWalletSignature(transaction, userPublicKey, 'User wallet'); + if (!signatureResult.success) { + return res.status(400).json({ error: signatureResult.error }); + } + + // Verify transaction integrity + const integrityResult = verifyTransactionIntegrity(transaction, storedData.hash); + if (!integrityResult.success) { + return res.status(400).json({ error: integrityResult.error }); + } + + // Verify blockhash + const blockhashResult = await verifyBlockhash(connection, transaction); + if (!blockhashResult.success) { + return res.status(400).json({ error: blockhashResult.error }); + } + + // Get blockhash info for robust confirmation (prevents hanging) + const blockhash = transaction.recentBlockhash!; + const { lastValidBlockHeight } = await connection.getLatestBlockhash(); + + // Send transaction + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation with blockhash-based timeout (prevents indefinite hang) + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed' + ); + + // Clean up + tradingStorage.delete(requestId); + + console.log(`Swap executed for proposal ${proposalPda}: ${signature}`); + + res.json({ + success: true, + signature, + proposalPda, + poolPda: storedData.poolAddress, + }); + } catch (error) { + console.error('Error executing swap:', error); + res.status(500).json({ error: 'Failed to execute swap', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/deposit/build +// Build a deposit (split) transaction +// ============================================================================ + +router.post('/:proposalPda/deposit/build', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { wallet, vaultType, amount } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!wallet || !isValidSolanaAddress(wallet)) { + return res.status(400).json({ error: 'Invalid or missing wallet' }); + } + + if (!vaultType || !['base', 'quote'].includes(vaultType)) { + return res.status(400).json({ error: 'Invalid vaultType. Must be "base" or "quote"' }); + } + + if (!amount) { + return res.status(400).json({ error: 'Missing required field: amount' }); + } + + const amountBN = new BN(amount.toString()); + const userPublicKey = new PublicKey(wallet); + const vt = getVaultType(vaultType); + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + // Fetch proposal + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + // Check proposal is in pending state + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + return res.status(400).json({ + error: 'Proposal is not active', + state, + message: 'Deposits are only available when proposal is in pending state', + }); + } + + const vaultPda = proposal.vault; + const numOptions = proposal.numOptions; + + // Build pre-instructions for ATAs if needed + const preInstructions: any[] = []; + for (let i = 0; i < numOptions; i++) { + const [condMint] = client.vault.deriveConditionalMint(vaultPda, vt, i); + const programId = await getTokenProgramForMint(connection, condMint); + const ata = await getAssociatedTokenAddress(condMint, userPublicKey, false, programId); + + try { + await getAccount(connection, ata, 'confirmed', programId); + } catch { + preInstructions.push( + createAssociatedTokenAccountInstruction( + userPublicKey, + ata, + userPublicKey, + condMint, + programId, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + ); + } + } + + // Build deposit (split) transaction + const builder = await client.vault.deposit(userPublicKey, vaultPda, vt, amountBN); + if (preInstructions.length > 0) { + builder.preInstructions(preInstructions); + } + + const tx = await builder.transaction(); + const { blockhash } = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = userPublicKey; + + // Compute hash for integrity verification + const hash = computeTransactionHash(tx); + + // Store request data + const requestId = tradingStorage.generateRequestId(); + tradingStorage.set(requestId, { + hash, + proposalPda, + poolAddress: vaultPda.toBase58(), + wallet, + operation: 'deposit', + vaultType, + inputAmount: amountBN.toString(), + }); + + const serializedTx = tx.serialize({ requireAllSignatures: false }); + + res.json({ + requestId, + transaction: bs58.encode(serializedTx), + expiresAt: Date.now() + REQUEST_EXPIRY.BUILD, + vaultPda: vaultPda.toBase58(), + vaultType, + amount: amountBN.toString(), + }); + } catch (error) { + console.error('Error building deposit transaction:', error); + res.status(500).json({ error: 'Failed to build deposit transaction', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/deposit/execute +// Execute a signed deposit (split) transaction +// ============================================================================ + +router.post('/:proposalPda/deposit/execute', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { requestId, signedTransaction } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!requestId || !signedTransaction) { + return res.status(400).json({ error: 'Missing required fields: requestId, signedTransaction' }); + } + + const storedData = tradingStorage.get(requestId); + if (!storedData) { + return res.status(400).json({ error: 'Request not found or expired' }); + } + + if (tradingStorage.isExpired(requestId, REQUEST_EXPIRY.CONFIRM)) { + tradingStorage.delete(requestId); + return res.status(400).json({ error: 'Request has expired' }); + } + + if (storedData.operation !== 'deposit') { + return res.status(400).json({ error: 'Invalid request type' }); + } + + if (storedData.proposalPda !== proposalPda) { + return res.status(400).json({ error: 'Proposal PDA mismatch' }); + } + + const connection = getConnection(); + + // Verify proposal is still pending before executing deposit + const readClient = createReadOnlyClient(connection); + const proposal = await readClient.fetchProposal(new PublicKey(storedData.proposalPda)); + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + tradingStorage.delete(requestId); + return res.status(400).json({ + error: 'Proposal is no longer pending', + currentState: state + }); + } + + const userPublicKey = new PublicKey(storedData.wallet); + + let transaction: Transaction; + try { + transaction = deserializeTransaction(signedTransaction); + } catch (err) { + return res.status(400).json({ error: 'Failed to deserialize transaction' }); + } + + const feePayerResult = verifyFeePayer(transaction, userPublicKey); + if (!feePayerResult.success) { + return res.status(400).json({ error: feePayerResult.error }); + } + + const signatureResult = verifyWalletSignature(transaction, userPublicKey, 'User wallet'); + if (!signatureResult.success) { + return res.status(400).json({ error: signatureResult.error }); + } + + const integrityResult = verifyTransactionIntegrity(transaction, storedData.hash); + if (!integrityResult.success) { + return res.status(400).json({ error: integrityResult.error }); + } + + const blockhashResult = await verifyBlockhash(connection, transaction); + if (!blockhashResult.success) { + return res.status(400).json({ error: blockhashResult.error }); + } + + // Get blockhash info for robust confirmation (prevents hanging) + const blockhash = transaction.recentBlockhash!; + const { lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation with blockhash-based timeout (prevents indefinite hang) + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed' + ); + tradingStorage.delete(requestId); + + console.log(`Deposit executed for proposal ${proposalPda}: ${signature}`); + + + res.json({ + success: true, + signature, + proposalPda, + vaultPda: storedData.poolAddress, + vaultType: storedData.vaultType, + }); + } catch (error) { + console.error('Error executing deposit:', error); + res.status(500).json({ error: 'Failed to execute deposit', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/withdraw/build +// Build a withdraw (merge) transaction +// ============================================================================ + +router.post('/:proposalPda/withdraw/build', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { wallet, vaultType, amount } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!wallet || !isValidSolanaAddress(wallet)) { + return res.status(400).json({ error: 'Invalid or missing wallet' }); + } + + if (!vaultType || !['base', 'quote'].includes(vaultType)) { + return res.status(400).json({ error: 'Invalid vaultType. Must be "base" or "quote"' }); + } + + if (!amount) { + return res.status(400).json({ error: 'Missing required field: amount' }); + } + + const amountBN = new BN(amount.toString()); + const userPublicKey = new PublicKey(wallet); + const vt = getVaultType(vaultType); + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + return res.status(400).json({ + error: 'Proposal is not active', + state, + message: 'Withdrawals are only available when proposal is in pending state', + }); + } + + const vaultPda = proposal.vault; + + // Build pre-instruction for underlying mint ATA if needed + const preInstructions: any[] = []; + const vault = await client.vault.fetchVault(vaultPda); + const mintInfo = vt === VaultType.Base ? vault.baseMint : vault.quoteMint; + const underlyingMint = 'address' in mintInfo ? mintInfo.address : (mintInfo as PublicKey); + const programId = await getTokenProgramForMint(connection, underlyingMint); + const ata = await getAssociatedTokenAddress(underlyingMint, userPublicKey, false, programId); + + try { + await getAccount(connection, ata, 'confirmed', programId); + } catch { + preInstructions.push( + createAssociatedTokenAccountInstruction( + userPublicKey, + ata, + userPublicKey, + underlyingMint, + programId, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + ); + } + + const builder = await client.vault.withdraw(userPublicKey, vaultPda, vt, amountBN); + if (preInstructions.length > 0) { + builder.preInstructions(preInstructions); + } + + const tx = await builder.transaction(); + const { blockhash } = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = userPublicKey; + + const hash = computeTransactionHash(tx); + + const requestId = tradingStorage.generateRequestId(); + tradingStorage.set(requestId, { + hash, + proposalPda, + poolAddress: vaultPda.toBase58(), + wallet, + operation: 'withdraw', + vaultType, + inputAmount: amountBN.toString(), + }); + + const serializedTx = tx.serialize({ requireAllSignatures: false }); + + res.json({ + requestId, + transaction: bs58.encode(serializedTx), + expiresAt: Date.now() + REQUEST_EXPIRY.BUILD, + vaultPda: vaultPda.toBase58(), + vaultType, + amount: amountBN.toString(), + }); + } catch (error) { + console.error('Error building withdraw transaction:', error); + res.status(500).json({ error: 'Failed to build withdraw transaction', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/withdraw/execute +// Execute a signed withdraw (merge) transaction +// ============================================================================ + +router.post('/:proposalPda/withdraw/execute', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { requestId, signedTransaction } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!requestId || !signedTransaction) { + return res.status(400).json({ error: 'Missing required fields: requestId, signedTransaction' }); + } + + const storedData = tradingStorage.get(requestId); + if (!storedData) { + return res.status(400).json({ error: 'Request not found or expired' }); + } + + if (tradingStorage.isExpired(requestId, REQUEST_EXPIRY.CONFIRM)) { + tradingStorage.delete(requestId); + return res.status(400).json({ error: 'Request has expired' }); + } + + if (storedData.operation !== 'withdraw') { + return res.status(400).json({ error: 'Invalid request type' }); + } + + if (storedData.proposalPda !== proposalPda) { + return res.status(400).json({ error: 'Proposal PDA mismatch' }); + } + + const connection = getConnection(); + + // Verify proposal is still pending before executing withdraw + const readClient = createReadOnlyClient(connection); + const proposal = await readClient.fetchProposal(new PublicKey(storedData.proposalPda)); + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'pending') { + tradingStorage.delete(requestId); + return res.status(400).json({ + error: 'Proposal is no longer pending', + currentState: state + }); + } + + const userPublicKey = new PublicKey(storedData.wallet); + + let transaction: Transaction; + try { + transaction = deserializeTransaction(signedTransaction); + } catch (err) { + return res.status(400).json({ error: 'Failed to deserialize transaction' }); + } + + const feePayerResult = verifyFeePayer(transaction, userPublicKey); + if (!feePayerResult.success) { + return res.status(400).json({ error: feePayerResult.error }); + } + + const signatureResult = verifyWalletSignature(transaction, userPublicKey, 'User wallet'); + if (!signatureResult.success) { + return res.status(400).json({ error: signatureResult.error }); + } + + const integrityResult = verifyTransactionIntegrity(transaction, storedData.hash); + if (!integrityResult.success) { + return res.status(400).json({ error: integrityResult.error }); + } + + const blockhashResult = await verifyBlockhash(connection, transaction); + if (!blockhashResult.success) { + return res.status(400).json({ error: blockhashResult.error }); + } + + // Get blockhash info for robust confirmation (prevents hanging) + const blockhash = transaction.recentBlockhash!; + const { lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation with blockhash-based timeout (prevents indefinite hang) + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed' + ); + tradingStorage.delete(requestId); + + console.log(`Withdraw executed for proposal ${proposalPda}: ${signature}`); + + + res.json({ + success: true, + signature, + proposalPda, + vaultPda: storedData.poolAddress, + vaultType: storedData.vaultType, + }); + } catch (error) { + console.error('Error executing withdraw:', error); + res.status(500).json({ error: 'Failed to execute withdraw', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/redeem/build +// Build a redeem transaction (for resolved proposals) +// ============================================================================ + +router.post('/:proposalPda/redeem/build', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { wallet, vaultType } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!wallet || !isValidSolanaAddress(wallet)) { + return res.status(400).json({ error: 'Invalid or missing wallet' }); + } + + if (!vaultType || !['base', 'quote'].includes(vaultType)) { + return res.status(400).json({ error: 'Invalid vaultType. Must be "base" or "quote"' }); + } + + const userPublicKey = new PublicKey(wallet); + const vt = getVaultType(vaultType); + + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + const { state, winningIdx } = futarchy.parseProposalState(proposal.state); + if (state !== 'resolved') { + return res.status(400).json({ + error: 'Proposal is not resolved', + state, + message: 'Redemptions are only available after proposal is resolved', + }); + } + + const vaultPda = proposal.vault; + + // Build pre-instruction for underlying mint ATA if needed + const preInstructions: any[] = []; + const vault = await client.vault.fetchVault(vaultPda); + const mintInfo = vt === VaultType.Base ? vault.baseMint : vault.quoteMint; + const underlyingMint = 'address' in mintInfo ? mintInfo.address : (mintInfo as PublicKey); + const programId = await getTokenProgramForMint(connection, underlyingMint); + const ata = await getAssociatedTokenAddress(underlyingMint, userPublicKey, false, programId); + + try { + await getAccount(connection, ata, 'confirmed', programId); + } catch { + preInstructions.push( + createAssociatedTokenAccountInstruction( + userPublicKey, + ata, + userPublicKey, + underlyingMint, + programId, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + ); + } + + const builder = await client.vault.redeemWinnings(userPublicKey, vaultPda, vt); + if (preInstructions.length > 0) { + builder.preInstructions(preInstructions); + } + + const tx = await builder.transaction(); + const { blockhash } = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = userPublicKey; + + const hash = computeTransactionHash(tx); + + const requestId = tradingStorage.generateRequestId(); + tradingStorage.set(requestId, { + hash, + proposalPda, + poolAddress: vaultPda.toBase58(), + wallet, + operation: 'redeem', + vaultType, + }); + + const serializedTx = tx.serialize({ requireAllSignatures: false }); + + res.json({ + requestId, + transaction: bs58.encode(serializedTx), + expiresAt: Date.now() + REQUEST_EXPIRY.BUILD, + vaultPda: vaultPda.toBase58(), + vaultType, + winningIndex: winningIdx, + }); + } catch (error) { + console.error('Error building redeem transaction:', error); + res.status(500).json({ error: 'Failed to build redeem transaction', details: String(error) }); + } +}); + +// ============================================================================ +// POST /dao/proposal/:proposalPda/redeem/execute +// Execute a signed redeem transaction +// ============================================================================ + +router.post('/:proposalPda/redeem/execute', async (req: Request, res: Response) => { + try { + const { proposalPda } = req.params; + const { requestId, signedTransaction } = req.body; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!requestId || !signedTransaction) { + return res.status(400).json({ error: 'Missing required fields: requestId, signedTransaction' }); + } + + const storedData = tradingStorage.get(requestId); + if (!storedData) { + return res.status(400).json({ error: 'Request not found or expired' }); + } + + if (tradingStorage.isExpired(requestId, REQUEST_EXPIRY.CONFIRM)) { + tradingStorage.delete(requestId); + return res.status(400).json({ error: 'Request has expired' }); + } + + if (storedData.operation !== 'redeem') { + return res.status(400).json({ error: 'Invalid request type' }); + } + + if (storedData.proposalPda !== proposalPda) { + return res.status(400).json({ error: 'Proposal PDA mismatch' }); + } + + const connection = getConnection(); + + // Verify proposal is resolved before executing redeem + const readClient = createReadOnlyClient(connection); + const proposal = await readClient.fetchProposal(new PublicKey(storedData.proposalPda)); + const { state } = futarchy.parseProposalState(proposal.state); + if (state !== 'resolved') { + tradingStorage.delete(requestId); + return res.status(400).json({ + error: 'Proposal is not resolved', + currentState: state + }); + } + + const userPublicKey = new PublicKey(storedData.wallet); + + let transaction: Transaction; + try { + transaction = deserializeTransaction(signedTransaction); + } catch (err) { + return res.status(400).json({ error: 'Failed to deserialize transaction' }); + } + + const feePayerResult = verifyFeePayer(transaction, userPublicKey); + if (!feePayerResult.success) { + return res.status(400).json({ error: feePayerResult.error }); + } + + const signatureResult = verifyWalletSignature(transaction, userPublicKey, 'User wallet'); + if (!signatureResult.success) { + return res.status(400).json({ error: signatureResult.error }); + } + + const integrityResult = verifyTransactionIntegrity(transaction, storedData.hash); + if (!integrityResult.success) { + return res.status(400).json({ error: integrityResult.error }); + } + + const blockhashResult = await verifyBlockhash(connection, transaction); + if (!blockhashResult.success) { + return res.status(400).json({ error: blockhashResult.error }); + } + + // Get blockhash info for robust confirmation (prevents hanging) + const blockhash = transaction.recentBlockhash!; + const { lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation with blockhash-based timeout (prevents indefinite hang) + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed' + ); + tradingStorage.delete(requestId); + + console.log(`Redeem executed for proposal ${proposalPda}: ${signature}`); + + res.json({ + success: true, + signature, + proposalPda, + vaultPda: storedData.poolAddress, + vaultType: storedData.vaultType, + }); + } catch (error) { + console.error('Error executing redeem:', error); + res.status(500).json({ error: 'Failed to execute redeem', details: String(error) }); + } +}); + +// ============================================================================ +// GET /dao/proposal/:proposalPda/balances/:wallet +// Get user balances for both base and quote vaults +// ============================================================================ + +router.get('/:proposalPda/balances/:wallet', async (req: Request, res: Response) => { + try { + const { proposalPda, wallet } = req.params; + + if (!isValidSolanaAddress(proposalPda)) { + return res.status(400).json({ error: 'Invalid proposal PDA' }); + } + + if (!isValidSolanaAddress(wallet)) { + return res.status(400).json({ error: 'Invalid wallet address' }); + } + + const userPublicKey = new PublicKey(wallet); + const connection = getConnection(); + const client = createReadOnlyClient(connection); + + const proposalPubkey = new PublicKey(proposalPda); + let proposal; + try { + proposal = await client.fetchProposal(proposalPubkey); + } catch (err) { + return res.status(404).json({ + error: 'Proposal not found on-chain', + details: String(err), + }); + } + + const vaultPda = proposal.vault; + + const [baseBalances, quoteBalances] = await Promise.all([ + client.vault.fetchUserBalances(vaultPda, userPublicKey, VaultType.Base), + client.vault.fetchUserBalances(vaultPda, userPublicKey, VaultType.Quote), + ]); + + res.json({ + proposalPda, + wallet, + vaultPda: vaultPda.toBase58(), + base: { + regular: baseBalances.userBalance.toString(), + conditionalBalances: baseBalances.condBalances.map((b: BN) => b.toString()), + }, + quote: { + regular: quoteBalances.userBalance.toString(), + conditionalBalances: quoteBalances.condBalances.map((b: BN) => b.toString()), + }, + }); + } catch (error) { + console.error('Error fetching user balances:', error); + res.status(500).json({ error: 'Failed to fetch user balances', details: String(error) }); + } +}); + +export default router; From eb9ac434706cd5120dac1a85f8a4e8a01e8ae43b Mon Sep 17 00:00:00 2001 From: dylanvu Date: Tue, 20 Jan 2026 17:10:12 -0500 Subject: [PATCH 2/3] fix: restore correct header comment --- ui/routes/dao/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/routes/dao/index.ts b/ui/routes/dao/index.ts index a8bec3d..f3def9c 100644 --- a/ui/routes/dao/index.ts +++ b/ui/routes/dao/index.ts @@ -1,5 +1,5 @@ /* - * Z Combinator - Solana Token Launchpad + * Combinator - Futarchy infrastructure for your project. * Copyright (C) 2026 Spice Finance Inc. * * This program is free software: you can redistribute it and/or modify From 3cb9fe237609e6e33121ef396aded28b7310d1ad Mon Sep 17 00:00:00 2001 From: dylanvu Date: Tue, 20 Jan 2026 17:27:37 -0500 Subject: [PATCH 3/3] reverted stupid changes --- ui/routes/dao/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/routes/dao/index.ts b/ui/routes/dao/index.ts index f3def9c..3927cb4 100644 --- a/ui/routes/dao/index.ts +++ b/ui/routes/dao/index.ts @@ -14,6 +14,10 @@ * * 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 { Router, Request, Response } from 'express'; @@ -66,7 +70,8 @@ router.use('/', queriesRouter); router.use('/', creationRouter); router.use('/', proposersRouter); router.use('/activity', activityRouter); -router.use('/trading', tradingRouter); +// NOTE: tradingRouter is mounted AFTER POST /proposal to prevent route interception +// See end of POST /proposal handler // ============================================================================ // Proposal Lifecycle Routes @@ -776,6 +781,10 @@ router.post('/proposal', requireSignedHash, async (req: Request, res: Response) } }); +// Mount trading router AFTER POST /proposal to prevent route interception +// If tradingRouter had a POST / or POST /:something, it would intercept proposal creation +router.use('/proposal', tradingRouter); + // ============================================================================ // ============================================================================ // Mutex locks for preventing concurrent processing of proposals