Skip to content

feat(cli): add serve subcommand for browsing decoded txs#273

Open
prasanna-anchorage wants to merge 5 commits into
mainfrom
prasanna/cli-serve-subcommand
Open

feat(cli): add serve subcommand for browsing decoded txs#273
prasanna-anchorage wants to merge 5 commits into
mainfrom
prasanna/cli-serve-subcommand

Conversation

@prasanna-anchorage
Copy link
Copy Markdown
Contributor

@prasanna-anchorage prasanna-anchorage commented Apr 30, 2026

Summary

Adds a serve subcommand to parser_cli that decodes a directory of raw-transaction files and serves a small read-only web UI on 127.0.0.1 so reviewers can scroll through results in a browser instead of running the CLI per-file.

The directory is re-decoded on every request — edit a fixture, refresh the browser, see the new state. No restart, no file watcher, no JS framework. .json files in the directory are passed through verbatim, so a tree of .expected outputs can be browsed alongside hex inputs (or on its own).

Each entry has its own browseable / curl-friendly URL, and the page is mobile-friendly with a per-entry copy button.

Screenshots

Desktop Mobile
add screenshot add screenshot

Suggested shots: index page on desktop, same page narrow / on a phone, and the copy button mid-press (the copied! confirmation state).

Try it against existing fixtures

The chain-parser fixture trees have .input (raw hex) and .expected (recorded JSON) sitting next to each other — perfect demo for this subcommand. The hex files get decoded fresh; the JSON files pass through as-is.

Run from src/ (same directory as the workspace Cargo.toml):

# Ethereum chain-parser fixtures
cargo run -p parser_cli --features serve -- serve \
  --chain ethereum -n ETHEREUM_MAINNET \
  --dir chain_parsers/visualsign-ethereum/tests/fixtures

# Solana chain-parser fixtures (recurses into jupiter_swap/, token_2022/)
cargo run -p parser_cli --features serve -- serve \
  --chain solana -n SOLANA_MAINNET \
  --dir chain_parsers/visualsign-solana/tests/fixtures

# Smaller CLI smoke set
cargo run -p parser_cli --features serve -- serve \
  --chain ethereum -n ETHEREUM_MAINNET \
  --dir parser/cli/tests/fixtures

Then http://127.0.0.1:47474. Each fixture name appears twice (1559.input decoded fresh, 1559.expected served as-is). Edit any file, refresh, see the new state.

--dir is resolved relative to the shell's CWD, so pass an absolute path if you want to invoke it from anywhere. Default port is 47474 rather than 8080 to avoid colliding with the dev-container REST port and the long list of other tools that grab 8080. Override with --port.

Routes

  • GET / — HTML index. One collapsible <details> per file, JSON inline. Each summary has a [json] link to the file's standalone URL plus a copy button that puts the rendered JSON onto the clipboard.
  • GET /<rel-path> — Bare payload as JSON, no envelope. e.g. /token_2022/transfer_checked.json returns that file's decoded payload (or its raw JSON, in passthrough mode). Browseable, bookmarkable, curl-friendly.
  • GET /api/file?path=<rel-path> — Wrapped envelope: {path, ok, payload?, error?}. Kept around for tooling that wants the success/error flag without checking the HTTP status code.

All routes re-walk the directory and re-decode each file on every hit. With ~tens of fixtures this is single-digit milliseconds; not worth a cache.

File handling

  • .json extension → file is parsed as serde_json::Value and served as-is. No schema validation, no coercion to SignablePayload.
  • everything else → trim, dispatch through the chain registry to a SignablePayload, serialize to JSON.

A directory can be all hex, all JSON, or mixed.

Mobile + copy button

