Skip to content
Draft
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
111 changes: 111 additions & 0 deletions docs/mosaic-flow.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 12 additions & 8 deletions mosaic/src/instructions/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ impl<'info> TryFrom<&'info [AccountView]> for ExecuteIxAccounts<'info> {

fn try_from(accounts: &'info [AccountView]) -> Result<Self, Self::Error> {
// 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);
Expand Down Expand Up @@ -167,6 +170,15 @@ impl<'info> Execute<'info> {
})
.collect::<Result<_, _>>()?;

// 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(),
Expand All @@ -175,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(())
}

Expand Down
125 changes: 125 additions & 0 deletions mosaic/tests/execute_failures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -837,3 +881,84 @@ 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::<SigningSession>(&updated_signing_session_pda_account.data).unwrap();
assert!(parsed_signing_session_pda_data.phase == SigningSessionPhase::Approved);
}