439 lines
17 KiB
Rust
439 lines
17 KiB
Rust
// file: kb-lib/src/decoder/spl/memo/decoder.rs
|
|
// version: 2
|
|
|
|
//! Exact SPL Memo v1, v3 and v4 dispatch for the common decode pipeline.
|
|
|
|
const MEMO_SURFACES: &[crate::DcApiDecoderSurface] = &[
|
|
crate::DcApiDecoderSurface {
|
|
program_id: kb_program_ids::SPL_MEMO_V1_PROGRAM_ID,
|
|
surface_code: crate::DC_SPL_MEMO_V1_SURFACE_CODE,
|
|
priority: 100,
|
|
},
|
|
crate::DcApiDecoderSurface {
|
|
program_id: kb_program_ids::SPL_MEMO_V3_PROGRAM_ID,
|
|
surface_code: crate::DC_SPL_MEMO_V3_SURFACE_CODE,
|
|
priority: 100,
|
|
},
|
|
crate::DcApiDecoderSurface {
|
|
program_id: kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
surface_code: crate::DC_SPL_MEMO_V4_SURFACE_CODE,
|
|
priority: 100,
|
|
},
|
|
];
|
|
|
|
const LEGACY_PROGRAM_IDS: &[&str] = &[
|
|
kb_program_ids::SPL_MEMO_V1_PROGRAM_ID,
|
|
kb_program_ids::SPL_MEMO_V3_PROGRAM_ID,
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
];
|
|
|
|
/// Exact decoder for the three registered SPL Memo generations.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct DcSplMemoDecoder;
|
|
|
|
impl crate::DcApiProtocolDecoder for crate::DcSplMemoDecoder {
|
|
fn decoder_name(&self) -> &'static str {
|
|
return "kb_decoder_spl_memo";
|
|
}
|
|
|
|
fn decoder_version(&self) -> &'static str {
|
|
return env!("CARGO_PKG_VERSION");
|
|
}
|
|
|
|
fn program_ids(&self) -> &'static [&'static str] {
|
|
return LEGACY_PROGRAM_IDS;
|
|
}
|
|
|
|
fn supports_observation(
|
|
&self,
|
|
observation: &crate::MdProgramObservation,
|
|
) -> crate::DcApiDecoderSupport {
|
|
if crate::DcApiProtocolDecoder::handles_program_id(self, &observation.program_id) {
|
|
return crate::DcApiDecoderSupport::Yes;
|
|
}
|
|
return crate::DcApiDecoderSupport::No;
|
|
}
|
|
|
|
fn decode_observation(
|
|
&self,
|
|
_observation: &crate::MdProgramObservation,
|
|
) -> kb_core::Result<std::vec::Vec<crate::MdDecodedProtocolEvent>> {
|
|
return std::result::Result::Ok(std::vec::Vec::new());
|
|
}
|
|
}
|
|
|
|
impl crate::DcApiInstructionDecoder for crate::DcSplMemoDecoder {
|
|
fn identity(&self) -> crate::DcApiDecoderIdentity {
|
|
return crate::DcApiDecoderIdentity {
|
|
name: "spl_memo".to_string(),
|
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
};
|
|
}
|
|
|
|
fn surfaces(&self) -> &'static [crate::DcApiDecoderSurface] {
|
|
return MEMO_SURFACES;
|
|
}
|
|
|
|
fn coverage(&self) -> std::vec::Vec<crate::DcApiDecoderCoverageDeclaration> {
|
|
return MEMO_SURFACES
|
|
.iter()
|
|
.map(|surface| {
|
|
return crate::DcApiDecoderCoverageDeclaration {
|
|
program_id: surface.program_id.to_string(),
|
|
surface_code: std::option::Option::Some(surface.surface_code.to_string()),
|
|
entry_kind: crate::DcApiDecoderCoverageEntryKind::Instruction,
|
|
entry_code: "add_memo".to_string(),
|
|
discriminator_hex: std::option::Option::None,
|
|
historical: surface.program_id != kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
};
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
fn recognize(
|
|
&self,
|
|
input: &crate::MdCoreInstructionReplayInput,
|
|
) -> crate::DcApiDecoderRecognition {
|
|
let surface = MEMO_SURFACES
|
|
.iter()
|
|
.find(|surface| return surface.program_id == input.program_id);
|
|
let surface = match surface {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return crate::DcApiDecoderRecognition::incompatible(),
|
|
};
|
|
return crate::DcApiDecoderRecognition::compatible(
|
|
true,
|
|
surface.priority,
|
|
std::option::Option::Some(surface.surface_code.to_string()),
|
|
std::option::Option::Some("add_memo".to_string()),
|
|
std::option::Option::None,
|
|
);
|
|
}
|
|
|
|
fn decode(
|
|
&self,
|
|
input: &crate::MdCoreInstructionReplayInput,
|
|
) -> crate::DcApiDecoderExecutionResult {
|
|
if !LEGACY_PROGRAM_IDS.contains(&input.program_id.as_str()) {
|
|
return crate::DcApiDecoderExecutionResult::unsupported(std::option::Option::None);
|
|
}
|
|
let result = crate::decoder_spl_memo_decode(input);
|
|
if matches!(
|
|
result.status,
|
|
crate::DcApiDecoderOutcomeStatus::Failed
|
|
| crate::DcApiDecoderOutcomeStatus::Unsupported
|
|
) {
|
|
tracing::error!(
|
|
target: crate::TRACING_TARGET_DECODER_SPL_MEMO,
|
|
action = "decode_failure",
|
|
signature = %input.signature,
|
|
slot = input.slot,
|
|
instruction_path = %input.instruction_path,
|
|
program_id = %input.program_id,
|
|
processor_name = "spl_memo",
|
|
processor_version = env!("CARGO_PKG_VERSION"),
|
|
input_key = %input.replay_input_key,
|
|
payload_hash = ?input.instruction_payload_hash,
|
|
transaction_failed = input.transaction_failed,
|
|
result_status = ?result.status,
|
|
diagnostics = ?result.diagnostics,
|
|
"SPL Memo instruction was not decoded successfully"
|
|
);
|
|
}
|
|
tracing::debug!(
|
|
target: crate::TRACING_TARGET_DECODER_SPL_MEMO,
|
|
action = "decode",
|
|
signature = %input.signature,
|
|
instruction_path = %input.instruction_path,
|
|
program_id = %input.program_id,
|
|
transaction_failed = input.transaction_failed,
|
|
result_status = ?result.status,
|
|
observation_count = result.observations.len(),
|
|
"SPL Memo 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::MdCoreInstructionReplayInput {
|
|
let result = crate::MdCoreInstructionReplayInput::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!("memo replay input failed: {error}"),
|
|
};
|
|
}
|
|
|
|
fn one_account(signer: bool) -> (serde_json::Value, serde_json::Value) {
|
|
return (
|
|
serde_json::json!([{"position":0,"accountIndex":1,"accountKey":"signer"}]),
|
|
serde_json::json!([{"accountIndex":1,"accountKey":"signer","signer":signer,"writable":false,"source":"static"}]),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_support_and_interface_ids_cover_all_generations() {
|
|
let decoder = crate::DcSplMemoDecoder;
|
|
assert_eq!(spl_memo_interface::v1::ID.to_string(), kb_program_ids::SPL_MEMO_V1_PROGRAM_ID);
|
|
assert_eq!(spl_memo_interface::v3::ID.to_string(), kb_program_ids::SPL_MEMO_V3_PROGRAM_ID);
|
|
assert_eq!(spl_memo_interface::v4::ID.to_string(), kb_program_ids::SPL_MEMO_V4_PROGRAM_ID);
|
|
assert_eq!(crate::DcApiInstructionDecoder::surfaces(&decoder).len(), 3);
|
|
assert!(
|
|
crate::DcApiInstructionDecoder::coverage(&decoder)
|
|
.iter()
|
|
.all(|entry| return entry.entry_code == "add_memo")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_empty_ascii_unicode_outer_and_inner_payloads() {
|
|
for (index, payload) in [b"".as_slice(), b"payment:42".as_slice(), "memo ".as_bytes()]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
let path = if index == 2 { "1/0" } else { "0" };
|
|
let input = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
payload,
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
path,
|
|
);
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &input);
|
|
assert_eq!(result.status, crate::DcApiDecoderOutcomeStatus::Decoded);
|
|
assert_eq!(
|
|
result.observations[0].payload_json["payloadLengthBytes"],
|
|
serde_json::json!(payload.len())
|
|
);
|
|
assert!(result.observations[0].observation_committed);
|
|
assert_eq!(
|
|
result.observations[0].event.source_kind,
|
|
if path.contains('/') {
|
|
crate::MdEventSourceKind::InnerInstruction
|
|
} else {
|
|
crate::MdEventSourceKind::Instruction
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn v1_ignores_accounts_while_v3_and_v4_require_every_account_to_sign() {
|
|
let (accounts, keys) = one_account(false);
|
|
let v1 = input(
|
|
kb_program_ids::SPL_MEMO_V1_PROGRAM_ID,
|
|
b"legacy",
|
|
accounts.clone(),
|
|
keys.clone(),
|
|
false,
|
|
"0",
|
|
);
|
|
let v1_result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &v1);
|
|
assert_eq!(v1_result.observations[0].event.event_name.0, "add_memo");
|
|
assert_eq!(
|
|
v1_result.observations[0].payload_json["signerValidation"]["status"],
|
|
"not_applicable"
|
|
);
|
|
for program_id in
|
|
[kb_program_ids::SPL_MEMO_V3_PROGRAM_ID, kb_program_ids::SPL_MEMO_V4_PROGRAM_ID]
|
|
{
|
|
let input = input(program_id, b"signed", accounts.clone(), keys.clone(), false, "0");
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &input);
|
|
assert_eq!(result.observations[0].event.event_name.0, "invalid_memo_attempt");
|
|
assert_eq!(
|
|
result.observations[0].payload_json["signerValidation"]["status"],
|
|
"invalid"
|
|
);
|
|
assert!(!result.observations[0].observation_committed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_utf8_and_failed_transactions_are_explicit_uncommitted_intents() {
|
|
let invalid = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
&[0xf0, 0x9f, 0x90, 0xff],
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
true,
|
|
"0",
|
|
);
|
|
let invalid_result =
|
|
crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &invalid);
|
|
assert_eq!(invalid_result.observations[0].event.event_name.0, "invalid_memo_attempt");
|
|
assert_eq!(
|
|
invalid_result.observations[0].payload_json["utf8Validation"]["status"],
|
|
"invalid"
|
|
);
|
|
assert!(!invalid_result.observations[0].observation_committed);
|
|
let valid_failed = input(
|
|
kb_program_ids::SPL_MEMO_V3_PROGRAM_ID,
|
|
b"rolled back",
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
true,
|
|
"0",
|
|
);
|
|
let failed_result =
|
|
crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &valid_failed);
|
|
assert_eq!(failed_result.observations[0].event.event_name.0, "memo_intent");
|
|
assert!(!failed_result.observations[0].observation_committed);
|
|
assert_eq!(
|
|
failed_result.observations[0].payload_json["runtimeValidation"],
|
|
"not_proven_transaction_failed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_duplicate_writable_and_multiple_signer_accounts_in_order() {
|
|
let accounts = serde_json::json!([
|
|
{"position":0,"accountIndex":1,"accountKey":"first"},
|
|
{"position":1,"accountIndex":2,"accountKey":"second"},
|
|
{"position":2,"accountIndex":1,"accountKey":"first"}
|
|
]);
|
|
let keys = serde_json::json!([
|
|
{"accountIndex":1,"accountKey":"first","signer":true,"writable":true,"source":"static"},
|
|
{"accountIndex":2,"accountKey":"second","signer":true,"writable":false,"source":"static"}
|
|
]);
|
|
let input =
|
|
input(kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, b"ordered", accounts, keys, false, "0");
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &input);
|
|
assert_eq!(result.observations[0].payload_json["accounts"][0]["pubkey"], "first");
|
|
assert_eq!(result.observations[0].payload_json["accounts"][0]["writable"], true);
|
|
assert_eq!(result.observations[0].payload_json["accounts"][2]["pubkey"], "first");
|
|
assert_eq!(
|
|
result.observations[0].payload_json["signerValidation"]["orderedRequiredSigners"],
|
|
serde_json::json!(["first", "second", "first"])
|
|
);
|
|
assert_eq!(
|
|
result.observations[0].payload_json["signerValidation"]["orderedObservedSigners"],
|
|
serde_json::json!(["first", "second", "first"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn payload_hash_is_canonical_sha256() {
|
|
let input = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
b"abc",
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
"0",
|
|
);
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &input);
|
|
assert_eq!(
|
|
result.observations[0].payload_json["payloadSha256"],
|
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn payload_bound_accepts_limit_and_rejects_oversize_and_malformed_base64() {
|
|
let at_limit = std::vec![b'x'; crate::DC_SPL_MEMO_MAX_PAYLOAD_BYTES];
|
|
let cinput = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
at_limit.as_slice(),
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
"0",
|
|
);
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &cinput);
|
|
assert_eq!(result.status, crate::DcApiDecoderOutcomeStatus::Decoded);
|
|
let oversize = std::vec![b'x'; crate::DC_SPL_MEMO_MAX_PAYLOAD_BYTES + 1];
|
|
let cinput = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
oversize.as_slice(),
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
"0",
|
|
);
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &cinput);
|
|
assert_eq!(result.status, crate::DcApiDecoderOutcomeStatus::Failed);
|
|
let mut malformed = input(
|
|
kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
|
b"valid",
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
"0",
|
|
);
|
|
malformed.instruction_payload_json =
|
|
std::option::Option::Some(serde_json::json!({"dataBase64":"%%%"}));
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &malformed);
|
|
assert_eq!(result.status, crate::DcApiDecoderOutcomeStatus::Failed);
|
|
assert!(result.observations.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_program_is_incompatible_and_never_emits_an_observation() {
|
|
let input = input(
|
|
kb_program_ids::SYSTEM_PROGRAM_ID,
|
|
b"not memo",
|
|
serde_json::json!([]),
|
|
serde_json::json!([]),
|
|
false,
|
|
"0",
|
|
);
|
|
let recognition =
|
|
crate::DcApiInstructionDecoder::recognize(&crate::DcSplMemoDecoder, &input);
|
|
assert!(!recognition.compatible);
|
|
let result = crate::DcApiInstructionDecoder::decode(&crate::DcSplMemoDecoder, &input);
|
|
assert_eq!(result.status, crate::DcApiDecoderOutcomeStatus::Unsupported);
|
|
assert!(result.observations.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn matrix_is_machine_readable_and_covers_required_cases() {
|
|
let parsed = serde_json::from_str::<serde_json::Value>(include_str!(
|
|
"../../../../../docs/SPL_MEMO_MATRIX.json"
|
|
));
|
|
let matrix = match parsed {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("memo matrix is invalid JSON: {error}"),
|
|
};
|
|
assert_eq!(matrix["matrixVersion"], 4);
|
|
assert_eq!(
|
|
matrix["generations"].as_array().map(std::vec::Vec::len),
|
|
std::option::Option::Some(3)
|
|
);
|
|
let cases = matrix["requiredCorpusCases"].as_array();
|
|
assert!(cases.is_some_and(|values| return values.len() >= 12));
|
|
}
|
|
}
|