Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8c5e78b
clarity6: allow leading-underscore identifiers, gated at Clarity 6
jbencin-stacks May 27, 2026
73d0665
clarity6: bare `_` discard binding in `let` and `match`
jbencin-stacks May 27, 2026
96bea09
clarity6: end-to-end tests + changelog for underscore identifiers
jbencin-stacks May 27, 2026
03515ec
clarity6: collapse `_`-leading identifier arm into letter arm
jbencin-stacks May 27, 2026
ab2e348
Simplify changelog text
jbencin-stacks May 27, 2026
9e2c066
clarity6: fix `<_foo>` trait refs + expand `underscore_checker` tests
jbencin-stacks May 27, 2026
b7978ce
Add more tests
jbencin-stacks May 27, 2026
6a85e12
Add property test
jbencin-stacks May 27, 2026
11f867e
Some code and comment cleanup
jbencin-stacks May 27, 2026
dcd782d
Disallow `_` as name except in `let`/`match`
jbencin-stacks May 27, 2026
3d2c49a
Add `DISCARD_IDENTIFIER` constant
jbencin-stacks May 27, 2026
3fc5f06
Disallow bare `_` in traits and tuple keys, and add tests
jbencin-stacks May 27, 2026
5b851d0
Add test for nested `let` expressions using `_`
jbencin-stacks May 27, 2026
e2d2961
Replace "SIP-04x" -> "Clarity 6"
jbencin-stacks May 27, 2026
f732e54
Address PR feedback from Claude
jbencin-stacks May 27, 2026
a45bc01
Fix `handle_use_trait()`
jbencin-stacks May 27, 2026
fa0b46e
Fix CI
jbencin-stacks May 27, 2026
cdad1c4
Add `ClarityVersion::allows_leading_underscore()`
jbencin-stacks May 29, 2026
e3f1de9
clarity6: add ClarityNameV6 with codec + From/TryFrom conversions
jbencin-stacks Jun 9, 2026
28a20d8
clarity6: replace ClarityNameV6 with LegacyClarityName (narrow type)
jbencin-stacks Jun 9, 2026
750c868
clarity6: migrate TransactionContractCall.function_name to LegacyClar…
jbencin-stacks Jun 9, 2026
e9625b6
clarity6: migrate AssetInfo.asset_name to LegacyClarityName
jbencin-stacks Jun 9, 2026
db357cd
clarity6: gate `_`-prefixed tuple keys at transaction admission
jbencin-stacks Jun 9, 2026
658adcb
Merge branch `stacks/pox-wf-integration` into `feat/underscore-prefix`
jbencin-stacks Jun 10, 2026
f25a3ec
Address Claude's PR comments
jbencin-stacks Jun 10, 2026
271f49d
Drop `LegacyClarityName`, move `_`-rejection to admission walker
jbencin-stacks Jun 10, 2026
042b1f4
Minor comment fixes
jbencin-stacks Jun 10, 2026
41d482a
Run `cargo fmt-stacks` and add better error message if user tries to …
jbencin-stacks Jun 10, 2026
bf14795
Address Claude's PR comments
jbencin-stacks Jun 10, 2026
54ae861
Fix error message
jbencin-stacks Jun 11, 2026
db6992e
Check for `_`-prefixed tuple keys in `from_consensus_buff()`
jbencin-stacks Jun 12, 2026
f1691e6
Merge branch `stacks/pox-wf-integration` into `feat/underscore-prefix`
jbencin-stacks Jun 12, 2026
252bfe9
Address PR comment about deeply nested tuple keys
jbencin-stacks Jun 18, 2026
13ed408
Change error message in snapshot so tests pass
jbencin-stacks Jun 18, 2026
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/underscore-identifiers.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Clarity 6: Allow identifiers to begin with `_`, and allow bare `_` identifier in `let` and `match` bindings to discard value
11 changes: 10 additions & 1 deletion clarity-types/src/representations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pub const CONTRACT_MIN_NAME_LENGTH: usize = 1;
pub const CONTRACT_MAX_NAME_LENGTH: usize = 40;
pub const MAX_STRING_LEN: u8 = 128;

/// The bare `_` identifier — a discard pattern in `let`/`match` bindings
/// from Clarity 6 onwards, rejected as a name in every other position.
pub const DISCARD_IDENTIFIER: &str = "_";

