Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ba8e57f
IDL freeze + futarchy crate bump
metapileks Jul 16, 2026
3e81a70
remove optimistic proposals
metapileks Jul 16, 2026
0ccd17b
mini-instructions: typed ProposalAction + params table, Dao/Proposal …
metapileks Jul 17, 2026
ba791c3
initialize proposal uses execute arbitrary
metapileks Jul 17, 2026
0d6ab0b
launch_proposal kind gates
metapileks Jul 20, 2026
d3dcea3
finalize_proposal should read off proposal threshold, not DAO
metapileks Jul 20, 2026
759a8a1
blockable proposal check
metapileks Jul 20, 2026
b26d280
set_spending_limit
metapileks Jul 20, 2026
fc4bb82
sync spending limit
metapileks Jul 20, 2026
ef2fbf7
shard typed proposal creation infra
metapileks Jul 20, 2026
9bf7aa5
init large spend proposal
metapileks Jul 21, 2026
cd1afde
mint tokens instruction + minor test refactor
metapileks Jul 21, 2026
9515959
spending limit change
metapileks Jul 21, 2026
59e57c6
hostile takeover
metapileks Jul 21, 2026
d8a8b0c
minor refactor
metapileks Jul 21, 2026
e9a8d78
hostile liquidation
metapileks Jul 22, 2026
13db286
liquidator path for admin execution
metapileks Jul 22, 2026
ee5bb42
liquidation guards
metapileks Jul 22, 2026
94166c0
migrate dao
metapileks Jul 22, 2026
7f42131
proposal migration
metapileks Jul 22, 2026
21a61a2
migration script
metapileks Jul 22, 2026
44bfa45
e2e tests
metapileks Jul 22, 2026
8d25d31
remove slop
metapileks Jul 22, 2026
686f9d5
fix up comments
metapileks Jul 23, 2026
a8110ca
minor additions to test utils
metapileks Jul 23, 2026
a298864
update idl, reduce cycles in tests
metapileks Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Anchor.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
solana_version = "1.17.34"

Expand Down Expand Up @@ -47,7 +47,6 @@
v05-execute-proposal = "yarn run tsx scripts/v0.5/executeProposal.ts"
v06-launch = "yarn run tsx scripts/v0.6/launch.ts"
claim-launch = "yarn run tsx scripts/v0.6/claimAllLaunch.ts"
v06-execute-proposal = "yarn run tsx scripts/v0.6/executeProposal.ts"
v06-execute-general-proposal = "yarn run tsx scripts/v0.6/executeGeneralProposal.ts"
v06-dump-daos-proposals = "yarn run tsx scripts/v0.6/dumpDaosProposals.ts"
v06-migrate-daos-proposals = "yarn run tsx scripts/v0.6/migrateDaosProposals.ts"
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion programs/futarchy/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "futarchy"
version = "0.6.1"
version = "0.6.2"
description = "SVM-based program for running futarchy"
edition = "2021"

Expand All @@ -23,3 +23,4 @@ solana-security-txt = "=1.1.1"
conditional_vault = { path = "../conditional_vault", features = ["cpi"] }
squads-multisig-program = { git = "https://github.com/Squads-Protocol/v4", package = "squads-multisig-program", rev = "6d5235da621a2e9b7379ea358e48760e981053be", features = ["cpi"] }
damm_v2_cpi = { path = "../damm_v2_cpi", features = ["cpi"] }
mint_governor = { path = "../mint_governor", features = ["cpi"] }
22 changes: 22 additions & 0 deletions programs/futarchy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,26 @@ pub enum FutarchyError {
InvalidSpendingLimitMint,
#[msg("No active optimistic proposal")]
NoActiveOptimisticProposal,
#[msg("This DAO has been liquidated")]
DaoLiquidated,
#[msg("A hostile proposal of this kind failed recently, so the cooldown must elapse first")]
HostileCooldownActive,
#[msg("The DAO has no spending limit")]
NoSpendingLimit,
#[msg("Amount exceeds the cap of 3x the monthly spending limit")]
SpendCapExceeded,
#[msg("The base mint's authority is neither the treasury vault nor a mint governor")]
UnknownMintAuthority,
#[msg("This proposal kind must be team-sponsored before it can launch")]
ProposalNotTeamSponsored,
#[msg("The spending limit record hasn't changed, so there is nothing to sync")]
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")]
InvalidLiquidator,
}
34 changes: 21 additions & 13 deletions programs/futarchy/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anchor_lang::prelude::*;

use crate::{FutarchyAmm, InitialSpendingLimit, Market, ProposalState, SwapType};
use crate::{FutarchyAmm, InitialSpendingLimit, Market, ProposalAction, ProposalState, SwapType};

