1use 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;
46const HASH_RECORD_BYTES_ESTIMATE: u64 = 256;
48const MERKLE_RECORD_BYTES_ESTIMATE: u64 = 320;
49
50#[derive(Debug, Args)]
52pub struct SubmitArgs {
53 #[arg(long)]
55 pub hash: Option<String>,
56 #[arg(long)]
58 pub file: Option<String>,
59 #[arg(long)]
61 pub merkle: Option<String>,
62 #[arg(long)]
64 pub alg: Option<String>,
65 #[arg(long = "api-key")]
68 pub api_key: Option<String>,
69 #[arg(long)]
73 pub seed: Option<String>,
74 #[arg(long = "seed-file")]
76 pub seed_file: Option<String>,
77 #[arg(long = "seed-stdin")]
79 pub seed_stdin: bool,
80 #[arg(long = "base-url")]
83 pub base_url: Option<String>,
84 #[arg(long = "gateway-profile")]
86 pub gateway_profile: Option<String>,
87 #[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
136fn 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
143fn 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
166fn 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
252fn 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
299fn map_publish_error(err: PublishHelperError) -> CliError {
301 match err {
302 PublishHelperError::Validation(e) => {
303 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
336pub 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
489fn 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 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 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 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}