svmscope 0.6.0

Transaction autopsy for Solana — decode any mainnet transaction, replay it locally in an embedded SVM, and mutate state to see what happens.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
//! Human names for instructions — the difference between a bare program id and
//! "Jupiter · Route V2". Native programs are decoded from their well-known
//! layouts; Anchor programs from their on-chain IDL's instruction discriminators.

use crate::cpi_tree::{IxAccount, IxArg};
use crate::idl;
use serde_json::Value;
use solana_address::Address;
use solana_client::rpc_client::RpcClient;
use std::collections::HashMap;
use std::str::FromStr;

const SYSTEM: &str = "11111111111111111111111111111111";
const COMPUTE_BUDGET: &str = "ComputeBudget111111111111111111111111111111";
const TOKEN: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const ATA: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
const MEMO: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
const MEMO_V1: &str = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
const STAKE: &str = "Stake11111111111111111111111111111111111111";
const VOTE: &str = "Vote111111111111111111111111111111111111111";
const ALT: &str = "AddressLookupTab1e1111111111111111111111111";
const LOADER_UPGRADEABLE: &str = "BPFLoaderUpgradeab1e11111111111111111111111";
const RAYDIUM_AMM: &str = "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8";
const SERUM_V3: &str = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin";
const OPENBOOK_V1: &str = "srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX";

/// Little-endian u32 tag at the start of the data (the enum layout of the
/// bincode-serialised native programs: Stake, Vote, ALT, the loaders).
fn u32_tag(data: &[u8]) -> Option<u32> {
    data.get(..4)
        .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
}

fn stake_ix(data: &[u8]) -> Option<&'static str> {
    Some(match u32_tag(data)? {
        0 => "Initialize",
        1 => "Authorize",
        2 => "Delegate Stake",
        3 => "Split",
        4 => "Withdraw",
        5 => "Deactivate",
        6 => "Set Lockup",
        7 => "Merge",
        8 => "Authorize With Seed",
        9 => "Initialize Checked",
        10 => "Authorize Checked",
        11 => "Authorize Checked With Seed",
        12 => "Set Lockup Checked",
        13 => "Get Minimum Delegation",
        14 => "Deactivate Delinquent",
        15 => "Redelegate",
        16 => "Move Stake",
        17 => "Move Lamports",
        _ => return None,
    })
}

fn vote_ix(data: &[u8]) -> Option<&'static str> {
    Some(match u32_tag(data)? {
        0 => "Initialize Account",
        1 => "Authorize",
        2 => "Vote",
        3 => "Withdraw",
        4 => "Update Validator Identity",
        5 => "Update Commission",
        6 => "Vote Switch",
        7 => "Authorize Checked",
        8 => "Update Vote State",
        9 => "Update Vote State Switch",
        10 => "Authorize With Seed",
        11 => "Authorize Checked With Seed",
        12 => "Compact Update Vote State",
        13 => "Compact Update Vote State Switch",
        14 => "Tower Sync",
        15 => "Tower Sync Switch",
        _ => return None,
    })
}

fn alt_ix(data: &[u8]) -> Option<&'static str> {
    Some(match u32_tag(data)? {
        0 => "Create Lookup Table",
        1 => "Freeze Lookup Table",
        2 => "Extend Lookup Table",
        3 => "Deactivate Lookup Table",
        4 => "Close Lookup Table",
        _ => return None,
    })
}

fn loader_ix(data: &[u8]) -> Option<&'static str> {
    Some(match u32_tag(data)? {
        0 => "Initialize Buffer",
        1 => "Write",
        2 => "Deploy With Max Data Len",
        3 => "Upgrade",
        4 => "Set Authority",
        5 => "Close",
        6 => "Extend Program",
        7 => "Set Authority Checked",
        8 => "Migrate",
        _ => return None,
    })
}

/// Raydium AMM v4 — native, single-byte tag, no IDL anywhere.
fn raydium_amm_ix(data: &[u8]) -> Option<&'static str> {
    Some(match data.first()? {
        0 => "Initialize",
        1 => "Initialize2",
        2 => "Monitor Step",
        3 => "Deposit",
        4 => "Withdraw",
        5 => "Migrate To OpenBook",
        6 => "Set Params",
        7 => "Withdraw Pnl",
        8 => "Withdraw Srm",
        9 => "Swap Base In",
        10 => "Pre Initialize",
        11 => "Swap Base Out",
        12 => "Simulate Info",
        13 => "Admin Cancel Orders",
        14 => "Create Config Account",
        15 => "Update Config Account",
        _ => return None,
    })
}

