Skip to main content

cardanowall_cli/commands/
submit.rs

1//! `cardanowall submit` — anchor a Label 309 PoE from the command line.
2//!
3//! Wraps the high-level publish helpers (`publish_content` / `publish_prehashed`
4//! / `publish_merkle`) and surfaces them as one subcommand with three mutually
5//! exclusive modes:
6//!
7//! - `--hash <64-hex>`         anchor a precomputed digest (no I/O)
8//! - `--file <path>`           hash the file contents and anchor the digest
9//! - `--merkle <leaves-file>`  read one 64-hex leaf per line, build a Merkle tree,
10//!   anchor the root + leaves-list (Arweave)
11//!
12//! Pricing protocol: each submit quotes the price, then passes the `quote_id` to
13//! the publish helper; the server consumes the quote atomically with the record
14//! insert.
15//!
16//! Signer architecture: the SDK never holds identity keys. The optional `--seed`
17//! is the 32-byte master identity seed; the record-signing Ed25519 key is derived
18//! from it (the same key `identity --seed` prints). Omit it to publish unsigned.
19//!
20//! Gateway-agnostic: `--base-url` (or `CARDANOWALL_BASE_URL`) and `--api-key` (or
21//! `CARDANOWALL_API_KEY`) are required; the key is an opaque bearer forwarded
22//! verbatim, never inspected.
23//!
24//! Exit codes: `0` ok / `1` server rejection / `2` network or partial-upload
25//! failure / `4` CLI input error.
26
27use cardanowall::client::types::{PublishContentInput, SupportedHashAlg};
28use cardanowall::client::{
29    ClientError, Label309Client, Label309ClientConfig, MerkleLeaf, PublishError,
30    PublishHelperError, PublishMerkleInput, PublishPrehashedInput, QuoteInput,
31};
32use cardanowall::seed_derive::{signer_from_seed, SeedSigner};
33use clap::Args;
34use serde::Serialize;
35
36use crate::config::{load_config_for_edit, SystemConfigEnv};
37use crate::secret::{
38    resolve_secret_bytes, resolve_service_gateway, SecretArgs, SecretEnv, SecretKind,
39    ServiceGateway, SystemSecretEnv,
40};
41use crate::util::{bytes_to_hex, hex_to_bytes, CliError};
42
43const SHA2_256_DIGEST_BYTES: usize = 32;
44const MASTER_SEED_BYTES: usize = 32;
45const HEX_PREFIX_BYTES_PER_LEAF: u64 = 32;
46// Conservative byte-budget inputs to the quote; the server re-prices.
47const HASH_RECORD_BYTES_ESTIMATE: u64 = 256;
48const MERKLE_RECORD_BYTES_ESTIMATE: u64 = 320;
49
50/// Arguments for `cardanowall submit`.
51#[derive(Debug, Args)]
52pub struct SubmitArgs {
53    /// 64-hex precomputed digest (default alg sha2-256).
54    #[arg(long)]
55    pub hash: Option<String>,
56    /// path to a file whose contents will be hashed and anchored.
57    #[arg(long)]
58    pub file: Option<String>,
59    /// file with one 64-hex sha2-256 leaf per line; anchors a Merkle root.
60    #[arg(long)]
61    pub merkle: Option<String>,
62    /// hash algorithm: 'sha2-256' (default) or 'blake2b-256' (--merkle: sha2-256 only).
63    #[arg(long)]
64    pub alg: Option<String>,
65    /// opaque bearer API key (or env CARDANOWALL_API_KEY, or the active gateway
66    /// profile). Required.
67    #[arg(long = "api-key")]
68    pub api_key: Option<String>,
69    /// 32-byte master identity seed (hex). Omit to publish unsigned. INSECURE on
70    /// argv (shell history / ps / CI logs); prefer --seed-file / --seed-stdin /
71    /// CARDANOWALL_SEED.
72    #[arg(long)]
73    pub seed: Option<String>,
74    /// read the seed from a file (trailing whitespace trimmed).
75    #[arg(long = "seed-file")]
76    pub seed_file: Option<String>,
77    /// read the seed from stdin (also `--seed -`).
78    #[arg(long = "seed-stdin")]
79    pub seed_stdin: bool,
80    /// target Label 309 gateway base URL (or env CARDANOWALL_BASE_URL, or the active
81    /// gateway profile). Required.
82    #[arg(long = "base-url")]
83    pub base_url: Option<String>,
84    /// use this saved gateway profile (overrides the config default_gateway).
85    #[arg(long = "gateway-profile")]
86    pub gateway_profile: Option<String>,
87    /// emit a machine-readable JSON summary on stdout.
88    #[arg(long)]
89    pub json: bool,
90}
91
92#[derive(Debug, Serialize)]
93struct SubmitOutcome {
94    mode: &'static str,
95    id: String,
96    tx_hash: Option<String>,
97    status: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    items_count: Option<u64>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    root: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    leaf_count: Option<u64>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    ar_uri: Option<String>,
106    balance_after_usd_micros: String,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum Mode {
111    Hash,
112    File,
113    Merkle,
114}
115
116impl Mode {
117    fn as_str(self) -> &'static str {
118        match self {
119            Mode::Hash => "hash",
120            Mode::File => "file",
121            Mode::Merkle => "merkle",
122        }
123    }
124}
125
126impl SubmitArgs {
127    fn seed_secret_args(&self) -> SecretArgs {
128        SecretArgs {
129            value: self.seed.clone(),
130            file: self.seed_file.clone(),
131            stdin: self.seed_stdin,
132        }
133    }
134}
135
136/// Resolve the required service gateway (base URL + optional API key) through
137/// `flag > env > active gateway profile`, and require a non-empty API key.
138fn resolve_gateway(args: &SubmitArgs, env: &dyn SecretEnv) -> Result<ServiceGateway, CliError> {
139    let config = load_config_for_edit(&SystemConfigEnv)?;
140    resolve_gateway_with(args, &config, env)
141}
142
143/// The config-injected core of [`resolve_gateway`], so tests need no on-disk file.
144fn resolve_gateway_with(
145    args: &SubmitArgs,
146    config: &crate::config::CardanoWallConfig,
147    env: &dyn SecretEnv,
148) -> Result<ServiceGateway, CliError> {
149    let profile = config.select_gateway(args.gateway_profile.as_deref(), "submit")?;
150    let gateway = resolve_service_gateway(
151        args.base_url.as_deref(),
152        args.api_key.as_deref(),
153        profile,
154        "submit",
155        env,
156    )?;
157    if gateway.api_key.as_deref().is_none_or(str::is_empty) {
158        return Err(CliError::input(
159            "submit: an API key is required — pass --api-key, set CARDANOWALL_API_KEY, \
160             or configure a gateway profile with a key",
161        ));
162    }
163    Ok(gateway)
164}
165
166/// Build the optional seed signer via the shared secret layer; a malformed seed is
167/// a CLI input error. The seed is OPTIONAL (omit to publish unsigned), so the
168/// hidden prompt never fires — only file/stdin/argv/env supply it.
169fn resolve_signer(args: &SubmitArgs, env: &dyn SecretEnv) -> Result<Option<SeedSigner>, CliError> {
170    let Some(seed) = resolve_secret_bytes(
171        SecretKind::Seed,
172        &args.seed_secret_args(),
173        MASTER_SEED_BYTES,
174        false,
175        "submit",
176        env,
177    )?
178    else {
179        return Ok(None);
180    };
181    signer_from_seed(&seed)
182        .map(Some)
183        .map_err(|e| CliError::input(format!("submit: --seed {e}")))
184}
185
186fn choose_mode(args: &SubmitArgs) -> Result<Mode, CliError> {
187    let mut modes = Vec::new();
188    if args.hash.is_some() {
189        modes.push(Mode::Hash);
190    }
191    if args.file.is_some() {
192        modes.push(Mode::File);
193    }
194    if args.merkle.is_some() {
195        modes.push(Mode::Merkle);
196    }
197    match modes.len() {
198        0 => Err(CliError::input(
199            "submit: exactly one of --hash / --file / --merkle is required",
200        )),
201        1 => Ok(modes[0]),
202        _ => Err(CliError::input(format!(
203            "submit: --hash / --file / --merkle are mutually exclusive (got: {})",
204            modes
205                .iter()
206                .map(|m| m.as_str())
207                .collect::<Vec<_>>()
208                .join(", ")
209        ))),
210    }
211}
212
213fn resolve_hash_alg(args: &SubmitArgs) -> Result<SupportedHashAlg, CliError> {
214    match args
215        .alg
216        .as_deref()
217        .map(str::to_lowercase)
218        .as_deref()
219        .unwrap_or("sha2-256")
220    {
221        "sha2-256" => Ok(SupportedHashAlg::Sha2_256),
222        "blake2b-256" => Ok(SupportedHashAlg::Blake2b256),
223        other => Err(CliError::input(format!(
224            "submit: --alg must be 'sha2-256' or 'blake2b-256' (got '{other}')"
225        ))),
226    }
227}
228
229fn parse_leaves_file(text: &str, path: &str) -> Result<Vec<String>, CliError> {
230    let mut leaves = Vec::new();
231    for (i, line) in text.lines().enumerate() {
232        let t = line.trim();
233        if t.is_empty() || t.starts_with('#') {
234            continue;
235        }
236        if t.len() != 64 || !t.bytes().all(|b| b.is_ascii_hexdigit()) {
237            return Err(CliError::input(format!(
238                "submit: --merkle {path}: line {} is not a 64-hex sha2-256 leaf: \"{t}\"",
239                i + 1
240            )));
241        }
242        leaves.push(t.to_lowercase());
243    }
244    if leaves.is_empty() {
245        return Err(CliError::input(format!(
246            "submit: --merkle {path} contains no leaves"
247        )));
248    }
249    Ok(leaves)
250}
251
252/// Render USD micro-cents as `$X.XX`.
253fn format_usd_micros(micros_str: &str) -> String {
254    let Ok(micros) = micros_str.parse::<i128>() else {
255        return micros_str.to_string();
256    };
257    let negative = micros < 0;
258    let abs = micros.unsigned_abs();
259    let dollars = abs / 1_000_000;
260    let fractional = abs % 1_000_000;
261    let cents = (fractional + 5_000) / 10_000;
262    let (whole_cents, display_cents) = if cents == 100 {
263        (dollars + 1, 0)
264    } else {
265        (dollars, cents)
266    };
267    let sign = if negative { "-" } else { "" };
268    format!("{sign}${whole_cents}.{display_cents:02}")
269}
270
271fn emit_outcome(outcome: &SubmitOutcome, json: bool) {
272    if json {
273        println!(
274            "{}",
275            serde_json::to_string(outcome).expect("SubmitOutcome serialises")
276        );
277        return;
278    }
279    println!("ok: {}", outcome.id);
280    println!("  status:      {}", outcome.status);
281    println!(
282        "  tx_hash:     {}",
283        outcome.tx_hash.as_deref().unwrap_or("<pending>")
284    );
285    if let Some(items) = outcome.items_count {
286        println!("  items_count: {items}");
287    }
288    if let Some(root) = &outcome.root {
289        println!("  root:        {root}");
290        println!("  leaf_count:  {}", outcome.leaf_count.unwrap_or(0));
291        println!("  ar_uri:      {}", outcome.ar_uri.as_deref().unwrap_or(""));
292    }
293    println!(
294        "  balance:     {}",
295        format_usd_micros(&outcome.balance_after_usd_micros)
296    );
297}
298
299/// Map a publish-helper error onto the submit exit-code contract.
300fn map_publish_error(err: PublishHelperError) -> CliError {
301    match err {
302        PublishHelperError::Validation(e) => {
303            // Pre-network input/shape error → CLI input error (4).
304            CliError::new(4, format!("submit: {}: {e}", PublishError::code(e)))
305        }
306        PublishHelperError::Signer(e) => CliError::new(4, format!("submit: signer: {e}")),
307        PublishHelperError::PartialUpload(e) => {
308            let indices = e
309                .failed_indices()
310                .iter()
311                .map(u64::to_string)
312                .collect::<Vec<_>>()
313                .join(", ");
314            CliError::network(format!(
315                "submit: partial-upload-failure (indices: {indices})"
316            ))
317        }
318        PublishHelperError::Http(ClientError::Http(http)) => {
319            let request_id = if http.request_id().is_empty() {
320                String::new()
321            } else {
322                format!(" (x-request-id: {})", http.request_id())
323            };
324            CliError::integrity(format!(
325                "submit: HTTP {} {}: {}{request_id}",
326                http.http_status(),
327                http.code(),
328                http.problem().detail
329            ))
330        }
331        PublishHelperError::Http(other) => CliError::network(format!("submit: {other}")),
332        PublishHelperError::Crypto(msg) => CliError::network(format!("submit: {msg}")),
333    }
334}
335
336/// Run the `submit` command.
337///
338/// # Errors
339///
340/// Returns [`CliError`] with the mapped exit code.
341pub fn run(args: SubmitArgs) -> Result<(), CliError> {
342    let mode = choose_mode(&args)?;
343    let gateway = resolve_gateway(&args, &SystemSecretEnv)?;
344    let signer = resolve_signer(&args, &SystemSecretEnv)?;
345    let signer_ref: Option<&dyn cardanowall::client::Signer> = signer
346        .as_ref()
347        .map(|s| s as &dyn cardanowall::client::Signer);
348
349    let client = Label309Client::new(Label309ClientConfig {
350        api_key: gateway.api_key,
351        base_url: Some(gateway.base_url),
352    })
353    .map_err(|e| CliError::input(format!("submit: {e}")))?;
354    let poe = client.poe();
355
356    match mode {
357        Mode::Hash => {
358            let hex = args.hash.as_ref().unwrap().trim().to_lowercase();
359            let digest =
360                hex_to_bytes(&hex).map_err(|e| CliError::input(format!("submit: --hash {e}")))?;
361            if digest.len() != SHA2_256_DIGEST_BYTES {
362                return Err(CliError::input(format!(
363                    "submit: --hash must decode to exactly {SHA2_256_DIGEST_BYTES} bytes (got {})",
364                    digest.len()
365                )));
366            }
367            let alg = resolve_hash_alg(&args)?;
368            let quote = poe
369                .quote(&QuoteInput {
370                    record_bytes: HASH_RECORD_BYTES_ESTIMATE,
371                    recipient_count: 0,
372                    file_bytes_total: 0,
373                })
374                .map_err(map_client_error)?;
375            let res = poe
376                .publish_prehashed(&PublishPrehashedInput {
377                    hashes: vec![(alg, bytes_to_hex(&digest))],
378                    quote_id: quote.quote_id,
379                    signer: signer_ref,
380                    idempotency_key: None,
381                })
382                .map_err(map_publish_error)?;
383            emit_outcome(
384                &SubmitOutcome {
385                    mode: "hash",
386                    id: res.id,
387                    tx_hash: res.tx_hash,
388                    status: res.status,
389                    items_count: Some(res.items_count),
390                    root: None,
391                    leaf_count: None,
392                    ar_uri: None,
393                    balance_after_usd_micros: res.balance_after_usd_micros,
394                },
395                args.json,
396            );
397            Ok(())
398        }
399        Mode::File => {
400            let path = args.file.as_ref().unwrap();
401            let content = std::fs::read(path).map_err(|e| {
402                CliError::network(format!("submit: cannot read --file {path}: {e}"))
403            })?;
404            let alg = resolve_hash_alg(&args)?;
405            let quote = poe
406                .quote(&QuoteInput {
407                    record_bytes: HASH_RECORD_BYTES_ESTIMATE,
408                    recipient_count: 0,
409                    file_bytes_total: 0,
410                })
411                .map_err(map_client_error)?;
412            let res = poe
413                .publish_content(&PublishContentInput {
414                    content,
415                    quote_id: quote.quote_id,
416                    hash_alg: Some(alg),
417                    signer: signer_ref,
418                    idempotency_key: None,
419                })
420                .map_err(map_publish_error)?;
421            emit_outcome(
422                &SubmitOutcome {
423                    mode: "file",
424                    id: res.id,
425                    tx_hash: res.tx_hash,
426                    status: res.status,
427                    items_count: Some(res.items_count),
428                    root: None,
429                    leaf_count: None,
430                    ar_uri: None,
431                    balance_after_usd_micros: res.balance_after_usd_micros,
432                },
433                args.json,
434            );
435            Ok(())
436        }
437        Mode::Merkle => {
438            let path = args.merkle.as_ref().unwrap();
439            let text = std::fs::read_to_string(path).map_err(|e| {
440                CliError::network(format!("submit: cannot read --merkle {path}: {e}"))
441            })?;
442            let leaves = parse_leaves_file(&text, path)?;
443            let alg = args
444                .alg
445                .as_deref()
446                .map(str::to_lowercase)
447                .unwrap_or_else(|| "sha2-256".to_string());
448            if alg != "sha2-256" {
449                return Err(CliError::input(format!(
450                    "submit: --merkle currently supports only sha2-256 leaves (got '{alg}')"
451                )));
452            }
453            let leaf_count = leaves.len() as u64;
454            let quote = poe
455                .quote(&QuoteInput {
456                    record_bytes: MERKLE_RECORD_BYTES_ESTIMATE,
457                    recipient_count: 0,
458                    file_bytes_total: leaf_count * HEX_PREFIX_BYTES_PER_LEAF + 64,
459                })
460                .map_err(map_client_error)?;
461            let res = poe
462                .publish_merkle(&PublishMerkleInput {
463                    leaves: leaves.into_iter().map(MerkleLeaf::Hex).collect(),
464                    quote_id: quote.quote_id,
465                    hash_alg: None,
466                    signer: signer_ref,
467                    idempotency_key: None,
468                })
469                .map_err(map_publish_error)?;
470            emit_outcome(
471                &SubmitOutcome {
472                    mode: "merkle",
473                    id: res.id,
474                    tx_hash: res.tx_hash,
475                    status: res.status,
476                    items_count: None,
477                    root: Some(res.root),
478                    leaf_count: Some(res.leaf_count),
479                    ar_uri: Some(res.ar_uri),
480                    balance_after_usd_micros: res.balance_after_usd_micros,
481                },
482                args.json,
483            );
484            Ok(())
485        }
486    }
487}
488
489/// Map a bare `ClientError` (from `quote`) onto the submit exit-code contract.
490fn map_client_error(err: ClientError) -> CliError {
491    match err {
492        ClientError::Http(http) => {
493            let request_id = if http.request_id().is_empty() {
494                String::new()
495            } else {
496                format!(" (x-request-id: {})", http.request_id())
497            };
498            CliError::integrity(format!(
499                "submit: HTTP {} {}: {}{request_id}",
500                http.http_status(),
501                http.code(),
502                http.problem().detail
503            ))
504        }
505        other => CliError::network(format!("submit: {other}")),
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::secret::test_support::FakeSecretEnv;
513
514    fn base_args() -> SubmitArgs {
515        SubmitArgs {
516            hash: None,
517            file: None,
518            merkle: None,
519            alg: None,
520            api_key: None,
521            seed: None,
522            seed_file: None,
523            seed_stdin: false,
524            base_url: None,
525            gateway_profile: None,
526            json: false,
527        }
528    }
529
530    #[test]
531    fn requires_exactly_one_mode() {
532        let mut args = base_args();
533        assert_eq!(choose_mode(&args).unwrap_err().code, 4);
534        args.hash = Some("aa".repeat(32));
535        args.file = Some("/x".to_string());
536        assert_eq!(choose_mode(&args).unwrap_err().code, 4);
537    }
538
539    #[test]
540    fn requires_base_url() {
541        // No base URL from any source → input error before any network call.
542        let args = base_args();
543        let env = FakeSecretEnv::default();
544        let config = crate::config::CardanoWallConfig::default();
545        let profile = config.select_gateway(None, "submit").unwrap();
546        let err = resolve_service_gateway(
547            args.base_url.as_deref(),
548            args.api_key.as_deref(),
549            profile,
550            "submit",
551            &env,
552        )
553        .unwrap_err();
554        assert_eq!(err.code, 4);
555    }
556
557    #[test]
558    fn requires_api_key_even_with_base_url() {
559        // A base URL but no API key → input error (the gateway API is key-only).
560        let mut args = base_args();
561        args.base_url = Some("https://gw.example".to_string());
562        let env = FakeSecretEnv::default();
563        let config = crate::config::CardanoWallConfig::default();
564        assert_eq!(
565            resolve_gateway_with(&args, &config, &env).unwrap_err().code,
566            4
567        );
568    }
569
570    #[test]
571    fn gateway_profile_supplies_base_url_and_key() {
572        // With no flags/env, the active profile fills both slots.
573        let mut config = crate::config::CardanoWallConfig::default();
574        config.gateways.insert(
575            "prod".to_string(),
576            crate::config::GatewayProfile {
577                base_url: "https://gw.example".to_string(),
578                api_key: Some("k".to_string()),
579            },
580        );
581        config.default_gateway = Some("prod".to_string());
582        let env = FakeSecretEnv::default();
583        let gw = resolve_gateway_with(&base_args(), &config, &env).unwrap();
584        assert_eq!(gw.base_url, "https://gw.example");
585        assert_eq!(gw.api_key.as_deref(), Some("k"));
586    }
587
588    #[test]
589    fn rejects_malformed_seed() {
590        let mut args = base_args();
591        args.seed = Some("dead".to_string());
592        let env = FakeSecretEnv::default();
593        assert_eq!(resolve_signer(&args, &env).unwrap_err().code, 4);
594    }
595
596    #[test]
597    fn no_seed_is_unsigned() {
598        let args = base_args();
599        let env = FakeSecretEnv::default();
600        assert!(resolve_signer(&args, &env).unwrap().is_none());
601    }
602
603    #[test]
604    fn formats_usd_micros() {
605        assert_eq!(format_usd_micros("1500000"), "$1.50");
606        assert_eq!(format_usd_micros("0"), "$0.00");
607        assert_eq!(format_usd_micros("999995"), "$1.00");
608        assert_eq!(format_usd_micros("-2500000"), "-$2.50");
609    }
610
611    #[test]
612    fn parses_leaves_file() {
613        let text = format!("# header\n{}\n\n{}\n", "ab".repeat(32), "cd".repeat(32));
614        let leaves = parse_leaves_file(&text, "f").unwrap();
615        assert_eq!(leaves.len(), 2);
616    }
617
618    #[test]
619    fn rejects_bad_leaf() {
620        assert_eq!(parse_leaves_file("zzz\n", "f").unwrap_err().code, 4);
621    }
622}