The index page works on a phone and ships a vanilla-JS clipboard helper:

  • <meta name=viewport content="width=device-width, initial-scale=1"> — the actual reason it wasn't mobile-friendly before.
  • Long rel-paths wrap with word-break: break-all; [json] link and copy button get tappable padding; narrow-viewport CSS via @media (max-width: 600px).
  • Each entry renders a <button class=copy> next to its [json] link. Click → navigator.clipboard.writeText(pre.textContent) → button flips to copied! for 1.2s. event.stopPropagation() so the click doesn't toggle the <details> open/closed.
  • No framework. ~10 lines of plain JS embedded in the page.

CLI shape (breaking)

The pre-existing flat invocation moves under a decode subcommand:

# before
parser_cli --chain ethereum -t @tx.hex

# after
parser_cli decode --chain ethereum -t @tx.hex
parser_cli serve  --chain ethereum --dir ./txs        # new

Pre-1.0 internal CLI; happy to revisit if the breakage is undesirable.

Dependencies

axum 0.8 + tokio are added as optional direct deps of parser_cli, gated behind a new serve Cargo feature. Both crates are already transitive via tonic in the lock file, so the resolved dep tree gains zero new compiled crates. Default builds (and parser_app/enclave) are unchanged — axum/tokio are not pulled in unless --features serve is passed.

[features]
serve = ["dep:axum", "dep:tokio"]

[dependencies]
axum  = { version = "0.8", optional = true, default-features = false, features = ["http1", "tokio", "query", "json"] }
tokio = { workspace = true, optional = true, features = ["rt", "macros", "net"] }

No new deps for live reload or the copy button — both are behavior changes in the existing handlers / a vanilla-JS snippet inlined into the rendered HTML.

Out of scope (deferred to follow-ups if useful)

  • File-watcher / SSE / WebSocket auto-refresh — manual browser refresh is the trigger by design
  • Multiple --dir flags / multiple chains in one server instance
  • Paste-and-decode form (POST endpoint)
  • Watching --abi-json-mappings / --idl-mappings files (registry stays static)
  • Auth / TLS — 127.0.0.1-only by design

Test plan

  • cargo build -p parser_cli (default) — unchanged, no axum/tokio
  • cargo build -p parser_cli --features serve
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo clippy -p parser_cli --features serve --all-targets -- -D warnings
  • cargo test -p parser_cli --features serve serve — 10 unit tests + 2 integration tests pass, including:
    • json_files_passthrough_as_is.json file served verbatim
    • malformed_json_errors_with_invalid_json_prefix — bad JSON surfaces as an error entry
    • mixed_hex_and_json_directory_decodes_both — both decode paths in one dir
    • url_encode_path_preserves_separators_and_safe_chars — rel-path → URL helper
    • render_html_contains_paths_and_payload — viewport meta tag present, copy button rendered per entry, clipboard JS hooked up
    • render_error_page_is_mobile_friendly — error page also carries the viewport meta
    • test_cli_serve_live_reload_and_json_passthrough — end-to-end: spawn server, mutate JSON file → second GET reflects change, drop in a new file → it shows up at /, truncate hex file → flips to error state, standalone-URL route returns bare payload + 404 on miss, all without restart
  • Manual smoke against chain_parsers/visualsign-ethereum/tests/fixtures — both .input and .expected files render, edits live-reload on browser refresh, standalone URLs (/<rel-path>) return bare payloads
  • Manual mobile check — load http://<dev-host>:47474 from a phone (or Chrome DevTools device emulation), confirm no horizontal scroll on the page chrome, copy button copies the entry's JSON

🤖 Generated with Claude Code

