v0.1.0-pre.006

This commit is contained in:
2026-07-23 19:34:48 +02:00
parent 2a4479a1d4
commit 1eeae9fa4b
19 changed files with 1821 additions and 60 deletions

View File

@@ -1,5 +1,5 @@
// file: kb-lib/src/decoder.rs
// version: 2
// version: 3
//! Consolidated decoder modules.
@@ -49,8 +49,32 @@ pub(crate) use self::spl::SPL_MEMO_V1_SURFACE_CODE;
pub(crate) use self::spl::SPL_MEMO_V3_SURFACE_CODE;
/// Stable Memo v4 surface code.
pub(crate) use self::spl::SPL_MEMO_V4_SURFACE_CODE;
/// Maximum prefix retained in bounded classic SPL Token diagnostics.
pub(crate) use self::spl::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES;
/// Current stable classic SPL Token decoded event contract version.
pub(crate) use self::spl::SPL_TOKEN_EVENT_VERSION;
/// One exact classic SPL Token instruction entry published by the official interface.
pub(crate) use self::spl::SPL_TOKEN_INSTRUCTION_ENTRIES;
/// Maximum total account metas consumed by one classic SPL Token batch.
pub(crate) use self::spl::SPL_TOKEN_MAX_BATCH_ACCOUNTS;
/// Maximum decoded sub-instructions in one classic SPL Token batch.
pub(crate) use self::spl::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS;
/// Maximum retained classic SPL Token instruction payload size.
pub(crate) use self::spl::SPL_TOKEN_MAX_INSTRUCTION_BYTES;
/// Stable protocol and surface code for the classic SPL Token program.
pub(crate) use self::spl::SPL_TOKEN_SURFACE_CODE;
/// Canonical tracing target for the classic SPL Token decoder.
pub(crate) use self::spl::SPL_TOKEN_TRACING_TARGET;
/// Decodes one bounded SPL Memo instruction.
pub(crate) use self::spl::spl_memo_decode;
/// Decodes one bounded classic SPL Token instruction.
pub(crate) use self::spl::spl_token_decode;
/// Returns the published entry matching one classic SPL Token tag.
pub(crate) use self::spl::spl_token_entry_for_tag;
/// Returns the first byte of one retained classic SPL Token payload.
pub(crate) use self::spl::spl_token_payload_tag;
/// Exact decoder for the three registered SPL Memo generations.
pub use self::spl::SplMemoDecoder;
/// Exact decoder for the classic SPL Token program.
pub use self::spl::SplTokenDecoder;

View File

@@ -1,5 +1,5 @@
// file: kb-lib/src/decoder/spl.rs
// version: 2
// version: 3
//! `spl` decoder family.
@@ -10,7 +10,7 @@ pub mod memo;
pub mod noop;
pub mod single_pool;
pub mod stake_pool;
pub mod token;
mod token;
pub mod token_2022;
/// Maximum payload prefix retained for bounded diagnostics.
@@ -31,6 +31,30 @@ pub(crate) use self::memo::SPL_MEMO_V3_SURFACE_CODE;
pub(crate) use self::memo::SPL_MEMO_V4_SURFACE_CODE;
/// Decodes one bounded SPL Memo instruction.
pub(crate) use self::memo::spl_memo_decode;
/// Maximum prefix retained in bounded classic SPL Token diagnostics.
pub(crate) use self::token::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES;
/// Current stable classic SPL Token decoded event contract version.
pub(crate) use self::token::SPL_TOKEN_EVENT_VERSION;
/// One exact classic SPL Token instruction entry published by the official interface.
pub(crate) use self::token::SPL_TOKEN_INSTRUCTION_ENTRIES;
/// Maximum total account metas consumed by one classic SPL Token batch.
pub(crate) use self::token::SPL_TOKEN_MAX_BATCH_ACCOUNTS;
/// Maximum decoded sub-instructions in one classic SPL Token batch.
pub(crate) use self::token::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS;
/// Maximum retained classic SPL Token instruction payload size.
pub(crate) use self::token::SPL_TOKEN_MAX_INSTRUCTION_BYTES;
/// Stable protocol and surface code for the classic SPL Token program.
pub(crate) use self::token::SPL_TOKEN_SURFACE_CODE;
/// Canonical tracing target for the classic SPL Token decoder.
pub(crate) use self::token::SPL_TOKEN_TRACING_TARGET;
/// Decodes one bounded classic SPL Token instruction.
pub(crate) use self::token::spl_token_decode;
/// Returns the published entry matching one classic SPL Token tag.
pub(crate) use self::token::spl_token_entry_for_tag;
/// Returns the first byte of one retained classic SPL Token payload.
pub(crate) use self::token::spl_token_payload_tag;
/// Exact decoder for the three registered SPL Memo generations.
pub use self::memo::SplMemoDecoder;
/// Exact decoder for the classic SPL Token program.
pub use self::token::SplTokenDecoder;

View File

@@ -1,10 +1,34 @@
// file: kb-lib/src/decoder/spl/token.rs
// version: 1
// version: 2
//! Migration boundary for legacy crate `kb_decoder_spl_token`.
//! Exact classic SPL Token decoder component.
/// Legacy crate name retained for migration and compatibility tracking.
pub const LEGACY_CRATE: &str = "kb_decoder_spl_token";
mod constants;
mod decoder;
mod wire;
/// Current porting status.
pub const MIGRATION_STATUS: &str = "source-preserved-pending-port";
/// Maximum prefix retained in bounded diagnostics.
pub(crate) use self::constants::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES;
/// Current stable decoded event contract version.
pub(crate) use self::constants::SPL_TOKEN_EVENT_VERSION;
/// One exact instruction entry published by `spl-token-interface` 3.0.0.
pub(crate) use self::constants::SPL_TOKEN_INSTRUCTION_ENTRIES;
/// Maximum total account metas consumed by one batch.
pub(crate) use self::constants::SPL_TOKEN_MAX_BATCH_ACCOUNTS;
/// Maximum decoded sub-instructions in one batch.
pub(crate) use self::constants::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS;
/// Maximum retained instruction payload size.
pub(crate) use self::constants::SPL_TOKEN_MAX_INSTRUCTION_BYTES;
/// Stable protocol and surface code for the classic SPL Token program.
pub(crate) use self::constants::SPL_TOKEN_SURFACE_CODE;
/// Canonical tracing target for the classic SPL Token decoder.
pub(crate) use self::constants::SPL_TOKEN_TRACING_TARGET;
/// Decodes one bounded classic SPL Token instruction.
pub(crate) use self::wire::decode as spl_token_decode;
/// Returns the published entry matching one classic SPL Token tag.
pub(crate) use self::wire::entry_for_tag as spl_token_entry_for_tag;
/// Returns the first byte of one retained classic SPL Token payload.
pub(crate) use self::wire::payload_tag as spl_token_payload_tag;
/// Exact decoder for the classic SPL Token program.
pub use self::decoder::SplTokenDecoder;

View File

