Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ed0d1de
typed proposals opt-in scaffolding
metapileks Sep 11, 2026
7b31c96
resize_dao - lift zero min liquidity to 1
metapileks Sep 11, 2026
0caf24d
typed proposals opt-in - update_dao mechanics
metapileks Sep 11, 2026
ec0110a
typed proposal gates
metapileks Sep 11, 2026
34af106
gate proposal launch based on typed proposal initialization state
metapileks Sep 11, 2026
bc69022
proposals should initialize with parameters according to their dao's …
metapileks Sep 11, 2026
e5927a4
admin override for new opt-in typed proposal scheme
metapileks Sep 11, 2026
c0b4053
proper proposal parameter application on launch
metapileks Sep 12, 2026
b593a0f
resize proposal - use dao-specific params based on dao's configuratio…
metapileks Sep 13, 2026
367a25c
typed proposal opt-in end-to-end test
metapileks Sep 13, 2026
629538f
Merge remote-tracking branch 'origin/pileks/mini-instructions-fixes' …
metapileks Sep 15, 2026
0d4dd3e
add clarifying comment
metapileks Sep 16, 2026
2a3f260
fix(scripts): update dao migration to include canonical spending limit
metapileks Sep 18, 2026
639098d
fix(futarchy): re-check hostile takeover target team at launch
metapileks Sep 25, 2026
9de5874
feat(futarchy): require frozen lookup tables for generic proposals
metapileks Sep 25, 2026
c09d43d
feat(futarchy): switch hostile takeovers off in production
metapileks Sep 25, 2026
cead9b6
fix(futarchy): remove any live spending limit on liquidation
metapileks Sep 25, 2026
cefe05d
hostile takeover applies DAO fields on proposal finalization
metapileks Sep 29, 2026
a65be8e
fix(futarchy): reintroduce deprecated update_dao parameter, keep inert
metapileks Sep 29, 2026
b9d8b9d
ensure DAO is migrated before mutations
metapileks Sep 30, 2026
55608e6
Merge remote-tracking branch 'origin/pileks/mini-instructions-fixes' …
metapileks Oct 12, 2026
a0b345e
bump SDK to new alpha
metapileks Oct 12, 2026
f4b8cf8
introduce missing events in SDK, bump package version
metapileks Oct 12, 2026
d1b36bf
fix(futarchy): properly assign team sponsorship in events, bump SDK v…
metapileks Oct 13, 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
10 changes: 10 additions & 0 deletions programs/futarchy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,14 @@ pub enum FutarchyError {
TeamSponsorshipForbidden,
#[msg("Squads proposal must be in Approved status to be cancelled")]
SquadsProposalNotApproved,
#[msg("This DAO has not opted into typed proposals")]
TypedProposalsDisabled,
#[msg("Typed proposals cannot be disabled")]
TypedProposalsCannotBeDisabled,
#[msg("Address lookup tables referenced by the vault transaction must be frozen")]
UnfrozenAddressLookupTable,
#[msg("Lookup table accounts must match the vault transaction's address table lookups")]
InvalidAddressLookupTable,
#[msg("Expected the proposal's Squads vault transaction as the first launch account")]
InvalidSquadsVaultTransaction,
}
6 changes: 6 additions & 0 deletions programs/futarchy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub struct InitializeDaoEvent {
pub squads_multisig_vault: Pubkey,
pub team_sponsored_pass_threshold_bps: i16,
pub team_address: Pubkey,
pub typed_proposals_enabled: bool,
}

#[event]
Expand All @@ -70,6 +71,7 @@ pub struct UpdateDaoEvent {
pub team_sponsored_pass_threshold_bps: i16,
pub team_address: Pubkey,
pub is_optimistic_governance_enabled: bool,
pub typed_proposals_enabled: bool,
}

#[event]
Expand Down Expand Up @@ -116,6 +118,10 @@ pub struct LaunchProposalEvent {
pub timestamp_enqueued: i64,
pub total_staked: u64,
pub post_amm_state: FutarchyAmm,
/// The terms the market opened with, as written by launch.
pub duration_in_seconds: u32,
pub pass_threshold_bps: i16,
pub is_team_sponsored: bool,
}

#[event]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use anchor_lang::prelude::declare_id;

// MetaDAO ops multisig — the same signer as the approval enqueue
declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf");

Check warning on line 7 in programs/futarchy/src/instructions/admin_update_proposal_params.rs

View workflow job for this annotation

GitHub Actions / repository-guard

declare_id! literal change; Hardcoded Solana address literal: + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf");
}

#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)]
Expand Down Expand Up @@ -55,7 +55,10 @@
// The same comparison `launch_proposal` makes
require_gt!(
duration_in_seconds,
self.proposal.action.params().twap_start_delay_seconds,
self.proposal
.action
.params_for(&self.dao, false)
.twap_start_delay_seconds,
FutarchyError::ProposalDurationTooShort
);
}
Expand Down Expand Up @@ -91,6 +94,11 @@
proposal.pass_threshold_bps = pass_threshold_bps;
}

