diff --git a/src/wallet/error.rs b/src/wallet/error.rs index ddd07478..fe494c6d 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -215,6 +215,17 @@ pub enum CreateTxError { MissingNonWitnessUtxo(OutPoint), /// Miniscript PSBT error MiniscriptPsbt(MiniscriptPsbtError), + /// OP_RETURN data payload exceeds the 80-byte standardness limit + /// + /// Bitcoin Core enforces a maximum `scriptPubKey` size of 83 bytes for data carrier outputs + /// (`MAX_OP_RETURN_RELAY`), which constrains the data payload to at most 80 bytes. + /// See + OpReturnInvalidDataSize(usize), + /// Transaction already contains an OP_RETURN output + /// + /// Bitcoin standardness rules allow at most one OP_RETURN output per transaction. + /// See + MultipleOpReturnOutputs, } impl fmt::Display for CreateTxError { @@ -281,6 +292,18 @@ impl fmt::Display for CreateTxError { CreateTxError::MiniscriptPsbt(err) => { write!(f, "Miniscript PSBT error: {err}") } + CreateTxError::OpReturnInvalidDataSize(size) => { + write!( + f, + "OP_RETURN data payload is {size} bytes; maximum allowed by standardness rules is 80" + ) + } + CreateTxError::MultipleOpReturnOutputs => { + write!( + f, + "Transaction already contains an OP_RETURN output; standardness rules allow at most one" + ) + } } } } diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index f22a62cf..6da20beb 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -680,13 +680,56 @@ impl<'a, Cs> TxBuilder<'a, Cs> { self } - /// Add data as an output, using OP_RETURN + /// Add data as an output, using OP_RETURN. + /// + /// # Deprecation + /// + /// This method does not enforce Bitcoin standardness rules. Use [`try_add_data`] instead, + /// which returns an error if the data exceeds 80 bytes or if an OP_RETURN output is already + /// present. + /// + /// [`try_add_data`]: Self::try_add_data + #[deprecated(since = "3.1.0", note = "use `try_add_data` instead")] pub fn add_data>(&mut self, data: &T) -> &mut Self { let script = ScriptBuf::new_op_return(data); self.add_recipient(script, Amount::ZERO); self } + /// Add data as an output, using OP_RETURN, enforcing Bitcoin standardness rules. + /// + /// Returns an error if: + /// - `data` exceeds 80 bytes ([`CreateTxError::OpReturnInvalidDataSize`]). Bitcoin Core's + /// `MAX_OP_RETURN_RELAY` limits the `scriptPubKey` to 83 bytes, which constrains the data + /// payload to at most 80 bytes. + /// - A recipient with an OP_RETURN script is already present + /// ([`CreateTxError::MultipleOpReturnOutputs`]). Standardness rules allow at most one + /// OP_RETURN output per transaction. + pub fn try_add_data>( + &mut self, + data: &T, + ) -> Result<&mut Self, CreateTxError> { + const MAX_OP_RETURN_DATA_BYTES: usize = 80; + + let bytes = data.as_ref(); + if bytes.len() > MAX_OP_RETURN_DATA_BYTES { + return Err(CreateTxError::OpReturnInvalidDataSize(bytes.len())); + } + + if self + .params + .recipients + .iter() + .any(|(script, _)| script.is_op_return()) + { + return Err(CreateTxError::MultipleOpReturnOutputs); + } + + let script = ScriptBuf::new_op_return(bytes); + self.add_recipient(script, Amount::ZERO); + Ok(self) + } + /// Sets the address to *drain* excess coins to. /// /// Usually, when there are excess coins they are sent to a change address generated by the diff --git a/tests/wallet.rs b/tests/wallet.rs index 4dd08d6d..d4f158ae 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -2624,8 +2624,8 @@ fn test_fee_rate_sign_no_grinding_high_r() { builder .drain_to(addr.script_pubkey()) .drain_wallet() - .fee_rate(fee_rate) - .add_data(&data); + .fee_rate(fee_rate); + builder.try_add_data(&data).unwrap(); let mut psbt = builder.finish().unwrap(); let fee = check_fee!(wallet, psbt); let (op_return_vout, _) = psbt @@ -3013,3 +3013,44 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() { "UTXOs should be ordered with required first, then selected" ); } + +#[test] +fn test_try_add_data_valid() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let data = PushBytesBuf::try_from(vec![0u8; 80]).unwrap(); + + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000)); + assert!(builder.try_add_data(&data).is_ok()); + assert!(builder.finish().is_ok()); +} + +#[test] +fn test_try_add_data_too_large() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let data = PushBytesBuf::try_from(vec![0u8; 81]).unwrap(); + + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000)); + assert_matches!( + builder.try_add_data(&data), + Err(CreateTxError::OpReturnInvalidDataSize(81)) + ); +} + +#[test] +fn test_try_add_data_multiple_op_return() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let addr = wallet.next_unused_address(KeychainKind::External); + let data = PushBytesBuf::try_from(vec![0u8; 4]).unwrap(); + + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000)); + builder.try_add_data(&data).unwrap(); + assert_matches!( + builder.try_add_data(&data), + Err(CreateTxError::MultipleOpReturnOutputs) + ); +}