Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions urtypes/src/registry/hdkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,23 +284,34 @@ impl<'b, C> Decode<'b, C> for DerivedKeyRef<'b> {
}
}

// BCR-2020-007 (legacy) and BCR-2023-007 (current) tag numbers.
// Sparrow and other wallets built against the original spec emit
// the legacy 304/305 tags; accept both so imports work either way.
const TAGGED_COININFO: Tag = Tag::new(40305);
const TAGGED_COININFO_LEGACY: Tag = Tag::new(305);
const TAGGED_KEYPATH: Tag = Tag::new(40304);
const TAGGED_KEYPATH_LEGACY: Tag = Tag::new(304);

match d.u32()? {
2 => is_private = d.bool()?,
3 => key_data = Some(DecodeBytes::decode_bytes(d, ctx)?),
4 => chain_code = Some(DecodeBytes::decode_bytes(d, ctx)?),
5 => match d.tag()? {
TAGGED_COININFO => use_info = Some(CoinInfo::decode(d, ctx)?),
TAGGED_COININFO | TAGGED_COININFO_LEGACY => {
use_info = Some(CoinInfo::decode(d, ctx)?)
}
_ => return Err(Error::message("invalid tag for coininfo")),
},
6 => match d.tag()? {
TAGGED_KEYPATH => origin = Some(KeypathRef::decode(d, ctx)?),
TAGGED_KEYPATH | TAGGED_KEYPATH_LEGACY => {
origin = Some(KeypathRef::decode(d, ctx)?)
}
_ => return Err(Error::message("invalid tag for keypath")),
},
7 => match d.tag()? {
TAGGED_KEYPATH => children = Some(KeypathRef::decode(d, ctx)?),
TAGGED_KEYPATH | TAGGED_KEYPATH_LEGACY => {
children = Some(KeypathRef::decode(d, ctx)?)
}
_ => return Err(Error::message("invalid tag for keypath")),
},
8 => {
Expand Down
124 changes: 123 additions & 1 deletion urtypes/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use core::fmt::{Display, Formatter};

use minicbor::{bytes::ByteSlice, encode::Write, Encode, Encoder};

use crate::registry::{HDKeyRef, PassportRequest, PassportResponse};
use crate::registry::{HDKeyRef, PassportRequest, PassportResponse, Terminal, TerminalContext};

#[derive(Debug, PartialEq)]
pub enum Value<'a> {
Expand Down Expand Up @@ -67,6 +67,20 @@ impl<'a> Value<'a> {
Ok(value)
}

/// Whether `ur_type` is the legacy `crypto-output` UR type (BCR-2020-010).
///
/// This is the flavor still emitted by e.g. Sparrow: a recursive CBOR
/// tree rooted at one of the script-type tags (400..=410) that decodes
/// into [`Terminal`].
///
/// The newer BCR-2023-010 `output-descriptor` UR (CBOR tag 40308 map
/// wrapping a text descriptor) is NOT the same wire format and is not
/// accepted here; feeding its payload through [`decode_output_descriptor`]
/// would produce `InvalidCbor(invalid tag)`.
pub fn is_output_descriptor(ur_type: &str) -> bool {
matches!(ur_type, "crypto-output")
}

/// Return the type of this value as a string.
///
/// # Notes
Expand All @@ -87,6 +101,29 @@ impl<'a> Value<'a> {
}
}

/// Decode a legacy `crypto-output` UR payload (BCR-2020-010) into a [`Terminal`].
///
/// [`Terminal`] is a recursive data structure, so its sub-nodes are allocated
/// into the caller-provided [`TerminalContext`] arena. `N` must be large
/// enough to hold every nested `Terminal` node in the descriptor (e.g. a
/// `wsh(sortedmulti(...))` needs at least 2 slots).
///
/// Dispatches on the UR type string so callers can route from a generic
/// "got a UR" entry point. Only `crypto-output` is accepted; the newer
/// BCR-2023-010 `output-descriptor` (tag 40308) uses a different CBOR shape
/// and is not supported by this helper. See [`Value::is_output_descriptor`].
pub fn decode_output_descriptor<'a, 'b, const N: usize>(
ur_type: &str,
payload: &'b [u8],
arena: &'a TerminalContext<'a, 'b, N>,
) -> Result<Terminal<'a, 'b>, Error> {
if !Value::is_output_descriptor(ur_type) {
return Err(Error::UnsupportedResource);
}
let mut ctx: &'a TerminalContext<'a, 'b, N> = arena;
minicbor::decode_with::<_, Terminal>(payload, &mut ctx).map_err(Into::into)
}

impl<'a, C> Encode<C> for Value<'a> {
fn encode<W: Write>(
&self,
Expand Down Expand Up @@ -141,6 +178,91 @@ impl From<minicbor::decode::Error> for Error {
mod tests {
use super::*;

use crate::registry::{ECKey, Key, Multikey, Terminal, TerminalContext};
use foundation_arena::boxed::Box as ArenaBox;

#[test]
fn test_is_output_descriptor() {
assert!(Value::is_output_descriptor("crypto-output"));
// `output-descriptor` is a distinct BCR-2023-010 UR (tag 40308 map,
// not the tag-400..410 tree) and must NOT be accepted by this helper
// — the reviewer on PR #54 reproduced `InvalidCbor(invalid tag)`
// when a spec-compliant `output-descriptor` payload was routed here.
assert!(!Value::is_output_descriptor("output-descriptor"));
// Bare `output` is not a registered UR type at all.
assert!(!Value::is_output_descriptor("output"));
assert!(!Value::is_output_descriptor("hdkey"));
assert!(!Value::is_output_descriptor("crypto-hdkey"));
assert!(!Value::is_output_descriptor(""));
}

fn sample_wsh_sortedmulti_cbor() -> alloc::vec::Vec<u8> {
// wsh(sorted_multi(2 of 2)) — the flavor Sparrow emits for BIP48
// P2WSH multisigs. Same keys as `output_descriptor::test_example_3`.
let a: TerminalContext<8> = TerminalContext::new();
let key1 = Key::ECKey(ECKey {
curve: ECKey::SECP256K1,
is_private: false,
data: &[
0x02, 0x2f, 0x01, 0xe5, 0xe1, 0x5c, 0xca, 0x35, 0x1d, 0xaf, 0xf3, 0x84, 0x3f, 0xb7,
0x0f, 0x3c, 0x2f, 0x0a, 0x1b, 0xdd, 0x05, 0xe5, 0xaf, 0x88, 0x8a, 0x67, 0x78, 0x4e,
0xf3, 0xe1, 0x0a, 0x2a, 0x01,
],
});
let key2 = Key::ECKey(ECKey {
curve: ECKey::SECP256K1,
is_private: false,
data: &[
0x03, 0xac, 0xd4, 0x84, 0xe2, 0xf0, 0xc7, 0xf6, 0x53, 0x09, 0xad, 0x17, 0x8a, 0x9f,
0x55, 0x9a, 0xbd, 0xe0, 0x97, 0x96, 0x97, 0x4c, 0x57, 0xe7, 0x14, 0xc3, 0x5f, 0x11,
0x0d, 0xfc, 0x27, 0xcc, 0xbe,
],
});
let keys: &[Key] = &[key1, key2];
let sortedmulti = ArenaBox::new_in(
Terminal::SortedMultisig(Multikey {
threshold: 2,
keys: keys.into(),
}),
&a,
)
.unwrap();
let descriptor = Terminal::WitnessScriptHash(sortedmulti);
minicbor::to_vec(&descriptor).unwrap()
}

#[test]
fn test_decode_output_descriptor_crypto_output_alias() {
let cbor = sample_wsh_sortedmulti_cbor();
let arena: TerminalContext<8> = TerminalContext::new();
let decoded = decode_output_descriptor("crypto-output", &cbor, &arena).unwrap();
assert!(matches!(decoded, Terminal::WitnessScriptHash(_)));
}

#[test]
fn test_decode_output_descriptor_rejects_output_descriptor_alias() {
// BCR-2023-010 `output-descriptor` uses a different CBOR shape (tag
// 40308 map), so this helper — which only understands the legacy
// BCR-2020-010 tag-400..410 tree — must reject it at the type-string
// layer rather than handing the bytes to the wrong decoder.
let cbor = sample_wsh_sortedmulti_cbor();
let arena: TerminalContext<8> = TerminalContext::new();
match decode_output_descriptor("output-descriptor", &cbor, &arena) {
Err(Error::UnsupportedResource) => {}
other => panic!("expected UnsupportedResource, got {other:?}"),
};
}

#[test]
fn test_decode_output_descriptor_rejects_other_types() {
let cbor = sample_wsh_sortedmulti_cbor();
let arena: TerminalContext<8> = TerminalContext::new();
match decode_output_descriptor("bytes", &cbor, &arena) {
Err(Error::UnsupportedResource) => {}
other => panic!("expected UnsupportedResource, got {other:?}"),
};
}

#[test]
fn test_byte_string_bytes() {
const BYTES_PAYLOAD: &[u8] = &[
Expand Down
Loading