Skip to content

Commit 906a560

Browse files
committed
bitcoin: add support for OP_RETURN outputs
We enforce 0 value on them, so the confirmation screen is not a the usual recipient component, but can be a full screen confirmation not showing the amount. We use verify_message flow as it handles both ascii/binary. We also only support OP_RETURN outputs with one data push, though an OP_RETURN output could contain multiple data pushes. This restriction is for simplicity and because we don't know of a use case. In the future, support for this can be added if needed.
1 parent d676035 commit 906a560

File tree

10 files changed

+221
-21
lines changed

10 files changed

+221
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
1414
- simulator: simulate a Nova device
1515
- Add API call to fetch multiple xpubs at once
1616
- Add the option for the simulator to write its memory to file.
17+
- Bitcoin: add support for OP_RETURN outputs
1718

1819
### 9.23.1
1920
- EVM: add HyperEVM (HYPE) and SONIC (S) to known networks

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 {

py/bitbox02/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# 7.1.0
66
- Add `btc_xpubs()` to fetch multiple xpubs at once
7+
- Bitcoin: add support for OP_RETURN outputs
78

89
# 7.0.0
910
- get_info: add optional device initialized boolean to returned tuple

py/bitbox02/bitbox02/bitbox02/bitbox02.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,16 @@ def btc_sign(
467467
# Attaching output info supported since v9.22.0.
468468
self._require_atleast(semver.VersionInfo(9, 22, 0))
469469

470+
if any(
471+
map(
472+
lambda output: isinstance(output, BTCOutputExternal)
473+
and output.type == btc.BTCOutputType.OP_RETURN,
474+
outputs,
475+
)
476+
):
477+
# OP_RETURN supported sice v9.24.0
478+
self._require_atleast(semver.VersionInfo(9, 24, 0))
479+
470480
supports_antiklepto = self.version >= semver.VersionInfo(9, 4, 0)
471481

472482
sigs: List[Tuple[int, bytes]] = []

py/bitbox02/bitbox02/communication/generated/btc_pb2.py

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

py/bitbox02/bitbox02/communication/generated/btc_pb2.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class _BTCOutputTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
7070
P2WPKH: _BTCOutputType.ValueType # 3
7171
P2WSH: _BTCOutputType.ValueType # 4
7272
P2TR: _BTCOutputType.ValueType # 5
73+
OP_RETURN: _BTCOutputType.ValueType # 6
7374

7475
class BTCOutputType(_BTCOutputType, metaclass=_BTCOutputTypeEnumTypeWrapper): ...
7576

@@ -79,6 +80,7 @@ P2SH: BTCOutputType.ValueType # 2
7980
P2WPKH: BTCOutputType.ValueType # 3
8081
P2WSH: BTCOutputType.ValueType # 4
8182
P2TR: BTCOutputType.ValueType # 5
83+
OP_RETURN: BTCOutputType.ValueType # 6
8284
global___BTCOutputType = BTCOutputType
8385

8486
@typing.final

py/send_message.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,43 @@ def _sign_btc_policy(self) -> None:
788788
for input_index, sig in sigs:
789789
print("Signature for input {}: {}".format(input_index, sig.hex()))
790790

791+
def _sign_btc_op_return(
792+
self,
793+
format_unit: "bitbox02.btc.BTCSignInitRequest.FormatUnit.V" = bitbox02.btc.BTCSignInitRequest.FormatUnit.DEFAULT,
794+
) -> None:
795+
# pylint: disable=no-member
796+
bip44_account: int = 0 + HARDENED
797+
inputs, outputs = _btc_demo_inputs_outputs(bip44_account)
798+
outputs.append(
799+
bitbox02.BTCOutputExternal(
800+
output_type=bitbox02.btc.OP_RETURN,
801+
output_payload=b"hello world",
802+
value=0,
803+
)
804+
)
805+
sigs = self._device.btc_sign(
806+
bitbox02.btc.BTC,
807+
[
808+
bitbox02.btc.BTCScriptConfigWithKeypath(
809+
script_config=bitbox02.btc.BTCScriptConfig(
810+
simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH
811+
),
812+
keypath=[84 + HARDENED, 0 + HARDENED, bip44_account],
813+
),
814+
bitbox02.btc.BTCScriptConfigWithKeypath(
815+
script_config=bitbox02.btc.BTCScriptConfig(
816+
simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH_P2SH
817+
),
818+
keypath=[49 + HARDENED, 0 + HARDENED, bip44_account],
819+
),
820+
],
821+
inputs=inputs,
822+
outputs=outputs,
823+
format_unit=format_unit,
824+
)
825+
for input_index, sig in sigs:
826+
print("Signature for input {}: {}".format(input_index, sig.hex()))
827+
791828
def _sign_btc_tx_from_raw(self) -> None:
792829
"""
793830
Experiment with testnet transactions.
@@ -896,6 +933,7 @@ def _sign_btc_tx(self) -> None:
896933
("Taproot inputs", self._sign_btc_taproot_inputs),
897934
("Taproot output", self._sign_btc_taproot_output),
898935
("Policy", self._sign_btc_policy),
936+
("OP_RETURN", self._sign_btc_op_return),
899937
("From testnet tx ID", self._sign_btc_tx_from_raw),
900938
)
901939
choice = ask_user(choices)

src/rust/bitbox02-rust/src/hww/api/bitcoin/common.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ impl Payload {
211211
}
212212
}
213213

214-
/// Converts a payload to an address.
214+
/// Converts a payload to an address. Returns an error for invalid input or if an address does
215+
/// not exist for the output type (e.g. OP_RETURN).
215216
pub fn address(&self, params: &Params) -> Result<String, ()> {
216217
let payload = self.data.as_slice();
217218
match self.output_type {
@@ -252,6 +253,7 @@ impl Payload {
252253
}
253254
encode_segwit_addr(params.bech32_hrp, 1, payload)
254255
}
256+
BtcOutputType::OpReturn => Err(()),
255257
}
256258
}
257259

@@ -289,6 +291,11 @@ impl Payload {
289291
);
290292
ScriptBuf::new_p2tr_tweaked(tweaked)
291293
}
294+
BtcOutputType::OpReturn => {
295+
let pushbytes: &bitcoin::script::PushBytes =
296+
payload.try_into().map_err(|_| Error::InvalidInput)?;
297+
ScriptBuf::new_op_return(pushbytes)
298+
}
292299
};
293300
Ok(script.into_bytes())
294301
}
@@ -657,6 +664,22 @@ mod tests {
657664
output_type: BtcOutputType::P2tr,
658665
expected_pkscript: "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c",
659666
},
667+
Test {
668+
payload: "aabbcc",
669+
output_type: BtcOutputType::OpReturn,
670+
expected_pkscript: "6a03aabbcc",
671+
},
672+
Test {
673+
payload: "",
674+
output_type: BtcOutputType::OpReturn,
675+
expected_pkscript: "6a00",
676+
},
677+
Test {
678+
// 80 byte payload
679+
payload: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
680+
output_type: BtcOutputType::OpReturn,
681+
expected_pkscript: "6a4c50aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
682+
},
660683
];
661684

662685
for test in tests {
@@ -667,15 +690,17 @@ mod tests {
667690
};
668691
assert_eq!(
669692
hex::encode(payload.pk_script(params).unwrap()),
670-
test.expected_pkscript
693+
test.expected_pkscript,
671694
);
672695

673-
// Payload of wrong size
674-
let payload = Payload {
675-
data: hex::decode(&test.payload[2..]).unwrap(),
676-
output_type: test.output_type,
677-
};
678-
assert_eq!(payload.pk_script(params), Err(Error::Generic));
696+
// Payload of wrong size. Does not apply to OpReturn, almost any size is accepted.
697+
if test.output_type != BtcOutputType::OpReturn {
698+
let payload = Payload {
699+
data: hex::decode(&test.payload[2..]).unwrap(),
700+
output_type: test.output_type,
701+
};
702+
assert_eq!(payload.pk_script(params), Err(Error::Generic));
703+
}
679704
}
680705
}
681706
}

src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022-2024 Shift Crypto AG
1+
// Copyright 2022-2025 Shift Crypto AG
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -853,12 +853,18 @@ async fn _process(
853853
};
854854
}
855855

856-
if tx_output.value == 0 {
856+
let output_type = pb::BtcOutputType::try_from(tx_output.r#type)?;
857+
858+
// We don't allow regular outputs to have 0 value.
859+
// OP_RETURN outputs however we require to have 0 value.
860+
if output_type == pb::BtcOutputType::OpReturn {
861+
if tx_output.value != 0 {
862+
return Err(Error::InvalidInput);
863+
}
864+
} else if tx_output.value == 0 {
857865
return Err(Error::InvalidInput);
858866
}
859867

860-
let output_type = pb::BtcOutputType::try_from(tx_output.r#type)?;
861-
862868
// Get payload. If the output is marked ours, we compute the payload from the keystore,
863869
// otherwise it is provided in tx_output.payload.
864870
let payload: common::Payload = if tx_output.ours {
@@ -949,16 +955,21 @@ async fn _process(
949955
if !is_change {
950956
// Verify output if it is not a change output.
951957
// Assemble address to display, get user confirmation.
952-
let address = if let Some(sp) = tx_output.silent_payment.as_ref() {
953-
sp.address.clone()
954-
} else {
955-
payload.address(coin_params)?
958+
let address = || -> Result<String, Error> {
959+
if let Some(sp) = tx_output.silent_payment.as_ref() {
960+
Ok(sp.address.clone())
961+
} else {
962+
Ok(payload.address(coin_params)?)
963+
}
956964
};
957965

958966
if let Some(output_payment_request_index) = tx_output.payment_request_index {
959967
if output_payment_request_index != 0 {
960968
return Err(Error::InvalidInput);
961969
}
970+
if output_type == pb::BtcOutputType::OpReturn {
971+
return Err(Error::InvalidInput);
972+
}
962973
if payment_request_seen {
963974
return Err(Error::InvalidInput);
964975
}
@@ -970,7 +981,7 @@ async fn _process(
970981
coin_params,
971982
&payment_request,
972983
tx_output.value,
973-
&address,
984+
&address()?,
974985
)
975986
.is_err()
976987
{
@@ -979,6 +990,16 @@ async fn _process(
979990
}
980991

981992
payment_request_seen = true;
993+
} else if output_type == pb::BtcOutputType::OpReturn {
994+
// OP_RETURN value was validated to be 0 above, so we don't need to show the amount.
995+
crate::workflow::verify_message::verify(
996+
hal,
997+
"OP_RETURN",
998+
"OP_RETURN",
999+
&tx_output.payload,
1000+
false,
1001+
)
1002+
.await?;
9821003
} else {
9831004
// When sending coins back to the same account (non-change), or another account of
9841005
// the same keystore (change or non-change), we show a prefix to let the user know.
@@ -1001,9 +1022,9 @@ async fn _process(
10011022
hal.ui()
10021023
.verify_recipient(
10031024
&(if let Some(prefix) = prefix {
1004-
format!("{}: {}", prefix, address)
1025+
format!("{}: {}", prefix, address()?)
10051026
} else {
1006-
address
1027+
address()?
10071028
}),
10081029
&format_amount(coin_params, format_unit, tx_output.value)?,
10091030
)
@@ -3683,4 +3704,102 @@ mod tests {
36833704
]
36843705
);
36853706
}
3707+
3708+
#[test]
3709+
fn test_op_return() {
3710+
let transaction =
3711+
alloc::rc::Rc::new(core::cell::RefCell::new(Transaction::new(pb::BtcCoin::Btc)));
3712+
3713+
// Attach OP_RETURN output
3714+
{
3715+
let mut tx = transaction.borrow_mut();
3716+
tx.outputs.push(pb::BtcSignOutputRequest {
3717+
r#type: pb::BtcOutputType::OpReturn as _,
3718+
value: 0,
3719+
payload: b"hello world".to_vec(),
3720+
..Default::default()
3721+
});
3722+
}
3723+
3724+
mock_host_responder(transaction.clone());
3725+
mock_unlocked();
3726+
bitbox02::random::fake_reset();
3727+
let init_request = transaction.borrow().init_request();
3728+
3729+
let mut mock_hal = TestingHal::new();
3730+
let result = block_on(process(&mut mock_hal, &init_request));
3731+
3732+
match result {
3733+
Ok(Response::BtcSignNext(next)) => {
3734+
assert!(next.has_signature);
3735+
assert_eq!(
3736+
hex::encode(next.signature),
3737+
"f49c71b89ec3510ebebae9aff9f967ad9bb6cc0c4cddbdf851f97e47e9922646622459e522b0751fa246e49a8e48417344a5384a9f68c1c85cd03804b35e1e1e",
3738+
);
3739+
}
3740+
_ => panic!("wrong result"),
3741+
}
3742+
3743+
assert!(mock_hal.ui.contains_confirm("OP_RETURN", "hello world"));
3744+
}
3745+
3746+
#[test]
3747+
fn test_op_return_nonascii() {
3748+
let transaction =
3749+
alloc::rc::Rc::new(core::cell::RefCell::new(Transaction::new(pb::BtcCoin::Btc)));
3750+
3751+
// Attach OP_RETURN output
3752+
{
3753+
let mut tx = transaction.borrow_mut();
3754+
tx.outputs.push(pb::BtcSignOutputRequest {
3755+
r#type: pb::BtcOutputType::OpReturn as _,
3756+
value: 0,
3757+
payload: vec![1, 2, 3, 4, 5],
3758+
..Default::default()
3759+
});
3760+
}
3761+
3762+
mock_host_responder(transaction.clone());
3763+
mock_unlocked();
3764+
bitbox02::random::fake_reset();
3765+
let init_request = transaction.borrow().init_request();
3766+
3767+
let mut mock_hal = TestingHal::new();
3768+
let result = block_on(process(&mut mock_hal, &init_request));
3769+
assert!(result.is_ok());
3770+
3771+
assert!(
3772+
mock_hal
3773+
.ui
3774+
.contains_confirm("OP_RETURN\ndata (hex)", "0102030405")
3775+
);
3776+
}
3777+
3778+
#[test]
3779+
fn test_op_return_fail_nonzero_value() {
3780+
let transaction =
3781+
alloc::rc::Rc::new(core::cell::RefCell::new(Transaction::new(pb::BtcCoin::Btc)));
3782+
3783+
// Attach OP_RETURN output
3784+
{
3785+
let mut tx = transaction.borrow_mut();
3786+
tx.outputs.push(pb::BtcSignOutputRequest {
3787+
r#type: pb::BtcOutputType::OpReturn as _,
3788+
value: 100,
3789+
payload: b"hello world".to_vec(),
3790+
..Default::default()
3791+
});
3792+
}
3793+
3794+
mock_host_responder(transaction.clone());
3795+
mock_unlocked();
3796+
bitbox02::random::fake_reset();
3797+
let init_request = transaction.borrow().init_request();
3798+
3799+
let mut mock_hal = TestingHal::new();
3800+
assert_eq!(
3801+
block_on(process(&mut mock_hal, &init_request)),
3802+
Err(Error::InvalidInput)
3803+
);
3804+
}
36863805
}

0 commit comments

Comments
 (0)