1802 lines
74 KiB
Rust
1802 lines
74 KiB
Rust
// file: kb_pipeline/src/solana_token_lifecycle.rs
|
|
// version: 4
|
|
|
|
//! Controlled Devnet lifecycle for freshly prepared classic SPL Token accounts.
|
|
|
|
const MINT_ACCOUNT_SPACE: u64 = 82;
|
|
const TOKEN_ACCOUNT_SPACE: u64 = 165;
|
|
|
|
/// Explicit request to prepare the three raw accounts used by one Devnet lifecycle.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecyclePreparationRequest {
|
|
/// Stable caller-provided preparation identifier.
|
|
pub intent_id: std::string::String,
|
|
/// Endpoint role used for cluster, rent, blockhash and account reads.
|
|
pub query_role: std::string::String,
|
|
/// Endpoint role used for simulation, submission and confirmation.
|
|
pub transaction_role: std::string::String,
|
|
/// Explicitly authorizes three rent-bearing System account creations.
|
|
pub submit: bool,
|
|
/// Explicit operator confirmation for the preparation spend.
|
|
pub operator_confirmed: bool,
|
|
}
|
|
|
|
impl crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationRequest {
|
|
/// Creates a conservative preparation request that cannot submit by default.
|
|
pub fn new(intent_id: impl std::convert::Into<std::string::String>) -> Self {
|
|
return Self {
|
|
intent_id: intent_id.into(),
|
|
query_role: "http_queries".to_string(),
|
|
transaction_role: "http_transactions".to_string(),
|
|
submit: false,
|
|
operator_confirmed: false,
|
|
};
|
|
}
|
|
|
|
fn validate(&self) -> kb_core::Result<()> {
|
|
if self.intent_id.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet SPL Token lifecycle preparation intent id must not be empty",
|
|
));
|
|
}
|
|
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet SPL Token lifecycle preparation endpoint roles must not be empty",
|
|
));
|
|
}
|
|
if !self.submit || !self.operator_confirmed {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_authorization_required",
|
|
"raw account preparation requires submit=true and explicit operator confirmation",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
}
|
|
|
|
/// Result retained for one confirmed raw System account creation.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecyclePreparationStep {
|
|
/// Stable account role: `mint`, `source` or `destination`.
|
|
pub account_role: std::string::String,
|
|
/// Newly generated account public key.
|
|
pub account: kb_model::Pubkey,
|
|
/// Exact allocated data length.
|
|
pub space: u64,
|
|
/// Exact rent-exempt lamports funded into the account.
|
|
pub rent_lamports: u64,
|
|
/// Confirmed System account-creation transaction signature.
|
|
pub signature: kb_model::Signature,
|
|
/// Terminal confirmation status.
|
|
pub confirmation_status: kb_execution_api::ExecutionConfirmationStatus,
|
|
}
|
|
|
|
/// Fresh raw accounts and identities ready for the controlled Token lifecycle.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecyclePreparationSummary {
|
|
/// Profile wallet that will own and administer the lifecycle accounts.
|
|
pub authority: kb_model::Pubkey,
|
|
/// Fresh uninitialized 82-byte Token-owned mint account.
|
|
pub mint: kb_model::Pubkey,
|
|
/// Fresh uninitialized 165-byte Token-owned source account.
|
|
pub source: kb_model::Pubkey,
|
|
/// Fresh uninitialized 165-byte Token-owned destination account.
|
|
pub destination: kb_model::Pubkey,
|
|
/// Fresh off-chain delegate identity used only by approve/revoke.
|
|
pub delegate: kb_model::Pubkey,
|
|
/// Ordered confirmed account-creation results.
|
|
pub steps: std::vec::Vec<crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationStep>,
|
|
}
|
|
|
|
/// Request for one destructive, explicitly authorized SPL Token Devnet lifecycle.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecycleRequest {
|
|
/// Stable caller-provided lifecycle identifier.
|
|
pub intent_id: std::string::String,
|
|
/// Endpoint role used for state reads and canonical hydration.
|
|
pub query_role: std::string::String,
|
|
/// Endpoint role used for simulation, submission and confirmation.
|
|
pub transaction_role: std::string::String,
|
|
/// Pre-created, rent-exempt, uninitialized 82-byte mint account.
|
|
pub mint: kb_model::Pubkey,
|
|
/// Pre-created, rent-exempt, uninitialized 165-byte source token account.
|
|
pub source: kb_model::Pubkey,
|
|
/// Pre-created, rent-exempt, uninitialized 165-byte destination token account.
|
|
pub destination: kb_model::Pubkey,
|
|
/// Delegate recorded by the approval and removed by the following revoke.
|
|
pub delegate: kb_model::Pubkey,
|
|
/// Mint authority and owner of both token accounts.
|
|
pub authority: kb_model::Pubkey,
|
|
/// Exact decimals used by all checked instructions.
|
|
pub decimals: u8,
|
|
/// Exact raw amount minted into the source account.
|
|
pub mint_amount: kb_executor_spl_token::SplTokenAmount,
|
|
/// Exact raw amount transferred from source to destination.
|
|
pub transfer_amount: kb_executor_spl_token::SplTokenAmount,
|
|
/// Exact raw allowance approved on the destination account.
|
|
pub approve_amount: kb_executor_spl_token::SplTokenAmount,
|
|
/// Explicitly authorizes every submitted lifecycle transaction.
|
|
pub submit: bool,
|
|
/// Explicit operator confirmation applied to every lifecycle transaction.
|
|
pub operator_confirmed: bool,
|
|
/// Number of canonical hydration retries after each confirmation.
|
|
pub post_validation_max_retries: u32,
|
|
/// First zero-based lifecycle step to execute.
|
|
pub first_step_index: u8,
|
|
/// Confirmed signature of the immediately preceding step when resuming.
|
|
pub resume_predecessor_signature: std::option::Option<kb_model::Signature>,
|
|
/// Bounded pause inserted between recovered or newly executed steps.
|
|
pub inter_step_delay_ms: u64,
|
|
}
|
|
|
|
impl crate::solana_token_lifecycle::DevnetSplTokenLifecycleRequest {
|
|
/// Creates a lifecycle request that remains non-submittable until explicitly authorized.
|
|
pub fn new(
|
|
intent_id: impl std::convert::Into<std::string::String>,
|
|
mint: kb_model::Pubkey,
|
|
source: kb_model::Pubkey,
|
|
destination: kb_model::Pubkey,
|
|
delegate: kb_model::Pubkey,
|
|
authority: kb_model::Pubkey,
|
|
) -> Self {
|
|
return Self {
|
|
intent_id: intent_id.into(),
|
|
query_role: "http_queries".to_string(),
|
|
transaction_role: "http_transactions".to_string(),
|
|
mint,
|
|
source,
|
|
destination,
|
|
delegate,
|
|
authority,
|
|
decimals: 9,
|
|
mint_amount: kb_executor_spl_token::SplTokenAmount("10".to_string()),
|
|
transfer_amount: kb_executor_spl_token::SplTokenAmount("4".to_string()),
|
|
approve_amount: kb_executor_spl_token::SplTokenAmount("2".to_string()),
|
|
submit: false,
|
|
operator_confirmed: false,
|
|
post_validation_max_retries: 10,
|
|
first_step_index: 0,
|
|
resume_predecessor_signature: std::option::Option::None,
|
|
inter_step_delay_ms: 2_000,
|
|
};
|
|
}
|
|
|
|
/// Validates lifecycle-local bounds and amount conservation.
|
|
pub fn validate(&self) -> kb_core::Result<()> {
|
|
if self.intent_id.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet SPL Token lifecycle intent id must not be empty",
|
|
));
|
|
}
|
|
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet SPL Token lifecycle endpoint roles must not be empty",
|
|
));
|
|
}
|
|
if self.post_validation_max_retries > 20 {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"lifecycle post-execution retries must not exceed 20",
|
|
));
|
|
}
|
|
if self.first_step_index > 10 {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"lifecycle first step index must be between 0 and 10",
|
|
));
|
|
}
|
|
if self.inter_step_delay_ms > 30_000 {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"lifecycle inter-step delay must not exceed 30000 milliseconds",
|
|
));
|
|
}
|
|
if (self.first_step_index == 0 && self.resume_predecessor_signature.is_some())
|
|
|| (self.first_step_index > 0 && self.resume_predecessor_signature.is_none())
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_contract_invalid",
|
|
"a resumed lifecycle requires exactly the confirmed predecessor signature",
|
|
));
|
|
}
|
|
if let std::option::Option::Some(signature) = &self.resume_predecessor_signature {
|
|
if let std::result::Result::Err(error) = kb_rpc::validate_transaction_signature_text(
|
|
signature.0.as_str(),
|
|
"lifecycle predecessor signature",
|
|
) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
}
|
|
if self.mint == self.source
|
|
|| self.mint == self.destination
|
|
|| self.source == self.destination
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_accounts_not_distinct",
|
|
"lifecycle mint, source and destination accounts must be distinct",
|
|
));
|
|
}
|
|
if self.delegate == self.authority {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_delegate_is_authority",
|
|
"lifecycle delegate must differ from the token-account authority",
|
|
));
|
|
}
|
|
let mint_amount =
|
|
match crate::solana_token_lifecycle::canonical_amount(&self.mint_amount, "mint_amount")
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let transfer_amount = match crate::solana_token_lifecycle::canonical_amount(
|
|
&self.transfer_amount,
|
|
"transfer_amount",
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let approve_amount = match crate::solana_token_lifecycle::canonical_amount(
|
|
&self.approve_amount,
|
|
"approve_amount",
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if mint_amount == 0
|
|
|| transfer_amount == 0
|
|
|| transfer_amount >= mint_amount
|
|
|| approve_amount == 0
|
|
|| approve_amount > transfer_amount
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_amounts_invalid",
|
|
"lifecycle requires mint > transfer > 0 and 0 < approval <= transfer",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
}
|
|
|
|
/// Result retained for one fully post-validated lifecycle transaction.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecycleStepSummary {
|
|
/// Zero-based position in the controlled lifecycle.
|
|
pub index: u8,
|
|
/// Stable SPL Token operation code.
|
|
pub operation_code: std::string::String,
|
|
/// Confirmed transaction signature.
|
|
pub signature: kb_model::Signature,
|
|
/// Terminal confirmation status.
|
|
pub confirmation_status: kb_execution_api::ExecutionConfirmationStatus,
|
|
/// Number of exact SPL Token materializations returned for the signature.
|
|
pub materialization_count: u32,
|
|
/// Whether the second replay produced no failure, refusal or new output.
|
|
pub idempotence_validated: bool,
|
|
}
|
|
|
|
/// Complete result of the eleven-step controlled lifecycle.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetSplTokenLifecycleSummary {
|
|
/// Stable lifecycle identifier.
|
|
pub intent_id: std::string::String,
|
|
/// Mint initialized and exercised by the lifecycle.
|
|
pub mint: kb_model::Pubkey,
|
|
/// Source token account closed by the lifecycle.
|
|
pub source: kb_model::Pubkey,
|
|
/// Destination token account closed by the lifecycle.
|
|
pub destination: kb_model::Pubkey,
|
|
/// Ordered, fully validated lifecycle transactions.
|
|
pub steps: std::vec::Vec<crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary>,
|
|
}
|
|
|
|
/// Creates and verifies the three raw Token-owned accounts required by one lifecycle.
|
|
pub async fn prepare_devnet_spl_token_lifecycle_accounts<O>(
|
|
http_pool: &kb_rpc::HttpEndpointPool,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationRequest,
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationSummary>
|
|
where
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if let std::result::Result::Err(error) = request.validate() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if profile.wallet.cluster != "devnet"
|
|
|| !profile.wallet.devnet_send_enabled
|
|
|| !profile.execution.require_simulation
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"lifecycle account preparation requires a simulation-enabled Devnet send profile",
|
|
));
|
|
}
|
|
if profile.execution.require_operator_confirmation && !request.operator_confirmed {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"lifecycle account preparation requires explicit operator confirmation",
|
|
));
|
|
}
|
|
let genesis = match http_pool.get_genesis_hash_for_role(request.query_role.as_str()).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if genesis.classified_cluster
|
|
!= std::option::Option::Some(kb_execution_api::ExecutionCluster::Devnet)
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_cluster_mismatch",
|
|
"lifecycle account preparation endpoint is not Devnet",
|
|
));
|
|
}
|
|
let payer = match crate::solana_execution::load_profile_wallet(profile, workspace_root).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let authority = kb_model::Pubkey(payer.public_key());
|
|
let mint_wallet = match crate::solana_token_lifecycle::generated_wallet("token-lifecycle-mint")
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let source_wallet =
|
|
match crate::solana_token_lifecycle::generated_wallet("token-lifecycle-source") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let destination_wallet =
|
|
match crate::solana_token_lifecycle::generated_wallet("token-lifecycle-destination") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let delegate_wallet =
|
|
match crate::solana_token_lifecycle::generated_wallet("token-lifecycle-delegate") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let rent_config = kb_rpc::GetMinimumBalanceForRentExemptionConfig::confirmed();
|
|
let mint_rent = match http_pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
request.query_role.as_str(),
|
|
MINT_ACCOUNT_SPACE,
|
|
&rent_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value.minimum_balance_lamports,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let token_rent = match http_pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
request.query_role.as_str(),
|
|
TOKEN_ACCOUNT_SPACE,
|
|
&rent_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value.minimum_balance_lamports,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let doubled_token_rent = token_rent.checked_mul(2);
|
|
let tripled_fee_ceiling = profile.execution.max_fee_lamports.checked_mul(3);
|
|
let required_balance = match doubled_token_rent
|
|
.and_then(|value| return mint_rent.checked_add(value))
|
|
.and_then(|value| {
|
|
return tripled_fee_ceiling.and_then(|fees| return value.checked_add(fees));
|
|
}) {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_cost_overflow",
|
|
"lifecycle preparation rent and fee ceiling overflow u64",
|
|
));
|
|
},
|
|
};
|
|
let balance = match http_pool
|
|
.get_balance_for_role(
|
|
request.query_role.as_str(),
|
|
&authority,
|
|
&kb_rpc::GetBalanceConfig::confirmed(),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value.lamports,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if balance < required_balance {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_balance_insufficient",
|
|
format!(
|
|
"lifecycle preparation requires at most {required_balance} lamports but wallet has {balance}"
|
|
),
|
|
));
|
|
}
|
|
let specifications = [
|
|
("mint", &mint_wallet, MINT_ACCOUNT_SPACE, mint_rent),
|
|
("source", &source_wallet, TOKEN_ACCOUNT_SPACE, token_rent),
|
|
("destination", &destination_wallet, TOKEN_ACCOUNT_SPACE, token_rent),
|
|
];
|
|
let mut steps = std::vec::Vec::with_capacity(specifications.len());
|
|
for (account_role, wallet, space, rent_lamports) in specifications {
|
|
let step = match crate::solana_token_lifecycle::create_raw_token_account(
|
|
http_pool,
|
|
profile,
|
|
request,
|
|
observer,
|
|
&payer,
|
|
wallet,
|
|
account_role,
|
|
space,
|
|
rent_lamports,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
steps.push(step);
|
|
}
|
|
return std::result::Result::Ok(
|
|
crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationSummary {
|
|
authority,
|
|
mint: kb_model::Pubkey(mint_wallet.public_key()),
|
|
source: kb_model::Pubkey(source_wallet.public_key()),
|
|
destination: kb_model::Pubkey(destination_wallet.public_key()),
|
|
delegate: kb_model::Pubkey(delegate_wallet.public_key()),
|
|
steps,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Executes initialize, mint, transfer, approve/revoke, burn and close on Devnet.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn execute_devnet_spl_token_lifecycle<S, O>(
|
|
http_pool: &kb_rpc::HttpEndpointPool,
|
|
store: &S,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::solana_token_lifecycle::DevnetSplTokenLifecycleRequest,
|
|
decoders: &[std::sync::Arc<dyn kb_decoder_api::InstructionDecoder>],
|
|
materializers: &[std::sync::Arc<dyn kb_materializer_api::EventMaterializer>],
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::solana_token_lifecycle::DevnetSplTokenLifecycleSummary>
|
|
where
|
|
S: kb_store_core::RawTransactionStore
|
|
+ kb_store_core::CoreExtractionStore
|
|
+ kb_store_core::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if let std::result::Result::Err(error) = request.validate() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if !request.submit || !request.operator_confirmed {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_authorization_required",
|
|
"the destructive lifecycle requires submit=true and explicit operator confirmation",
|
|
));
|
|
}
|
|
let profile_wallet =
|
|
match crate::solana_execution::load_profile_wallet(profile, workspace_root).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if profile_wallet.public_key() != request.authority.0 {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_authority_wallet_mismatch",
|
|
"lifecycle authority must equal the selected profile wallet before the first mutation",
|
|
));
|
|
}
|
|
let operations = match crate::solana_token_lifecycle::lifecycle_operations(request) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut steps = std::vec::Vec::with_capacity(operations.len());
|
|
if request.first_step_index > 0 {
|
|
let predecessor_index = usize::from(request.first_step_index.saturating_sub(1));
|
|
let predecessor_operation = match operations.get(predecessor_index) {
|
|
std::option::Option::Some(value) => value.clone(),
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_index_missing",
|
|
"lifecycle predecessor operation is unavailable",
|
|
));
|
|
},
|
|
};
|
|
let predecessor_signature = match request.resume_predecessor_signature.as_ref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_signature_missing",
|
|
"lifecycle predecessor signature is required for recovery",
|
|
));
|
|
},
|
|
};
|
|
let recovered = match crate::solana_token_lifecycle::recover_predecessor_step(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
request,
|
|
predecessor_index,
|
|
predecessor_operation,
|
|
predecessor_signature,
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
crate::solana_token_lifecycle::emit_completed_step(observer, &recovered, true);
|
|
steps.push(recovered);
|
|
}
|
|
for (index, operation) in
|
|
operations.into_iter().enumerate().skip(usize::from(request.first_step_index))
|
|
{
|
|
if !steps.is_empty() && request.inter_step_delay_ms > 0 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(request.inter_step_delay_ms)).await;
|
|
}
|
|
let operation_code = operation.operation_code().to_string();
|
|
let mut step_request = crate::DevnetSplTokenExecutionRequest::new(
|
|
format!("{}-{index:02}-{}", request.intent_id, operation_code),
|
|
operation,
|
|
);
|
|
step_request.query_role = request.query_role.clone();
|
|
step_request.transaction_role = request.transaction_role.clone();
|
|
step_request.submit = true;
|
|
step_request.operator_confirmed = true;
|
|
step_request.post_validation_max_retries = request.post_validation_max_retries;
|
|
let summary = match crate::execute_devnet_spl_token(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
workspace_root,
|
|
&step_request,
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_step_failed",
|
|
format!("lifecycle step {index} {operation_code} failed: {error}"),
|
|
));
|
|
},
|
|
};
|
|
let step = match crate::solana_token_lifecycle::validated_step(
|
|
index,
|
|
operation_code.as_str(),
|
|
&summary,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
crate::solana_token_lifecycle::emit_completed_step(observer, &step, false);
|
|
steps.push(step);
|
|
}
|
|
return std::result::Result::Ok(
|
|
crate::solana_token_lifecycle::DevnetSplTokenLifecycleSummary {
|
|
intent_id: request.intent_id.clone(),
|
|
mint: request.mint.clone(),
|
|
source: request.source.clone(),
|
|
destination: request.destination.clone(),
|
|
steps,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn recover_predecessor_step<S, O>(
|
|
http_pool: &kb_rpc::HttpEndpointPool,
|
|
store: &S,
|
|
profile: &kb_config::ProfileConfig,
|
|
lifecycle_request: &crate::solana_token_lifecycle::DevnetSplTokenLifecycleRequest,
|
|
index: usize,
|
|
operation: kb_executor_spl_token::SplTokenOperation,
|
|
signature: &kb_model::Signature,
|
|
decoders: &[std::sync::Arc<dyn kb_decoder_api::InstructionDecoder>],
|
|
materializers: &[std::sync::Arc<dyn kb_materializer_api::EventMaterializer>],
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary>
|
|
where
|
|
S: kb_store_core::RawTransactionStore
|
|
+ kb_store_core::CoreExtractionStore
|
|
+ kb_store_core::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
let operation_code = operation.operation_code().to_string();
|
|
let expected_operation =
|
|
crate::solana_token_lifecycle::materialized_operation_code(&operation).to_string();
|
|
let mut request = crate::DevnetSplTokenExecutionRequest::new(
|
|
format!("{}-recover-{index:02}", lifecycle_request.intent_id),
|
|
operation,
|
|
);
|
|
request.query_role = lifecycle_request.query_role.clone();
|
|
request.transaction_role = lifecycle_request.transaction_role.clone();
|
|
request.post_validation_max_retries = lifecycle_request.post_validation_max_retries;
|
|
let backfill = match crate::solana_token_execution::hydrate_signature(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
request.query_role.as_str(),
|
|
request.post_validation_max_retries,
|
|
observer,
|
|
signature,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !crate::solana_token_execution::canonical_available(&backfill) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_canonical_missing",
|
|
"confirmed lifecycle predecessor is unavailable for canonical recovery",
|
|
));
|
|
}
|
|
let extraction = match crate::execute_core_extraction(
|
|
store,
|
|
&crate::CoreExtractionRequest {
|
|
source: crate::CoreExtractionSource::Signatures(std::vec![signature.0.clone()]),
|
|
limit: 1,
|
|
max_concurrent_extractions: 1,
|
|
force_replay: false,
|
|
},
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if extraction.failed != 0
|
|
|| extraction.cancelled
|
|
|| extraction.selected != 1
|
|
|| extraction.extracted.saturating_add(extraction.skipped) < 1
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_extraction_incomplete",
|
|
"lifecycle predecessor core extraction did not complete",
|
|
));
|
|
}
|
|
let first_replay = match crate::solana_token_execution::replay_program(
|
|
store,
|
|
signature,
|
|
false,
|
|
request.force_post_validation_replay,
|
|
&[kb_program_ids::SPL_TOKEN_PROGRAM_ID],
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !crate::solana_token_execution::decode_completed(&first_replay) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_decode_incomplete",
|
|
"lifecycle predecessor decode and materialization did not complete",
|
|
));
|
|
}
|
|
let filter = match kb_store_core::MaterializedEventFilter::new(
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(signature.0.clone()),
|
|
64,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let rows =
|
|
match kb_store_core::DecodePipelineStore::list_materialized_events(store, &filter).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let recovered_count = rows
|
|
.iter()
|
|
.filter(|row| {
|
|
return row.source_decoder_name == "spl_token"
|
|
&& row.payload_json.get("operation").and_then(serde_json::Value::as_str)
|
|
== std::option::Option::Some(expected_operation.as_str());
|
|
})
|
|
.count();
|
|
if recovered_count == 0 {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_materialization_missing",
|
|
format!(
|
|
"lifecycle predecessor signature did not materialize expected operation {expected_operation}"
|
|
),
|
|
));
|
|
}
|
|
let second_replay = match crate::solana_token_execution::replay_program(
|
|
store,
|
|
signature,
|
|
true,
|
|
request.force_post_validation_replay,
|
|
&[kb_program_ids::SPL_TOKEN_PROGRAM_ID],
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let idempotent = second_replay.failed_inputs == 0
|
|
&& second_replay.processors.iter().all(|processor| {
|
|
return processor.failed == 0
|
|
&& processor.materialized_outputs == 0
|
|
&& processor.materialization_refused == 0;
|
|
});
|
|
if !idempotent {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_resume_not_idempotent",
|
|
"lifecycle predecessor second replay was not idempotent",
|
|
));
|
|
}
|
|
let materialization_count = match u32::try_from(recovered_count) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_materializations_overflow",
|
|
"recovered lifecycle materialization count exceeds u32",
|
|
));
|
|
},
|
|
};
|
|
let step_index = match u8::try_from(index) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_step_index_overflow",
|
|
"recovered lifecycle step index exceeds u8",
|
|
));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(
|
|
crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary {
|
|
index: step_index,
|
|
operation_code,
|
|
signature: signature.clone(),
|
|
confirmation_status: kb_execution_api::ExecutionConfirmationStatus::Confirmed,
|
|
materialization_count,
|
|
idempotence_validated: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn materialized_operation_code(operation: &kb_executor_spl_token::SplTokenOperation) -> &str {
|
|
return match operation {
|
|
kb_executor_spl_token::SplTokenOperation::Instruction {
|
|
value: kb_executor_spl_token::SplTokenSingleOperation::InitializeMint { .. },
|
|
} => "initialize_mint2",
|
|
kb_executor_spl_token::SplTokenOperation::Instruction {
|
|
value: kb_executor_spl_token::SplTokenSingleOperation::InitializeAccount { .. },
|
|
} => "initialize_account3",
|
|
_ => match operation.operation_code().strip_prefix("spl_token.") {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => operation.operation_code(),
|
|
},
|
|
};
|
|
}
|
|
|
|
fn emit_completed_step<O>(
|
|
observer: &O,
|
|
step: &crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary,
|
|
recovered: bool,
|
|
) where
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
crate::solana_execution::emit(
|
|
observer,
|
|
crate::SolanaExecutionProgressLevel::Info,
|
|
"spl_token_lifecycle_step",
|
|
format!(
|
|
"lifecycle step {} {} {}",
|
|
step.index,
|
|
step.operation_code,
|
|
if recovered { "recovered" } else { "completed" }
|
|
),
|
|
std::option::Option::Some(step.signature.0.clone()),
|
|
);
|
|
}
|
|
|
|
fn generated_wallet(alias: &str) -> kb_core::Result<kb_wallet::TemporaryWallet> {
|
|
let alias = match kb_wallet::WalletAlias::parse(alias) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(kb_wallet::TemporaryWallet::generate(alias));
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn create_raw_token_account<O>(
|
|
http_pool: &kb_rpc::HttpEndpointPool,
|
|
profile: &kb_config::ProfileConfig,
|
|
request: &crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationRequest,
|
|
observer: &O,
|
|
payer: &kb_wallet::TemporaryWallet,
|
|
account_wallet: &kb_wallet::TemporaryWallet,
|
|
account_role: &str,
|
|
space: u64,
|
|
rent_lamports: u64,
|
|
) -> kb_core::Result<crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationStep>
|
|
where
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if let std::result::Result::Err(error) =
|
|
crate::solana_execution::ensure_not_cancelled(observer, "token_lifecycle_prepare")
|
|
{
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let account = kb_model::Pubkey(account_wallet.public_key());
|
|
let preflight = match http_pool
|
|
.get_account_info_for_role(
|
|
request.query_role.as_str(),
|
|
&account,
|
|
&kb_rpc::GetAccountInfoConfig::confirmed(),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if preflight.account.is_some() {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_account_exists",
|
|
format!("generated lifecycle {account_role} account already exists"),
|
|
));
|
|
}
|
|
let payer_pubkey = kb_model::Pubkey(payer.public_key());
|
|
let plan = match crate::solana_token_lifecycle::build_account_creation_plan(
|
|
profile,
|
|
format!("{}-{account_role}", request.intent_id),
|
|
payer_pubkey,
|
|
account.clone(),
|
|
space,
|
|
rent_lamports,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let plan_evaluation =
|
|
match kb_execution_safety::ExecutionSafetyChecker.evaluate_prepared_plan(&plan) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if plan_evaluation.decision == kb_execution_safety::ExecutionSafetyDecision::Deny {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_plan_denied",
|
|
crate::solana_execution::violation_message(plan_evaluation.violations.as_slice()),
|
|
));
|
|
}
|
|
let latest_blockhash = match http_pool
|
|
.get_latest_blockhash_for_role(
|
|
request.query_role.as_str(),
|
|
&kb_rpc::GetLatestBlockhashConfig::confirmed(),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let unsigned = match kb_execution_solana::build_legacy_transaction(
|
|
&plan,
|
|
latest_blockhash.blockhash.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let fee = match http_pool
|
|
.get_fee_for_message_for_role(
|
|
request.query_role.as_str(),
|
|
unsigned.message_base64().as_str(),
|
|
&kb_rpc::GetFeeForMessageConfig::new(
|
|
kb_rpc::RpcCommitmentLevel::Confirmed,
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if fee.fee_lamports.is_none() {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_fee_unavailable",
|
|
"lifecycle account-creation fee is unavailable",
|
|
));
|
|
}
|
|
let unsigned_base64 = match unsigned.transaction_base64() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let simulation_config = match kb_rpc::SimulateTransactionConfig::new(
|
|
kb_rpc::RpcCommitmentLevel::Confirmed,
|
|
false,
|
|
false,
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
true,
|
|
std::option::Option::None,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let simulation_rpc = match http_pool
|
|
.simulate_transaction_for_role(
|
|
request.transaction_role.as_str(),
|
|
unsigned_base64.as_str(),
|
|
&simulation_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let simulation = simulation_rpc.to_execution_result(
|
|
kb_execution_api::ExecutionCluster::Devnet,
|
|
kb_execution_api::ExecutionBlockhashKind::Latest,
|
|
std::option::Option::Some(
|
|
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
|
),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(&fee),
|
|
);
|
|
let send_evaluation =
|
|
match kb_execution_safety::ExecutionSafetyChecker.evaluate_send(&plan, &simulation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if send_evaluation.decision == kb_execution_safety::ExecutionSafetyDecision::Deny {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_send_denied",
|
|
crate::solana_execution::violation_message(send_evaluation.violations.as_slice()),
|
|
));
|
|
}
|
|
let evidence = unsigned.bind_simulation(simulation);
|
|
let signed = match unsigned
|
|
.sign_after_simulation(&evidence, &[payer.as_signer(), account_wallet.as_signer()])
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let signature = signed.primary_signature().clone();
|
|
let send_config = match kb_rpc::SendTransactionConfig::from_execution_config(
|
|
&profile.execution,
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::result::Result::Err(error) = http_pool
|
|
.send_transaction_for_role(
|
|
request.transaction_role.as_str(),
|
|
signed.transaction_base64().as_str(),
|
|
&signature,
|
|
&send_config,
|
|
)
|
|
.await
|
|
{
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let confirmation_config = match kb_rpc::ConfirmTransactionConfig::from_execution_config(
|
|
&profile.execution,
|
|
std::option::Option::Some(latest_blockhash.last_valid_block_height),
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let confirmation = match http_pool
|
|
.confirm_transaction_for_roles(
|
|
request.transaction_role.as_str(),
|
|
request.query_role.as_str(),
|
|
kb_execution_api::ExecutionCluster::Devnet,
|
|
&signature,
|
|
&confirmation_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !matches!(
|
|
confirmation.status,
|
|
kb_execution_api::ExecutionConfirmationStatus::Confirmed
|
|
| kb_execution_api::ExecutionConfirmationStatus::Finalized
|
|
) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_unconfirmed",
|
|
format!("lifecycle {account_role} account creation was not confirmed"),
|
|
));
|
|
}
|
|
let data_limit = match usize::try_from(space) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_space_overflow",
|
|
"lifecycle account space exceeds usize",
|
|
));
|
|
},
|
|
};
|
|
let account_config = match kb_rpc::GetAccountInfoConfig::confirmed_with_data(data_limit) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let account_info = match http_pool
|
|
.get_account_info_for_role(request.query_role.as_str(), &account, &account_config)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let value = match account_info.account {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_account_missing",
|
|
format!("confirmed lifecycle {account_role} account is unavailable"),
|
|
));
|
|
},
|
|
};
|
|
if value.owner.0 != kb_program_ids::SPL_TOKEN_PROGRAM_ID
|
|
|| value.executable
|
|
|| value.space != space
|
|
|| value.data.len() != data_limit
|
|
|| value.data.iter().any(|byte| return *byte != 0)
|
|
|| value.lamports < rent_lamports
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_preparation_account_invalid",
|
|
format!("prepared lifecycle {account_role} account failed exact state validation"),
|
|
));
|
|
}
|
|
return std::result::Result::Ok(
|
|
crate::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationStep {
|
|
account_role: account_role.to_string(),
|
|
account,
|
|
space,
|
|
rent_lamports,
|
|
signature,
|
|
confirmation_status: confirmation.status,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn build_account_creation_plan(
|
|
profile: &kb_config::ProfileConfig,
|
|
intent_id: std::string::String,
|
|
payer: kb_model::Pubkey,
|
|
new_account: kb_model::Pubkey,
|
|
space: u64,
|
|
rent_lamports: u64,
|
|
) -> kb_core::Result<kb_execution_api::PreparedExecutionPlan> {
|
|
let intent = kb_executor_solana_core::SolanaCoreExecutionIntent {
|
|
intent_id,
|
|
fee_payer: payer.clone(),
|
|
policy: kb_execution_api::ExecutionPolicy {
|
|
cluster: kb_execution_api::ExecutionClusterPolicy {
|
|
expected_cluster: kb_execution_api::ExecutionCluster::Devnet,
|
|
allow_mainnet: false,
|
|
mainnet_confirmation: false,
|
|
},
|
|
simulation: kb_execution_api::ExecutionSimulationPolicy::Required,
|
|
blockhash: kb_execution_api::ExecutionBlockhashPolicy {
|
|
kind: kb_execution_api::ExecutionBlockhashKind::Latest,
|
|
max_age_slots: std::option::Option::Some(
|
|
profile.execution.recent_blockhash_max_age_slots,
|
|
),
|
|
nonce_account: std::option::Option::None,
|
|
nonce_authority: std::option::Option::None,
|
|
},
|
|
cost_limit: kb_execution_api::ExecutionCostLimit {
|
|
max_spend_lamports: std::option::Option::Some(
|
|
profile.execution.devnet_max_spend_lamports,
|
|
),
|
|
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
|
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
|
profile.execution.max_compute_unit_price_micro_lamports,
|
|
),
|
|
},
|
|
authorized_signers: std::vec![payer.clone(), new_account.clone()],
|
|
dry_run: false,
|
|
post_execution_validation: kb_execution_api::PostExecutionValidationPolicy {
|
|
canonical_insert_required: false,
|
|
core_extraction_required: false,
|
|
decode_replay_required: false,
|
|
materialization_required: false,
|
|
},
|
|
},
|
|
operation: kb_executor_solana_core::SolanaCoreOperation::SystemCreateAccount {
|
|
from: payer,
|
|
new_account,
|
|
lamports: rent_lamports,
|
|
space,
|
|
owner: kb_model::Pubkey(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()),
|
|
},
|
|
};
|
|
return kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
|
&kb_executor_solana_core::SolanaCoreExecutor,
|
|
&intent,
|
|
);
|
|
}
|
|
|
|
fn canonical_amount(
|
|
amount: &kb_executor_spl_token::SplTokenAmount,
|
|
field: &str,
|
|
) -> kb_core::Result<u64> {
|
|
return match amount.0.parse::<u64>() {
|
|
std::result::Result::Ok(value) if value.to_string() == amount.0 => {
|
|
std::result::Result::Ok(value)
|
|
},
|
|
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
|
|
std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_amount_invalid",
|
|
format!("{field} must be a canonical u64 decimal string"),
|
|
))
|
|
},
|
|
};
|
|
}
|
|
|
|
fn lifecycle_operations(
|
|
request: &crate::solana_token_lifecycle::DevnetSplTokenLifecycleRequest,
|
|
) -> kb_core::Result<std::vec::Vec<kb_executor_spl_token::SplTokenOperation>> {
|
|
let mint_amount = match crate::solana_token_lifecycle::canonical_amount(
|
|
&request.mint_amount,
|
|
"mint_amount",
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let transfer_amount = match crate::solana_token_lifecycle::canonical_amount(
|
|
&request.transfer_amount,
|
|
"transfer_amount",
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let authority = kb_executor_spl_token::SplTokenAuthority {
|
|
authority: request.authority.clone(),
|
|
multisig_signers: std::vec::Vec::new(),
|
|
};
|
|
let source_burn = mint_amount.saturating_sub(transfer_amount).to_string();
|
|
let operations = std::vec![
|
|
kb_executor_spl_token::SplTokenSingleOperation::InitializeMint {
|
|
mint: request.mint.clone(),
|
|
mint_authority: request.authority.clone(),
|
|
freeze_authority: std::option::Option::Some(request.authority.clone()),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::InitializeAccount {
|
|
account: request.source.clone(),
|
|
mint: request.mint.clone(),
|
|
owner: request.authority.clone(),
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::InitializeAccount {
|
|
account: request.destination.clone(),
|
|
mint: request.mint.clone(),
|
|
owner: request.authority.clone(),
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::MintToChecked {
|
|
mint: request.mint.clone(),
|
|
destination: request.source.clone(),
|
|
authority: authority.clone(),
|
|
amount: request.mint_amount.clone(),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::TransferChecked {
|
|
source: request.source.clone(),
|
|
mint: request.mint.clone(),
|
|
destination: request.destination.clone(),
|
|
authority: authority.clone(),
|
|
amount: request.transfer_amount.clone(),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::ApproveChecked {
|
|
source: request.destination.clone(),
|
|
mint: request.mint.clone(),
|
|
delegate: request.delegate.clone(),
|
|
authority: authority.clone(),
|
|
amount: request.approve_amount.clone(),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::Revoke {
|
|
source: request.destination.clone(),
|
|
authority: authority.clone(),
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::BurnChecked {
|
|
source: request.source.clone(),
|
|
mint: request.mint.clone(),
|
|
authority: authority.clone(),
|
|
amount: kb_executor_spl_token::SplTokenAmount(source_burn),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::BurnChecked {
|
|
source: request.destination.clone(),
|
|
mint: request.mint.clone(),
|
|
authority: authority.clone(),
|
|
amount: request.transfer_amount.clone(),
|
|
decimals: request.decimals,
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::CloseAccount {
|
|
account: request.source.clone(),
|
|
destination: request.authority.clone(),
|
|
authority: authority.clone(),
|
|
},
|
|
kb_executor_spl_token::SplTokenSingleOperation::CloseAccount {
|
|
account: request.destination.clone(),
|
|
destination: request.authority.clone(),
|
|
authority,
|
|
},
|
|
];
|
|
return std::result::Result::Ok(
|
|
operations
|
|
.into_iter()
|
|
.map(|value| return kb_executor_spl_token::SplTokenOperation::Instruction { value })
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
fn validated_step(
|
|
index: usize,
|
|
operation_code: &str,
|
|
summary: &crate::DevnetSplTokenExecutionSummary,
|
|
) -> kb_core::Result<crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary> {
|
|
let send = match summary.send_result.as_ref() {
|
|
std::option::Option::Some(value) if value.submitted => value,
|
|
std::option::Option::Some(_) | std::option::Option::None => {
|
|
return std::result::Result::Err(crate::solana_token_lifecycle::incomplete_step(
|
|
index,
|
|
operation_code,
|
|
"submission",
|
|
));
|
|
},
|
|
};
|
|
let confirmation = match summary.confirmation.as_ref() {
|
|
std::option::Option::Some(value)
|
|
if matches!(
|
|
value.status,
|
|
kb_execution_api::ExecutionConfirmationStatus::Confirmed
|
|
| kb_execution_api::ExecutionConfirmationStatus::Finalized
|
|
) =>
|
|
{
|
|
value
|
|
},
|
|
std::option::Option::Some(_) | std::option::Option::None => {
|
|
return std::result::Result::Err(crate::solana_token_lifecycle::incomplete_step(
|
|
index,
|
|
operation_code,
|
|
"confirmation",
|
|
));
|
|
},
|
|
};
|
|
match summary.post_execution.as_ref() {
|
|
std::option::Option::Some(value)
|
|
if value.canonical_inserted
|
|
&& value.core_extracted
|
|
&& value.decode_replayed
|
|
&& value.materialized => {},
|
|
std::option::Option::Some(_) | std::option::Option::None => {
|
|
return std::result::Result::Err(crate::solana_token_lifecycle::incomplete_step(
|
|
index,
|
|
operation_code,
|
|
"post_validation",
|
|
));
|
|
},
|
|
};
|
|
let idempotence = summary.idempotence_replay.as_ref().is_some_and(|value| {
|
|
return value.failed_inputs == 0
|
|
&& value.processors.iter().all(|processor| {
|
|
return processor.failed == 0
|
|
&& processor.materialized_outputs == 0
|
|
&& processor.materialization_refused == 0;
|
|
});
|
|
});
|
|
if !idempotence || summary.materializations.is_empty() {
|
|
return std::result::Result::Err(crate::solana_token_lifecycle::incomplete_step(
|
|
index,
|
|
operation_code,
|
|
"materialization_idempotence",
|
|
));
|
|
}
|
|
let materialization_count = match u32::try_from(summary.materializations.len()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_materializations_overflow",
|
|
"lifecycle materialization count exceeds u32",
|
|
));
|
|
},
|
|
};
|
|
let step_index = match u8::try_from(index) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_step_index_overflow",
|
|
"lifecycle step index exceeds u8",
|
|
));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(
|
|
crate::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary {
|
|
index: step_index,
|
|
operation_code: operation_code.to_string(),
|
|
signature: send.signature.clone(),
|
|
confirmation_status: confirmation.status,
|
|
materialization_count,
|
|
idempotence_validated: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn incomplete_step(index: usize, operation_code: &str, stage: &str) -> kb_core::Error {
|
|
return kb_core::Error::new(
|
|
"execution_spl_token_lifecycle_post_validation_incomplete",
|
|
format!("lifecycle step {index} {operation_code} did not validate {stage}"),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
struct LifecycleTestObserver;
|
|
|
|
impl crate::BackfillObserver for LifecycleTestObserver {
|
|
fn on_progress(&self, _event: &crate::BackfillProgressEvent) {
|
|
return;
|
|
}
|
|
|
|
fn is_cancelled(&self) -> bool {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
impl crate::CoreExtractionObserver for LifecycleTestObserver {
|
|
fn on_progress(&self, _event: &crate::CoreExtractionProgressEvent) {
|
|
return;
|
|
}
|
|
|
|
fn is_cancelled(&self) -> bool {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
impl crate::DecodeReplayObserver for LifecycleTestObserver {
|
|
fn on_progress(&self, _event: &crate::DecodeReplayProgressEvent) {
|
|
return;
|
|
}
|
|
|
|
fn is_cancelled(&self) -> bool {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
impl crate::SolanaExecutionObserver for LifecycleTestObserver {
|
|
fn on_execution_progress(&self, event: &crate::SolanaExecutionProgressEvent) {
|
|
if event.stage == "spl_token_lifecycle_step" {
|
|
let signature = match event.signature.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => "missing",
|
|
};
|
|
println!("SPL Token lifecycle progress={} signature={}", event.message, signature,);
|
|
}
|
|
return;
|
|
}
|
|
|
|
fn is_execution_cancelled(&self) -> bool {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
fn key(value: &str) -> kb_model::Pubkey {
|
|
return kb_model::Pubkey(value.to_string());
|
|
}
|
|
|
|
fn request() -> crate::DevnetSplTokenLifecycleRequest {
|
|
return crate::DevnetSplTokenLifecycleRequest::new(
|
|
"lifecycle-1",
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::STAKE_PROGRAM_ID),
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::VOTE_PROGRAM_ID),
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::CONFIG_PROGRAM_ID),
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID),
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::SYSTEM_PROGRAM_ID),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conservative_request_requires_explicit_destructive_authorization() {
|
|
let request = crate::solana_token_lifecycle::tests::request();
|
|
assert!(!request.submit);
|
|
assert!(!request.operator_confirmed);
|
|
assert!(request.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_operations_preserve_order_and_conserve_raw_amounts() {
|
|
let request = crate::solana_token_lifecycle::tests::request();
|
|
let operations = match super::lifecycle_operations(&request) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("lifecycle construction failed: {error}"),
|
|
};
|
|
let codes = operations
|
|
.iter()
|
|
.map(|value| return value.operation_code())
|
|
.collect::<std::vec::Vec<_>>();
|
|
assert_eq!(
|
|
codes,
|
|
std::vec![
|
|
"spl_token.initialize_mint",
|
|
"spl_token.initialize_account",
|
|
"spl_token.initialize_account",
|
|
"spl_token.mint_to_checked",
|
|
"spl_token.transfer_checked",
|
|
"spl_token.approve_checked",
|
|
"spl_token.revoke",
|
|
"spl_token.burn_checked",
|
|
"spl_token.burn_checked",
|
|
"spl_token.close_account",
|
|
"spl_token.close_account",
|
|
],
|
|
);
|
|
let source_burn = match &operations[7] {
|
|
kb_executor_spl_token::SplTokenOperation::Instruction {
|
|
value: kb_executor_spl_token::SplTokenSingleOperation::BurnChecked { amount, .. },
|
|
} => amount.0.as_str(),
|
|
_ => panic!("source burn step missing"),
|
|
};
|
|
assert_eq!(source_burn, "6");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_amounts_aliases_and_delegate_fail_closed() {
|
|
let mut request = crate::solana_token_lifecycle::tests::request();
|
|
request.transfer_amount = kb_executor_spl_token::SplTokenAmount("10".to_string());
|
|
assert!(request.validate().is_err());
|
|
request.transfer_amount = kb_executor_spl_token::SplTokenAmount("04".to_string());
|
|
assert!(request.validate().is_err());
|
|
request.transfer_amount = kb_executor_spl_token::SplTokenAmount("4".to_string());
|
|
request.delegate = request.authority.clone();
|
|
assert!(request.validate().is_err());
|
|
request.delegate =
|
|
crate::solana_token_lifecycle::tests::key(kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID);
|
|
request.destination = request.source.clone();
|
|
assert!(request.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn account_preparation_requires_explicit_spend_authorization() {
|
|
let mut request = crate::DevnetSplTokenLifecyclePreparationRequest::new("prepare-1");
|
|
assert!(request.validate().is_err());
|
|
request.submit = true;
|
|
assert!(request.validate().is_err());
|
|
request.operator_confirmed = true;
|
|
assert!(request.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn account_creation_plan_uses_exact_token_owner_space_spend_and_signers() {
|
|
let profile = crate::solana_token_lifecycle::tests::local_devnet_profile();
|
|
let payer = crate::solana_token_lifecycle::tests::key(kb_program_ids::SYSTEM_PROGRAM_ID);
|
|
let account = crate::solana_token_lifecycle::tests::key(kb_program_ids::STAKE_PROGRAM_ID);
|
|
let plan = match super::build_account_creation_plan(
|
|
&profile,
|
|
"prepare-mint".to_string(),
|
|
payer.clone(),
|
|
account.clone(),
|
|
super::MINT_ACCOUNT_SPACE,
|
|
1_461_600,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("account creation plan failed: {error}"),
|
|
};
|
|
assert_eq!(plan.operation_code, "solana_core.system.create_account");
|
|
assert_eq!(plan.requested_spend_lamports, 1_461_600);
|
|
assert_eq!(plan.instructions.len(), 1);
|
|
assert_eq!(plan.instructions[0].accounts.len(), 2);
|
|
assert_eq!(plan.instructions[0].accounts[0].pubkey, payer);
|
|
assert_eq!(plan.instructions[0].accounts[1].pubkey, account);
|
|
assert_eq!(plan.required_signers.len(), 2);
|
|
assert_eq!(plan.policy.authorized_signers.len(), 2);
|
|
assert!(!plan.policy.dry_run);
|
|
}
|
|
|
|
#[test]
|
|
fn resume_requires_exact_predecessor_signature_and_bounded_delay() {
|
|
let mut request = crate::solana_token_lifecycle::tests::request();
|
|
request.first_step_index = 3;
|
|
assert!(request.validate().is_err());
|
|
request.resume_predecessor_signature = std::option::Option::Some(kb_model::Signature(
|
|
"3yxsCf8tc1H2P9E3m6Kgu7J3XVeBw7wePuRTHvCaidQ74bsa2TP5xg4hpXAw1jkRSB3LrkLrXgx5eqvCQqRsXcfq".to_string(),
|
|
));
|
|
assert!(request.validate().is_ok());
|
|
request.inter_step_delay_ms = 30_001;
|
|
assert!(request.validate().is_err());
|
|
request.inter_step_delay_ms = 2_000;
|
|
request.first_step_index = 0;
|
|
assert!(request.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_uses_current_initialization_wire_names() {
|
|
let request = crate::solana_token_lifecycle::tests::request();
|
|
let operations = match super::lifecycle_operations(&request) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("lifecycle construction failed: {error}"),
|
|
};
|
|
assert_eq!(super::materialized_operation_code(&operations[0]), "initialize_mint2");
|
|
assert_eq!(super::materialized_operation_code(&operations[2]), "initialize_account3");
|
|
assert_eq!(super::materialized_operation_code(&operations[3]), "mint_to_checked");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn optional_devnet_controlled_lifecycle_from_env() {
|
|
if std::env::var("KB_DEVNET_SPL_TOKEN_LIFECYCLE_TEST").ok().as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
return;
|
|
}
|
|
if std::env::var("KB_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT").ok().as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
panic!("controlled lifecycle requires KB_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT=1");
|
|
}
|
|
let mut profile = crate::solana_token_lifecycle::tests::local_devnet_profile();
|
|
if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") {
|
|
profile.wallet.wallet_dir = directory;
|
|
}
|
|
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_POSTGRES_TEST_URL is required for lifecycle submission: {error}");
|
|
},
|
|
};
|
|
profile.database.backend = "postgres".to_string();
|
|
profile.database.postgres.url = database_url;
|
|
let pool = match kb_rpc::HttpEndpointPool::from_profile(&profile) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"),
|
|
};
|
|
let store = match kb_store_pg::PostgresStore::connect_from_profile_config(&profile).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("PostgreSQL connection failed: {error}"),
|
|
};
|
|
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
|
panic!("PostgreSQL schema initialization failed: {error}");
|
|
}
|
|
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
|
};
|
|
let observer = crate::solana_token_lifecycle::tests::LifecycleTestObserver;
|
|
let prepared_accounts = if std::env::var("KB_DEVNET_SPL_TOKEN_LIFECYCLE_PREPARE")
|
|
.ok()
|
|
.as_deref()
|
|
== std::option::Option::Some("1")
|
|
{
|
|
let mut preparation = crate::DevnetSplTokenLifecyclePreparationRequest::new(format!(
|
|
"devnet-token-lifecycle-prepare-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
preparation.submit = true;
|
|
preparation.operator_confirmed = true;
|
|
let summary = match crate::prepare_devnet_spl_token_lifecycle_accounts(
|
|
&pool,
|
|
&profile,
|
|
workspace_root,
|
|
&preparation,
|
|
&observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("Devnet Token lifecycle account preparation failed: {error}");
|
|
},
|
|
};
|
|
for step in &summary.steps {
|
|
println!(
|
|
"SPL Token lifecycle preparation role={} account={} space={} rent={} signature={} status={:?}",
|
|
step.account_role,
|
|
step.account.0,
|
|
step.space,
|
|
step.rent_lamports,
|
|
step.signature.0,
|
|
step.confirmation_status,
|
|
);
|
|
}
|
|
std::option::Option::Some(summary)
|
|
} else {
|
|
std::option::Option::None
|
|
};
|
|
if prepared_accounts.is_some() {
|
|
tokio::time::sleep(std::time::Duration::from_millis(
|
|
crate::solana_token_lifecycle::tests::optional_u64_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_PREPARATION_SETTLE_DELAY_MS",
|
|
10_000,
|
|
),
|
|
))
|
|
.await;
|
|
}
|
|
let (mint, source, destination, delegate, authority) = match prepared_accounts.as_ref() {
|
|
std::option::Option::Some(value) => (
|
|
value.mint.clone(),
|
|
value.source.clone(),
|
|
value.destination.clone(),
|
|
value.delegate.clone(),
|
|
value.authority.clone(),
|
|
),
|
|
std::option::Option::None => (
|
|
crate::solana_token_lifecycle::tests::required_pubkey_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_MINT",
|
|
),
|
|
crate::solana_token_lifecycle::tests::required_pubkey_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_SOURCE",
|
|
),
|
|
crate::solana_token_lifecycle::tests::required_pubkey_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_DESTINATION",
|
|
),
|
|
crate::solana_token_lifecycle::tests::required_pubkey_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_DELEGATE",
|
|
),
|
|
crate::solana_token_lifecycle::tests::required_pubkey_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_AUTHORITY",
|
|
),
|
|
),
|
|
};
|
|
let mut request = crate::DevnetSplTokenLifecycleRequest::new(
|
|
format!("devnet-token-lifecycle-{}", uuid::Uuid::new_v4()),
|
|
mint,
|
|
source,
|
|
destination,
|
|
delegate,
|
|
authority,
|
|
);
|
|
request.decimals = crate::solana_token_lifecycle::tests::optional_u8_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_DECIMALS",
|
|
9,
|
|
);
|
|
request.mint_amount = kb_executor_spl_token::SplTokenAmount(
|
|
crate::solana_token_lifecycle::tests::optional_string_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_MINT_AMOUNT",
|
|
"10",
|
|
),
|
|
);
|
|
request.transfer_amount = kb_executor_spl_token::SplTokenAmount(
|
|
crate::solana_token_lifecycle::tests::optional_string_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_TRANSFER_AMOUNT",
|
|
"4",
|
|
),
|
|
);
|
|
request.approve_amount = kb_executor_spl_token::SplTokenAmount(
|
|
crate::solana_token_lifecycle::tests::optional_string_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_APPROVE_AMOUNT",
|
|
"2",
|
|
),
|
|
);
|
|
request.submit = true;
|
|
request.operator_confirmed = true;
|
|
request.post_validation_max_retries = 20;
|
|
request.first_step_index = crate::solana_token_lifecycle::tests::optional_u8_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_FIRST_STEP_INDEX",
|
|
0,
|
|
);
|
|
request.resume_predecessor_signature = if request.first_step_index > 0 {
|
|
std::option::Option::Some(kb_model::Signature(
|
|
crate::solana_token_lifecycle::tests::required_string_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_RESUME_PREDECESSOR_SIGNATURE",
|
|
),
|
|
))
|
|
} else {
|
|
std::option::Option::None
|
|
};
|
|
request.inter_step_delay_ms = crate::solana_token_lifecycle::tests::optional_u64_env(
|
|
"KB_DEVNET_SPL_TOKEN_LIFECYCLE_INTER_STEP_DELAY_MS",
|
|
2_000,
|
|
);
|
|
let decoders: std::vec::Vec<std::sync::Arc<dyn kb_decoder_api::InstructionDecoder>> =
|
|
std::vec![std::sync::Arc::new(kb_decoder_spl_token::SplTokenDecoder)];
|
|
let materializers: std::vec::Vec<
|
|
std::sync::Arc<dyn kb_materializer_api::EventMaterializer>,
|
|
> = std::vec![std::sync::Arc::new(
|
|
kb_materializer_token_accounts::TokenAccountsMaterializer,
|
|
)];
|
|
let summary = match crate::execute_devnet_spl_token_lifecycle(
|
|
&pool,
|
|
&store,
|
|
&profile,
|
|
workspace_root,
|
|
&request,
|
|
decoders.as_slice(),
|
|
materializers.as_slice(),
|
|
&observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Devnet Token lifecycle failed: {error}"),
|
|
};
|
|
let expected_step_count = if request.first_step_index == 0 {
|
|
11
|
|
} else {
|
|
12_usize.saturating_sub(usize::from(request.first_step_index))
|
|
};
|
|
assert_eq!(summary.steps.len(), expected_step_count);
|
|
assert!(summary.steps.iter().all(|step| {
|
|
return step.idempotence_validated && step.materialization_count > 0;
|
|
}));
|
|
for step in summary.steps {
|
|
println!(
|
|
"SPL Token lifecycle step={} operation={} signature={} status={:?} materializations={} idempotent={}",
|
|
step.index,
|
|
step.operation_code,
|
|
step.signature.0,
|
|
step.confirmation_status,
|
|
step.materialization_count,
|
|
step.idempotence_validated,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn local_devnet_profile() -> kb_config::ProfileConfig {
|
|
let config =
|
|
match kb_config::parse_config_json(include_str!("../../config/example.config.json")) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
|
};
|
|
for profile in config.profiles {
|
|
if profile.name == "local_devnet" {
|
|
return profile;
|
|
}
|
|
}
|
|
panic!("local_devnet profile missing");
|
|
}
|
|
|
|
fn required_pubkey_env(name: &str) -> kb_model::Pubkey {
|
|
return match std::env::var(name) {
|
|
std::result::Result::Ok(value) => kb_model::Pubkey(value),
|
|
std::result::Result::Err(error) => panic!("{name} is required: {error}"),
|
|
};
|
|
}
|
|
|
|
fn optional_string_env(name: &str, default: &str) -> std::string::String {
|
|
return match std::env::var(name) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => default.to_string(),
|
|
};
|
|
}
|
|
|
|
fn required_string_env(name: &str) -> std::string::String {
|
|
return match std::env::var(name) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("{name} is required: {error}"),
|
|
};
|
|
}
|
|
|
|
fn optional_u64_env(name: &str, default: u64) -> u64 {
|
|
return match std::env::var(name) {
|
|
std::result::Result::Ok(value) => match value.parse::<u64>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("{name} is invalid: {error}"),
|
|
},
|
|
std::result::Result::Err(_) => default,
|
|
};
|
|
}
|
|
|
|
fn optional_u8_env(name: &str, default: u8) -> u8 {
|
|
return match std::env::var(name) {
|
|
std::result::Result::Ok(value) => match value.parse::<u8>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("{name} is invalid: {error}"),
|
|
},
|
|
std::result::Result::Err(_) => default,
|
|
};
|
|
}
|
|
}
|