// `launch_proposal` keeps these values instead of writing its own.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// It is assumed that a change to the proposal params applies to both,
// even when only one is changed.
proposal.params_overridden = true;

dao.seq_num += 1;
let clock = Clock::get()?;

Expand Down
47 changes: 30 additions & 17 deletions programs/futarchy/src/instructions/finalize_proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ impl FinalizeProposal<'_> {

let clock = Clock::get()?;

// Read before a passed takeover moves the team below.
let is_team_sponsored = proposal.is_sponsored_by(dao.team_address);

let calculate_twap = |amm: &Pool| -> Result<u128> {
let twap_start_timestamp =
amm.oracle.created_at_timestamp + amm.oracle.start_delay_seconds as i64;
Expand Down Expand Up @@ -165,7 +168,6 @@ impl FinalizeProposal<'_> {
proposal.state = new_proposal_state;

// We want to prevent certain proposals from being retried immediately after failure.
// This is to prevent abuse of the system, given that the council can't veto the proposal.
if new_proposal_state == ProposalState::Failed {
match proposal.action {
ProposalAction::HostileTakeover { .. } => {
Expand All @@ -178,26 +180,37 @@ 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.
// Hostile actions that change the DAO itself land said changes immediately.
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;
match &proposal.action {
// This write can only occur once, as a liquidated DAO can't start another proposal.
ProposalAction::HostileLiquidate { liquidator } => {
dao.liquidator = Some(*liquidator);

// The spending limit must be zeroed so that the estate can be swept.
dao.set_spending_limit(None);
}
// The team moves at once, so the outgoing team's sponsorships stop counting.
ProposalAction::HostileTakeover {
new_team_address,
spending_limit_action,
} => {
dao.team_address = *new_team_address;

match spending_limit_action {
SpendingLimitAction::Keep => {}
SpendingLimitAction::Remove => dao.set_spending_limit(None),
SpendingLimitAction::Set(config) => {
dao.set_spending_limit(Some(config.clone()))
}
}
}
_ => {}
}
}

// 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
// retries of a rejected proposal. `admin_cancel_proposal` writes no
// timestamp, so a council block can't lock buybacks out for a quarter.
// The buyback cooldown stamps on either outcome, so back-to-back
// buybacks can't drain the treasury.
if matches!(proposal.action, ProposalAction::BuybackToken { .. }) {
dao.last_buyback_finalized_at = clock.unix_timestamp;
}
Expand Down Expand Up @@ -321,7 +334,7 @@ impl FinalizeProposal<'_> {
squads_proposal: squads_proposal.key(),
squads_multisig: dao.squads_multisig,
post_amm_state: dao.amm.clone(),
is_team_sponsored: proposal.is_sponsored_by(dao.team_address),
is_team_sponsored,
});

Ok(())
Expand Down
2 changes: 2 additions & 0 deletions programs/futarchy/src/instructions/initialize_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ impl InitializeDao<'_> {
last_failed_liquidation_at: 0,
spending_limit_dirty: false,
last_buyback_finalized_at: 0,
typed_proposals_enabled: true,
});

dao.invariant()?;
Expand All @@ -250,6 +251,7 @@ impl InitializeDao<'_> {
squads_multisig_vault: dao.squads_multisig_vault,
team_sponsored_pass_threshold_bps: dao.team_sponsored_pass_threshold_bps,
team_address: dao.team_address,
typed_proposals_enabled: dao.typed_proposals_enabled,
});

Ok(())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
use anchor_lang::solana_program::instruction::Instruction;
use anchor_lang::InstructionData;

use super::*;

#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
Expand All @@ -17,6 +14,11 @@ pub struct InitializeHostileTakeoverProposal<'info> {

