diff --git a/src/subcommand/wallet/burn.rs b/src/subcommand/wallet/burn.rs index 6f36887b06..bdf4e70bc2 100644 --- a/src/subcommand/wallet/burn.rs +++ b/src/subcommand/wallet/burn.rs @@ -28,68 +28,96 @@ pub struct Burn { you understand the implications." )] no_limit: bool, - inscription: InscriptionId, + asset: Outgoing, } impl Burn { pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { - let inscription_info = wallet - .inscription_info() - .get(&self.inscription) - .ok_or_else(|| anyhow!("inscription {} not found", self.inscription))? - .clone(); - - let metadata = WalletCommand::parse_metadata(self.cbor_metadata, self.json_metadata)?; - - ensure!( - inscription_info.value.is_some(), - "Cannot burn unbound inscription" - ); - - let mut builder = script::Builder::new().push_opcode(opcodes::all::OP_RETURN); - - // add empty metadata if none is supplied so we can add padding - let metadata = metadata.unwrap_or_default(); - - let push: &script::PushBytes = metadata.as_slice().try_into().with_context(|| { - format!( - "metadata length {} over maximum {}", - metadata.len(), - u32::MAX - ) - })?; - builder = builder.push_slice(push); - - // pad OP_RETURN script to least five bytes to ensure transaction base size - // is greater than 64 bytes - let padding = 5usize.saturating_sub(builder.as_script().len()); - if padding > 0 { - // subtract one byte push opcode from padding length - let padding = vec![0; padding - 1]; - let push: &script::PushBytes = padding.as_slice().try_into().unwrap(); - builder = builder.push_slice(push); - } - - let script_pubkey = builder.into_script(); - - ensure!( - self.no_limit || script_pubkey.len() <= MAX_STANDARD_OP_RETURN_SIZE, - "OP_RETURN with metadata larger than maximum: {} > {}", - script_pubkey.len(), - MAX_STANDARD_OP_RETURN_SIZE, - ); - - let burn_amount = Amount::from_sat(1); - - let unsigned_transaction = Self::create_unsigned_burn_transaction( - &wallet, - inscription_info.satpoint, - self.fee_rate, - script_pubkey, - burn_amount, - )?; + let (unsigned_transaction, burn_amount) = match self.asset { + Outgoing::InscriptionId(id) => { + let inscription_info = wallet + .inscription_info() + .get(&id) + .ok_or_else(|| anyhow!("inscription {id} not found"))? + .clone(); + + let metadata = WalletCommand::parse_metadata(self.cbor_metadata, self.json_metadata)?; + + ensure!( + inscription_info.value.is_some(), + "Cannot burn unbound inscription" + ); + + let mut builder = script::Builder::new().push_opcode(opcodes::all::OP_RETURN); + + // add empty metadata if none is supplied so we can add padding + let metadata = metadata.unwrap_or_default(); + + let push: &script::PushBytes = metadata.as_slice().try_into().with_context(|| { + format!( + "metadata length {} over maximum {}", + metadata.len(), + u32::MAX + ) + })?; + builder = builder.push_slice(push); + + // pad OP_RETURN script to least five bytes to ensure transaction base size + // is greater than 64 bytes + let padding = 5usize.saturating_sub(builder.as_script().len()); + if padding > 0 { + // subtract one byte push opcode from padding length + let padding = vec![0; padding - 1]; + let push: &script::PushBytes = padding.as_slice().try_into().unwrap(); + builder = builder.push_slice(push); + } + + let script_pubkey = builder.into_script(); + + ensure!( + self.no_limit || script_pubkey.len() <= MAX_STANDARD_OP_RETURN_SIZE, + "OP_RETURN with metadata larger than maximum: {} > {}", + script_pubkey.len(), + MAX_STANDARD_OP_RETURN_SIZE, + ); + + let burn_amount = Amount::from_sat(1); + + ( + Self::create_unsigned_burn_satpoint_transaction( + &wallet, + inscription_info.satpoint, + self.fee_rate, + script_pubkey, + burn_amount, + )?, + burn_amount, + ) + } + Outgoing::Rune { decimal, rune } => { + ensure!( + self.cbor_metadata.is_none() && self.json_metadata.is_none(), + "metadata not supported when burning runes" + ); + + ( + wallet.create_unsigned_send_or_burn_runes_transaction( + None, + rune, + decimal, + None, + self.fee_rate, + )?, + Amount::ZERO, + ) + } + Outgoing::Amount(_) => bail!("burning bitcoin not supported"), + Outgoing::Sat(_) => bail!("burning sat not supported"), + Outgoing::SatPoint(_) => bail!("burning satpoint not supported"), + }; let base_size = unsigned_transaction.base_size(); + assert!( base_size >= 65, "transaction base size less than minimum standard tx nonwitness size: {base_size} < 65", @@ -104,12 +132,12 @@ impl Burn { Ok(Some(Box::new(send::Output { txid, psbt, - asset: Outgoing::InscriptionId(self.inscription), + asset: self.asset, fee, }))) } - fn create_unsigned_burn_transaction( + fn create_unsigned_burn_satpoint_transaction( wallet: &Wallet, satpoint: SatPoint, fee_rate: FeeRate, diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index ace88b13ce..987e3e0f60 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -40,18 +40,16 @@ impl Send { let unsigned_transaction = match self.asset { Outgoing::Amount(amount) => { - Self::create_unsigned_send_amount_transaction(&wallet, address, amount, self.fee_rate)? + wallet.create_unsigned_send_amount_transaction(address, amount, self.fee_rate)? } - Outgoing::Rune { decimal, rune } => Self::create_unsigned_send_runes_transaction( - &wallet, - address, + Outgoing::Rune { decimal, rune } => wallet.create_unsigned_send_or_burn_runes_transaction( + Some(address), rune, decimal, - self.postage.unwrap_or(TARGET_POSTAGE), + self.postage, self.fee_rate, )?, - Outgoing::InscriptionId(id) => Self::create_unsigned_send_satpoint_transaction( - &wallet, + Outgoing::InscriptionId(id) => wallet.create_unsigned_send_satpoint_transaction( address, wallet .inscription_info() @@ -62,16 +60,14 @@ impl Send { self.fee_rate, true, )?, - Outgoing::SatPoint(satpoint) => Self::create_unsigned_send_satpoint_transaction( - &wallet, + Outgoing::SatPoint(satpoint) => wallet.create_unsigned_send_satpoint_transaction( address, satpoint, self.postage, self.fee_rate, false, )?, - Outgoing::Sat(sat) => Self::create_unsigned_send_satpoint_transaction( - &wallet, + Outgoing::Sat(sat) => wallet.create_unsigned_send_satpoint_transaction( address, wallet.find_sat_in_outputs(sat)?, self.postage, @@ -90,224 +86,4 @@ impl Send { fee, }))) } - - fn create_unsigned_send_amount_transaction( - wallet: &Wallet, - destination: Address, - amount: Amount, - fee_rate: FeeRate, - ) -> Result { - wallet.lock_non_cardinal_outputs()?; - - let unfunded_transaction = Transaction { - version: Version(2), - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { - script_pubkey: destination.script_pubkey(), - value: amount, - }], - }; - - let unsigned_transaction = consensus::encode::deserialize(&fund_raw_transaction( - wallet.bitcoin_client(), - fee_rate, - &unfunded_transaction, - )?)?; - - Ok(unsigned_transaction) - } - - fn create_unsigned_send_satpoint_transaction( - wallet: &Wallet, - destination: Address, - satpoint: SatPoint, - postage: Option, - fee_rate: FeeRate, - sending_inscription: bool, - ) -> Result { - if !sending_inscription { - for inscription_satpoint in wallet.inscriptions().keys() { - if satpoint == *inscription_satpoint { - bail!("inscriptions must be sent by inscription ID"); - } - } - } - - let runic_outputs = wallet.get_runic_outputs()?; - - ensure!( - !runic_outputs.contains(&satpoint.outpoint), - "runic outpoints may not be sent by satpoint" - ); - - let change = [wallet.get_change_address()?, wallet.get_change_address()?]; - - let postage = if let Some(postage) = postage { - Target::ExactPostage(postage) - } else { - Target::Postage - }; - - Ok( - TransactionBuilder::new( - satpoint, - wallet.inscriptions().clone(), - wallet.utxos().clone(), - wallet.locked_utxos().clone().into_keys().collect(), - runic_outputs, - destination.script_pubkey(), - change, - fee_rate, - postage, - wallet.chain().network(), - ) - .build_transaction()?, - ) - } - - fn create_unsigned_send_runes_transaction( - wallet: &Wallet, - destination: Address, - spaced_rune: SpacedRune, - decimal: Decimal, - postage: Amount, - fee_rate: FeeRate, - ) -> Result { - ensure!( - wallet.has_rune_index(), - "sending runes with `ord send` requires index created with `--index-runes` flag", - ); - - wallet.lock_non_cardinal_outputs()?; - - let (id, entry, _parent) = wallet - .get_rune(spaced_rune.rune)? - .with_context(|| format!("rune `{}` has not been etched", spaced_rune.rune))?; - - let amount = decimal.to_integer(entry.divisibility)?; - - let inscribed_outputs = wallet - .inscriptions() - .keys() - .map(|satpoint| satpoint.outpoint) - .collect::>(); - - let balances = wallet - .get_runic_outputs()? - .into_iter() - .filter(|output| !inscribed_outputs.contains(output)) - .map(|output| { - wallet.get_runes_balances_in_output(&output).map(|balance| { - ( - output, - balance - .into_iter() - .map(|(spaced_rune, pile)| (spaced_rune.rune, pile.amount)) - .collect(), - ) - }) - }) - .collect::>>>()?; - - let mut inputs = Vec::new(); - let mut input_rune_balances: BTreeMap = BTreeMap::new(); - - for (output, runes) in balances { - if let Some(balance) = runes.get(&spaced_rune.rune) { - if *balance > 0 { - for (rune, balance) in runes { - *input_rune_balances.entry(rune).or_default() += balance; - } - - inputs.push(output); - - if input_rune_balances - .get(&spaced_rune.rune) - .cloned() - .unwrap_or_default() - >= amount - { - break; - } - } - } - } - - let input_rune_balance = input_rune_balances - .get(&spaced_rune.rune) - .cloned() - .unwrap_or_default(); - - let needs_runes_change_output = input_rune_balance > amount || input_rune_balances.len() > 1; - - ensure! { - input_rune_balance >= amount, - "insufficient `{}` balance, only {} in wallet", - spaced_rune, - Pile { - amount: input_rune_balance, - divisibility: entry.divisibility, - symbol: entry.symbol - }, - } - - let runestone = Runestone { - edicts: vec![Edict { - amount, - id, - output: 2, - }], - ..default() - }; - - let unfunded_transaction = Transaction { - version: Version(2), - lock_time: LockTime::ZERO, - input: inputs - .into_iter() - .map(|previous_output| TxIn { - previous_output, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::new(), - }) - .collect(), - output: if needs_runes_change_output { - vec![ - TxOut { - script_pubkey: runestone.encipher(), - value: Amount::from_sat(0), - }, - TxOut { - script_pubkey: wallet.get_change_address()?.script_pubkey(), - value: postage, - }, - TxOut { - script_pubkey: destination.script_pubkey(), - value: postage, - }, - ] - } else { - vec![TxOut { - script_pubkey: destination.script_pubkey(), - value: postage, - }] - }, - }; - - let unsigned_transaction = - fund_raw_transaction(wallet.bitcoin_client(), fee_rate, &unfunded_transaction)?; - - let unsigned_transaction = consensus::encode::deserialize(&unsigned_transaction)?; - - if needs_runes_change_output { - assert_eq!( - Runestone::decipher(&unsigned_transaction), - Some(Artifact::Runestone(runestone)), - ); - } - - Ok(unsigned_transaction) - } } diff --git a/src/wallet.rs b/src/wallet.rs index f226f7cfa3..f45fc9dd6a 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -835,4 +835,269 @@ impl Wallet { .call("sendrawtransaction", &arguments)?, ) } + + pub fn create_unsigned_send_amount_transaction( + &self, + destination: Address, + amount: Amount, + fee_rate: FeeRate, + ) -> Result { + self.lock_non_cardinal_outputs()?; + + let unfunded_transaction = Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { + script_pubkey: destination.script_pubkey(), + value: amount, + }], + }; + + let unsigned_transaction = consensus::encode::deserialize(&fund_raw_transaction( + self.bitcoin_client(), + fee_rate, + &unfunded_transaction, + )?)?; + + Ok(unsigned_transaction) + } + + pub fn create_unsigned_send_satpoint_transaction( + &self, + destination: Address, + satpoint: SatPoint, + postage: Option, + fee_rate: FeeRate, + sending_inscription: bool, + ) -> Result { + if !sending_inscription { + for inscription_satpoint in self.inscriptions().keys() { + if satpoint == *inscription_satpoint { + bail!("inscriptions must be sent by inscription ID"); + } + } + } + + let runic_outputs = self.get_runic_outputs()?; + + ensure!( + !runic_outputs.contains(&satpoint.outpoint), + "runic outpoints may not be sent by satpoint" + ); + + let change = [self.get_change_address()?, self.get_change_address()?]; + + let postage = if let Some(postage) = postage { + Target::ExactPostage(postage) + } else { + Target::Postage + }; + + Ok( + TransactionBuilder::new( + satpoint, + self.inscriptions().clone(), + self.utxos().clone(), + self.locked_utxos().clone().into_keys().collect(), + runic_outputs, + destination.script_pubkey(), + change, + fee_rate, + postage, + self.chain().network(), + ) + .build_transaction()?, + ) + } + + pub fn create_unsigned_send_or_burn_runes_transaction( + &self, + destination: Option
, + spaced_rune: SpacedRune, + decimal: Decimal, + postage: Option, + fee_rate: FeeRate, + ) -> Result { + ensure!( + self.has_rune_index(), + "sending runes with `ord send` requires index created with `--index-runes` flag", + ); + + self.lock_non_cardinal_outputs()?; + + let (id, entry, _parent) = self + .get_rune(spaced_rune.rune)? + .with_context(|| format!("rune `{}` has not been etched", spaced_rune.rune))?; + + let amount = decimal.to_integer(entry.divisibility)?; + + let inscribed_outputs = self + .inscriptions() + .keys() + .map(|satpoint| satpoint.outpoint) + .collect::>(); + + let balances = self + .get_runic_outputs()? + .into_iter() + .filter(|output| !inscribed_outputs.contains(output)) + .map(|output| { + self.get_runes_balances_in_output(&output).map(|balance| { + ( + output, + balance + .into_iter() + .map(|(spaced_rune, pile)| (spaced_rune.rune, pile.amount)) + .collect(), + ) + }) + }) + .collect::>>>()?; + + let mut inputs = Vec::new(); + let mut input_rune_balances: BTreeMap = BTreeMap::new(); + + for (output, runes) in balances { + if let Some(balance) = runes.get(&spaced_rune.rune) { + if *balance > 0 { + for (rune, balance) in runes { + *input_rune_balances.entry(rune).or_default() += balance; + } + + inputs.push(output); + + if input_rune_balances + .get(&spaced_rune.rune) + .cloned() + .unwrap_or_default() + >= amount + { + break; + } + } + } + } + + let input_rune_balance = input_rune_balances + .get(&spaced_rune.rune) + .cloned() + .unwrap_or_default(); + + let needs_runes_change_output = input_rune_balance > amount || input_rune_balances.len() > 1; + + ensure! { + input_rune_balance >= amount, + "insufficient `{}` balance, only {} in wallet", + spaced_rune, + Pile { + amount: input_rune_balance, + divisibility: entry.divisibility, + symbol: entry.symbol + }, + } + + let runestone; + let postage = postage.unwrap_or(TARGET_POSTAGE); + + let unfunded_transaction = if let Some(destination) = destination { + runestone = Runestone { + edicts: vec![Edict { + amount, + id, + output: 2, + }], + ..default() + }; + + Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect(), + output: if needs_runes_change_output { + vec![ + TxOut { + script_pubkey: runestone.encipher(), + value: Amount::from_sat(0), + }, + TxOut { + script_pubkey: self.get_change_address()?.script_pubkey(), + value: postage, + }, + TxOut { + script_pubkey: destination.script_pubkey(), + value: postage, + }, + ] + } else { + vec![TxOut { + script_pubkey: destination.script_pubkey(), + value: postage, + }] + }, + } + } else { + runestone = Runestone { + edicts: vec![Edict { + amount, + id, + output: 0, + }], + ..default() + }; + + Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect(), + output: if needs_runes_change_output { + vec![ + TxOut { + script_pubkey: runestone.encipher(), + value: Amount::from_sat(0), + }, + TxOut { + script_pubkey: self.get_change_address()?.script_pubkey(), + value: postage, + }, + ] + } else { + vec![TxOut { + script_pubkey: runestone.encipher(), + value: Amount::from_sat(0), + }] + }, + } + }; + + let unsigned_transaction = + fund_raw_transaction(self.bitcoin_client(), fee_rate, &unfunded_transaction)?; + + let unsigned_transaction = consensus::encode::deserialize(&unsigned_transaction)?; + + if needs_runes_change_output { + assert_eq!( + Runestone::decipher(&unsigned_transaction), + Some(Artifact::Runestone(runestone)), + ); + } + + Ok(unsigned_transaction) + } } diff --git a/tests/lib.rs b/tests/lib.rs index cedafcaf2e..19a69fcef5 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -10,8 +10,9 @@ use { executable_path::executable_path, mockcore::TransactionTemplate, ord::{ - api, chain::Chain, outgoing::Outgoing, subcommand::runes::RuneInfo, templates::InscriptionHtml, - wallet::batch, wallet::ListDescriptorsResult, Inscription, InscriptionId, RuneEntry, + api, chain::Chain, decimal::Decimal, outgoing::Outgoing, subcommand::runes::RuneInfo, + templates::InscriptionHtml, wallet::batch, wallet::ListDescriptorsResult, Inscription, + InscriptionId, RuneEntry, }, ordinals::{ Artifact, Charm, Edict, Pile, Rarity, Rune, RuneId, Runestone, Sat, SatPoint, SpacedRune, diff --git a/tests/wallet/burn.rs b/tests/wallet/burn.rs index 232c04a44e..cd99863152 100644 --- a/tests/wallet/burn.rs +++ b/tests/wallet/burn.rs @@ -408,3 +408,345 @@ fn oversize_metadata_requires_no_limit_flag() { .expected_exit_code(1) .run_and_extract_stdout(); } + +#[test] +fn burn_rune() { + let core = mockcore::builder().network(Network::Regtest).build(); + + let ord = TestServer::spawn_with_server_args(&core, &["--regtest", "--index-runes"], &[]); + + create_wallet(&core, &ord); + + let rune = Rune(RUNE); + etch(&core, &ord, rune); + + core.mine_blocks(1); + + CommandBuilder::new(format!("--regtest wallet burn --fee-rate 1 500:{rune}",)) + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + core.mine_blocks(1); + + pretty_assert_eq!( + CommandBuilder::new("--regtest wallet balance") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(), + Balance { + cardinal: 450 * COIN_VALUE - 2 * 10000 + 129, + ordinal: 10000, + runic: Some(9871), + runes: Some( + [( + SpacedRune { rune, spacers: 0 }, + Decimal { + value: 500, + scale: 0 + } + )] + .into_iter() + .collect() + ), + total: 450 * COIN_VALUE, + } + ); + + CommandBuilder::new(format!("--regtest wallet burn --fee-rate 1 500:{rune}",)) + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + core.mine_blocks(1); + + pretty_assert_eq!( + CommandBuilder::new("--regtest wallet balance") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(), + Balance { + cardinal: 500 * COIN_VALUE - 10000, + ordinal: 10000, + runic: Some(0), + runes: Some(BTreeMap::new()), + total: 500 * COIN_VALUE, + } + ); +} + +#[test] +fn burn_rune_with_many_assets_in_wallet() { + let core = mockcore::builder().network(Network::Regtest).build(); + + let ord = TestServer::spawn_with_server_args(&core, &["--regtest", "--index-runes"], &[]); + + create_wallet(&core, &ord); + + core.mine_blocks(1); + + inscribe(&core, &ord); + + let rune_0 = Rune(RUNE); + etch(&core, &ord, rune_0); + + let rune_1 = Rune(RUNE - 1); + etch(&core, &ord, rune_1); + + let rune_2 = Rune(RUNE - 2); + etch(&core, &ord, rune_2); + + pretty_assert_eq!( + CommandBuilder::new("--regtest wallet balance") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(), + Balance { + cardinal: 119999930000, + ordinal: 40000, + runic: Some(30000), + runes: Some( + [ + ( + SpacedRune { + rune: rune_0, + spacers: 0 + }, + Decimal { + value: 1000, + scale: 0 + } + ), + ( + SpacedRune { + rune: rune_1, + spacers: 0 + }, + Decimal { + value: 1000, + scale: 0 + } + ), + ( + SpacedRune { + rune: rune_2, + spacers: 0 + }, + Decimal { + value: 1000, + scale: 0 + } + ) + ] + .into_iter() + .collect() + ), + total: 24 * 50 * COIN_VALUE, + } + ); + + CommandBuilder::new(format!("--regtest wallet burn --fee-rate 1 1111:{rune_0}",)) + .core(&core) + .ord(&ord) + .expected_exit_code(1) + .stderr_regex("error: insufficient `AAAAAAAAAAAAA` balance.*") + .run_and_extract_stdout(); + + CommandBuilder::new(format!("--regtest wallet burn --fee-rate 1 1000:{rune_2}",)) + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + core.mine_blocks(1); + + pretty_assert_eq!( + CommandBuilder::new("--regtest wallet balance") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(), + Balance { + cardinal: 124999940000, + ordinal: 40000, + runic: Some(20000), + runes: Some( + [ + ( + SpacedRune { + rune: rune_0, + spacers: 0 + }, + Decimal { + value: 1000, + scale: 0 + } + ), + ( + SpacedRune { + rune: rune_1, + spacers: 0 + }, + Decimal { + value: 1000, + scale: 0 + } + ), + ] + .into_iter() + .collect() + ), + total: 25 * 50 * COIN_VALUE, + } + ); +} + +#[test] +fn burning_rune_creates_change_output_for_non_burnt_runes() { + let core = mockcore::builder().network(Network::Regtest).build(); + + let ord = TestServer::spawn_with_server_args(&core, &["--index-runes", "--regtest"], &[]); + + create_wallet(&core, &ord); + + let a = etch(&core, &ord, Rune(RUNE)); + let b = etch(&core, &ord, Rune(RUNE + 1)); + + let (a_block, a_tx) = core.tx_index(a.output.reveal); + let (b_block, b_tx) = core.tx_index(b.output.reveal); + + core.mine_blocks(1); + + let address = CommandBuilder::new("--regtest wallet receive") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::() + .addresses + .into_iter() + .next() + .unwrap(); + + let merge = core.broadcast_tx(TransactionTemplate { + inputs: &[(a_block, a_tx, 1, default()), (b_block, b_tx, 1, default())], + recipient: Some(address.require_network(Network::Regtest).unwrap()), + ..default() + }); + + core.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + pretty_assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: [ + ( + SpacedRune::new(Rune(RUNE), 0), + [( + OutPoint { + txid: merge, + vout: 0 + }, + Pile { + amount: 1000, + divisibility: 0, + symbol: Some('¢') + }, + )] + .into() + ), + ( + SpacedRune::new(Rune(RUNE + 1), 0), + [( + OutPoint { + txid: merge, + vout: 0 + }, + Pile { + amount: 1000, + divisibility: 0, + symbol: Some('¢') + }, + )] + .into() + ), + ] + .into() + } + ); + + let output = CommandBuilder::new(format!( + "--chain regtest --index-runes wallet burn --fee-rate 1 500:{}", + Rune(RUNE) + )) + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + core.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(); + + pretty_assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: [ + ( + SpacedRune::new(Rune(RUNE), 0), + [( + OutPoint { + txid: output.txid, + vout: 1 + }, + Pile { + amount: 500, + divisibility: 0, + symbol: Some('¢') + }, + )] + .into() + ), + ( + SpacedRune::new(Rune(RUNE + 1), 0), + [( + OutPoint { + txid: output.txid, + vout: 1 + }, + Pile { + amount: 1000, + divisibility: 0, + symbol: Some('¢') + }, + )] + .into() + ) + ] + .into() + } + ); + + pretty_assert_eq!( + CommandBuilder::new("--regtest --index-runes wallet balance") + .core(&core) + .ord(&ord) + .run_and_deserialize_output::(), + Balance { + cardinal: 84999970000, + ordinal: 20000, + runes: Some( + [ + (SpacedRune::new(Rune(RUNE), 0), "500".parse().unwrap()), + (SpacedRune::new(Rune(RUNE + 1), 0), "1000".parse().unwrap()) + ] + .into() + ), + runic: Some(10000), + total: 17 * 50 * COIN_VALUE, + } + ); +}