/// Serum DEX v3 / OpenBook v1 — a version byte then a u32 tag.
fn serum_ix(data: &[u8]) -> Option<&'static str> {
    Some(match u32_tag(data.get(1..)?)? {
        0 => "Initialize Market",
        1 => "New Order",
        2 => "Match Orders",
        3 => "Consume Events",
        4 => "Cancel Order",
        5 => "Settle Funds",
        6 => "Cancel Order By Client Id",
        7 => "Disable Market",
        8 => "Sweep Fees",
        9 => "New Order V2",
        10 => "New Order V3",
        11 => "Cancel Order V2",
        12 => "Cancel Order By Client Id V2",
        13 => "Send Take",
        14 => "Close Open Orders",
        15 => "Init Open Orders",
        16 => "Prune",
        17 => "Consume Events Permissioned",
        18 => "Cancel Orders By Client Ids",
        19 => "Replace Order By Client Id",
        20 => "Replace Orders By Client Ids",
        _ => return None,
    })
}

/// Decode a Token-program instruction (Tokenkeg / Token-2022) by its leading tag.
fn token_ix(data: &[u8]) -> Option<&'static str> {
    Some(match data.first()? {
        0 => "Initialize Mint",
        1 => "Initialize Account",
        3 => "Transfer",
        4 => "Approve",
        5 => "Revoke",
        6 => "Set Authority",
        7 => "Mint To",
        8 => "Burn",
        9 => "Close Account",
        10 => "Freeze Account",
        11 => "Thaw Account",
        12 => "Transfer Checked",
        13 => "Approve Checked",
        14 => "Mint To Checked",
        15 => "Burn Checked",
        16 => "Initialize Account 2",
        17 => "Sync Native",
        18 => "Initialize Account 3",
        19 => "Initialize Multisig 2",
        20 => "Initialize Mint 2",
        21 => "Get Account Data Size",
        22 => "Initialize Immutable Owner",
        23 => "Amount To Ui Amount",
        24 => "Ui Amount To Amount",
        // Token-2022 only.
        25 => "Initialize Mint Close Authority",
        26 => "Transfer Fee Extension",
        27 => "Confidential Transfer Extension",
        28 => "Default Account State Extension",
        29 => "Reallocate",
        30 => "Memo Transfer Extension",
        31 => "Create Native Mint",
        32 => "Initialize Non Transferable Mint",
        33 => "Interest Bearing Mint Extension",
        34 => "Cpi Guard Extension",
        35 => "Initialize Permanent Delegate",
        36 => "Transfer Hook Extension",
        37 => "Confidential Transfer Fee Extension",
        38 => "Withdraw Excess Lamports",
        39 => "Metadata Pointer Extension",
        40 => "Group Pointer Extension",
        41 => "Group Member Pointer Extension",
        42 => "Confidential Mint Burn Extension",
        43 => "Scaled Ui Amount Extension",
        44 => "Pausable Extension",
        _ => return None,
    })
}

/// Decode a System-program instruction by its 4-byte little-endian tag.
fn system_ix(data: &[u8]) -> Option<&'static str> {
    let tag = u32::from_le_bytes(data.get(0..4)?.try_into().ok()?);
    Some(match tag {
        0 => "Create Account",
        1 => "Assign",
        2 => "Transfer",
        3 => "Create Account With Seed",
        4 => "Advance Nonce Account",
        5 => "Withdraw Nonce Account",
        6 => "Initialize Nonce Account",
        7 => "Authorize Nonce Account",
        8 => "Allocate",
        9 => "Allocate With Seed",
        10 => "Assign With Seed",
        11 => "Transfer With Seed",
        12 => "Upgrade Nonce Account",
        _ => return None,
    })
}

