Skip to content

Commit 33fb97b

Browse files
committed
fix(wallet): enforce OP_RETURN standardness via try_add_data
Add TxBuilder::try_add_data which validates against Bitcoin Core standardness rules before adding an OP_RETURN output: - Data payload must not exceed 80 bytes (Bitcoin Core MAX_OP_RETURN_RELAY limits the scriptPubKey to 83 bytes, constraining the payload to 80 bytes) - At most one OP_RETURN output per transaction is permitted Add CreateTxError::OpReturnInvalidDataSize and CreateTxError::MultipleOpReturnOutputs error variants with links to the relevant Bitcoin Core policy source. Deprecate add_data in favour of try_add_data per the project deprecation policy. Closes #44
1 parent 44abb68 commit 33fb97b

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

src/wallet/error.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,17 @@ pub enum CreateTxError {
215215
MissingNonWitnessUtxo(OutPoint),
216216
/// Miniscript PSBT error
217217
MiniscriptPsbt(MiniscriptPsbtError),
218+
/// OP_RETURN data payload exceeds the 80-byte standardness limit
219+
///
220+
/// Bitcoin Core enforces a maximum `scriptPubKey` size of 83 bytes for data carrier outputs
221+
/// (`MAX_OP_RETURN_RELAY`), which constrains the data payload to at most 80 bytes.
222+
/// See <https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.h>
223+
OpReturnInvalidDataSize(usize),
224+
/// Transaction already contains an OP_RETURN output
225+
///
226+
/// Bitcoin standardness rules allow at most one OP_RETURN output per transaction.
227+
/// See <https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp>
228+
MultipleOpReturnOutputs,
218229
}
219230

220231
impl fmt::Display for CreateTxError {
@@ -281,6 +292,18 @@ impl fmt::Display for CreateTxError {
281292
CreateTxError::MiniscriptPsbt(err) => {
282293
write!(f, "Miniscript PSBT error: {err}")
283294
}
295+
CreateTxError::OpReturnInvalidDataSize(size) => {
296+
write!(
297+
f,
298+
"OP_RETURN data payload is {size} bytes; maximum allowed by standardness rules is 80"
299+
)
300+
}
301+
CreateTxError::MultipleOpReturnOutputs => {
302+
write!(
303+
f,
304+
"Transaction already contains an OP_RETURN output; standardness rules allow at most one"
305+
)
306+
}
284307
}
285308
}
286309
}

src/wallet/tx_builder.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,13 +680,56 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
680680
self
681681
}
682682

683-
/// Add data as an output, using OP_RETURN
683+
/// Add data as an output, using OP_RETURN.
684+
///
685+
/// # Deprecation
686+
///
687+
/// This method does not enforce Bitcoin standardness rules. Use [`try_add_data`] instead,
688+
/// which returns an error if the data exceeds 80 bytes or if an OP_RETURN output is already
689+
/// present.
690+
///
691+
/// [`try_add_data`]: Self::try_add_data
692+
#[deprecated(since = "3.1.0", note = "use `try_add_data` instead")]
684693
pub fn add_data<T: AsRef<PushBytes>>(&mut self, data: &T) -> &mut Self {
685694
let script = ScriptBuf::new_op_return(data);
686695
self.add_recipient(script, Amount::ZERO);
687696
self
688697
}
689698

699+
/// Add data as an output, using OP_RETURN, enforcing Bitcoin standardness rules.
700+
///
701+
/// Returns an error if:
702+
/// - `data` exceeds 80 bytes ([`CreateTxError::OpReturnInvalidDataSize`]). Bitcoin Core's
703+
/// `MAX_OP_RETURN_RELAY` limits the `scriptPubKey` to 83 bytes, which constrains the data
704+
/// payload to at most 80 bytes.
705+
/// - A recipient with an OP_RETURN script is already present
706+
/// ([`CreateTxError::MultipleOpReturnOutputs`]). Standardness rules allow at most one
707+
/// OP_RETURN output per transaction.
708+
pub fn try_add_data<T: AsRef<PushBytes>>(
709+
&mut self,
710+
data: &T,
711+
) -> Result<&mut Self, CreateTxError> {
712+
const MAX_OP_RETURN_DATA_BYTES: usize = 80;
713+
714+
let bytes = data.as_ref();
715+
if bytes.len() > MAX_OP_RETURN_DATA_BYTES {
716+
return Err(CreateTxError::OpReturnInvalidDataSize(bytes.len()));
717+
}
718+
719+
if self
720+
.params
721+
.recipients
722+
.iter()
723+
.any(|(script, _)| script.is_op_return())
724+
{
725+
return Err(CreateTxError::MultipleOpReturnOutputs);
726+
}
727+
728+
let script = ScriptBuf::new_op_return(bytes);
729+
self.add_recipient(script, Amount::ZERO);
730+
Ok(self)
731+
}
732+
690733
/// Sets the address to *drain* excess coins to.
691734
///
692735
/// Usually, when there are excess coins they are sent to a change address generated by the

tests/wallet.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,8 +2624,8 @@ fn test_fee_rate_sign_no_grinding_high_r() {
26242624
builder
26252625
.drain_to(addr.script_pubkey())
26262626
.drain_wallet()
2627-
.fee_rate(fee_rate)
2628-
.add_data(&data);
2627+
.fee_rate(fee_rate);
2628+
builder.try_add_data(&data).unwrap();
26292629
let mut psbt = builder.finish().unwrap();
26302630
let fee = check_fee!(wallet, psbt);
26312631
let (op_return_vout, _) = psbt
@@ -3013,3 +3013,44 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() {
30133013
"UTXOs should be ordered with required first, then selected"
30143014
);
30153015
}
3016+
3017+
#[test]
3018+
fn test_try_add_data_valid() {
3019+
let (mut wallet, _) = get_funded_wallet_wpkh();
3020+
let addr = wallet.next_unused_address(KeychainKind::External);
3021+
let data = PushBytesBuf::try_from(vec![0u8; 80]).unwrap();
3022+
3023+
let mut builder = wallet.build_tx();
3024+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000));
3025+
assert!(builder.try_add_data(&data).is_ok());
3026+
assert!(builder.finish().is_ok());
3027+
}
3028+
3029+
#[test]
3030+
fn test_try_add_data_too_large() {
3031+
let (mut wallet, _) = get_funded_wallet_wpkh();
3032+
let addr = wallet.next_unused_address(KeychainKind::External);
3033+
let data = PushBytesBuf::try_from(vec![0u8; 81]).unwrap();
3034+
3035+
let mut builder = wallet.build_tx();
3036+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000));
3037+
assert_matches!(
3038+
builder.try_add_data(&data),
3039+
Err(CreateTxError::OpReturnInvalidDataSize(81))
3040+
);
3041+
}
3042+
3043+
#[test]
3044+
fn test_try_add_data_multiple_op_return() {
3045+
let (mut wallet, _) = get_funded_wallet_wpkh();
3046+
let addr = wallet.next_unused_address(KeychainKind::External);
3047+
let data = PushBytesBuf::try_from(vec![0u8; 4]).unwrap();
3048+
3049+
let mut builder = wallet.build_tx();
3050+
builder.add_recipient(addr.script_pubkey(), Amount::from_sat(1000));
3051+
builder.try_add_data(&data).unwrap();
3052+
assert_matches!(
3053+
builder.try_add_data(&data),
3054+
Err(CreateTxError::MultipleOpReturnOutputs)
3055+
);
3056+
}

0 commit comments

Comments
 (0)