Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
2608fb8
rename typed "create" accounts to typed initialize accounts, propagat…
metapileks Aug 4, 2026
4a23ef9
add memo, better comments
metapileks Aug 4, 2026
69df3dc
update test to reflect memo
metapileks Aug 5, 2026
feebe8a
refactor core liquidity withdrawal logic to run same code from two ca…
metapileks Aug 5, 2026
796ffe8
give each proposal kind its own twap start delay
metapileks Aug 7, 2026
6c5f5f0
admin_update_proposal_params ix, sdk, tests
metapileks Aug 7, 2026
79544df
initialize_buyback_token_proposal ix, sdk, tests
metapileks Aug 10, 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
Expand Up @@ -47,7 +47,6 @@ v04-initialize-proposal = "yarn run tsx scripts/v0.4/initializeProposal.ts"
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
4 changes: 3 additions & 1 deletion Cargo.lock

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

4 changes: 3 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 @@ -20,6 +20,8 @@ production = []
anchor-lang = "=0.29.0"
anchor-spl = "=0.29.0"
solana-security-txt = "=1.1.1"
spl-memo = { version = "=4.0.0", features = ["no-entrypoint"] }
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"] }
42 changes: 42 additions & 0 deletions programs/futarchy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,46 @@ pub enum FutarchyError {
InvalidSpendingLimitMint,
#[msg("No active optimistic proposal")]
NoActiveOptimisticProposal,
#[msg("This DAO has been liquidated")]
DaoLiquidated,
#[msg("A proposal of this kind finalized recently, so the cooldown must elapse first")]
ProposalKindCooldownActive,
#[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,
#[msg("Pass threshold must be between -99.99% and 99.99%")]
InvalidProposalPassThreshold,
#[msg("A proposal params update must set at least one field")]
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")]
InvalidBuybackAmount,
#[msg("Cycle frequency must be between 60 seconds and 1 year")]
InvalidBuybackCycleFrequency,
#[msg("Start delay must be at most 30 days")]
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")]
InvalidTreasuryAccount,
#[msg("Treasury accounts must be in strictly ascending key order")]
TreasuryAccountsNotSorted,
#[msg("This proposal kind's launch takes no extra accounts")]
UnexpectedLaunchAccounts,
}
46 changes: 33 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 @@ -200,6 +201,18 @@ pub struct RemoveProposalEvent {
pub admin: Pubkey,
}

#[event]
pub struct AdminUpdateProposalParamsEvent {
pub common: CommonFields,
pub dao: Pubkey,
pub proposal: Pubkey,
pub admin: Pubkey,
pub old_duration_in_seconds: u32,
pub new_duration_in_seconds: u32,
pub old_pass_threshold_bps: i16,
pub new_pass_threshold_bps: i16,
}

#[event]
pub struct AdminCancelProposalEvent {
pub common: CommonFields,
Expand Down Expand Up @@ -233,23 +246,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
);

Comment on lines +72 to +79

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like admin should be more for security stuff and we can have a different one for council blocking proposals, maybe? Not a strong opinion

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Mainly holding this here until we make the decision on whether we're merging in #469 so that we know whether that will be the council (one of the parties in the multisig), or we will have a separate council that can cancel a proposal at any time.

#[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.
Comment on lines +66 to +68

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We reuse our existing system of enqueueing and executing an admin-controller Squads proposal approval, but now whoever is the liquidator can do the enqueueing of the approval. Running the approval itself is always permissionless.
The reason for it having two instructions (enqueue approval, execute approval) is because that way we can enqueue using another Squads multisig then permissionlessly approve, thus avoiding reentrancy into Squads (if it were a single instruction).
Squads (liquidator/admin) -> Futarchy -> Squads (DAO) is the reentrancy path we're avoiding.

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
110 changes: 110 additions & 0 deletions programs/futarchy/src/instructions/admin_update_proposal_params.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use super::*;

mod admin {
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)]
pub struct AdminUpdateProposalParamsArgs {
pub duration_in_seconds: Option<u32>,
pub pass_threshold_bps: Option<i16>,
}

#[derive(Accounts)]
#[event_cpi]
pub struct AdminUpdateProposalParams<'info> {
#[account(mut)]
pub dao: Box<Account<'info, Dao>>,

#[account(mut, has_one = dao)]
pub proposal: Box<Account<'info, Proposal>>,

pub admin: Signer<'info>,
}

impl AdminUpdateProposalParams<'_> {
pub fn validate(&self, args: &AdminUpdateProposalParamsArgs) -> Result<()> {
#[cfg(feature = "production")]
require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin);

require!(self.dao.liquidator.is_none(), FutarchyError::DaoLiquidated);

// Only arbitrary proposals can be modified
require!(
matches!(self.proposal.action, ProposalAction::ExecuteArbitrary),
FutarchyError::InvalidProposalKind
);

// A live proposal's terms never move: the snapshot exists so later
// changes can't shift the goalposts under it
require!(
matches!(self.proposal.state, ProposalState::Draft { .. }),
FutarchyError::ProposalNotInDraftState
);

// An admin transaction that does nothing should fail
require!(
args.duration_in_seconds.is_some() || args.pass_threshold_bps.is_some(),
FutarchyError::EmptyProposalParamsUpdate
);

if let Some(duration_in_seconds) = args.duration_in_seconds {
// The same comparison `launch_proposal` makes
require_gt!(
duration_in_seconds,
self.proposal.action.params().twap_start_delay_seconds,
FutarchyError::ProposalDurationTooShort
);
}

if let Some(pass_threshold_bps) = args.pass_threshold_bps {
require_gte!(
MAX_PROPOSAL_PASS_THRESHOLD_BPS,
pass_threshold_bps,
FutarchyError::InvalidProposalPassThreshold
);
require_gte!(
pass_threshold_bps,
MIN_PROPOSAL_PASS_THRESHOLD_BPS,
FutarchyError::InvalidProposalPassThreshold
);
}

Ok(())
}

pub fn handle(ctx: Context<Self>, args: AdminUpdateProposalParamsArgs) -> Result<()> {
let proposal = &mut ctx.accounts.proposal;
let dao = &mut ctx.accounts.dao;

let old_duration_in_seconds = proposal.duration_in_seconds;
let old_pass_threshold_bps = proposal.pass_threshold_bps;

if let Some(duration_in_seconds) = args.duration_in_seconds {
proposal.duration_in_seconds = duration_in_seconds;
}

if let Some(pass_threshold_bps) = args.pass_threshold_bps {
proposal.pass_threshold_bps = pass_threshold_bps;
}

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

emit_cpi!(AdminUpdateProposalParamsEvent {
common: CommonFields::new(&clock, dao.seq_num),
dao: dao.key(),
proposal: proposal.key(),
admin: ctx.accounts.admin.key(),
old_duration_in_seconds,
new_duration_in_seconds: proposal.duration_in_seconds,
old_pass_threshold_bps,
new_pass_threshold_bps: proposal.pass_threshold_bps,
});

Ok(())
}
}
Loading
Loading