/// Decode a Compute-Budget instruction by its leading tag.
fn compute_budget_ix(data: &[u8]) -> Option<&'static str> {
    Some(match data.first()? {
        1 => "Request Heap Frame",
        2 => "Set Compute Unit Limit",
        3 => "Set Compute Unit Price",
        4 => "Set Loaded Accounts Data Size Limit",
        _ => return None,
    })
}

/// Turn an IDL instruction name (`routeV2`, `shared_accounts_route`) into a
/// display label (`Route V2`, `Shared Accounts Route`).
fn titleize(name: &str) -> String {
    let mut out = String::new();
    let mut prev_lower = false;
    for ch in name.chars() {
        if ch == '_' || ch == '-' {
            out.push(' ');
            prev_lower = false;
            continue;
        }
        // Split camelCase (lower→UPPER) only; keep digits attached ("v2" → "V2").
        if ch.is_uppercase() && prev_lower {
            out.push(' ');
        }
        out.push(ch);
        prev_lower = ch.is_lowercase();
    }
    // Capitalize the first letter of each word.
    out.split(' ')
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_string()
}

/// Positional account role names for a native program's instruction, so the
/// call trace reads "Source / Destination / Authority", not bare addresses.
fn native_account_names(program: &str, data: &[u8]) -> Vec<&'static str> {
    let v = |s: &[&'static str]| s.to_vec();
    match program {
        TOKEN | TOKEN_2022 => match data.first() {
            Some(1) => v(&["Account", "Mint", "Owner", "Rent Sysvar"]),
            Some(3) => v(&["Source", "Destination", "Authority"]),
            Some(4) => v(&["Source", "Delegate", "Authority"]),
            Some(7) => v(&["Mint", "Destination", "Authority"]),
            Some(8) => v(&["Account", "Mint", "Authority"]),
            Some(9) => v(&["Account", "Destination", "Authority"]),
            Some(12) => v(&["Source", "Mint", "Destination", "Authority"]),
            Some(14) => v(&["Mint", "Destination", "Authority"]),
            Some(15) => v(&["Account", "Mint", "Authority"]),
            _ => vec![],
        },
        SYSTEM => match data
            .get(0..4)
            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
        {
            Some(0) => v(&["Funder", "New Account"]),
            Some(2) => v(&["From", "To"]),
            _ => vec![],
        },
        ATA => v(&[
            "Funder",
            "Associated Token Account",
            "Wallet",
            "Mint",
            "System Program",
            "Token Program",
        ]),
        _ => vec![],
    }
}

/// Decode a native instruction's key arguments (the amounts developers look for).
fn native_args(program: &str, data: &[u8]) -> Vec<IxArg> {
    let u64_at = |o: usize| {
        data.get(o..o + 8)
            .map(|b| u64::from_le_bytes(b.try_into().unwrap()))
    };
    let u32_at = |o: usize| {
        data.get(o..o + 4)
            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
    };
    let arg = |name: &str, ty: &str, v: u64| IxArg {
        name: name.into(),
        ty: ty.into(),
        value: v.to_string(),
    };
    match program {
        TOKEN | TOKEN_2022 => match data.first() {
            Some(3) | Some(4) | Some(7) | Some(8) => u64_at(1)
                .map(|a| vec![arg("amount", "u64", a)])
                .unwrap_or_default(),
            Some(12) | Some(13) | Some(14) | Some(15) => {
                let mut out = vec![];
                if let Some(a) = u64_at(1) {
                    out.push(arg("amount", "u64", a));
                }
                if let Some(&d) = data.get(9) {
                    out.push(arg("decimals", "u8", d as u64));
                }
                out
            }
            _ => vec![],
        },
        SYSTEM => match data
            .get(0..4)
            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
        {
            Some(2) => u64_at(4)
                .map(|l| vec![arg("lamports", "u64", l)])
                .unwrap_or_default(),
            _ => vec![],
        },
        COMPUTE_BUDGET => match data.first() {
            Some(2) => u32_at(1)
                .map(|u| vec![arg("units", "u32", u as u64)])
                .unwrap_or_default(),
            Some(3) => u64_at(1)
                .map(|p| vec![arg("micro_lamports", "u64", p)])
                .unwrap_or_default(),
            Some(4) => u32_at(1)
                .map(|b| vec![arg("bytes", "u32", b as u64)])
                .unwrap_or_default(),
            _ => vec![],
        },
        _ => vec![],
    }
}

