diff --git a/Anchor.toml b/Anchor.toml index e8494edd..01faabd9 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -69,6 +69,7 @@ v07-dump-launches-funding-records = "yarn run tsx scripts/v0.7/dumpLaunchesAndFu v07-resize-launches-funding-records = "yarn run tsx scripts/v0.7/resizeLaunchesAndFundingRecords.ts" v07-dump-launches = "yarn run tsx scripts/v0.7/dumpLaunches.ts" v07-resize-launches = "yarn run tsx scripts/v0.7/resizeLaunches.ts" +v07-extend-launch = "yarn run tsx scripts/v0.7/extendLaunch.ts" [test] startup_wait = 5000 diff --git a/programs/v07_launchpad/src/error.rs b/programs/v07_launchpad/src/error.rs index 73fd0b95..3a8252e9 100644 --- a/programs/v07_launchpad/src/error.rs +++ b/programs/v07_launchpad/src/error.rs @@ -64,4 +64,6 @@ pub enum LaunchpadError { InvalidDao, #[msg("Accumulator activation delay must be less than the launch duration")] InvalidAccumulatorActivationDelaySeconds, + #[msg("The extend duration would exceed the maximum allowed launch duration")] + ExtendDurationExceedsMax, } diff --git a/programs/v07_launchpad/src/events.rs b/programs/v07_launchpad/src/events.rs index 914b4262..dabd9467 100644 --- a/programs/v07_launchpad/src/events.rs +++ b/programs/v07_launchpad/src/events.rs @@ -125,3 +125,11 @@ pub struct LaunchPerformancePackageInitializedEvent { pub launch: Pubkey, pub performance_package: Pubkey, } + +#[event] +pub struct LaunchExtendedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub old_seconds_for_launch: u32, + pub new_seconds_for_launch: u32, +} diff --git a/programs/v07_launchpad/src/instructions/complete_launch.rs b/programs/v07_launchpad/src/instructions/complete_launch.rs index a5e8796d..39be1f37 100644 --- a/programs/v07_launchpad/src/instructions/complete_launch.rs +++ b/programs/v07_launchpad/src/instructions/complete_launch.rs @@ -17,8 +17,9 @@ use crate::error::LaunchpadError; use crate::events::{CommonFields, LaunchCloseEvent, LaunchCompletedEvent}; use crate::state::{Launch, LaunchState}; use crate::{ - fee_recipient, PRICE_SCALE, PROPOSAL_MIN_STAKE_TOKENS, TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED, - TOKENS_TO_FUTARCHY_LIQUIDITY, TOKENS_TO_PARTICIPANTS, TOKEN_SCALE, + metadao_multisig_vault, PRICE_SCALE, PROPOSAL_MIN_STAKE_TOKENS, + TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED, TOKENS_TO_FUTARCHY_LIQUIDITY, TOKENS_TO_PARTICIPANTS, + TOKEN_SCALE, }; use anchor_spl::metadata::{ mpl_token_metadata::ID as MPL_TOKEN_METADATA_PROGRAM_ID, update_metadata_accounts_v2, Metadata, @@ -213,7 +214,7 @@ pub struct CompleteLaunch<'info> { pub bid_wall_quote_token_account: UncheckedAccount<'info>, /// CHECK: The fee recipient of bid wall fees, a fixed address - #[account(address = fee_recipient::id())] + #[account(address = metadao_multisig_vault::id())] pub fee_recipient: AccountInfo<'info>, pub system_program: Program<'info, System>, diff --git a/programs/v07_launchpad/src/instructions/extend_launch.rs b/programs/v07_launchpad/src/instructions/extend_launch.rs new file mode 100644 index 00000000..6e4f891f --- /dev/null +++ b/programs/v07_launchpad/src/instructions/extend_launch.rs @@ -0,0 +1,69 @@ +use anchor_lang::prelude::*; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchExtendedEvent}; +use crate::state::{Launch, LaunchState}; + +#[cfg(feature = "production")] +use crate::metadao_multisig_vault; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ExtendLaunchArgs { + pub duration_seconds: u32, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ExtendLaunch<'info> { + #[account(mut)] + pub launch: Account<'info, Launch>, + + pub admin: Signer<'info>, +} + +impl ExtendLaunch<'_> { + pub fn validate(&self, args: &ExtendLaunchArgs) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), metadao_multisig_vault::ID); + + require!( + self.launch.state == LaunchState::Live, + LaunchpadError::InvalidLaunchState + ); + + require_gt!(args.duration_seconds, 0, LaunchpadError::InvalidAmount); + + require!( + self.launch + .seconds_for_launch + .checked_add(args.duration_seconds) + .is_some(), + LaunchpadError::ExtendDurationExceedsMax + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: ExtendLaunchArgs) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let clock = Clock::get()?; + + let old_seconds_for_launch = launch.seconds_for_launch; + + launch.seconds_for_launch = launch + .seconds_for_launch + .checked_add(args.duration_seconds) + .unwrap(); + + launch.seq_num += 1; + + emit_cpi!(LaunchExtendedEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + old_seconds_for_launch, + new_seconds_for_launch: launch.seconds_for_launch, + }); + + Ok(()) + } +} diff --git a/programs/v07_launchpad/src/instructions/mod.rs b/programs/v07_launchpad/src/instructions/mod.rs index e3441167..14702976 100644 --- a/programs/v07_launchpad/src/instructions/mod.rs +++ b/programs/v07_launchpad/src/instructions/mod.rs @@ -2,6 +2,7 @@ pub mod claim; pub mod claim_additional_token_allocation; pub mod close_launch; pub mod complete_launch; +pub mod extend_launch; pub mod fund; pub mod initialize_launch; pub mod initialize_performance_package; @@ -15,6 +16,7 @@ pub use claim::*; pub use claim_additional_token_allocation::*; pub use close_launch::*; pub use complete_launch::*; +pub use extend_launch::*; pub use fund::*; pub use initialize_launch::*; pub use initialize_performance_package::*; diff --git a/programs/v07_launchpad/src/lib.rs b/programs/v07_launchpad/src/lib.rs index 63134f9d..ccd3f3be 100644 --- a/programs/v07_launchpad/src/lib.rs +++ b/programs/v07_launchpad/src/lib.rs @@ -50,11 +50,10 @@ pub mod usdc_mint { declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); } -// TODO - Pileks: Set this to the correct fee recipient address -pub mod fee_recipient { +pub mod metadao_multisig_vault { use anchor_lang::prelude::declare_id; - // MetaDAO multisig vault + // MetaDAO operations multisig vault declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); } @@ -129,4 +128,9 @@ pub mod launchpad_v7 { pub fn resize_launch(ctx: Context) -> Result<()> { ResizeLaunch::handle(ctx) } + + #[access_control(ctx.accounts.validate(&args))] + pub fn extend_launch(ctx: Context, args: ExtendLaunchArgs) -> Result<()> { + ExtendLaunch::handle(ctx, args) + } } diff --git a/scripts/v0.7/extendLaunch.ts b/scripts/v0.7/extendLaunch.ts new file mode 100644 index 00000000..8d94cfa4 --- /dev/null +++ b/scripts/v0.7/extendLaunch.ts @@ -0,0 +1,118 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { + LaunchpadClient, + METADAO_MULTISIG_VAULT, +} from "@metadaoproject/futarchy/v0.7"; +import { PublicKey, Transaction, TransactionMessage } from "@solana/web3.js"; + +// Set the launch address before running the script +const launch = new PublicKey(""); + +// Set the number of seconds to extend the launch by +const durationSeconds = 60 * 60 * 24; // 1 day + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be a signer with permissions to propose transactions on MetaDAO's multisig +const payer = provider.wallet["payer"]; + +const launchpad: LaunchpadClient = LaunchpadClient.createClient({ provider }); + +// MetaDAO Squads multisig and vault addresses +const metadaoSquadsMultisig = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); +const metadaoSquadsMultisigVault = METADAO_MULTISIG_VAULT; + +export const extendLaunch = async () => { + const launchAccount = await launchpad.getLaunch(launch); + + console.log(`Extending launch: ${launch.toBase58()}`); + console.log(`Current seconds_for_launch: ${launchAccount.secondsForLaunch}`); + console.log(`Extension: ${durationSeconds} seconds`); + console.log( + `New seconds_for_launch: ${launchAccount.secondsForLaunch + durationSeconds}`, + ); + + // Build the extend_launch instruction + const extendLaunchIx = await launchpad + .extendLaunchIx({ + launch, + durationSeconds, + admin: metadaoSquadsMultisigVault, + }) + .instruction(); + + // Build the transaction message with the multisig vault as payer + const transactionMessage = new TransactionMessage({ + instructions: [extendLaunchIx], + payerKey: metadaoSquadsMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + }); + + // Log the transaction message as base64 + const compiledMessage = transactionMessage.compileToLegacyMessage(); + const base64Message = Buffer.from(compiledMessage.serialize()).toString( + "base64", + ); + console.log("\nTransaction message (base64):"); + console.log(base64Message); + + // TODO: Uncomment this when ready to extend the launch + return; + + // Fetch the current multisig state to get the next transaction index + const metaDaoSquadsMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + metadaoSquadsMultisig, + ); + + const transactionIndex = + BigInt(metaDaoSquadsMultisigAccount.transactionIndex.toString()) + 1n; + + // Create vault transaction instruction + const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ + multisigPda: metadaoSquadsMultisig, + transactionIndex, + creator: payer.publicKey, + rentPayer: payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage, + }); + + // Create proposal instruction + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: metadaoSquadsMultisig, + transactionIndex, + creator: payer.publicKey, + rentPayer: payer.publicKey, + isDraft: false, + }); + + // Build, sign, and send the transaction + const tx = new Transaction().add(vaultTxCreateIx, proposalCreateIx); + tx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + const txHash = await provider.connection.sendRawTransaction(tx.serialize()); + await provider.connection.confirmTransaction(txHash, "confirmed"); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda: metadaoSquadsMultisig, + transactionIndex, + }); + + console.log("\nVault transaction + proposal created successfully!"); + console.log("Transaction signature:", txHash); + console.log("Proposal index:", transactionIndex.toString()); + console.log("Proposal PDA:", proposalPda.toBase58()); + console.log("Go ahead and approve/execute the transaction through Squads."); +}; + +extendLaunch().catch(console.error); diff --git a/sdk/src/v0.7/LaunchpadClient.ts b/sdk/src/v0.7/LaunchpadClient.ts index 47922e99..70c31f45 100644 --- a/sdk/src/v0.7/LaunchpadClient.ts +++ b/sdk/src/v0.7/LaunchpadClient.ts @@ -715,6 +715,21 @@ export class LaunchpadClient { }); } + extendLaunchIx({ + launch, + durationSeconds, + admin = METADAO_MULTISIG_VAULT, + }: { + launch: PublicKey; + durationSeconds: number; + admin?: PublicKey; + }) { + return this.launchpad.methods.extendLaunch({ durationSeconds }).accounts({ + launch, + admin, + }); + } + getLaunchAddress({ baseMint }: { baseMint: PublicKey }): PublicKey { return getLaunchAddr(this.launchpad.programId, baseMint)[0]; } diff --git a/sdk/src/v0.7/types/launchpad_v7.ts b/sdk/src/v0.7/types/launchpad_v7.ts index fb82a3b6..e5b1bf7e 100644 --- a/sdk/src/v0.7/types/launchpad_v7.ts +++ b/sdk/src/v0.7/types/launchpad_v7.ts @@ -808,6 +808,39 @@ export type LaunchpadV7 = { ]; args: []; }, + { + name: "extendLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ExtendLaunchArgs"; + }; + }, + ]; + }, ]; accounts: [ { @@ -1358,6 +1391,18 @@ export type LaunchpadV7 = { ]; }; }, + { + name: "ExtendLaunchArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "durationSeconds"; + type: "u32"; + }, + ]; + }; + }, { name: "InitializeLaunchArgs"; type: { @@ -1873,6 +1918,33 @@ export type LaunchpadV7 = { }, ]; }, + { + name: "LaunchExtendedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "oldSecondsForLaunch"; + type: "u32"; + index: false; + }, + { + name: "newSecondsForLaunch"; + type: "u32"; + index: false; + }, + ]; + }, ]; errors: [ { @@ -2030,6 +2102,11 @@ export type LaunchpadV7 = { name: "InvalidAccumulatorActivationDelaySeconds"; msg: "Accumulator activation delay must be less than the launch duration"; }, + { + code: 6031; + name: "ExtendDurationExceedsMax"; + msg: "The extend duration would exceed the maximum allowed launch duration"; + }, ]; }; @@ -2843,6 +2920,39 @@ export const IDL: LaunchpadV7 = { ], args: [], }, + { + name: "extendLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ExtendLaunchArgs", + }, + }, + ], + }, ], accounts: [ { @@ -3393,6 +3503,18 @@ export const IDL: LaunchpadV7 = { ], }, }, + { + name: "ExtendLaunchArgs", + type: { + kind: "struct", + fields: [ + { + name: "durationSeconds", + type: "u32", + }, + ], + }, + }, { name: "InitializeLaunchArgs", type: { @@ -3908,6 +4030,33 @@ export const IDL: LaunchpadV7 = { }, ], }, + { + name: "LaunchExtendedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "oldSecondsForLaunch", + type: "u32", + index: false, + }, + { + name: "newSecondsForLaunch", + type: "u32", + index: false, + }, + ], + }, ], errors: [ { @@ -4065,5 +4214,10 @@ export const IDL: LaunchpadV7 = { name: "InvalidAccumulatorActivationDelaySeconds", msg: "Accumulator activation delay must be less than the launch duration", }, + { + code: 6031, + name: "ExtendDurationExceedsMax", + msg: "The extend duration would exceed the maximum allowed launch duration", + }, ], }; diff --git a/tests/launchpad_v7/main.test.ts b/tests/launchpad_v7/main.test.ts index 53bbf1d0..2398a586 100644 --- a/tests/launchpad_v7/main.test.ts +++ b/tests/launchpad_v7/main.test.ts @@ -8,6 +8,7 @@ import closeLaunch from "./unit/closeLaunch.test.js"; import setFundingRecordApproval from "./unit/setFundingRecordApproval.test.js"; import claimAdditionalTokenAllocation from "./unit/claimAdditionalTokenAllocation.test.js"; import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; +import extendLaunch from "./unit/extendLaunch.test.js"; import { PublicKey } from "@solana/web3.js"; import { LAUNCHPAD_PROGRAM_ID, @@ -85,4 +86,5 @@ export default function suite() { "#claim_additional_token_allocation_v7", claimAdditionalTokenAllocation, ); + describe("#extend_launch_v7", extendLaunch); } diff --git a/tests/launchpad_v7/unit/extendLaunch.test.ts b/tests/launchpad_v7/unit/extendLaunch.test.ts new file mode 100644 index 00000000..7b0490c0 --- /dev/null +++ b/tests/launchpad_v7/unit/extendLaunch.test.ts @@ -0,0 +1,141 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Signer, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { LaunchpadClient, MAINNET_USDC } from "@metadaoproject/futarchy/v0.7"; +import { BN } from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Signer; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + before(async function () { + launchpadClient = this.launchpad_v7; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v7, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ launch, launchAuthority: launchAuthority.publicKey }) + .signers([launchAuthority]) + .rpc(); + }); + + it("successfully extends a live launch", async function () { + const launchBefore = await launchpadClient.getLaunch(launch); + const originalSeconds = launchBefore.secondsForLaunch; + + const extensionSeconds = 60 * 60 * 24; // 1 day + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + const launchAfter = await launchpadClient.getLaunch(launch); + assert.equal( + launchAfter.secondsForLaunch, + originalSeconds + extensionSeconds, + ); + assert.equal( + launchAfter.seqNum.toNumber(), + launchBefore.seqNum.toNumber() + 1, + ); + }); + + it("funders can still fund after original deadline if extended", async function () { + const extensionSeconds = 60 * 60 * 24 * 2; // 2 extra days + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + // Advance past the *original* deadline but still within extended window + await this.advanceBySeconds(secondsForLaunch + 100); + + // Funding should still work + const fundAmount = new BN(100_000_000); // 100 USDC + await launchpadClient.fundIx({ launch, amount: fundAmount }).rpc(); + + const launchAccount = await launchpadClient.getLaunch(launch); + assert.equal( + launchAccount.totalCommittedAmount.toString(), + fundAmount.toString(), + ); + }); + + it("close_launch respects new extended deadline", async function () { + const extensionSeconds = 60 * 60 * 24 * 2; // 2 extra days + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + // Fund the launch + const fundAmount = new BN(100_000_000); // 100 USDC + await launchpadClient.fundIx({ launch, amount: fundAmount }).rpc(); + + // Advance past original deadline but before extended deadline + await this.advanceBySeconds(secondsForLaunch + 100); + + // close_launch should fail — still within extended window + try { + await launchpadClient.closeLaunchIx({ launch }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "LaunchPeriodNotOver"); + } + + // Advance past the extended deadline + await this.advanceBySeconds(extensionSeconds); + + // close_launch should now succeed + await launchpadClient + .closeLaunchIx({ launch }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const launchAccount = await launchpadClient.getLaunch(launch); + assert.isDefined( + launchAccount.state.refunding || launchAccount.state.closed, + ); + }); +}