#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct CommonFields {
Expand Down Expand Up @@ -87,6 +87,7 @@ pub struct InitializeProposalEvent {
pub squads_proposal: Pubkey,
pub squads_multisig: Pubkey,
pub squads_multisig_vault: Pubkey,
pub action: ProposalAction,
}

#[event]
Expand Down Expand Up @@ -233,23 +234,30 @@ pub struct AdminFixPositionAuthorityEvent {
}

#[event]
pub struct InitiateVaultSpendOptimisticProposalEvent {
pub struct SetSpendingLimitEvent {
pub common: CommonFields,
pub dao: Pubkey,
pub proposer: Pubkey,
pub squads_proposal: Pubkey,
pub squads_multisig: Pubkey,
pub squads_multisig_vault: Pubkey,
pub amount: u64,
pub recipient: Pubkey,
pub dao_quote_vault_account: Pubkey,
pub recipient_quote_account: Pubkey,
pub enqueued_timestamp: i64,
pub config: Option<InitialSpendingLimit>,
}

#[event]
pub struct FinalizeOptimisticProposalEvent {
pub struct SyncSpendingLimitEvent {
pub common: CommonFields,
pub dao: Pubkey,
pub squads_proposal: Pubkey,
/// The Squads-side SpendingLimit PDA the record was projected onto.
pub spending_limit: Pubkey,
/// The projected record — the Squads-side end-state after the sync.
/// `None` = no limit (removed or never existed).
pub config: Option<InitialSpendingLimit>,
}

#[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,
}
8 changes: 8 additions & 0 deletions programs/futarchy/src/instructions/admin_cancel_proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ pub struct AdminCancelProposal<'info> {

impl AdminCancelProposal<'_> {
pub fn validate(&self) -> Result<()> {
// 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.
require!(
self.proposal.council_can_block,
FutarchyError::InvalidProposalKind
);

#[cfg(feature = "production")]
require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,28 @@ pub struct AdminEnqueueMultisigProposalApproval<'info> {

impl AdminEnqueueMultisigProposalApproval<'_> {
pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> {
#[cfg(feature = "production")]
require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin);
// 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
// ordinary top-level Squads execution.
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);
}
}

if !matches!(self.dao.amm.state, PoolState::Spot { .. }) {
return Err(FutarchyError::PoolNotInSpotState.into());
}

if self.dao.optimistic_proposal.is_some() {
return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into());
}

validate_squads_proposal(
&self.squads_multisig_proposal,
&self.squads_multisig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,6 @@ impl<'info, 'c: 'info> AdminExecuteMultisigProposal<'info> {
return Err(FutarchyError::PoolNotInSpotState.into());
}

if self.dao.optimistic_proposal.is_some() {
return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into());
}

Ok(())
}

Expand Down
197 changes: 197 additions & 0 deletions programs/futarchy/src/instructions/apply_liquidation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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<'info, Proposal>>,
#[account(mut, has_one = squads_multisig_vault)]
pub dao: Box<Account<'info, Dao>>,
/// 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<Self>) -> 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;
let total_liquidity = dao.amm.total_liquidity;
require_gt!(total_liquidity, 0, FutarchyError::AssertFailed);

{
let PoolState::Spot { ref mut spot } = dao.amm.state else {
return err!(FutarchyError::PoolNotInSpotState);
};

let (base_to_sweep, quote_to_sweep) =
spot.get_base_and_quote_withdrawable(liquidity_to_sweep, total_liquidity);
spot.base_reserves -= base_to_sweep;
spot.quote_reserves -= quote_to_sweep;

base_swept = base_to_sweep;
quote_swept = quote_to_sweep;
}

dao.amm.total_liquidity -= liquidity_to_sweep;

position.liquidity = 0;
{
let mut data = amm_position.try_borrow_mut_data()?;
let mut writer: &mut [u8] = &mut data;
position.try_serialize(&mut writer)?;
}

let dao_creator = dao.dao_creator;
let nonce = dao.nonce.to_le_bytes();
let signer_seeds = &[
SEED_DAO,
dao_creator.as_ref(),
nonce.as_ref(),
&[dao.pda_bump],
];

for (amount_to_sweep, from, to) in [
(base_swept, amm_base_vault, vault_base_account),
(quote_swept, amm_quote_vault, vault_quote_account),
] {
token::transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
Transfer {
from: from.to_account_info(),
to: to.to_account_info(),
authority: dao.to_account_info(),
},
&[&signer_seeds[..]],
),
amount_to_sweep,
)?;
}
}
}

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(())
}
}
2 changes: 2 additions & 0 deletions programs/futarchy/src/instructions/conditional_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ pub struct ConditionalSwap<'info> {

impl ConditionalSwap<'_> {
pub fn validate(&self, params: &ConditionalSwapParams) -> Result<()> {
require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated);

require_neq!(params.market, Market::Spot);

require_gte!(
Expand Down
Loading
Loading