@@ -0,0 +1,50 @@
// file: kb-lib/src/decoder/spl/token/constants.rs
// version: 1
//! Local constants for the `kb-lib` SPL Token component. Program identifiers live in `kb_program_ids`.
/// Stable protocol and surface code for the classic SPL Token program.
pub(crate) const SPL_TOKEN_SURFACE_CODE: &str = "spl_token";
/// Current stable decoded event contract version.
pub(crate) const SPL_TOKEN_EVENT_VERSION: u32 = 1;
/// Maximum retained instruction payload size.
pub(crate) const SPL_TOKEN_MAX_INSTRUCTION_BYTES: usize = 16_384;
/// Maximum prefix retained in diagnostics.
pub(crate) const SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES: usize = 32;
/// Maximum decoded sub-instructions in one batch.
pub(crate) const SPL_TOKEN_MAX_BATCH_INSTRUCTIONS: usize = 64;
/// Maximum total account metas consumed by one batch.
pub(crate) const SPL_TOKEN_MAX_BATCH_ACCOUNTS: usize = 512;
/// One exact instruction entry published by `spl-token-interface` 3.0.0.
pub(crate) const SPL_TOKEN_INSTRUCTION_ENTRIES: &[(u8, &str, bool)] = &[
(0, "initialize_mint", true),
(1, "initialize_account", true),
(2, "initialize_multisig", true),
(3, "transfer", true),
(4, "approve", true),
(5, "revoke", true),
(6, "set_authority", true),
(7, "mint_to", true),
(8, "burn", true),
(9, "close_account", true),
(10, "freeze_account", true),
(11, "thaw_account", true),
(12, "transfer_checked", false),
(13, "approve_checked", false),
(14, "mint_to_checked", false),
(15, "burn_checked", false),
(16, "initialize_account2", true),
(17, "sync_native", false),
(18, "initialize_account3", false),
(19, "initialize_multisig2", false),
(20, "initialize_mint2", false),
(21, "get_account_data_size", false),
(22, "initialize_immutable_owner", false),
(23, "amount_to_ui_amount", false),
(24, "ui_amount_to_amount", false),
(38, "withdraw_excess_lamports", false),
(45, "unwrap_lamports", false),
(255, "batch", false),
];
/// Canonical tracing target for this crate.
pub(crate) const SPL_TOKEN_TRACING_TARGET: &str = "kb-lib.decoder.spl.token";

View File

