Marvin Jones fb84915935 fix(state_machine): reject public transactions that silently drop a declared account
ValidatedStateDiff::from_public_transaction never checked that the accounts
touched in a program's output matched the caller-declared message.account_ids
— it just folded whatever pairs the program returned into the diff. A program
(or a macro-generated dispatcher wrapping one) that silently drops an account
from both sides of its own output together stays internally consistent
(pre_states.len() == post_states.len()) and passes validate_execution's
existing checks, so the dropped account simply vanishes with no error.

Add a check after the chained-call loop: every account_id in
message.account_ids must appear in the final state_diff, or the transaction is
rejected with the new DeclaredAccountMissingFromOutput error.

Add a dropped_account test-guest program that reproduces the exact shape of
the bug (two pre_states in, one consistent (pre, post) pair out) and a
regression test proving the transaction is now rejected. Verified the test
fails with Ok(()) when the check is removed, and passes once it's restored.
2026-07-15 15:00:05 -04:00

159 lines
4.3 KiB
Rust

use std::io;
use lee_core::{
account::{Account, AccountId},
program::ProgramId,
};
use thiserror::Error;
#[macro_export]
macro_rules! ensure {
($cond:expr, $err:expr) => {
if !$cond {
return Err($err.into());
}
};
}
#[derive(Error, Debug)]
pub enum LeeError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Program violated execution rules")]
InvalidProgramBehavior(#[from] InvalidProgramBehaviorError),
#[error("Serialization error: {0}")]
InstructionSerializationError(String),
#[error("Invalid private key")]
InvalidPrivateKey,
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Invalid Public Key")]
InvalidPublicKey(#[source] k256::schnorr::Error),
#[error("Invalid hex for public key")]
InvalidHexPublicKey(#[source] hex::FromHexError),
#[error("Failed to write program input: {0}")]
ProgramWriteInputFailed(String),
#[error("Failed to execute program: {0}")]
ProgramExecutionFailed(String),
#[error("Failed to prove program: {0}")]
ProgramProveFailed(String),
#[error("Invalid transaction: {0}")]
TransactionDeserializationError(String),
#[error("Core error")]
Core(#[from] lee_core::error::LeeCoreError),
#[error("Program output deserialization error: {0}")]
ProgramOutputDeserializationError(String),
#[error("Circuit output deserialization error: {0}")]
CircuitOutputDeserializationError(String),
#[error("Invalid privacy preserving execution circuit proof")]
InvalidPrivacyPreservingProof,
#[error("Circuit proving error")]
CircuitProvingError(String),
#[error("Invalid program bytecode")]
InvalidProgramBytecode(#[source] anyhow::Error),
#[error("Program already exists")]
ProgramAlreadyExists,
#[error("Chain of calls is too long")]
MaxChainedCallsDepthExceeded,
#[error("Max account nonce reached")]
MaxAccountNonceReached,
#[error("Execution outside of the validity window")]
OutOfValidityWindow,
}
#[derive(Error, Debug)]
pub enum InvalidProgramBehaviorError {
#[error(
"Inconsistent pre-state for account {account_id} : expected {expected:?}, actual {actual:?}"
)]
InconsistentAccountPreState {
account_id: AccountId,
// Boxed to reduce the size of the error type
expected: Box<Account>,
actual: Box<Account>,
},
#[error("Unauthorized account marked as authorized")]
InvalidAccountAuthorization { account_id: AccountId },
#[error("Authorized account marked as not authorized")]
AuthorizedAccountMarkedAsNotAuthorized { account_id: AccountId },
#[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedProgramId {
expected: ProgramId,
actual: ProgramId,
},
#[error("Caller program ID mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedCallerProgramId {
expected: Option<ProgramId>,
actual: Option<ProgramId>,
},
#[error(transparent)]
ExecutionValidationFailed(#[from] lee_core::program::ExecutionValidationError),
#[error("Trying to claim account {account_id} which is not default")]
ClaimedNonDefaultAccount { account_id: AccountId },
#[error("Trying to claim account {account_id} which is not authorized")]
ClaimedUnauthorizedAccount { account_id: AccountId },
#[error("PDA claim mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedPdaClaim {
expected: AccountId,
actual: AccountId,
},
#[error("Default account {account_id} was modified without being claimed")]
DefaultAccountModifiedWithoutClaim { account_id: AccountId },
#[error("Called program {program_id:?} which is not listed in dependencies")]
UndeclaredProgramDependency { program_id: ProgramId },
#[error("Account {account_id} was declared in the transaction but is missing from the program output")]
DeclaredAccountMissingFromOutput { account_id: AccountId },
}
#[cfg(test)]
mod tests {
#[derive(Debug)]
enum TestError {
TestErr,
}
fn test_function_ensure(cond: bool) -> Result<(), TestError> {
ensure!(cond, TestError::TestErr);
Ok(())
}
#[test]
fn ensure_works() {
assert!(test_function_ensure(true).is_ok());
assert!(test_function_ensure(false).is_err());
}
}