paritytech / paritytech/polkadot-cli
Feasibility: rewrite polkadot-cli in pure Rust (subxt + polkadot-sdk)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 10
- Forks
- 2
- Avg merge
- 12h 35m
- Merged PRs (30d)
- 4
Description
What this issue is about
A feasibility study on rewriting polkadot-cli (currently TypeScript + Bun + polkadot-api v2) in pure Rust on top of subxt + polkadot-sdk crates + paritytech/verifiable.
This is a decision-input ticket, not a "let's start coding" ticket. Read the verdict, the pros/cons, and the risks. The implementation phasing at the bottom is for orientation only — it's deliberately a "nice to have, read last" section. The point is to decide whether to do this, not yet how.
Source: polkadot-cli v1.19.0 — 49 source files (~12K LOC), 47 test files (~15K LOC), comprehensive command surface (query, tx, const, events, errors, apis, extensions, rpc, chain, account, metadata, inspect, hash, sign, parachain, verifiable, completions).
Verdict
Feasibility: 7/10.
Recommendation: dual-track with a hard prove-it gate. Don't commit up front. Do this instead:
- Phase 0 spike (1.5–2 weeks, one engineer). Build, against
nextv2-ah, byte-identical signed extrinsic hex compared to the TS CLI, including at least one custom signed extension via a--ext-style override. Plus byte-identical Bandersnatch member key against existing TS golden vectors. If those bytes don't match in 7 working days, abort or extend the spike — don't wave at it. - If the spike passes: commit to Phase 1 (4–6 wks). Ship as
dot-rsalongside TS, opt-in. Pull internal Parity user feedback. - After Phase 1: decide on the remaining phases based on whether the 60-case parity suite is paying for itself.
- TS stays at maintenance pace during dual-track. No new features in TS once Rust catches up. Hard-cut name
dot-rs→dotonce parity is declared. Keep npm package as a binary-downloader shim for 12 months.
The one thing to do before committing anything: the Phase 0 byte-equality artefact for --ext. A spike that ends with "looks the same" is theater. A spike that ends with "here is the extrinsic hex from both CLIs, identical to the last byte" is a real go/no-go signal.
Why 7 and not higher
- 1 point off: the
--extschemaless signed-extension encoder is the single concentrated engineering risk. subxt assumes the SE set is known at compile time; replicating papi's "any JSON, any SE, any chain" requires a dynamic encoder built onscale-value+frame-decode+ metadata. ~1–2 weeks of focused work and the kind of mistake that bites a year later. - 1 point off:
subxtis pre-1.0 (0.50.1, 2026-04-27). Quarterly minor releases are still breaking; a 0.5–2 day upgrade tax per quarter for 12–18 months. - 1 point off: parity-testing is large enough that a careless team would skip it and ship a subtly-wrong CLI.
Why 7 and not lower
- Every required capability has a known Rust dep with a known story (subxt + scale-value + frame-decode + sp-core + schnorrkel + paritytech/verifiable + clap v4 + jsonrpsee + tokio).
- The TS codebase ports almost module-for-module. Tests are subprocess-based and the 442 KB Polkadot v15 metadata fixture binary is portable to a Rust test runner unchanged.
- The wins are concrete and load-bearing (cold start, no Node toolchain on user machines,
paritytech/verifiabledirect, single binary, embedded smoldot becomes practical). - The biggest cryptographically-sensitive component shrinks instead of growing:
verifiablejsis literally awasm-bindgenwrapper overparitytech/verifiable(Cargo.toml inspected). Rust calls upstream directly — byte-identical by construction, no WASM init. - Exit ramps are clear: dual-track preserves options, deprecation is mechanical.
Pros and cons
Strictly better in Rust (concrete, not marketing)
- Cold start. Bun TS CLI: ~120–200 ms before any work begins. Rust release+LTO: ~5–15 ms. For a CLI invoked 50× a day, the single most-noticed improvement.
- No Node/Bun on user machines. Single static binary, ~25 MB stripped (~12 MB UPX). CI runners, internal infra, ARM boxes all get one-line installs.
paritytech/verifiabledirect. Removes the WASM init step (~30–50 ms first call) and theverifiablejsJS wrapper hop. Byte-identical output.- Memory.
query.System.Account --dumpis ~80–120 MB resident in TS, ~10–20 MB in Rust. - Distribution.
cargo-dist→ GH releases + Homebrew tap + cargo + alpine static. Aligned withsubxt-cli,polkadot-omni-node,try-runtime,chopsticksbinary norms. - Concurrent chain ops.
tokio+ subxt parallelizes cheaper than Promise.all (same code, lower per-op overhead). - Embedded smoldot. Feature flag in Rust, ~40 MB cost. Practical "no-RPC, no-internet" operation against trusted chain specs.
- No native-module rebuild. Static crypto; no postinstall scripts; no platform-specific subpath imports.
Strictly worse in Rust (concrete)
- Dynamic-API ergonomics.
subxt::dynamicis decisively second-class to the codegen path. Every call site is ~3× the LOC and harder to skim. Felt throughout the codebase.unsafeApi.query.System.Account.getValue(addr)→client.storage().fetch(&dynamic("System", "Account", vec![Value::from_bytes(addr)]), block_hash).await?.unwrap().to_value()?. - Compile times. Clean release build 4–8 min Apple Silicon, 8–15 min x86 CI. Incremental dev: 5–30 s. TS build is ~1 s. Iteration on commands slows noticeably.
- Binary size. ~25 MB stripped, ~65 MB with embedded smoldot. Roughly even-on-disk vs node_modules, worse perception.
- subxt pre-1.0 churn. 0.5–2 days/quarter upgrade tax.
- npm discovery loss.
npm search polkadot-cliis a real onboarding surface. Cargo discovery is weaker for non-Rust users. Mitigation: keep npm package as a binary-downloader for 12 months. - YAML library situation.
serde_yamlis officially unmaintained (rustsec advisory-db #2132);serde_ymlhas provenance concerns;serde_yaml2works but doesn't preserve key order. Hand-roll--to-yamloutput for stability. - Dynamic
--extporting cost. ~1–2 wks of focused work. The concentrated risk. - Lost workflow: JS REPL debugging. papi's
getUnsafeApi()is excellent as a debug aid alongside the CLI. Rust CLI is results-only. Phase 4'sdot-txcrate publication recovers this for Rust users only. - Drive-by community PRs. The TS CLI's recent history shows non-Parity contributors. Rust raises the contribution bar; this surface will drop ~to zero.
What Rust enables that TS struggles with
- Embedded light client. smoldot in Rust is a feature flag, not a 5 MB WASM bundle with init cost. Genuine no-RPC operation becomes practical.
- In-process composition with
try-runtime. Rust composesframe-try-runtimedirectly. TS does subprocess + JSON bridge at best. - Reusable
dot-meta+dot-txcrates for the ecosystem. There is no good "dynamic papi-equivalent for Rust" today. Indexers, bots, monitoring scripts, signing services all want this. Force multiplier; not achievable from TS. - FFI surface. Python/Go/Swift bindings via C ABI become straightforward.
- WASM target for browser later.
cargo build --target wasm32-unknown-unknownofdot-meta/dot-txis a real product opportunity — dynamic transaction builder for browser dApps that don't ship papi. - Static reproducible builds. GitHub-attested binaries, sigstore signing, deterministic builds.
- Performance ceiling for batch ops. 1M-key storage dump streams cleanly in Rust; V8 GC-pressures the TS path.
What current users lose / break
npm install -g polkadot-clipath. Phase 4 keeps the package as a binary-downloader for 12 months.- During dual-track, Rust ships as
dot-rs; renames todotonly once parity is declared. TS users keep working through the overlap. - Hold
~/.polkadot/schema stable. Don't XDG-migrate as part of this project; that's a separate breaking-change ticket. - Behavior dependence on papi internals (e.g. the
ChargeAssetTxPaymentisAssetCompatbypass attx.ts:340-353). The Rust port won't carry the workarounds. Document explicitly. - Any commands deferred past Phase 3: smoldot light mode (optional), update notifier, niche outputs like
--rawSCALE hex on edge categories. Track as known gaps, not silent drops.
Capability matrix (the technical grounding for the verdict)
Verified against current crates.io / docs.rs / GitHub state (2025-2026).
| # | Capability | Recommended crate(s) | Verdict | Risk |
|---|---|---|---|---|
| 1 | Dynamic chain client (no codegen) | subxt 0.50.1 + subxt::dynamic + scale-value |
Workable, ergonomics tax vs papi | Medium |
| 2 | Schemaless --ext signed extensions |
Custom on scale-value + frame-decode + metadata |
Biggest gap. ~1–2 wks. | High |
| 3 | chainHead_v1 / archive RPC | subxt-rpcs UnstableBackend + auto-fallback LegacyBackend |
Solved, dual backend required (most 3rd-party RPCs are legacy only) | Low |
| 4 | sr25519 + BIP39 + HDKD (SURI) | sp-core / schnorrkel / substrate-bip39 |
Direct port, byte-identical to hdkd-helpers | Low |
| 5 | SS58 codec | sp_core::crypto::Ss58Codec + ss58-registry 1.46 |
Solved | None |
| 6 | Bandersnatch member key | paritytech/verifiable v0.5.0 (git dep, not on crates.io) |
Strictly better than TS — same crate, no WASM, no wrapper | None |
| 7 | SCALE + metadata V8–V16 | parity-scale-codec 3.7.5, frame-metadata, frame-decode |
Solved | Low |
| 8 | CLI framework | clap v4 builder + passthrough subcommands |
Solved | Low |
| 9 | tokio + WS JSON-RPC | tokio + jsonrpsee 0.26 (transitive via subxt) |
Solved | Low |
| 10 | Distribution | cargo-dist; npm shim during deprecation |
Strictly better tech; comms risk | Medium (comms) |
| 11 | Shell completion (static + dynamic) | clap_complete::CompleteEnv (unstable-dynamic) |
Solved, better than current static path | Low |
| 12 | Cross-platform paths | directories crate; keep ~/.polkadot/ |
Solved; don't XDG-migrate now | UX risk |
| 13 | YAML + ${VAR} |
serde_yaml2 + hand-rolled substitution; avoid serde_yml; serde_yaml is rustsec-advisory #2132 unmaintained |
Messy but workable | Low |
Capability #2 is the only one whose risk is concentrated. Everything else is mechanical or well-served.
Capability #6 is the upside that flipped this from "be cautious" to "feasible". The cryptographically-sensitive component shrinks instead of growing.
Risks that move the estimate
In priority order:
- Dynamic
--extbyte-equality mismatch. Ifscale_value::scale::encode_as_typeproduces different bytes from papi'sbuilder.buildDefinition(typeId).enc(value)for any non-trivial SE, debugging is multi-day SCALE-spec archaeology per case. Mitigation: Phase 0 hard gate againstnextv2-ah. - subxt 0.x minor churn during Phases 1–2. Pin
Cargo.lock. Budget 1 day per upgrade. - Schnorrkel HDKD edge cases. TS mirrors
@polkadot-labs/hdkd-helpersinternals (accounts.ts:56-89) because they aren't re-exported. sp-core must produce identical chain codes for numeric and string junctions. Mitigation: Phase 0 golden-vector test. - YAML round-trip.
serde_yaml2doesn't preserve key order; TSyamlpackage does. Mitigation: hand-serialize fixed-order YAML for--to-yaml. --extJSON normalization. The shape papi accepts is loose (bare value vs{value}, enums as{type, value}or string, BigInt as string or number). PortparseTypedArg+normalizeValue(tx.ts:1100-1540) faithfully.- Output formatter surface. ~30 small format functions in
tx.ts. Tedious not hard. ~3–5 days, not "trivial".
Parity testing strategy (the single most important non-code investment)
The failure mode this defends against: "ship a Rust CLI with 95% parity, where 5% silently produces wrong bytes."
Two-CLI diff harness, co-located in this repo
// tests/parity/run_both.rs
struct ParityCase { name, args, expects: ByteIdentical | JsonEqual | SemanticEqual(fn) }
fn run_ts(args, env) -> Output { /* exec `bun src/cli.ts` */ }
fn run_rs(args, env) -> Output { /* exec `target/release/dot` */ }
ByteIdentical for crypto-driven goldens (addresses, encoded calls, signatures, storage keys). JsonEqual for --json (key order may differ; use indexmap if it must be preserved). SemanticEqual(fn) for content with timestamps or other non-determinism.
Goldens are TS-CLI-captured JSON outputs, regenerated by a script. Rust diffs against them. Intentional TS behavior change → regen + review in a PR. Unintentional drift → caught at CI time.
Test-category strategy
| Category | TS count | Strategy |
|---|---|---|
E2E subprocess via Bun.spawn + fixture |
~300 | Port runCli → runDot (~80 LOC Rust). Reuse fixture binary. |
| Unit (output formatting, parsers) | ~150 | Reimplement; port the expected outputs as goldens, not test code. |
| Golden crypto (deterministic addresses/keys/sigs) | ~20 | Highest leverage. Same vectors. assert_eq!. |
| Network-touching | ~20 (mostly skipped in TS CI) | Chopsticks pre-merge for tx submission paths only. |
| Bandersnatch | ~10 | WASM-vs-native, same vectors. |
The 60-case must-pass parity suite (gating release)
All 60 must pass byte-identically (modulo platform newlines + ANSI under --json).
- Crypto (15): SS58 prefix 0/2/42, sign hello/0xhex, mnemonic+derivation pubkey, pallet+parachain sovereigns (treasury / 1000-child / 1000-sibling), bandersnatch member, blake2-128/256, xxh64, keccak256.
- SCALE encoding (15):
Balances.transfer_keep_alive,System.remark, nestedUtility.batch, XCM LocationPolkadotXcm.send, asset transfer on asset-hub, compact BigInt, rawtx.0x1f...decode,--to-yaml/--to-jsonround-trips, storage-key hash + decoded value, runtime APIapis.Core.version. - Dynamic
--ext(10):{}produces same hex as no-ext,CheckMetadataHashoverride, unsigned/general v5 0x45,--assetXCM Location, full custom SE onpaseo-asset-hub, value-and-additionalSigned override, shape-mismatch error category equivalence, era=immortal exact bytes, era=Mortal64 exact bytes, tip+nonce+mortality combined. - Submission flow on chopsticks (10): dry-run fee ±1%, broadcast event txHash, best-block events, finalized dispatch result, unsigned Sudo bypass,
--atagainst pinned block,Balances.InsufficientBalancedecode,Balances.Transferevent decode, NDJSON byte-equal, mortality expiry behavior. - CLI surface (10):
--help, completions script validity,chain list --json,chain add+ read-back on-disk, file-based./transfer.yaml --to-json,--varsubstitution,--version, fuzzy-match suggestions text,--at 0xshorterror,__complete dot quer<TAB>.
CI layout
- Pre-merge gate: 60 cases against the fixture binary only (zero network). ~90s added to CI.
- Nightly: chopsticks-backed for the 10 submission-flow cases. 5–10 min.
- Quarterly manual: Westend/Paseo sanity run before each release.
Verification spike (do this before acting on this issue)
Three smoke tests before committing to the Phase 0 spike. If any fails, the assumptions here need revision.
paritytech/verifiablebuilds in isolation:
≤ 5 min, no toolchain errors.cargo new --bin verifiable-spike && cd verifiable-spike cargo add verifiable --git https://github.com/paritytech/verifiable.git cargo buildsubxt::dynamicagainstnextv2-ah: 20-LOC storage query from current docs.rs/subxt.- Metadata fixture loads from Rust: 5-LOC integration test that loads
src/commands/__fixtures__/polkadot-metadata.binviaframe-metadata+scale-infoand prints pallet names.
Implementation phasing — read last; not the point of this issue
All estimates assume one engineer fluent in both ecosystems. Multiply by 1.6 for "Rust-fluent but new to subxt".
Phase 0 — Spike & Go/No-Go (1.5–2 wks)
200-LOC binary against nextv2-ah that:
- Fetches v15 metadata via
subxt-rpcslegacy backend. - Encodes
Balances.transfer_keep_aliveandAssets.transferdynamically. - Encodes a full signed v4 extrinsic with the chain's complete SE set, including
ChargeAssetTxPaymentwith an XCMLocationasset, where one extension is overridden via--ext-style JSON. - sr25519 dev
//Alicederived two ways (sp-core + scure-shaped) → same pubkey, same signature. verifiablegit dep linked,member_from_entropycalled from Rust, byte-equal to TS golden vectors.
Hard exit: byte-identical signed extrinsic hex vs TS-produced for same nonce/era/genesis/spec with --ext overriding ≥1 custom extension; byte-identical Bandersnatch member key.
Phase 1 — Minimum useful CLI (4–6 wks)
query, tx (with --dry-run/--encode/--to-yaml/--to-json/--wait/--nonce/--tip/--mortality/--at/--unsigned/--asset/--ext, dev accounts only), const, metadata, chain config commands, account inspect, --json everywhere, static completions. Same on-disk config schema as TS. Legacy backend only.
Out of scope: real-account secret storage, sovereigns, events|errors|extensions|apis|rpc categories, inspect/focused-inspect, hash, sign, verifiable, parachain, file-based input, raw call hex, chainHead_v1, update notifier, dynamic completion.
Exit: ~35–40 parity tests against the metadata fixture. Live chopsticks test: same --asset transfer lands from both CLIs. Cold start dot --help ≤ 50 ms.
Phase 2 — Parity wave A (3–4 wks)
events, errors, extensions, apis, rpc; account add/import/export/remove/list/derive; sign; hash; inspect/focused-inspect; pallet + parachain sovereigns; file-based commands + --var.
Phase 3 — Parity wave B + Rust-only wins (3–4 wks)
verifiable (direct paritytech/verifiable); chainHead_v1/archive via UnstableBackend with auto-fallback; raw call hex (tx.0x1f...); dynamic completion via CompleteEnv; update notifier. Optional: smoldot embedded behind --light (feature-gated).
Phase 4 — Deprecation & ecosystem (2–3 wks)
dot migrate-config; publish dot-meta + dot-tx crates; rename dot-rs → dot; npm becomes binary-downloader shim for 12 months; final TS release v1.20 + deprecation banner. Optional: wasm32-wasi build for npm fallback.
Calendar totals
| Phase | Best | Expected | Worst |
|---|---|---|---|
| 0 — Spike | 1.0w | 1.5w | 3.0w |
| 1 — Min useful | 4.0w | 5.0w | 7.0w |
| 2 — Parity A | 3.0w | 3.5w | 5.0w |
| 3 — Parity B + wins | 3.0w | 3.5w | 5.0w |
| 4 — Deprecation | 2.0w | 2.5w | 4.0w |
| Total (1 eng) | 13w | 16w | 24w |
Bottom-up by block totals ~22 wks expected. Use 22 for budget, 16 for milestones.
Two engineers: ~25% savings on Phase 1 (tx is a chokepoint), ~40% on Phases 2–3. Realistic 2-eng calendar: 11–16 wks.
Architecture sketch
Cargo workspace, single binary dot, four library crates so the dynamic encoder is testable in isolation and the meta/tx crates are independently publishable:
dot/
├── crates/
│ ├── dot-meta/ # MetadataBundle, lookup, fingerprint, describe, fetch
│ ├── dot-tx/ # Dynamic call codec + SE encoder + general-tx (the risky bit)
│ ├── dot-keys/ # sr25519 + BIP39 + SURI + SS58 + bandersnatch + sovereigns
│ ├── dot-client/ # ChainClient over Legacy + Unstable backends; submit-and-watch
│ └── dot-cli/ # [[bin]] name = "dot": clap, commands, config I/O, output
└── tests/
├── fixtures/polkadot-metadata.bin # symlink → src/commands/__fixtures__/polkadot-metadata.bin
└── parity/ # cross-CLI diff harness
Local equivalent of papi's getUnsafeApi(): ChainClient struct with storage(pallet, item, &[Value]), constant(...), runtime_api(...), build_call(...), submit_and_watch(...) → impl Stream<Item = TxEvent>. scale_value::Value is the JSON-shaped lingua franca. ~3× the LOC at call sites, same semantics.
Dynamic SE encoder in dot-tx/src/extensions/:
- Walk SE registry from metadata.
- For each SE, type-lookup → detect auto-default shape (void,
Option<T>, enum-with-Disabled-variant). - Builtins (
PAPI_BUILTIN_EXTENSIONS): client fills in. - Non-builtins: take
--extJSON, parse toscale_value::Value, validate shape, SCALE-encode viaencode_as_type. - Concatenate in registry order. Round-trip verify.
Mode-switch (signed v4 vs general v5 0x45) in general_tx.rs. Wire format directly portable from tx.ts:1685+.
Critical files to read before starting Phase 0
src/commands/tx.ts— the largest, riskiest command; SE encoder lives here.src/core/metadata.ts— metadata fetch + decode + fingerprint + persistence.src/core/client.ts— client lifecycle, lazy connect, destroy semantics.src/core/accounts.ts— sr25519 + BIP39 + SURI + sovereign derivation.src/commands/__fixtures__/run-cli.ts—runClipattern that the RustrunDotmust mirror 1:1.src/utils/runtime-fingerprint.ts— fingerprint shape; Rust cache must match for dual-track compat.src/commands/__fixtures__/polkadot-metadata.bin— symlink target for Rust test fixture.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the Verdict and Phase 0 prove-it gate, then read the referenced TypeScript paths tx.ts:1100-1540 and accounts.ts:56-89 plus the proposed tests/parity/run_both.rs harness. This issue is complete when the feasibility decision is supported by the specified byte-equality and golden-vector evidence; it is not yet a self-contained coding task.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, rust, typescript
- Domain
- cli, developer-experience, testing-qa
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100