/// Decode an instruction fully: its name, its arguments, and its accounts named
/// from the IDL (Anchor) or a known layout (native). `idl_cache` avoids re-fetching
/// an IDL for every instruction of the same program.
pub(crate) fn enrich(
    client: &RpcClient,
    idl_cache: &mut HashMap<String, Option<Value>>,
    program: &str,
    data: &[u8],
    account_indexes: &[usize],
    account_keys: &[String],
) -> (Option<String>, Vec<IxArg>, Vec<IxAccount>) {
    let mut lookup = |p: &str| -> Option<Value> {
        idl_cache
            .entry(p.to_string())
            .or_insert_with(|| {
                Address::from_str(p)
                    .ok()
                    .and_then(|a| idl::fetch_idl_json(client, a))
            })
            .clone()
    };
    enrich_with(&mut lookup, program, data, account_indexes, account_keys)
}

/// [`enrich`] without RPC: resolves IDLs from an already-loaded map only. Used
/// by the debugger and by fixture replays, where every relevant IDL was
/// captured up front.
pub(crate) fn enrich_offline(
    idls: &HashMap<String, Value>,
    program: &str,
    data: &[u8],
    account_indexes: &[usize],
    account_keys: &[String],
) -> (Option<String>, Vec<IxArg>, Vec<IxAccount>) {
    let mut lookup = |p: &str| idls.get(p).cloned();
    enrich_with(&mut lookup, program, data, account_indexes, account_keys)
}