@@ -0,0 +1,500 @@
// file: kb-lib/src/decoder/spl/token/decoder.rs
// version: 1
//! Exact classic SPL Token dispatch for the common decode pipeline.
const TOKEN_SURFACES: &[crate::DecoderSurface] = &[crate::DecoderSurface {
program_id: kb_program_ids::SPL_TOKEN_PROGRAM_ID,
surface_code: crate::SPL_TOKEN_SURFACE_CODE,
priority: 100,
}];
const PROGRAM_IDS: &[&str] = &[kb_program_ids::SPL_TOKEN_PROGRAM_ID];
/// Exact decoder for the classic SPL Token program.
#[derive(Clone, Debug, Default)]
pub struct SplTokenDecoder;
impl crate::ProtocolDecoder for crate::SplTokenDecoder {
fn decoder_name(&self) -> &'static str {
return "kb_decoder_spl_token";
}
fn decoder_version(&self) -> &'static str {
return env!("CARGO_PKG_VERSION");
}
fn program_ids(&self) -> &'static [&'static str] {
return PROGRAM_IDS;
}
fn supports_observation(
&self,
observation: &crate::ProgramObservation,
) -> crate::DecoderSupport {
return if crate::ProtocolDecoder::handles_program_id(self, &observation.program_id) {
crate::DecoderSupport::Yes
} else {
crate::DecoderSupport::No
};
}
fn decode_observation(
&self,
_observation: &crate::ProgramObservation,
) -> kb_core::Result<std::vec::Vec<crate::DecodedProtocolEvent>> {
return std::result::Result::Ok(std::vec::Vec::new());
}
}
impl crate::InstructionDecoder for crate::SplTokenDecoder {
fn identity(&self) -> crate::DecoderIdentity {
return crate::DecoderIdentity {
name: "spl_token".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
}
fn surfaces(&self) -> &'static [crate::DecoderSurface] {
return TOKEN_SURFACES;
}
fn coverage(&self) -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
return crate::SPL_TOKEN_INSTRUCTION_ENTRIES
.iter()
.map(|(tag, code, historical)| {
return crate::DecoderCoverageDeclaration {
program_id: kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string(),
surface_code: std::option::Option::Some(
crate::SPL_TOKEN_SURFACE_CODE.to_string(),
),
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
entry_code: (*code).to_string(),
discriminator_hex: std::option::Option::Some(format!("{tag:02x}")),
historical: *historical,
};
})
.collect();
}
fn recognize(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderRecognition {
if input.program_id != kb_program_ids::SPL_TOKEN_PROGRAM_ID {
return crate::DecoderRecognition::incompatible();
}
let tag = crate::spl_token_payload_tag(input);
let entry = tag.and_then(crate::spl_token_entry_for_tag);
return crate::DecoderRecognition::compatible(
entry.is_some(),
100,
std::option::Option::Some(crate::SPL_TOKEN_SURFACE_CODE.to_string()),
entry.map(|(_, code, _)| return code.to_string()),
tag.map(|value| return format!("{value:02x}")),
);
}
fn decode(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderExecutionResult {
if input.program_id != kb_program_ids::SPL_TOKEN_PROGRAM_ID {
return crate::DecoderExecutionResult::unsupported(std::option::Option::None);
}
let result = crate::spl_token_decode(input);
if matches!(
result.status,
crate::DecoderOutcomeStatus::Failed | crate::DecoderOutcomeStatus::Unsupported
) {
tracing::error!(
target: crate::SPL_TOKEN_TRACING_TARGET,
action = "decode_failure",
signature = %input.signature,
slot = input.slot,
instruction_path = %input.instruction_path,
program_id = %input.program_id,
processor_name = "spl_token",
processor_version = env!("CARGO_PKG_VERSION"),
input_key = %input.replay_input_key,
payload_hash = ?input.instruction_payload_hash,
result_status = ?result.status,
diagnostics = ?result.diagnostics,
"classic SPL Token instruction was not decoded successfully"
);
}
tracing::debug!(
target: crate::SPL_TOKEN_TRACING_TARGET,
action = "decode",
signature = %input.signature,
instruction_path = %input.instruction_path,
transaction_failed = input.transaction_failed,
result_status = ?result.status,
observation_count = result.observations.len(),
"classic SPL Token instruction decode completed"
);
return result;
}
}
#[cfg(test)]
mod tests {
use base64::Engine; // rust-rules: trait-import
fn input(
program_id: &str,
payload: &[u8],
accounts: serde_json::Value,
keys: serde_json::Value,
failed: bool,
path: &str,
) -> crate::CoreInstructionReplayInput {
let result = crate::CoreInstructionReplayInput::new(
format!("signature:{path}"),
"signature",
42,
path,
program_id,
failed,
if failed {
std::option::Option::Some(serde_json::json!({"InstructionError":[0,"Custom"]}))
} else {
std::option::Option::None
},
keys,
accounts,
std::option::Option::Some(serde_json::json!({
"dataBase64": base64::engine::general_purpose::STANDARD.encode(payload)
})),
std::option::Option::None,
serde_json::json!([]),
serde_json::json!([]),
serde_json::json!([]),
serde_json::json!([]),
);
return match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("Token replay input failed: {error}"),
};
}
fn minimal_wire(tag: u8) -> std::vec::Vec<u8> {
return match tag {
0 | 20 => {
let mut value = std::vec![tag, 9];
value.extend_from_slice(&[1; 32]);
value.push(0);
value
},
2 | 19 => std::vec![tag, 1],
3 | 4 | 7 | 8 | 23 => {
let mut value = std::vec![tag];
value.extend_from_slice(&1_u64.to_le_bytes());
value
},
6 => std::vec![tag, 0, 0],
12 | 13 | 14 | 15 => {
let mut value = std::vec![tag];
value.extend_from_slice(&1_u64.to_le_bytes());
value.push(9);
value
},
16 | 18 => {
let mut value = std::vec![tag];
value.extend_from_slice(&[2; 32]);
value
},
24 => std::vec![tag, b'1'],
45 => std::vec![tag, 0],
255 => std::vec![tag, 0, 1, 1],
_ => std::vec![tag],
};
}
fn account(position: usize, signer: bool, writable: bool) -> serde_json::Value {
return serde_json::json!({
"position": position,
"accountIndex": position,
"accountKey": format!("account{position}"),
"signer": signer,
"writable": writable,
"source": "static",
});
}
#[test]
fn exact_support_program_id_and_interface_match() {
assert_eq!(spl_token_interface::ID.to_string(), kb_program_ids::SPL_TOKEN_PROGRAM_ID);
let decoder = crate::SplTokenDecoder;
assert_eq!(crate::InstructionDecoder::surfaces(&decoder).len(), 1);
let observation = crate::ProgramObservation {
signature: crate::Signature("signature".to_string()),
slot: crate::Slot(1),
instruction_path: crate::InstructionPath("0".to_string()),
program_id: crate::ProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()),
discriminator_8: std::option::Option::None,
data_len: 1,
accounts_len: 0,
failed: false,
};
assert_eq!(
crate::ProtocolDecoder::supports_observation(&decoder, &observation),
crate::DecoderSupport::Yes
);
}
#[test]
fn coverage_matrix_and_compiled_entries_are_equal() {
let matrix: serde_json::Value =
match serde_json::from_str(include_str!("../../../../../docs/SPL_TOKEN_MATRIX.json")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("SPL Token matrix is invalid: {error}"),
};
let rows = match matrix.get("instructions").and_then(serde_json::Value::as_array) {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("SPL Token matrix has no instructions"),
};
let matrix_entries = rows
.iter()
.map(|row| {
return (
row.get("tag").and_then(serde_json::Value::as_u64).unwrap_or(u64::MAX) as u8,
row.get("name").and_then(serde_json::Value::as_str).unwrap_or(""),
);
})
.collect::<std::vec::Vec<_>>();
let compiled_entries = crate::SPL_TOKEN_INSTRUCTION_ENTRIES
.iter()
.map(|(tag, code, _)| return (*tag, *code))
.collect::<std::vec::Vec<_>>();
assert_eq!(matrix_entries, compiled_entries);
assert_eq!(crate::InstructionDecoder::coverage(&crate::SplTokenDecoder).len(), 28);
}
#[test]
fn cluster_evidence_scopes_recent_instruction_deployment_exactly() {
let matrix: serde_json::Value =
match serde_json::from_str(include_str!("../../../../../docs/SPL_TOKEN_MATRIX.json")) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("SPL Token matrix is invalid: {error}"),
};
assert_eq!(
matrix["deploymentAudit"]["mainnet"]["status"],
"real_corpus_decode_and_materialization_validated"
);
assert_eq!(matrix["corpusAudit"]["mainnet"]["invariants"]["uncommittedOutputs"], 0);
assert_eq!(matrix["corpusAudit"]["mainnet"]["invariants"]["secondReplaySelected"], 0);
assert_eq!(matrix["corpusAudit"]["devnet"]["recentInstructionProbe"]["submission"], false);
assert_eq!(
matrix["corpusAudit"]["devnet"]["recentInstructionProbe"]["batch"]["success"],
true
);
assert_eq!(
matrix["corpusAudit"]["devnet"]["recentInstructionProbe"]["batch"]["computeUnits"],
270
);
assert_eq!(
matrix["corpusAudit"]["devnet"]["recentInstructionProbe"]["unwrapLamports"]["success"],
true
);
assert_eq!(
matrix["corpusAudit"]["devnet"]["recentInstructionProbe"]["unwrapLamports"]["computeUnits"],
140
);
let instructions = match matrix.get("instructions").and_then(serde_json::Value::as_array) {
std::option::Option::Some(value) => value,
std::option::Option::None => panic!("SPL Token matrix has no instructions"),
};
for recent_name in ["unwrap_lamports", "batch"] {
let recent = match instructions.iter().find(|row| {
return row.get("name").and_then(serde_json::Value::as_str)
== std::option::Option::Some(recent_name);
}) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
panic!("recent SPL Token instruction {recent_name} is absent")
},
};
assert_eq!(recent["executorSupport"]["constructibility"], "official_builder_proven");
assert_eq!(
recent["executorSupport"]["clusterDeployment"],
"devnet_simulation_succeeded_2026-07-16"
);
assert_eq!(recent["clusterStatus"]["localnet"], "not_tested");
assert_eq!(recent["clusterStatus"]["devnet"], "simulation_succeeded_2026-07-16");
assert_eq!(recent["clusterStatus"]["mainnet"], "not_tested");
assert_eq!(recent["realSignatures"].as_array().map(std::vec::Vec::len), Some(0));
}
}
#[test]
fn every_published_tag_decodes_and_matches_official_unpack() {
for (tag, code, _) in crate::SPL_TOKEN_INSTRUCTION_ENTRIES {
let wire = minimal_wire(*tag);
let official = spl_token_interface::instruction::TokenInstruction::unpack(&wire);
assert!(official.is_ok(), "official unpack rejected tag {tag}");
let input = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
wire.as_slice(),
serde_json::json!([]),
serde_json::json!([]),
false,
"0",
);
let result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &input);
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
assert_eq!(result.recognized_entry_code.as_deref(), std::option::Option::Some(*code));
}
}
#[test]
fn failed_transfer_is_an_exact_uncommitted_inner_intent_without_invented_mint() {
let mut wire = std::vec![3];
wire.extend_from_slice(&u64::MAX.to_le_bytes());
let accounts = serde_json::json!([
{"position":0,"accountIndex":0,"accountKey":"source"},
{"position":1,"accountIndex":1,"accountKey":"destination"},
{"position":2,"accountIndex":2,"accountKey":"authority"}
]);
let keys = serde_json::json!([
{"accountIndex":0,"accountKey":"source","signer":false,"writable":true,"source":"static"},
{"accountIndex":1,"accountKey":"destination","signer":false,"writable":true,"source":"static"},
{"accountIndex":2,"accountKey":"authority","signer":true,"writable":false,"source":"static"}
]);
let input = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
wire.as_slice(),
accounts,
keys,
true,
"2/1",
);
let result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &input);
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
assert!(!result.observations[0].observation_committed);
assert_eq!(
result.observations[0].payload_json["parameters"]["amountRaw"],
u64::MAX.to_string()
);
assert_eq!(result.observations[0].payload_json["inference"]["mintInvented"], false);
assert_eq!(
result.observations[0].event.source_kind,
crate::EventSourceKind::InnerInstruction
);
}
#[test]
fn suffix_unknown_empty_truncated_and_invalid_utf8_are_bounded() {
let mut transfer = minimal_wire(3);
transfer.extend_from_slice(&[0xaa, 0xbb]);
let suffix = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
transfer.as_slice(),
serde_json::json!([]),
serde_json::json!([]),
false,
"0",
);
let suffix_result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &suffix);
assert_eq!(suffix_result.status, crate::DecoderOutcomeStatus::Decoded);
assert_eq!(suffix_result.observations[0].payload_json["wire"]["suffixLengthBytes"], 2);
for wire in [std::vec::Vec::new(), std::vec![3, 1], std::vec![24, 0xff], std::vec![255]] {
let input = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
wire.as_slice(),
serde_json::json!([]),
serde_json::json!([]),
false,
"0",
);
let result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &input);
assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed);
assert!(result.observations.is_empty());
}
let unknown = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
&[44],
serde_json::json!([]),
serde_json::json!([]),
false,
"0",
);
let result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &unknown);
assert_eq!(result.status, crate::DecoderOutcomeStatus::Unsupported);
assert!(!result.diagnostics.is_empty());
}
#[test]
fn batch_preserves_order_slices_paths_and_rejects_nested_batch() {
let mut batch = std::vec![255, 0, 9, 3];
batch.extend_from_slice(&1_u64.to_le_bytes());
batch.extend_from_slice(&[0, 1, 17]);
let cinput = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
batch.as_slice(),
serde_json::json!([]),
serde_json::json!([]),
false,
"4/2",
);
let result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &cinput);
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
assert_eq!(result.observations.len(), 3);
assert_eq!(result.observations[1].event.instruction_path.0, "4/2/batch/0");
assert_eq!(result.observations[2].event.instruction_path.0, "4/2/batch/1");
assert_eq!(result.observations[0].payload_json["parameters"]["subInstructionCount"], 2);
let nested = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
&[255, 0, 1, 255],
serde_json::json!([]),
serde_json::json!([]),
false,
"0",
);
let nested_result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &nested);
assert_eq!(nested_result.status, crate::DecoderOutcomeStatus::Failed);
}
#[test]
fn authority_forms_and_multisig_threshold_diagnostics_are_explicit() {
let wire = minimal_wire(3);
let simple_accounts = serde_json::json!([
{"position":0,"accountIndex":0,"accountKey":"account0"},
{"position":1,"accountIndex":1,"accountKey":"account1"},
{"position":2,"accountIndex":2,"accountKey":"account2"}
]);
let simple_keys = serde_json::json!([
account(0, false, true),
account(1, false, true),
account(2, true, false)
]);
let simple = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
wire.as_slice(),
simple_accounts.clone(),
simple_keys,
false,
"0",
);
let simple_result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &simple);
assert_eq!(simple_result.observations[0].payload_json["authority"]["form"], "single");
let mut multisig_accounts = simple_accounts.as_array().cloned().unwrap_or_default();
multisig_accounts
.push(serde_json::json!({"position":3,"accountIndex":3,"accountKey":"account3"}));
let multisig_keys = serde_json::json!([
account(0, false, true),
account(1, false, true),
account(2, false, false),
account(3, true, false)
]);
let multisig = input(
kb_program_ids::SPL_TOKEN_PROGRAM_ID,
wire.as_slice(),
serde_json::Value::Array(multisig_accounts),
multisig_keys,
false,
"0",
);
let multisig_result = crate::InstructionDecoder::decode(&crate::SplTokenDecoder, &multisig);
assert_eq!(multisig_result.observations[0].payload_json["authority"]["form"], "multisig");
assert_eq!(
multisig_result.observations[0].payload_json["authority"]["statefulMultisigValidation"],
"requires_multisig_account_snapshot"
);
}
}

