From 896a4117662d64b223afb912f4c35535233a6324 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 20 Feb 2026 00:47:15 +0100 Subject: [PATCH 1/3] Fix TWAP accumulator weighting and add virtual crank to get_twap --- .../futarchy/src/instructions/finalize_proposal.rs | 6 +++--- programs/futarchy/src/state/futarchy_amm.rs | 14 ++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/programs/futarchy/src/instructions/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index aeacfe146..84345f58b 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -115,6 +115,8 @@ impl FinalizeProposal<'_> { ]; let proposal_signer = &[&proposal_seeds[..]]; + let clock = Clock::get()?; + let calculate_twap = |amm: &Pool| -> Result { let seconds_passed = amm.oracle.last_updated_timestamp - proposal.timestamp_enqueued; @@ -124,7 +126,7 @@ impl FinalizeProposal<'_> { FutarchyError::MarketsTooYoung ); - amm.get_twap() + amm.get_twap(clock.unix_timestamp) }; let PoolState::Futarchy { @@ -269,8 +271,6 @@ impl FinalizeProposal<'_> { dao.seq_num += 1; - let clock = Clock::get()?; - emit_cpi!(FinalizeProposalEvent { common: CommonFields::new(&clock, dao.seq_num), proposal: proposal.key(), diff --git a/programs/futarchy/src/state/futarchy_amm.rs b/programs/futarchy/src/state/futarchy_amm.rs index 4dc6d7cd4..9344d9d75 100644 --- a/programs/futarchy/src/state/futarchy_amm.rs +++ b/programs/futarchy/src/state/futarchy_amm.rs @@ -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) }; @@ -449,17 +449,23 @@ impl Pool { } /// Returns the time-weighted average price since market creation - pub fn get_twap(&self) -> Result { + pub fn get_twap(&self, current_timestamp: i64) -> Result { 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) } } From ebb8c7fc85c85310ffc5d948884220a549e6c8ae Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 20 Feb 2026 02:06:02 +0100 Subject: [PATCH 2/3] prevent same mint on dao init, adjust error --- programs/futarchy/src/error.rs | 4 ++- .../src/instructions/initialize_dao.rs | 9 ++++++ programs/futarchy/src/lib.rs | 1 + sdk/src/v0.7/types/futarchy.ts | 14 ++++++++-- tests/futarchy/unit/initializeDao.test.ts | 28 +++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index e40223c50..150e72973 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -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, @@ -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, } diff --git a/programs/futarchy/src/instructions/initialize_dao.rs b/programs/futarchy/src/instructions/initialize_dao.rs index b271f51c0..5854bc7e5 100644 --- a/programs/futarchy/src/instructions/initialize_dao.rs +++ b/programs/futarchy/src/instructions/initialize_dao.rs @@ -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, params: InitializeDaoParams) -> Result<()> { let InitializeDaoParams { twap_initial_observation, diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index b060cb11e..642e9a7de 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -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, params: InitializeDaoParams) -> Result<()> { InitializeDao::handle(ctx, params) } diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index fc8d3ddd4..383868227 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -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; @@ -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"; + }, ]; }; @@ -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, @@ -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", + }, ], }; diff --git a/tests/futarchy/unit/initializeDao.test.ts b/tests/futarchy/unit/initializeDao.test.ts index 06948538b..5470340fc 100644 --- a/tests/futarchy/unit/initializeDao.test.ts +++ b/tests/futarchy/unit/initializeDao.test.ts @@ -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", From cb2c17bdc952629625a2a82b299ea29a17b2b81e Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 20 Feb 2026 02:29:16 +0100 Subject: [PATCH 3/3] reject performance package init/change that would result in same authority and recipient --- .../src/error.rs | 2 + .../change_performance_package_authority.rs | 10 +++++ .../src/instructions/execute_change.rs | 8 ++++ .../initialize_performance_package.rs | 10 +++++ .../src/lib.rs | 2 + .../types/price_based_performance_package.ts | 10 +++++ .../changePerformancePackageAuthority.test.ts | 17 ++++++++ .../unit/executeChange.test.ts | 41 +++++++++++++++++++ .../unit/initializePerformancePackage.test.ts | 41 +++++++++++++++++++ 9 files changed, 141 insertions(+) diff --git a/programs/price_based_performance_package/src/error.rs b/programs/price_based_performance_package/src/error.rs index 236eaa2a3..da0e0bd26 100644 --- a/programs/price_based_performance_package/src/error.rs +++ b/programs/price_based_performance_package/src/error.rs @@ -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, } diff --git a/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs b/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs index d2477788c..02c620b30 100644 --- a/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs +++ b/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs @@ -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, params: ChangePerformancePackageAuthorityParams, diff --git a/programs/price_based_performance_package/src/instructions/execute_change.rs b/programs/price_based_performance_package/src/instructions/execute_change.rs index 582da768e..258a65de8 100644 --- a/programs/price_based_performance_package/src/instructions/execute_change.rs +++ b/programs/price_based_performance_package/src/instructions/execute_change.rs @@ -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(()) } diff --git a/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs b/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs index 4aee933ef..1f52ba6f2 100644 --- a/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs +++ b/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs @@ -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, params: InitializePerformancePackageParams) -> Result<()> { let Self { performance_package, diff --git a/programs/price_based_performance_package/src/lib.rs b/programs/price_based_performance_package/src/lib.rs index d161b4c1c..bc4c938d6 100644 --- a/programs/price_based_performance_package/src/lib.rs +++ b/programs/price_based_performance_package/src/lib.rs @@ -39,6 +39,7 @@ declare_id!("pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS"); pub mod price_based_performance_package { use super::*; + #[access_control(ctx.accounts.validate(¶ms))] pub fn initialize_performance_package( ctx: Context, params: InitializePerformancePackageParams, @@ -66,6 +67,7 @@ pub mod price_based_performance_package { ExecuteChange::handle(ctx) } + #[access_control(ctx.accounts.validate(¶ms))] pub fn change_performance_package_authority( ctx: Context, params: ChangePerformancePackageAuthorityParams, diff --git a/sdk/src/v0.7/types/price_based_performance_package.ts b/sdk/src/v0.7/types/price_based_performance_package.ts index fe69a3267..70ffba3f9 100644 --- a/sdk/src/v0.7/types/price_based_performance_package.ts +++ b/sdk/src/v0.7/types/price_based_performance_package.ts @@ -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"; + }, ]; }; @@ -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", + }, ], }; diff --git a/tests/priceBasedPerformancePackage/unit/changePerformancePackageAuthority.test.ts b/tests/priceBasedPerformancePackage/unit/changePerformancePackageAuthority.test.ts index aa7d15ec2..db1168a19 100644 --- a/tests/priceBasedPerformancePackage/unit/changePerformancePackageAuthority.test.ts +++ b/tests/priceBasedPerformancePackage/unit/changePerformancePackageAuthority.test.ts @@ -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; @@ -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(); diff --git a/tests/priceBasedPerformancePackage/unit/executeChange.test.ts b/tests/priceBasedPerformancePackage/unit/executeChange.test.ts index 5350fc453..42d8f0f03 100644 --- a/tests/priceBasedPerformancePackage/unit/executeChange.test.ts +++ b/tests/priceBasedPerformancePackage/unit/executeChange.test.ts @@ -306,6 +306,47 @@ export default function () { .rpc(); }); + it("should fail if new recipient equals current authority", async function () { + const pdaNonce = Math.floor(Math.random() * 1000000); + + // Recipient proposes changing recipient to authority's key (this.payer.publicKey) + await this.priceBasedPerformancePackage + .proposeChangeIx({ + params: { + changeType: { + recipient: { newRecipient: this.payer.publicKey }, + }, + pdaNonce, + }, + performancePackage, + proposer: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + const changeRequestAddr = + this.priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + recipient.publicKey, + pdaNonce, + ); + + const callbacks = expectError( + "RecipientAuthorityMustDiffer", + "Recipient and performance package authority must be different keys", + ); + + // Authority executes - should fail because new recipient == authority + await this.priceBasedPerformancePackage + .executeChangeIx({ + performancePackage, + changeRequest: changeRequestAddr, + executor: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("should fail if wrong vault tries to execute", async function () { // Recipient proposes change (correct pattern) const pdaNonce = Math.floor(Math.random() * 1000000); diff --git a/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts b/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts index 1edc0ec0c..e162fecfc 100644 --- a/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts +++ b/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts @@ -244,6 +244,47 @@ export default function () { } }); + it("should fail if recipient equals authority", async function () { + const sameKeyCreateKey = Keypair.generate(); + + const params = { + tranches: [ + { + priceThreshold: new BN(1000000), + tokenAmount: new BN(100000), + }, + ], + grantee: this.payer.publicKey, + performancePackageAuthority: this.payer.publicKey, + minUnlockTimestamp: new BN( + Number((await this.context.banksClient.getClock()).unixTimestamp) + + 3600, + ), + oracleConfig: { + oracleAccount: oracleAccount.publicKey, + byteOffset: 0, + }, + twapLengthSeconds: new BN(86_400), + tokenRecipient: this.payer.publicKey, + }; + + const callbacks = expectError( + "RecipientAuthorityMustDiffer", + "Recipient and performance package authority must be different keys", + ); + + await this.priceBasedPerformancePackage + .initializePerformancePackageIx({ + params, + createKey: sameKeyCreateKey.publicKey, + tokenMint, + grantor: tokenAuthority.publicKey, + }) + .signers([sameKeyCreateKey, tokenAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("should fail if token amount is zero", async function () { const zeroCreateKey = Keypair.generate();