0.1.0
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# file: kb_decoder_spl_memo/Cargo.toml
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "kb_decoder_spl_memo"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
kb_core = { path = "../kb_core" }
|
||||
kb_decoder_api = { path = "../kb_decoder_api" }
|
||||
kb_model = { path = "../kb_model" }
|
||||
kb_program_ids = { path = "../kb_program_ids" }
|
||||
kb_store_core = { path = "../kb_store_core" }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
spl-memo-interface.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,55 @@
|
||||
<!-- file: kb_decoder_spl_memo/README.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# kb_decoder_spl_memo
|
||||
|
||||
Ce crate décode les programmes SPL Memo v1, v3 et v4 depuis l’instruction core contextualisée.
|
||||
|
||||
## Surface exacte
|
||||
|
||||
- v1 : `Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo` ;
|
||||
- v3 : `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr` ;
|
||||
- v4 : `Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH`.
|
||||
|
||||
`SplMemoDecoder` implémente le contrat contextualisé `InstructionDecoder`. La reconnaissance est exacte pour ces trois IDs et incompatible avec tout autre programme. L’adaptateur historique `ProtocolDecoder` déclare `Yes` pour ces IDs, mais ne fabrique pas d’événement sans instruction contextualisée.
|
||||
|
||||
## Contrat décodé
|
||||
|
||||
Le payload Memo est la totalité des octets de l’instruction, sans discriminator ni préfixe de longueur. Le décodeur conserve :
|
||||
|
||||
- la génération et le Program ID exacts ;
|
||||
- le texte complet lorsque l’UTF‑8 est valide ;
|
||||
- la longueur en octets, le SHA-256 et un préfixe hexadécimal borné à 32 octets ;
|
||||
- tous les comptes dans leur ordre, y compris les doublons, avec position, index, pubkey, signer et writable ;
|
||||
- les signataires observés et les comptes non signers ;
|
||||
- le chemin outer/inner, le succès de transaction et l’état committed ;
|
||||
- les validations syntaxiques et le statut de preuve runtime.
|
||||
|
||||
Le décodeur accepte au maximum 4 096 octets décodés. Cette limite protège le replay contre un payload core anormal tout en dépassant la taille pratique d’une instruction contenue dans une transaction Solana actuelle. Dans cette borne, le texte valide est conservé sans troncature.
|
||||
|
||||
Les événements sont :
|
||||
|
||||
- `add_memo` pour une transaction réussie dont le contrat est valide ;
|
||||
- `memo_intent` pour un payload lisible dans une transaction échouée ;
|
||||
- `invalid_memo_attempt` pour un UTF‑8 invalide ou un compte non signer lorsque la génération l’exige.
|
||||
|
||||
Une transaction échouée n’est jamais marquée committed et son statut runtime reste `not_proven_transaction_failed`, même lorsque l’analyse statique des octets et comptes est valide.
|
||||
|
||||
## Différences de génération
|
||||
|
||||
Memo v1 valide l’UTF‑8 et ignore les comptes. Memo v3 et v4 parcourent tous les comptes fournis et échouent si l’un d’eux n’est pas signer ; zéro compte reste valide. Les trois générations partagent le même wire de données brutes, mais le décodeur conserve des surfaces et règles séparées.
|
||||
|
||||
La matrice normative et les références officielles sont dans `docs/SPL_MEMO_MATRIX.json`.
|
||||
|
||||
Le corpus réel couvre les trois générations sur Mainnet. La validation opérateur ajoute trois signatures v4 Devnet confirmées, toutes décodées comme `add_memo` outer au path `0`, avec compte wallet signer readonly, sans échec de décodage. v1 et v3 ne sont pas prétendues déployées sur Devnet : leur couverture réelle reste assurée par le replay Mainnet et leur construction reste soumise à simulation avant tout envoi.
|
||||
|
||||
## Projection
|
||||
|
||||
`kb_materializer_transaction_annotations` projette uniquement les `add_memo` réussis et commités dans la famille dédiée `TransactionAnnotation`. Les intentions échouées et tentatives invalides restent consultables dans le decode sans devenir des annotations réussies. Les comptes complets, flags writable, diagnostics, préfixes hexadécimaux et preuves restent volontairement dans l’événement décodé ; la projection conserve le texte, la longueur, le hash et les signataires utiles à la recherche et à la corrélation.
|
||||
|
||||
## Règles locales
|
||||
|
||||
- Les commentaires de code restent en anglais.
|
||||
- La documentation Markdown reste en français.
|
||||
- Les exports publics sont contrôlés depuis `lib.rs`.
|
||||
- Les erreurs de rétention core, de base64, de taille ou de résolution de comptes produisent un résultat `Failed` borné, jamais un faux Memo.
|
||||
@@ -0,0 +1,21 @@
|
||||
// file: kb_decoder_spl_memo/src/constants.rs
|
||||
// version: 3
|
||||
|
||||
//! Local constants for the `kb_decoder_spl_memo` crate. Program identifiers live in `kb_program_ids`.
|
||||
|
||||
/// Stable protocol code shared by Memo generations.
|
||||
pub(crate) const PROTOCOL_CODE: &str = "spl_memo";
|
||||
/// Stable Memo v1 surface code.
|
||||
pub(crate) const V1_SURFACE_CODE: &str = "spl_memo_v1";
|
||||
/// Stable Memo v3 surface code.
|
||||
pub(crate) const V3_SURFACE_CODE: &str = "spl_memo_v3";
|
||||
/// Stable Memo v4 surface code.
|
||||
pub(crate) const V4_SURFACE_CODE: &str = "spl_memo_v4";
|
||||
/// Maximum decoded instruction data retained as a complete Memo payload.
|
||||
pub(crate) const MAX_MEMO_PAYLOAD_BYTES: usize = 4_096;
|
||||
/// Maximum payload prefix retained for bounded diagnostics.
|
||||
pub(crate) const MEMO_DIAGNOSTIC_PREFIX_BYTES: usize = 32;
|
||||
/// Current stable Memo decoded event contract version.
|
||||
pub(crate) const MEMO_EVENT_VERSION: u32 = 1;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "kb_decoder_spl_memo";
|
||||
@@ -0,0 +1,438 @@
|
||||
// file: kb_decoder_spl_memo/src/decoder.rs
|
||||
// version: 10
|
||||
|
||||
//! Exact SPL Memo v1, v3 and v4 dispatch for the common decode pipeline.
|
||||
|
||||
const MEMO_SURFACES: &[kb_decoder_api::DecoderSurface] = &[
|
||||
kb_decoder_api::DecoderSurface {
|
||||
program_id: kb_program_ids::SPL_MEMO_V1_PROGRAM_ID,
|
||||
surface_code: crate::V1_SURFACE_CODE,
|
||||
priority: 100,
|
||||
},
|
||||
kb_decoder_api::DecoderSurface {
|
||||
program_id: kb_program_ids::SPL_MEMO_V3_PROGRAM_ID,
|
||||
surface_code: crate::V3_SURFACE_CODE,
|
||||
priority: 100,
|
||||
},
|
||||
kb_decoder_api::DecoderSurface {
|
||||
program_id: kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
||||
surface_code: crate::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 SplMemoDecoder;
|
||||
|
||||
impl kb_decoder_api::ProtocolDecoder for crate::SplMemoDecoder {
|
||||
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: &kb_model::ProgramObservation,
|
||||
) -> kb_decoder_api::DecoderSupport {
|
||||
if kb_decoder_api::ProtocolDecoder::handles_program_id(self, &observation.program_id) {
|
||||
return kb_decoder_api::DecoderSupport::Yes;
|
||||
}
|
||||
return kb_decoder_api::DecoderSupport::No;
|
||||
}
|
||||
|
||||
fn decode_observation(
|
||||
&self,
|
||||
_observation: &kb_model::ProgramObservation,
|
||||
) -> kb_core::Result<std::vec::Vec<kb_model::DecodedProtocolEvent>> {
|
||||
return std::result::Result::Ok(std::vec::Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_decoder_api::InstructionDecoder for crate::SplMemoDecoder {
|
||||
fn identity(&self) -> kb_decoder_api::DecoderIdentity {
|
||||
return kb_decoder_api::DecoderIdentity {
|
||||
name: "spl_memo".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
fn surfaces(&self) -> &'static [kb_decoder_api::DecoderSurface] {
|
||||
return MEMO_SURFACES;
|
||||
}
|
||||
|
||||
fn coverage(&self) -> std::vec::Vec<kb_decoder_api::DecoderCoverageDeclaration> {
|
||||
return MEMO_SURFACES
|
||||
.iter()
|
||||
.map(|surface| {
|
||||
return kb_decoder_api::DecoderCoverageDeclaration {
|
||||
program_id: surface.program_id.to_string(),
|
||||
surface_code: std::option::Option::Some(surface.surface_code.to_string()),
|
||||
entry_kind: kb_decoder_api::DecoderCoverageEntryKind::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: &kb_store_core::CoreInstructionReplayInput,
|
||||
) -> kb_decoder_api::DecoderRecognition {
|
||||
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 kb_decoder_api::DecoderRecognition::incompatible(),
|
||||
};
|
||||
return kb_decoder_api::DecoderRecognition::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: &kb_store_core::CoreInstructionReplayInput,
|
||||
) -> kb_decoder_api::DecoderExecutionResult {
|
||||
if !LEGACY_PROGRAM_IDS.contains(&input.program_id.as_str()) {
|
||||
return kb_decoder_api::DecoderExecutionResult::unsupported(std::option::Option::None);
|
||||
}
|
||||
let result = crate::decode(input);
|
||||
if matches!(
|
||||
result.status,
|
||||
kb_decoder_api::DecoderOutcomeStatus::Failed
|
||||
| kb_decoder_api::DecoderOutcomeStatus::Unsupported
|
||||
) {
|
||||
tracing::error!(
|
||||
target: crate::TRACING_TARGET,
|
||||
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,
|
||||
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,
|
||||
) -> kb_store_core::CoreInstructionReplayInput {
|
||||
let result = kb_store_core::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!("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::SplMemoDecoder;
|
||||
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!(kb_decoder_api::InstructionDecoder::surfaces(&decoder).len(), 3);
|
||||
assert!(
|
||||
kb_decoder_api::InstructionDecoder::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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &input);
|
||||
assert_eq!(result.status, kb_decoder_api::DecoderOutcomeStatus::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('/') {
|
||||
kb_model::EventSourceKind::InnerInstruction
|
||||
} else {
|
||||
kb_model::EventSourceKind::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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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 =
|
||||
kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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 =
|
||||
kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &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::MAX_MEMO_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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &cinput);
|
||||
assert_eq!(result.status, kb_decoder_api::DecoderOutcomeStatus::Decoded);
|
||||
let oversize = std::vec![b'x'; crate::MAX_MEMO_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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &cinput);
|
||||
assert_eq!(result.status, kb_decoder_api::DecoderOutcomeStatus::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 = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &malformed);
|
||||
assert_eq!(result.status, kb_decoder_api::DecoderOutcomeStatus::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 =
|
||||
kb_decoder_api::InstructionDecoder::recognize(&crate::SplMemoDecoder, &input);
|
||||
assert!(!recognition.compatible);
|
||||
let result = kb_decoder_api::InstructionDecoder::decode(&crate::SplMemoDecoder, &input);
|
||||
assert_eq!(result.status, kb_decoder_api::DecoderOutcomeStatus::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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// file: kb_decoder_spl_memo/src/lib.rs
|
||||
// version: 5
|
||||
|
||||
//! Decoder crate for `spl_memo`.
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod constants;
|
||||
mod decoder;
|
||||
mod memo;
|
||||
|
||||
/// Maximum decoded instruction data retained as a complete Memo payload.
|
||||
pub(crate) use crate::constants::MAX_MEMO_PAYLOAD_BYTES;
|
||||
/// Maximum payload prefix retained for bounded diagnostics.
|
||||
pub(crate) use crate::constants::MEMO_DIAGNOSTIC_PREFIX_BYTES;
|
||||
/// Current stable Memo decoded event contract version.
|
||||
pub(crate) use crate::constants::MEMO_EVENT_VERSION;
|
||||
/// Stable protocol code shared by Memo generations.
|
||||
pub(crate) use crate::constants::PROTOCOL_CODE;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use crate::constants::TRACING_TARGET;
|
||||
/// Stable Memo v1 surface code.
|
||||
pub(crate) use crate::constants::V1_SURFACE_CODE;
|
||||
/// Stable Memo v3 surface code.
|
||||
pub(crate) use crate::constants::V3_SURFACE_CODE;
|
||||
/// Stable Memo v4 surface code.
|
||||
pub(crate) use crate::constants::V4_SURFACE_CODE;
|
||||
/// Crate-root access to `decode` from `memo`.
|
||||
pub(crate) use crate::memo::decode;
|
||||
|
||||
/// Exposes the decoder type implemented by this crate.
|
||||
pub use crate::decoder::SplMemoDecoder;
|
||||
@@ -0,0 +1,375 @@
|
||||
// file: kb_decoder_spl_memo/src/memo.rs
|
||||
// version: 2
|
||||
|
||||
//! Bounded Memo payload, account and validation projection.
|
||||
|
||||
use base64::Engine; // rust-rules: trait-import
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum MemoGeneration {
|
||||
V1,
|
||||
V3,
|
||||
V4,
|
||||
}
|
||||
|
||||
impl MemoGeneration {
|
||||
fn from_program_id(program_id: &str) -> std::option::Option<Self> {
|
||||
if program_id == kb_program_ids::SPL_MEMO_V1_PROGRAM_ID {
|
||||
return std::option::Option::Some(Self::V1);
|
||||
}
|
||||
if program_id == kb_program_ids::SPL_MEMO_V3_PROGRAM_ID {
|
||||
return std::option::Option::Some(Self::V3);
|
||||
}
|
||||
if program_id == kb_program_ids::SPL_MEMO_V4_PROGRAM_ID {
|
||||
return std::option::Option::Some(Self::V4);
|
||||
}
|
||||
return std::option::Option::None;
|
||||
}
|
||||
|
||||
fn code(self) -> &'static str {
|
||||
return match self {
|
||||
Self::V1 => "v1",
|
||||
Self::V3 => "v3",
|
||||
Self::V4 => "v4",
|
||||
};
|
||||
}
|
||||
|
||||
fn surface_code(self) -> &'static str {
|
||||
return match self {
|
||||
Self::V1 => crate::V1_SURFACE_CODE,
|
||||
Self::V3 => crate::V3_SURFACE_CODE,
|
||||
Self::V4 => crate::V4_SURFACE_CODE,
|
||||
};
|
||||
}
|
||||
|
||||
fn validates_signers(self) -> bool {
|
||||
return self != Self::V1;
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedAccounts {
|
||||
values: serde_json::Value,
|
||||
ordered_accounts: std::vec::Vec<std::string::String>,
|
||||
ordered_signers: std::vec::Vec<std::string::String>,
|
||||
missing_signers: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
pub(crate) fn decode(
|
||||
input: &kb_store_core::CoreInstructionReplayInput,
|
||||
) -> kb_decoder_api::DecoderExecutionResult {
|
||||
let generation = match MemoGeneration::from_program_id(input.program_id.as_str()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return kb_decoder_api::DecoderExecutionResult::unsupported(std::option::Option::None);
|
||||
},
|
||||
};
|
||||
let bytes = match decode_payload(input) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return failed("memo_payload_unavailable", error.to_string());
|
||||
},
|
||||
};
|
||||
let accounts = match resolve_accounts(input) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return failed("memo_accounts_malformed", error.to_string());
|
||||
},
|
||||
};
|
||||
let text_result = std::str::from_utf8(bytes.as_slice());
|
||||
let utf8_valid = text_result.is_ok();
|
||||
let signer_valid = !generation.validates_signers() || accounts.missing_signers.is_empty();
|
||||
let structurally_valid = utf8_valid && signer_valid;
|
||||
let event_name = if !structurally_valid {
|
||||
"invalid_memo_attempt"
|
||||
} else if input.transaction_failed {
|
||||
"memo_intent"
|
||||
} else {
|
||||
"add_memo"
|
||||
};
|
||||
let memo_text = match text_result {
|
||||
std::result::Result::Ok(value) => serde_json::Value::String(value.to_string()),
|
||||
std::result::Result::Err(_error) => serde_json::Value::Null,
|
||||
};
|
||||
let utf8_error_offset = std::str::from_utf8(bytes.as_slice())
|
||||
.err()
|
||||
.map(|error| return error.valid_up_to());
|
||||
let signer_status = if !generation.validates_signers() {
|
||||
"not_applicable"
|
||||
} else if signer_valid {
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
};
|
||||
let runtime_validation = if input.transaction_failed {
|
||||
"not_proven_transaction_failed"
|
||||
} else if structurally_valid {
|
||||
"proven_by_successful_transaction"
|
||||
} else {
|
||||
"inconsistent_with_successful_transaction"
|
||||
};
|
||||
let missing_required_signers = if generation.validates_signers() {
|
||||
accounts.missing_signers.clone()
|
||||
} else {
|
||||
std::vec::Vec::new()
|
||||
};
|
||||
let ordered_required_signers = if generation.validates_signers() {
|
||||
accounts.ordered_accounts.clone()
|
||||
} else {
|
||||
std::vec::Vec::new()
|
||||
};
|
||||
let payload_hash = hash(bytes.as_slice());
|
||||
let event = kb_model::DecodedProtocolEvent {
|
||||
signature: kb_model::Signature(input.signature.clone()),
|
||||
slot: kb_model::Slot(input.slot),
|
||||
instruction_path: kb_model::InstructionPath(input.instruction_path.clone()),
|
||||
program_id: kb_model::ProgramId(input.program_id.clone()),
|
||||
protocol_code: kb_model::ProtocolCode(crate::PROTOCOL_CODE.to_string()),
|
||||
surface_code: kb_model::SurfaceCode(generation.surface_code().to_string()),
|
||||
event_code: kb_model::EventCode(format!("{}.{event_name}", generation.surface_code())),
|
||||
event_name: kb_model::EventName(event_name.to_string()),
|
||||
event_family: kb_model::EventFamily::Audit,
|
||||
source_kind: if input.instruction_path.contains('/') {
|
||||
kb_model::EventSourceKind::InnerInstruction
|
||||
} else {
|
||||
kb_model::EventSourceKind::Instruction
|
||||
},
|
||||
confidence: kb_model::DecoderConfidence::ManualExact,
|
||||
};
|
||||
let observation = kb_decoder_api::DecodedObservation {
|
||||
event_key: "memo:0".to_string(),
|
||||
event,
|
||||
payload_json: serde_json::json!({
|
||||
"eventVersion": crate::MEMO_EVENT_VERSION,
|
||||
"generation": generation.code(),
|
||||
"programId": input.program_id,
|
||||
"instructionPath": input.instruction_path,
|
||||
"instructionLocation": if input.instruction_path.contains('/') { "inner" } else { "outer" },
|
||||
"transactionSucceeded": !input.transaction_failed,
|
||||
"committed": !input.transaction_failed && structurally_valid,
|
||||
"memoText": memo_text,
|
||||
"payloadLengthBytes": bytes.len(),
|
||||
"payloadSha256": payload_hash,
|
||||
"payloadHexPrefix": hex_prefix(bytes.as_slice()),
|
||||
"payloadComplete": true,
|
||||
"utf8Validation": {
|
||||
"status": if utf8_valid { "valid" } else { "invalid" },
|
||||
"invalidFromByte": utf8_error_offset
|
||||
},
|
||||
"signerValidation": {
|
||||
"requiredForGeneration": generation.validates_signers(),
|
||||
"accountsIgnoredByRuntime": !generation.validates_signers(),
|
||||
"status": signer_status,
|
||||
"orderedRequiredSigners": ordered_required_signers,
|
||||
"orderedObservedSigners": accounts.ordered_signers,
|
||||
"missingRequiredSigners": missing_required_signers
|
||||
},
|
||||
"runtimeValidation": runtime_validation,
|
||||
"accounts": accounts.values,
|
||||
"diagnostic": if structurally_valid {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"code": if !utf8_valid { "invalid_utf8" } else { "missing_required_signature" },
|
||||
"payloadHexPrefix": hex_prefix(bytes.as_slice())
|
||||
})
|
||||
}
|
||||
}),
|
||||
transaction_failed: input.transaction_failed,
|
||||
transaction_error: input.transaction_err_json.clone(),
|
||||
observation_committed: !input.transaction_failed && structurally_valid,
|
||||
proof: kb_decoder_api::DecoderProof {
|
||||
kind: kb_decoder_api::DecoderProofKind::Manual,
|
||||
confidence: kb_model::DecoderConfidence::ManualExact,
|
||||
evidence: std::vec![
|
||||
"spl_memo_exact_raw_instruction_data".to_string(),
|
||||
format!("payload_sha256:{payload_hash}"),
|
||||
format!("generation:{}", generation.code()),
|
||||
],
|
||||
},
|
||||
};
|
||||
return kb_decoder_api::DecoderExecutionResult {
|
||||
status: kb_decoder_api::DecoderOutcomeStatus::Decoded,
|
||||
recognized_entry_code: std::option::Option::Some("add_memo".to_string()),
|
||||
observations: std::vec![observation],
|
||||
diagnostics: std::vec::Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn decode_payload(
|
||||
input: &kb_store_core::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(
|
||||
"Memo 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(
|
||||
"Memo instruction payload does not contain dataBase64",
|
||||
));
|
||||
},
|
||||
};
|
||||
let maximum_encoded = crate::MAX_MEMO_PAYLOAD_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!(
|
||||
"Memo 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!(
|
||||
"Memo payload is not valid base64: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if decoded.len() > crate::MAX_MEMO_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||
"Memo payload exceeds {} decoded bytes",
|
||||
crate::MAX_MEMO_PAYLOAD_BYTES
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(decoded);
|
||||
}
|
||||
|
||||
fn resolve_accounts(
|
||||
input: &kb_store_core::CoreInstructionReplayInput,
|
||||
) -> kb_core::Result<ResolvedAccounts> {
|
||||
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(
|
||||
"Memo 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(
|
||||
"Memo transaction account keys must be a JSON array",
|
||||
));
|
||||
},
|
||||
};
|
||||
let mut values = std::vec::Vec::with_capacity(instruction_accounts.len());
|
||||
let mut ordered_accounts = std::vec::Vec::with_capacity(instruction_accounts.len());
|
||||
let mut ordered_signers = std::vec::Vec::new();
|
||||
let mut missing_signers = std::vec::Vec::new();
|
||||
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!(
|
||||
"Memo 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!(
|
||||
"Memo 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!(
|
||||
"Memo account index {account_index} is absent from resolved keys"
|
||||
)));
|
||||
},
|
||||
};
|
||||
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!(
|
||||
"Memo 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!(
|
||||
"Memo 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!(
|
||||
"Memo account index {account_index} has no writable flag"
|
||||
)));
|
||||
},
|
||||
};
|
||||
ordered_accounts.push(account_key.to_string());
|
||||
if signer {
|
||||
ordered_signers.push(account_key.to_string());
|
||||
} else {
|
||||
missing_signers.push(account_key.to_string());
|
||||
}
|
||||
let source = match resolved.get("source") {
|
||||
std::option::Option::Some(value) => value.clone(),
|
||||
std::option::Option::None => serde_json::Value::Null,
|
||||
};
|
||||
values.push(serde_json::json!({
|
||||
"position": position,
|
||||
"accountIndex": account_index,
|
||||
"pubkey": account_key,
|
||||
"signer": signer,
|
||||
"writable": writable,
|
||||
"source": source
|
||||
}));
|
||||
}
|
||||
return std::result::Result::Ok(ResolvedAccounts {
|
||||
values: serde_json::Value::Array(values),
|
||||
ordered_accounts,
|
||||
ordered_signers,
|
||||
missing_signers,
|
||||
});
|
||||
}
|
||||
|
||||
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::MEMO_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(code: &str, message: std::string::String) -> kb_decoder_api::DecoderExecutionResult {
|
||||
return kb_decoder_api::DecoderExecutionResult {
|
||||
status: kb_decoder_api::DecoderOutcomeStatus::Failed,
|
||||
recognized_entry_code: std::option::Option::Some("add_memo".to_string()),
|
||||
observations: std::vec::Vec::new(),
|
||||
diagnostics: std::vec![kb_decoder_api::DecoderDiagnostic {
|
||||
code: code.to_string(),
|
||||
message,
|
||||
retriable: false,
|
||||
}],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user