@prasanna-anchorage prasanna-anchorage marked this pull request as ready for review May 4, 2026 23:46
Base automatically changed from prasanna/cli-transaction-from-file to main May 5, 2026 11:11
@prasanna-anchorage prasanna-anchorage force-pushed the prasanna/cli-serve-subcommand branch from e88e62a to 3918f22 Compare May 5, 2026 12:46
Copilot AI review requested due to automatic review settings May 5, 2026 12:46
@prasanna-anchorage prasanna-anchorage changed the title feat(cli): add serve subcommand for browsing decoded txs (stacked on #271) feat(cli): add serve subcommand for browsing decoded txs May 5, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new serve subcommand to parser_cli, alongside moving the existing one-shot transaction decoding flow under a new decode subcommand. This extends the CLI from single-transaction inspection to local directory-based browsing of decoded fixtures in a lightweight web UI.

Changes:

  • Refactors the CLI into decode and feature-gated serve subcommands.
  • Adds the local HTTP server, HTML renderer, and tests for live reload / JSON passthrough behavior.
  • Updates fixtures, docs, and Cargo feature wiring to match the new CLI shape.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/parser/cli/tests/fixtures/solana-text.input Updates fixture invocation to use the new decode subcommand.
src/parser/cli/tests/fixtures/solana-json.input Updates fixture invocation to use the new decode subcommand.
src/parser/cli/tests/fixtures/ethereum-json.input Updates fixture invocation to use the new decode subcommand.
src/parser/cli/tests/fixtures/ethereum-from-file.input Updates fixture invocation to use the new decode subcommand.
src/parser/cli/tests/cli_test.rs Adapts existing CLI tests and adds integration coverage for the new serve mode.
src/parser/cli/src/serve.rs Introduces the new directory-scanning HTTP server, routes, HTML output, and unit tests.
src/parser/cli/src/lib.rs Exposes the new serve module and refactors plugin-arg plumbing for multiple subcommands.
src/parser/cli/src/cli.rs Reworks CLI parsing around subcommands and factors shared runtime preparation.
src/parser/cli/Cargo.toml Adds the optional serve feature and feature-gated axum/tokio dependencies.
src/Cargo.lock Records the new optional direct dependencies for parser_cli.
CLAUDE.md Updates local usage examples for decode and documents the new serve command.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +224 to +225
if metadata.is_dir() {
walk(base, &path, out, chain_str, runtime)?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Switched to entry.file_type() (uses lstat) and explicitly skip is_symlink() entries before recursing or decoding. The new test (fn walk_skips_symlinks would be a follow-up — the structural fix is the file_type/is_symlink path itself).

Comment on lines +318 to +343
) -> Result<Json<serde_json::Value>, StatusCode> {
let entries = load_entries(&state)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let entry = entries
.iter()
.find(|e| e.rel_path == q.path)
.ok_or(StatusCode::NOT_FOUND)?;

let response = match &entry.result {
Ok(payload) => FileResponse {
path: &entry.rel_path,
ok: true,
payload: Some(payload),
error: None,
},
Err(err) => FileResponse {
path: &entry.rel_path,
ok: false,
payload: None,
error: Some(err),
},
};
serde_json::to_value(&response)
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. handle_file now always returns the { path, ok, payload?, error? } envelope. Missing paths and load_entries failures populate ok: false with the cause in error. The HTTP status code still reflects outcome (200/404/422/500) so status-aware clients keep working, but the body contract is honored unconditionally.

Comment on lines +398 to +409
const COPY_SCRIPT: &str = "document.addEventListener('click',function(e){\
var btn=e.target.closest('button.copy');\
if(!btn)return;\
e.preventDefault();e.stopPropagation();\
var pre=btn.closest('details').querySelector('pre');\
if(!pre||!navigator.clipboard)return;\
navigator.clipboard.writeText(pre.textContent).then(function(){\
var orig=btn.textContent;\
btn.textContent='copied!';btn.classList.add('copied');\
setTimeout(function(){btn.textContent=orig;btn.classList.remove('copied');},1200);\
});\
});";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description nit; the body actually already says "a vanilla-JS clipboard helper" and "No framework. ~10 lines of plain JS" in a later section, so the framing is internally consistent, just spread across sections. Will tighten the wording in the description but no code change is needed.

Comment on lines +246 to +249
full.strip_prefix(base)
.unwrap_or(full)
.display()
.to_string()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. rel_path now joins the strip_prefix-ed components() with / so Windows paths produce dir/file.hex to match the URL routes. It also returns None if strip_prefix fails (pepe-anchor flagged the same fallback path leaking the absolute host path) — callers skip the entry rather than emit a defective rel_path.

Comment on lines +406 to +410
// Ask the OS for a free port: bind ephemeral, drop, race-tolerable since
// the server immediately re-binds.
let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
let port = probe.local_addr().expect("local_addr").port();
drop(probe);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The test now passes --port 0 and parses the actual bound port out of the server-printed Serving on http://127.0.0.1:N line. The existing local_addr() print already carries the real port, so this drops the probe/drop/rebind race entirely.

Comment on lines +505 to +507
let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
let port = probe.local_addr().expect("local_addr").port();
drop(probe);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Same --port 0 + parse-from-stdout pattern as test_cli_serve_renders_directory.

continue;
}
let path = entry.path();
let metadata = match entry.metadata() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

walk() has no recursion depth limit — symlink loops crash the server

DirEntry::metadata() follows symlinks (stat, not lstat), so a symlink loop (e.g. dir/loop -> ..) causes unbounded recursion and a stack overflow on the first HTTP request. No visited-set or depth cap.

Fix: skip symlinks via entry.file_type().is_symlink().


Found by Claude on behalf of @pepe-anchor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the same commit as the Copilot symlink thread above. entry.file_type() is lstat-based and is_symlink() entries are skipped before recursion. No visited-set/depth-cap needed once symlinks are out of the picture.

Ok(())
}

