diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index c4e81709..16725002 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -72,4 +72,6 @@ pub enum FutarchyError { ProposalAlreadySponsored, #[msg("Team sponsored pass threshold must be between -10% and 10%")] InvalidTeamSponsoredPassThreshold, + #[msg("Target K must be greater than the current K")] + InvalidTargetK, } diff --git a/programs/futarchy/src/instructions/collect_lp_fees.rs b/programs/futarchy/src/instructions/collect_lp_fees.rs new file mode 100644 index 00000000..4ad2383d --- /dev/null +++ b/programs/futarchy/src/instructions/collect_lp_fees.rs @@ -0,0 +1,197 @@ +use super::*; + +pub mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO multisig + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct CollectLpFeesArgs { + pub target_k: u128, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct CollectLpFees<'info> { + #[account(mut)] + pub dao: Account<'info, Dao>, + pub admin: Signer<'info>, + #[account(mut, token::mint = dao.base_mint)] + pub base_token_account: Account<'info, TokenAccount>, + #[account(mut, token::mint = dao.quote_mint)] + pub quote_token_account: Account<'info, TokenAccount>, + #[account(mut, associated_token::mint = dao.base_mint, associated_token::authority = dao)] + pub amm_base_vault: Account<'info, TokenAccount>, + #[account(mut, associated_token::mint = dao.quote_mint, associated_token::authority = dao)] + pub amm_quote_vault: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, +} + +impl CollectLpFees<'_> { + pub fn validate(&self, args: &CollectLpFeesArgs) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + if let PoolState::Spot { ref spot } = self.dao.amm.state { + require_gt!(spot.k(), args.target_k, FutarchyError::InvalidTargetK); + } else { + return err!(FutarchyError::PoolNotInSpotState); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: CollectLpFeesArgs) -> Result<()> { + // Take accrued liquidity as fees while maintaining the price + // + // We have two constraints: + // 1. Liquidity: + // Current: K = x * y + // Target: K' = x' * y' + // + // 2. Price: P = y / x = y' / x' <- price remains the same + // + // x is the base reserves and y is the quote reserves. + // + // We need to calculate the target reserves x' and y' that would result + // in the target liquidity K' while maintaining the price P = y / x = y' / x' + // + // If we substitute constraint 2 into constraint 1, we get: + // + // K' = x' * (P * x') + // K' = P * x'^2 + // x'^2 = K' / P + // x' = sqrt(K' / P) + // + // y' = P * x' = P * sqrt(K' / P) = sqrt(P^2 * K' / P) = sqrt(P * K') + // + // Since P = y / x, we can substitute P into the formulas to get: + // + // x' = sqrt(K' / (y/x)) = sqrt(K' * x / y) + // y' = sqrt(K' * (y/x)) = sqrt(K' * y / x) + // + // Using K = x * y (thus y = K / x and x = K / y), we can express the formulas as: + // + // x' = sqrt(K' * x / y) = sqrt(K' / K * x^2) = x * sqrt(K' / K) + // y' = sqrt(K' * y / x) = sqrt(K' / K * y^2) = y * sqrt(K' / K) + // + // Therefore, we can calculate the target reserves x' and y' as: + // + // x' = x * sqrt(K') / sqrt(K) + // y' = y * sqrt(K') / sqrt(K) + // + // The amount of fees to collect is then: + // + // base_fees = x - x' + // quote_fees = y - y' + + let PoolState::Spot { ref mut spot } = ctx.accounts.dao.amm.state else { + return err!(FutarchyError::PoolNotInSpotState); + }; + + let sqrt_k = sqrt_u128(spot.k(), false); + let sqrt_target_k = sqrt_u128(args.target_k, true); + + require_gt!(sqrt_k, sqrt_target_k, FutarchyError::InvalidTargetK); + + let target_base_reserves = + div_ceil_u128(spot.base_reserves as u128 * sqrt_target_k, sqrt_k) as u64; + let target_quote_reserves = + div_ceil_u128(spot.quote_reserves as u128 * sqrt_target_k, sqrt_k) as u64; + + let base_fees = spot.base_reserves - target_base_reserves; + let quote_fees = spot.quote_reserves - target_quote_reserves; + + spot.base_reserves = target_base_reserves; + spot.quote_reserves = target_quote_reserves; + + let dao_creator = ctx.accounts.dao.dao_creator; + let nonce = ctx.accounts.dao.nonce.to_le_bytes(); + let dao_seeds = &[ + b"dao".as_ref(), + dao_creator.as_ref(), + nonce.as_ref(), + &[ctx.accounts.dao.pda_bump], + ]; + let dao_signer = &[&dao_seeds[..]]; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.amm_base_vault.to_account_info(), + to: ctx.accounts.base_token_account.to_account_info(), + authority: ctx.accounts.dao.to_account_info(), + }, + dao_signer, + ), + base_fees, + )?; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.amm_quote_vault.to_account_info(), + to: ctx.accounts.quote_token_account.to_account_info(), + authority: ctx.accounts.dao.to_account_info(), + }, + dao_signer, + ), + quote_fees, + )?; + + ctx.accounts.dao.seq_num += 1; + + emit_cpi!(CollectFeesEvent { + common: CommonFields::new(&Clock::get()?, ctx.accounts.dao.seq_num), + dao: ctx.accounts.dao.key(), + base_token_account: ctx.accounts.base_token_account.key(), + quote_token_account: ctx.accounts.quote_token_account.key(), + amm_base_vault: ctx.accounts.amm_base_vault.key(), + amm_quote_vault: ctx.accounts.amm_quote_vault.key(), + quote_mint: ctx.accounts.dao.quote_mint, + base_mint: ctx.accounts.dao.base_mint, + quote_fees_collected: quote_fees, + base_fees_collected: base_fees, + post_amm_state: ctx.accounts.dao.amm.clone(), + }); + + Ok(()) + } +} + +/// Integer square root using Newton's method +/// Returns floor(sqrt(n)) +fn sqrt_u128(n: u128, ceil: bool) -> u128 { + if n == 0 { + return 0; + } + + let mut x = n; + let mut y = (x + 1) >> 1; + + while y < x { + x = y; + y = (x + n / x) >> 1; + } + + // x is floor(sqrt(n)) + // If x² == n exactly, ceil equals floor + // Otherwise, ceil = floor + 1 + if ceil { + if x * x == n { + x + } else { + x + 1 + } + } else { + x + } +} + +fn div_ceil_u128(a: u128, b: u128) -> u128 { + a / b + if a % b == 0 { 0 } else { 1 } +} diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 356267fb..2e16fd2f 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -1,6 +1,7 @@ use super::*; pub mod collect_fees; +pub mod collect_lp_fees; pub mod conditional_swap; pub mod execute_spending_limit_change; pub mod finalize_proposal; @@ -18,6 +19,7 @@ pub mod update_dao; pub mod withdraw_liquidity; pub use collect_fees::*; +pub use collect_lp_fees::*; pub use conditional_swap::*; pub use execute_spending_limit_change::*; pub use finalize_proposal::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 09fc3e7f..952aea46 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -42,9 +42,9 @@ pub const PRICE_SCALE: u128 = 1_000_000_000_000; // by default, the pass price needs to be 3% higher than the fail price pub const DEFAULT_PASS_THRESHOLD_BPS: u16 = 300; -// MetaDAO takes 0.25%, LP takes 0.25% -pub const LP_TAKER_FEE_BPS: u16 = 25; -pub const PROTOCOL_TAKER_FEE_BPS: u16 = 25; +// MetaDAO takes 0.5%, LP takes 0% +pub const LP_TAKER_FEE_BPS: u16 = 0; +pub const PROTOCOL_TAKER_FEE_BPS: u16 = 50; pub const MAX_BPS: u16 = 10_000; // the index of the fail and pass outcomes in the question and the index of @@ -131,6 +131,11 @@ pub mod futarchy { CollectFees::handle(ctx) } + #[access_control(ctx.accounts.validate(&args))] + pub fn collect_lp_fees(ctx: Context, args: CollectLpFeesArgs) -> Result<()> { + CollectLpFees::handle(ctx, args) + } + #[access_control(ctx.accounts.validate())] pub fn execute_spending_limit_change<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, ExecuteSpendingLimitChange<'info>>, diff --git a/scripts/v0.7/collectFees.ts b/scripts/v0.7/collectFees.ts new file mode 100644 index 00000000..723a8b6a --- /dev/null +++ b/scripts/v0.7/collectFees.ts @@ -0,0 +1,94 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + FutarchyClient, + FEE_RECIPIENT, +} from "@metadaoproject/futarchy/v0.7"; +import { PublicKey, TransactionMessage } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; + +// Set the DAO address before running the script +const dao = new PublicKey(""); + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be the non-Squads signer - tSTp6B6kE9o6ZaTmHm2ZwnJBBtgd3x112tapxFhmBEQ +const payer = provider.wallet["payer"]; + +const futarchy: FutarchyClient = new FutarchyClient( + provider, + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + [], +); + +// We need both the multisig and vault addresses for the Metadao DAO +const metadaoSquadsMultisig = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); +const metadaoSquadsMultisigVault = FEE_RECIPIENT; + +export const collectFees = async () => { + const daoAccount = await futarchy.fetchDao(dao); + + // We call the collect fees instruction from Metadao DAO's multisig account + // It's the only one that can call the collect fees instruction + const metaDaoSquadsMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + anchor.getProvider().connection, + metadaoSquadsMultisig, + ); + + // We want to receive the fees in the Metadao DAO's multisig vault + const feeRecipientBaseTokenAccount = getAssociatedTokenAddressSync( + daoAccount.baseMint, + metadaoSquadsMultisigVault, + true, + ); + + const feeRecipientQuoteTokenAccount = getAssociatedTokenAddressSync( + daoAccount.quoteMint, + metadaoSquadsMultisigVault, + true, + ); + + // Prepare transaction message + const collectFeesIx = await futarchy + .collectFeesIx({ + dao, + baseMint: daoAccount.baseMint, + quoteMint: daoAccount.quoteMint, + baseTokenAccount: feeRecipientBaseTokenAccount, + quoteTokenAccount: feeRecipientQuoteTokenAccount, + }) + .instruction(); + + const transactionMessage = new TransactionMessage({ + instructions: [collectFeesIx], + payerKey: metadaoSquadsMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + }); + + // Create vault transaction + const vaultTxCreateSignature = await multisig.rpc.vaultTransactionCreate({ + connection: anchor.getProvider().connection, + creator: payer.publicKey, + feePayer: payer.publicKey, + ephemeralSigners: 0, + multisigPda: metadaoSquadsMultisig, + transactionIndex: + BigInt(metaDaoSquadsMultisigAccount.transactionIndex.toString()) + 1n, + vaultIndex: 0, + transactionMessage, + }); + + console.log( + "Vault collect fees transaction create signature:", + vaultTxCreateSignature, + ); + console.log("Go ahead and execute the transaction through Squads."); +}; + +collectFees().catch(console.error); diff --git a/scripts/v0.7/collectLpFees.ts b/scripts/v0.7/collectLpFees.ts new file mode 100644 index 00000000..c96210d1 --- /dev/null +++ b/scripts/v0.7/collectLpFees.ts @@ -0,0 +1,110 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + FutarchyClient, + FEE_RECIPIENT, +} from "@metadaoproject/futarchy/v0.7"; +import { PublicKey, TransactionMessage } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { BN } from "@coral-xyz/anchor"; + +// Set the DAO address before running the script +const dao = new PublicKey(""); + +// Set the target K before running the script +const initialBaseReserves = new BN(0); +const initialQuoteReserves = new BN(0); +const targetK = new BN(initialBaseReserves.mul(initialQuoteReserves)); + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be the non-Squads signer - tSTp6B6kE9o6ZaTmHm2ZwnJBBtgd3x112tapxFhmBEQ +const payer = provider.wallet["payer"]; + +const futarchy: FutarchyClient = new FutarchyClient( + provider, + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + [], +); + +// We need both the multisig and vault addresses for the Metadao DAO +const metadaoSquadsMultisig = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); +const metadaoSquadsMultisigVault = FEE_RECIPIENT; + +// This should only be run once per DAO/AMM +// It's meant to be a one-off operation that reduces liquidity to a target K (the inital pool's liquidity) and collect it as "fees" +// We're using this because we didn't track LP fee collection in the pool state, nor did we exclude those fees from liquidity +export const collectLpFees = async () => { + if (targetK.isZero()) { + throw new Error( + "Target K is zero. Please set initial base and quote reserves before running the script.", + ); + } + + const daoAccount = await futarchy.fetchDao(dao); + + // We call the collect fees instruction from Metadao DAO's multisig account + // It's the only one that can call the collect fees instruction + const metaDaoSquadsMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + anchor.getProvider().connection, + metadaoSquadsMultisig, + ); + + // We want to receive the fees in the Metadao DAO's multisig vault + const feeRecipientBaseTokenAccount = getAssociatedTokenAddressSync( + daoAccount.baseMint, + metadaoSquadsMultisigVault, + true, + ); + + const feeRecipientQuoteTokenAccount = getAssociatedTokenAddressSync( + daoAccount.quoteMint, + metadaoSquadsMultisigVault, + true, + ); + + // Prepare transaction message + const collectLpFeesIx = await futarchy + .collectLpFeesIx({ + dao, + baseMint: daoAccount.baseMint, + quoteMint: daoAccount.quoteMint, + baseTokenAccount: feeRecipientBaseTokenAccount, + quoteTokenAccount: feeRecipientQuoteTokenAccount, + targetK: targetK, + }) + .instruction(); + + const transactionMessage = new TransactionMessage({ + instructions: [collectLpFeesIx], + payerKey: metadaoSquadsMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + }); + + // Create vault transaction + const vaultTxCreateSignature = await multisig.rpc.vaultTransactionCreate({ + connection: anchor.getProvider().connection, + creator: payer.publicKey, + feePayer: payer.publicKey, + ephemeralSigners: 0, + multisigPda: metadaoSquadsMultisig, + transactionIndex: + BigInt(metaDaoSquadsMultisigAccount.transactionIndex.toString()) + 1n, + vaultIndex: 0, + transactionMessage, + }); + + console.log( + "Vault collect fees transaction create signature:", + vaultTxCreateSignature, + ); + console.log("Go ahead and execute the transaction through Squads."); +}; + +collectLpFees().catch(console.error); diff --git a/sdk/src/v0.6/types/futarchy.ts b/sdk/src/v0.6/types/futarchy.ts index 2eb3290a..30b43eb4 100644 --- a/sdk/src/v0.6/types/futarchy.ts +++ b/sdk/src/v0.6/types/futarchy.ts @@ -945,6 +945,64 @@ export type Futarchy = { ]; args: []; }, + { + name: "collectLpFees"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "baseTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "quoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "CollectLpFeesArgs"; + }; + }, + ]; + }, { name: "executeSpendingLimitChange"; accounts: [ @@ -1342,6 +1400,18 @@ export type Futarchy = { ]; }; }, + { + name: "CollectLpFeesArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "targetK"; + type: "u128"; + }, + ]; + }; + }, { name: "ConditionalSwapParams"; type: { @@ -2916,6 +2986,11 @@ export type Futarchy = { name: "InvalidTeamSponsoredPassThreshold"; msg: "Team sponsored pass threshold must be between -10% and 10%"; }, + { + code: 6034; + name: "InvalidTargetK"; + msg: "Target K must be greater than the current K"; + }, ]; }; @@ -3866,6 +3941,64 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "collectLpFees", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "baseTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "quoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "CollectLpFeesArgs", + }, + }, + ], + }, { name: "executeSpendingLimitChange", accounts: [ @@ -4263,6 +4396,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "CollectLpFeesArgs", + type: { + kind: "struct", + fields: [ + { + name: "targetK", + type: "u128", + }, + ], + }, + }, { name: "ConditionalSwapParams", type: { @@ -5837,5 +5982,10 @@ export const IDL: Futarchy = { name: "InvalidTeamSponsoredPassThreshold", msg: "Team sponsored pass threshold must be between -10% and 10%", }, + { + code: 6034, + name: "InvalidTargetK", + msg: "Target K must be greater than the current K", + }, ], }; diff --git a/sdk/src/v0.7/FutarchyClient.ts b/sdk/src/v0.7/FutarchyClient.ts index f2f7a492..39702b61 100644 --- a/sdk/src/v0.7/FutarchyClient.ts +++ b/sdk/src/v0.7/FutarchyClient.ts @@ -987,4 +987,35 @@ export class FutarchyClient { teamAddress, }); } + + collectLpFeesIx({ + dao, + baseMint, + quoteMint, + baseTokenAccount = getAssociatedTokenAddressSync( + baseMint, + this.provider.publicKey, + ), + quoteTokenAccount = getAssociatedTokenAddressSync( + quoteMint, + this.provider.publicKey, + ), + targetK, + }: { + dao: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + baseTokenAccount?: PublicKey; + quoteTokenAccount?: PublicKey; + targetK: BN; + }) { + return this.autocrat.methods.collectLpFees({ targetK }).accounts({ + dao, + admin: this.provider.publicKey, + ammBaseVault: getAssociatedTokenAddressSync(baseMint, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(quoteMint, dao, true), + baseTokenAccount, + quoteTokenAccount, + }); + } } diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 2eb3290a..30b43eb4 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -945,6 +945,64 @@ export type Futarchy = { ]; args: []; }, + { + name: "collectLpFees"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "baseTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "quoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "CollectLpFeesArgs"; + }; + }, + ]; + }, { name: "executeSpendingLimitChange"; accounts: [ @@ -1342,6 +1400,18 @@ export type Futarchy = { ]; }; }, + { + name: "CollectLpFeesArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "targetK"; + type: "u128"; + }, + ]; + }; + }, { name: "ConditionalSwapParams"; type: { @@ -2916,6 +2986,11 @@ export type Futarchy = { name: "InvalidTeamSponsoredPassThreshold"; msg: "Team sponsored pass threshold must be between -10% and 10%"; }, + { + code: 6034; + name: "InvalidTargetK"; + msg: "Target K must be greater than the current K"; + }, ]; }; @@ -3866,6 +3941,64 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "collectLpFees", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "baseTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "quoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "CollectLpFeesArgs", + }, + }, + ], + }, { name: "executeSpendingLimitChange", accounts: [ @@ -4263,6 +4396,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "CollectLpFeesArgs", + type: { + kind: "struct", + fields: [ + { + name: "targetK", + type: "u128", + }, + ], + }, + }, { name: "ConditionalSwapParams", type: { @@ -5837,5 +5982,10 @@ export const IDL: Futarchy = { name: "InvalidTeamSponsoredPassThreshold", msg: "Team sponsored pass threshold must be between -10% and 10%", }, + { + code: 6034, + name: "InvalidTargetK", + msg: "Target K must be greater than the current K", + }, ], }; diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index c90076f2..37a6ca5c 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -5,6 +5,7 @@ import initializeProposal from "./unit/initializeProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import collectFees from "./unit/collectFees.test.js"; +import collectLpFees from "./unit/collectLpFees.test.js"; import conditionalSwap from "./unit/conditionalSwap.test.js"; import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.js"; @@ -15,9 +16,9 @@ export default function suite() { describe("#finalize_proposal", finalizeProposal); describe("#collect_fees", collectFees); + describe("#collect_lp_fees", collectLpFees); describe("#conditional_swap", conditionalSwap); describe("#execute_spending_limit_change", executeSpendingLimitChange); - // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); describe("futarchy amm", futarchyAmm); diff --git a/tests/futarchy/unit/collectFees.test.ts b/tests/futarchy/unit/collectFees.test.ts index 492d26e2..13d8c16c 100644 --- a/tests/futarchy/unit/collectFees.test.ts +++ b/tests/futarchy/unit/collectFees.test.ts @@ -80,8 +80,8 @@ export default function suite() { const quoteFeesCollected = postQuoteBalance - preQuoteBalance; const baseFeesCollected = postBaseBalance - preBaseBalance; - assert.equal(quoteFeesCollected, 250_000n); - assert.equal(baseFeesCollected, 5_000n); + assert.equal(quoteFeesCollected, 500_000n); + assert.equal(baseFeesCollected, 10_000n); }); it("fails when the pool is not in the spot state", async function () { diff --git a/tests/futarchy/unit/collectLpFees.test.ts b/tests/futarchy/unit/collectLpFees.test.ts new file mode 100644 index 00000000..fe0a2117 --- /dev/null +++ b/tests/futarchy/unit/collectLpFees.test.ts @@ -0,0 +1,200 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionMessage, +} from "@solana/web3.js"; +import { expectError, setupBasicDao } from "../../utils.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/futarchy/v0.6"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.mintTo(USDC, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 6); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + it("collects LP fees by reducing liquidity to a target K", async function () { + // This is meant to be a one-off operation that reduces liquidity to a target K (the inital pool's liquidity) and collect it as "fees" + // We're using this because we didn't track LP fee collection in the pool state, nor did we exclude those fees from liquidity + + let daoAccount = await this.futarchy.fetchDao(dao); + let seqNum = daoAccount.seqNum; + + const preQuoteBalance = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + const preBaseBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + // We can calculate K and know the "fees collected" this way because we know the price is 1:1 + // Usually, we'd want to calculate K based on the target liquidity, which is usually what K was when the pool was initialized + const targetQuoteReserves = new BN(90_000 * 10 ** 6); + const targetBaseReserves = new BN(90_000 * 10 ** 6); + + const targetK = targetBaseReserves.mul(targetQuoteReserves); + + await this.futarchy + .collectLpFeesIx({ + dao, + baseMint: META, + quoteMint: USDC, + targetK, + }) + .rpc(); + + const postQuoteBalance = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + const postBaseBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + const quoteFeesCollected = postQuoteBalance - preQuoteBalance; + const baseFeesCollected = postBaseBalance - preBaseBalance; + + assert.equal(quoteFeesCollected, 10_000n * 10n ** 6n); + assert.equal(baseFeesCollected, 10_000n * 10n ** 6n); + + daoAccount = await this.futarchy.fetchDao(dao); + assert.equal( + daoAccount.seqNum.toString(), + seqNum.add(new BN(1)).toString(), + ); + assert.equal( + daoAccount.amm.state.spot.spot.baseReserves.toString(), + targetBaseReserves.toString(), + ); + assert.equal( + daoAccount.amm.state.spot.spot.quoteReserves.toString(), + targetQuoteReserves.toString(), + ); + }); + + it("collects LP fees by reducing liquidity to a target K whose square root is not an exact integer", async function () { + // In cases where target K is not an exact square, we want to round up the target reserves + // We want to favor the protocol in this case instead of taking the atom to ourselves + + let daoAccount = await this.futarchy.fetchDao(dao); + let seqNum = daoAccount.seqNum; + + const preQuoteBalance = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + const preBaseBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + // We can calculate K and know the "fees collected" this way because we know the price is 1:1 + // Usually, we'd want to calculate K based on the target liquidity, which is usually what K was when the pool was initialized + const targetQuoteReserves = new BN(90_000 * 10 ** 6); + const targetBaseReserves = new BN(90_000 * 10 ** 6); + + // Slighly lower target K to ensure we round up to the correct amount + const targetK = targetBaseReserves.mul(targetQuoteReserves).sub(new BN(1)); + + await this.futarchy + .collectLpFeesIx({ + dao, + baseMint: META, + quoteMint: USDC, + targetK, + }) + .rpc(); + + const postQuoteBalance = await this.getTokenBalance( + USDC, + this.payer.publicKey, + ); + const postBaseBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + const quoteFeesCollected = postQuoteBalance - preQuoteBalance; + const baseFeesCollected = postBaseBalance - preBaseBalance; + + assert.equal(quoteFeesCollected, 10_000n * 10n ** 6n); + assert.equal(baseFeesCollected, 10_000n * 10n ** 6n); + + daoAccount = await this.futarchy.fetchDao(dao); + assert.equal( + daoAccount.seqNum.toString(), + seqNum.add(new BN(1)).toString(), + ); + assert.equal( + daoAccount.amm.state.spot.spot.baseReserves.toString(), + targetBaseReserves.toString(), + ); + assert.equal( + daoAccount.amm.state.spot.spot.quoteReserves.toString(), + targetQuoteReserves.toString(), + ); + }); + + it("fails when the pool is not in the spot state", async function () { + await this.initializeAndLaunchProposal({ + dao, + instructions: [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("hello, world"), + }, + ], + }); + + const callbacks = expectError( + "PoolNotInSpotState", + "fee collect worked on a futarchy pool", + ); + + await this.futarchy + .collectLpFeesIx({ + dao, + baseMint: META, + quoteMint: USDC, + targetK: new BN(100_000 * 10 ** 6).mul(new BN(100_000 * 10 ** 6)), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when target K is greater than current K", async function () { + const callbacks = expectError( + "InvalidTargetK", + "Target K must be greater than the current K", + ); + + await this.futarchy + .collectLpFeesIx({ + dao, + baseMint: META, + quoteMint: USDC, + targetK: new BN(100_000 * 10 ** 6) + .mul(new BN(100_000 * 10 ** 6)) + .add(new BN(1)), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/futarchy/unit/conditionalSwap.test.ts b/tests/futarchy/unit/conditionalSwap.test.ts index 8159bd32..05c6eb8d 100644 --- a/tests/futarchy/unit/conditionalSwap.test.ts +++ b/tests/futarchy/unit/conditionalSwap.test.ts @@ -85,7 +85,7 @@ export default function suite() { ); assert.equal( postAmmState.state.futarchy.pass.quoteProtocolFeeBalance.toString(), - "25000", + "50000", ); // 2.5 cent fee on $100 swap assert.equal( postAmmState.state.futarchy.pass.baseProtocolFeeBalance.toString(), @@ -102,7 +102,7 @@ export default function suite() { // I ran the math by hand assuming 50k reserves on each side and got these results assert.equal(postPassQuoteBalance, 40_000_000n); - assert.equal(postPassBaseBalance, 9_948_082n); + assert.equal(postPassBaseBalance, 9_948_020n); // now we do a swap that should trigger arbitrage @@ -128,7 +128,7 @@ export default function suite() { ); assert.equal(postFailQuoteBalance, 40_000_000n); - assert.equal(postFailBaseBalance, 9_948_082n + 991n); // extra profit + assert.equal(postFailBaseBalance, 9_948_020n + 988n); // extra profit }); it("fails when user has insufficient balance", async function () {