lazy_static! {
pub static ref STANDARD_PRINCIPAL_REGEX_STRING: String =
"[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}".into();
Expand All @@ -48,8 +52,13 @@ lazy_static! {
"({})|({})",
*STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING
);
// `ClarityName` permits identifiers to begin with `_`, including the
// bare `_` discard binding (Clarity 6). Wire-level rejection of
// `_`-prefixed names for pre-Clarity-6 transactions happens at
// `StacksBlock::validate_transaction_static_epoch`, not at this codec
// layer — see that function for the consensus reasoning.
pub static ref CLARITY_NAME_REGEX_STRING: String =
"^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into();
"^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about changing this without a gate at this level. I think this could cause an accidental hard fork due to its use in wire-level validation. Like could a transaction with a leading _ somewhere get into a block that is accepted by updated nodes and rejected by unupdated nodes, before the epoch transition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've got a point here, I can try to tighten this up and make better use the type system to "make invalid states unrepresentable"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm planning to fix by leaving the current ClarityName alone and creating a new struct, ClarityNameV6, which allows the leading underscore. This should be the safest and most correct way to proceed, as we'll never be able to use a leading _ in the legacy code paths, but will also be a large diff

@brice-stacks Let me know if this sounds right to you

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that sounds good. Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up not doing this as the diff would have been huge. Added admission checks in StacksBlock::validate_transaction_static_epoch() instead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's still a potential divergence with runtime values. For example, a from-consensus-buff? where the buffer encodes a tuple with a key starting with an _, e.g.

(from-consensus-buff? {a: int} 0x0c00000002016100000000000000000000000000000001c8025f78000000000000000000000000000000007b)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f1691e6 by adding check in from_consensus_buff()

pub static ref CLARITY_NAME_REGEX: Regex =
{
Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap()
Expand Down
6 changes: 6 additions & 0 deletions clarity-types/src/tests/representations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ use crate::stacks_common::codec::StacksMessageCodec;
#[case::slash("/")]
#[case::dash_only("-")]
#[case::equals("=")]
#[case::leading_underscore("_admin")]
#[case::leading_underscore_with_operators("_check!?")]
#[case::leading_underscore_with_digits("_var123")]
#[case::bare_underscore("_")]
#[case::double_underscore("__")]
#[case::underscore_then_dash("_-")]
fn test_clarity_name_valid(#[case] name: &str) {
let clarity_name = ClarityName::try_from(name.to_string())
.unwrap_or_else(|_| panic!("Should parse valid clarity name: {name}"));
Expand Down
133 changes: 133 additions & 0 deletions clarity-types/src/tests/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,3 +902,136 @@ fn test_sequence_try_retain_internal_error() {
let err = seq.try_retain::<(), _>(utils::keep_all).unwrap_err();
assert!(matches!(err, RetainValuesError::Internal(_)));
}

// `Value::find_invalid_tuple_key` — the recursive walker that backs the
// transaction-admission epoch gate for `_`-prefixed tuple keys.

/// Build a one-key tuple Value with the given key name.
fn tuple_with_key(key: &str) -> Value {
Value::Tuple(
TupleData::from_data(vec![(
ClarityName::try_from(key.to_string()).unwrap(),
Value::Int(0),
)])
.unwrap(),
)
}

/// The bare `_` tuple key is invalid at *every* epoch — it's reserved
/// as the `let` / `match` discard marker and cannot be referenced.
#[rstest]
#[case::pre_clarity6(StacksEpochId::Epoch34)]
#[case::clarity6(StacksEpochId::Epoch40)]
fn test_find_invalid_tuple_key_rejects_bare_underscore(#[case] epoch: StacksEpochId) {
let value = tuple_with_key("_");
assert_eq!(value.find_invalid_tuple_key(epoch), Some("_"));
}

/// Pre-Clarity-6 epochs reject *every* leading-`_` tuple key — matches
/// the narrow wire codec on un-upgraded nodes so consensus is preserved
/// during the upgrade window.
#[rstest]
#[case::leading_underscore("_admin")]
#[case::underscore_with_operators("_check!?")]
#[case::underscore_with_digits("_var123")]
fn test_find_invalid_tuple_key_rejects_leading_underscore_pre_clarity6(#[case] key: &str) {
let value = tuple_with_key(key);
assert_eq!(
value.find_invalid_tuple_key(StacksEpochId::Epoch34),
Some(key),
);
}

/// Post-activation, `_foo`-shaped tuple keys are permitted.
#[rstest]
#[case::leading_underscore("_admin")]
#[case::underscore_with_operators("_check!?")]
#[case::underscore_with_digits("_var123")]
fn test_find_invalid_tuple_key_admits_leading_underscore_in_clarity6(#[case] key: &str) {
let value = tuple_with_key(key);
assert_eq!(value.find_invalid_tuple_key(StacksEpochId::Epoch40), None);
}

/// Plain identifiers are admissible at every epoch.
#[rstest]
#[case::plain("foo")]
#[case::with_dash("foo-bar")]
#[case::with_interior_underscore("foo_bar")]
fn test_find_invalid_tuple_key_admits_plain_keys(#[case] key: &str) {
let value = tuple_with_key(key);
assert!(
value
.find_invalid_tuple_key(StacksEpochId::Epoch34)
.is_none()
&& value
.find_invalid_tuple_key(StacksEpochId::Epoch40)
.is_none(),
"key {key:?} should be admissible at every epoch",
);
}

/// Leaf and tuple-free variants short-circuit to `None`.
#[test]
fn test_find_invalid_tuple_key_leaf_values() {
let epoch = StacksEpochId::Epoch34;
assert!(Value::Int(1).find_invalid_tuple_key(epoch).is_none());
assert!(Value::UInt(1).find_invalid_tuple_key(epoch).is_none());
assert!(Value::Bool(true).find_invalid_tuple_key(epoch).is_none());
assert!(Value::none().find_invalid_tuple_key(epoch).is_none());
assert!(
Value::buff_from(vec![1, 2, 3])
.unwrap()
.find_invalid_tuple_key(epoch)
.is_none()
);
}

/// The walker descends into `Optional::Some`, `Response`, `Sequence(List)`,
/// and nested `Tuple` so an invalid key buried inside is still surfaced.
#[test]
fn test_find_invalid_tuple_key_descends_into_compound_values() {
let epoch = StacksEpochId::Epoch34;

// tuple inside `(some ...)`
let some_value = Value::some(tuple_with_key("_buried")).unwrap();
assert_eq!(some_value.find_invalid_tuple_key(epoch), Some("_buried"));

// tuple inside `(ok ...)`
let ok_value = Value::okay(tuple_with_key("_buried")).unwrap();
assert_eq!(ok_value.find_invalid_tuple_key(epoch), Some("_buried"));

// tuple inside `(err ...)`
let err_value = Value::error(tuple_with_key("_buried")).unwrap();
assert_eq!(err_value.find_invalid_tuple_key(epoch), Some("_buried"));

// Tuple inside a list. Clarity lists are homogeneous, so the
// single-element form is sufficient: the walker's for-loop over
// `Sequence(List)` makes the second-element behavior identical to
// the first.
let list_value = Value::list_from(vec![tuple_with_key("_buried")]).unwrap();
assert_eq!(list_value.find_invalid_tuple_key(epoch), Some("_buried"));

// tuple nested inside a tuple — `_buried` is the inner key, but the
// walker reports the *first* offender it encounters and either inner
// or outer is fine; here only the inner has a `_` prefix.
let nested = Value::Tuple(
TupleData::from_data(vec![(
ClarityName::from_literal("outer"),
tuple_with_key("_buried"),
)])
.unwrap(),
);
assert_eq!(nested.find_invalid_tuple_key(epoch), Some("_buried"));
}

/// When the same value sits behind `(none)` (no payload), nothing is
/// reported — the walker doesn't peek inside `Optional::None`.
#[test]
fn test_find_invalid_tuple_key_optional_none_is_inert() {
let none_value = Value::none();
assert!(
none_value
.find_invalid_tuple_key(StacksEpochId::Epoch34)
.is_none()
);
}
59 changes: 58 additions & 1 deletion clarity-types/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub use self::signatures::{
TupleTypeSignature, TypeSignature,
};
use crate::errors::ClarityTypeError;
use crate::representations::{ClarityName, ContractName, SymbolicExpression};
use crate::representations::{ClarityName, ContractName, DISCARD_IDENTIFIER, SymbolicExpression};

/// Maximum size in bytes allowed for types.
pub const MAX_VALUE_SIZE: u32 = 1024 * 1024; // 1MB
Expand Down Expand Up @@ -970,6 +970,63 @@ impl PartialEq for TupleData {
pub const NONE: Value = Value::Optional(OptionalData { data: None });

impl Value {
/// Walk this value recursively and return the first tuple key that
/// would not be accepted at `epoch`. Bare `_` is invalid at every
/// epoch (reserved as the discard pattern); other leading-`_` keys
/// are invalid pre-Clarity-6.
///
/// NOTE: this is the consensus-critical companion to the value-
/// sanitization routine above the `Value` enum. Any new compound
/// `Value` variant — one that can carry other values — must be
/// handled here too, or `_`-prefixed keys could escape into a
/// transaction's wire payload.
pub fn find_invalid_tuple_key(&self, epoch: StacksEpochId) -> Option<&str> {
match self {
Value::Tuple(data) => {
for (key, value) in data.data_map.iter() {
if Self::tuple_key_invalid_for_epoch(key.as_str(), epoch) {
return Some(key.as_str());
}
if let Some(found) = value.find_invalid_tuple_key(epoch) {
return Some(found);
}
}
None
}
Value::Sequence(SequenceData::List(list)) => {
for item in &list.data {
if let Some(found) = item.find_invalid_tuple_key(epoch) {
return Some(found);
}
}
None
}
Value::Optional(OptionalData { data: Some(inner) }) => {
inner.find_invalid_tuple_key(epoch)
}
Value::Response(ResponseData { data, .. }) => data.find_invalid_tuple_key(epoch),
// Leaf / no-tuple variants.
Value::Int(_)
| Value::UInt(_)
| Value::Bool(_)
| Value::Principal(_)
| Value::CallableContract(_)
| Value::Sequence(SequenceData::Buffer(_))
| Value::Sequence(SequenceData::String(_))
| Value::Optional(OptionalData { data: None }) => None,
}
}

fn tuple_key_invalid_for_epoch(key: &str, epoch: StacksEpochId) -> bool {
if key == DISCARD_IDENTIFIER {
return true;
}
if epoch < StacksEpochId::Epoch40 && key.starts_with('_') {
return true;
}
false
}

pub fn some(data: Value) -> Result<Value, ClarityTypeError> {
if data.size()? + WRAPPER_VALUE_SIZE > MAX_VALUE_SIZE {
Err(ClarityTypeError::ValueTooLarge)
Expand Down
34 changes: 23 additions & 11 deletions clarity-types/src/types/serialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,14 @@ impl Value {
} else {
BOUND_VALUE_SERIALIZATION_BYTES as u64
};
let max_depth = if sanitize {
MAX_TYPE_DEPTH as usize
} else {
UNSANITIZED_DEPTH_CHECK
};
let mut bound_reader = BoundReader::from_reader(r, bound_value_serialization_bytes);
let value = Value::inner_deserialize_read(&mut bound_reader, expected_type, sanitize)?;
let value =
Value::inner_deserialize_read(&mut bound_reader, expected_type, sanitize, max_depth)?;
let bytes_read = bound_reader.num_read();
if let Some(expected_type) = expected_type {
let expect_size = match expected_type.max_serialized_size() {
Expand Down Expand Up @@ -569,6 +575,7 @@ impl Value {
r: &mut R,
top_expected_type: Option<&TypeSignature>,
sanitize: bool,
max_depth: usize,
) -> Result<Value, SerializationError> {
use super::Value::*;

Expand All @@ -577,12 +584,7 @@ impl Value {
}];

while !stack.is_empty() {
let depth_check = if sanitize {
MAX_TYPE_DEPTH as usize
} else {
UNSANITIZED_DEPTH_CHECK
};
if stack.len() > depth_check {
if stack.len() > max_depth {
return Err(ClarityTypeError::TypeSignatureTooDeep.into());
}

Expand Down Expand Up @@ -1164,13 +1166,23 @@ impl Value {

/// Try to deserialize a value without type information. This *does not* perform sanitization
/// so it should not be used when decoding clarity database values.
/// Public for testing purposes only.
pub(crate) fn try_deserialize_bytes_untyped(
bytes: &Vec<u8>,
) -> Result<Value, SerializationError> {
pub fn try_deserialize_bytes_untyped(bytes: &Vec<u8>) -> Result<Value, SerializationError> {
Value::deserialize_read(&mut bytes.as_slice(), None, false)
}

/// Untyped deserialize using the full `MAX_TYPE_DEPTH` cap rather than the
/// legacy pre-2.4 depth. Pair with a typed sanitizing pass to inspect keys
/// the typed pass would otherwise elide — the depths must match, or values
/// nested beyond the legacy cap escape pre-checks.
pub fn try_deserialize_bytes_untyped_full_depth(
bytes: &[u8],
) -> Result<Value, SerializationError> {
let mut reader = bytes;
let mut bound_reader =
BoundReader::from_reader(&mut reader, BOUND_VALUE_SERIALIZATION_BYTES as u64);
Value::inner_deserialize_read(&mut bound_reader, None, false, MAX_TYPE_DEPTH as usize)
}

/// Try to deserialize a value from a hex string without type information. This *does not*
/// perform sanitization.
pub fn try_deserialize_hex_untyped(hex: &str) -> Result<Value, SerializationError> {
Expand Down
9 changes: 9 additions & 0 deletions clarity-types/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ impl ClarityVersion {
pub fn supports_variadic_concat(&self) -> bool {
self >= &ClarityVersion::Clarity6
}

/// Beginning in Clarity 6, identifiers may begin with `_`:
/// 1. Any identifier can start with `_` (e.g. `_foo`, `_admin`).
/// 2. A bare `_` can be used in `let` / `match` expressions to
/// discard the result of an expression. The expression is
/// evaluated, but the result cannot be referenced.
pub fn allows_underscore_prefix(&self) -> bool {
self >= &ClarityVersion::Clarity6
}
}

impl FromStr for ClarityVersion {
Expand Down
Loading
Loading