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/Cargo.toml
# version: 4
# version: 5
[package]
name = "kb-lib"
@@ -31,6 +31,7 @@ wincode.workspace = true
[dev-dependencies]
spl-memo-interface.workspace = true
spl-token-interface.workspace = true
[lints]
workspace = true

View File

@@ -1,5 +1,5 @@
<!-- file: kb-lib/README.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# kb-lib
@@ -29,10 +29,25 @@ La matrice `docs/NATIVE_SOLANA_DECODER_MATRIX.json` reste la source machine-read
Memo v1 ignore les comptes lors de la validation runtime. Memo v3 et v4 exigent que chaque compte fourni soit signer. Les transactions échouées restent des intentions non commitées et les payloads UTF-8 invalides ou signatures manquantes deviennent des tentatives invalides explicites. La matrice normative est `docs/SPL_MEMO_MATRIX.json`.
## Décodeur SPL Token classique
`SplTokenDecoder` couvre exclusivement le Program ID classique
`TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`. Il publie 28 déclarations de couverture pour
les tags `0..24`, `38`, `45` et `255`, y compris les variantes historiques, checked,
`WithdrawExcessLamports`, `UnwrapLamports` et `Batch`.
Le parseur conserve les montants bruts sans perte, les comptes dans leur ordre dorigine, les
formes dautorité simple ou multisig, les suffixes runtime et les chemins outer/inner. Une
transaction échouée reste une intention non commitée. `Batch` est borné à 64 enfants, 512 comptes
cumulés et interdit les batches imbriqués. Le décodeur ninvente ni mint, ni decimals, ni état
final ; les validations `M/N`, soldes et autorités existantes restent stateful. La matrice
normative est `docs/SPL_TOKEN_MATRIX.json`.
## API publique utile
- `SolanaCoreDecoder` : décodeur concret natif ;
- `SplMemoDecoder` : décodeur exact des trois générations SPL Memo ;
- `SplTokenDecoder` : décodeur exact du programme SPL Token classique ;
- `InstructionDecoder` : contrat de reconnaissance, couverture et décodage contextualisé ;
- `ProtocolDecoder` : contrat de compatibilité avec les observations historiques ;
- `CoreInstructionReplayInput` : input source-neutral produit par lextraction core ;

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.