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
1 change: 1 addition & 0 deletions changelog.d/clarity-value-deser-perf.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reduced Clarity `Value` deserialization cost: ~57% lower peak memory for untyped reads of large lists and 15-55% less CPU across deserialization paths.
33 changes: 33 additions & 0 deletions clarity-types/src/tests/types/serialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,36 @@ fn test_serialize_to_hex_returns_serialization_failure() {
err
);
}

/// Truncating after the first entry's value makes the unwind loop hit EOF
/// while reading the next key of a partially-filled tuple frame.
#[test]
fn test_tuple_truncated_at_next_key_errors() {
let tuple = Value::from(
TupleData::from_data(vec![
(
ClarityName::try_from("a".to_string()).unwrap(),
Value::Bool(true),
),
(
ClarityName::try_from("b".to_string()).unwrap(),
Value::Bool(false),
),
])
.unwrap(),
);
let mut bytes = vec![];
tuple.serialize_write(&mut bytes).unwrap();

// tuple prefix (1) + entry count (4) + key "a" (1-byte len + 1) +
// BoolTrue prefix (1) = 8 bytes; cut right before key "b".
bytes.truncate(8);

match Value::deserialize_read(&mut bytes.as_slice(), None, false) {
Ok(value) => panic!("accidentally parsed truncated tuple: {value}"),
Err(SerializationError::IOError(e)) => {
assert_eq!(e.err.kind(), std::io::ErrorKind::UnexpectedEof);
}
Err(other) => panic!("expected IOError(UnexpectedEof), got {other:?}"),
}
}
163 changes: 163 additions & 0 deletions clarity-types/src/tests/types/signatures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;

use rstest::rstest;
use stacks_common::types::StacksEpochId;

use crate::errors::ClarityTypeError;
Expand Down Expand Up @@ -1059,3 +1060,165 @@ fn test_least_supertype() {
);
}
}

fn trait_id(contract: &str, name: &str) -> TraitIdentifier {
TraitIdentifier {
name: ClarityName::try_from(name.to_string()).unwrap(),
contract_identifier: QualifiedContractIdentifier::local(contract).unwrap(),
}
}

fn callable_principal_subtype(contract: &str) -> CallableSubtype {
CallableSubtype::Principal(QualifiedContractIdentifier::local(contract).unwrap())
}

fn callable_trait_subtype(contract: &str, name: &str) -> CallableSubtype {
CallableSubtype::Trait(trait_id(contract, name))
}

fn list_union_type(members: Vec<CallableSubtype>) -> TypeSignature {
TypeSignature::ListUnionType(members.into_iter().collect())
}

fn buff_type(len: u32) -> TypeSignature {
TypeSignature::SequenceType(SequenceSubtype::BufferType(
BufferLength::try_from(len).unwrap(),
))
}

fn ascii_type(len: u32) -> TypeSignature {
TypeSignature::SequenceType(SequenceSubtype::StringType(StringSubtype::ASCII(
BufferLength::try_from(len).unwrap(),
)))
}

fn utf8_type(len: u32) -> TypeSignature {
TypeSignature::SequenceType(SequenceSubtype::StringType(StringSubtype::UTF8(
StringUTF8Length::try_from(len).unwrap(),
)))
}

