0.1.0
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# file: kb_executor_spl_associated_token_account/Cargo.toml
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "kb_executor_spl_associated_token_account"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kb_core = { path = "../kb_core" }
|
||||
kb_execution_api = { path = "../kb_execution_api" }
|
||||
kb_model = { path = "../kb_model" }
|
||||
kb_program_ids = { path = "../kb_program_ids" }
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
solana-instruction.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
spl-associated-token-account-interface.workspace = true
|
||||
tracing.workspace = true
|
||||
ts-rs.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- file: kb_executor_spl_associated_token_account/README.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# kb_executor_spl_associated_token_account
|
||||
|
||||
Ce crate construit les plans typés de la surface SPL Associated Token Account courante.
|
||||
|
||||
## Surface
|
||||
|
||||
- `Create` ;
|
||||
- `CreateIdempotent` ;
|
||||
- `RecoverNested` ;
|
||||
- Token Program classique ou Token-2022 comme cible explicite de la dérivation.
|
||||
|
||||
Chaque plan conserve l’ordre et les flags des metas produits par le builder officiel de
|
||||
`spl-associated-token-account-interface 2.0.0`. Les signataires requis sont dédupliqués séparément,
|
||||
sans réordonner les comptes de l’instruction.
|
||||
|
||||
## Garde-fous
|
||||
|
||||
La simulation est obligatoire, le mode par défaut reste `dry_run`, les plafonds de rent et de frais
|
||||
sont explicites et le replay post-exécution complet est requis. Le préflight stateful Localnet/Devnet
|
||||
est orchestré par `kb_pipeline` : il contrôle cluster, mints, PDA, comptes Token existants,
|
||||
`RecoverNested`, signataires et solde couvrant le plafond rent-plus-frais.
|
||||
|
||||
Pour Token-2022, le préflight valide uniquement le préfixe commun Mint/Token Account et accepte des
|
||||
octets d’extension suffixés. Il ne décode ni instruction ni extension Token-2022 ; la simulation
|
||||
officielle reste l’autorité pour la taille de compte et le rent exacts liés aux extensions.
|
||||
|
||||
## Preuves Devnet
|
||||
|
||||
Les parcours contrôlés du 16 juillet 2026 ont validé `CreateIdempotent` pour un mint classique et
|
||||
un mint Token-2022, puis la réutilisation des deux ATA avec second replay sans doublon. Un scénario
|
||||
`RecoverNested` classique a transféré 1 000 000 000 unités brutes vers l'ATA wallet, fermé le nested
|
||||
ATA et confirmé les postconditions ainsi que deux matérialisations distinctes lifecycle/risk.
|
||||
@@ -0,0 +1,490 @@
|
||||
// file: kb_executor_spl_associated_token_account/src/builder.rs
|
||||
// version: 4
|
||||
|
||||
//! Official ATA builders and conservative simulation-first plan validation.
|
||||
|
||||
macro_rules! parse_key {
|
||||
($value:expr, $field:expr) => {
|
||||
match $value.0.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_pubkey_invalid",
|
||||
format!("invalid ATA {} public key: {error}", $field),
|
||||
));
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn build_prepared_plan(
|
||||
intent: &crate::SplAssociatedTokenAccountExecutionIntent,
|
||||
) -> kb_core::Result<kb_execution_api::PreparedExecutionPlan> {
|
||||
if intent.intent_id.trim().is_empty() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_intent_id_empty",
|
||||
"ATA execution intent id must not be empty",
|
||||
));
|
||||
}
|
||||
match crate::builder::validate_policy(intent) {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let fee_payer = parse_key!(&intent.fee_payer, "fee_payer");
|
||||
let token_program_text = intent.operation.token_program().program_id();
|
||||
let instruction = match &intent.operation {
|
||||
crate::SplAssociatedTokenAccountOperation::Create {
|
||||
wallet_owner,
|
||||
mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = parse_key!(wallet_owner, "wallet_owner");
|
||||
let mint = parse_key!(mint, "mint");
|
||||
let token_program: solana_pubkey::Pubkey = match token_program_text.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_token_program_invalid",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
spl_associated_token_account_interface::instruction::create_associated_token_account(
|
||||
&fee_payer,
|
||||
&wallet_owner,
|
||||
&mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner,
|
||||
mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = parse_key!(wallet_owner, "wallet_owner");
|
||||
let mint = parse_key!(mint, "mint");
|
||||
let token_program: solana_pubkey::Pubkey = match token_program_text.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_token_program_invalid",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
spl_associated_token_account_interface::instruction::create_associated_token_account_idempotent(
|
||||
&fee_payer,
|
||||
&wallet_owner,
|
||||
&mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
crate::SplAssociatedTokenAccountOperation::RecoverNested {
|
||||
wallet_owner,
|
||||
owner_mint,
|
||||
nested_mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = parse_key!(wallet_owner, "wallet_owner");
|
||||
let owner_mint = parse_key!(owner_mint, "owner_mint");
|
||||
let nested_mint = parse_key!(nested_mint, "nested_mint");
|
||||
let token_program: solana_pubkey::Pubkey = match token_program_text.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_token_program_invalid",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
spl_associated_token_account_interface::instruction::recover_nested(
|
||||
&wallet_owner,
|
||||
&owner_mint,
|
||||
&nested_mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
};
|
||||
let planned_instruction =
|
||||
crate::builder::planned_instruction(intent.operation.operation_code(), &instruction);
|
||||
let required_signers =
|
||||
crate::builder::required_signers(&intent.fee_payer, &planned_instruction.accounts);
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "build_prepared_plan",
|
||||
intent_id = %intent.intent_id,
|
||||
operation_code = intent.operation.operation_code(),
|
||||
token_program_id = token_program_text,
|
||||
account_occurrence_count = planned_instruction.accounts.len(),
|
||||
required_signer_count = required_signers.len(),
|
||||
max_rent_lamports = intent.max_rent_lamports,
|
||||
dry_run = intent.policy.dry_run,
|
||||
"built SPL Associated Token Account execution plan"
|
||||
);
|
||||
return std::result::Result::Ok(kb_execution_api::PreparedExecutionPlan {
|
||||
executor_name: "kb_executor_spl_associated_token_account".to_string(),
|
||||
executor_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
intent_id: intent.intent_id.clone(),
|
||||
operation_code: intent.operation.operation_code().to_string(),
|
||||
fee_payer: kb_model::Pubkey(fee_payer.to_string()),
|
||||
instructions: std::vec![planned_instruction],
|
||||
required_signers,
|
||||
policy: intent.policy.clone(),
|
||||
requested_spend_lamports: intent.max_rent_lamports,
|
||||
requested_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_policy(
|
||||
intent: &crate::SplAssociatedTokenAccountExecutionIntent,
|
||||
) -> kb_core::Result<()> {
|
||||
if intent.policy.simulation != kb_execution_api::ExecutionSimulationPolicy::Required {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_simulation_required",
|
||||
"ATA execution requires simulation before signing or sending",
|
||||
));
|
||||
}
|
||||
if intent.operation.is_creation() && intent.max_rent_lamports == 0 {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_rent_limit_missing",
|
||||
"ATA creation requires a positive rent spending ceiling",
|
||||
));
|
||||
}
|
||||
if !intent.operation.is_creation() && intent.max_rent_lamports != 0 {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_recovery_rent_must_be_zero",
|
||||
"RecoverNested must declare zero rent spending",
|
||||
));
|
||||
}
|
||||
if intent.policy.cost_limit.max_spend_lamports
|
||||
!= std::option::Option::Some(intent.max_rent_lamports)
|
||||
{
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_spend_limit_mismatch",
|
||||
"ATA policy max spend must equal the declared maximum rent lamports",
|
||||
));
|
||||
}
|
||||
match intent.policy.cost_limit.max_fee_lamports {
|
||||
std::option::Option::Some(value) if value > 0 => {},
|
||||
std::option::Option::Some(_) | std::option::Option::None => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_fee_limit_missing",
|
||||
"ATA execution requires a positive transaction fee ceiling",
|
||||
));
|
||||
},
|
||||
}
|
||||
let validation = &intent.policy.post_execution_validation;
|
||||
if !validation.canonical_insert_required
|
||||
|| !validation.core_extraction_required
|
||||
|| !validation.decode_replay_required
|
||||
|| !validation.materialization_required
|
||||
{
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_post_validation_required",
|
||||
"ATA execution requires canonical insertion, core extraction, decode replay and materialization validation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
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 required_signers(
|
||||
fee_payer: &kb_model::Pubkey,
|
||||
accounts: &[kb_execution_api::PlannedAccount],
|
||||
) -> std::vec::Vec<kb_execution_api::RequiredSigner> {
|
||||
let mut required = std::vec![kb_execution_api::RequiredSigner {
|
||||
pubkey: fee_payer.clone(),
|
||||
role: "fee_payer".to_string(),
|
||||
}];
|
||||
for account in accounts.iter().filter(|account| return account.is_signer) {
|
||||
if required.iter().any(|candidate| return candidate.pubkey == account.pubkey) {
|
||||
continue;
|
||||
}
|
||||
required.push(kb_execution_api::RequiredSigner {
|
||||
pubkey: account.pubkey.clone(),
|
||||
role: "wallet_owner".to_string(),
|
||||
});
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
fn pubkey(value: &str) -> kb_model::Pubkey {
|
||||
return kb_model::Pubkey(value.to_string());
|
||||
}
|
||||
|
||||
fn intent(
|
||||
operation: crate::SplAssociatedTokenAccountOperation,
|
||||
max_rent_lamports: u64,
|
||||
) -> crate::SplAssociatedTokenAccountExecutionIntent {
|
||||
let fee_payer = pubkey(kb_program_ids::SYSTEM_PROGRAM_ID);
|
||||
let wallet_owner = match &operation {
|
||||
crate::SplAssociatedTokenAccountOperation::Create { wallet_owner, .. }
|
||||
| crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner, ..
|
||||
}
|
||||
| crate::SplAssociatedTokenAccountOperation::RecoverNested { wallet_owner, .. } => {
|
||||
wallet_owner.clone()
|
||||
},
|
||||
};
|
||||
let mut authorized_signers = std::vec![fee_payer.clone()];
|
||||
if !authorized_signers.contains(&wallet_owner) {
|
||||
authorized_signers.push(wallet_owner);
|
||||
}
|
||||
return crate::SplAssociatedTokenAccountExecutionIntent {
|
||||
intent_id: "ata-intent-1".to_string(),
|
||||
fee_payer,
|
||||
max_rent_lamports,
|
||||
policy: kb_execution_api::ExecutionPolicy {
|
||||
simulation: kb_execution_api::ExecutionSimulationPolicy::Required,
|
||||
cost_limit: kb_execution_api::ExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(max_rent_lamports),
|
||||
max_fee_lamports: std::option::Option::Some(10_000),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
},
|
||||
authorized_signers,
|
||||
dry_run: true,
|
||||
post_execution_validation: kb_execution_api::PostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
..kb_execution_api::ExecutionPolicy::default()
|
||||
},
|
||||
operation,
|
||||
};
|
||||
}
|
||||
|
||||
fn official_instruction(
|
||||
intent: &crate::SplAssociatedTokenAccountExecutionIntent,
|
||||
) -> solana_instruction::Instruction {
|
||||
let fee_payer = intent
|
||||
.fee_payer
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid fee payer fixture: {error}"));
|
||||
let token_program = intent
|
||||
.operation
|
||||
.token_program()
|
||||
.program_id()
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid Token Program fixture: {error}"));
|
||||
return match &intent.operation {
|
||||
crate::SplAssociatedTokenAccountOperation::Create {
|
||||
wallet_owner,
|
||||
mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = wallet_owner
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid wallet fixture: {error}"));
|
||||
let mint =
|
||||
mint.0.parse().unwrap_or_else(|error| panic!("invalid mint fixture: {error}"));
|
||||
spl_associated_token_account_interface::instruction::create_associated_token_account(
|
||||
&fee_payer,
|
||||
&wallet_owner,
|
||||
&mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner,
|
||||
mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = wallet_owner
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid wallet fixture: {error}"));
|
||||
let mint =
|
||||
mint.0.parse().unwrap_or_else(|error| panic!("invalid mint fixture: {error}"));
|
||||
spl_associated_token_account_interface::instruction::create_associated_token_account_idempotent(
|
||||
&fee_payer,
|
||||
&wallet_owner,
|
||||
&mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
crate::SplAssociatedTokenAccountOperation::RecoverNested {
|
||||
wallet_owner,
|
||||
owner_mint,
|
||||
nested_mint,
|
||||
token_program: _,
|
||||
} => {
|
||||
let wallet_owner = wallet_owner
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid wallet fixture: {error}"));
|
||||
let owner_mint = owner_mint
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid owner mint fixture: {error}"));
|
||||
let nested_mint = nested_mint
|
||||
.0
|
||||
.parse()
|
||||
.unwrap_or_else(|error| panic!("invalid nested mint fixture: {error}"));
|
||||
spl_associated_token_account_interface::instruction::recover_nested(
|
||||
&wallet_owner,
|
||||
&owner_mint,
|
||||
&nested_mint,
|
||||
&token_program,
|
||||
)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn assert_matches_official(intent: &crate::SplAssociatedTokenAccountExecutionIntent) {
|
||||
let plan = kb_execution_api::TypedInstructionExecutor::build_prepared_plan(
|
||||
&crate::SplAssociatedTokenAccountExecutor,
|
||||
intent,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("ATA plan failed: {error}"));
|
||||
let official = official_instruction(intent);
|
||||
assert_eq!(plan.instructions.len(), 1);
|
||||
let actual = &plan.instructions[0];
|
||||
assert_eq!(actual.program_id.0, official.program_id.to_string());
|
||||
assert_eq!(actual.data, official.data);
|
||||
assert_eq!(actual.accounts.len(), official.accounts.len());
|
||||
for (actual_account, official_account) in
|
||||
actual.accounts.iter().zip(official.accounts.iter())
|
||||
{
|
||||
assert_eq!(actual_account.pubkey.0, official_account.pubkey.to_string());
|
||||
assert_eq!(actual_account.is_signer, official_account.is_signer);
|
||||
assert_eq!(actual_account.is_writable, official_account.is_writable);
|
||||
}
|
||||
assert_eq!(plan.requested_spend_lamports, intent.max_rent_lamports);
|
||||
assert_eq!(plan.policy.simulation, kb_execution_api::ExecutionSimulationPolicy::Required);
|
||||
assert!(plan.policy.dry_run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variant_and_token_program_matches_the_official_builder() {
|
||||
for token_program in [
|
||||
crate::SplAssociatedTokenProgram::Classic,
|
||||
crate::SplAssociatedTokenProgram::Token2022,
|
||||
] {
|
||||
for operation in [
|
||||
crate::SplAssociatedTokenAccountOperation::Create {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
token_program,
|
||||
},
|
||||
crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
token_program,
|
||||
},
|
||||
] {
|
||||
let intent = intent(operation, 2_100_000);
|
||||
assert_matches_official(&intent);
|
||||
}
|
||||
let recovery = intent(
|
||||
crate::SplAssociatedTokenAccountOperation::RecoverNested {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
owner_mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
nested_mint: pubkey(kb_program_ids::CONFIG_PROGRAM_ID),
|
||||
token_program,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_matches_official(&recovery);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_set_is_unique_without_reordering_instruction_metas() {
|
||||
let intent = intent(
|
||||
crate::SplAssociatedTokenAccountOperation::RecoverNested {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
owner_mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
nested_mint: pubkey(kb_program_ids::CONFIG_PROGRAM_ID),
|
||||
token_program: crate::SplAssociatedTokenProgram::Classic,
|
||||
},
|
||||
0,
|
||||
);
|
||||
let plan = crate::build_prepared_plan(&intent)
|
||||
.unwrap_or_else(|error| panic!("ATA recovery plan failed: {error}"));
|
||||
assert_eq!(plan.required_signers.len(), 2);
|
||||
assert_eq!(plan.required_signers[0].role, "fee_payer");
|
||||
assert_eq!(plan.required_signers[1].role, "wallet_owner");
|
||||
assert_eq!(plan.instructions[0].accounts[5].pubkey, plan.required_signers[1].pubkey);
|
||||
assert!(plan.instructions[0].accounts[5].is_signer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conservative_policy_rejects_unsafe_cost_and_replay_shapes() {
|
||||
let operation = crate::SplAssociatedTokenAccountOperation::Create {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
token_program: crate::SplAssociatedTokenProgram::Classic,
|
||||
};
|
||||
let mut intent = intent(operation, 2_100_000);
|
||||
intent.policy.simulation = kb_execution_api::ExecutionSimulationPolicy::Optional;
|
||||
let error = crate::build_prepared_plan(&intent).expect_err("optional simulation must fail");
|
||||
assert_eq!(error.code(), "execution_spl_ata_simulation_required");
|
||||
|
||||
intent.policy.simulation = kb_execution_api::ExecutionSimulationPolicy::Required;
|
||||
intent.policy.cost_limit.max_spend_lamports = std::option::Option::Some(1);
|
||||
let error =
|
||||
crate::build_prepared_plan(&intent).expect_err("mismatched spend ceiling must fail");
|
||||
assert_eq!(error.code(), "execution_spl_ata_spend_limit_mismatch");
|
||||
|
||||
intent.policy.cost_limit.max_spend_lamports = std::option::Option::Some(2_100_000);
|
||||
intent.policy.post_execution_validation.materialization_required = false;
|
||||
let error = crate::build_prepared_plan(&intent)
|
||||
.expect_err("incomplete replay validation must fail");
|
||||
assert_eq!(error.code(), "execution_spl_ata_post_validation_required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_requires_zero_rent_and_creation_requires_a_positive_ceiling() {
|
||||
let create = intent(
|
||||
crate::SplAssociatedTokenAccountOperation::Create {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
token_program: crate::SplAssociatedTokenProgram::Classic,
|
||||
},
|
||||
0,
|
||||
);
|
||||
let error =
|
||||
crate::build_prepared_plan(&create).expect_err("zero creation ceiling must fail");
|
||||
assert_eq!(error.code(), "execution_spl_ata_rent_limit_missing");
|
||||
|
||||
let recovery = intent(
|
||||
crate::SplAssociatedTokenAccountOperation::RecoverNested {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
owner_mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
nested_mint: pubkey(kb_program_ids::CONFIG_PROGRAM_ID),
|
||||
token_program: crate::SplAssociatedTokenProgram::Token2022,
|
||||
},
|
||||
1,
|
||||
);
|
||||
let error =
|
||||
crate::build_prepared_plan(&recovery).expect_err("recovery rent spending must fail");
|
||||
assert_eq!(error.code(), "execution_spl_ata_recovery_rent_must_be_zero");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// file: kb_executor_spl_associated_token_account/src/constants.rs
|
||||
// version: 3
|
||||
|
||||
//! Local constants for the `kb_executor_spl_associated_token_account` crate. Program identifiers live in `kb_program_ids`.
|
||||
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "kb_executor_spl_associated_token_account";
|
||||
@@ -0,0 +1,284 @@
|
||||
// file: kb_executor_spl_associated_token_account/src/executor.rs
|
||||
// version: 4
|
||||
|
||||
//! Exact ATA capability dispatch and typed plan construction.
|
||||
|
||||
/// SPL Associated Token Account executor implementation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SplAssociatedTokenAccountExecutor;
|
||||
|
||||
impl crate::SplAssociatedTokenAccountExecutor {
|
||||
fn exact_capability(
|
||||
&self,
|
||||
program_id: &kb_model::ProgramId,
|
||||
operation_code: &str,
|
||||
) -> kb_execution_api::ExecutionCapability {
|
||||
if program_id.0 != kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID {
|
||||
return kb_execution_api::ExecutionCapability::unsupported(
|
||||
"execution_spl_ata_program_not_owned",
|
||||
format!(
|
||||
"program {} is not owned by kb_executor_spl_associated_token_account",
|
||||
program_id.0
|
||||
),
|
||||
);
|
||||
}
|
||||
if crate::SUPPORTED_OPERATION_CODES.contains(&operation_code) {
|
||||
return kb_execution_api::ExecutionCapability::supported(operation_code);
|
||||
}
|
||||
return kb_execution_api::ExecutionCapability::unsupported(
|
||||
"execution_spl_ata_operation_unsupported",
|
||||
format!("SPL Associated Token Account operation {operation_code} is not implemented"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_execution_api::TypedInstructionExecutor for crate::SplAssociatedTokenAccountExecutor {
|
||||
type Intent = crate::SplAssociatedTokenAccountExecutionIntent;
|
||||
|
||||
fn capability(
|
||||
&self,
|
||||
program_id: &kb_model::ProgramId,
|
||||
operation_code: &str,
|
||||
) -> kb_execution_api::ExecutionCapability {
|
||||
return self.exact_capability(program_id, operation_code);
|
||||
}
|
||||
|
||||
fn build_prepared_plan(
|
||||
&self,
|
||||
intent: &Self::Intent,
|
||||
) -> kb_core::Result<kb_execution_api::PreparedExecutionPlan> {
|
||||
let program_id = kb_model::ProgramId(std::string::String::from(
|
||||
kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID,
|
||||
));
|
||||
return match self.exact_capability(&program_id, intent.operation.operation_code()) {
|
||||
kb_execution_api::ExecutionCapability::Supported { operation_code: _ } => {
|
||||
crate::build_prepared_plan(intent)
|
||||
},
|
||||
kb_execution_api::ExecutionCapability::Unsupported { reason_code, reason } => {
|
||||
std::result::Result::Err(kb_core::Error::new(reason_code, reason))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_execution_api::InstructionExecutor for crate::SplAssociatedTokenAccountExecutor {
|
||||
fn executor_name(&self) -> &'static str {
|
||||
return "kb_executor_spl_associated_token_account";
|
||||
}
|
||||
|
||||
fn executor_version(&self) -> &'static str {
|
||||
return env!("CARGO_PKG_VERSION");
|
||||
}
|
||||
|
||||
fn program_ids(&self) -> &'static [&'static str] {
|
||||
return &[kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID];
|
||||
}
|
||||
|
||||
fn supports_request(
|
||||
&self,
|
||||
request: &kb_execution_api::ExecutionRequest,
|
||||
) -> kb_execution_api::ExecutionSupport {
|
||||
return match self.exact_capability(&request.program_id, &request.operation_code) {
|
||||
kb_execution_api::ExecutionCapability::Supported { operation_code: _ } => {
|
||||
kb_execution_api::ExecutionSupport::Yes
|
||||
},
|
||||
kb_execution_api::ExecutionCapability::Unsupported { reason_code: _, reason: _ } => {
|
||||
kb_execution_api::ExecutionSupport::No
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn build_plan(
|
||||
&self,
|
||||
request: &kb_execution_api::ExecutionRequest,
|
||||
) -> kb_core::Result<kb_execution_api::ExecutionPlan> {
|
||||
match self.exact_capability(&request.program_id, &request.operation_code) {
|
||||
kb_execution_api::ExecutionCapability::Supported { operation_code: _ } => {},
|
||||
kb_execution_api::ExecutionCapability::Unsupported { reason_code, reason } => {
|
||||
return std::result::Result::Err(kb_core::Error::new(reason_code, reason));
|
||||
},
|
||||
}
|
||||
let intent = match serde_json::from_str::<crate::SplAssociatedTokenAccountExecutionIntent>(
|
||||
&request.payload_json,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_intent_deserialize_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
if intent.operation.operation_code() != request.operation_code.as_str() {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_operation_code_mismatch",
|
||||
format!(
|
||||
"request operation {} does not match typed intent operation {}",
|
||||
request.operation_code,
|
||||
intent.operation.operation_code()
|
||||
),
|
||||
));
|
||||
}
|
||||
let prepared =
|
||||
match kb_execution_api::TypedInstructionExecutor::build_prepared_plan(self, &intent) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payload_value = match serde_json::to_value(&prepared) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(kb_core::Error::new(
|
||||
"execution_spl_ata_plan_serialize_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let payload_json = match kb_execution_api::serialize_payload_json(&payload_value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(kb_execution_api::ExecutionPlan {
|
||||
executor_name: std::string::String::from("kb_executor_spl_associated_token_account"),
|
||||
instruction_count: prepared.instructions.len(),
|
||||
payload_json,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(value: &str) -> kb_model::Pubkey {
|
||||
return kb_model::Pubkey(value.to_string());
|
||||
}
|
||||
|
||||
fn intent(
|
||||
operation: crate::SplAssociatedTokenAccountOperation,
|
||||
max_rent_lamports: u64,
|
||||
) -> crate::SplAssociatedTokenAccountExecutionIntent {
|
||||
let fee_payer = pubkey(kb_program_ids::SYSTEM_PROGRAM_ID);
|
||||
let wallet_owner = match &operation {
|
||||
crate::SplAssociatedTokenAccountOperation::Create { wallet_owner, .. }
|
||||
| crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner, ..
|
||||
}
|
||||
| crate::SplAssociatedTokenAccountOperation::RecoverNested { wallet_owner, .. } => {
|
||||
wallet_owner.clone()
|
||||
},
|
||||
};
|
||||
let mut authorized_signers = std::vec![fee_payer.clone()];
|
||||
if !authorized_signers.contains(&wallet_owner) {
|
||||
authorized_signers.push(wallet_owner);
|
||||
}
|
||||
return crate::SplAssociatedTokenAccountExecutionIntent {
|
||||
intent_id: "ata-intent-1".to_string(),
|
||||
fee_payer,
|
||||
max_rent_lamports,
|
||||
policy: kb_execution_api::ExecutionPolicy {
|
||||
simulation: kb_execution_api::ExecutionSimulationPolicy::Required,
|
||||
cost_limit: kb_execution_api::ExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(max_rent_lamports),
|
||||
max_fee_lamports: std::option::Option::Some(10_000),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::None,
|
||||
},
|
||||
authorized_signers,
|
||||
dry_run: true,
|
||||
post_execution_validation: kb_execution_api::PostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
..kb_execution_api::ExecutionPolicy::default()
|
||||
},
|
||||
operation,
|
||||
};
|
||||
}
|
||||
|
||||
fn request(program_id: &str, operation_code: &str) -> kb_execution_api::ExecutionRequest {
|
||||
return kb_execution_api::ExecutionRequest {
|
||||
program_id: kb_model::ProgramId(program_id.to_string()),
|
||||
operation_code: operation_code.to_string(),
|
||||
payload_json: std::string::String::from("{}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_capabilities_cover_every_published_current_variant() {
|
||||
let executor = crate::SplAssociatedTokenAccountExecutor;
|
||||
for operation_code in crate::SUPPORTED_OPERATION_CODES {
|
||||
let request = request(kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID, operation_code);
|
||||
assert_eq!(
|
||||
kb_execution_api::InstructionExecutor::supports_request(&executor, &request),
|
||||
kb_execution_api::ExecutionSupport::Yes
|
||||
);
|
||||
}
|
||||
for (program_id, operation_code) in [
|
||||
(kb_program_ids::SYSTEM_PROGRAM_ID, crate::CREATE_OPERATION),
|
||||
(
|
||||
kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID,
|
||||
"spl_associated_token_account.close",
|
||||
),
|
||||
] {
|
||||
let request = request(program_id, operation_code);
|
||||
assert_eq!(
|
||||
kb_execution_api::InstructionExecutor::supports_request(&executor, &request),
|
||||
kb_execution_api::ExecutionSupport::No
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_request_roundtrips_one_typed_instruction() {
|
||||
let pintent = intent(
|
||||
crate::SplAssociatedTokenAccountOperation::CreateIdempotent {
|
||||
wallet_owner: pubkey(kb_program_ids::STAKE_PROGRAM_ID),
|
||||
mint: pubkey(kb_program_ids::VOTE_PROGRAM_ID),
|
||||
token_program: crate::SplAssociatedTokenProgram::Token2022,
|
||||
},
|
||||
2_100_000,
|
||||
);
|
||||
let payload_json = serde_json::to_string(&pintent)
|
||||
.unwrap_or_else(|error| panic!("ATA intent serialization failed: {error}"));
|
||||
let request = kb_execution_api::ExecutionRequest {
|
||||
program_id: kb_model::ProgramId(
|
||||
kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID.to_string(),
|
||||
),
|
||||
operation_code: crate::CREATE_IDEMPOTENT_OPERATION.to_string(),
|
||||
payload_json,
|
||||
};
|
||||
let plan = kb_execution_api::InstructionExecutor::build_plan(
|
||||
&crate::SplAssociatedTokenAccountExecutor,
|
||||
&request,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("generic ATA plan failed: {error}"));
|
||||
assert_eq!(plan.instruction_count, 1);
|
||||
let prepared =
|
||||
serde_json::from_str::<kb_execution_api::PreparedExecutionPlan>(&plan.payload_json)
|
||||
.unwrap_or_else(|error| panic!("prepared ATA plan parsing failed: {error}"));
|
||||
assert_eq!(prepared.operation_code, crate::CREATE_IDEMPOTENT_OPERATION);
|
||||
assert_eq!(prepared.instructions[0].data, std::vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_readable_matrix_matches_compiled_executor_capabilities() {
|
||||
let matrix: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../docs/SPL_ASSOCIATED_TOKEN_ACCOUNT_MATRIX.json"
|
||||
))
|
||||
.unwrap_or_else(|error| panic!("ATA matrix parsing failed: {error}"));
|
||||
let instructions = matrix["instructions"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("ATA matrix instructions must be an array"));
|
||||
let matrix_operations = instructions
|
||||
.iter()
|
||||
.map(|instruction| {
|
||||
assert_eq!(instruction["executorSupport"]["status"], "supported");
|
||||
return instruction["executorSupport"]["operationCode"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("ATA operation code must be a string"));
|
||||
})
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(matrix_operations, crate::SUPPORTED_OPERATION_CODES);
|
||||
assert_eq!(matrix["surfaceEquality"]["matrixVariantCount"], 3);
|
||||
assert_eq!(matrix["surfaceEquality"]["officialInterfaceVariantCount"], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// file: kb_executor_spl_associated_token_account/src/intent.rs
|
||||
// version: 2
|
||||
|
||||
//! Typed SPL Associated Token Account execution intents.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Stable operation code for strict ATA creation.
|
||||
pub const CREATE_OPERATION: &str = "spl_associated_token_account.create";
|
||||
/// Stable operation code for idempotent ATA creation or reuse.
|
||||
pub const CREATE_IDEMPOTENT_OPERATION: &str = "spl_associated_token_account.create_idempotent";
|
||||
/// Stable operation code for nested ATA recovery.
|
||||
pub const RECOVER_NESTED_OPERATION: &str = "spl_associated_token_account.recover_nested";
|
||||
/// Every current officially constructible ATA operation.
|
||||
pub const SUPPORTED_OPERATION_CODES: &[&str] =
|
||||
&[CREATE_OPERATION, CREATE_IDEMPOTENT_OPERATION, RECOVER_NESTED_OPERATION];
|
||||
|
||||
/// Exact Token Program targeted by ATA derivation and runtime CPIs.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_executor_spl_associated_token_account/intent/SplAssociatedTokenProgram.ts"
|
||||
)]
|
||||
pub enum SplAssociatedTokenProgram {
|
||||
/// Classic SPL Token program.
|
||||
Classic,
|
||||
/// Token-2022 program without extension-specific executor semantics.
|
||||
Token2022,
|
||||
}
|
||||
|
||||
impl crate::SplAssociatedTokenProgram {
|
||||
/// Returns the exact target Token Program ID.
|
||||
pub fn program_id(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Classic => kb_program_ids::SPL_TOKEN_PROGRAM_ID,
|
||||
Self::Token2022 => kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One officially constructible ATA operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_executor_spl_associated_token_account/intent/SplAssociatedTokenAccountOperation.ts"
|
||||
)]
|
||||
pub enum SplAssociatedTokenAccountOperation {
|
||||
/// Strictly create an absent canonical ATA.
|
||||
Create {
|
||||
/// Wallet owner used as the first PDA seed.
|
||||
wallet_owner: kb_model::Pubkey,
|
||||
/// Mint used as the third PDA seed.
|
||||
mint: kb_model::Pubkey,
|
||||
/// Token Program used as the second PDA seed and runtime CPI target.
|
||||
token_program: crate::SplAssociatedTokenProgram,
|
||||
},
|
||||
/// Create an absent ATA or accept an existing compatible ATA.
|
||||
CreateIdempotent {
|
||||
/// Wallet owner used as the first PDA seed.
|
||||
wallet_owner: kb_model::Pubkey,
|
||||
/// Mint used as the third PDA seed.
|
||||
mint: kb_model::Pubkey,
|
||||
/// Token Program used as the second PDA seed and runtime CPI target.
|
||||
token_program: crate::SplAssociatedTokenProgram,
|
||||
},
|
||||
/// Recover tokens and lamports from one canonical nested ATA.
|
||||
RecoverNested {
|
||||
/// Wallet owner that must sign the recovery.
|
||||
wallet_owner: kb_model::Pubkey,
|
||||
/// Mint whose ATA currently owns the nested ATA.
|
||||
owner_mint: kb_model::Pubkey,
|
||||
/// Mint held by the nested ATA and the wallet destination ATA.
|
||||
nested_mint: kb_model::Pubkey,
|
||||
/// Token Program shared by all three derived ATA addresses.
|
||||
token_program: crate::SplAssociatedTokenProgram,
|
||||
},
|
||||
}
|
||||
|
||||
impl crate::SplAssociatedTokenAccountOperation {
|
||||
/// Returns the stable generic dispatch operation code.
|
||||
pub fn operation_code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Create { .. } => crate::CREATE_OPERATION,
|
||||
Self::CreateIdempotent { .. } => crate::CREATE_IDEMPOTENT_OPERATION,
|
||||
Self::RecoverNested { .. } => crate::RECOVER_NESTED_OPERATION,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact target Token Program.
|
||||
pub fn token_program(&self) -> crate::SplAssociatedTokenProgram {
|
||||
return match self {
|
||||
Self::Create { token_program, .. }
|
||||
| Self::CreateIdempotent { token_program, .. }
|
||||
| Self::RecoverNested { token_program, .. } => *token_program,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn is_creation(&self) -> bool {
|
||||
return matches!(self, Self::Create { .. } | Self::CreateIdempotent { .. });
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete typed intent accepted by the ATA executor.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_executor_spl_associated_token_account/intent/SplAssociatedTokenAccountExecutionIntent.ts"
|
||||
)]
|
||||
pub struct SplAssociatedTokenAccountExecutionIntent {
|
||||
/// Stable caller-provided identifier used for logs and replay correlation.
|
||||
pub intent_id: std::string::String,
|
||||
/// Transaction fee payer and Create/CreateIdempotent funding account.
|
||||
pub fee_payer: kb_model::Pubkey,
|
||||
/// Maximum rent lamports the creation operation may spend; zero for RecoverNested.
|
||||
pub max_rent_lamports: u64,
|
||||
/// Conservative common execution policy.
|
||||
pub policy: kb_execution_api::ExecutionPolicy,
|
||||
/// Typed ATA operation and exact address inputs.
|
||||
pub operation: crate::SplAssociatedTokenAccountOperation,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// file: kb_executor_spl_associated_token_account/src/lib.rs
|
||||
// version: 5
|
||||
|
||||
//! Executor crate for `spl_associated_token_account`.
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod builder;
|
||||
mod constants;
|
||||
mod executor;
|
||||
mod intent;
|
||||
|
||||
/// Crate-root access to `build_prepared_plan` from `builder`.
|
||||
pub(crate) use crate::builder::build_prepared_plan;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use crate::constants::TRACING_TARGET;
|
||||
|
||||
/// Exposes the exact ATA executor.
|
||||
pub use crate::executor::SplAssociatedTokenAccountExecutor;
|
||||
/// Stable CreateIdempotent operation code.
|
||||
pub use crate::intent::CREATE_IDEMPOTENT_OPERATION;
|
||||
/// Stable Create operation code.
|
||||
pub use crate::intent::CREATE_OPERATION;
|
||||
/// Stable RecoverNested operation code.
|
||||
pub use crate::intent::RECOVER_NESTED_OPERATION;
|
||||
/// All officially constructible ATA operation codes.
|
||||
pub use crate::intent::SUPPORTED_OPERATION_CODES;
|
||||
/// Exposes the typed ATA execution intent.
|
||||
pub use crate::intent::SplAssociatedTokenAccountExecutionIntent;
|
||||
/// Exposes the typed ATA operation.
|
||||
pub use crate::intent::SplAssociatedTokenAccountOperation;
|
||||
/// Exposes the exact target Token Program selection.
|
||||
pub use crate::intent::SplAssociatedTokenProgram;
|
||||
Reference in New Issue
Block a user