diff --git a/package.json b/package.json index 53e0e5ad25..4eb397e2d6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "setup": "bash ./gateway-setup.sh", "setup:with-defaults": "bash ./gateway-setup.sh --with-defaults", "start": "START_SERVER=true node dist/index.js", - "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/**/*.json' dist && copyfiles -u 1 'src/connectors/pancakeswap-sol/idl/*.json' dist", + "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/**/*.json' dist && copyfiles -u 1 'src/connectors/pancakeswap-sol/idl/*.json' dist && copyfiles -u 1 'src/connectors/metadao/*.json' dist", "test": "GATEWAY_TEST_MODE=dev jest --verbose", "test:clear-cache": "jest --clearCache", "test:debug": "GATEWAY_TEST_MODE=dev jest --watch --runInBand", diff --git a/scripts/fetch-metadao-daos.ts b/scripts/fetch-metadao-daos.ts new file mode 100644 index 0000000000..8cba11b3ba --- /dev/null +++ b/scripts/fetch-metadao-daos.ts @@ -0,0 +1,112 @@ +/** + * Fetch all MetaDAO pools/DAOs and save to a JSON file. + * Run with: npx ts-node scripts/fetch-metadao-daos.ts + * Requires server running: pnpm start --passphrase=test --dev + */ + +const BASE_URL = 'http://localhost:15888'; +const DELAY_MS = 20000; // Delay between requests (20s to avoid rate limits) + +// Use any type since we want to save all fields from the API response +type DaoInfo = Record; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchDaoList(): Promise { + const response = await fetch(`${BASE_URL}/connectors/metadao/futarchy/daos`); + if (!response.ok) { + throw new Error(`Failed to fetch DAO list: ${response.status} ${response.statusText}`); + } + const data = await response.json(); + return data.daos; +} + +async function fetchDaoInfo(address: string): Promise { + try { + const url = `${BASE_URL}/connectors/metadao/futarchy/dao-info?pool=${encodeURIComponent(address)}`; + const response = await fetch(url); + if (!response.ok) { + console.error(`Failed to fetch DAO info for ${address}: ${response.status}`); + return null; + } + return await response.json(); + } catch (error) { + console.error(`Error fetching DAO info for ${address}:`, error); + return null; + } +} + +async function saveToken(address: string): Promise { + try { + const url = `${BASE_URL}/tokens/save/${encodeURIComponent(address)}?chainNetwork=solana-mainnet-beta`; + const response = await fetch(url, { method: 'POST' }); + if (!response.ok) { + console.warn(` -> token save skipped for ${address}: ${response.status}`); + return; + } + const data = await response.json(); + console.log(` -> token saved/found: ${data.token?.symbol || address}`); + } catch (error) { + console.warn(` -> token save failed for ${address}: ${error}`); + } +} + +async function main() { + console.log('Fetching MetaDAO DAO list...'); + const allDaos = await fetchDaoList(); + const existingDaos = [...new Set(allDaos)]; + console.log(`Found ${allDaos.length} DAOs (${existingDaos.length} distinct)`); + + const daos: DaoInfo[] = []; + const seenPools = new Set(); + let successCount = 0; + let failCount = 0; + let duplicateCount = allDaos.length - existingDaos.length; + + for (let i = 0; i < existingDaos.length; i++) { + const address = existingDaos[i]; + if (seenPools.has(address)) { + duplicateCount++; + continue; + } + seenPools.add(address); + + console.log(`[${i + 1}/${existingDaos.length}] Fetching ${address.slice(0, 8)}...`); + + const info = await fetchDaoInfo(address); + if (info) { + daos.push(info); + successCount++; + console.log(` -> ${info.baseSymbol}/${info.quoteSymbol} (${info.state})`); + await saveToken(String(info.baseMint)); + await saveToken(String(info.quoteMint)); + } else { + failCount++; + } + + // Rate limit delay + if (i < existingDaos.length - 1) { + await sleep(DELAY_MS); + } + } + + // Sort by baseSymbol + daos.sort((a, b) => String(a.baseSymbol || '').localeCompare(String(b.baseSymbol || ''))); + + // Save to file + const outputPath = './src/connectors/metadao/daos.json'; + const fs = await import('fs'); + + fs.writeFileSync(outputPath, JSON.stringify(daos, null, 2)); + + console.log('\n=== Summary ==='); + console.log(`Success: ${successCount}`); + console.log(`Failed: ${failCount}`); + console.log(`Skipped duplicates: ${duplicateCount}`); + console.log(`Distinct pools saved: ${daos.length}`); + console.log(`Saved to: ${outputPath}`); +} + +main().catch(console.error); diff --git a/src/app.ts b/src/app.ts index 0d1591e162..3310499470 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,6 +19,7 @@ import { configRoutes } from './config/config.routes'; import { register0xRoutes } from './connectors/0x/0x.routes'; import { dflowRoutes } from './connectors/dflow/dflow.routes'; import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; +import { metadaoRoutes } from './connectors/metadao/metadao.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { okxRoutes } from './connectors/okx/okx.routes'; import { orcaRoutes } from './connectors/orca/orca.routes'; @@ -97,6 +98,10 @@ const swaggerOptions = { name: '/connector/jupiter', description: 'Jupiter connector endpoints', }, + { + name: '/connector/metadao', + description: 'MetaDAO Futarchy connector endpoints (experimental)', + }, { name: '/connector/meteora', description: 'Meteora connector endpoints', @@ -312,6 +317,9 @@ const configureGatewayServer = () => { prefix: '/connectors/dflow/router', }); + // MetaDAO routes (experimental) + app.register(metadaoRoutes.futarchy, { prefix: '/connectors/metadao/futarchy' }); + // OKX DEX aggregator routes app.register(okxRoutes.router, { prefix: '/connectors/okx/router', diff --git a/src/config/routes/getConnectors.ts b/src/config/routes/getConnectors.ts index c6eed4dd2c..bcc7d5e277 100644 --- a/src/config/routes/getConnectors.ts +++ b/src/config/routes/getConnectors.ts @@ -6,6 +6,7 @@ import { PancakeswapConfig } from '#src/connectors/pancakeswap/pancakeswap.confi import { ZeroXConfig } from '../../connectors/0x/0x.config'; import { DFlowConfig } from '../../connectors/dflow/dflow.config'; import { JupiterConfig } from '../../connectors/jupiter/jupiter.config'; +import { MetaDaoConfig } from '../../connectors/metadao/metadao.config'; import { MeteoraConfig } from '../../connectors/meteora/meteora.config'; import { OkxConfig } from '../../connectors/okx/okx.config'; import { OrcaConfig } from '../../connectors/orca/orca.config'; @@ -86,6 +87,12 @@ export const connectorsConfig = [ chain: DFlowConfig.chain, networks: [...DFlowConfig.networks], }, + { + name: 'metadao', + trading_types: [...MetaDaoConfig.tradingTypes], + chain: MetaDaoConfig.chain, + networks: [...MetaDaoConfig.networks], + }, { name: 'okx', trading_types: [...OkxConfig.tradingTypes], diff --git a/src/connectors/metadao/dao-lookup.ts b/src/connectors/metadao/dao-lookup.ts new file mode 100644 index 0000000000..324348088c --- /dev/null +++ b/src/connectors/metadao/dao-lookup.ts @@ -0,0 +1,88 @@ +/** + * DAO lookup helper - finds DAO address by baseToken symbol or mint + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +interface DaoEntry { + pool: string; + baseMint: string; + quoteMint: string; + baseSymbol: string; + quoteSymbol: string; + state: string; + totalLiquidity: number; +} + +let daosCache: DaoEntry[] | null = null; + +function loadDaos(): DaoEntry[] { + if (daosCache) return daosCache; + + const daosPath = path.join(__dirname, 'daos.json'); + const data = fs.readFileSync(daosPath, 'utf-8'); + daosCache = JSON.parse(data) as DaoEntry[]; + return daosCache; +} + +/** + * Find DAO address by baseToken and optionally quoteToken + * Returns the DAO with highest liquidity when multiple DAOs match + * @param baseToken - Token symbol (e.g., "SOLO") or mint address + * @param quoteToken - Optional quote token symbol (e.g., "USDC") or mint address + * @returns DAO address or null if not found + */ +export function findDaoByBaseToken(baseToken: string, quoteToken?: string): string | null { + const daos = loadDaos(); + + // Filter function for quote token matching + const matchesQuote = (d: DaoEntry): boolean => { + if (!quoteToken) return true; + return d.quoteSymbol.toLowerCase() === quoteToken.toLowerCase() || d.quoteMint === quoteToken; + }; + + // First try exact base mint address match (most specific) + const byMint = daos.filter((d) => d.baseMint === baseToken && matchesQuote(d)); + if (byMint.length > 0) { + return byMint.sort((a, b) => b.totalLiquidity - a.totalLiquidity)[0].pool; + } + + // Then try symbol match - return DAO with highest liquidity if multiple matches + const bySymbol = daos + .filter((d) => d.baseSymbol.toLowerCase() === baseToken.toLowerCase() && matchesQuote(d)) + .sort((a, b) => b.totalLiquidity - a.totalLiquidity); + + if (bySymbol.length > 0) return bySymbol[0].pool; + + return null; +} + +/** + * Find all DAOs matching a baseToken (for cases where multiple DAOs have same symbol) + * @param baseToken - Token symbol or mint address + * @returns Array of matching DAO entries + */ +export function findAllDaosByBaseToken(baseToken: string): DaoEntry[] { + const daos = loadDaos(); + + // Match by symbol (case-insensitive) or mint address + return daos.filter((d) => d.baseSymbol.toLowerCase() === baseToken.toLowerCase() || d.baseMint === baseToken); +} + +/** + * Get DAO entry by address + * @param daoAddress - DAO public key + * @returns DAO entry or null if not found + */ +export function getDaoEntry(daoAddress: string): DaoEntry | null { + const daos = loadDaos(); + return daos.find((d) => d.pool === daoAddress) || null; +} + +/** + * Clear the cache (useful for testing) + */ +export function clearDaoCache(): void { + daosCache = null; +} diff --git a/src/connectors/metadao/daos.json b/src/connectors/metadao/daos.json new file mode 100644 index 0000000000..5139d68435 --- /dev/null +++ b/src/connectors/metadao/daos.json @@ -0,0 +1,1104 @@ +[ + { + "pool": "E3BjsvLSFqUqVtDP76qMw4QbETkxvqvg8RTSbRZxWCK4", + "baseMint": "9uUsek16PojfDyrM6pb5cWiv5DV2Qk2Z8wc1gp4GsuG9", + "quoteMint": "JBqfRDVRdGNBJMm7WqJxgfbkn8CPrLxFaD257XA3gn8p", + "baseSymbol": "9uUsek16", + "quoteSymbol": "JBqfRDVR", + "state": "spot", + "totalLiquidity": 100, + "totalLiquidityRaw": "100000000000000000", + "spot": { + "baseReserves": 99.014797, + "quoteReserves": 100.9975, + "baseReservesRaw": "99014797", + "quoteReservesRaw": "100997500", + "price": 1.0200243101038726, + "baseVault": "Zop2FUGah5k5TkYHbFdUksa2vJ9M1yMcXkbx37JEP51", + "quoteVault": "F1ujoyDYTucDiLSmrkehUQGqfuzEMdukXcvnsEPgDWu5" + } + }, + { + "pool": "3D854kknnQhu9xVaRNV154oZ9oN2WF3tXsq3LDu7fFMn", + "baseMint": "BANKJmvhT8tiJRsBSS1n2HryMBPvT5Ze4HU95DUAmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "AVICI", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 700000, + "totalLiquidityRaw": "700000000000000000000", + "spot": { + "baseReserves": 1234214.533164, + "quoteReserves": 1134327.732253, + "baseReservesRaw": "1234214533164", + "quoteReservesRaw": "1134327732253", + "price": 0.9190685263971631, + "baseVault": "Bq2PMcBAFGLent72NgibJZJEXiy4n2dkbteACXn28u3g", + "quoteVault": "8TkybHFgvJme5D29Xb6ZjHaycnHYbYKz2pJBUEED7F1e" + } + }, + { + "pool": "BpHjC6EK2TuFt1SwHa2HbxMv4rvUbdr4wkNJ8XJCQTzK", + "baseMint": "E3yUqBNTZxV8ELvW99oRLC7z4ddbJqqR4NphwrMug9zu", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "coal", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "BTspKTPswiDy8bRsfia59h5A4uR9d8Rmuge8QK81iEFN", + "quoteVault": "GDCzvfiRLYkT3Ji2ehKTct5rgU1Q17yrQwbhrBezKAPA" + } + }, + { + "pool": "9u61DTe6UX9XEUNyNxD8z4vtCBNXTo4MnzVvuDz62VY", + "baseMint": "Fsce3kKc7QLdQ1Lr9ahVPDRVYdRW4WdjcnsBeBXe2uHC", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "CRIME", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "D2UhvrWGWDpQsFPEh6CuYyueX2m6AudgjKREfAQhBT2Z", + "quoteVault": "BZfYHyoi8qxi61UAZjv9WFyDp7JKERJVfvRELCVzevbG" + } + }, + { + "pool": "8dxtDjgRiFFSbYY81kBi1HDNNaVo7LjxMptqPjUFW9g5", + "baseMint": "Fsce3kKc7QLdQ1Lr9ahVPDRVYdRW4WdjcnsBeBXe2uHC", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "CRIME", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 1, + "totalLiquidityRaw": "1000000000000000", + "spot": { + "baseReserves": 19346.787508, + "quoteReserves": 0.051689, + "baseReservesRaw": "19346787508", + "quoteReservesRaw": "51689", + "price": 0.000002671709707806855, + "baseVault": "G4vBwa7mXFU3qNRQEkKgqsy6y7tu1BCqMPwqiGKjo2Uj", + "quoteVault": "8yrJCC6AdJRLGQm1j731ykJTUBmhFHgZ4NDTdkjk73re" + } + }, + { + "pool": "DMB74TZgN7Rqfwtqqm3VQBgKBb2WYPdBqVtHbvB4LLeV", + "baseMint": "Fsce3kKc7QLdQ1Lr9ahVPDRVYdRW4WdjcnsBeBXe2uHC", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "CRIME", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "AR1g6Qg1PB4drFGhPHQVMRiWKLwSNqb3JTM36TuSMbC", + "quoteVault": "Frz8cZQEgH2wG7gaYdsFbb1SciS75F7H35a9NBMQtyxj" + } + }, + { + "pool": "EbcsPbXZa81xUunDSmzYrcAWGURxcZB6BTkgzqvNJBZH", + "baseMint": "2yei47BYXeGuvabpSFXjmfhg6gJ2J9T2xAWHZjwtX5uX", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "CRIME", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "9BLMdeZsnp6qCX2CPtp5j6NiyrGKoBkxBUb7oFjLuYD8", + "quoteVault": "E45gAtN6FQNcyWQnTxEPRJwabvgCHXLdR8YSytuzjuAo" + } + }, + { + "pool": "39nV4Yz76XQrduyyhFoFnLbtqzsQNVSQMnZMbjKZW19A", + "baseMint": "HFAXQMARWvmFbLgM9jiSoL9wKiTpg6HvRytmrqsoWfmJ", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "DB73", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "4D3BybDR22UzYRpDDPnsrmQriMwnrsDt3KFMTY5mQzi5", + "quoteVault": "Bg72JTokLsGR4QpPT4fSBC7NHoke4JLZAMtnKWSr2mFT" + } + }, + { + "pool": "CTvMjLvG4GcLHUiGA9nwX7p9EpthNfCXM9DDkVyWiZUu", + "baseMint": "EVswE2DhM6Axyv2YWt5Dpwso1h4EGUh1fyV4Eo8zDmd", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "EVswE2Dh", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 10, + "totalLiquidityRaw": "10000000000000000", + "spot": { + "baseReserves": 10, + "quoteReserves": 10, + "baseReservesRaw": "10000000", + "quoteReservesRaw": "10000000", + "price": 1, + "baseVault": "5TKsMt3Sjs5U2Vz5fn4ZsAhiPw3BDnEa6Mm7JfVmgb6J", + "quoteVault": "Aq9k7LXYFLCV8hh1Jeh391BzFD7He3Yy6rCEChdNct6T" + } + }, + { + "pool": "4rW6iVKUq1RWYQ1VBTrjvP9FL4G3Sn7mBj7Yg12kuckv", + "baseMint": "8XaEjbnWERh9qwgEwM4BGmn4C95cmVoHtuy81A9ghD6D", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "EXMPL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.1, + "totalLiquidityRaw": "100000000000000", + "spot": { + "baseReserves": 984437.128613, + "quoteReserves": 0.203493, + "baseReservesRaw": "984437128613", + "quoteReservesRaw": "203493", + "price": 2.067100011624986e-7, + "baseVault": "ELL7MXHyejT4sDxapNFx4ffMCU4qhvTAkfUxszi3VSeu", + "quoteVault": "53dzFxTK3qbYp1fqGgkdxW3usfC2mqzBPkdPkpo4hkGe" + } + }, + { + "pool": "9NEgXTunQVxfz9SgADwaBERc9umRREQuYkvRGQxFNP49", + "baseMint": "5tmUS4qhr7aJpgfxKtZucjfqwwSWBNomUN9GDExv6pko", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "EXMPL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "2JaaJQFNiCSZaRiKjP46QQuTZo67ihN3dAyAHZDj4Bdg", + "quoteVault": "H7tQ4Ykg8AuG2J7d5ABajpkuz2cF4w5XuAzRcevX6BvY" + } + }, + { + "pool": "BgNq2V6vea2C7Z3cZhDUJTbmN4Y9bKG6dfEPhH19J7Fb", + "baseMint": "4ZYGY49yFDjJVgmSxRypXHvPhPi5vNfxbBCTDapyNdAR", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "EXMPL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "DZ4Qaxa9S9R52YNBiUc4M7UzbpBbdZJijCq3942RKhhu", + "quoteVault": "8inKYXdpZB596eRiCEYQ2qecXs8baVShQKa2LX69VGBe" + } + }, + { + "pool": "Gxvt2wKNiDZcnEK8GQ6Cyb8x1Kvm4nm2kT2itDmYdEFa", + "baseMint": "FAFxVxnkzZHMCodkWyoccgUNgVScqMw2mhhQBYDFjFAF", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "FAF", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 218653.47454118897, + "totalLiquidityRaw": "218653474541188969402", + "spot": { + "baseReserves": 65444989.873863, + "quoteReserves": 240621.589702, + "baseReservesRaw": "65444989873863", + "quoteReservesRaw": "240621589702", + "price": 0.003676699930212655, + "baseVault": "Dg9okAkkXoGaj9pXdrMBbUeLtb11SpM6uwGfK59EKPm9", + "quoteVault": "5a9e7jnaAnzcCg6BEbdkkPRnk8YQsXYV7QVkCeZAxhXk" + } + }, + { + "pool": "CTYxPujxrXiiqwG3gSBVNKuBk8u7mPG9qVMUc4aT1L8u", + "baseMint": "4aLjJawZZ7rHb1aLmGq6KnNqf39mW7Qsu1vuRTvTCqiH", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "FTD", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "6fzkdd4TD9aRmUiX5gg7HTPqoGf5eHwSdgE6aJqebkNh", + "quoteVault": "CNkKixcJAhbGUkm4qERQwARBZ9WjpBzt4tKW3e4vGdL4" + } + }, + { + "pool": "CkEUCAooQi64UFhPFS5MWpZw6LQqjsDQBj3Z5uiXS1eN", + "baseMint": "Cbjr1Nvcay3QWDriyRKtokJ7V4PMknesGxeK8z7Zmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "FUTARDIO", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 10000, + "totalLiquidityRaw": "10000000000000000000", + "spot": { + "baseReserves": 2481892.121265, + "quoteReserves": 8058.53033, + "baseReservesRaw": "2481892121265", + "quoteReservesRaw": "8058530330", + "price": 0.003246930138886388, + "baseVault": "81ddWXkUBcq59GjjFFvdmA5zYLx7kGCfLrDBpNnPeSfP", + "quoteVault": "AtmHEmmAEgKs2DJeKNs3zBosqqHd9mknYZGaXMqqoWfn" + } + }, + { + "pool": "A9HAiPRsBdXXM66o99PTuHHmgSqgzfvwgKqRdEBt8W4G", + "baseMint": "Ctvgw4yfk9CHdCVPyHkuMkzuYDQD9tuFXGXHpNpdgTiW", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "HBD", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1192115.595666, + "quoteReserves": 0.67108, + "baseReservesRaw": "1192115595666", + "quoteReservesRaw": "671080", + "price": 5.629319861595195e-7, + "baseVault": "2jPbnowaKEWgzzq8arjobGUrdJj3tLwo6mLn2ZL4JDKf", + "quoteVault": "DpTE1KwKXvQ1iChgpkrBuWXmTmjcQi8xh2h2bfGAae59" + } + }, + { + "pool": "HPL5gw1Yoc9d2QGJydrij9Gh7dibnPRHvZJ1R4t82iCY", + "baseMint": "95FUf6qxFVCAm9XagFZpBi4F1A2EUkxhDRCewosfZfGy", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "HPBD", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1999975.186722, + "quoteReserves": 0.400006, + "baseReservesRaw": "1999975186722", + "quoteReservesRaw": "400006", + "price": 2.000054813958057e-7, + "baseVault": "7d9rpGQhALYCzTctJnqUimKCpL8oHsP5Cv696mSoTVu5", + "quoteVault": "iCLpHBB18UQegeq2ZCZvfcnbxgxhskLmFQQbT5Cd6xy" + } + }, + { + "pool": "6L9Gu3XErwxjNxNC3f8RxL21CtJRzAidHPP2kqqzua1P", + "baseMint": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "JUP", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "By95D6pv8G7FGs2e54zX5VBmD9YPeZh2ZLTASoGCa65i", + "quoteVault": "ALjCNHDg7GJqGLEHUp5ga9mvusaYdoyDqQrRQ9xJPkyV" + } + }, + { + "pool": "EYhmutGvi9XFmtbqaCwMLJ4cqaHaL97ru8KEwn4utiAf", + "baseMint": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "JUP", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "2ynNAJzm8LnitGgtqbyMPNyYKmsKRKLBwH8yUfSbnSBo", + "quoteVault": "4Wy5nKGPzUFornMGnp1nJmd7NZLAKSYBVc66KHtjMbqj" + } + }, + { + "pool": "GxpJkPEsPmuRCCTNnfZaDKg4X3gf4ZPgmqgFqtibaPtK", + "baseMint": "LYLikzBQtpa9ZgVrJsqYGQpR3cC1WMJrBHaXGrQmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "LOYAL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 500000, + "totalLiquidityRaw": "500000000000000000000", + "spot": { + "baseReserves": 2582904.849599, + "quoteReserves": 387161.311846, + "baseReservesRaw": "2582904849599", + "quoteReservesRaw": "387161311846", + "price": 0.1498937569868698, + "baseVault": "9LmAt9RwbP2bPaYZyy9oZiPkzm4T6fEKhCS3DGuGURsn", + "quoteVault": "Ej5B14mBrjuLe67XaqECQBwaNDeJ6CvKrvDUZ7D9dRrx" + } + }, + { + "pool": "3uQ1F8ZWS4yRHAxSNiYo1K8Dpb4q7WZFCCGVykdpAkm4", + "baseMint": "METAwkXcqyXKy1AtsSgJ8JiUHwGCafnZL38n3vYmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "META", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "AG8zHkedamcXYKifeQypgGLck3jhSQrQuqyB4mfQGDZD", + "quoteVault": "12ZthXWFSxCHGC5tts8d4GVsTBsfVzGL2KBrF2iVkx4n" + } + }, + { + "pool": "CUPoiqkK4hxyCiJcLC4yE9AtJP1MoV1vFV2vx3jqwWeS", + "baseMint": "METAwkXcqyXKy1AtsSgJ8JiUHwGCafnZL38n3vYmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "META", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 1611356.19087, + "totalLiquidityRaw": "1611356190870000000000", + "spot": { + "baseReserves": 352567.188658, + "quoteReserves": 784334.072488, + "baseReservesRaw": "352567188658", + "quoteReservesRaw": "784334072488", + "price": 2.224637169083893, + "baseVault": "JDx4m2VpQQLqCdd3URUThhAY2wJePpAn49f2BcNUU3WX", + "quoteVault": "HgtoZVvdVjrCm7jmKmiySjHmvbXRahqmRP8WSxGPC5VM" + } + }, + { + "pool": "9ikE7Yrwy5Q6hfr7LdiPyNnenmcN9BzgrdjZyjLoiTQV", + "baseMint": "FeDBzydS44NbFfttqJbkit9Lq7U1JgUpMCXu2KP9HXXb", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1873711.823122, + "quoteReserves": 0.42696, + "baseReservesRaw": "1873711823122", + "quoteReservesRaw": "426960", + "price": 2.2786855199995183e-7, + "baseVault": "CJY3u9KexM7vd6UuBcz8HxMyttPYGSKb5419zWNzYWwS", + "quoteVault": "fhthMvxzc47Bfz3bDHYmgb4wAV86wf33bhaYyhJiY3X" + } + }, + { + "pool": "AXkQJCX18pQQXaC5g25s5wZvU1socDYg3cfGAuBfZkqb", + "baseMint": "3k7Txeb1x6kJVgrdtEanFXz6ZfH37fPSR2bMq1gM9heR", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 2000000, + "quoteReserves": 0.4, + "baseReservesRaw": "2000000000000", + "quoteReservesRaw": "400000", + "price": 2.0000000000000002e-7, + "baseVault": "ANRLU2qebwpNBxeFs8ZHVBoff66QrzXR75gaDGASmHPo", + "quoteVault": "AHi9LXcRMpswi5f8khYkZGxpahdBybfVzhUQj67GCDT7" + } + }, + { + "pool": "AmtT5VcGW36QvU8w68TauvRNz6NuW8mHfz9LUPjEjxFq", + "baseMint": "7vmUFwmADQHBNB8LwpSAExxdMsxfUU3Kc7N8ZDPkbixi", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1210980.00987, + "quoteReserves": 0.660626, + "baseReservesRaw": "1210980009870", + "quoteReservesRaw": "660626", + "price": 5.45530062111363e-7, + "baseVault": "5gByuzUuNGzQjDU8zdZJ9FKEHTUKyc7WTK3sX68X1vNm", + "quoteVault": "BbXREHJDrqLQ4U5LvBNejnBKLLXeXcF54a6GdwtocMZu" + } + }, + { + "pool": "BoH5panDWJYNnPfW3RMymKWV5LL944mHar15XnPmmyrS", + "baseMint": "2Y7Wh61aZdgnfxaB4ueBmu8C3KXyhjVBfzyiBTcXeDeC", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1176051.008855, + "quoteReserves": 0.680243, + "baseReservesRaw": "1176051008855", + "quoteReservesRaw": "680243", + "price": 5.784128365845992e-7, + "baseVault": "GvCZQofyQo8BMchom7HYQidK8hpU3iuJYKvbADJ1yoUH", + "quoteVault": "NU8Vb2XAzs1y2HpjDu3B3DuG1jFMgzmqEhv9qeJZRNN" + } + }, + { + "pool": "DphmxxkVRiNRnezDjVQUHokzgiZUgHNViCxuYxZBqspa", + "baseMint": "9bSjVGp2s6cDdTMNUfcASat1gsie2eJ1HrtQmU3LoZ6W", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1873220.440582, + "quoteReserves": 0.427072, + "baseReservesRaw": "1873220440582", + "quoteReservesRaw": "427072", + "price": 2.2798811647993277e-7, + "baseVault": "HbCGZoXn9crAMKeXfnXfpGsUaQvumchxk9CqNiD3nMFC", + "quoteVault": "GknkBKxWMtfji57ZETsnj19tbMq33eP1XbiPK2fecb8K" + } + }, + { + "pool": "GdhFuiDBmwaEAYcEhKVZyZP7ELKsSYzmtsTpsDUBmzyw", + "baseMint": "BtSbzUvx6TeY2UPcRcZaaK9LuymPQfEYNaYUqiFmjZvZ", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "2gChgFrqhfuFgSpGX2kdGfEmcxezJLdASPrXJmJ3uegp", + "quoteVault": "BoUn1ZVtwnMcsdWhuHqdaxfQmo4AdDtWen4N3FRgega" + } + }, + { + "pool": "J9LT5TJ32BfLKWGBn3zSpfuFgmeWz7Cgvo8GYUWj7wos", + "baseMint": "8rda42wYxnkADAEwq6CNSPk72CjmFhwF1n1RxxiiUZ61", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "MTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1215662.813862, + "quoteReserves": 0.658085, + "baseReservesRaw": "1215662813862", + "quoteReservesRaw": "658085", + "price": 5.413384307687681e-7, + "baseVault": "7J3uvNcHazbtLxhmnvmWk27N7iD93qyGtdgT9UFvYme3", + "quoteVault": "4vmbyes5wFMzaEJof3CrM2Gr9Ua8nWCa6QtX9e64U2bf" + } + }, + { + "pool": "UHThZd3AeVpS2mKREGyVWmCLRyk6qmgWwJFAwjenwYW", + "baseMint": "EmJXFEg22CsXt7gZcBTME2shA6gJ3BJyYZ5SGp1QNkm8", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "NTKN", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0.4, + "totalLiquidityRaw": "400000000000000", + "spot": { + "baseReserves": 1191525.870844, + "quoteReserves": 0.671416, + "baseReservesRaw": "1191525870844", + "quoteReservesRaw": "671416", + "price": 5.634925908276018e-7, + "baseVault": "C9sAcoPSpiSHUxBd2RKEcXvBRbsDHKSo4XwERRGj7EBM", + "quoteVault": "CCMpYCCWqJLbXp9XDFcsNN6Z6H2qrw1QKZMHLKvB2dBP" + } + }, + { + "pool": "s45fTDhkzKPMFbNmUXA3bJNdF92z5cbVvHdY8LpznWQ", + "baseMint": "omfgRBnxHsNJh6YeGbGAmWenNkenzsXyBXm3WDhmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "OMFG", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 421193.451992, + "totalLiquidityRaw": "421193451992000000000", + "spot": { + "baseReserves": 1554430.276735, + "quoteReserves": 298251.77007, + "baseReservesRaw": "1554430276735", + "quoteReservesRaw": "298251770070", + "price": 0.19187207977990647, + "baseVault": "D9qPboXwfi8bDR3oS6udcCzv2eyj4JTx9ZcqQk9zR9Dm", + "quoteVault": "G1WcJYBszv2kHALHoZFHnuW75ydNmUeboA7A4KxGqiZb" + } + }, + { + "pool": "CFYmVUEYikV8DaKDNs6WSHC5uAxG6T7KqFBCsAebACFu", + "baseMint": "P2PXup1ZvMpCDkJn3PQxtBYgxeCSfH39SFeurGSmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "P2P", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 1200000, + "totalLiquidityRaw": "1200000000000000000000", + "spot": { + "baseReserves": 2111005.886932, + "quoteReserves": 1136898.795519, + "baseReservesRaw": "2111005886932", + "quoteReservesRaw": "1136898795519", + "price": 0.5385578517600893, + "baseVault": "7XBV6p8aw5HFatfpzhxUjne2uAcZWFuohQhZvfKvkueU", + "quoteVault": "Gqeq7obPYete5Um7aPUaKX1Zp9p9WDEM2Za584vbWpcQ" + } + }, + { + "pool": "6FRXzTe3HajL8Fwmmkupp8g3y3wn3g3QEjj8sABndre3", + "baseMint": "PAYZP1W3UmdEsNLJwmH61TNqACYJTvhXy8SCN4Tmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "PAYS", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 150000, + "totalLiquidityRaw": "150000000000000000000", + "spot": { + "baseReserves": 2872065.318376, + "quoteReserves": 104469.662125, + "baseReservesRaw": "2872065318376", + "quoteReservesRaw": "104469662125", + "price": 0.036374403275783444, + "baseVault": "7BmyPoTkgVPmjdFFcjhmxTrXLVieNvUspyrNwukMabw4", + "quoteVault": "AncBeQe6QsTR29CsrVBEGxKfokra7TWh9MkqZ3UR7CEG" + } + }, + { + "pool": "F3APFzjG4ekCohguP7emX2pEwE3CoVQR9s8zwNYfZq4R", + "baseMint": "WnbW7kcpfKzrDhujtTD8MFfqc8mck4o26fbTRSameta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "PFT", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 3.904485, + "totalLiquidityRaw": "3904485000000000", + "spot": { + "baseReserves": 1031586.386409, + "quoteReserves": 3.904485, + "baseReservesRaw": "1031586386409", + "quoteReservesRaw": "3904485", + "price": 0.0000037849326546385447, + "baseVault": "GU1kxdEXe9ZxBH8dTuUPk7iDF2AuvCNx83XrqcBNnWkR", + "quoteVault": "843uDi19PMTx2BEkgKcKvXXx7Mf9ztE9oHfUuJ6cfTmZ" + } + }, + { + "pool": "AE7jPb9jYzbUE5GYJToKvXaRkJL2Q7Mm3Ek6KqyBGuxe", + "baseMint": "4Ueb677S8FVCH5ot5rrB6MFq5XsFgpQBgri1oNCnx6HW", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "PPRD", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "5ZfDwVFyTGfVQkzZzews47hmEM2XH9rFijm46b78ELbH", + "quoteVault": "CRKU2mL8CrZSZZUJP8aJNMx6ZcMB93rtUsNPvUhjVvYW" + } + }, + { + "pool": "82XqLX4wEuiCcCWMx6mzhrkLuCEPVqaHKdUGqBWaWS7z", + "baseMint": "GBXKJSjyx76MbsooT8kCnjhPrDxkvWwscxXw2BBftdio", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "R2D2", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "5vx3iBb2xDDxsGodiTnR7Yp4D3zEw7XPLz96Ww5N8LJS", + "quoteVault": "9gyEqcUSFGLYxcCkKX9qjQxNbaDG9RyjdLN7ADLA63jD" + } + }, + { + "pool": "FU7mKs4cyAV9pCha1map4K5xTtkWN7uAjeM2dsGVBnGy", + "baseMint": "6tSJq2sA4kdqEMy9WxgRPTYpdtAiC954quENGvj6meta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "RABD", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 20, + "totalLiquidityRaw": "20000000000000000", + "spot": { + "baseReserves": 6855320.06275, + "quoteReserves": 5.834885, + "baseReservesRaw": "6855320062750", + "quoteReservesRaw": "5834885", + "price": 8.511469846178627e-7, + "baseVault": "2hxAsb4uSsHdcLx2C8QnZVptHBZsqcX12c2NLJWcta3M", + "quoteVault": "B3vfANevM67eqULAzzKkjhLPJQFaKuwCUVczxtKiNbKZ" + } + }, + { + "pool": "1PAwyDkWNFCcR96GhEReXHJBv3YEFVazCaQgNicVuKv", + "baseMint": "RNGRtJMbCveqCp7AC6U95KmrdKecFckaJZiWbPGmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "RNGR", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "7mpbZ7juJcKdU4iKJwp6LYd394xNmM9KZR2gov6QwZcE", + "quoteVault": "5YuhLmu5uadFDtSVgPg7Lo43s1fk9NJUdjWzDhXMMyT6" + } + }, + { + "pool": "8uKoo9UATno8xsrjfQqi5aBYE12AS5QoWNyD2x85GauA", + "baseMint": "3n6X4XRJHrkckqX21a5yJdSiGXXZo4MtEvVVsgSAmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "ROCK", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 2, + "totalLiquidityRaw": "2000000000000000", + "spot": { + "baseReserves": 2876246.443528, + "quoteReserves": 1.390705, + "baseReservesRaw": "2876246443528", + "quoteReservesRaw": "1390705", + "price": 4.835138529694845e-7, + "baseVault": "8Mbv5rgdxRNPtwFKi4kcQViYzyBn8wQeQmeLzSP6uBHN", + "quoteVault": "9bU7YSKV9dftfUNAYiUpmY4y9dW6s2k4rR4NbEgTrnex" + } + }, + { + "pool": "6jQt41FAvrBhz9hErkHaDuQbKd9bKCLAn4sdED3MRg8y", + "baseMint": "ey9DmQUxPKnABU6LrWsQCvFVhZcYk2j4BmBSXLKmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "RSTFUL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "B54PwyM3BETwtYsjzbSxKyqJX9oGmJ344rv5Y6U38v6M", + "quoteVault": "4et4NxFWqzXJQstZE3byjSXHYc8gwxu4BTxrcqCcYTDn" + } + }, + { + "pool": "Cn2wML7SWX2x5mroSKp5eSd9QEkBRjccAXqQ9YWiwZNx", + "baseMint": "ey9DmQUxPKnABU6LrWsQCvFVhZcYk2j4BmBSXLKmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "RSTFUL", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "7phPFRkQeJ6GLYXCJuvoumrMV4HNg1axeGqHQJZ56TQn", + "quoteVault": "7KtwwkWrsfwyMLWP4tPqKbYh15NLJKiWXRXvGdHoh3a5" + } + }, + { + "pool": "DzYtzoNvPbyFCzwZA6cSm9eDEEmxEB9f8AGkJXUXgnSA", + "baseMint": "SoLo9oxzLDpcq1dpqAgMwgce5WqkRDtNXK7EPnbmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "SOLO", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 1600000, + "totalLiquidityRaw": "1600000000000000000000", + "spot": { + "baseReserves": 2232430.101781, + "quoteReserves": 1433428.638213, + "baseReservesRaw": "2232430101781", + "quoteReservesRaw": "1433428638213", + "price": 0.6420934017461204, + "baseVault": "EFtLfKoWcsctiKF4FDvoyjouTd6QvVtRAgWNBNrZd1mR", + "quoteVault": "BjJ3i66YMuybeKsjMNE4NhjghTF3GnWKY9xaCRLqavP3" + } + }, + { + "pool": "6WSUiKmBSM2B7QSxFAxgD9wquekzpkoRvKteFLvWWryU", + "baseMint": "5TbDn1dFEcUTJp69Fxnu5wbwNec6LmoK42Sr5mmNmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "SUPER", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 10000, + "totalLiquidityRaw": "10000000000000000000", + "spot": { + "baseReserves": 2702794.085992, + "quoteReserves": 7399.788059, + "baseReservesRaw": "2702794085992", + "quoteReservesRaw": "7399788059", + "price": 0.002737829010856398, + "baseVault": "DgLa4wLy8NoR5Y8dAxQgmB85dSUrfa9tqbJu4MnmHrPh", + "quoteVault": "C1SsTJoBCN6U2JYMJLb4PDvDKYUPqVMixsovbgHQ6zcK" + } + }, + { + "pool": "3CQ6wE1eSqmYT3SpkrNqj9FSzzMDU33GeBiF7imAt33S", + "baseMint": "7EJRXkBfoAYtzAXE7PRry4gqh6NciY3Yt5YF3GR8LC8V", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "H8D2XFCFa8tZv9TmxwYhvUc7ecaJ68Q2a4QFAQKBkWPx", + "quoteVault": "7sCABQZMwFJt7J9CtF8cJypproDdqcKpXs5TifYS2V7r" + } + }, + { + "pool": "7WnY1TZLfpxxTEbi1LN5JwKohuu49mE2sJpgZTSdmneH", + "baseMint": "GCU4rpSTk2JavuTvYJGP6ah7RxEo5xaKrsRRfLwNkH45", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "BuCQgj6aK2XMKA8NxfUG49x11RKXmnHxXTuWECXxfmA8", + "quoteVault": "wCrLFQyVhPsC852W5S8a7Z46yz8VCHmSAZgmQYbBDaD" + } + }, + { + "pool": "CJCgDqiDtkQvwXT2iiyY7QVajKLH3VRVbcsNQgtttrHn", + "baseMint": "ATZKEKSNqvauS2YdoeQhEBZDvF35JLohboA1mDr3GMEn", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "4NExW6YYJPcxvmiNjxfBBKr2XSodv2y1sv6DFpAeVkZr", + "quoteVault": "6XRheF4UzuH5D7Hd5BgvXSXqRqCe3F95FGCoAgzn4HN6" + } + }, + { + "pool": "CLoqV77NtkbrsvtCRDP1vdYxgPZua3nnh7gCNPLzDQQ8", + "baseMint": "GqC4nrFEKWA3w7Skavxt5zyEzWi6WE5CceSVhqzNsQjR", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "4q52onfmSqL8fuQg6wAjUKfzRGXtpLqTAs8qcnpUxv54", + "quoteVault": "J8ruST5xinsrRvqnAg77xakxUHKiaeyoh8FjfpaoqZFP" + } + }, + { + "pool": "CnPz48JYS2x5XLRXdPFLip84GGbrrLeF2D2mPojZb6fz", + "baseMint": "9m2xaDCUwGA1r3GTWNwEA1oAmeHx53QUGBxsnYAK7bUG", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "MDZSdMeGZjzSuEDQGxtc1aHxr51xapo6PGZdunZrCJG", + "quoteVault": "FWdWvXHzaQgQyU5SQPNEgwktBSNkYMLCpYFCm96dGmno" + } + }, + { + "pool": "CnUUCGbSrAoaJniPifRU8zHRZ6e5uGRVSpCEj2WMeeSv", + "baseMint": "7AQbeEfCeFUNAvQvqHfhB6gJgM7srN8YzfHWu2YN2eyE", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "Et2QXo3MBQzn4yPQ5LZfteWb2LBxffZwJBYG9ym7edtZ", + "quoteVault": "jMkVb13qUZ4kN3s4DZBfcyjEjvEHvDCJHcEo64ZYjAN" + } + }, + { + "pool": "DHjQLd6LCM4yzZ9e8eabyGofDJLjbouqpuX8wh1rQuBs", + "baseMint": "C5yXcUj9stvaW6cPTmmxWyCCjLxxCiJtYnwKGS129vGJ", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TEST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "ErR5YeyC1sdBkDLxWRKxWKUxmVPfJoW1yoprvW3J7ihY", + "quoteVault": "Cy9ijMfPyxKmEgWLk7faYWc3nYWkoKLQiUrAFNvq6GB3" + } + }, + { + "pool": "BK1eLdYQuvMNf1F66yDhWXeacysSRMwJVZvi1NTk2TYA", + "baseMint": "FEPBkwPxvPUvFB1szZgJtXBhiBGi3CvDW2pJB8VAfjhL", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "EUZkMVkNquakVTPCQu9TCztHYTtGXvWrCgAqg1dRj3bK", + "quoteVault": "CoRTmxEPmJTZEKNw5k8nD7WsBaVzFrkj93M45EF4eWUu" + } + }, + { + "pool": "GZiuCnc4fXG9hB4oYCtTe5VjPXc46vNaZHTdw9cLrqzo", + "baseMint": "4gjaKkjA9UANid6cyYH4p4asVUnhJdLRB5mgdPE67beo", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TST", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "4n3gLsPMk7aaZJ1h636jmFH2varMUKd3TyuaL7XpUAup", + "quoteVault": "2Ew9QDyXBK4WWZ3zmJtpHk2DaBJA4vj4feiksmbrrktX" + } + }, + { + "pool": "651uV1hcd7SprwwkumFfkWtx5WrnD53awpjduGtGsHzS", + "baseMint": "EjvxFhuzMoFiXuuKU82Dfv1xw5VWReoPS1ErkZ2xvQDL", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TST30", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "99Y4gE75pAahZuiXsCrTRJWvqb5CYg3YEoN4RA7nkjj2", + "quoteVault": "AMSpo55pdP6EUmYjhLbH9Xxi7oYKohC4hgP9Wd5AjVh6" + } + }, + { + "pool": "BQjNtXjZB7b9WrqgJZQWfR52T1MqZoqMELAoombywDi8", + "baseMint": "7X6DYSHEJEFMEhJcL9t8YFv7YcJwKqNWwi3NAdJ3EDud", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TST30M", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "GrSQM1JrsPz4JayjLtjMzBMB53pGfWmWLJXUwojLjBfo", + "quoteVault": "BmGSoM1Hq4mk4FwM85THesTtDAVyHPT1CunHg4vt3J3E" + } + }, + { + "pool": "j6Hx7bdAzcj1NsoRBqdafFuRkgEU48QeZ1i5NVXz9fF", + "baseMint": "4qeRy1oBAkpTVoLWYkRCaearcYFvpvramGQLkLmfZcDd", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TSTAVI", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "6UmoyQxwuLXH6pQXUfynMX3wMQzeGj55xdHt5yA6wqi2", + "quoteVault": "EcvyJ3q1XgqG6teQwAmj9pr8ne3XzAhaxv4Dfd5i72LQ" + } + }, + { + "pool": "Eo1BLMVRLJspjP5dDnwzK1m6FxMUcQDG6kDA8CjWPzRW", + "baseMint": "F1vYk6VERRasDhW1fq7aru3sTDNMH9q5d62RZvNypE7T", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "TSTJUP", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 0, + "totalLiquidityRaw": "0", + "spot": { + "baseReserves": 0, + "quoteReserves": 0, + "baseReservesRaw": "0", + "quoteReservesRaw": "0", + "price": 0, + "baseVault": "E5zXWemNUs8eQNxdeoBi2urnqxEpkSepPGzCCzSfrLFw", + "quoteVault": "2pGQ3UHmfQHVWU2YsbpihFXXfvSopBPVNVPXP1cTe3wm" + } + }, + { + "pool": "BLkBSE96kQys7SrMioKxeMiVbeo4Ckk2Y4n1JphKxYnv", + "baseMint": "PRVT6TB7uss3FrUd2D9xs2zqDBsa3GbMJMwCQsgmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "UMBRA", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 600000, + "totalLiquidityRaw": "600000000000000000000", + "spot": { + "baseReserves": 1827588.30985, + "quoteReserves": 656603.026722, + "baseReservesRaw": "1827588309850", + "quoteReservesRaw": "656603026722", + "price": 0.3592729408385693, + "baseVault": "71naRuPZLV3T6BSk4YpNYhyb2kKWbrefDMFu2Q49e9yd", + "quoteVault": "4zpwwXCcYrFivt57esQdbLfx1mAPRyDrG7Sf2RgRVc8b" + } + }, + { + "pool": "5FPGRzY9ArJFwY2Hp2y2eqMzVewyWCBox7esmpuZfCvE", + "baseMint": "ZKFHiLAfAFMTcDAuCtjNW54VzpERvoe7PBF9mYgmeta", + "quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "baseSymbol": "ZKFG", + "quoteSymbol": "USDC", + "state": "spot", + "totalLiquidity": 96942, + "totalLiquidityRaw": "96942000000000000000", + "spot": { + "baseReserves": 1797727.05375, + "quoteReserves": 53987.377577, + "baseReservesRaw": "1797727053750", + "quoteReservesRaw": "53987377577", + "price": 0.030030909010566476, + "baseVault": "6TgzwqKHiakXvquRFzRQHNmEAY9QFh55dcF7tXoKeCUF", + "quoteVault": "BbLBAbCSeRR9Yc1GtrbSg47uCYvnsKkW3dmuadAnmnP2" + } + } +] diff --git a/src/connectors/metadao/futarchy-routes/addLiquidity.ts b/src/connectors/metadao/futarchy-routes/addLiquidity.ts new file mode 100644 index 0000000000..3f3e1d33b5 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/addLiquidity.ts @@ -0,0 +1,152 @@ +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; +import { + MetaDaoAddLiquidityRequest, + MetaDaoAddLiquidityRequestType, + MetaDaoAddLiquidityResponse, + MetaDaoAddLiquidityResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: MetaDaoAddLiquidityRequestType; + Reply: MetaDaoAddLiquidityResponseType; + }>( + '/add-liquidity', + { + schema: { + description: 'Add liquidity to MetaDAO spot pool', + tags: ['/connector/metadao'], + body: MetaDaoAddLiquidityRequest, + response: { + 200: MetaDaoAddLiquidityResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken, quoteAmount, maxBaseAmount, minLiquidity, slippagePct = 0.5 } = request.body; + const network = request.body.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken + const daoAddress = findDaoByBaseToken(baseToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No DAO found for baseToken: ${baseToken}`); + } + + const metadao = await MetaDao.getInstance(network); + const solana = metadao.getSolana(); + + // Get wallet + const walletAddress = request.body.walletAddress || getSolanaChainConfig().defaultWallet; + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet configured'); + } + + const wallet = await solana.getWallet(walletAddress); + const lpPubkey = new PublicKey(walletAddress); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Check pool state + const poolStateType = metadao.getPoolStateType(daoAccount); + if (poolStateType !== 'spot') { + throw fastify.httpErrors.badRequest('Liquidity provision is unavailable while a proposal market is active'); + } + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Get spot pool + const spotPool = metadao.getSpotPool(daoAccount); + + // Convert quote amount to raw + const rawQuoteAmount = metadao.toRawAmount(quoteAmount, quoteDecimals); + + // Calculate base amount and liquidity if not provided + const { baseAmount: rawBaseAmount, liquidityMinted: rawLiquidityMinted } = metadao.calculateLiquidityMint( + rawQuoteAmount, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.totalLiquidity, + ); + + // Calculate max base with slippage + let rawMaxBaseAmount = rawBaseAmount.muln(Math.floor((1 + slippagePct / 100) * 10000)).divn(10000); + if (maxBaseAmount !== undefined) { + rawMaxBaseAmount = metadao.toRawAmount(maxBaseAmount, baseDecimals); + } + + // Calculate min liquidity with slippage + let rawMinLiquidity = rawLiquidityMinted.muln(Math.floor((1 - slippagePct / 100) * 10000)).divn(10000); + if (minLiquidity !== undefined) { + rawMinLiquidity = metadao.toRawLiquidity(minLiquidity, quoteDecimals); + } + + // Build add liquidity instruction + const addLiquidityIx = await metadao.buildProvideLiquidityIx({ + dao: new PublicKey(daoAddress), + liquidityProvider: lpPubkey, + quoteAmount: rawQuoteAmount, + maxBaseAmount: rawMaxBaseAmount, + minLiquidity: rawMinLiquidity, + }); + + // Build and send transaction + const connection = solana.connection; + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const createBaseAtaIx = await metadao.buildCreateAtaIx(lpPubkey, daoAccount.baseMint); + const createQuoteAtaIx = await metadao.buildCreateAtaIx(lpPubkey, daoAccount.quoteMint); + + const transaction = new Transaction({ + feePayer: lpPubkey, + blockhash, + lastValidBlockHeight, + }).add(createBaseAtaIx, createQuoteAtaIx, addLiquidityIx); + + // Sign and send + transaction.sign(wallet); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ); + + const status = confirmation.value.err ? -1 : 1; + + // Get position address + const positionAddress = metadao.getAmmPositionAddress(daoAddress, walletAddress); + + return { + signature, + status, + data: { + pool: daoAddress, + positionAddress: positionAddress.toString(), + quoteDeposited: quoteAmount, + baseDeposited: metadao.fromRawAmount(rawBaseAmount, baseDecimals), + liquidityMinted: metadao.fromRawLiquidity(rawLiquidityMinted, quoteDecimals), + fee: 0, + }, + }; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error adding liquidity for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to add liquidity: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/balance.ts b/src/connectors/metadao/futarchy-routes/balance.ts new file mode 100644 index 0000000000..da3ebd68af --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/balance.ts @@ -0,0 +1,179 @@ +import { BN } from '@coral-xyz/anchor'; +import { getAssociatedTokenAddressSync, getAccount } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoBalanceRequest, + MetaDaoBalanceRequestType, + MetaDaoBalanceResponse, + MetaDaoBalanceResponseType, + MetaDaoTokenBalance, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { MetaDao } from '../metadao'; + +async function getTokenBalance( + metadao: MetaDao, + owner: PublicKey, + mint: PublicKey, + symbol: string, + decimals: number, +): Promise { + const solana = metadao.getSolana(); + + try { + const ata = getAssociatedTokenAddressSync(mint, owner); + const account = await getAccount(solana.connection, ata); + + const balanceRaw = account.amount.toString(); + const balance = metadao.fromRawAmount(new BN(balanceRaw), decimals); + + return { + mint: mint.toString(), + symbol, + balance, + balanceRaw, + decimals, + ata: ata.toString(), + }; + } catch { + // Account doesn't exist - return zero balance + return { + mint: mint.toString(), + symbol, + balance: 0, + balanceRaw: '0', + decimals, + }; + } +} + +export const balanceRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoBalanceRequestType; + Reply: MetaDaoBalanceResponseType; + }>( + '/balance', + { + schema: { + description: 'Get token balances for MetaDAO (base, quote, and conditional tokens)', + tags: ['/connector/metadao'], + querystring: MetaDaoBalanceRequest, + response: { + 200: MetaDaoBalanceResponse, + }, + }, + }, + async (request): Promise => { + const { dao: daoAddress, proposal: proposalAddress } = request.query; + const network = request.query.network || 'mainnet-beta'; + const ownerAddress = request.query.ownerAddress; + + if (!ownerAddress) { + throw fastify.httpErrors.badRequest('ownerAddress is required'); + } + + try { + const metadao = await MetaDao.getInstance(network); + + const owner = new PublicKey(ownerAddress); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Get token decimals and symbols + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + const baseSymbol = await metadao.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await metadao.getTokenSymbol(daoAccount.quoteMint.toString()); + + // Get spot token balances + const baseBalance = await getTokenBalance(metadao, owner, daoAccount.baseMint, baseSymbol, baseDecimals); + + const quoteBalance = await getTokenBalance(metadao, owner, daoAccount.quoteMint, quoteSymbol, quoteDecimals); + + const response: MetaDaoBalanceResponseType = { + owner: ownerAddress, + pool: daoAddress, + balances: { + base: baseBalance, + quote: quoteBalance, + }, + }; + + // Get LP position if any + try { + const ammPosition = await metadao.getAmmPosition(daoAddress, ownerAddress); + if (ammPosition && !ammPosition.liquidity.isZero()) { + const liquidity = metadao.fromRawLiquidity(ammPosition.liquidity, quoteDecimals); + const totalLiquidity = metadao.fromRawLiquidity(daoAccount.totalLiquidity, quoteDecimals); + const shareOfPool = totalLiquidity > 0 ? (liquidity / totalLiquidity) * 100 : 0; + + response.balances.lpPosition = { + liquidity, + liquidityRaw: ammPosition.liquidity.toString(), + shareOfPool, + }; + } + } catch { + // LP position doesn't exist - that's fine + } + + // Get conditional token balances if proposal provided + if (proposalAddress) { + response.proposal = proposalAddress; + + try { + const proposalAccount = await metadao.getProposal(proposalAddress); + response.proposalState = metadao.getProposalState(proposalAccount); + + const pdas = await metadao.getProposalPdas(daoAddress, proposalAddress); + + // Get all conditional token balances + response.balances.passBase = await getTokenBalance( + metadao, + owner, + pdas.passBaseMint, + `p${baseSymbol}`, + baseDecimals, + ); + + response.balances.passQuote = await getTokenBalance( + metadao, + owner, + pdas.passQuoteMint, + `p${quoteSymbol}`, + quoteDecimals, + ); + + response.balances.failBase = await getTokenBalance( + metadao, + owner, + pdas.failBaseMint, + `f${baseSymbol}`, + baseDecimals, + ); + + response.balances.failQuote = await getTokenBalance( + metadao, + owner, + pdas.failQuoteMint, + `f${quoteSymbol}`, + quoteDecimals, + ); + } catch (error) { + logger.warn(`Failed to fetch conditional token balances for proposal ${proposalAddress}: ${error}`); + // Don't fail the whole request if proposal fetch fails + } + } + + return response; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error getting balances for owner ${ownerAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to get balances: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/daoInfo.ts b/src/connectors/metadao/futarchy-routes/daoInfo.ts new file mode 100644 index 0000000000..ea92c733f2 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/daoInfo.ts @@ -0,0 +1,117 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoDaoInfoRequest, + MetaDaoDaoInfoRequestType, + MetaDaoDaoInfoResponse, + MetaDaoDaoInfoResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { MetaDao } from '../metadao'; + +// /dao-info - fetches info for a specific pool address from chain +export const daoInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoDaoInfoRequestType; + Reply: MetaDaoDaoInfoResponseType; + }>( + '/dao-info', + { + schema: { + description: 'Get MetaDAO pool info by pool address (fetches from chain)', + tags: ['/connector/metadao'], + querystring: MetaDaoDaoInfoRequest, + response: { + 200: MetaDaoDaoInfoResponse, + }, + }, + }, + async (request): Promise => { + const { pool: daoAddress } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + + // Fetch DAO account directly by address + const daoAccount = await metadao.getDao(daoAddress); + + // Get token decimals for display + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Resolve token symbols + const baseSymbol = await metadao.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await metadao.getTokenSymbol(daoAccount.quoteMint.toString()); + + // Get pool state + const poolStateType = metadao.getPoolStateType(daoAccount); + const spotPool = metadao.getSpotPool(daoAccount); + + // Build spot pool info + const spotPoolInfo = { + baseReserves: metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals), + baseReservesRaw: spotPool.baseReserves.toString(), + quoteReservesRaw: spotPool.quoteReserves.toString(), + price: spotPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + baseVault: daoAccount.ammBaseVault.toString(), + quoteVault: daoAccount.ammQuoteVault.toString(), + }; + + const response: MetaDaoDaoInfoResponseType = { + pool: daoAddress, + baseMint: daoAccount.baseMint.toString(), + quoteMint: daoAccount.quoteMint.toString(), + baseSymbol, + quoteSymbol, + state: poolStateType, + totalLiquidity: metadao.fromRawLiquidity(daoAccount.totalLiquidity, quoteDecimals), + totalLiquidityRaw: daoAccount.totalLiquidity.toString(), + spot: spotPoolInfo, + }; + + // Include pass/fail pools if in futarchy state + if (poolStateType === 'futarchy') { + const passPool = metadao.getPassPool(daoAccount); + const failPool = metadao.getFailPool(daoAccount); + + if (passPool) { + response.pass = { + baseReserves: metadao.fromRawAmount(passPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(passPool.quoteReserves, quoteDecimals), + baseReservesRaw: passPool.baseReserves.toString(), + quoteReservesRaw: passPool.quoteReserves.toString(), + price: passPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(passPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(passPool.baseReserves, baseDecimals), + }; + } + + if (failPool) { + response.fail = { + baseReserves: metadao.fromRawAmount(failPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(failPool.quoteReserves, quoteDecimals), + baseReservesRaw: failPool.baseReserves.toString(), + quoteReservesRaw: failPool.quoteReserves.toString(), + price: failPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(failPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(failPool.baseReserves, baseDecimals), + }; + } + } + + return response; + } catch (error) { + if ((error as any).statusCode) throw error; + logger.error(`Error fetching dao info for pool ${daoAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to fetch dao info: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/executeConditionalSwap.ts b/src/connectors/metadao/futarchy-routes/executeConditionalSwap.ts new file mode 100644 index 0000000000..39c0f706d7 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/executeConditionalSwap.ts @@ -0,0 +1,199 @@ +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; +import { + MetaDaoExecuteConditionalSwapRequest, + MetaDaoExecuteConditionalSwapRequestType, + MetaDaoExecuteConditionalSwapResponse, + MetaDaoExecuteConditionalSwapResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { MetaDao } from '../metadao'; + +export const executeConditionalSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: MetaDaoExecuteConditionalSwapRequestType; + Reply: MetaDaoExecuteConditionalSwapResponseType; + }>( + '/execute-conditional-swap', + { + schema: { + description: 'Execute a conditional market swap on MetaDAO (PASS or FAIL market)', + tags: ['/connector/metadao'], + body: MetaDaoExecuteConditionalSwapRequest, + response: { + 200: MetaDaoExecuteConditionalSwapResponse, + }, + }, + }, + async (request): Promise => { + const { dao: daoAddress, proposal: proposalAddress, market, side, amount, slippagePct = 0.5 } = request.body; + const network = request.body.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + const solana = metadao.getSolana(); + + // Get wallet + const walletAddress = request.body.walletAddress || getSolanaChainConfig().defaultWallet; + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet configured'); + } + + const wallet = await solana.getWallet(walletAddress); + const traderPubkey = new PublicKey(walletAddress); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Check pool state + const poolStateType = metadao.getPoolStateType(daoAccount); + if (poolStateType !== 'futarchy') { + throw fastify.httpErrors.badRequest('Conditional swaps are only available when a proposal market is active'); + } + + // Fetch proposal and validate state + const proposalAccount = await metadao.getProposal(proposalAddress); + const proposalState = metadao.getProposalState(proposalAccount); + + if (proposalState !== 'pending') { + throw fastify.httpErrors.badRequest(`Proposal is not in active trading state (current: ${proposalState})`); + } + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + const pdas = { + question: proposalAccount.question, + baseVault: proposalAccount.baseVault, + quoteVault: proposalAccount.quoteVault, + passBaseMint: proposalAccount.passBaseMint, + passQuoteMint: proposalAccount.passQuoteMint, + failBaseMint: proposalAccount.failBaseMint, + failQuoteMint: proposalAccount.failQuoteMint, + }; + + let tokenIn: string; + let tokenOut: string; + + if (side === 'BUY') { + if (market === 'PASS') { + tokenIn = pdas.passQuoteMint.toString(); + tokenOut = pdas.passBaseMint.toString(); + } else { + tokenIn = pdas.failQuoteMint.toString(); + tokenOut = pdas.failBaseMint.toString(); + } + } else { + if (market === 'PASS') { + tokenIn = pdas.passBaseMint.toString(); + tokenOut = pdas.passQuoteMint.toString(); + } else { + tokenIn = pdas.failBaseMint.toString(); + tokenOut = pdas.failQuoteMint.toString(); + } + } + + const pool = market === 'PASS' ? metadao.getPassPool(daoAccount) : metadao.getFailPool(daoAccount); + + if (!pool) { + throw fastify.httpErrors.internalServerError(`${market} pool not found`); + } + + const rawBaseAmount = metadao.toRawAmount(amount, baseDecimals); + const slippageMultiplier = 1 - slippagePct / 100; + const maxSlippageMultiplier = 1 + slippagePct / 100; + + let rawInputAmount = rawBaseAmount; + let rawMinOutput = metadao + .calculateSwapOutput( + rawInputAmount, + pool.baseReserves, + pool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ) + .output.muln(Math.floor(slippageMultiplier * 10000)) + .divn(10000); + let swapType: 'BUY' | 'SELL' = 'SELL'; + let amountIn = amount; + let amountOut = metadao.fromRawAmount(rawMinOutput, quoteDecimals); + + if (side === 'BUY') { + swapType = 'BUY'; + const rawRequiredInput = metadao.calculateSwapInputForOutput( + rawBaseAmount, + pool.quoteReserves, + pool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + rawInputAmount = rawRequiredInput.muln(Math.floor(maxSlippageMultiplier * 10000)).divn(10000); + rawMinOutput = rawBaseAmount; + amountIn = metadao.fromRawAmount(rawInputAmount, quoteDecimals); + amountOut = amount; + } + + // Build conditional swap instruction + const swapIx = await metadao.buildConditionalSwapIx({ + dao: new PublicKey(daoAddress), + proposal: new PublicKey(proposalAddress), + trader: traderPubkey, + market: market.toLowerCase() as 'pass' | 'fail', + swapType: swapType.toLowerCase() as 'buy' | 'sell', + inputAmount: rawInputAmount, + minOutputAmount: rawMinOutput, + }); + + // Build and send transaction + const connection = solana.connection; + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const createInputAtaIx = await metadao.buildCreateAtaIx(traderPubkey, new PublicKey(tokenIn)); + const createOutputAtaIx = await metadao.buildCreateAtaIx(traderPubkey, new PublicKey(tokenOut)); + + const transaction = new Transaction({ + feePayer: traderPubkey, + blockhash, + lastValidBlockHeight, + }).add(createInputAtaIx, createOutputAtaIx, swapIx); + + // Sign and send + transaction.sign(wallet); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ); + + const status = confirmation.value.err ? -1 : 1; + + return { + signature, + status, + data: { + market, + tokenIn, + tokenOut, + amountIn, + amountOut, + fee: 0, + baseTokenBalanceChange: side === 'BUY' ? amountOut : -amountIn, + quoteTokenBalanceChange: side === 'BUY' ? -amountIn : amountOut, + }, + }; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error executing conditional swap for DAO ${daoAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to execute conditional swap: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/executeSwap.ts b/src/connectors/metadao/futarchy-routes/executeSwap.ts new file mode 100644 index 0000000000..33fd4c6b56 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/executeSwap.ts @@ -0,0 +1,159 @@ +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; +import { + MetaDaoExecuteSwapRequest, + MetaDaoExecuteSwapRequestType, + MetaDaoExecuteSwapResponse, + MetaDaoExecuteSwapResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: MetaDaoExecuteSwapRequestType; + Reply: MetaDaoExecuteSwapResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Execute a spot market swap on MetaDAO', + tags: ['/connector/metadao'], + body: MetaDaoExecuteSwapRequest, + response: { + 200: MetaDaoExecuteSwapResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken, quoteToken = 'USDC', amount, side, slippagePct = 0.5 } = request.body; + const network = request.body.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken and quoteToken + const daoAddress = findDaoByBaseToken(baseToken, quoteToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No pool found for baseToken: ${baseToken}, quoteToken: ${quoteToken}`); + } + + const metadao = await MetaDao.getInstance(network); + const solana = metadao.getSolana(); + + // Get wallet + const walletAddress = request.body.walletAddress || getSolanaChainConfig().defaultWallet; + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet configured'); + } + + const wallet = await solana.getWallet(walletAddress); + const traderPubkey = new PublicKey(walletAddress); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + const baseMint = daoAccount.baseMint.toString(); + const quoteMint = daoAccount.quoteMint.toString(); + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(baseMint); + const quoteDecimals = await metadao.getTokenDecimals(quoteMint); + const spotPool = metadao.getSpotPool(daoAccount); + + const rawBaseAmount = metadao.toRawAmount(amount, baseDecimals); + const slippageMultiplier = 1 - slippagePct / 100; + const maxSlippageMultiplier = 1 + slippagePct / 100; + + let rawInputAmount = rawBaseAmount; + let rawMinOutput = metadao + .calculateSwapOutput( + rawInputAmount, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ) + .output.muln(Math.floor(slippageMultiplier * 10000)) + .divn(10000); + let swapType: 'BUY' | 'SELL' = 'SELL'; + let tokenIn = baseMint; + let tokenOut = quoteMint; + let amountIn = amount; + let amountOut = metadao.fromRawAmount(rawMinOutput, quoteDecimals); + + if (side === 'BUY') { + swapType = 'BUY'; + tokenIn = quoteMint; + tokenOut = baseMint; + const rawRequiredInput = metadao.calculateSwapInputForOutput( + rawBaseAmount, + spotPool.quoteReserves, + spotPool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + rawInputAmount = rawRequiredInput.muln(Math.floor(maxSlippageMultiplier * 10000)).divn(10000); + rawMinOutput = rawBaseAmount; + amountIn = metadao.fromRawAmount(rawInputAmount, quoteDecimals); + amountOut = amount; + } + + // Build swap instruction + const swapIx = await metadao.buildSpotSwapIx({ + dao: new PublicKey(daoAddress), + trader: traderPubkey, + swapType: swapType.toLowerCase() as 'buy' | 'sell', + inputAmount: rawInputAmount, + minOutputAmount: rawMinOutput, + }); + + // Build and send transaction + const connection = solana.connection; + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const createBaseAtaIx = await metadao.buildCreateAtaIx(traderPubkey, daoAccount.baseMint); + const createQuoteAtaIx = await metadao.buildCreateAtaIx(traderPubkey, daoAccount.quoteMint); + + const transaction = new Transaction({ + feePayer: traderPubkey, + blockhash, + lastValidBlockHeight, + }).add(createBaseAtaIx, createQuoteAtaIx, swapIx); + + // Sign and send + transaction.sign(wallet); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ); + + const status = confirmation.value.err ? -1 : 1; + + return { + signature, + status, + data: { + tokenIn, + tokenOut, + amountIn, + amountOut, + fee: 0, + baseTokenBalanceChange: side === 'BUY' ? amountOut : -amountIn, + quoteTokenBalanceChange: side === 'BUY' ? -amountIn : amountOut, + }, + }; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error executing swap for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to execute swap: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/index.ts b/src/connectors/metadao/futarchy-routes/index.ts new file mode 100644 index 0000000000..d4145a9fb7 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/index.ts @@ -0,0 +1,49 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { addLiquidityRoute } from './addLiquidity'; +import { balanceRoute } from './balance'; +import { daoInfoRoute } from './daoInfo'; +import { executeConditionalSwapRoute } from './executeConditionalSwap'; +import { executeSwapRoute } from './executeSwap'; +import { listDaosRoute, listPoolsRoute } from './listDaos'; +import { listProposalsRoute } from './listProposals'; +import { poolInfoRoute } from './poolInfo'; +import { proposalInfoRoute } from './proposalInfo'; +import { quoteConditionalSwapRoute } from './quoteConditionalSwap'; +import { quoteLiquidityRoute } from './quoteLiquidity'; +import { quoteSwapRoute } from './quoteSwap'; +import { removeLiquidityRoute } from './removeLiquidity'; +import { splitTokensRoute } from './splitTokens'; + +export const metadaoFutarchyRoutes: FastifyPluginAsync = async (fastify) => { + // DAO and Pool Discovery + await fastify.register(listDaosRoute); // /daos - fetches from chain + await fastify.register(listPoolsRoute); // /pools - cached from daos.json + await fastify.register(listProposalsRoute); + + // Pool and Proposal info + await fastify.register(daoInfoRoute); // /dao-info - lookup by pool address (from chain) + await fastify.register(poolInfoRoute); // /pool-info - lookup by baseToken (uses cached lookup) + await fastify.register(proposalInfoRoute); + + // Spot swap + await fastify.register(quoteSwapRoute); + await fastify.register(executeSwapRoute); + + // Liquidity + await fastify.register(quoteLiquidityRoute); + await fastify.register(addLiquidityRoute); + await fastify.register(removeLiquidityRoute); + + // Conditional swap + await fastify.register(quoteConditionalSwapRoute); + await fastify.register(executeConditionalSwapRoute); + + // Conditional vault split + await fastify.register(splitTokensRoute); + + // Balance + await fastify.register(balanceRoute); +}; + +export default metadaoFutarchyRoutes; diff --git a/src/connectors/metadao/futarchy-routes/listDaos.ts b/src/connectors/metadao/futarchy-routes/listDaos.ts new file mode 100644 index 0000000000..65e6e37b80 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/listDaos.ts @@ -0,0 +1,69 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoListDaosItem, + MetaDaoListDaosRequest, + MetaDaoListDaosRequestType, + MetaDaoListDaosResponse, + MetaDaoListDaosResponseType, + MetaDaoListPoolsResponse, + MetaDaoListPoolsResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import poolsData from '../daos.json'; +import { MetaDao } from '../metadao'; + +const pools: MetaDaoListDaosItem[] = poolsData as MetaDaoListDaosItem[]; + +// /daos - fetches DAO addresses from chain +export const listDaosRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoListDaosRequestType; + Reply: MetaDaoListDaosResponseType; + }>( + '/daos', + { + schema: { + description: 'List all MetaDAO DAO addresses from chain', + tags: ['/connector/metadao'], + querystring: MetaDaoListDaosRequest, + response: { + 200: MetaDaoListDaosResponse, + }, + }, + }, + async (request): Promise => { + const network = request.query.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + const daos = await metadao.listDaos(); + return { daos }; + } catch (error) { + logger.error(`Error listing DAOs: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to list DAOs: ${error}`); + } + }, + ); +}; + +// /pools - returns cached pool list from daos.json +export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Reply: MetaDaoListPoolsResponseType; + }>( + '/pools', + { + schema: { + description: 'List cached MetaDAO pools with token information (from daos.json)', + tags: ['/connector/metadao'], + response: { + 200: MetaDaoListPoolsResponse, + }, + }, + }, + async (): Promise => { + return { pools }; + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/listProposals.ts b/src/connectors/metadao/futarchy-routes/listProposals.ts new file mode 100644 index 0000000000..3bd28039ce --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/listProposals.ts @@ -0,0 +1,55 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoListProposalsRequest, + MetaDaoListProposalsRequestType, + MetaDaoListProposalsResponse, + MetaDaoListProposalsResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const listProposalsRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoListProposalsRequestType; + Reply: MetaDaoListProposalsResponseType; + }>( + '/proposals', + { + schema: { + description: 'List proposals for a MetaDAO DAO', + tags: ['/connector/metadao'], + querystring: MetaDaoListProposalsRequest, + response: { + 200: MetaDaoListProposalsResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken } = request.query; + const status = (request.query.status || 'all') as 'pending' | 'passed' | 'failed' | 'all'; + const network = request.query.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken + const daoAddress = findDaoByBaseToken(baseToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No DAO found for baseToken: ${baseToken}`); + } + + const metadao = await MetaDao.getInstance(network); + const proposals = await metadao.listProposals(daoAddress, status); + + return { + pool: daoAddress, + proposals, + }; + } catch (error) { + if ((error as any).statusCode) throw error; + logger.error(`Error listing proposals for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to list proposals: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/poolInfo.ts b/src/connectors/metadao/futarchy-routes/poolInfo.ts new file mode 100644 index 0000000000..02df2e4339 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/poolInfo.ts @@ -0,0 +1,122 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoPoolInfoRequest, + MetaDaoPoolInfoRequestType, + MetaDaoPoolInfoResponse, + MetaDaoPoolInfoResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoPoolInfoRequestType; + Reply: MetaDaoPoolInfoResponseType; + }>( + '/pool-info', + { + schema: { + description: 'Get MetaDAO DAO AMM state, reserves, and proposal info', + tags: ['/connector/metadao'], + querystring: MetaDaoPoolInfoRequest, + response: { + 200: MetaDaoPoolInfoResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken + const daoAddress = findDaoByBaseToken(baseToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No DAO found for baseToken: ${baseToken}`); + } + + const metadao = await MetaDao.getInstance(network); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Get token decimals for display + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Resolve token symbols + const baseSymbol = await metadao.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await metadao.getTokenSymbol(daoAccount.quoteMint.toString()); + + // Get pool state + const poolStateType = metadao.getPoolStateType(daoAccount); + const spotPool = metadao.getSpotPool(daoAccount); + + // Build spot pool info + const spotPoolInfo = { + baseReserves: metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals), + baseReservesRaw: spotPool.baseReserves.toString(), + quoteReservesRaw: spotPool.quoteReserves.toString(), + price: spotPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + baseVault: daoAccount.ammBaseVault.toString(), + quoteVault: daoAccount.ammQuoteVault.toString(), + }; + + const response: MetaDaoPoolInfoResponseType = { + pool: daoAddress, + baseMint: daoAccount.baseMint.toString(), + quoteMint: daoAccount.quoteMint.toString(), + baseSymbol, + quoteSymbol, + state: poolStateType, + totalLiquidity: metadao.fromRawLiquidity(daoAccount.totalLiquidity, quoteDecimals), + totalLiquidityRaw: daoAccount.totalLiquidity.toString(), + spot: spotPoolInfo, + }; + + // Include pass/fail pools if in futarchy state + if (poolStateType === 'futarchy') { + const passPool = metadao.getPassPool(daoAccount); + const failPool = metadao.getFailPool(daoAccount); + + if (passPool) { + response.pass = { + baseReserves: metadao.fromRawAmount(passPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(passPool.quoteReserves, quoteDecimals), + baseReservesRaw: passPool.baseReserves.toString(), + quoteReservesRaw: passPool.quoteReserves.toString(), + price: passPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(passPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(passPool.baseReserves, baseDecimals), + }; + } + + if (failPool) { + response.fail = { + baseReserves: metadao.fromRawAmount(failPool.baseReserves, baseDecimals), + quoteReserves: metadao.fromRawAmount(failPool.quoteReserves, quoteDecimals), + baseReservesRaw: failPool.baseReserves.toString(), + quoteReservesRaw: failPool.quoteReserves.toString(), + price: failPool.baseReserves.isZero() + ? 0 + : metadao.fromRawAmount(failPool.quoteReserves, quoteDecimals) / + metadao.fromRawAmount(failPool.baseReserves, baseDecimals), + }; + } + } + + return response; + } catch (error) { + logger.error(`Error fetching pool info for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to fetch pool info: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/proposalInfo.ts b/src/connectors/metadao/futarchy-routes/proposalInfo.ts new file mode 100644 index 0000000000..71b81a0150 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/proposalInfo.ts @@ -0,0 +1,72 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoProposalInfoRequest, + MetaDaoProposalInfoRequestType, + MetaDaoProposalInfoResponse, + MetaDaoProposalInfoResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { MetaDao } from '../metadao'; + +export const proposalInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoProposalInfoRequestType; + Reply: MetaDaoProposalInfoResponseType; + }>( + '/proposal-info', + { + schema: { + description: 'Get detailed information about a MetaDAO proposal including pass/fail markets', + tags: ['/connector/metadao'], + querystring: MetaDaoProposalInfoRequest, + response: { + 200: MetaDaoProposalInfoResponse, + }, + }, + }, + async (request): Promise => { + const { proposal: proposalAddress } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + const info = await metadao.getProposalInfo(undefined, proposalAddress); + + return { + proposal: info.proposal, + pool: info.dao, + number: info.number, + status: info.status, + launchedAt: info.launchedAt, + tradingEndsAt: info.tradingEndsAt, + baseMint: info.baseMint, + quoteMint: info.quoteMint, + baseSymbol: info.baseSymbol, + quoteSymbol: info.quoteSymbol, + pdas: { + question: info.pdas.question.toString(), + baseVault: info.pdas.baseVault.toString(), + quoteVault: info.pdas.quoteVault.toString(), + passBaseMint: info.pdas.passBaseMint.toString(), + passQuoteMint: info.pdas.passQuoteMint.toString(), + failBaseMint: info.pdas.failBaseMint.toString(), + failQuoteMint: info.pdas.failQuoteMint.toString(), + }, + passPool: info.passPool, + failPool: info.failPool, + impliedProbability: info.impliedProbability, + passThresholdBps: info.passThresholdBps, + isTeamSponsored: info.isTeamSponsored, + twap: info.twap, + passMarketCap: info.passMarketCap, + failMarketCap: info.failMarketCap, + }; + } catch (error) { + if ((error as any).statusCode) throw error; + logger.error(`Error getting proposal info for ${proposalAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to get proposal info: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/quoteConditionalSwap.ts b/src/connectors/metadao/futarchy-routes/quoteConditionalSwap.ts new file mode 100644 index 0000000000..a9b7f21516 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/quoteConditionalSwap.ts @@ -0,0 +1,218 @@ +import { FastifyPluginAsync } from 'fastify'; +import { v4 as uuidv4 } from 'uuid'; + +import { + MetaDaoQuoteConditionalSwapRequest, + MetaDaoQuoteConditionalSwapRequestType, + MetaDaoQuoteConditionalSwapResponse, + MetaDaoQuoteConditionalSwapResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { quoteCache } from '../../../services/quote-cache'; +import { MetaDao } from '../metadao'; + +export const quoteConditionalSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoQuoteConditionalSwapRequestType; + Reply: MetaDaoQuoteConditionalSwapResponseType; + }>( + '/quote-conditional-swap', + { + schema: { + description: 'Get a quote for conditional market swap on MetaDAO (PASS or FAIL market)', + tags: ['/connector/metadao'], + querystring: MetaDaoQuoteConditionalSwapRequest, + response: { + 200: MetaDaoQuoteConditionalSwapResponse, + }, + }, + }, + async (request): Promise => { + const { dao: daoAddress, proposal: proposalAddress, market, side, amount, slippagePct = 0.5 } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Check pool state - must be in futarchy state for conditional swaps + const poolStateType = metadao.getPoolStateType(daoAccount); + if (poolStateType !== 'futarchy') { + throw fastify.httpErrors.badRequest('Conditional swaps are only available when a proposal market is active'); + } + + // Fetch proposal and validate state + const proposalAccount = await metadao.getProposal(proposalAddress); + const proposalState = metadao.getProposalState(proposalAccount); + + if (proposalState !== 'pending') { + throw fastify.httpErrors.badRequest(`Proposal is not in active trading state (current: ${proposalState})`); + } + + // Get proposal PDAs for conditional token mints + const pdas = await metadao.getProposalPdas(daoAddress, proposalAddress); + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Get the appropriate pool and determine input/output mints + const pool = market === 'PASS' ? metadao.getPassPool(daoAccount) : metadao.getFailPool(daoAccount); + + if (!pool) { + throw fastify.httpErrors.internalServerError(`${market} pool not found in futarchy state`); + } + + // Determine input/output mints and symbols based on market and side + let tokenIn: string; + let tokenOut: string; + let inputSymbol: string; + let outputSymbol: string; + + const baseSymbol = await metadao.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await metadao.getTokenSymbol(daoAccount.quoteMint.toString()); + + const prefix = market === 'PASS' ? 'p' : 'f'; + + if (side === 'BUY') { + // BUY conditional base with conditional quote + if (market === 'PASS') { + tokenIn = pdas.passQuoteMint.toString(); + tokenOut = pdas.passBaseMint.toString(); + } else { + tokenIn = pdas.failQuoteMint.toString(); + tokenOut = pdas.failBaseMint.toString(); + } + inputSymbol = `${prefix}${quoteSymbol}`; + outputSymbol = `${prefix}${baseSymbol}`; + } else { + // SELL conditional base for conditional quote + if (market === 'PASS') { + tokenIn = pdas.passBaseMint.toString(); + tokenOut = pdas.passQuoteMint.toString(); + } else { + tokenIn = pdas.failBaseMint.toString(); + tokenOut = pdas.failQuoteMint.toString(); + } + inputSymbol = `${prefix}${baseSymbol}`; + outputSymbol = `${prefix}${quoteSymbol}`; + } + + const slippageMultiplier = 1 - slippagePct / 100; + const maxSlippageMultiplier = 1 + slippagePct / 100; + const rawBaseAmount = metadao.toRawAmount(amount, baseDecimals); + + let rawAmountIn = rawBaseAmount; + let rawAmountOut = metadao.calculateSwapOutput( + rawAmountIn, + pool.baseReserves, + pool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ).output; + let amountIn = amount; + let amountOut = metadao.fromRawAmount(rawAmountOut, quoteDecimals); + let minAmountOut = amountOut * slippageMultiplier; + let maxAmountIn = amount; + let priceImpactPct = metadao.calculateSwapOutput( + rawAmountIn, + pool.baseReserves, + pool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ).priceImpact; + + if (side === 'BUY') { + rawAmountOut = rawBaseAmount; + rawAmountIn = metadao.calculateSwapInputForOutput( + rawAmountOut, + pool.quoteReserves, + pool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + const quoteResult = metadao.calculateSwapOutput( + rawAmountIn, + pool.quoteReserves, + pool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + amountIn = metadao.fromRawAmount(rawAmountIn, quoteDecimals); + amountOut = amount; + minAmountOut = amount; + maxAmountIn = amountIn * maxSlippageMultiplier; + priceImpactPct = quoteResult.priceImpact; + } + + const price = amountOut > 0 ? amountIn / amountOut : 0; + + // Calculate trading end time (3 days from launch) + const tradingEndsAt = proposalAccount.launchedAt.toNumber() + 3 * 24 * 60 * 60; + + // Generate quote ID and cache + const quoteId = uuidv4(); + quoteCache.set(quoteId, { + daoAddress, + proposalAddress, + market, + side, + amount, + amountIn, + amountOut, + minAmountOut, + maxAmountIn, + rawInputAmount: rawAmountIn.toString(), + rawOutputAmount: rawAmountOut.toString(), + inputDecimals: side === 'BUY' ? quoteDecimals : baseDecimals, + outputDecimals: side === 'BUY' ? baseDecimals : quoteDecimals, + inputMint: tokenIn, + outputMint: tokenOut, + }); + + const response: MetaDaoQuoteConditionalSwapResponseType = { + quoteId, + pool: daoAddress, + proposal: proposalAddress, + market, + side, + tokenIn, + tokenOut, + inputSymbol, + outputSymbol, + amountIn, + amountOut, + price, + priceImpactPct, + minAmountOut, + maxAmountIn, + poolReserves: { + base: metadao.fromRawAmount(pool.baseReserves, baseDecimals), + quote: metadao.fromRawAmount(pool.quoteReserves, quoteDecimals), + baseRaw: pool.baseReserves.toString(), + quoteRaw: pool.quoteReserves.toString(), + }, + proposalState, + tradingEndsAt, + pdas: { + question: pdas.question.toString(), + baseVault: pdas.baseVault.toString(), + quoteVault: pdas.quoteVault.toString(), + passBaseMint: pdas.passBaseMint.toString(), + passQuoteMint: pdas.passQuoteMint.toString(), + failBaseMint: pdas.failBaseMint.toString(), + failQuoteMint: pdas.failQuoteMint.toString(), + }, + }; + + return response; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error getting conditional swap quote for DAO ${daoAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to get conditional swap quote: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/quoteLiquidity.ts b/src/connectors/metadao/futarchy-routes/quoteLiquidity.ts new file mode 100644 index 0000000000..973c1f3df4 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/quoteLiquidity.ts @@ -0,0 +1,114 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { + MetaDaoQuoteLiquidityRequest, + MetaDaoQuoteLiquidityRequestType, + MetaDaoQuoteLiquidityResponse, + MetaDaoQuoteLiquidityResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoQuoteLiquidityRequestType; + Reply: MetaDaoQuoteLiquidityResponseType; + }>( + '/quote-liquidity', + { + schema: { + description: 'Get a quote for adding liquidity to MetaDAO spot pool', + tags: ['/connector/metadao'], + querystring: MetaDaoQuoteLiquidityRequest, + response: { + 200: MetaDaoQuoteLiquidityResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken, quoteAmount, slippagePct = 0.5 } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken + const daoAddress = findDaoByBaseToken(baseToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No DAO found for baseToken: ${baseToken}`); + } + + const metadao = await MetaDao.getInstance(network); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Check pool state - liquidity operations only work in Spot state + const poolStateType = metadao.getPoolStateType(daoAccount); + if (poolStateType !== 'spot') { + throw fastify.httpErrors.badRequest('Liquidity provision is unavailable while a proposal market is active'); + } + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Get spot pool + const spotPool = metadao.getSpotPool(daoAccount); + + // Convert quote amount to raw + const rawQuoteAmount = metadao.toRawAmount(quoteAmount, quoteDecimals); + + // Calculate liquidity + const { baseAmount: rawBaseAmount, liquidityMinted: rawLiquidityMinted } = metadao.calculateLiquidityMint( + rawQuoteAmount, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.totalLiquidity, + ); + + // Convert to human-readable + const baseAmount = metadao.fromRawAmount(rawBaseAmount, baseDecimals); + const liquidityMinted = metadao.fromRawLiquidity(rawLiquidityMinted, quoteDecimals); + const totalLiquidity = metadao.fromRawLiquidity(daoAccount.totalLiquidity, quoteDecimals); + + // Calculate with slippage + const slippageMultiplier = 1 + slippagePct / 100; + const maxBaseAmount = baseAmount * slippageMultiplier; + + const minSlippageMultiplier = 1 - slippagePct / 100; + const minLiquidity = liquidityMinted * minSlippageMultiplier; + + // Calculate share of pool after deposit + const newTotalLiquidity = totalLiquidity + liquidityMinted; + const shareOfPool = newTotalLiquidity > 0 ? (liquidityMinted / newTotalLiquidity) * 100 : 100; + + const response: MetaDaoQuoteLiquidityResponseType = { + pool: daoAddress, + baseMint: daoAccount.baseMint.toString(), + quoteMint: daoAccount.quoteMint.toString(), + poolState: poolStateType, + quoteAmount, + baseAmount, + maxBaseAmount, + liquidityMinted, + minLiquidity, + totalLiquidity, + totalLiquidityRaw: daoAccount.totalLiquidity.toString(), + shareOfPool, + poolReserves: { + base: metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + quote: metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals), + baseRaw: spotPool.baseReserves.toString(), + quoteRaw: spotPool.quoteReserves.toString(), + }, + }; + + return response; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error getting liquidity quote for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to get liquidity quote: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/quoteSwap.ts b/src/connectors/metadao/futarchy-routes/quoteSwap.ts new file mode 100644 index 0000000000..976e2eebe7 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/quoteSwap.ts @@ -0,0 +1,164 @@ +import { FastifyPluginAsync } from 'fastify'; +import { v4 as uuidv4 } from 'uuid'; + +import { + MetaDaoQuoteSwapRequest, + MetaDaoQuoteSwapRequestType, + MetaDaoQuoteSwapResponse, + MetaDaoQuoteSwapResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { quoteCache } from '../../../services/quote-cache'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: MetaDaoQuoteSwapRequestType; + Reply: MetaDaoQuoteSwapResponseType; + }>( + '/quote-swap', + { + schema: { + description: 'Get a quote for spot market swap on MetaDAO', + tags: ['/connector/metadao'], + querystring: MetaDaoQuoteSwapRequest, + response: { + 200: MetaDaoQuoteSwapResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken, quoteToken = 'USDC', amount, side, slippagePct = 0.5 } = request.query; + const network = request.query.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken and quoteToken + const daoAddress = findDaoByBaseToken(baseToken, quoteToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No pool found for baseToken: ${baseToken}, quoteToken: ${quoteToken}`); + } + + const metadao = await MetaDao.getInstance(network); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + const baseMint = daoAccount.baseMint.toString(); + const quoteMint = daoAccount.quoteMint.toString(); + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(baseMint); + const quoteDecimals = await metadao.getTokenDecimals(quoteMint); + + // Get spot pool + const spotPool = metadao.getSpotPool(daoAccount); + + const slippageMultiplier = 1 - slippagePct / 100; + const maxSlippageMultiplier = 1 + slippagePct / 100; + const rawBaseAmount = metadao.toRawAmount(amount, baseDecimals); + + let tokenIn = baseMint; + let tokenOut = quoteMint; + let rawAmountIn = rawBaseAmount; + let rawAmountOut = metadao.calculateSwapOutput( + rawAmountIn, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ).output; + let amountIn = amount; + let amountOut = metadao.fromRawAmount(rawAmountOut, quoteDecimals); + let minAmountOut = amountOut * slippageMultiplier; + let maxAmountIn = amount; + let priceImpactPct = metadao.calculateSwapOutput( + rawAmountIn, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ).priceImpact; + + if (side === 'BUY') { + tokenIn = quoteMint; + tokenOut = baseMint; + rawAmountOut = rawBaseAmount; + rawAmountIn = metadao.calculateSwapInputForOutput( + rawAmountOut, + spotPool.quoteReserves, + spotPool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + const quoteResult = metadao.calculateSwapOutput( + rawAmountIn, + spotPool.quoteReserves, + spotPool.baseReserves, + daoAccount.protocolFeeBps, + daoAccount.lpFeeBps, + ); + amountIn = metadao.fromRawAmount(rawAmountIn, quoteDecimals); + amountOut = amount; + minAmountOut = amount; + maxAmountIn = amountIn * maxSlippageMultiplier; + priceImpactPct = quoteResult.priceImpact; + } + + // Price is always quote per base + const price = + side === 'SELL' + ? amountIn > 0 + ? amountOut / amountIn + : 0 // SELL base -> get quote + : amountOut > 0 + ? amountIn / amountOut + : 0; // BUY base <- pay quote + + // Generate quote ID and cache + const quoteId = uuidv4(); + quoteCache.set(quoteId, { + daoAddress, + side, + amount, + amountIn, + amountOut, + minAmountOut, + maxAmountIn, + rawInputAmount: rawAmountIn.toString(), + rawOutputAmount: rawAmountOut.toString(), + inputDecimals: side === 'BUY' ? quoteDecimals : baseDecimals, + outputDecimals: side === 'BUY' ? baseDecimals : quoteDecimals, + }); + + const response: MetaDaoQuoteSwapResponseType = { + quoteId, + pool: daoAddress, + tokenIn, + tokenOut, + amountIn, + amountOut, + price, + priceImpactPct, + minAmountOut, + maxAmountIn, + fee: { + lpFeeBps: daoAccount.lpFeeBps, + protocolFeeBps: daoAccount.protocolFeeBps, + totalFeeBps: daoAccount.lpFeeBps + daoAccount.protocolFeeBps, + }, + poolReserves: { + base: metadao.fromRawAmount(spotPool.baseReserves, baseDecimals), + quote: metadao.fromRawAmount(spotPool.quoteReserves, quoteDecimals), + baseRaw: spotPool.baseReserves.toString(), + quoteRaw: spotPool.quoteReserves.toString(), + }, + }; + + return response; + } catch (error) { + logger.error(`Error getting swap quote for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to get swap quote: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/removeLiquidity.ts b/src/connectors/metadao/futarchy-routes/removeLiquidity.ts new file mode 100644 index 0000000000..28e2446693 --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/removeLiquidity.ts @@ -0,0 +1,151 @@ +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; +import { + MetaDaoRemoveLiquidityRequest, + MetaDaoRemoveLiquidityRequestType, + MetaDaoRemoveLiquidityResponse, + MetaDaoRemoveLiquidityResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { findDaoByBaseToken } from '../dao-lookup'; +import { MetaDao } from '../metadao'; + +export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: MetaDaoRemoveLiquidityRequestType; + Reply: MetaDaoRemoveLiquidityResponseType; + }>( + '/remove-liquidity', + { + schema: { + description: 'Remove liquidity from MetaDAO spot pool', + tags: ['/connector/metadao'], + body: MetaDaoRemoveLiquidityRequest, + response: { + 200: MetaDaoRemoveLiquidityResponse, + }, + }, + }, + async (request): Promise => { + const { baseToken, liquidityAmount, minBaseAmount, minQuoteAmount, slippagePct = 0.5 } = request.body; + const network = request.body.network || 'mainnet-beta'; + + try { + // Look up DAO by baseToken + const daoAddress = findDaoByBaseToken(baseToken); + if (!daoAddress) { + throw fastify.httpErrors.notFound(`No DAO found for baseToken: ${baseToken}`); + } + + const metadao = await MetaDao.getInstance(network); + const solana = metadao.getSolana(); + + // Get wallet + const walletAddress = request.body.walletAddress || getSolanaChainConfig().defaultWallet; + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet configured'); + } + + const wallet = await solana.getWallet(walletAddress); + const lpPubkey = new PublicKey(walletAddress); + + // Fetch DAO account + const daoAccount = await metadao.getDao(daoAddress); + + // Check pool state + const poolStateType = metadao.getPoolStateType(daoAccount); + if (poolStateType !== 'spot') { + throw fastify.httpErrors.badRequest('Liquidity withdrawal is unavailable while a proposal market is active'); + } + + // Get token decimals + const baseDecimals = await metadao.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await metadao.getTokenDecimals(daoAccount.quoteMint.toString()); + + // Get spot pool + const spotPool = metadao.getSpotPool(daoAccount); + + const rawLiquidityAmount = metadao.toRawLiquidity(liquidityAmount, quoteDecimals); + + // Calculate expected outputs + const { baseAmount: rawBaseAmount, quoteAmount: rawQuoteAmount } = metadao.calculateLiquidityWithdraw( + rawLiquidityAmount, + spotPool.baseReserves, + spotPool.quoteReserves, + daoAccount.totalLiquidity, + ); + + // Calculate min amounts with slippage + const slippageMultiplier = Math.floor((1 - slippagePct / 100) * 10000); + let rawMinBaseAmount = rawBaseAmount.muln(slippageMultiplier).divn(10000); + let rawMinQuoteAmount = rawQuoteAmount.muln(slippageMultiplier).divn(10000); + + if (minBaseAmount !== undefined) { + rawMinBaseAmount = metadao.toRawAmount(minBaseAmount, baseDecimals); + } + if (minQuoteAmount !== undefined) { + rawMinQuoteAmount = metadao.toRawAmount(minQuoteAmount, quoteDecimals); + } + + // Build remove liquidity instruction + const removeLiquidityIx = await metadao.buildWithdrawLiquidityIx({ + dao: new PublicKey(daoAddress), + liquidityProvider: lpPubkey, + liquidityAmount: rawLiquidityAmount, + minBaseAmount: rawMinBaseAmount, + minQuoteAmount: rawMinQuoteAmount, + }); + + // Build and send transaction + const connection = solana.connection; + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const createBaseAtaIx = await metadao.buildCreateAtaIx(lpPubkey, daoAccount.baseMint); + const createQuoteAtaIx = await metadao.buildCreateAtaIx(lpPubkey, daoAccount.quoteMint); + + const transaction = new Transaction({ + feePayer: lpPubkey, + blockhash, + lastValidBlockHeight, + }).add(createBaseAtaIx, createQuoteAtaIx, removeLiquidityIx); + + // Sign and send + transaction.sign(wallet); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ); + + const status = confirmation.value.err ? -1 : 1; + + // Get position address + const positionAddress = metadao.getAmmPositionAddress(daoAddress, walletAddress); + + return { + signature, + status, + data: { + pool: daoAddress, + positionAddress: positionAddress.toString(), + liquidityBurned: liquidityAmount, + baseReceived: metadao.fromRawAmount(rawBaseAmount, baseDecimals), + quoteReceived: metadao.fromRawAmount(rawQuoteAmount, quoteDecimals), + fee: 0, + }, + }; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error removing liquidity for baseToken ${baseToken}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to remove liquidity: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/futarchy-routes/splitTokens.ts b/src/connectors/metadao/futarchy-routes/splitTokens.ts new file mode 100644 index 0000000000..dec8f1eb6c --- /dev/null +++ b/src/connectors/metadao/futarchy-routes/splitTokens.ts @@ -0,0 +1,110 @@ +import { BN } from '@coral-xyz/anchor'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { getSolanaChainConfig } from '../../../chains/solana/solana.config'; +import { + MetaDaoSplitTokensRequest, + MetaDaoSplitTokensRequestType, + MetaDaoSplitTokensResponse, + MetaDaoSplitTokensResponseType, +} from '../../../schemas/metadao-schema'; +import { logger } from '../../../services/logger'; +import { MetaDao } from '../metadao'; + +export const splitTokensRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: MetaDaoSplitTokensRequestType; + Reply: MetaDaoSplitTokensResponseType; + }>( + '/split-tokens', + { + schema: { + description: + 'Split underlying tokens into equal amounts of pass and fail conditional tokens (conditional vault deposit)', + tags: ['/connector/metadao'], + body: MetaDaoSplitTokensRequest, + response: { + 200: MetaDaoSplitTokensResponse, + }, + }, + }, + async (request): Promise => { + const { dao: daoAddress, proposal: proposalAddress, asset, amount } = request.body; + const network = request.body.network || 'mainnet-beta'; + + try { + const metadao = await MetaDao.getInstance(network); + const solana = metadao.getSolana(); + + const walletAddress = request.body.walletAddress || getSolanaChainConfig().defaultWallet; + if (!walletAddress) { + throw fastify.httpErrors.badRequest('No wallet address provided and no default wallet configured'); + } + + const wallet = await solana.getWallet(walletAddress); + const userPubkey = new PublicKey(walletAddress); + + const daoAccount = await metadao.getDao(daoAddress); + const proposalAccount = await metadao.getProposal(proposalAddress); + const proposalState = metadao.getProposalState(proposalAccount); + if (proposalState !== 'pending') { + throw fastify.httpErrors.badRequest(`Proposal is not in active trading state (current: ${proposalState})`); + } + + const underlyingMint = asset === 'base' ? daoAccount.baseMint : daoAccount.quoteMint; + const decimals = await metadao.getTokenDecimals(underlyingMint.toString()); + const rawAmount: BN = metadao.toRawAmount(amount, decimals); + + const ixs = await metadao.buildSplitTokensIxs({ + dao: new PublicKey(daoAddress), + proposal: new PublicKey(proposalAddress), + user: userPubkey, + asset, + amount: rawAmount, + }); + + const connection = solana.connection; + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + const transaction = new Transaction({ + feePayer: userPubkey, + blockhash, + lastValidBlockHeight, + }).add(...ixs); + + transaction.sign(wallet); + const signature = await connection.sendRawTransaction(transaction.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + const confirmation = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ); + + const status = confirmation.value.err ? -1 : 1; + + const passMint = asset === 'base' ? proposalAccount.passBaseMint : proposalAccount.passQuoteMint; + const failMint = asset === 'base' ? proposalAccount.failBaseMint : proposalAccount.failQuoteMint; + + return { + signature, + status, + data: { + asset, + underlyingMint: underlyingMint.toString(), + passMint: passMint.toString(), + failMint: failMint.toString(), + amount, + }, + }; + } catch (error) { + if (error.statusCode) throw error; + logger.error(`Error splitting tokens for proposal ${proposalAddress}: ${error}`); + throw fastify.httpErrors.internalServerError(`Failed to split tokens: ${error}`); + } + }, + ); +}; diff --git a/src/connectors/metadao/idl/futarchy.json b/src/connectors/metadao/idl/futarchy.json new file mode 100644 index 0000000000..c79737fbae --- /dev/null +++ b/src/connectors/metadao/idl/futarchy.json @@ -0,0 +1,202 @@ +{ + "version": "0.6.0", + "name": "futarchy", + "instructions": [ + { + "name": "spotSwap", + "accounts": [ + { "name": "dao", "isMut": true, "isSigner": false }, + { "name": "trader", "isMut": true, "isSigner": true }, + { "name": "baseMint", "isMut": false, "isSigner": false }, + { "name": "quoteMint", "isMut": false, "isSigner": false }, + { "name": "ammBaseVault", "isMut": true, "isSigner": false }, + { "name": "ammQuoteVault", "isMut": true, "isSigner": false }, + { "name": "traderBaseAccount", "isMut": true, "isSigner": false }, + { "name": "traderQuoteAccount", "isMut": true, "isSigner": false }, + { "name": "protocolFeeBaseVault", "isMut": true, "isSigner": false }, + { "name": "protocolFeeQuoteVault", "isMut": true, "isSigner": false }, + { "name": "tokenProgram", "isMut": false, "isSigner": false } + ], + "args": [ + { "name": "swapType", "type": { "defined": "SwapType" } }, + { "name": "inputAmount", "type": "u64" }, + { "name": "minOutputAmount", "type": "u64" } + ] + }, + { + "name": "conditionalSwap", + "accounts": [ + { "name": "dao", "isMut": true, "isSigner": false }, + { "name": "proposal", "isMut": true, "isSigner": false }, + { "name": "trader", "isMut": true, "isSigner": true }, + { "name": "conditionalInputMint", "isMut": false, "isSigner": false }, + { "name": "conditionalOutputMint", "isMut": false, "isSigner": false }, + { "name": "conditionalInputVault", "isMut": true, "isSigner": false }, + { "name": "conditionalOutputVault", "isMut": true, "isSigner": false }, + { "name": "traderInputAccount", "isMut": true, "isSigner": false }, + { "name": "traderOutputAccount", "isMut": true, "isSigner": false }, + { "name": "tokenProgram", "isMut": false, "isSigner": false } + ], + "args": [ + { "name": "market", "type": { "defined": "Market" } }, + { "name": "swapType", "type": { "defined": "SwapType" } }, + { "name": "inputAmount", "type": "u64" }, + { "name": "minOutputAmount", "type": "u64" } + ] + }, + { + "name": "provideLiquidity", + "accounts": [ + { "name": "dao", "isMut": true, "isSigner": false }, + { "name": "liquidityProvider", "isMut": true, "isSigner": true }, + { "name": "ammPosition", "isMut": true, "isSigner": false }, + { "name": "baseMint", "isMut": false, "isSigner": false }, + { "name": "quoteMint", "isMut": false, "isSigner": false }, + { "name": "ammBaseVault", "isMut": true, "isSigner": false }, + { "name": "ammQuoteVault", "isMut": true, "isSigner": false }, + { "name": "lpBaseAccount", "isMut": true, "isSigner": false }, + { "name": "lpQuoteAccount", "isMut": true, "isSigner": false }, + { "name": "tokenProgram", "isMut": false, "isSigner": false }, + { "name": "systemProgram", "isMut": false, "isSigner": false } + ], + "args": [ + { "name": "quoteAmount", "type": "u64" }, + { "name": "maxBaseAmount", "type": "u64" }, + { "name": "minLiquidity", "type": "u64" } + ] + }, + { + "name": "withdrawLiquidity", + "accounts": [ + { "name": "dao", "isMut": true, "isSigner": false }, + { "name": "liquidityProvider", "isMut": true, "isSigner": true }, + { "name": "ammPosition", "isMut": true, "isSigner": false }, + { "name": "baseMint", "isMut": false, "isSigner": false }, + { "name": "quoteMint", "isMut": false, "isSigner": false }, + { "name": "ammBaseVault", "isMut": true, "isSigner": false }, + { "name": "ammQuoteVault", "isMut": true, "isSigner": false }, + { "name": "lpBaseAccount", "isMut": true, "isSigner": false }, + { "name": "lpQuoteAccount", "isMut": true, "isSigner": false }, + { "name": "tokenProgram", "isMut": false, "isSigner": false } + ], + "args": [ + { "name": "liquidityAmount", "type": "u64" }, + { "name": "minBaseAmount", "type": "u64" }, + { "name": "minQuoteAmount", "type": "u64" } + ] + } + ], + "accounts": [ + { + "name": "Dao", + "type": { + "kind": "struct", + "fields": [ + { "name": "treasuryPdaBump", "type": "u8" }, + { "name": "protocolFeeBps", "type": "u16" }, + { "name": "lpFeeBps", "type": "u16" }, + { "name": "baseMint", "type": "publicKey" }, + { "name": "quoteMint", "type": "publicKey" }, + { "name": "ammBaseVault", "type": "publicKey" }, + { "name": "ammQuoteVault", "type": "publicKey" }, + { "name": "protocolFeeBaseVault", "type": "publicKey" }, + { "name": "protocolFeeQuoteVault", "type": "publicKey" }, + { "name": "totalLiquidity", "type": "u64" }, + { "name": "amm", "type": { "defined": "Amm" } } + ] + } + }, + { + "name": "Proposal", + "type": { + "kind": "struct", + "fields": [ + { "name": "number", "type": "u64" }, + { "name": "state", "type": { "defined": "ProposalState" } }, + { "name": "launchedAt", "type": "i64" }, + { "name": "tradingEndSlot", "type": "u64" } + ] + } + }, + { + "name": "AmmPosition", + "type": { + "kind": "struct", + "fields": [ + { "name": "authority", "type": "publicKey" }, + { "name": "liquidity", "type": "u64" } + ] + } + } + ], + "types": [ + { + "name": "SwapType", + "type": { + "kind": "enum", + "variants": [{ "name": "Buy" }, { "name": "Sell" }] + } + }, + { + "name": "Market", + "type": { + "kind": "enum", + "variants": [{ "name": "Pass" }, { "name": "Fail" }, { "name": "Spot" }] + } + }, + { + "name": "ProposalState", + "type": { + "kind": "enum", + "variants": [{ "name": "Pending" }, { "name": "Passed" }, { "name": "Failed" }] + } + }, + { + "name": "PoolState", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Spot", + "fields": [{ "name": "pool", "type": { "defined": "Pool" } }] + }, + { + "name": "Futarchy", + "fields": [ + { "name": "spot", "type": { "defined": "Pool" } }, + { "name": "pass", "type": { "defined": "Pool" } }, + { "name": "fail", "type": { "defined": "Pool" } } + ] + } + ] + } + }, + { + "name": "Pool", + "type": { + "kind": "struct", + "fields": [ + { "name": "baseReserves", "type": "u64" }, + { "name": "quoteReserves", "type": "u64" } + ] + } + }, + { + "name": "Amm", + "type": { + "kind": "struct", + "fields": [{ "name": "state", "type": { "defined": "PoolState" } }] + } + } + ], + "errors": [ + { "code": 6000, "name": "ProposalNotActive", "msg": "Proposal is not in an active trading state" }, + { "code": 6001, "name": "SwapSlippageExceeded", "msg": "Swap output is less than minimum expected" }, + { "code": 6002, "name": "PoolNotInSpotState", "msg": "Pool must be in Spot state for this operation" }, + { "code": 6003, "name": "InsufficientBalance", "msg": "Insufficient token balance" }, + { "code": 6004, "name": "InvalidMarket", "msg": "Invalid market selection" } + ], + "metadata": { + "address": "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" + } +} diff --git a/src/connectors/metadao/metadao.config.ts b/src/connectors/metadao/metadao.config.ts new file mode 100644 index 0000000000..05657d1c6e --- /dev/null +++ b/src/connectors/metadao/metadao.config.ts @@ -0,0 +1,45 @@ +import { getAvailableSolanaNetworks } from '../../chains/solana/solana.utils'; +import { AvailableNetworks } from '../../services/base'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; + +// Helper to safely get config with fallback (handles missing namespace in tests) +function getConfigSafe(key: string, defaultValue: T): T { + try { + const value = ConfigManagerV2.getInstance().get(key); + return value !== undefined ? value : defaultValue; + } catch { + return defaultValue; + } +} + +export namespace MetaDaoConfig { + // Supported networks for MetaDAO (Solana only) + export const chain = 'solana'; + export const networks = getAvailableSolanaNetworks(); + export type Network = string; + + // Supported trading types + export const tradingTypes = ['futarchy'] as const; + + export interface RootConfig { + programId: string; + conditionalVaultProgramId: string; + defaultSlippagePct: number; + availableNetworks: Array; + } + + export const config: RootConfig = { + programId: getConfigSafe('metadao.programId', 'FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq'), + conditionalVaultProgramId: getConfigSafe( + 'metadao.conditionalVaultProgramId', + 'VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg', + ), + defaultSlippagePct: getConfigSafe('metadao.defaultSlippagePct', 0.5), + availableNetworks: [ + { + chain, + networks: networks, + }, + ], + }; +} diff --git a/src/connectors/metadao/metadao.routes.ts b/src/connectors/metadao/metadao.routes.ts new file mode 100644 index 0000000000..525f32d5a6 --- /dev/null +++ b/src/connectors/metadao/metadao.routes.ts @@ -0,0 +1,24 @@ +import sensible from '@fastify/sensible'; +import type { FastifyPluginAsync } from 'fastify'; + +import { metadaoFutarchyRoutes } from './futarchy-routes'; + +// Futarchy routes wrapper with Swagger tag +const metadaoFutarchyRoutesWrapper: FastifyPluginAsync = async (fastify) => { + await fastify.register(sensible); + + await fastify.register(async (instance) => { + instance.addHook('onRoute', (routeOptions) => { + if (routeOptions.schema && routeOptions.schema.tags) { + routeOptions.schema.tags = ['/connector/metadao']; + } + }); + + await instance.register(metadaoFutarchyRoutes); + }); +}; + +// Main export that combines all routes +export const metadaoRoutes = { + futarchy: metadaoFutarchyRoutesWrapper, +}; diff --git a/src/connectors/metadao/metadao.ts b/src/connectors/metadao/metadao.ts new file mode 100644 index 0000000000..d2ac75a19f --- /dev/null +++ b/src/connectors/metadao/metadao.ts @@ -0,0 +1,1516 @@ +import { BN } from '@coral-xyz/anchor'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { PublicKey, TransactionInstruction, SystemProgram, Connection } from '@solana/web3.js'; +import Decimal from 'decimal.js'; + +import { Solana } from '../../chains/solana/solana'; +import { logger } from '../../services/logger'; +import { fetchTokenInfo } from '../../tokens/token-lookup-helper'; + +import { MetaDaoConfig } from './metadao.config'; + +// Constants for fee calculations +const BPS_SCALE = new BN(10000); +const DEFAULT_PROTOCOL_FEE_BPS = 25; +const DEFAULT_LP_FEE_BPS = 25; +const ANCHOR_DISCRIMINATOR_SIZE = 8; +const U64_SIZE = 8; +const U128_SIZE = 16; +const LP_LIQUIDITY_SCALE_DECIMALS = 9; +// TwapOracle serialized size in the current MetaDAO futarchy program. +const TWAP_ORACLE_SIZE = 100; + +// Pool state types matching on-chain structure +export interface TwapOracleData { + aggregator: BN; + lastUpdatedTimestamp: BN; + createdAtTimestamp: BN; + lastPrice: BN; + lastObservation: BN; + maxObservationChangePerUpdate: BN; + initialObservation: BN; + startDelaySeconds: number; +} + +interface Pool { + baseReserves: BN; + quoteReserves: BN; + baseProtocolFees: BN; + quoteProtocolFees: BN; + twap?: TwapOracleData; +} + +export interface TwapMetrics { + windowOpen: boolean; + twapStartsAt: number; + twapEndsAt: number; + elapsedSeconds: number; + passTwapObs: number; + failTwapObs: number; + passLastObs: number; + failLastObs: number; + passLastUpdated: number; + failLastUpdated: number; + marginPct: number; + thresholdPct: number; + marginVsThresholdPct: number; + attackerWinning: boolean; + lastObsMarginPct: number; + lastObsMarginVsThresholdPct: number; +} + +interface PoolStateFutarchy { + spot: Pool; + pass: Pool; + fail: Pool; +} + +// PoolState is either a single spot pool or a futarchy state with 3 pools +type PoolState = { spot: Pool } | { futarchy: PoolStateFutarchy }; + +interface DaoAccount { + treasuryPdaBump: number; + protocolFeeBps: number; + lpFeeBps: number; + baseMint: PublicKey; + quoteMint: PublicKey; + ammBaseVault: PublicKey; + ammQuoteVault: PublicKey; + protocolFeeBaseVault: PublicKey; + protocolFeeQuoteVault: PublicKey; + totalLiquidity: BN; + passThresholdBps: number; + amm: { + state: PoolState; + }; +} + +type ProposalState = + | { pending: Record } + | { passed: Record } + | { failed: Record }; + +interface ProposalAccount { + dao: PublicKey; + number: BN; + state: ProposalState; + launchedAt: BN; + tradingEndSlot: BN; + baseVault: PublicKey; + quoteVault: PublicKey; + question: PublicKey; + passBaseMint: PublicKey; + passQuoteMint: PublicKey; + failBaseMint: PublicKey; + failQuoteMint: PublicKey; + isTeamSponsored: boolean; +} + +interface AmmPositionAccount { + authority: PublicKey; + liquidity: BN; +} + +export interface ProposalPdas { + question: PublicKey; + baseVault: PublicKey; + quoteVault: PublicKey; + passBaseMint: PublicKey; + passQuoteMint: PublicKey; + failBaseMint: PublicKey; + failQuoteMint: PublicKey; +} + +export interface SwapQuoteResult { + output: BN; + priceImpact: number; + protocolFee: BN; + lpFee: BN; +} + +export class MetaDao { + private static _instances: { [network: string]: MetaDao } = {}; + private solana: Solana; + private connection: Connection; + private programId: PublicKey; + private network: string; + public config: MetaDaoConfig.RootConfig; + + private constructor() { + this.config = MetaDaoConfig.config; + } + + public static async getInstance(network: string): Promise { + if (!MetaDao._instances[network]) { + const instance = new MetaDao(); + await instance.init(network); + MetaDao._instances[network] = instance; + } + return MetaDao._instances[network]; + } + + private async init(network: string): Promise { + this.network = network; + this.solana = await Solana.getInstance(network); + this.connection = this.solana.connection; + this.programId = new PublicKey(this.config.programId); + + logger.info(`MetaDAO connector initialized for network: ${network}`); + } + + // ============================================================================ + // DAO Discovery Methods + // ============================================================================ + + /** + * List all DAO addresses (lightweight - no parsing, no extra RPC calls) + */ + async listDaos(): Promise { + // DAO discriminator: sha256('account:Dao')[0..8] = a3092f1f3455c531 = 'owkvHzRVxTE=' in base64 + const DAO_DISCRIMINATOR_BASE64 = 'owkvHzRVxTE='; + + const accounts = await this.connection.getProgramAccounts(this.programId, { + filters: [ + { + memcmp: { + offset: 0, + bytes: DAO_DISCRIMINATOR_BASE64, + encoding: 'base64', + }, + }, + ], + dataSlice: { offset: 0, length: 0 }, // Don't fetch data, just addresses + }); + + logger.info(`Found ${accounts.length} DAO accounts for MetaDAO program`); + return accounts.map(({ pubkey }) => pubkey.toString()); + } + + /** + * Get detailed info for a single DAO + */ + async getDaoInfo(daoAddress: string): Promise<{ + address: string; + baseMint: string; + quoteMint: string; + baseSymbol: string; + quoteSymbol: string; + poolState: 'spot' | 'futarchy'; + totalLiquidity: number; + totalLiquidityRaw: string; + protocolFeeBps: number; + lpFeeBps: number; + }> { + const daoAccount = await this.getDao(daoAddress); + const poolState = this.getPoolStateType(daoAccount); + + const quoteDecimals = await this.getTokenDecimals(daoAccount.quoteMint.toString()); + const baseSymbol = await this.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await this.getTokenSymbol(daoAccount.quoteMint.toString()); + + return { + address: daoAddress, + baseMint: daoAccount.baseMint.toString(), + quoteMint: daoAccount.quoteMint.toString(), + baseSymbol, + quoteSymbol, + poolState, + totalLiquidity: this.fromRawLiquidity(daoAccount.totalLiquidity, quoteDecimals), + totalLiquidityRaw: daoAccount.totalLiquidity.toString(), + protocolFeeBps: daoAccount.protocolFeeBps, + lpFeeBps: daoAccount.lpFeeBps, + }; + } + + /** + * List proposals for a specific DAO. + * Note: This uses getProgramAccounts which may be slow on mainnet. + */ + async listProposals( + daoAddress: string, + statusFilter: 'pending' | 'passed' | 'failed' | 'all' = 'pending', + ): Promise< + Array<{ + address: string; + number?: number; + status: 'pending' | 'passed' | 'failed'; + launchedAt: number; + tradingEndsAt: number; + passBaseMint: string; + passQuoteMint: string; + failBaseMint: string; + failQuoteMint: string; + passPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + failPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + impliedProbability: number; + }> + > { + const dao = new PublicKey(daoAddress); + + // Proposal has a variable-size state enum, so DAO is not at a stable memcmp offset for drafts. + // Fetch proposal accounts by discriminator and filter after decoding. + const PROPOSAL_DISCRIMINATOR_BASE64 = 'Gl69u3SINSE='; + const accounts = await this.connection.getProgramAccounts(this.programId, { + filters: [ + { + memcmp: { + offset: 0, + bytes: PROPOSAL_DISCRIMINATOR_BASE64, + encoding: 'base64', + }, + }, + ], + }); + + const daoAccount = await this.getDao(daoAddress); + const baseDecimals = await this.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await this.getTokenDecimals(daoAccount.quoteMint.toString()); + + const proposals: Array<{ + address: string; + number?: number; + status: 'pending' | 'passed' | 'failed'; + launchedAt: number; + tradingEndsAt: number; + passBaseMint: string; + passQuoteMint: string; + failBaseMint: string; + failQuoteMint: string; + passPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + failPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + impliedProbability: number; + }> = []; + + for (const { pubkey, account } of accounts) { + try { + const proposalAccount = this.parseProposalAccount(account.data); + if (!proposalAccount.dao.equals(dao)) { + continue; + } + + const status = this.getProposalState(proposalAccount); + + // Apply status filter + if (statusFilter !== 'all' && status !== statusFilter) { + continue; + } + + // Get pool reserves from DAO if in futarchy state + let passPool = { + baseReserves: 0, + quoteReserves: 0, + baseReservesRaw: '0', + quoteReservesRaw: '0', + price: 0, + }; + let failPool = { + baseReserves: 0, + quoteReserves: 0, + baseReservesRaw: '0', + quoteReservesRaw: '0', + price: 0, + }; + + const passPoolData = this.getPassPool(daoAccount); + const failPoolData = this.getFailPool(daoAccount); + + if (passPoolData) { + const baseReserves = this.fromRawAmount(passPoolData.baseReserves, baseDecimals); + const quoteReserves = this.fromRawAmount(passPoolData.quoteReserves, quoteDecimals); + passPool = { + baseReserves, + quoteReserves, + baseReservesRaw: passPoolData.baseReserves.toString(), + quoteReservesRaw: passPoolData.quoteReserves.toString(), + price: baseReserves > 0 ? quoteReserves / baseReserves : 0, + }; + } + + if (failPoolData) { + const baseReserves = this.fromRawAmount(failPoolData.baseReserves, baseDecimals); + const quoteReserves = this.fromRawAmount(failPoolData.quoteReserves, quoteDecimals); + failPool = { + baseReserves, + quoteReserves, + baseReservesRaw: failPoolData.baseReserves.toString(), + quoteReservesRaw: failPoolData.quoteReserves.toString(), + price: baseReserves > 0 ? quoteReserves / baseReserves : 0, + }; + } + + // Calculate implied probability from pool prices + // P(pass) = passPrice / (passPrice + failPrice) + const impliedProbability = + passPool.price + failPool.price > 0 ? (passPool.price / (passPool.price + failPool.price)) * 100 : 50; + + proposals.push({ + address: pubkey.toString(), + number: proposalAccount.number.toNumber(), + status, + launchedAt: proposalAccount.launchedAt.toNumber(), + tradingEndsAt: proposalAccount.tradingEndSlot.toNumber(), + passBaseMint: proposalAccount.passBaseMint.toString(), + passQuoteMint: proposalAccount.passQuoteMint.toString(), + failBaseMint: proposalAccount.failBaseMint.toString(), + failQuoteMint: proposalAccount.failQuoteMint.toString(), + passPool, + failPool, + impliedProbability, + }); + } catch (error) { + logger.warn(`Failed to parse proposal ${pubkey.toString()}: ${error}`); + } + } + + return proposals; + } + + // ============================================================================ + // DAO Methods + // ============================================================================ + + async getDao(daoAddress: string): Promise { + const dao = new PublicKey(daoAddress); + const accountInfo = await this.connection.getAccountInfo(dao); + if (!accountInfo) { + throw new Error(`DAO account not found: ${daoAddress}`); + } + // Parse DAO account data (simplified - real implementation would use proper deserialization) + return this.parseDaoAccount(accountInfo.data, dao); + } + + private parseDaoAccount(data: Buffer, _pubkey: PublicKey): DaoAccount { + let offset = ANCHOR_DISCRIMINATOR_SIZE; + + const stateDiscriminant = data.readUInt8(offset); + offset += 1; + + let ammState: PoolState; + if (stateDiscriminant === 0) { + const parsed = this.parsePool(data, offset); + offset = parsed.nextOffset; + ammState = { spot: parsed.pool }; + } else if (stateDiscriminant === 1) { + const spot = this.parsePool(data, offset); + const pass = this.parsePool(data, spot.nextOffset); + const fail = this.parsePool(data, pass.nextOffset); + offset = fail.nextOffset; + + ammState = { + futarchy: { + spot: spot.pool, + pass: pass.pool, + fail: fail.pool, + }, + }; + } else { + throw new Error(`Unknown DAO AMM pool state discriminant: ${stateDiscriminant}`); + } + + const totalLiquidity = this.readU128(data, offset); + offset += U128_SIZE; + + // Skip amm_base_mint (32 bytes) + offset += 32; + + // Skip amm_quote_mint (32 bytes) + offset += 32; + + const ammBaseVault = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const ammQuoteVault = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + offset += U64_SIZE; // nonce + offset += 32; // dao_creator + const treasuryPdaBump = data.readUInt8(offset); + offset += 1; + offset += 32; // squads_multisig + offset += 32; // squads_multisig_vault + + const daoBaseMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const daoQuoteMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + offset += 4; // proposal_count (u32) + const passThresholdBps = data.readUInt16LE(offset); + + return { + treasuryPdaBump, + protocolFeeBps: DEFAULT_PROTOCOL_FEE_BPS, + lpFeeBps: DEFAULT_LP_FEE_BPS, + baseMint: daoBaseMint, + quoteMint: daoQuoteMint, + ammBaseVault, + ammQuoteVault, + protocolFeeBaseVault: ammBaseVault, + protocolFeeQuoteVault: ammQuoteVault, + totalLiquidity, + passThresholdBps, + amm: { state: ammState }, + }; + } + + private parsePool(data: Buffer, offset: number): { pool: Pool; nextOffset: number } { + // TwapOracle (100 bytes): aggregator u128, lastUpdatedTimestamp i64, + // createdAtTimestamp i64, lastPrice u128, lastObservation u128, + // maxObservationChangePerUpdate u128, initialObservation u128, startDelaySeconds u32 + const twap: TwapOracleData = { + aggregator: this.readU128(data, offset), + lastUpdatedTimestamp: new BN(data.slice(offset + 16, offset + 24), 'le'), + createdAtTimestamp: new BN(data.slice(offset + 24, offset + 32), 'le'), + lastPrice: this.readU128(data, offset + 32), + lastObservation: this.readU128(data, offset + 48), + maxObservationChangePerUpdate: this.readU128(data, offset + 64), + initialObservation: this.readU128(data, offset + 80), + startDelaySeconds: data.readUInt32LE(offset + 96), + }; + offset += TWAP_ORACLE_SIZE; + + const quoteReserves = this.readU64(data, offset); + offset += U64_SIZE; + + const baseReserves = this.readU64(data, offset); + offset += U64_SIZE; + + const quoteProtocolFees = this.readU64(data, offset); + offset += U64_SIZE; + + const baseProtocolFees = this.readU64(data, offset); + offset += U64_SIZE; + + return { + pool: { + baseReserves, + quoteReserves, + baseProtocolFees, + quoteProtocolFees, + twap, + }, + nextOffset: offset, + }; + } + + private readU64(data: Buffer, offset: number): BN { + return new BN(data.slice(offset, offset + U64_SIZE), 'le'); + } + + private readU128(data: Buffer, offset: number): BN { + return new BN(data.slice(offset, offset + U128_SIZE), 'le'); + } + + // ============================================================================ + // Proposal Methods + // ============================================================================ + + async getProposal(proposalAddress: string): Promise { + const proposal = new PublicKey(proposalAddress); + const accountInfo = await this.connection.getAccountInfo(proposal); + if (!accountInfo) { + throw new Error(`Proposal account not found: ${proposalAddress}`); + } + return this.parseProposalAccount(accountInfo.data); + } + + private parseProposalAccount(data: Buffer): ProposalAccount { + // Skip discriminator (8 bytes) + let offset = 8; + + const number = new BN(data.readUInt32LE(offset)); + offset += 4; + + offset += 32; // proposer + + const launchedAt = new BN(data.slice(offset, offset + 8), 'le'); + offset += 8; + + const stateDiscriminant = data.readUInt8(offset); + offset += 1; + + // ProposalState enum variants have different sizes: + // - Draft (0): amount_staked (u64) = 8 bytes + // - Pending (1), Passed (2), Failed (3), Removed (4): no payload + if (stateDiscriminant === 0) { + offset += U64_SIZE; // Draft.amount_staked + } + + let state: ProposalState; + if (stateDiscriminant === 0 || stateDiscriminant === 1) { + state = { pending: {} }; + } else if (stateDiscriminant === 2) { + state = { passed: {} }; + } else { + state = { failed: {} }; + } + + const baseVault = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const quoteVault = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const dao = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + offset += 1; // pda_bump + + const question = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const durationInSeconds = new BN(data.readUInt32LE(offset)); + offset += 4; + + offset += 32; // squads_proposal + + const passBaseMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const passQuoteMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const failBaseMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const failQuoteMint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const isTeamSponsored = data.readUInt8(offset) === 1; + + const tradingEndSlot = launchedAt.add(durationInSeconds); + + return { + dao, + number, + state, + launchedAt, + tradingEndSlot, + baseVault, + quoteVault, + question, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + isTeamSponsored, + }; + } + + getProposalState(proposal: ProposalAccount): 'pending' | 'passed' | 'failed' { + if ('pending' in proposal.state) return 'pending'; + if ('passed' in proposal.state) return 'passed'; + if ('failed' in proposal.state) return 'failed'; + throw new Error('Unknown proposal state'); + } + + /** + * Get full proposal information including DAO data and market reserves + */ + async getProposalInfo( + daoAddress: string | undefined, + proposalAddress: string, + ): Promise<{ + proposal: string; + dao: string; + number: number; + status: 'pending' | 'passed' | 'failed'; + launchedAt: number; + tradingEndsAt: number; + baseMint: string; + quoteMint: string; + baseSymbol: string; + quoteSymbol: string; + pdas: ProposalPdas; + passPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + failPool: { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + }; + impliedProbability: number; + passThresholdBps: number; + isTeamSponsored: boolean; + twap?: TwapMetrics; + passMarketCap?: number; + failMarketCap?: number; + }> { + const proposalAccount = await this.getProposal(proposalAddress); + const resolvedDaoAddress = daoAddress || proposalAccount.dao.toString(); + if (daoAddress && !proposalAccount.dao.equals(new PublicKey(daoAddress))) { + throw new Error( + `Proposal ${proposalAddress} belongs to DAO ${proposalAccount.dao.toString()}, not ${daoAddress}`, + ); + } + + const daoAccount = await this.getDao(resolvedDaoAddress); + + const baseDecimals = await this.getTokenDecimals(daoAccount.baseMint.toString()); + const quoteDecimals = await this.getTokenDecimals(daoAccount.quoteMint.toString()); + const baseSymbol = await this.getTokenSymbol(daoAccount.baseMint.toString()); + const quoteSymbol = await this.getTokenSymbol(daoAccount.quoteMint.toString()); + + const pdas = { + question: proposalAccount.question, + baseVault: proposalAccount.baseVault, + quoteVault: proposalAccount.quoteVault, + passBaseMint: proposalAccount.passBaseMint, + passQuoteMint: proposalAccount.passQuoteMint, + failBaseMint: proposalAccount.failBaseMint, + failQuoteMint: proposalAccount.failQuoteMint, + }; + + // Get pool reserves + interface PoolInfoOut { + baseReserves: number; + quoteReserves: number; + baseReservesRaw: string; + quoteReservesRaw: string; + price: number; + } + let passPool: PoolInfoOut = { + baseReserves: 0, + quoteReserves: 0, + baseReservesRaw: '0', + quoteReservesRaw: '0', + price: 0, + }; + let failPool: PoolInfoOut = { + baseReserves: 0, + quoteReserves: 0, + baseReservesRaw: '0', + quoteReservesRaw: '0', + price: 0, + }; + + const passPoolData = this.getPassPool(daoAccount); + const failPoolData = this.getFailPool(daoAccount); + + if (passPoolData) { + const baseReserves = this.fromRawAmount(passPoolData.baseReserves, baseDecimals); + const quoteReserves = this.fromRawAmount(passPoolData.quoteReserves, quoteDecimals); + passPool = { + baseReserves, + quoteReserves, + baseReservesRaw: passPoolData.baseReserves.toString(), + quoteReservesRaw: passPoolData.quoteReserves.toString(), + price: baseReserves > 0 ? quoteReserves / baseReserves : 0, + }; + } + + if (failPoolData) { + const baseReserves = this.fromRawAmount(failPoolData.baseReserves, baseDecimals); + const quoteReserves = this.fromRawAmount(failPoolData.quoteReserves, quoteDecimals); + failPool = { + baseReserves, + quoteReserves, + baseReservesRaw: failPoolData.baseReserves.toString(), + quoteReservesRaw: failPoolData.quoteReserves.toString(), + price: baseReserves > 0 ? quoteReserves / baseReserves : 0, + }; + } + + // Calculate implied probability: P(pass) = passPrice / (passPrice + failPrice) + const impliedProbability = + passPool.price + failPool.price > 0 ? (passPool.price / (passPool.price + failPool.price)) * 100 : 50; + + const twap = this.computeTwapMetrics( + passPoolData?.twap, + failPoolData?.twap, + daoAccount.passThresholdBps, + proposalAccount.tradingEndSlot.toNumber(), + ); + + return { + proposal: proposalAddress, + dao: resolvedDaoAddress, + number: proposalAccount.number.toNumber(), + status: this.getProposalState(proposalAccount), + launchedAt: proposalAccount.launchedAt.toNumber(), + tradingEndsAt: proposalAccount.tradingEndSlot.toNumber(), + baseMint: daoAccount.baseMint.toString(), + quoteMint: daoAccount.quoteMint.toString(), + baseSymbol, + quoteSymbol, + pdas, + passPool, + failPool, + impliedProbability, + passThresholdBps: daoAccount.passThresholdBps, + isTeamSponsored: proposalAccount.isTeamSponsored, + twap, + passMarketCap: passPool.baseReserves * passPool.price, + failMarketCap: failPool.baseReserves * failPool.price, + }; + } + + /** + * Derive the decision-relevant TWAP signal from the two pool oracles. + * The proposal passes iff pass_twap >= fail_twap * (1 + passThresholdBps/10000), + * so the realized margin vs threshold tells a defender whether the attacker is + * currently winning — the trigger for a reactive (rather than always-on) defense. + * + * Observation units cancel in the pass/fail ratio, so the margin is robust to + * the oracle's internal fixed-point scale. + */ + private computeTwapMetrics( + passTwap: TwapOracleData | undefined, + failTwap: TwapOracleData | undefined, + passThresholdBps: number, + twapEndsAt: number, + ): TwapMetrics | undefined { + if (!passTwap || !failTwap) return undefined; + + // The oracle only accumulates after its start delay, so the realized TWAP + // is aggregator / (lastUpdated - twapStartsAt), NOT / (lastUpdated - created). + // Dividing by full pool age would understate the average by the delay. + const passStart = passTwap.createdAtTimestamp.toNumber() + passTwap.startDelaySeconds; + const failStart = failTwap.createdAtTimestamp.toNumber() + failTwap.startDelaySeconds; + const twapStartsAt = passStart; + const passDt = passTwap.lastUpdatedTimestamp.toNumber() - passStart; + const failDt = failTwap.lastUpdatedTimestamp.toNumber() - failStart; + + const thresholdPct = passThresholdBps / 100; + + // Instantaneous margin from the current resting observations (the sampled + // price the attacker is pushing) — meaningful even before the window opens. + const passLastObs = Number(passTwap.lastObservation.toString()); + const failLastObs = Number(failTwap.lastObservation.toString()); + const lastObsMarginPct = failLastObs > 0 ? (passLastObs / failLastObs - 1) * 100 : 0; + const lastObsMarginVsThresholdPct = lastObsMarginPct - thresholdPct; + + // No accumulation yet → window not meaningfully open + if (passDt <= 0 || failDt <= 0 || passTwap.aggregator.isZero() || failTwap.aggregator.isZero()) { + return { + windowOpen: false, + twapStartsAt, + twapEndsAt, + elapsedSeconds: Math.max(0, passDt), + passTwapObs: 0, + failTwapObs: 0, + passLastObs, + failLastObs, + passLastUpdated: passTwap.lastUpdatedTimestamp.toNumber(), + failLastUpdated: failTwap.lastUpdatedTimestamp.toNumber(), + marginPct: 0, + thresholdPct, + marginVsThresholdPct: -thresholdPct, + attackerWinning: false, + lastObsMarginPct, + lastObsMarginVsThresholdPct, + }; + } + + // Realized TWAP so far = aggregator / accumulation_seconds, per oracle. + // Observation scale is ~1e12, so aggregator ~ 1e12 * elapsed can reach ~1e17; + // dividing by dt first keeps the result near 1e12 (under 2^53), so + // toNumber() is safe. + const passDtBN = new BN(passDt); + const failDtBN = new BN(failDt); + const passTwapObs = passTwap.aggregator.div(passDtBN).toNumber(); + const failTwapObs = failTwap.aggregator.div(failDtBN).toNumber(); + + // Compute the pass/fail ratio in BN with 1e9 precision to avoid both the + // overflow above and float error from dividing two ~1e12 integers. + const RATIO_SCALE = new BN(1_000_000_000); + const ratioNum = passTwap.aggregator.mul(failDtBN).mul(RATIO_SCALE); + const ratioDen = failTwap.aggregator.mul(passDtBN); + const ratio = ratioDen.isZero() ? 0 : ratioNum.div(ratioDen).toNumber() / 1_000_000_000; + const marginPct = (ratio - 1) * 100; + const marginVsThresholdPct = marginPct - thresholdPct; + + return { + windowOpen: true, + twapStartsAt, + twapEndsAt, + elapsedSeconds: passDt, + passTwapObs, + failTwapObs, + passLastObs, + failLastObs, + passLastUpdated: passTwap.lastUpdatedTimestamp.toNumber(), + failLastUpdated: failTwap.lastUpdatedTimestamp.toNumber(), + marginPct, + thresholdPct, + marginVsThresholdPct, + attackerWinning: marginVsThresholdPct >= 0, + lastObsMarginPct, + lastObsMarginVsThresholdPct, + }; + } + + /** + * Get all proposal-related PDAs for conditional tokens. + * Read from the on-chain proposal account, which stores the authoritative + * question, vault, and conditional mint addresses. + */ + async getProposalPdas(_daoAddress: string, proposalAddress: string): Promise { + const proposalAccount = await this.getProposal(proposalAddress); + + return { + question: proposalAccount.question, + baseVault: proposalAccount.baseVault, + quoteVault: proposalAccount.quoteVault, + passBaseMint: proposalAccount.passBaseMint, + passQuoteMint: proposalAccount.passQuoteMint, + failBaseMint: proposalAccount.failBaseMint, + failQuoteMint: proposalAccount.failQuoteMint, + }; + } + + /** + * Event authority PDA (Anchor `emit_cpi!`) for a program. + */ + private getEventAuthority(programId: PublicKey): PublicKey { + const [eventAuthority] = PublicKey.findProgramAddressSync([Buffer.from('__event_authority')], programId); + return eventAuthority; + } + + // ============================================================================ + // LP Position Methods + // ============================================================================ + + getAmmPositionAddress(daoAddress: string, authority: string): PublicKey { + const dao = new PublicKey(daoAddress); + const auth = new PublicKey(authority); + const futarchyProgram = new PublicKey(this.config.programId); + + const [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from('amm_position'), dao.toBuffer(), auth.toBuffer()], + futarchyProgram, + ); + + return ammPosition; + } + + async getAmmPosition(daoAddress: string, authority: string): Promise { + try { + const positionAddress = this.getAmmPositionAddress(daoAddress, authority); + const accountInfo = await this.connection.getAccountInfo(positionAddress); + if (!accountInfo) { + return null; + } + return this.parseAmmPositionAccount(accountInfo.data); + } catch { + return null; + } + } + + private parseAmmPositionAccount(data: Buffer): AmmPositionAccount { + // Skip discriminator (8 bytes) + let offset = 8; + + const authority = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + + const liquidity = new BN(data.slice(offset, offset + 8), 'le'); + + return { authority, liquidity }; + } + + // ============================================================================ + // Quote Methods + // ============================================================================ + + /** + * Calculate swap output using MetaDAO's fee structure: + * Protocol fee first, then LP fee, then constant product formula + */ + calculateSwapOutput( + inputAmount: BN, + inputReserves: BN, + outputReserves: BN, + protocolFeeBps: number = DEFAULT_PROTOCOL_FEE_BPS, + lpFeeBps: number = DEFAULT_LP_FEE_BPS, + ): SwapQuoteResult { + // MetaDAO applies protocol fee first, then LP fee, before the AMM curve + const protocolFeeMultiplier = BPS_SCALE.sub(new BN(protocolFeeBps)); + const lpFeeMultiplier = BPS_SCALE.sub(new BN(lpFeeBps)); + + // Calculate fees + const protocolFee = inputAmount.mul(new BN(protocolFeeBps)).div(BPS_SCALE); + const inputAfterProtocolFee = inputAmount.mul(protocolFeeMultiplier).div(BPS_SCALE); + + const lpFee = inputAfterProtocolFee.mul(new BN(lpFeeBps)).div(BPS_SCALE); + const inputAfterLpFee = inputAfterProtocolFee.mul(lpFeeMultiplier).div(BPS_SCALE); + + // Constant product formula: output = inputAfterFees * outputReserves / (inputReserves + inputAfterFees) + const numerator = inputAfterLpFee.mul(outputReserves); + const denominator = inputReserves.add(inputAfterLpFee); + const output = numerator.div(denominator); + + // Calculate price impact using Decimal for precision + const spotPrice = new Decimal(outputReserves.toString()).div(inputReserves.toString()); + const execPrice = output.isZero() ? new Decimal(0) : new Decimal(output.toString()).div(inputAmount.toString()); + const priceImpact = spotPrice.isZero() ? 0 : spotPrice.minus(execPrice).abs().div(spotPrice).mul(100).toNumber(); + + return { output, priceImpact, protocolFee, lpFee }; + } + + calculateSwapInputForOutput( + outputAmount: BN, + inputReserves: BN, + outputReserves: BN, + protocolFeeBps: number = DEFAULT_PROTOCOL_FEE_BPS, + lpFeeBps: number = DEFAULT_LP_FEE_BPS, + ): BN { + if (outputAmount.gte(outputReserves)) { + throw new Error('Requested output amount exceeds available reserves'); + } + + const feeMultiplier = BPS_SCALE.sub(new BN(protocolFeeBps)).mul(BPS_SCALE.sub(new BN(lpFeeBps))); + const numerator = inputReserves.mul(outputAmount).mul(BPS_SCALE).mul(BPS_SCALE); + const denominator = outputReserves.sub(outputAmount).mul(feeMultiplier); + + return numerator.add(denominator).sub(new BN(1)).div(denominator); + } + + /** + * Calculate liquidity tokens to mint for a deposit + */ + calculateLiquidityMint( + quoteAmount: BN, + baseReserves: BN, + quoteReserves: BN, + totalLiquidity: BN, + ): { baseAmount: BN; liquidityMinted: BN } { + if (totalLiquidity.isZero()) { + // Initial deposit: liquidity = quoteAmount * 1_000_000_000 + const liquidityMinted = quoteAmount.mul(new BN(1_000_000_000)); + return { baseAmount: quoteAmount, liquidityMinted }; + } + + // Calculate base amount: ceil(quoteAmount * baseReserves / quoteReserves) + const baseAmount = quoteAmount + .mul(baseReserves) + .add(quoteReserves.sub(new BN(1))) + .div(quoteReserves); + + // Calculate liquidity tokens: quoteAmount * totalLiquidity / quoteReserves + const liquidityMinted = quoteAmount.mul(totalLiquidity).div(quoteReserves); + + return { baseAmount, liquidityMinted }; + } + + /** + * Calculate tokens to receive for liquidity withdrawal + */ + calculateLiquidityWithdraw( + liquidityAmount: BN, + baseReserves: BN, + quoteReserves: BN, + totalLiquidity: BN, + ): { baseAmount: BN; quoteAmount: BN } { + const baseAmount = liquidityAmount.mul(baseReserves).div(totalLiquidity); + const quoteAmount = liquidityAmount.mul(quoteReserves).div(totalLiquidity); + + return { baseAmount, quoteAmount }; + } + + // ============================================================================ + // Instruction Builders + // ============================================================================ + + async buildSpotSwapIx(params: { + dao: PublicKey; + trader: PublicKey; + swapType: 'buy' | 'sell'; + inputAmount: BN; + minOutputAmount: BN; + }): Promise { + const daoAccount = await this.getDao(params.dao.toString()); + + // Get token programs for base and quote mints + const baseTokenProgramId = await this.getTokenProgramId(daoAccount.baseMint); + const quoteTokenProgramId = await this.getTokenProgramId(daoAccount.quoteMint); + + // Get trader's token accounts with correct token program + const traderBaseAccount = getAssociatedTokenAddressSync( + daoAccount.baseMint, + params.trader, + false, + baseTokenProgramId, + ); + const traderQuoteAccount = getAssociatedTokenAddressSync( + daoAccount.quoteMint, + params.trader, + false, + quoteTokenProgramId, + ); + + // Build instruction data: discriminator + swapType + inputAmount + minOutputAmount + const discriminator = new Uint8Array([0xa7, 0x61, 0x0c, 0xe7, 0xed, 0x4e, 0xa6, 0xfb]); + const swapTypeData = new Uint8Array([params.swapType === 'buy' ? 0 : 1]); + const inputAmountData = new Uint8Array(params.inputAmount.toArrayLike(Buffer, 'le', 8)); + const minOutputAmountData = new Uint8Array(params.minOutputAmount.toArrayLike(Buffer, 'le', 8)); + + const data = Buffer.from([...discriminator, ...swapTypeData, ...inputAmountData, ...minOutputAmountData]); + + const keys = [ + { pubkey: params.dao, isSigner: false, isWritable: true }, + { pubkey: params.trader, isSigner: true, isWritable: true }, + { pubkey: daoAccount.baseMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.quoteMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.ammBaseVault, isSigner: false, isWritable: true }, + { pubkey: daoAccount.ammQuoteVault, isSigner: false, isWritable: true }, + { pubkey: traderBaseAccount, isSigner: false, isWritable: true }, + { pubkey: traderQuoteAccount, isSigner: false, isWritable: true }, + { pubkey: daoAccount.protocolFeeBaseVault, isSigner: false, isWritable: true }, + { pubkey: daoAccount.protocolFeeQuoteVault, isSigner: false, isWritable: true }, + { pubkey: baseTokenProgramId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: this.programId, + data, + }); + } + + async buildConditionalSwapIx(params: { + dao: PublicKey; + proposal: PublicKey; + trader: PublicKey; + market: 'pass' | 'fail'; + swapType: 'buy' | 'sell'; + inputAmount: BN; + minOutputAmount: BN; + }): Promise { + const daoAccount = await this.getDao(params.dao.toString()); + const pdas = await this.getProposalPdas(params.dao.toString(), params.proposal.toString()); + const conditionalVaultProgram = new PublicKey(this.config.conditionalVaultProgramId); + + // Determine input/output mints based on market and swap type + let inputMint: PublicKey; + let outputMint: PublicKey; + + if (params.market === 'pass') { + if (params.swapType === 'buy') { + inputMint = pdas.passQuoteMint; + outputMint = pdas.passBaseMint; + } else { + inputMint = pdas.passBaseMint; + outputMint = pdas.passQuoteMint; + } + } else { + if (params.swapType === 'buy') { + inputMint = pdas.failQuoteMint; + outputMint = pdas.failBaseMint; + } else { + inputMint = pdas.failBaseMint; + outputMint = pdas.failQuoteMint; + } + } + + const traderInputAccount = getAssociatedTokenAddressSync(inputMint, params.trader, true); + const traderOutputAccount = getAssociatedTokenAddressSync(outputMint, params.trader, true); + + // Instruction data: discriminator + ConditionalSwapParams { market, swapType, inputAmount, minOutputAmount } + // Market enum: Spot = 0, Pass = 1, Fail = 2. SwapType enum: Buy = 0, Sell = 1. + const discriminator = new Uint8Array([0xc2, 0x88, 0xdc, 0x59, 0xf2, 0xa9, 0x82, 0x9d]); + const marketData = new Uint8Array([params.market === 'pass' ? 1 : 2]); + const swapTypeData = new Uint8Array([params.swapType === 'buy' ? 0 : 1]); + const inputAmountData = new Uint8Array(params.inputAmount.toArrayLike(Buffer, 'le', 8)); + const minOutputAmountData = new Uint8Array(params.minOutputAmount.toArrayLike(Buffer, 'le', 8)); + + const data = Buffer.from([ + ...discriminator, + ...marketData, + ...swapTypeData, + ...inputAmountData, + ...minOutputAmountData, + ]); + + // Account order must match the deployed futarchy program's conditionalSwap instruction + const keys = [ + { pubkey: params.dao, isSigner: false, isWritable: true }, + { + pubkey: getAssociatedTokenAddressSync(daoAccount.baseMint, params.dao, true), + isSigner: false, + isWritable: true, + }, + { + pubkey: getAssociatedTokenAddressSync(daoAccount.quoteMint, params.dao, true), + isSigner: false, + isWritable: true, + }, + { pubkey: params.proposal, isSigner: false, isWritable: false }, + { pubkey: getAssociatedTokenAddressSync(pdas.passBaseMint, params.dao, true), isSigner: false, isWritable: true }, + { + pubkey: getAssociatedTokenAddressSync(pdas.passQuoteMint, params.dao, true), + isSigner: false, + isWritable: true, + }, + { pubkey: getAssociatedTokenAddressSync(pdas.failBaseMint, params.dao, true), isSigner: false, isWritable: true }, + { + pubkey: getAssociatedTokenAddressSync(pdas.failQuoteMint, params.dao, true), + isSigner: false, + isWritable: true, + }, + { pubkey: params.trader, isSigner: true, isWritable: false }, + { pubkey: traderInputAccount, isSigner: false, isWritable: true }, + { pubkey: traderOutputAccount, isSigner: false, isWritable: true }, + { pubkey: pdas.baseVault, isSigner: false, isWritable: true }, + { + pubkey: getAssociatedTokenAddressSync(daoAccount.baseMint, pdas.baseVault, true), + isSigner: false, + isWritable: true, + }, + { pubkey: pdas.quoteVault, isSigner: false, isWritable: true }, + { + pubkey: getAssociatedTokenAddressSync(daoAccount.quoteMint, pdas.quoteVault, true), + isSigner: false, + isWritable: true, + }, + { pubkey: pdas.passBaseMint, isSigner: false, isWritable: true }, + { pubkey: pdas.failBaseMint, isSigner: false, isWritable: true }, + { pubkey: pdas.passQuoteMint, isSigner: false, isWritable: true }, + { pubkey: pdas.failQuoteMint, isSigner: false, isWritable: true }, + { pubkey: conditionalVaultProgram, isSigner: false, isWritable: false }, + { pubkey: this.getEventAuthority(conditionalVaultProgram), isSigner: false, isWritable: false }, + { pubkey: pdas.question, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: this.getEventAuthority(this.programId), isSigner: false, isWritable: false }, + { pubkey: this.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: this.programId, + data, + }); + } + + /** + * Build a conditional-vault splitTokens instruction: deposit `amount` of the + * underlying (base or quote) and mint equal amounts of the pass and fail + * conditional tokens. Returns the ATA-creation preInstructions plus the split + * instruction itself. + */ + async buildSplitTokensIxs(params: { + dao: PublicKey; + proposal: PublicKey; + user: PublicKey; + asset: 'base' | 'quote'; + amount: BN; + }): Promise { + const daoAccount = await this.getDao(params.dao.toString()); + const pdas = await this.getProposalPdas(params.dao.toString(), params.proposal.toString()); + const conditionalVaultProgram = new PublicKey(this.config.conditionalVaultProgramId); + + const underlyingMint = params.asset === 'base' ? daoAccount.baseMint : daoAccount.quoteMint; + const vault = params.asset === 'base' ? pdas.baseVault : pdas.quoteVault; + // Conditional mints in outcome-index order: index 0 = fail, index 1 = pass + const conditionalMints = + params.asset === 'base' ? [pdas.failBaseMint, pdas.passBaseMint] : [pdas.failQuoteMint, pdas.passQuoteMint]; + + const userConditionalAccounts = conditionalMints.map((mint) => + getAssociatedTokenAddressSync(mint, params.user, true), + ); + + // Idempotently create the user's conditional token ATAs + const preInstructions = conditionalMints.map((mint, i) => + createAssociatedTokenAccountIdempotentInstruction(params.user, userConditionalAccounts[i], params.user, mint), + ); + + // Instruction data: discriminator + amount (u64) + const discriminator = new Uint8Array([0x4f, 0xc3, 0x74, 0x00, 0x8c, 0xb0, 0x49, 0xb3]); + const amountData = new Uint8Array(params.amount.toArrayLike(Buffer, 'le', 8)); + const data = Buffer.from([...discriminator, ...amountData]); + + const keys = [ + { pubkey: pdas.question, isSigner: false, isWritable: false }, + { pubkey: vault, isSigner: false, isWritable: true }, + { pubkey: getAssociatedTokenAddressSync(underlyingMint, vault, true), isSigner: false, isWritable: true }, + { pubkey: params.user, isSigner: true, isWritable: false }, + { pubkey: getAssociatedTokenAddressSync(underlyingMint, params.user, true), isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: this.getEventAuthority(conditionalVaultProgram), isSigner: false, isWritable: false }, + { pubkey: conditionalVaultProgram, isSigner: false, isWritable: false }, + // Remaining accounts: conditional mints then user conditional ATAs, all writable + ...conditionalMints.map((mint) => ({ pubkey: mint, isSigner: false, isWritable: true })), + ...userConditionalAccounts.map((ata) => ({ pubkey: ata, isSigner: false, isWritable: true })), + ]; + + const splitIx = new TransactionInstruction({ + keys, + programId: conditionalVaultProgram, + data, + }); + + return [...preInstructions, splitIx]; + } + + async buildProvideLiquidityIx(params: { + dao: PublicKey; + liquidityProvider: PublicKey; + quoteAmount: BN; + maxBaseAmount: BN; + minLiquidity: BN; + }): Promise { + const daoAccount = await this.getDao(params.dao.toString()); + + // Get LP position PDA + const ammPosition = this.getAmmPositionAddress(params.dao.toString(), params.liquidityProvider.toString()); + + // Get LP's token accounts + const lpBaseAccount = getAssociatedTokenAddressSync(daoAccount.baseMint, params.liquidityProvider); + const lpQuoteAccount = getAssociatedTokenAddressSync(daoAccount.quoteMint, params.liquidityProvider); + + // Build instruction data: discriminator + quoteAmount + maxBaseAmount + minLiquidity + const discriminator = new Uint8Array([0x28, 0x6e, 0x6b, 0x74, 0xae, 0x7f, 0x61, 0xcc]); + const quoteAmountData = new Uint8Array(params.quoteAmount.toArrayLike(Buffer, 'le', 8)); + const maxBaseAmountData = new Uint8Array(params.maxBaseAmount.toArrayLike(Buffer, 'le', 8)); + const minLiquidityData = new Uint8Array(params.minLiquidity.toArrayLike(Buffer, 'le', 8)); + + const data = Buffer.from([...discriminator, ...quoteAmountData, ...maxBaseAmountData, ...minLiquidityData]); + + const keys = [ + { pubkey: params.dao, isSigner: false, isWritable: true }, + { pubkey: params.liquidityProvider, isSigner: true, isWritable: true }, + { pubkey: ammPosition, isSigner: false, isWritable: true }, + { pubkey: daoAccount.baseMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.quoteMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.ammBaseVault, isSigner: false, isWritable: true }, + { pubkey: daoAccount.ammQuoteVault, isSigner: false, isWritable: true }, + { pubkey: lpBaseAccount, isSigner: false, isWritable: true }, + { pubkey: lpQuoteAccount, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: this.programId, + data, + }); + } + + async buildWithdrawLiquidityIx(params: { + dao: PublicKey; + liquidityProvider: PublicKey; + liquidityAmount: BN; + minBaseAmount: BN; + minQuoteAmount: BN; + }): Promise { + const daoAccount = await this.getDao(params.dao.toString()); + + // Get LP position PDA + const ammPosition = this.getAmmPositionAddress(params.dao.toString(), params.liquidityProvider.toString()); + + // Get LP's token accounts + const lpBaseAccount = getAssociatedTokenAddressSync(daoAccount.baseMint, params.liquidityProvider); + const lpQuoteAccount = getAssociatedTokenAddressSync(daoAccount.quoteMint, params.liquidityProvider); + + // Build instruction data: discriminator + liquidityAmount + minBaseAmount + minQuoteAmount + const discriminator = new Uint8Array([0x95, 0x9e, 0x21, 0xb9, 0x2f, 0xf3, 0xfd, 0x1f]); + const liquidityAmountData = new Uint8Array(params.liquidityAmount.toArrayLike(Buffer, 'le', 8)); + const minBaseAmountData = new Uint8Array(params.minBaseAmount.toArrayLike(Buffer, 'le', 8)); + const minQuoteAmountData = new Uint8Array(params.minQuoteAmount.toArrayLike(Buffer, 'le', 8)); + + const data = Buffer.from([...discriminator, ...liquidityAmountData, ...minBaseAmountData, ...minQuoteAmountData]); + + const keys = [ + { pubkey: params.dao, isSigner: false, isWritable: true }, + { pubkey: params.liquidityProvider, isSigner: true, isWritable: true }, + { pubkey: ammPosition, isSigner: false, isWritable: true }, + { pubkey: daoAccount.baseMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.quoteMint, isSigner: false, isWritable: false }, + { pubkey: daoAccount.ammBaseVault, isSigner: false, isWritable: true }, + { pubkey: daoAccount.ammQuoteVault, isSigner: false, isWritable: true }, + { pubkey: lpBaseAccount, isSigner: false, isWritable: true }, + { pubkey: lpQuoteAccount, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: this.programId, + data, + }); + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + getSolana(): Solana { + return this.solana; + } + + getProgramId(): PublicKey { + return this.programId; + } + + getNetwork(): string { + return this.network; + } + + async getTokenProgramId(mint: PublicKey): Promise { + const mintInfo = await this.connection.getAccountInfo(mint); + if (!mintInfo) { + throw new Error(`Mint account not found: ${mint.toString()}`); + } + // Check if mint is owned by Token-2022 program + if (mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID)) { + return TOKEN_2022_PROGRAM_ID; + } + return TOKEN_PROGRAM_ID; + } + + async buildCreateAtaIx(owner: PublicKey, mint: PublicKey, payer: PublicKey = owner): Promise { + const tokenProgramId = await this.getTokenProgramId(mint); + const ata = getAssociatedTokenAddressSync(mint, owner, false, tokenProgramId); + return createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint, tokenProgramId); + } + + async getTokenDecimals(mint: string): Promise { + const mintInfo = await this.solana.connection.getParsedAccountInfo(new PublicKey(mint)); + const decimals = (mintInfo.value?.data as any)?.parsed?.info?.decimals; + if (typeof decimals !== 'number') { + throw new Error(`Unable to read SPL mint decimals for ${mint}`); + } + return decimals; + } + + /** + * Convert human-readable amount to raw BN. + * Use Decimal library to avoid floating point precision loss. + */ + toRawAmount(amount: string | number, decimals: number): BN { + const d = new Decimal(amount).mul(new Decimal(10).pow(decimals)); + return new BN(d.toFixed(0)); // Truncate, don't round + } + + /** + * Convert raw BN to human-readable number. + * WARNING: Only use for display purposes. Large amounts may lose precision. + * For calculations, keep values as BN throughout. + */ + fromRawAmount(amount: BN, decimals: number): number { + const d = new Decimal(amount.toString()).div(new Decimal(10).pow(decimals)); + return d.toNumber(); + } + + /** + * Convert raw BN to string (no precision loss). + * Preferred over fromRawAmount for API responses. + */ + fromRawAmountString(amount: BN, decimals: number): string { + const d = new Decimal(amount.toString()).div(new Decimal(10).pow(decimals)); + return d.toString(); + } + + /** + * MetaDAO LP liquidity is scaled as raw quote units * 1e9. + */ + fromRawLiquidity(amount: BN, quoteDecimals: number): number { + return this.fromRawAmount(amount, quoteDecimals + LP_LIQUIDITY_SCALE_DECIMALS); + } + + toRawLiquidity(amount: string | number, quoteDecimals: number): BN { + return this.toRawAmount(amount, quoteDecimals + LP_LIQUIDITY_SCALE_DECIMALS); + } + + /** + * Get pool state type + */ + getPoolStateType(dao: DaoAccount): 'spot' | 'futarchy' { + if ('spot' in dao.amm.state) return 'spot'; + return 'futarchy'; + } + + /** + * Get spot pool reserves (works in both spot and futarchy state) + */ + getSpotPool(dao: DaoAccount): Pool { + if ('spot' in dao.amm.state) { + return (dao.amm.state as { spot: Pool }).spot; + } + return (dao.amm.state as { futarchy: PoolStateFutarchy }).futarchy.spot; + } + + /** + * Get pass pool reserves (only in futarchy state) + */ + getPassPool(dao: DaoAccount): Pool | null { + if ('futarchy' in dao.amm.state) { + return (dao.amm.state as { futarchy: PoolStateFutarchy }).futarchy.pass; + } + return null; + } + + /** + * Get fail pool reserves (only in futarchy state) + */ + getFailPool(dao: DaoAccount): Pool | null { + if ('futarchy' in dao.amm.state) { + return (dao.amm.state as { futarchy: PoolStateFutarchy }).futarchy.fail; + } + return null; + } + + /** + * Get token symbol from mint address. + * First checks local token list, then fetches from GeckoTerminal if not found. + */ + async getTokenSymbol(mintAddress: string): Promise { + const tokenInfo = await this.solana.getToken(mintAddress); + + // If found and not a dummy token, return the symbol + if (tokenInfo?.symbol && !tokenInfo.symbol.startsWith('DUMMY_')) { + return tokenInfo.symbol; + } + + // Try to fetch from GeckoTerminal + try { + const chainNetwork = `solana-${this.network}`; + const geckoTokenInfo = await fetchTokenInfo(chainNetwork, mintAddress); + if (geckoTokenInfo?.symbol) { + return geckoTokenInfo.symbol; + } + } catch (error) { + logger.debug(`GeckoTerminal lookup failed for ${mintAddress}: ${error}`); + } + + return mintAddress.slice(0, 8); + } +} diff --git a/src/schemas/metadao-schema.ts b/src/schemas/metadao-schema.ts new file mode 100644 index 0000000000..1d5612bffb --- /dev/null +++ b/src/schemas/metadao-schema.ts @@ -0,0 +1,673 @@ +import { Type, Static } from '@sinclair/typebox'; + +import { getSolanaChainConfig } from '../chains/solana/solana.config'; + +// Get chain config for defaults +const solanaChainConfig = getSolanaChainConfig(); + +// ============================================================================ +// Common Types +// ============================================================================ + +export const MetaDaoMarketEnum = Type.String({ + description: 'Conditional market type', + enum: ['PASS', 'FAIL'], +}); + +export const MetaDaoSwapTypeEnum = Type.String({ + description: 'Swap direction - BUY base with quote, SELL base for quote', + enum: ['BUY', 'SELL'], +}); + +export const MetaDaoPoolStateEnum = Type.String({ + description: 'Pool state - spot (normal trading) or futarchy (proposal active)', + enum: ['spot', 'futarchy'], +}); + +// ============================================================================ +// Pool Info Types +// ============================================================================ + +export const MetaDaoPoolReservesSchema = Type.Object({ + baseReserves: Type.Number({ description: 'Human-readable base token reserves' }), + quoteReserves: Type.Number({ description: 'Human-readable quote token reserves' }), + baseReservesRaw: Type.String({ description: 'Raw integer base reserves (on-chain)' }), + quoteReservesRaw: Type.String({ description: 'Raw integer quote reserves (on-chain)' }), + price: Type.Number({ description: 'Quote per base price' }), + baseVault: Type.Optional(Type.String({ description: 'Base token vault address' })), + quoteVault: Type.Optional(Type.String({ description: 'Quote token vault address' })), + protocolFeeBase: Type.Optional(Type.Number({ description: 'Human-readable accumulated base protocol fees' })), + protocolFeeQuote: Type.Optional(Type.Number({ description: 'Human-readable accumulated quote protocol fees' })), + protocolFeeBaseRaw: Type.Optional(Type.String({ description: 'Raw integer accumulated base protocol fees' })), + protocolFeeQuoteRaw: Type.Optional(Type.String({ description: 'Raw integer accumulated quote protocol fees' })), +}); +export type MetaDaoPoolReserves = Static; + +export const MetaDaoReservePairSchema = Type.Object({ + base: Type.Number({ description: 'Human-readable base reserves' }), + quote: Type.Number({ description: 'Human-readable quote reserves' }), + baseRaw: Type.String({ description: 'Raw integer base reserves' }), + quoteRaw: Type.String({ description: 'Raw integer quote reserves' }), +}); +export type MetaDaoReservePair = Static; + +export const MetaDaoProposalPdasSchema = Type.Object({ + question: Type.String(), + baseVault: Type.String(), + quoteVault: Type.String(), + passBaseMint: Type.String(), + passQuoteMint: Type.String(), + failBaseMint: Type.String(), + failQuoteMint: Type.String(), +}); +export type MetaDaoProposalPdas = Static; + +export const MetaDaoPoolInfoRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + }, + { $id: 'MetaDaoPoolInfoRequest' }, +); +export type MetaDaoPoolInfoRequestType = Static; + +export const MetaDaoPoolInfoResponse = Type.Object( + { + pool: Type.String({ description: 'Pool/DAO address' }), + baseMint: Type.String(), + quoteMint: Type.String(), + baseSymbol: Type.String(), + quoteSymbol: Type.String(), + state: MetaDaoPoolStateEnum, + totalLiquidity: Type.Number({ description: 'Total LP token supply' }), + totalLiquidityRaw: Type.String({ description: 'Raw integer total LP token supply' }), + spot: MetaDaoPoolReservesSchema, + pass: Type.Optional(MetaDaoPoolReservesSchema), + fail: Type.Optional(MetaDaoPoolReservesSchema), + }, + { $id: 'MetaDaoPoolInfoResponse' }, +); +export type MetaDaoPoolInfoResponseType = Static; + +// ============================================================================ +// Spot Swap Types +// ============================================================================ + +export const MetaDaoQuoteSwapRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + quoteToken: Type.Optional( + Type.String({ + default: 'USDC', + description: 'Quote token symbol (e.g., USDC) or mint address. Used to match pool.', + }), + ), + amount: Type.Number({ description: 'Amount of base token to trade', minimum: 0 }), + side: MetaDaoSwapTypeEnum, + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoQuoteSwapRequest' }, +); +export type MetaDaoQuoteSwapRequestType = Static; + +export const MetaDaoQuoteSwapResponse = Type.Object( + { + quoteId: Type.String(), + pool: Type.String({ description: 'Pool/DAO address' }), + tokenIn: Type.String(), + tokenOut: Type.String(), + amountIn: Type.Number(), + amountOut: Type.Number(), + price: Type.Number({ description: 'Effective quote per base price' }), + priceImpactPct: Type.Number(), + minAmountOut: Type.Number(), + maxAmountIn: Type.Number(), + fee: Type.Object({ + lpFeeBps: Type.Number(), + protocolFeeBps: Type.Number(), + totalFeeBps: Type.Number(), + }), + poolReserves: MetaDaoReservePairSchema, + }, + { $id: 'MetaDaoQuoteSwapResponse' }, +); +export type MetaDaoQuoteSwapResponseType = Static; + +export const MetaDaoExecuteSwapRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + walletAddress: Type.Optional(Type.String({ description: 'Wallet address (uses default wallet if not provided)' })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + quoteToken: Type.Optional( + Type.String({ + default: 'USDC', + description: 'Quote token symbol (e.g., USDC) or mint address. Used to match pool.', + }), + ), + amount: Type.Number({ description: 'Amount of base token to trade', minimum: 0 }), + side: MetaDaoSwapTypeEnum, + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoExecuteSwapRequest' }, +); +export type MetaDaoExecuteSwapRequestType = Static; + +export const MetaDaoExecuteSwapResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ description: 'TransactionStatus: -1=failed, 0=pending, 1=confirmed', enum: [-1, 0, 1] }), + data: Type.Optional( + Type.Object({ + tokenIn: Type.String(), + tokenOut: Type.String(), + amountIn: Type.Number(), + amountOut: Type.Number(), + fee: Type.Number({ description: 'SOL transaction fee' }), + baseTokenBalanceChange: Type.Number(), + quoteTokenBalanceChange: Type.Number(), + }), + ), + }, + { $id: 'MetaDaoExecuteSwapResponse' }, +); +export type MetaDaoExecuteSwapResponseType = Static; + +// ============================================================================ +// Conditional Swap Types +// ============================================================================ + +export const MetaDaoQuoteConditionalSwapRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + dao: Type.String({ description: 'DAO public key' }), + proposal: Type.String({ description: 'Proposal public key' }), + market: MetaDaoMarketEnum, + amount: Type.Number({ description: 'Amount of conditional base token to trade', minimum: 0 }), + side: MetaDaoSwapTypeEnum, + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoQuoteConditionalSwapRequest' }, +); +export type MetaDaoQuoteConditionalSwapRequestType = Static; + +export const MetaDaoQuoteConditionalSwapResponse = Type.Object( + { + quoteId: Type.String(), + pool: Type.String({ description: 'Pool/DAO address' }), + proposal: Type.String(), + market: MetaDaoMarketEnum, + side: MetaDaoSwapTypeEnum, + + // Token info + tokenIn: Type.String({ description: 'Conditional input token mint' }), + tokenOut: Type.String({ description: 'Conditional output token mint' }), + inputSymbol: Type.String({ description: 'e.g., pUSDC, fMETA' }), + outputSymbol: Type.String({ description: 'e.g., pMETA, fUSDC' }), + + // Amounts + amountIn: Type.Number(), + amountOut: Type.Number(), + price: Type.Number({ description: 'Effective quote per base price' }), + priceImpactPct: Type.Number(), + minAmountOut: Type.Number(), + maxAmountIn: Type.Number(), + + // Pool state + poolReserves: MetaDaoReservePairSchema, + + // Proposal context + proposalState: Type.String({ enum: ['pending', 'passed', 'failed'] }), + tradingEndsAt: Type.Number({ description: 'Unix timestamp' }), + + // All proposal PDAs for reference + pdas: MetaDaoProposalPdasSchema, + }, + { $id: 'MetaDaoQuoteConditionalSwapResponse' }, +); +export type MetaDaoQuoteConditionalSwapResponseType = Static; + +export const MetaDaoExecuteConditionalSwapRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + walletAddress: Type.Optional(Type.String({ description: 'Wallet address (uses default wallet if not provided)' })), + dao: Type.String(), + proposal: Type.String(), + market: MetaDaoMarketEnum, + amount: Type.Number({ description: 'Amount of conditional base token to trade', minimum: 0 }), + side: MetaDaoSwapTypeEnum, + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoExecuteConditionalSwapRequest' }, +); +export type MetaDaoExecuteConditionalSwapRequestType = Static; + +export const MetaDaoExecuteConditionalSwapResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ enum: [-1, 0, 1] }), + data: Type.Optional( + Type.Object({ + market: MetaDaoMarketEnum, + tokenIn: Type.String(), + tokenOut: Type.String(), + amountIn: Type.Number(), + amountOut: Type.Number(), + fee: Type.Number(), + baseTokenBalanceChange: Type.Number(), + quoteTokenBalanceChange: Type.Number(), + }), + ), + }, + { $id: 'MetaDaoExecuteConditionalSwapResponse' }, +); +export type MetaDaoExecuteConditionalSwapResponseType = Static; + +export const MetaDaoSplitTokensRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + walletAddress: Type.Optional(Type.String({ description: 'Wallet address (uses default wallet if not provided)' })), + dao: Type.String(), + proposal: Type.String(), + asset: Type.Union([Type.Literal('base'), Type.Literal('quote')], { + description: 'Which underlying to split: base token or quote token', + }), + amount: Type.Number({ description: 'Amount of underlying token to split into pass + fail tokens', minimum: 0 }), + }, + { $id: 'MetaDaoSplitTokensRequest' }, +); +export type MetaDaoSplitTokensRequestType = Static; + +export const MetaDaoSplitTokensResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ enum: [-1, 0, 1] }), + data: Type.Optional( + Type.Object({ + asset: Type.String(), + underlyingMint: Type.String(), + passMint: Type.String(), + failMint: Type.String(), + amount: Type.Number(), + }), + ), + }, + { $id: 'MetaDaoSplitTokensResponse' }, +); +export type MetaDaoSplitTokensResponseType = Static; + +// ============================================================================ +// Liquidity Types +// ============================================================================ + +export const MetaDaoQuoteLiquidityRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + quoteAmount: Type.Number({ description: 'Quote token amount to deposit', minimum: 0 }), + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoQuoteLiquidityRequest' }, +); +export type MetaDaoQuoteLiquidityRequestType = Static; + +export const MetaDaoQuoteLiquidityResponse = Type.Object( + { + pool: Type.String({ description: 'Pool/DAO address' }), + baseMint: Type.String(), + quoteMint: Type.String(), + poolState: MetaDaoPoolStateEnum, + + // Deposit amounts + quoteAmount: Type.Number(), + baseAmount: Type.Number({ description: 'Calculated base to deposit' }), + maxBaseAmount: Type.Number({ description: 'With slippage' }), + + // LP tokens + liquidityMinted: Type.Number({ description: 'LP shares to receive' }), + minLiquidity: Type.Number({ description: 'With slippage' }), + totalLiquidity: Type.Number({ description: 'Current total LP supply' }), + totalLiquidityRaw: Type.String({ description: 'Raw integer current LP supply' }), + shareOfPool: Type.Number({ description: 'Percentage of pool after deposit' }), + + // Current pool state + poolReserves: MetaDaoReservePairSchema, + }, + { $id: 'MetaDaoQuoteLiquidityResponse' }, +); +export type MetaDaoQuoteLiquidityResponseType = Static; + +export const MetaDaoAddLiquidityRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + walletAddress: Type.Optional(Type.String({ description: 'Wallet address (uses default wallet if not provided)' })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + quoteAmount: Type.Number({ minimum: 0 }), + maxBaseAmount: Type.Optional(Type.Number({ description: 'Maximum base to deposit (calculated if omitted)' })), + minLiquidity: Type.Optional(Type.Number({ description: 'Minimum LP shares to receive' })), + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoAddLiquidityRequest' }, +); +export type MetaDaoAddLiquidityRequestType = Static; + +export const MetaDaoAddLiquidityResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ enum: [-1, 0, 1] }), + data: Type.Optional( + Type.Object({ + pool: Type.String({ description: 'Pool/DAO address' }), + positionAddress: Type.String(), + quoteDeposited: Type.Number(), + baseDeposited: Type.Number(), + liquidityMinted: Type.Number(), + fee: Type.Number(), + }), + ), + }, + { $id: 'MetaDaoAddLiquidityResponse' }, +); +export type MetaDaoAddLiquidityResponseType = Static; + +export const MetaDaoRemoveLiquidityRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + walletAddress: Type.Optional(Type.String({ description: 'Wallet address (uses default wallet if not provided)' })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + liquidityAmount: Type.Number({ description: 'LP shares to burn', minimum: 0 }), + minBaseAmount: Type.Optional(Type.Number({ description: 'Minimum base to receive' })), + minQuoteAmount: Type.Optional(Type.Number({ description: 'Minimum quote to receive' })), + slippagePct: Type.Optional(Type.Number({ default: 0.5, minimum: 0, maximum: 100 })), + }, + { $id: 'MetaDaoRemoveLiquidityRequest' }, +); +export type MetaDaoRemoveLiquidityRequestType = Static; + +export const MetaDaoRemoveLiquidityResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ enum: [-1, 0, 1] }), + data: Type.Optional( + Type.Object({ + pool: Type.String({ description: 'Pool/DAO address' }), + positionAddress: Type.String(), + liquidityBurned: Type.Number(), + baseReceived: Type.Number(), + quoteReceived: Type.Number(), + fee: Type.Number(), + }), + ), + }, + { $id: 'MetaDaoRemoveLiquidityResponse' }, +); +export type MetaDaoRemoveLiquidityResponseType = Static; + +// ============================================================================ +// Balance Types +// ============================================================================ + +export const MetaDaoTokenBalanceSchema = Type.Object({ + mint: Type.String(), + symbol: Type.String(), + balance: Type.Number(), + balanceRaw: Type.String({ description: 'Raw integer token balance' }), + decimals: Type.Number(), + ata: Type.Optional(Type.String({ description: 'Associated token account address' })), +}); +export type MetaDaoTokenBalance = Static; + +export const MetaDaoBalanceRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + dao: Type.String(), + proposal: Type.Optional(Type.String({ description: 'Proposal to check conditional tokens for' })), + ownerAddress: Type.Optional(Type.String({ default: solanaChainConfig.defaultWallet })), + }, + { $id: 'MetaDaoBalanceRequest' }, +); +export type MetaDaoBalanceRequestType = Static; + +export const MetaDaoBalanceResponse = Type.Object( + { + owner: Type.String(), + pool: Type.String({ description: 'Pool/DAO address' }), + proposal: Type.Optional(Type.String()), + balances: Type.Object({ + // Spot tokens (always present) + base: MetaDaoTokenBalanceSchema, + quote: MetaDaoTokenBalanceSchema, + + // Conditional tokens (only if proposal provided) + passBase: Type.Optional(MetaDaoTokenBalanceSchema), + passQuote: Type.Optional(MetaDaoTokenBalanceSchema), + failBase: Type.Optional(MetaDaoTokenBalanceSchema), + failQuote: Type.Optional(MetaDaoTokenBalanceSchema), + + // LP position + lpPosition: Type.Optional( + Type.Object({ + liquidity: Type.Number({ description: 'LP shares held' }), + liquidityRaw: Type.String({ description: 'Raw integer LP shares held' }), + shareOfPool: Type.Number({ description: 'Percentage of total pool' }), + }), + ), + }), + proposalState: Type.Optional(Type.String({ enum: ['pending', 'passed', 'failed'] })), + }, + { $id: 'MetaDaoBalanceResponse' }, +); +export type MetaDaoBalanceResponseType = Static; + +// ============================================================================ +// DAO and Proposal Discovery Types +// ============================================================================ + +export const MetaDaoDaoInfoSchema = Type.Object({ + address: Type.String(), + name: Type.Optional(Type.String()), + baseMint: Type.String(), + quoteMint: Type.String(), + baseSymbol: Type.String(), + quoteSymbol: Type.String(), + poolState: MetaDaoPoolStateEnum, + totalLiquidity: Type.Number(), + totalLiquidityRaw: Type.String(), + activeProposals: Type.Number(), +}); +export type MetaDaoDaoInfo = Static; + +export const MetaDaoListDaosRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + }, + { $id: 'MetaDaoListDaosRequest' }, +); +export type MetaDaoListDaosRequestType = Static; + +export const MetaDaoListDaosItemSchema = Type.Object({ + pool: Type.String({ description: 'Pool/DAO address' }), + baseMint: Type.String({ description: 'Base token mint address' }), + quoteMint: Type.String({ description: 'Quote token mint address' }), + baseSymbol: Type.String({ description: 'Base token symbol' }), + quoteSymbol: Type.String({ description: 'Quote token symbol' }), + state: Type.String({ description: 'Pool state (spot or futarchy)' }), + totalLiquidity: Type.Number({ description: 'Total liquidity in human-readable form' }), + totalLiquidityRaw: Type.String({ description: 'Raw total liquidity value' }), + spot: Type.Optional(MetaDaoPoolReservesSchema), +}); +export type MetaDaoListDaosItem = Static; + +// /daos returns DAO addresses from chain +export const MetaDaoListDaosResponse = Type.Object( + { + daos: Type.Array(Type.String({ description: 'DAO address' })), + }, + { $id: 'MetaDaoListDaosResponse' }, +); +export type MetaDaoListDaosResponseType = Static; + +// /pools returns cached pool data from daos.json +export const MetaDaoListPoolsResponse = Type.Object( + { + pools: Type.Array(MetaDaoListDaosItemSchema), + }, + { $id: 'MetaDaoListPoolsResponse' }, +); +export type MetaDaoListPoolsResponseType = Static; + +// /dao-info request - lookup by pool address +export const MetaDaoDaoInfoRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + pool: Type.String({ description: 'Pool/DAO address to fetch info for' }), + }, + { $id: 'MetaDaoDaoInfoRequest' }, +); +export type MetaDaoDaoInfoRequestType = Static; + +// /dao-info response - same as pool-info +export const MetaDaoDaoInfoResponse = Type.Object( + { + pool: Type.String({ description: 'Pool/DAO address' }), + baseMint: Type.String(), + quoteMint: Type.String(), + baseSymbol: Type.String(), + quoteSymbol: Type.String(), + state: MetaDaoPoolStateEnum, + totalLiquidity: Type.Number({ description: 'Total LP token supply' }), + totalLiquidityRaw: Type.String({ description: 'Raw integer total LP token supply' }), + spot: MetaDaoPoolReservesSchema, + pass: Type.Optional(MetaDaoPoolReservesSchema), + fail: Type.Optional(MetaDaoPoolReservesSchema), + }, + { $id: 'MetaDaoDaoInfoResponse' }, +); +export type MetaDaoDaoInfoResponseType = Static; + +export const MetaDaoProposalInfoSchema = Type.Object({ + address: Type.String(), + number: Type.Optional(Type.Number()), + status: Type.String({ enum: ['pending', 'passed', 'failed'] }), + launchedAt: Type.Number(), + tradingEndsAt: Type.Number(), + + // Conditional mints + passBaseMint: Type.String(), + passQuoteMint: Type.String(), + failBaseMint: Type.String(), + failQuoteMint: Type.String(), + + // Pool metrics + passPool: MetaDaoPoolReservesSchema, + failPool: MetaDaoPoolReservesSchema, + + // Derived metrics + impliedProbability: Type.Number({ description: 'Probability proposal passes based on prices' }), +}); +export type MetaDaoProposalInfo = Static; + +export const MetaDaoListProposalsRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + baseToken: Type.String({ description: 'Base token symbol (e.g., META) or mint address. Used to identify pool.' }), + status: Type.Optional(Type.String({ enum: ['pending', 'passed', 'failed', 'all'], default: 'all' })), + }, + { $id: 'MetaDaoListProposalsRequest' }, +); +export type MetaDaoListProposalsRequestType = Static; + +export const MetaDaoListProposalsResponse = Type.Object( + { + pool: Type.String({ description: 'Pool/DAO address' }), + proposals: Type.Array(MetaDaoProposalInfoSchema), + }, + { $id: 'MetaDaoListProposalsResponse' }, +); +export type MetaDaoListProposalsResponseType = Static; + +// ============================================================================ +// Proposal Info Types (standalone proposal lookup) +// ============================================================================ + +export const MetaDaoProposalInfoRequest = Type.Object( + { + network: Type.Optional(Type.String({ default: solanaChainConfig.defaultNetwork })), + proposal: Type.String({ description: 'Proposal public key' }), + }, + { $id: 'MetaDaoProposalInfoRequest' }, +); +export type MetaDaoProposalInfoRequestType = Static; + +export const MetaDaoProposalInfoResponse = Type.Object( + { + proposal: Type.String(), + pool: Type.String({ description: 'Pool/DAO address' }), + number: Type.Optional(Type.Number()), + status: Type.String({ enum: ['pending', 'passed', 'failed'] }), + launchedAt: Type.Number(), + tradingEndsAt: Type.Number(), + + // DAO token info + baseMint: Type.String(), + quoteMint: Type.String(), + baseSymbol: Type.String(), + quoteSymbol: Type.String(), + + // Conditional token mints + pdas: MetaDaoProposalPdasSchema, + + // Pass market + passPool: Type.Object({ + baseReserves: Type.Number(), + quoteReserves: Type.Number(), + baseReservesRaw: Type.String(), + quoteReservesRaw: Type.String(), + price: Type.Number({ description: 'Pass market price (quote per base)' }), + }), + + // Fail market + failPool: Type.Object({ + baseReserves: Type.Number(), + quoteReserves: Type.Number(), + baseReservesRaw: Type.String(), + quoteReservesRaw: Type.String(), + price: Type.Number({ description: 'Fail market price (quote per base)' }), + }), + + // Derived metrics + impliedProbability: Type.Number({ description: 'Probability proposal passes (0-100%)' }), + passThresholdBps: Type.Number({ description: 'Pass TWAP must exceed fail TWAP by this many bps to pass' }), + isTeamSponsored: Type.Boolean(), + + // TWAP oracle state — the decision-relevant signal for a reactive defense. + // The proposal passes iff passTwap >= failTwap * (1 + passThresholdBps/10000). + twap: Type.Optional( + Type.Object({ + windowOpen: Type.Boolean({ description: 'Whether the TWAP window has started accumulating' }), + twapStartsAt: Type.Number({ description: 'Unix ts when observations begin (created + startDelaySeconds)' }), + twapEndsAt: Type.Number({ description: 'Unix ts when the TWAP window closes (proposal trading end)' }), + elapsedSeconds: Type.Number({ description: 'Seconds of TWAP accumulation so far' }), + passTwapObs: Type.Number({ description: 'Realized pass TWAP so far (oracle units)' }), + failTwapObs: Type.Number({ description: 'Realized fail TWAP so far (oracle units)' }), + passLastObs: Type.Number({ description: 'Current pass resting observation (oracle units)' }), + failLastObs: Type.Number({ description: 'Current fail resting observation (oracle units)' }), + passLastUpdated: Type.Number({ description: 'Unix ts of last pass observation' }), + failLastUpdated: Type.Number({ description: 'Unix ts of last fail observation' }), + marginPct: Type.Number({ description: 'realized (passTwap/failTwap - 1) * 100' }), + thresholdPct: Type.Number({ description: 'passThresholdBps / 100' }), + marginVsThresholdPct: Type.Number({ description: 'marginPct - thresholdPct; > 0 means currently passing' }), + attackerWinning: Type.Boolean({ description: 'Whether the realized TWAP currently clears the pass threshold' }), + lastObsMarginPct: Type.Number({ description: 'Instantaneous (passLastObs/failLastObs - 1) * 100' }), + lastObsMarginVsThresholdPct: Type.Number({ + description: 'lastObsMarginPct - thresholdPct; the live sampled-price signal', + }), + }), + ), + + passMarketCap: Type.Optional(Type.Number({ description: 'Pass market cap in quote tokens' })), + failMarketCap: Type.Optional(Type.Number({ description: 'Fail market cap in quote tokens' })), + }, + { $id: 'MetaDaoProposalInfoResponse' }, +); +export type MetaDaoProposalInfoResponseType = Static; diff --git a/src/templates/connectors/metadao.yml b/src/templates/connectors/metadao.yml new file mode 100644 index 0000000000..25216e4f03 --- /dev/null +++ b/src/templates/connectors/metadao.yml @@ -0,0 +1,9 @@ +# MetaDAO Connector Configuration +# Futarchy program ID +programId: 'FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq' + +# Conditional vault program ID +conditionalVaultProgramId: 'VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg' + +# Default slippage percentage for swaps (e.g., 0.5 = 0.5%) +defaultSlippagePct: 0.5 diff --git a/src/templates/namespace/metadao-schema.json b/src/templates/namespace/metadao-schema.json new file mode 100644 index 0000000000..808baa99f2 --- /dev/null +++ b/src/templates/namespace/metadao-schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "programId": { + "type": "string", + "description": "MetaDAO Futarchy program ID" + }, + "conditionalVaultProgramId": { + "type": "string", + "description": "Conditional vault program ID" + }, + "defaultSlippagePct": { + "type": "number", + "description": "Default slippage percentage (e.g., 0.5 for 0.5%)" + } + }, + "additionalProperties": false, + "required": ["programId", "conditionalVaultProgramId", "defaultSlippagePct"] +} diff --git a/src/templates/root.yml b/src/templates/root.yml index 05c0702ba0..8ae90bcbe9 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -108,6 +108,10 @@ configurations: configurationPath: connectors/dflow.yml schemaPath: dflow-schema.json + $namespace metadao: + configurationPath: connectors/metadao.yml + schemaPath: metadao-schema.json + $namespace okx: configurationPath: connectors/okx.yml schemaPath: okx-schema.json