#![allow(missing_docs)]
mod cmd;
#[cfg(feature = "cli-compiler")]
mod compile;
mod error;
mod format;
mod parse;
use clap::{Parser, Subcommand};
use std::process::ExitCode;
use error::CliError;
/// CLI-facing network selector. Maps to `bitcoin::Network`.
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
enum CliNetwork {
Mainnet,
Testnet,
Signet,
Regtest,
}
impl From<CliNetwork> for bitcoin::Network {
fn from(n: CliNetwork) -> Self {
match n {
CliNetwork::Mainnet => bitcoin::Network::Bitcoin,
CliNetwork::Testnet => bitcoin::Network::Testnet,
CliNetwork::Signet => bitcoin::Network::Signet,
CliNetwork::Regtest => bitcoin::Network::Regtest,
}
}
}
impl CliNetwork {
/// Stable kebab-cased name for JSON output. Matches the clap
/// `value_enum` rendering, NOT `bitcoin::Network::Display` (which
/// emits "bitcoin" for mainnet — confusing for JSON consumers).
fn as_str(self) -> &'static str {
match self {
CliNetwork::Mainnet => "mainnet",
CliNetwork::Testnet => "testnet",
CliNetwork::Signet => "signet",
CliNetwork::Regtest => "regtest",
}
}
}
#[derive(Debug, Parser)]
#[command(name = "md", version, about = "Mnemonic Descriptor (MD) — engravable BIP 388 wallet policy backups", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Encode a wallet policy into MD backup string(s).
#[command(
after_long_help = "EXAMPLES:\n $ md encode wpkh(@0/<0;1>/*)\n md1yqpqqxqq8xtwhw4xwn4qh"
)]
Encode {
/// BIP 388 template, e.g. `wsh(multi(2,@0/<0;1>/*,@1/<0;1>/*))`.
template: Option<String>,
/// Compile a sub-Miniscript-Policy expression into a template (cli-compiler).
#[arg(long = "from-policy", value_name = "EXPR", conflicts_with = "template")]
from_policy: Option<String>,
/// Script context for `--from-policy`.
#[arg(long, value_name = "CTX", value_parser = ["tap", "segwitv0"])]
context: Option<String>,
/// Tap-context only: fallback unspendable internal key passed to
/// miniscript's `compile_tr`. Defaults to BIP-341 NUMS H-point when
/// omitted (auto-NUMS); supplying a value is rare and used to force
/// a specific NUMS-equivalent key. Rejected when --context segwitv0.
#[arg(long, value_name = "KEY")]
unspendable_key: Option<String>,
/// Override the inferred origin path with a single shared path
/// (flattens Divergent mode to Shared). Accepts named (bip44|48|49|84|86),
/// hex (0xNN), or literal (m/...) forms.
#[arg(long, value_name = "PATH")]
path: Option<String>,
/// Concrete xpub for placeholder `@i`. Repeatable.
#[arg(long = "key", value_name = "@i=XPUB")]
keys: Vec<String>,
/// Master-key fingerprint for placeholder `@i`. Repeatable.
#[arg(long = "fingerprint", value_name = "@i=HEX")]
fingerprints: Vec<String>,
/// Network for xpub validation (and JSON output labeling).
#[arg(long, value_enum, default_value_t = CliNetwork::Mainnet)]
network: CliNetwork,
/// Force chunked encoding even for short policies.
#[arg(long)]
force_chunked: bool,
/// Force the long BCH code even when the regular code suffices.
#[arg(long)]
force_long_code: bool,
/// Print the freshly-computed PolicyId fingerprint after the phrase.
#[arg(long)]
policy_id_fingerprint: bool,
/// Emit JSON output.
#[arg(long)]
json: bool,
},
/// Decode one or more MD backup strings into a wallet policy template.
#[command(
after_long_help = "EXAMPLES:\n $ md decode md1yqpqqxqq8xtwhw4xwn4qh\n wpkh(@0/<0;1>/*)"
)]
Decode {
#[arg(required = true, num_args = 1..)]
strings: Vec<String>,
/// Emit a structured JSON object on stdout instead of the
/// plain BIP-388 wallet-policy template string.
#[arg(long)]
json: bool,
},
/// Verify backup strings re-encode to a given template.
Verify {
#[arg(required = true, num_args = 1..)]
strings: Vec<String>,
#[arg(long, required = true)]
template: String,
#[arg(long = "key", value_name = "@i=XPUB")]
keys: Vec<String>,
#[arg(long = "fingerprint", value_name = "@i=HEX")]
fingerprints: Vec<String>,
/// Network for xpub validation.
#[arg(long, value_enum, default_value_t = CliNetwork::Mainnet)]
network: CliNetwork,
},
/// Decode + pretty-print everything the codec sees.
Inspect {
#[arg(required = true, num_args = 1..)]
strings: Vec<String>,
/// Emit a structured JSON object on stdout instead of the
/// pretty-printed multi-line text form.
#[arg(long)]
json: bool,
},
/// Dump the raw payload bits in an annotated layout.
Bytecode {
#[arg(required = true, num_args = 1..)]
strings: Vec<String>,
#[arg(long)]
json: bool,
},
/// Regenerate the project's test-vector corpus (maintainer tool).
Vectors {
#[arg(long, value_name = "DIR")]
out: Option<String>,
},
/// Compile a sub-Miniscript-Policy expression into a BIP 388 template.
Compile {
expr: String,
#[arg(long, value_name = "CTX", value_parser = ["tap", "segwitv0"], required = true)]
context: String,
/// Tap-context only: fallback unspendable internal key passed to
/// miniscript's `compile_tr`. Defaults to BIP-341 NUMS H-point when
/// omitted (auto-NUMS); supplying a value is rare. Rejected when
/// --context segwitv0.
#[arg(long, value_name = "KEY")]
unspendable_key: Option<String>,
#[arg(long)]
json: bool,
},
/// Derive bitcoin addresses from a wallet-policy-mode descriptor.
#[command(after_long_help = "EXAMPLES:\n $ md address md1qq...\n bc1q...",
group = clap::ArgGroup::new("address_input").required(true).args(["phrases", "template"]))]
Address {
/// One or more md1 phrases. Mutually exclusive with --template.
#[arg(num_args = 0..)]
phrases: Vec<String>,
/// BIP 388 template. Requires at least one --key. Mutually exclusive with phrases.
#[arg(long, value_name = "TEMPLATE", conflicts_with = "phrases")]
template: Option<String>,
/// Concrete xpub for placeholder @i. Repeatable. Requires --template.
#[arg(long = "key", value_name = "@i=XPUB", requires = "template")]
keys: Vec<String>,
/// Master-key fingerprint for placeholder @i. Repeatable. Requires --template.
#[arg(long = "fingerprint", value_name = "@i=HEX", requires = "template")]
fingerprints: Vec<String>,
/// Network for xpub validation and address rendering.
#[arg(long, value_enum, default_value_t = CliNetwork::Mainnet)]
network: CliNetwork,
/// Multipath alternative selector (0 = receive, 1 = change for canonical <0;1>/*).
#[arg(long, default_value_t = 0)]
chain: u32,
/// Sugar for --chain 1.
#[arg(long, conflicts_with = "chain")]
change: bool,
/// Starting index along the wildcard.
#[arg(long, default_value_t = 0)]
index: u32,
/// Number of consecutive addresses to derive starting at --index.
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=1000))]
count: u32,
/// Emit JSON output.
#[arg(long)]
json: bool,
},
/// Emit a machine-readable JSON description of this CLI's flag surface
/// (SPEC §7 of the mnemonic-gui v0.2 plan). Consumed by the mnemonic-gui
/// overlay to bootstrap and drift-check per-subcommand widget schemas.
#[cfg(feature = "json")]
#[command(name = "gui-schema")]
GuiSchema,
/// BCH error-correction for md1 strings. Wraps `md_codec::decode_with_correction`
/// and renders a per-chunk repair report.
///
/// Exit codes (D26 cross-CLI parity with `ms repair` / `mk repair` /
/// `mnemonic repair`):
/// 0 — every input was already valid (no corrections applied)
/// 5 — at least one chunk had corrections applied (REPAIR_APPLIED)
/// 2 — atomic-fail per plan §1 D28: ANY chunk failing BCH capacity
/// fails the whole call; the failing chunk's index is named in
/// the stderr message and NO partial corrected output is emitted
/// on stdout.
#[command(
after_long_help = "ATOMIC SEMANTICS (multi-chunk):\n When more than one md1 chunk is supplied, the call is atomic per plan\n §1 D28: if ANY chunk fails BCH error-correction capacity (> 4 errors),\n the WHOLE call exits 2 with the failing chunk index named on stderr.\n NO partial corrected chunks are emitted on stdout.\n\nINPUT FORMAT:\n Accepts chunked-form md1 strings only (those bearing a chunk header,\n as emitted by `md encode --force-chunked` or by automatic chunking when\n the payload exceeds 320 bits). Non-chunked single-string md1 (those\n emitted by plain `md encode` for small payloads) are rejected with a\n wire-format error — use `md decode` for read-only inspection of those.\n\nEXAMPLES:\n $ md repair md1qq...\n $ md repair md1qq... md1qq... md1qq...\n $ md repair --json md1qq..."
)]
Repair(cmd::repair::RepairArgs),
}
fn main() -> ExitCode {
let cli = Cli::parse();
match dispatch(cli.command) {
Ok(code) => ExitCode::from(code),
Err(CliError::BadArg(m)) => {
eprintln!("md: {m}");
ExitCode::from(2)
}
Err(e) => {
eprintln!("md: {e}");
ExitCode::from(1)
}
}
}
/// v0.18 Item G — reject `--unspendable-key` values that aren't the BIP-341
/// NUMS H-point literal hex. Empty-string and segwitv0-incompat checks fire
/// upstream of this guard; what reaches here is `Some(<non-empty-tap-value>)`.
#[cfg(feature = "cli-compiler")]
fn validate_unspendable_key_nums_only(uk: Option<&str>) -> Result<(), CliError> {
if let Some(v) = uk {
if v != parse::template::NUMS_H_POINT_X_ONLY_HEX {
return Err(CliError::BadArg(
"--unspendable-key currently only accepts the BIP-341 NUMS H-point literal hex \
(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) or omitted \
(auto-NUMS default). Other forms (xpub-style descriptor keys, arbitrary x-only \
hex) are deferred to a future version."
.into(),
));
}
}
Ok(())
}
fn dispatch(c: Command) -> Result<u8, CliError> {
match c {
Command::Encode {
template,
from_policy,
context,
unspendable_key,
path,
keys,
fingerprints,
network,
force_chunked,
force_long_code,
policy_id_fingerprint,
json,
} => {
let template_str: String = if let Some(expr) = from_policy {
#[cfg(feature = "cli-compiler")]
{
if unspendable_key.as_deref() == Some("") {
return Err(CliError::BadArg(
"--unspendable-key must not be empty (omit the flag for auto-NUMS default)".into()));
}
let ctx: compile::ScriptContext = context
.ok_or_else(|| {
CliError::BadArg("--from-policy requires --context tap|segwitv0".into())
})?
.parse()
.map_err(|e: compile::CompileError| CliError::Compile(e.to_string()))?;
if matches!(ctx, compile::ScriptContext::SegwitV0) && unspendable_key.is_some()
{
return Err(CliError::BadArg(
"--unspendable-key is only valid for --context tap (segwitv0 has no internal key)".into()));
}
validate_unspendable_key_nums_only(unspendable_key.as_deref())?;
compile::compile_policy_to_template(&expr, ctx, unspendable_key.as_deref())
.map_err(CliError::from)?
}
#[cfg(not(feature = "cli-compiler"))]
{
let _ = (expr, context, unspendable_key);
return Err(CliError::BadArg(
"--from-policy requires the cli-compiler feature".into(),
));
}
} else {
if unspendable_key.is_some() {
return Err(CliError::BadArg(
"--unspendable-key is only meaningful with --from-policy".into(),
));
}
template.ok_or_else(|| {
CliError::BadArg(
"encode: TEMPLATE required (or use --from-policy with cli-compiler)".into(),
)
})?
};
cmd::encode::run(cmd::encode::EncodeArgs {
template: &template_str,
keys: &keys,
fingerprints: &fingerprints,
path: path.as_deref(),
network: network.into(),
network_str: network.as_str(),
force_chunked,
force_long_code,
policy_id_fingerprint,
json,
})
}
Command::Decode { strings, json } => cmd::decode::run(&strings, json),
Command::Verify {
strings,
template,
keys,
fingerprints,
network,
} => cmd::verify::run(cmd::verify::VerifyArgs {
strings: &strings,
template: &template,
keys: &keys,
fingerprints: &fingerprints,
network: network.into(),
}),
Command::Inspect { strings, json } => cmd::inspect::run(&strings, json),
Command::Bytecode { strings, json } => cmd::bytecode::run(&strings, json),
Command::Vectors { out } => cmd::vectors::run(out),
Command::Compile {
expr,
context,
unspendable_key,
json,
} => {
#[cfg(feature = "cli-compiler")]
{
if unspendable_key.as_deref() == Some("") {
return Err(CliError::BadArg(
"--unspendable-key must not be empty (omit the flag for auto-NUMS default)"
.into(),
));
}
if context == "segwitv0" && unspendable_key.is_some() {
return Err(CliError::BadArg(
"--unspendable-key is only valid for --context tap (segwitv0 has no internal key)".into()));
}
validate_unspendable_key_nums_only(unspendable_key.as_deref())?;
cmd::compile::run(&expr, &context, unspendable_key.as_deref(), json)
}
#[cfg(not(feature = "cli-compiler"))]
{
let _ = (expr, context, unspendable_key, json);
Err(CliError::BadArg(
"compile requires the cli-compiler feature; rebuild with --features cli-compiler".into()))
}
}
Command::Address {
phrases,
template,
keys,
fingerprints,
network,
chain,
change,
index,
count,
json,
} => {
let chain = if change { 1 } else { chain };
cmd::address::run(cmd::address::AddressArgs {
phrases: &phrases,
template: template.as_deref(),
keys: &keys,
fingerprints: &fingerprints,
network: network.into(),
network_str: network.as_str(),
chain,
index,
count,
json,
})
}
#[cfg(feature = "json")]
Command::GuiSchema => cmd::gui_schema::run(),
Command::Repair(a) => cmd::repair::run(a),
}
}