Skip to content

Commit 08726b6

Browse files
committed
txn-inspector tool
1 parent a71d4cc commit 08726b6

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

apps/jet/src/bin/txn-inspector.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
use {
2+
anyhow::{Context, Result, bail},
3+
base64::{Engine, prelude::BASE64_STANDARD},
4+
clap::{Parser, ValueEnum},
5+
solana_message::VersionedMessage,
6+
solana_transaction::versioned::VersionedTransaction,
7+
std::io::Read,
8+
};
9+
10+
#[derive(Debug, Clone, Copy, ValueEnum)]
11+
enum Encoding {
12+
Auto,
13+
Base64,
14+
Base58,
15+
}
16+
17+
#[derive(Debug, Parser)]
18+
#[clap(author, version, about = "Decode and inspect a Solana VersionedTransaction")]
19+
struct Args {
20+
/// Encoded transaction input. Use "-" or omit to read stdin.
21+
#[clap(value_name = "INPUT")]
22+
input: Option<String>,
23+
24+
/// Transaction encoding
25+
#[clap(long, value_enum, default_value_t = Encoding::Auto)]
26+
encoding: Encoding,
27+
28+
/// Print full transaction debug output
29+
#[clap(long)]
30+
detailed: bool,
31+
}
32+
33+
fn main() -> Result<()> {
34+
let args = Args::parse();
35+
let input = read_input(args.input)?;
36+
let normalized = normalize_input(&input)?;
37+
let (encoding, bytes) = decode_input(&normalized, args.encoding)?;
38+
39+
let tx: VersionedTransaction = bincode::deserialize(&bytes)
40+
.with_context(|| format!("failed to deserialize {} bytes as VersionedTransaction", bytes.len()))?;
41+
42+
print_summary(encoding, &bytes, &tx, args.detailed);
43+
Ok(())
44+
}
45+
46+
fn read_input(input: Option<String>) -> Result<String> {
47+
if let Some(input) = input {
48+
if input != "-" {
49+
return Ok(input);
50+
}
51+
}
52+
53+
let mut buf = String::new();
54+
std::io::stdin()
55+
.read_to_string(&mut buf)
56+
.context("failed to read stdin")?;
57+
Ok(buf)
58+
}
59+
60+
fn normalize_input(input: &str) -> Result<String> {
61+
let normalized = input
62+
.trim()
63+
.trim_matches('"')
64+
.trim_matches('\'')
65+
.trim_matches(';')
66+
.trim_matches(',')
67+
.trim()
68+
.chars()
69+
.filter(|c| !c.is_ascii_whitespace())
70+
.collect::<String>();
71+
72+
if normalized.is_empty() {
73+
bail!("no encoded transaction data found in input");
74+
}
75+
76+
Ok(normalized)
77+
}
78+
79+
fn decode_input(input: &str, encoding: Encoding) -> Result<(&'static str, Vec<u8>)> {
80+
match encoding {
81+
Encoding::Base64 => Ok((
82+
"base64",
83+
BASE64_STANDARD
84+
.decode(input)
85+
.with_context(|| "base64 decode failed")?,
86+
)),
87+
Encoding::Base58 => Ok((
88+
"base58",
89+
bs58::decode(input)
90+
.into_vec()
91+
.with_context(|| "base58 decode failed")?,
92+
)),
93+
Encoding::Auto => {
94+
if let Ok(decoded) = BASE64_STANDARD.decode(input) {
95+
return Ok(("base64", decoded));
96+
}
97+
98+
if let Ok(decoded) = bs58::decode(input).into_vec() {
99+
return Ok(("base58", decoded));
100+
}
101+
102+
bail!("failed to decode input as base64 or base58")
103+
}
104+
}
105+
}
106+
107+
fn print_summary(encoding: &str, bytes: &[u8], tx: &VersionedTransaction, detailed: bool) {
108+
let message_version = match &tx.message {
109+
VersionedMessage::Legacy(_) => "legacy",
110+
VersionedMessage::V0(_) => "v0",
111+
};
112+
let instruction_count = tx.message.instructions().len();
113+
let static_accounts = tx
114+
.message
115+
.static_account_keys()
116+
.iter()
117+
.map(ToString::to_string)
118+
.collect::<Vec<_>>();
119+
120+
println!("decoded encoding: {encoding}");
121+
println!("decoded bytes: {}", bytes.len());
122+
println!("message version: {message_version}");
123+
println!("signatures: {}", tx.signatures.len());
124+
println!("instruction count: {instruction_count}");
125+
println!("static accounts ({}):", static_accounts.len());
126+
for account in &static_accounts {
127+
println!("- {account}");
128+
}
129+
130+
match &tx.message {
131+
VersionedMessage::Legacy(_) => {
132+
println!("alt accounts: none (legacy message)");
133+
}
134+
VersionedMessage::V0(message) => {
135+
println!("alt accounts (lookups: {}):", message.address_table_lookups.len());
136+
for lookup in &message.address_table_lookups {
137+
println!(
138+
"- table={} writable_indexes={:?} readonly_indexes={:?}",
139+
lookup.account_key, lookup.writable_indexes, lookup.readonly_indexes
140+
);
141+
}
142+
}
143+
}
144+
145+
if let Some(first_sig) = tx.signatures.first() {
146+
println!("first signature: {first_sig}");
147+
}
148+
149+
if detailed {
150+
println!();
151+
println!("transaction (detailed):");
152+
print_detailed_transaction(tx);
153+
}
154+
}
155+
156+
fn print_detailed_transaction(tx: &VersionedTransaction) {
157+
println!("signatures ({}):", tx.signatures.len());
158+
for (i, sig) in tx.signatures.iter().enumerate() {
159+
println!("- [{i}] {sig}");
160+
}
161+
162+
match &tx.message {
163+
VersionedMessage::Legacy(message) => {
164+
println!("message: legacy");
165+
println!(
166+
"header: required_signatures={} readonly_signed={} readonly_unsigned={}",
167+
message.header.num_required_signatures,
168+
message.header.num_readonly_signed_accounts,
169+
message.header.num_readonly_unsigned_accounts
170+
);
171+
println!("recent_blockhash: {}", message.recent_blockhash);
172+
println!("account_keys ({}):", message.account_keys.len());
173+
for (i, key) in message.account_keys.iter().enumerate() {
174+
println!("- [{i}] {key}");
175+
}
176+
println!("instructions ({}):", message.instructions.len());
177+
for (i, ix) in message.instructions.iter().enumerate() {
178+
println!(
179+
"- [{i}] program_id_index={} accounts={:?} data_len={} data={:?}",
180+
ix.program_id_index,
181+
ix.accounts,
182+
ix.data.len(),
183+
ix.data
184+
);
185+
}
186+
}
187+
VersionedMessage::V0(message) => {
188+
println!("message: v0");
189+
println!(
190+
"header: required_signatures={} readonly_signed={} readonly_unsigned={}",
191+
message.header.num_required_signatures,
192+
message.header.num_readonly_signed_accounts,
193+
message.header.num_readonly_unsigned_accounts
194+
);
195+
println!("recent_blockhash: {}", message.recent_blockhash);
196+
println!("account_keys ({}):", message.account_keys.len());
197+
for (i, key) in message.account_keys.iter().enumerate() {
198+
println!("- [{i}] {key}");
199+
}
200+
println!("instructions ({}):", message.instructions.len());
201+
for (i, ix) in message.instructions.iter().enumerate() {
202+
println!(
203+
"- [{i}] program_id_index={} accounts={:?} data_len={} data={:?}",
204+
ix.program_id_index,
205+
ix.accounts,
206+
ix.data.len(),
207+
ix.data
208+
);
209+
}
210+
println!("address_table_lookups ({}):", message.address_table_lookups.len());
211+
for (i, lookup) in message.address_table_lookups.iter().enumerate() {
212+
println!(
213+
"- [{i}] table={} writable_indexes={:?} readonly_indexes={:?}",
214+
lookup.account_key, lookup.writable_indexes, lookup.readonly_indexes
215+
);
216+
}
217+
}
218+
}
219+
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::{Encoding, decode_input, normalize_input};
224+
225+
#[test]
226+
fn normalize_supports_multiline_payload() {
227+
let input = "abc\n123\n==\n";
228+
let out = normalize_input(input).unwrap();
229+
assert_eq!(out, "abc123==");
230+
}
231+
232+
#[test]
233+
fn normalize_without_marker_keeps_input() {
234+
let input = " abc123== ";
235+
let out = normalize_input(input).unwrap();
236+
assert_eq!(out, "abc123==");
237+
}
238+
239+
#[test]
240+
fn decode_auto_prefers_base64() {
241+
let encoded = "aGVsbG8=";
242+
let (encoding, bytes) = decode_input(encoded, Encoding::Auto).unwrap();
243+
assert_eq!(encoding, "base64");
244+
assert_eq!(bytes, b"hello");
245+
}
246+
}

0 commit comments

Comments
 (0)