1697 lines
66 KiB
Rust
1697 lines
66 KiB
Rust
// file: kb-pipeline/src/solana_token_stateful.rs
|
|
// version: 1
|
|
|
|
//! Stateful Localnet and Devnet readiness checks for classic SPL Token operations.
|
|
|
|
const MINT_LEN: usize = 82;
|
|
const TOKEN_ACCOUNT_LEN: usize = 165;
|
|
const MULTISIG_LEN: usize = 355;
|
|
const MAX_STATE_ACCOUNTS: usize = 128;
|
|
|
|
/// Stateful readiness outcome for one classic SPL Token operation.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SplTokenStatefulReadinessStatus {
|
|
/// Every stateful preflight check passed.
|
|
Ready,
|
|
/// At least one stateful preflight check failed.
|
|
Blocked,
|
|
}
|
|
|
|
/// One machine-readable classic SPL Token stateful check.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct SplTokenStatefulCheck {
|
|
/// Stable diagnostic code.
|
|
pub code: std::string::String,
|
|
/// Whether the check passed.
|
|
pub passed: bool,
|
|
/// Operator-readable explanation.
|
|
pub message: std::string::String,
|
|
}
|
|
|
|
/// One contextual fact measured during classic SPL Token state inspection.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct SplTokenStatefulFact {
|
|
/// Stable fact key.
|
|
pub key: std::string::String,
|
|
/// Exact string representation of the measured value.
|
|
pub value: std::string::String,
|
|
}
|
|
|
|
/// Complete request for one classic SPL Token stateful readiness inspection.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct SplTokenStatefulReadinessRequest {
|
|
/// HTTP endpoint role used for all state reads.
|
|
pub query_role: std::string::String,
|
|
/// Expected Localnet or Devnet cluster.
|
|
pub cluster: kb_lib::ExApiExecutionCluster,
|
|
/// Typed operation whose external state must be inspected.
|
|
pub operation: kb_lib::ExSplClassicTokenOperation,
|
|
}
|
|
|
|
/// Complete classic SPL Token stateful readiness report.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
|
pub struct SplTokenStatefulReadinessReport {
|
|
/// Expected cluster.
|
|
pub cluster: kb_lib::ExApiExecutionCluster,
|
|
/// Stable top-level operation code.
|
|
pub operation_code: std::string::String,
|
|
/// Aggregate readiness status.
|
|
pub status: crate::SplTokenStatefulReadinessStatus,
|
|
/// Highest contextual slot observed while reading accounts.
|
|
pub context_slot: std::option::Option<u64>,
|
|
/// Ordered stateful checks.
|
|
pub checks: std::vec::Vec<crate::SplTokenStatefulCheck>,
|
|
/// Ordered measured facts.
|
|
pub facts: std::vec::Vec<crate::SplTokenStatefulFact>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct MintState {
|
|
mint_authority: std::option::Option<std::string::String>,
|
|
supply: u64,
|
|
decimals: u8,
|
|
initialized: bool,
|
|
freeze_authority: std::option::Option<std::string::String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct TokenAccountState {
|
|
mint: std::string::String,
|
|
owner: std::string::String,
|
|
amount: u64,
|
|
delegate: std::option::Option<std::string::String>,
|
|
state: u8,
|
|
native_reserve: std::option::Option<u64>,
|
|
delegated_amount: u64,
|
|
close_authority: std::option::Option<std::string::String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct MultisigState {
|
|
threshold: u8,
|
|
member_count: u8,
|
|
initialized: bool,
|
|
members: std::vec::Vec<std::string::String>,
|
|
}
|
|
|
|
type AccountCache = std::collections::BTreeMap<
|
|
std::string::String,
|
|
std::option::Option<kb_onchain_transport::AccountInfoValue>,
|
|
>;
|
|
|
|
/// Inspects Localnet or Devnet Token state required before simulation.
|
|
pub async fn inspect_spl_token_stateful_readiness(
|
|
pool: &kb_onchain_transport::HttpEndpointPool,
|
|
request: &crate::SplTokenStatefulReadinessRequest,
|
|
) -> kb_core::Result<crate::SplTokenStatefulReadinessReport> {
|
|
if request.query_role.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"SPL Token stateful readiness query_role must not be empty",
|
|
));
|
|
}
|
|
match request.cluster {
|
|
kb_lib::ExApiExecutionCluster::Localnet | kb_lib::ExApiExecutionCluster::Devnet => {},
|
|
kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"spl_token_stateful_cluster_unsupported",
|
|
"SPL Token stateful readiness is restricted to Localnet and Devnet",
|
|
));
|
|
},
|
|
}
|
|
let genesis = match 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),
|
|
};
|
|
match validate_cluster(request.cluster, &genesis) {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let addresses = match operation_addresses(&request.operation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let config = match kb_onchain_transport::GetAccountInfoConfig::confirmed_with_data(MULTISIG_LEN)
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut cache = AccountCache::new();
|
|
let mut highest_slot: std::option::Option<u64> = std::option::Option::None;
|
|
for address in addresses {
|
|
let result = pool
|
|
.get_account_info_for_role(request.query_role.as_str(), &address, &config)
|
|
.await;
|
|
let result = match result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
highest_slot = std::option::Option::Some(match highest_slot {
|
|
std::option::Option::Some(slot) => slot.max(result.context.slot),
|
|
std::option::Option::None => result.context.slot,
|
|
});
|
|
cache.insert(address.0, result.account);
|
|
}
|
|
let mut report = crate::SplTokenStatefulReadinessReport {
|
|
cluster: request.cluster,
|
|
operation_code: request.operation.operation_code().to_string(),
|
|
status: crate::SplTokenStatefulReadinessStatus::Ready,
|
|
context_slot: highest_slot,
|
|
checks: std::vec::Vec::new(),
|
|
facts: std::vec![crate::SplTokenStatefulFact {
|
|
key: "genesis_hash".to_string(),
|
|
value: genesis.genesis_hash,
|
|
}],
|
|
};
|
|
let initialization_accounts = initialization_accounts(&request.operation);
|
|
for (address, data_length) in initialization_accounts {
|
|
let rent = pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
request.query_role.as_str(),
|
|
data_length as u64,
|
|
&kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
|
)
|
|
.await;
|
|
let rent = match rent {
|
|
std::result::Result::Ok(value) => value.minimum_balance_lamports,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
push_fact(&mut report, "rent_exempt_minimum_lamports", format!("{}:{rent}", address.0));
|
|
let lamports = cache
|
|
.get(address.0.as_str())
|
|
.and_then(|value| return value.as_ref())
|
|
.map(|value| return value.lamports);
|
|
push_check(
|
|
&mut report,
|
|
"rent_exempt",
|
|
lamports.is_some_and(|value| return value >= rent),
|
|
format!("initialization account {} must be rent exempt", address.0),
|
|
);
|
|
}
|
|
let withdraw_accounts = withdraw_excess_accounts(&request.operation);
|
|
for address in withdraw_accounts {
|
|
let account =
|
|
cache.get(address.0.as_str()).and_then(|value| return value.as_ref()).cloned();
|
|
if let std::option::Option::Some(account) = account {
|
|
let rent = pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
request.query_role.as_str(),
|
|
account.space,
|
|
&kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
|
)
|
|
.await;
|
|
let rent = match rent {
|
|
std::result::Result::Ok(value) => value.minimum_balance_lamports,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
push_fact(
|
|
&mut report,
|
|
"withdraw_rent_exempt_minimum_lamports",
|
|
format!("{}:{rent}", address.0),
|
|
);
|
|
push_check(
|
|
&mut report,
|
|
"excess_lamports_present",
|
|
account.lamports > rent,
|
|
format!("source account {} must contain lamports above rent exemption", address.0),
|
|
);
|
|
}
|
|
}
|
|
match &request.operation {
|
|
kb_lib::ExSplClassicTokenOperation::Instruction { value } => {
|
|
inspect_single(&mut report, &cache, value);
|
|
},
|
|
kb_lib::ExSplClassicTokenOperation::Batch { instructions } => {
|
|
for (index, value) in instructions.iter().enumerate() {
|
|
push_fact(
|
|
&mut report,
|
|
"batch_child",
|
|
format!("{index}:{}", value.operation_code()),
|
|
);
|
|
inspect_single(&mut report, &cache, value);
|
|
}
|
|
},
|
|
}
|
|
report.status = if report.checks.iter().all(|check| return check.passed) {
|
|
crate::SplTokenStatefulReadinessStatus::Ready
|
|
} else {
|
|
crate::SplTokenStatefulReadinessStatus::Blocked
|
|
};
|
|
return std::result::Result::Ok(report);
|
|
}
|
|
|
|
fn validate_cluster(
|
|
expected: kb_lib::ExApiExecutionCluster,
|
|
genesis: &kb_onchain_transport::GenesisHashResult,
|
|
) -> kb_core::Result<()> {
|
|
return match expected {
|
|
kb_lib::ExApiExecutionCluster::Devnet => {
|
|
if genesis.classified_cluster
|
|
== std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet)
|
|
{
|
|
std::result::Result::Ok(())
|
|
} else {
|
|
std::result::Result::Err(kb_core::Error::new(
|
|
"spl_token_stateful_devnet_genesis_mismatch",
|
|
"SPL Token Devnet readiness requires the official Devnet genesis hash",
|
|
))
|
|
}
|
|
},
|
|
kb_lib::ExApiExecutionCluster::Localnet => {
|
|
if genesis.classified_cluster.is_none() {
|
|
std::result::Result::Ok(())
|
|
} else {
|
|
std::result::Result::Err(kb_core::Error::new(
|
|
"spl_token_stateful_localnet_public_cluster",
|
|
"Localnet readiness refuses endpoints classified as a public cluster",
|
|
))
|
|
}
|
|
},
|
|
kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => {
|
|
std::result::Result::Err(kb_core::Error::new(
|
|
"spl_token_stateful_cluster_unsupported",
|
|
"SPL Token stateful readiness is restricted to Localnet and Devnet",
|
|
))
|
|
},
|
|
};
|
|
}
|
|
|
|
fn operation_addresses(
|
|
operation: &kb_lib::ExSplClassicTokenOperation,
|
|
) -> kb_core::Result<std::vec::Vec<kb_lib::MdPubkey>> {
|
|
let mut addresses = std::vec::Vec::new();
|
|
match operation {
|
|
kb_lib::ExSplClassicTokenOperation::Instruction { value } => {
|
|
collect_single_addresses(&mut addresses, value);
|
|
},
|
|
kb_lib::ExSplClassicTokenOperation::Batch { instructions } => {
|
|
for value in instructions {
|
|
collect_single_addresses(&mut addresses, value);
|
|
}
|
|
},
|
|
}
|
|
if addresses.len() > MAX_STATE_ACCOUNTS {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"spl_token_stateful_account_limit",
|
|
format!("SPL Token stateful readiness exceeds {MAX_STATE_ACCOUNTS} unique accounts"),
|
|
));
|
|
}
|
|
return std::result::Result::Ok(addresses);
|
|
}
|
|
|
|
fn initialization_accounts(
|
|
operation: &kb_lib::ExSplClassicTokenOperation,
|
|
) -> std::vec::Vec<(kb_lib::MdPubkey, usize)> {
|
|
let mut values = std::vec::Vec::new();
|
|
let instructions: std::vec::Vec<&kb_lib::ExSplClassicTokenSingleOperation> = match operation {
|
|
kb_lib::ExSplClassicTokenOperation::Instruction { value } => {
|
|
std::vec![value]
|
|
},
|
|
kb_lib::ExSplClassicTokenOperation::Batch { instructions } => instructions.iter().collect(),
|
|
};
|
|
for operation in instructions {
|
|
let candidate = match operation {
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. } => {
|
|
std::option::Option::Some((mint.clone(), MINT_LEN))
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, .. } => {
|
|
std::option::Option::Some((account.clone(), TOKEN_ACCOUNT_LEN))
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => {
|
|
std::option::Option::Some((multisig.clone(), MULTISIG_LEN))
|
|
},
|
|
_ => std::option::Option::None,
|
|
};
|
|
if let std::option::Option::Some(candidate) = candidate {
|
|
if !values
|
|
.iter()
|
|
.any(|(address, length)| return address == &candidate.0 && *length == candidate.1)
|
|
{
|
|
values.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
fn withdraw_excess_accounts(
|
|
operation: &kb_lib::ExSplClassicTokenOperation,
|
|
) -> std::vec::Vec<kb_lib::MdPubkey> {
|
|
let instructions: std::vec::Vec<&kb_lib::ExSplClassicTokenSingleOperation> = match operation {
|
|
kb_lib::ExSplClassicTokenOperation::Instruction { value } => {
|
|
std::vec![value]
|
|
},
|
|
kb_lib::ExSplClassicTokenOperation::Batch { instructions } => instructions.iter().collect(),
|
|
};
|
|
let mut values = std::vec::Vec::new();
|
|
for operation in instructions {
|
|
if let kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports {
|
|
account, ..
|
|
} = operation
|
|
{
|
|
if !values.iter().any(|value| return value == account) {
|
|
values.push(account.clone());
|
|
}
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
fn add_address(addresses: &mut std::vec::Vec<kb_lib::MdPubkey>, value: &kb_lib::MdPubkey) {
|
|
if !addresses.iter().any(|existing| return existing == value) {
|
|
addresses.push(value.clone());
|
|
}
|
|
}
|
|
|
|
fn add_authority(
|
|
addresses: &mut std::vec::Vec<kb_lib::MdPubkey>,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
) {
|
|
if !authority.multisig_signers.is_empty() {
|
|
add_address(addresses, &authority.authority);
|
|
}
|
|
}
|
|
|
|
fn collect_single_addresses(
|
|
addresses: &mut std::vec::Vec<kb_lib::MdPubkey>,
|
|
operation: &kb_lib::ExSplClassicTokenSingleOperation,
|
|
) {
|
|
match operation {
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { mint }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { mint, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { mint, .. } => {
|
|
add_address(addresses, mint);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, mint, .. } => {
|
|
add_address(addresses, account);
|
|
add_address(addresses, mint);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => {
|
|
add_address(addresses, multisig);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Transfer {
|
|
source,
|
|
destination,
|
|
authority,
|
|
..
|
|
} => {
|
|
add_address(addresses, source);
|
|
add_address(addresses, destination);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Approve { source, authority, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::Revoke { source, authority } => {
|
|
add_address(addresses, source);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::SetAuthority {
|
|
owned, current_authority, ..
|
|
} => {
|
|
add_address(addresses, owned);
|
|
add_authority(addresses, current_authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::MintTo {
|
|
mint, destination, authority, ..
|
|
}
|
|
| kb_lib::ExSplClassicTokenSingleOperation::MintToChecked {
|
|
mint,
|
|
destination,
|
|
authority,
|
|
..
|
|
} => {
|
|
add_address(addresses, mint);
|
|
add_address(addresses, destination);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Burn { source, mint, authority, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::BurnChecked {
|
|
source, mint, authority, ..
|
|
} => {
|
|
add_address(addresses, source);
|
|
add_address(addresses, mint);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { account, authority, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports { account, authority, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports {
|
|
account,
|
|
authority,
|
|
..
|
|
} => {
|
|
add_address(addresses, account);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::FreezeAccount { account, mint, authority }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::ThawAccount { account, mint, authority } => {
|
|
add_address(addresses, account);
|
|
add_address(addresses, mint);
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::TransferChecked {
|
|
source,
|
|
mint,
|
|
destination,
|
|
authority,
|
|
..
|
|
}
|
|
| kb_lib::ExSplClassicTokenSingleOperation::ApproveChecked {
|
|
source,
|
|
mint,
|
|
delegate: destination,
|
|
authority,
|
|
..
|
|
} => {
|
|
add_address(addresses, source);
|
|
add_address(addresses, mint);
|
|
if matches!(operation, kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { .. })
|
|
{
|
|
add_address(addresses, destination);
|
|
}
|
|
add_authority(addresses, authority);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::SyncNative { account, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { account } => {
|
|
add_address(addresses, account);
|
|
},
|
|
}
|
|
}
|
|
|
|
fn inspect_single(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
operation: &kb_lib::ExSplClassicTokenSingleOperation,
|
|
) {
|
|
match operation {
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. } => {
|
|
let state = mint_state(report, cache, mint);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"mint_uninitialized",
|
|
!state.initialized,
|
|
"mint must be uninitialized before InitializeMint2",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, mint, .. } => {
|
|
let state = token_state(report, cache, account);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"account_uninitialized",
|
|
state.state == 0,
|
|
"token account must be uninitialized before InitializeAccount3",
|
|
);
|
|
}
|
|
let mint_state = mint_state(report, cache, mint);
|
|
check_mint_initialized(report, mint_state.as_ref());
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => {
|
|
let state = multisig_state(report, cache, multisig);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"multisig_uninitialized",
|
|
!state.initialized,
|
|
"multisig must be uninitialized before InitializeMultisig2",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Transfer {
|
|
source,
|
|
destination,
|
|
authority,
|
|
amount,
|
|
} => {
|
|
inspect_transfer(
|
|
report,
|
|
cache,
|
|
source,
|
|
destination,
|
|
std::option::Option::None,
|
|
authority,
|
|
amount,
|
|
std::option::Option::None,
|
|
);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::TransferChecked {
|
|
source,
|
|
mint,
|
|
destination,
|
|
authority,
|
|
amount,
|
|
decimals,
|
|
} => {
|
|
inspect_transfer(
|
|
report,
|
|
cache,
|
|
source,
|
|
destination,
|
|
std::option::Option::Some(mint),
|
|
authority,
|
|
amount,
|
|
std::option::Option::Some(*decimals),
|
|
);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Approve { source, authority, amount, .. } => {
|
|
inspect_owner_amount(report, cache, source, authority, amount, false);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::ApproveChecked {
|
|
source,
|
|
mint,
|
|
authority,
|
|
amount,
|
|
decimals,
|
|
..
|
|
} => {
|
|
inspect_owner_amount(report, cache, source, authority, amount, false);
|
|
inspect_checked_mint(report, cache, source, mint, *decimals);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Revoke { source, authority } => {
|
|
let state = token_state(report, cache, source);
|
|
if let std::option::Option::Some(state) = state {
|
|
check_authority(
|
|
report,
|
|
cache,
|
|
authority,
|
|
std::option::Option::Some(state.owner.as_str()),
|
|
);
|
|
push_check(
|
|
report,
|
|
"delegate_present",
|
|
state.delegate.is_some(),
|
|
"revoke requires an existing delegate",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::MintTo {
|
|
mint,
|
|
destination,
|
|
authority,
|
|
amount,
|
|
}
|
|
| kb_lib::ExSplClassicTokenSingleOperation::MintToChecked {
|
|
mint,
|
|
destination,
|
|
authority,
|
|
amount,
|
|
..
|
|
} => {
|
|
let mint_value = mint_state(report, cache, mint);
|
|
let destination_value = token_state(report, cache, destination);
|
|
check_mint_initialized(report, mint_value.as_ref());
|
|
if let (
|
|
std::option::Option::Some(mint_value),
|
|
std::option::Option::Some(destination_value),
|
|
) = (mint_value, destination_value)
|
|
{
|
|
push_check(
|
|
report,
|
|
"destination_mint_matches",
|
|
destination_value.mint == mint.0,
|
|
"destination token account must use the mint",
|
|
);
|
|
check_authority(report, cache, authority, mint_value.mint_authority.as_deref());
|
|
let parsed = amount_value(report, amount);
|
|
if let std::option::Option::Some(parsed) = parsed {
|
|
push_check(
|
|
report,
|
|
"mint_supply_no_overflow",
|
|
mint_value.supply.checked_add(parsed).is_some(),
|
|
"mint supply must not overflow u64",
|
|
);
|
|
}
|
|
}
|
|
if let kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { decimals, .. } =
|
|
operation
|
|
{
|
|
if let std::option::Option::Some(mint_value) = mint_state_quiet(cache, mint) {
|
|
push_check(
|
|
report,
|
|
"mint_decimals_match",
|
|
mint_value.decimals == *decimals,
|
|
"checked decimals must match mint state",
|
|
);
|
|
}
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::Burn { source, mint, authority, amount }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::BurnChecked {
|
|
source,
|
|
mint,
|
|
authority,
|
|
amount,
|
|
..
|
|
} => {
|
|
inspect_owner_amount(report, cache, source, authority, amount, true);
|
|
inspect_checked_relation(report, cache, source, mint);
|
|
if let (std::option::Option::Some(mint_state), std::option::Option::Some(parsed)) =
|
|
(mint_state_quiet(cache, mint), amount.0.parse::<u64>().ok())
|
|
{
|
|
push_check(
|
|
report,
|
|
"mint_supply_sufficient",
|
|
mint_state.supply >= parsed,
|
|
"mint supply must cover burned amount",
|
|
);
|
|
}
|
|
if let kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { decimals, .. } =
|
|
operation
|
|
{
|
|
inspect_checked_mint(report, cache, source, mint, *decimals);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { account, authority, .. } => {
|
|
let state = token_state(report, cache, account);
|
|
if let std::option::Option::Some(state) = state {
|
|
let expected = match state.close_authority.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => state.owner.as_str(),
|
|
};
|
|
check_authority(report, cache, authority, std::option::Option::Some(expected));
|
|
push_check(
|
|
report,
|
|
"close_balance_allowed",
|
|
state.native_reserve.is_some() || state.amount == 0,
|
|
"non-native token account must have zero token balance before close",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::FreezeAccount { account, mint, authority } => {
|
|
inspect_freeze_thaw(report, cache, account, mint, authority, 1);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::ThawAccount { account, mint, authority } => {
|
|
inspect_freeze_thaw(report, cache, account, mint, authority, 2);
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::SyncNative { account, .. } => {
|
|
let state = token_state(report, cache, account);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"native_account",
|
|
state.native_reserve.is_some(),
|
|
"SyncNative requires a wrapped-SOL token account",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { mint }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { mint, .. }
|
|
| kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { mint, .. } => {
|
|
let state = mint_state(report, cache, mint);
|
|
check_mint_initialized(report, state.as_ref());
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { account } => {
|
|
let state = token_state(report, cache, account);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"account_uninitialized",
|
|
state.state == 0,
|
|
"classic immutable-owner compatibility no-op precedes account initialization",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports {
|
|
account,
|
|
authority,
|
|
amount_lamports,
|
|
..
|
|
} => {
|
|
let state = token_state(report, cache, account);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"native_account",
|
|
state.native_reserve.is_some(),
|
|
"UnwrapLamports requires a wrapped-SOL token account",
|
|
);
|
|
let expected = match state.close_authority.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => state.owner.as_str(),
|
|
};
|
|
check_authority(report, cache, authority, std::option::Option::Some(expected));
|
|
if let std::option::Option::Some(amount) = amount_lamports {
|
|
if let std::option::Option::Some(parsed) = amount_value(report, amount) {
|
|
push_check(
|
|
report,
|
|
"unwrap_balance_sufficient",
|
|
state.amount >= parsed,
|
|
"wrapped-SOL balance must cover requested unwrap amount",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports {
|
|
account,
|
|
authority,
|
|
..
|
|
} => {
|
|
let value = account_value(report, cache, account, std::option::Option::None);
|
|
if let std::option::Option::Some(value) = value {
|
|
match value.data.len() {
|
|
MINT_LEN => {
|
|
if let std::result::Result::Ok(state) = parse_mint(value.data.as_slice()) {
|
|
check_authority(
|
|
report,
|
|
cache,
|
|
authority,
|
|
state.mint_authority.as_deref(),
|
|
);
|
|
}
|
|
},
|
|
TOKEN_ACCOUNT_LEN => {
|
|
if let std::result::Result::Ok(state) =
|
|
parse_token_account(value.data.as_slice())
|
|
{
|
|
check_authority(
|
|
report,
|
|
cache,
|
|
authority,
|
|
std::option::Option::Some(state.owner.as_str()),
|
|
);
|
|
}
|
|
},
|
|
MULTISIG_LEN => {
|
|
check_authority(
|
|
report,
|
|
cache,
|
|
authority,
|
|
std::option::Option::Some(account.0.as_str()),
|
|
);
|
|
},
|
|
_ => {},
|
|
}
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenSingleOperation::SetAuthority {
|
|
owned,
|
|
authority_type,
|
|
new_authority,
|
|
current_authority,
|
|
} => {
|
|
inspect_set_authority(
|
|
report,
|
|
cache,
|
|
owned,
|
|
*authority_type,
|
|
new_authority,
|
|
current_authority,
|
|
);
|
|
},
|
|
}
|
|
}
|
|
|
|
fn inspect_transfer(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
source: &kb_lib::MdPubkey,
|
|
destination: &kb_lib::MdPubkey,
|
|
explicit_mint: std::option::Option<&kb_lib::MdPubkey>,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
amount: &kb_lib::ExSplClassicTokenAmount,
|
|
decimals: std::option::Option<u8>,
|
|
) {
|
|
let source_state = token_state(report, cache, source);
|
|
let destination_state = token_state(report, cache, destination);
|
|
if let (std::option::Option::Some(source_state), std::option::Option::Some(destination_state)) =
|
|
(source_state, destination_state)
|
|
{
|
|
push_check(
|
|
report,
|
|
"transfer_mint_matches",
|
|
source_state.mint == destination_state.mint,
|
|
"source and destination token accounts must share a mint",
|
|
);
|
|
push_check(
|
|
report,
|
|
"source_not_frozen",
|
|
source_state.state == 1,
|
|
"source token account must be initialized and unfrozen",
|
|
);
|
|
push_check(
|
|
report,
|
|
"destination_not_frozen",
|
|
destination_state.state == 1,
|
|
"destination token account must be initialized and unfrozen",
|
|
);
|
|
if let std::option::Option::Some(parsed) = amount_value(report, amount) {
|
|
push_check(
|
|
report,
|
|
"source_balance_sufficient",
|
|
source_state.amount >= parsed,
|
|
"source token balance must cover transfer amount",
|
|
);
|
|
if source_state.delegate.as_deref()
|
|
== std::option::Option::Some(authority.authority.0.as_str())
|
|
{
|
|
push_check(
|
|
report,
|
|
"delegate_allowance_sufficient",
|
|
source_state.delegated_amount >= parsed,
|
|
"delegate allowance must cover transfer amount",
|
|
);
|
|
}
|
|
}
|
|
check_token_authority(report, cache, authority, &source_state);
|
|
if let std::option::Option::Some(mint) = explicit_mint {
|
|
push_check(
|
|
report,
|
|
"explicit_mint_matches",
|
|
source_state.mint == mint.0,
|
|
"checked mint must match source account state",
|
|
);
|
|
if let std::option::Option::Some(decimals) = decimals {
|
|
if let std::option::Option::Some(mint_state) = mint_state(report, cache, mint) {
|
|
push_check(
|
|
report,
|
|
"mint_decimals_match",
|
|
mint_state.decimals == decimals,
|
|
"checked decimals must match mint state",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn inspect_owner_amount(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
source: &kb_lib::MdPubkey,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
amount: &kb_lib::ExSplClassicTokenAmount,
|
|
require_balance: bool,
|
|
) {
|
|
let state = token_state(report, cache, source);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"source_not_frozen",
|
|
state.state == 1,
|
|
"source token account must be initialized and unfrozen",
|
|
);
|
|
check_token_authority(report, cache, authority, &state);
|
|
if let std::option::Option::Some(parsed) = amount_value(report, amount) {
|
|
if require_balance {
|
|
push_check(
|
|
report,
|
|
"source_balance_sufficient",
|
|
state.amount >= parsed,
|
|
"source token balance must cover requested amount",
|
|
);
|
|
}
|
|
if state.delegate.as_deref()
|
|
== std::option::Option::Some(authority.authority.0.as_str())
|
|
{
|
|
push_check(
|
|
report,
|
|
"delegate_allowance_sufficient",
|
|
state.delegated_amount >= parsed,
|
|
"delegate allowance must cover requested amount",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn inspect_checked_relation(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
source: &kb_lib::MdPubkey,
|
|
mint: &kb_lib::MdPubkey,
|
|
) {
|
|
if let std::option::Option::Some(state) = token_state_quiet(cache, source) {
|
|
push_check(
|
|
report,
|
|
"source_mint_matches",
|
|
state.mint == mint.0,
|
|
"source token account must use the explicit mint",
|
|
);
|
|
}
|
|
let mint_state = mint_state(report, cache, mint);
|
|
check_mint_initialized(report, mint_state.as_ref());
|
|
}
|
|
|
|
fn inspect_checked_mint(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
source: &kb_lib::MdPubkey,
|
|
mint: &kb_lib::MdPubkey,
|
|
decimals: u8,
|
|
) {
|
|
inspect_checked_relation(report, cache, source, mint);
|
|
if let std::option::Option::Some(state) = mint_state_quiet(cache, mint) {
|
|
push_check(
|
|
report,
|
|
"mint_decimals_match",
|
|
state.decimals == decimals,
|
|
"checked decimals must match mint state",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn inspect_freeze_thaw(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
account: &kb_lib::MdPubkey,
|
|
mint: &kb_lib::MdPubkey,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
required_state: u8,
|
|
) {
|
|
let account_state = token_state(report, cache, account);
|
|
let mint_state = mint_state(report, cache, mint);
|
|
if let std::option::Option::Some(account_state) = account_state {
|
|
push_check(
|
|
report,
|
|
"account_mint_matches",
|
|
account_state.mint == mint.0,
|
|
"token account must use the supplied mint",
|
|
);
|
|
push_check(
|
|
report,
|
|
"account_state_matches",
|
|
account_state.state == required_state,
|
|
"token account state must match freeze or thaw precondition",
|
|
);
|
|
}
|
|
if let std::option::Option::Some(mint_state) = mint_state {
|
|
check_authority(report, cache, authority, mint_state.freeze_authority.as_deref());
|
|
}
|
|
}
|
|
|
|
fn inspect_set_authority(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
owned: &kb_lib::MdPubkey,
|
|
authority_type: kb_lib::ExSplClassicTokenAuthorityType,
|
|
new_authority: &std::option::Option<kb_lib::MdPubkey>,
|
|
current: &kb_lib::ExSplClassicTokenAuthority,
|
|
) {
|
|
let value = account_value(report, cache, owned, std::option::Option::None);
|
|
if let std::option::Option::Some(value) = value {
|
|
match authority_type {
|
|
kb_lib::ExSplClassicTokenAuthorityType::MintTokens
|
|
| kb_lib::ExSplClassicTokenAuthorityType::FreezeAccount => {
|
|
if let std::result::Result::Ok(state) = parse_mint(value.data.as_slice()) {
|
|
let expected =
|
|
if authority_type == kb_lib::ExSplClassicTokenAuthorityType::MintTokens {
|
|
state.mint_authority.as_deref()
|
|
} else {
|
|
state.freeze_authority.as_deref()
|
|
};
|
|
check_authority(report, cache, current, expected);
|
|
} else {
|
|
push_check(
|
|
report,
|
|
"authority_domain_layout",
|
|
false,
|
|
"mint authority domains require a Mint account",
|
|
);
|
|
}
|
|
},
|
|
kb_lib::ExSplClassicTokenAuthorityType::AccountOwner
|
|
| kb_lib::ExSplClassicTokenAuthorityType::CloseAccount => {
|
|
if let std::result::Result::Ok(state) = parse_token_account(value.data.as_slice()) {
|
|
let close_authority = match state.close_authority.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => state.owner.as_str(),
|
|
};
|
|
let expected =
|
|
if authority_type == kb_lib::ExSplClassicTokenAuthorityType::AccountOwner {
|
|
std::option::Option::Some(state.owner.as_str())
|
|
} else {
|
|
std::option::Option::Some(close_authority)
|
|
};
|
|
check_authority(report, cache, current, expected);
|
|
if authority_type == kb_lib::ExSplClassicTokenAuthorityType::AccountOwner {
|
|
push_check(
|
|
report,
|
|
"account_owner_not_revoked",
|
|
new_authority.is_some(),
|
|
"token-account owner cannot be revoked",
|
|
);
|
|
}
|
|
} else {
|
|
push_check(
|
|
report,
|
|
"authority_domain_layout",
|
|
false,
|
|
"account authority domains require a token Account",
|
|
);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_token_authority(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
account: &TokenAccountState,
|
|
) {
|
|
let supplied = authority.authority.0.as_str();
|
|
let expected = if account.delegate.as_deref() == std::option::Option::Some(supplied) {
|
|
account.delegate.as_deref()
|
|
} else {
|
|
std::option::Option::Some(account.owner.as_str())
|
|
};
|
|
check_authority(report, cache, authority, expected);
|
|
}
|
|
|
|
fn check_authority(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
authority: &kb_lib::ExSplClassicTokenAuthority,
|
|
expected: std::option::Option<&str>,
|
|
) {
|
|
push_check(
|
|
report,
|
|
"authority_present",
|
|
expected.is_some(),
|
|
"required authority must not be revoked",
|
|
);
|
|
if let std::option::Option::Some(expected) = expected {
|
|
push_check(
|
|
report,
|
|
"authority_matches",
|
|
authority.authority.0 == expected,
|
|
"declared authority must match on-chain state",
|
|
);
|
|
}
|
|
if authority.multisig_signers.is_empty() {
|
|
return;
|
|
}
|
|
let state = multisig_state(report, cache, &authority.authority);
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(
|
|
report,
|
|
"multisig_initialized",
|
|
state.initialized,
|
|
"multisig authority must be initialized",
|
|
);
|
|
push_check(
|
|
report,
|
|
"multisig_shape",
|
|
state.threshold >= 1
|
|
&& state.threshold <= state.member_count
|
|
&& state.member_count <= 11,
|
|
"multisig M/N must satisfy 1 <= M <= N <= 11",
|
|
);
|
|
let mut unique = std::collections::BTreeSet::new();
|
|
for signer in &authority.multisig_signers {
|
|
if state.members.iter().any(|member| return member == &signer.0) {
|
|
unique.insert(signer.0.clone());
|
|
}
|
|
}
|
|
push_check(
|
|
report,
|
|
"multisig_threshold",
|
|
unique.len() >= usize::from(state.threshold),
|
|
"distinct supplied member signers must meet the multisig threshold",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn check_mint_initialized(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
state: std::option::Option<&MintState>,
|
|
) {
|
|
if let std::option::Option::Some(state) = state {
|
|
push_check(report, "mint_initialized", state.initialized, "mint must be initialized");
|
|
}
|
|
}
|
|
|
|
fn account_value<'a>(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &'a AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
expected_len: std::option::Option<usize>,
|
|
) -> std::option::Option<&'a kb_onchain_transport::AccountInfoValue> {
|
|
let value = cache.get(address.0.as_str()).and_then(|value| return value.as_ref());
|
|
push_check(
|
|
report,
|
|
"account_exists",
|
|
value.is_some(),
|
|
format!("state account {} must exist", address.0),
|
|
);
|
|
if let std::option::Option::Some(value) = value {
|
|
push_check(
|
|
report,
|
|
"classic_token_owner",
|
|
value.owner.0 == kb_program_ids::SPL_TOKEN_PROGRAM_ID,
|
|
format!("state account {} must be owned by classic SPL Token", address.0),
|
|
);
|
|
if let std::option::Option::Some(expected_len) = expected_len {
|
|
push_check(
|
|
report,
|
|
"account_layout",
|
|
value.data.len() == expected_len && value.space == expected_len as u64,
|
|
format!(
|
|
"state account {} must use the exact {expected_len}-byte layout",
|
|
address.0
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
fn mint_state(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
) -> std::option::Option<MintState> {
|
|
let value = account_value(report, cache, address, std::option::Option::Some(MINT_LEN));
|
|
return match value {
|
|
std::option::Option::Some(value) => match parse_mint(value.data.as_slice()) {
|
|
std::result::Result::Ok(state) => {
|
|
push_fact(report, "mint_decimals", state.decimals);
|
|
push_fact(report, "mint_supply", state.supply);
|
|
std::option::Option::Some(state)
|
|
},
|
|
std::result::Result::Err(message) => {
|
|
push_check(report, "mint_layout_valid", false, message);
|
|
std::option::Option::None
|
|
},
|
|
},
|
|
std::option::Option::None => std::option::Option::None,
|
|
};
|
|
}
|
|
|
|
fn mint_state_quiet(
|
|
cache: &AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
) -> std::option::Option<MintState> {
|
|
return cache
|
|
.get(address.0.as_str())
|
|
.and_then(|value| return value.as_ref())
|
|
.and_then(|value| return parse_mint(value.data.as_slice()).ok());
|
|
}
|
|
|
|
fn token_state(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
) -> std::option::Option<TokenAccountState> {
|
|
let value = account_value(report, cache, address, std::option::Option::Some(TOKEN_ACCOUNT_LEN));
|
|
return match value {
|
|
std::option::Option::Some(value) => match parse_token_account(value.data.as_slice()) {
|
|
std::result::Result::Ok(state) => {
|
|
push_fact(report, "token_amount", state.amount);
|
|
push_fact(report, "token_mint", state.mint.as_str());
|
|
std::option::Option::Some(state)
|
|
},
|
|
std::result::Result::Err(message) => {
|
|
push_check(report, "token_account_layout_valid", false, message);
|
|
std::option::Option::None
|
|
},
|
|
},
|
|
std::option::Option::None => std::option::Option::None,
|
|
};
|
|
}
|
|
|
|
fn token_state_quiet(
|
|
cache: &AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
) -> std::option::Option<TokenAccountState> {
|
|
return cache.get(address.0.as_str()).and_then(|value| return value.as_ref()).and_then(
|
|
|value| {
|
|
return parse_token_account(value.data.as_slice()).ok();
|
|
},
|
|
);
|
|
}
|
|
|
|
fn multisig_state(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
cache: &AccountCache,
|
|
address: &kb_lib::MdPubkey,
|
|
) -> std::option::Option<MultisigState> {
|
|
let value = account_value(report, cache, address, std::option::Option::Some(MULTISIG_LEN));
|
|
return match value {
|
|
std::option::Option::Some(value) => match parse_multisig(value.data.as_slice()) {
|
|
std::result::Result::Ok(state) => std::option::Option::Some(state),
|
|
std::result::Result::Err(message) => {
|
|
push_check(report, "multisig_layout_valid", false, message);
|
|
std::option::Option::None
|
|
},
|
|
},
|
|
std::option::Option::None => std::option::Option::None,
|
|
};
|
|
}
|
|
|
|
fn amount_value(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
amount: &kb_lib::ExSplClassicTokenAmount,
|
|
) -> std::option::Option<u64> {
|
|
return match amount.0.parse::<u64>() {
|
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
|
std::result::Result::Err(_) => {
|
|
push_check(report, "amount_valid", false, "raw token amount must be a canonical u64");
|
|
std::option::Option::None
|
|
},
|
|
};
|
|
}
|
|
|
|
fn push_check(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
code: &str,
|
|
passed: bool,
|
|
message: impl std::convert::Into<std::string::String>,
|
|
) {
|
|
report.checks.push(crate::SplTokenStatefulCheck {
|
|
code: code.to_string(),
|
|
passed,
|
|
message: message.into(),
|
|
});
|
|
}
|
|
|
|
fn push_fact(
|
|
report: &mut crate::SplTokenStatefulReadinessReport,
|
|
key: &str,
|
|
value: impl std::fmt::Display,
|
|
) {
|
|
report.facts.push(crate::SplTokenStatefulFact {
|
|
key: key.to_string(),
|
|
value: value.to_string(),
|
|
});
|
|
}
|
|
|
|
fn parse_mint(bytes: &[u8]) -> std::result::Result<MintState, &'static str> {
|
|
if bytes.len() != MINT_LEN {
|
|
return std::result::Result::Err("Mint data must contain exactly 82 bytes");
|
|
}
|
|
let mint_authority = match parse_coption_pubkey(&bytes[0..36]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let supply = match read_u64(&bytes[36..44]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let initialized = match bytes[45] {
|
|
0 => false,
|
|
1 => true,
|
|
_ => return std::result::Result::Err("Mint initialized flag must be 0 or 1"),
|
|
};
|
|
let freeze_authority = match parse_coption_pubkey(&bytes[46..82]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
return std::result::Result::Ok(MintState {
|
|
mint_authority,
|
|
supply,
|
|
decimals: bytes[44],
|
|
initialized,
|
|
freeze_authority,
|
|
});
|
|
}
|
|
|
|
fn parse_token_account(bytes: &[u8]) -> std::result::Result<TokenAccountState, &'static str> {
|
|
if bytes.len() != TOKEN_ACCOUNT_LEN {
|
|
return std::result::Result::Err("Token Account data must contain exactly 165 bytes");
|
|
}
|
|
if bytes[108] > 2 {
|
|
return std::result::Result::Err(
|
|
"Token Account state must be Uninitialized, Initialized or Frozen",
|
|
);
|
|
}
|
|
let amount = match read_u64(&bytes[64..72]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let delegate = match parse_coption_pubkey(&bytes[72..108]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let native_reserve = match parse_coption_u64(&bytes[109..121]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let delegated_amount = match read_u64(&bytes[121..129]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
let close_authority = match parse_coption_pubkey(&bytes[129..165]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(message) => return std::result::Result::Err(message),
|
|
};
|
|
return std::result::Result::Ok(TokenAccountState {
|
|
mint: bs58::encode(&bytes[0..32]).into_string(),
|
|
owner: bs58::encode(&bytes[32..64]).into_string(),
|
|
amount,
|
|
delegate,
|
|
state: bytes[108],
|
|
native_reserve,
|
|
delegated_amount,
|
|
close_authority,
|
|
});
|
|
}
|
|
|
|
fn parse_multisig(bytes: &[u8]) -> std::result::Result<MultisigState, &'static str> {
|
|
if bytes.len() != MULTISIG_LEN {
|
|
return std::result::Result::Err("Multisig data must contain exactly 355 bytes");
|
|
}
|
|
let initialized = match bytes[2] {
|
|
0 => false,
|
|
1 => true,
|
|
_ => return std::result::Result::Err("Multisig initialized flag must be 0 or 1"),
|
|
};
|
|
let count = usize::from(bytes[1].min(11));
|
|
let mut members = std::vec::Vec::new();
|
|
for index in 0..count {
|
|
let start = 3 + index * 32;
|
|
members.push(bs58::encode(&bytes[start..start + 32]).into_string());
|
|
}
|
|
return std::result::Result::Ok(MultisigState {
|
|
threshold: bytes[0],
|
|
member_count: bytes[1],
|
|
initialized,
|
|
members,
|
|
});
|
|
}
|
|
|
|
fn parse_coption_pubkey(
|
|
bytes: &[u8],
|
|
) -> std::result::Result<std::option::Option<std::string::String>, &'static str> {
|
|
if bytes.len() != 36 {
|
|
return std::result::Result::Err("COption<Pubkey> must contain exactly 36 bytes");
|
|
}
|
|
return match &bytes[0..4] {
|
|
[0, 0, 0, 0] => std::result::Result::Ok(std::option::Option::None),
|
|
[1, 0, 0, 0] => std::result::Result::Ok(std::option::Option::Some(
|
|
bs58::encode(&bytes[4..36]).into_string(),
|
|
)),
|
|
_ => std::result::Result::Err("COption<Pubkey> tag must be 0 or 1"),
|
|
};
|
|
}
|
|
|
|
fn parse_coption_u64(bytes: &[u8]) -> std::result::Result<std::option::Option<u64>, &'static str> {
|
|
if bytes.len() != 12 {
|
|
return std::result::Result::Err("COption<u64> must contain exactly 12 bytes");
|
|
}
|
|
return match &bytes[0..4] {
|
|
[0, 0, 0, 0] => std::result::Result::Ok(std::option::Option::None),
|
|
[1, 0, 0, 0] => match read_u64(&bytes[4..12]) {
|
|
std::result::Result::Ok(value) => {
|
|
std::result::Result::Ok(std::option::Option::Some(value))
|
|
},
|
|
std::result::Result::Err(message) => std::result::Result::Err(message),
|
|
},
|
|
_ => std::result::Result::Err("COption<u64> tag must be 0 or 1"),
|
|
};
|
|
}
|
|
|
|
fn read_u64(bytes: &[u8]) -> std::result::Result<u64, &'static str> {
|
|
let array = match <[u8; 8]>::try_from(bytes) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err("u64 field must contain exactly 8 bytes");
|
|
},
|
|
};
|
|
return std::result::Result::Ok(u64::from_le_bytes(array));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn empty_report() -> crate::SplTokenStatefulReadinessReport {
|
|
return crate::SplTokenStatefulReadinessReport {
|
|
cluster: kb_lib::ExApiExecutionCluster::Devnet,
|
|
operation_code: kb_lib::EX_SPL_TOKEN_TRANSFER_CHECKED_OPERATION.to_string(),
|
|
status: crate::SplTokenStatefulReadinessStatus::Ready,
|
|
context_slot: std::option::Option::Some(1),
|
|
checks: std::vec::Vec::new(),
|
|
facts: std::vec::Vec::new(),
|
|
};
|
|
}
|
|
|
|
fn account_info(data: std::vec::Vec<u8>) -> kb_onchain_transport::AccountInfoValue {
|
|
return kb_onchain_transport::AccountInfoValue {
|
|
lamports: 10_000_000,
|
|
owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()),
|
|
executable: false,
|
|
rent_epoch: 0,
|
|
space: data.len() as u64,
|
|
data,
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn exact_mint_account_and_multisig_layouts_are_parsed() {
|
|
let mut mint = [0_u8; super::MINT_LEN];
|
|
mint[0..4].copy_from_slice(&1_u32.to_le_bytes());
|
|
mint[4..36].copy_from_slice(&[1_u8; 32]);
|
|
mint[36..44].copy_from_slice(&u64::MAX.to_le_bytes());
|
|
mint[44] = 9;
|
|
mint[45] = 1;
|
|
let parsed_mint = match super::parse_mint(&mint) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Mint parse failed: {error}"),
|
|
};
|
|
assert_eq!(parsed_mint.supply, u64::MAX);
|
|
assert_eq!(parsed_mint.decimals, 9);
|
|
assert!(parsed_mint.initialized);
|
|
|
|
let mut account = [0_u8; super::TOKEN_ACCOUNT_LEN];
|
|
account[0..32].copy_from_slice(&[2_u8; 32]);
|
|
account[32..64].copy_from_slice(&[3_u8; 32]);
|
|
account[64..72].copy_from_slice(&42_u64.to_le_bytes());
|
|
account[108] = 1;
|
|
account[109..113].copy_from_slice(&1_u32.to_le_bytes());
|
|
account[113..121].copy_from_slice(&2_039_280_u64.to_le_bytes());
|
|
let parsed_account = match super::parse_token_account(&account) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Account parse failed: {error}"),
|
|
};
|
|
assert_eq!(parsed_account.amount, 42);
|
|
assert_eq!(parsed_account.native_reserve, std::option::Option::Some(2_039_280));
|
|
|
|
let mut multisig = [0_u8; super::MULTISIG_LEN];
|
|
multisig[0] = 2;
|
|
multisig[1] = 3;
|
|
multisig[2] = 1;
|
|
multisig[3..35].copy_from_slice(&[4_u8; 32]);
|
|
multisig[35..67].copy_from_slice(&[5_u8; 32]);
|
|
multisig[67..99].copy_from_slice(&[6_u8; 32]);
|
|
let parsed_multisig = match super::parse_multisig(&multisig) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Multisig parse failed: {error}"),
|
|
};
|
|
assert_eq!(parsed_multisig.threshold, 2);
|
|
assert_eq!(parsed_multisig.members.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_layouts_and_option_tags_fail_closed() {
|
|
assert!(super::parse_mint(&[0_u8; 81]).is_err());
|
|
let mut account = [0_u8; super::TOKEN_ACCOUNT_LEN];
|
|
account[108] = 3;
|
|
assert!(super::parse_token_account(&account).is_err());
|
|
assert!(super::parse_coption_pubkey(&[2_u8; 36]).is_err());
|
|
assert!(super::parse_coption_u64(&[2_u8; 12]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn checked_transfer_state_checks_exact_mint_decimals_balance_and_owner() {
|
|
let mint_key = kb_lib::MdPubkey(bs58::encode([2_u8; 32]).into_string());
|
|
let owner_key = kb_lib::MdPubkey(bs58::encode([3_u8; 32]).into_string());
|
|
let source_key = kb_lib::MdPubkey(bs58::encode([7_u8; 32]).into_string());
|
|
let destination_key = kb_lib::MdPubkey(bs58::encode([8_u8; 32]).into_string());
|
|
let mut mint = [0_u8; super::MINT_LEN];
|
|
mint[44] = 9;
|
|
mint[45] = 1;
|
|
let mut source = [0_u8; super::TOKEN_ACCOUNT_LEN];
|
|
source[0..32].copy_from_slice(&[2_u8; 32]);
|
|
source[32..64].copy_from_slice(&[3_u8; 32]);
|
|
source[64..72].copy_from_slice(&42_u64.to_le_bytes());
|
|
source[108] = 1;
|
|
let mut destination = [0_u8; super::TOKEN_ACCOUNT_LEN];
|
|
destination[0..32].copy_from_slice(&[2_u8; 32]);
|
|
destination[32..64].copy_from_slice(&[9_u8; 32]);
|
|
destination[108] = 1;
|
|
let mut cache = super::AccountCache::new();
|
|
cache.insert(mint_key.0.clone(), std::option::Option::Some(account_info(mint.to_vec())));
|
|
cache
|
|
.insert(source_key.0.clone(), std::option::Option::Some(account_info(source.to_vec())));
|
|
cache.insert(
|
|
destination_key.0.clone(),
|
|
std::option::Option::Some(account_info(destination.to_vec())),
|
|
);
|
|
let mut report = empty_report();
|
|
super::inspect_single(
|
|
&mut report,
|
|
&cache,
|
|
&kb_lib::ExSplClassicTokenSingleOperation::TransferChecked {
|
|
source: source_key,
|
|
mint: mint_key,
|
|
destination: destination_key,
|
|
authority: kb_lib::ExSplClassicTokenAuthority {
|
|
authority: owner_key,
|
|
multisig_signers: std::vec::Vec::new(),
|
|
},
|
|
amount: kb_lib::ExSplClassicTokenAmount("42".to_string()),
|
|
decimals: 9,
|
|
},
|
|
);
|
|
assert!(report.checks.iter().all(|check| return check.passed));
|
|
assert!(report.checks.iter().any(|check| return check.code == "mint_decimals_match"));
|
|
assert!(
|
|
report
|
|
.checks
|
|
.iter()
|
|
.any(|check| return check.code == "source_balance_sufficient")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multisig_members_are_deduplicated_before_threshold_validation() {
|
|
let multisig_key = kb_lib::MdPubkey(bs58::encode([10_u8; 32]).into_string());
|
|
let member_one = kb_lib::MdPubkey(bs58::encode([11_u8; 32]).into_string());
|
|
let member_two = kb_lib::MdPubkey(bs58::encode([12_u8; 32]).into_string());
|
|
let mut multisig = [0_u8; super::MULTISIG_LEN];
|
|
multisig[0] = 2;
|
|
multisig[1] = 2;
|
|
multisig[2] = 1;
|
|
multisig[3..35].copy_from_slice(&[11_u8; 32]);
|
|
multisig[35..67].copy_from_slice(&[12_u8; 32]);
|
|
let mut cache = super::AccountCache::new();
|
|
cache.insert(
|
|
multisig_key.0.clone(),
|
|
std::option::Option::Some(account_info(multisig.to_vec())),
|
|
);
|
|
let mut report = empty_report();
|
|
super::check_authority(
|
|
&mut report,
|
|
&cache,
|
|
&kb_lib::ExSplClassicTokenAuthority {
|
|
authority: multisig_key.clone(),
|
|
multisig_signers: std::vec![member_one.clone(), member_one, member_two,],
|
|
},
|
|
std::option::Option::Some(multisig_key.0.as_str()),
|
|
);
|
|
assert!(report.checks.iter().all(|check| return check.passed));
|
|
assert!(report.checks.iter().any(|check| return check.code == "multisig_threshold"));
|
|
}
|
|
|
|
#[test]
|
|
fn operation_account_collection_is_bounded_and_deduplicated() {
|
|
let key = kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string());
|
|
let operation = kb_lib::ExSplClassicTokenOperation::Instruction {
|
|
value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked {
|
|
source: key.clone(),
|
|
mint: key.clone(),
|
|
destination: key.clone(),
|
|
authority: kb_lib::ExSplClassicTokenAuthority {
|
|
authority: key.clone(),
|
|
multisig_signers: std::vec::Vec::new(),
|
|
},
|
|
amount: kb_lib::ExSplClassicTokenAmount("1".to_string()),
|
|
decimals: 9,
|
|
},
|
|
};
|
|
let addresses = match super::operation_addresses(&operation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("address collection failed: {error}"),
|
|
};
|
|
assert_eq!(addresses, std::vec![key]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn optional_devnet_checked_transfer_readiness_from_env() {
|
|
if std::env::var("KB_DEVNET_SPL_TOKEN_PREFLIGHT_TEST").ok().as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
return;
|
|
}
|
|
let source = match std::env::var("KB_DEVNET_SPL_TOKEN_SOURCE") {
|
|
std::result::Result::Ok(value) => kb_lib::MdPubkey(value),
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_SPL_TOKEN_SOURCE is required: {error}");
|
|
},
|
|
};
|
|
let mint = match std::env::var("KB_DEVNET_SPL_TOKEN_MINT") {
|
|
std::result::Result::Ok(value) => kb_lib::MdPubkey(value),
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_SPL_TOKEN_MINT is required: {error}");
|
|
},
|
|
};
|
|
let destination = match std::env::var("KB_DEVNET_SPL_TOKEN_DESTINATION") {
|
|
std::result::Result::Ok(value) => kb_lib::MdPubkey(value),
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_SPL_TOKEN_DESTINATION is required: {error}");
|
|
},
|
|
};
|
|
let authority = match std::env::var("KB_DEVNET_SPL_TOKEN_AUTHORITY") {
|
|
std::result::Result::Ok(value) => kb_lib::MdPubkey(value),
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_SPL_TOKEN_AUTHORITY is required: {error}");
|
|
},
|
|
};
|
|
let decimals = match std::env::var("KB_DEVNET_SPL_TOKEN_DECIMALS") {
|
|
std::result::Result::Ok(value) => match value.parse::<u8>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_SPL_TOKEN_DECIMALS must be a u8: {error}");
|
|
},
|
|
},
|
|
std::result::Result::Err(_) => 9,
|
|
};
|
|
let amount = match std::env::var("KB_DEVNET_SPL_TOKEN_AMOUNT") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => "1".to_string(),
|
|
};
|
|
let pool =
|
|
match kb_onchain_transport::HttpEndpointPool::from_profile(&local_devnet_profile()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"),
|
|
};
|
|
let report = match crate::inspect_spl_token_stateful_readiness(
|
|
&pool,
|
|
&crate::SplTokenStatefulReadinessRequest {
|
|
query_role: "http_queries".to_string(),
|
|
cluster: kb_lib::ExApiExecutionCluster::Devnet,
|
|
operation: kb_lib::ExSplClassicTokenOperation::Instruction {
|
|
value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked {
|
|
source,
|
|
mint,
|
|
destination,
|
|
authority: kb_lib::ExSplClassicTokenAuthority {
|
|
authority,
|
|
multisig_signers: std::vec::Vec::new(),
|
|
},
|
|
amount: kb_lib::ExSplClassicTokenAmount(amount),
|
|
decimals,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Devnet Token preflight failed: {error}"),
|
|
};
|
|
assert_eq!(
|
|
report.status,
|
|
crate::SplTokenStatefulReadinessStatus::Ready,
|
|
"{:#?}",
|
|
report.checks
|
|
);
|
|
}
|
|
}
|