impl InitializeHostileTakeoverProposal<'_> {
pub fn validate(&self, args: &InitializeHostileTakeoverProposalArgs) -> Result<()> {
// Hostile takeovers are switched off in production for now.
if cfg!(feature = "production") {
return err!(FutarchyError::InvalidProposalKind);
}

self.typed_initialize_accounts.validate()?;

require_keys_neq!(
Expand All @@ -35,66 +37,22 @@ impl InitializeHostileTakeoverProposal<'_> {
pub fn handle(ctx: Context<Self>, args: InitializeHostileTakeoverProposalArgs) -> Result<()> {
let typed_initialize_accounts = &mut ctx.accounts.typed_initialize_accounts;

let (event_authority, _) =
Pubkey::find_program_address(&[b"__event_authority"], &crate::ID);

let update_dao_ix = Instruction {
program_id: crate::ID,
accounts: crate::accounts::UpdateDao {
dao: typed_initialize_accounts.dao.key(),
squads_multisig_vault: typed_initialize_accounts.dao.squads_multisig_vault,
event_authority,
program: crate::ID,
}
.to_account_metas(None),
data: crate::instruction::UpdateDao {
dao_params: UpdateDaoParams {
pass_threshold_bps: None,
seconds_per_proposal: None,
twap_initial_observation: None,
twap_max_observation_change_per_update: None,
twap_start_delay_seconds: None,
min_quote_futarchic_liquidity: None,
min_base_futarchic_liquidity: None,
base_to_stake: None,
team_sponsored_pass_threshold_bps: None,
team_address: Some(args.new_team_address),
},
}
.data(),
// The payload is only a memo: finalize_proposal applies a passed takeover itself.
let spending_limit = match &args.spending_limit_action {
SpendingLimitAction::Keep => "keep".to_string(),
SpendingLimitAction::Remove => "remove".to_string(),
SpendingLimitAction::Set(limit) => format!("set:{}", limit.amount_per_month),
};

let mut instructions = vec![update_dao_ix];

// `Keep` bakes no second instruction; `Remove`/`Set` append a
// vault-signed set_spending_limit carrying the declared end state.
let spending_limit_config = match &args.spending_limit_action {
SpendingLimitAction::Keep => None,
SpendingLimitAction::Remove => Some(SetSpendingLimitArgs { config: None }),
SpendingLimitAction::Set(limit) => Some(SetSpendingLimitArgs {
config: Some(limit.clone()),
}),
};

if let Some(set_spending_limit_args) = spending_limit_config {
instructions.push(Instruction {
program_id: crate::ID,
accounts: crate::accounts::SetSpendingLimit {
dao: typed_initialize_accounts.dao.key(),
squads_multisig_vault: typed_initialize_accounts.dao.squads_multisig_vault,
event_authority,
program: crate::ID,
}
.to_account_metas(None),
data: crate::instruction::SetSpendingLimit {
args: set_spending_limit_args,
}
.data(),
});
}
let memo = format!(
"metadao-takeover/1 proposal={} new_team={} spending_limit={}",
typed_initialize_accounts.proposal.key(),
args.new_team_address,
spending_limit,
);
let memo_ix = spl_memo::build_memo(memo.as_bytes(), &[]);

let event = typed_initialize_accounts.initialize_proposal(
&instructions,
&[memo_ix],
ProposalAction::HostileTakeover {
new_team_address: args.new_team_address,
spending_limit_action: args.spending_limit_action,
Expand Down
24 changes: 21 additions & 3 deletions programs/futarchy/src/instructions/initialize_proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ pub struct InitializeProposal<'info> {
pub proposal: Box<Account<'info, Proposal>>,
pub squads_proposal: Box<Account<'info, squads_multisig_program::Proposal>>,
pub squads_multisig: Box<Account<'info, squads_multisig_program::Multisig>>,
#[account(
seeds = [
squads_multisig_program::SEED_PREFIX,
squads_multisig.key().as_ref(),
squads_multisig_program::SEED_TRANSACTION,
squads_proposal.transaction_index.to_le_bytes().as_ref(),
],
bump,
seeds::program = squads_multisig_program::ID,
)]
pub squads_vault_transaction: Box<Account<'info, squads_multisig_program::VaultTransaction>>,
#[account(mut, has_one = squads_multisig)]
pub dao: Box<Account<'info, Dao>>,
#[account(
Expand All @@ -35,8 +46,8 @@ pub struct InitializeProposal<'info> {
pub system_program: Program<'info, System>,
}

impl InitializeProposal<'_> {
pub fn validate(&self) -> Result<()> {
impl<'info> InitializeProposal<'info> {
pub fn validate(&self, remaining_accounts: &'info [AccountInfo<'info>]) -> Result<()> {
require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated);

require_eq!(
Expand All @@ -61,6 +72,10 @@ impl InitializeProposal<'_> {
self.squads_multisig.stale_transaction_index
);

// Every lookup table the payload resolves through must be frozen, so
// the accounts the market prices are the ones that execute.
validate_address_lookup_tables(&self.squads_vault_transaction.message, remaining_accounts)?;

// Should never be the case because the oracle is the proposal account, and you can't re-initialize a proposal
assert!(!self.question.is_resolved());

Expand All @@ -75,6 +90,7 @@ impl InitializeProposal<'_> {
proposal,
squads_proposal,
squads_multisig: _,
squads_vault_transaction: _,
dao,
proposer,
payer: _,
Expand All @@ -88,7 +104,8 @@ impl InitializeProposal<'_> {
dao.proposal_count += 1;

let action = ProposalAction::ExecuteArbitrary;
let params = action.params();
// A preview: launch writes the terms from the configuration in force later.
let params = action.params_for(dao, false);

proposal.set_inner(Proposal {
number: dao.proposal_count,
Expand All @@ -110,6 +127,7 @@ impl InitializeProposal<'_> {
pass_threshold_bps: params.pass_threshold_bps,
council_can_block: params.council_can_block,
action,
params_overridden: false,
});

dao.seq_num += 1;
Expand Down
Loading
Loading