Skip to content

Commit dff44c2

Browse files
benma's agentbenma
authored andcommitted
btc: support op_return outputs
1 parent 1bd47c2 commit dff44c2

File tree

10 files changed

+242
-7
lines changed

10 files changed

+242
-7
lines changed

CHANGELOG-npm.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 0.12.0
4+
- btc: add support for OP_RETURN outputs
5+
36
## 0.11.0
47
- Add `btcXpubs()`
58

CHANGELOG-rust.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 0.11.0
4+
- btc: add support for OP_RETURN outputs
5+
36
## 0.10.0
47
- Add `btc_xpubs()`
58

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "bitbox-api"
33
authors = ["Marko Bencun <[email protected]>"]
4-
version = "0.10.0"
4+
version = "0.11.0"
55
homepage = "https://bitbox.swiss/"
66
repository = "https://github.com/BitBoxSwiss/bitbox-api-rs/"
77
readme = "README-rust.md"

NPM_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.11.0
1+
0.12.0

messages/btc.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ enum BTCOutputType {
175175
P2WPKH = 3;
176176
P2WSH = 4;
177177
P2TR = 5;
178+
OP_RETURN = 6;
178179
}
179180

180181
message BTCSignOutputRequest {

sandbox/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/btc.rs

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ pub use bitcoin::{
1313
Script,
1414
};
1515

16+
use bitcoin::blockdata::{opcodes, script::Instruction};
17+
1618
#[cfg(feature = "wasm")]
1719
use enum_assoc::Assoc;
1820

@@ -160,6 +162,8 @@ pub struct Payload {
160162
pub enum PayloadError {
161163
#[error("unrecognized pubkey script")]
162164
Unrecognized,
165+
#[error("{0}")]
166+
InvalidOpReturn(&'static str),
163167
}
164168

165169
impl Payload {
@@ -190,6 +194,46 @@ impl Payload {
190194
data: pkscript[2..].to_vec(),
191195
output_type: pb::BtcOutputType::P2tr,
192196
})
197+
} else if matches!(script.as_bytes().first(), Some(byte) if *byte == opcodes::all::OP_RETURN.to_u8())
198+
{
199+
let mut instructions = script.instructions_minimal();
200+
match instructions.next() {
201+
Some(Ok(Instruction::Op(op))) if op == opcodes::all::OP_RETURN => {}
202+
_ => return Err(PayloadError::Unrecognized),
203+
}
204+
205+
let payload = match instructions.next() {
206+
None => {
207+
return Err(PayloadError::InvalidOpReturn(
208+
"naked OP_RETURN is not supported",
209+
))
210+
}
211+
Some(Ok(Instruction::Op(op))) if op == opcodes::all::OP_PUSHBYTES_0 => Vec::new(),
212+
Some(Ok(Instruction::PushBytes(push))) => push.as_bytes().to_vec(),
213+
Some(Ok(_)) => {
214+
return Err(PayloadError::InvalidOpReturn(
215+
"no data push found after OP_RETURN",
216+
))
217+
}
218+
Some(Err(_)) => {
219+
return Err(PayloadError::InvalidOpReturn(
220+
"failed to parse OP_RETURN payload",
221+
))
222+
}
223+
};
224+
225+
match instructions.next() {
226+
None => Ok(Payload {
227+
data: payload,
228+
output_type: pb::BtcOutputType::OpReturn,
229+
}),
230+
Some(Ok(_)) => Err(PayloadError::InvalidOpReturn(
231+
"only one data push supported after OP_RETURN",
232+
)),
233+
Some(Err(_)) => Err(PayloadError::InvalidOpReturn(
234+
"failed to parse OP_RETURN payload",
235+
)),
236+
}
193237
} else {
194238
Err(PayloadError::Unrecognized)
195239
}
@@ -206,8 +250,7 @@ impl TryFrom<&bitcoin::TxOut> for TxExternalOutput {
206250
type Error = PsbtError;
207251
fn try_from(value: &bitcoin::TxOut) -> Result<Self, Self::Error> {
208252
Ok(TxExternalOutput {
209-
payload: Payload::from_pkscript(value.script_pubkey.as_bytes())
210-
.map_err(|_| PsbtError::UnknownOutputType)?,
253+
payload: Payload::from_pkscript(value.script_pubkey.as_bytes())?,
211254
value: value.value.to_sat(),
212255
})
213256
}
@@ -251,6 +294,18 @@ pub enum PsbtError {
251294
#[error("Unrecognized/unsupported output type.")]
252295
#[cfg_attr(feature = "wasm", assoc(js_code = "unknown-output-type"))]
253296
UnknownOutputType,
297+
#[error("Invalid OP_RETURN script: {0}")]
298+
#[cfg_attr(feature = "wasm", assoc(js_code = "invalid-op-return"))]
299+
InvalidOpReturn(&'static str),
300+
}
301+
302+
impl From<PayloadError> for PsbtError {
303+
fn from(value: PayloadError) -> Self {
304+
match value {
305+
PayloadError::Unrecognized => PsbtError::UnknownOutputType,
306+
PayloadError::InvalidOpReturn(message) => PsbtError::InvalidOpReturn(message),
307+
}
308+
}
254309
}
255310

256311
enum OurKey {
@@ -753,6 +808,15 @@ impl<R: Runtime> PairedBitBox<R> {
753808
if transaction.script_configs.iter().any(is_taproot_simple) {
754809
self.validate_version(">=9.10.0")?; // taproot since 9.10.0
755810
}
811+
if transaction.outputs.iter().any(|output| {
812+
matches!(
813+
output,
814+
TxOutput::External(tx_output)
815+
if tx_output.payload.output_type == pb::BtcOutputType::OpReturn
816+
)
817+
}) {
818+
self.validate_version(">=9.24.0")?;
819+
}
756820

757821
let mut sigs: Vec<Vec<u8>> = Vec::new();
758822

@@ -1181,6 +1245,62 @@ mod tests {
11811245
output_type: pb::BtcOutputType::P2tr,
11821246
}
11831247
);
1248+
1249+
// OP_RETURN empty (OP_0)
1250+
let pkscript = hex::decode("6a00").unwrap();
1251+
assert_eq!(
1252+
Payload::from_pkscript(&pkscript).unwrap(),
1253+
Payload {
1254+
data: Vec::new(),
1255+
output_type: pb::BtcOutputType::OpReturn,
1256+
}
1257+
);
1258+
1259+
// OP_RETURN with data push
1260+
let pkscript = hex::decode("6a03aabbcc").unwrap();
1261+
assert_eq!(
1262+
Payload::from_pkscript(&pkscript).unwrap(),
1263+
Payload {
1264+
data: vec![0xaa, 0xbb, 0xcc],
1265+
output_type: pb::BtcOutputType::OpReturn,
1266+
}
1267+
);
1268+
1269+
// OP_RETURN with 80-byte payload (PUSHDATA1)
1270+
let mut pkscript = vec![opcodes::all::OP_RETURN.to_u8(), 0x4c, 0x50];
1271+
pkscript.extend(std::iter::repeat_n(0xaa, 80));
1272+
assert_eq!(
1273+
Payload::from_pkscript(&pkscript).unwrap(),
1274+
Payload {
1275+
data: vec![0xaa; 80],
1276+
output_type: pb::BtcOutputType::OpReturn,
1277+
}
1278+
);
1279+
1280+
// Invalid OP_RETURN scripts
1281+
let pkscript = hex::decode("6a").unwrap();
1282+
assert!(matches!(
1283+
Payload::from_pkscript(&pkscript),
1284+
Err(PayloadError::InvalidOpReturn(
1285+
"naked OP_RETURN is not supported"
1286+
))
1287+
));
1288+
1289+
let pkscript = hex::decode("6a6a").unwrap();
1290+
assert!(matches!(
1291+
Payload::from_pkscript(&pkscript),
1292+
Err(PayloadError::InvalidOpReturn(
1293+
"no data push found after OP_RETURN"
1294+
))
1295+
));
1296+
1297+
let pkscript = hex::decode("6a0000").unwrap();
1298+
assert!(matches!(
1299+
Payload::from_pkscript(&pkscript),
1300+
Err(PayloadError::InvalidOpReturn(
1301+
"only one data push supported after OP_RETURN"
1302+
))
1303+
));
11841304
}
11851305

11861306
// Test that a PSBT containing only p2wpkh inputs is converted correctly to a transaction to be

src/shiftcrypto.bitbox02.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,7 @@ pub enum BtcOutputType {
11901190
P2wpkh = 3,
11911191
P2wsh = 4,
11921192
P2tr = 5,
1193+
OpReturn = 6,
11931194
}
11941195
impl BtcOutputType {
11951196
/// String value of the enum field names used in the ProtoBuf definition.
@@ -1204,6 +1205,7 @@ impl BtcOutputType {
12041205
Self::P2wpkh => "P2WPKH",
12051206
Self::P2wsh => "P2WSH",
12061207
Self::P2tr => "P2TR",
1208+
Self::OpReturn => "OP_RETURN",
12071209
}
12081210
}
12091211
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -1215,6 +1217,7 @@ impl BtcOutputType {
12151217
"P2WPKH" => Some(Self::P2wpkh),
12161218
"P2WSH" => Some(Self::P2wsh),
12171219
"P2TR" => Some(Self::P2tr),
1220+
"OP_RETURN" => Some(Self::OpReturn),
12181221
_ => None,
12191222
}
12201223
}

tests/test_btc_psbt.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ use util::test_initialized_simulators;
1212
use bitbox_api::{btc::Xpub, pb};
1313

1414
use bitcoin::bip32::DerivationPath;
15+
use bitcoin::opcodes::all;
1516
use bitcoin::psbt::Psbt;
1617
use bitcoin::secp256k1;
1718
use bitcoin::{
18-
transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness,
19+
blockdata::script::Builder, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction,
20+
TxIn, TxOut, Witness,
1921
};
2022
use miniscript::psbt::PsbtExt;
2123

@@ -338,6 +340,109 @@ async fn test_btc_psbt_mixed_spend() {
338340
.await
339341
}
340342

343+
#[tokio::test]
344+
async fn test_btc_psbt_op_return() {
345+
test_initialized_simulators(async |bitbox| {
346+
if !semver::VersionReq::parse(">=9.24.0")
347+
.unwrap()
348+
.matches(bitbox.version())
349+
{
350+
// OP_RETURN outputs are supported from firmware v9.24.0.
351+
return;
352+
}
353+
354+
let secp = secp256k1::Secp256k1::new();
355+
let fingerprint = util::simulator_xprv().fingerprint(&secp);
356+
357+
let input_path: DerivationPath = "m/84'/1'/0'/0/5".parse().unwrap();
358+
let change_path: DerivationPath = "m/84'/1'/0'/1/0".parse().unwrap();
359+
360+
let input_pub = util::simulator_xpub_at(&secp, &input_path).to_pub();
361+
let change_pub = util::simulator_xpub_at(&secp, &change_path).to_pub();
362+
363+
let prev_tx = Transaction {
364+
version: transaction::Version::TWO,
365+
lock_time: bitcoin::absolute::LockTime::ZERO,
366+
input: vec![TxIn {
367+
previous_output:
368+
"3131313131313131313131313131313131313131313131313131313131313131:0"
369+
.parse()
370+
.unwrap(),
371+
script_sig: ScriptBuf::new(),
372+
sequence: Sequence(0xFFFFFFFF),
373+
witness: Witness::default(),
374+
}],
375+
output: vec![TxOut {
376+
value: Amount::from_sat(50_000_000),
377+
script_pubkey: ScriptBuf::new_p2wpkh(&input_pub.wpubkey_hash()),
378+
}],
379+
};
380+
381+
let op_return_data = b"hello world";
382+
let op_return_script = Builder::new()
383+
.push_opcode(all::OP_RETURN)
384+
.push_slice(op_return_data)
385+
.into_script();
386+
387+
let tx = Transaction {
388+
version: transaction::Version::TWO,
389+
lock_time: bitcoin::absolute::LockTime::ZERO,
390+
input: vec![TxIn {
391+
previous_output: OutPoint {
392+
txid: prev_tx.compute_txid(),
393+
vout: 0,
394+
},
395+
script_sig: ScriptBuf::new(),
396+
sequence: Sequence(0xFFFFFFFF),
397+
witness: Witness::default(),
398+
}],
399+
output: vec![
400+
TxOut {
401+
value: Amount::from_sat(49_000_000),
402+
script_pubkey: ScriptBuf::new_p2wpkh(&change_pub.wpubkey_hash()),
403+
},
404+
TxOut {
405+
value: Amount::from_sat(0),
406+
script_pubkey: op_return_script.clone(),
407+
},
408+
],
409+
};
410+
411+
let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
412+
413+
psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
414+
psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
415+
psbt.inputs[0]
416+
.bip32_derivation
417+
.insert(input_pub.0, (fingerprint, input_path.clone()));
418+
419+
psbt.outputs[0]
420+
.bip32_derivation
421+
.insert(change_pub.0, (fingerprint, change_path.clone()));
422+
423+
bitbox
424+
.btc_sign_psbt(
425+
pb::BtcCoin::Tbtc,
426+
&mut psbt,
427+
None,
428+
pb::btc_sign_init_request::FormatUnit::Default,
429+
)
430+
.await
431+
.unwrap();
432+
433+
psbt.finalize_mut(&secp).unwrap();
434+
435+
let final_tx = psbt.clone().extract_tx_unchecked_fee_rate();
436+
assert_eq!(final_tx.output.len(), 2);
437+
assert_eq!(final_tx.output[1].value, Amount::from_sat(0));
438+
assert_eq!(final_tx.output[1].script_pubkey, op_return_script);
439+
440+
// Verify the signed tx, including that all sigs/witnesses are correct.
441+
verify_transaction(psbt);
442+
})
443+
.await
444+
}
445+
341446
#[tokio::test]
342447
async fn test_btc_psbt_multisig_p2wsh() {
343448
test_initialized_simulators(async |bitbox| {

0 commit comments

Comments
 (0)