diff --git a/programs/bid_wall/src/error.rs b/programs/bid_wall/src/error.rs index 0e19747c..e6a99e3c 100644 --- a/programs/bid_wall/src/error.rs +++ b/programs/bid_wall/src/error.rs @@ -16,4 +16,6 @@ pub enum BidWallError { InvalidInputAmount, #[msg("Invalid crank address")] InvalidCrankAddress, + #[msg("Invalid fee decay duration. Must be greater than 0")] + InvalidFeeDecayDuration, } diff --git a/programs/bid_wall/src/events.rs b/programs/bid_wall/src/events.rs index fd4ee75c..3ad24767 100644 --- a/programs/bid_wall/src/events.rs +++ b/programs/bid_wall/src/events.rs @@ -30,6 +30,9 @@ pub struct BidWallInitializedEvent { pub base_mint: Pubkey, pub fee_recipient: Pubkey, pub duration_seconds: u32, + pub fee_decay_duration_seconds: u32, + pub max_fee_bps: u16, + pub min_fee_bps: u16, pub pda_bump: u8, } diff --git a/programs/bid_wall/src/instructions/initialize_bid_wall.rs b/programs/bid_wall/src/instructions/initialize_bid_wall.rs index 9f49b01e..6056cb2c 100644 --- a/programs/bid_wall/src/instructions/initialize_bid_wall.rs +++ b/programs/bid_wall/src/instructions/initialize_bid_wall.rs @@ -5,6 +5,7 @@ use anchor_spl::{ }; use crate::{ + error::BidWallError, events::{BidWallInitializedEvent, CommonFields}, state::BidWall, usdc_mint, @@ -16,6 +17,9 @@ pub struct InitializeBidWallArgs { pub nonce: u64, pub initial_amm_quote_reserves: u64, pub duration_seconds: u32, + pub fee_decay_duration_seconds: u32, + pub max_fee_bps: u16, + pub min_fee_bps: u16, } #[event_cpi] @@ -63,7 +67,14 @@ pub struct InitializeBidWall<'info> { } impl InitializeBidWall<'_> { - pub fn validate(&self, _args: &InitializeBidWallArgs) -> Result<()> { + pub fn validate(&self, args: &InitializeBidWallArgs) -> Result<()> { + // Prevents division by zero in the sell_tokens instruction + require_gt!( + args.fee_decay_duration_seconds, + 0, + BidWallError::InvalidFeeDecayDuration + ); + Ok(()) } @@ -101,6 +112,9 @@ impl InitializeBidWall<'_> { base_mint: ctx.accounts.base_mint.key(), fee_recipient: ctx.accounts.fee_recipient.key(), duration_seconds: args.duration_seconds, + fee_decay_duration_seconds: args.fee_decay_duration_seconds, + max_fee_bps: args.max_fee_bps, + min_fee_bps: args.min_fee_bps, pda_bump: ctx.bumps.bid_wall, }); @@ -116,6 +130,9 @@ impl InitializeBidWall<'_> { base_mint: ctx.accounts.base_mint.key(), fee_recipient: ctx.accounts.fee_recipient.key(), duration_seconds: args.duration_seconds, + fee_decay_duration_seconds: ctx.accounts.bid_wall.fee_decay_duration_seconds, + max_fee_bps: ctx.accounts.bid_wall.max_fee_bps, + min_fee_bps: ctx.accounts.bid_wall.min_fee_bps, pda_bump: ctx.bumps.bid_wall, }); diff --git a/programs/bid_wall/src/instructions/sell_tokens.rs b/programs/bid_wall/src/instructions/sell_tokens.rs index 1081297c..4a9f6018 100644 --- a/programs/bid_wall/src/instructions/sell_tokens.rs +++ b/programs/bid_wall/src/instructions/sell_tokens.rs @@ -2,7 +2,7 @@ use crate::{ error::BidWallError, events::{BidWallTokensSoldEvent, CommonFields}, state::BidWall, - usdc_mint, FEE_BPS, TOKENS_TO_PARTICIPANTS, + usdc_mint, TOKENS_TO_PARTICIPANTS, }; use anchor_lang::prelude::*; @@ -87,6 +87,8 @@ impl SellTokens<'_> { pub fn handle(ctx: Context, args: SellTokensArgs) -> Result<()> { let SellTokensArgs { amount_in } = args; + let clock = Clock::get()?; + // We calculate the total NAV as as sum of: // - The initial quote reserves of the Futarchy AMM // - The quote tokens in the DAO treasury (which can be spent by the DAO) @@ -109,8 +111,22 @@ impl SellTokens<'_> { BidWallError::InsufficientQuoteReserves ); + let bid_wall_age_seconds = clock.unix_timestamp - ctx.accounts.bid_wall.created_timestamp; + + let min_fee_bps = ctx.accounts.bid_wall.min_fee_bps as u128; + let max_fee_bps = ctx.accounts.bid_wall.max_fee_bps as u128; + + // Calculate the fee in basis points based on the bid wall age. + // Formula is simple linear decay based on the fee decay duration. + // max_fee - (max_fee - min_fee) * bid_wall_age / fee_decay_duration_seconds + let fee_bps = min_fee_bps.max( + max_fee_bps + - (max_fee_bps - min_fee_bps) * bid_wall_age_seconds as u128 + / ctx.accounts.bid_wall.fee_decay_duration_seconds as u128, + ); + let amount_out_after_fee = - ((10_000_u128 - FEE_BPS as u128) * amount_out_before_fee as u128 / 10_000_u128) as u64; + ((10_000_u128 - fee_bps) * amount_out_before_fee as u128 / 10_000_u128) as u64; let fee = amount_out_before_fee - amount_out_after_fee; @@ -157,7 +173,7 @@ impl SellTokens<'_> { ctx.accounts.bid_wall.seq_num += 1; emit_cpi!(BidWallTokensSoldEvent { - common: CommonFields::new(&Clock::get()?, ctx.accounts.bid_wall.seq_num), + common: CommonFields::new(&clock, ctx.accounts.bid_wall.seq_num), bid_wall: ctx.accounts.bid_wall.key(), amount_in: amount_in, amount_out: amount_out_after_fee, diff --git a/programs/bid_wall/src/lib.rs b/programs/bid_wall/src/lib.rs index 29a6a874..73e4db87 100644 --- a/programs/bid_wall/src/lib.rs +++ b/programs/bid_wall/src/lib.rs @@ -34,8 +34,6 @@ pub mod usdc_mint { declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); } -pub const FEE_BPS: u16 = 100; - pub const TOKEN_SCALE: u64 = 1_000_000; /// 10M tokens with 6 decimals - the exact amount of tokens that end up in floating supply at launch pub const TOKENS_TO_PARTICIPANTS: u64 = 10_000_000 * TOKEN_SCALE; diff --git a/programs/bid_wall/src/state/bid_wall.rs b/programs/bid_wall/src/state/bid_wall.rs index ecb4bc42..d79add2d 100644 --- a/programs/bid_wall/src/state/bid_wall.rs +++ b/programs/bid_wall/src/state/bid_wall.rs @@ -31,6 +31,12 @@ pub struct BidWall { pub fee_recipient: Pubkey, /// The minimum duration in seconds before the bid wall can be closed. pub duration_seconds: u32, + /// The duration in seconds over which the fee linearly decays from the max fee to the min fee. + pub fee_decay_duration_seconds: u32, + /// The maximum fee in basis points. + pub max_fee_bps: u16, + /// The minimum fee in basis points. + pub min_fee_bps: u16, /// The PDA bump. pub pda_bump: u8, } diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index a9b72d8d..16c99f7f 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -29,6 +29,8 @@ use futarchy::{InitialSpendingLimit, InitializeDaoParams, ProvideLiquidityParams use damm_v2_cpi::program::DammV2Cpi; +pub const PASS_THRESHOLD_BPS: u16 = 300; // 3% + /// Static accounts for completing a launch, used to reduce code duplication /// and conserve stack space. #[derive(Accounts)] @@ -425,7 +427,7 @@ impl CompleteLaunch<'_> { // We're providing liquidity, so that can be used for proposals min_quote_futarchic_liquidity: 0, min_base_futarchic_liquidity: 0, - pass_threshold_bps: 300, + pass_threshold_bps: PASS_THRESHOLD_BPS, base_to_stake: TOKENS_TO_PARTICIPANTS / 20, seconds_per_proposal: 3 * 24 * 60 * 60, twap_start_delay_seconds: 24 * 60 * 60, @@ -478,6 +480,9 @@ impl CompleteLaunch<'_> { nonce: 0, initial_amm_quote_reserves: usdc_to_lp, duration_seconds: 3 * 30 * 24 * 60 * 60, // 3 months + fee_decay_duration_seconds: 14 * 24 * 60 * 60, // 14 days + max_fee_bps: PASS_THRESHOLD_BPS + 200, // 2% more than the pass threshold = 5% + min_fee_bps: PASS_THRESHOLD_BPS, // Set min_fee to be the same as the pass threshold }, ) } diff --git a/sdk/src/v0.7/BidWallClient.ts b/sdk/src/v0.7/BidWallClient.ts index 347fd218..4bc60515 100644 --- a/sdk/src/v0.7/BidWallClient.ts +++ b/sdk/src/v0.7/BidWallClient.ts @@ -56,6 +56,9 @@ export class BidWallClient { initializeBidWallIx({ amount, durationSeconds, + feeDecayDurationSeconds, + maxFeeBps, + minFeeBps, initialAmmQuoteReserves, daoTreasury, authority, @@ -68,6 +71,9 @@ export class BidWallClient { }: { amount: number; durationSeconds: number; + feeDecayDurationSeconds: number; + maxFeeBps: number; + minFeeBps: number; initialAmmQuoteReserves: number; daoTreasury: PublicKey; creator?: PublicKey; @@ -98,6 +104,9 @@ export class BidWallClient { nonce, durationSeconds, initialAmmQuoteReserves: new BN(initialAmmQuoteReserves), + feeDecayDurationSeconds, + maxFeeBps, + minFeeBps, }) .accounts({ bidWall, diff --git a/sdk/src/v0.7/types/bid_wall.ts b/sdk/src/v0.7/types/bid_wall.ts index 9f1f4989..a3271fd5 100644 --- a/sdk/src/v0.7/types/bid_wall.ts +++ b/sdk/src/v0.7/types/bid_wall.ts @@ -439,6 +439,23 @@ export type BidWall = { ]; type: "u32"; }, + { + name: "feeDecayDurationSeconds"; + docs: [ + "The duration in seconds over which the fee linearly decays from the max fee to the min fee.", + ]; + type: "u32"; + }, + { + name: "maxFeeBps"; + docs: ["The maximum fee in basis points."]; + type: "u16"; + }, + { + name: "minFeeBps"; + docs: ["The minimum fee in basis points."]; + type: "u16"; + }, { name: "pdaBump"; docs: ["The PDA bump."]; @@ -490,6 +507,18 @@ export type BidWall = { name: "durationSeconds"; type: "u32"; }, + { + name: "feeDecayDurationSeconds"; + type: "u32"; + }, + { + name: "maxFeeBps"; + type: "u16"; + }, + { + name: "minFeeBps"; + type: "u16"; + }, ]; }; }, @@ -567,6 +596,21 @@ export type BidWall = { type: "u32"; index: false; }, + { + name: "feeDecayDurationSeconds"; + type: "u32"; + index: false; + }, + { + name: "maxFeeBps"; + type: "u16"; + index: false; + }, + { + name: "minFeeBps"; + type: "u16"; + index: false; + }, { name: "pdaBump"; type: "u8"; @@ -719,6 +763,11 @@ export type BidWall = { name: "InvalidCrankAddress"; msg: "Invalid crank address"; }, + { + code: 6007; + name: "InvalidFeeDecayDuration"; + msg: "Invalid fee decay duration. Must be greater than 0"; + }, ]; }; @@ -1163,6 +1212,23 @@ export const IDL: BidWall = { ], type: "u32", }, + { + name: "feeDecayDurationSeconds", + docs: [ + "The duration in seconds over which the fee linearly decays from the max fee to the min fee.", + ], + type: "u32", + }, + { + name: "maxFeeBps", + docs: ["The maximum fee in basis points."], + type: "u16", + }, + { + name: "minFeeBps", + docs: ["The minimum fee in basis points."], + type: "u16", + }, { name: "pdaBump", docs: ["The PDA bump."], @@ -1214,6 +1280,18 @@ export const IDL: BidWall = { name: "durationSeconds", type: "u32", }, + { + name: "feeDecayDurationSeconds", + type: "u32", + }, + { + name: "maxFeeBps", + type: "u16", + }, + { + name: "minFeeBps", + type: "u16", + }, ], }, }, @@ -1291,6 +1369,21 @@ export const IDL: BidWall = { type: "u32", index: false, }, + { + name: "feeDecayDurationSeconds", + type: "u32", + index: false, + }, + { + name: "maxFeeBps", + type: "u16", + index: false, + }, + { + name: "minFeeBps", + type: "u16", + index: false, + }, { name: "pdaBump", type: "u8", @@ -1443,5 +1536,10 @@ export const IDL: BidWall = { name: "InvalidCrankAddress", msg: "Invalid crank address", }, + { + code: 6007, + name: "InvalidFeeDecayDuration", + msg: "Invalid fee decay duration. Must be greater than 0", + }, ], }; diff --git a/tests/bidWall/unit/cancelBidWall.test.ts b/tests/bidWall/unit/cancelBidWall.test.ts index 49e12b4d..0ad36ed7 100644 --- a/tests/bidWall/unit/cancelBidWall.test.ts +++ b/tests/bidWall/unit/cancelBidWall.test.ts @@ -149,6 +149,9 @@ export default function suite() { .initializeBidWallIx({ amount: 100_000_000000, durationSeconds, + feeDecayDurationSeconds: 2, + maxFeeBps: 200, + minFeeBps: 100, initialAmmQuoteReserves: ammQuoteVaultReserves.toNumber(), authority: this.payer.publicKey, creator: this.payer.publicKey, @@ -229,10 +232,10 @@ export default function suite() { authorityUsdcBalanceAfter, authorityUsdcBalanceBefore + 50_000_000000n, ); - // Fee recipient received 500 USDC in fees + // Fee recipient received 1000 USDC in fees assert.equal( feeRecipientUsdcBalanceAfter, - feeRecipientUsdcBalanceBefore + 500_000000n, + feeRecipientUsdcBalanceBefore + 1_000_000000n, ); }); diff --git a/tests/bidWall/unit/closeBidWall.test.ts b/tests/bidWall/unit/closeBidWall.test.ts index 3507fffe..d3c0f4e3 100644 --- a/tests/bidWall/unit/closeBidWall.test.ts +++ b/tests/bidWall/unit/closeBidWall.test.ts @@ -150,6 +150,9 @@ export default function suite() { .initializeBidWallIx({ amount: 100_000_000000, durationSeconds, + feeDecayDurationSeconds: 2, + maxFeeBps: 200, + minFeeBps: 100, initialAmmQuoteReserves: ammQuoteVaultReserves.toNumber(), authority: this.payer.publicKey, creator: this.payer.publicKey, @@ -232,10 +235,10 @@ export default function suite() { authorityUsdcBalanceAfter, authorityUsdcBalanceBefore + 50_000_000000n, ); - // Fee recipient received 500 USDC in fees + // Fee recipient received 1000 USDC in fees assert.equal( feeRecipientUsdcBalanceAfter, - feeRecipientUsdcBalanceBefore + 500_000000n, + feeRecipientUsdcBalanceBefore + 1_000_000000n, ); }); @@ -295,10 +298,10 @@ export default function suite() { assert.equal(bidWallUsdcBalanceAfter, 0n); // Authority received no USDC, as none is left over from the bid wall assert.equal(authorityUsdcBalanceAfter, authorityUsdcBalanceBefore); - // Fee recipient received 1000 USDC in fees + // Fee recipient received 2000 USDC in fees assert.equal( feeRecipientUsdcBalanceAfter, - feeRecipientUsdcBalanceBefore + 1_000_000000n, + feeRecipientUsdcBalanceBefore + 2_000_000000n, ); }); @@ -364,10 +367,10 @@ export default function suite() { authorityUsdcBalanceAfter, authorityUsdcBalanceBefore + 1_000_000000n, ); - // Fee recipient received 1000 USDC in fees + // Fee recipient received 2000 USDC in fees assert.equal( feeRecipientUsdcBalanceAfter, - feeRecipientUsdcBalanceBefore + 1_000_000000n, + feeRecipientUsdcBalanceBefore + 2_000_000000n, ); }); diff --git a/tests/bidWall/unit/collectFees.test.ts b/tests/bidWall/unit/collectFees.test.ts index 6b12d2f8..a9daca07 100644 --- a/tests/bidWall/unit/collectFees.test.ts +++ b/tests/bidWall/unit/collectFees.test.ts @@ -173,6 +173,9 @@ export default function suite() { .initializeBidWallIx({ amount: 100_000_000000, durationSeconds, + feeDecayDurationSeconds: 2, + maxFeeBps: 200, + minFeeBps: 100, initialAmmQuoteReserves: ammQuoteVaultReserves.toNumber(), authority: this.payer.publicKey, creator: this.payer.publicKey, @@ -234,7 +237,7 @@ export default function suite() { METADAO_MULTISIG_VAULT, ); - const expectedFeesCollected = 1_000_000000n; + const expectedFeesCollected = 2_000_000000n; assert.equal( bidWallUsdcBalanceAfter, diff --git a/tests/bidWall/unit/initializeBidWall.test.ts b/tests/bidWall/unit/initializeBidWall.test.ts index 56b1547e..21e20892 100644 --- a/tests/bidWall/unit/initializeBidWall.test.ts +++ b/tests/bidWall/unit/initializeBidWall.test.ts @@ -153,6 +153,9 @@ export default function suite() { .initializeBidWallIx({ amount: fundAmount.sub(minRaiseAmount).toNumber(), durationSeconds, + feeDecayDurationSeconds: 1, + maxFeeBps: 500, + minFeeBps: 300, initialAmmQuoteReserves: ammQuoteVaultReserves.toNumber(), authority: this.payer.publicKey, creator: this.payer.publicKey, @@ -197,6 +200,9 @@ export default function suite() { ); assert.equal(bidWallAccount.baseMint.toBase58(), META.toBase58()); assert.equal(bidWallAccount.durationSeconds, durationSeconds); + assert.equal(bidWallAccount.feeDecayDurationSeconds, 1); + assert.equal(bidWallAccount.maxFeeBps, 500); + assert.equal(bidWallAccount.minFeeBps, 300); assert.equal(bidWallAccount.pdaBump, bump); }); } diff --git a/tests/bidWall/unit/sellTokens.test.ts b/tests/bidWall/unit/sellTokens.test.ts index 3c568e69..ad03ffaa 100644 --- a/tests/bidWall/unit/sellTokens.test.ts +++ b/tests/bidWall/unit/sellTokens.test.ts @@ -150,6 +150,9 @@ export default function suite() { .initializeBidWallIx({ amount: 100_000_000000, durationSeconds, + feeDecayDurationSeconds: 2, + maxFeeBps: 200, + minFeeBps: 100, initialAmmQuoteReserves: ammQuoteVaultReserves.toNumber(), authority: this.payer.publicKey, creator: this.payer.publicKey, @@ -216,15 +219,15 @@ export default function suite() { this.payer.publicKey, ); - // Seller received 99_000_000000 USDC (99K), which is 100_000_000000 - 1_000_000000 (fee) - assert.equal(usdcBalanceAfter, usdcBalanceBefore + 99_000_000000n); + // Seller received 98_000_000000 USDC (98K), which is 100_000_000000 - 2_000_000000 (fee) + assert.equal(usdcBalanceAfter, usdcBalanceBefore + 98_000_000000n); assert.equal(metaBalanceAfter, 5_000_000_000000n); - // Bid wall collected 1_000_000000 USDC (1K) in fees + // Bid wall collected 2_000_000000 USDC (2K) in fees const bidWallAccount = await bidWallClient.fetchBidWall(bidWall); assert.equal( bidWallAccount.feesCollected.toString(), - new BN(1_000_000000).toString(), + new BN(2_000_000000).toString(), ); }); @@ -273,24 +276,24 @@ export default function suite() { this.payer.publicKey, ); - // Seller received 39_600_000000 USDC (39.6K), which is 40_000_000000 - 400_000000 (fee) - assert.equal(usdcBalanceAfterFirstSell, usdcBalanceBefore + 39_600_000000n); + // Seller received 39_200_000000 USDC (39.2K), which is 40_000_000000 - 800_000000 (fee) + assert.equal(usdcBalanceAfterFirstSell, usdcBalanceBefore + 39_200_000000n); assert.equal(metaBalanceAfterFirstSell, 8_000_000_000000n); - // Bid wall collected 400_000000 USDC (0.4K) in fees + // Bid wall collected 800_000000 USDC (0.8K) in fees let bidWallAccount = await bidWallClient.fetchBidWall(bidWall); assert.equal( bidWallAccount.feesCollected.toString(), - new BN(400_000000).toString(), + new BN(800_000000).toString(), ); const bidWallUsdcBalanceAfterFirstSell = await this.getTokenBalance( MAINNET_USDC, bidWall, ); - // Bid wall should have 60.4K USDC after the first sell - // That is 100k (initial bid wall balance) reduced by 40k (bought) minus the fee (400) - assert.equal(bidWallUsdcBalanceAfterFirstSell, 60_400_000000n); + // Bid wall should have 60.8K USDC after the first sell + // That is 100k (initial bid wall balance) reduced by 40k (bought) minus the fee (800) + assert.equal(bidWallUsdcBalanceAfterFirstSell, 60_800_000000n); const daoTreasuryQuoteTokenAccountAddress = getAssociatedTokenAddressSync( MAINNET_USDC, @@ -335,7 +338,7 @@ export default function suite() { // Active supply = 8_000_000_000000 META (8M) // Price = 100_000_000000 / 8_000_000_000000 = ~0.0125 USDC per META // Assume user sells 2M META - // User will receive 24_750 USDC, which is 25_000 USDC (2M * 0.0125) minus 1% fee (250 USDC), rounded down. + // User will receive 24_500 USDC, which is 25_000 USDC (2M * 0.0125) minus 2% fee (500 USDC), rounded down. await bidWallClient .sellTokensIx({ @@ -361,24 +364,24 @@ export default function suite() { ); assert.equal( usdcBalanceAfterSecondSell, - usdcBalanceAfterFirstSell + 24_750_000000n, + usdcBalanceAfterFirstSell + 24_500_000000n, ); assert.equal(metaBalanceAfterSecondSell, 6_000_000_000000n); - // Bid wall collected an additional 250_000000 USDC (250) in fees, totalling 650 USDC + // Bid wall collected an additional 500_000000 USDC (500) in fees, totalling 1,300 USDC bidWallAccount = await bidWallClient.fetchBidWall(bidWall); assert.equal( bidWallAccount.feesCollected.toString(), - new BN(650_000000).toString(), + new BN(1_300_000000).toString(), ); const bidWallUsdcBalanceAfterSecondSell = await this.getTokenBalance( MAINNET_USDC, bidWall, ); - // Bid wall should have 35_650 USDC after the second sell - // That is 60_400 (after first sell) reduced by 25_000 (bought) minus the fee (250) - assert.equal(bidWallUsdcBalanceAfterSecondSell, 35_650_000_000n); + // Bid wall should have 36_300 USDC after the second sell + // That is 60_800 (after first sell) reduced by 25_000 (bought) minus the fee (1,300) + assert.equal(bidWallUsdcBalanceAfterSecondSell, 36_300_000_000n); // Confirm that a third sell would get the same price per token as the second sell await bidWallClient @@ -405,24 +408,24 @@ export default function suite() { ); assert.equal( usdcBalanceAfterThirdSell, - usdcBalanceAfterSecondSell + 24_750_000000n, + usdcBalanceAfterSecondSell + 24_500_000000n, ); assert.equal(metaBalanceAfterThirdSell, 4_000_000_000000n); - // Bid wall collected an additional 250 USDC in fees, totalling 900 USDC + // Bid wall collected an additional 500 USDC in fees, totalling 1800 USDC bidWallAccount = await bidWallClient.fetchBidWall(bidWall); assert.equal( bidWallAccount.feesCollected.toString(), - new BN(900_000000).toString(), + new BN(1_800_000000).toString(), ); const bidWallUsdcBalanceAfterThirdSell = await this.getTokenBalance( MAINNET_USDC, bidWall, ); - // Bid wall should have 10_900 USDC after the third sell - // That is 35_650 (after second sell) reduced by 25_000 (bought) minus the fee (250) - assert.equal(bidWallUsdcBalanceAfterThirdSell, 10_900_000000n); + // Bid wall should have 11_800 USDC after the third sell + // That is 36_300 (after second sell) reduced by 25_000 (bought) minus the fee (500) + assert.equal(bidWallUsdcBalanceAfterThirdSell, 11_800_000000n); }); it("sending quote tokens to a bid wall beyond what was originally allocated doesn't change the NAV per token", async function () { @@ -450,10 +453,163 @@ export default function suite() { this.payer.publicKey, ); - // User received 99_000_000000 USDC (99K), which is 100_000_000000 - 1_000_000000 (fee) + // User received 98_000_000000 USDC (98K), which is 100_000_000000 - 2_000_000000 (fee) // This is the same case as in the "successfully sells tokens into a bid wall" test // Bid wall remains unaffected by the transfer of USDC into it - assert.equal(usdcBalanceAfter, usdcBalanceBefore + 99_000_000000n); + assert.equal(usdcBalanceAfter, usdcBalanceBefore + 98_000_000000n); + }); + + it("fee decays over time", async function () { + const usdcBalanceBefore = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + const metaBalanceBefore = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + // User should have gotten 10M META from the launch + assert.equal(metaBalanceBefore, 10_000_000_000000n); + + // As it stands: + // DAO treasury = 80_000_000000 USDC (100K) + // Futarchy AMM = 20_000_000000 USDC (20K) + // Bid wall = 100_000_000000 USDC (100K) + // Total assumed NAV = 200_000_000000 USDC (200K) + // Active supply = 10_000_000_000000 META (10M) + // Price = 200_000_000000 / 10_000_000_000000 = ~0.02 USDC per META + // Assume user sells 1M META + // User will receive ~20_000 USDC (1M * 0.02) minus 2% fee, rounded down. + + await bidWallClient + .sellTokensIx({ + amount: 1_000_000_000000, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .rpc(); + + const usdcBalanceAfterFirstSell = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + const metaBalanceAfterFirstSell = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + + // Seller received 19_600_000000 USDC (19.6K), which is 20_000_000000 - 400_000000 (2% fee) + assert.equal(usdcBalanceAfterFirstSell, usdcBalanceBefore + 19_600_000000n); + assert.equal(metaBalanceAfterFirstSell, 9_000_000_000000n); + + // Bid wall collected 400_000000 USDC (400) in fees + const bidWallAccount = await bidWallClient.fetchBidWall(bidWall); + assert.equal( + bidWallAccount.feesCollected.toString(), + new BN(400_000000).toString(), + ); + + // One second passes, making the fee decay by 0.5% - from 2% to 1.5% + await this.advanceBySeconds(1); + + await bidWallClient + .sellTokensIx({ + amount: 1_000_000_000000, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_003 }), + ]) + .rpc(); + + const usdcBalanceAfterSecondSell = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + const metaBalanceAfterSecondSell = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + // Seller received 19_700_000000 USDC (19.7K), which is 20_000_000000 - 300_000000 (1.5% fee) + assert.equal( + usdcBalanceAfterSecondSell, + usdcBalanceAfterFirstSell + 19_700_000000n, + ); + assert.equal(metaBalanceAfterSecondSell, 8_000_000_000000n); + + // Another second passes, making the fee decay by 0.5% - from 1.5% to 1% + await this.advanceBySeconds(1); + + await bidWallClient + .sellTokensIx({ + amount: 1_000_000_000000, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_004 }), + ]) + .rpc(); + + const usdcBalanceAfterThirdSell = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + const metaBalanceAfterThirdSell = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + // Seller received 19_800_000000 USDC (19.8K), which is 20_000_000000 - 200_000000 (1% fee) + assert.equal( + usdcBalanceAfterThirdSell, + usdcBalanceAfterSecondSell + 19_800_000000n, + ); + assert.equal(metaBalanceAfterThirdSell, 7_000_000_000000n); + + // Another second passes, but the fee doesn't decay anymore because it's at the minimum + await this.advanceBySeconds(1); + + await bidWallClient + .sellTokensIx({ + amount: 1_000_000_000000, + bidWall, + baseMint: META, + daoTreasury: daoTreasury, + quoteMint: MAINNET_USDC, + user: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_005 }), + ]) + .rpc(); + + const usdcBalanceAfterFourthSell = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + const metaBalanceAfterFourthSell = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + // Seller received 19_800_000000 USDC (19.8K), which is 20_000_000000 - 200_000000 (1% fee) + assert.equal( + usdcBalanceAfterFourthSell, + usdcBalanceAfterThirdSell + 19_800_000000n, + ); + assert.equal(metaBalanceAfterFourthSell, 6_000_000_000000n); }); it("fails to sell tokens into a bid wall when bid wall is expired", async function () {