ord 0.27.1

◉ Ordinal wallet and block explorer
Documentation
use super::*;

#[derive(Debug, Parser)]
pub(crate) struct Batch {
  #[command(flatten)]
  shared: SharedArgs,
  #[arg(
    long,
    help = "Inscribe multiple inscriptions and rune defined in YAML <BATCH_FILE>.",
    value_name = "BATCH_FILE"
  )]
  pub(crate) batch: PathBuf,
}

impl Batch {
  pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult {
    let utxos = wallet.utxos();

    let batchfile = batch::File::load(&self.batch)?;

    let mut ids = BTreeSet::new();

    for inscription in &batchfile.inscriptions {
      if let Some(delegate) = inscription.delegate {
        ids.insert(delegate);
      }

      for item in &inscription.gallery {
        ids.insert(item.id);
      }
    }

    let missing = wallet.missing_inscriptions(&ids.into_iter().collect::<Vec<InscriptionId>>())?;

    ensure! {
      missing.is_empty(),
      "inscriptions referenced in batchfile do not exist: {}",
      missing.into_iter().map(|id| id.to_string()).collect::<Vec<String>>().join(", "),
    }

    let parent_info = wallet.get_parent_info(&batchfile.parents)?;

    let (inscriptions, reveal_satpoints, postages, destinations) = batchfile.inscriptions(
      &wallet,
      utxos,
      parent_info
        .iter()
        .map(|info| info.tx_out.value.to_sat())
        .collect(),
      self.shared.compress,
    )?;

    let mut locked_utxos = wallet.locked_utxos().clone();

    locked_utxos.extend(
      reveal_satpoints
        .iter()
        .map(|(satpoint, txout)| (satpoint.outpoint, txout.clone())),
    );

    if let Some(etching) = batchfile.etching {
      Self::check_etching(&wallet, &etching)?;
    }

    batch::Plan {
      commit_fee_rate: self.shared.commit_fee_rate.unwrap_or(self.shared.fee_rate),
      destinations,
      dry_run: self.shared.dry_run,
      etching: batchfile.etching,
      inscriptions,
      mode: batchfile.mode,
      no_backup: self.shared.no_backup,
      no_limit: self.shared.no_limit,
      parent_info,
      postages,
      reinscribe: batchfile.reinscribe,
      reveal_fee_rate: self.shared.fee_rate,
      reveal_satpoints,
      satpoint: if let Some(sat) = batchfile.sat {
        Some(wallet.find_sat_in_outputs(sat)?)
      } else {
        batchfile.satpoint
      },
    }
    .inscribe(
      &locked_utxos.into_keys().collect(),
      wallet.get_runic_outputs()?.unwrap_or_default(),
      utxos,
      &wallet,
    )
  }