View File

@@ -0,0 +1,999 @@
// file: kb-lib/src/decoder/spl/token/wire.rs
// version: 1
//! Bounded classic SPL Token wire, account and authority decoding.
use base64::Engine; // rust-rules: trait-import
use sha2::Digest; // rust-rules: trait-import
#[derive(Clone, Debug)]
struct ParsedInstruction {
code: &'static str,
tag: u8,
parameters: serde_json::Value,
consumed: usize,
}
#[derive(Clone, Debug)]
struct ResolvedAccount {
position: usize,
account_index: u64,
account_key: std::string::String,
signer: bool,
writable: bool,
source: serde_json::Value,
}
pub(crate) fn entry_for_tag(tag: u8) -> std::option::Option<(u8, &'static str, bool)> {
return crate::SPL_TOKEN_INSTRUCTION_ENTRIES
.iter()
.copied()
.find(|(candidate, _, _)| return *candidate == tag);
}
pub(crate) fn payload_tag(input: &crate::CoreInstructionReplayInput) -> std::option::Option<u8> {
let bytes = decode_payload(input).ok();
return bytes.and_then(|value| return value.first().copied());
}
pub(crate) fn decode(input: &crate::CoreInstructionReplayInput) -> crate::DecoderExecutionResult {
let bytes = match decode_payload(input) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return failed(
std::option::Option::None,
"token_payload_unavailable",
error.to_string(),
);
},
};
let tag = match bytes.first().copied() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return failed(
std::option::Option::None,
"token_payload_empty",
"classic SPL Token instruction payload is empty".to_string(),
);
},
};
let entry = match crate::spl_token_entry_for_tag(tag) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return crate::DecoderExecutionResult {
status: crate::DecoderOutcomeStatus::Unsupported,
recognized_entry_code: std::option::Option::None,
observations: std::vec::Vec::new(),
diagnostics: std::vec![crate::DecoderDiagnostic {
code: "unknown_token_instruction_tag".to_string(),
message: format!(
"unknown classic SPL Token tag {tag}; payloadPrefixHex={}",
hex_prefix(bytes.as_slice())
),
retriable: false,
}],
};
},
};
let parsed = match parse_instruction(bytes.as_slice(), false) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => {
return failed(
std::option::Option::Some(entry.1),
"malformed_token_instruction",
format!("{message}; payloadPrefixHex={}", hex_prefix(bytes.as_slice())),
);
},
};
let accounts = match resolve_accounts(input) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return failed(
std::option::Option::Some(parsed.code),
"token_accounts_malformed",
error.to_string(),
);
},
};
let (parameters, batch_children) = if parsed.code == "batch" {
match batch_observations(input, bytes.as_slice(), accounts.as_slice()) {
std::result::Result::Ok((children, manifest)) => (manifest, children),
std::result::Result::Err(message) => {
return failed(
std::option::Option::Some("batch"),
"malformed_token_batch",
message,
);
},
}
} else {
(parsed.parameters.clone(), std::vec::Vec::new())
};
let account_projection = project_accounts(parsed.code, accounts.as_slice());
let suffix_length = bytes.len().saturating_sub(parsed.consumed);
let authority = authority_projection(parsed.code, accounts.as_slice());
let semantic_diagnostics =
semantic_diagnostics(parsed.code, parameters.clone(), accounts.as_slice(), suffix_length);
let committed = !input.transaction_failed;
let parent = observation(
input,
parsed.code,
parsed.tag,
parameters,
account_projection,
authority,
semantic_diagnostics,
bytes.as_slice(),
suffix_length,
committed,
format!("token:{}:0", parsed.code),
input.instruction_path.clone(),
event_family(parsed.code),
);
let mut observations = std::vec![parent];
observations.extend(batch_children);
return crate::DecoderExecutionResult {
status: crate::DecoderOutcomeStatus::Decoded,
recognized_entry_code: std::option::Option::Some(parsed.code.to_string()),
observations,
diagnostics: std::vec::Vec::new(),
};
}
fn decode_payload(input: &crate::CoreInstructionReplayInput) -> kb_core::Result<std::vec::Vec<u8>> {
let payload = match input.instruction_payload_json.as_ref() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(
"classic SPL Token instruction payload is not retained",
));
},
};
let encoded = match payload.get("dataBase64").and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(
"classic SPL Token payload does not contain dataBase64",
));
},
};
let maximum_encoded = crate::SPL_TOKEN_MAX_INSTRUCTION_BYTES
.saturating_mul(4)
.saturating_div(3)
.saturating_add(4);
if encoded.len() > maximum_encoded {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token base64 payload exceeds {maximum_encoded} bytes"
)));
}
let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token payload is not valid base64: {error}"
)));
},
};
if decoded.len() > crate::SPL_TOKEN_MAX_INSTRUCTION_BYTES {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token payload exceeds {} decoded bytes",
crate::SPL_TOKEN_MAX_INSTRUCTION_BYTES
)));
}
return std::result::Result::Ok(decoded);
}
fn parse_instruction(
bytes: &[u8],
nested_in_batch: bool,
) -> std::result::Result<ParsedInstruction, std::string::String> {
let tag = match bytes.first().copied() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err("empty instruction".to_string());
},
};
let code = match crate::spl_token_entry_for_tag(tag) {
std::option::Option::Some((_, value, _)) => value,
std::option::Option::None => {
return std::result::Result::Err(format!("unknown instruction tag {tag}"));
},
};
if nested_in_batch && tag == 255 {
return std::result::Result::Err("nested Batch instruction is forbidden".to_string());
}
let result = match tag {
0 | 20 => parse_initialize_mint(bytes, code, tag),
1 | 5 | 9 | 10 | 11 | 17 | 21 | 22 | 38 => std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({}),
consumed: 1,
}),
255 => std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({}),
consumed: bytes.len(),
}),
2 | 19 => parse_one_u8(bytes, code, tag, "m"),
3 | 4 | 7 | 8 | 23 => parse_one_u64(bytes, code, tag, "amountRaw"),
6 => parse_set_authority(bytes, code, tag),
12 | 13 | 14 | 15 => parse_amount_decimals(bytes, code, tag),
16 | 18 => parse_pubkey_parameter(bytes, code, tag, "owner"),
24 => parse_ui_amount(bytes, code, tag),
45 => parse_unwrap_lamports(bytes, code, tag),
_ => std::result::Result::Err(format!("unsupported instruction tag {tag}")),
};
return result;
}
fn parse_initialize_mint(
bytes: &[u8],
code: &'static str,
tag: u8,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 35 {
return std::result::Result::Err(format!("{code} requires at least 35 bytes"));
}
let mint_authority = pubkey_text(&bytes[2..34]);
let (freeze_authority, option_length) = match pubkey_option(&bytes[34..]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => return std::result::Result::Err(message),
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({
"decimals": bytes[1],
"mintAuthority": mint_authority,
"freezeAuthority": freeze_authority,
}),
consumed: 34 + option_length,
});
}
fn parse_one_u8(
bytes: &[u8],
code: &'static str,
tag: u8,
field: &str,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 2 {
return std::result::Result::Err(format!("{code} requires 2 bytes"));
}
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: one_parameter(field, serde_json::Value::from(bytes[1])),
consumed: 2,
});
}
fn parse_one_u64(
bytes: &[u8],
code: &'static str,
tag: u8,
field: &str,
) -> std::result::Result<ParsedInstruction, std::string::String> {
let amount = match read_u64(bytes, 1) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => return std::result::Result::Err(message),
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: one_parameter(field, serde_json::Value::String(amount.to_string())),
consumed: 9,
});
}
fn parse_set_authority(
bytes: &[u8],
code: &'static str,
tag: u8,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 3 {
return std::result::Result::Err("set_authority requires at least 3 bytes".to_string());
}
let authority_type = match bytes[1] {
0 => "mint_tokens",
1 => "freeze_account",
2 => "account_owner",
3 => "close_account",
value => return std::result::Result::Err(format!("invalid authority type {value}")),
};
let (new_authority, option_length) = match pubkey_option(&bytes[2..]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => return std::result::Result::Err(message),
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({
"authorityType": authority_type,
"newAuthority": new_authority,
}),
consumed: 2 + option_length,
});
}
fn parse_amount_decimals(
bytes: &[u8],
code: &'static str,
tag: u8,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 10 {
return std::result::Result::Err(format!("{code} requires 10 bytes"));
}
let amount = match read_u64(bytes, 1) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => return std::result::Result::Err(message),
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({
"amountRaw": amount.to_string(),
"decimals": bytes[9],
}),
consumed: 10,
});
}
fn parse_pubkey_parameter(
bytes: &[u8],
code: &'static str,
tag: u8,
field: &str,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 33 {
return std::result::Result::Err(format!("{code} requires 33 bytes"));
}
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: one_parameter(field, serde_json::Value::String(pubkey_text(&bytes[1..33]))),
consumed: 33,
});
}
fn parse_ui_amount(
bytes: &[u8],
code: &'static str,
tag: u8,
) -> std::result::Result<ParsedInstruction, std::string::String> {
let text = match std::str::from_utf8(&bytes[1..]) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(format!(
"ui amount is not UTF-8 at byte {}",
error.valid_up_to()
));
},
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({"uiAmount": text}),
consumed: bytes.len(),
});
}
fn parse_unwrap_lamports(
bytes: &[u8],
code: &'static str,
tag: u8,
) -> std::result::Result<ParsedInstruction, std::string::String> {
if bytes.len() < 2 {
return std::result::Result::Err("unwrap_lamports requires an option tag".to_string());
}
let (amount, consumed) = match bytes[1] {
0 => (serde_json::Value::Null, 2),
1 => {
let value = match read_u64(bytes, 2) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => return std::result::Result::Err(message),
};
(serde_json::Value::String(value.to_string()), 10)
},
value => return std::result::Result::Err(format!("invalid unwrap amount option {value}")),
};
return std::result::Result::Ok(ParsedInstruction {
code,
tag,
parameters: serde_json::json!({"amountLamports": amount}),
consumed,
});
}
fn read_u64(bytes: &[u8], offset: usize) -> std::result::Result<u64, std::string::String> {
let slice = match bytes.get(offset..offset.saturating_add(8)) {
std::option::Option::Some(value) if value.len() == 8 => value,
_ => return std::result::Result::Err("truncated little-endian u64".to_string()),
};
let array = match <[u8; 8]>::try_from(slice) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_error) => {
return std::result::Result::Err("invalid little-endian u64".to_string());
},
};
return std::result::Result::Ok(u64::from_le_bytes(array));
}
fn pubkey_option(
bytes: &[u8],
) -> std::result::Result<(serde_json::Value, usize), std::string::String> {
return match bytes.first().copied() {
std::option::Option::Some(0) => std::result::Result::Ok((serde_json::Value::Null, 1)),
std::option::Option::Some(1) if bytes.len() >= 33 => {
std::result::Result::Ok((serde_json::Value::String(pubkey_text(&bytes[1..33])), 33))
},
std::option::Option::Some(1) => {
std::result::Result::Err("truncated optional pubkey".to_string())
},
std::option::Option::Some(value) => {
std::result::Result::Err(format!("invalid optional pubkey tag {value}"))
},
std::option::Option::None => {
std::result::Result::Err("missing optional pubkey tag".to_string())
},
};
}
fn pubkey_text(bytes: &[u8]) -> std::string::String {
return bs58::encode(bytes).into_string();
}
fn one_parameter(field: &str, value: serde_json::Value) -> serde_json::Value {
let mut parameters = serde_json::Map::new();
parameters.insert(field.to_string(), value);
return serde_json::Value::Object(parameters);
}
fn resolve_accounts(
input: &crate::CoreInstructionReplayInput,
) -> kb_core::Result<std::vec::Vec<ResolvedAccount>> {
let instruction_accounts = match input.instruction_accounts_json.as_array() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(
"classic SPL Token instruction accounts must be a JSON array",
));
},
};
let account_keys = match input.account_keys_json.as_array() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(
"classic SPL Token transaction account keys must be a JSON array",
));
},
};
let mut output = std::vec::Vec::with_capacity(instruction_accounts.len());
for (position, account) in instruction_accounts.iter().enumerate() {
let account_index = match account.get("accountIndex").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token instruction account {position} has no accountIndex"
)));
},
};
let account_key = match account.get("accountKey").and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) if !value.trim().is_empty() => value,
_ => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token instruction account {position} has no accountKey"
)));
},
};
let resolved = account_keys.iter().find(|candidate| {
return candidate.get("accountIndex").and_then(serde_json::Value::as_u64)
== std::option::Option::Some(account_index);
});
let resolved = match resolved {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token account index {account_index} is not resolved"
)));
},
};
if resolved.get("accountKey").and_then(serde_json::Value::as_str)
!= std::option::Option::Some(account_key)
{
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token account index {account_index} resolves to a different key"
)));
}
let signer = match resolved.get("signer").and_then(serde_json::Value::as_bool) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token account index {account_index} has no signer flag"
)));
},
};
let writable = match resolved.get("writable").and_then(serde_json::Value::as_bool) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
"classic SPL Token account index {account_index} has no writable flag"
)));
},
};
output.push(ResolvedAccount {
position,
account_index,
account_key: account_key.to_string(),
signer,
writable,
source: resolved.get("source").cloned().unwrap_or(serde_json::Value::Null),
});
}
return std::result::Result::Ok(output);
}
fn project_accounts(code: &str, accounts: &[ResolvedAccount]) -> serde_json::Value {
let authority_index = authority_index(code);
let values = accounts
.iter()
.enumerate()
.map(|(role_position, account)| {
return serde_json::json!({
"position": account.position,
"rolePosition": role_position,
"accountIndex": account.account_index,
"accountKey": account.account_key,
"signer": account.signer,
"writable": account.writable,
"source": account.source,
"role": account_role(code, role_position, authority_index),
});
})
.collect::<std::vec::Vec<_>>();
return serde_json::Value::Array(values);
}
fn authority_projection(code: &str, accounts: &[ResolvedAccount]) -> serde_json::Value {
let authority_index = authority_index(code);
let index = match authority_index {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return serde_json::json!({
"form": "not_applicable",
"declaredAuthority": serde_json::Value::Null,
"providedSigners": [],
"statefulMultisigValidation": "not_applicable",
});
},
};
let authority = accounts.get(index);
let provided_signers = accounts
.iter()
.skip(index.saturating_add(1))
.filter(|account| return account.signer)
.map(|account| return account.account_key.clone())
.collect::<std::vec::Vec<_>>();
let form = if accounts.len() > index.saturating_add(1) { "multisig" } else { "single" };
return serde_json::json!({
"form": form,
"declaredAuthority": authority.map(|value| return value.account_key.clone()),
"declaredAuthoritySigner": authority.map(|value| return value.signer),
"providedSigners": provided_signers,
"providedSignerCount": provided_signers.len(),
"statefulMultisigValidation": if form == "multisig" {
"requires_multisig_account_snapshot"
} else {
"not_applicable"
},
"membersClaimed": false,
});
}
fn authority_index(code: &str) -> std::option::Option<usize> {
return match code {
"transfer"
| "approve"
| "mint_to"
| "burn"
| "close_account"
| "freeze_account"
| "thaw_account"
| "withdraw_excess_lamports"
| "unwrap_lamports" => std::option::Option::Some(2),
"revoke" | "set_authority" => std::option::Option::Some(1),
"transfer_checked" | "approve_checked" => std::option::Option::Some(3),
"mint_to_checked" | "burn_checked" => std::option::Option::Some(2),
_ => std::option::Option::None,
};
}
fn account_role(
code: &str,
position: usize,
authority_index: std::option::Option<usize>,
) -> std::string::String {
if authority_index.is_some_and(|index| return position > index) {
return "multisig_signer".to_string();
}
let role = match (code, position) {
("initialize_mint", 0) | ("initialize_mint2", 0) => "mint",
("initialize_mint", 1) => "rent_sysvar",
("initialize_account", 0) | ("initialize_account2", 0) | ("initialize_account3", 0) => {
"token_account"
},
("initialize_account", 1) | ("initialize_account2", 1) | ("initialize_account3", 1) => {
"mint"
},
("initialize_account", 2) => "owner",
("initialize_account", 3) | ("initialize_account2", 2) => "rent_sysvar",
("initialize_multisig", 0) | ("initialize_multisig2", 0) => "multisig_account",
("initialize_multisig", 1) => "rent_sysvar",
("initialize_multisig", _) | ("initialize_multisig2", _) => "multisig_member",
("transfer", 0) | ("transfer_checked", 0) => "source_token_account",
("transfer", 1) => "destination_token_account",
("transfer_checked", 1) => "mint",
("transfer_checked", 2) => "destination_token_account",
("approve", 0) | ("approve_checked", 0) | ("revoke", 0) => "source_token_account",
("approve", 1) | ("approve_checked", 2) => "delegate",
("approve_checked", 1) => "mint",
("mint_to", 0) | ("mint_to_checked", 0) => "mint",
("mint_to", 1) | ("mint_to_checked", 1) => "destination_token_account",
("burn", 0) | ("burn_checked", 0) => "source_token_account",
("burn", 1) | ("burn_checked", 1) => "mint",
("close_account", 0) | ("withdraw_excess_lamports", 0) | ("unwrap_lamports", 0) => {
"source_token_account"
},
("close_account", 1) | ("withdraw_excess_lamports", 1) | ("unwrap_lamports", 1) => {
"destination_account"
},
("freeze_account", 0) | ("thaw_account", 0) => "token_account",
("freeze_account", 1) | ("thaw_account", 1) => "mint",
("set_authority", 0) => "authority_target",
("sync_native", 0) | ("initialize_immutable_owner", 0) => "token_account",
("sync_native", 1) => "rent_sysvar",
("get_account_data_size", 0) | ("amount_to_ui_amount", 0) | ("ui_amount_to_amount", 0) => {
"mint"
},
(_, value) if authority_index == std::option::Option::Some(value) => "authority",
_ => "unresolved",
};
return role.to_string();
}
fn semantic_diagnostics(
code: &str,
parameters: serde_json::Value,
accounts: &[ResolvedAccount],
suffix_length: usize,
) -> serde_json::Value {
let mut values = std::vec::Vec::new();
if suffix_length > 0 {
values.push(serde_json::json!({
"code": "ignored_wire_suffix",
"lengthBytes": suffix_length,
"runtimeSemantics": "official_interface_unpack_ignores_suffix_for_this_variant",
}));
}
let valid_count = valid_account_count(code, accounts.len());
if !valid_count {
values.push(serde_json::json!({
"code": "unexpected_account_count",
"provided": accounts.len(),
}));
}
if matches!(code, "initialize_multisig" | "initialize_multisig2") {
let member_start = if code == "initialize_multisig" { 2 } else { 1 };
let n = accounts.len().saturating_sub(member_start);
let m = parameters.get("m").and_then(serde_json::Value::as_u64).unwrap_or(0);
if !(1..=11).contains(&n) || m == 0 || m > n as u64 {
values.push(serde_json::json!({
"code": "invalid_multisig_threshold",
"m": m,
"n": n,
"minSigners": 1,
"maxSigners": 11,
}));
}
}
let authority_index = authority_index(code);
if let std::option::Option::Some(index) = authority_index {
if let std::option::Option::Some(authority) = accounts.get(index) {
let multisig_form = accounts.len() > index.saturating_add(1);
if !multisig_form && !authority.signer {
values.push(serde_json::json!({"code":"missing_single_authority_signature"}));
}
if multisig_form {
let invalid_signers = accounts
.iter()
.skip(index.saturating_add(1))
.filter(|account| return !account.signer)
.map(|account| return account.position)
.collect::<std::vec::Vec<_>>();
if !invalid_signers.is_empty() {
values.push(serde_json::json!({
"code": "multisig_meta_missing_signature",
"positions": invalid_signers,
}));
}
}
}
}
return serde_json::Value::Array(values);
}
fn valid_account_count(code: &str, count: usize) -> bool {
return match code {
"initialize_mint" => count == 2,
"initialize_mint2" => count == 1,
"initialize_account" => count == 4,
"initialize_account2" => count == 3,
"initialize_account3" => count == 2,
"initialize_multisig" => (3..=13).contains(&count),
"initialize_multisig2" => (2..=12).contains(&count),
"transfer"
| "approve"
| "mint_to"
| "burn"
| "close_account"
| "freeze_account"
| "thaw_account"
| "withdraw_excess_lamports"
| "unwrap_lamports" => count >= 3,
"revoke" | "set_authority" => count >= 2,
"transfer_checked" | "approve_checked" => count >= 4,
"mint_to_checked" | "burn_checked" => count >= 3,
"sync_native" => count == 1 || count == 2,
"get_account_data_size"
| "initialize_immutable_owner"
| "amount_to_ui_amount"
| "ui_amount_to_amount" => count == 1,
"batch" => true,
_ => false,
};
}
#[allow(clippy::too_many_arguments)]
fn observation(
input: &crate::CoreInstructionReplayInput,
code: &str,
tag: u8,
parameters: serde_json::Value,
accounts: serde_json::Value,
authority: serde_json::Value,
semantic_diagnostics: serde_json::Value,
wire: &[u8],
suffix_length: usize,
committed: bool,
event_key: std::string::String,
instruction_path: std::string::String,
family: crate::EventFamily,
) -> crate::DecodedObservation {
let wire_hash = hash(wire);
let event = crate::DecodedProtocolEvent {
signature: crate::Signature(input.signature.clone()),
slot: crate::Slot(input.slot),
instruction_path: crate::InstructionPath(instruction_path.clone()),
program_id: crate::ProgramId(input.program_id.clone()),
protocol_code: crate::ProtocolCode(crate::SPL_TOKEN_SURFACE_CODE.to_string()),
surface_code: crate::SurfaceCode(crate::SPL_TOKEN_SURFACE_CODE.to_string()),
event_code: crate::EventCode(format!("{}.{}", crate::SPL_TOKEN_SURFACE_CODE, code)),
event_name: crate::EventName(code.to_string()),
event_family: family,
source_kind: if input.instruction_path.contains('/') {
crate::EventSourceKind::InnerInstruction
} else {
crate::EventSourceKind::Instruction
},
confidence: crate::DecoderConfidence::ManualExact,
};
return crate::DecodedObservation {
event_key,
event,
payload_json: serde_json::json!({
"eventVersion": crate::SPL_TOKEN_EVENT_VERSION,
"programId": input.program_id,
"instruction": code,
"wireTag": tag,
"wireTagHex": format!("{tag:02x}"),
"instructionPath": instruction_path,
"instructionLocation": if input.instruction_path.contains('/') { "inner" } else { "outer" },
"transactionSucceeded": !input.transaction_failed,
"committed": committed,
"parameters": parameters,
"accounts": accounts,
"authority": authority,
"semanticDiagnostics": semantic_diagnostics,
"wire": {
"lengthBytes": wire.len(),
"sha256": wire_hash,
"prefixHex": hex_prefix(wire),
"suffixLengthBytes": suffix_length,
"complete": true,
},
"inference": {
"mintFromCoreBalanceChanges": serde_json::Value::Null,
"mintInvented": false,
"rpcAccountReadUsed": false,
},
"returnData": {
"availableInCoreReplayInput": false,
"validated": false,
},
}),
transaction_failed: input.transaction_failed,
transaction_error: input.transaction_err_json.clone(),
observation_committed: committed,
proof: crate::DecoderProof {
kind: crate::DecoderProofKind::Manual,
confidence: crate::DecoderConfidence::ManualExact,
evidence: std::vec![
"spl_token_interface_3_0_0_wire_audit".to_string(),
format!("wire_tag:{tag}"),
format!("wire_sha256:{wire_hash}"),
],
},
};
}
fn batch_observations(
input: &crate::CoreInstructionReplayInput,
bytes: &[u8],
accounts: &[ResolvedAccount],
) -> std::result::Result<
(std::vec::Vec<crate::DecodedObservation>, serde_json::Value),
std::string::String,
> {
if bytes.len() == 1 {
return std::result::Result::Err("Batch requires at least one sub-instruction".to_string());
}
let mut cursor = 1_usize;
let mut account_cursor = 0_usize;
let mut index = 0_usize;
let mut observations = std::vec::Vec::new();
let mut manifest = std::vec::Vec::new();
while cursor < bytes.len() {
if index >= crate::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS {
return std::result::Result::Err(format!(
"Batch exceeds {} sub-instructions",
crate::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS
));
}
let account_count = match bytes.get(cursor).copied() {
std::option::Option::Some(value) => value as usize,
std::option::Option::None => {
return std::result::Result::Err(format!(
"Batch entry {index} has no account count"
));
},
};
let data_length = match bytes.get(cursor.saturating_add(1)).copied() {
std::option::Option::Some(value) => value as usize,
std::option::Option::None => {
return std::result::Result::Err(format!("Batch entry {index} has no data length"));
},
};
if data_length == 0 {
return std::result::Result::Err(format!("Batch entry {index} has empty data"));
}
let data_start = cursor.saturating_add(2);
let data_end = data_start.saturating_add(data_length);
let child_wire = match bytes.get(data_start..data_end) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(format!(
"Batch entry {index} data length exceeds payload"
));
},
};
let account_end = account_cursor.saturating_add(account_count);
if account_end > crate::SPL_TOKEN_MAX_BATCH_ACCOUNTS || account_end > accounts.len() {
return std::result::Result::Err(format!(
"Batch entry {index} account slice exceeds provided accounts or bound"
));
}
let parsed = match parse_instruction(child_wire, true) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(message) => {
return std::result::Result::Err(format!("Batch entry {index}: {message}"));
},
};
let child_accounts = &accounts[account_cursor..account_end];
let suffix_length = child_wire.len().saturating_sub(parsed.consumed);
observations.push(observation(
input,
parsed.code,
parsed.tag,
parsed.parameters.clone(),
project_accounts(parsed.code, child_accounts),
authority_projection(parsed.code, child_accounts),
semantic_diagnostics(parsed.code, parsed.parameters, child_accounts, suffix_length),
child_wire,
suffix_length,
!input.transaction_failed,
format!("token:batch:{index}"),
format!("{}/batch/{index}", input.instruction_path),
event_family(parsed.code),
));
manifest.push(serde_json::json!({
"position": index,
"instruction": parsed.code,
"wireTag": parsed.tag,
"accountStart": account_cursor,
"accountCount": account_count,
"dataOffset": data_start,
"dataLength": data_length,
"derivedInstructionPath": format!("{}/batch/{index}", input.instruction_path),
}));
cursor = data_end;
account_cursor = account_end;
index = index.saturating_add(1);
}
if account_cursor != accounts.len() {
return std::result::Result::Err(format!(
"Batch consumed {account_cursor} of {} provided accounts",
accounts.len()
));
}
return std::result::Result::Ok((
observations,
serde_json::json!({
"subInstructionCount": manifest.len(),
"subInstructions": manifest,
"nestedBatchAllowed": false,
}),
));
}
fn event_family(code: &str) -> crate::EventFamily {
return match code {
"set_authority" | "initialize_multisig" | "initialize_multisig2" => {
crate::EventFamily::Admin
},
"initialize_mint" | "initialize_mint2" | "mint_to" | "mint_to_checked" => {
crate::EventFamily::TokenMint
},
"burn" | "burn_checked" => crate::EventFamily::TokenBurn,
"batch" | "get_account_data_size" | "amount_to_ui_amount" | "ui_amount_to_amount" => {
crate::EventFamily::Audit
},
_ => crate::EventFamily::TokenAccount,
};
}
fn hash(bytes: &[u8]) -> std::string::String {
let digest = sha2::Sha256::digest(bytes);
let mut output = std::string::String::with_capacity(64);
for byte in digest {
output.push_str(format!("{byte:02x}").as_str());
}
return output;
}
fn hex_prefix(bytes: &[u8]) -> std::string::String {
let length = std::cmp::min(bytes.len(), crate::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES);
let mut output = std::string::String::with_capacity(length.saturating_mul(2));
for byte in &bytes[..length] {
output.push_str(format!("{byte:02x}").as_str());
}
return output;
}
fn failed(
entry_code: std::option::Option<&str>,
code: &str,
message: std::string::String,
) -> crate::DecoderExecutionResult {
return crate::DecoderExecutionResult {
status: crate::DecoderOutcomeStatus::Failed,
recognized_entry_code: entry_code.map(str::to_string),
observations: std::vec::Vec::new(),
diagnostics: std::vec![crate::DecoderDiagnostic {
code: code.to_string(),
message,
retriable: false,
}],
};
}

