diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index db9a94f7ff..fa15275149 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -15,8 +15,12 @@ impl Send { let utxos = list_unspent(&options, &index)?.into_iter().collect(); + let change = client + .call("getrawchangeaddress", &[]) + .context("Could not get change addresses from wallet")?; + let unsigned_transaction = - TransactionBuilder::build_transaction(utxos, self.ordinal, self.address)?; + TransactionBuilder::build_transaction(utxos, self.ordinal, self.address, change)?; let signed_tx = client .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? diff --git a/src/subcommand/wallet/transaction_builder.rs b/src/subcommand/wallet/transaction_builder.rs index 5653627dc5..9bbe3ebbea 100644 --- a/src/subcommand/wallet/transaction_builder.rs +++ b/src/subcommand/wallet/transaction_builder.rs @@ -25,6 +25,7 @@ impl std::error::Error for Error {} #[derive(Debug, PartialEq)] pub(crate) struct TransactionBuilder { + change: Address, inputs: Vec, ordinal: Ordinal, outputs: Vec<(Address, Amount)>, @@ -36,13 +37,18 @@ pub(crate) struct TransactionBuilder { type Result = std::result::Result; impl TransactionBuilder { + const TARGET_POSTAGE: u64 = 10_000; + const MAX_POSTAGE: u64 = 2 * Self::TARGET_POSTAGE; + pub(crate) fn build_transaction( ranges: BTreeMap>, ordinal: Ordinal, recipient: Address, + change: Address, ) -> Result { - Self::new(ranges, ordinal, recipient) + Self::new(ranges, ordinal, recipient, change) .select_ordinal()? + .strip_excess_postage()? .deduct_fee()? .build() } @@ -51,6 +57,7 @@ impl TransactionBuilder { ranges: BTreeMap>, ordinal: Ordinal, recipient: Address, + change: Address, ) -> Self { Self { utxos: ranges.keys().cloned().collect(), @@ -59,6 +66,7 @@ impl TransactionBuilder { outputs: Vec::new(), ranges, recipient, + change, } } @@ -84,6 +92,32 @@ impl TransactionBuilder { Ok(self) } + fn strip_excess_postage(mut self) -> Result { + let ordinal_offset = self.calculate_ordinal_offset(); + let output_total = self + .outputs + .iter() + .map(|(_address, amount)| *amount) + .sum::(); + + assert_eq!(self.outputs.len(), 1, "invariant: only one output"); + + assert_eq!( + self.outputs[0].0, self.recipient, + "invariant: first output is recipient" + ); + + if output_total > Amount::from_sat(ordinal_offset + Self::MAX_POSTAGE) { + self.outputs[0].1 = Amount::from_sat(Self::TARGET_POSTAGE); + self.outputs.push(( + self.change.clone(), + output_total - Amount::from_sat(ordinal_offset + Self::TARGET_POSTAGE), + )); + } + + Ok(self) + } + fn deduct_fee(mut self) -> Result { let ordinal_offset = self.calculate_ordinal_offset(); @@ -188,6 +222,15 @@ impl TransactionBuilder { } assert!(found, "invariant: ordinal is found in outputs"); + for output in &transaction.output { + if output.script_pubkey != self.change.script_pubkey() { + assert!( + output.value < Self::MAX_POSTAGE, + "invariant: excess postage is stripped" + ); + } + } + Ok(transaction) } @@ -238,6 +281,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ) .select_ordinal() .unwrap(); @@ -295,6 +341,9 @@ mod tests { recipient: "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + change: "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), inputs: vec![ "1111111111111111111111111111111111111111111111111111111111111111:1" .parse() @@ -402,6 +451,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ), Ok(Transaction { version: 1, @@ -415,7 +467,7 @@ mod tests { witness: Witness::new(), },], output: vec![TxOut { - value: 4836, + value: 5000 - 164, script_pubkey: "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse::
() .unwrap() @@ -441,6 +493,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ), Err(Error::ConsumedByFee(Ordinal(14900))) ) @@ -462,6 +517,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ) .build() .ok(); @@ -483,6 +541,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ) .build() .ok(); @@ -504,6 +565,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ) .select_ordinal() .unwrap(); @@ -531,6 +595,9 @@ mod tests { "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" .parse() .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), ) .select_ordinal() .unwrap(); @@ -539,4 +606,81 @@ mod tests { builder.build().ok(); } + + #[test] + fn excess_postage_is_stripped() { + let utxos = vec![( + "1111111111111111111111111111111111111111111111111111111111111111:1" + .parse() + .unwrap(), + vec![(0, 1_000_000)], + )]; + + pretty_assert_eq!( + TransactionBuilder::build_transaction( + utxos.into_iter().collect(), + Ordinal(0), + "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" + .parse() + .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), + ), + Ok(Transaction { + version: 1, + lock_time: PackedLockTime::ZERO, + input: vec![TxIn { + previous_output: "1111111111111111111111111111111111111111111111111111111111111111:1" + .parse() + .unwrap(), + script_sig: Script::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + },], + output: vec![ + TxOut { + value: TransactionBuilder::TARGET_POSTAGE, + script_pubkey: "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" + .parse::
() + .unwrap() + .script_pubkey(), + }, + TxOut { + value: 1_000_000 - TransactionBuilder::TARGET_POSTAGE - 226, + script_pubkey: "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse::
() + .unwrap() + .script_pubkey(), + } + ], + }) + ) + } + + #[test] + #[should_panic(expected = "invariant: excess postage is stripped")] + fn invariant_excess_postage_is_stripped() { + let utxos = vec![( + "1111111111111111111111111111111111111111111111111111111111111111:1" + .parse() + .unwrap(), + vec![(0, 1_000_000)], + )]; + + TransactionBuilder::new( + utxos.into_iter().collect(), + Ordinal(0), + "tb1q6en7qjxgw4ev8xwx94pzdry6a6ky7wlfeqzunz" + .parse() + .unwrap(), + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), + ) + .select_ordinal() + .unwrap() + .build() + .unwrap(); + } } diff --git a/test-bitcoincore-rpc/src/api.rs b/test-bitcoincore-rpc/src/api.rs index 41c19d7910..f460ce2cc0 100644 --- a/test-bitcoincore-rpc/src/api.rs +++ b/test-bitcoincore-rpc/src/api.rs @@ -66,4 +66,7 @@ pub trait Api { include_unsafe: Option, query_options: Option, ) -> Result, jsonrpc_core::Error>; + + #[rpc(name = "getrawchangeaddress")] + fn get_raw_change_address(&self) -> Result; } diff --git a/test-bitcoincore-rpc/src/server.rs b/test-bitcoincore-rpc/src/server.rs index e1bee2ff43..4b54041095 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -299,4 +299,12 @@ impl Api for Server { .collect(), ) } + + fn get_raw_change_address(&self) -> Result { + Ok( + "tb1qjsv26lap3ffssj6hfy8mzn0lg5vte6a42j75ww" + .parse() + .unwrap(), + ) + } }