Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion programs/futarchy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub enum FutarchyError {
PassThresholdTooHigh,
#[msg("Question must have exactly 2 outcomes for binary futarchy")]
QuestionMustBeBinary,
#[msg("Squads proposal must be in Draft status")]
#[msg("Squads proposal must be in Active status")]
InvalidSquadsProposalStatus,
#[msg("Casting overflow. If you're seeing this, please report this")]
CastingOverflow,
Expand Down Expand Up @@ -76,4 +76,6 @@ pub enum FutarchyError {
InvalidTargetK,
#[msg("Failed to compile transaction message for Squads vault transaction")]
InvalidTransactionMessage,
#[msg("Base mint and quote mint must be different")]
InvalidMint,
}
15 changes: 8 additions & 7 deletions programs/futarchy/src/instructions/finalize_proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,19 @@ impl FinalizeProposal<'_> {
];
let proposal_signer = &[&proposal_seeds[..]];

let clock = Clock::get()?;

let calculate_twap = |amm: &Pool| -> Result<u128> {
let seconds_passed = amm.oracle.last_updated_timestamp - proposal.timestamp_enqueued;
let twap_start_timestamp =
amm.oracle.created_at_timestamp + amm.oracle.start_delay_seconds as i64;

require_gte!(
seconds_passed,
proposal.duration_in_seconds as i64,
require_gt!(
amm.oracle.last_updated_timestamp,
twap_start_timestamp,
FutarchyError::MarketsTooYoung
);

amm.get_twap()
amm.get_twap(clock.unix_timestamp)
};

let PoolState::Futarchy {
Expand Down Expand Up @@ -269,8 +272,6 @@ impl FinalizeProposal<'_> {

dao.seq_num += 1;

let clock = Clock::get()?;

emit_cpi!(FinalizeProposalEvent {
common: CommonFields::new(&clock, dao.seq_num),
proposal: proposal.key(),
Expand Down
9 changes: 9 additions & 0 deletions programs/futarchy/src/instructions/initialize_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ pub mod permissionless_account {
}

impl InitializeDao<'_> {
pub fn validate(&self) -> Result<()> {
require_keys_neq!(
self.base_mint.key(),
self.quote_mint.key(),
FutarchyError::InvalidMint
);
Ok(())
}

pub fn handle(ctx: Context<Self>, params: InitializeDaoParams) -> Result<()> {
let InitializeDaoParams {
twap_initial_observation,
Expand Down
1 change: 1 addition & 0 deletions programs/futarchy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub const DEFAULT_MAX_OBSERVATION_CHANGE_PER_UPDATE_LOTS: u64 = 5_000;
pub mod futarchy {
use super::*;

#[access_control(ctx.accounts.validate())]
pub fn initialize_dao(ctx: Context<InitializeDao>, params: InitializeDaoParams) -> Result<()> {
InitializeDao::handle(ctx, params)
}
Expand Down
14 changes: 10 additions & 4 deletions programs/futarchy/src/state/futarchy_amm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ impl Pool {

// if this saturates, the aggregator will wrap back to 0, so this value doesn't
// really matter. we just can't panic.
let weighted_observation = new_observation.saturating_mul(time_difference);
let weighted_observation = last_observation.saturating_mul(time_difference);

oracle.aggregator.wrapping_add(weighted_observation)
};
Expand Down Expand Up @@ -449,17 +449,23 @@ impl Pool {
}

/// Returns the time-weighted average price since market creation
pub fn get_twap(&self) -> Result<u128> {
pub fn get_twap(&self, current_timestamp: i64) -> Result<u128> {
let start_timestamp =
self.oracle.created_at_timestamp + self.oracle.start_delay_seconds as i64;

require_gt!(self.oracle.last_updated_timestamp, start_timestamp);
let seconds_passed = (self.oracle.last_updated_timestamp - start_timestamp) as u128;

let seconds_passed = (current_timestamp - start_timestamp) as u128;

require_neq!(seconds_passed, 0);
require_neq!(self.oracle.aggregator, 0);

Ok(self.oracle.aggregator / seconds_passed)
// include the final interval that hasn't been accumulated yet
let final_interval = (current_timestamp - self.oracle.last_updated_timestamp) as u128;
let final_contribution = self.oracle.last_observation.saturating_mul(final_interval);
let total_aggregator = self.oracle.aggregator.wrapping_add(final_contribution);

Ok(total_aggregator / seconds_passed)
}
}

Expand Down
2 changes: 2 additions & 0 deletions programs/price_based_performance_package/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ pub enum PriceBasedPerformancePackageError {
InvalidAdmin,
#[msg("Total token amount calculation would overflow")]
TotalTokenAmountOverflow,
#[msg("Recipient and performance package authority must be different keys")]
RecipientAuthorityMustDiffer,
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ pub struct ChangePerformancePackageAuthority<'info> {
}

impl<'info> ChangePerformancePackageAuthority<'info> {
pub fn validate(&self, params: &ChangePerformancePackageAuthorityParams) -> Result<()> {
require_keys_neq!(
params.new_performance_package_authority,
self.performance_package.recipient,
PriceBasedPerformancePackageError::RecipientAuthorityMustDiffer
);

Ok(())
}

pub fn handle(
ctx: Context<Self>,
params: ChangePerformancePackageAuthorityParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ impl<'info> ExecuteChange<'info> {
return Err(PriceBasedPerformancePackageError::UnauthorizedChangeRequest.into());
}

if let ChangeType::Recipient { new_recipient } = &self.change_request.change_type {
require_keys_neq!(
*new_recipient,
self.performance_package.performance_package_authority,
PriceBasedPerformancePackageError::RecipientAuthorityMustDiffer
);
}

Ok(())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ pub struct InitializePerformancePackage<'info> {
}

impl InitializePerformancePackage<'_> {
pub fn validate(&self, params: &InitializePerformancePackageParams) -> Result<()> {
require_keys_neq!(
params.grantee,
params.performance_package_authority,
PriceBasedPerformancePackageError::RecipientAuthorityMustDiffer
);

Ok(())
}

pub fn handle(ctx: Context<Self>, params: InitializePerformancePackageParams) -> Result<()> {
let Self {
performance_package,
Expand Down
2 changes: 2 additions & 0 deletions programs/price_based_performance_package/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ declare_id!("pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS");
pub mod price_based_performance_package {
use super::*;

#[access_control(ctx.accounts.validate(&params))]
pub fn initialize_performance_package(
ctx: Context<InitializePerformancePackage>,
params: InitializePerformancePackageParams,
Expand Down Expand Up @@ -66,6 +67,7 @@ pub mod price_based_performance_package {
ExecuteChange::handle(ctx)
}

#[access_control(ctx.accounts.validate(&params))]
pub fn change_performance_package_authority(
ctx: Context<ChangePerformancePackageAuthority>,
params: ChangePerformancePackageAuthorityParams,
Expand Down
14 changes: 12 additions & 2 deletions sdk/src/v0.7/types/futarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3146,7 +3146,7 @@ export type Futarchy = {
{
code: 6014;
name: "InvalidSquadsProposalStatus";
msg: "Squads proposal must be in Draft status";
msg: "Squads proposal must be in Active status";
},
{
code: 6015;
Expand Down Expand Up @@ -3253,6 +3253,11 @@ export type Futarchy = {
name: "InvalidTransactionMessage";
msg: "Failed to compile transaction message for Squads vault transaction";
},
{
code: 6036;
name: "InvalidMint";
msg: "Base mint and quote mint must be different";
},
];
};

Expand Down Expand Up @@ -6404,7 +6409,7 @@ export const IDL: Futarchy = {
{
code: 6014,
name: "InvalidSquadsProposalStatus",
msg: "Squads proposal must be in Draft status",
msg: "Squads proposal must be in Active status",
},
{
code: 6015,
Expand Down Expand Up @@ -6511,5 +6516,10 @@ export const IDL: Futarchy = {
name: "InvalidTransactionMessage",
msg: "Failed to compile transaction message for Squads vault transaction",
},
{
code: 6036,
name: "InvalidMint",
msg: "Base mint and quote mint must be different",
},
],
};
10 changes: 10 additions & 0 deletions sdk/src/v0.7/types/price_based_performance_package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,11 @@ export type PriceBasedPerformancePackage = {
name: "TotalTokenAmountOverflow";
msg: "Total token amount calculation would overflow";
},
{
code: 6015;
name: "RecipientAuthorityMustDiffer";
msg: "Recipient and performance package authority must be different keys";
},
];
};

Expand Down Expand Up @@ -1947,5 +1952,10 @@ export const IDL: PriceBasedPerformancePackage = {
name: "TotalTokenAmountOverflow",
msg: "Total token amount calculation would overflow",
},
{
code: 6015,
name: "RecipientAuthorityMustDiffer",
msg: "Recipient and performance package authority must be different keys",
},
],
};
79 changes: 79 additions & 0 deletions tests/futarchy/unit/finalizeProposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,85 @@ export default function suite() {
);
});

it("finalizes when last trade is before the deadline (virtual crank covers the gap)", async function () {
// Trade for ~200,000s of the 259,200s proposal duration, then stop
// trading and advance the clock past the deadline before finalizing.
// The virtual crank in get_twap() fills in the gap after the last trade.

const { baseVault, quoteVault, question } = this.futarchy.getProposalPdas(
proposal,
META,
USDC,
dao,
);

await this.conditionalVault
.splitTokensIx(question, baseVault, META, new BN(10 * 10 ** 9), 2)
.rpc();
await this.conditionalVault
.splitTokensIx(question, quoteVault, USDC, new BN(11_000 * 1_000_000), 2)
.rpc();

// Initial swap to seed the pass market
await this.futarchy
.conditionalSwapIx({
dao,
baseMint: META,
quoteMint: USDC,
proposal,
market: "pass",
swapType: "buy",
inputAmount: new BN(10_000 * 1_000_000),
minOutputAmount: new BN(0),
})
.rpc();

// Trade for ~200,000 seconds (10 swaps × 20,000s each)
// This is ~77% of the 259,200s proposal duration
for (let i = 0; i < 10; i++) {
await this.advanceBySeconds(20_000);

await this.futarchy
.conditionalSwapIx({
dao,
baseMint: META,
quoteMint: USDC,
proposal,
market: "pass",
swapType: "buy",
inputAmount: new BN(10),
minOutputAmount: new BN(0),
})
.preInstructions([
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }),
])
.rpc();
}

// At ~200,000s into a 259,200s proposal — finalization should fail
// because wall-clock time hasn't reached the deadline yet.
const earlyCallbacks = expectError(
"ProposalTooYoung",
"proposal should not finalize before the deadline",
);
await this.futarchy
.finalizeProposal(proposal)
.then(earlyCallbacks[0], earlyCallbacks[1]);

// Stop trading. Advance time past the proposal deadline (259,200s).
// Last trade was at ~200,000s. We need at least 60,000 more seconds.
await this.advanceBySeconds(70_000);

// Finalize — should succeed because:
// 1. Wall-clock time is past the deadline (validate() passes)
// 2. At least one trade occurred after TWAP start delay (new check passes)
// 3. get_twap()'s virtual crank extends the last observation to current time
await this.futarchy.finalizeProposal(proposal);

const storedProposal = await this.futarchy.getProposal(proposal);
assert.exists(storedProposal.state.passed);
});

it("passes proposals when the team sponsors them and pass twap is slightly below fail twap", async function () {
// Create a new DAO with -5% team-sponsored threshold
const META = await this.createMint(this.payer.publicKey, 6);
Expand Down
28 changes: 28 additions & 0 deletions tests/futarchy/unit/initializeDao.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,34 @@ export default function suite() {
assert.equal(storedSpendingLimit.destinations.length, 0);
});

it("doesn't allow DAOs with identical base and quote mints", async function () {
const SAME_MINT = await this.createMint(this.payer.publicKey, 6);

const callbacks = expectError(
"InvalidMint",
"DAO initialized despite base and quote mints being identical",
);

await this.futarchy
.initializeDaoIx({
baseMint: SAME_MINT,
quoteMint: SAME_MINT,
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),
passThresholdBps: 300,
nonce: new BN(9999),
initialSpendingLimit: null,
},
})
.rpc()
.then(callbacks[0], callbacks[1]);
});

it("doesn't allow DAOs with proposal duration less than TWAP start delay", async function () {
const callbacks = expectError(
"ProposalDurationTooShort",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@solana/web3.js";
import { assert } from "chai";
import BN from "bn.js";
import { expectError } from "../../utils.js";

export default function () {
let createKey: Keypair;
Expand Down Expand Up @@ -90,6 +91,22 @@ export default function () {
.rpc();
});

it("should fail if new authority equals current recipient", async function () {
const callbacks = expectError(
"RecipientAuthorityMustDiffer",
"Recipient and performance package authority must be different keys",
);

await this.priceBasedPerformancePackage
.changePerformancePackageAuthorityIx({
performancePackage,
currentAuthority: this.payer.publicKey,
newPerformancePackageAuthority: recipient.publicKey,
})
.rpc()
.then(callbacks[0], callbacks[1]);
});

it("should fail if unauthorized party tries to change authority", async function () {
const unauthorizedWallet = Keypair.generate();

Expand Down
Loading
Loading