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/finalize_proposal.rs b/programs/futarchy/src/instructions/finalize_proposal.rs index aeacfe146..3da644f00 100644 --- a/programs/futarchy/src/instructions/finalize_proposal.rs +++ b/programs/futarchy/src/instructions/finalize_proposal.rs @@ -115,16 +115,19 @@ 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; + 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 { @@ -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(), 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/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) } } 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/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/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/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 56e3c47e9..a93fec1cd 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -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); 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", 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();