|
|
|
|
@@ -0,0 +1,698 @@
|
|
|
|
|
// file: kb_execution_safety/src/safety.rs
|
|
|
|
|
// version: 6
|
|
|
|
|
|
|
|
|
|
//! Safety checks applied before simulation, signing or sending.
|
|
|
|
|
|
|
|
|
|
use ts_rs::TS; // rust-rules: derive-import
|
|
|
|
|
|
|
|
|
|
/// Safety decision returned before continuing an execution stage.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
#[ts(
|
|
|
|
|
export,
|
|
|
|
|
export_to = "../frontend/ts/bindings/kb_execution_safety/safety/ExecutionSafetyDecision.ts"
|
|
|
|
|
)]
|
|
|
|
|
pub enum ExecutionSafetyDecision {
|
|
|
|
|
/// The plan is not allowed to continue.
|
|
|
|
|
Deny,
|
|
|
|
|
/// The plan requires explicit operator confirmation.
|
|
|
|
|
RequireConfirmation,
|
|
|
|
|
/// The plan may continue to the requested stage.
|
|
|
|
|
Allow,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One stable safety policy violation.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
|
|
|
|
#[ts(
|
|
|
|
|
export,
|
|
|
|
|
export_to = "../frontend/ts/bindings/kb_execution_safety/safety/ExecutionSafetyViolation.ts"
|
|
|
|
|
)]
|
|
|
|
|
pub struct ExecutionSafetyViolation {
|
|
|
|
|
/// Stable violation code.
|
|
|
|
|
pub code: std::string::String,
|
|
|
|
|
/// Human-readable violation message.
|
|
|
|
|
pub message: std::string::String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Complete safety evaluation for one execution stage.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
|
|
|
|
#[ts(
|
|
|
|
|
export,
|
|
|
|
|
export_to = "../frontend/ts/bindings/kb_execution_safety/safety/ExecutionSafetyEvaluation.ts"
|
|
|
|
|
)]
|
|
|
|
|
pub struct ExecutionSafetyEvaluation {
|
|
|
|
|
/// Aggregate decision.
|
|
|
|
|
pub decision: crate::ExecutionSafetyDecision,
|
|
|
|
|
/// Violations that produced a denial or confirmation requirement.
|
|
|
|
|
pub violations: std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stateless execution safety checker.
|
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
|
|
|
pub struct ExecutionSafetyChecker;
|
|
|
|
|
|
|
|
|
|
impl crate::ExecutionSafetyChecker {
|
|
|
|
|
/// Evaluates a legacy reserved plan conservatively.
|
|
|
|
|
pub fn evaluate_plan(
|
|
|
|
|
&self,
|
|
|
|
|
_plan: &kb_execution_api::ExecutionPlan,
|
|
|
|
|
) -> kb_core::Result<crate::ExecutionSafetyDecision> {
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyDecision::RequireConfirmation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Evaluates whether a typed plan may proceed to RPC simulation.
|
|
|
|
|
pub fn evaluate_prepared_plan(
|
|
|
|
|
&self,
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
) -> kb_core::Result<crate::ExecutionSafetyEvaluation> {
|
|
|
|
|
let mut violations = std::vec::Vec::new();
|
|
|
|
|
if plan.instructions.is_empty() {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_plan_has_no_instructions",
|
|
|
|
|
"the prepared execution plan contains no instruction",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if plan.required_signers.is_empty() {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_plan_has_no_signers",
|
|
|
|
|
"the prepared execution plan declares no required signer",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if plan.policy.simulation != kb_execution_api::ExecutionSimulationPolicy::Required {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_simulation_not_required",
|
|
|
|
|
"simulation must be required before signing or sending",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
validate_blockhash_policy(plan, &mut violations);
|
|
|
|
|
validate_signer_contract(plan, &mut violations);
|
|
|
|
|
validate_authorized_signers(plan, &mut violations);
|
|
|
|
|
validate_cost_limits(plan, &mut violations);
|
|
|
|
|
if !violations.is_empty() {
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision: crate::ExecutionSafetyDecision::Deny,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if plan.policy.cluster.expected_cluster == kb_execution_api::ExecutionCluster::Mainnet {
|
|
|
|
|
if !plan.policy.cluster.allow_mainnet {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_mainnet_disabled",
|
|
|
|
|
"mainnet execution is disabled by the cluster policy",
|
|
|
|
|
);
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision: crate::ExecutionSafetyDecision::Deny,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if !plan.policy.cluster.mainnet_confirmation {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_mainnet_confirmation_required",
|
|
|
|
|
"mainnet execution requires explicit operator confirmation",
|
|
|
|
|
);
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision: crate::ExecutionSafetyDecision::RequireConfirmation,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision: crate::ExecutionSafetyDecision::Allow,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Evaluates whether a simulated plan may proceed to signing and sending.
|
|
|
|
|
pub fn evaluate_send(
|
|
|
|
|
&self,
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
simulation: &kb_execution_api::ExecutionSimulationResult,
|
|
|
|
|
) -> kb_core::Result<crate::ExecutionSafetyEvaluation> {
|
|
|
|
|
let plan_evaluation = match self.evaluate_prepared_plan(plan) {
|
|
|
|
|
std::result::Result::Ok(evaluation) => evaluation,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
if plan_evaluation.decision == crate::ExecutionSafetyDecision::Deny {
|
|
|
|
|
return std::result::Result::Ok(plan_evaluation);
|
|
|
|
|
}
|
|
|
|
|
let mut violations = plan_evaluation.violations;
|
|
|
|
|
if plan.policy.dry_run {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_dry_run_enabled",
|
|
|
|
|
"dry-run mode forbids transaction signing and sending",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if !simulation.simulated {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_simulation_missing",
|
|
|
|
|
"an RPC simulation result is required before sending",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
if !simulation.success {
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_simulation_failed",
|
|
|
|
|
"the simulated transaction did not complete successfully",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if simulation.replacement_blockhash.is_some()
|
|
|
|
|
|| simulation.replacement_last_valid_block_height.is_some()
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
&mut violations,
|
|
|
|
|
"execution_simulation_replaced_blockhash",
|
|
|
|
|
"a simulation with a replacement blockhash cannot authorize signing or sending the original message",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
validate_simulation_context(plan, simulation, &mut violations);
|
|
|
|
|
}
|
|
|
|
|
validate_simulated_fee(plan, simulation, &mut violations);
|
|
|
|
|
if !violations.is_empty() {
|
|
|
|
|
let decision = if violations.iter().all(|violation| {
|
|
|
|
|
return violation.code == "execution_mainnet_confirmation_required";
|
|
|
|
|
}) {
|
|
|
|
|
crate::ExecutionSafetyDecision::RequireConfirmation
|
|
|
|
|
} else {
|
|
|
|
|
crate::ExecutionSafetyDecision::Deny
|
|
|
|
|
};
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(crate::ExecutionSafetyEvaluation {
|
|
|
|
|
decision: crate::ExecutionSafetyDecision::Allow,
|
|
|
|
|
violations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_blockhash_policy(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
match plan.policy.blockhash.kind {
|
|
|
|
|
kb_execution_api::ExecutionBlockhashKind::Latest => {
|
|
|
|
|
if plan.policy.blockhash.max_age_slots == std::option::Option::Some(0)
|
|
|
|
|
|| plan.policy.blockhash.max_age_slots.is_none()
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_blockhash_age_missing",
|
|
|
|
|
"a positive maximum blockhash age is required",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if plan.policy.blockhash.nonce_account.is_some()
|
|
|
|
|
|| plan.policy.blockhash.nonce_authority.is_some()
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_latest_blockhash_has_nonce_fields",
|
|
|
|
|
"latest blockhash policy must not carry durable nonce fields",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
kb_execution_api::ExecutionBlockhashKind::DurableNonce => {
|
|
|
|
|
if plan.policy.blockhash.nonce_account.is_none()
|
|
|
|
|
|| plan.policy.blockhash.nonce_authority.is_none()
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_durable_nonce_context_missing",
|
|
|
|
|
"durable nonce policy requires both account and authority",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if plan.policy.blockhash.max_age_slots.is_some() {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_durable_nonce_has_blockhash_age",
|
|
|
|
|
"durable nonce policy must not carry a recent blockhash age",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_signer_contract(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
let mut unique_signers = std::collections::BTreeSet::new();
|
|
|
|
|
for required in &plan.required_signers {
|
|
|
|
|
if !unique_signers.insert(required.pubkey.0.as_str()) {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_required_signer_duplicate",
|
|
|
|
|
format!("required signer {} is declared more than once", required.pubkey.0),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !plan
|
|
|
|
|
.required_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|required| return required.pubkey == plan.fee_payer)
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_fee_payer_not_declared",
|
|
|
|
|
"the transaction fee payer is missing from required signers",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for instruction in &plan.instructions {
|
|
|
|
|
for account in &instruction.accounts {
|
|
|
|
|
if account.is_signer
|
|
|
|
|
&& !plan
|
|
|
|
|
.required_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|required| return required.pubkey == account.pubkey)
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_instruction_signer_not_declared",
|
|
|
|
|
format!(
|
|
|
|
|
"instruction signer {} is missing from required signers",
|
|
|
|
|
account.pubkey.0
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_authorized_signers(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
for required in &plan.required_signers {
|
|
|
|
|
if !plan
|
|
|
|
|
.policy
|
|
|
|
|
.authorized_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|authorized| return authorized == &required.pubkey)
|
|
|
|
|
{
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_signer_not_authorized",
|
|
|
|
|
format!(
|
|
|
|
|
"required signer {} with role {} is not authorized",
|
|
|
|
|
required.pubkey.0, required.role
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_cost_limits(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
match plan.policy.cost_limit.max_fee_lamports {
|
|
|
|
|
std::option::Option::Some(max_fee_lamports) if max_fee_lamports > 0 => {},
|
|
|
|
|
std::option::Option::Some(_) | std::option::Option::None => {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_fee_limit_missing",
|
|
|
|
|
"a positive transaction fee ceiling is required",
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if plan.requested_spend_lamports > 0 {
|
|
|
|
|
match plan.policy.cost_limit.max_spend_lamports {
|
|
|
|
|
std::option::Option::Some(limit) if plan.requested_spend_lamports <= limit => {},
|
|
|
|
|
std::option::Option::Some(_) => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_spend_limit_exceeded",
|
|
|
|
|
"the requested lamport spend exceeds the configured ceiling",
|
|
|
|
|
),
|
|
|
|
|
std::option::Option::None => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_spend_limit_missing",
|
|
|
|
|
"a lamport spend ceiling is required for this operation",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let std::option::Option::Some(requested_price) =
|
|
|
|
|
plan.requested_compute_unit_price_micro_lamports
|
|
|
|
|
{
|
|
|
|
|
match plan.policy.cost_limit.max_compute_unit_price_micro_lamports {
|
|
|
|
|
std::option::Option::Some(limit) if requested_price <= limit => {},
|
|
|
|
|
std::option::Option::Some(_) => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_compute_unit_price_limit_exceeded",
|
|
|
|
|
"the requested compute-unit price exceeds the configured ceiling",
|
|
|
|
|
),
|
|
|
|
|
std::option::Option::None => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_compute_unit_price_limit_missing",
|
|
|
|
|
"a compute-unit price ceiling is required for this operation",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_simulation_context(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
simulation: &kb_execution_api::ExecutionSimulationResult,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
if simulation.cluster != plan.policy.cluster.expected_cluster {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_cluster_mismatch",
|
|
|
|
|
"the simulation cluster does not match the execution policy",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if simulation.blockhash_kind != plan.policy.blockhash.kind {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_blockhash_kind_mismatch",
|
|
|
|
|
"the simulated blockhash source does not match the execution policy",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
match plan.policy.blockhash.kind {
|
|
|
|
|
kb_execution_api::ExecutionBlockhashKind::Latest => {
|
|
|
|
|
let max_age_slots = match plan.policy.blockhash.max_age_slots {
|
|
|
|
|
std::option::Option::Some(max_age_slots) => max_age_slots,
|
|
|
|
|
std::option::Option::None => return,
|
|
|
|
|
};
|
|
|
|
|
match simulation.blockhash_age_slots {
|
|
|
|
|
std::option::Option::Some(age_slots) if age_slots <= max_age_slots => {},
|
|
|
|
|
std::option::Option::Some(_) => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_blockhash_too_old",
|
|
|
|
|
"the simulated recent blockhash exceeds the configured maximum age",
|
|
|
|
|
),
|
|
|
|
|
std::option::Option::None => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_blockhash_age_missing",
|
|
|
|
|
"the simulation adapter did not report the recent blockhash age",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
if simulation.nonce_account.is_some() || simulation.nonce_authority.is_some() {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_latest_has_nonce_fields",
|
|
|
|
|
"a latest-blockhash simulation must not report durable nonce fields",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
kb_execution_api::ExecutionBlockhashKind::DurableNonce => {
|
|
|
|
|
if simulation.blockhash_age_slots.is_some() {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_nonce_has_blockhash_age",
|
|
|
|
|
"a durable nonce simulation must not report recent blockhash age",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if simulation.nonce_account != plan.policy.blockhash.nonce_account {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_nonce_account_mismatch",
|
|
|
|
|
"the simulated durable nonce account does not match the execution policy",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if simulation.nonce_authority != plan.policy.blockhash.nonce_authority {
|
|
|
|
|
push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulation_nonce_authority_mismatch",
|
|
|
|
|
"the simulated durable nonce authority does not match the execution policy",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_simulated_fee(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
simulation: &kb_execution_api::ExecutionSimulationResult,
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
) {
|
|
|
|
|
let limit = match plan.policy.cost_limit.max_fee_lamports {
|
|
|
|
|
std::option::Option::Some(limit) => limit,
|
|
|
|
|
std::option::Option::None => return,
|
|
|
|
|
};
|
|
|
|
|
match simulation.estimated_fee_lamports {
|
|
|
|
|
std::option::Option::Some(fee) if fee <= limit => {},
|
|
|
|
|
std::option::Option::Some(_) => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulated_fee_limit_exceeded",
|
|
|
|
|
"the simulated transaction fee exceeds the configured ceiling",
|
|
|
|
|
),
|
|
|
|
|
std::option::Option::None => push_violation(
|
|
|
|
|
violations,
|
|
|
|
|
"execution_simulated_fee_missing",
|
|
|
|
|
"the simulation did not report an estimated transaction fee",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_violation(
|
|
|
|
|
violations: &mut std::vec::Vec<crate::ExecutionSafetyViolation>,
|
|
|
|
|
code: impl std::convert::Into<std::string::String>,
|
|
|
|
|
message: impl std::convert::Into<std::string::String>,
|
|
|
|
|
) {
|
|
|
|
|
violations.push(crate::ExecutionSafetyViolation {
|
|
|
|
|
code: code.into(),
|
|
|
|
|
message: message.into(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
fn sample_plan() -> kb_execution_api::PreparedExecutionPlan {
|
|
|
|
|
let signer =
|
|
|
|
|
kb_model::Pubkey(std::string::String::from("11111111111111111111111111111111"));
|
|
|
|
|
return kb_execution_api::PreparedExecutionPlan {
|
|
|
|
|
executor_name: std::string::String::from("sample_executor"),
|
|
|
|
|
executor_version: std::string::String::from("0.4.2"),
|
|
|
|
|
intent_id: std::string::String::from("intent-1"),
|
|
|
|
|
operation_code: std::string::String::from("sample.operation"),
|
|
|
|
|
fee_payer: signer.clone(),
|
|
|
|
|
instructions: vec![kb_execution_api::PlannedInstruction {
|
|
|
|
|
program_id: kb_model::ProgramId(std::string::String::from(
|
|
|
|
|
"11111111111111111111111111111111",
|
|
|
|
|
)),
|
|
|
|
|
operation_code: std::string::String::from("sample.operation"),
|
|
|
|
|
accounts: vec![kb_execution_api::PlannedAccount {
|
|
|
|
|
pubkey: signer.clone(),
|
|
|
|
|
is_signer: true,
|
|
|
|
|
is_writable: true,
|
|
|
|
|
}],
|
|
|
|
|
data: vec![1],
|
|
|
|
|
}],
|
|
|
|
|
required_signers: vec![kb_execution_api::RequiredSigner {
|
|
|
|
|
pubkey: signer.clone(),
|
|
|
|
|
role: std::string::String::from("fee_payer"),
|
|
|
|
|
}],
|
|
|
|
|
policy: kb_execution_api::ExecutionPolicy {
|
|
|
|
|
cost_limit: kb_execution_api::ExecutionCostLimit {
|
|
|
|
|
max_spend_lamports: std::option::Option::Some(100),
|
|
|
|
|
max_fee_lamports: std::option::Option::Some(10_000),
|
|
|
|
|
max_compute_unit_price_micro_lamports: std::option::Option::Some(5),
|
|
|
|
|
},
|
|
|
|
|
authorized_signers: vec![signer],
|
|
|
|
|
..kb_execution_api::ExecutionPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
requested_spend_lamports: 100,
|
|
|
|
|
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn successful_simulation() -> kb_execution_api::ExecutionSimulationResult {
|
|
|
|
|
return kb_execution_api::ExecutionSimulationResult {
|
|
|
|
|
simulated: true,
|
|
|
|
|
success: true,
|
|
|
|
|
cluster: kb_execution_api::ExecutionCluster::Devnet,
|
|
|
|
|
blockhash_kind: kb_execution_api::ExecutionBlockhashKind::Latest,
|
|
|
|
|
blockhash_age_slots: std::option::Option::Some(3),
|
|
|
|
|
replacement_blockhash: std::option::Option::None,
|
|
|
|
|
replacement_last_valid_block_height: std::option::Option::None,
|
|
|
|
|
nonce_account: std::option::Option::None,
|
|
|
|
|
nonce_authority: std::option::Option::None,
|
|
|
|
|
units_consumed: std::option::Option::Some(500),
|
|
|
|
|
estimated_fee_lamports: std::option::Option::Some(5_000),
|
|
|
|
|
logs: std::vec::Vec::new(),
|
|
|
|
|
error: std::option::Option::None,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn conservative_plan_is_allowed_for_simulation() {
|
|
|
|
|
let plan = sample_plan();
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Allow);
|
|
|
|
|
assert!(evaluation.violations.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dry_run_blocks_send_after_successful_simulation() {
|
|
|
|
|
let plan = sample_plan();
|
|
|
|
|
let simulation = successful_simulation();
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_dry_run_enabled";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn successful_simulation_allows_devnet_send_when_dry_run_is_disabled() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.dry_run = false;
|
|
|
|
|
let simulation = successful_simulation();
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Allow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn undeclared_fee_payer_and_instruction_signer_are_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.required_signers.clear();
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_fee_payer_not_declared";
|
|
|
|
|
}));
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_instruction_signer_not_declared";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn duplicate_required_signer_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
let duplicate = plan.required_signers[0].clone();
|
|
|
|
|
plan.required_signers.push(duplicate);
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_required_signer_duplicate";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unauthorized_signer_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.authorized_signers.clear();
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_signer_not_authorized";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn spend_above_ceiling_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.cost_limit.max_spend_lamports = std::option::Option::Some(99);
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_spend_limit_exceeded";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mainnet_requires_enablement_and_confirmation() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.cluster.expected_cluster = kb_execution_api::ExecutionCluster::Mainnet;
|
|
|
|
|
let disabled = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(disabled.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
plan.policy.cluster.allow_mainnet = true;
|
|
|
|
|
let unconfirmed = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(unconfirmed.decision, crate::ExecutionSafetyDecision::RequireConfirmation);
|
|
|
|
|
plan.policy.cluster.mainnet_confirmation = true;
|
|
|
|
|
let confirmed = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_prepared_plan(&plan)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(confirmed.decision, crate::ExecutionSafetyDecision::Allow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn simulation_cluster_mismatch_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.dry_run = false;
|
|
|
|
|
let mut simulation = successful_simulation();
|
|
|
|
|
simulation.cluster = kb_execution_api::ExecutionCluster::Testnet;
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_simulation_cluster_mismatch";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn stale_simulation_blockhash_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.dry_run = false;
|
|
|
|
|
let mut simulation = successful_simulation();
|
|
|
|
|
simulation.blockhash_age_slots = std::option::Option::Some(151);
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_simulation_blockhash_too_old";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn replacement_blockhash_simulation_cannot_authorize_send() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.dry_run = false;
|
|
|
|
|
let mut simulation = successful_simulation();
|
|
|
|
|
simulation.replacement_blockhash =
|
|
|
|
|
std::option::Option::Some(std::string::String::from("replacement-blockhash"));
|
|
|
|
|
simulation.replacement_last_valid_block_height = std::option::Option::Some(200);
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_simulation_replaced_blockhash";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn simulated_fee_above_ceiling_is_denied() {
|
|
|
|
|
let mut plan = sample_plan();
|
|
|
|
|
plan.policy.dry_run = false;
|
|
|
|
|
let mut simulation = successful_simulation();
|
|
|
|
|
simulation.estimated_fee_lamports = std::option::Option::Some(10_001);
|
|
|
|
|
let evaluation = crate::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&plan, &simulation)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected error: {error}"));
|
|
|
|
|
assert_eq!(evaluation.decision, crate::ExecutionSafetyDecision::Deny);
|
|
|
|
|
assert!(evaluation.violations.iter().any(|violation| {
|
|
|
|
|
return violation.code == "execution_simulated_fee_limit_exceeded";
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|