View File

@@ -1,5 +1,5 @@
// file: kb-lib/src/lib.rs
// version: 7
// version: 8
//! Consolidated decoder, executor, materializer and shared model library.
#![warn(missing_docs)]
@@ -27,6 +27,22 @@ pub(crate) use crate::decoder::SPL_MEMO_V1_SURFACE_CODE;
pub(crate) use crate::decoder::SPL_MEMO_V3_SURFACE_CODE;
/// Stable Memo v4 surface code.
pub(crate) use crate::decoder::SPL_MEMO_V4_SURFACE_CODE;
/// Maximum prefix retained in bounded classic SPL Token diagnostics.
pub(crate) use crate::decoder::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES;
/// Current stable classic SPL Token decoded event contract version.
pub(crate) use crate::decoder::SPL_TOKEN_EVENT_VERSION;
/// One exact classic SPL Token instruction entry published by the official interface.
pub(crate) use crate::decoder::SPL_TOKEN_INSTRUCTION_ENTRIES;
/// Maximum total account metas consumed by one classic SPL Token batch.
pub(crate) use crate::decoder::SPL_TOKEN_MAX_BATCH_ACCOUNTS;
/// Maximum decoded sub-instructions in one classic SPL Token batch.
pub(crate) use crate::decoder::SPL_TOKEN_MAX_BATCH_INSTRUCTIONS;
/// Maximum retained classic SPL Token instruction payload size.
pub(crate) use crate::decoder::SPL_TOKEN_MAX_INSTRUCTION_BYTES;
/// Stable protocol and surface code for the classic SPL Token program.
pub(crate) use crate::decoder::SPL_TOKEN_SURFACE_CODE;
/// Canonical tracing target for the classic SPL Token decoder.
pub(crate) use crate::decoder::SPL_TOKEN_TRACING_TARGET;
/// Stable Address Lookup Table surface code.
pub(crate) use crate::decoder::solana::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE;
/// Stable deprecated immutable BPF Loader surface code.
@@ -79,6 +95,12 @@ pub(crate) use crate::decoder::solana::solana_core_config_recognize;
pub(crate) use crate::decoder::solana::solana_core_resolve_accounts;
/// Decodes one bounded SPL Memo instruction.
pub(crate) use crate::decoder::spl_memo_decode;
/// Decodes one bounded classic SPL Token instruction.
pub(crate) use crate::decoder::spl_token_decode;
/// Returns the published entry matching one classic SPL Token tag.
pub(crate) use crate::decoder::spl_token_entry_for_tag;
/// Returns the first byte of one retained classic SPL Token payload.
pub(crate) use crate::decoder::spl_token_payload_tag;
/// Stable protocol code shared by native Solana events.
pub(crate) use crate::decoder::solana::SOLANA_CORE_PROTOCOL_CODE;
@@ -204,6 +226,8 @@ pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_recognize;
/// Exact decoder for the three registered SPL Memo generations.
pub use crate::decoder::SplMemoDecoder;
/// Exact decoder for the classic SPL Token program.
pub use crate::decoder::SplTokenDecoder;
/// Current contextual core instruction input contract version.
pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION;
/// Stable contextual decoded observation.