diff --git a/.github/workflows/repo-guard.yml b/.github/workflows/repo-guard.yml index 966f3d60d..464d1e753 100644 --- a/.github/workflows/repo-guard.yml +++ b/.github/workflows/repo-guard.yml @@ -40,11 +40,7 @@ jobs: node-version: '20.18.0' cache: 'yarn' - - name: Verify root yarn.lock is up to date - id: yarn_root - continue-on-error: true - run: yarn install --frozen-lockfile --ignore-scripts --non-interactive - + # sdk first: the root install links `link:./sdk` and needs sdk/node_modules to exist. - name: Verify sdk yarn.lock is up to date id: yarn_sdk continue-on-error: true @@ -52,6 +48,12 @@ jobs: cd sdk yarn install --frozen-lockfile --ignore-scripts --non-interactive + - name: Verify root yarn.lock is up to date + id: yarn_root + continue-on-error: true + if: steps.yarn_sdk.outcome == 'success' + run: yarn install --frozen-lockfile --ignore-scripts --non-interactive + - name: Run repository guard checks id: guard continue-on-error: true @@ -83,24 +85,26 @@ jobs: echo " - Out of sync with the workspace manifests. Run \`cargo update --workspace\` (or rebuild) and commit the updated \`Cargo.lock\`." fi - if [ "$YARN_ROOT_OUTCOME" = "success" ]; then - echo "- yarn.lock (root): pass" - else - echo "- yarn.lock (root): fail" - echo " - Root \`yarn.lock\` is out of date. Run \`yarn install\` at the repo root and commit the result." - fi - if [ "$YARN_SDK_OUTCOME" = "success" ]; then echo "- yarn.lock (sdk): pass" else echo "- yarn.lock (sdk): fail" - echo " - \`sdk/yarn.lock\` is out of date. Run \`yarn install\` in \`sdk/\` and commit the result." + echo " - \`yarn install --frozen-lockfile\` failed in \`sdk/\`. Usually \`sdk/yarn.lock\` is out of date: run \`yarn install\` in \`sdk/\` and commit the result. See the step log for the actual error." + fi + + if [ "$YARN_ROOT_OUTCOME" = "success" ]; then + echo "- yarn.lock (root): pass" + elif [ "$YARN_ROOT_OUTCOME" = "skipped" ]; then + echo "- yarn.lock (root): skipped (sdk install failed - fix that first)" + else + echo "- yarn.lock (root): fail" + echo " - \`yarn install --frozen-lockfile\` failed at the repo root. Usually the root \`yarn.lock\` is out of date: run \`yarn install\` at the repo root and commit the result. See the step log for the actual error." fi if [ "$GUARD_OUTCOME" = "success" ]; then echo "- Repo guard: pass" elif [ "$GUARD_OUTCOME" = "skipped" ]; then - echo "- Repo guard: skipped (root yarn install failed - fix that first)" + echo "- Repo guard: skipped (yarn install failed - fix that first)" else if [ "$EMERGENCY_BYPASS" = "true" ]; then echo "- Repo guard: bypassed with \`emergency-override\`" @@ -117,21 +121,29 @@ jobs: fi } > body.md + # Render the same content on the run's Summary page - visible on + # fork PRs too, where the comment steps below are skipped. + cat body.md >> "$GITHUB_STEP_SUMMARY" + { echo "body<> "$GITHUB_OUTPUT" + # Fork PRs get a read-only GITHUB_TOKEN, so the comment steps would 403. + # Skip them there - the "Fail if any check failed" step still gates. - name: Find existing comment uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3 id: find + if: github.event.pull_request.head.repo.full_name == github.repository with: issue-number: ${{ github.event.pull_request.number }} body-includes: "" - name: Create or update PR comment uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4 + if: github.event.pull_request.head.repo.full_name == github.repository with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find.outputs.comment-id }} @@ -153,11 +165,13 @@ jobs: if [ "$CARGO_OUTCOME" != "success" ]; then failed+=("Cargo.lock out of sync - run \`cargo update --workspace\` (or rebuild) and commit") fi - if [ "$YARN_ROOT_OUTCOME" != "success" ]; then - failed+=("root yarn.lock out of date - run \`yarn install\` at repo root and commit") - fi if [ "$YARN_SDK_OUTCOME" != "success" ]; then - failed+=("sdk yarn.lock out of date - run \`yarn install\` in \`sdk/\` and commit") + failed+=("sdk yarn install failed - usually \`sdk/yarn.lock\` is out of date: run \`yarn install\` in \`sdk/\` and commit") + fi + if [ "$YARN_ROOT_OUTCOME" = "skipped" ]; then + failed+=("root yarn.lock check skipped - fix the sdk install first") + elif [ "$YARN_ROOT_OUTCOME" != "success" ]; then + failed+=("root yarn install failed - usually root \`yarn.lock\` is out of date: run \`yarn install\` at repo root and commit") fi # The guard itself (exact-version, age, action-pinning, toolchain diff --git a/CLAUDE.md b/CLAUDE.md index 13a1b7caf..86ec6b110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,7 @@ External programs required for tests. These are pre-compiled `.so` files in `tes **"blockstore error"**: `rm -rf .anchor/test-ledger test-ledger` -**Module resolution errors**: `cd sdk && yarn build-local && cd .. && yarn install --force` +**Module resolution errors**: `cd sdk && yarn build-local` (the root `node_modules` entry is a symlink to `sdk/`, so no root reinstall is needed) **Tests timeout**: Increase `startup_wait` in `Anchor.toml` diff --git a/README.md b/README.md index d14e0bcc3..3aee5b7cf 100644 --- a/README.md +++ b/README.md @@ -125,38 +125,23 @@ Reload your shell configuration: source ~/.zshrc # or source ~/.bash_profile ``` -#### 7. Install Dependencies +#### 7. Build Programs and Install Dependencies -Install root project dependencies: +Build all programs, install and build the SDK, install the root dependencies, and lint in one step: ```bash -yarn install +./rebuild.sh ``` -Install SDK dependencies and build: +Re-run `./rebuild.sh` after changing program or SDK code so tests run against your latest changes. -```bash -cd sdk -yarn install -yarn build-local -cd .. -``` - -#### 8. Build Programs - -Build all Solana programs: - -```bash -anchor build -``` - -Or build a specific program: +To build a single program on its own: ```bash -anchor build -p programs +anchor build -p futarchy ``` -#### 9. Run Tests +#### 8. Run Tests Run all tests: @@ -184,13 +169,12 @@ Then run `anchor test` again. #### "Cannot find module" errors -If you see module resolution errors, rebuild the SDK: +If you see module resolution errors, rebuild the SDK. The root `node_modules` entry for `@metadaoproject/programs` is a symlink to `sdk/`, so no root reinstall is needed: ```bash cd sdk yarn build-local cd .. -yarn install --force ``` #### Tests timeout or validator doesn't start diff --git a/package.json b/package.json index 6a7dd00b9..72d7b887a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@inquirer/prompts": "7.9.0", "@ledgerhq/hw-app-solana": "7.10.4", "@ledgerhq/hw-transport-node-hid": "6.33.4", - "@metadaoproject/programs": "./sdk", + "@metadaoproject/programs": "link:./sdk", "@metaplex-foundation/mpl-token-metadata": "3.4.0", "@metaplex-foundation/umi": "0.9.2", "@metaplex-foundation/umi-bundle-defaults": "0.9.2", diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index 508a07e0b..8af623e97 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -106,8 +106,6 @@ pub enum FutarchyError { SpendingLimitNotDirty, #[msg("Wrong proposal kind for this instruction")] InvalidProposalKind, - #[msg("This DAO has already been liquidated")] - AlreadyLiquidated, #[msg("A spending limit can have at most 10 members")] TooManySpendingLimitMembers, #[msg("Invalid liquidator")] @@ -118,7 +116,7 @@ pub enum FutarchyError { EmptyProposalParamsUpdate, #[msg("Buyback amount exceeds 25% of the treasury")] BuybackCapExceeded, - #[msg("The total must be an exact multiple of the non-zero per-cycle amount, at least twice over")] + #[msg("Buyback total must be non-zero")] InvalidBuybackAmount, #[msg("Cycle frequency must be between 60 seconds and 1 year")] InvalidBuybackCycleFrequency, @@ -126,10 +124,32 @@ pub enum FutarchyError { InvalidBuybackStartDelay, #[msg("min_price must be no greater than max_price")] InvalidBuybackPriceBand, - #[msg("A treasury account is neither a vault-owned quote account nor the treasury's AMM position")] + #[msg( + "A treasury account is neither a vault-owned quote account nor the treasury's AMM position" + )] InvalidTreasuryAccount, #[msg("Treasury accounts must be in strictly ascending key order")] TreasuryAccountsNotSorted, #[msg("This proposal kind's launch takes no extra accounts")] UnexpectedLaunchAccounts, + #[msg("Spending limit account is not the canonical spending-limit PDA")] + InvalidSpendingLimitAccount, + #[msg("The DAO's team has changed since this draft was created")] + StaleTeamAddress, + #[msg("Account is not migrated to latest layout")] + AccountNotMigrated, + #[msg("A spending limit's monthly amount must be non-zero")] + InvalidSpendingLimitAmount, + #[msg("A spending limit must have at least one member")] + EmptySpendingLimitMembers, + #[msg("A spending limit's members must be unique")] + DuplicateSpendingLimitMember, + #[msg("A buyback must run at least two cycles")] + InvalidBuybackCycleCount, + #[msg("Invalid team address")] + InvalidTeamAddress, + #[msg("This proposal kind cannot be team-sponsored")] + TeamSponsorshipForbidden, + #[msg("Squads proposal must be in Approved status to be cancelled")] + SquadsProposalNotApproved, } diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index 06498b162..22eb8e045 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -262,14 +262,3 @@ pub struct SyncSpendingLimitEvent { /// `None` = no limit (removed or never existed). pub config: Option, } - -#[event] -pub struct ApplyLiquidationEvent { - pub common: CommonFields, - pub dao: Pubkey, - pub proposal: Pubkey, - pub liquidator: Pubkey, - pub base_swept: u64, - pub quote_swept: u64, - pub post_amm_state: FutarchyAmm, -} diff --git a/programs/futarchy/src/instructions/admin_cancel_proposal.rs b/programs/futarchy/src/instructions/admin_cancel_proposal.rs index ce38d2e9e..b220fa567 100644 --- a/programs/futarchy/src/instructions/admin_cancel_proposal.rs +++ b/programs/futarchy/src/instructions/admin_cancel_proposal.rs @@ -69,6 +69,10 @@ pub struct AdminCancelProposal<'info> { impl AdminCancelProposal<'_> { pub fn validate(&self) -> Result<()> { + // Ensure the proposal and DAO are migrated. + Proposal::assert_migrated(&self.proposal.to_account_info())?; + Dao::assert_migrated(&self.dao.to_account_info())?; + // Unblockable proposals are censorship-proof once live: nobody, including // the council, can cancel them. Reads the create-time snapshot so a // live proposal keeps the flag it launched with. diff --git a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs index 9d6ecaffb..2ccc1ff01 100644 --- a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs @@ -62,6 +62,9 @@ pub struct AdminEnqueueMultisigProposalApproval<'info> { impl AdminEnqueueMultisigProposalApproval<'_> { pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> { + // Ensure the DAO is migrated before reading `liquidator`. + Dao::assert_migrated(&self.dao.to_account_info())?; + // On a liquidated DAO the liquidator replaces the admin id as the // required signer. Enqueueing is the only capability the liquidator // gains: the approve leg stays permissionless and execution is diff --git a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_cancellation.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_cancellation.rs new file mode 100644 index 000000000..79b41ff17 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_cancellation.rs @@ -0,0 +1,125 @@ +use super::*; + +mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO ops multisig — the same signer as the approval enqueue + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct AdminEnqueueMultisigProposalCancellationArgs { + pub transaction_index: u64, +} + +#[derive(Accounts)] +#[instruction(args: AdminEnqueueMultisigProposalCancellationArgs)] +pub struct AdminEnqueueMultisigProposalCancellation<'info> { + #[account(has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + args.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + init, + payer = admin, + space = 8 + EnqueuedMultisigProposalCancellation::INIT_SPACE, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION, + dao.key().as_ref(), + args.transaction_index.to_le_bytes().as_ref(), + ], + bump, + )] + pub enqueued_cancellation: Account<'info, EnqueuedMultisigProposalCancellation>, + + pub system_program: Program<'info, System>, +} + +impl AdminEnqueueMultisigProposalCancellation<'_> { + pub fn validate(&self, _args: &AdminEnqueueMultisigProposalCancellationArgs) -> Result<()> { + // Ensure the DAO is migrated before reading `liquidator`. + Dao::assert_migrated(&self.dao.to_account_info())?; + + // On a liquidated DAO the liquidator replaces the admin id as the + // required signer. Enqueueing is the only capability the liquidator + // gains: the cancel leg stays permissionless. + match self.dao.liquidator { + Some(liquidator) => { + require_keys_eq!( + self.admin.key(), + liquidator, + FutarchyError::InvalidLiquidator + ); + } + None => { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + } + } + + validate_squads_proposal_for_cancellation( + &self.squads_multisig_proposal, + &self.dao.squads_multisig, + )?; + + Ok(()) + } + + pub fn handle( + ctx: Context, + args: AdminEnqueueMultisigProposalCancellationArgs, + ) -> Result<()> { + let enqueued = &mut ctx.accounts.enqueued_cancellation; + + enqueued.dao = ctx.accounts.dao.key(); + enqueued.transaction_index = args.transaction_index; + enqueued.pda_bump = ctx.bumps.enqueued_cancellation; + + Ok(()) + } +} + +/// A cancellation targets an `Approved` proposal. Squads permits cancelling a +/// stale proposal, so there is no stale-index check here. +pub fn validate_squads_proposal_for_cancellation( + squads_proposal: &squads_multisig_program::Proposal, + dao_multisig_key: &Pubkey, +) -> Result<()> { + require_keys_eq!(squads_proposal.multisig, *dao_multisig_key); + + require!( + matches!( + squads_proposal.status, + squads_multisig_program::ProposalStatus::Approved { .. } + ), + FutarchyError::SquadsProposalNotApproved + ); + + Ok(()) +} diff --git a/programs/futarchy/src/instructions/apply_liquidation.rs b/programs/futarchy/src/instructions/apply_liquidation.rs deleted file mode 100644 index d3abd847e..000000000 --- a/programs/futarchy/src/instructions/apply_liquidation.rs +++ /dev/null @@ -1,163 +0,0 @@ -use super::*; - -#[derive(Accounts)] -#[event_cpi] -pub struct ApplyLiquidation<'info> { - /// The linked liquidation proposal, baked into the payload at create. - #[account(has_one = dao)] - pub proposal: Box>, - #[account(mut, has_one = squads_multisig_vault)] - pub dao: Box>, - /// The vault's signature is only obtainable through a Squads vault - /// transaction execution, so the caller is a passed proposal's payload. - pub squads_multisig_vault: Signer<'info>, - /// CHECK: the treasury's own LP position. The address is pinned by the - /// seeds, but whether the account exists at execution is unknowable at - /// create, so it is parsed manually — a passed liquidation must never - /// brick on treasury shape. - #[account( - mut, - seeds = [SEED_AMM_POSITION, dao.key().as_ref(), squads_multisig_vault.key().as_ref()], - bump, - )] - pub amm_position: UncheckedAccount<'info>, - #[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>, - #[account( - mut, - associated_token::mint = dao.base_mint, - associated_token::authority = squads_multisig_vault, - )] - pub vault_base_account: Account<'info, TokenAccount>, - #[account( - mut, - associated_token::mint = dao.quote_mint, - associated_token::authority = squads_multisig_vault, - )] - pub vault_quote_account: Account<'info, TokenAccount>, - pub token_program: Program<'info, Token>, -} - -impl ApplyLiquidation<'_> { - pub fn validate(&self) -> Result<()> { - // Like every payload instruction that mutates the DAO, only lands in - // Spot — the sweep always computes against a whole spot pool. - require!( - matches!(self.dao.amm.state, PoolState::Spot { .. }), - FutarchyError::PoolNotInSpotState - ); - - require!( - self.proposal.state == ProposalState::Passed, - FutarchyError::ProposalNotPassed - ); - - // Execution is permissionless and a second passed liquidation can - // exist, so replay must be refused, not double-applied. - require!( - self.dao.liquidator.is_none(), - FutarchyError::AlreadyLiquidated - ); - - Ok(()) - } - - pub fn handle(ctx: Context) -> Result<()> { - let Self { - proposal, - dao, - squads_multisig_vault: _, - amm_position, - amm_base_vault, - amm_quote_vault, - vault_base_account, - vault_quote_account, - token_program, - event_authority: _, - program: _, - } = ctx.accounts; - - // The destructure is the kind check: the vault's signature alone is - // kind-blind, so without it an execute_arbitrary payload could invoke - // liquidation at a different duration/threshold. - let ProposalAction::HostileLiquidate { liquidator } = &proposal.action else { - return err!(FutarchyError::InvalidProposalKind); - }; - let liquidator = *liquidator; - - // `Some` is the liquidated flag, and it is terminal. - dao.liquidator = Some(liquidator); - - // Zero the record; the next sync removes the Squads-side limit, so - // the outgoing team's pull rights end. - dao.initial_spending_limit = None; - dao.spending_limit_dirty = true; - - // Sweep the treasury's own AMM position pro-rata into the vault's - // token accounts. Third-party positions are untouched — they exit on - // their own schedule via withdraw_liquidity. A missing or empty - // position is skipped, never a failure. - let mut base_swept = 0u64; - let mut quote_swept = 0u64; - - if !amm_position.data_is_empty() { - require_keys_eq!( - *amm_position.owner, - crate::ID, - anchor_lang::error::ErrorCode::AccountOwnedByWrongProgram - ); - - let mut position: AmmPosition = { - let data = amm_position.try_borrow_data()?; - AmmPosition::try_deserialize(&mut &data[..])? - }; - - if position.liquidity > 0 { - let liquidity_to_sweep = position.liquidity; - (base_swept, quote_swept) = withdraw_from_position( - dao, - &mut position, - liquidity_to_sweep, - amm_base_vault, - amm_quote_vault, - vault_base_account, - vault_quote_account, - token_program, - )?; - - // The position sits behind an UncheckedAccount, so Anchor - // won't write it back on exit — persist it manually. - { - let mut data = amm_position.try_borrow_mut_data()?; - let mut writer: &mut [u8] = &mut data; - position.try_serialize(&mut writer)?; - } - } - } - - dao.seq_num += 1; - - let clock = Clock::get()?; - emit_cpi!(ApplyLiquidationEvent { - common: CommonFields::new(&clock, dao.seq_num), - dao: dao.key(), - proposal: proposal.key(), - liquidator, - base_swept, - quote_swept, - post_amm_state: dao.amm.clone(), - }); - - Ok(()) - } -} diff --git a/programs/futarchy/src/instructions/execute_multisig_proposal_cancellation.rs b/programs/futarchy/src/instructions/execute_multisig_proposal_cancellation.rs new file mode 100644 index 000000000..5934cfbd5 --- /dev/null +++ b/programs/futarchy/src/instructions/execute_multisig_proposal_cancellation.rs @@ -0,0 +1,95 @@ +use super::*; + +#[derive(Accounts)] +pub struct ExecuteMultisigProposalCancellation<'info> { + #[account(mut, has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub rent_receiver: Signer<'info>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + mut, + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + enqueued_cancellation.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + mut, + close = rent_receiver, + has_one = dao, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION, + dao.key().as_ref(), + enqueued_cancellation.transaction_index.to_le_bytes().as_ref(), + ], + bump = enqueued_cancellation.pda_bump, + )] + pub enqueued_cancellation: Account<'info, EnqueuedMultisigProposalCancellation>, + + pub squads_multisig_program: + Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, +} + +impl ExecuteMultisigProposalCancellation<'_> { + pub fn validate(&self) -> Result<()> { + // No Spot-state gate: a live market's Squads proposal is Active, never Approved. + validate_squads_proposal_for_cancellation( + &self.squads_multisig_proposal, + &self.dao.squads_multisig, + )?; + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let Self { + dao, + rent_receiver: _, + squads_multisig, + squads_multisig_proposal, + enqueued_cancellation: _, + squads_multisig_program, + } = ctx.accounts; + + let dao_nonce = &dao.nonce.to_le_bytes(); + let dao_creator_key = &dao.dao_creator.as_ref(); + let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; + let dao_signer = &[&dao_seeds[..]]; + + squads_multisig_program::cpi::proposal_cancel( + CpiContext::new_with_signer( + squads_multisig_program.to_account_info(), + squads_multisig_program::cpi::accounts::ProposalVote { + proposal: squads_multisig_proposal.to_account_info(), + multisig: squads_multisig.to_account_info(), + member: dao.to_account_info(), + }, + dao_signer, + ), + squads_multisig_program::ProposalVoteArgs { memo: None }, + )?; + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index e3b4ec4b7..8c231c729 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -62,6 +62,10 @@ pub struct FinalizeProposal<'info> { impl FinalizeProposal<'_> { pub fn validate(&self) -> Result<()> { + // Ensure the proposal and DAO are migrated. + Proposal::assert_migrated(&self.proposal.to_account_info())?; + Dao::assert_migrated(&self.dao.to_account_info())?; + let clock = Clock::get()?; require_gte!( @@ -174,6 +178,21 @@ impl FinalizeProposal<'_> { } } + // In case of a hostile liquidation, set the liquidator immediately. + // This write can only occur once, as a liquidated DAO can't start another proposal. + if new_proposal_state == ProposalState::Passed { + if let ProposalAction::HostileLiquidate { liquidator } = &proposal.action { + dao.liquidator = Some(*liquidator); + + // The spending limit must be zeroed so that the estate can be swept. + // Otherwise a still-live limit member could drain the estate. + if dao.initial_spending_limit.is_some() { + dao.initial_spending_limit = None; + dao.spending_limit_dirty = true; + } + } + } + // The buyback cooldown stamps on either outcome: it rate-limits an // action the DAO consented to — draining the treasury through a // sequence of individually reasonable votes — rather than deterring @@ -302,7 +321,7 @@ impl FinalizeProposal<'_> { squads_proposal: squads_proposal.key(), squads_multisig: dao.squads_multisig, post_amm_state: dao.amm.clone(), - is_team_sponsored: proposal.is_team_sponsored, + is_team_sponsored: proposal.is_sponsored_by(dao.team_address), }); Ok(()) diff --git a/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs b/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs index 4318c59da..fb5e8d11c 100644 --- a/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_buyback_token_proposal.rs @@ -1,8 +1,11 @@ use super::*; -/// The venue's DCA interface bounds (Jupiter's Trigger API suite): an -/// integral order count of at least 2, an interval between a minute and a -/// year, and a start at most 30 days out. +/// The venue's DCA interface bounds (Jupiter's Trigger API suite): a total +/// split across an integral order count of at least 2, with any remainder +/// landing in the last order; an interval between a minute and a year; and a +/// start at most 30 days out. Its per-order value floor is a USD figure set by +/// venue policy, so it is deliberately not mirrored here. +pub const MIN_BUYBACK_CYCLE_COUNT: u32 = 2; pub const MIN_BUYBACK_CYCLE_SECONDS: u32 = 60; pub const MAX_BUYBACK_CYCLE_SECONDS: u32 = 365 * DAY_SECONDS; pub const MAX_BUYBACK_START_DELAY_SECONDS: u32 = 30 * DAY_SECONDS; @@ -10,7 +13,7 @@ pub const MAX_BUYBACK_START_DELAY_SECONDS: u32 = 30 * DAY_SECONDS; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct InitializeBuybackTokenProposalArgs { pub quote_amount: u64, - pub quote_amount_per_cycle: u64, + pub cycle_count: u32, pub cycle_frequency_seconds: u32, pub start_delay_seconds: u32, pub min_price: Option, @@ -27,23 +30,12 @@ impl InitializeBuybackTokenProposal<'_> { pub fn validate(&self, args: &InitializeBuybackTokenProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; - // The venue takes an integral order count with a two-order minimum, - // so the total must be an exact multiple of the per-cycle amount, at - // least twice over. A zero total falls out of the two-order check. - require_gt!( - args.quote_amount_per_cycle, - 0, - FutarchyError::InvalidBuybackAmount - ); - require_eq!( - args.quote_amount % args.quote_amount_per_cycle, - 0, - FutarchyError::InvalidBuybackAmount - ); + // A zero total is a mandate to buy nothing. + require_gt!(args.quote_amount, 0, FutarchyError::InvalidBuybackAmount); require_gte!( - args.quote_amount / args.quote_amount_per_cycle, - 2, - FutarchyError::InvalidBuybackAmount + args.cycle_count, + MIN_BUYBACK_CYCLE_COUNT, + FutarchyError::InvalidBuybackCycleCount ); require_gte!( @@ -78,10 +70,10 @@ impl InitializeBuybackTokenProposal<'_> { None => "none".to_string(), }; let memo = format!( - "metadao-buyback/1 proposal={} spend={} per_cycle={} cycle_seconds={} start_delay={} min_price={} max_price={}", + "metadao-buyback/1 proposal={} spend={} cycles={} cycle_seconds={} start_delay={} min_price={} max_price={}", typed_initialize_accounts.proposal.key(), args.quote_amount, - args.quote_amount_per_cycle, + args.cycle_count, args.cycle_frequency_seconds, args.start_delay_seconds, format_price(args.min_price), @@ -94,7 +86,7 @@ impl InitializeBuybackTokenProposal<'_> { &[memo_ix], ProposalAction::BuybackToken { quote_amount: args.quote_amount, - quote_amount_per_cycle: args.quote_amount_per_cycle, + cycle_count: args.cycle_count, cycle_frequency_seconds: args.cycle_frequency_seconds, start_delay_seconds: args.start_delay_seconds, min_price: args.min_price, diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index 4b9291adc..9c8161a1e 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -147,10 +147,7 @@ impl InitializeDao<'_> { )?; if let Some(initial_spending_limit) = initial_spending_limit.clone() { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - initial_spending_limit.members.len() - ); + initial_spending_limit.validate()?; squads_multisig_program::cpi::multisig_add_spending_limit( CpiContext::new_with_signer( diff --git a/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs index 62ce649d9..d9e7654b7 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_liquidate_proposal.rs @@ -1,8 +1,12 @@ -use anchor_lang::solana_program::instruction::Instruction; -use anchor_lang::InstructionData; - use super::*; +pub mod metadao_multisig_vault { + use anchor_lang::prelude::declare_id; + + // MetaDAO operations multisig vault + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct InitializeHostileLiquidateProposalArgs { pub liquidator: Pubkey, @@ -15,56 +19,22 @@ pub struct InitializeHostileLiquidateProposal<'info> { } impl InitializeHostileLiquidateProposal<'_> { - pub fn validate(&self) -> Result<()> { + pub fn validate(&self, args: &InitializeHostileLiquidateProposalArgs) -> Result<()> { + // Only the MetaDAO operations multisig vault can be named liquidator in production. + #[cfg(feature = "production")] + require_keys_eq!( + args.liquidator, + metadao_multisig_vault::ID, + FutarchyError::InvalidLiquidator + ); + #[cfg(not(feature = "production"))] + let _ = args; + self.typed_initialize_accounts.validate() } pub fn handle(ctx: Context, args: InitializeHostileLiquidateProposalArgs) -> Result<()> { let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts; - let dao = &typed_initialize_accounts.dao; - - let (event_authority, _) = - Pubkey::find_program_address(&[b"__event_authority"], &crate::ID); - - // The treasury's own LP position: the Squads vault is the position - // authority. May not exist — apply_liquidation tolerates that. - let (amm_position, _) = Pubkey::find_program_address( - &[ - SEED_AMM_POSITION, - dao.key().as_ref(), - dao.squads_multisig_vault.as_ref(), - ], - &crate::ID, - ); - - // The payload calls back into this program. The first account is this - // proposal's own PDA — knowable here because it is seeded on the - // Squads proposal this instruction creates at the next transaction - // index (the proposal_create CPI enforces that address). - let apply_liquidation_ix = Instruction { - program_id: crate::ID, - accounts: crate::accounts::ApplyLiquidation { - proposal: typed_initialize_accounts.proposal.key(), - dao: dao.key(), - squads_multisig_vault: dao.squads_multisig_vault, - amm_position, - amm_base_vault: dao.amm.amm_base_vault, - amm_quote_vault: dao.amm.amm_quote_vault, - vault_base_account: anchor_spl::associated_token::get_associated_token_address( - &dao.squads_multisig_vault, - &dao.base_mint, - ), - vault_quote_account: anchor_spl::associated_token::get_associated_token_address( - &dao.squads_multisig_vault, - &dao.quote_mint, - ), - token_program: token::ID, - event_authority, - program: crate::ID, - } - .to_account_metas(None), - data: crate::instruction::ApplyLiquidation.data(), - }; // The IP transfer is a legal-layer fact. // The memo records it in the executed transaction. @@ -73,8 +43,10 @@ impl InitializeHostileLiquidateProposal<'_> { &[], ); + // The on-chain actions of liquidation are handled by the liquidator. + let event = typed_initialize_accounts.initialize_proposal( - &[apply_liquidation_ix, memo_ix], + &[memo_ix], ProposalAction::HostileLiquidate { liquidator: args.liquidator, }, diff --git a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs index 87621ea76..c04e838d0 100644 --- a/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_hostile_takeover_proposal.rs @@ -19,12 +19,14 @@ impl InitializeHostileTakeoverProposal<'_> { pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; + require_keys_neq!( + args.new_team_address, + self.typed_initialize_accounts.dao.team_address, + FutarchyError::InvalidTeamAddress + ); + if let SpendingLimitAction::Set(config) = &args.spending_limit_action { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) @@ -57,7 +59,6 @@ impl InitializeHostileTakeoverProposal<'_> { base_to_stake: None, team_sponsored_pass_threshold_bps: None, team_address: Some(args.new_team_address), - is_optimistic_governance_enabled: None, }, } .data(), diff --git a/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs index d3ce5c50b..6f67f1336 100644 --- a/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_large_spend_proposal.rs @@ -15,18 +15,7 @@ impl InitializeLargeSpendProposal<'_> { pub fn validate(&self, args: &InitializeLargeSpendProposalArgs) -> Result<()> { self.typed_initialize_accounts.validate()?; - let record = self - .typed_initialize_accounts - .dao - .initial_spending_limit - .as_ref() - .ok_or(FutarchyError::NoSpendingLimit)?; - - require_gte!( - record.amount_per_month.saturating_mul(3), - args.amount, - FutarchyError::SpendCapExceeded - ); + verify_large_spend_cap(args.amount, &self.typed_initialize_accounts.dao)?; Ok(()) } @@ -35,8 +24,9 @@ impl InitializeLargeSpendProposal<'_> { let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts; let dao = &typed_initialize_accounts.dao; - // The recipient is pinned to the DAO's team address at create; a later - // team change does not re-point it. + // The recipient is pinned to the DAO's team address at create. The + // action snapshots the same team so launch can reject the draft if the + // team has changed since. let transfer_ix = token::spl_token::instruction::transfer( &token::ID, &anchor_spl::associated_token::get_associated_token_address( @@ -56,6 +46,7 @@ impl InitializeLargeSpendProposal<'_> { &[transfer_ix], ProposalAction::LargeSpend { amount: args.amount, + team_address: dao.team_address, }, ctx.bumps.typed_initialize_accounts.proposal, )?; diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index d1a945b07..201e305d9 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -106,7 +106,7 @@ impl InitializeProposal<'_> { fail_base_mint: base_vault.conditional_token_mints[0], pass_quote_mint: quote_vault.conditional_token_mints[1], fail_quote_mint: quote_vault.conditional_token_mints[0], - is_team_sponsored: false, + sponsored_by: None, pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, diff --git a/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs index f58ac5831..a3b2fc173 100644 --- a/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_spending_limit_change_proposal.rs @@ -20,11 +20,7 @@ impl InitializeSpendingLimitChangeProposal<'_> { self.typed_initialize_accounts.validate()?; if let Some(config) = &args.config { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 9f5e7a70b..a63ad73eb 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -48,8 +48,11 @@ impl<'info> LaunchProposal<'info> { require_keys_eq!(self.proposal.dao, self.dao.key()); + // A sponsorship only counts while the sponsor is still the DAO's team. + let is_team_sponsored = self.proposal.is_sponsored_by(self.dao.team_address); + // If the proposal is not team sponsored, check if sufficient stake has been accumulated - if !self.proposal.is_team_sponsored { + if !is_team_sponsored { if let ProposalState::Draft { amount_staked } = self.proposal.state { require_gte!( amount_staked, @@ -63,11 +66,8 @@ impl<'info> LaunchProposal<'info> { // drafts can't bypass them let params = self.proposal.action.params(); - if params.requires_team_sponsorship { - require!( - self.proposal.is_team_sponsored, - FutarchyError::ProposalNotTeamSponsored - ); + if params.team_sponsorship_policy == TeamSponsorshipPolicy::Required { + require!(is_team_sponsored, FutarchyError::ProposalNotTeamSponsored); } // A market that doesn't outlive its start delay reaches its nominal end diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 89034d7d8..0b8d7c8f6 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -2,14 +2,15 @@ use super::*; pub mod admin_cancel_proposal; pub mod admin_enqueue_multisig_proposal_approval; +pub mod admin_enqueue_multisig_proposal_cancellation; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; pub mod admin_update_proposal_params; -pub mod apply_liquidation; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; pub mod execute_multisig_proposal_approval; +pub mod execute_multisig_proposal_cancellation; pub mod finalize_proposal; pub mod initialize_buyback_token_proposal; pub mod initialize_dao; @@ -35,14 +36,15 @@ pub mod withdraw_liquidity; pub use admin_cancel_proposal::*; pub use admin_enqueue_multisig_proposal_approval::*; +pub use admin_enqueue_multisig_proposal_cancellation::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; pub use admin_update_proposal_params::*; -pub use apply_liquidation::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; pub use execute_multisig_proposal_approval::*; +pub use execute_multisig_proposal_cancellation::*; pub use finalize_proposal::*; pub use initialize_buyback_token_proposal::*; pub use initialize_dao::*; diff --git a/programs/futarchy/src/instructions/resize_dao.rs b/programs/futarchy/src/instructions/resize_dao.rs index 5be9c25b6..f97fc8514 100644 --- a/programs/futarchy/src/instructions/resize_dao.rs +++ b/programs/futarchy/src/instructions/resize_dao.rs @@ -1,4 +1,5 @@ use anchor_lang::{system_program, Discriminator}; +use squads_multisig_program::{Period, SpendingLimit}; use super::*; @@ -7,6 +8,10 @@ pub struct ResizeDao<'info> { /// CHECK: we check the discriminator #[account(mut)] pub dao: UncheckedAccount<'info>, + /// CHECK: verified in the handler against the canonical Squads + /// spending-limit PDA (`create_key` is always the DAO); read-only and may + /// not exist + pub spending_limit: UncheckedAccount<'info>, #[account(mut)] pub payer: Signer<'info>, pub system_program: Program<'info, System>, @@ -20,7 +25,7 @@ impl ResizeDao<'_> { let is_discriminator_correct = dao.try_borrow_data().unwrap()[..8] == Dao::discriminator(); require_eq!(is_discriminator_correct, true); - const AFTER_REALLOC_SIZE: usize = Dao::INIT_SPACE + 8; + const AFTER_REALLOC_SIZE: usize = Dao::MIGRATED_SIZE; // 58 bytes: 33 (Option liquidator) + 8 (i64) + 8 (i64) + 1 (bool) + 8 (i64) const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 58; @@ -32,6 +37,28 @@ impl ResizeDao<'_> { let old_dao_data = OldDao::deserialize(&mut &dao.try_borrow_data().unwrap()[8..])?; + let (canonical_spending_limit, _) = Pubkey::find_program_address( + &[ + squads_multisig_program::SEED_PREFIX, + old_dao_data.squads_multisig.as_ref(), + squads_multisig_program::SEED_SPENDING_LIMIT, + dao.key().as_ref(), + ], + &squads_multisig_program::ID, + ); + require_keys_eq!( + ctx.accounts.spending_limit.key(), + canonical_spending_limit, + FutarchyError::InvalidSpendingLimitAccount + ); + + // The record must reflect the live Squads account because of the + // LargeSpend authorization cap. + let live_spending_limit = Self::live_canonical_spending_limit( + &ctx.accounts.spending_limit, + &old_dao_data.quote_mint, + ); + let new_dao_data = Dao { amm: old_dao_data.amm, nonce: old_dao_data.nonce, @@ -52,13 +79,14 @@ impl ResizeDao<'_> { min_base_futarchic_liquidity: old_dao_data.min_base_futarchic_liquidity, base_to_stake: old_dao_data.base_to_stake, seq_num: old_dao_data.seq_num, - initial_spending_limit: old_dao_data.initial_spending_limit, + initial_spending_limit: live_spending_limit, team_sponsored_pass_threshold_bps: old_dao_data.team_sponsored_pass_threshold_bps, team_address: old_dao_data.team_address, // The optimistic execution machinery is gone; any in-flight - // optimistic spend is cleared rather than carried over. + // optimistic spend is cleared rather than carried over, and the + // governance flag is explicitly reset to false. optimistic_proposal: None, - is_optimistic_governance_enabled: old_dao_data.is_optimistic_governance_enabled, + is_optimistic_governance_enabled: false, liquidator: None, last_failed_takeover_at: 0, last_failed_liquidation_at: 0, @@ -87,4 +115,30 @@ impl ResizeDao<'_> { Ok(()) } + + /// Reads the live canonical Squads spending limit into a record, or `None` + /// if the account doesn't exist or holds any shape `initialize_dao` could + /// not have created. + fn live_canonical_spending_limit( + spending_limit: &UncheckedAccount, + quote_mint: &Pubkey, + ) -> Option { + if spending_limit.owner != &squads_multisig_program::ID { + return None; + } + + let data = spending_limit.try_borrow_data().ok()?; + let live = SpendingLimit::try_deserialize(&mut &**data).ok()?; + + let is_canonical_shape = live.vault_index == 0 + && live.mint == *quote_mint + && matches!(live.period, Period::Month) + && live.members.len() <= MAX_SPENDING_LIMIT_MEMBERS + && live.destinations.is_empty(); + + is_canonical_shape.then(|| InitialSpendingLimit { + amount_per_month: live.amount, + members: live.members.clone(), + }) + } } diff --git a/programs/futarchy/src/instructions/resize_proposal.rs b/programs/futarchy/src/instructions/resize_proposal.rs index d8ff70b69..7e3d73507 100644 --- a/programs/futarchy/src/instructions/resize_proposal.rs +++ b/programs/futarchy/src/instructions/resize_proposal.rs @@ -25,10 +25,11 @@ impl ResizeProposal<'_> { proposal.try_borrow_data().unwrap()[..8] == Proposal::discriminator(); require_eq!(is_discriminator_correct, true); - const AFTER_REALLOC_SIZE: usize = Proposal::INIT_SPACE + 8; - // 369 bytes: 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) + const AFTER_REALLOC_SIZE: usize = Proposal::MIGRATED_SIZE; + // 401 bytes: 32 (Option sponsored_by replacing the bool) + // + 2 (i16 pass_threshold_bps) + 1 (bool council_can_block) // + 366 (ProposalAction) - const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 369; + const BEFORE_REALLOC_SIZE: usize = AFTER_REALLOC_SIZE - 401; if proposal.data_len() != BEFORE_REALLOC_SIZE { // already realloced @@ -41,12 +42,29 @@ impl ResizeProposal<'_> { require_keys_eq!(old_proposal_data.dao, dao.key()); - // The one and only read of the vestigial per-DAO threshold fields: - // live markets keep the rules they were created and staked under. - let pass_threshold_bps = if old_proposal_data.is_team_sponsored { - dao.team_sponsored_pass_threshold_bps + let action = ProposalAction::ExecuteArbitrary; + + // Draft proposals take the kind's catalog params like any new proposal. + // Launched proposals keep the rules they were launched under. + let (pass_threshold_bps, duration_in_seconds) = + if matches!(old_proposal_data.state, ProposalState::Draft { .. }) { + let params = action.params(); + (params.pass_threshold_bps, params.duration_seconds) + } else { + let pass_threshold_bps = if old_proposal_data.is_team_sponsored { + dao.team_sponsored_pass_threshold_bps + } else { + dao.pass_threshold_bps as i16 + }; + (pass_threshold_bps, old_proposal_data.duration_in_seconds) + }; + + // A legacy sponsorship was signed by the team of its day, which is + // still the DAO's team. Before this migration, we never had a team change. + let sponsored_by = if old_proposal_data.is_team_sponsored { + Some(dao.team_address) } else { - dao.pass_threshold_bps as i16 + None }; let new_proposal_data = Proposal { @@ -59,16 +77,16 @@ impl ResizeProposal<'_> { dao: old_proposal_data.dao, pda_bump: old_proposal_data.pda_bump, question: old_proposal_data.question, - duration_in_seconds: old_proposal_data.duration_in_seconds, + duration_in_seconds, squads_proposal: old_proposal_data.squads_proposal, pass_base_mint: old_proposal_data.pass_base_mint, pass_quote_mint: old_proposal_data.pass_quote_mint, fail_base_mint: old_proposal_data.fail_base_mint, fail_quote_mint: old_proposal_data.fail_quote_mint, - is_team_sponsored: old_proposal_data.is_team_sponsored, + sponsored_by, pass_threshold_bps, council_can_block: true, - action: ProposalAction::ExecuteArbitrary, + action, }; proposal.realloc(AFTER_REALLOC_SIZE, true)?; diff --git a/programs/futarchy/src/instructions/set_spending_limit.rs b/programs/futarchy/src/instructions/set_spending_limit.rs index b2c37bdba..cc5144e29 100644 --- a/programs/futarchy/src/instructions/set_spending_limit.rs +++ b/programs/futarchy/src/instructions/set_spending_limit.rs @@ -24,11 +24,7 @@ impl SetSpendingLimit<'_> { require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); if let Some(config) = &args.config { - require_gte!( - MAX_SPENDING_LIMIT_MEMBERS, - config.members.len(), - FutarchyError::TooManySpendingLimitMembers - ); + config.validate()?; } Ok(()) diff --git a/programs/futarchy/src/instructions/sponsor_proposal.rs b/programs/futarchy/src/instructions/sponsor_proposal.rs index 725aa6292..e9ccb9a05 100644 --- a/programs/futarchy/src/instructions/sponsor_proposal.rs +++ b/programs/futarchy/src/instructions/sponsor_proposal.rs @@ -12,14 +12,22 @@ pub struct SponsorProposal<'info> { impl SponsorProposal<'_> { pub fn validate(&self) -> Result<()> { + require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); + require!( matches!(self.proposal.state, ProposalState::Draft { .. }), FutarchyError::ProposalNotInDraftState ); - require_neq!( - self.proposal.is_team_sponsored, - true, + require!( + self.proposal.action.params().team_sponsorship_policy + != TeamSponsorshipPolicy::Forbidden, + FutarchyError::TeamSponsorshipForbidden + ); + + // A previous team's sponsorship can be replaced, the current team's can't be repeated. + require!( + !self.proposal.is_sponsored_by(self.dao.team_address), FutarchyError::ProposalAlreadySponsored ); @@ -35,7 +43,7 @@ impl SponsorProposal<'_> { program: _, } = ctx.accounts; - proposal.is_team_sponsored = true; + proposal.sponsored_by = Some(team_address.key()); dao.seq_num += 1; diff --git a/programs/futarchy/src/instructions/spot_swap.rs b/programs/futarchy/src/instructions/spot_swap.rs index 9f45b5088..09aa2f801 100644 --- a/programs/futarchy/src/instructions/spot_swap.rs +++ b/programs/futarchy/src/instructions/spot_swap.rs @@ -43,12 +43,6 @@ pub struct SpotSwap<'info> { } impl SpotSwap<'_> { - pub fn validate(&self) -> Result<()> { - require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated); - - Ok(()) - } - pub fn handle(ctx: Context, params: SpotSwapParams) -> Result<()> { let SpotSwapParams { swap_type, diff --git a/programs/futarchy/src/instructions/typed_initialize.rs b/programs/futarchy/src/instructions/typed_initialize.rs index cc87aafae..ff9fb74e6 100644 --- a/programs/futarchy/src/instructions/typed_initialize.rs +++ b/programs/futarchy/src/instructions/typed_initialize.rs @@ -146,7 +146,7 @@ impl TypedInitializeAccounts<'_> { fail_base_mint: self.base_vault.conditional_token_mints[0], pass_quote_mint: self.quote_vault.conditional_token_mints[1], fail_quote_mint: self.quote_vault.conditional_token_mints[0], - is_team_sponsored: false, + sponsored_by: None, pass_threshold_bps: params.pass_threshold_bps, council_can_block: params.council_can_block, action, diff --git a/programs/futarchy/src/instructions/update_dao.rs b/programs/futarchy/src/instructions/update_dao.rs index 6a140e2ca..4acacf080 100644 --- a/programs/futarchy/src/instructions/update_dao.rs +++ b/programs/futarchy/src/instructions/update_dao.rs @@ -12,7 +12,6 @@ pub struct UpdateDaoParams { pub base_to_stake: Option, pub team_sponsored_pass_threshold_bps: Option, pub team_address: Option, - pub is_optimistic_governance_enabled: Option, } #[derive(Accounts)] @@ -77,9 +76,7 @@ impl UpdateDao<'_> { .unwrap_or(dao.team_sponsored_pass_threshold_bps), team_address: dao_params.team_address.unwrap_or(dao.team_address), optimistic_proposal: dao.optimistic_proposal.clone(), - is_optimistic_governance_enabled: dao_params - .is_optimistic_governance_enabled - .unwrap_or(dao.is_optimistic_governance_enabled), + is_optimistic_governance_enabled: dao.is_optimistic_governance_enabled, liquidator: dao.liquidator, last_failed_takeover_at: dao.last_failed_takeover_at, last_failed_liquidation_at: dao.last_failed_liquidation_at, diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 5a83362a7..561fba53f 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -106,7 +106,7 @@ pub mod futarchy { InitializeHostileTakeoverProposal::handle(ctx, args) } - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(&args))] pub fn initialize_hostile_liquidate_proposal( ctx: Context, args: InitializeHostileLiquidateProposalArgs, @@ -168,11 +168,6 @@ pub mod futarchy { SyncSpendingLimit::handle(ctx) } - #[access_control(ctx.accounts.validate())] - pub fn apply_liquidation(ctx: Context) -> Result<()> { - ApplyLiquidation::handle(ctx) - } - pub fn resize_dao(ctx: Context) -> Result<()> { ResizeDao::handle(ctx) } @@ -183,7 +178,6 @@ pub mod futarchy { // AMM instructions - #[access_control(ctx.accounts.validate())] pub fn spot_swap(ctx: Context, params: SpotSwapParams) -> Result<()> { SpotSwap::handle(ctx, params) } @@ -241,6 +235,21 @@ pub mod futarchy { ExecuteMultisigProposalApproval::handle(ctx) } + #[access_control(ctx.accounts.validate(&args))] + pub fn admin_enqueue_multisig_proposal_cancellation( + ctx: Context, + args: AdminEnqueueMultisigProposalCancellationArgs, + ) -> Result<()> { + AdminEnqueueMultisigProposalCancellation::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn execute_multisig_proposal_cancellation( + ctx: Context, + ) -> Result<()> { + ExecuteMultisigProposalCancellation::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn admin_execute_multisig_proposal<'c: 'info, 'info>( ctx: Context<'_, '_, 'c, 'info, AdminExecuteMultisigProposal<'info>>, diff --git a/programs/futarchy/src/state/dao.rs b/programs/futarchy/src/state/dao.rs index 413692783..ffe80a89e 100644 --- a/programs/futarchy/src/state/dao.rs +++ b/programs/futarchy/src/state/dao.rs @@ -67,10 +67,13 @@ pub struct Dao { /// Can be negative to allow for team-sponsored proposals to pass by default. pub team_sponsored_pass_threshold_bps: i16, pub team_address: Pubkey, + /// Deprecated in favor of typed proposals pub optimistic_proposal: Option, + /// Deprecated in favor of typed proposals pub is_optimistic_governance_enabled: bool, /// `Some` means the DAO has been liquidated, and holds who runs the estate. - /// Set once by `apply_liquidation`, never cleared. + /// Set once by `finalize_proposal` the moment a hostile liquidation + /// passes, never cleared. pub liquidator: Option, /// Unix time of the last failed hostile takeover. 0 = never. pub last_failed_takeover_at: i64, @@ -98,7 +101,50 @@ pub struct InitialSpendingLimit { pub members: Vec, } +impl InitialSpendingLimit { + /// Rejects any record that the Squads spending-limit invariant would refuse + /// to create, so every stored record can be projected by `sync_spending_limit`. + pub fn validate(&self) -> Result<()> { + require_neq!( + self.amount_per_month, + 0, + FutarchyError::InvalidSpendingLimitAmount + ); + + require!( + !self.members.is_empty(), + FutarchyError::EmptySpendingLimitMembers + ); + + require_gte!( + MAX_SPENDING_LIMIT_MEMBERS, + self.members.len(), + FutarchyError::TooManySpendingLimitMembers + ); + + let mut sorted_members = self.members.clone(); + sorted_members.sort(); + let has_duplicates = sorted_members.windows(2).any(|win| win[0] == win[1]); + require!(!has_duplicates, FutarchyError::DuplicateSpendingLimitMember); + + Ok(()) + } +} + impl Dao { + /// A migrated `Dao` account is exactly this long. + pub const MIGRATED_SIZE: usize = Dao::INIT_SPACE + 8; + + /// Errors unless `resize_dao` has migrated the account. + pub fn assert_migrated(account: &AccountInfo) -> Result<()> { + require_eq!( + account.data_len(), + Dao::MIGRATED_SIZE, + FutarchyError::AccountNotMigrated + ); + Ok(()) + } + pub fn invariant(&self) -> Result<()> { require_gte!( self.seconds_per_proposal, diff --git a/programs/futarchy/src/state/enqueued_multisig_proposal_cancellation.rs b/programs/futarchy/src/state/enqueued_multisig_proposal_cancellation.rs new file mode 100644 index 000000000..945f046c8 --- /dev/null +++ b/programs/futarchy/src/state/enqueued_multisig_proposal_cancellation.rs @@ -0,0 +1,11 @@ +use super::*; + +pub const SEED_ENQUEUED_MULTISIG_PROPOSAL_CANCELLATION: &[u8] = b"enqueued_cancellation"; + +#[account] +#[derive(InitSpace)] +pub struct EnqueuedMultisigProposalCancellation { + pub dao: Pubkey, + pub transaction_index: u64, + pub pda_bump: u8, +} diff --git a/programs/futarchy/src/state/mod.rs b/programs/futarchy/src/state/mod.rs index 6726cecfb..95c0cb6bd 100644 --- a/programs/futarchy/src/state/mod.rs +++ b/programs/futarchy/src/state/mod.rs @@ -1,6 +1,7 @@ pub mod amm_position; pub mod dao; pub mod enqueued_multisig_proposal_approval; +pub mod enqueued_multisig_proposal_cancellation; pub mod futarchy_amm; pub mod proposal; pub mod proposal_action; @@ -9,6 +10,7 @@ pub mod stake_account; pub use amm_position::*; pub use dao::*; pub use enqueued_multisig_proposal_approval::*; +pub use enqueued_multisig_proposal_cancellation::*; pub use futarchy_amm::*; pub use proposal::*; pub use proposal_action::*; diff --git a/programs/futarchy/src/state/proposal.rs b/programs/futarchy/src/state/proposal.rs index 9053d227f..9e1562209 100644 --- a/programs/futarchy/src/state/proposal.rs +++ b/programs/futarchy/src/state/proposal.rs @@ -39,7 +39,8 @@ pub struct Proposal { pub pass_quote_mint: Pubkey, pub fail_base_mint: Pubkey, pub fail_quote_mint: Pubkey, - pub is_team_sponsored: bool, + /// The team that last sponsored the proposal. `None` = never sponsored. + pub sponsored_by: Option, /// Snapshot of the kind's threshold at create. pub pass_threshold_bps: i16, /// Snapshot of the kind's blockable flag at create. @@ -48,6 +49,26 @@ pub struct Proposal { pub action: ProposalAction, } +impl Proposal { + /// Whether the sponsorship is by the DAO's current team. + pub fn is_sponsored_by(&self, team_address: Pubkey) -> bool { + self.sponsored_by == Some(team_address) + } + + /// A migrated `Proposal` account is exactly this long. + pub const MIGRATED_SIZE: usize = Proposal::INIT_SPACE + 8; + + /// Errors unless `resize_proposal` has migrated the account. + pub fn assert_migrated(account: &AccountInfo) -> Result<()> { + require_eq!( + account.data_len(), + Proposal::MIGRATED_SIZE, + FutarchyError::AccountNotMigrated + ); + Ok(()) + } +} + #[account] #[derive(InitSpace)] pub struct OldProposal { diff --git a/programs/futarchy/src/state/proposal_action.rs b/programs/futarchy/src/state/proposal_action.rs index 193ebb7cb..067579161 100644 --- a/programs/futarchy/src/state/proposal_action.rs +++ b/programs/futarchy/src/state/proposal_action.rs @@ -2,14 +2,24 @@ use super::*; pub const DAY_SECONDS: u32 = 24 * 60 * 60; +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum TeamSponsorshipPolicy { + /// Must be team-sponsored to launch. + Required, + /// May be team-sponsored. Sponsorship waives the stake. + Optional, + /// Cannot be team-sponsored. + Forbidden, +} + #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] pub struct InstructionParams { pub duration_seconds: u32, /// Signed: a negative threshold lets a proposal pass even when the pass /// price is below the fail price. pub pass_threshold_bps: i16, - /// Launch condition: the proposal must be team-sponsored to launch. - pub requires_team_sponsorship: bool, + /// Sponsorship policy + pub team_sponsorship_policy: TeamSponsorshipPolicy, pub council_can_block: bool, /// Cooldown checked at launch. 0 = none. pub cooldown_seconds: u32, @@ -32,6 +42,9 @@ pub enum SpendingLimitAction { pub enum ProposalAction { LargeSpend { amount: u64, + /// The team the baked transfer pays, snapshotted at create. Launch + /// requires it to still be the DAO's team. + team_address: Pubkey, }, MintTokens { amount: u64, @@ -52,7 +65,8 @@ pub enum ProposalAction { BuybackToken { /// Total quote to deploy. Capped at 25% of the treasury. quote_amount: u64, - quote_amount_per_cycle: u64, + /// Orders the total is split across. At least 2. + cycle_count: u32, /// Seconds between orders. cycle_frequency_seconds: u32, /// Seconds after execution before the first order. 0 = immediately. @@ -73,7 +87,7 @@ impl ProposalAction { ProposalAction::LargeSpend { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 3 / 2, // 1.5 days pass_threshold_bps: -1000, - requires_team_sponsorship: true, + team_sponsorship_policy: TeamSponsorshipPolicy::Required, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS / 2, @@ -81,7 +95,7 @@ impl ProposalAction { ProposalAction::MintTokens { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 5, pass_threshold_bps: 500, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -89,7 +103,7 @@ impl ProposalAction { ProposalAction::SpendingLimitChange { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 5, pass_threshold_bps: 500, - requires_team_sponsorship: true, + team_sponsorship_policy: TeamSponsorshipPolicy::Required, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -97,7 +111,7 @@ impl ProposalAction { ProposalAction::ExecuteArbitrary => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: 0, twap_start_delay_seconds: DAY_SECONDS, @@ -105,7 +119,7 @@ impl ProposalAction { ProposalAction::HostileTakeover { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 20, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Forbidden, council_can_block: true, cooldown_seconds: DAY_SECONDS * 20, twap_start_delay_seconds: DAY_SECONDS, @@ -113,7 +127,7 @@ impl ProposalAction { ProposalAction::HostileLiquidate { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 2500, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Forbidden, council_can_block: true, cooldown_seconds: DAY_SECONDS * 10, twap_start_delay_seconds: DAY_SECONDS, @@ -121,7 +135,7 @@ impl ProposalAction { ProposalAction::BuybackToken { .. } => InstructionParams { duration_seconds: DAY_SECONDS * 10, pass_threshold_bps: 1000, - requires_team_sponsorship: false, + team_sponsorship_policy: TeamSponsorshipPolicy::Optional, council_can_block: true, cooldown_seconds: DAY_SECONDS * 90, twap_start_delay_seconds: DAY_SECONDS, @@ -140,18 +154,56 @@ impl ProposalAction { ProposalAction::BuybackToken { quote_amount, .. } => { verify_buyback_treasury_cap(*quote_amount, dao, accounts) } + ProposalAction::LargeSpend { + amount, + team_address, + } => verify_large_spend_launch(*amount, *team_address, dao, accounts), _ => { - require_eq!( - accounts.len(), - 0, - FutarchyError::UnexpectedLaunchAccounts - ); + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); Ok(()) } } } } +/// The large-spend launch gate: no extra accounts, and the create-time checks +/// re-run against current state. +fn verify_large_spend_launch( + amount: u64, + team_address: Pubkey, + dao: &Dao, + accounts: &[AccountInfo], +) -> Result<()> { + require_eq!(accounts.len(), 0, FutarchyError::UnexpectedLaunchAccounts); + + verify_large_spend_cap(amount, dao)?; + + require_keys_eq!( + team_address, + dao.team_address, + FutarchyError::StaleTeamAddress + ); + + Ok(()) +} + +/// The three-month spending cap, checked against the DAO's current record. +/// Run at both create and launch. +pub fn verify_large_spend_cap(amount: u64, dao: &Dao) -> Result<()> { + let record = dao + .initial_spending_limit + .as_ref() + .ok_or(FutarchyError::NoSpendingLimit)?; + + require_gte!( + record.amount_per_month.saturating_mul(3), + amount, + FutarchyError::SpendCapExceeded + ); + + Ok(()) +} + /// The 25% treasury cap, measured from the supplied account list. Launch is /// permissionless, so the list is considered adversarial input. fn verify_buyback_treasury_cap<'info>( @@ -220,11 +272,14 @@ fn verify_buyback_treasury_cap<'info>( dao.squads_multisig_vault, FutarchyError::InvalidTreasuryAccount ); - // The quote a withdrawal would deliver right now. + // The quote a withdrawal would deliver, with the pool valued at + // its rate-limited observation: `min(quote, base × observation)` if dao.amm.total_liquidity > 0 { - treasury_quote += spot - .get_quote_withdrawable(position.liquidity, dao.amm.total_liquidity) - as u128; + let quote_at_observation = (spot.base_reserves as u128) + .saturating_mul(spot.oracle.last_observation) + / PRICE_SCALE; + let quote_reserves = (spot.quote_reserves as u128).min(quote_at_observation); + treasury_quote += position.liquidity * quote_reserves / dao.amm.total_liquidity; } } else { return err!(FutarchyError::InvalidTreasuryAccount); diff --git a/rebuild.sh b/rebuild.sh index 953ae8e22..d4c4daab7 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -6,6 +6,6 @@ cd sdk yarn install yarn build-local cd .. -yarn install --force +yarn install yarn lint:fix echo "✅ rebuild complete" \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index 325a5bf71..b9bf77a65 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,22 +4,13 @@ A collection of scripts to interact with various parts of the futarchy protocol ## Setup -It's best to use these scripts with the latest build of the SDK. - -To do this, run the following commands, starting in the root dir of the repository: +The scripts import `@metadaoproject/programs` from the root `node_modules`, where it is a symlink to `sdk/`, so they always run against your local SDK build. No linking is needed. From the root of the repository: ```sh -cd sdk # Move into the repository dir - -yarn -yarn build -yarn link - -cd .. # Move back into the root dir -yarn link @metadaoproject/futarchy # Link to your local build of the futarchy sdk +./rebuild.sh ``` -Afterwards, you can run the scripts as you see fit. +This builds the programs, installs and builds the SDK, and installs the root dependencies. See [Development Setup](../README.md#development-setup) in the main README for toolchain prerequisites. If the programs are already built and you only changed SDK code, `cd sdk && yarn build-local` is enough. ## Launchpad diff --git a/scripts/assets/CARS/CARS.json b/scripts/assets/CARS/CARS.json new file mode 100644 index 000000000..b282750e3 --- /dev/null +++ b/scripts/assets/CARS/CARS.json @@ -0,0 +1,6 @@ +{ + "name": "Rip Cars", + "symbol": "CARS", + "description": "The world’s first Hot Wheels gacha platform", + "image": "https://raw.githubusercontent.com/metaDAOproject/futarchy/refs/heads/develop/scripts/assets/CARS/CARS.png" +} \ No newline at end of file diff --git a/scripts/assets/CARS/CARS.png b/scripts/assets/CARS/CARS.png new file mode 100644 index 000000000..a3294f036 Binary files /dev/null and b/scripts/assets/CARS/CARS.png differ diff --git a/scripts/assets/LOYAL/LOYAL.json b/scripts/assets/LOYAL/LOYAL.json index 0b3984b32..a22e59760 100644 --- a/scripts/assets/LOYAL/LOYAL.json +++ b/scripts/assets/LOYAL/LOYAL.json @@ -1,6 +1,15 @@ { "name": "Loyal", "symbol": "LOYAL", - "description": "Loyal is a Solana-based private decentralized intelligence protocol. It enables full data ownership, auditable and decentralized compute and complete censorship resistance.", - "image": "https://raw.githubusercontent.com/metaDAOproject/futarchy/refs/heads/develop/scripts/assets/LOYAL/LOYAL.png" -} \ No newline at end of file + "description": "Loyal is self-custodial agentic finance on Solana. Agents make DeFi safer and simpler to use, and you keep your keys and the final say. Open source and governed by MetaDAO futarchy, with the treasury in a DAO-controlled multisig and protocol fees flowing back to it.", + "image": "https://raw.githubusercontent.com/metaDAOproject/futarchy/refs/heads/develop/scripts/assets/LOYAL/LOYAL.png", + "external_url": "https://askloyal.com", + "extensions": { + "website": "https://askloyal.com", + "app": "https://app.askloyal.com", + "docs": "https://docs.askloyal.com", + "twitter": "https://x.com/loyal_hq", + "telegram": "https://t.me/loyal_tgchat", + "github": "https://github.com/loyal-labs" + } +} diff --git a/scripts/v0.7/resizeDaos.ts b/scripts/v0.7/resizeDaos.ts index d11ceb4f3..b4443525b 100644 --- a/scripts/v0.7/resizeDaos.ts +++ b/scripts/v0.7/resizeDaos.ts @@ -27,7 +27,9 @@ async function main() { const daoDiscriminator = getDiscriminator("Dao"); - const batchSize = 20; + // Each resize now references two per-DAO accounts (dao + spending limit); + // 10 keeps the transaction under the 1232-byte packet limit. + const batchSize = 10; console.log(`Dao discriminator (hex): ${daoDiscriminator.toString("hex")}`); console.log(`Program ID: ${futarchyClient.getProgramId().toBase58()}\n`); @@ -58,12 +60,8 @@ async function main() { const ixs = await Promise.all( batch.map(async ({ pubkey }) => { - return await autocrat.methods - .resizeDao() - .accounts({ - dao: pubkey, - payer: payer.publicKey, - }) + return await futarchyClient + .resizeDaoIx({ dao: pubkey, payer: payer.publicKey }) .instruction(); }), ); @@ -85,6 +83,13 @@ async function main() { console.log( ` Optimistic governance enabled: ${dao.isOptimisticGovernanceEnabled}`, ); + console.log( + ` Spending limit: ${ + dao.initialSpendingLimit + ? `${dao.initialSpendingLimit.amountPerMonth.toString()}/mo, ${dao.initialSpendingLimit.members.length} member(s)` + : "none" + } (dirty: ${dao.spendingLimitDirty})`, + ); } } diff --git a/scripts/v0.7/rip-cars/constants.ts b/scripts/v0.7/rip-cars/constants.ts new file mode 100644 index 000000000..96ba0c86c --- /dev/null +++ b/scripts/v0.7/rip-cars/constants.ts @@ -0,0 +1,44 @@ +import { PublicKey } from "@solana/web3.js"; + +// Token Details +export const TOKEN_SEED = "TVOzl2TKhXCRVn9U"; +export const TOKEN_ADDRESS = new PublicKey( + "CARSsxWPkpQWvfyRBwfGMGvysJBHdHGfE46X5MNgmeta", +); + +export const LAUNCH_AUTHORITY = new PublicKey( + "LncRyJVBbek7EFhnd6QNZRTLT9mReKWPGCpsfu5bqJS", +); + +// Team Config Details +export const TEAM_ADDRESS = new PublicKey( + "CnTDWPAEsN5RAgNapTJerDAc45TWavRQ1m3ACMpukYPd", +); // Rip Cars team squads address + +// export const LAUNCH_ADDRESS = new PublicKey(""); + +export const SPENDING_MEMBERS = [ + new PublicKey("CqK6aBSSycQU3igvptxvhf9YNC1v5EodCuCxuVLPPohh"), + new PublicKey("4Huto5Lv8z59tW4EezrYNSvBJhCc9U5bgmR5yH5csFDc"), +]; +// Even without a performance package, defaults need to be set +export const PERFORMANCE_PACKAGE_GRANTEE = new PublicKey( + "7iiE6ncVh5uJuBKw7JcwCjhUT8o2VT2JMeQZ8Tsi5Ckf", +); + +// Amount Details +export const MIN_GOAL = 250_000; // 250k USDC +export const SPENDING_LIMIT = 40_000; // 40k USDC +export const PERFORMANCE_PACKAGE_TOKEN_AMOUNT = 12_900_000; // 12.9M CARS +export const TOTAL_ALLOCATION = 250_000; // 250k USDC +export const PERFORMANCE_PACKAGE_UNLOCK_MONTHS = 18; // 18 months +export const ADDITIONAL_CARVEOUT = 0; // 0 CARS +export const ADDITIONAL_CARVEOUT_RECIPIENT = new PublicKey( + "11111111111111111111111111111111", +); // Unused +export const LAUNCH_DAYS = 4; + +export const TOKEN_NAME = "Rip Cars"; +export const TOKEN_SYMBOL = "CARS"; +export const TOKEN_URI = + "https://raw.githubusercontent.com/metaDAOproject/programs/refs/heads/develop/scripts/assets/CARS/CARS.json"; diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index fdf4830b3..e51b91517 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -57,6 +57,8 @@ import { } from "./types/v0.6.1-futarchy.js"; import { getDaoAddr, + getEnqueuedMultisigProposalApprovalAddr, + getEnqueuedMultisigProposalCancellationAddr, getProposalAddr, getProposalAddrV2, getProposalAddrsForTransactionIndex, @@ -1222,15 +1224,15 @@ export class FutarchyClient { .signers([PERMISSIONLESS_ACCOUNT]); } - // The payload is one apply_liquidation call whose accounts — including this - // proposal's own not-yet-created PDA — the program bakes by derivation from - // the next transaction index. The liquidator is stored in `action`. + // The payload is the IP-transfer memo alone — finalize_proposal performs the + // state flip, and the liquidator (stored in `action`) unwinds the treasury + // position afterward through the estate cycle. async initializeHostileLiquidateProposal({ dao, - liquidator, + liquidator = METADAO_MULTISIG_VAULT, }: { dao: PublicKey; - liquidator: PublicKey; + liquidator?: PublicKey; }): Promise<{ proposal: PublicKey; squadsProposal: PublicKey; @@ -1253,7 +1255,7 @@ export class FutarchyClient { dao, baseMint, quoteMint, - liquidator, + liquidator = METADAO_MULTISIG_VAULT, transactionIndex, proposer = this.provider.publicKey, payer = this.provider.publicKey, @@ -1261,7 +1263,7 @@ export class FutarchyClient { dao: PublicKey; baseMint: PublicKey; quoteMint: PublicKey; - liquidator: PublicKey; + liquidator?: PublicKey; transactionIndex: bigint; proposer?: PublicKey; payer?: PublicKey; @@ -1287,7 +1289,7 @@ export class FutarchyClient { async initializeBuybackTokenProposal({ dao, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1295,7 +1297,7 @@ export class FutarchyClient { }: { dao: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1313,7 +1315,7 @@ export class FutarchyClient { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1328,7 +1330,7 @@ export class FutarchyClient { baseMint, quoteMint, quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice = null, @@ -1341,7 +1343,7 @@ export class FutarchyClient { baseMint: PublicKey; quoteMint: PublicKey; quoteAmount: BN; - quoteAmountPerCycle: BN; + cycleCount: number; cycleFrequencySeconds: number; startDelaySeconds: number; minPrice?: BN | null; @@ -1353,7 +1355,7 @@ export class FutarchyClient { return this.futarchy.methods .initializeBuybackTokenProposal({ quoteAmount, - quoteAmountPerCycle, + cycleCount, cycleFrequencySeconds, startDelaySeconds, minPrice, @@ -1538,6 +1540,22 @@ export class FutarchyClient { }); } + resizeDaoIx({ + dao, + payer = this.provider.publicKey, + }: { + dao: PublicKey; + payer?: PublicKey; + }) { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + + return this.futarchy.methods.resizeDao().accounts({ + dao, + spendingLimit, + payer, + }); + } + stakeToProposalIx({ proposal, dao, @@ -1700,6 +1718,118 @@ export class FutarchyClient { }); } + adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex, + admin = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + admin?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedApproval] = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + }); + } + + executeMultisigProposalApprovalIx({ + dao, + transactionIndex, + rentReceiver = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + rentReceiver?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedApproval] = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods.executeMultisigProposalApproval().accounts({ + dao, + rentReceiver, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + }); + } + + adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex, + admin = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + admin?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedCancellation] = getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .adminEnqueueMultisigProposalCancellation({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao, + admin, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedCancellation, + }); + } + + executeMultisigProposalCancellationIx({ + dao, + transactionIndex, + rentReceiver = this.provider.publicKey, + }: { + dao: PublicKey; + transactionIndex: bigint; + rentReceiver?: PublicKey; + }) { + const { squadsMultisig, squadsProposal } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + const [enqueuedCancellation] = getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + }); + + return this.futarchy.methods + .executeMultisigProposalCancellation() + .accounts({ + dao, + rentReceiver, + squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedCancellation, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + }); + } + collectMeteoraDammFeesIx({ dao, baseMint, diff --git a/sdk/src/futarchy/v0.6/pda.ts b/sdk/src/futarchy/v0.6/pda.ts index b2796fb49..eb28f9253 100644 --- a/sdk/src/futarchy/v0.6/pda.ts +++ b/sdk/src/futarchy/v0.6/pda.ts @@ -1,5 +1,6 @@ -import { BN, utils } from "@coral-xyz/anchor"; +import { utils } from "@coral-xyz/anchor"; import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; import * as multisig from "@sqds/multisig"; import { FUTARCHY_V0_6_PROGRAM_ID } from "../../constants.js"; @@ -81,6 +82,44 @@ export const getProposalAddrsForTransactionIndex = ({ }; }; +export const getEnqueuedMultisigProposalApprovalAddr = ({ + dao, + transactionIndex, + programId = FUTARCHY_V0_6_PROGRAM_ID, +}: { + dao: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("enqueued_approval"), + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + programId, + ); +}; + +export const getEnqueuedMultisigProposalCancellationAddr = ({ + dao, + transactionIndex, + programId = FUTARCHY_V0_6_PROGRAM_ID, +}: { + dao: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("enqueued_cancellation"), + dao.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + programId, + ); +}; + // The Squads spending-limit PDA — `create_key` is always the DAO, so the // address is derivable from the DAO alone. export const getSpendingLimitAddr = ({ diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 3ffccf28b..668d31092 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -1313,86 +1313,22 @@ export type Futarchy = { args: []; }, { - name: "applyLiquidation"; + name: "resizeDao"; accounts: [ - { - name: "proposal"; - isMut: false; - isSigner: false; - docs: [ - "The linked liquidation proposal, baked into the payload at create.", - ]; - }, { name: "dao"; isMut: true; isSigner: false; }, { - name: "squadsMultisigVault"; + name: "spendingLimit"; isMut: false; - isSigner: true; - docs: [ - "The vault's signature is only obtainable through a Squads vault", - "transaction execution, so the caller is a passed proposal's payload.", - ]; - }, - { - name: "ammPosition"; - isMut: true; isSigner: false; docs: [ - "seeds, but whether the account exists at execution is unknowable at", - "create, so it is parsed manually — a passed liquidation must never", - "brick on treasury shape.", + "spending-limit PDA (`create_key` is always the DAO); read-only and may", + "not exist", ]; }, - { - name: "ammBaseVault"; - isMut: true; - isSigner: false; - }, - { - name: "ammQuoteVault"; - isMut: true; - isSigner: false; - }, - { - name: "vaultBaseAccount"; - isMut: true; - isSigner: false; - }, - { - name: "vaultQuoteAccount"; - isMut: true; - isSigner: false; - }, - { - name: "tokenProgram"; - isMut: false; - isSigner: false; - }, - { - name: "eventAuthority"; - isMut: false; - isSigner: false; - }, - { - name: "program"; - isMut: false; - isSigner: false; - }, - ]; - args: []; - }, - { - name: "resizeDao"; - accounts: [ - { - name: "dao"; - isMut: true; - isSigner: false; - }, { name: "payer"; isMut: true; @@ -2077,6 +2013,85 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminEnqueueMultisigProposalCancellation"; + accounts: [ + { + name: "dao"; + isMut: false; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: false; + isSigner: false; + }, + { + name: "enqueuedCancellation"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "AdminEnqueueMultisigProposalCancellationArgs"; + }; + }, + ]; + }, + { + name: "executeMultisigProposalCancellation"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "rentReceiver"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "enqueuedCancellation"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminExecuteMultisigProposal"; accounts: [ @@ -2480,6 +2495,7 @@ export type Futarchy = { }, { name: "optimisticProposal"; + docs: ["Deprecated in favor of typed proposals"]; type: { option: { defined: "OptimisticProposal"; @@ -2488,13 +2504,15 @@ export type Futarchy = { }, { name: "isOptimisticGovernanceEnabled"; + docs: ["Deprecated in favor of typed proposals"]; type: "bool"; }, { name: "liquidator"; docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ]; type: { option: "publicKey"; @@ -2696,6 +2714,26 @@ export type Futarchy = { ]; }; }, + { + name: "enqueuedMultisigProposalCancellation"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -2764,8 +2802,13 @@ export type Futarchy = { type: "publicKey"; }, { - name: "isTeamSponsored"; - type: "bool"; + name: "sponsoredBy"; + docs: [ + "The team that last sponsored the proposal. `None` = never sponsored.", + ]; + type: { + option: "publicKey"; + }; }, { name: "passThresholdBps"; @@ -2919,6 +2962,18 @@ export type Futarchy = { ]; }; }, + { + name: "AdminEnqueueMultisigProposalCancellationArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "transactionIndex"; + type: "u64"; + }, + ]; + }; + }, { name: "AdminUpdateProposalParamsArgs"; type: { @@ -2977,8 +3032,8 @@ export type Futarchy = { type: "u64"; }, { - name: "quoteAmountPerCycle"; - type: "u64"; + name: "cycleCount"; + type: "u32"; }, { name: "cycleFrequencySeconds"; @@ -3299,12 +3354,6 @@ export type Futarchy = { option: "publicKey"; }; }, - { - name: "isOptimisticGovernanceEnabled"; - type: { - option: "bool"; - }; - }, ]; }; }, @@ -3525,11 +3574,11 @@ export type Futarchy = { type: "i16"; }, { - name: "requiresTeamSponsorship"; - docs: [ - "Launch condition: the proposal must be team-sponsored to launch.", - ]; - type: "bool"; + name: "teamSponsorshipPolicy"; + docs: ["Sponsorship policy"]; + type: { + defined: "TeamSponsorshipPolicy"; + }; }, { name: "councilCanBlock"; @@ -3635,6 +3684,23 @@ export type Futarchy = { ]; }; }, + { + name: "TeamSponsorshipPolicy"; + type: { + kind: "enum"; + variants: [ + { + name: "Required"; + }, + { + name: "Optional"; + }, + { + name: "Forbidden"; + }, + ]; + }; + }, { name: "SpendingLimitAction"; docs: ["What a hostile takeover declares for the spending limit."]; @@ -3675,6 +3741,14 @@ export type Futarchy = { name: "amount"; type: "u64"; }, + { + name: "teamAddress"; + docs: [ + "The team the baked transfer pays, snapshotted at create. Launch", + "requires it to still be the DAO's team.", + ]; + type: "publicKey"; + }, ]; }, { @@ -3739,8 +3813,9 @@ export type Futarchy = { type: "u64"; }, { - name: "quoteAmountPerCycle"; - type: "u64"; + name: "cycleCount"; + docs: ["Orders the total is split across. At least 2."]; + type: "u32"; }, { name: "cycleFrequencySeconds"; @@ -4790,50 +4865,6 @@ export type Futarchy = { }, ]; }, - { - name: "ApplyLiquidationEvent"; - fields: [ - { - name: "common"; - type: { - defined: "CommonFields"; - }; - index: false; - }, - { - name: "dao"; - type: "publicKey"; - index: false; - }, - { - name: "proposal"; - type: "publicKey"; - index: false; - }, - { - name: "liquidator"; - type: "publicKey"; - index: false; - }, - { - name: "baseSwept"; - type: "u64"; - index: false; - }, - { - name: "quoteSwept"; - type: "u64"; - index: false; - }, - { - name: "postAmmState"; - type: { - defined: "FutarchyAmm"; - }; - index: false; - }, - ]; - }, ]; errors: [ { @@ -5093,69 +5124,114 @@ export type Futarchy = { }, { code: 6051; - name: "AlreadyLiquidated"; - msg: "This DAO has already been liquidated"; - }, - { - code: 6052; name: "TooManySpendingLimitMembers"; msg: "A spending limit can have at most 10 members"; }, { - code: 6053; + code: 6052; name: "InvalidLiquidator"; msg: "Invalid liquidator"; }, { - code: 6054; + code: 6053; name: "InvalidProposalPassThreshold"; msg: "Pass threshold must be between -99.99% and 99.99%"; }, { - code: 6055; + code: 6054; name: "EmptyProposalParamsUpdate"; msg: "A proposal params update must set at least one field"; }, { - code: 6056; + code: 6055; name: "BuybackCapExceeded"; msg: "Buyback amount exceeds 25% of the treasury"; }, { - code: 6057; + code: 6056; name: "InvalidBuybackAmount"; - msg: "The total must be an exact multiple of the non-zero per-cycle amount, at least twice over"; + msg: "Buyback total must be non-zero"; }, { - code: 6058; + code: 6057; name: "InvalidBuybackCycleFrequency"; msg: "Cycle frequency must be between 60 seconds and 1 year"; }, { - code: 6059; + code: 6058; name: "InvalidBuybackStartDelay"; msg: "Start delay must be at most 30 days"; }, { - code: 6060; + code: 6059; name: "InvalidBuybackPriceBand"; msg: "min_price must be no greater than max_price"; }, { - code: 6061; + code: 6060; name: "InvalidTreasuryAccount"; msg: "A treasury account is neither a vault-owned quote account nor the treasury's AMM position"; }, { - code: 6062; + code: 6061; name: "TreasuryAccountsNotSorted"; msg: "Treasury accounts must be in strictly ascending key order"; }, { - code: 6063; + code: 6062; name: "UnexpectedLaunchAccounts"; msg: "This proposal kind's launch takes no extra accounts"; }, + { + code: 6063; + name: "InvalidSpendingLimitAccount"; + msg: "Spending limit account is not the canonical spending-limit PDA"; + }, + { + code: 6064; + name: "StaleTeamAddress"; + msg: "The DAO's team has changed since this draft was created"; + }, + { + code: 6065; + name: "AccountNotMigrated"; + msg: "Account is not migrated to latest layout"; + }, + { + code: 6066; + name: "InvalidSpendingLimitAmount"; + msg: "A spending limit's monthly amount must be non-zero"; + }, + { + code: 6067; + name: "EmptySpendingLimitMembers"; + msg: "A spending limit must have at least one member"; + }, + { + code: 6068; + name: "DuplicateSpendingLimitMember"; + msg: "A spending limit's members must be unique"; + }, + { + code: 6069; + name: "InvalidBuybackCycleCount"; + msg: "A buyback must run at least two cycles"; + }, + { + code: 6070; + name: "InvalidTeamAddress"; + msg: "Invalid team address"; + }, + { + code: 6071; + name: "TeamSponsorshipForbidden"; + msg: "This proposal kind cannot be team-sponsored"; + }, + { + code: 6072; + name: "SquadsProposalNotApproved"; + msg: "Squads proposal must be in Approved status to be cancelled"; + }, ]; }; @@ -6426,110 +6502,37 @@ export const IDL: Futarchy = { }, { name: "syncSpendingLimit", - accounts: [ - { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "squadsMultisig", - isMut: true, - isSigner: false, - }, - { - name: "spendingLimit", - isMut: true, - isSigner: false, - }, - { - name: "rentPayer", - isMut: true, - isSigner: true, - docs: [ - "Pays rent when the limit is recreated and receives freed rent when it is removed.", - ], - }, - { - name: "squadsProgram", - isMut: false, - isSigner: false, - }, - { - name: "systemProgram", - isMut: false, - isSigner: false, - }, - { - name: "eventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "program", - isMut: false, - isSigner: false, - }, - ], - args: [], - }, - { - name: "applyLiquidation", - accounts: [ - { - name: "proposal", - isMut: false, - isSigner: false, - docs: [ - "The linked liquidation proposal, baked into the payload at create.", - ], - }, - { - name: "dao", - isMut: true, - isSigner: false, - }, - { - name: "squadsMultisigVault", - isMut: false, - isSigner: true, - docs: [ - "The vault's signature is only obtainable through a Squads vault", - "transaction execution, so the caller is a passed proposal's payload.", - ], - }, + accounts: [ { - name: "ammPosition", + name: "dao", isMut: true, isSigner: false, - docs: [ - "seeds, but whether the account exists at execution is unknowable at", - "create, so it is parsed manually — a passed liquidation must never", - "brick on treasury shape.", - ], }, { - name: "ammBaseVault", + name: "squadsMultisig", isMut: true, isSigner: false, }, { - name: "ammQuoteVault", + name: "spendingLimit", isMut: true, isSigner: false, }, { - name: "vaultBaseAccount", + name: "rentPayer", isMut: true, - isSigner: false, + isSigner: true, + docs: [ + "Pays rent when the limit is recreated and receives freed rent when it is removed.", + ], }, { - name: "vaultQuoteAccount", - isMut: true, + name: "squadsProgram", + isMut: false, isSigner: false, }, { - name: "tokenProgram", + name: "systemProgram", isMut: false, isSigner: false, }, @@ -6554,6 +6557,15 @@ export const IDL: Futarchy = { isMut: true, isSigner: false, }, + { + name: "spendingLimit", + isMut: false, + isSigner: false, + docs: [ + "spending-limit PDA (`create_key` is always the DAO); read-only and may", + "not exist", + ], + }, { name: "payer", isMut: true, @@ -7238,6 +7250,85 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminEnqueueMultisigProposalCancellation", + accounts: [ + { + name: "dao", + isMut: false, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: false, + isSigner: false, + }, + { + name: "enqueuedCancellation", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "AdminEnqueueMultisigProposalCancellationArgs", + }, + }, + ], + }, + { + name: "executeMultisigProposalCancellation", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "rentReceiver", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "enqueuedCancellation", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminExecuteMultisigProposal", accounts: [ @@ -7641,6 +7732,7 @@ export const IDL: Futarchy = { }, { name: "optimisticProposal", + docs: ["Deprecated in favor of typed proposals"], type: { option: { defined: "OptimisticProposal", @@ -7649,13 +7741,15 @@ export const IDL: Futarchy = { }, { name: "isOptimisticGovernanceEnabled", + docs: ["Deprecated in favor of typed proposals"], type: "bool", }, { name: "liquidator", docs: [ "`Some` means the DAO has been liquidated, and holds who runs the estate.", - "Set once by `apply_liquidation`, never cleared.", + "Set once by `finalize_proposal` the moment a hostile liquidation", + "passes, never cleared.", ], type: { option: "publicKey", @@ -7857,6 +7951,26 @@ export const IDL: Futarchy = { ], }, }, + { + name: "enqueuedMultisigProposalCancellation", + type: { + kind: "struct", + fields: [ + { + name: "dao", + type: "publicKey", + }, + { + name: "transactionIndex", + type: "u64", + }, + { + name: "pdaBump", + type: "u8", + }, + ], + }, + }, { name: "proposal", type: { @@ -7925,8 +8039,13 @@ export const IDL: Futarchy = { type: "publicKey", }, { - name: "isTeamSponsored", - type: "bool", + name: "sponsoredBy", + docs: [ + "The team that last sponsored the proposal. `None` = never sponsored.", + ], + type: { + option: "publicKey", + }, }, { name: "passThresholdBps", @@ -8080,6 +8199,18 @@ export const IDL: Futarchy = { ], }, }, + { + name: "AdminEnqueueMultisigProposalCancellationArgs", + type: { + kind: "struct", + fields: [ + { + name: "transactionIndex", + type: "u64", + }, + ], + }, + }, { name: "AdminUpdateProposalParamsArgs", type: { @@ -8138,8 +8269,8 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "quoteAmountPerCycle", - type: "u64", + name: "cycleCount", + type: "u32", }, { name: "cycleFrequencySeconds", @@ -8460,12 +8591,6 @@ export const IDL: Futarchy = { option: "publicKey", }, }, - { - name: "isOptimisticGovernanceEnabled", - type: { - option: "bool", - }, - }, ], }, }, @@ -8686,11 +8811,11 @@ export const IDL: Futarchy = { type: "i16", }, { - name: "requiresTeamSponsorship", - docs: [ - "Launch condition: the proposal must be team-sponsored to launch.", - ], - type: "bool", + name: "teamSponsorshipPolicy", + docs: ["Sponsorship policy"], + type: { + defined: "TeamSponsorshipPolicy", + }, }, { name: "councilCanBlock", @@ -8796,6 +8921,23 @@ export const IDL: Futarchy = { ], }, }, + { + name: "TeamSponsorshipPolicy", + type: { + kind: "enum", + variants: [ + { + name: "Required", + }, + { + name: "Optional", + }, + { + name: "Forbidden", + }, + ], + }, + }, { name: "SpendingLimitAction", docs: ["What a hostile takeover declares for the spending limit."], @@ -8836,6 +8978,14 @@ export const IDL: Futarchy = { name: "amount", type: "u64", }, + { + name: "teamAddress", + docs: [ + "The team the baked transfer pays, snapshotted at create. Launch", + "requires it to still be the DAO's team.", + ], + type: "publicKey", + }, ], }, { @@ -8900,8 +9050,9 @@ export const IDL: Futarchy = { type: "u64", }, { - name: "quoteAmountPerCycle", - type: "u64", + name: "cycleCount", + docs: ["Orders the total is split across. At least 2."], + type: "u32", }, { name: "cycleFrequencySeconds", @@ -9951,50 +10102,6 @@ export const IDL: Futarchy = { }, ], }, - { - name: "ApplyLiquidationEvent", - fields: [ - { - name: "common", - type: { - defined: "CommonFields", - }, - index: false, - }, - { - name: "dao", - type: "publicKey", - index: false, - }, - { - name: "proposal", - type: "publicKey", - index: false, - }, - { - name: "liquidator", - type: "publicKey", - index: false, - }, - { - name: "baseSwept", - type: "u64", - index: false, - }, - { - name: "quoteSwept", - type: "u64", - index: false, - }, - { - name: "postAmmState", - type: { - defined: "FutarchyAmm", - }, - index: false, - }, - ], - }, ], errors: [ { @@ -10254,68 +10361,113 @@ export const IDL: Futarchy = { }, { code: 6051, - name: "AlreadyLiquidated", - msg: "This DAO has already been liquidated", - }, - { - code: 6052, name: "TooManySpendingLimitMembers", msg: "A spending limit can have at most 10 members", }, { - code: 6053, + code: 6052, name: "InvalidLiquidator", msg: "Invalid liquidator", }, { - code: 6054, + code: 6053, name: "InvalidProposalPassThreshold", msg: "Pass threshold must be between -99.99% and 99.99%", }, { - code: 6055, + code: 6054, name: "EmptyProposalParamsUpdate", msg: "A proposal params update must set at least one field", }, { - code: 6056, + code: 6055, name: "BuybackCapExceeded", msg: "Buyback amount exceeds 25% of the treasury", }, { - code: 6057, + code: 6056, name: "InvalidBuybackAmount", - msg: "The total must be an exact multiple of the non-zero per-cycle amount, at least twice over", + msg: "Buyback total must be non-zero", }, { - code: 6058, + code: 6057, name: "InvalidBuybackCycleFrequency", msg: "Cycle frequency must be between 60 seconds and 1 year", }, { - code: 6059, + code: 6058, name: "InvalidBuybackStartDelay", msg: "Start delay must be at most 30 days", }, { - code: 6060, + code: 6059, name: "InvalidBuybackPriceBand", msg: "min_price must be no greater than max_price", }, { - code: 6061, + code: 6060, name: "InvalidTreasuryAccount", msg: "A treasury account is neither a vault-owned quote account nor the treasury's AMM position", }, { - code: 6062, + code: 6061, name: "TreasuryAccountsNotSorted", msg: "Treasury accounts must be in strictly ascending key order", }, { - code: 6063, + code: 6062, name: "UnexpectedLaunchAccounts", msg: "This proposal kind's launch takes no extra accounts", }, + { + code: 6063, + name: "InvalidSpendingLimitAccount", + msg: "Spending limit account is not the canonical spending-limit PDA", + }, + { + code: 6064, + name: "StaleTeamAddress", + msg: "The DAO's team has changed since this draft was created", + }, + { + code: 6065, + name: "AccountNotMigrated", + msg: "Account is not migrated to latest layout", + }, + { + code: 6066, + name: "InvalidSpendingLimitAmount", + msg: "A spending limit's monthly amount must be non-zero", + }, + { + code: 6067, + name: "EmptySpendingLimitMembers", + msg: "A spending limit must have at least one member", + }, + { + code: 6068, + name: "DuplicateSpendingLimitMember", + msg: "A spending limit's members must be unique", + }, + { + code: 6069, + name: "InvalidBuybackCycleCount", + msg: "A buyback must run at least two cycles", + }, + { + code: 6070, + name: "InvalidTeamAddress", + msg: "Invalid team address", + }, + { + code: 6071, + name: "TeamSponsorshipForbidden", + msg: "This proposal kind cannot be team-sponsored", + }, + { + code: 6072, + name: "SquadsProposalNotApproved", + msg: "Squads proposal must be in Approved status to be cancelled", + }, ], }; diff --git a/sdk/src/futarchy/v0.6/types/index.ts b/sdk/src/futarchy/v0.6/types/index.ts index 62a6691a4..d6153fbbb 100644 --- a/sdk/src/futarchy/v0.6/types/index.ts +++ b/sdk/src/futarchy/v0.6/types/index.ts @@ -33,6 +33,8 @@ export type InitializeHostileTakeoverProposalArgs = export type InitializeBuybackTokenProposalArgs = IdlTypes["InitializeBuybackTokenProposalArgs"]; export type InstructionParams = IdlTypes["InstructionParams"]; +export type TeamSponsorshipPolicy = + IdlTypes["TeamSponsorshipPolicy"]; export type SpendingLimitAction = IdlTypes["SpendingLimitAction"]; export type ProposalAction = IdlTypes["ProposalAction"]; diff --git a/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts b/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts new file mode 100644 index 000000000..2358e704a --- /dev/null +++ b/tests/futarchy/integration/cancelApprovedPayloadAfterLiquidation.test.ts @@ -0,0 +1,266 @@ +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { + executeVaultTransaction, + expectError, + passProposal, +} from "../../utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +// A payload the team had approved before a liquidation stays executable +// afterwards; the cancellation set is how the liquidator kills it. +export default function suite() { + it("lets the liquidator cancel a large spend approved before the liquidation", async function () { + const META = await this.createMint(this.payer.publicKey, 6); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const team = Keypair.generate(); + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + // 10% per update: TWAPs converge to actual prices fast enough that + // a pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [this.payer.publicKey], + }, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: team.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + const vault = (await this.futarchy.getDao(dao)).squadsMultisigVault; + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The team's large spend passes, and nobody executes it + const amount = new BN(3_000_000_000); // 3,000 USDC + await this.createTokenAccount(USDC, team.publicKey); + + const { + proposal: spendProposal, + squadsProposal: spendSquadsProposal, + squadsTransaction: spendSquadsTransaction, + } = await this.futarchy.initializeLargeSpendProposal({ dao, amount }); + + await this.futarchy + .sponsorProposalIx({ + proposal: spendProposal, + dao, + teamAddress: team.publicKey, + }) + .signers([team]) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal: spendProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: spendSquadsProposal, + }) + .rpc(); + + // Uncontested: one swap after the kind's 12-hour TWAP delay leaves the + // TWAPs equal, which clears the sponsored -10% threshold + await this.advanceBySeconds(60 * 60 * 12 + 60); + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(1_000), + }) + .rpc(); + await this.advanceBySeconds(129_600); + await this.futarchy.finalizeProposal(spendProposal); + + let storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedSpend.status), + ); + + // Fund the treasury ATA the payload pulls from, so only the cancellation + // stands between the old team and the money + await this.createTokenAccount(USDC, vault); + await this.mintTo(USDC, vault, this.payer, amount.toNumber()); + + // A hostile liquidation passes + const liquidator = Keypair.generate(); + const { + proposal: liquidateProposal, + squadsProposal: liquidateSquadsProposal, + } = await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal: liquidateProposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal: liquidateSquadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal: liquidateProposal, + baseMint: META, + quoteMint: USDC, + cranks: 50, + }); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.liquidator.equals(liquidator.publicKey)); + + // The spend is still a standing mandate + storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusApproved(storedSpend.status), + ); + + // The liquidator pays rent for the enqueued cancellation account + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: liquidator.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // The spend was the DAO's first Squads transaction + const spendTransactionIndex = 1n; + + const callbacks = expectError( + "InvalidLiquidator", + "enqueue by a non-liquidator should fail on a liquidated DAO", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // The liquidator enqueues; anyone runs the cancel + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalCancellationIx({ + dao, + transactionIndex: spendTransactionIndex, + }) + .rpc(); + + storedSpend = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + spendSquadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedSpend.status), + ); + assert.deepEqual( + storedSpend.cancelled.map((k) => k.toBase58()), + [dao.toBase58()], + ); + + // The payload can no longer move the estate + try { + await executeVaultTransaction(this, dao, spendSquadsTransaction); + assert.fail("Should have thrown error"); + } catch (e) { + // Squads' InvalidProposalStatus (0x1778 = 6008) + assert.isTrue(e.toString().includes("0x1778"), `unexpected error: ${e}`); + } + + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + amount.toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, team.publicKey)).toString(), + "0", + ); + }); +} diff --git a/tests/futarchy/integration/cooldownRoundTrip.test.ts b/tests/futarchy/integration/cooldownRoundTrip.test.ts index f078aa704..3289fba5d 100644 --- a/tests/futarchy/integration/cooldownRoundTrip.test.ts +++ b/tests/futarchy/integration/cooldownRoundTrip.test.ts @@ -116,6 +116,8 @@ export default function suite() { storedDao.lastFailedLiquidationAt.toString(), clock.unixTimestamp.toString(), ); + // Only a PASSED liquidation reserves the DAO at finalize + assert.isNull(storedDao.liquidator); // An immediate relaunch is refused const second = await this.futarchy.initializeHostileLiquidateProposal({ diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index b74c7ede8..6d6062aa6 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -81,7 +81,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/integration/gatedLiquidationUnwind.test.ts b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts new file mode 100644 index 000000000..bc01828bf --- /dev/null +++ b/tests/futarchy/integration/gatedLiquidationUnwind.test.ts @@ -0,0 +1,501 @@ +import { + FUTARCHY_V0_6_PROGRAM_ID, + GatedMintClient, + getDaoAddr, + getEventAuthorityAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, + PriceMath, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { executeVaultTransaction, passProposal } from "../../utils.js"; +import { + setupGatedMint, + whitelistUser, + freezeTokenAccount, + getTokenAccountState, + TOKEN_STATE_FROZEN, + TOKEN_STATE_INITIALIZED, +} from "../../gatedMint/utils.js"; + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +export default function suite() { + it("liquidates a gated DAO with a frozen AMM base vault and unwinds via gated_invoke", async function () { + const gatedMintClient = GatedMintClient.createClient({ + provider: this.provider as any, + }); + + const gatedAdmin = Keypair.generate(); + const { mint: GATED } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + gatedAdmin.publicKey, + ); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(GATED, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + GATED, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: GATED, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + // 10% per update: TWAPs converge to actual prices fast enough that + // the pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + const storedDaoBefore = await this.futarchy.getDao(dao); + const vault = storedDaoBefore.squadsMultisigVault; + + // The unwind destination: the vault's ATAs + const vaultBaseAta = await this.createTokenAccount(GATED, vault); + const vaultQuoteAta = await this.createTokenAccount(USDC, vault); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 GATED + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The treasury's own LP position, unwound after liquidation + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(25_000 * 1_000_000), // 25,000 USDC + maxBaseAmount: new BN(25 * 1_000_000), // 25 GATED + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // The liquidator's cooperative whitelisted caller for the gated leg + const unwinder = Keypair.generate(); + await whitelistUser( + gatedMintClient, + GATED, + gatedAdmin, + unwinder.publicKey, + this.payer, + ); + + const liquidator = Keypair.generate(); + + const { proposal, squadsProposal, squadsTransaction } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: GATED, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: GATED, + quoteMint: USDC, + cranks: 50, + }); + + const ammBaseVault = getAssociatedTokenAddressSync(GATED, dao, true); + const ammQuoteVault = getAssociatedTokenAddressSync(USDC, dao, true); + + // The gated ratchet has left the AMM base vault frozen on a liquidated + // DAO + await freezeTokenAccount(this.context, this.banksClient, ammBaseVault); + + // The memo payload touches no accounts, so the immutable transaction + // executes even against the frozen vault — where the old token-moving + // payload rolled back forever + await executeVaultTransaction(this, dao, squadsTransaction); + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusExecuted(storedSquadsProposal.status), + ); + + // The liquidator pays rent for the enqueued approval account + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: liquidator.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // The unwind payload, authored at unwind time when "is this mint gated?" + // is a known fact: gated_invoke thaws the frozen vault, invokes + // withdraw_liquidity with the Squads-minted vault signature, and refreezes + const preUnwindDao = await this.futarchy.getDao(dao); + const preUnwindSpot = preUnwindDao.amm.state.spot.spot; + + const [treasuryPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, + ); + const storedTreasuryPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + const expectedBase = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.baseReserves) + .div(preUnwindDao.amm.totalLiquidity); + const expectedQuote = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.quoteReserves) + .div(preUnwindDao.amm.totalLiquidity); + + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const withdrawIx = await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: storedTreasuryPosition.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }) + .accounts({ + dao, + positionAuthority: vault, + liquidityProviderBaseAccount: vaultBaseAta, + liquidityProviderQuoteAccount: vaultQuoteAta, + ammBaseVault, + ammQuoteVault, + ammPosition: treasuryPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, + }) + .instruction(); + + const gatedWithdrawIx = await gatedMintClient + .gatedInvokeIx({ + caller: unwinder.publicKey, + mint: GATED, + instruction: withdrawIx, + }) + .instruction(); + + // The memo payload was transaction 1; the estate starts at 2 + const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [gatedWithdrawIx], + transactionIndex: 2n, + }); + estateCreateTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + estateCreateTx.feePayer = this.payer.publicKey; + estateCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(estateCreateTx); + + const { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 2n, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) + .rpc(); + + // The whitelisted caller co-signs the execution alongside the Squads + // member; the vault's signature comes from Squads itself + await executeVaultTransaction( + this, + dao, + estateSquadsTransaction, + [ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 })], + [unwinder], + ); + + // The sweep landed + const postUnwindPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + assert.equal(postUnwindPosition.liquidity.toString(), "0"); + + assert.equal( + (await this.getTokenBalance(GATED, vault)).toString(), + expectedBase.toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + expectedQuote.toString(), + ); + + // The ratchet: every gated-mint account in the invoke ends frozen, the + // AMM vault again and the swept base alongside it + assert.equal( + await getTokenAccountState(this.banksClient, ammBaseVault), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, vaultBaseAta), + TOKEN_STATE_FROZEN, + ); + + // The quote leg is not gated, so the swept quote stays spendable + assert.equal( + await getTokenAccountState(this.banksClient, vaultQuoteAta), + TOKEN_STATE_INITIALIZED, + ); + }); + + it("a liquidated DAO holding gating authority drops the gate through its liquidator", async function () { + const gatedMintClient = GatedMintClient.createClient({ + provider: this.provider as any, + }); + + // The DAO treasury itself is the gating admin. The vault address is + // derivable before the DAO exists, so the mint is configured up front + const nonce = new BN(Math.floor(Math.random() * 1000000)); + const [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + const [multisigPda] = multisig.getMultisigPda({ createKey: dao }); + const [vault] = multisig.getVaultPda({ multisigPda, index: 0 }); + + const { mint: GATED, gatedMintConfig } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + vault, + ); + const USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(GATED, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + GATED, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + await this.futarchy + .initializeDaoIx({ + baseMint: GATED, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + // 10% per update: TWAPs converge to actual prices fast enough that + // the pumped pass market clears HostileLiquidate's +25% + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const storedDao = await this.futarchy.getDao(dao); + assert.ok(storedDao.squadsMultisigVault.equals(vault)); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: GATED, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 GATED + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const liquidator = Keypair.generate(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeHostileLiquidateProposal({ + dao, + liquidator: liquidator.publicKey, + }); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: GATED, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao, + proposal, + baseMint: GATED, + quoteMint: USDC, + cranks: 50, + }); + + // The ratcheted estate the gate drop must free + const ammBaseVault = getAssociatedTokenAddressSync(GATED, dao, true); + await freezeTokenAccount(this.context, this.banksClient, ammBaseVault); + + // The liquidator pays rent for the enqueued approval account + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: liquidator.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // The estate cycle carries disable_gating: the config's admin is the + // vault, whose signature Squads mints at execution + const disableGatingIx = await gatedMintClient + .disableGatingIx({ mint: GATED, admin: vault }) + .instruction(); + + // The memo payload was transaction 1; the estate starts at 2 + const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions: [disableGatingIx], + transactionIndex: 2n, + }); + estateCreateTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + estateCreateTx.feePayer = this.payer.publicKey; + estateCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(estateCreateTx); + + const { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 2n, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) + .rpc(); + + await executeVaultTransaction(this, dao, estateSquadsTransaction); + + const storedConfig = + await gatedMintClient.program.account.gatedMintConfig.fetch( + gatedMintConfig, + ); + assert.isTrue(storedConfig.gatingDisabled); + + // With the gate down, the frozen estate thaws permissionlessly + await gatedMintClient + .thawAccountIx({ mint: GATED, tokenAccount: ammBaseVault }) + .rpc(); + assert.equal( + await getTokenAccountState(this.banksClient, ammBaseVault), + TOKEN_STATE_INITIALIZED, + ); + }); +} diff --git a/tests/futarchy/integration/liquidationEndToEnd.test.ts b/tests/futarchy/integration/liquidationEndToEnd.test.ts index 637468f70..0e46e183b 100644 --- a/tests/futarchy/integration/liquidationEndToEnd.test.ts +++ b/tests/futarchy/integration/liquidationEndToEnd.test.ts @@ -13,8 +13,7 @@ import { PublicKey, SystemProgram, Transaction, - TransactionMessage, - VersionedTransaction, + TransactionInstruction, } from "@solana/web3.js"; import { createTransferInstruction, @@ -24,21 +23,16 @@ import { import BN from "bn.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; -import { - createLookupTableForTransaction, - executeVaultTransaction, - pumpPassMarket, -} from "../../utils.js"; +import { executeVaultTransaction, passProposal } from "../../utils.js"; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); -// The no-window path: finalize + execute + sync land as one -// transaction, then the liquidated DAO runs as an estate — liquidator-gated -// enqueue, permissionless approve, ordinary Squads execution — while +// The lazy-unwind path: finalize bricks the DAO (liquidator written, limit +// zeroed), the payload is ceremony (memo only), and the treasury position +// exits afterward through a liquidator-authored estate cycle, while // third-party LPs exit on their own schedule. export default function suite() { - it("liquidates in one transaction, runs the estate cycle, and lets a third-party LP exit", async function () { + it("liquidates at finalize, unwinds the treasury through the estate cycle, and lets a third-party LP exit", async function () { const META = await this.createMint(this.payer.publicKey, 6); const USDC = await this.createMint(this.payer.publicKey, 6); @@ -93,9 +87,8 @@ export default function suite() { const storedDaoBefore = await this.futarchy.getDao(dao); const vault = storedDaoBefore.squadsMultisigVault; - const multisigPda = storedDaoBefore.squadsMultisig; - // The baked apply_liquidation payload requires the vault's ATAs to exist + // The unwind destination: the vault's ATAs await this.createTokenAccount(META, vault); await this.createTokenAccount(USDC, vault); @@ -116,7 +109,7 @@ export default function suite() { ]) .rpc(); - // The treasury's own LP position, swept at liquidation + // The treasury's own LP position, unwound after liquidation await this.futarchy .provideLiquidityIx({ dao, @@ -151,9 +144,7 @@ export default function suite() { }) .rpc(); - // Runs out the 10-day snapshot with the pass market above +25%, without - // finalizing — finalize rides in the packed transaction below - await pumpPassMarket(this, { + await passProposal(this, { dao, proposal, baseMint: META, @@ -161,78 +152,34 @@ export default function suite() { cranks: 50, }); - // finalize + execute + sync packed in ONE transaction: the DAO never - // exists in a passed-but-not-liquidated state - const vaultTransaction = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - squadsTransaction, - ); - const packIxs = [ - await this.futarchy - .finalizeProposalIxV2({ - squadsProposal, - dao, - baseMint: META, - quoteMint: USDC, - }) - .instruction(), - ( - await multisig.instructions.vaultTransactionExecute({ - connection: this.squadsConnection, - multisigPda, - transactionIndex: BigInt(vaultTransaction.index.toString()), - member: PERMISSIONLESS_ACCOUNT.publicKey, - }) - ).instruction, - await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), - ]; - - const lut = await createLookupTableForTransaction( - new Transaction().add(...packIxs), - this, - ); - - const packMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [ - ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), - ...packIxs, - ], - }).compileToV0Message([lut]); - const packTx = new VersionedTransaction(packMessage); - packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); - await this.banksClient.processTransaction(packTx); - - // The liquidated end state, all landed by the single transaction - const storedProposal = await this.futarchy.getProposal(proposal); - assert.exists(storedProposal.state.passed); - - const storedDao = await this.futarchy.getDao(dao); + // Finalize marks the DAO as liquidated: the liquidator is installed and the + // spending-limit record zeroed before any payload runs + let storedDao = await this.futarchy.getDao(dao); assert.ok(storedDao.liquidator.equals(liquidator.publicKey)); assert.isNull(storedDao.initialSpendingLimit); - assert.isFalse(storedDao.spendingLimitDirty); + assert.isTrue(storedDao.spendingLimitDirty); + + // The permissionless sync removes the Squads-side limit, so the outgoing + // team's pull rights die before any funds reach the vault + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); + storedDao = await this.futarchy.getDao(dao); + assert.isFalse(storedDao.spendingLimitDirty); const [spendingLimitPda] = getSpendingLimitAddr({ dao }); assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); - const [treasuryPosition] = PublicKey.findProgramAddressSync( - [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], - FUTARCHY_V0_6_PROGRAM_ID, + // The ceremonial payload + await executeVaultTransaction(this, dao, squadsTransaction); + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusExecuted(storedSquadsProposal.status), ); - const storedTreasuryPosition = - await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); - assert.equal(storedTreasuryPosition.liquidity.toString(), "0"); - - const sweptBase = await this.getTokenBalance(META, vault); - const sweptQuote = await this.getTokenBalance(USDC, vault); - assert.isTrue(sweptBase > 0n); - assert.isTrue(sweptQuote > 0n); - // The estate cycle: the liquidator enqueues a distribution from the swept - // treasury, the approval executes permissionlessly, and ordinary Squads - // execution pays out + // The liquidator pays rent for the enqueued approval accounts const fundTx = new Transaction().add( SystemProgram.transfer({ fromPubkey: this.payer.publicKey, @@ -245,87 +192,100 @@ export default function suite() { fundTx.sign(this.payer); await this.banksClient.processTransaction(fundTx); - const recipient = Keypair.generate().publicKey; - const recipientAta = await this.createTokenAccount(USDC, recipient); - const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + // One estate cycle: liquidator-authored vault transaction, liquidator + // enqueue, permissionless approve, ordinary Squads execution + const runEstateCycle = async ( + transactionIndex: bigint, + instructions: TransactionInstruction[], + ) => { + const { tx: createTx } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex, + }); + createTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createTx.feePayer = this.payer.publicKey; + createTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(createTx); - // The liquidation payload was transaction 1; the estate starts at 2 - const { tx: estateCreateTx } = this.futarchy.squadsProposalCreateTx({ - dao, - instructions: [ - createTransferInstruction( - vaultUsdcAta, - recipientAta, - vault, - 600 * 1_000_000, - ), - ], - transactionIndex: 2n, - }); - estateCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - estateCreateTx.feePayer = this.payer.publicKey; - estateCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - await this.banksClient.processTransaction(estateCreateTx); - - const { - squadsProposal: estateSquadsProposal, - squadsTransaction: estateSquadsTransaction, - } = getProposalAddrsForTransactionIndex({ dao, transactionIndex: 2n }); - - const [enqueuedApproval] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(2).toArrayLike(Buffer, "le", 8), - ], - this.futarchy.futarchy.programId, + const { squadsTransaction: estateSquadsTransaction } = + getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex, + admin: liquidator.publicKey, + }) + .signers([liquidator]) + .rpc(); + + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) + .rpc(); + + await executeVaultTransaction(this, dao, estateSquadsTransaction); + }; + + // Estate cycle #1 unwinds the treasury position into the vault's ATAs. + const preUnwindDao = await this.futarchy.getDao(dao); + const preUnwindSpot = preUnwindDao.amm.state.spot.spot; + + const [treasuryPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, ); + const storedTreasuryPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + const expectedBase = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.baseReserves) + .div(preUnwindDao.amm.totalLiquidity); + const expectedQuote = storedTreasuryPosition.liquidity + .mul(preUnwindSpot.quoteReserves) + .div(preUnwindDao.amm.totalLiquidity); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ - dao, - admin: liquidator.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: estateSquadsProposal, - enqueuedApproval, + const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); + const withdrawIx = await this.futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw: storedTreasuryPosition.liquidity, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), }) - .signers([liquidator]) - .rpc(); - - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() .accounts({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: estateSquadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, + positionAuthority: vault, + liquidityProviderBaseAccount: getAssociatedTokenAddressSync( + META, + vault, + true, + ), + liquidityProviderQuoteAccount: getAssociatedTokenAddressSync( + USDC, + vault, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), + ammPosition: treasuryPosition, + tokenProgram: TOKEN_PROGRAM_ID, + eventAuthority, + program: FUTARCHY_V0_6_PROGRAM_ID, }) - .rpc(); + .instruction(); - const storedEstateProposal = - await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - estateSquadsProposal, - ); - assert.isTrue( - multisig.generated.isProposalStatusApproved(storedEstateProposal.status), - ); + // The memo payload was transaction 1; the estate starts at 2 + await runEstateCycle(2n, [withdrawIx]); - await executeVaultTransaction(this, dao, estateSquadsTransaction); + const postUnwindPosition = + await this.futarchy.futarchy.account.ammPosition.fetch(treasuryPosition); + assert.equal(postUnwindPosition.liquidity.toString(), "0"); - assert.equal( - (await this.getTokenBalance(USDC, recipient)).toString(), - (600 * 1_000_000).toString(), - ); - assert.equal( - (await this.getTokenBalance(USDC, vault)).toString(), - (sweptQuote - BigInt(600 * 1_000_000)).toString(), - ); + const sweptBase = await this.getTokenBalance(META, vault); + const sweptQuote = await this.getTokenBalance(USDC, vault); + assert.equal(sweptBase.toString(), expectedBase.toString()); + assert.equal(sweptQuote.toString(), expectedQuote.toString()); // Liquidation never traps third-party LPs: withdraw_liquidity is exempt // from the liquidated guards @@ -343,7 +303,6 @@ export default function suite() { const preBase = await this.getTokenBalance(META, this.payer.publicKey); const preQuote = await this.getTokenBalance(USDC, this.payer.publicKey); - const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); await this.futarchy.futarchy.methods .withdrawLiquidity({ liquidityToWithdraw: storedLpPosition.liquidity, @@ -382,5 +341,29 @@ export default function suite() { const postLpPosition = await this.futarchy.futarchy.account.ammPosition.fetch(lpPosition); assert.equal(postLpPosition.liquidity.toString(), "0"); + + // Estate cycle #2 distributes from the swept treasury + // Simply proof that the liquidator can move funds out of the DAO + const recipient = Keypair.generate().publicKey; + const recipientAta = await this.createTokenAccount(USDC, recipient); + const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); + + await runEstateCycle(3n, [ + createTransferInstruction( + vaultUsdcAta, + recipientAta, + vault, + 600 * 1_000_000, + ), + ]); + + assert.equal( + (await this.getTokenBalance(USDC, recipient)).toString(), + (600 * 1_000_000).toString(), + ); + assert.equal( + (await this.getTokenBalance(USDC, vault)).toString(), + (sweptQuote - BigInt(600 * 1_000_000)).toString(), + ); }); } diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 300f8f457..464753245 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -1,6 +1,8 @@ import futarchyAmm from "./integration/futarchyAmm.test.js"; import takeoverEndToEnd from "./integration/takeoverEndToEnd.test.js"; import liquidationEndToEnd from "./integration/liquidationEndToEnd.test.js"; +import cancelApprovedPayloadAfterLiquidation from "./integration/cancelApprovedPayloadAfterLiquidation.test.js"; +import gatedLiquidationUnwind from "./integration/gatedLiquidationUnwind.test.js"; import largeSpendEndToEnd from "./integration/largeSpendEndToEnd.test.js"; import cooldownRoundTrip from "./integration/cooldownRoundTrip.test.js"; @@ -13,11 +15,11 @@ import initializeHostileTakeoverProposal from "./unit/initializeHostileTakeoverP import initializeHostileLiquidateProposal from "./unit/initializeHostileLiquidateProposal.test.js"; import initializeBuybackTokenProposal from "./unit/initializeBuybackTokenProposal.test.js"; import launchProposal from "./unit/launchProposal.test.js"; +import sponsorProposal from "./unit/sponsorProposal.test.js"; import finalizeProposal from "./unit/finalizeProposal.test.js"; import updateDao from "./unit/updateDao.test.js"; import setSpendingLimit from "./unit/setSpendingLimit.test.js"; import syncSpendingLimit from "./unit/syncSpendingLimit.test.js"; -import applyLiquidation from "./unit/applyLiquidation.test.js"; import liquidatorPath from "./unit/liquidatorPath.test.js"; import liquidatedGuards from "./unit/liquidatedGuards.test.js"; @@ -30,6 +32,8 @@ import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import adminEnqueueMultisigProposalApproval from "./unit/adminEnqueueMultisigProposalApproval.test.js"; import executeMultisigProposalApproval from "./unit/executeMultisigProposalApproval.test.js"; +import adminEnqueueMultisigProposalCancellation from "./unit/adminEnqueueMultisigProposalCancellation.test.js"; +import executeMultisigProposalCancellation from "./unit/executeMultisigProposalCancellation.test.js"; import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; @@ -92,11 +96,11 @@ export default function suite() { initializeBuybackTokenProposal, ); describe("#launch_proposal", launchProposal); + describe("#sponsor_proposal", sponsorProposal); describe("#finalize_proposal", finalizeProposal); describe("#update_dao", updateDao); describe("#set_spending_limit", setSpendingLimit); describe("#sync_spending_limit", syncSpendingLimit); - describe("#apply_liquidation", applyLiquidation); describe("liquidator path", liquidatorPath); describe("liquidated guards", liquidatedGuards); @@ -115,6 +119,14 @@ export default function suite() { "#execute_multisig_proposal_approval", executeMultisigProposalApproval, ); + describe( + "#admin_enqueue_multisig_proposal_cancellation", + adminEnqueueMultisigProposalCancellation, + ); + describe( + "#execute_multisig_proposal_cancellation", + executeMultisigProposalCancellation, + ); describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); @@ -127,6 +139,11 @@ export default function suite() { describe("futarchy amm", futarchyAmm); describe("integration: takeover end to end", takeoverEndToEnd); describe("integration: liquidation end to end", liquidationEndToEnd); + describe( + "integration: cancel approved payload after liquidation", + cancelApprovedPayloadAfterLiquidation, + ); + describe("integration: gated liquidation unwind", gatedLiquidationUnwind); describe("integration: large spend end to end", largeSpendEndToEnd); describe("integration: cooldown round-trip", cooldownRoundTrip); } diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index 71da7216c..e99ac999e 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -13,7 +13,7 @@ import { } from "@solana/web3.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -75,7 +75,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -335,6 +334,219 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); + it("rejects a legacy-sized proposal that has not been migrated", async function () { + // Shrink the live proposal to the pre-migration allocation: the 8-byte + // discriminator plus the 339-byte Pending body, then 8 bytes standing in + // for the residue a legacy account carries past its Pending body. The + // residue decodes as council_can_block = false — exactly the value that + // would otherwise shield the proposal from cancellation — so the size + // guard must reject it before that flag is ever consulted. + const raw = await this.banksClient.getAccount(proposal); + const legacy = Buffer.concat([ + Buffer.from(raw.data.subarray(0, 347)), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + ]); + assert.equal(legacy.length, 355); + this.context.setAccount(proposal, { ...raw, data: legacy }); + + const crafted = await this.futarchy.getProposal(proposal); + assert.exists(crafted.state.pending); + assert.isFalse(crafted.councilCanBlock); + + const storedDao = await this.futarchy.getDao(dao); + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + ); + + const callbacks = expectError( + "AccountNotMigrated", + "cancelled an un-migrated legacy proposal", + ); + + await this.futarchy.futarchy.methods + .adminCancelProposal() + .accounts({ + proposal, + dao, + question, + squadsProposal: squadsProposalPda, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + dao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + dao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a legacy-sized DAO that has not been migrated", async function () { + // Shrink only the DAO to the pre-migration allocation; the proposal keeps + // its migrated size so its own guard passes. + await makeOldDaoLayout(this, dao); + + const storedDao = await this.futarchy.getDao(dao); + assert.exists(storedDao.amm.state.futarchy); + assert.isNull(storedDao.liquidator); + + const { + question, + baseVault, + quoteVault, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + } = this.futarchy.getProposalPdas( + proposal, + storedDao.baseMint, + storedDao.quoteMint, + dao, + ); + + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const [vaultEventAuthority] = getEventAuthorityAddr( + CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + ); + + const callbacks = expectError( + "AccountNotMigrated", + "cancelled a proposal on an un-migrated legacy DAO", + ); + + await this.futarchy.futarchy.methods + .adminCancelProposal() + .accounts({ + proposal, + dao, + question, + squadsProposal: squadsProposalPda, + squadsMultisig: multisigPda, + squadsMultisigProgram: SQUADS_PROGRAM_ID, + admin: this.payer.publicKey, + ammPassBaseVault: getAssociatedTokenAddressSync( + passBaseMint, + dao, + true, + ), + ammPassQuoteVault: getAssociatedTokenAddressSync( + passQuoteMint, + dao, + true, + ), + ammFailBaseVault: getAssociatedTokenAddressSync( + failBaseMint, + dao, + true, + ), + ammFailQuoteVault: getAssociatedTokenAddressSync( + failQuoteMint, + dao, + true, + ), + ammBaseVault: getAssociatedTokenAddressSync( + storedDao.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + storedDao.quoteMint, + dao, + true, + ), + vaultProgram: CONDITIONAL_VAULT_V0_4_PROGRAM_ID, + vaultEventAuthority, + quoteVault, + quoteVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.quoteMint, + quoteVault, + true, + ), + passQuoteMint, + failQuoteMint, + passBaseMint, + failBaseMint, + baseVault, + baseVaultUnderlyingTokenAccount: getAssociatedTokenAddressSync( + storedDao.baseMint, + baseVault, + true, + ), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + // This will be blockable in the future it("should cancel a live hostile proposal", async function () { // Fresh DAO — the suite DAO already has a live blockable proposal diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts index e210cea1b..fb5ee2f51 100644 --- a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -1,17 +1,19 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + getEnqueuedMultisigProposalApprovalAddr, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, + SystemProgram, Transaction, TransactionMessage, } from "@solana/web3.js"; -import { expectError } from "../../utils.js"; +import { expectError, makeOldDaoLayout } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -42,22 +44,6 @@ export default function suite() { }); }); - const deriveEnqueuedApprovalPda = ( - context: any, - daoKey: PublicKey, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - daoKey.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - const createSquadsVaultTxAndProposal = async function ( context: any, squadsMultisig: PublicKey, @@ -106,24 +92,15 @@ export default function suite() { it("should enqueue a proposal approval", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + const enqueuedApprovalPda = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex: 1n, + })[0]; - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); const enqueued = @@ -134,71 +111,90 @@ export default function suite() { assert.equal(enqueued.transactionIndex.toString(), "1"); }); - it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { + it("rejects a legacy-sized DAO whose residue decodes as a liquidator", async function () { const daoAccount = await this.futarchy.getDao(dao); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); + + // The attacker pays rent for the enqueued approval account + const attacker = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: attacker.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // Shrink the DAO to the pre-migration allocation and plant, immediately + // after its Spot-layout body, the bytes a legacy DAO carries there once a + // finalized proposal has collapsed its AMM from Futarchy back to Spot: + // `Some(attacker)` where the new layout reads `liquidator`, then zeros for + // the two timestamps, the dirty flag, and the buyback timestamp. + const residue = Buffer.concat([ + Buffer.from([1]), + attacker.publicKey.toBuffer(), + Buffer.alloc(25), + ]); + await makeOldDaoLayout(this, dao, {}, { residue }); + + // The account still decodes — with the attacker as the liquidator + // authority — so only the size guard stands between them and enqueueing. + const crafted = await this.futarchy.getDao(dao); + assert.equal(crafted.liquidator.toBase58(), attacker.publicKey.toBase58()); + assert.exists(crafted.amm.state.spot); + const callbacks = expectError( + "AccountNotMigrated", + "enqueued on an un-migrated legacy DAO", + ); + + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: 1n, + admin: attacker.publicKey, + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { // Launching a futarchy proposal creates a Squads proposal at index 1 and // moves the AMM out of Spot. Use that Squads proposal as our approval // target — any Active Squads proposal would do here; we just need one // that exists when the AMM is non-Spot. - const { squadsProposal } = await this.initializeAndLaunchProposal({ + await this.initializeAndLaunchProposal({ dao, instructions: [], }); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - const callbacks = expectError( "PoolNotInSpotState", "enqueue should fail when the AMM is not in Spot state", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); }); it("should fail when enqueuing twice for the same transaction_index", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); - - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); try { - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) @@ -214,37 +210,14 @@ export default function suite() { it("should fail with InvalidSquadsProposalStatus when the Squads proposal is no longer Active", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); const callbacks = expectError( @@ -252,15 +225,8 @@ export default function suite() { "second enqueue should fail because proposal is no longer Active", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) @@ -272,13 +238,12 @@ export default function suite() { it("should fail with RequireGtViolated when the Squads proposal is stale", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda: victimProposalPda } = - await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - "will be invalidated", - ); + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); const configTransactionIndex = 2n; const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ @@ -330,37 +295,19 @@ export default function suite() { multisigPda: daoAccount.squadsMultisig, transactionIndex: configTransactionIndex, }); - const configEnqueuedApprovalPda = deriveEnqueuedApprovalPda( - this, - dao, - configTransactionIndex, - ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(configTransactionIndex.toString()), - }) - .accounts({ + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); const configTransactionAccount = @@ -401,22 +348,13 @@ export default function suite() { .signers([this.payer]) .rpc(); - const victimEnqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); - const callbacks = expectError( "RequireGtViolated", "enqueue should fail because the proposal was invalidated by a later config tx", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: victimProposalPda, - enqueuedApproval: victimEnqueuedApprovalPda, - }) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts new file mode 100644 index 000000000..360fef702 --- /dev/null +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalCancellation.test.ts @@ -0,0 +1,228 @@ +import { + getEnqueuedMultisigProposalCancellationAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, + makeOldDaoLayout, +} from "../../utils.js"; +import { assert } from "chai"; +import { createMemoInstruction } 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, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + // A memo vault transaction + Active proposal at `transactionIndex` + const createPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [createMemoInstruction("hello world")], + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + }; + + // The same payload, force-approved so it is executable without a market + const createApprovedPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const addrs = await createPayload(context, transactionIndex); + await forceApproveSquadsProposal(context, addrs.squadsProposal); + return addrs; + }; + + it("enqueues a cancellation for an approved Squads proposal", async function () { + await createApprovedPayload(this, 1n); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + const [enqueuedCancellationPda] = + getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex: 1n, + }); + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalCancellation.fetch( + enqueuedCancellationPda, + ); + assert.equal(enqueued.dao.toBase58(), dao.toBase58()); + assert.equal(enqueued.transactionIndex.toString(), "1"); + }); + + it("fails with SquadsProposalNotApproved when the Squads proposal is still Active", async function () { + await createPayload(this, 1n); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "enqueue should fail while the Squads proposal is Active", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with SquadsProposalNotApproved when the Squads proposal was already executed", async function () { + const { squadsTransaction } = await createApprovedPayload(this, 1n); + await executeVaultTransaction(this, dao, squadsTransaction); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "enqueue should fail once the payload has executed", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when enqueuing twice for the same transaction_index", async function () { + await createApprovedPayload(this, 1n); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + try { + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + // The init constraint fails because the account already exists + // (system program error 0x0). + assert.include(e.message, "custom program error: 0x0"); + } + }); + + it("rejects a legacy-sized DAO whose residue decodes as a liquidator", async function () { + await createApprovedPayload(this, 1n); + + // The attacker pays rent for the enqueued cancellation account + const attacker = Keypair.generate(); + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: attacker.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.feePayer = this.payer.publicKey; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + // Shrink the DAO to the pre-migration allocation and plant, immediately + // after its Spot-layout body, the bytes a legacy DAO carries there once a + // finalized proposal has collapsed its AMM from Futarchy back to Spot: + // `Some(attacker)` where the new layout reads `liquidator`, then zeros for + // the two timestamps, the dirty flag, and the buyback timestamp. + const residue = Buffer.concat([ + Buffer.from([1]), + attacker.publicKey.toBuffer(), + Buffer.alloc(25), + ]); + await makeOldDaoLayout(this, dao, {}, { residue }); + + // The account still decodes — with the attacker as the liquidator + // authority — so only the size guard stands between them and enqueueing. + const crafted = await this.futarchy.getDao(dao); + assert.equal(crafted.liquidator.toBase58(), attacker.publicKey.toBase58()); + assert.exists(crafted.amm.state.spot); + + const callbacks = expectError( + "AccountNotMigrated", + "enqueued on an un-migrated legacy DAO", + ); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + admin: attacker.publicKey, + }) + .signers([attacker]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("enqueues while a futarchy proposal is live", async function () { + // The market's own Squads proposal takes index 1 and stays Active; the + // approved payload is the separate transaction at index 2 + await this.initializeAndLaunchProposal({ dao, instructions: [] }); + await createApprovedPayload(this, 2n); + + const storedDao = await this.futarchy.getDao(dao); + assert.exists(storedDao.amm.state.futarchy); + + await this.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex: 2n }) + .rpc(); + + const [enqueuedCancellationPda] = + getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex: 2n, + }); + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalCancellation.fetch( + enqueuedCancellationPda, + ); + assert.equal(enqueued.transactionIndex.toString(), "2"); + }); +} diff --git a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts index 5c9d7ef7e..e35ab56ff 100644 --- a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts +++ b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts @@ -1,17 +1,9 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; +import { PublicKey, Transaction, TransactionMessage } from "@solana/web3.js"; import { expectError, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -111,40 +103,14 @@ export default function suite() { programId: multisig.PROGRAM_ID, }); - const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(1).toArrayLike(Buffer, "le", 8), - ], - this.futarchy.futarchy.programId, - ); - // First enqueue an approval (admin-gated) - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - admin: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); // Then execute the approval (permissionless) - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao: dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc(); // Then execute diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 565313bf9..88dbd5aa4 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -50,7 +50,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/adminUpdateProposalParams.test.ts b/tests/futarchy/unit/adminUpdateProposalParams.test.ts index 6c0c40626..240c00825 100644 --- a/tests/futarchy/unit/adminUpdateProposalParams.test.ts +++ b/tests/futarchy/unit/adminUpdateProposalParams.test.ts @@ -231,7 +231,10 @@ export default function suite() { .rpc(); const after = await this.futarchy.getProposal(proposal); - assert.isTrue(after.isTeamSponsored); + assert.equal( + after.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); }); @@ -250,7 +253,10 @@ export default function suite() { await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); const after = await this.futarchy.getProposal(proposal); - assert.isTrue(after.isTeamSponsored); + assert.equal( + after.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); assert.equal(after.durationInSeconds, DAY_SECONDS * 2); assert.equal(after.passThresholdBps, 200); }); @@ -437,7 +443,7 @@ export default function suite() { }); it("refuses on a liquidated DAO", async function () { - // `apply_liquidation` is the only writer of `dao.liquidator`, and reaching + // `finalize_proposal` is the only writer of `dao.liquidator`, and reaching // it takes a full hostile-liquidate market. await rewriteAccount(this, dao, "dao", (decoded) => { decoded.liquidator = Keypair.generate().publicKey; diff --git a/tests/futarchy/unit/applyLiquidation.test.ts b/tests/futarchy/unit/applyLiquidation.test.ts deleted file mode 100644 index 0a2f993e0..000000000 --- a/tests/futarchy/unit/applyLiquidation.test.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { - ComputeBudgetProgram, - Keypair, - PublicKey, - Transaction, - TransactionMessage, - VersionedTransaction, -} from "@solana/web3.js"; -import { assert } from "chai"; -import * as multisig from "@sqds/multisig"; -import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; -import { - FUTARCHY_V0_6_PROGRAM_ID, - getDaoAddr, - getEventAuthorityAddr, - getProposalAddrsForTransactionIndex, - getSpendingLimitAddr, - PERMISSIONLESS_ACCOUNT, - PriceMath, -} from "@metadaoproject/programs"; -import BN from "bn.js"; -import { - createLookupTableForTransaction, - executeVaultTransaction, - passProposal, -} from "../../utils.js"; -import { TestContext } from "../../main.test.js"; - -const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); - -// The treasury's own LP position: the Squads vault is the position authority -async function provideTreasuryLiquidity( - context: TestContext, - { - dao, - vault, - baseMint, - quoteMint, - }: { - dao: PublicKey; - vault: PublicKey; - baseMint: PublicKey; - quoteMint: PublicKey; - }, -) { - await context.futarchy - .provideLiquidityIx({ - dao, - baseMint, - quoteMint, - quoteAmount: new BN(25_000 * 1_000_000), // 25,000 USDC - maxBaseAmount: new BN(25 * 1_000_000), // 25 META - minLiquidity: new BN(1), - positionAuthority: vault, - liquidityProvider: context.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); -} - -export default function suite() { - let META: PublicKey, - USDC: PublicKey, - dao: PublicKey, - vault: PublicKey, - ammPosition: PublicKey; - - beforeEach(async function () { - META = await this.createMint(this.payer.publicKey, 6); - USDC = await this.createMint(this.payer.publicKey, 6); - - await this.createTokenAccount(META, this.payer.publicKey); - await this.createTokenAccount(USDC, this.payer.publicKey); - - await this.mintTo( - META, - this.payer.publicKey, - this.payer, - 1_000 * 1_000_000, - ); - await this.mintTo( - USDC, - this.payer.publicKey, - this.payer, - 500_000 * 1_000_000, - ); - - const nonce = new BN(Math.floor(Math.random() * 1000000)); - - await this.futarchy - .initializeDaoIx({ - baseMint: META, - quoteMint: USDC, - params: { - secondsPerProposal: 60 * 60 * 24 * 3, - twapStartDelaySeconds: 60 * 60 * 24, - twapInitialObservation: THOUSAND_BUCK_PRICE, - // 10% per update: TWAPs converge to actual prices fast enough that - // a pumped pass market clears +25% even on a repeat run, where the - // fail market starts at an already-appreciated spot price - twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), - minQuoteFutarchicLiquidity: new BN(10_000), - minBaseFutarchicLiquidity: new BN(10_000), - passThresholdBps: 300, - nonce, - initialSpendingLimit: { - amountPerMonth: new BN(10_000_000_000), // 10,000 USDC - members: [this.payer.publicKey], - }, - baseToStake: new BN(0), - teamSponsoredPassThresholdBps: 300, - teamAddress: this.payer.publicKey, - }, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - - [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); - - const storedDao = await this.futarchy.getDao(dao); - vault = storedDao.squadsMultisigVault; - - [ammPosition] = PublicKey.findProgramAddressSync( - [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], - FUTARCHY_V0_6_PROGRAM_ID, - ); - - // The sweep destination: the vault's ATAs - await this.createTokenAccount(META, vault); - await this.createTokenAccount(USDC, vault); - - await this.futarchy - .provideLiquidityIx({ - dao, - baseMint: META, - quoteMint: USDC, - quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC - maxBaseAmount: new BN(100 * 1_000_000), // 100 META - minLiquidity: new BN(0), - positionAuthority: this.payer.publicKey, - liquidityProvider: this.payer.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), - ]) - .rpc(); - }); - - it("installs the liquidator, zeroes the record, and sweeps the treasury position", async function () { - await provideTreasuryLiquidity(this, { - dao, - vault, - baseMint: META, - quoteMint: USDC, - }); - - const liquidator = Keypair.generate().publicKey; - const { proposal, squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator, - }); - - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - await passProposal(this, { - dao, - proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - // The expected sweep, computed from the live pre-execution reserves - const preDao = await this.futarchy.getDao(dao); - const preSpot = preDao.amm.state.spot.spot; - const prePosition = - await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); - const expectedBase = prePosition.liquidity - .mul(preSpot.baseReserves) - .div(preDao.amm.totalLiquidity); - const expectedQuote = prePosition.liquidity - .mul(preSpot.quoteReserves) - .div(preDao.amm.totalLiquidity); - const preVaultBase = await this.getTokenBalance(META, vault); - const preVaultQuote = await this.getTokenBalance(USDC, vault); - - // Executing the baked payload is the byte-level proof that the baked - // instruction matches the deployed apply_liquidation - await executeVaultTransaction(this, dao, squadsTransaction); - - const storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidator)); - assert.isNull(storedDao.initialSpendingLimit); - assert.isTrue(storedDao.spendingLimitDirty); - - const postPosition = - await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); - assert.equal(postPosition.liquidity.toString(), "0"); - - const postVaultBase = await this.getTokenBalance(META, vault); - const postVaultQuote = await this.getTokenBalance(USDC, vault); - assert.equal( - (postVaultBase - preVaultBase).toString(), - expectedBase.toString(), - ); - assert.equal( - (postVaultQuote - preVaultQuote).toString(), - expectedQuote.toString(), - ); - - const postSpot = storedDao.amm.state.spot.spot; - assert.equal( - postSpot.baseReserves.toString(), - preSpot.baseReserves.sub(expectedBase).toString(), - ); - assert.equal( - postSpot.quoteReserves.toString(), - preSpot.quoteReserves.sub(expectedQuote).toString(), - ); - assert.equal( - storedDao.amm.totalLiquidity.toString(), - preDao.amm.totalLiquidity.sub(prePosition.liquidity).toString(), - ); - }); - - it("refuses an execute_arbitrary proposal whose payload calls apply_liquidation", async function () { - // An arbitrary proposal carrying apply_liquidation would reach - // liquidation at ExecuteArbitrary's terms (10 days, +10%, blockable); - // the kind check is what closes that hole - const { squadsProposal, squadsTransaction, proposal } = - getProposalAddrsForTransactionIndex({ dao, transactionIndex: 1n }); - - const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); - const applyLiquidationIx = await this.futarchy.futarchy.methods - .applyLiquidation() - .accounts({ - proposal, - dao, - squadsMultisigVault: vault, - ammPosition, - ammBaseVault: getAssociatedTokenAddressSync(META, dao, true), - ammQuoteVault: getAssociatedTokenAddressSync(USDC, dao, true), - vaultBaseAccount: getAssociatedTokenAddressSync(META, vault, true), - vaultQuoteAccount: getAssociatedTokenAddressSync(USDC, vault, true), - eventAuthority, - program: FUTARCHY_V0_6_PROGRAM_ID, - }) - .instruction(); - - const { tx: createTx } = this.futarchy.squadsProposalCreateTx({ - dao, - instructions: [applyLiquidationIx], - transactionIndex: 1n, - }); - createTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; - createTx.feePayer = this.payer.publicKey; - createTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - await this.banksClient.processTransaction(createTx); - - await this.futarchy.initializeProposal(dao, squadsProposal); - - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - await passProposal(this, { - dao, - proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - try { - await executeVaultTransaction(this, dao, squadsTransaction); - assert.fail("Should have failed with InvalidProposalKind"); - } catch (e) { - // The error surfaces through the Squads CPI: InvalidProposalKind (0x17a2 = 6050) - assert( - e.toString().includes("InvalidProposalKind") || - e.toString().includes("0x17a2"), - `Expected InvalidProposalKind error, got: ${e}`, - ); - } - - const storedDao = await this.futarchy.getDao(dao); - assert.isNull(storedDao.liquidator); - }); - - it("refuses a second passed liquidation after the first has executed", async function () { - await provideTreasuryLiquidity(this, { - dao, - vault, - baseMint: META, - quoteMint: USDC, - }); - - const liquidatorA = Keypair.generate().publicKey; - const liquidatorB = Keypair.generate().publicKey; - - const a = await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator: liquidatorA, - }); - const b = await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator: liquidatorB, - }); - - // Both markets run to Passed before either payload executes — possible - // because the DAO only becomes liquidated at execution - await this.futarchy - .launchProposalIx({ - proposal: a.proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal: a.squadsProposal, - }) - .rpc(); - await passProposal(this, { - dao, - proposal: a.proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - await this.futarchy - .launchProposalIx({ - proposal: b.proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal: b.squadsProposal, - }) - .rpc(); - await passProposal(this, { - dao, - proposal: b.proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - await executeVaultTransaction(this, dao, a.squadsTransaction); - - let storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidatorA)); - - try { - await executeVaultTransaction(this, dao, b.squadsTransaction); - assert.fail("Should have failed with AlreadyLiquidated"); - } catch (e) { - // The error surfaces through the Squads CPI: AlreadyLiquidated (0x17a3 = 6051) - assert( - e.toString().includes("AlreadyLiquidated") || - e.toString().includes("0x17a3"), - `Expected AlreadyLiquidated error, got: ${e}`, - ); - } - - // The first liquidator is not overwritten - storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidatorA)); - }); - - it("succeeds when the treasury position doesn't exist", async function () { - const liquidator = Keypair.generate().publicKey; - const { proposal, squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator, - }); - - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - await passProposal(this, { - dao, - proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - const preDao = await this.futarchy.getDao(dao); - const preSpot = preDao.amm.state.spot.spot; - - await executeVaultTransaction(this, dao, squadsTransaction); - - const storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidator)); - assert.isNull(storedDao.initialSpendingLimit); - assert.isTrue(storedDao.spendingLimitDirty); - - // Nothing to sweep, nothing swept - assert.equal((await this.getTokenBalance(META, vault)).toString(), "0"); - assert.equal((await this.getTokenBalance(USDC, vault)).toString(), "0"); - const postSpot = storedDao.amm.state.spot.spot; - assert.equal( - postSpot.baseReserves.toString(), - preSpot.baseReserves.toString(), - ); - assert.equal( - postSpot.quoteReserves.toString(), - preSpot.quoteReserves.toString(), - ); - assert.equal( - storedDao.amm.totalLiquidity.toString(), - preDao.amm.totalLiquidity.toString(), - ); - }); - - it("succeeds when the treasury position exists with zero liquidity", async function () { - const liquidator = Keypair.generate().publicKey; - const { proposal, squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator, - }); - - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - await passProposal(this, { - dao, - proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - // Manufacture an existing-but-empty position at the treasury's PDA - const positionData = await this.futarchy.futarchy.coder.accounts.encode( - "ammPosition", - { - dao, - positionAuthority: vault, - liquidity: new BN(0), - }, - ); - this.context.setAccount(ammPosition, { - lamports: 10_000_000, - data: positionData, - owner: FUTARCHY_V0_6_PROGRAM_ID, - executable: false, - }); - - await executeVaultTransaction(this, dao, squadsTransaction); - - const storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidator)); - assert.equal((await this.getTokenBalance(META, vault)).toString(), "0"); - assert.equal((await this.getTokenBalance(USDC, vault)).toString(), "0"); - }); - - it("reverts mid-market and lands with the packed finalize + execute + sync once that market finalizes", async function () { - await provideTreasuryLiquidity(this, { - dao, - vault, - baseMint: META, - quoteMint: USDC, - }); - - const liquidator = Keypair.generate().publicKey; - const { proposal, squadsProposal, squadsTransaction } = - await this.futarchy.initializeHostileLiquidateProposal({ - dao, - liquidator, - }); - - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal, - }) - .rpc(); - - await passProposal(this, { - dao, - proposal, - baseMint: META, - quoteMint: USDC, - cranks: 50, - }); - - // A proposal launched in the finalize→execute gap puts the pool - // mid-market before anyone executes the liquidation payload - const { tx: gapCreateTx, squadsProposal: gapSquadsProposal } = - this.futarchy.squadsProposalCreateTx({ - dao, - instructions: [ - { - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("gap proposal"), - }, - ], - transactionIndex: 2n, - }); - gapCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - gapCreateTx.feePayer = this.payer.publicKey; - gapCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - await this.banksClient.processTransaction(gapCreateTx); - - const gapProposal = await this.futarchy.initializeProposal( - dao, - gapSquadsProposal, - ); - await this.futarchy - .launchProposalIx({ - proposal: gapProposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal: gapSquadsProposal, - }) - .rpc(); - - try { - await executeVaultTransaction(this, dao, squadsTransaction); - assert.fail("Should have failed with PoolNotInSpotState"); - } catch (e) { - // The error surfaces through the Squads CPI: PoolNotInSpotState (0x178a = 6026) - assert( - e.toString().includes("PoolNotInSpotState") || - e.toString().includes("0x178a"), - `Expected PoolNotInSpotState error, got: ${e}`, - ); - } - - // Nothing was lost: the approved Squads transaction stays retryable - let storedDao = await this.futarchy.getDao(dao); - assert.isNull(storedDao.liquidator); - - // Run out the gap market uncontested (one observation after the TWAP - // start delay lets it finalize) - await this.advanceBySeconds(60 * 60 * 24 + 60); - await this.futarchy - .spotSwapIx({ - dao, - baseMint: META, - quoteMint: USDC, - swapType: "buy", - inputAmount: new BN(1_000), - }) - .rpc(); - await this.advanceBySeconds(864_000); - - // The same payload lands as one transaction: the gap market's - // finalize_proposal + vault_transaction_execute + sync_spending_limit. - const packIxs = [ - await this.futarchy - .finalizeProposalIxV2({ - squadsProposal: gapSquadsProposal, - dao, - baseMint: META, - quoteMint: USDC, - }) - .instruction(), - ( - await multisig.instructions.vaultTransactionExecute({ - connection: this.squadsConnection, - multisigPda: multisig.getMultisigPda({ createKey: dao })[0], - transactionIndex: 1n, - member: PERMISSIONLESS_ACCOUNT.publicKey, - }) - ).instruction, - await this.futarchy.syncSpendingLimitIx({ dao }).instruction(), - ]; - - const lut = await createLookupTableForTransaction( - new Transaction().add(...packIxs), - this, - ); - - const packMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [ - ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), - ...packIxs, - ], - }).compileToV0Message([lut]); - const packTx = new VersionedTransaction(packMessage); - packTx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); - await this.banksClient.processTransaction(packTx); - - const storedGap = await this.futarchy.getProposal(gapProposal); - assert.exists(storedGap.state.failed); - - storedDao = await this.futarchy.getDao(dao); - assert.ok(storedDao.liquidator.equals(liquidator)); - assert.isNull(storedDao.initialSpendingLimit); - // The packed sync already projected the removal onto Squads - assert.isFalse(storedDao.spendingLimitDirty); - const [spendingLimit] = getSpendingLimitAddr({ dao }); - assert.isNull(await this.banksClient.getAccount(spendingLimit)); - - const postPosition = - await this.futarchy.futarchy.account.ammPosition.fetch(ammPosition); - assert.equal(postPosition.liquidity.toString(), "0"); - }); -} diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 407e28e03..9db46ec18 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -1,4 +1,7 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + getEnqueuedMultisigProposalApprovalAddr, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; import { ComputeBudgetProgram, PublicKey, @@ -9,9 +12,6 @@ import { expectError } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -42,22 +42,6 @@ export default function suite() { }); }); - const deriveEnqueuedApprovalPda = ( - context: any, - daoKey: PublicKey, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - daoKey.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - const createSquadsVaultTxAndProposal = async function ( context: any, squadsMultisig: PublicKey, @@ -104,32 +88,14 @@ export default function suite() { return { proposalPda }; }; - const enqueue = async function ( - context: any, - daoKey: PublicKey, - squadsMultisigKey: PublicKey, - squadsProposalPda: PublicKey, - transactionIndex: bigint, - ) { - const enqueuedApprovalPda = deriveEnqueuedApprovalPda( - context, - daoKey, - transactionIndex, - ); - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao: daoKey, - admin: context.payer.publicKey, - squadsMultisig: squadsMultisigKey, - squadsMultisigProposal: squadsProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .signers([context.payer]) + const enqueue = async function (context: any, transactionIndex: bigint) { + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - return enqueuedApprovalPda; + return getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex, + })[0]; }; it("should execute an enqueued approval with a permissionless signer", async function () { @@ -139,13 +105,7 @@ export default function suite() { daoAccount.squadsMultisig, 1n, ); - const enqueuedApprovalPda = await enqueue( - this, - dao, - daoAccount.squadsMultisig, - proposalPda, - 1n, - ); + const enqueuedApprovalPda = await enqueue(this, 1n); let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( this.squadsConnection, @@ -155,15 +115,11 @@ export default function suite() { multisig.generated.isProposalStatusActive(squadsProposal.status), ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, + transactionIndex: 1n, rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, }) .signers([PERMISSIONLESS_ACCOUNT]) .rpc(); @@ -184,50 +140,27 @@ export default function suite() { it("should fail when no enqueued approval exists", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda } = await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - ); - - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + await createSquadsVaultTxAndProposal(this, daoAccount.squadsMultisig, 1n); const callbacks = expectError( "AccountNotInitialized", "execute should fail without an enqueued approval PDA", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); }); it("should fail with PoolNotInSpotState when a futarchy proposal launches between enqueue and execute", async function () { - const daoAccount = await this.futarchy.getDao(dao); - // Initialize (but don't launch) a futarchy proposal. This creates a // Squads proposal at index 1 and leaves the AMM in Spot — so we can // enqueue approval against it. Launching is done separately below. const { proposal, squadsProposal: proposalPda } = await this.initializeProposal({ dao, instructions: [] }); - const enqueuedApprovalPda = await enqueue( - this, - dao, - daoAccount.squadsMultisig, - proposalPda, - 1n, - ); + const enqueuedApprovalPda = await enqueue(this, 1n); // Now launch the futarchy proposal to push the AMM out of Spot. const storedDao = await this.futarchy.getDao(dao); @@ -246,17 +179,8 @@ export default function suite() { "execute should fail once the AMM is no longer in Spot state", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: proposalPda, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .rpc() .then(callbacks[0], callbacks[1]); @@ -268,22 +192,15 @@ export default function suite() { it("should fail with RequireGtViolated when the Squads proposal is invalidated between enqueue and execute", async function () { const daoAccount = await this.futarchy.getDao(dao); - const { proposalPda: victimProposalPda } = - await createSquadsVaultTxAndProposal( - this, - daoAccount.squadsMultisig, - 1n, - "will be invalidated", - ); - - const victimEnqueuedApprovalPda = await enqueue( + await createSquadsVaultTxAndProposal( this, - dao, daoAccount.squadsMultisig, - victimProposalPda, 1n, + "will be invalidated", ); + const victimEnqueuedApprovalPda = await enqueue(this, 1n); + const configTransactionIndex = 2n; const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ multisigPda: daoAccount.squadsMultisig, @@ -336,23 +253,14 @@ export default function suite() { }); const configEnqueuedApprovalPda = await enqueue( this, - dao, - daoAccount.squadsMultisig, - configProposalPda, configTransactionIndex, ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: configProposalPda, - enqueuedApproval: configEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, + transactionIndex: configTransactionIndex, }) - .signers([this.payer]) .rpc(); const configTransactionAccount = @@ -398,16 +306,8 @@ export default function suite() { "execute should fail because the proposal was invalidated by the config tx", ); - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: victimProposalPda, - enqueuedApproval: victimEnqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 1n }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), ]) diff --git a/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts b/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts new file mode 100644 index 000000000..51921d243 --- /dev/null +++ b/tests/futarchy/unit/executeMultisigProposalCancellation.test.ts @@ -0,0 +1,272 @@ +import { + getEnqueuedMultisigProposalCancellationAddr, + getProposalAddrsForTransactionIndex, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; +import { PublicKey } from "@solana/web3.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; + +export default function suite() { + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + squadsMultisig: PublicKey, + squadsMultisigVault: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + // 200/200k tokens (not 100/100k): setupBasicDaoWithLiquidity mints the + // same 10^11 atoms to the same ATAs, and identical amounts would make + // these mintTo transactions byte-identical to the helper's, failing with + // "This transaction has already been processed" when they share a + // blockhash tick + await this.mintTo(META, this.payer.publicKey, this.payer, 200 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 200_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + + const storedDao = await this.futarchy.getDao(dao); + squadsMultisig = storedDao.squadsMultisig; + squadsMultisigVault = storedDao.squadsMultisigVault; + }); + + // A memo vault transaction + proposal at `transactionIndex`, force-approved + // so it is executable without a market + const createApprovedPayload = async function ( + context: any, + transactionIndex: bigint, + ) { + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [createMemoInstruction("hello world")], + transactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + const addrs = getProposalAddrsForTransactionIndex({ + dao, + transactionIndex, + }); + await forceApproveSquadsProposal(context, addrs.squadsProposal); + return addrs; + }; + + const enqueue = async function (context: any, transactionIndex: bigint) { + await context.futarchy + .adminEnqueueMultisigProposalCancellationIx({ dao, transactionIndex }) + .rpc(); + return getEnqueuedMultisigProposalCancellationAddr({ + dao, + transactionIndex, + })[0]; + }; + + // Runs a multisig_set_time_lock config instruction as vault transaction + // `configTransactionIndex`: approved through the approval set, then executed + // through admin_execute_multisig_proposal so the DAO PDA signs as config + // authority. Squads invalidates every prior transaction on the way. + const advanceStaleIndex = async function ( + context: any, + configTransactionIndex: bigint, + ) { + const setTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const { tx } = context.futarchy.squadsProposalCreateTx({ + dao, + instructions: [setTimeLockIx], + transactionIndex: configTransactionIndex, + }); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + await context.banksClient.processTransaction(tx); + + const { squadsProposal: configProposal, squadsTransaction } = + getProposalAddrsForTransactionIndex({ + dao, + transactionIndex: configTransactionIndex, + }); + + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ + dao, + transactionIndex: configTransactionIndex, + }) + .rpc(); + + await context.futarchy + .executeMultisigProposalApprovalIx({ + dao, + transactionIndex: configTransactionIndex, + }) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsTransaction, + ); + const { accountMetas } = await multisig.utils.accountsForTransactionExecute( + { + connection: context.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: squadsMultisigVault, + transactionPda: squadsTransaction, + programId: multisig.PROGRAM_ID, + }, + ); + + await context.futarchy.futarchy.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig, + squadsMultisigProposal: configProposal, + squadsMultisigVaultTransaction: squadsTransaction, + admin: context.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + accountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .signers([context.payer]) + .rpc(); + }; + + it("cancels the Squads proposal with a permissionless signer and closes the enqueued cancellation", async function () { + const { squadsProposal, squadsTransaction } = await createApprovedPayload( + this, + 1n, + ); + const enqueuedCancellationPda = await enqueue(this, 1n); + + await this.futarchy + .executeMultisigProposalCancellationIx({ + dao, + transactionIndex: 1n, + rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, + }) + .signers([PERMISSIONLESS_ACCOUNT]) + .rpc(); + + const storedSquadsProposal = + await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + squadsProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedSquadsProposal.status), + ); + assert.deepEqual( + storedSquadsProposal.cancelled.map((k) => k.toBase58()), + [dao.toBase58()], + ); + + assert.isNull(await this.banksClient.getAccount(enqueuedCancellationPda)); + + // The payload is dead: Squads' execute requires Approved + try { + await executeVaultTransaction(this, dao, squadsTransaction); + assert.fail("Should have thrown error"); + } catch (e) { + // Squads' InvalidProposalStatus (0x1778 = 6008) + assert.isTrue(e.toString().includes("0x1778"), `unexpected error: ${e}`); + } + }); + + it("fails when no enqueued cancellation exists", async function () { + await createApprovedPayload(this, 1n); + + const callbacks = expectError( + "AccountNotInitialized", + "execute should fail without an enqueued cancellation PDA", + ); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with SquadsProposalNotApproved when the payload executes between enqueue and execute", async function () { + const { squadsTransaction } = await createApprovedPayload(this, 1n); + const enqueuedCancellationPda = await enqueue(this, 1n); + + await executeVaultTransaction(this, dao, squadsTransaction); + + const callbacks = expectError( + "SquadsProposalNotApproved", + "execute should fail once the payload has executed", + ); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc() + .then(callbacks[0], callbacks[1]); + + assert.isNotNull( + await this.banksClient.getAccount(enqueuedCancellationPda), + ); + }); + + it("cancels a stale Approved proposal", async function () { + const { squadsProposal: victimProposal } = await createApprovedPayload( + this, + 1n, + ); + + await advanceStaleIndex(this, 2n); + + const storedMultisig = await multisig.accounts.Multisig.fromAccountAddress( + this.squadsConnection, + squadsMultisig, + ); + assert.equal(storedMultisig.staleTransactionIndex.toString(), "2"); + + await enqueue(this, 1n); + + await this.futarchy + .executeMultisigProposalCancellationIx({ dao, transactionIndex: 1n }) + .rpc(); + + const storedVictim = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + victimProposal, + ); + assert.isTrue( + multisig.generated.isProposalStatusCancelled(storedVictim.status), + ); + }); +} diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index d19aaf4c3..ef242fbd7 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -15,7 +15,7 @@ import { getAssociatedTokenAddressSync, } from "@solana/spl-token"; import BN from "bn.js"; -import { expectError, setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -23,7 +23,11 @@ const { Permissions, Permission } = multisig.types; const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); export default function suite() { - let META: PublicKey, USDC: PublicKey, dao: PublicKey, proposal: PublicKey; + let META: PublicKey, + USDC: PublicKey, + dao: PublicKey, + proposal: PublicKey, + squadsProposalPda: PublicKey; beforeEach(async function () { META = await this.createMint(this.payer.publicKey, 6); @@ -79,7 +83,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -108,7 +111,7 @@ export default function suite() { rentPayer: this.payer.publicKey, }); - const [squadsProposalPda] = multisig.getProposalPda({ + [squadsProposalPda] = multisig.getProposalPda({ multisigPda, transactionIndex: 1n, }); @@ -146,6 +149,68 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); + it("rejects a legacy-sized proposal that has not been migrated", async function () { + // Shrink the live proposal to the pre-migration allocation: the 8-byte + // discriminator plus the 339-byte Pending body, then 8 bytes standing in + // for the residue a legacy account carries past its Pending body. The + // residue decodes as pass_threshold_bps = -3151, council_can_block = + // false, action = ExecuteArbitrary — a well-formed new-layout read, so + // only the size guard stands between it and finalization. + const raw = await this.banksClient.getAccount(proposal); + const legacy = Buffer.concat([ + Buffer.from(raw.data.subarray(0, 347)), + Buffer.from([0xb1, 0xf3, 0x00, 0x03, 0xf0, 0x37, 0xa2, 0x00]), + ]); + assert.equal(legacy.length, 355); + this.context.setAccount(proposal, { ...raw, data: legacy }); + + const crafted = await this.futarchy.getProposal(proposal); + assert.exists(crafted.state.pending); + assert.equal(crafted.passThresholdBps, -3151); + assert.isFalse(crafted.councilCanBlock); + assert.isDefined(crafted.action.executeArbitrary); + + const callbacks = expectError( + "AccountNotMigrated", + "finalized an un-migrated legacy proposal", + ); + + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: squadsProposalPda, + dao, + baseMint: META, + quoteMint: USDC, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a legacy-sized DAO that has not been migrated", async function () { + // Shrink only the DAO to the pre-migration allocation; the proposal keeps + // its migrated size so its own guard passes. + await makeOldDaoLayout(this, dao); + + const crafted = await this.futarchy.getDao(dao); + assert.exists(crafted.amm.state.futarchy); + assert.isNull(crafted.liquidator); + + const callbacks = expectError( + "AccountNotMigrated", + "finalized a proposal on an un-migrated legacy DAO", + ); + + await this.futarchy + .finalizeProposalIxV2({ + squadsProposal: squadsProposalPda, + dao, + baseMint: META, + quoteMint: USDC, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("passes proposals when Pass TWAP > Fail TWAP", async function () { // Split tokens into the vaults const { baseVault, quoteVault, question } = this.futarchy.getProposalPdas( @@ -544,7 +609,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts index 6e3cd85a7..56a980dd8 100644 --- a/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts +++ b/tests/futarchy/unit/initializeBuybackTokenProposal.test.ts @@ -232,7 +232,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000_000), - quoteAmountPerCycle: new BN(5_000_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -241,7 +241,7 @@ export default function suite() { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from( - `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000000 per_cycle=5000000000 cycle_seconds=86400 start_delay=0 min_price=none max_price=none`, + `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000000 cycles=80 cycle_seconds=86400 start_delay=0 min_price=none max_price=none`, "utf8", ), }); @@ -255,7 +255,7 @@ export default function suite() { assert.ok(storedProposal.dao.equals(dao)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); }); it("formats a banded mandate's prices into the memo", async function () { @@ -263,7 +263,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 3_600, startDelaySeconds: 60, minPrice: new BN(1_600_000), @@ -274,7 +274,7 @@ export default function suite() { programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from( - `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000 per_cycle=5000000 cycle_seconds=3600 start_delay=60 min_price=1600000 max_price=2000000`, + `metadao-buyback/1 proposal=${proposal.toBase58()} spend=400000000 cycles=80 cycle_seconds=3600 start_delay=60 min_price=1600000 max_price=2000000`, "utf8", ), }); @@ -288,7 +288,7 @@ export default function suite() { const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 3_600, minPrice: new BN(1_600_000), @@ -304,7 +304,7 @@ export default function suite() { const action = storedProposal.action.buybackToken; assert.equal(action.quoteAmount.toString(), "400000000"); - assert.equal(action.quoteAmountPerCycle.toString(), "5000000"); + assert.equal(action.cycleCount, 80); assert.equal(action.cycleFrequencySeconds, 86_400); assert.equal(action.startDelaySeconds, 3_600); assert.equal(action.minPrice.toString(), "1600000"); @@ -321,7 +321,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), // 400 tokens = 25% of 1,600 - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -350,7 +350,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_001), - quoteAmountPerCycle: new BN(1), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -401,7 +401,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(300_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 60, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -439,6 +439,149 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("values the position at the pool's observation, so pumping the quote reserves can't lift the cap", async function () { + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(1_000 * 1_000_000), + maxBaseAmount: new BN(2 * 1_000_000), + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // 600 * 4 = 2,400 against a 2,000 treasury + const { proposal, squadsProposal } = + await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(600_000_000), + cycleCount: 120, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(50_000 * 1_000_000), + }) + .rpc(); + + // The pump lifts the position's reserve-based quote share past the cap; + // only valuing it at the observation keeps the launch out. + const storedDao = await this.futarchy.getDao(dao); + const spot = storedDao.amm.state.spot.spot; + const position = + await this.futarchy.futarchy.account.ammPosition.fetch(vaultPosition); + const reserveBasedTreasury = position.liquidity + .mul(spot.quoteReserves) + .div(storedDao.amm.totalLiquidity) + .add(new BN(1_000 * 1_000_000)); + assert.isTrue(reserveBasedTreasury.gte(new BN(2_400_000_000))); + + const callbacks = expectError( + "BuybackCapExceeded", + "launched a buyback against pumped quote reserves", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + treasuryAccounts: await this.futarchy.assembleBuybackTreasuryAccounts({ + dao, + }), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("takes the lower of the reserve and observation figures, so dumping into the pool can't lift it either", async function () { + await this.mintTo(USDC, vault, this.payer, 1_000 * 1_000_000); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(1_000 * 1_000_000), + maxBaseAmount: new BN(2 * 1_000_000), + minLiquidity: new BN(1), + positionAuthority: vault, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(600_000_000), + cycleCount: 120, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "sell", + inputAmount: new BN(45 * 1_000_000), + }) + .rpc(); + + // The dump lifts the position's observation-priced base share past the + // cap while its quote share falls; the lower figure is the one that counts. + const storedDao = await this.futarchy.getDao(dao); + const spot = storedDao.amm.state.spot.spot; + const position = + await this.futarchy.futarchy.account.ammPosition.fetch(vaultPosition); + const observationBasedTreasury = position.liquidity + .mul(spot.baseReserves) + .mul(spot.oracle.lastObservation) + .div(new BN(10).pow(new BN(12))) + .div(storedDao.amm.totalLiquidity) + .add(new BN(1_000 * 1_000_000)); + assert.isTrue(observationBasedTreasury.gte(new BN(2_400_000_000))); + + const callbacks = expectError( + "BuybackCapExceeded", + "launched a buyback against dumped base reserves", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + treasuryAccounts: await this.futarchy.assembleBuybackTreasuryAccounts({ + dao, + }), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("binds the cap to the launch-time balance, not create's", async function () { await this.mintTo(USDC, vault, this.payer, 1_600 * 1_000_000); @@ -446,7 +589,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -566,7 +709,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(10_000_000), // comfortably under the cap - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 2, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -675,7 +818,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -707,7 +850,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -735,7 +878,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -771,7 +914,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -823,7 +966,7 @@ export default function suite() { const first = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -858,7 +1001,7 @@ export default function suite() { const second = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -910,7 +1053,7 @@ export default function suite() { await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); @@ -948,23 +1091,6 @@ export default function suite() { assert.equal(storedSquadsProposal.status.__kind, "Executed"); }); - it("rejects a zero per-cycle amount", async function () { - const callbacks = expectError( - "InvalidBuybackAmount", - "created a buyback with a zero per-cycle amount", - ); - - await this.futarchy - .initializeBuybackTokenProposal({ - dao, - quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(0), - cycleFrequencySeconds: 86_400, - startDelaySeconds: 0, - }) - .then(callbacks[0], callbacks[1]); - }); - it("rejects a zero total", async function () { const callbacks = expectError( "InvalidBuybackAmount", @@ -975,41 +1101,41 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(0), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }) .then(callbacks[0], callbacks[1]); }); - it("rejects a per-cycle that doesn't divide the total", async function () { - const callbacks = expectError( - "InvalidBuybackAmount", - "created a buyback whose per-cycle doesn't divide the total", - ); + it("accepts a total that doesn't split evenly across the cycles", async function () { + // 100 USDC over 3 cycles: the venue puts the remainder in the last order, + // so the mandate records the total and the count as given + const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ + dao, + quoteAmount: new BN(100_000_000), + cycleCount: 3, + cycleFrequencySeconds: 86_400, + startDelaySeconds: 0, + }); - await this.futarchy - .initializeBuybackTokenProposal({ - dao, - quoteAmount: new BN(100_000_000), - quoteAmountPerCycle: new BN(30_000_000), - cycleFrequencySeconds: 86_400, - startDelaySeconds: 0, - }) - .then(callbacks[0], callbacks[1]); + const action = (await this.futarchy.getProposal(proposal)).action + .buybackToken; + assert.equal(action.quoteAmount.toString(), "100000000"); + assert.equal(action.cycleCount, 3); }); - it("rejects a single-order programme", async function () { + it("rejects a single-cycle programme", async function () { const callbacks = expectError( - "InvalidBuybackAmount", - "created a single-order buyback", + "InvalidBuybackCycleCount", + "created a single-cycle buyback", ); await this.futarchy .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(100_000_000), - quoteAmountPerCycle: new BN(100_000_000), + cycleCount: 1, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }) @@ -1026,7 +1152,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, minPrice: new BN(2_000_000), @@ -1045,7 +1171,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 59, startDelaySeconds: 0, }) @@ -1062,7 +1188,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 365 * 24 * 60 * 60 + 1, startDelaySeconds: 0, }) @@ -1079,7 +1205,7 @@ export default function suite() { .initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 30 * 24 * 60 * 60 + 1, }) @@ -1090,7 +1216,7 @@ export default function suite() { const { proposal } = await this.futarchy.initializeBuybackTokenProposal({ dao, quoteAmount: new BN(400_000_000), - quoteAmountPerCycle: new BN(5_000_000), + cycleCount: 80, cycleFrequencySeconds: 86_400, startDelaySeconds: 0, }); diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 110c3faae..d1658d25b 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -191,6 +191,38 @@ export default function suite() { assert.isFalse(storedDao.isOptimisticGovernanceEnabled); }); + it("doesn't allow an initial spending limit with a zero monthly amount", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "DAO initialized despite a zero monthly spending limit", + ); + + await this.futarchy + .initializeDaoIx({ + baseMint: META, + quoteMint: USDC, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(1), + minBaseFutarchicLiquidity: new BN(1000), + baseToStake: new BN(1000), + passThresholdBps: 300, + nonce: new BN(421), + initialSpendingLimit: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + teamSponsoredPassThresholdBps: 123, + teamAddress: this.payer.publicKey, + }, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("doesn't allow DAOs with identical base and quote mints", async function () { const SAME_MINT = await this.createMint(this.payer.publicKey, 6); diff --git a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts index 47f9c0377..9e55c0a9e 100644 --- a/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileLiquidateProposal.test.ts @@ -1,19 +1,10 @@ -import { - FUTARCHY_V0_6_PROGRAM_ID, - getDaoAddr, - getEventAuthorityAddr, - PriceMath, -} from "@metadaoproject/programs"; +import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, PublicKey, TransactionInstruction, } from "@solana/web3.js"; -import { - getAssociatedTokenAddressSync, - TOKEN_PROGRAM_ID, -} from "@solana/spl-token"; import BN from "bn.js"; import { assert } from "chai"; import { assertVaultTransactionPayload } from "../../utils.js"; @@ -59,7 +50,7 @@ export default function suite() { [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); }); - it("bakes an apply_liquidation whose accounts are exactly the derived set, plus an IP-transfer memo", async function () { + it("bakes the IP-transfer memo into the Squads payload", async function () { const liquidator = Keypair.generate().publicKey; const { proposal, squadsProposal, squadsTransaction } = @@ -68,32 +59,9 @@ export default function suite() { liquidator, }); - const storedDao = await this.futarchy.getDao(dao); - const vault = storedDao.squadsMultisigVault; - - const [eventAuthority] = getEventAuthorityAddr(FUTARCHY_V0_6_PROGRAM_ID); - const [ammPosition] = PublicKey.findProgramAddressSync( - [Buffer.from("amm_position"), dao.toBuffer(), vault.toBuffer()], - FUTARCHY_V0_6_PROGRAM_ID, - ); - - const expectedApplyLiquidationIx = await this.futarchy.futarchy.methods - .applyLiquidation() - .accounts({ - proposal, - dao, - squadsMultisigVault: vault, - ammPosition, - ammBaseVault: storedDao.amm.ammBaseVault, - ammQuoteVault: storedDao.amm.ammQuoteVault, - vaultBaseAccount: getAssociatedTokenAddressSync(META, vault, true), - vaultQuoteAccount: getAssociatedTokenAddressSync(USDC, vault, true), - tokenProgram: TOKEN_PROGRAM_ID, - eventAuthority, - program: FUTARCHY_V0_6_PROGRAM_ID, - }) - .instruction(); - + // The payload is ceremony: a memo touches no accounts, so the immutable + // Squads transaction cannot fail on any DAO configuration. The state flip + // happens at finalize; the liquidator unwinds through the estate cycle. const expectedMemoIx = new TransactionInstruction({ programId: new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), keys: [], @@ -104,7 +72,6 @@ export default function suite() { }); await assertVaultTransactionPayload(this, dao, squadsTransaction, [ - expectedApplyLiquidationIx, expectedMemoIx, ]); @@ -115,7 +82,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.ok( storedProposal.action.hostileLiquidate.liquidator.equals(liquidator), ); diff --git a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts index 767d48196..e2d33e365 100644 --- a/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts +++ b/tests/futarchy/unit/initializeHostileTakeoverProposal.test.ts @@ -75,7 +75,6 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: newTeamAddress, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -102,7 +101,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.ok( storedProposal.action.hostileTakeover.newTeamAddress.equals( @@ -257,4 +256,84 @@ export default function suite() { }) .then(...callbacks); }); + + it("throws error when a Set action's monthly amount is zero", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "created a hostile takeover proposal with a zero monthly amount", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + }, + }, + }) + .then(...callbacks); + }); + + it("throws error when a Set action has no members", async function () { + const callbacks = expectError( + "EmptySpendingLimitMembers", + "created a hostile takeover proposal with no members", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + }, + }, + }) + .then(...callbacks); + }); + + it("throws error when a Set action has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + const callbacks = expectError( + "DuplicateSpendingLimitMember", + "created a hostile takeover proposal with duplicate members", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { + set: { + 0: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + }, + }, + }) + .then(...callbacks); + }); + + it("throws error when the new team address is the current team", async function () { + const callbacks = expectError( + "InvalidTeamAddress", + "created a hostile takeover proposal targeting the current team", + ); + await this.futarchy + .initializeHostileTakeoverProposal({ + dao, + newTeamAddress: this.payer.publicKey, + spendingLimitAction: { keep: {} }, + }) + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts index 2b2e5ce77..7154c5e32 100644 --- a/tests/futarchy/unit/initializeLargeSpendProposal.test.ts +++ b/tests/futarchy/unit/initializeLargeSpendProposal.test.ts @@ -108,12 +108,15 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.equal( storedProposal.action.largeSpend.amount.toString(), amount.toString(), ); + assert.ok( + storedProposal.action.largeSpend.teamAddress.equals(this.payer.publicKey), + ); assert.equal(storedProposal.durationInSeconds, 129_600); assert.equal(storedProposal.passThresholdBps, -1000); assert.isTrue(storedProposal.councilCanBlock); diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index bf47558ff..e51427d3a 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -88,7 +88,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts index bbafd8906..c5d89a7b1 100644 --- a/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts +++ b/tests/futarchy/unit/initializeSpendingLimitChangeProposal.test.ts @@ -78,7 +78,7 @@ export default function suite() { assert.ok(storedProposal.proposer.equals(this.payer.publicKey)); assert.ok(storedProposal.squadsProposal.equals(squadsProposal)); assert.exists(storedProposal.state.draft); - assert.isFalse(storedProposal.isTeamSponsored); + assert.isNull(storedProposal.sponsoredBy); assert.equal( storedProposal.action.spendingLimitChange.config.amountPerMonth.toString(), @@ -136,6 +136,57 @@ export default function suite() { .then(...callbacks); }); + it("throws error when the config's monthly amount is zero", async function () { + const callbacks = expectError( + "InvalidSpendingLimitAmount", + "created a spending limit change proposal with a zero monthly amount", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + }) + .then(...callbacks); + }); + + it("throws error when the config has no members", async function () { + const callbacks = expectError( + "EmptySpendingLimitMembers", + "created a spending limit change proposal with no members", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + }) + .then(...callbacks); + }); + + it("throws error when the config has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + const callbacks = expectError( + "DuplicateSpendingLimitMember", + "created a spending limit change proposal with duplicate members", + ); + await this.futarchy + .initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + }) + .then(...callbacks); + }); + it("the executed and synced end state matches the declaration", async function () { const config = { amountPerMonth: new BN(25_000_000_000), // 25,000 USDC diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index fe10a8988..6970cabc4 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -11,7 +11,11 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError } from "../../utils.js"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -190,7 +194,7 @@ export default function suite() { const { proposal, squadsProposal } = await initializeProposal(this, dao); - // Sponsor the proposal (makes is_team_sponsored = true) + // Sponsor the proposal (sets sponsored_by to the team) await this.futarchy .sponsorProposalIx({ proposal, @@ -633,6 +637,228 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a large_spend paying the previous team even after the new team sponsors it", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: new BN(10_000), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + // Replace the team while the sponsored draft is still unlaunched + const newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const staleSponsorCallbacks = expectError( + "ProposalNotTeamSponsored", + "launched a large spend sponsored by the previous team", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(staleSponsorCallbacks[0], staleSponsorCallbacks[1]); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + const stalePayeeCallbacks = expectError( + "StaleTeamAddress", + "launched a large spend paying the previous team", + ); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc() + .then(stalePayeeCallbacks[0], stalePayeeCallbacks[1]); + }); + + it("fails to launch a sponsored large_spend after the limit drops below its amount", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + // Exactly the three-month cap, so any reduction puts it over + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: spendingLimit.muln(3), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const change = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: spendingLimit.divn(10), + members: [this.payer.publicKey], + }, + }); + await forceApproveSquadsProposal(this, change.squadsProposal); + await executeVaultTransaction(this, dao, change.squadsTransaction); + + const callbacks = expectError( + "SpendCapExceeded", + "launched a large spend above the reduced cap", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails to launch a sponsored large_spend after the limit is removed", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeLargeSpendProposal({ + dao, + amount: new BN(10_000), + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const removal = await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: null, + }); + await forceApproveSquadsProposal(this, removal.squadsProposal); + await executeVaultTransaction(this, dao, removal.squadsTransaction); + + const callbacks = expectError( + "NoSpendingLimit", + "launched a large spend with no spending limit", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("fails to launch an unsponsored spending_limit_change, launches once sponsored", async function () { const dao = await createDaoWithStakeThreshold( this, @@ -708,6 +934,189 @@ export default function suite() { assert.exists(storedProposal.state.pending); }); + it("fails to launch a spending_limit_change sponsored by the previous team, launches once the new team sponsors it", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = + await this.futarchy.initializeSpendingLimitChangeProposal({ + dao, + config: { + amountPerMonth: new BN(20_000), + members: [Keypair.generate().publicKey], + }, + }); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const callbacks = expectError( + "ProposalNotTeamSponsored", + "launched a spending limit change sponsored by the previous team", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + newTeam.publicKey.toBase58(), + ); + }); + + it("requires the stake once a sponsorship goes stale", async function () { + const stakeThreshold = new BN(100 * 10 ** 6); + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + stakeThreshold, + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { proposal, squadsProposal } = await initializeProposal(this, dao); + + await this.futarchy + .sponsorProposalIx({ + proposal, + dao, + teamAddress: this.payer.publicKey, + }) + .rpc(); + + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const callbacks = expectError( + "InsufficientStakeToLaunch", + "launched on a stale sponsorship with no stake", + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + await this.futarchy + .stakeToProposalIx({ + proposal, + dao, + baseMint: META, + amount: stakeThreshold, + }) + .rpc(); + + // The compute unit price makes this transaction's hash differ from the + // first launch attempt, so it isn't rejected as already processed + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); + it("fails to launch a hostile takeover during its cooldown, launches once it elapses", async function () { const dao = await createDaoWithStakeThreshold( this, diff --git a/tests/futarchy/unit/liquidatedGuards.test.ts b/tests/futarchy/unit/liquidatedGuards.test.ts index 0ac93fa71..c159c32ae 100644 --- a/tests/futarchy/unit/liquidatedGuards.test.ts +++ b/tests/futarchy/unit/liquidatedGuards.test.ts @@ -27,10 +27,10 @@ import { TestContext } from "../../main.test.js"; // Every blocked instruction refuses on a liquidated DAO; every allowed one // still works. Not covered here because a liquidated DAO can't reach them: -// - finalize_proposal: a market can never be live once the DAO is liquidated -// (launch is guarded and apply_liquidation requires a spot pool), so the -// gap-market interleaving it exists for is pinned by the packed -// finalize + execute + sync case in applyLiquidation.test.ts +// - finalize_proposal: the liquidator is written by finalize itself, while no +// other market can be live, and launch refuses from then on — so a +// liquidated DAO never has a market left to finalize (the +// finalize → sync → unwind flow is pinned by liquidationEndToEnd.test.ts) // - the liquidator path: liquidatorPath.test.ts runs the estate cycle // - collect_meteora_damm_fees: reads no liquidation state (its own suite // covers the mechanics; setup needs a full launchpad DAMM pool) @@ -38,15 +38,10 @@ export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, - vault: PublicKey, draftProposal: PublicKey, draftSquadsProposal: PublicKey, liquidationProposal: PublicKey; - // A single liquidated DAO serves every case: blocked instructions are pure - // refusals, and the allowed ones each touch disjoint state (the sync flag, - // the LP position, the stake, the fee balances), so one `before` avoids - // re-running the whole market flow per test. before(async function () { META = await this.createMint(this.payer.publicKey, 6); USDC = await this.createMint(this.payer.publicKey, 6); @@ -100,13 +95,6 @@ export default function suite() { [dao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); - const storedDao = await this.futarchy.getDao(dao); - vault = storedDao.squadsMultisigVault; - - // The baked apply_liquidation payload requires the vault's ATAs to exist - await this.createTokenAccount(META, vault); - await this.createTokenAccount(USDC, vault); - // Destination ATAs for the post-liquidation collect_fees case await this.createTokenAccount(META, METADAO_MULTISIG_VAULT); await this.createTokenAccount(USDC, METADAO_MULTISIG_VAULT); @@ -187,6 +175,7 @@ export default function suite() { cranks: 50, }); + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); await executeVaultTransaction(this, dao, squadsTransaction); const liquidatedDao = await this.futarchy.getDao(dao); @@ -198,8 +187,9 @@ export default function suite() { const createSquadsVaultTx = async function ( context: TestContext, instructions: any[], + targetDao: PublicKey = dao, ) { - const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const multisigPda = multisig.getMultisigPda({ createKey: targetDao })[0]; const multisigAccount = await multisig.accounts.Multisig.fromAccountAddress( context.squadsConnection, multisigPda, @@ -208,7 +198,7 @@ export default function suite() { BigInt(multisigAccount.transactionIndex.toString()) + 1n; const { tx } = context.futarchy.squadsProposalCreateTx({ - dao, + dao: targetDao, instructions, transactionIndex, }); @@ -217,7 +207,10 @@ export default function suite() { tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(tx); - return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); + return getProposalAddrsForTransactionIndex({ + dao: targetDao, + transactionIndex, + }); }; it("refuses initialize_proposal", async function () { @@ -369,22 +362,19 @@ export default function suite() { .then(callbacks[0], callbacks[1]); }); - it("refuses spot_swap", async function () { + it("refuses sponsor_proposal", async function () { const callbacks = expectError( "DaoLiquidated", - "spot_swap should refuse on a liquidated DAO", + "sponsor_proposal should refuse on a liquidated DAO", ); await this.futarchy - .spotSwapIx({ - dao, - baseMint: META, - quoteMint: USDC, - swapType: "buy", - inputAmount: new BN(1_000_000), - }) + .sponsorProposalIx({ proposal: draftProposal, dao }) .rpc() .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(draftProposal); + assert.isNull(storedProposal.sponsoredBy); }); it("refuses conditional_swap", async function () { @@ -444,7 +434,6 @@ export default function suite() { baseToStake: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); @@ -498,17 +487,47 @@ export default function suite() { } }); - it("allows sync_spending_limit, which removes the Squads limit without recreating", async function () { + it("holds no live spending limit: the pre-sweep sync removed it, and a re-sync refuses", async function () { const [spendingLimitPda] = getSpendingLimitAddr({ dao }); - assert.isNotNull(await this.banksClient.getAccount(spendingLimitPda)); - - await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); - assert.isNull(await this.banksClient.getAccount(spendingLimitPda)); const storedDao = await this.futarchy.getDao(dao); assert.isNull(storedDao.initialSpendingLimit); assert.isFalse(storedDao.spendingLimitDirty); + + const callbacks = expectError( + "SpendingLimitNotDirty", + "re-sync should refuse once the flag is consumed", + ); + + await this.futarchy + .syncSpendingLimitIx({ dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + // Runs before the LP exit below: a swap needs the pool still funded. + it("allows spot_swap", async function () { + const preSpot = (await this.futarchy.getDao(dao)).amm.state.spot.spot; + const preBase = await this.getTokenBalance(META, this.payer.publicKey); + + await this.futarchy + .spotSwapIx({ + dao, + baseMint: META, + quoteMint: USDC, + swapType: "buy", + inputAmount: new BN(500 * 1_000_000), + }) + .rpc(); + + assert.isTrue( + (await this.getTokenBalance(META, this.payer.publicKey)) > preBase, + ); + + const postSpot = (await this.futarchy.getDao(dao)).amm.state.spot.spot; + assert.isTrue(postSpot.quoteReserves.gt(preSpot.quoteReserves)); + assert.isTrue(postSpot.baseReserves.lt(preSpot.baseReserves)); }); it("allows withdraw_liquidity", async function () { @@ -611,4 +630,179 @@ export default function suite() { "0", ); }); + + // dao.liquidator is written by finalize itself, so the DAO is bricked + // the moment the market resolves. The ceremonial payload is never executed + // here — none of these guards depend on it. + describe("liquidation marker set by finalize", function () { + let base: PublicKey, + quote: PublicKey, + reservedDao: PublicKey, + liquidatorA: PublicKey, + rivalLiquidation: { proposal: PublicKey; squadsProposal: PublicKey }, + stagedDraft: { proposal: PublicKey; squadsProposal: PublicKey }; + + before(async function () { + base = await this.createMint(this.payer.publicKey, 6); + quote = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(base, this.payer.publicKey); + await this.createTokenAccount(quote, this.payer.publicKey); + + await this.mintTo( + base, + this.payer.publicKey, + this.payer, + 1_000 * 1_000_000, + ); + await this.mintTo( + quote, + this.payer.publicKey, + this.payer, + 500_000 * 1_000_000, + ); + + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await this.futarchy + .initializeDaoIx({ + baseMint: base, + quoteMint: quote, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(10), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + [reservedDao] = getDaoAddr({ nonce, daoCreator: this.payer.publicKey }); + + await this.futarchy + .provideLiquidityIx({ + dao: reservedDao, + baseMint: base, + quoteMint: quote, + quoteAmount: new BN(100_000 * 1_000_000), // 100,000 USDC + maxBaseAmount: new BN(100 * 1_000_000), // 100 META + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + liquidatorA = Keypair.generate().publicKey; + + // Everything is staged while the DAO is still healthy: the winning + // liquidation, a rival liquidation, and an ordinary draft + const winner = await this.futarchy.initializeHostileLiquidateProposal({ + dao: reservedDao, + liquidator: liquidatorA, + }); + rivalLiquidation = await this.futarchy.initializeHostileLiquidateProposal( + { + dao: reservedDao, + liquidator: Keypair.generate().publicKey, + }, + ); + + const { squadsProposal: stagedSquadsProposal } = + await createSquadsVaultTx( + this, + [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("gap proposal"), + }, + ], + reservedDao, + ); + stagedDraft = { + proposal: await this.futarchy.initializeProposal( + reservedDao, + stagedSquadsProposal, + ), + squadsProposal: stagedSquadsProposal, + }; + + await this.futarchy + .launchProposalIx({ + proposal: winner.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: winner.squadsProposal, + }) + .rpc(); + + await passProposal(this, { + dao: reservedDao, + proposal: winner.proposal, + baseMint: base, + quoteMint: quote, + cranks: 50, + }); + }); + + it("writes the liquidator at finalize, before the payload ever executes", async function () { + const storedDao = await this.futarchy.getDao(reservedDao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + }); + + it("refuses to launch a second liquidation once the first has passed", async function () { + const callbacks = expectError( + "DaoLiquidated", + "launched a liquidation after another had already passed", + ); + + await this.futarchy + .launchProposalIx({ + proposal: rivalLiquidation.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: rivalLiquidation.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // First writer wins: only one liquidation record ever holds the DAO + const storedDao = await this.futarchy.getDao(reservedDao); + assert.ok(storedDao.liquidator.equals(liquidatorA)); + }); + + it("refuses to launch a pre-staged draft in the finalize→execute gap", async function () { + const callbacks = expectError( + "DaoLiquidated", + "launched a blocker in the finalize→execute gap", + ); + + await this.futarchy + .launchProposalIx({ + proposal: stagedDraft.proposal, + dao: reservedDao, + baseMint: base, + quoteMint: quote, + squadsProposal: stagedDraft.squadsProposal, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + }); } diff --git a/tests/futarchy/unit/liquidatorPath.test.ts b/tests/futarchy/unit/liquidatorPath.test.ts index c28fe30a1..3cd2b3356 100644 --- a/tests/futarchy/unit/liquidatorPath.test.ts +++ b/tests/futarchy/unit/liquidatorPath.test.ts @@ -13,6 +13,7 @@ import { } from "@solana/spl-token"; import { getDaoAddr, + getEnqueuedMultisigProposalApprovalAddr, getProposalAddrsForTransactionIndex, PERMISSIONLESS_ACCOUNT, } from "@metadaoproject/programs"; @@ -24,14 +25,11 @@ import { THOUSAND_BUCK_PRICE, } from "../../utils.js"; -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); - export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey, vault: PublicKey, - squadsMultisig: PublicKey, liquidator: Keypair; // The estate cycle starts from a genuinely liquidated DAO: a hostile @@ -91,10 +89,7 @@ export default function suite() { const storedDao = await this.futarchy.getDao(dao); vault = storedDao.squadsMultisigVault; - squadsMultisig = storedDao.squadsMultisig; - // The baked apply_liquidation payload requires the vault's ATAs to exist - await this.createTokenAccount(META, vault); await this.createTokenAccount(USDC, vault); await this.futarchy @@ -139,6 +134,7 @@ export default function suite() { cranks: 50, }); + await this.futarchy.syncSpendingLimitIx({ dao }).rpc(); await executeVaultTransaction(this, dao, squadsTransaction); // The liquidator pays rent for the enqueued approval account @@ -178,28 +174,13 @@ export default function suite() { return getProposalAddrsForTransactionIndex({ dao, transactionIndex }); }; - const deriveEnqueuedApprovalPda = ( - context: any, - transactionIndex: bigint, - ): PublicKey => { - const [pda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - return pda; - }; - it("refuses a non-liquidator enqueue once the DAO is liquidated", async function () { const recipient = Keypair.generate().publicKey; const recipientAta = await this.createTokenAccount(USDC, recipient); const vaultUsdcAta = getAssociatedTokenAddressSync(USDC, vault, true); // The liquidation payload was transaction 1; the estate starts at 2 - const { squadsProposal } = await createEstateProposal(this, 2n, [ + await createEstateProposal(this, 2n, [ createTransferInstruction( vaultUsdcAta, recipientAta, @@ -213,16 +194,8 @@ export default function suite() { "enqueue by a non-liquidator should fail on a liquidated DAO", ); - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ - dao, - admin: this.payer.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: deriveEnqueuedApprovalPda(this, 2n), - }) - .signers([this.payer]) + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) .rpc() .then(callbacks[0], callbacks[1]); }); @@ -245,16 +218,16 @@ export default function suite() { ], ); - const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, 2n); + const enqueuedApprovalPda = getEnqueuedMultisigProposalApprovalAddr({ + dao, + transactionIndex: 2n, + })[0]; - await this.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(2) }) - .accounts({ + await this.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, + transactionIndex: 2n, admin: liquidator.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, }) .signers([liquidator]) .rpc(); @@ -268,17 +241,8 @@ export default function suite() { // The middle leg stays permissionless: any signer cranks the DAO PDA's // approve vote, which meets the threshold of 1 on its own - await this.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: this.payer.publicKey, - squadsMultisig, - squadsMultisigProposal: squadsProposal, - enqueuedApproval: enqueuedApprovalPda, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) + await this.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex: 2n }) .rpc(); let storedSquadsProposal = diff --git a/tests/futarchy/unit/resizeDao.test.ts b/tests/futarchy/unit/resizeDao.test.ts index 44e07a974..7c9c015a5 100644 --- a/tests/futarchy/unit/resizeDao.test.ts +++ b/tests/futarchy/unit/resizeDao.test.ts @@ -1,3 +1,4 @@ +import { getSpendingLimitAddr } from "@metadaoproject/programs"; import { ComputeBudgetProgram, Keypair, @@ -6,62 +7,58 @@ import { Transaction, } from "@solana/web3.js"; import BN from "bn.js"; -import { setupBasicDao } from "../../utils.js"; +import { expectError, makeOldDaoLayout, setupBasicDao } from "../../utils.js"; import { TestContext } from "../../main.test.js"; import { assert } from "chai"; -type OldLayoutOverrides = { - optimisticProposal?: { - squadsProposal: PublicKey; - enqueuedTimestamp: BN; - } | null; - isOptimisticGovernanceEnabled?: boolean; -}; - -// Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout -// by re-encoding its body as the `oldDao` IDL type (dropping the appended -// `liquidator`, failure timestamps, and `spending_limit_dirty`). Truncation does -// NOT work for Dao: its Option slack would leave the fields' bytes in place. -// Optional overrides let a test pin the optimistic fields without driving the -// (now deleted) optimistic instructions. -async function makeOldLayout( +// Byte offsets into a Squads SpendingLimit account's data: +// disc(8) multisig(32) create_key(32) vault_index(1) mint(32) amount(8) +// period(1) remaining_amount(8) last_reset(8) bump(1) members(vec) destinations(vec) +const SL_VAULT_INDEX_OFFSET = 72; +const SL_MINT_OFFSET = 73; +const SL_PERIOD_OFFSET = 113; +const SL_MEMBERS_LEN_OFFSET = 131; +const PERIOD_DAY = 1; + +// Overwrites the live Squads spending-limit account with mutated bytes — +// shapes the old program's governance path could have created but the new +// program never writes. Returns the patched data so tests can assert the +// migration left the account untouched. +async function patchLiveSpendingLimit( ctx: TestContext, dao: PublicKey, - overrides: OldLayoutOverrides = {}, - opts: { lamports?: number } = {}, -): Promise<{ AFTER: number; BEFORE: number }> { - const raw = await ctx.banksClient.getAccount(dao); - const AFTER = raw.data.length; - // 58 bytes: liquidator (Option) + last_failed_takeover_at (i64) - // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) - // + last_buyback_finalized_at (i64) - const BEFORE = AFTER - 58; - - const disc = Buffer.from(raw.data.slice(0, 8)); - const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; - const decoded = coder.decode("dao", Buffer.from(raw.data)); - - if (overrides.optimisticProposal !== undefined) - decoded.optimisticProposal = overrides.optimisticProposal; - if (overrides.isOptimisticGovernanceEnabled !== undefined) - decoded.isOptimisticGovernanceEnabled = - overrides.isOptimisticGovernanceEnabled; - - // Encode as oldDao (mainnet layout, ending at is_optimistic_governance_enabled); - // drop its discriminator and reattach the real Dao discriminator at the - // pre-migration size. - const body = await coder.encode("oldDao", decoded); - const buf = Buffer.alloc(BEFORE); - disc.copy(buf, 0); - body.subarray(8).copy(buf, 8); - - ctx.context.setAccount(dao, { - ...raw, - data: buf, - ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), - }); + mutate: (data: Buffer) => Buffer, +): Promise { + const [spendingLimit] = getSpendingLimitAddr({ dao }); + const raw = await ctx.banksClient.getAccount(spendingLimit); + const patched = mutate(Buffer.from(raw.data)); + ctx.context.setAccount(spendingLimit, { ...raw, data: patched }); + return patched; +} - return { AFTER, BEFORE }; +function withMembers(data: Buffer, members: PublicKey[]): Buffer { + const oldLen = data.readUInt32LE(SL_MEMBERS_LEN_OFFSET); + const destinationsOffset = SL_MEMBERS_LEN_OFFSET + 4 + 32 * oldLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(members.length, 0); + return Buffer.concat([ + data.subarray(0, SL_MEMBERS_LEN_OFFSET), + len, + ...members.map((m) => Buffer.from(m.toBytes())), + data.subarray(destinationsOffset), + ]); +} + +function withDestinations(data: Buffer, destinations: PublicKey[]): Buffer { + const membersLen = data.readUInt32LE(SL_MEMBERS_LEN_OFFSET); + const destinationsOffset = SL_MEMBERS_LEN_OFFSET + 4 + 32 * membersLen; + const len = Buffer.alloc(4); + len.writeUInt32LE(destinations.length, 0); + return Buffer.concat([ + data.subarray(0, destinationsOffset), + len, + ...destinations.map((d) => Buffer.from(d.toBytes())), + ]); } export default function suite() { @@ -88,15 +85,12 @@ export default function suite() { assert.isFalse(original.spendingLimitDirty); assert.equal(original.lastBuybackFinalizedAt.toString(), "0"); - const { AFTER, BEFORE } = await makeOldLayout(this, dao); + const { AFTER, BEFORE } = await makeOldDaoLayout(this, dao); const short = await this.banksClient.getAccount(dao); assert.equal(short.data.length, BEFORE); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: this.payer.publicKey }) - .rpc(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const resized = await this.banksClient.getAccount(dao); assert.equal(resized.data.length, AFTER); @@ -114,9 +108,8 @@ export default function suite() { ); // Idempotent: a second crank is a no-op (compute-budget bump for a unique sig). - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: this.payer.publicKey }) + await this.futarchy + .resizeDaoIx({ dao }) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ]) @@ -126,9 +119,9 @@ export default function suite() { assert.equal(after2.data.length, AFTER); }); - it("clears an in-flight optimistic proposal and carries the governance flag", async function () { + it("clears an in-flight optimistic proposal and disables the governance flag", async function () { const fakeSquadsProposal = Keypair.generate().publicKey; - await makeOldLayout(this, dao, { + await makeOldDaoLayout(this, dao, { isOptimisticGovernanceEnabled: true, optimisticProposal: { squadsProposal: fakeSquadsProposal, @@ -136,16 +129,13 @@ export default function suite() { }, }); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: this.payer.publicKey }) - .rpc(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const migrated = await this.futarchy.getDao(dao); // The optimistic machinery is gone: in-flight spends are cleared, not - // carried into a state nothing can finalize. + // carried into a state nothing can finalize, and the flag is reset. assert.isNull(migrated.optimisticProposal); - assert.isTrue(migrated.isOptimisticGovernanceEnabled); + assert.isFalse(migrated.isOptimisticGovernanceEnabled); assert.isNull(migrated.liquidator); assert.equal(migrated.lastFailedTakeoverAt.toString(), "0"); assert.equal(migrated.lastFailedLiquidationAt.toString(), "0"); @@ -157,10 +147,7 @@ export default function suite() { const before = await this.futarchy.getDao(dao); const beforeRaw = await this.banksClient.getAccount(dao); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: this.payer.publicKey }) - .rpc(); + await this.futarchy.resizeDaoIx({ dao }).rpc(); const afterRaw = await this.banksClient.getAccount(dao); assert.equal(afterRaw.data.length, beforeRaw.data.length); @@ -183,7 +170,7 @@ export default function suite() { // Shrink to old layout AND drop lamports to the old rent-exempt minimum so // the realloc forces a top-up transfer. - await makeOldLayout(this, dao, {}, { lamports: Number(rentBefore) }); + await makeOldDaoLayout(this, dao, {}, { lamports: Number(rentBefore) }); // Dedicated crank payer (not the fee payer) so its balance change isolates // the top-up transfer from transaction fees. @@ -202,9 +189,8 @@ export default function suite() { const payerBefore = await this.banksClient.getBalance(crankPayer.publicKey); - await this.futarchy.futarchy.methods - .resizeDao() - .accounts({ dao, payer: crankPayer.publicKey }) + await this.futarchy + .resizeDaoIx({ dao, payer: crankPayer.publicKey }) .signers([crankPayer]) .rpc(); @@ -215,4 +201,140 @@ export default function suite() { assert.equal(daoLamports.toString(), rentAfter.toString()); assert.equal((payerBefore - payerAfter).toString(), delta.toString()); }); + + it("migrates the live Squads limit, not the stale legacy field", async function () { + const limitDao = await setupBasicDao({ + context: this, + baseMint: META, + quoteMint: USDC, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [this.payer.publicKey], + }, + }); + + // The legacy field claims 5,000/month even though the live limit is + // 1,000 — the divergence pre-upgrade governance could have created. + await makeOldDaoLayout(this, limitDao, { + initialSpendingLimit: { + amountPerMonth: new BN(5_000_000_000), + members: [Keypair.generate().publicKey], + }, + }); + + await this.futarchy.resizeDaoIx({ dao: limitDao }).rpc(); + + const migrated = await this.futarchy.getDao(limitDao); + assert.equal( + migrated.initialSpendingLimit.amountPerMonth.toString(), + "1000000000", + ); + assert.deepEqual( + migrated.initialSpendingLimit.members.map((m: PublicKey) => m.toBase58()), + [this.payer.publicKey.toBase58()], + ); + assert.isFalse(migrated.spendingLimitDirty); + }); + + it("migrates a stale legacy value as none when no live limit exists", async function () { + // The beforeEach DAO never created a Squads limit, but the legacy field + // claims one exists. + await makeOldDaoLayout(this, dao, { + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [this.payer.publicKey], + }, + }); + + await this.futarchy.resizeDaoIx({ dao }).rpc(); + + const migrated = await this.futarchy.getDao(dao); + assert.isNull(migrated.initialSpendingLimit); + assert.isFalse(migrated.spendingLimitDirty); + }); + + async function assertShapeMigratesAsNone( + ctx: TestContext, + mutate: (data: Buffer) => Buffer, + ) { + const limitDao = await setupBasicDao({ + context: ctx, + baseMint: META, + quoteMint: USDC, + initialSpendingLimit: { + amountPerMonth: new BN(1_000_000_000), + members: [ctx.payer.publicKey], + }, + }); + + const patched = await patchLiveSpendingLimit(ctx, limitDao, mutate); + await makeOldDaoLayout(ctx, limitDao); + + await ctx.futarchy.resizeDaoIx({ dao: limitDao }).rpc(); + + const migrated = await ctx.futarchy.getDao(limitDao); + assert.isNull(migrated.initialSpendingLimit); + + // The live Squads account is read, never written. + const [spendingLimit] = getSpendingLimitAddr({ dao: limitDao }); + const after = await ctx.banksClient.getAccount(spendingLimit); + assert.isTrue(Buffer.from(after.data).equals(patched)); + } + + it("migrates a non-Month live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + data[SL_PERIOD_OFFSET] = PERIOD_DAY; + return data; + }); + }); + + it("migrates a foreign-mint live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + Buffer.from(Keypair.generate().publicKey.toBytes()).copy( + data, + SL_MINT_OFFSET, + ); + return data; + }); + }); + + it("migrates a non-zero-vault live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => { + data[SL_VAULT_INDEX_OFFSET] = 1; + return data; + }); + }); + + it("migrates a live limit with too many members as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => + withMembers( + data, + Array.from({ length: 11 }, () => Keypair.generate().publicKey), + ), + ); + }); + + it("migrates a destination-restricted live limit as none, leaving Squads untouched", async function () { + await assertShapeMigratesAsNone(this, (data) => + withDestinations(data, [Keypair.generate().publicKey]), + ); + }); + + it("throws when passed a non-canonical spending-limit account", async function () { + await makeOldDaoLayout(this, dao); + + const callbacks = expectError( + "InvalidSpendingLimitAccount", + "resize succeeded despite a wrong spending-limit account", + ); + await this.futarchy.futarchy.methods + .resizeDao() + .accounts({ + dao, + spendingLimit: Keypair.generate().publicKey, + payer: this.payer.publicKey, + }) + .rpc() + .then(...callbacks); + }); } diff --git a/tests/futarchy/unit/resizeProposal.test.ts b/tests/futarchy/unit/resizeProposal.test.ts index ba8b005a7..9aec322a7 100644 --- a/tests/futarchy/unit/resizeProposal.test.ts +++ b/tests/futarchy/unit/resizeProposal.test.ts @@ -13,26 +13,33 @@ import { assert } from "chai"; // Rewrites a real (new-layout) Proposal account to the pre-migration on-chain // layout by re-encoding its body as the `oldProposal` IDL type (dropping the -// appended `pass_threshold_bps`, `council_can_block`, and `action`). The -// optional override lets a test pin `is_team_sponsored` without driving the -// sponsor flow. +// appended `pass_threshold_bps`, `council_can_block`, and `action`, and +// collapsing `sponsored_by` back to the `is_team_sponsored` bit). The +// optional overrides let a test pin `is_team_sponsored`, the state, or the +// duration without driving the sponsor/launch flows. async function makeOldLayout( ctx: TestContext, proposal: PublicKey, - overrides: { isTeamSponsored?: boolean } = {}, + overrides: { + isTeamSponsored?: boolean; + state?: object; + durationInSeconds?: number; + } = {}, ): Promise<{ AFTER: number; BEFORE: number }> { const raw = await ctx.banksClient.getAccount(proposal); const AFTER = raw.data.length; - // 369 bytes: pass_threshold_bps (i16) + council_can_block (bool) - // + action (ProposalAction) - const BEFORE = AFTER - 369; + // 401 bytes: sponsored_by (Option) in place of is_team_sponsored (bool) + // + pass_threshold_bps (i16) + council_can_block (bool) + action (ProposalAction) + const BEFORE = AFTER - 401; const disc = Buffer.from(raw.data.slice(0, 8)); const coder = ctx.futarchy.futarchy.account.proposal.coder.accounts; const decoded = coder.decode("proposal", Buffer.from(raw.data)); - if (overrides.isTeamSponsored !== undefined) - decoded.isTeamSponsored = overrides.isTeamSponsored; + decoded.isTeamSponsored = overrides.isTeamSponsored ?? false; + if (overrides.state !== undefined) decoded.state = overrides.state; + if (overrides.durationInSeconds !== undefined) + decoded.durationInSeconds = overrides.durationInSeconds; const body = await coder.encode("oldProposal", decoded); const buf = Buffer.alloc(BEFORE); @@ -114,12 +121,16 @@ export default function suite() { proposal = await createProposal(this, dao); }); - it("migrates an old proposal with defaults snapshotted from the DAO, preserving every other field", async function () { + it("migrates an old draft to the kind's catalog params, preserving every other field", async function () { const original = await this.futarchy.getProposal(proposal); - assert.isFalse(original.isTeamSponsored); + assert.isNull(original.sponsoredBy); assert.equal(original.passThresholdBps, 1000); + assert.equal(original.durationInSeconds, 60 * 60 * 24 * 10); - const { AFTER, BEFORE } = await makeOldLayout(this, proposal); + // A distinctive legacy duration proves normalization to the catalog value. + const { AFTER, BEFORE } = await makeOldLayout(this, proposal, { + durationInSeconds: 3600, + }); const short = await this.banksClient.getAccount(proposal); assert.equal(short.data.length, BEFORE); @@ -135,10 +146,12 @@ export default function suite() { const migrated = await this.futarchy.getProposal(proposal); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); - // The vestigial per-DAO threshold (300), not the kind constant (1000). - assert.equal(migrated.passThresholdBps, 300); + // The kind constants, not the vestigial per-DAO threshold (300) or the + // legacy duration: a draft has no live market, so the permissionless + // crank's timing must not decide the rules it finalizes under. + assert.equal(migrated.passThresholdBps, 1000); + assert.equal(migrated.durationInSeconds, 60 * 60 * 24 * 10); - original.passThresholdBps = 300; assert.deepEqual( JSON.parse(JSON.stringify(migrated)), JSON.parse(JSON.stringify(original)), @@ -163,7 +176,7 @@ export default function suite() { ); }); - it("snapshots the team-sponsored threshold for a team-sponsored proposal", async function () { + it("migrates a team-sponsored draft to the catalog params too", async function () { await makeOldLayout(this, proposal, { isTeamSponsored: true }); await this.futarchy.futarchy.methods @@ -172,12 +185,53 @@ export default function suite() { .rpc(); const migrated = await this.futarchy.getProposal(proposal); - assert.isTrue(migrated.isTeamSponsored); - assert.equal(migrated.passThresholdBps, -100); + assert.equal( + migrated.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal(migrated.passThresholdBps, 1000); + }); + + it("snapshots the DAO threshold and preserves the duration for a launched proposal", async function () { + await makeOldLayout(this, proposal, { + state: { pending: {} }, + durationInSeconds: 3600, + }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.isDefined(migrated.state.pending); + // A live market keeps the rules it was staked and traded under: the + // vestigial per-DAO threshold (300), not the kind constant (1000). + assert.equal(migrated.passThresholdBps, 300); + assert.equal(migrated.durationInSeconds, 3600); assert.isDefined(migrated.action.executeArbitrary); assert.isTrue(migrated.councilCanBlock); }); + it("snapshots the team-sponsored threshold for a launched team-sponsored proposal", async function () { + await makeOldLayout(this, proposal, { + state: { pending: {} }, + isTeamSponsored: true, + }); + + await this.futarchy.futarchy.methods + .resizeProposal() + .accounts({ proposal, dao, payer: this.payer.publicKey }) + .rpc(); + + const migrated = await this.futarchy.getProposal(proposal); + assert.equal( + migrated.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal(migrated.passThresholdBps, -100); + }); + it("is a no-op on an already-new-layout proposal", async function () { const before = await this.futarchy.getProposal(proposal); const beforeRaw = await this.banksClient.getAccount(proposal); @@ -205,21 +259,20 @@ export default function suite() { .accounts({ proposal, dao, payer: this.payer.publicKey }) .rpc(); - // Migration stamps every proposal `ExecuteArbitrary` with a threshold - // copied from the retired per-DAO field, so retuning is the only way to - // bring one in line with the catalog without starting over. + // Migrated drafts land on the catalog params, and stay `ExecuteArbitrary` + // drafts — so the per-proposal admin lever must still apply to them. await this.futarchy .adminUpdateProposalParamsIx({ proposal, dao, durationInSeconds: 60 * 60 * 24 * 2, - passThresholdBps: 1000, + passThresholdBps: 500, }) .rpc(); const retuned = await this.futarchy.getProposal(proposal); assert.equal(retuned.durationInSeconds, 60 * 60 * 24 * 2); - assert.equal(retuned.passThresholdBps, 1000); + assert.equal(retuned.passThresholdBps, 500); }); it("rejects a DAO that is not the proposal's", async function () { diff --git a/tests/futarchy/unit/setSpendingLimit.test.ts b/tests/futarchy/unit/setSpendingLimit.test.ts index 9d05889a5..6918ca2d8 100644 --- a/tests/futarchy/unit/setSpendingLimit.test.ts +++ b/tests/futarchy/unit/setSpendingLimit.test.ts @@ -16,7 +16,6 @@ import { expectError } from "../../utils.js"; import { TestContext } from "../../main.test.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); // The vault PDA can only sign via a Squads vault transaction execution, so the // record is written by creating + approving + executing one containing a @@ -52,43 +51,12 @@ async function executeSetSpendingLimitViaVault( createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(createTx); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda, - transactionIndex, - }); - - const [enqueuedApproval] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao, - admin: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - }) + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - await context.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await context.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); // Execute as a top-level Squads instruction so the vault PDA signs the @@ -109,6 +77,33 @@ async function executeSetSpendingLimitViaVault( await context.banksClient.processTransaction(executeTx); } +// A rejected set_spending_limit surfaces through the Squads execute CPI as a +// raw transaction error, so match on the error name or its hex code and then +// confirm the record and dirty flag were left untouched. +async function assertSetSpendingLimitRejected( + context: TestContext, + dao: PublicKey, + config: { amountPerMonth: BN; members: PublicKey[] }, + errorName: string, + errorHex: string, +) { + await executeSetSpendingLimitViaVault(context, dao, config).then( + () => assert.fail(`set_spending_limit should have thrown ${errorName}`), + (e) => + assert( + e.toString().includes(errorName) || e.toString().includes(errorHex), + `Expected ${errorName} error, got: ${e}`, + ), + ); + + const daoAccount = await context.futarchy.getDao(dao); + assert.equal( + daoAccount.initialSpendingLimit.amountPerMonth.toString(), + "10000000000", + ); + assert.isFalse(daoAccount.spendingLimitDirty); +} + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -213,26 +208,57 @@ export default function suite() { () => Keypair.generate().publicKey, ); - try { - await executeSetSpendingLimitViaVault(this, dao, { + await assertSetSpendingLimitRejected( + this, + dao, + { amountPerMonth: new BN(1_000_000_000), // 1,000 USDC members: elevenMembers, - }); - assert.fail("Should have failed with TooManySpendingLimitMembers"); - } catch (e) { - // The error surfaces through the Squads CPI: TooManySpendingLimitMembers (0x17a4 = 6052) - assert( - e.toString().includes("TooManySpendingLimitMembers") || - e.toString().includes("0x17a4"), - `Expected TooManySpendingLimitMembers error, got: ${e}`, - ); - } + }, + "TooManySpendingLimitMembers", + "0x17a3", // 6051 + ); + }); - const daoAccount = await this.futarchy.getDao(dao); - assert.equal( - daoAccount.initialSpendingLimit.amountPerMonth.toString(), - "10000000000", + it("throws when the config's monthly amount is zero", async function () { + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(0), + members: [Keypair.generate().publicKey], + }, + "InvalidSpendingLimitAmount", + "0x17b2", // 6066 + ); + }); + + it("throws when the config has no members", async function () { + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + members: [], + }, + "EmptySpendingLimitMembers", + "0x17b3", // 6067 + ); + }); + + it("throws when the config has duplicate members", async function () { + const member = Keypair.generate().publicKey; + + await assertSetSpendingLimitRejected( + this, + dao, + { + amountPerMonth: new BN(1_000_000_000), // 1,000 USDC + // Non-adjacent so the check must sort before comparing neighbours + members: [member, Keypair.generate().publicKey, member], + }, + "DuplicateSpendingLimitMember", + "0x17b4", // 6068 ); - assert.isFalse(daoAccount.spendingLimitDirty); }); } diff --git a/tests/futarchy/unit/sponsorProposal.test.ts b/tests/futarchy/unit/sponsorProposal.test.ts new file mode 100644 index 000000000..01844420f --- /dev/null +++ b/tests/futarchy/unit/sponsorProposal.test.ts @@ -0,0 +1,259 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, +} from "@solana/web3.js"; +import { + AuthorityType, + createSetAuthorityInstruction, +} from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + executeVaultTransaction, + expectError, + forceApproveSquadsProposal, +} from "../../utils.js"; +import { TestContext } from "../../main.test.js"; + +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.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo( + META, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 10 ** 6, + ); + + dao = await this.setupBasicDao({ baseMint: META, quoteMint: USDC }); + }); + + async function createMintTokensDraft(ctx: TestContext) { + const { squadsMultisigVault } = await ctx.futarchy.getDao(dao); + + const tx = new Transaction().add( + createSetAuthorityInstruction( + META, + ctx.payer.publicKey, + AuthorityType.MintTokens, + squadsMultisigVault, + ), + ); + [tx.recentBlockhash] = await ctx.banksClient.getLatestBlockhash(); + tx.feePayer = ctx.payer.publicKey; + tx.sign(ctx.payer); + await ctx.banksClient.processTransaction(tx); + + return ctx.futarchy.initializeMintTokensProposal({ + dao, + amount: new BN(1_000_000_000), + recipient: Keypair.generate().publicKey, + }); + } + + it("rejects a hostile takeover draft", async function () { + const { proposal } = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + + const callbacks = expectError( + "TeamSponsorshipForbidden", + "sponsored a hostile takeover", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a hostile liquidate draft", async function () { + const { proposal } = await this.futarchy.initializeHostileLiquidateProposal( + { dao }, + ); + + const callbacks = expectError( + "TeamSponsorshipForbidden", + "sponsored a hostile liquidation", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("sponsors a mint tokens draft", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + }); + + it("rejects a second sponsorship", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const callbacks = expectError( + "ProposalAlreadySponsored", + "sponsored a proposal twice", + ); + + // Compute unit price makes this transaction's hash differ from the first one + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + this.payer.publicKey.toBase58(), + ); + }); + + it("records the new team after a team change", async function () { + const { proposal } = await createMintTokensDraft(this); + + await this.futarchy.sponsorProposalIx({ proposal, dao }).rpc(); + + const newTeam = Keypair.generate(); + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: newTeam.publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: newTeam.publicKey }) + .signers([newTeam]) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.equal( + storedProposal.sponsoredBy?.toBase58(), + newTeam.publicKey.toBase58(), + ); + }); + + it("rejects the previous team after a team change", async function () { + const { proposal } = await createMintTokensDraft(this); + + const takeover = await this.futarchy.initializeHostileTakeoverProposal({ + dao, + newTeamAddress: Keypair.generate().publicKey, + spendingLimitAction: { keep: {} }, + }); + await forceApproveSquadsProposal(this, takeover.squadsProposal); + await executeVaultTransaction(this, dao, takeover.squadsTransaction); + + const callbacks = expectError( + "ConstraintHasOne", + "sponsored with the previous team", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a launched proposal", async function () { + const { proposal, squadsProposal } = await createMintTokensDraft(this); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(10_000 * 10 ** 6), + maxBaseAmount: new BN(20_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + }) + .rpc(); + + const callbacks = expectError( + "ProposalNotInDraftState", + "sponsored a launched proposal", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); + + it("rejects a signer that is not the team", async function () { + const { proposal } = await createMintTokensDraft(this); + const stranger = Keypair.generate(); + + const callbacks = expectError( + "ConstraintHasOne", + "sponsored with a non-team signer", + ); + + await this.futarchy + .sponsorProposalIx({ proposal, dao, teamAddress: stranger.publicKey }) + .signers([stranger]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.isNull(storedProposal.sponsoredBy); + }); +} diff --git a/tests/futarchy/unit/syncSpendingLimit.test.ts b/tests/futarchy/unit/syncSpendingLimit.test.ts index 6ec105a8e..4fbc8e62f 100644 --- a/tests/futarchy/unit/syncSpendingLimit.test.ts +++ b/tests/futarchy/unit/syncSpendingLimit.test.ts @@ -19,7 +19,6 @@ import { TestContext } from "../../main.test.js"; const { Period } = multisig.types; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); -const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); async function initializeTestDao( context: TestContext, @@ -90,43 +89,12 @@ async function executeSetSpendingLimitViaVault( createTx.sign(context.payer, PERMISSIONLESS_ACCOUNT); await context.banksClient.processTransaction(createTx); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda, - transactionIndex, - }); - - const [enqueuedApproval] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - context.futarchy.futarchy.programId, - ); - - await context.futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(transactionIndex.toString()), - }) - .accounts({ - dao, - admin: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - }) + await context.futarchy + .adminEnqueueMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); - await context.futarchy.futarchy.methods - .executeMultisigProposalApproval() - .accounts({ - dao, - rentReceiver: context.payer.publicKey, - squadsMultisig: multisigPda, - squadsMultisigProposal: squadsProposal, - enqueuedApproval, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) + await context.futarchy + .executeMultisigProposalApprovalIx({ dao, transactionIndex }) .rpc(); // Execute as a top-level Squads instruction so the vault PDA signs the diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 96a31a728..c982187b7 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -76,7 +76,6 @@ export default function suite() { twapStartDelaySeconds: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 0eeaea7a0..c5f55607c 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -104,7 +104,6 @@ export default function suite() { teamSponsoredPassThresholdBps: null, teamAddress: null, twapStartDelaySeconds: null, - isOptimisticGovernanceEnabled: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587c..038ac5f0f 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -384,7 +384,6 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: false, }, }) .instruction(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d0..7fdaeba42 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -429,7 +429,6 @@ export default async function suite() { minBaseFutarchicLiquidity: null, teamSponsoredPassThresholdBps: null, teamAddress: null, - isOptimisticGovernanceEnabled: true, }, }) .instruction(); @@ -617,7 +616,7 @@ export default async function suite() { const storedDao2 = await this.futarchy.getDao(dao); assert.equal(storedDao2.passThresholdBps, 500); - assert.isTrue(storedDao2.isOptimisticGovernanceEnabled); + assert.isFalse(storedDao2.isOptimisticGovernanceEnabled); const storedMeta = await this.getMint(META); diff --git a/tests/liquidation/unit/refund.test.ts b/tests/liquidation/unit/refund.test.ts index d53303734..9ef7157be 100644 --- a/tests/liquidation/unit/refund.test.ts +++ b/tests/liquidation/unit/refund.test.ts @@ -4,7 +4,12 @@ import { PublicKey, ComputeBudgetProgram, SystemProgram, + Transaction, } from "@solana/web3.js"; +import { + createMintToInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; import { assert } from "chai"; import { expectError } from "../../utils.js"; import { @@ -146,13 +151,21 @@ export default function suite() { assert.equal(record.baseBurned.toString(), "500000000"); assert.equal(record.quoteRefunded.toString(), "250000000"); - // Mint 500 more tokens - await this.mintTo( - baseMint, - recipient.publicKey, - baseMintAuthority, - 500_000_000, + // Mint 500 more tokens. The compute-unit price makes this transaction's + // hash differ from the first mint. + const mintTx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + createMintToInstruction( + baseMint, + getAssociatedTokenAddressSync(baseMint, recipient.publicKey, true), + baseMintAuthority.publicKey, + 500_000_000, + ), ); + [mintTx.recentBlockhash] = await this.banksClient.getLatestBlockhash(); + mintTx.feePayer = this.payer.publicKey; + mintTx.sign(this.payer, baseMintAuthority); + await this.banksClient.processTransaction(mintTx); // Second refund: burns remaining 500, gets 250 more await liquidationClient diff --git a/tests/utils.ts b/tests/utils.ts index 6290deaec..35763ec43 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -81,6 +81,63 @@ export async function setupBasicDao({ return dao; } +export type OldDaoLayoutOverrides = { + optimisticProposal?: { + squadsProposal: PublicKey; + enqueuedTimestamp: typeof BN.prototype; + } | null; + isOptimisticGovernanceEnabled?: boolean; + initialSpendingLimit?: { + amountPerMonth: typeof BN.prototype; + members: PublicKey[]; + } | null; +}; + +// Rewrites a real (new-layout) Dao account to the pre-migration on-chain layout. +export async function makeOldDaoLayout( + ctx: TestContext, + dao: PublicKey, + overrides: OldDaoLayoutOverrides = {}, + opts: { lamports?: number; residue?: Buffer } = {}, +): Promise<{ AFTER: number; BEFORE: number }> { + const raw = await ctx.banksClient.getAccount(dao); + const AFTER = raw.data.length; + // 58 bytes: liquidator (Option) + last_failed_takeover_at (i64) + // + last_failed_liquidation_at (i64) + spending_limit_dirty (bool) + // + last_buyback_finalized_at (i64) + const BEFORE = AFTER - 58; + + const disc = Buffer.from(raw.data.slice(0, 8)); + const coder = ctx.futarchy.futarchy.account.dao.coder.accounts; + const decoded = coder.decode("dao", Buffer.from(raw.data)); + + if (overrides.optimisticProposal !== undefined) + decoded.optimisticProposal = overrides.optimisticProposal; + if (overrides.isOptimisticGovernanceEnabled !== undefined) + decoded.isOptimisticGovernanceEnabled = + overrides.isOptimisticGovernanceEnabled; + if (overrides.initialSpendingLimit !== undefined) + decoded.initialSpendingLimit = overrides.initialSpendingLimit; + + // Encode as oldDao and truncate to the pre-migration size. + const body = await coder.encode("oldDao", decoded); + const buf = Buffer.alloc(BEFORE); + disc.copy(buf, 0); + body.subarray(8).copy(buf, 8); + if (opts.residue !== undefined) { + assert.isAtMost(body.length + opts.residue.length, BEFORE); + opts.residue.copy(buf, body.length); + } + + ctx.context.setAccount(dao, { + ...raw, + data: buf, + ...(opts.lamports !== undefined ? { lamports: opts.lamports } : {}), + }); + + return { AFTER, BEFORE }; +} + // Pumps the pass market with a one-shot conditional-quote buy, then cranks // the TWAPs `cranks` times, 20,000s apart. The defaults clear every kind's // threshold (including HostileLiquidate's +25%) for the standard test market @@ -198,6 +255,10 @@ export async function executeVaultTransaction( context: TestContext, dao: PublicKey, squadsTransaction: PublicKey, + preInstructions: TransactionInstruction[] = [], + // For payloads whose inner message names signers beyond the vault PDA + // (e.g. a gated_invoke caller) — Squads requires them on the execute + extraSigners: Keypair[] = [], ) { const vaultTransaction = await multisig.accounts.VaultTransaction.fromAccountAddress( @@ -212,10 +273,10 @@ export async function executeVaultTransaction( member: PERMISSIONLESS_ACCOUNT.publicKey, }); - const tx = new Transaction().add(instruction); + const tx = new Transaction().add(...preInstructions, instruction); [tx.recentBlockhash] = await context.banksClient.getLatestBlockhash(); tx.feePayer = context.payer.publicKey; - tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT, ...extraSigners); await context.banksClient.processTransaction(tx); } diff --git a/yarn.lock b/yarn.lock index 56dad3081..7a0c3f06d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -933,15 +933,9 @@ resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-6.17.0.tgz#370840b915a0b44fc867fc4e6afc68d26a2055dd" integrity sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w== -"@metadaoproject/programs@./sdk": - version "0.1.1-alpha.0" - dependencies: - "@coral-xyz/anchor" "0.29.0" - "@noble/hashes" "1.8.0" - "@solana/spl-token" "0.3.11" - "@solana/web3.js" "1.98.4" - "@sqds/multisig" "2.1.4" - bn.js "5.2.2" +"@metadaoproject/programs@link:./sdk": + version "0.0.0" + uid "" "@metaplex-foundation/beet-solana@0.4.0": version "0.4.0"