From 06370e05a60b433eda0a6e0e8635b235457e33f2 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 17 Mar 2026 12:15:42 +0100 Subject: [PATCH 1/3] bugfix: panic on execute if passed accounts count was less than 5 --- mosaic/src/instructions/execute.rs | 3 ++ mosaic/tests/execute_failures.rs | 45 +++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/mosaic/src/instructions/execute.rs b/mosaic/src/instructions/execute.rs index ba1a66f..4135448 100644 --- a/mosaic/src/instructions/execute.rs +++ b/mosaic/src/instructions/execute.rs @@ -39,6 +39,9 @@ impl<'info> TryFrom<&'info [AccountView]> for ExecuteIxAccounts<'info> { fn try_from(accounts: &'info [AccountView]) -> Result { // perform accounts attribute check + if accounts.len() < 5 { + return Err(ProgramError::NotEnoughAccountKeys); + } let (required_accounts, remaining) = accounts.split_at(5); let [payer, root, signing_session, _sys_program, _dst_program] = required_accounts else { return Err(ProgramError::NotEnoughAccountKeys); diff --git a/mosaic/tests/execute_failures.rs b/mosaic/tests/execute_failures.rs index f39d5a0..a3f27ac 100644 --- a/mosaic/tests/execute_failures.rs +++ b/mosaic/tests/execute_failures.rs @@ -20,6 +20,50 @@ use solana_sdk::{ pubkey::Pubkey, }; +#[test] +fn test_execute_not_enough_accounts_failure() { + let mollusk = Mollusk::new(&PROGRAM_ID, MOSAIC_BINARY_PATH); + let (system_program, system_account) = mollusk_svm::program::keyed_account_for_system_program(); + + let payer = Pubkey::new_unique(); + let root = Pubkey::new_unique(); + let signing = Pubkey::new_unique(); + + let payer_account = AccountSharedData::new(1_000_000, 0, &system_program); + let root_account = AccountSharedData::new(0, 0, &PROGRAM_ID); + let signing_account = AccountSharedData::new(0, 0, &PROGRAM_ID); + + let ix_data_execute = ExecuteIxData {}; + let data_execute = [ + vec![ProgramIx::Execute as u8], + to_vec(&ix_data_execute).unwrap(), + ] + .concat(); + + // only 4 accounts; execute requires at least 5 + let instruction = Instruction::new_with_bytes( + PROGRAM_ID, + &data_execute, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new_readonly(root, false), + AccountMeta::new(signing, false), + AccountMeta::new_readonly(system_program, false), + ], + ); + + let _result: mollusk_svm::result::InstructionResult = mollusk.process_and_validate_instruction( + &instruction, + &[ + (payer, payer_account.into()), + (root, root_account.into()), + (signing, signing_account.into()), + (system_program, system_account.into()), + ], + &[Check::err(ProgramError::NotEnoughAccountKeys)], + ); +} + #[test] fn test_execute_payer_is_not_signer_failure() { let mut mollusk = Mollusk::new(&PROGRAM_ID, MOSAIC_BINARY_PATH); @@ -836,4 +880,3 @@ fn test_execute_destination_program_mismatch_failure() { ))], ); } - From af453a09ac62721cf0995be2fc66afb81c0660ec Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 17 Mar 2026 12:23:32 +0100 Subject: [PATCH 2/3] bugfix: reentry on execute no more possible --- mosaic/src/instructions/execute.rs | 17 ++++--- mosaic/tests/execute_failures.rs | 82 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/mosaic/src/instructions/execute.rs b/mosaic/src/instructions/execute.rs index 4135448..bd890e8 100644 --- a/mosaic/src/instructions/execute.rs +++ b/mosaic/src/instructions/execute.rs @@ -170,6 +170,15 @@ impl<'info> Execute<'info> { }) .collect::>()?; + // Lock the session before external CPI to avoid a reentrancy window. + let mut signing_data = signing_data; + signing_data.progress_phase_checked()?; /* set signing session phase to executed */ + let (serialized_data, serialized_len) = signing_data.pack()?; + { + let mut signing_account = self.accounts.signing_session.try_borrow_mut()?; + signing_account[..serialized_len].copy_from_slice(&serialized_data); + } + // cpi to destination program let instruction = InstructionView { program_id: self.accounts._dst_program.address(), @@ -178,14 +187,6 @@ impl<'info> Execute<'info> { }; invoke_signed_dynamic!(&instruction, account_views, &[cpi_signer])?; - // update signing session / prevent re-execution - let mut signing_data = signing_data; - signing_data.progress_phase_checked()?; /* set signing session phase to executed */ - - let (serialized_data, serialized_len) = signing_data.pack()?; - let mut signing_account = self.accounts.signing_session.try_borrow_mut()?; - signing_account[..serialized_len].copy_from_slice(&serialized_data); - Ok(()) } diff --git a/mosaic/tests/execute_failures.rs b/mosaic/tests/execute_failures.rs index a3f27ac..6f97d0d 100644 --- a/mosaic/tests/execute_failures.rs +++ b/mosaic/tests/execute_failures.rs @@ -880,3 +880,85 @@ fn test_execute_destination_program_mismatch_failure() { ))], ); } + +#[test] +fn test_execute_missing_remaining_account_rolls_back_phase() { + let mut mollusk = Mollusk::new(&PROGRAM_ID, MOSAIC_BINARY_PATH); + mollusk.add_program(&DESTINATION_PROGRAM_ID, "tests/spl_record"); + + let (system_program, system_account) = mollusk_svm::program::keyed_account_for_system_program(); + let dst_program_account = AccountSharedData::new(0, 0, &solana_sdk::bpf_loader::id()); + + let operators = Operators::new(3, system_program); + let operators_pubkey: Vec<_> = operators + .operators + .iter() + .map(|operator| operator.0) + .collect(); + let (signer, signer_account) = operators.operators[0].clone(); + + let session_id = 1; + + let (root_pda, _, _, _, root_account) = prepare_root( + &mollusk, + operators, + operators_pubkey.clone(), + session_id, + DESTINATION_PROGRAM_ID.as_ref().try_into().unwrap(), + ); + + // Build CPI payload that expects a storage account in `remaining`. + let (storage_pda, _storage_pda_account) = + prepare_storage_account(&mollusk, session_id, root_pda); + let (cpi_instruction_accounts, cpi_instruction_data) = + records_program_ix_accs(storage_pda, root_pda); + + let (signing_pda, _signing_pda_bump, _signing_init_state_serialized, signing_account) = + prepare_signing_session( + &mollusk, + session_id, + root_pda, + vec![signer, operators_pubkey[1]], + SigningSessionPhase::Approved, + cpi_instruction_accounts, + cpi_instruction_data, + ); + + let ix_data_execute = ExecuteIxData {}; + let data_execute = [ + vec![ProgramIx::Execute as u8], + to_vec(&ix_data_execute).unwrap(), + ] + .concat(); + + // Intentionally pass only the 5 required accounts and omit the storage account. + let instruction = Instruction::new_with_bytes( + PROGRAM_ID, + &data_execute, + vec![ + AccountMeta::new(signer.into(), true), + AccountMeta::new_readonly(root_pda, false), + AccountMeta::new(signing_pda, false), + AccountMeta::new_readonly(system_program, false), + AccountMeta::new_readonly(DESTINATION_PROGRAM_ID, false), + ], + ); + + let result: mollusk_svm::result::InstructionResult = mollusk.process_and_validate_instruction( + &instruction, + &[ + (signer.into(), signer_account.clone().into()), + (root_pda, root_account.clone().into()), + (signing_pda, signing_account.clone().into()), + (system_program, system_account.clone().into()), + (DESTINATION_PROGRAM_ID, dst_program_account.clone().into()), + ], + &[Check::err(ProgramError::NotEnoughAccountKeys)], + ); + + // If state is locked before CPI and call fails, rollback must keep phase unchanged. + let updated_signing_session_pda_account = result.get_account(&signing_pda).unwrap(); + let parsed_signing_session_pda_data = + borsh::from_slice::(&updated_signing_session_pda_account.data).unwrap(); + assert!(parsed_signing_session_pda_data.phase == SigningSessionPhase::Approved); +} From 366bfae83bea16184c82ccd41f0047a4ac52fbc7 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 17 Mar 2026 12:45:05 +0100 Subject: [PATCH 3/3] extend docs --- docs/mosaic-flow.md | 111 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/mosaic-flow.md diff --git a/docs/mosaic-flow.md b/docs/mosaic-flow.md new file mode 100644 index 0000000..817cca0 --- /dev/null +++ b/docs/mosaic-flow.md @@ -0,0 +1,111 @@ +# Mosaic Program Flow + +Mosaic is basically a multisig control layer: +- a `Root` PDA stores governance configuration, +- `SigningSession` PDAs store proposals and approvals, +- once threshold is hit, Mosaic executes via CPI to your destination pgoram. + +## Authority delegation model + +This is the authority handoff. +External program points its privileged authority to Mosaic `Root` PDA. +After that, no single key can do admin stuff solo; multisig rules gate it. + +```mermaid +flowchart LR + A0[User program admin] --> A1[Set user program authority to Mosaic Root PDA] + A1 --> A2[Mosaic Root PDA becomes privileged authority] + A2 --> A3[Mosaic multisig approves action] + A3 --> A4[Mosaic Execute performs CPI as Root PDA signer] + A4 --> A5[User program accepts privileged action] +``` + +## Use case overview + +Typical lifecycle: +one-time setup, then loop proposal -> sign -> execute for each governed action. + +```mermaid +flowchart LR + U0[Operators multisig group] --> U1[Initialize root configuration] + U1 --> U2[Create signing session proposal] + U2 --> U3[Collect signatures] + U3 --> U4[Mark session approved] + U4 --> U5[Execute governed CPI] + U5 --> U6[Mark session executed] + U6 --> U7[Destination program action applied] +``` + +## Core instruction flows (side by side) + +Same flows as 2.2-2.5, just packed side by side. + +```mermaid +flowchart LR + O1[InitializeOperators] --> O2[InitializeSigningSession] --> O3[Sign] --> O4[Execute] + + subgraph ROW[ ] + direction LR + + subgraph IRO[InitializeOperators] + direction TB + A0[Operator] --> A1[Create root PDA account] + A1 --> A2[Write Root fields operators last_id threshold destination_program bump] + end + + subgraph ISS[InitializeSigningSession] + direction TB + B0[Operator] --> B1[Load and validate Root PDA] + B1 --> B2[Increment Root last_id] + B2 --> B3[Derive and validate SigningSession PDA] + B3 --> B4[Create signing session account] + B4 --> B5[Write Root and SigningSession phase Active] + end + + subgraph SIG[Sign] + direction TB + C0[Operator] --> C1[Load Root and SigningSession] + C1 --> C2[Validate PDAs and active session] + C2 --> C3[Add operator approval] + C3 --> C4[If threshold reached set phase Approved] + C4 --> C5[Write updated SigningSession] + end + + subgraph EXE[Execute] + direction TB + E0[Operator] --> E1[Load Root and SigningSession] + E1 --> E2[Validate PDAs session phase and destination program] + E2 --> E3[Build CPI metas from stored instruction accounts] + E3 --> E4[Lock session phase Executed and write state] + E4 --> E5[Invoke CPI with Root PDA signer] + E5 --> E6[Finish execution] + end + end + + O1 -.-> A0 + O2 -.-> B0 + O3 -.-> C0 + O4 -.-> E0 +``` + +## Signing session lifecycle + +Signing session lifecycle is linear. +Proposal moves forward only: create -> approve -> execute. + +```mermaid +flowchart LR + S0[Uninitialized] --> S1[Active] + S1 --> S2[Approved] + S2 --> S3[Executed] +``` + +## PDA formulas + +These PDA formulas are the address truth source. + +The `Root PDA` comes from the static `root_pda` seed plus its bump. +The `SigningSession PDA` comes from the root address, the session id, the `signing_session_pda` seed, and its bump. + +What matters most is the binding: the root address is part of the signing session derivation. +So a signing session created for one root is automatically invalid for any other root. \ No newline at end of file