/// The equal-type fast path in `parent_list_type_from_iter` is only sound if
/// `least_supertype_v2_1(T, T) == Ok(T)` for every `T`. Each case seeds one
/// leaf type; its nested compounds (~64 shapes) are generated in the body.
#[rstest]
#[case::no_type(TypeSignature::NoType)]
#[case::int(TypeSignature::IntType)]
#[case::uint(TypeSignature::UIntType)]
#[case::bool(TypeSignature::BoolType)]
#[case::principal(TypeSignature::PrincipalType)]
#[case::buffer_zero_len(buff_type(0))]
#[case::buffer(buff_type(17))]
#[case::string_ascii(ascii_type(5))]
#[case::string_utf8(utf8_type(3))]
#[case::callable_principal(TypeSignature::CallableType(callable_principal_subtype("contract-a")))]
#[case::callable_trait(TypeSignature::CallableType(callable_trait_subtype(
"contract-a",
"trait-a"
)))]
#[case::trait_reference(TypeSignature::TraitReferenceType(trait_id("contract-a", "trait-a")))]
#[case::list_union_single(list_union_type(vec![callable_principal_subtype("contract-a")]))]
#[case::list_union_mixed(list_union_type(vec![
callable_principal_subtype("contract-a"),
callable_principal_subtype("contract-b"),
callable_trait_subtype("contract-a", "trait-a"),
callable_trait_subtype("contract-b", "trait-b"),
]))]
#[case::empty_list(TypeSignature::from(TypeSignature::empty_list()))]
fn test_least_supertype_v2_1_idempotent(#[case] leaf: TypeSignature) {
use crate::types::TypeSignature::*;

let mut corpus = vec![leaf];
// two wrapping rounds cover nested compounds
for _ in 0..2 {
let snapshot = corpus.clone();
for t in snapshot {
corpus.push(OptionalType(Box::new(t.clone())));
corpus.push(ResponseType(Box::new((t.clone(), NoType))));
corpus.push(ResponseType(Box::new((NoType, t.clone()))));
corpus.push(ResponseType(Box::new((t.clone(), t.clone()))));
corpus.push(TypeSignature::list_of(t.clone(), 0).unwrap());
corpus.push(TypeSignature::list_of(t.clone(), 4).unwrap());
corpus.push(
TupleTypeSignature::try_from(vec![(
ClarityName::try_from("field-a".to_string()).unwrap(),
t,
)])
.unwrap()
.into(),
);
}
}

for t in &corpus {
assert_eq!(
TypeSignature::least_supertype_v2_1(t, t).as_ref(),
Ok(t),
"least_supertype_v2_1 must be idempotent for {t:?}"
);
}
}

fn buff_value(len: usize) -> Value {
Value::buff_from(vec![0; len]).unwrap()
}

fn ascii_value(s: &str) -> Value {
Value::string_ascii_from_bytes(s.as_bytes().to_vec()).unwrap()
}

fn callable_value(contract: &str) -> Value {
Value::CallableContract(CallableData {
contract_identifier: QualifiedContractIdentifier::local(contract).unwrap(),
trait_identifier: None,
})
}

fn tuple_value(v: Value) -> Value {
Value::from(
TupleData::from_data(vec![(
ClarityName::try_from("field-a".to_string()).unwrap(),
v,
)])
.unwrap(),
)
}

/// `construct_parent_list_type` and `parent_list_type` must stay equivalent
/// for values satisfying constructor invariants, including identical errors.
#[rstest]
#[case::empty(vec![])]
#[case::homogeneous_bools(vec![Value::Bool(true), Value::Bool(false), Value::Bool(true)])]
#[case::homogeneous_ints(vec![Value::Int(1), Value::Int(2)])]
#[case::int_uint_mismatch(vec![Value::Int(1), Value::UInt(2)])]
#[case::bool_int_mismatch(vec![Value::Bool(true), Value::Int(1)])]
#[case::buffers_different_lengths(vec![buff_value(3), buff_value(5)])]
#[case::ascii_different_lengths(vec![ascii_value("abc"), ascii_value("defgh")])]
#[case::identical_callables(vec![callable_value("contract-a"), callable_value("contract-a")])]
#[case::distinct_callables(vec![callable_value("contract-a"), callable_value("contract-b")])]
#[case::callable_with_standard_principal(vec![
callable_value("contract-a"),
Value::from(StandardPrincipalData::transient()),
])]
#[case::optional_none_and_some(vec![Value::none(), Value::some(Value::Int(5)).unwrap()])]
#[case::response_ok_and_err(vec![
Value::okay(Value::Int(1)).unwrap(),
Value::error(Value::Bool(false)).unwrap(),
])]
#[case::empty_and_nonempty_lists(vec![
Value::cons_list_unsanitized(vec![]).unwrap(),
Value::cons_list_unsanitized(vec![Value::Int(3)]).unwrap(),
])]
#[case::tuple_field_supertype(vec![tuple_value(buff_value(3)), tuple_value(buff_value(5))])]
fn test_construct_parent_list_type_matches_parent_list_type(#[case] values: Vec<Value>) {
let streamed = TypeSignature::construct_parent_list_type(&values);
let children: Vec<_> = values
.iter()
.map(|v| TypeSignature::type_of(v).unwrap())
.collect();
let collected = TypeSignature::parent_list_type(&children);
assert_eq!(
streamed, collected,
"construct_parent_list_type diverged from parent_list_type for {values:?}"
);
}
Loading
Loading