|
|
|
|
@@ -0,0 +1,982 @@
|
|
|
|
|
// file: kb_execution_solana/src/transaction.rs
|
|
|
|
|
// version: 7
|
|
|
|
|
|
|
|
|
|
//! Legacy and durable nonce Solana transaction assembly, simulation binding and signing.
|
|
|
|
|
|
|
|
|
|
use base64::Engine; // rust-rules: trait-import
|
|
|
|
|
use std::str::FromStr; // rust-rules: trait-import
|
|
|
|
|
|
|
|
|
|
/// Unsigned assembled Solana transaction used for fee estimation and simulation.
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct UnsignedSolanaTransaction {
|
|
|
|
|
plan: kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
transaction: solana_transaction::Transaction,
|
|
|
|
|
message_hash: std::string::String,
|
|
|
|
|
blockhash: std::string::String,
|
|
|
|
|
required_signer_pubkeys: std::vec::Vec<std::string::String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl crate::UnsignedSolanaTransaction {
|
|
|
|
|
/// Returns the effective immutable plan represented by this transaction.
|
|
|
|
|
///
|
|
|
|
|
/// Durable nonce transactions include the injected nonce-advance instruction.
|
|
|
|
|
pub fn plan(&self) -> &kb_execution_api::PreparedExecutionPlan {
|
|
|
|
|
return &self.plan;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the base58 hash of the exact compiled transaction message.
|
|
|
|
|
pub fn message_hash(&self) -> &str {
|
|
|
|
|
return self.message_hash.as_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the recent blockhash or durable nonce value compiled into the message.
|
|
|
|
|
pub fn blockhash(&self) -> &str {
|
|
|
|
|
return self.blockhash.as_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns required signer public keys in compiled message order.
|
|
|
|
|
pub fn required_signer_pubkeys(&self) -> &[std::string::String] {
|
|
|
|
|
return self.required_signer_pubkeys.as_slice();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the serialized message in base64 for `getFeeForMessage`.
|
|
|
|
|
pub fn message_base64(&self) -> std::string::String {
|
|
|
|
|
return base64::engine::general_purpose::STANDARD.encode(self.transaction.message_data());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the unsigned serialized transaction in base64 for `simulateTransaction` with `sigVerify = false`.
|
|
|
|
|
pub fn transaction_base64(&self) -> kb_core::Result<std::string::String> {
|
|
|
|
|
let wire_bytes = match serialize_transaction(&self.transaction) {
|
|
|
|
|
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
return std::result::Result::Ok(
|
|
|
|
|
base64::engine::general_purpose::STANDARD.encode(wire_bytes),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Binds a typed RPC simulation result to this exact compiled message.
|
|
|
|
|
pub fn bind_simulation(
|
|
|
|
|
&self,
|
|
|
|
|
simulation: kb_execution_api::ExecutionSimulationResult,
|
|
|
|
|
) -> crate::SolanaSimulationEvidence {
|
|
|
|
|
return crate::SolanaSimulationEvidence {
|
|
|
|
|
message_hash: self.message_hash.clone(),
|
|
|
|
|
blockhash: self.blockhash.clone(),
|
|
|
|
|
simulation,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Signs the transaction after safety validation of a matching simulation result.
|
|
|
|
|
pub fn sign_after_simulation(
|
|
|
|
|
mut self,
|
|
|
|
|
evidence: &crate::SolanaSimulationEvidence,
|
|
|
|
|
signers: &[&dyn solana_signer::Signer],
|
|
|
|
|
) -> kb_core::Result<crate::SignedSolanaTransaction> {
|
|
|
|
|
if evidence.message_hash != self.message_hash {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_simulation_message_mismatch",
|
|
|
|
|
"the simulation result is not bound to the transaction message being signed",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if evidence.blockhash != self.blockhash {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_simulation_blockhash_mismatch",
|
|
|
|
|
"the simulation result is not bound to the blockhash or durable nonce value being signed",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let safety_evaluation = match kb_execution_safety::ExecutionSafetyChecker
|
|
|
|
|
.evaluate_send(&self.plan, &evidence.simulation)
|
|
|
|
|
{
|
|
|
|
|
std::result::Result::Ok(evaluation) => evaluation,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
if safety_evaluation.decision != kb_execution_safety::ExecutionSafetyDecision::Allow {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_signing_not_authorized",
|
|
|
|
|
format!(
|
|
|
|
|
"execution safety decision {:?} forbids signing: {}",
|
|
|
|
|
safety_evaluation.decision,
|
|
|
|
|
violation_codes(&safety_evaluation.violations)
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let signers = match resolve_signers(self.required_signer_pubkeys.as_slice(), signers) {
|
|
|
|
|
std::result::Result::Ok(signers) => signers,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let recent_blockhash = self.transaction.message.recent_blockhash;
|
|
|
|
|
if let std::result::Result::Err(error) =
|
|
|
|
|
self.transaction.try_sign(signers.as_slice(), recent_blockhash)
|
|
|
|
|
{
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_transaction_sign_failed",
|
|
|
|
|
error.to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !self.transaction.is_signed() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_transaction_not_fully_signed",
|
|
|
|
|
"the transaction is not fully signed after signer resolution",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let wire_bytes = match serialize_transaction(&self.transaction) {
|
|
|
|
|
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let primary_signature = match self.transaction.signatures.first() {
|
|
|
|
|
std::option::Option::Some(signature) => signature.to_string(),
|
|
|
|
|
std::option::Option::None => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_primary_signature_missing",
|
|
|
|
|
"the signed transaction contains no primary signature",
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
tracing::info!(
|
|
|
|
|
target: crate::TRACING_TARGET,
|
|
|
|
|
action = "sign_transaction",
|
|
|
|
|
intent_id = %self.plan.intent_id,
|
|
|
|
|
operation_code = %self.plan.operation_code,
|
|
|
|
|
message_hash = %self.message_hash,
|
|
|
|
|
signer_count = self.required_signer_pubkeys.len(),
|
|
|
|
|
wire_length = wire_bytes.len(),
|
|
|
|
|
"signed prepared Solana transaction"
|
|
|
|
|
);
|
|
|
|
|
return std::result::Result::Ok(crate::SignedSolanaTransaction {
|
|
|
|
|
transaction: self.transaction,
|
|
|
|
|
message_hash: self.message_hash,
|
|
|
|
|
primary_signature: kb_model::Signature(primary_signature),
|
|
|
|
|
signer_pubkeys: self.required_signer_pubkeys,
|
|
|
|
|
wire_bytes,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Simulation result bound to the exact assembled message that was simulated.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct SolanaSimulationEvidence {
|
|
|
|
|
message_hash: std::string::String,
|
|
|
|
|
blockhash: std::string::String,
|
|
|
|
|
simulation: kb_execution_api::ExecutionSimulationResult,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl crate::SolanaSimulationEvidence {
|
|
|
|
|
/// Returns the base58 hash of the simulated compiled message.
|
|
|
|
|
pub fn message_hash(&self) -> &str {
|
|
|
|
|
return self.message_hash.as_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the recent blockhash or durable nonce value used by the simulated message.
|
|
|
|
|
pub fn blockhash(&self) -> &str {
|
|
|
|
|
return self.blockhash.as_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the typed simulation result.
|
|
|
|
|
pub fn simulation(&self) -> &kb_execution_api::ExecutionSimulationResult {
|
|
|
|
|
return &self.simulation;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Signed Solana transaction ready for RPC submission.
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct SignedSolanaTransaction {
|
|
|
|
|
transaction: solana_transaction::Transaction,
|
|
|
|
|
message_hash: std::string::String,
|
|
|
|
|
primary_signature: kb_model::Signature,
|
|
|
|
|
signer_pubkeys: std::vec::Vec<std::string::String>,
|
|
|
|
|
wire_bytes: std::vec::Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl crate::SignedSolanaTransaction {
|
|
|
|
|
/// Returns the base58 hash of the signed compiled message.
|
|
|
|
|
pub fn message_hash(&self) -> &str {
|
|
|
|
|
return self.message_hash.as_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the transaction's primary signature.
|
|
|
|
|
pub fn primary_signature(&self) -> &kb_model::Signature {
|
|
|
|
|
return &self.primary_signature;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns signer public keys in signature order.
|
|
|
|
|
pub fn signer_pubkeys(&self) -> &[std::string::String] {
|
|
|
|
|
return self.signer_pubkeys.as_slice();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the signed transaction wire length.
|
|
|
|
|
pub fn wire_length(&self) -> usize {
|
|
|
|
|
return self.wire_bytes.len();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the signed transaction in base64 for future RPC submission.
|
|
|
|
|
pub fn transaction_base64(&self) -> std::string::String {
|
|
|
|
|
return base64::engine::general_purpose::STANDARD.encode(self.wire_bytes.as_slice());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verifies every signature against the compiled message.
|
|
|
|
|
pub fn verify_signatures(&self) -> kb_core::Result<()> {
|
|
|
|
|
return match self.transaction.verify() {
|
|
|
|
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
|
|
|
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_transaction_signature_verification_failed",
|
|
|
|
|
error.to_string(),
|
|
|
|
|
)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds an unsigned legacy Solana transaction from a prepared execution plan.
|
|
|
|
|
pub fn build_legacy_transaction(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
recent_blockhash: &str,
|
|
|
|
|
) -> kb_core::Result<crate::UnsignedSolanaTransaction> {
|
|
|
|
|
if plan.policy.blockhash.kind != kb_execution_api::ExecutionBlockhashKind::Latest {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_requires_dedicated_assembly",
|
|
|
|
|
"durable nonce plans require build_durable_nonce_transaction",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if plan.instructions.is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_plan_has_no_instructions",
|
|
|
|
|
"cannot assemble a Solana transaction without instructions",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let fee_payer = match parse_pubkey(plan.fee_payer.0.as_str(), "execution fee payer") {
|
|
|
|
|
std::result::Result::Ok(fee_payer) => fee_payer,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let blockhash = match solana_hash::Hash::from_str(recent_blockhash) {
|
|
|
|
|
std::result::Result::Ok(blockhash) => blockhash,
|
|
|
|
|
std::result::Result::Err(error) => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_recent_blockhash_invalid",
|
|
|
|
|
format!("invalid recent blockhash: {error}"),
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
let mut instructions = std::vec::Vec::with_capacity(plan.instructions.len());
|
|
|
|
|
for planned_instruction in &plan.instructions {
|
|
|
|
|
let instruction = match convert_instruction(planned_instruction) {
|
|
|
|
|
std::result::Result::Ok(instruction) => instruction,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
instructions.push(instruction);
|
|
|
|
|
}
|
|
|
|
|
let message = solana_message::Message::new_with_blockhash(
|
|
|
|
|
instructions.as_slice(),
|
|
|
|
|
std::option::Option::Some(&fee_payer),
|
|
|
|
|
&blockhash,
|
|
|
|
|
);
|
|
|
|
|
let compiled_signers = message
|
|
|
|
|
.signer_keys()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|pubkey| return pubkey.to_string())
|
|
|
|
|
.collect::<std::vec::Vec<_>>();
|
|
|
|
|
match validate_compiled_signers(plan, compiled_signers.as_slice()) {
|
|
|
|
|
std::result::Result::Ok(()) => {},
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
}
|
|
|
|
|
let transaction = solana_transaction::Transaction::new_unsigned(message);
|
|
|
|
|
let wire_bytes = match serialize_transaction(&transaction) {
|
|
|
|
|
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let message_hash = transaction.message.hash().to_string();
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: crate::TRACING_TARGET,
|
|
|
|
|
action = "assemble_transaction",
|
|
|
|
|
intent_id = %plan.intent_id,
|
|
|
|
|
operation_code = %plan.operation_code,
|
|
|
|
|
instruction_count = plan.instructions.len(),
|
|
|
|
|
signer_count = compiled_signers.len(),
|
|
|
|
|
message_hash = %message_hash,
|
|
|
|
|
wire_length = wire_bytes.len(),
|
|
|
|
|
"assembled unsigned legacy Solana transaction"
|
|
|
|
|
);
|
|
|
|
|
return std::result::Result::Ok(crate::UnsignedSolanaTransaction {
|
|
|
|
|
plan: plan.clone(),
|
|
|
|
|
transaction,
|
|
|
|
|
message_hash,
|
|
|
|
|
blockhash: blockhash.to_string(),
|
|
|
|
|
required_signer_pubkeys: compiled_signers,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds an unsigned transaction that consumes a validated durable nonce state.
|
|
|
|
|
pub fn build_durable_nonce_transaction(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
nonce_state: &crate::DurableNonceAccountState,
|
|
|
|
|
) -> kb_core::Result<crate::UnsignedSolanaTransaction> {
|
|
|
|
|
if plan.policy.blockhash.kind != kb_execution_api::ExecutionBlockhashKind::DurableNonce {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_policy_required",
|
|
|
|
|
"the dedicated durable nonce assembly path requires a DurableNonce blockhash policy",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if plan.policy.blockhash.max_age_slots.is_some() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_has_blockhash_age",
|
|
|
|
|
"durable nonce assembly cannot use a recent blockhash age limit",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if plan.instructions.is_empty() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_plan_has_no_instructions",
|
|
|
|
|
"cannot assemble a durable nonce transaction without operation instructions",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let policy_nonce_account = match &plan.policy.blockhash.nonce_account {
|
|
|
|
|
std::option::Option::Some(account) => account,
|
|
|
|
|
std::option::Option::None => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_account_missing",
|
|
|
|
|
"durable nonce assembly requires the nonce account in the execution policy",
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
let policy_nonce_authority = match &plan.policy.blockhash.nonce_authority {
|
|
|
|
|
std::option::Option::Some(authority) => authority,
|
|
|
|
|
std::option::Option::None => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_authority_missing",
|
|
|
|
|
"durable nonce assembly requires the nonce authority in the execution policy",
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
if policy_nonce_account != nonce_state.account() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_account_mismatch",
|
|
|
|
|
format!(
|
|
|
|
|
"execution policy nonce account {} differs from validated account {}",
|
|
|
|
|
policy_nonce_account.0,
|
|
|
|
|
nonce_state.account().0
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if policy_nonce_authority != nonce_state.authority() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_authority_mismatch",
|
|
|
|
|
format!(
|
|
|
|
|
"execution policy nonce authority {} differs from validated authority {}",
|
|
|
|
|
policy_nonce_authority.0,
|
|
|
|
|
nonce_state.authority().0
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !plan
|
|
|
|
|
.required_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|required| return &required.pubkey == policy_nonce_authority)
|
|
|
|
|
{
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_authority_not_declared",
|
|
|
|
|
format!(
|
|
|
|
|
"durable nonce authority {} is missing from required signers",
|
|
|
|
|
policy_nonce_authority.0
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let fee_payer = match parse_pubkey(plan.fee_payer.0.as_str(), "execution fee payer") {
|
|
|
|
|
std::result::Result::Ok(fee_payer) => fee_payer,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let nonce_account = match parse_pubkey(policy_nonce_account.0.as_str(), "durable nonce account")
|
|
|
|
|
{
|
|
|
|
|
std::result::Result::Ok(nonce_account) => nonce_account,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let nonce_authority =
|
|
|
|
|
match parse_pubkey(policy_nonce_authority.0.as_str(), "durable nonce authority") {
|
|
|
|
|
std::result::Result::Ok(nonce_authority) => nonce_authority,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let nonce_blockhash = match solana_hash::Hash::from_str(nonce_state.blockhash()) {
|
|
|
|
|
std::result::Result::Ok(blockhash) => blockhash,
|
|
|
|
|
std::result::Result::Err(error) => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_blockhash_invalid",
|
|
|
|
|
format!("invalid durable nonce value: {error}"),
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
let advance_instruction = solana_system_interface::instruction::advance_nonce_account(
|
|
|
|
|
&nonce_account,
|
|
|
|
|
&nonce_authority,
|
|
|
|
|
);
|
|
|
|
|
let mut effective_plan = plan.clone();
|
|
|
|
|
effective_plan.instructions.insert(
|
|
|
|
|
0,
|
|
|
|
|
planned_instruction(crate::DURABLE_NONCE_ADVANCE_OPERATION, &advance_instruction),
|
|
|
|
|
);
|
|
|
|
|
let mut operation_instructions = std::vec::Vec::with_capacity(plan.instructions.len());
|
|
|
|
|
for planned_instruction in &plan.instructions {
|
|
|
|
|
let instruction = match convert_instruction(planned_instruction) {
|
|
|
|
|
std::result::Result::Ok(instruction) => instruction,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
operation_instructions.push(instruction);
|
|
|
|
|
}
|
|
|
|
|
let mut message = solana_message::Message::new_with_nonce(
|
|
|
|
|
operation_instructions,
|
|
|
|
|
std::option::Option::Some(&fee_payer),
|
|
|
|
|
&nonce_account,
|
|
|
|
|
&nonce_authority,
|
|
|
|
|
);
|
|
|
|
|
message.recent_blockhash = nonce_blockhash;
|
|
|
|
|
let compiled_signers = message
|
|
|
|
|
.signer_keys()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|pubkey| return pubkey.to_string())
|
|
|
|
|
.collect::<std::vec::Vec<_>>();
|
|
|
|
|
match validate_compiled_signers(&effective_plan, compiled_signers.as_slice()) {
|
|
|
|
|
std::result::Result::Ok(()) => {},
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
}
|
|
|
|
|
let transaction = solana_transaction::Transaction::new_unsigned(message);
|
|
|
|
|
if solana_transaction::uses_durable_nonce(&transaction).is_none() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_durable_nonce_marker_missing",
|
|
|
|
|
"the assembled transaction is not recognized as a durable nonce transaction",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let wire_bytes = match serialize_transaction(&transaction) {
|
|
|
|
|
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let message_hash = transaction.message.hash().to_string();
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: crate::TRACING_TARGET,
|
|
|
|
|
action = "assemble_durable_nonce_transaction",
|
|
|
|
|
intent_id = %effective_plan.intent_id,
|
|
|
|
|
operation_code = %effective_plan.operation_code,
|
|
|
|
|
nonce_account = %policy_nonce_account.0,
|
|
|
|
|
nonce_authority = %policy_nonce_authority.0,
|
|
|
|
|
nonce_blockhash = %nonce_state.blockhash(),
|
|
|
|
|
instruction_count = effective_plan.instructions.len(),
|
|
|
|
|
signer_count = compiled_signers.len(),
|
|
|
|
|
message_hash = %message_hash,
|
|
|
|
|
wire_length = wire_bytes.len(),
|
|
|
|
|
"assembled unsigned durable nonce Solana transaction"
|
|
|
|
|
);
|
|
|
|
|
return std::result::Result::Ok(crate::UnsignedSolanaTransaction {
|
|
|
|
|
plan: effective_plan,
|
|
|
|
|
transaction,
|
|
|
|
|
message_hash,
|
|
|
|
|
blockhash: nonce_state.blockhash().to_string(),
|
|
|
|
|
required_signer_pubkeys: compiled_signers,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn convert_instruction(
|
|
|
|
|
planned: &kb_execution_api::PlannedInstruction,
|
|
|
|
|
) -> kb_core::Result<solana_instruction::Instruction> {
|
|
|
|
|
let program_id =
|
|
|
|
|
match parse_pubkey(planned.program_id.0.as_str(), "planned instruction program id") {
|
|
|
|
|
std::result::Result::Ok(program_id) => program_id,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
let mut accounts = std::vec::Vec::with_capacity(planned.accounts.len());
|
|
|
|
|
for planned_account in &planned.accounts {
|
|
|
|
|
let pubkey =
|
|
|
|
|
match parse_pubkey(planned_account.pubkey.0.as_str(), "planned instruction account") {
|
|
|
|
|
std::result::Result::Ok(pubkey) => pubkey,
|
|
|
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
|
|
|
};
|
|
|
|
|
accounts.push(solana_instruction::AccountMeta {
|
|
|
|
|
pubkey,
|
|
|
|
|
is_signer: planned_account.is_signer,
|
|
|
|
|
is_writable: planned_account.is_writable,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(solana_instruction::Instruction {
|
|
|
|
|
program_id,
|
|
|
|
|
accounts,
|
|
|
|
|
data: planned.data.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn planned_instruction(
|
|
|
|
|
operation_code: &str,
|
|
|
|
|
instruction: &solana_instruction::Instruction,
|
|
|
|
|
) -> kb_execution_api::PlannedInstruction {
|
|
|
|
|
return kb_execution_api::PlannedInstruction {
|
|
|
|
|
program_id: kb_model::ProgramId(instruction.program_id.to_string()),
|
|
|
|
|
operation_code: operation_code.to_string(),
|
|
|
|
|
accounts: instruction
|
|
|
|
|
.accounts
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|account| {
|
|
|
|
|
return kb_execution_api::PlannedAccount {
|
|
|
|
|
pubkey: kb_model::Pubkey(account.pubkey.to_string()),
|
|
|
|
|
is_signer: account.is_signer,
|
|
|
|
|
is_writable: account.is_writable,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
data: instruction.data.clone(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_pubkey(value: &str, field_name: &str) -> kb_core::Result<solana_pubkey::Pubkey> {
|
|
|
|
|
return match solana_pubkey::Pubkey::from_str(value) {
|
|
|
|
|
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
|
|
|
|
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_pubkey_invalid",
|
|
|
|
|
format!("invalid {field_name} {value}: {error}"),
|
|
|
|
|
)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_compiled_signers(
|
|
|
|
|
plan: &kb_execution_api::PreparedExecutionPlan,
|
|
|
|
|
compiled_signers: &[std::string::String],
|
|
|
|
|
) -> kb_core::Result<()> {
|
|
|
|
|
let declared_signers = plan
|
|
|
|
|
.required_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|required| return required.pubkey.0.as_str())
|
|
|
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
|
|
|
let compiled_signer_set = compiled_signers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(std::string::String::as_str)
|
|
|
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
|
|
|
if declared_signers != compiled_signer_set {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_compiled_signer_contract_mismatch",
|
|
|
|
|
format!(
|
|
|
|
|
"declared signers [{}] differ from compiled signers [{}]",
|
|
|
|
|
declared_signers.into_iter().collect::<std::vec::Vec<_>>().join(","),
|
|
|
|
|
compiled_signer_set.into_iter().collect::<std::vec::Vec<_>>().join(",")
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_signers<'a>(
|
|
|
|
|
required_signers: &[std::string::String],
|
|
|
|
|
signers: &'a [&'a dyn solana_signer::Signer],
|
|
|
|
|
) -> kb_core::Result<std::vec::Vec<&'a dyn solana_signer::Signer>> {
|
|
|
|
|
let mut signer_by_pubkey = std::collections::BTreeMap::new();
|
|
|
|
|
for signer in signers {
|
|
|
|
|
let public_key = signer.pubkey().to_string();
|
|
|
|
|
if signer_by_pubkey.insert(public_key.clone(), *signer).is_some() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_signer_duplicate",
|
|
|
|
|
format!("signer {public_key} was supplied more than once"),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if signer_by_pubkey.len() != required_signers.len() {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_signer_count_mismatch",
|
|
|
|
|
format!(
|
|
|
|
|
"{} signers were supplied for {} required signers",
|
|
|
|
|
signer_by_pubkey.len(),
|
|
|
|
|
required_signers.len()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let mut resolved = std::vec::Vec::with_capacity(required_signers.len());
|
|
|
|
|
for required_signer in required_signers {
|
|
|
|
|
let signer = match signer_by_pubkey.get(required_signer) {
|
|
|
|
|
std::option::Option::Some(signer) => *signer,
|
|
|
|
|
std::option::Option::None => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_required_signer_missing",
|
|
|
|
|
format!("required signer {required_signer} was not supplied"),
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
resolved.push(signer);
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(resolved);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn serialize_transaction(
|
|
|
|
|
transaction: &solana_transaction::Transaction,
|
|
|
|
|
) -> kb_core::Result<std::vec::Vec<u8>> {
|
|
|
|
|
let wire_bytes = match wincode::serialize(transaction) {
|
|
|
|
|
std::result::Result::Ok(wire_bytes) => wire_bytes,
|
|
|
|
|
std::result::Result::Err(error) => {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_transaction_serialize_failed",
|
|
|
|
|
error.to_string(),
|
|
|
|
|
));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
if wire_bytes.len() > crate::MAX_TRANSACTION_WIRE_BYTES {
|
|
|
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
|
|
|
"execution_transaction_wire_size_exceeded",
|
|
|
|
|
format!(
|
|
|
|
|
"serialized transaction length {} exceeds the Solana packet limit {}",
|
|
|
|
|
wire_bytes.len(),
|
|
|
|
|
crate::MAX_TRANSACTION_WIRE_BYTES
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return std::result::Result::Ok(wire_bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn violation_codes(
|
|
|
|
|
violations: &[kb_execution_safety::ExecutionSafetyViolation],
|
|
|
|
|
) -> std::string::String {
|
|
|
|
|
return violations
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|violation| return violation.code.as_str())
|
|
|
|
|
.collect::<std::vec::Vec<_>>()
|
|
|
|
|
.join(",");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use solana_signer::Signer; // rust-rules: trait-import
|
|
|
|
|
use std::str::FromStr; // rust-rules: trait-import
|
|
|
|
|
|
|
|
|
|
fn wallet(alias: &str) -> kb_wallet::TemporaryWallet {
|
|
|
|
|
let alias = kb_wallet::WalletAlias::parse(alias)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
|
|
|
|
return kb_wallet::TemporaryWallet::generate(alias);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn transfer_plan(
|
|
|
|
|
wallet: &kb_wallet::TemporaryWallet,
|
|
|
|
|
dry_run: bool,
|
|
|
|
|
lamports: u64,
|
|
|
|
|
) -> kb_execution_api::PreparedExecutionPlan {
|
|
|
|
|
let payer = kb_model::Pubkey(wallet.public_key());
|
|
|
|
|
let destination = kb_model::Pubkey(solana_keypair::Keypair::new().pubkey().to_string());
|
|
|
|
|
let intent = kb_executor_solana_core::SolanaCoreExecutionIntent {
|
|
|
|
|
intent_id: std::string::String::from("transfer-test"),
|
|
|
|
|
fee_payer: payer.clone(),
|
|
|
|
|
operation: kb_executor_solana_core::SolanaCoreOperation::SystemTransfer {
|
|
|
|
|
from: payer.clone(),
|
|
|
|
|
to: destination,
|
|
|
|
|
lamports,
|
|
|
|
|
},
|
|
|
|
|
policy: kb_execution_api::ExecutionPolicy {
|
|
|
|
|
dry_run,
|
|
|
|
|
authorized_signers: vec![payer],
|
|
|
|
|
cost_limit: kb_execution_api::ExecutionCostLimit {
|
|
|
|
|
max_spend_lamports: std::option::Option::Some(lamports),
|
|
|
|
|
max_fee_lamports: std::option::Option::Some(10_000),
|
|
|
|
|
max_compute_unit_price_micro_lamports: std::option::Option::Some(100),
|
|
|
|
|
},
|
|
|
|
|
..kb_execution_api::ExecutionPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
|
|
|
|
&kb_executor_solana_core::SolanaCoreExecutor,
|
|
|
|
|
&intent,
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected plan error: {error}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn durable_transfer_plan(
|
|
|
|
|
payer: &kb_wallet::TemporaryWallet,
|
|
|
|
|
nonce_authority: &kb_wallet::TemporaryWallet,
|
|
|
|
|
nonce_account: &kb_model::Pubkey,
|
|
|
|
|
) -> kb_execution_api::PreparedExecutionPlan {
|
|
|
|
|
let payer_pubkey = kb_model::Pubkey(payer.public_key());
|
|
|
|
|
let authority_pubkey = kb_model::Pubkey(nonce_authority.public_key());
|
|
|
|
|
let destination = kb_model::Pubkey(solana_keypair::Keypair::new().pubkey().to_string());
|
|
|
|
|
let intent = kb_executor_solana_core::SolanaCoreExecutionIntent {
|
|
|
|
|
intent_id: std::string::String::from("durable-transfer-test"),
|
|
|
|
|
fee_payer: payer_pubkey.clone(),
|
|
|
|
|
operation: kb_executor_solana_core::SolanaCoreOperation::SystemTransfer {
|
|
|
|
|
from: payer_pubkey.clone(),
|
|
|
|
|
to: destination,
|
|
|
|
|
lamports: 100,
|
|
|
|
|
},
|
|
|
|
|
policy: kb_execution_api::ExecutionPolicy {
|
|
|
|
|
dry_run: false,
|
|
|
|
|
authorized_signers: vec![payer_pubkey, authority_pubkey.clone()],
|
|
|
|
|
blockhash: kb_execution_api::ExecutionBlockhashPolicy {
|
|
|
|
|
kind: kb_execution_api::ExecutionBlockhashKind::DurableNonce,
|
|
|
|
|
max_age_slots: std::option::Option::None,
|
|
|
|
|
nonce_account: std::option::Option::Some(nonce_account.clone()),
|
|
|
|
|
nonce_authority: std::option::Option::Some(authority_pubkey),
|
|
|
|
|
},
|
|
|
|
|
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(100),
|
|
|
|
|
},
|
|
|
|
|
..kb_execution_api::ExecutionPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
|
|
|
|
&kb_executor_solana_core::SolanaCoreExecutor,
|
|
|
|
|
&intent,
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected durable plan error: {error}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nonce_state(
|
|
|
|
|
nonce_account: &kb_model::Pubkey,
|
|
|
|
|
nonce_authority: &kb_wallet::TemporaryWallet,
|
|
|
|
|
) -> crate::DurableNonceAccountState {
|
|
|
|
|
let source_blockhash = solana_hash::Hash::new_unique();
|
|
|
|
|
let durable_nonce = solana_nonce::state::DurableNonce::from_blockhash(&source_blockhash);
|
|
|
|
|
let authority = solana_pubkey::Pubkey::from_str(nonce_authority.public_key().as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("invalid authority fixture: {error}"));
|
|
|
|
|
let versions = solana_nonce::versions::Versions::new(
|
|
|
|
|
solana_nonce::state::State::new_initialized(&authority, durable_nonce, 5_000),
|
|
|
|
|
);
|
|
|
|
|
let bytes = wincode::serialize(&versions)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected nonce serialization error: {error}"));
|
|
|
|
|
return crate::parse_durable_nonce_account(
|
|
|
|
|
nonce_account,
|
|
|
|
|
&kb_model::ProgramId(solana_sdk_ids::system_program::id().to_string()),
|
|
|
|
|
false,
|
|
|
|
|
bytes.len() as u64,
|
|
|
|
|
bytes.as_slice(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected nonce parse error: {error}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(1),
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn successful_nonce_simulation(
|
|
|
|
|
nonce_account: &kb_model::Pubkey,
|
|
|
|
|
nonce_authority: &kb_wallet::TemporaryWallet,
|
|
|
|
|
) -> 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::DurableNonce,
|
|
|
|
|
blockhash_age_slots: std::option::Option::None,
|
|
|
|
|
replacement_blockhash: std::option::Option::None,
|
|
|
|
|
replacement_last_valid_block_height: std::option::Option::None,
|
|
|
|
|
nonce_account: std::option::Option::Some(nonce_account.clone()),
|
|
|
|
|
nonce_authority: std::option::Option::Some(kb_model::Pubkey(
|
|
|
|
|
nonce_authority.public_key(),
|
|
|
|
|
)),
|
|
|
|
|
units_consumed: std::option::Option::Some(700),
|
|
|
|
|
estimated_fee_lamports: std::option::Option::Some(10_000),
|
|
|
|
|
logs: std::vec::Vec::new(),
|
|
|
|
|
error: std::option::Option::None,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn legacy_transaction_preserves_plan_and_serializes_for_rpc() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let plan = transfer_plan(&wallet, false, 100);
|
|
|
|
|
let blockhash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
|
|
|
|
assert_eq!(unsigned.plan().intent_id, plan.intent_id);
|
|
|
|
|
assert_eq!(unsigned.required_signer_pubkeys(), &[wallet.public_key()]);
|
|
|
|
|
assert!(!unsigned.message_hash().is_empty());
|
|
|
|
|
assert!(!unsigned.message_base64().is_empty());
|
|
|
|
|
assert!(
|
|
|
|
|
!unsigned
|
|
|
|
|
.transaction_base64()
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected serialization error: {error}"))
|
|
|
|
|
.is_empty()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn matching_simulation_and_signer_produce_verified_signature() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let plan = transfer_plan(&wallet, false, 100);
|
|
|
|
|
let blockhash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
|
|
|
|
let evidence = unsigned.bind_simulation(successful_simulation());
|
|
|
|
|
let signed = unsigned
|
|
|
|
|
.sign_after_simulation(&evidence, &[wallet.as_signer()])
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected signing error: {error}"));
|
|
|
|
|
signed
|
|
|
|
|
.verify_signatures()
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected verification error: {error}"));
|
|
|
|
|
assert!(!signed.primary_signature().0.is_empty());
|
|
|
|
|
assert_eq!(signed.signer_pubkeys(), &[wallet.public_key()]);
|
|
|
|
|
assert!(signed.wire_length() <= crate::MAX_TRANSACTION_WIRE_BYTES);
|
|
|
|
|
assert!(!signed.transaction_base64().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dry_run_policy_blocks_signing_after_successful_simulation() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let plan = transfer_plan(&wallet, true, 100);
|
|
|
|
|
let blockhash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
|
|
|
|
let evidence = unsigned.bind_simulation(successful_simulation());
|
|
|
|
|
let result = unsigned.sign_after_simulation(&evidence, &[wallet.as_signer()]);
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn simulation_evidence_from_another_message_is_rejected() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let plan = transfer_plan(&wallet, false, 100);
|
|
|
|
|
let first_hash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let second_hash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let first = crate::build_legacy_transaction(&plan, first_hash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected first assembly error: {error}"));
|
|
|
|
|
let second = crate::build_legacy_transaction(&plan, second_hash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected second assembly error: {error}"));
|
|
|
|
|
let evidence = first.bind_simulation(successful_simulation());
|
|
|
|
|
let result = second.sign_after_simulation(&evidence, &[wallet.as_signer()]);
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_extra_and_duplicate_signers_are_rejected() {
|
|
|
|
|
let tmpwallet = wallet("payer");
|
|
|
|
|
let extra = wallet("extra");
|
|
|
|
|
let plan = transfer_plan(&tmpwallet, false, 100);
|
|
|
|
|
let blockhash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
let unsigned = crate::build_legacy_transaction(&plan, blockhash.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected assembly error: {error}"));
|
|
|
|
|
let evidence = unsigned.bind_simulation(successful_simulation());
|
|
|
|
|
assert!(unsigned.clone().sign_after_simulation(&evidence, &[]).is_err());
|
|
|
|
|
assert!(
|
|
|
|
|
unsigned
|
|
|
|
|
.clone()
|
|
|
|
|
.sign_after_simulation(&evidence, &[tmpwallet.as_signer(), extra.as_signer()],)
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
unsigned
|
|
|
|
|
.sign_after_simulation(&evidence, &[tmpwallet.as_signer(), tmpwallet.as_signer()],)
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn durable_nonce_transaction_injects_exact_advance_and_signs() {
|
|
|
|
|
let payer = wallet("durable-payer");
|
|
|
|
|
let authority = wallet("durable-authority");
|
|
|
|
|
let nonce_account = kb_model::Pubkey(solana_pubkey::Pubkey::new_unique().to_string());
|
|
|
|
|
let plan = durable_transfer_plan(&payer, &authority, &nonce_account);
|
|
|
|
|
assert_eq!(plan.instructions.len(), 1);
|
|
|
|
|
assert_eq!(plan.required_signers.len(), 2);
|
|
|
|
|
let nonce_state = nonce_state(&nonce_account, &authority);
|
|
|
|
|
let unsigned = crate::build_durable_nonce_transaction(&plan, &nonce_state)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected durable assembly error: {error}"));
|
|
|
|
|
assert_eq!(plan.instructions.len(), 1);
|
|
|
|
|
assert_eq!(unsigned.plan().instructions.len(), 2);
|
|
|
|
|
assert_eq!(unsigned.blockhash(), nonce_state.blockhash());
|
|
|
|
|
let authority_address = solana_pubkey::Pubkey::from_str(authority.public_key().as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("invalid authority fixture: {error}"));
|
|
|
|
|
let nonce_address = solana_pubkey::Pubkey::from_str(nonce_account.0.as_str())
|
|
|
|
|
.unwrap_or_else(|error| panic!("invalid nonce fixture: {error}"));
|
|
|
|
|
let expected_advance = solana_system_interface::instruction::advance_nonce_account(
|
|
|
|
|
&nonce_address,
|
|
|
|
|
&authority_address,
|
|
|
|
|
);
|
|
|
|
|
let actual_advance = &unsigned.plan().instructions[0];
|
|
|
|
|
assert_eq!(actual_advance.operation_code, crate::DURABLE_NONCE_ADVANCE_OPERATION);
|
|
|
|
|
assert_eq!(actual_advance.program_id.0, expected_advance.program_id.to_string());
|
|
|
|
|
assert_eq!(actual_advance.data, expected_advance.data);
|
|
|
|
|
assert_eq!(actual_advance.accounts.len(), expected_advance.accounts.len());
|
|
|
|
|
for (actual, expected) in
|
|
|
|
|
actual_advance.accounts.iter().zip(expected_advance.accounts.iter())
|
|
|
|
|
{
|
|
|
|
|
assert_eq!(actual.pubkey.0, expected.pubkey.to_string());
|
|
|
|
|
assert_eq!(actual.is_signer, expected.is_signer);
|
|
|
|
|
assert_eq!(actual.is_writable, expected.is_writable);
|
|
|
|
|
}
|
|
|
|
|
assert!(solana_transaction::uses_durable_nonce(&unsigned.transaction).is_some());
|
|
|
|
|
let compiled_first = &unsigned.transaction.message.instructions[0];
|
|
|
|
|
assert_eq!(
|
|
|
|
|
unsigned.transaction.message.account_keys[compiled_first.program_id_index as usize],
|
|
|
|
|
expected_advance.program_id
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(compiled_first.data, expected_advance.data);
|
|
|
|
|
let signer_set = unsigned
|
|
|
|
|
.required_signer_pubkeys()
|
|
|
|
|
.iter()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
signer_set,
|
|
|
|
|
std::collections::BTreeSet::from([payer.public_key(), authority.public_key()])
|
|
|
|
|
);
|
|
|
|
|
let evidence =
|
|
|
|
|
unsigned.bind_simulation(successful_nonce_simulation(&nonce_account, &authority));
|
|
|
|
|
assert_eq!(evidence.blockhash(), nonce_state.blockhash());
|
|
|
|
|
let signed = unsigned
|
|
|
|
|
.sign_after_simulation(&evidence, &[payer.as_signer(), authority.as_signer()])
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected durable signing error: {error}"));
|
|
|
|
|
signed
|
|
|
|
|
.verify_signatures()
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected signature verification error: {error}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn durable_nonce_assembly_rejects_mismatched_state_and_simulation() {
|
|
|
|
|
let payer = wallet("mismatch-payer");
|
|
|
|
|
let authority = wallet("mismatch-authority");
|
|
|
|
|
let wrong_authority = wallet("wrong-authority");
|
|
|
|
|
let nonce_account = kb_model::Pubkey(solana_pubkey::Pubkey::new_unique().to_string());
|
|
|
|
|
let plan = durable_transfer_plan(&payer, &authority, &nonce_account);
|
|
|
|
|
let wrong_state = nonce_state(&nonce_account, &wrong_authority);
|
|
|
|
|
assert!(crate::build_durable_nonce_transaction(&plan, &wrong_state).is_err());
|
|
|
|
|
let nonce_state = nonce_state(&nonce_account, &authority);
|
|
|
|
|
let unsigned = crate::build_durable_nonce_transaction(&plan, &nonce_state)
|
|
|
|
|
.unwrap_or_else(|error| panic!("unexpected durable assembly error: {error}"));
|
|
|
|
|
let latest_evidence = unsigned.bind_simulation(successful_simulation());
|
|
|
|
|
assert!(
|
|
|
|
|
unsigned
|
|
|
|
|
.sign_after_simulation(
|
|
|
|
|
&latest_evidence,
|
|
|
|
|
&[payer.as_signer(), authority.as_signer()],
|
|
|
|
|
)
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
let mut undeclared = plan.clone();
|
|
|
|
|
undeclared
|
|
|
|
|
.required_signers
|
|
|
|
|
.retain(|required| return required.pubkey.0 != authority.public_key());
|
|
|
|
|
assert!(crate::build_durable_nonce_transaction(&undeclared, &nonce_state).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn durable_nonce_plan_requires_dedicated_assembly() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let mut plan = transfer_plan(&wallet, false, 100);
|
|
|
|
|
plan.policy.blockhash.kind = kb_execution_api::ExecutionBlockhashKind::DurableNonce;
|
|
|
|
|
plan.policy.blockhash.max_age_slots = std::option::Option::None;
|
|
|
|
|
plan.policy.blockhash.nonce_account =
|
|
|
|
|
std::option::Option::Some(kb_model::Pubkey(wallet.public_key()));
|
|
|
|
|
plan.policy.blockhash.nonce_authority =
|
|
|
|
|
std::option::Option::Some(kb_model::Pubkey(wallet.public_key()));
|
|
|
|
|
let nonce = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
assert!(crate::build_legacy_transaction(&plan, nonce.as_str()).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_blockhash_and_signer_contract_are_rejected() {
|
|
|
|
|
let wallet = wallet("payer");
|
|
|
|
|
let mut plan = transfer_plan(&wallet, false, 100);
|
|
|
|
|
assert!(crate::build_legacy_transaction(&plan, "invalid").is_err());
|
|
|
|
|
plan.required_signers.clear();
|
|
|
|
|
let blockhash = solana_hash::Hash::new_unique().to_string();
|
|
|
|
|
assert!(crate::build_legacy_transaction(&plan, blockhash.as_str()).is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|