  fn check_etching(wallet: &Wallet, etching: &batch::Etching) -> Result {
    let rune = etching.rune.rune;

    ensure!(
      wallet.load_etching(rune)?.is_none(),
      "rune `{rune}` has pending etching, resume with `ord wallet resume`"
    );

    ensure!(!rune.is_reserved(), "rune `{rune}` is reserved");

    ensure!(
      etching.divisibility <= Etching::MAX_DIVISIBILITY,
      "<DIVISIBILITY> must be less than or equal 38"
    );

    ensure!(
      wallet.has_rune_index(),
      "etching runes requires index created with `--index-runes`",
    );

    ensure!(
      wallet.get_rune(rune)?.is_none(),
      "rune `{rune}` has already been etched",
    );

    let premine = etching.premine.to_integer(etching.divisibility)?;

    let supply = etching.supply.to_integer(etching.divisibility)?;

    let mintable = etching
      .terms
      .map(|terms| -> Result<u128> {
        terms
          .cap
          .checked_mul(terms.amount.to_integer(etching.divisibility)?)
          .ok_or_else(|| anyhow!("`terms.cap` * `terms.amount` over maximum"))
      })
      .transpose()?
      .unwrap_or_default();

    ensure!(
      supply
        == premine
          .checked_add(mintable)
          .ok_or_else(|| anyhow!("`premine` + `terms.cap` * `terms.amount` over maximum"))?,
      "`supply` not equal to `premine` + `terms.cap` * `terms.amount`"
    );

    ensure!(supply > 0, "`supply` must be greater than zero");

    let bitcoin_client = wallet.bitcoin_client();

    let current_height = u32::try_from(bitcoin_client.get_block_count()?).unwrap();

    let reveal_height = current_height + u32::from(Runestone::COMMIT_CONFIRMATIONS);

    let first_rune_height = Rune::first_rune_height(wallet.chain().into());

    ensure!(
      reveal_height >= first_rune_height,
      "rune reveal height below rune activation height: {reveal_height} < {first_rune_height}",
    );

    if let Some(terms) = etching.terms {
      if let Some((start, end)) = terms.offset.and_then(|range| range.start.zip(range.end)) {
        ensure!(
          end > start,
          "`terms.offset.end` must be greater than `terms.offset.start`"
        );
      }

      if let Some((start, end)) = terms.height.and_then(|range| range.start.zip(range.end)) {
        ensure!(
          end > start,
          "`terms.height.end` must be greater than `terms.height.start`"
        );
      }

      if let Some(end) = terms.height.and_then(|range| range.end) {
        ensure!(
          end > u64::from(reveal_height),
          "`terms.height.end` must be greater than the reveal transaction block height of {reveal_height}"
        );
      }

      if let Some(start) = terms.height.and_then(|range| range.start) {
        ensure!(
          start > u64::from(reveal_height),
          "`terms.height.start` must be greater than the reveal transaction block height of {reveal_height}"
        );
      }

      ensure!(terms.cap > 0, "`terms.cap` must be greater than zero");

      ensure!(
        terms.amount.to_integer(etching.divisibility)? > 0,
        "`terms.amount` must be greater than zero",
      );
    }

    let minimum = Rune::minimum_at_height(wallet.chain().into(), Height(reveal_height));

    ensure!(
      rune >= minimum,
      "rune is less than minimum for next block: {rune} < {minimum}",
    );

    Ok(())
  }
}

#[cfg(test)]
mod tests {
  use {
    super::*,
    crate::wallet::batch,
    serde_yaml::{Mapping, Value},
    tempfile::TempDir,
  };

  #[test]
  fn batch_is_loaded_from_yaml_file() {
    let parent = "8d363b28528b0cb86b5fd48615493fb175bdf132d2a3d20b4251bba3f130a5abi0"
      .parse::<InscriptionId>()
      .unwrap();

    let tempdir = TempDir::new().unwrap();

    let inscription_path = tempdir.path().join("tulip.txt");
    fs::write(&inscription_path, "tulips are pretty").unwrap();

    let brc20_path = tempdir.path().join("token.json");

    let batch_path = tempdir.path().join("batch.yaml");
    fs::write(
      &batch_path,
      format!(
        "mode: separate-outputs
parents:
- {parent}
inscriptions:
- file: {}
  metadata:
    title: Lorem Ipsum
    description: Lorem ipsum dolor sit amet, consectetur adipiscing elit. In tristique, massa nec condimentum venenatis, ante massa tempor velit, et accumsan ipsum ligula a massa. Nunc quis orci ante.
- file: {}
  metaprotocol: brc-20
",
        inscription_path.display(),
        brc20_path.display()
      ),
    )
    .unwrap();

    let mut metadata = Mapping::new();
    metadata.insert(
      Value::String("title".to_string()),
      Value::String("Lorem Ipsum".to_string()),
    );
    metadata.insert(Value::String("description".to_string()), Value::String("Lorem ipsum dolor sit amet, consectetur adipiscing elit. In tristique, massa nec condimentum venenatis, ante massa tempor velit, et accumsan ipsum ligula a massa. Nunc quis orci ante.".to_string()));

    assert_eq!(
      batch::File::load(&batch_path).unwrap(),
      batch::File {
        inscriptions: vec![
          batch::Entry {
            file: Some(inscription_path),
            metadata: Some(Value::Mapping(metadata)),
            ..default()
          },
          batch::Entry {
            file: Some(brc20_path),
            metaprotocol: Some("brc-20".to_string()),
            ..default()
          }
        ],
        parents: vec![parent],
        ..default()
      }
    );
  }

  #[test]
  fn batch_with_unknown_field_throws_error() {
    let tempdir = TempDir::new().unwrap();
    let batch_path = tempdir.path().join("batch.yaml");
    fs::write(
      &batch_path,
      "mode: shared-output\ninscriptions:\n- file: meow.wav\nunknown: 1.)what",
    )
    .unwrap();

    assert!(
      batch::File::load(&batch_path)
        .unwrap_err()
        .to_string()
        .contains("unknown field `unknown`")
    );
  }
}