fn enrich_with(
    lookup: &mut dyn FnMut(&str) -> Option<Value>,
    program: &str,
    data: &[u8],
    account_indexes: &[usize],
    account_keys: &[String],
) -> (Option<String>, Vec<IxArg>, Vec<IxAccount>) {
    let addresses: Vec<String> = account_indexes
        .iter()
        .map(|&i| account_keys.get(i).cloned().unwrap_or_default())
        .collect();

    let is_native = matches!(
        program,
        TOKEN
            | TOKEN_2022
            | SYSTEM
            | COMPUTE_BUDGET
            | ATA
            | MEMO
            | MEMO_V1
            | STAKE
            | VOTE
            | ALT
            | LOADER_UPGRADEABLE
            | RAYDIUM_AMM
            | SERUM_V3
            | OPENBOOK_V1
    );

    // Anchor's `emit_cpi!` invokes the program itself with the event bytes,
    // prefixed by a fixed 8-byte "event CPI" discriminator. Name it so the tree
    // says what it is instead of `?`.
    const ANCHOR_EVENT_CPI: [u8; 8] = [0xe4, 0x45, 0xa5, 0x2e, 0x51, 0xcb, 0x9a, 0x1d];
    if data.len() >= 8 && data[..8] == ANCHOR_EVENT_CPI {
        let accounts = addresses
            .into_iter()
            .enumerate()
            .map(|(i, address)| IxAccount {
                name: (i == 0).then(|| "Event Authority".to_string()),
                address,
            })
            .collect();
        return (Some("Emit Event".into()), vec![], accounts);
    }

    // Name + args + per-position account names.
    let (name, args, names): (Option<String>, Vec<IxArg>, Vec<Option<String>>) = if is_native {
        let name = match program {
            TOKEN | TOKEN_2022 => token_ix(data).map(String::from),
            SYSTEM => system_ix(data).map(String::from),
            COMPUTE_BUDGET => compute_budget_ix(data).map(String::from),
            // ATA discriminants: (empty data or) 0 = Create, 1 = CreateIdempotent,
            // 2 = RecoverNested.
            ATA => Some(
                match data.first() {
                    Some(1) => "Create Idempotent",
                    Some(2) => "Recover Nested",
                    _ => "Create",
                }
                .into(),
            ),
            STAKE => stake_ix(data).map(String::from),
            VOTE => vote_ix(data).map(String::from),
            ALT => alt_ix(data).map(String::from),
            LOADER_UPGRADEABLE => loader_ix(data).map(String::from),
            RAYDIUM_AMM => raydium_amm_ix(data).map(String::from),
            SERUM_V3 | OPENBOOK_V1 => serum_ix(data).map(String::from),
            _ => Some("Memo".into()),
        };
        let names = native_account_names(program, data)
            .into_iter()
            .map(|n| Some(n.to_string()))
            .collect();
        (name, native_args(program, data), names)
    } else {
        // Anchor program: resolve (and cache) its IDL, then match by discriminator.
        let idl = lookup(program);
        match idl.as_ref().and_then(|i| idl::find_ix(i, data)) {
            Some(ix) => {
                let name = ix.name.as_deref().map(titleize);
                let args = idl::decode_ix_args(&ix, data)
                    .into_iter()
                    .map(|(name, ty, value)| IxArg { name, ty, value })
                    .collect();
                // An Anchor account *group* (nested `accounts`) occupies one
                // position per leaf in the real account list, so flatten to
                // leaves with the group name as a prefix — otherwise every
                // account after a group is named after the wrong entry.
                fn leaves(
                    nodes: &[crate::idl_model::AccountNode],
                    prefix: &str,
                    out: &mut Vec<Option<String>>,
                ) {
                    for n in nodes {
                        let own = n.name.as_deref().map(titleize);
                        match &n.children {
                            Some(kids) => {
                                let p = match &own {
                                    Some(g) => format!("{prefix}{g} · "),
                                    None => prefix.to_string(),
                                };
                                leaves(kids, &p, out);
                            }
                            None => out.push(own.map(|o| format!("{prefix}{o}"))),
                        }
                    }
                }
                let mut named: Vec<Option<String>> = Vec::new();
                leaves(&ix.accounts, "", &mut named);
                (name, args, named)
            }
            None => (None, vec![], vec![]),
        }
    };

    // Pair each account with its role name; extras beyond the named list are the
    // program's "remaining accounts" (Anchor's variadic tail).
    let named_len = names.len();
    let accounts = addresses
        .into_iter()
        .enumerate()
        .map(|(i, address)| {
            let name = names.get(i).cloned().flatten().or_else(|| {
                (!is_native && !names.is_empty() && i >= named_len)
                    .then(|| format!("Remaining Account #{}", i - named_len + 1))
            });
            IxAccount { name, address }
        })
        .collect();

    (name, args, accounts)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn titleize_handles_camel_snake_and_digits() {
        assert_eq!(titleize("routeV2"), "Route V2");
        assert_eq!(titleize("route_v2"), "Route V2");
        assert_eq!(titleize("swapV2"), "Swap V2");
        assert_eq!(titleize("shared_accounts_route"), "Shared Accounts Route");
        assert_eq!(titleize("swap"), "Swap");
    }

    #[test]
    fn native_program_instructions_decode() {
        assert_eq!(token_ix(&[12]), Some("Transfer Checked"));
        assert_eq!(token_ix(&[3]), Some("Transfer"));
        assert_eq!(system_ix(&[2, 0, 0, 0]), Some("Transfer"));
        assert_eq!(compute_budget_ix(&[2]), Some("Set Compute Unit Limit"));
        assert_eq!(
            compute_budget_ix(&[4]),
            Some("Set Loaded Accounts Data Size Limit")
        );
        assert_eq!(token_ix(&[]), None);
    }

    #[test]
    fn native_account_layouts() {
        // Token TransferChecked → Source / Mint / Destination / Authority.
        assert_eq!(
            native_account_names(TOKEN, &[12]),
            vec!["Source", "Mint", "Destination", "Authority"]
        );
        // System Transfer → From / To.
        assert_eq!(
            native_account_names(SYSTEM, &[2, 0, 0, 0]),
            vec!["From", "To"]
        );
    }

    #[test]
    fn native_argument_decoding() {
        // Token Transfer: amount u64 at offset 1.
        let mut d = vec![3u8];
        d.extend_from_slice(&1_000_000u64.to_le_bytes());
        let a = native_args(TOKEN, &d);
        assert_eq!(
            (a[0].name.as_str(), a[0].value.as_str()),
            ("amount", "1000000")
        );
        // Compute Budget SetComputeUnitLimit: u32 at offset 1.
        let mut c = vec![2u8];
        c.extend_from_slice(&169_062u32.to_le_bytes());
        assert_eq!(native_args(COMPUTE_BUDGET, &c)[0].value, "169062");
    }
}