use crate::utils::resolve_account_keys;
use serde_json::Value;
#[derive(Debug, Clone, serde::Serialize)]
pub struct IxAccount {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub address: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct IxArg {
pub name: String,
#[serde(rename = "type")]
pub ty: String,
pub value: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CpiEntry {
pub index: usize,
pub program: String,
pub stack_height: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub accounts: Vec<IxAccount>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<IxArg>,
#[serde(skip)]
pub(crate) data: Vec<u8>,
#[serde(skip)]
pub(crate) account_indexes: Vec<usize>,
}
pub(crate) fn build_cpi_tree(tx: &Value) -> Vec<CpiEntry> {
let empty = vec![];
let instructions = tx["transaction"]["message"]["instructions"]
.as_array()
.unwrap_or(&empty);
let account_keys = resolve_account_keys(tx);
let program_at = |ix: &Value| -> Option<String> {
let i = ix["programIdIndex"].as_u64()? as usize;
account_keys.get(i).cloned()
};
let data_of = |ix: &Value| -> Vec<u8> {
ix["data"]
.as_str()
.and_then(|s| bs58::decode(s).into_vec().ok())
.unwrap_or_default()
};
let accts_of = |ix: &Value| -> Vec<usize> {
ix["accounts"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|i| i.as_u64().map(|v| v as usize))
.collect()
})
.unwrap_or_default()
};
let entry = |index, program, stack_height, ix: &Value| CpiEntry {
index,
program,
stack_height,
name: None,
accounts: vec![],
args: vec![],
data: data_of(ix),
account_indexes: accts_of(ix),
};
let mut entries: Vec<CpiEntry> = Vec::new();
for (index, ix) in instructions.iter().enumerate() {
let Some(program) = program_at(ix) else {
continue;
};
entries.push(entry(index, program, 1, ix));
let my_group = tx["meta"]["innerInstructions"]
.as_array()
.unwrap_or(&empty)
.iter()
.find(|group| group["index"].as_u64() == Some(index as u64));
if let Some(my_group) = my_group {
for inner in my_group["instructions"].as_array().unwrap_or(&empty) {
let Some(program) = program_at(inner) else {
continue;
};
let stack_height = inner["stackHeight"].as_u64().unwrap_or(2);
entries.push(entry(index, program, stack_height, inner));
}
}
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tx(inner: Value) -> Value {
json!({
"transaction": { "message": {
"accountKeys": ["Payer111", "ProgA", "ProgB"],
"instructions": [{ "programIdIndex": 1 }]
}},
"meta": { "innerInstructions": inner }
})
}
#[test]
fn builds_tree_with_stack_heights() {
let t = tx(json!([{ "index": 0, "instructions": [
{ "programIdIndex": 2, "stackHeight": 2 },
{ "programIdIndex": 1, "stackHeight": 3 }
]}]));
let tree = build_cpi_tree(&t);
assert_eq!(tree.len(), 3);
assert_eq!(
(tree[0].program.as_str(), tree[0].stack_height),
("ProgA", 1)
);
assert_eq!(
(tree[1].program.as_str(), tree[1].stack_height),
("ProgB", 2)
);
assert_eq!(
(tree[2].program.as_str(), tree[2].stack_height),
("ProgA", 3)
);
}
#[test]
fn missing_stack_height_defaults_to_direct_cpi() {
let t = tx(json!([{ "index": 0, "instructions": [{ "programIdIndex": 2 }] }]));
let tree = build_cpi_tree(&t);
assert_eq!(tree[1].stack_height, 2);
}
#[test]
fn tolerates_missing_meta_and_instructions() {
assert!(build_cpi_tree(&json!({})).is_empty());
let no_inner = json!({
"transaction": { "message": {
"accountKeys": ["Payer111", "ProgA"],
"instructions": [{ "programIdIndex": 1 }]
}},
"meta": {}
});
let tree = build_cpi_tree(&no_inner);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].stack_height, 1);
}
}