fn rel_path(base: &Path, full: &Path) -> String {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rel_path falls back to the absolute path when strip_prefix fails

If strip_prefix(base) fails (e.g. after symlink resolution diverges), unwrap_or(full) returns the full absolute path, which is then embedded in the rendered HTML. This leaks the host filesystem layout to anyone who can reach 127.0.0.1.

Better: skip the entry with a synthetic error.


Found by Claude on behalf of @pepe-anchor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the same commit. rel_path now returns Option<String> and only emits a path when strip_prefix succeeds; callers skip the entry on None rather than pushing a DecodedEntry with an absolute path. The rendered HTML and route table can no longer carry host filesystem layout.

/// `/token_2022/transfer_checked.json` returns just that file's payload as
/// JSON. Wraps no envelope around it so browsers and `curl` see the raw
/// `SignablePayload` (or the verbatim file content for `.json` passthrough).
async fn handle_payload(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-file endpoints decode the entire directory on every request

handle_payload and handle_file both call load_entries, which walks and decodes all N files in --dir just to find one by equality. For single-file routes, validate the path is under state.dir and call decode_file directly -- same freshness at O(1) instead of O(N).


Found by Claude on behalf of @pepe-anchor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring intentionally: typical --dir is the in-repo tests/fixtures/... tree (tens of files), which decode_directory walks in single-digit ms. The doc comment on the live-reload behavior calls out re-decoding on every request as a deliberate design choice (so editing a file → refresh → see new state with no cache invalidation). If the use case grows to "point it at a hundreds-of-files dir", a single-file fast path that validates the rel_path is canonicalized under state.dir and then calls decode_file directly is a small follow-up — happy to do it if you want it in this PR, just flag and I will.

Comment thread src/parser/cli/src/cli.rs
pub struct Cli;
impl Cli {
/// start the parser cli
pub fn execute() -> Result<(), String> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cli::execute signature promises Result but always returns Ok(())

After the refactor, both execute_decode and execute_serve call process::exit(1) directly on errors, so Cli::execute never returns Err. The if let Err(e) = Cli::execute() branch in main.rs is now dead code.

Either change the signature to -> (), or thread errors back as Result from both delegates.


Found by Claude on behalf of @pepe-anchor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Cli::execute now returns () (the Result<(), String> was always Ok(()) since both branches process::exit(1) directly), and the dead if let Err branch in main.rs is dropped.

Comment thread src/parser/cli/src/cli.rs
@@ -9,6 +9,21 @@ use visualsign::{SignablePayload, SignablePayloadField};
#[command(version = "1.0")]
#[command(about = "Converts raw transactions to visual signing properties")]
pub(crate) struct Args {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirm no external callers of the old flat --chain / -t syntax

The old parser_cli --chain ethereum -t <hex> interface is hard-broken. In-tree tests and fixtures were updated correctly.

Before merging: any external scripts, CI pipelines, or docs outside this repo still using the flat syntax?


Found by Claude on behalf of @pepe-anchor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: searched anchorageoss/* for parser_cli --chain and parser_cli -t invocations outside this repo via gh code-search and found none. In-tree calls (CI, integration tests, docs) all already use the decode subcommand. The break is intentional and pre-1.0; happy to ship a one-release transition alias (parser_cli --chain ... -t ... warns + dispatches to decode) if you want a softer migration, but my read is the explicit break is fine here.

prasanna-anchorage and others added 5 commits May 14, 2026 18:50
Restructures `parser_cli` into a true subcommand model:

  parser_cli decode --chain X -t @file       # was: parser_cli --chain X -t @file
  parser_cli serve  --chain X --dir ./txs    # new

`serve` (gated behind a `serve` Cargo feature) scans a directory of raw
transaction files at startup, decodes each one against the requested
chain, and serves a small read-only web UI on `127.0.0.1:<port>`:

  - GET /                       HTML page, one collapsible <details>
                                block per file with the pretty JSON inline
  - GET /api/file?path=<rel>    JSON for a single entry

Hand-rolled HTML, no JS, no template engine. Server is single-thread
tokio (axum 0.8). Both `axum` and `tokio` are already transitive via
`tonic` in the lock file, so the resolved dep tree gains nothing by
adding them as direct deps for `parser_cli`.

The default build (no `--features serve`) is unchanged: axum and tokio
are not pulled in, the `Serve` variant of the subcommand enum is gated
out, and `parser_app`/enclave callers see no new deps.

Existing fixtures get a leading `decode` line to match the new grammar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-decode the directory on every HTTP request instead of caching at
startup, so editing a fixture and refreshing the browser is enough to
see the new state — no server restart needed. Useful when iterating on
fixtures across multiple worktrees.

Files with a `.json` extension are parsed as `serde_json::Value` and
served as-is, so a directory of `.expected` outputs (or any pre-recorded
JSON) can be browsed without going through the chain decoder. Other
files take the existing hex-decode path. Mixed dirs work either way.

`DecodedEntry.result` and `FileResponse.payload` switch from
`SignablePayload` to `serde_json::Value` so both decode paths share one
shape. Re-decoding moves into the request handlers via
`tokio::task::spawn_blocking` to keep fs+CPU off the current-thread
async executor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dev container already uses 8080 for REST, and 8080 collides with
just about every other local dev tool. 47474 is uncommon enough to
avoid most conflicts and easy enough to type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a wildcard route so any rel path under the served directory becomes
a bookmarkable URL — `GET /token_2022/transfer_checked.json` returns
that file's payload directly as JSON, no envelope. Useful when sharing
a fixture's decoded view (e.g. through a Cloud Workstations tunnel) or
when piping into curl/jq.

Each entry on the index page now has a small `[json]` link next to its
path, pointing at the standalone URL. Clicking the path text still
toggles the `<details>` block; the `[json]` link navigates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing viewport meta tag (the actual reason the page wasn't
mobile-friendly), small responsive CSS tweaks, and a vanilla-JS copy
button next to each entry's `[json]` link.
@prasanna-anchorage prasanna-anchorage force-pushed the prasanna/cli-serve-subcommand branch from 3918f22 to cadccbf Compare May 14, 2026 18:59
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants