From 8c5e78b1a192bbc90e49e8e1c746fae96a19cb66 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 10:54:26 -0400 Subject: [PATCH 01/32] clarity6: allow leading-underscore identifiers, gated at Clarity 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIP-04x relaxes the `ClarityName` rules so that identifiers may begin with `_` (and the bare `_` is a valid identifier). The relaxation is wire-level unconditional but the language-level rule is gated at `ClarityVersion::Clarity6` via a new AST pass. * `clarity-types/src/representations.rs` — add a fourth alternation arm to `CLARITY_NAME_REGEX_STRING` for `^_([a-zA-Z0-9]|[-_!?+<>=/*])*$`. Tests in `clarity-types/src/tests/representations.rs` and the matching proptests in `clarity/src/vm/tests/representations.rs` are extended for the new arm and the bare-`_` case. * `clarity/src/vm/ast/parser/v2/lexer/mod.rs` — accept `_` as an identifier first-character. * `clarity/src/vm/ast/underscore_checker.rs` (new) — `BuildASTPass` that walks `pre_expressions` and rejects atoms / trait references / field identifiers beginning with `_` when `ClarityVersion < Clarity6`. Wired into `inner_build_ast` between the depth checks and `ExpressionIdentifier`. * `clarity/src/vm/ast/errors.rs` — new `UnderscoreIdentifierNotAllowed(String)` variant with a precise diagnostic. * `stackslib/src/chainstate/tests/parse_tests.rs` — exhaustive `variant_coverage_report` arm for the new variant, marked `Ignored` because it is covered by the clarity-side unit tests rather than by a consensus snapshot. --- clarity-types/src/representations.rs | 9 +- clarity-types/src/tests/representations.rs | 6 + clarity/src/vm/ast/errors.rs | 7 + clarity/src/vm/ast/mod.rs | 14 ++ clarity/src/vm/ast/parser/v2/lexer/mod.rs | 5 +- clarity/src/vm/ast/underscore_checker.rs | 184 ++++++++++++++++++ clarity/src/vm/tests/representations.rs | 30 ++- stackslib/src/chainstate/tests/parse_tests.rs | 1 + 8 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 clarity/src/vm/ast/underscore_checker.rs diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index 5dbb5a4b867..cdcf2589afb 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -48,8 +48,15 @@ lazy_static! { "({})|({})", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING ); + // Four alternation arms: + // 1) `[a-zA-Z]...` — identifier starting with a letter (the historical form). + // 2) `_...` — identifier starting with `_`, including the bare `_` + // (Clarity 6 SIP-04x; codec/lexer accept always, but + // pre-Clarity-6 ASTs reject these at the parser pass). + // 3) `[-+=/*]` — single-char operator name. + // 4) `[<>]=?` — comparison operator name. pub static ref CLARITY_NAME_REGEX_STRING: String = - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); + "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); pub static ref CLARITY_NAME_REGEX: Regex = { Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap() diff --git a/clarity-types/src/tests/representations.rs b/clarity-types/src/tests/representations.rs index 4494034c8a7..f589093f277 100644 --- a/clarity-types/src/tests/representations.rs +++ b/clarity-types/src/tests/representations.rs @@ -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}")); diff --git a/clarity/src/vm/ast/errors.rs b/clarity/src/vm/ast/errors.rs index ab93d0f6ddf..064c2dfe9ee 100644 --- a/clarity/src/vm/ast/errors.rs +++ b/clarity/src/vm/ast/errors.rs @@ -176,6 +176,10 @@ pub enum ParseErrorKind { /// Contract name contains invalid characters or violates naming rules. /// The `String` represents the invalid contract name. IllegalContractName(String), + /// Identifier starts with an underscore in a Clarity version that predates + /// `ClarityVersion::Clarity6` (SIP-04x), where leading-`_` names are not yet + /// permitted. The `String` is the offending name. + UnderscoreIdentifierNotAllowed(String), // Notes /// Indicates a token mismatch for internal parser diagnostics. @@ -403,6 +407,9 @@ impl DiagnosableError for ParseErrorKind { } ParseErrorKind::TupleValueExpected => "expected value expression for tuple".into(), ParseErrorKind::IllegalClarityName(name) => format!("illegal clarity name, '{name}'"), + ParseErrorKind::UnderscoreIdentifierNotAllowed(name) => { + format!("identifier '{name}' starts with '_', which requires Clarity 6 or later") + } ParseErrorKind::IllegalASCIIString(s) => format!("illegal ascii string \"{s}\""), ParseErrorKind::ExpectedWhitespace => "expected whitespace before expression".into(), ParseErrorKind::NoteToMatchThis(token) => format!("to match this '{token}'"), diff --git a/clarity/src/vm/ast/mod.rs b/clarity/src/vm/ast/mod.rs index 561df22e8ad..2f991727349 100644 --- a/clarity/src/vm/ast/mod.rs +++ b/clarity/src/vm/ast/mod.rs @@ -23,6 +23,7 @@ pub mod errors; pub mod stack_depth_checker; pub mod sugar_expander; pub mod types; +pub mod underscore_checker; use stacks_common::types::StacksEpochId; use self::definition_sorter::DefinitionSorter; @@ -36,6 +37,7 @@ use self::sugar_expander::SugarExpander; use self::traits_resolver::TraitsResolver; use self::types::BuildASTPass; pub use self::types::ContractAST; +use self::underscore_checker::UnderscoreIdentifierChecker; use crate::vm::ClarityVersion; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{CostTracker, runtime_cost}; @@ -194,6 +196,18 @@ fn inner_build_ast( _ => (), } + // SIP-04x: reject identifiers beginning with `_` for `ClarityVersion < + // Clarity6`. The wire-level regex and the v2 lexer both accept them so + // that the parser can produce a precise diagnostic here. + match UnderscoreIdentifierChecker::run_pass(&mut contract_ast, clarity_version, epoch) { + Err(e) if error_early => return Err(e), + Err(e) => { + diagnostics.push(e.diagnostic); + success = false; + } + _ => (), + } + match ExpressionIdentifier::run_pre_expression_pass(&mut contract_ast, clarity_version) { Err(e) if error_early => return Err(e), Err(e) => { diff --git a/clarity/src/vm/ast/parser/v2/lexer/mod.rs b/clarity/src/vm/ast/parser/v2/lexer/mod.rs index fd2e232c8b0..f5726b7bb04 100644 --- a/clarity/src/vm/ast/parser/v2/lexer/mod.rs +++ b/clarity/src/vm/ast/parser/v2/lexer/mod.rs @@ -811,7 +811,10 @@ impl<'a> Lexer<'a> { } } _ => { - if self.next.is_ascii_alphabetic() { + // `_` may lead an identifier from Clarity 6 onwards. The lexer + // accepts it unconditionally; an AST pass rejects underscore + // identifiers for `ClarityVersion < Clarity6`. + if self.next.is_ascii_alphabetic() || self.next == '_' { advance = false; self.read_identifier(None)? } else if self.next.is_ascii_digit() { diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs new file mode 100644 index 00000000000..61605f4b186 --- /dev/null +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -0,0 +1,184 @@ +// Copyright (C) 2025-2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! AST pass that rejects identifiers beginning with `_` for pre-`Clarity6` +//! contracts. +//! +//! The wire-level `ClarityName` regex and the v2 lexer accept underscore-led +//! names unconditionally so that the parser can produce a well-formed AST and +//! report a precise, version-aware diagnostic here rather than a generic +//! "illegal name" lexer error. SIP-04x permits the relaxation only for +//! `ClarityVersion::Clarity6` onwards. + +use clarity_types::representations::ClarityName; +use stacks_common::types::StacksEpochId; + +use crate::vm::ClarityVersion; +use crate::vm::ast::errors::{ParseError, ParseErrorKind, ParseResult}; +use crate::vm::ast::types::{BuildASTPass, ContractAST}; +use crate::vm::representations::{PreSymbolicExpression, PreSymbolicExpressionType}; + +pub struct UnderscoreIdentifierChecker; + +impl BuildASTPass for UnderscoreIdentifierChecker { + fn run_pass( + contract_ast: &mut ContractAST, + version: ClarityVersion, + _epoch: StacksEpochId, + ) -> ParseResult<()> { + if version >= ClarityVersion::Clarity6 { + return Ok(()); + } + check(&contract_ast.pre_expressions) + } +} + +fn check(exprs: &[PreSymbolicExpression]) -> ParseResult<()> { + for expr in exprs { + check_one(expr)?; + } + Ok(()) +} + +fn check_one(expr: &PreSymbolicExpression) -> ParseResult<()> { + match &expr.pre_expr { + PreSymbolicExpressionType::Atom(name) => reject_if_underscore(name, expr), + PreSymbolicExpressionType::TraitReference(name) => reject_if_underscore(name, expr), + PreSymbolicExpressionType::SugaredFieldIdentifier(_, trait_name) => { + reject_if_underscore(trait_name, expr) + } + PreSymbolicExpressionType::FieldIdentifier(trait_id) => { + reject_if_underscore(&trait_id.name, expr) + } + PreSymbolicExpressionType::List(inner) | PreSymbolicExpressionType::Tuple(inner) => { + check(inner) + } + PreSymbolicExpressionType::AtomValue(_) + | PreSymbolicExpressionType::SugaredContractIdentifier(_) + | PreSymbolicExpressionType::Comment(_) + | PreSymbolicExpressionType::Placeholder(_) => Ok(()), + } +} + +fn reject_if_underscore(name: &ClarityName, expr: &PreSymbolicExpression) -> ParseResult<()> { + if name.starts_with('_') { + let mut err = ParseError::new(ParseErrorKind::UnderscoreIdentifierNotAllowed( + name.to_string(), + )); + err.diagnostic.spans = vec![expr.span().clone()]; + return Err(err); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use clarity_types::types::QualifiedContractIdentifier; + + use super::*; + use crate::vm::ast::build_ast_with_diagnostics; + use crate::vm::costs::LimitedCostTracker; + + fn parses(source: &str, version: ClarityVersion) -> bool { + let contract_id = QualifiedContractIdentifier::transient(); + let (_ast, _diag, success) = build_ast_with_diagnostics( + &contract_id, + source, + &mut LimitedCostTracker::new_free(), + version, + StacksEpochId::latest(), + ); + success + } + + #[test] + fn underscore_prefix_rejected_pre_clarity6() { + // Reject `_admin` as a constant name in Clarity 5. + assert!(!parses( + "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn underscore_prefix_accepted_in_clarity6() { + // Accept `_admin` as a constant name in Clarity 6. + assert!(parses( + "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", + ClarityVersion::Clarity6, + )); + } + + #[test] + fn underscore_prefix_in_let_binding_pre_clarity6_rejected() { + assert!(!parses("(let ((_x 1)) (+ _x 1))", ClarityVersion::Clarity5,)); + } + + #[test] + fn bare_underscore_rejected_pre_clarity6() { + assert!(!parses("(let ((_ 1)) 0)", ClarityVersion::Clarity5)); + } + + #[test] + fn bare_underscore_accepted_in_clarity6() { + assert!(parses("(let ((_ 1)) 0)", ClarityVersion::Clarity6)); + } + + #[test] + fn underscore_in_function_arg_rejected_pre_clarity6() { + assert!(!parses( + "(define-public (foo (_addr principal)) (ok _addr))", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn nested_underscore_atom_rejected_pre_clarity6() { + // The check must descend into nested lists. + assert!(!parses( + "(define-public (foo) (ok (let ((y 1)) _bad)))", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn non_underscore_names_still_accepted_pre_clarity6() { + // Regression guard: legacy identifiers must still parse. + assert!(parses( + "(define-constant admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7) + (define-public (foo (addr principal)) (ok addr))", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn interior_underscore_still_accepted_pre_clarity6() { + // Underscores inside an identifier remain legal pre-Clarity-6; + // only the leading position is gated. + assert!(parses( + "(define-constant my_admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn underscore_trait_reference_rejected_pre_clarity6() { + // `<_foo>` inside a function signature should be caught too. + assert!(!parses( + "(define-trait t ((bar (<_foo>) (response uint uint))))", + ClarityVersion::Clarity5, + )); + } +} diff --git a/clarity/src/vm/tests/representations.rs b/clarity/src/vm/tests/representations.rs index 53e6059af2c..823e103c154 100644 --- a/clarity/src/vm/tests/representations.rs +++ b/clarity/src/vm/tests/representations.rs @@ -33,15 +33,17 @@ fn assert_regex_unchanged(actual: &str, expected: &str) { /// /// This function creates a branched strategy based on the `CLARITY_NAME_REGEX_STRING` pattern. /// -/// The strategy covers three categories of valid names: +/// The strategy covers four categories of valid names: /// - Letter-based names starting with a letter followed by alphanumeric or symbol characters +/// - Underscore-led names (Clarity 6 / SIP-04x): a single `_` followed by zero or more +/// alphanumeric or symbol characters; includes the bare `_` discard name /// - Single arithmetic operators (`-`, `+`, `=`, `/`, `*`) /// - Comparison operators (`<`, `>`, `<=`, `>=`) fn any_valid_clarity_name() -> impl Strategy { // Ensure the regex branches match the actual validator. assert_regex_unchanged( CLARITY_NAME_REGEX_STRING.as_str(), - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", + "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", ); let letter_names = string_regex(&format!( @@ -50,6 +52,14 @@ fn any_valid_clarity_name() -> impl Strategy { )) .unwrap(); + // Underscore-led names (SIP-04x). The body length range starts at 0 so the + // bare `_` is included. + let underscore_names = string_regex(&format!( + "_[a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", + (MAX_STRING_LEN as usize).saturating_sub(1) + )) + .unwrap(); + let single_ops = prop_oneof![ Just("-".to_string()), Just("+".to_string()), @@ -65,7 +75,7 @@ fn any_valid_clarity_name() -> impl Strategy { Just(">=".to_string()), ]; - prop_oneof![letter_names, single_ops, comparison_ops] + prop_oneof![letter_names, underscore_names, single_ops, comparison_ops] } #[tag(t_prop)] @@ -86,21 +96,23 @@ fn prop_clarity_name_valid_patterns() { /// This function creates a strategy that generates strings that should be rejected /// by `ClarityName::try_from()` validation by systematically violating each valid branch. /// -/// The strategy generates names that violate the three valid branches: +/// The strategy generates names that violate the four valid branches: /// - Branch 1 violations: Invalid starting characters or invalid characters in letter-based names -/// - Branch 2 violations: Multi-character strings starting with single operators -/// - Branch 3 violations: Invalid extensions to comparison operators +/// - Branch 2 violations: Invalid characters anywhere in underscore-led names (SIP-04x) +/// - Branch 3 violations: Multi-character strings starting with single operators +/// - Branch 4 violations: Invalid extensions to comparison operators /// - General violations: Empty strings and length violations /// /// Valid branches being violated: /// 1. `^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Letter-based names -/// 2. `^[-+=/*]$` - Single arithmetic operators -/// 3. `^[<>]=?$` - Comparison operators +/// 2. `^_([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Underscore-led names (Clarity 6) +/// 3. `^[-+=/*]$` - Single arithmetic operators +/// 4. `^[<>]=?$` - Comparison operators fn any_invalid_clarity_name() -> impl Strategy { // Ensure the regex branches match the actual validator. assert_regex_unchanged( CLARITY_NAME_REGEX_STRING.as_str(), - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", + "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", ); let empty_string = Just("".to_string()); diff --git a/stackslib/src/chainstate/tests/parse_tests.rs b/stackslib/src/chainstate/tests/parse_tests.rs index 697b59294a4..6f7f7cf28a4 100644 --- a/stackslib/src/chainstate/tests/parse_tests.rs +++ b/stackslib/src/chainstate/tests/parse_tests.rs @@ -96,6 +96,7 @@ fn variant_coverage_report(variant: ParseErrorKind) { IllegalClarityName(_) => Unreachable_Functionally("prevented by Lexer checks returning `Lexer` variant"), IllegalASCIIString(_) => Tested(vec![test_illegal_ascii_string]), IllegalContractName(_) => Unreachable_Functionally("prevented by Lexer checks returning `Lexer` variant or Parser by MAX_CONTRACT_NAME_LEN returning `ContractNameTooLong` variant"), + UnderscoreIdentifierNotAllowed(_) => Ignored("Reachable via deploys of pre-Clarity-6 contracts that contain `_`-prefixed identifiers (SIP-04x). Covered by `clarity::vm::ast::underscore_checker::tests` rather than consensus-snapshot tests."), NoteToMatchThis(_) => Unreachable_Functionally("It is reachable, but only visible in diagnostic mode as it comes as a later diagnostic error"), UnexpectedParserFailure => Unreachable_ExpectLike, InterpreterFailure => Unreachable_ExpectLike, // currently cause block rejection From 73d06656fd31bcdd5ed5b2a10c1f6340ec9bc436 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 10:54:26 -0400 Subject: [PATCH 02/32] clarity6: bare `_` discard binding in `let` and `match` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per SIP-04x: the bare identifier `_` is a discard pattern in `let` and `match` binding positions. The bound expression is still evaluated (preserving the short-circuit effects of `try!`/`unwrap!`) but the name is not placed in scope, and repeated `_` bindings in the same form do not raise `NameAlreadyUsed`. Both the runtime and the type checker treat `_` as discard only when the contract's `ClarityVersion >= Clarity6`. Memory and cost accounting are preserved: a discard binding charges the same as a non-discard one, avoiding any gas-side-channel asymmetry. The value drops naturally at scope exit because it is never inserted into `inner_context.variables`. * `clarity/src/vm/functions/mod.rs` — `special_let` gates the conflict checks and the scope insertion on `!is_discard`. * `clarity/src/vm/functions/options.rs` — `eval_with_new_binding` (used by `match` opt/resp arms) gates the same way. * `clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs` — `check_special_let` skips `check_name_used`/`lookup_variable_type` and the `add_variable_type` for discards while still type-checking the bound expression. * `clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs` — analyzer `eval_with_new_binding` gates similarly; new `use crate::vm::ClarityVersion`. --- .../analysis/type_checker/v2_1/natives/mod.rs | 26 ++++++++--- .../type_checker/v2_1/natives/options.rs | 18 ++++++-- clarity/src/vm/functions/mod.rs | 17 +++++-- clarity/src/vm/functions/options.rs | 46 +++++++++++-------- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index 8d82611d2dc..9f21ae82f16 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -305,11 +305,19 @@ fn check_special_let( binding_list, SyntaxBindingErrorType::Let, |var_name, var_sexp| { - checker.contract_context.check_name_used(var_name)?; - if out_context.lookup_variable_type(var_name).is_some() { - return Err(StaticCheckError::new( - StaticCheckErrorKind::NameAlreadyUsed(var_name.to_string()), - )); + // SIP-04x: bare `_` is a discard binding in Clarity 6 — still + // type-check the value (so its type errors surface) but don't + // add the name to the typing context and skip name-collision + // checks across repeated discards. + let is_discard = + var_name.as_str() == "_" && checker.clarity_version >= ClarityVersion::Clarity6; + if !is_discard { + checker.contract_context.check_name_used(var_name)?; + if out_context.lookup_variable_type(var_name).is_some() { + return Err(StaticCheckError::new( + StaticCheckErrorKind::NameAlreadyUsed(var_name.to_string()), + )); + } } let typed_result = checker.type_check(var_sexp, &out_context)?; @@ -328,7 +336,13 @@ fn check_special_let( .ok_or_else(|| CostErrors::CostOverflow)?; checker.add_memory(memory_use)?; } - out_context.add_variable_type(var_name.clone(), typed_result, checker.clarity_version); + if !is_discard { + out_context.add_variable_type( + var_name.clone(), + typed_result, + checker.clarity_version, + ); + } Ok(()) }, )?; diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs index f52a1330116..de1cae1af86 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs @@ -22,6 +22,7 @@ use super::{ StaticCheckError, StaticCheckErrorKind, TypeChecker, check_argument_count, check_arguments_at_least, no_type, }; +use crate::vm::ClarityVersion; use crate::vm::analysis::type_checker::contexts::TypingContext; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{CostErrors, CostTracker, analysis_typecheck_cost, runtime_cost}; @@ -306,14 +307,21 @@ fn eval_with_new_binding( .ok_or_else(|| CostErrors::CostOverflow)?; checker.add_memory(memory_use)?; } - checker.contract_context.check_name_used(&bind_name)?; + // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` + // discards the matched value — skip name-collision checks and don't + // place the name in the typing context for the branch body. + let is_discard = + bind_name.as_str() == "_" && checker.clarity_version >= ClarityVersion::Clarity6; + if !is_discard { + checker.contract_context.check_name_used(&bind_name)?; + + if inner_context.lookup_variable_type(&bind_name).is_some() { + return Err(StaticCheckErrorKind::NameAlreadyUsed(bind_name.into()).into()); + } - if inner_context.lookup_variable_type(&bind_name).is_some() { - return Err(StaticCheckErrorKind::NameAlreadyUsed(bind_name.into()).into()); + inner_context.add_variable_type(bind_name, bind_type, checker.clarity_version); } - inner_context.add_variable_type(bind_name, bind_type, checker.clarity_version); - let result = checker.type_check(body, &inner_context); if checker.epoch.analysis_memory() { checker.drop_memory(memory_use)?; diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index 1c7016d1262..8c32afc0461 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -806,9 +806,17 @@ fn special_let( finally_drop_memory!( exec_state, memory_use; { handle_binding_list::<_, VmExecutionError>(bindings, SyntaxBindingErrorType::Let, |binding_name, var_sexp| { - if is_reserved(binding_name, invoke_ctx.contract_context.get_clarity_version()) || - invoke_ctx.contract_context.lookup_function(binding_name).is_some() || - inner_context.lookup_variable(binding_name).is_some() { + // SIP-04x: a bare `_` is a discard binding. Evaluate the bound + // expression (preserving `try!`/`unwrap!` short-circuits) but do + // not place it in scope, and do not treat repeated `_` bindings + // as name conflicts. + let is_discard = binding_name.as_str() == "_" + && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; + + if !is_discard + && (is_reserved(binding_name, invoke_ctx.contract_context.get_clarity_version()) || + invoke_ctx.contract_context.lookup_function(binding_name).is_some() || + inner_context.lookup_variable(binding_name).is_some()) { return Err(RuntimeCheckErrorKind::NameAlreadyUsed(binding_name.clone().into()).into()) } @@ -818,6 +826,9 @@ fn special_let( exec_state.add_memory(bind_mem_use)?; memory_use += bind_mem_use; // no check needed, b/c it's done in add_memory. let binding_value = binding_value.clone_with_cost(exec_state)?; + if is_discard { + return Ok(()); + } if *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity2 && let CallableContract(trait_data) = &binding_value { inner_context.callable_contracts.insert(binding_name.clone(), trait_data.clone()); } diff --git a/clarity/src/vm/functions/options.rs b/clarity/src/vm/functions/options.rs index c338fd2efe4..04d0fff29ab 100644 --- a/clarity/src/vm/functions/options.rs +++ b/clarity/src/vm/functions/options.rs @@ -128,14 +128,20 @@ fn eval_with_new_binding( context: &LocalContext, ) -> Result { let mut inner_context = context.extend()?; - if vm::is_reserved( - &bind_name, - invoke_ctx.contract_context.get_clarity_version(), - ) || invoke_ctx - .contract_context - .lookup_function(&bind_name) - .is_some() - || inner_context.lookup_variable(&bind_name).is_some() + // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` + // discards the value — execute the branch without binding the name and + // without raising `NameAlreadyUsed` on re-use across nested match arms. + let is_discard = bind_name.as_str() == "_" + && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; + if !is_discard + && (vm::is_reserved( + &bind_name, + invoke_ctx.contract_context.get_clarity_version(), + ) || invoke_ctx + .contract_context + .lookup_function(&bind_name) + .is_some() + || inner_context.lookup_variable(&bind_name).is_some()) { return Err(RuntimeCheckErrorKind::NameAlreadyUsed(bind_name.into()).into()); } @@ -143,18 +149,20 @@ fn eval_with_new_binding( let memory_use = bind_value.get_memory_use()?; exec_state.add_memory(memory_use)?; - if *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity2 - && let CallableContract(trait_data) = &bind_value - { - inner_context.callable_contracts.insert( - bind_name.clone(), - CallableData { - contract_identifier: trait_data.contract_identifier.clone(), - trait_identifier: trait_data.trait_identifier.clone(), - }, - ); + if !is_discard { + if *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity2 + && let CallableContract(trait_data) = &bind_value + { + inner_context.callable_contracts.insert( + bind_name.clone(), + CallableData { + contract_identifier: trait_data.contract_identifier.clone(), + trait_identifier: trait_data.trait_identifier.clone(), + }, + ); + } + inner_context.variables.insert(bind_name, bind_value); } - inner_context.variables.insert(bind_name, bind_value); let result = vm::eval(body, exec_state, invoke_ctx, &inner_context) .and_then(|v| v.clone_with_cost(exec_state)); From 96bea09e0944f7927d3bed7d731365235eb226fb Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 10:54:26 -0400 Subject: [PATCH 03/32] clarity6: end-to-end tests + changelog for underscore identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `clarity/src/vm/tests/simple_apply_eval.rs` — five end-to-end tests: - `test_let_discard_bare_underscore`: multiple `_` in one `let` form coexist without `NameAlreadyUsed`. - `test_let_discard_underscore_not_referenceable`: a `_` binding does not place the name in scope; the body referring to `_` fails as unbound. - `test_let_underscore_prefix_is_regular_binding`: `_admin` (the leading-`_` convention from the SIP example) binds normally and is readable in the body — the leading `_` is convention only. - `test_match_opt_discard_bare_underscore` / `_resp_…`: `_` discard semantics extend to `match` on `(some …)` and `(ok …)/(err …)`. * `changelog.d/underscore-identifiers.added` — release note. --- changelog.d/underscore-identifiers.added | 1 + clarity/src/vm/tests/simple_apply_eval.rs | 98 +++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 changelog.d/underscore-identifiers.added diff --git a/changelog.d/underscore-identifiers.added b/changelog.d/underscore-identifiers.added new file mode 100644 index 00000000000..1ef3df7163d --- /dev/null +++ b/changelog.d/underscore-identifiers.added @@ -0,0 +1 @@ +Clarity 6 (SIP-04x): allow identifiers to begin with `_` (e.g. `_admin`, `_-internal`) for `define-*` names, function arguments, and `let`/`match` bindings. Additionally, the bare identifier `_` is a discard pattern in `let` and `match` bindings — its value is evaluated (preserving short-circuit effects of `try!`/`unwrap!`) but no binding is added to scope, and repeated `_` bindings in the same form do not conflict. Gated on `ClarityVersion::Clarity6`; pre-Clarity-6 contracts continue to reject leading-`_` identifiers at parse time. diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 474981505a8..e0afafe3598 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -64,6 +64,104 @@ fn test_doubly_defined_persisted_vars() { } } +/// SIP-04x: bare `_` is a discard binding in `let`. The value is evaluated +/// (so early-exit forms like `unwrap-panic!` would still fire) but the name +/// is not added to scope, and multiple `_` bindings in the same form do not +/// conflict. +#[test] +fn test_let_discard_bare_underscore() { + // Multiple bare-_ bindings in the same form should not raise NameAlreadyUsed. + let program = "(let ((_ 1) (_ 2) (x 3)) (+ x 4))"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(7)); +} + +/// SIP-04x: a bare `_` binding in `let` does not place `_` in scope. The body +/// referring to `_` should fail with an unbound-variable error rather than +/// returning the discarded value. +#[test] +fn test_let_discard_underscore_not_referenceable() { + let program = "(let ((_ 7)) _)"; + let err = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("UndefinedVariable") || msg.contains("Undefined") || msg.contains("not"), + "expected an unbound/undefined-variable error, got: {msg}" + ); +} + +/// SIP-04x: underscore-prefixed names (e.g. `_admin`) are *regular* bindings +/// — the leading `_` is just a convention. They can be read back. +#[test] +fn test_let_underscore_prefix_is_regular_binding() { + let program = "(let ((_admin 42)) _admin)"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(42)); +} + +/// SIP-04x: bare `_` in `match` (optional form) discards the matched value +/// without binding the name. +#[test] +fn test_match_opt_discard_bare_underscore() { + let program = "(match (some 5) _ 1 0)"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(1)); +} + +/// SIP-04x: bare `_` in `match` (response form) discards on both arms. +#[test] +fn test_match_resp_discard_bare_underscore() { + let program_ok = "(match (ok 7) _ 1 _ 2)"; + let result = execute_with_parameters( + program_ok, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(1)); + + // Force the err-arm with a `response int int` whose err side is taken. + let program_err = "(match (err 7) _ 1 _ 2)"; + let result = execute_with_parameters( + program_err, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(2)); +} + #[apply(test_clarity_versions)] fn test_simple_let(#[case] version: ClarityVersion, #[case] epoch: StacksEpochId) { /* From 03515ecebbd9ae39d77cec62f41388c00cbe3235 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 10:54:26 -0400 Subject: [PATCH 04/32] clarity6: collapse `_`-leading identifier arm into letter arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated `^_(...)$` alternation arm added in bca48790fe duplicated the body character class from the letter arm. Since the body class `[-_!?+<>=/*]` already accepts `_`, the cleaner factoring is to widen the leading character class from `[a-zA-Z]` to `[a-zA-Z_]` and drop the second arm entirely. The bare-`_` case still works because the body quantifier is `*` (zero or more). Old: ^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$ New: ^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$ The matched language is identical — purely a regex simplification. Tests in `clarity-types/src/tests/representations.rs` are unchanged; the proptest strategies in `clarity/src/vm/tests/representations.rs` collapse their two identifier branches into one and the `assert_regex_unchanged` literals are updated. --- clarity-types/src/representations.rs | 17 +++++----- clarity/src/vm/tests/representations.rs | 44 ++++++++++--------------- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index cdcf2589afb..9cd805aad7e 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -48,15 +48,16 @@ lazy_static! { "({})|({})", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING ); - // Four alternation arms: - // 1) `[a-zA-Z]...` — identifier starting with a letter (the historical form). - // 2) `_...` — identifier starting with `_`, including the bare `_` - // (Clarity 6 SIP-04x; codec/lexer accept always, but - // pre-Clarity-6 ASTs reject these at the parser pass). - // 3) `[-+=/*]` — single-char operator name. - // 4) `[<>]=?` — comparison operator name. + // Three alternation arms: + // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` (including + // the bare `_`). The `_` leading position is accepted + // unconditionally at the codec/lexer level per + // Clarity 6 SIP-04x; pre-Clarity-6 ASTs reject these + // at the parser pass. + // 2) `[-+=/*]` — single-char operator name. + // 3) `[<>]=?` — comparison operator name. pub static ref CLARITY_NAME_REGEX_STRING: String = - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); + "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); pub static ref CLARITY_NAME_REGEX: Regex = { Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap() diff --git a/clarity/src/vm/tests/representations.rs b/clarity/src/vm/tests/representations.rs index 823e103c154..e291906ae6f 100644 --- a/clarity/src/vm/tests/representations.rs +++ b/clarity/src/vm/tests/representations.rs @@ -33,29 +33,23 @@ fn assert_regex_unchanged(actual: &str, expected: &str) { /// /// This function creates a branched strategy based on the `CLARITY_NAME_REGEX_STRING` pattern. /// -/// The strategy covers four categories of valid names: -/// - Letter-based names starting with a letter followed by alphanumeric or symbol characters -/// - Underscore-led names (Clarity 6 / SIP-04x): a single `_` followed by zero or more -/// alphanumeric or symbol characters; includes the bare `_` discard name +/// The strategy covers three categories of valid names: +/// - Identifier names starting with a letter or `_` (Clarity 6 / SIP-04x added +/// the `_` leading position, including the bare `_` discard name) followed +/// by zero or more alphanumeric or symbol characters /// - Single arithmetic operators (`-`, `+`, `=`, `/`, `*`) /// - Comparison operators (`<`, `>`, `<=`, `>=`) fn any_valid_clarity_name() -> impl Strategy { // Ensure the regex branches match the actual validator. assert_regex_unchanged( CLARITY_NAME_REGEX_STRING.as_str(), - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", + "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", ); - let letter_names = string_regex(&format!( - "[a-zA-Z][a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", - (MAX_STRING_LEN as usize).saturating_sub(1) - )) - .unwrap(); - - // Underscore-led names (SIP-04x). The body length range starts at 0 so the - // bare `_` is included. - let underscore_names = string_regex(&format!( - "_[a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", + // Identifier names: letter-or-underscore start (the `_` case includes the + // bare `_` because the body length floor is 0). + let identifier_names = string_regex(&format!( + "[a-zA-Z_][a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", (MAX_STRING_LEN as usize).saturating_sub(1) )) .unwrap(); @@ -75,7 +69,7 @@ fn any_valid_clarity_name() -> impl Strategy { Just(">=".to_string()), ]; - prop_oneof![letter_names, underscore_names, single_ops, comparison_ops] + prop_oneof![identifier_names, single_ops, comparison_ops] } #[tag(t_prop)] @@ -96,23 +90,21 @@ fn prop_clarity_name_valid_patterns() { /// This function creates a strategy that generates strings that should be rejected /// by `ClarityName::try_from()` validation by systematically violating each valid branch. /// -/// The strategy generates names that violate the four valid branches: -/// - Branch 1 violations: Invalid starting characters or invalid characters in letter-based names -/// - Branch 2 violations: Invalid characters anywhere in underscore-led names (SIP-04x) -/// - Branch 3 violations: Multi-character strings starting with single operators -/// - Branch 4 violations: Invalid extensions to comparison operators +/// The strategy generates names that violate the three valid branches: +/// - Branch 1 violations: Invalid starting characters or invalid characters in identifier names +/// - Branch 2 violations: Multi-character strings starting with single operators +/// - Branch 3 violations: Invalid extensions to comparison operators /// - General violations: Empty strings and length violations /// /// Valid branches being violated: -/// 1. `^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Letter-based names -/// 2. `^_([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Underscore-led names (Clarity 6) -/// 3. `^[-+=/*]$` - Single arithmetic operators -/// 4. `^[<>]=?$` - Comparison operators +/// 1. `^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$` - Identifier names (letter- or `_`-led) +/// 2. `^[-+=/*]$` - Single arithmetic operators +/// 3. `^[<>]=?$` - Comparison operators fn any_invalid_clarity_name() -> impl Strategy { // Ensure the regex branches match the actual validator. assert_regex_unchanged( CLARITY_NAME_REGEX_STRING.as_str(), - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^_([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", + "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$", ); let empty_string = Just("".to_string()); From ab2e34886db65293314189b69143a37f91742c7b Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 10:58:52 -0400 Subject: [PATCH 05/32] Simplify changelog text --- changelog.d/underscore-identifiers.added | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/underscore-identifiers.added b/changelog.d/underscore-identifiers.added index 1ef3df7163d..9fbb89d3572 100644 --- a/changelog.d/underscore-identifiers.added +++ b/changelog.d/underscore-identifiers.added @@ -1 +1 @@ -Clarity 6 (SIP-04x): allow identifiers to begin with `_` (e.g. `_admin`, `_-internal`) for `define-*` names, function arguments, and `let`/`match` bindings. Additionally, the bare identifier `_` is a discard pattern in `let` and `match` bindings — its value is evaluated (preserving short-circuit effects of `try!`/`unwrap!`) but no binding is added to scope, and repeated `_` bindings in the same form do not conflict. Gated on `ClarityVersion::Clarity6`; pre-Clarity-6 contracts continue to reject leading-`_` identifiers at parse time. +Clarity 6: Allow identifiers to begin with `_`, and allow bare `_` identifier in `let` and `match` bindings to discard value From 9e2c0667369919da4a911974a3663863b98a278e Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 12:39:00 -0400 Subject: [PATCH 06/32] clarity6: fix `<_foo>` trait refs + expand `underscore_checker` tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While auditing coverage on `underscore_checker.rs` I noticed two gaps: 1. The lexer dispatch for `<` only enters `read_trait_identifier` when the next char `is_ascii_alphabetic()`. So `<_foo>` was being lexed as three tokens (`Less`, the atom `_foo`, `Greater`) rather than as one `Token::TraitIdent("_foo")`. The pre-Clarity-6 rejection test for `<_foo>` was passing for the wrong reason — the underscore checker was catching the `_foo` `Atom` branch, never the `TraitReference` branch. Fix: also enter the trait-identifier reader when `<` is followed by `_`, matching the relaxation done earlier in the main identifier dispatch. 2. Missing coverage in `underscore_checker::tests`: - The `Tuple` recursion arm of `check_one` was unexercised. New tests `underscore_in_tuple_key_{rejected,accepted}` cover it. - The `SugaredFieldIdentifier` and `FieldIdentifier` arms (the `.contract.trait` and `'.contract.trait` desugarings) were unexercised. New rejection tests cover both. - Symmetric Clarity-6 acceptance tests for `_x` in let bindings, `_addr` function arguments, and `<_foo>` trait references were missing — added. - No test verified the *specific* `ParseErrorKind` emitted. Added `rejection_emits_underscore_identifier_not_allowed_kind`, which uses `build_ast` (with `error_early=true`) to assert `ParseErrorKind::UnderscoreIdentifierNotAllowed("_admin")`. - Added `leading_operator_names_unaffected_pre_clarity6` as a regression guard: the pass must leave `+`, `<=`, `*` etc. alone. Test count goes from 10 to 19. --- clarity/src/vm/ast/parser/v2/lexer/mod.rs | 5 +- clarity/src/vm/ast/underscore_checker.rs | 110 +++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/clarity/src/vm/ast/parser/v2/lexer/mod.rs b/clarity/src/vm/ast/parser/v2/lexer/mod.rs index f5726b7bb04..c898e350dd7 100644 --- a/clarity/src/vm/ast/parser/v2/lexer/mod.rs +++ b/clarity/src/vm/ast/parser/v2/lexer/mod.rs @@ -733,7 +733,10 @@ impl<'a> Lexer<'a> { self.read_char()?; if self.next == '=' { Token::LessEqual - } else if self.next.is_ascii_alphabetic() { + } else if self.next.is_ascii_alphabetic() || self.next == '_' { + // `_` may lead a trait identifier in Clarity 6 onwards + // (SIP-04x); accept unconditionally here and let the + // version-gated AST pass reject in older versions. self.read_trait_identifier()? } else { advance = false; diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 61605f4b186..8f0ff3a67de 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -88,7 +88,7 @@ mod tests { use clarity_types::types::QualifiedContractIdentifier; use super::*; - use crate::vm::ast::build_ast_with_diagnostics; + use crate::vm::ast::{build_ast, build_ast_with_diagnostics}; use crate::vm::costs::LimitedCostTracker; fn parses(source: &str, version: ClarityVersion) -> bool { @@ -103,6 +103,21 @@ mod tests { success } + /// Like `parses(...)` but with error-early enabled, so callers can match + /// on the specific `ParseErrorKind` returned for a pre-Clarity-6 contract. + fn parse_err(source: &str, version: ClarityVersion) -> ParseErrorKind { + let contract_id = QualifiedContractIdentifier::transient(); + let err = build_ast( + &contract_id, + source, + &mut LimitedCostTracker::new_free(), + version, + StacksEpochId::latest(), + ) + .expect_err("expected parse error"); + *err.err + } + #[test] fn underscore_prefix_rejected_pre_clarity6() { // Reject `_admin` as a constant name in Clarity 5. @@ -181,4 +196,97 @@ mod tests { ClarityVersion::Clarity5, )); } + + #[test] + fn underscore_trait_reference_accepted_in_clarity6() { + // Symmetry with the rejection test above. We must define `_foo` + // first, otherwise the later `TraitsResolver` pass (which the + // pre-Clarity-6 test never reaches because the underscore check + // short-circuits earlier) would fail with `TraitReferenceUnknown`. + assert!(parses( + "(define-trait _foo ((m (uint) (response uint uint)))) + (define-trait t ((bar (<_foo>) (response uint uint))))", + ClarityVersion::Clarity6, + )); + } + + #[test] + fn underscore_in_let_binding_accepted_in_clarity6() { + // Symmetry with `underscore_prefix_in_let_binding_pre_clarity6_rejected`. + assert!(parses("(let ((_x 1)) (+ _x 1))", ClarityVersion::Clarity6)); + } + + #[test] + fn underscore_in_function_arg_accepted_in_clarity6() { + // Symmetry with `underscore_in_function_arg_rejected_pre_clarity6`. + assert!(parses( + "(define-public (foo (_addr principal)) (ok _addr))", + ClarityVersion::Clarity6, + )); + } + + #[test] + fn underscore_in_tuple_key_rejected_pre_clarity6() { + // The pass descends into `Tuple` nodes — exercise that match arm via a + // tuple-literal whose key is underscore-prefixed. + assert!(!parses( + "(define-constant x { _k: 1 })", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn underscore_in_tuple_key_accepted_in_clarity6() { + assert!(parses( + "(define-constant x { _k: 1 })", + ClarityVersion::Clarity6, + )); + } + + #[test] + fn underscore_in_sugared_field_identifier_rejected_pre_clarity6() { + // `.contract.trait` desugars to `SugaredFieldIdentifier`. The trait + // name `_t` should trigger the leading-`_` check via that arm. + assert!(!parses( + "(use-trait t .my-contract._t)", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn underscore_in_fully_qualified_field_identifier_rejected_pre_clarity6() { + // The fully-qualified `'..` form yields a + // `FieldIdentifier(TraitIdentifier { name, ... })`; exercise that + // distinct match arm. + assert!(!parses( + "(use-trait t 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.my-contract._t)", + ClarityVersion::Clarity5, + )); + } + + #[test] + fn rejection_emits_underscore_identifier_not_allowed_kind() { + // Verify the specific `ParseErrorKind` rather than just success=false. + // Without this, the gate could regress to (say) `IllegalClarityName` + // and the boolean-only tests would still pass. + let err = parse_err( + "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", + ClarityVersion::Clarity5, + ); + match err { + ParseErrorKind::UnderscoreIdentifierNotAllowed(name) => assert_eq!(name, "_admin"), + other => panic!("expected UnderscoreIdentifierNotAllowed, got {other:?}"), + } + } + + #[test] + fn leading_operator_names_unaffected_pre_clarity6() { + // Regression guard: the pass should leave operator names like `+`, + // `<=`, `*` alone — they are valid `ClarityName`s via the operator + // alternation arms and never start with `_`. + assert!(parses( + "(define-private (foo) (+ 1 2)) (define-private (bar) (<= 1 2))", + ClarityVersion::Clarity5, + )); + } } From b7978ce47e17eb99b0915ff87f4a74650dc456bf Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 13:52:10 -0400 Subject: [PATCH 07/32] Add more tests --- clarity/src/vm/ast/underscore_checker.rs | 22 ++++++++ clarity/src/vm/tests/simple_apply_eval.rs | 64 ++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 8f0ff3a67de..df25b4495f2 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -289,4 +289,26 @@ mod tests { ClarityVersion::Clarity5, )); } + + /// SIP-04x ambiguity: the spec carves out bare `_` as a discard binding + /// only "in `let` and `match` bindings". Outside those positions (e.g. + /// as a `define-constant` name or function-arg name), bare `_` is just + /// another identifier whose first character is `_` — so the AST pass + /// accepts it for Clarity 6+ and rejects it for older versions, exactly + /// like `_admin`. This test pins down that behavior so it doesn't drift + /// silently if a future reviewer reads the SIP more strictly. + #[test] + fn bare_underscore_as_define_name_accepted_in_clarity6() { + // Documents: `(define-constant _ 1)` parses in Clarity 6. The bare-`_` + // discard semantics from `let`/`match` do NOT apply at top-level + // define positions; this is a regular (referenceable) constant named + // `_`. The SIP's "does not create a binding that can be referenced + // later" wording is scoped to let/match bindings only. + assert!(parses("(define-constant _ 1)", ClarityVersion::Clarity6)); + } + + #[test] + fn bare_underscore_as_define_name_rejected_pre_clarity6() { + assert!(!parses("(define-constant _ 1)", ClarityVersion::Clarity5)); + } } diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index e0afafe3598..c1e7882a35a 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -98,8 +98,8 @@ fn test_let_discard_underscore_not_referenceable() { .unwrap_err(); let msg = format!("{err:?}"); assert!( - msg.contains("UndefinedVariable") || msg.contains("Undefined") || msg.contains("not"), - "expected an unbound/undefined-variable error, got: {msg}" + msg.contains("Undefined variable: _"), + "expected an unbound-variable error for `_`, got: {msg}" ); } @@ -162,6 +162,66 @@ fn test_match_resp_discard_bare_underscore() { assert_eq!(result, Value::Int(2)); } +/// SIP-04x: a bare-`_` `let` binding must still *evaluate* its value +/// expression — short-circuit forms like `try!` rely on this. Using a +/// guaranteed runtime fault (division by zero) in the bound expression +/// proves the value is computed; if the discard skipped evaluation, the +/// program would return `(int 0)` rather than erroring. +#[test] +fn test_let_discard_still_evaluates_value() { + let program = "(let ((_ (/ 1 0))) 0)"; + let err = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("DivisionByZero") || msg.contains("division") || msg.contains("Division"), + "expected a division-by-zero error from the discarded expression, got: {msg}" + ); +} + +/// SIP-04x: `match` discard semantics extend to evaluating the matched +/// expression for side-effecting forms like `try!`. The bound value is +/// computed; it just isn't placed in scope. Here we exercise that the +/// matched expression is still evaluated by relying on its value being +/// used by branch selection (some vs. none), even though `_` isn't readable. +#[test] +fn test_match_opt_none_arm_with_discard_some() { + let program = "(match (some 99) _ 11 22)"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + // Some-arm taken: discards `99`, returns `11`. None-arm (`22`) untaken. + assert_eq!(result, Value::Int(11)); +} + +/// SIP-04x: a bare-`_` `match` branch must not be referenceable in its body. +#[test] +fn test_match_opt_discard_underscore_not_referenceable() { + let program = "(match (some 5) _ _ 0)"; + let err = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("Undefined variable: _"), + "expected an unbound-variable error for `_`, got: {msg}" + ); +} + #[apply(test_clarity_versions)] fn test_simple_let(#[case] version: ClarityVersion, #[case] epoch: StacksEpochId) { /* From 6a85e126392591c2fc49718c18b8b2219e7d469b Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 14:15:49 -0400 Subject: [PATCH 08/32] Add property test --- clarity/src/vm/ast/underscore_checker.rs | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index df25b4495f2..640d3ed2be0 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -85,7 +85,11 @@ fn reject_if_underscore(name: &ClarityName, expr: &PreSymbolicExpression) -> Par #[cfg(test)] mod tests { + use clarity_types::representations::MAX_STRING_LEN; use clarity_types::types::QualifiedContractIdentifier; + use pinny::tag; + use proptest::prelude::*; + use proptest::string::string_regex; use super::*; use crate::vm::ast::{build_ast, build_ast_with_diagnostics}; @@ -311,4 +315,48 @@ mod tests { fn bare_underscore_as_define_name_rejected_pre_clarity6() { assert!(!parses("(define-constant _ 1)", ClarityVersion::Clarity5)); } + + /// Generates valid `_`-led `ClarityName` strings, including the bare `_`. + /// The body length floor is 0 so the bare case is covered; the ceiling + /// is `MAX_STRING_LEN - 1` so the total length stays within the codec + /// limit. + fn any_underscore_led_clarity_name() -> impl Strategy { + string_regex(&format!( + "_[a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", + (MAX_STRING_LEN as usize).saturating_sub(1) + )) + .unwrap() + } + + /// Property test: the version gate is exactly `< Clarity6` vs. `>= Clarity6` + /// for every valid `_`-led `ClarityName`. Pre-Clarity-6 must reject with + /// the specific `UnderscoreIdentifierNotAllowed(name)` kind; Clarity 6 + /// must accept. Exercises the full name-space — including the bare `_`, + /// underscore-followed-by-operator-chars (e.g. `_>=`, `_+!`), and names + /// at the `MAX_STRING_LEN` boundary — rather than relying on the handful + /// of example tests above to catch every shape. + #[tag(t_prop)] + #[test] + fn prop_underscore_led_names_gated_by_clarity_version() { + proptest!(|(name in any_underscore_led_clarity_name())| { + // Filter to names that pass the codec regex — the generator + // pattern is a superset of the strict `ClarityName` grammar. + prop_assume!(ClarityName::try_from(name.clone()).is_ok()); + + let src = format!("(define-constant {name} 1)"); + + // Pre-Clarity-6: reject with the specific error variant. + let err = parse_err(&src, ClarityVersion::Clarity5); + prop_assert!( + matches!(&err, ParseErrorKind::UnderscoreIdentifierNotAllowed(n) if n == &name), + "expected UnderscoreIdentifierNotAllowed({name:?}), got {err:?}" + ); + + // Clarity 6: accept. + prop_assert!( + parses(&src, ClarityVersion::Clarity6), + "expected `_`-led name {name:?} to parse in Clarity 6" + ); + }); + } } From 11f867e93e2960b6266cf09db095a869067433e1 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 14:50:51 -0400 Subject: [PATCH 09/32] Some code and comment cleanup --- clarity/src/vm/ast/underscore_checker.rs | 71 ++++++++--------------- clarity/src/vm/tests/simple_apply_eval.rs | 25 +++----- 2 files changed, 31 insertions(+), 65 deletions(-) diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 640d3ed2be0..a8445f844eb 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -124,7 +124,6 @@ mod tests { #[test] fn underscore_prefix_rejected_pre_clarity6() { - // Reject `_admin` as a constant name in Clarity 5. assert!(!parses( "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", ClarityVersion::Clarity5, @@ -133,7 +132,6 @@ mod tests { #[test] fn underscore_prefix_accepted_in_clarity6() { - // Accept `_admin` as a constant name in Clarity 6. assert!(parses( "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", ClarityVersion::Clarity6, @@ -194,7 +192,6 @@ mod tests { #[test] fn underscore_trait_reference_rejected_pre_clarity6() { - // `<_foo>` inside a function signature should be caught too. assert!(!parses( "(define-trait t ((bar (<_foo>) (response uint uint))))", ClarityVersion::Clarity5, @@ -203,10 +200,8 @@ mod tests { #[test] fn underscore_trait_reference_accepted_in_clarity6() { - // Symmetry with the rejection test above. We must define `_foo` - // first, otherwise the later `TraitsResolver` pass (which the - // pre-Clarity-6 test never reaches because the underscore check - // short-circuits earlier) would fail with `TraitReferenceUnknown`. + // Defines `_foo` so the later `TraitsResolver` pass doesn't fail with + // `TraitReferenceUnknown` — pre-Clarity-6 short-circuits before that. assert!(parses( "(define-trait _foo ((m (uint) (response uint uint)))) (define-trait t ((bar (<_foo>) (response uint uint))))", @@ -216,13 +211,11 @@ mod tests { #[test] fn underscore_in_let_binding_accepted_in_clarity6() { - // Symmetry with `underscore_prefix_in_let_binding_pre_clarity6_rejected`. assert!(parses("(let ((_x 1)) (+ _x 1))", ClarityVersion::Clarity6)); } #[test] fn underscore_in_function_arg_accepted_in_clarity6() { - // Symmetry with `underscore_in_function_arg_rejected_pre_clarity6`. assert!(parses( "(define-public (foo (_addr principal)) (ok _addr))", ClarityVersion::Clarity6, @@ -268,46 +261,39 @@ mod tests { )); } + /// Regression guard: the gate must emit `UnderscoreIdentifierNotAllowed`, + /// not (say) a generic `IllegalClarityName`. The boolean-only tests + /// above would not catch such a drift. #[test] fn rejection_emits_underscore_identifier_not_allowed_kind() { - // Verify the specific `ParseErrorKind` rather than just success=false. - // Without this, the gate could regress to (say) `IllegalClarityName` - // and the boolean-only tests would still pass. let err = parse_err( "(define-constant _admin 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7)", ClarityVersion::Clarity5, ); - match err { - ParseErrorKind::UnderscoreIdentifierNotAllowed(name) => assert_eq!(name, "_admin"), - other => panic!("expected UnderscoreIdentifierNotAllowed, got {other:?}"), - } + let ParseErrorKind::UnderscoreIdentifierNotAllowed(name) = err else { + panic!("expected UnderscoreIdentifierNotAllowed, got {err:?}"); + }; + assert_eq!(name, "_admin"); } + /// Regression guard: operator names like `+`, `<=`, `*` never start with + /// `_` and must pass the pass cleanly under any Clarity version. #[test] fn leading_operator_names_unaffected_pre_clarity6() { - // Regression guard: the pass should leave operator names like `+`, - // `<=`, `*` alone — they are valid `ClarityName`s via the operator - // alternation arms and never start with `_`. assert!(parses( "(define-private (foo) (+ 1 2)) (define-private (bar) (<= 1 2))", ClarityVersion::Clarity5, )); } - /// SIP-04x ambiguity: the spec carves out bare `_` as a discard binding - /// only "in `let` and `match` bindings". Outside those positions (e.g. - /// as a `define-constant` name or function-arg name), bare `_` is just - /// another identifier whose first character is `_` — so the AST pass - /// accepts it for Clarity 6+ and rejects it for older versions, exactly - /// like `_admin`. This test pins down that behavior so it doesn't drift - /// silently if a future reviewer reads the SIP more strictly. + /// The SIP carves out bare `_` as a discard pattern *only* inside + /// `let`/`match` bindings. Outside those positions (`define-constant + /// _ …`, function-arg names) bare `_` is a regular identifier whose + /// first char is `_`, so the AST pass treats it like `_admin`. Pinned + /// down so this can't silently drift if the SIP is later read more + /// strictly. #[test] fn bare_underscore_as_define_name_accepted_in_clarity6() { - // Documents: `(define-constant _ 1)` parses in Clarity 6. The bare-`_` - // discard semantics from `let`/`match` do NOT apply at top-level - // define positions; this is a regular (referenceable) constant named - // `_`. The SIP's "does not create a binding that can be referenced - // later" wording is scoped to let/match bindings only. assert!(parses("(define-constant _ 1)", ClarityVersion::Clarity6)); } @@ -316,10 +302,8 @@ mod tests { assert!(!parses("(define-constant _ 1)", ClarityVersion::Clarity5)); } - /// Generates valid `_`-led `ClarityName` strings, including the bare `_`. - /// The body length floor is 0 so the bare case is covered; the ceiling - /// is `MAX_STRING_LEN - 1` so the total length stays within the codec - /// limit. + /// Generates valid `_`-led `ClarityName` strings (including bare `_`), + /// bounded by `MAX_STRING_LEN`. fn any_underscore_led_clarity_name() -> impl Strategy { string_regex(&format!( "_[a-zA-Z0-9_!?+<>=/*-]{{0,{}}}", @@ -328,31 +312,22 @@ mod tests { .unwrap() } - /// Property test: the version gate is exactly `< Clarity6` vs. `>= Clarity6` - /// for every valid `_`-led `ClarityName`. Pre-Clarity-6 must reject with - /// the specific `UnderscoreIdentifierNotAllowed(name)` kind; Clarity 6 - /// must accept. Exercises the full name-space — including the bare `_`, - /// underscore-followed-by-operator-chars (e.g. `_>=`, `_+!`), and names - /// at the `MAX_STRING_LEN` boundary — rather than relying on the handful - /// of example tests above to catch every shape. + /// For every valid `_`-led `ClarityName`, pre-Clarity-6 must reject with + /// `UnderscoreIdentifierNotAllowed(name)` and Clarity 6 must accept. + /// Covers bare `_`, `_>=` / `_+!` shapes, and names at the + /// `MAX_STRING_LEN` boundary. #[tag(t_prop)] #[test] fn prop_underscore_led_names_gated_by_clarity_version() { proptest!(|(name in any_underscore_led_clarity_name())| { - // Filter to names that pass the codec regex — the generator - // pattern is a superset of the strict `ClarityName` grammar. - prop_assume!(ClarityName::try_from(name.clone()).is_ok()); - let src = format!("(define-constant {name} 1)"); - // Pre-Clarity-6: reject with the specific error variant. let err = parse_err(&src, ClarityVersion::Clarity5); prop_assert!( matches!(&err, ParseErrorKind::UnderscoreIdentifierNotAllowed(n) if n == &name), "expected UnderscoreIdentifierNotAllowed({name:?}), got {err:?}" ); - // Clarity 6: accept. prop_assert!( parses(&src, ClarityVersion::Clarity6), "expected `_`-led name {name:?} to parse in Clarity 6" diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index c1e7882a35a..ff35e0c1a04 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -65,12 +65,10 @@ fn test_doubly_defined_persisted_vars() { } /// SIP-04x: bare `_` is a discard binding in `let`. The value is evaluated -/// (so early-exit forms like `unwrap-panic!` would still fire) but the name -/// is not added to scope, and multiple `_` bindings in the same form do not -/// conflict. +/// (so early-exit forms like `unwrap-panic` still fire) but the name is not +/// added to scope, and multiple `_` bindings do not conflict. #[test] fn test_let_discard_bare_underscore() { - // Multiple bare-_ bindings in the same form should not raise NameAlreadyUsed. let program = "(let ((_ 1) (_ 2) (x 3)) (+ x 4))"; let result = execute_with_parameters( program, @@ -162,11 +160,9 @@ fn test_match_resp_discard_bare_underscore() { assert_eq!(result, Value::Int(2)); } -/// SIP-04x: a bare-`_` `let` binding must still *evaluate* its value -/// expression — short-circuit forms like `try!` rely on this. Using a -/// guaranteed runtime fault (division by zero) in the bound expression -/// proves the value is computed; if the discard skipped evaluation, the -/// program would return `(int 0)` rather than erroring. +/// A bare-`_` `let` binding must still evaluate its bound expression. +/// A guaranteed runtime fault in the expression proves evaluation +/// happened: were it skipped, the let would return `0` instead. #[test] fn test_let_discard_still_evaluates_value() { let program = "(let ((_ (/ 1 0))) 0)"; @@ -179,16 +175,12 @@ fn test_let_discard_still_evaluates_value() { .unwrap_err(); let msg = format!("{err:?}"); assert!( - msg.contains("DivisionByZero") || msg.contains("division") || msg.contains("Division"), - "expected a division-by-zero error from the discarded expression, got: {msg}" + msg.contains("DivisionByZero"), + "expected a division-by-zero error, got: {msg}" ); } -/// SIP-04x: `match` discard semantics extend to evaluating the matched -/// expression for side-effecting forms like `try!`. The bound value is -/// computed; it just isn't placed in scope. Here we exercise that the -/// matched expression is still evaluated by relying on its value being -/// used by branch selection (some vs. none), even though `_` isn't readable. +/// Some-arm taken with a bare-`_` bind: discards `99`, returns `11`. #[test] fn test_match_opt_none_arm_with_discard_some() { let program = "(match (some 99) _ 11 22)"; @@ -200,7 +192,6 @@ fn test_match_opt_none_arm_with_discard_some() { ) .unwrap() .unwrap(); - // Some-arm taken: discards `99`, returns `11`. None-arm (`22`) untaken. assert_eq!(result, Value::Int(11)); } From dcd782de721b1c4468f4842a657817c8621e3d2f Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 15:22:54 -0400 Subject: [PATCH 10/32] Disallow `_` as name except in `let`/`match` --- clarity/src/vm/analysis/errors.rs | 11 ++++++ .../vm/analysis/type_checker/v2_1/contexts.rs | 5 +++ clarity/src/vm/ast/underscore_checker.rs | 17 +++++---- clarity/src/vm/functions/define.rs | 3 ++ clarity/src/vm/tests/simple_apply_eval.rs | 37 +++++++++++++++++++ 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index cb8c1773df3..7a76427c4b1 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -405,6 +405,10 @@ pub enum StaticCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), + /// SIP-04x: bare `_` is reserved as a discard pattern in `let`/`match` + /// bindings and cannot be used to name a top-level definition or function + /// argument. + BareUnderscoreReserved, /// Name is a reserved word in Clarity and cannot be used. /// The `String` wraps the reserved name. ReservedWord(String), @@ -610,6 +614,10 @@ pub enum RuntimeCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), + /// SIP-04x: bare `_` is reserved as a discard pattern in `let`/`match` + /// bindings and cannot be used to name a top-level definition or function + /// argument. + BareUnderscoreReserved, /// Referenced function is not defined in the current scope. /// The `String` wraps the non-existent function name. @@ -1201,6 +1209,9 @@ impl DiagnosableError for StaticCheckErrorKind { StaticCheckErrorKind::GetStacksBlockInfoExpectPropertyName => "missing property name for stacks block info introspection".into(), StaticCheckErrorKind::GetTenureInfoExpectPropertyName => "missing property name for tenure info introspection".into(), StaticCheckErrorKind::NameAlreadyUsed(name) => format!("defining '{name}' conflicts with previous value"), + StaticCheckErrorKind::BareUnderscoreReserved => { + "'_' is reserved as a discard pattern and cannot be used as a name".into() + } StaticCheckErrorKind::ReservedWord(name) => format!("{name} is a reserved word"), StaticCheckErrorKind::NonFunctionApplication => "expecting expression of type function".into(), StaticCheckErrorKind::ExpectedListApplication => "expecting expression of type list".into(), diff --git a/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs b/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs index 2b1e152cb6a..b630857d5c6 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs @@ -167,6 +167,11 @@ impl ContractContext { } pub fn check_name_used(&self, name: &str) -> Result<(), StaticCheckError> { + if name == "_" { + return Err(StaticCheckError::new( + StaticCheckErrorKind::BareUnderscoreReserved, + )); + } if is_reserved_word(name, self.clarity_version) { return Err(StaticCheckError::new(StaticCheckErrorKind::ReservedWord( name.to_string(), diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index a8445f844eb..efb6c7c440b 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -286,14 +286,17 @@ mod tests { )); } - /// The SIP carves out bare `_` as a discard pattern *only* inside - /// `let`/`match` bindings. Outside those positions (`define-constant - /// _ …`, function-arg names) bare `_` is a regular identifier whose - /// first char is `_`, so the AST pass treats it like `_admin`. Pinned - /// down so this can't silently drift if the SIP is later read more - /// strictly. + /// Bare `_` is reserved as a discard pattern; outside `let`/`match` + /// bindings it is rejected at the analyzer/runtime layer (see + /// `check_name_used` / `check_legal_define`). The AST pass itself + /// happily lets `_` through in Clarity 6+ — the rejection lives one + /// layer up so let/match discards can use the same character. #[test] - fn bare_underscore_as_define_name_accepted_in_clarity6() { + fn bare_underscore_passes_ast_pass_in_clarity6() { + // AST pass alone accepts bare `_` as a define name; the analyzer + // will reject it. The full-pipeline rejection is covered by + // `test_bare_underscore_as_define_name_rejected_in_clarity6` in + // `vm::tests::simple_apply_eval`. assert!(parses("(define-constant _ 1)", ClarityVersion::Clarity6)); } diff --git a/clarity/src/vm/functions/define.rs b/clarity/src/vm/functions/define.rs index bceab2fbecb..ba8e7562cab 100644 --- a/clarity/src/vm/functions/define.rs +++ b/clarity/src/vm/functions/define.rs @@ -123,6 +123,9 @@ fn check_legal_define( name: &str, contract_context: &ContractContext, ) -> Result<(), RuntimeCheckErrorKind> { + if name == "_" { + return Err(RuntimeCheckErrorKind::BareUnderscoreReserved); + } if contract_context.is_name_used(name) { Err(RuntimeCheckErrorKind::NameAlreadyUsed(name.to_string())) } else { diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index ff35e0c1a04..75d8d8eb95e 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -195,6 +195,43 @@ fn test_match_opt_none_arm_with_discard_some() { assert_eq!(result, Value::Int(11)); } +/// SIP-04x: bare `_` is reserved as a discard pattern; it cannot name a +/// top-level definition. Rejected by the analyzer's `check_name_used`. +#[test] +fn test_bare_underscore_as_define_name_rejected_in_clarity6() { + let program = "(define-constant _ 1)"; + let err = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + +/// SIP-04x: bare `_` cannot name a function argument either. +#[test] +fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { + let program = "(define-public (foo (_ uint)) (ok true)) (foo u1)"; + let err = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + /// SIP-04x: a bare-`_` `match` branch must not be referenceable in its body. #[test] fn test_match_opt_discard_underscore_not_referenceable() { From 3d2c49adcecf3f17b93ccb313c36fd65f02c6f81 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 15:37:40 -0400 Subject: [PATCH 11/32] Add `DISCARD_IDENTIFIER` constant --- clarity-types/src/representations.rs | 8 ++++++++ clarity/src/vm/analysis/type_checker/v2_1/contexts.rs | 4 ++-- clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs | 5 +++-- .../src/vm/analysis/type_checker/v2_1/natives/options.rs | 6 +++--- clarity/src/vm/functions/define.rs | 4 ++-- clarity/src/vm/functions/mod.rs | 6 ++++-- clarity/src/vm/functions/options.rs | 3 ++- clarity/src/vm/representations.rs | 2 +- 8 files changed, 25 insertions(+), 13 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index 9cd805aad7e..9bc9c405d11 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -30,6 +30,14 @@ 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 reserved as a discard pattern by SIP-04x. In +/// `let` and `match` binding positions it discards the bound value; in +/// every other naming position (function/constant/map/var names, function +/// arguments, etc.) it is rejected at the analyzer/runtime layer. Rust, +/// Scala, Swift, OCaml and Haskell use the same character for the same +/// purpose (variously called the "wildcard" or "discard" pattern). +pub const DISCARD_IDENTIFIER: &str = "_"; + lazy_static! { pub static ref STANDARD_PRINCIPAL_REGEX_STRING: String = "[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}".into(); diff --git a/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs b/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs index b630857d5c6..4f46b6ca619 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/contexts.rs @@ -20,7 +20,7 @@ use crate::vm::ClarityVersion; use crate::vm::analysis::errors::{StaticCheckError, StaticCheckErrorKind}; use crate::vm::analysis::type_checker::is_reserved_word; use crate::vm::analysis::types::ContractAnalysis; -use crate::vm::representations::ClarityName; +use crate::vm::representations::{ClarityName, DISCARD_IDENTIFIER}; use crate::vm::types::signatures::FunctionSignature; use crate::vm::types::{FunctionType, QualifiedContractIdentifier, TraitIdentifier, TypeSignature}; @@ -167,7 +167,7 @@ impl ContractContext { } pub fn check_name_used(&self, name: &str) -> Result<(), StaticCheckError> { - if name == "_" { + if name == DISCARD_IDENTIFIER { return Err(StaticCheckError::new( StaticCheckErrorKind::BareUnderscoreReserved, )); diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index 9f21ae82f16..c96d5f75feb 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -28,6 +28,7 @@ use crate::vm::costs::{CostErrors, CostTracker, analysis_typecheck_cost, runtime use crate::vm::diagnostic::DiagnosableError; use crate::vm::functions::bitcoin::VERIFY_MERKLE_PROOF_MAX_DEPTH; use crate::vm::functions::{NativeFunctions, handle_binding_list}; +use crate::vm::representations::DISCARD_IDENTIFIER; use crate::vm::types::signatures::{ CallableSubtype, FunctionArgSignature, FunctionReturnsSignature, SequenceSubtype, }; @@ -309,8 +310,8 @@ fn check_special_let( // type-check the value (so its type errors surface) but don't // add the name to the typing context and skip name-collision // checks across repeated discards. - let is_discard = - var_name.as_str() == "_" && checker.clarity_version >= ClarityVersion::Clarity6; + let is_discard = var_name.as_str() == DISCARD_IDENTIFIER + && checker.clarity_version >= ClarityVersion::Clarity6; if !is_discard { checker.contract_context.check_name_used(var_name)?; if out_context.lookup_variable_type(var_name).is_some() { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs index de1cae1af86..4b0fbbb9bf0 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use clarity_types::representations::ClarityName; +use clarity_types::representations::{ClarityName, DISCARD_IDENTIFIER}; use clarity_types::types::TypeSignature; use stacks_common::types::StacksEpochId; @@ -310,8 +310,8 @@ fn eval_with_new_binding( // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` // discards the matched value — skip name-collision checks and don't // place the name in the typing context for the branch body. - let is_discard = - bind_name.as_str() == "_" && checker.clarity_version >= ClarityVersion::Clarity6; + let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER + && checker.clarity_version >= ClarityVersion::Clarity6; if !is_discard { checker.contract_context.check_name_used(&bind_name)?; diff --git a/clarity/src/vm/functions/define.rs b/clarity/src/vm/functions/define.rs index ba8e7562cab..97be3d41bb3 100644 --- a/clarity/src/vm/functions/define.rs +++ b/clarity/src/vm/functions/define.rs @@ -24,7 +24,7 @@ use crate::vm::errors::{ }; use crate::vm::eval; use crate::vm::representations::SymbolicExpressionType::Field; -use crate::vm::representations::{ClarityName, SymbolicExpression}; +use crate::vm::representations::{ClarityName, DISCARD_IDENTIFIER, SymbolicExpression}; use crate::vm::types::signatures::FunctionSignature; use crate::vm::types::{ TraitIdentifier, TypeSignature, TypeSignatureExt as _, Value, parse_name_type_pairs, @@ -123,7 +123,7 @@ fn check_legal_define( name: &str, contract_context: &ContractContext, ) -> Result<(), RuntimeCheckErrorKind> { - if name == "_" { + if name == DISCARD_IDENTIFIER { return Err(RuntimeCheckErrorKind::BareUnderscoreReserved); } if contract_context.is_name_used(name) { diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index 8c32afc0461..8f215a34dc1 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -27,7 +27,9 @@ use crate::vm::errors::{ VmExecutionError, check_argument_count, check_arguments_at_least, }; pub use crate::vm::functions::assets::stx_transfer_consolidated; -use crate::vm::representations::{ClarityName, SymbolicExpression, SymbolicExpressionType}; +use crate::vm::representations::{ + ClarityName, DISCARD_IDENTIFIER, SymbolicExpression, SymbolicExpressionType, +}; use crate::vm::types::{PrincipalData, TypeSignature, Value}; use crate::vm::{LocalContext, eval, is_reserved}; @@ -810,7 +812,7 @@ fn special_let( // expression (preserving `try!`/`unwrap!` short-circuits) but do // not place it in scope, and do not treat repeated `_` bindings // as name conflicts. - let is_discard = binding_name.as_str() == "_" + let is_discard = binding_name.as_str() == DISCARD_IDENTIFIER && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; if !is_discard diff --git a/clarity/src/vm/functions/options.rs b/clarity/src/vm/functions/options.rs index 04d0fff29ab..811f0bd8434 100644 --- a/clarity/src/vm/functions/options.rs +++ b/clarity/src/vm/functions/options.rs @@ -22,6 +22,7 @@ use crate::vm::errors::{ EarlyReturnError, RuntimeCheckErrorKind, RuntimeError, VmExecutionError, VmInternalError, check_arguments_at_least, }; +use crate::vm::representations::DISCARD_IDENTIFIER; use crate::vm::types::{CallableData, OptionalData, ResponseData, TypeSignature, Value}; use crate::vm::{self, ClarityName, ClarityVersion, SymbolicExpression}; @@ -131,7 +132,7 @@ fn eval_with_new_binding( // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` // discards the value — execute the branch without binding the name and // without raising `NameAlreadyUsed` on re-use across nested match arms. - let is_discard = bind_name.as_str() == "_" + let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; if !is_discard && (vm::is_reserved( diff --git a/clarity/src/vm/representations.rs b/clarity/src/vm/representations.rs index 23db9e05905..7f2c6370ed0 100644 --- a/clarity/src/vm/representations.rs +++ b/clarity/src/vm/representations.rs @@ -17,7 +17,7 @@ pub use clarity_types::representations::{ CLARITY_NAME_REGEX, CLARITY_NAME_REGEX_STRING, CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, CONTRACT_NAME_REGEX, CONTRACT_NAME_REGEX_STRING, - CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, MAX_STRING_LEN, + CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, DISCARD_IDENTIFIER, MAX_STRING_LEN, PRINCIPAL_DATA_REGEX_STRING, PreSymbolicExpression, PreSymbolicExpressionType, STANDARD_PRINCIPAL_REGEX_STRING, Span, SymbolicExpression, SymbolicExpressionCommon, SymbolicExpressionType, TraitDefinition, depth_traverse, From 3fc5f06e50b9409fcaf047a0fee3c9572bdf9109 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 15:58:54 -0400 Subject: [PATCH 12/32] Disallow bare `_` in traits and tuple keys, and add tests --- clarity/src/vm/analysis/errors.rs | 9 ++ .../analysis/type_checker/v2_1/natives/mod.rs | 5 + clarity/src/vm/functions/define.rs | 14 ++- clarity/src/vm/functions/tuples.rs | 9 +- clarity/src/vm/tests/simple_apply_eval.rs | 95 +++++++++++++++++++ clarity/src/vm/types/signatures.rs | 5 + stacks-node/src/tests/signer/multiversion.rs | 5 +- stacks-node/src/tests/stackerdb.rs | 3 +- stackslib/src/net/http/response.rs | 3 +- 9 files changed, 141 insertions(+), 7 deletions(-) diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index 7a76427c4b1..ad4c8d5d814 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -215,6 +215,9 @@ pub enum CommonCheckErrorKind { /// Too many trait methods specified. /// The first `usize` represents the number of methods found, the second the maximum allowed. TraitTooManyMethods(usize, usize), + /// SIP-04x: bare `_` cannot be used as a trait method name, tuple key, or + /// any other position covered by shared validation flow. + BareUnderscoreReserved, } /// An error detected during the static analysis of a smart contract at deployment time. @@ -1026,6 +1029,9 @@ impl From for RuntimeCheckErrorKind { CommonCheckErrorKind::UnknownTypeName(name) => { RuntimeCheckErrorKind::Unreachable(format!("Unknown type name: {name}")) } + CommonCheckErrorKind::BareUnderscoreReserved => { + RuntimeCheckErrorKind::BareUnderscoreReserved + } } } } @@ -1067,6 +1073,9 @@ impl From for StaticCheckErrorKind { CommonCheckErrorKind::UnknownTypeName(name) => { StaticCheckErrorKind::UnknownTypeName(name) } + CommonCheckErrorKind::BareUnderscoreReserved => { + StaticCheckErrorKind::BareUnderscoreReserved + } } } } diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index c96d5f75feb..ab518f1b99a 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -254,6 +254,11 @@ pub fn check_special_tuple_cons( args, SyntaxBindingErrorType::TupleCons, |var_name, var_sexp| { + // SIP-04x: bare `_` cannot name a tuple key — it would be + // referenceable via `get`, contradicting the discard semantics. + if var_name.as_str() == DISCARD_IDENTIFIER { + return Err(StaticCheckErrorKind::BareUnderscoreReserved.into()); + } checker.type_check(var_sexp, context).and_then(|var_type| { runtime_cost( ClarityCostFunction::AnalysisTupleItemsCheck, diff --git a/clarity/src/vm/functions/define.rs b/clarity/src/vm/functions/define.rs index 97be3d41bb3..eb6c93b3036 100644 --- a/clarity/src/vm/functions/define.rs +++ b/clarity/src/vm/functions/define.rs @@ -297,8 +297,16 @@ fn handle_define_trait( Ok(DefineResult::Trait(name.clone(), trait_signature)) } -fn handle_use_trait(name: &ClarityName, trait_identifier: &TraitIdentifier) -> DefineResult { - DefineResult::UseTrait(name.clone(), trait_identifier.clone()) +fn handle_use_trait( + name: &ClarityName, + trait_identifier: &TraitIdentifier, + invoke_ctx: &InvocationContext, +) -> Result { + check_legal_define(name, invoke_ctx.contract_context)?; + Ok(DefineResult::UseTrait( + name.clone(), + trait_identifier.clone(), + )) } fn handle_impl_trait(trait_identifier: &TraitIdentifier) -> DefineResult { @@ -501,7 +509,7 @@ pub fn evaluate_define( DefineFunctionsParsed::UseTrait { name, trait_identifier, - } => Ok(handle_use_trait(name, trait_identifier)), + } => handle_use_trait(name, trait_identifier, invoke_ctx), DefineFunctionsParsed::ImplTrait { trait_identifier } => { Ok(handle_impl_trait(trait_identifier)) } diff --git a/clarity/src/vm/functions/tuples.rs b/clarity/src/vm/functions/tuples.rs index d3f062db07e..e446b2c443d 100644 --- a/clarity/src/vm/functions/tuples.rs +++ b/clarity/src/vm/functions/tuples.rs @@ -20,7 +20,7 @@ use crate::vm::errors::{ RuntimeCheckErrorKind, SyntaxBindingErrorType, VmExecutionError, VmInternalError, check_argument_count, check_arguments_at_least, }; -use crate::vm::representations::SymbolicExpression; +use crate::vm::representations::{DISCARD_IDENTIFIER, SymbolicExpression}; use crate::vm::types::{TupleData, TypeSignature, Value}; use crate::vm::{LocalContext, eval}; @@ -43,6 +43,13 @@ pub fn tuple_cons( invoke_ctx, context, )?; + // SIP-04x: bare `_` is reserved as a discard pattern and cannot be used + // as a tuple key (it would create a referenceable binding via `get`). + for (name, _) in &bindings { + if name.as_str() == DISCARD_IDENTIFIER { + return Err(RuntimeCheckErrorKind::BareUnderscoreReserved.into()); + } + } runtime_cost(ClarityCostFunction::TupleCons, exec_state, bindings.len())?; Ok(TupleData::from_data(bindings).map(Value::from)?) diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 75d8d8eb95e..35586bc730a 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -101,6 +101,47 @@ fn test_let_discard_underscore_not_referenceable() { ); } +/// SIP-04x: a bare-`_` `let` binding must short-circuit on `try!` just +/// like a regular binding would — the SIP's worked example uses this. +#[test] +fn test_let_discard_with_try_short_circuits() { + let program = "(define-public (foo) + (let ((_ (try! (err u7)))) + (ok u0))) + (foo)"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + // `try!` should propagate `(err u7)` out of `foo` instead of returning `(ok u0)`. + let msg = format!("{result:?}"); + assert!( + msg.contains("Response(ResponseData") && msg.contains("UInt(7)"), + "expected `(err u7)` propagated by `try!`, got: {msg}" + ); +} + +/// Symmetry with `test_let_underscore_prefix_is_regular_binding`: +/// underscore-prefixed match-arm names are regular bindings (not discards) +/// and can be referenced in the branch body. +#[test] +fn test_match_underscore_prefix_is_regular_binding() { + let program = "(match (some 42) _val _val 0)"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(42)); +} + /// SIP-04x: underscore-prefixed names (e.g. `_admin`) are *regular* bindings /// — the leading `_` is just a convention. They can be read back. #[test] @@ -214,6 +255,60 @@ fn test_bare_underscore_as_define_name_rejected_in_clarity6() { ); } +/// SIP-04x: bare `_` cannot name a `use-trait` alias — would create a +/// referenceable `<_>` trait alias otherwise. +#[test] +fn test_bare_underscore_as_use_trait_alias_rejected_in_clarity6() { + let err = execute_with_parameters( + "(use-trait _ 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7.foo.bar)", + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + +/// SIP-04x: bare `_` cannot name a `define-trait` method — implementing +/// contracts would have a referenceable `_` function. +#[test] +fn test_bare_underscore_as_trait_method_rejected_in_clarity6() { + let err = execute_with_parameters( + "(define-trait t ((_ (uint) (response uint uint))))", + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + +/// SIP-04x: bare `_` cannot be a tuple key — `(get _ tup)` would resolve +/// the value, making `_` referenceable. +#[test] +fn test_bare_underscore_as_tuple_key_rejected_in_clarity6() { + let err = execute_with_parameters( + "(define-constant x { _: u1 })", + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + /// SIP-04x: bare `_` cannot name a function argument either. #[test] fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs index 62659be5daa..b6bbe037c06 100644 --- a/clarity/src/vm/types/signatures.rs +++ b/clarity/src/vm/types/signatures.rs @@ -439,6 +439,11 @@ impl TypeSignatureExt for TypeSignature { let fn_name = args[0] .match_atom() .ok_or(CommonCheckErrorKind::DefineTraitBadSignature)?; + // SIP-04x: bare `_` is reserved as a discard pattern and cannot + // name a trait method. + if fn_name.as_str() == clarity_types::representations::DISCARD_IDENTIFIER { + return Err(CommonCheckErrorKind::BareUnderscoreReserved); + } // Extract function's arguments let fn_args_exprs = args[1] diff --git a/stacks-node/src/tests/signer/multiversion.rs b/stacks-node/src/tests/signer/multiversion.rs index acb5a20bda6..aff6dbc0ee4 100644 --- a/stacks-node/src/tests/signer/multiversion.rs +++ b/stacks-node/src/tests/signer/multiversion.rs @@ -20,19 +20,22 @@ use libsigner::v0::messages::{ SignerMessageMetadata, }; use libsigner::v0::signer_state::{MinerState, ReplayTransactionSet, SignerStateMachine}; +use libsigner_v3_3_0_0_5; use libsigner_v3_3_0_0_5::v0::messages::SignerMessage as OldSignerMessage; +use signer_v3_3_0_0_5_0; use signer_v3_3_0_0_5_0::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION as OldSupportedVersion; use stacks::chainstate::stacks::StacksTransaction; use stacks::util::hash::{Hash160, Sha512Trunc256Sum}; use stacks::util::secp256k1::{MessageSignature, Secp256k1PrivateKey}; use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId}; +use stacks_common_v3_3_0_0_5; use stacks_common_v3_3_0_0_5::codec::StacksMessageCodec as OldStacksMessageCodec; use stacks_signer::runloop::{RewardCycleInfo, State, StateInfo}; use stacks_signer::v0::signer_state::{ LocalStateMachine, SUPPORTED_SIGNER_PROTOCOL_VERSION as NewSupportedVersion, }; use stacks_signer::v0::SpawnedSigner; -use {libsigner_v3_3_0_0_5, signer_v3_3_0_0_5_0, stacks_common_v3_3_0_0_5, stacks_v3_3_0_0_5}; +use stacks_v3_3_0_0_5; use super::SpawnedSignerTrait; use crate::stacks_common::codec::StacksMessageCodec; diff --git a/stacks-node/src/tests/stackerdb.rs b/stacks-node/src/tests/stackerdb.rs index 5ddd5fa06db..7130e72460b 100644 --- a/stacks-node/src/tests/stackerdb.rs +++ b/stacks-node/src/tests/stackerdb.rs @@ -18,12 +18,13 @@ use std::{env, thread}; use clarity::vm::types::QualifiedContractIdentifier; use clarity::vm::ContractName; +use reqwest; +use serde_json; use stacks::chainstate::stacks::StacksPrivateKey; use stacks::config::{EventKeyType, InitialBalance}; use stacks::libstackerdb::{StackerDBChunkAckData, StackerDBChunkData}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::util::hash::Sha512Trunc256Sum; -use {reqwest, serde_json}; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; use crate::burnchains::BurnchainController; diff --git a/stackslib/src/net/http/response.rs b/stackslib/src/net/http/response.rs index 1428352e3e8..7cf3ec8d58c 100644 --- a/stackslib/src/net/http/response.rs +++ b/stackslib/src/net/http/response.rs @@ -18,6 +18,8 @@ use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::io::{Read, Write}; +use serde; +use serde_json; use stacks_common::codec::{Error as CodecError, StacksMessageCodec}; use stacks_common::deps_common::httparse; use stacks_common::util::chunked_encoding::{ @@ -25,7 +27,6 @@ use stacks_common::util::chunked_encoding::{ }; use stacks_common::util::hash::to_hex; use stacks_common::util::pipe::PipeWrite; -use {serde, serde_json}; use crate::net::http::common::{ HttpReservedHeader, HTTP_PREAMBLE_MAX_ENCODED_SIZE, HTTP_PREAMBLE_MAX_NUM_HEADERS, From 5b851d08bfe061f00bf4c3feb4a9e5696cd75d0e Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 16:13:33 -0400 Subject: [PATCH 13/32] Add test for nested `let` expressions using `_` --- clarity/src/vm/tests/simple_apply_eval.rs | 19 +++++++++++++++++++ stacks-node/src/tests/signer/multiversion.rs | 5 +---- stacks-node/src/tests/stackerdb.rs | 3 +-- stackslib/src/net/http/response.rs | 3 +-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 35586bc730a..990387ed951 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -125,6 +125,25 @@ fn test_let_discard_with_try_short_circuits() { ); } +/// Nested `let`s where both outer and inner bind bare `_`. Each scope's +/// discard is independent — neither should leak the other's `_` nor raise +/// `NameAlreadyUsed` on the inner binding. +#[test] +fn test_nested_let_with_bare_underscore_discards() { + let program = "(let ((_ 1) (x 10)) + (let ((_ 2) (y 20)) + (+ x y)))"; + let result = execute_with_parameters( + program, + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result, Value::Int(30)); +} + /// Symmetry with `test_let_underscore_prefix_is_regular_binding`: /// underscore-prefixed match-arm names are regular bindings (not discards) /// and can be referenced in the branch body. diff --git a/stacks-node/src/tests/signer/multiversion.rs b/stacks-node/src/tests/signer/multiversion.rs index aff6dbc0ee4..acb5a20bda6 100644 --- a/stacks-node/src/tests/signer/multiversion.rs +++ b/stacks-node/src/tests/signer/multiversion.rs @@ -20,22 +20,19 @@ use libsigner::v0::messages::{ SignerMessageMetadata, }; use libsigner::v0::signer_state::{MinerState, ReplayTransactionSet, SignerStateMachine}; -use libsigner_v3_3_0_0_5; use libsigner_v3_3_0_0_5::v0::messages::SignerMessage as OldSignerMessage; -use signer_v3_3_0_0_5_0; use signer_v3_3_0_0_5_0::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION as OldSupportedVersion; use stacks::chainstate::stacks::StacksTransaction; use stacks::util::hash::{Hash160, Sha512Trunc256Sum}; use stacks::util::secp256k1::{MessageSignature, Secp256k1PrivateKey}; use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId}; -use stacks_common_v3_3_0_0_5; use stacks_common_v3_3_0_0_5::codec::StacksMessageCodec as OldStacksMessageCodec; use stacks_signer::runloop::{RewardCycleInfo, State, StateInfo}; use stacks_signer::v0::signer_state::{ LocalStateMachine, SUPPORTED_SIGNER_PROTOCOL_VERSION as NewSupportedVersion, }; use stacks_signer::v0::SpawnedSigner; -use stacks_v3_3_0_0_5; +use {libsigner_v3_3_0_0_5, signer_v3_3_0_0_5_0, stacks_common_v3_3_0_0_5, stacks_v3_3_0_0_5}; use super::SpawnedSignerTrait; use crate::stacks_common::codec::StacksMessageCodec; diff --git a/stacks-node/src/tests/stackerdb.rs b/stacks-node/src/tests/stackerdb.rs index 7130e72460b..5ddd5fa06db 100644 --- a/stacks-node/src/tests/stackerdb.rs +++ b/stacks-node/src/tests/stackerdb.rs @@ -18,13 +18,12 @@ use std::{env, thread}; use clarity::vm::types::QualifiedContractIdentifier; use clarity::vm::ContractName; -use reqwest; -use serde_json; use stacks::chainstate::stacks::StacksPrivateKey; use stacks::config::{EventKeyType, InitialBalance}; use stacks::libstackerdb::{StackerDBChunkAckData, StackerDBChunkData}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::util::hash::Sha512Trunc256Sum; +use {reqwest, serde_json}; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; use crate::burnchains::BurnchainController; diff --git a/stackslib/src/net/http/response.rs b/stackslib/src/net/http/response.rs index 7cf3ec8d58c..1428352e3e8 100644 --- a/stackslib/src/net/http/response.rs +++ b/stackslib/src/net/http/response.rs @@ -18,8 +18,6 @@ use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::io::{Read, Write}; -use serde; -use serde_json; use stacks_common::codec::{Error as CodecError, StacksMessageCodec}; use stacks_common::deps_common::httparse; use stacks_common::util::chunked_encoding::{ @@ -27,6 +25,7 @@ use stacks_common::util::chunked_encoding::{ }; use stacks_common::util::hash::to_hex; use stacks_common::util::pipe::PipeWrite; +use {serde, serde_json}; use crate::net::http::common::{ HttpReservedHeader, HTTP_PREAMBLE_MAX_ENCODED_SIZE, HTTP_PREAMBLE_MAX_NUM_HEADERS, From e2d2961770c4ac0ece72ccaa33a9db50525dbdc3 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 16:30:34 -0400 Subject: [PATCH 14/32] Replace "SIP-04x" -> "Clarity 6" --- clarity-types/src/representations.rs | 4 ++-- clarity/src/vm/analysis/errors.rs | 6 ++--- .../analysis/type_checker/v2_1/natives/mod.rs | 4 ++-- .../type_checker/v2_1/natives/options.rs | 2 +- clarity/src/vm/ast/errors.rs | 2 +- clarity/src/vm/ast/mod.rs | 2 +- clarity/src/vm/ast/parser/v2/lexer/mod.rs | 6 ++--- clarity/src/vm/ast/underscore_checker.rs | 2 +- clarity/src/vm/functions/mod.rs | 2 +- clarity/src/vm/functions/options.rs | 2 +- clarity/src/vm/functions/tuples.rs | 2 +- clarity/src/vm/tests/representations.rs | 2 +- clarity/src/vm/tests/simple_apply_eval.rs | 24 +++++++++---------- clarity/src/vm/types/signatures.rs | 2 +- stackslib/src/chainstate/tests/parse_tests.rs | 2 +- 15 files changed, 32 insertions(+), 32 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index 9bc9c405d11..c16e3eb4d63 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -30,7 +30,7 @@ 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 reserved as a discard pattern by SIP-04x. In +/// The bare `_` identifier reserved as a discard pattern in Clarity 6. In /// `let` and `match` binding positions it discards the bound value; in /// every other naming position (function/constant/map/var names, function /// arguments, etc.) it is rejected at the analyzer/runtime layer. Rust, @@ -60,7 +60,7 @@ lazy_static! { // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` (including // the bare `_`). The `_` leading position is accepted // unconditionally at the codec/lexer level per - // Clarity 6 SIP-04x; pre-Clarity-6 ASTs reject these + // Clarity 6; pre-Clarity-6 ASTs reject these // at the parser pass. // 2) `[-+=/*]` — single-char operator name. // 3) `[<>]=?` — comparison operator name. diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index ad4c8d5d814..b7c6bb3d635 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -215,7 +215,7 @@ pub enum CommonCheckErrorKind { /// Too many trait methods specified. /// The first `usize` represents the number of methods found, the second the maximum allowed. TraitTooManyMethods(usize, usize), - /// SIP-04x: bare `_` cannot be used as a trait method name, tuple key, or + /// Clarity 6: bare `_` cannot be used as a trait method name, tuple key, or /// any other position covered by shared validation flow. BareUnderscoreReserved, } @@ -408,7 +408,7 @@ pub enum StaticCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), - /// SIP-04x: bare `_` is reserved as a discard pattern in `let`/`match` + /// Clarity 6: bare `_` is reserved as a discard pattern in `let`/`match` /// bindings and cannot be used to name a top-level definition or function /// argument. BareUnderscoreReserved, @@ -617,7 +617,7 @@ pub enum RuntimeCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), - /// SIP-04x: bare `_` is reserved as a discard pattern in `let`/`match` + /// Clarity 6: bare `_` is reserved as a discard pattern in `let`/`match` /// bindings and cannot be used to name a top-level definition or function /// argument. BareUnderscoreReserved, diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index ab518f1b99a..ce73cb1ae7a 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -254,7 +254,7 @@ pub fn check_special_tuple_cons( args, SyntaxBindingErrorType::TupleCons, |var_name, var_sexp| { - // SIP-04x: bare `_` cannot name a tuple key — it would be + // Clarity 6: bare `_` cannot name a tuple key — it would be // referenceable via `get`, contradicting the discard semantics. if var_name.as_str() == DISCARD_IDENTIFIER { return Err(StaticCheckErrorKind::BareUnderscoreReserved.into()); @@ -311,7 +311,7 @@ fn check_special_let( binding_list, SyntaxBindingErrorType::Let, |var_name, var_sexp| { - // SIP-04x: bare `_` is a discard binding in Clarity 6 — still + // Clarity 6: bare `_` is a discard binding — still // type-check the value (so its type errors surface) but don't // add the name to the typing context and skip name-collision // checks across repeated discards. diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs index 4b0fbbb9bf0..2368012f348 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs @@ -307,7 +307,7 @@ fn eval_with_new_binding( .ok_or_else(|| CostErrors::CostOverflow)?; checker.add_memory(memory_use)?; } - // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` + // Clarity 6: a `match` arm whose bind name is bare `_` // discards the matched value — skip name-collision checks and don't // place the name in the typing context for the branch body. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER diff --git a/clarity/src/vm/ast/errors.rs b/clarity/src/vm/ast/errors.rs index 064c2dfe9ee..4a478135a54 100644 --- a/clarity/src/vm/ast/errors.rs +++ b/clarity/src/vm/ast/errors.rs @@ -177,7 +177,7 @@ pub enum ParseErrorKind { /// The `String` represents the invalid contract name. IllegalContractName(String), /// Identifier starts with an underscore in a Clarity version that predates - /// `ClarityVersion::Clarity6` (SIP-04x), where leading-`_` names are not yet + /// `ClarityVersion::Clarity6`, where leading-`_` names are not yet /// permitted. The `String` is the offending name. UnderscoreIdentifierNotAllowed(String), diff --git a/clarity/src/vm/ast/mod.rs b/clarity/src/vm/ast/mod.rs index 2f991727349..dba426f0cbf 100644 --- a/clarity/src/vm/ast/mod.rs +++ b/clarity/src/vm/ast/mod.rs @@ -196,7 +196,7 @@ fn inner_build_ast( _ => (), } - // SIP-04x: reject identifiers beginning with `_` for `ClarityVersion < + // Clarity 6: reject identifiers beginning with `_` for `ClarityVersion < // Clarity6`. The wire-level regex and the v2 lexer both accept them so // that the parser can produce a precise diagnostic here. match UnderscoreIdentifierChecker::run_pass(&mut contract_ast, clarity_version, epoch) { diff --git a/clarity/src/vm/ast/parser/v2/lexer/mod.rs b/clarity/src/vm/ast/parser/v2/lexer/mod.rs index c898e350dd7..586d79a009b 100644 --- a/clarity/src/vm/ast/parser/v2/lexer/mod.rs +++ b/clarity/src/vm/ast/parser/v2/lexer/mod.rs @@ -734,9 +734,9 @@ impl<'a> Lexer<'a> { if self.next == '=' { Token::LessEqual } else if self.next.is_ascii_alphabetic() || self.next == '_' { - // `_` may lead a trait identifier in Clarity 6 onwards - // (SIP-04x); accept unconditionally here and let the - // version-gated AST pass reject in older versions. + // `_` may lead a trait identifier in Clarity 6 onwards; + // accept unconditionally here and let the version-gated + // AST pass reject in older versions. self.read_trait_identifier()? } else { advance = false; diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index efb6c7c440b..28999567f06 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -19,7 +19,7 @@ //! The wire-level `ClarityName` regex and the v2 lexer accept underscore-led //! names unconditionally so that the parser can produce a well-formed AST and //! report a precise, version-aware diagnostic here rather than a generic -//! "illegal name" lexer error. SIP-04x permits the relaxation only for +//! "illegal name" lexer error. Clarity 6 permits the relaxation only from //! `ClarityVersion::Clarity6` onwards. use clarity_types::representations::ClarityName; diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index 8f215a34dc1..b38cbc64d56 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -808,7 +808,7 @@ fn special_let( finally_drop_memory!( exec_state, memory_use; { handle_binding_list::<_, VmExecutionError>(bindings, SyntaxBindingErrorType::Let, |binding_name, var_sexp| { - // SIP-04x: a bare `_` is a discard binding. Evaluate the bound + // Clarity 6: a bare `_` is a discard binding. Evaluate the bound // expression (preserving `try!`/`unwrap!` short-circuits) but do // not place it in scope, and do not treat repeated `_` bindings // as name conflicts. diff --git a/clarity/src/vm/functions/options.rs b/clarity/src/vm/functions/options.rs index 811f0bd8434..0eb2be814cf 100644 --- a/clarity/src/vm/functions/options.rs +++ b/clarity/src/vm/functions/options.rs @@ -129,7 +129,7 @@ fn eval_with_new_binding( context: &LocalContext, ) -> Result { let mut inner_context = context.extend()?; - // SIP-04x: in Clarity 6, a `match` arm whose bind name is bare `_` + // Clarity 6: a `match` arm whose bind name is bare `_` // discards the value — execute the branch without binding the name and // without raising `NameAlreadyUsed` on re-use across nested match arms. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER diff --git a/clarity/src/vm/functions/tuples.rs b/clarity/src/vm/functions/tuples.rs index e446b2c443d..e4b7d5b6c4c 100644 --- a/clarity/src/vm/functions/tuples.rs +++ b/clarity/src/vm/functions/tuples.rs @@ -43,7 +43,7 @@ pub fn tuple_cons( invoke_ctx, context, )?; - // SIP-04x: bare `_` is reserved as a discard pattern and cannot be used + // Clarity 6: bare `_` is reserved as a discard pattern and cannot be used // as a tuple key (it would create a referenceable binding via `get`). for (name, _) in &bindings { if name.as_str() == DISCARD_IDENTIFIER { diff --git a/clarity/src/vm/tests/representations.rs b/clarity/src/vm/tests/representations.rs index e291906ae6f..43218a034be 100644 --- a/clarity/src/vm/tests/representations.rs +++ b/clarity/src/vm/tests/representations.rs @@ -34,7 +34,7 @@ fn assert_regex_unchanged(actual: &str, expected: &str) { /// This function creates a branched strategy based on the `CLARITY_NAME_REGEX_STRING` pattern. /// /// The strategy covers three categories of valid names: -/// - Identifier names starting with a letter or `_` (Clarity 6 / SIP-04x added +/// - Identifier names starting with a letter or `_` (Clarity 6 added /// the `_` leading position, including the bare `_` discard name) followed /// by zero or more alphanumeric or symbol characters /// - Single arithmetic operators (`-`, `+`, `=`, `/`, `*`) diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 990387ed951..bc0b110e90f 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -64,7 +64,7 @@ fn test_doubly_defined_persisted_vars() { } } -/// SIP-04x: bare `_` is a discard binding in `let`. The value is evaluated +/// Clarity 6: bare `_` is a discard binding in `let`. The value is evaluated /// (so early-exit forms like `unwrap-panic` still fire) but the name is not /// added to scope, and multiple `_` bindings do not conflict. #[test] @@ -81,7 +81,7 @@ fn test_let_discard_bare_underscore() { assert_eq!(result, Value::Int(7)); } -/// SIP-04x: a bare `_` binding in `let` does not place `_` in scope. The body +/// Clarity 6: a bare `_` binding in `let` does not place `_` in scope. The body /// referring to `_` should fail with an unbound-variable error rather than /// returning the discarded value. #[test] @@ -101,7 +101,7 @@ fn test_let_discard_underscore_not_referenceable() { ); } -/// SIP-04x: a bare-`_` `let` binding must short-circuit on `try!` just +/// Clarity 6: a bare-`_` `let` binding must short-circuit on `try!` just /// like a regular binding would — the SIP's worked example uses this. #[test] fn test_let_discard_with_try_short_circuits() { @@ -161,7 +161,7 @@ fn test_match_underscore_prefix_is_regular_binding() { assert_eq!(result, Value::Int(42)); } -/// SIP-04x: underscore-prefixed names (e.g. `_admin`) are *regular* bindings +/// Clarity 6: underscore-prefixed names (e.g. `_admin`) are *regular* bindings /// — the leading `_` is just a convention. They can be read back. #[test] fn test_let_underscore_prefix_is_regular_binding() { @@ -177,7 +177,7 @@ fn test_let_underscore_prefix_is_regular_binding() { assert_eq!(result, Value::Int(42)); } -/// SIP-04x: bare `_` in `match` (optional form) discards the matched value +/// Clarity 6: bare `_` in `match` (optional form) discards the matched value /// without binding the name. #[test] fn test_match_opt_discard_bare_underscore() { @@ -193,7 +193,7 @@ fn test_match_opt_discard_bare_underscore() { assert_eq!(result, Value::Int(1)); } -/// SIP-04x: bare `_` in `match` (response form) discards on both arms. +/// Clarity 6: bare `_` in `match` (response form) discards on both arms. #[test] fn test_match_resp_discard_bare_underscore() { let program_ok = "(match (ok 7) _ 1 _ 2)"; @@ -255,7 +255,7 @@ fn test_match_opt_none_arm_with_discard_some() { assert_eq!(result, Value::Int(11)); } -/// SIP-04x: bare `_` is reserved as a discard pattern; it cannot name a +/// Clarity 6: bare `_` is reserved as a discard pattern; it cannot name a /// top-level definition. Rejected by the analyzer's `check_name_used`. #[test] fn test_bare_underscore_as_define_name_rejected_in_clarity6() { @@ -274,7 +274,7 @@ fn test_bare_underscore_as_define_name_rejected_in_clarity6() { ); } -/// SIP-04x: bare `_` cannot name a `use-trait` alias — would create a +/// Clarity 6: bare `_` cannot name a `use-trait` alias — would create a /// referenceable `<_>` trait alias otherwise. #[test] fn test_bare_underscore_as_use_trait_alias_rejected_in_clarity6() { @@ -292,7 +292,7 @@ fn test_bare_underscore_as_use_trait_alias_rejected_in_clarity6() { ); } -/// SIP-04x: bare `_` cannot name a `define-trait` method — implementing +/// Clarity 6: bare `_` cannot name a `define-trait` method — implementing /// contracts would have a referenceable `_` function. #[test] fn test_bare_underscore_as_trait_method_rejected_in_clarity6() { @@ -310,7 +310,7 @@ fn test_bare_underscore_as_trait_method_rejected_in_clarity6() { ); } -/// SIP-04x: bare `_` cannot be a tuple key — `(get _ tup)` would resolve +/// Clarity 6: bare `_` cannot be a tuple key — `(get _ tup)` would resolve /// the value, making `_` referenceable. #[test] fn test_bare_underscore_as_tuple_key_rejected_in_clarity6() { @@ -328,7 +328,7 @@ fn test_bare_underscore_as_tuple_key_rejected_in_clarity6() { ); } -/// SIP-04x: bare `_` cannot name a function argument either. +/// Clarity 6: bare `_` cannot name a function argument either. #[test] fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { let program = "(define-public (foo (_ uint)) (ok true)) (foo u1)"; @@ -346,7 +346,7 @@ fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { ); } -/// SIP-04x: a bare-`_` `match` branch must not be referenceable in its body. +/// Clarity 6: a bare-`_` `match` branch must not be referenceable in its body. #[test] fn test_match_opt_discard_underscore_not_referenceable() { let program = "(match (some 5) _ _ 0)"; diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs index b6bbe037c06..08d24970c66 100644 --- a/clarity/src/vm/types/signatures.rs +++ b/clarity/src/vm/types/signatures.rs @@ -439,7 +439,7 @@ impl TypeSignatureExt for TypeSignature { let fn_name = args[0] .match_atom() .ok_or(CommonCheckErrorKind::DefineTraitBadSignature)?; - // SIP-04x: bare `_` is reserved as a discard pattern and cannot + // Clarity 6: bare `_` is reserved as a discard pattern and cannot // name a trait method. if fn_name.as_str() == clarity_types::representations::DISCARD_IDENTIFIER { return Err(CommonCheckErrorKind::BareUnderscoreReserved); diff --git a/stackslib/src/chainstate/tests/parse_tests.rs b/stackslib/src/chainstate/tests/parse_tests.rs index 6f7f7cf28a4..91be7c8891a 100644 --- a/stackslib/src/chainstate/tests/parse_tests.rs +++ b/stackslib/src/chainstate/tests/parse_tests.rs @@ -96,7 +96,7 @@ fn variant_coverage_report(variant: ParseErrorKind) { IllegalClarityName(_) => Unreachable_Functionally("prevented by Lexer checks returning `Lexer` variant"), IllegalASCIIString(_) => Tested(vec![test_illegal_ascii_string]), IllegalContractName(_) => Unreachable_Functionally("prevented by Lexer checks returning `Lexer` variant or Parser by MAX_CONTRACT_NAME_LEN returning `ContractNameTooLong` variant"), - UnderscoreIdentifierNotAllowed(_) => Ignored("Reachable via deploys of pre-Clarity-6 contracts that contain `_`-prefixed identifiers (SIP-04x). Covered by `clarity::vm::ast::underscore_checker::tests` rather than consensus-snapshot tests."), + UnderscoreIdentifierNotAllowed(_) => Ignored("Reachable via deploys of pre-Clarity-6 contracts that contain `_`-prefixed identifiers. Covered by `clarity::vm::ast::underscore_checker::tests` rather than consensus-snapshot tests."), NoteToMatchThis(_) => Unreachable_Functionally("It is reachable, but only visible in diagnostic mode as it comes as a later diagnostic error"), UnexpectedParserFailure => Unreachable_ExpectLike, InterpreterFailure => Unreachable_ExpectLike, // currently cause block rejection From f732e54e233685c508a1cce8961919226db11151 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 16:54:18 -0400 Subject: [PATCH 15/32] Address PR feedback from Claude --- clarity-types/src/representations.rs | 8 +--- .../analysis/type_checker/v2_1/natives/mod.rs | 6 +-- .../type_checker/v2_1/natives/options.rs | 5 +- clarity/src/vm/ast/underscore_checker.rs | 14 +++--- clarity/src/vm/functions/mod.rs | 6 +-- clarity/src/vm/functions/options.rs | 5 +- clarity/src/vm/functions/tuples.rs | 19 +++++--- clarity/src/vm/tests/simple_apply_eval.rs | 47 +++++++++++++++++++ clarity/src/vm/types/signatures.rs | 11 ++++- 9 files changed, 86 insertions(+), 35 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index c16e3eb4d63..c0275e2d656 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -30,12 +30,8 @@ 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 reserved as a discard pattern in Clarity 6. In -/// `let` and `match` binding positions it discards the bound value; in -/// every other naming position (function/constant/map/var names, function -/// arguments, etc.) it is rejected at the analyzer/runtime layer. Rust, -/// Scala, Swift, OCaml and Haskell use the same character for the same -/// purpose (variously called the "wildcard" or "discard" pattern). +/// 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! { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index ce73cb1ae7a..51db844dd5c 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -311,10 +311,8 @@ fn check_special_let( binding_list, SyntaxBindingErrorType::Let, |var_name, var_sexp| { - // Clarity 6: bare `_` is a discard binding — still - // type-check the value (so its type errors surface) but don't - // add the name to the typing context and skip name-collision - // checks across repeated discards. + // Clarity 6: bare `_` is a discard binding — still type-check + // the value (so type errors surface) but don't add to scope. let is_discard = var_name.as_str() == DISCARD_IDENTIFIER && checker.clarity_version >= ClarityVersion::Clarity6; if !is_discard { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs index 2368012f348..596a8727473 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs @@ -307,9 +307,8 @@ fn eval_with_new_binding( .ok_or_else(|| CostErrors::CostOverflow)?; checker.add_memory(memory_use)?; } - // Clarity 6: a `match` arm whose bind name is bare `_` - // discards the matched value — skip name-collision checks and don't - // place the name in the typing context for the branch body. + // Clarity 6: bare `_` discards the matched value — don't place the + // name in the typing context for the branch body. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER && checker.clarity_version >= ClarityVersion::Clarity6; if !is_discard { diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 28999567f06..992585a83c0 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -73,14 +73,14 @@ fn check_one(expr: &PreSymbolicExpression) -> ParseResult<()> { } fn reject_if_underscore(name: &ClarityName, expr: &PreSymbolicExpression) -> ParseResult<()> { - if name.starts_with('_') { - let mut err = ParseError::new(ParseErrorKind::UnderscoreIdentifierNotAllowed( - name.to_string(), - )); - err.diagnostic.spans = vec![expr.span().clone()]; - return Err(err); + if !name.starts_with('_') { + return Ok(()); } - Ok(()) + let mut err = ParseError::new(ParseErrorKind::UnderscoreIdentifierNotAllowed( + name.to_string(), + )); + err.diagnostic.spans = vec![expr.span().clone()]; + Err(err) } #[cfg(test)] diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index b38cbc64d56..36a8703a8a6 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -808,10 +808,8 @@ fn special_let( finally_drop_memory!( exec_state, memory_use; { handle_binding_list::<_, VmExecutionError>(bindings, SyntaxBindingErrorType::Let, |binding_name, var_sexp| { - // Clarity 6: a bare `_` is a discard binding. Evaluate the bound - // expression (preserving `try!`/`unwrap!` short-circuits) but do - // not place it in scope, and do not treat repeated `_` bindings - // as name conflicts. + // Clarity 6: bare `_` is a discard binding — evaluate the value + // (preserving `try!`/`unwrap!` short-circuit) but don't bind it. let is_discard = binding_name.as_str() == DISCARD_IDENTIFIER && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; diff --git a/clarity/src/vm/functions/options.rs b/clarity/src/vm/functions/options.rs index 0eb2be814cf..1d1f81cc88a 100644 --- a/clarity/src/vm/functions/options.rs +++ b/clarity/src/vm/functions/options.rs @@ -129,9 +129,8 @@ fn eval_with_new_binding( context: &LocalContext, ) -> Result { let mut inner_context = context.extend()?; - // Clarity 6: a `match` arm whose bind name is bare `_` - // discards the value — execute the branch without binding the name and - // without raising `NameAlreadyUsed` on re-use across nested match arms. + // Clarity 6: bare `_` discards the matched value — execute the branch + // without binding the name. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; if !is_discard diff --git a/clarity/src/vm/functions/tuples.rs b/clarity/src/vm/functions/tuples.rs index e4b7d5b6c4c..16fb988e868 100644 --- a/clarity/src/vm/functions/tuples.rs +++ b/clarity/src/vm/functions/tuples.rs @@ -36,6 +36,18 @@ pub fn tuple_cons( check_arguments_at_least(1, args)?; + // Clarity 6: reject bare `_` keys before evaluating any values — matches + // the analyzer's ordering and avoids paying for evaluations that will be + // discarded. A `_` key would create a referenceable binding via `get`. + for arg in args { + if let Some(pair) = arg.match_list() + && let Some(name) = pair.first().and_then(|e| e.match_atom()) + && name.as_str() == DISCARD_IDENTIFIER + { + return Err(RuntimeCheckErrorKind::BareUnderscoreReserved.into()); + } + } + let bindings = parse_eval_bindings( args, SyntaxBindingErrorType::TupleCons, @@ -43,13 +55,6 @@ pub fn tuple_cons( invoke_ctx, context, )?; - // Clarity 6: bare `_` is reserved as a discard pattern and cannot be used - // as a tuple key (it would create a referenceable binding via `get`). - for (name, _) in &bindings { - if name.as_str() == DISCARD_IDENTIFIER { - return Err(RuntimeCheckErrorKind::BareUnderscoreReserved.into()); - } - } runtime_cost(ClarityCostFunction::TupleCons, exec_state, bindings.len())?; Ok(TupleData::from_data(bindings).map(Value::from)?) diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index bc0b110e90f..9ffe7f73694 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -328,6 +328,25 @@ fn test_bare_underscore_as_tuple_key_rejected_in_clarity6() { ); } +/// Clarity 6: bare `_` is rejected in tuple TYPE positions too, not just +/// tuple-literal values. `(define-map foo { _: uint } …)` would otherwise +/// register a referenceable `_` field. +#[test] +fn test_bare_underscore_as_tuple_type_key_rejected_in_clarity6() { + let err = execute_with_parameters( + "(define-map foo { _: uint } { val: uint })", + ClarityVersion::Clarity6, + StacksEpochId::Epoch40, + false, + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BareUnderscoreReserved"), + "expected BareUnderscoreReserved error, got: {msg}" + ); +} + /// Clarity 6: bare `_` cannot name a function argument either. #[test] fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { @@ -346,6 +365,34 @@ fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { ); } +/// The SIP requires that referencing `_` after a discard is an +/// *analysis* error (not just a runtime failure). The analyzer's +/// type-check sees `_` as unbound and emits `UndefinedVariable("_")`. +#[test] +fn test_let_discard_underscore_reference_is_analysis_error() { + let err = crate::vm::analysis::type_checker::v2_1::tests::mem_type_check("(let ((_ 7)) _)") + .expect_err("expected analysis error"); + let msg = format!("{err:?}"); + assert!( + msg.contains("UndefinedVariable") && msg.contains("\"_\""), + "expected `UndefinedVariable(\"_\")` analysis error, got: {msg}" + ); +} + +/// Same SIP requirement for `match` arms. +#[test] +fn test_match_discard_underscore_reference_is_analysis_error() { + let err = crate::vm::analysis::type_checker::v2_1::tests::mem_type_check( + "(match (some 5) _ _ 0)", + ) + .expect_err("expected analysis error"); + let msg = format!("{err:?}"); + assert!( + msg.contains("UndefinedVariable") && msg.contains("\"_\""), + "expected `UndefinedVariable(\"_\")` analysis error, got: {msg}" + ); +} + /// Clarity 6: a bare-`_` `match` branch must not be referenceable in its body. #[test] fn test_match_opt_discard_underscore_not_referenceable() { diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs index 08d24970c66..7ab9762f21c 100644 --- a/clarity/src/vm/types/signatures.rs +++ b/clarity/src/vm/types/signatures.rs @@ -31,7 +31,7 @@ use crate::vm::analysis::type_checker::v2_1::{MAX_FUNCTION_PARAMETERS, MAX_TRAIT use crate::vm::costs::{CostOverflowingMath, runtime_cost}; use crate::vm::errors::{SyntaxBindingError, SyntaxBindingErrorType}; use crate::vm::representations::{ - ClarityName, SymbolicExpression, SymbolicExpressionType, TraitDefinition, + ClarityName, DISCARD_IDENTIFIER, SymbolicExpression, SymbolicExpressionType, TraitDefinition, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -255,6 +255,15 @@ impl TypeSignatureExt for TypeSignature { SyntaxBindingErrorType::TupleCons, accounting, )?; + // Clarity 6: bare `_` cannot name a tuple-type key. Without this, + // `(define-map foo { _: uint } …)` would register a referenceable + // `_` field accessible via `(get _ …)`. + if mapped_key_types + .iter() + .any(|(name, _)| name.as_str() == DISCARD_IDENTIFIER) + { + return Err(CommonCheckErrorKind::BareUnderscoreReserved); + } let tuple_type_signature = TupleTypeSignature::try_from(mapped_key_types)?; Ok(TypeSignature::from(tuple_type_signature)) } From a45bc01f1297a0b18aadb1c94c3842135fb2cf34 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 17:10:45 -0400 Subject: [PATCH 16/32] Fix `handle_use_trait()` --- clarity/src/vm/functions/define.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/clarity/src/vm/functions/define.rs b/clarity/src/vm/functions/define.rs index eb6c93b3036..672e133c3ce 100644 --- a/clarity/src/vm/functions/define.rs +++ b/clarity/src/vm/functions/define.rs @@ -300,9 +300,10 @@ fn handle_define_trait( fn handle_use_trait( name: &ClarityName, trait_identifier: &TraitIdentifier, - invoke_ctx: &InvocationContext, ) -> Result { - check_legal_define(name, invoke_ctx.contract_context)?; + if name.as_str() == DISCARD_IDENTIFIER { + return Err(RuntimeCheckErrorKind::BareUnderscoreReserved.into()); + } Ok(DefineResult::UseTrait( name.clone(), trait_identifier.clone(), @@ -509,7 +510,7 @@ pub fn evaluate_define( DefineFunctionsParsed::UseTrait { name, trait_identifier, - } => handle_use_trait(name, trait_identifier, invoke_ctx), + } => handle_use_trait(name, trait_identifier), DefineFunctionsParsed::ImplTrait { trait_identifier } => { Ok(handle_impl_trait(trait_identifier)) } From fa0b46ea71b940bd9c5ffaacbb4f65c71821bc46 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 27 May 2026 18:46:20 -0400 Subject: [PATCH 17/32] Fix CI --- clarity/src/vm/tests/simple_apply_eval.rs | 7 +++---- stackslib/src/chainstate/tests/runtime_analysis_tests.rs | 1 + stackslib/src/chainstate/tests/static_analysis_tests.rs | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 9ffe7f73694..574549421e8 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -382,10 +382,9 @@ fn test_let_discard_underscore_reference_is_analysis_error() { /// Same SIP requirement for `match` arms. #[test] fn test_match_discard_underscore_reference_is_analysis_error() { - let err = crate::vm::analysis::type_checker::v2_1::tests::mem_type_check( - "(match (some 5) _ _ 0)", - ) - .expect_err("expected analysis error"); + let err = + crate::vm::analysis::type_checker::v2_1::tests::mem_type_check("(match (some 5) _ _ 0)") + .expect_err("expected analysis error"); let msg = format!("{err:?}"); assert!( msg.contains("UndefinedVariable") && msg.contains("\"_\""), diff --git a/stackslib/src/chainstate/tests/runtime_analysis_tests.rs b/stackslib/src/chainstate/tests/runtime_analysis_tests.rs index a5d2720b7e9..e0ed4805094 100644 --- a/stackslib/src/chainstate/tests/runtime_analysis_tests.rs +++ b/stackslib/src/chainstate/tests/runtime_analysis_tests.rs @@ -136,6 +136,7 @@ fn variant_coverage_report(variant: RuntimeCheckErrorKind) { InvalidUTF8Encoding => { Ignored("Only reachable via legacy v1 parsing paths") }, + BareUnderscoreReserved => Ignored("Reachable only in Clarity 6+ when `_` appears as a name in a non-discard position. Covered by `clarity::vm::tests::simple_apply_eval` rather than consensus-snapshot tests."), }; } diff --git a/stackslib/src/chainstate/tests/static_analysis_tests.rs b/stackslib/src/chainstate/tests/static_analysis_tests.rs index 1f34c9311ba..9e7027043da 100644 --- a/stackslib/src/chainstate/tests/static_analysis_tests.rs +++ b/stackslib/src/chainstate/tests/static_analysis_tests.rs @@ -177,6 +177,7 @@ fn variant_coverage_report(variant: StaticCheckErrorKind) { WithNftExpectedListOfIdentifiers => Tested(vec![static_check_error_with_nft_expected_list_of_identifiers]), MaxIdentifierLengthExceeded(_, _) => Tested(vec![static_check_error_max_identifier_length_exceeded]), TooManyAllowances(_, _) => Tested(vec![static_check_error_too_many_allowances]), + BareUnderscoreReserved => Ignored("Reachable only in Clarity 6+ when `_` appears as a name in a non-discard position. Covered by `clarity::vm::tests::simple_apply_eval` rather than consensus-snapshot tests."), } } From cdad1c41281fd4e896cfc86c542179cbf122d2c8 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Fri, 29 May 2026 13:04:01 -0400 Subject: [PATCH 18/32] Add `ClarityVersion::allows_leading_underscore()` --- clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs | 2 +- .../src/vm/analysis/type_checker/v2_1/natives/options.rs | 3 +-- clarity/src/vm/ast/underscore_checker.rs | 2 +- clarity/src/vm/functions/mod.rs | 2 +- clarity/src/vm/functions/options.rs | 5 ++++- clarity/src/vm/version.rs | 9 +++++++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs index 51db844dd5c..e700eb1b736 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/mod.rs @@ -314,7 +314,7 @@ fn check_special_let( // Clarity 6: bare `_` is a discard binding — still type-check // the value (so type errors surface) but don't add to scope. let is_discard = var_name.as_str() == DISCARD_IDENTIFIER - && checker.clarity_version >= ClarityVersion::Clarity6; + && checker.clarity_version.allows_underscore_prefix(); if !is_discard { checker.contract_context.check_name_used(var_name)?; if out_context.lookup_variable_type(var_name).is_some() { diff --git a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs index 596a8727473..8f8a1afe637 100644 --- a/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs +++ b/clarity/src/vm/analysis/type_checker/v2_1/natives/options.rs @@ -22,7 +22,6 @@ use super::{ StaticCheckError, StaticCheckErrorKind, TypeChecker, check_argument_count, check_arguments_at_least, no_type, }; -use crate::vm::ClarityVersion; use crate::vm::analysis::type_checker::contexts::TypingContext; use crate::vm::costs::cost_functions::ClarityCostFunction; use crate::vm::costs::{CostErrors, CostTracker, analysis_typecheck_cost, runtime_cost}; @@ -310,7 +309,7 @@ fn eval_with_new_binding( // Clarity 6: bare `_` discards the matched value — don't place the // name in the typing context for the branch body. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER - && checker.clarity_version >= ClarityVersion::Clarity6; + && checker.clarity_version.allows_underscore_prefix(); if !is_discard { checker.contract_context.check_name_used(&bind_name)?; diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 992585a83c0..44c6d71e5c2 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -38,7 +38,7 @@ impl BuildASTPass for UnderscoreIdentifierChecker { version: ClarityVersion, _epoch: StacksEpochId, ) -> ParseResult<()> { - if version >= ClarityVersion::Clarity6 { + if version.allows_underscore_prefix() { return Ok(()); } check(&contract_ast.pre_expressions) diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index 36a8703a8a6..00aafaa918c 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -811,7 +811,7 @@ fn special_let( // Clarity 6: bare `_` is a discard binding — evaluate the value // (preserving `try!`/`unwrap!` short-circuit) but don't bind it. let is_discard = binding_name.as_str() == DISCARD_IDENTIFIER - && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; + && invoke_ctx.contract_context.get_clarity_version().allows_underscore_prefix(); if !is_discard && (is_reserved(binding_name, invoke_ctx.contract_context.get_clarity_version()) || diff --git a/clarity/src/vm/functions/options.rs b/clarity/src/vm/functions/options.rs index 1d1f81cc88a..df0dcff10e0 100644 --- a/clarity/src/vm/functions/options.rs +++ b/clarity/src/vm/functions/options.rs @@ -132,7 +132,10 @@ fn eval_with_new_binding( // Clarity 6: bare `_` discards the matched value — execute the branch // without binding the name. let is_discard = bind_name.as_str() == DISCARD_IDENTIFIER - && *invoke_ctx.contract_context.get_clarity_version() >= ClarityVersion::Clarity6; + && invoke_ctx + .contract_context + .get_clarity_version() + .allows_underscore_prefix(); if !is_discard && (vm::is_reserved( &bind_name, diff --git a/clarity/src/vm/version.rs b/clarity/src/vm/version.rs index 6aa4cf7cdaa..ce60c516c1c 100644 --- a/clarity/src/vm/version.rs +++ b/clarity/src/vm/version.rs @@ -94,6 +94,15 @@ impl ClarityVersion { pub fn protects_logn_cost_fn(&self) -> bool { self >= &ClarityVersion::Clarity5 } + + /// 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 { From e3f1de9ef4df0270734da1a2b4247a5d013b1a06 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Tue, 9 Jun 2026 16:44:55 -0400 Subject: [PATCH 19/32] clarity6: add ClarityNameV6 with codec + From/TryFrom conversions Introduces the parallel name type that Clarity 6 uses for wide identifiers (leading `_` admitted, including the bare `_`). The companion legacy `ClarityName` keeps its current (still-widened) regex in this commit so the AST and analyzer continue to compile; the narrowing of `ClarityName` back to its pre-PR form is deferred to a follow-up commit after the AST has been migrated to consume `ClarityNameV6` for source-level identifiers. * `clarity-types/src/representations.rs` - `CLARITY_NAME_V6_REGEX_STRING` / `CLARITY_NAME_V6_REGEX`: the Clarity-6 regex (currently identical in shape to the widened `CLARITY_NAME_REGEX_STRING`; the legacy regex will narrow in a later commit and diverge from this one). - `ClarityNameV6` defined via `guarded_string!`, with `StacksMessageCodec` impl mirroring `ClarityName`. - `impl From for ClarityNameV6` (infallible widening) and `impl TryFrom for ClarityName` (fallible narrowing) for boundary conversions. * `clarity-types/src/types/serialization.rs` - `serialize_guarded_string!(ClarityNameV6)` so the new type is Value-codec-ready for the forthcoming Value::TupleV6 variant. * `clarity-types/src/tests/representations.rs` - ClarityNameV6 validity / invalidity / codec round-trip cases. - Widening (ClarityName -> ClarityNameV6) and narrowing (ClarityNameV6 -> ClarityName) conversion tests, including a `_currently_transitional` case that documents the contract for the upcoming narrowing commit. No production code path consumes ClarityNameV6 yet; this is the foundation for the type-system safety property that the next set of commits will activate. --- clarity-types/src/representations.rs | 105 ++++++++++++++++++++- clarity-types/src/tests/representations.rs | 95 ++++++++++++++++++- clarity-types/src/types/serialization.rs | 3 +- 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index c0275e2d656..e4774073eae 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -52,20 +52,42 @@ lazy_static! { "({})|({})", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING ); + // `ClarityName` (legacy, pre-Clarity-6) — identifier must begin with a + // letter or be an operator name. The PR-7243 transitional state widens + // this to also accept a leading `_`; a follow-up commit will narrow it + // back once the AST/analyzer have been migrated to `ClarityNameV6` for + // their source-level identifier representation. After that narrowing, + // `ClarityName` is the type used in wire-narrow positions where a + // leading `_` must be statically forbidden (e.g. + // `TransactionContractCall.function_name`, post-condition `asset_name`, + // and legacy `TupleData` keys). + // // Three alternation arms: - // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` (including - // the bare `_`). The `_` leading position is accepted - // unconditionally at the codec/lexer level per - // Clarity 6; pre-Clarity-6 ASTs reject these - // at the parser pass. + // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` + // (transitional; will narrow to `[a-zA-Z]`). // 2) `[-+=/*]` — single-char operator name. // 3) `[<>]=?` — comparison operator name. pub static ref CLARITY_NAME_REGEX_STRING: String = "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); + // `ClarityNameV6` — Clarity-6 relaxation. Permits identifiers to begin + // with `_`, including the bare `_` discard binding. Used by Clarity-6 + // wire variants (e.g. `TransactionContractCallV6`, `Value::TupleV6`) + // and by source-level AST representations where leading-`_` names + // must be admitted. + // + // The arm shape mirrors `CLARITY_NAME_REGEX_STRING` but allows `_` in + // the leading position. The bare `_` is accepted because the `*` + // quantifier admits zero trailing chars. + pub static ref CLARITY_NAME_V6_REGEX_STRING: String = + "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); pub static ref CLARITY_NAME_REGEX: Regex = { Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap() }; + pub static ref CLARITY_NAME_V6_REGEX: Regex = + { + Regex::new(CLARITY_NAME_V6_REGEX_STRING.as_str()).unwrap() + }; pub static ref CONTRACT_NAME_REGEX: Regex = { Regex::new(format!("^{}$|^__transient$", CONTRACT_NAME_REGEX_STRING.as_str()).as_str()) @@ -81,6 +103,14 @@ guarded_string!( ClarityTypeError::InvalidClarityName ); +guarded_string!( + ClarityNameV6, + CLARITY_NAME_V6_REGEX, + MAX_STRING_LEN, + ClarityTypeError, + ClarityTypeError::InvalidClarityName +); + guarded_string!( ContractName, CONTRACT_NAME_REGEX, @@ -89,6 +119,34 @@ guarded_string!( ClarityTypeError::InvalidContractName ); +/// Widening from the narrow legacy `ClarityName` to the Clarity-6 +/// `ClarityNameV6`. Infallible: every string accepted by the legacy +/// regex is also accepted by the V6 regex, so the inner `String` is +/// guaranteed valid in the target type. +impl From for ClarityNameV6 { + fn from(name: ClarityName) -> Self { + // SAFETY: `name.0` already passed the narrow regex, which is a + // subset of the V6 regex. We reconstruct via `try_from` rather + // than touching the private field, which keeps the V6 invariant + // enforced by its own constructor. + let raw: String = name.into(); + ClarityNameV6::try_from(raw) + .expect("BUG: every ClarityName must be a valid ClarityNameV6") + } +} + +/// Narrowing from `ClarityNameV6` to `ClarityName`. Fallible: a V6 name +/// beginning with `_` (or the bare `_`) is not a legal legacy +/// `ClarityName`. Use this at boundaries where a Clarity-6 value flows +/// into a legacy wire-narrow position. +impl TryFrom for ClarityName { + type Error = ClarityTypeError; + fn try_from(name: ClarityNameV6) -> Result { + let raw: String = name.into(); + ClarityName::try_from(raw) + } +} + impl StacksMessageCodec for ClarityName { #[allow(clippy::needless_as_bytes)] // as_bytes isn't necessary, but verbosity is preferable in the codec impls fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { @@ -130,6 +188,43 @@ impl StacksMessageCodec for ClarityName { } } +impl StacksMessageCodec for ClarityNameV6 { + #[allow(clippy::needless_as_bytes)] + fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { + if self.as_bytes().len() > MAX_STRING_LEN as usize { + return Err(codec_error::SerializeError( + "Failed to serialize clarity name (v6): too long".to_string(), + )); + } + write_next(fd, &(self.as_bytes().len() as u8))?; + fd.write_all(self.as_bytes()) + .map_err(codec_error::WriteError)?; + Ok(()) + } + + fn consensus_deserialize(fd: &mut R) -> Result { + let len_byte: u8 = read_next(fd)?; + if len_byte > MAX_STRING_LEN { + return Err(codec_error::DeserializeError( + "Failed to deserialize clarity name (v6): too long".to_string(), + )); + } + let mut bytes = vec![0u8; len_byte as usize]; + fd.read_exact(&mut bytes).map_err(codec_error::ReadError)?; + + let s = String::from_utf8(bytes).map_err(|_e| { + codec_error::DeserializeError( + "Failed to parse Clarity name (v6): could not construct from utf8".to_string(), + ) + })?; + + let name = ClarityNameV6::try_from(s).map_err(|e| { + codec_error::DeserializeError(format!("Failed to parse Clarity name (v6): {e:?}")) + })?; + Ok(name) + } +} + impl StacksMessageCodec for ContractName { #[allow(clippy::needless_as_bytes)] // as_bytes isn't necessary, but verbosity is preferable in the codec impls fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { diff --git a/clarity-types/src/tests/representations.rs b/clarity-types/src/tests/representations.rs index f589093f277..7f868dc190f 100644 --- a/clarity-types/src/tests/representations.rs +++ b/clarity-types/src/tests/representations.rs @@ -17,7 +17,8 @@ use rstest::rstest; use crate::errors::ClarityTypeError; use crate::representations::{ - CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ContractName, MAX_STRING_LEN, + CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ClarityNameV6, ContractName, + MAX_STRING_LEN, }; use crate::stacks_common::codec::StacksMessageCodec; @@ -216,6 +217,98 @@ fn test_contract_name_deserialization_errors(#[case] buffer: Vec, #[case] er assert_eq!(result.unwrap_err().to_string(), error_message); } +#[rstest] +#[case::leading_underscore("_admin")] +#[case::bare_underscore("_")] +#[case::double_underscore("__")] +#[case::underscore_with_operators("_check!?")] +#[case::underscore_with_digits("_var123")] +#[case::underscore_then_dash("_-")] +#[case::plain_letter("hello")] +#[case::with_dash("hello-dash")] +#[case::single_operator("*")] +fn test_clarity_name_v6_valid(#[case] name: &str) { + let clarity_name = ClarityNameV6::try_from(name.to_string()) + .unwrap_or_else(|_| panic!("Should parse valid ClarityNameV6: {name}")); + assert_eq!(clarity_name.as_str(), name); +} + +#[rstest] +#[case::empty("")] +#[case::starts_with_number("123abc")] +#[case::contains_space("hello world")] +#[case::contains_dot("hello.world")] +#[case::too_long(&"_".repeat(MAX_STRING_LEN as usize + 1))] +fn test_clarity_name_v6_invalid(#[case] name: &str) { + let result = ClarityNameV6::try_from(name.to_string()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ClarityTypeError::InvalidClarityName(_) + )); +} + +#[rstest] +#[case("hello")] +#[case("_admin")] +#[case("_")] +fn test_clarity_name_v6_serialization(#[case] name: &str) { + let name = ClarityNameV6::try_from(name.to_string()).unwrap(); + + let mut buffer = Vec::new(); + name.consensus_serialize(&mut buffer) + .unwrap_or_else(|_| panic!("Serialization should succeed for name: {name}")); + + assert_eq!(buffer[0], name.len()); + assert_eq!(&buffer[1..], name.as_bytes()); + + let deserialized = ClarityNameV6::consensus_deserialize(&mut buffer.as_slice()).unwrap(); + assert_eq!(deserialized, name); +} + +/// Widening conversion: every `ClarityName` must produce a valid `ClarityNameV6`. +/// Until `ClarityName` is narrowed back to its pre-PR form, this exercises the +/// shared-acceptance path; once narrowed, it remains an infallible operation +/// because every legacy name is a subset of the V6 set. +#[rstest] +#[case("hello")] +#[case("contract-call?")] +#[case("set!")] +#[case("*")] +#[case("<=")] +fn test_clarity_name_to_v6_widening(#[case] name: &str) { + let narrow = ClarityName::try_from(name.to_string()).unwrap(); + let wide: ClarityNameV6 = narrow.clone().into(); + assert_eq!(wide.as_str(), narrow.as_str()); +} + +/// Narrowing conversion: a `ClarityNameV6` whose underlying string also +/// satisfies the legacy regex narrows cleanly. The case below uses names +/// that are valid under both regexes. +#[rstest] +#[case("hello")] +#[case("foo-bar")] +fn test_clarity_name_v6_to_narrow_ok(#[case] name: &str) { + let wide = ClarityNameV6::try_from(name.to_string()).unwrap(); + let narrow = ClarityName::try_from(wide).expect("should narrow"); + assert_eq!(narrow.as_str(), name); +} + +/// Narrowing conversion fails for V6-only names. Note: while `ClarityName`'s +/// regex is in its transitional (widened) state, this test will not yet +/// observe a narrowing failure for `_`-prefixed names. The follow-up commit +/// that narrows `ClarityName` flips this into an `Err` assertion — the test +/// is left here as the documented narrowing-fails contract. +#[test] +fn test_clarity_name_v6_to_narrow_underscore_currently_transitional() { + let wide = ClarityNameV6::try_from("_foo".to_string()).unwrap(); + let narrowed = ClarityName::try_from(wide); + // Transitional state: ClarityName still admits leading `_`, so this + // succeeds. After the narrowing commit, replace this assertion with + // `assert!(narrowed.is_err())` and add a matching invalid-narrowing case. + assert!(narrowed.is_ok()); +} + /// Regression test for the issue where some `try_*` calls might panic instead of /// returning an error as they should. See https://github.com/stacks-network/stacks-core/pull/7065 #[test] diff --git a/clarity-types/src/types/serialization.rs b/clarity-types/src/types/serialization.rs index 2aa1cd26268..ee900e4bf45 100644 --- a/clarity-types/src/types/serialization.rs +++ b/clarity-types/src/types/serialization.rs @@ -24,7 +24,7 @@ use stacks_common::util::retry::BoundReader; use super::{ListTypeData, TupleTypeSignature}; use crate::errors::{ClarityTypeError, IncomparableError}; -use crate::representations::{ClarityName, ContractName, MAX_STRING_LEN}; +use crate::representations::{ClarityName, ClarityNameV6, ContractName, MAX_STRING_LEN}; use crate::types::{ BOUND_VALUE_SERIALIZATION_BYTES, BufferLength, CallableData, CharType, MAX_TYPE_DEPTH, MAX_VALUE_SIZE, OptionalData, PrincipalData, QualifiedContractIdentifier, SequenceData, @@ -248,6 +248,7 @@ macro_rules! serialize_guarded_string { } serialize_guarded_string!(ClarityName); +serialize_guarded_string!(ClarityNameV6); serialize_guarded_string!(ContractName); impl PrincipalData { From 28a20d8acd6251a4c4139e0bd76c54b8bc87f982 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Tue, 9 Jun 2026 16:44:55 -0400 Subject: [PATCH 20/32] clarity6: replace ClarityNameV6 with LegacyClarityName (narrow type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the option-2 design (chosen after discussing the trade-off with the AST migration cost), the *narrow* type is the new addition rather than the wide one. `ClarityName` stays at its PR-widened semantics — used by the AST, analyzer, runtime, and Clarity-6 wire positions — and a new `LegacyClarityName` carries the pre-Clarity-6 (narrow) regex for the wire-narrow positions that must statically reject leading-`_` names. This commit supersedes the foundation introduced in the previous commit; the V6-named type and its inverted conversions are removed in favor of the legacy-named type. * `clarity-types/src/representations.rs` - `LEGACY_CLARITY_NAME_REGEX_STRING` / `LEGACY_CLARITY_NAME_REGEX`: the pre-PR narrow regex (no leading `_`). - `LegacyClarityName` via `guarded_string!`, with `StacksMessageCodec` impl. The codec's `try_from`-via-narrow-regex is exactly where the consensus property gets enforced: bytes encoding a `_`-prefixed name produce a `DeserializeError` — matching what un-modified (pre-PR) nodes do. - `From for ClarityName` (infallible widening) and `TryFrom for LegacyClarityName` (fallible narrowing) for boundary use. * `clarity-types/src/types/serialization.rs` - `serialize_guarded_string!(LegacyClarityName)` so the narrow type is ready for use as a `TupleData` key once that field type is migrated in a follow-up commit. * `clarity-types/src/tests/representations.rs` - Validity / invalidity / codec round-trip cases for `LegacyClarityName`. - `test_legacy_clarity_name_rejects_leading_underscore`: the type-system contract. - `test_legacy_clarity_name_rejects_underscore_on_the_wire`: the consensus contract. If this ever flips to passing, the chain-split risk that motivated this refactor has silently reappeared. - Widening / narrowing conversion tests, including the `test_wide_to_legacy_underscore_rejected` cases that lock in the fallible-narrowing direction. No production code path consumes `LegacyClarityName` yet; follow-up commits migrate the wire-narrow positions (function_name, asset_name, tuple keys) one at a time. --- clarity-types/src/representations.rs | 96 ++++++++--------- clarity-types/src/tests/representations.rs | 118 +++++++++++++-------- clarity-types/src/types/serialization.rs | 4 +- 3 files changed, 121 insertions(+), 97 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index e4774073eae..403b33abcf1 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -52,41 +52,34 @@ lazy_static! { "({})|({})", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING ); - // `ClarityName` (legacy, pre-Clarity-6) — identifier must begin with a - // letter or be an operator name. The PR-7243 transitional state widens - // this to also accept a leading `_`; a follow-up commit will narrow it - // back once the AST/analyzer have been migrated to `ClarityNameV6` for - // their source-level identifier representation. After that narrowing, - // `ClarityName` is the type used in wire-narrow positions where a - // leading `_` must be statically forbidden (e.g. - // `TransactionContractCall.function_name`, post-condition `asset_name`, - // and legacy `TupleData` keys). + // `ClarityName` — the type used in the language AST, analyzer, runtime, + // and (for now) all wire positions. Permits identifiers to begin with + // `_` — including the bare `_` discard binding — per Clarity 6. This + // is the *wide* type in the two-type design. // // Three alternation arms: // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` - // (transitional; will narrow to `[a-zA-Z]`). + // (Clarity 6 admits leading `_`). // 2) `[-+=/*]` — single-char operator name. // 3) `[<>]=?` — comparison operator name. pub static ref CLARITY_NAME_REGEX_STRING: String = "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); - // `ClarityNameV6` — Clarity-6 relaxation. Permits identifiers to begin - // with `_`, including the bare `_` discard binding. Used by Clarity-6 - // wire variants (e.g. `TransactionContractCallV6`, `Value::TupleV6`) - // and by source-level AST representations where leading-`_` names - // must be admitted. - // - // The arm shape mirrors `CLARITY_NAME_REGEX_STRING` but allows `_` in - // the leading position. The bare `_` is accepted because the `*` - // quantifier admits zero trailing chars. - pub static ref CLARITY_NAME_V6_REGEX_STRING: String = - "^[a-zA-Z_]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); + // `LegacyClarityName` — the pre-Clarity-6 (narrow) rules. Identifiers + // must begin with a letter or be an operator name; leading `_` is + // forbidden. This type is used at wire positions that must statically + // reject `_`-prefixed names so that legacy variants in + // `TransactionPayload`, post-conditions, and tuple keys can't carry a + // value that would deserialize on updated nodes but not on + // un-updated ones (the consensus risk that motivated this refactor). + pub static ref LEGACY_CLARITY_NAME_REGEX_STRING: String = + "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); pub static ref CLARITY_NAME_REGEX: Regex = { Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap() }; - pub static ref CLARITY_NAME_V6_REGEX: Regex = + pub static ref LEGACY_CLARITY_NAME_REGEX: Regex = { - Regex::new(CLARITY_NAME_V6_REGEX_STRING.as_str()).unwrap() + Regex::new(LEGACY_CLARITY_NAME_REGEX_STRING.as_str()).unwrap() }; pub static ref CONTRACT_NAME_REGEX: Regex = { @@ -104,8 +97,8 @@ guarded_string!( ); guarded_string!( - ClarityNameV6, - CLARITY_NAME_V6_REGEX, + LegacyClarityName, + LEGACY_CLARITY_NAME_REGEX, MAX_STRING_LEN, ClarityTypeError, ClarityTypeError::InvalidClarityName @@ -119,31 +112,31 @@ guarded_string!( ClarityTypeError::InvalidContractName ); -/// Widening from the narrow legacy `ClarityName` to the Clarity-6 -/// `ClarityNameV6`. Infallible: every string accepted by the legacy -/// regex is also accepted by the V6 regex, so the inner `String` is -/// guaranteed valid in the target type. -impl From for ClarityNameV6 { - fn from(name: ClarityName) -> Self { +/// Widening from the narrow `LegacyClarityName` to the wide `ClarityName`. +/// Infallible: every string accepted by the legacy regex is also accepted +/// by the wide `ClarityName` regex. +impl From for ClarityName { + fn from(name: LegacyClarityName) -> Self { // SAFETY: `name.0` already passed the narrow regex, which is a - // subset of the V6 regex. We reconstruct via `try_from` rather - // than touching the private field, which keeps the V6 invariant - // enforced by its own constructor. + // subset of the wide regex. Reconstructed via `try_from` so the + // wide invariant is enforced by its own constructor (defensive + // against any future tightening of the narrow regex relative to + // the wide one). let raw: String = name.into(); - ClarityNameV6::try_from(raw) - .expect("BUG: every ClarityName must be a valid ClarityNameV6") + ClarityName::try_from(raw) + .expect("BUG: every LegacyClarityName must be a valid ClarityName") } } -/// Narrowing from `ClarityNameV6` to `ClarityName`. Fallible: a V6 name -/// beginning with `_` (or the bare `_`) is not a legal legacy -/// `ClarityName`. Use this at boundaries where a Clarity-6 value flows -/// into a legacy wire-narrow position. -impl TryFrom for ClarityName { +/// Narrowing from the wide `ClarityName` to the narrow `LegacyClarityName`. +/// Fallible: a name beginning with `_` (or the bare `_`) is admitted by +/// the wide regex but not by the legacy one. Use this at boundaries where +/// an AST/runtime value flows into a wire-narrow position. +impl TryFrom for LegacyClarityName { type Error = ClarityTypeError; - fn try_from(name: ClarityNameV6) -> Result { + fn try_from(name: ClarityName) -> Result { let raw: String = name.into(); - ClarityName::try_from(raw) + LegacyClarityName::try_from(raw) } } @@ -188,12 +181,12 @@ impl StacksMessageCodec for ClarityName { } } -impl StacksMessageCodec for ClarityNameV6 { +impl StacksMessageCodec for LegacyClarityName { #[allow(clippy::needless_as_bytes)] fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { if self.as_bytes().len() > MAX_STRING_LEN as usize { return Err(codec_error::SerializeError( - "Failed to serialize clarity name (v6): too long".to_string(), + "Failed to serialize legacy clarity name: too long".to_string(), )); } write_next(fd, &(self.as_bytes().len() as u8))?; @@ -202,11 +195,11 @@ impl StacksMessageCodec for ClarityNameV6 { Ok(()) } - fn consensus_deserialize(fd: &mut R) -> Result { + fn consensus_deserialize(fd: &mut R) -> Result { let len_byte: u8 = read_next(fd)?; if len_byte > MAX_STRING_LEN { return Err(codec_error::DeserializeError( - "Failed to deserialize clarity name (v6): too long".to_string(), + "Failed to deserialize legacy clarity name: too long".to_string(), )); } let mut bytes = vec![0u8; len_byte as usize]; @@ -214,12 +207,15 @@ impl StacksMessageCodec for ClarityNameV6 { let s = String::from_utf8(bytes).map_err(|_e| { codec_error::DeserializeError( - "Failed to parse Clarity name (v6): could not construct from utf8".to_string(), + "Failed to parse legacy Clarity name: could not construct from utf8".to_string(), ) })?; - let name = ClarityNameV6::try_from(s).map_err(|e| { - codec_error::DeserializeError(format!("Failed to parse Clarity name (v6): {e:?}")) + // Narrow regex enforced here — bytes that decode to a `_`-prefixed + // name produce a `DeserializeError`, which is the exact behavior + // unmodified (pre-PR) nodes exhibit. Consensus preserved. + let name = LegacyClarityName::try_from(s).map_err(|e| { + codec_error::DeserializeError(format!("Failed to parse legacy Clarity name: {e:?}")) })?; Ok(name) } diff --git a/clarity-types/src/tests/representations.rs b/clarity-types/src/tests/representations.rs index 7f868dc190f..3b1f6898092 100644 --- a/clarity-types/src/tests/representations.rs +++ b/clarity-types/src/tests/representations.rs @@ -17,8 +17,8 @@ use rstest::rstest; use crate::errors::ClarityTypeError; use crate::representations::{ - CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ClarityNameV6, ContractName, - MAX_STRING_LEN, + CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ContractName, + LegacyClarityName, MAX_STRING_LEN, }; use crate::stacks_common::codec::StacksMessageCodec; @@ -217,6 +217,29 @@ fn test_contract_name_deserialization_errors(#[case] buffer: Vec, #[case] er assert_eq!(result.unwrap_err().to_string(), error_message); } +// `LegacyClarityName` — the narrow (pre-Clarity-6) name type used at +// wire positions that must reject `_`-prefixed names for consensus +// safety. Mirrors the historical `ClarityName` acceptance set. + +#[rstest] +#[case::plain_letter("hello")] +#[case::dash("hello-dash")] +#[case::interior_underscore("hello_underscore")] +#[case::numbers("test123")] +#[case::single_letter("a")] +#[case::exclamation_mark("set-token-uri!")] +#[case::question_mark("is-owner?")] +#[case::single_operator("*")] +#[case::less_than_or_equal_to("<=")] +fn test_legacy_clarity_name_valid(#[case] name: &str) { + let legacy = LegacyClarityName::try_from(name.to_string()) + .unwrap_or_else(|_| panic!("Should parse valid LegacyClarityName: {name}")); + assert_eq!(legacy.as_str(), name); +} + +/// The defining contract of `LegacyClarityName`: it MUST reject every +/// name that begins with `_`, including the bare `_`. These are the +/// names whose wire-level acceptance would split the chain. #[rstest] #[case::leading_underscore("_admin")] #[case::bare_underscore("_")] @@ -224,13 +247,13 @@ fn test_contract_name_deserialization_errors(#[case] buffer: Vec, #[case] er #[case::underscore_with_operators("_check!?")] #[case::underscore_with_digits("_var123")] #[case::underscore_then_dash("_-")] -#[case::plain_letter("hello")] -#[case::with_dash("hello-dash")] -#[case::single_operator("*")] -fn test_clarity_name_v6_valid(#[case] name: &str) { - let clarity_name = ClarityNameV6::try_from(name.to_string()) - .unwrap_or_else(|_| panic!("Should parse valid ClarityNameV6: {name}")); - assert_eq!(clarity_name.as_str(), name); +fn test_legacy_clarity_name_rejects_leading_underscore(#[case] name: &str) { + let result = LegacyClarityName::try_from(name.to_string()); + assert!(result.is_err(), "expected {name:?} to be rejected by LegacyClarityName"); + assert!(matches!( + result.unwrap_err(), + ClarityTypeError::InvalidClarityName(_) + )); } #[rstest] @@ -238,9 +261,9 @@ fn test_clarity_name_v6_valid(#[case] name: &str) { #[case::starts_with_number("123abc")] #[case::contains_space("hello world")] #[case::contains_dot("hello.world")] -#[case::too_long(&"_".repeat(MAX_STRING_LEN as usize + 1))] -fn test_clarity_name_v6_invalid(#[case] name: &str) { - let result = ClarityNameV6::try_from(name.to_string()); +#[case::too_long(&"a".repeat(MAX_STRING_LEN as usize + 1))] +fn test_legacy_clarity_name_invalid(#[case] name: &str) { + let result = LegacyClarityName::try_from(name.to_string()); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -250,10 +273,10 @@ fn test_clarity_name_v6_invalid(#[case] name: &str) { #[rstest] #[case("hello")] -#[case("_admin")] -#[case("_")] -fn test_clarity_name_v6_serialization(#[case] name: &str) { - let name = ClarityNameV6::try_from(name.to_string()).unwrap(); +#[case("contract-call?")] +#[case("*")] +fn test_legacy_clarity_name_serialization(#[case] name: &str) { + let name = LegacyClarityName::try_from(name.to_string()).unwrap(); let mut buffer = Vec::new(); name.consensus_serialize(&mut buffer) @@ -262,51 +285,56 @@ fn test_clarity_name_v6_serialization(#[case] name: &str) { assert_eq!(buffer[0], name.len()); assert_eq!(&buffer[1..], name.as_bytes()); - let deserialized = ClarityNameV6::consensus_deserialize(&mut buffer.as_slice()).unwrap(); + let deserialized = LegacyClarityName::consensus_deserialize(&mut buffer.as_slice()).unwrap(); assert_eq!(deserialized, name); } -/// Widening conversion: every `ClarityName` must produce a valid `ClarityNameV6`. -/// Until `ClarityName` is narrowed back to its pre-PR form, this exercises the -/// shared-acceptance path; once narrowed, it remains an infallible operation -/// because every legacy name is a subset of the V6 set. +/// The codec contract that makes this whole refactor consensus-safe: a +/// `_`-prefixed name MUST fail to deserialize at the wire layer. If this +/// test ever flips to passing, the chain-split risk that motivated the +/// `LegacyClarityName` introduction has silently reappeared. +#[test] +fn test_legacy_clarity_name_rejects_underscore_on_the_wire() { + let underscore_bytes = [4, b'_', b'f', b'o', b'o']; + let result = LegacyClarityName::consensus_deserialize(&mut underscore_bytes.as_slice()); + assert!(result.is_err(), "leading `_` must not deserialize as LegacyClarityName"); +} + +/// Widening (`LegacyClarityName` -> `ClarityName`) is infallible — every +/// legacy name is also a valid wide name. #[rstest] #[case("hello")] #[case("contract-call?")] #[case("set!")] #[case("*")] #[case("<=")] -fn test_clarity_name_to_v6_widening(#[case] name: &str) { - let narrow = ClarityName::try_from(name.to_string()).unwrap(); - let wide: ClarityNameV6 = narrow.clone().into(); - assert_eq!(wide.as_str(), narrow.as_str()); +fn test_legacy_to_wide_widening(#[case] name: &str) { + let legacy = LegacyClarityName::try_from(name.to_string()).unwrap(); + let wide: ClarityName = legacy.clone().into(); + assert_eq!(wide.as_str(), legacy.as_str()); } -/// Narrowing conversion: a `ClarityNameV6` whose underlying string also -/// satisfies the legacy regex narrows cleanly. The case below uses names -/// that are valid under both regexes. +/// Narrowing (`ClarityName` -> `LegacyClarityName`) succeeds for names +/// that also satisfy the legacy regex. #[rstest] #[case("hello")] #[case("foo-bar")] -fn test_clarity_name_v6_to_narrow_ok(#[case] name: &str) { - let wide = ClarityNameV6::try_from(name.to_string()).unwrap(); - let narrow = ClarityName::try_from(wide).expect("should narrow"); - assert_eq!(narrow.as_str(), name); +fn test_wide_to_legacy_ok(#[case] name: &str) { + let wide = ClarityName::try_from(name.to_string()).unwrap(); + let legacy = LegacyClarityName::try_from(wide).expect("should narrow"); + assert_eq!(legacy.as_str(), name); } -/// Narrowing conversion fails for V6-only names. Note: while `ClarityName`'s -/// regex is in its transitional (widened) state, this test will not yet -/// observe a narrowing failure for `_`-prefixed names. The follow-up commit -/// that narrows `ClarityName` flips this into an `Err` assertion — the test -/// is left here as the documented narrowing-fails contract. -#[test] -fn test_clarity_name_v6_to_narrow_underscore_currently_transitional() { - let wide = ClarityNameV6::try_from("_foo".to_string()).unwrap(); - let narrowed = ClarityName::try_from(wide); - // Transitional state: ClarityName still admits leading `_`, so this - // succeeds. After the narrowing commit, replace this assertion with - // `assert!(narrowed.is_err())` and add a matching invalid-narrowing case. - assert!(narrowed.is_ok()); +/// Narrowing fails for `_`-prefixed names — the type-system guarantee +/// that legacy wire positions can't hold a leading-`_` name. +#[rstest] +#[case("_foo")] +#[case("_")] +#[case("__transient_like")] +fn test_wide_to_legacy_underscore_rejected(#[case] name: &str) { + let wide = ClarityName::try_from(name.to_string()).unwrap(); + let narrowed = LegacyClarityName::try_from(wide); + assert!(narrowed.is_err(), "narrowing of {name:?} must fail"); } /// Regression test for the issue where some `try_*` calls might panic instead of diff --git a/clarity-types/src/types/serialization.rs b/clarity-types/src/types/serialization.rs index ee900e4bf45..5db6f98ef17 100644 --- a/clarity-types/src/types/serialization.rs +++ b/clarity-types/src/types/serialization.rs @@ -24,7 +24,7 @@ use stacks_common::util::retry::BoundReader; use super::{ListTypeData, TupleTypeSignature}; use crate::errors::{ClarityTypeError, IncomparableError}; -use crate::representations::{ClarityName, ClarityNameV6, ContractName, MAX_STRING_LEN}; +use crate::representations::{ClarityName, ContractName, LegacyClarityName, MAX_STRING_LEN}; use crate::types::{ BOUND_VALUE_SERIALIZATION_BYTES, BufferLength, CallableData, CharType, MAX_TYPE_DEPTH, MAX_VALUE_SIZE, OptionalData, PrincipalData, QualifiedContractIdentifier, SequenceData, @@ -248,7 +248,7 @@ macro_rules! serialize_guarded_string { } serialize_guarded_string!(ClarityName); -serialize_guarded_string!(ClarityNameV6); +serialize_guarded_string!(LegacyClarityName); serialize_guarded_string!(ContractName); impl PrincipalData { From 750c868e16fc1b2c6ee0cae1a9d20b20e89f6b9f Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Tue, 9 Jun 2026 16:44:55 -0400 Subject: [PATCH 21/32] clarity6: migrate TransactionContractCall.function_name to LegacyClarityName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TransactionContractCall.function_name` is the first wire-narrow position to receive the type-system safety property: the field type is now `LegacyClarityName` (narrow regex), so any attempt to construct or deserialize a contract-call transaction with a `_`-prefixed function name is rejected by the constructor / codec rather than the runtime. The wire codec error wording is preserved verbatim so existing diagnostics — and the `tx_stacks_transaction_payload_invalid_function_name` test that keys off them — remain stable. Type-level change: * `stackslib/src/chainstate/stacks/mod.rs` — field type flipped, with a doc comment noting that Clarity-6 `_`-prefixed function calls would need a versioned payload sibling (analogous to `VersionedSmartContract`), which is not yet introduced. Codec / construction sites updated to the new type: * `stackslib/src/chainstate/stacks/transaction.rs` — `consensus_deserialize` and the `try_from_parts` constructor. * `stackslib/src/chainstate/nakamoto/signer_set.rs` — the `SIGNERS_VOTING_FUNCTION_NAME` literal comparison. * `stackslib/src/util_lib/strings.rs` — new `impl From for StacksString` mirroring the existing `ClarityName` impl. * `stackslib/src/core/test_util.rs` — `make_contract_call_tx` and `make_contract_call_mblock_only` helpers. * `contrib/stacks-cli/src/main.rs` — the CLI's `make_contract_call_payload`. Test-only construction sites mechanically updated via the same sed pattern (each carries the new `LegacyClarityName` import and keeps everything else unchanged): * `stackslib/src/chainstate/coordinator/tests.rs` * `stackslib/src/chainstate/nakamoto/tests/mod.rs` * `stackslib/src/chainstate/stacks/transaction.rs` (tests) * `stackslib/src/chainstate/stacks/mod.rs` (tests) * `stackslib/src/clarity_vm/clarity.rs` (tests) * `stackslib/src/clarity_vm/tests/ephemeral.rs` * `stackslib/src/cost_estimates/tests/{cost_estimators,fee_medians,fee_scalar}.rs` * `stackslib/src/net/api/tests/{blockreplay,blocksimulate}.rs` * `stacks-node/src/event_dispatcher/tests.rs` * `stacks-node/src/tests/{integrations,neon_integrations}.rs` `cargo test -p clarity-types --lib` (256 tests) and `cargo test -p stackslib --lib chainstate::stacks::transaction` (122 tests) pass; full workspace `cargo check --all-targets` clean with no warnings. --- clarity-types/src/representations.rs | 12 ++-- clarity/src/vm/representations.rs | 3 +- contrib/stacks-cli/src/main.rs | 8 ++- stacks-node/src/event_dispatcher/tests.rs | 5 +- stacks-node/src/tests/integrations.rs | 5 +- stacks-node/src/tests/neon_integrations.rs | 5 +- stackslib/src/chainstate/coordinator/tests.rs | 5 +- .../src/chainstate/nakamoto/signer_set.rs | 3 +- .../src/chainstate/nakamoto/tests/mod.rs | 55 ++++++++++--------- stackslib/src/chainstate/stacks/mod.rs | 14 +++-- .../src/chainstate/stacks/transaction.rs | 19 ++++--- stackslib/src/clarity_vm/clarity.rs | 5 +- stackslib/src/clarity_vm/tests/ephemeral.rs | 5 +- stackslib/src/core/test_util.rs | 7 ++- .../cost_estimates/tests/cost_estimators.rs | 7 ++- .../src/cost_estimates/tests/fee_medians.rs | 5 +- .../src/cost_estimates/tests/fee_scalar.rs | 5 +- stackslib/src/net/api/tests/blockreplay.rs | 5 +- stackslib/src/net/api/tests/blocksimulate.rs | 5 +- stackslib/src/util_lib/strings.rs | 10 +++- 20 files changed, 114 insertions(+), 74 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index 403b33abcf1..51c88a61019 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -184,9 +184,13 @@ impl StacksMessageCodec for ClarityName { impl StacksMessageCodec for LegacyClarityName { #[allow(clippy::needless_as_bytes)] fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { + // Error wording deliberately matches the historical + // `ClarityName::consensus_serialize` message so wire-level + // diagnostics — and any downstream tests that key off them — + // remain stable across this refactor. if self.as_bytes().len() > MAX_STRING_LEN as usize { return Err(codec_error::SerializeError( - "Failed to serialize legacy clarity name: too long".to_string(), + "Failed to serialize clarity name: too long".to_string(), )); } write_next(fd, &(self.as_bytes().len() as u8))?; @@ -199,7 +203,7 @@ impl StacksMessageCodec for LegacyClarityName { let len_byte: u8 = read_next(fd)?; if len_byte > MAX_STRING_LEN { return Err(codec_error::DeserializeError( - "Failed to deserialize legacy clarity name: too long".to_string(), + "Failed to deserialize clarity name: too long".to_string(), )); } let mut bytes = vec![0u8; len_byte as usize]; @@ -207,7 +211,7 @@ impl StacksMessageCodec for LegacyClarityName { let s = String::from_utf8(bytes).map_err(|_e| { codec_error::DeserializeError( - "Failed to parse legacy Clarity name: could not construct from utf8".to_string(), + "Failed to parse Clarity name: could not construct from utf8".to_string(), ) })?; @@ -215,7 +219,7 @@ impl StacksMessageCodec for LegacyClarityName { // name produce a `DeserializeError`, which is the exact behavior // unmodified (pre-PR) nodes exhibit. Consensus preserved. let name = LegacyClarityName::try_from(s).map_err(|e| { - codec_error::DeserializeError(format!("Failed to parse legacy Clarity name: {e:?}")) + codec_error::DeserializeError(format!("Failed to parse Clarity name: {e:?}")) })?; Ok(name) } diff --git a/clarity/src/vm/representations.rs b/clarity/src/vm/representations.rs index 7f2c6370ed0..803c7403a1f 100644 --- a/clarity/src/vm/representations.rs +++ b/clarity/src/vm/representations.rs @@ -17,7 +17,8 @@ pub use clarity_types::representations::{ CLARITY_NAME_REGEX, CLARITY_NAME_REGEX_STRING, CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, CONTRACT_NAME_REGEX, CONTRACT_NAME_REGEX_STRING, - CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, DISCARD_IDENTIFIER, MAX_STRING_LEN, + CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, DISCARD_IDENTIFIER, + LEGACY_CLARITY_NAME_REGEX, LEGACY_CLARITY_NAME_REGEX_STRING, LegacyClarityName, MAX_STRING_LEN, PRINCIPAL_DATA_REGEX_STRING, PreSymbolicExpression, PreSymbolicExpressionType, STANDARD_PRINCIPAL_REGEX_STRING, Span, SymbolicExpression, SymbolicExpressionCommon, SymbolicExpressionType, TraitDefinition, depth_traverse, diff --git a/contrib/stacks-cli/src/main.rs b/contrib/stacks-cli/src/main.rs index 8de2f629962..54962351f65 100644 --- a/contrib/stacks-cli/src/main.rs +++ b/contrib/stacks-cli/src/main.rs @@ -24,8 +24,9 @@ use std::{env, fs, io}; use clarity::vm::ast::errors::ParseError; use clarity::vm::errors::{ClarityEvalError, ClarityTypeError, VmExecutionError}; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::PrincipalData; -use clarity::vm::{ClarityName, ClarityVersion, ContractName, Value}; +use clarity::vm::{ClarityVersion, ContractName, Value}; use clarity_cli::vm_execute; use stacks_common::address::{AddressHashMode, b58}; use stacks_common::codec::{Error as CodecError, StacksMessageCodec}; @@ -268,7 +269,10 @@ fn make_contract_call( let address = StacksAddress::from_string(&contract_address).ok_or("Failed to parse contract address")?; let contract_name = ContractName::try_from(contract_name)?; - let function_name = ClarityName::try_from(function_name)?; + // Wire-narrow `LegacyClarityName`. Calls to Clarity-6 `_`-prefixed + // functions are unsupported here until a versioned `ContractCall` + // payload is introduced. + let function_name = LegacyClarityName::try_from(function_name)?; Ok(TransactionContractCall { address, diff --git a/stacks-node/src/event_dispatcher/tests.rs b/stacks-node/src/event_dispatcher/tests.rs index 6bf72be9268..ba5c20b2985 100644 --- a/stacks-node/src/event_dispatcher/tests.rs +++ b/stacks-node/src/event_dispatcher/tests.rs @@ -19,10 +19,11 @@ use std::thread; use std::time::{Instant, SystemTime}; use clarity::boot_util::boot_code_id; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::costs::ExecutionCost; use clarity::vm::events::SmartContractEventData; use clarity::vm::types::StacksAddressExtensions; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::{ContractName, Value}; use rusqlite::Connection; use serial_test::serial; use stacks::address::{AddressHashMode, C32_ADDRESS_VERSION_TESTNET_SINGLESIG}; @@ -60,7 +61,7 @@ fn test_post_condition_aborted_transaction_does_not_emit_events() { let addr = to_addr(&private_key); let contract_name = ContractName::from_literal("test"); - let function_name = ClarityName::from_literal("test"); + let function_name = LegacyClarityName::from_literal("test"); let payload = TransactionContractCall { address: addr.clone(), diff --git a/stacks-node/src/tests/integrations.rs b/stacks-node/src/tests/integrations.rs index 993dca370ed..f748885d041 100644 --- a/stacks-node/src/tests/integrations.rs +++ b/stacks-node/src/tests/integrations.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use std::fmt::Write; use std::sync::Mutex; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::analysis::contract_interface_builder::{ build_contract_interface, ContractInterface, }; @@ -985,7 +986,7 @@ fn integration_test_get_info() { let tx_payload = TransactionPayload::from(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("get-info"), - function_name: ClarityName::from_literal("update-info"), + function_name: LegacyClarityName::from_literal("update-info"), function_args: vec![], }); @@ -1035,7 +1036,7 @@ fn integration_test_get_info() { let tx_payload = TransactionPayload::from(TransactionContractCall { address: contract_addr, contract_name: ContractName::from_literal("get-info"), - function_name: ClarityName::from_literal("update-info"), + function_name: LegacyClarityName::from_literal("update-info"), function_args: vec![], }); diff --git a/stacks-node/src/tests/neon_integrations.rs b/stacks-node/src/tests/neon_integrations.rs index a6291f5e108..f14a32fa36e 100644 --- a/stacks-node/src/tests/neon_integrations.rs +++ b/stacks-node/src/tests/neon_integrations.rs @@ -20,12 +20,13 @@ use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; use std::{cmp, env, fs, io, thread}; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::ast::stack_depth_checker::StackDepthLimits; use clarity::vm::costs::ExecutionCost; use clarity::vm::types::serialization::SerializationError; use clarity::vm::types::PrincipalData; use clarity::vm::{ - execute_with_parameters as execute, ClarityName, ClarityVersion, ContractName, Value, + execute_with_parameters as execute, ClarityVersion, ContractName, Value, }; use rusqlite::params; use serde::Deserialize; @@ -7360,7 +7361,7 @@ fn fuzzed_median_fee_rate_estimation_test(window_size: u64, expected_final_value let tx_payload = TransactionPayload::ContractCall(TransactionContractCall { address: spender_addr.clone(), contract_name: ContractName::from_literal("increment-contract"), - function_name: ClarityName::from_literal("increment-many"), + function_name: LegacyClarityName::from_literal("increment-many"), function_args: vec![], }); diff --git a/stackslib/src/chainstate/coordinator/tests.rs b/stackslib/src/chainstate/coordinator/tests.rs index f27d294620a..262335835b2 100644 --- a/stackslib/src/chainstate/coordinator/tests.rs +++ b/stackslib/src/chainstate/coordinator/tests.rs @@ -24,8 +24,9 @@ use clarity::vm::clarity::TransactionConnection; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::database::BurnStateDB; use clarity::vm::errors::ClarityEvalError; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::{ContractName, Value}; use lazy_static::lazy_static; use rusqlite::Connection; use stacks_common::address; @@ -4729,7 +4730,7 @@ fn atlas_stop_start() { TransactionPayload::ContractCall(TransactionContractCall { address: signer_pk.clone(), contract_name: atlas_name.clone(), - function_name: ClarityName::from_literal("make-attach"), + function_name: LegacyClarityName::from_literal("make-attach"), function_args: vec![Value::buff_from(vec![ix; 20]).unwrap()], }), ), diff --git a/stackslib/src/chainstate/nakamoto/signer_set.rs b/stackslib/src/chainstate/nakamoto/signer_set.rs index d823cd19630..2cf0ac64ec9 100644 --- a/stackslib/src/chainstate/nakamoto/signer_set.rs +++ b/stackslib/src/chainstate/nakamoto/signer_set.rs @@ -18,6 +18,7 @@ use std::sync::{LazyLock, RwLock}; use clarity::vm::events::StacksTransactionEvent; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, TupleData}; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::{ClarityName, SymbolicExpression, Value}; use stacks_common::types::chainstate::{StacksAddress, StacksBlockId}; use stacks_common::types::StacksEpochId; @@ -1073,7 +1074,7 @@ impl NakamotoSigners { }; if payload.contract_identifier() != boot_code_id(SIGNERS_VOTING_NAME, transaction.is_mainnet()) - || payload.function_name != ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME) + || payload.function_name != LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME) { // This is not a special cased transaction. return None; diff --git a/stackslib/src/chainstate/nakamoto/tests/mod.rs b/stackslib/src/chainstate/nakamoto/tests/mod.rs index f7bbc26e4c2..03adc23f200 100644 --- a/stackslib/src/chainstate/nakamoto/tests/mod.rs +++ b/stackslib/src/chainstate/nakamoto/tests/mod.rs @@ -19,8 +19,9 @@ use std::collections::HashMap; use clarity::types::chainstate::{SortitionId, StacksBlockId}; use clarity::util::secp256k1::Secp256k1PrivateKey; use clarity::vm::costs::ExecutionCost; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::StacksAddressExtensions; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::{ContractName, Value}; use libstackerdb::StackerDBChunkData; use rand::distributions::Standard; use rand::{thread_rng, Rng, RngCore}; @@ -2458,7 +2459,7 @@ fn parse_vote_for_aggregate_public_key_valid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2511,7 +2512,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { &StacksPublicKey::from_private(&signer_private_key), ), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2527,7 +2528,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("bad-signers-contract-name"), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2543,7 +2544,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal("some-other-function"), + function_name: LegacyClarityName::from_literal("some-other-function"), function_args: valid_function_args, }), }; @@ -2559,7 +2560,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ aggregate_key_arg.clone(), aggregate_key_arg.clone(), @@ -2580,7 +2581,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), signer_index_arg.clone(), @@ -2601,7 +2602,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), aggregate_key_arg.clone(), @@ -2622,7 +2623,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg, aggregate_key_arg.clone(), @@ -2692,7 +2693,7 @@ fn valid_vote_transaction() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2766,7 +2767,7 @@ fn valid_vote_transaction_malformed_transactions() { &StacksPublicKey::from_private(&signer_private_key), ), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2782,7 +2783,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("bad-signers-contract-name"), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2798,7 +2799,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2814,7 +2815,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal("some-other-function"), + function_name: LegacyClarityName::from_literal("some-other-function"), function_args: valid_function_args.clone(), }), }; @@ -2830,7 +2831,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ aggregate_key_arg.clone(), aggregate_key_arg.clone(), @@ -2851,7 +2852,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), signer_index_arg.clone(), @@ -2872,7 +2873,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), aggregate_key_arg.clone(), @@ -2893,7 +2894,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg, aggregate_key_arg.clone(), @@ -2914,7 +2915,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2983,7 +2984,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -2999,7 +3000,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3015,7 +3016,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3031,7 +3032,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3047,7 +3048,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args, }), }; @@ -3114,7 +3115,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3130,7 +3131,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3146,7 +3147,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args, }), }; diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index d9f73b6054d..a4aabbee262 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -21,7 +21,7 @@ use std::{error, fmt, io}; use clarity::vm::contexts::GlobalContext; use clarity::vm::costs::{CostErrors, ExecutionCost}; use clarity::vm::errors::VmExecutionError; -use clarity::vm::representations::{ClarityName, ContractName}; +use clarity::vm::representations::{ClarityName, ContractName, LegacyClarityName}; use clarity::vm::types::{ PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, Value, }; @@ -663,12 +663,18 @@ pub enum TransactionAuth { Sponsored(TransactionSpendingCondition, TransactionSpendingCondition), // the second account pays on behalf of the first account } -/// A transaction that calls into a smart contract +/// A transaction that calls into a smart contract. +/// +/// The `function_name` is held as a [`LegacyClarityName`] so the wire codec +/// statically rejects names beginning with `_`. Calls into Clarity-6 +/// `_`-prefixed functions would need a versioned variant of this payload +/// (analogous to [`TransactionPayloadID::VersionedSmartContract`]), which +/// is not yet introduced. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TransactionContractCall { pub address: StacksAddress, pub contract_name: ContractName, - pub function_name: ClarityName, + pub function_name: LegacyClarityName, pub function_args: Vec, } @@ -1541,7 +1547,7 @@ pub mod test { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(4, Hash160([0xfc; 20])).unwrap(), contract_name: ContractName::try_from("hello-contract-name").unwrap(), - function_name: ClarityName::try_from("hello-contract-call").unwrap(), + function_name: LegacyClarityName::try_from("hello-contract-call").unwrap(), function_args: vec![Value::Int(0)], }), TransactionPayload::SmartContract( diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs index 073cd9c1043..18517322bf6 100644 --- a/stackslib/src/chainstate/stacks/transaction.rs +++ b/stackslib/src/chainstate/stacks/transaction.rs @@ -17,7 +17,7 @@ use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; -use clarity::vm::representations::{ClarityName, ContractName}; +use clarity::vm::representations::{ClarityName, ContractName, LegacyClarityName}; use clarity::vm::types::{QualifiedContractIdentifier, StandardPrincipalData}; use clarity::vm::{ClarityVersion, Value}; use stacks_common::codec::{read_next, write_next, Error as codec_error, StacksMessageCodec}; @@ -42,7 +42,7 @@ impl StacksMessageCodec for TransactionContractCall { fn consensus_deserialize(fd: &mut R) -> Result { let address: StacksAddress = read_next(fd)?; let contract_name: ContractName = read_next(fd)?; - let function_name: ClarityName = read_next(fd)?; + let function_name: LegacyClarityName = read_next(fd)?; let function_args: Vec = { let mut bound_read = BoundReader::from_reader(fd, u64::from(MAX_TRANSACTION_LEN)); read_next(&mut bound_read) @@ -382,7 +382,10 @@ impl TransactionPayload { } }; - let function_name_str = match ClarityName::try_from(function_name.to_string()) { + // The wire-narrow `LegacyClarityName` constructor — rejects leading + // `_` names. Clarity-6 callable names beginning with `_` are + // unsupported here until a versioned `ContractCall` payload exists. + let function_name_str = match LegacyClarityName::try_from(function_name.to_string()) { Ok(s) => s, Err(_) => { test_debug!("Not a clarity name: '{}'", contract_name); @@ -1221,7 +1224,7 @@ impl StacksTransactionSigner { #[cfg(test)] mod test { use clarity::types::StacksEpochId; - use clarity::vm::representations::{ClarityName, ContractName}; + use clarity::vm::representations::{ClarityName, ContractName, LegacyClarityName}; use clarity::vm::tests::test_clarity_versions; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; use rstest::rstest; @@ -1866,7 +1869,7 @@ mod test { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - function_name: ClarityName::try_from("hello-function").unwrap(), + function_name: LegacyClarityName::try_from("hello-function").unwrap(), function_args: vec![Value::Int(0)], }) } @@ -2007,7 +2010,7 @@ mod test { TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from("hello-contract-name").unwrap(), - function_name: ClarityName::try_from("hello-function-name").unwrap(), + function_name: LegacyClarityName::try_from("hello-function-name").unwrap(), function_args: vec![Value::Int(0)], } } @@ -3345,7 +3348,7 @@ mod test { let contract_call = TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from(hello_contract_name).unwrap(), - function_name: ClarityName::try_from(hello_function_name).unwrap(), + function_name: LegacyClarityName::try_from(hello_function_name).unwrap(), function_args: vec![Value::Int(0)], }; @@ -3384,7 +3387,7 @@ mod test { // test invalid contract name let address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); let contract_name = "hello\x00contract-name"; - let function_name = ClarityName::try_from("hello-function-name").unwrap(); + let function_name = LegacyClarityName::try_from("hello-function-name").unwrap(); let function_args = vec![Value::Int(0)]; let mut contract_call_bytes = vec![]; diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index ee371c39406..cf60ee0dff7 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -2484,9 +2484,10 @@ mod tests { use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId, StacksAddress}; use clarity::vm::analysis::errors::RuntimeCheckErrorKind; use clarity::vm::database::{ClarityBackingStore, STXBalance, SqliteConnection}; + use clarity::vm::representations::LegacyClarityName; use clarity::vm::test_util::{TEST_BURN_STATE_DB, TEST_HEADER_DB}; use clarity::vm::types::{StandardPrincipalData, TupleData, Value}; - use clarity::vm::ClarityName; + use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::chainstate::ConsensusHash; use stacks_common::types::sqlite::NO_PARAMS; @@ -3261,7 +3262,7 @@ mod tests { TransactionPayload::ContractCall(TransactionContractCall { address: sender.clone(), contract_name: ContractName::from_literal("hello-world"), - function_name: ClarityName::from_literal("foo"), + function_name: LegacyClarityName::from_literal("foo"), function_args: vec![], }), ); diff --git a/stackslib/src/clarity_vm/tests/ephemeral.rs b/stackslib/src/clarity_vm/tests/ephemeral.rs index ef9410c62e3..f9fd9b439d1 100644 --- a/stackslib/src/clarity_vm/tests/ephemeral.rs +++ b/stackslib/src/clarity_vm/tests/ephemeral.rs @@ -15,8 +15,9 @@ use std::fs; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::StacksAddressExtensions; -use clarity::vm::{ClarityName, ContractName}; +use clarity::vm::ContractName; use pinny::tag; use proptest::prelude::*; use rand::seq::SliceRandom; @@ -680,7 +681,7 @@ fn test_ephemeral_nakamoto_block_replay_smart_contract() { let cc_payload = TransactionPayload::ContractCall(TransactionContractCall { address: addr.clone(), contract_name: ContractName::try_from("test-clarity-db").unwrap(), - function_name: ClarityName::try_from("test-all").unwrap(), + function_name: LegacyClarityName::try_from("test-all").unwrap(), function_args: vec![], }); diff --git a/stackslib/src/core/test_util.rs b/stackslib/src/core/test_util.rs index 9d17fdfd998..1568eaf98fb 100644 --- a/stackslib/src/core/test_util.rs +++ b/stackslib/src/core/test_util.rs @@ -23,7 +23,8 @@ use clarity::types::chainstate::{ use clarity::vm::costs::ExecutionCost; use clarity::vm::tests::BurnStateDB; use clarity::vm::types::PrincipalData; -use clarity::vm::{ClarityName, ClarityVersion, ContractName, Value}; +use clarity::vm::representations::LegacyClarityName; +use clarity::vm::{ClarityVersion, ContractName, Value}; use crate::chainstate::stacks::db::StacksChainState; use crate::chainstate::stacks::miner::{BlockBuilderSettings, StacksMicroblockBuilder}; @@ -447,7 +448,7 @@ pub fn make_contract_call_tx( chain_id: u32, contract_addr: &StacksAddress, contract_name: ContractName, - function_name: ClarityName, + function_name: LegacyClarityName, function_args: &[Value], ) -> StacksTransaction { let payload = TransactionContractCall { @@ -496,7 +497,7 @@ pub fn make_contract_call_mblock_only( function_args: &[Value], ) -> Vec { let contract_name = ContractName::from_literal(contract_name); - let function_name = ClarityName::from_literal(function_name); + let function_name = LegacyClarityName::from_literal(function_name); let payload = TransactionContractCall { address: contract_addr.clone(), diff --git a/stackslib/src/cost_estimates/tests/cost_estimators.rs b/stackslib/src/cost_estimates/tests/cost_estimators.rs index 0c94e2713d8..691c579a738 100644 --- a/stackslib/src/cost_estimates/tests/cost_estimators.rs +++ b/stackslib/src/cost_estimates/tests/cost_estimators.rs @@ -16,8 +16,9 @@ use std::env; use clarity::vm::costs::ExecutionCost; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::{PrincipalData, StandardPrincipalData}; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::{ContractName, Value}; use rand::Rng; use stacks_common::types::chainstate::StacksAddress; use stacks_common::util::hash::{to_hex, Hash160}; @@ -260,13 +261,13 @@ fn pessimistic_estimator_contract_owner_separation() { let cc_payload_0 = TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("contract-1"), - function_name: ClarityName::from_literal("func1"), + function_name: LegacyClarityName::from_literal("func1"), function_args: vec![], }); let cc_payload_1 = TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([1; 20])).unwrap(), contract_name: ContractName::from_literal("contract-1"), - function_name: ClarityName::from_literal("func1"), + function_name: LegacyClarityName::from_literal("func1"), function_args: vec![], }); diff --git a/stackslib/src/cost_estimates/tests/fee_medians.rs b/stackslib/src/cost_estimates/tests/fee_medians.rs index a12626ebad8..1bf42622aff 100644 --- a/stackslib/src/cost_estimates/tests/fee_medians.rs +++ b/stackslib/src/cost_estimates/tests/fee_medians.rs @@ -16,7 +16,8 @@ use std::env; use clarity::vm::costs::ExecutionCost; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::representations::LegacyClarityName; +use clarity::vm::{ContractName, Value}; use rand::Rng; use stacks_common::types::chainstate::StacksAddress; use stacks_common::util::hash::{to_hex, Hash160}; @@ -75,7 +76,7 @@ fn make_dummy_cc_tx(fee: u64, execution_cost: &ExecutionCost) -> StacksTransacti TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("cc-dummy"), - function_name: ClarityName::from_literal("func-name"), + function_name: LegacyClarityName::from_literal("func-name"), function_args: vec![], }), ); diff --git a/stackslib/src/cost_estimates/tests/fee_scalar.rs b/stackslib/src/cost_estimates/tests/fee_scalar.rs index b95decc8210..e75e1e93dc3 100644 --- a/stackslib/src/cost_estimates/tests/fee_scalar.rs +++ b/stackslib/src/cost_estimates/tests/fee_scalar.rs @@ -16,8 +16,9 @@ use std::env; use clarity::vm::costs::ExecutionCost; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::{PrincipalData, StandardPrincipalData}; -use clarity::vm::{ClarityName, ContractName, Value}; +use clarity::vm::{ContractName, Value}; use rand::seq::SliceRandom; use rand::Rng; use stacks_common::types::chainstate::StacksAddress; @@ -112,7 +113,7 @@ fn make_dummy_cc_tx(fee: u64) -> StacksTransactionReceipt { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("cc-dummy"), - function_name: ClarityName::from_literal("func-name"), + function_name: LegacyClarityName::from_literal("func-name"), function_args: vec![], }), ); diff --git a/stackslib/src/net/api/tests/blockreplay.rs b/stackslib/src/net/api/tests/blockreplay.rs index 7653180d570..839730b245c 100644 --- a/stackslib/src/net/api/tests/blockreplay.rs +++ b/stackslib/src/net/api/tests/blockreplay.rs @@ -17,7 +17,8 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use clarity::types::chainstate::StacksPrivateKey; -use clarity::vm::{ClarityName, ContractName}; +use clarity::vm::representations::LegacyClarityName; +use clarity::vm::ContractName; use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::chainstate::StacksBlockId; @@ -256,7 +257,7 @@ fn replay_block_with_pc_failure() { let contract_call = { let contract_name = ContractName::from_literal("test"); - let function_name = ClarityName::from_literal("test"); + let function_name = LegacyClarityName::from_literal("test"); let payload = TransactionContractCall { address: addr.clone(), diff --git a/stackslib/src/net/api/tests/blocksimulate.rs b/stackslib/src/net/api/tests/blocksimulate.rs index 3d513fba225..f7ada61add3 100644 --- a/stackslib/src/net/api/tests/blocksimulate.rs +++ b/stackslib/src/net/api/tests/blocksimulate.rs @@ -17,8 +17,9 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use clarity::types::chainstate::StacksPrivateKey; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::types::PrincipalData; -use clarity::vm::{ClarityName, ContractName}; +use clarity::vm::ContractName; use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::chainstate::StacksBlockId; @@ -286,7 +287,7 @@ fn simulate_block_with_pc_failure() { let address = to_addr(&private_key); let contract_name = ContractName::from_literal("test"); - let function_name = ClarityName::from_literal("test"); + let function_name = LegacyClarityName::from_literal("test"); // Set up the RPC test with a contract, so that we can test a post-condition failure let rpc_test = diff --git a/stackslib/src/util_lib/strings.rs b/stackslib/src/util_lib/strings.rs index 04db157d1dd..f7504170117 100644 --- a/stackslib/src/util_lib/strings.rs +++ b/stackslib/src/util_lib/strings.rs @@ -21,7 +21,7 @@ use std::ops::{Deref, DerefMut}; use clarity::vm::errors::ClarityTypeError; use clarity::vm::representations::{ - ClarityName, ContractName, MAX_STRING_LEN as CLARITY_MAX_STRING_LENGTH, + ClarityName, ContractName, LegacyClarityName, MAX_STRING_LEN as CLARITY_MAX_STRING_LENGTH, }; use lazy_static::lazy_static; use regex::Regex; @@ -179,6 +179,14 @@ impl From for StacksString { } } +impl From for StacksString { + fn from(legacy_name: LegacyClarityName) -> StacksString { + // .unwrap() is safe since StacksString is less strict than + // LegacyClarityName's narrow regex. + StacksString::from_str(&legacy_name).unwrap() + } +} + impl From for StacksString { fn from(contract_name: ContractName) -> StacksString { // .unwrap() is safe since StacksString is less strict From e9625b686fd2298526250efa28178f80873a3a30 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Tue, 9 Jun 2026 16:44:55 -0400 Subject: [PATCH 22/32] clarity6: migrate AssetInfo.asset_name to LegacyClarityName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AssetInfo` (used in `TransactionPostCondition::Fungible` and `Nonfungible` post-conditions) is the second wire-narrow position to receive the safety property: `asset_name` is now `LegacyClarityName`, so any attempt to construct or deserialize a post-condition that references a `_`-prefixed asset is rejected by the constructor / codec rather than the runtime. The two boundary points where the wire-narrow `AssetInfo.asset_name` flows into the runtime `AssetIdentifier.asset_name` (which remains a wide `ClarityName`, since `AssetIdentifier` lives entirely in the VM and never crosses the wire) get an explicit `.into()` widening conversion — `From for ClarityName`. The conversion is infallible, so no error path is added. Production code: * `stackslib/src/chainstate/stacks/mod.rs` — `AssetInfo.asset_name` field type flipped to `LegacyClarityName`, with a doc comment. * `stackslib/src/chainstate/stacks/transaction.rs` — `AssetInfo::consensus_deserialize` now reads a `LegacyClarityName`. * `stackslib/src/chainstate/stacks/db/transactions.rs` — the two post-condition processing branches widen the asset name when building `AssetIdentifier`. Test-only construction sites mechanically updated: * `stackslib/src/chainstate/stacks/{block,mod,transaction}.rs` — rename `asset_name: ClarityName::*` to `asset_name: LegacyClarityName::*`, including `let`-bindings that feed `AssetInfo` literal struct construction. * `stackslib/src/chainstate/stacks/db/transactions.rs` — test sites that construct `AssetIdentifier` from `AssetInfo` add `.into()` widening. * `stacks-node/src/tests/nakamoto_integrations.rs` — one construction site renamed plus `LegacyClarityName` import. The `cargo fix` pass dropped now-unused `ClarityName` imports in the affected files. `cargo test -p clarity-types --lib` (256 tests) and `cargo test -p stackslib --lib chainstate::stacks::transaction` (122 tests) pass; full workspace `cargo check --all-targets` clean with no warnings. --- .../src/tests/nakamoto_integrations.rs | 3 +- stackslib/src/chainstate/stacks/block.rs | 2 +- .../src/chainstate/stacks/db/transactions.rs | 46 ++++++++++--------- stackslib/src/chainstate/stacks/mod.rs | 12 +++-- .../src/chainstate/stacks/transaction.rs | 18 ++++---- 5 files changed, 46 insertions(+), 35 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 358aa487a63..22aa8d4b3d4 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -24,6 +24,7 @@ use std::time::{Duration, Instant}; use std::{env, thread}; use clarity::boot_util::boot_code_addr; +use clarity::vm::representations::LegacyClarityName; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::representations::ContractName; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; @@ -15975,7 +15976,7 @@ fn check_sip040_post_conditions() { AssetInfo { contract_address: sender_addr.clone(), contract_name: ContractName::from_literal(contract_name), - asset_name: ClarityName::from_literal("asset"), + asset_name: LegacyClarityName::from_literal("asset"), }, Value::UInt(1), NonfungibleConditionCode::MaybeSent, diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index d8093e155df..0fda333171a 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -2207,7 +2207,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x22; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("asset").unwrap(), + asset_name: LegacyClarityName::try_from("asset").unwrap(), }, Value::Int(1), NonfungibleConditionCode::MaybeSent, diff --git a/stackslib/src/chainstate/stacks/db/transactions.rs b/stackslib/src/chainstate/stacks/db/transactions.rs index b6d165dff60..b0b44b700c4 100644 --- a/stackslib/src/chainstate/stacks/db/transactions.rs +++ b/stackslib/src/chainstate/stacks/db/transactions.rs @@ -762,7 +762,9 @@ impl StacksChainState { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone(), + // Widen the wire-narrow LegacyClarityName into the + // runtime ClarityName carried by AssetIdentifier. + asset_name: asset_info.asset_name.clone().into(), }; let amount_sent = asset_map @@ -798,7 +800,9 @@ impl StacksChainState { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone(), + // Widen the wire-narrow LegacyClarityName into the + // runtime ClarityName carried by AssetIdentifier. + asset_name: asset_info.asset_name.clone().into(), }; let empty_assets = vec![]; @@ -1764,7 +1768,7 @@ impl StacksChainState { #[cfg(test)] pub mod test { use clarity::util::secp256k1::Secp256k1PrivateKey; - use clarity::vm::representations::{ClarityName, ContractName}; + use clarity::vm::representations::ContractName; use clarity::vm::test_util::{UnitTestBurnStateDB, TEST_BURN_STATE_DB}; use clarity::vm::tests::TEST_HEADER_DB; use clarity::vm::types::ResponseData; @@ -2059,7 +2063,7 @@ pub mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("asset").unwrap(), + asset_name: LegacyClarityName::try_from("asset").unwrap(), }, Value::Int(1), NonfungibleConditionCode::MaybeSent, @@ -3757,13 +3761,13 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: ClarityName::try_from("stackaroos").unwrap(), + asset_name: LegacyClarityName::try_from("stackaroos").unwrap(), }; let name_asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: ClarityName::try_from("names").unwrap(), + asset_name: LegacyClarityName::try_from("names").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -4462,13 +4466,13 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: ClarityName::try_from("stackaroos").unwrap(), + asset_name: LegacyClarityName::try_from("stackaroos").unwrap(), }; let name_asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: ClarityName::try_from("names").unwrap(), + asset_name: LegacyClarityName::try_from("names").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -5117,7 +5121,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name, - asset_name: ClarityName::try_from("connect-token").unwrap(), + asset_name: LegacyClarityName::try_from("connect-token").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -5212,19 +5216,19 @@ pub mod test { let asset_info_1 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset-1").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset-1").unwrap(), }; let asset_info_2 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset-2").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset-2").unwrap(), }; let asset_info_3 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset-3").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset-3").unwrap(), }; let asset_id_1 = AssetIdentifier { @@ -5232,7 +5236,7 @@ pub mod test { StandardPrincipalData::from(asset_info_1.contract_address.clone()), asset_info_1.contract_name.clone(), ), - asset_name: asset_info_1.asset_name.clone(), + asset_name: asset_info_1.asset_name.clone().into(), }; let asset_id_2 = AssetIdentifier { @@ -5240,7 +5244,7 @@ pub mod test { StandardPrincipalData::from(asset_info_2.contract_address.clone()), asset_info_2.contract_name.clone(), ), - asset_name: asset_info_2.asset_name.clone(), + asset_name: asset_info_2.asset_name.clone().into(), }; let _asset_id_3 = AssetIdentifier { @@ -5248,7 +5252,7 @@ pub mod test { StandardPrincipalData::from(asset_info_3.contract_address.clone()), asset_info_3.contract_name.clone(), ), - asset_name: asset_info_3.asset_name.clone(), + asset_name: asset_info_3.asset_name.clone().into(), }; // multi-ft @@ -7056,7 +7060,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { @@ -7064,7 +7068,7 @@ pub mod test { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone(), + asset_name: asset_info.asset_name.clone().into(), }; // multi-nft transfer @@ -7484,7 +7488,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { @@ -7492,7 +7496,7 @@ pub mod test { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone(), + asset_name: asset_info.asset_name.clone().into(), }; let mut nft_sent_value_1 = AssetMap::new(); @@ -7654,14 +7658,14 @@ pub mod test { let asset_info = AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x01; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: ClarityName::try_from("test-asset").unwrap(), + asset_name: LegacyClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { contract_identifier: QualifiedContractIdentifier::new( StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone(), + asset_name: asset_info.asset_name.clone().into(), }; let mut asset_map = AssetMap::new(); diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index a4aabbee262..01508d6a409 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -971,11 +971,17 @@ define_u8_enum!(TransactionPayloadID { }); /// Encoding of an asset type identifier +/// Wire-narrow identifier for an asset referenced in a post-condition. +/// +/// `asset_name` is a [`LegacyClarityName`] so the codec rejects bytes +/// encoding a `_`-prefixed asset on deserialize. Post-conditions +/// referencing Clarity-6 `_`-prefixed assets are unsupported here until +/// a versioned `AssetInfo` variant is introduced alongside. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AssetInfo { pub contract_address: StacksAddress, pub contract_name: ContractName, - pub asset_name: ClarityName, + pub asset_name: LegacyClarityName, } /// numeric wire-format ID of an asset info type variant @@ -1240,7 +1246,7 @@ pub const MAX_MICROBLOCK_SIZE: u32 = 65536; #[cfg(test)] pub mod test { - use clarity::vm::representations::{ClarityName, ContractName}; + use clarity::vm::representations::ContractName; use clarity::vm::ClarityVersion; use stacks_common::bitvec::BitVec; use stacks_common::util::get_epoch_time_secs; @@ -1260,7 +1266,7 @@ pub mod test { epoch_id: StacksEpochId, ) -> Vec { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = ClarityName::try_from("hello-asset").unwrap(); + let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); let asset_value = Value::buff_from(vec![0, 1, 2, 3]).unwrap(); let contract_name = ContractName::try_from("hello-world").unwrap(); let hello_contract_call = "hello contract call"; diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs index 18517322bf6..be890adec7e 100644 --- a/stackslib/src/chainstate/stacks/transaction.rs +++ b/stackslib/src/chainstate/stacks/transaction.rs @@ -17,7 +17,7 @@ use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; -use clarity::vm::representations::{ClarityName, ContractName, LegacyClarityName}; +use clarity::vm::representations::{ContractName, LegacyClarityName}; use clarity::vm::types::{QualifiedContractIdentifier, StandardPrincipalData}; use clarity::vm::{ClarityVersion, Value}; use stacks_common::codec::{read_next, write_next, Error as codec_error, StacksMessageCodec}; @@ -433,7 +433,7 @@ impl StacksMessageCodec for AssetInfo { fn consensus_deserialize(fd: &mut R) -> Result { let contract_address: StacksAddress = read_next(fd)?; let contract_name: ContractName = read_next(fd)?; - let asset_name: ClarityName = read_next(fd)?; + let asset_name: LegacyClarityName = read_next(fd)?; Ok(AssetInfo { contract_address, contract_name, @@ -1224,7 +1224,7 @@ impl StacksTransactionSigner { #[cfg(test)] mod test { use clarity::types::StacksEpochId; - use clarity::vm::representations::{ClarityName, ContractName, LegacyClarityName}; + use clarity::vm::representations::{ContractName, LegacyClarityName}; use clarity::vm::tests::test_clarity_versions; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; use rstest::rstest; @@ -3460,7 +3460,7 @@ mod test { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, ]; - let asset_name = ClarityName::try_from("hello-asset").unwrap(); + let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); let mut asset_name_bytes = vec![ // length asset_name.len(), @@ -3510,7 +3510,7 @@ mod test { for tx_pcp in tx_post_condition_principals { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = ClarityName::try_from("hello-asset").unwrap(); + let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); let contract_name = ContractName::try_from("contract-name").unwrap(); let stx_pc = @@ -3620,7 +3620,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), contract_name: ContractName::try_from("contract-name").unwrap(), - asset_name: ClarityName::try_from("hello-asset").unwrap(), + asset_name: LegacyClarityName::try_from("hello-asset").unwrap(), }, Value::buff_from(vec![0, 1, 2, 3]).unwrap(), NonfungibleConditionCode::MaybeSent, @@ -3680,7 +3680,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x33; 20])).unwrap(), contract_name: ContractName::try_from("contract-name").unwrap(), - asset_name: ClarityName::try_from("hello-asset").unwrap(), + asset_name: LegacyClarityName::try_from("hello-asset").unwrap(), }, Value::buff_from(vec![4, 5, 6, 7]).unwrap(), NonfungibleConditionCode::MaybeSent, @@ -3726,7 +3726,7 @@ mod test { #[test] fn tx_stacks_postcondition_invalid() { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = ClarityName::try_from("hello-asset").unwrap(); + let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); let contract_name = ContractName::try_from("hello-world").unwrap(); // can't parse a postcondition with an invalid condition code @@ -3950,7 +3950,7 @@ mod test { let hello_token_name = "hello-token"; let contract_name = ContractName::try_from(hello_contract_name).unwrap(); - let asset_name = ClarityName::try_from(hello_asset_name).unwrap(); + let asset_name = LegacyClarityName::try_from(hello_asset_name).unwrap(); let token_name = StacksString::from_str(hello_token_name).unwrap(); let asset_value = StacksString::from_str("asset-value").unwrap(); From db357cd0bc06d7cc1be971b65d37f079f299a2fc Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Tue, 9 Jun 2026 16:44:55 -0400 Subject: [PATCH 23/32] clarity6: gate `_`-prefixed tuple keys at transaction admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the third (and largest) wire-narrow position in the LegacyClarityName refactor: tuple keys carried inside `Value` payloads on a transaction. Because `Value::Tuple` keys live behind the wide `TupleData.data_map: BTreeMap` (intentionally — `TupleData` is shared by the source-level VM and must admit Clarity-6 `_foo` keys), we can't enforce the rule statically at the codec. Instead, `StacksBlock::validate_transaction_static_epoch` walks the embedded `Value`s and rejects offending keys. The rule, matching the source-level AST pass: * bare `_` is reserved as the `let` / `match` discard marker and is rejected at every epoch * any other leading-`_` key is rejected when `epoch_id < StacksEpochId::Epoch40` (pre-Clarity-6). The narrow wire codec on un-upgraded nodes already rejects every leading-`_` name, so matching that behavior on upgraded nodes during the upgrade window preserves consensus. Post-activation, `_foo` tuple keys are permitted * `clarity-types/src/types/mod.rs` - `Value::find_invalid_tuple_key(epoch)` — recursive walker that descends into `Tuple`, `Sequence(List)`, `Optional::Some`, and `Response`, returning the first key that would be rejected at `epoch`. Leaf and tuple-free variants short-circuit to `None`. - Private `tuple_key_invalid_for_epoch` predicate that owns the two-pronged rule. * `clarity-types/src/tests/types/mod.rs` - 14 walker cases: bare `_` rejected at every epoch, leading-`_` rejected pre-Clarity-6 but admitted in Clarity-6, plain keys admitted at every epoch, leaf variants short-circuit, and the walker descends into `(some ...)`, `(ok ...)`, `(err ...)`, `(list ...)`, and nested tuples. * `stackslib/src/chainstate/stacks/block.rs` - `validate_transaction_static_epoch` now invokes the walker on every `ContractCall.function_args` element and on every `Nonfungible` post-condition's `asset_value` — the two positions where a transaction can embed a Value. - 9 admission cases covering both ContractCall args and NFT post-condition payloads, across pre- and post-Clarity-6 epochs. `cargo test -p clarity-types --lib` (270 tests) and `cargo test -p stackslib --lib chainstate::stacks::block` (58 tests) pass; full workspace `cargo check --all-targets` clean. --- clarity-types/src/tests/types/mod.rs | 141 +++++++++++++++++++ clarity-types/src/types/mod.rs | 64 ++++++++- stackslib/src/chainstate/stacks/block.rs | 170 +++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) diff --git a/clarity-types/src/tests/types/mod.rs b/clarity-types/src/tests/types/mod.rs index 4cf492aba7f..d7adbbcec88 100644 --- a/clarity-types/src/tests/types/mod.rs +++ b/clarity-types/src/tests/types/mod.rs @@ -902,3 +902,144 @@ 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. + +/// Helper: a one-key tuple Value with the given key name and `Int(0)` +/// payload. `ClarityName::try_from` is used (rather than `from_literal`) +/// because the test inputs aren't `'static`; the walker only inspects +/// the string content of the key, not its regex validity. +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).as_deref(), 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).as_deref(), + 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).as_deref(), + Some("_buried"), + ); + + // tuple inside `(ok ...)` + let ok_value = Value::okay(tuple_with_key("_buried")).unwrap(); + assert_eq!( + ok_value.find_invalid_tuple_key(epoch).as_deref(), + Some("_buried"), + ); + + // tuple inside `(err ...)` + let err_value = Value::error(tuple_with_key("_buried")).unwrap(); + assert_eq!( + err_value.find_invalid_tuple_key(epoch).as_deref(), + Some("_buried"), + ); + + // tuple inside a list — list elements must share a schema, so the + // list is a single tuple whose key is `_buried`. + let list_value = Value::list_from(vec![tuple_with_key("_buried")]).unwrap(); + assert_eq!( + list_value.find_invalid_tuple_key(epoch).as_deref(), + 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).as_deref(), + 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()); +} diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index c1b822b0b12..5616f0cac83 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -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 @@ -952,6 +952,68 @@ 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`. Used by transaction admission + /// (see `StacksBlock::validate_transaction_static_epoch`) to keep + /// `_`-prefixed tuple keys off the wire when the active epoch + /// would not accept them. + /// + /// Two rules: + /// 1. Bare `_` is reserved as the `let` / `match` discard marker + /// and is never a valid *tuple key* — rejected at every epoch. + /// 2. Any other leading-`_` key is rejected when `epoch < Epoch40` + /// (pre-Clarity-6). The narrow wire codec on un-upgraded nodes + /// already rejects every leading-`_` name; matching that + /// behavior on upgraded nodes pre-activation preserves + /// consensus during the upgrade window. Post-activation, + /// `_foo` tuple keys are permitted. + pub fn find_invalid_tuple_key(&self, epoch: StacksEpochId) -> Option { + 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().to_string()); + } + 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 { if data.size()? + WRAPPER_VALUE_SIZE > MAX_VALUE_SIZE { Err(ClarityTypeError::ValueTooLarge) diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index 0fda333171a..a26e5206519 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -632,6 +632,47 @@ impl StacksBlock { error!("Authentication mode not supported in Epoch {epoch_id}"); return false; } + // Reject transactions whose Values carry a tuple key that isn't + // accepted at this epoch: + // * bare `_` is never a valid tuple key + // * leading-`_` is rejected pre-Clarity-6 (Epoch40) to + // preserve consensus with un-upgraded nodes whose narrow + // wire codec rejects every leading-`_` name. The companion + // wire types `LegacyClarityName` (used by + // `TransactionContractCall.function_name` and + // `AssetInfo.asset_name`) already enforce the rule for + // those fields at deserialize time; this walk covers the + // embedded `Value` positions — + // `TransactionContractCall.function_args` and the + // `Nonfungible` post-condition's `asset_value` — that the + // codec can't gate without a versioned `Value` variant. + if let TransactionPayload::ContractCall(ref cc) = &tx.payload { + for arg in &cc.function_args { + if let Some(bad_key) = arg.find_invalid_tuple_key(epoch_id) { + error!( + "Disallowed tuple key in function argument"; + "txid" => %tx.txid(), + "epoch" => %epoch_id, + "key" => %bad_key, + ); + return false; + } + } + } + for post_condition in tx.post_conditions.iter() { + if let TransactionPostCondition::Nonfungible(_, _, ref asset_value, _) = post_condition + { + if let Some(bad_key) = asset_value.find_invalid_tuple_key(epoch_id) { + error!( + "Disallowed tuple key in NFT post-condition asset value"; + "txid" => %tx.txid(), + "epoch" => %epoch_id, + "key" => %bad_key, + ); + return false; + } + } + } return true; } @@ -959,6 +1000,7 @@ impl StacksMicroblock { #[cfg(test)] mod test { use clarity::types::PublicKey; + use clarity::vm::types::TupleData; use rstest::rstest; use stacks_common::address::*; use stacks_common::types::chainstate::StacksAddress; @@ -2219,6 +2261,134 @@ mod test { ); } + /// Build a single-key tuple `Value` for use as a function argument + /// or post-condition asset value in the tests below. + fn single_key_tuple(key: &str) -> Value { + Value::Tuple( + TupleData::from_data(vec![( + ClarityName::try_from(key.to_string()).unwrap(), + Value::Int(0), + )]) + .unwrap(), + ) + } + + fn admission_test_auth(privk: &StacksPrivateKey) -> TransactionAuth { + TransactionAuth::Standard( + TransactionSpendingCondition::new_singlesig_p2pkh(StacksPublicKey::from_private(privk)) + .unwrap(), + ) + } + + /// Contract-call transaction whose `function_args` contain a one-key + /// tuple with the supplied key. + fn admission_test_contract_call_with_tuple_arg(key: &str) -> StacksTransaction { + let privk = StacksPrivateKey::random(); + StacksTransaction::new( + TransactionVersion::Testnet, + admission_test_auth(&privk), + TransactionPayload::ContractCall(TransactionContractCall { + address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), + contract_name: ContractName::try_from("hello-world").unwrap(), + function_name: LegacyClarityName::try_from("do-thing").unwrap(), + function_args: vec![single_key_tuple(key)], + }), + ) + } + + /// Token-transfer transaction carrying a single Nonfungible + /// post-condition whose `asset_value` is a one-key tuple with the + /// supplied key. + fn admission_test_nft_post_condition_with_tuple(key: &str) -> StacksTransaction { + let privk = StacksPrivateKey::random(); + let mut tx = StacksTransaction::new( + TransactionVersion::Testnet, + admission_test_auth(&privk), + TransactionPayload::TokenTransfer( + PrincipalData::from(StacksAddress::new(1, Hash160([0x11; 20])).unwrap()), + 1, + TokenTransferMemo([0u8; 34]), + ), + ); + tx.post_conditions + .push(TransactionPostCondition::Nonfungible( + PostConditionPrincipal::Origin, + AssetInfo { + contract_address: StacksAddress::new(1, Hash160([0x22; 20])).unwrap(), + contract_name: ContractName::try_from("hello-world").unwrap(), + asset_name: LegacyClarityName::try_from("asset").unwrap(), + }, + single_key_tuple(key), + NonfungibleConditionCode::Sent, + )); + tx + } + + /// Plain (no leading `_`) tuple keys are admissible at every epoch in + /// both function arguments and NFT post-condition payloads. + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + #[case(StacksEpochId::Epoch40)] + fn test_validate_transaction_static_epoch_admits_plain_tuple_keys( + #[case] epoch_id: StacksEpochId, + ) { + let cc = admission_test_contract_call_with_tuple_arg("foo"); + assert!(StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + + let pc = admission_test_nft_post_condition_with_tuple("foo"); + assert!(StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + } + + /// Bare `_` is rejected at every epoch — it's reserved as the discard + /// marker and never a valid tuple key on the wire. + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + #[case(StacksEpochId::Epoch40)] + fn test_validate_transaction_static_epoch_rejects_bare_underscore_tuple_key( + #[case] epoch_id: StacksEpochId, + ) { + let cc = admission_test_contract_call_with_tuple_arg("_"); + assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + + let pc = admission_test_nft_post_condition_with_tuple("_"); + assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + } + + /// Pre-Clarity-6 (pre-Epoch40) — leading-`_` tuple keys are rejected + /// to preserve consensus with un-upgraded nodes whose narrow wire + /// codec rejects every leading-`_` name. + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + fn test_validate_transaction_static_epoch_rejects_leading_underscore_pre_clarity6( + #[case] epoch_id: StacksEpochId, + ) { + let cc = admission_test_contract_call_with_tuple_arg("_admin"); + assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + + let pc = admission_test_nft_post_condition_with_tuple("_admin"); + assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + } + + /// At Clarity-6 (Epoch40) and beyond, leading-`_` tuple keys are + /// admissible. Bare `_` is still rejected (covered by its own test). + #[test] + fn test_validate_transaction_static_epoch_admits_leading_underscore_in_clarity6() { + let cc = admission_test_contract_call_with_tuple_arg("_admin"); + assert!(StacksBlock::validate_transaction_static_epoch( + &cc, + StacksEpochId::Epoch40, + )); + + let pc = admission_test_nft_post_condition_with_tuple("_admin"); + assert!(StacksBlock::validate_transaction_static_epoch( + &pc, + StacksEpochId::Epoch40, + )); + } + // TODO: // * size limits } From f25a3ecec69b4334f376e294f838874ef936a0b6 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 10 Jun 2026 12:12:36 -0400 Subject: [PATCH 24/32] Address Claude's PR comments --- clarity-types/src/tests/types/mod.rs | 9 ++- clarity-types/src/types/mod.rs | 13 +++- clarity/src/vm/analysis/errors.rs | 20 +++--- clarity/src/vm/ast/underscore_checker.rs | 15 ++-- stackslib/src/chainstate/stacks/block.rs | 9 +++ .../src/chainstate/stacks/transaction.rs | 68 +++++++++++++++++++ 6 files changed, 116 insertions(+), 18 deletions(-) diff --git a/clarity-types/src/tests/types/mod.rs b/clarity-types/src/tests/types/mod.rs index d7adbbcec88..a5693de35ca 100644 --- a/clarity-types/src/tests/types/mod.rs +++ b/clarity-types/src/tests/types/mod.rs @@ -1012,8 +1012,13 @@ fn test_find_invalid_tuple_key_descends_into_compound_values() { Some("_buried"), ); - // tuple inside a list — list elements must share a schema, so the - // list is a single tuple whose key is `_buried`. + // Tuple inside a list. Clarity lists are homogeneous (all elements + // share a `TupleTypeSignature`), so we can't easily exercise the + // "offender is the second element" path with mixed schemas — the + // single-element form is sufficient to prove the walker descends + // into `Sequence(List)`. The for-loop in + // `find_invalid_tuple_key`'s `Sequence(List)` arm makes the + // second-element behavior identical. let list_value = Value::list_from(vec![tuple_with_key("_buried")]).unwrap(); assert_eq!( list_value.find_invalid_tuple_key(epoch).as_deref(), diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index da03eeab741..671e0e44e7c 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -972,9 +972,8 @@ 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`. Used by transaction admission - /// (see `StacksBlock::validate_transaction_static_epoch`) to keep - /// `_`-prefixed tuple keys off the wire when the active epoch - /// would not accept them. + /// to keep `_`-prefixed tuple keys off the wire when the active + /// epoch would not accept them. /// /// Two rules: /// 1. Bare `_` is reserved as the `let` / `match` discard marker @@ -985,6 +984,14 @@ impl Value { /// behavior on upgraded nodes pre-activation preserves /// consensus during the upgrade window. Post-activation, /// `_foo` tuple keys are permitted. + /// + /// NOTE: this walker is the consensus-critical companion to the + /// "value sanitization" routine flagged above the `Value` enum (see + /// `Value`'s definition). Any new compound `Value` variant added in + /// the future — one that can carry `_other_ values_` like `Tuple`, + /// `Optional`, `Response`, or `Sequence(List)` — must be handled + /// here too, or `_`-prefixed tuple keys could escape into the + /// wire-format payload of an admitted transaction. pub fn find_invalid_tuple_key(&self, epoch: StacksEpochId) -> Option { match self { Value::Tuple(data) => { diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index d31b4b0968b..821e4dacabe 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -215,8 +215,10 @@ pub enum CommonCheckErrorKind { /// Too many trait methods specified. /// The first `usize` represents the number of methods found, the second the maximum allowed. TraitTooManyMethods(usize, usize), - /// Clarity 6: bare `_` cannot be used as a trait method name, tuple key, or - /// any other position covered by shared validation flow. + /// Clarity 6: bare `_` is reserved as the discard pattern in `let`/`match` + /// bindings and cannot be used to name a top-level definition, function + /// argument, trait method, tuple key, tuple-type field, or `use-trait` + /// alias. BareUnderscoreReserved, } @@ -408,9 +410,10 @@ pub enum StaticCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), - /// Clarity 6: bare `_` is reserved as a discard pattern in `let`/`match` - /// bindings and cannot be used to name a top-level definition or function - /// argument. + /// Clarity 6: bare `_` is reserved as the discard pattern in `let`/`match` + /// bindings and cannot be used to name a top-level definition, function + /// argument, trait method, tuple key, tuple-type field, or `use-trait` + /// alias. BareUnderscoreReserved, /// Name is a reserved word in Clarity and cannot be used. /// The `String` wraps the reserved name. @@ -626,9 +629,10 @@ pub enum RuntimeCheckErrorKind { /// Name (e.g., variable, function) is already in use within the same scope. /// The `String` wraps the conflicting name. NameAlreadyUsed(String), - /// Clarity 6: bare `_` is reserved as a discard pattern in `let`/`match` - /// bindings and cannot be used to name a top-level definition or function - /// argument. + /// Clarity 6: bare `_` is reserved as the discard pattern in `let`/`match` + /// bindings and cannot be used to name a top-level definition, function + /// argument, trait method, tuple key, tuple-type field, or `use-trait` + /// alias. BareUnderscoreReserved, /// Referenced function is not defined in the current scope. diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index 44c6d71e5c2..f0a18ef8822 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -16,11 +16,16 @@ //! AST pass that rejects identifiers beginning with `_` for pre-`Clarity6` //! contracts. //! -//! The wire-level `ClarityName` regex and the v2 lexer accept underscore-led -//! names unconditionally so that the parser can produce a well-formed AST and -//! report a precise, version-aware diagnostic here rather than a generic -//! "illegal name" lexer error. Clarity 6 permits the relaxation only from -//! `ClarityVersion::Clarity6` onwards. +//! The wide `ClarityName` regex and the v2 lexer accept underscore-led +//! names unconditionally so that the parser can produce a well-formed AST +//! and report a precise, version-aware diagnostic here rather than a +//! generic "illegal name" lexer error. The narrow `LegacyClarityName` +//! type, used at wire-narrow positions (`TransactionContractCall.function_name`, +//! `AssetInfo.asset_name`), separately enforces the pre-Clarity-6 rule at +//! the codec layer; together with this pass and the tuple-key admission +//! check in `StacksBlock::validate_transaction_static_epoch`, leading-`_` +//! names are kept out of pre-Clarity-6 contexts at every layer. Clarity 6 +//! permits the relaxation only from `ClarityVersion::Clarity6` onwards. use clarity_types::representations::ClarityName; use stacks_common::types::StacksEpochId; diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index d45b5f064ad..384bf8c5303 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -645,6 +645,15 @@ impl StacksBlock { // `TransactionContractCall.function_args` and the // `Nonfungible` post-condition's `asset_value` — that the // codec can't gate without a versioned `Value` variant. + // + // Note: `TransactionPayload::SmartContract.code_body` is not + // walked here because the contract source is `StacksString` + // (raw bytes), not a structured `Value`. Source-level + // leading-`_` names are gated by the `UnderscoreIdentifierChecker` + // AST pass at analysis time, and the bytes themselves are + // version-blind on the wire (both upgraded and un-upgraded + // nodes accept the same source-text payload), so no codec + // divergence is possible. if let TransactionPayload::ContractCall(ref cc) = &tx.payload { for arg in &cc.function_args { if let Some(bad_key) = arg.find_invalid_tuple_key(epoch_id) { diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs index a146cb90290..c59acb2c701 100644 --- a/stackslib/src/chainstate/stacks/transaction.rs +++ b/stackslib/src/chainstate/stacks/transaction.rs @@ -2477,6 +2477,74 @@ mod test { ); } + /// The consensus contract that makes the LegacyClarityName refactor + /// safe: bytes encoding a leading-`_` `function_name` MUST fail to + /// deserialize as a `TransactionContractCall`. Un-upgraded nodes + /// already reject these bytes via their narrow `ClarityName` codec; + /// the `LegacyClarityName` codec on upgraded nodes is required to + /// reject them identically. If this test ever flips to passing, the + /// chain-split risk that motivated the refactor has silently + /// reappeared. + #[test] + fn tx_contract_call_function_name_rejects_leading_underscore_on_the_wire() { + let address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); + let contract_name = ContractName::try_from("hello-contract-name").unwrap(); + let bad_function_name = "_admin"; + let mut bad_function_name_bytes = vec![bad_function_name.len() as u8]; + bad_function_name_bytes.extend_from_slice(bad_function_name.as_bytes()); + + let function_args: Vec = vec![]; + + let mut contract_call_bytes = vec![]; + address + .consensus_serialize(&mut contract_call_bytes) + .unwrap(); + contract_name + .consensus_serialize(&mut contract_call_bytes) + .unwrap(); + contract_call_bytes.extend_from_slice(&bad_function_name_bytes); + function_args + .consensus_serialize(&mut contract_call_bytes) + .unwrap(); + + let mut tx_bytes = vec![TransactionPayloadID::ContractCall as u8]; + tx_bytes.append(&mut contract_call_bytes); + + let err = TransactionPayload::consensus_deserialize(&mut &tx_bytes[..]) + .expect_err("leading-`_` function_name must not deserialize"); + assert!( + err.to_string().find("Failed to parse Clarity name").is_some(), + "expected `Failed to parse Clarity name` in error, got: {err}", + ); + } + + /// Analogous to the function_name test: bytes encoding a leading-`_` + /// `asset_name` inside an `AssetInfo` MUST fail to deserialize. + #[test] + fn tx_asset_info_asset_name_rejects_leading_underscore_on_the_wire() { + let contract_address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); + let contract_name = ContractName::try_from("hello-contract-name").unwrap(); + let bad_asset_name = "_admin"; + let mut bad_asset_name_bytes = vec![bad_asset_name.len() as u8]; + bad_asset_name_bytes.extend_from_slice(bad_asset_name.as_bytes()); + + let mut asset_info_bytes = vec![]; + contract_address + .consensus_serialize(&mut asset_info_bytes) + .unwrap(); + contract_name + .consensus_serialize(&mut asset_info_bytes) + .unwrap(); + asset_info_bytes.extend_from_slice(&bad_asset_name_bytes); + + let err = AssetInfo::consensus_deserialize(&mut &asset_info_bytes[..]) + .expect_err("leading-`_` asset_name must not deserialize"); + assert!( + err.to_string().find("Failed to parse Clarity name").is_some(), + "expected `Failed to parse Clarity name` in error, got: {err}", + ); + } + #[test] fn tx_stacks_asset() { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); From 271f49d6e4b56898707fed4d45c77149a3d6f7a7 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 10 Jun 2026 14:48:24 -0400 Subject: [PATCH 25/32] Drop `LegacyClarityName`, move `_`-rejection to admission walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier commits in this branch introduced `LegacyClarityName` as a narrow refinement type at `TransactionContractCall.function_name` and `AssetInfo.asset_name`, with the wide `ClarityName` everywhere else. That hybrid (codec-level enforcement for scalars, admission-walker for tuple keys) was consensus-safe but left a permanent language-level irregularity: a Clarity-6 contract can define `(define-public (_foo) ...)` or `(define-fungible-token _coin ...)` but those names can never be referenced from external transactions, because their wire-narrow codec rejects `_foo`. The restriction would persist forever, with no language-level rationale — it's a wire-format implementation detail masquerading as a language rule. This commit unifies the design on the admission-walker pattern, which is how the Stacks codebase already gates every other epoch-conditional wire rule (SIP-040 post-conditions, originator mode, NFT MaybeSent, Nakamoto coinbase VRF, TenureChange, ...). Both upgraded and un-upgraded nodes reach the same verdict on every transaction during the upgrade window, so consensus is preserved; after Clarity-6 activates, the admission gate flips to accept and `_foo` becomes callable. Changes: * `clarity-types/src/representations.rs` — drop `LegacyClarityName` type, `LEGACY_CLARITY_NAME_REGEX_STRING`, `LEGACY_CLARITY_NAME_REGEX`, the `From for ClarityName` widening, the `TryFrom for LegacyClarityName` narrowing, and the `StacksMessageCodec` impl. * `clarity-types/src/types/serialization.rs` — drop `serialize_guarded_string!(LegacyClarityName)`. * `clarity-types/src/tests/representations.rs` — drop the `LegacyClarityName` test suite (validity, codec round-trip, conversions). The remaining tests cover the wide `ClarityName` which now is the only name type. * `clarity/src/vm/representations.rs` — drop the `LegacyClarityName` / regex re-exports. * `stacks-codec/src/strings.rs` — drop `impl From for StacksString`. * `stacks-codec/src/transaction.rs` — `TransactionContractCall.function_name` and `AssetInfo.asset_name` revert to `ClarityName`. `new_contract_call` constructor uses `ClarityName::try_from` again. * `stackslib/src/chainstate/stacks/block.rs::validate_transaction_static_epoch` — extended to reject leading-`_` `function_name` and `asset_name` (Fungible + Nonfungible post-conditions) when `epoch_id < StacksEpochId::Epoch40`. Tuple-key check unchanged. Five new admission tests cover the scalar paths (`rejects_leading_underscore_function_name_pre_clarity6`, `rejects_leading_underscore_asset_name_pre_clarity6`, `admits_leading_underscore_scalars_in_clarity6`, `admits_plain_scalar_names`) plus existing tuple-key tests. * `stackslib/src/chainstate/stacks/db/transactions.rs` — the runtime widening `.into()` calls at `AssetIdentifier::asset_name` are now no-ops (both sides are `ClarityName`); dropped along with their inline `// Widen LegacyClarityName -> ClarityName` comments. * `stackslib/src/chainstate/stacks/transaction.rs` — codec round-trip tests for leading-`_` `function_name` and `asset_name` flip semantics: the codec now *accepts* the bytes (deserialization succeeds), and the admission tests in `block.rs` lock the consensus rule. * All previously-migrated test construction sites (~20 files across stackslib, stacks-node, contrib, ...) revert to `ClarityName::try_from` / `ClarityName::from_literal`. * `clarity/src/vm/ast/underscore_checker.rs` — module doc refreshed to point at the admission walker (no longer at `LegacyClarityName`). Net result: one wide `ClarityName` type, no language-level restrictions on leading `_`, single uniform epoch-gated admission pattern. The PR's source-level relaxation can stand on its own without any permanent wire-format or type-system irregularities. `cargo check --workspace --all-targets` clean; targeted tests all pass (clarity-types: 236; transaction.rs: 124; block.rs: 40). --- clarity-types/src/representations.rs | 108 +----------- clarity-types/src/tests/representations.rs | 123 +------------ clarity-types/src/tests/types/mod.rs | 16 +- clarity-types/src/types/mod.rs | 32 ++-- clarity-types/src/types/serialization.rs | 3 +- clarity/src/vm/ast/underscore_checker.rs | 18 +- clarity/src/vm/representations.rs | 3 +- contrib/stacks-cli/src/main.rs | 7 +- stacks-codec/src/strings.rs | 10 +- stacks-codec/src/transaction.rs | 26 +-- stacks-node/src/event_dispatcher/tests.rs | 4 +- stacks-node/src/tests/integrations.rs | 5 +- .../src/tests/nakamoto_integrations.rs | 3 +- stacks-node/src/tests/neon_integrations.rs | 4 +- stackslib/src/chainstate/coordinator/tests.rs | 4 +- .../src/chainstate/nakamoto/signer_set.rs | 3 +- .../src/chainstate/nakamoto/tests/mod.rs | 54 +++--- stackslib/src/chainstate/stacks/block.rs | 163 +++++++++++++++--- .../src/chainstate/stacks/db/transactions.rs | 44 +++-- stackslib/src/chainstate/stacks/mod.rs | 6 +- .../src/chainstate/stacks/transaction.rs | 81 ++++----- stackslib/src/chainstate/tests/consensus.rs | 6 +- .../tests/madhouse/commands/sip040.rs | 4 +- stackslib/src/clarity_vm/clarity.rs | 4 +- stackslib/src/clarity_vm/tests/ephemeral.rs | 4 +- stackslib/src/core/test_util.rs | 6 +- .../cost_estimates/tests/cost_estimators.rs | 6 +- .../src/cost_estimates/tests/fee_medians.rs | 4 +- .../src/cost_estimates/tests/fee_scalar.rs | 4 +- stackslib/src/net/api/tests/blockreplay.rs | 4 +- stackslib/src/net/api/tests/blocksimulate.rs | 4 +- 31 files changed, 300 insertions(+), 463 deletions(-) diff --git a/clarity-types/src/representations.rs b/clarity-types/src/representations.rs index 51c88a61019..49b089889c8 100644 --- a/clarity-types/src/representations.rs +++ b/clarity-types/src/representations.rs @@ -52,35 +52,17 @@ lazy_static! { "({})|({})", *STANDARD_PRINCIPAL_REGEX_STRING, *CONTRACT_PRINCIPAL_REGEX_STRING ); - // `ClarityName` — the type used in the language AST, analyzer, runtime, - // and (for now) all wire positions. Permits identifiers to begin with - // `_` — including the bare `_` discard binding — per Clarity 6. This - // is the *wide* type in the two-type design. - // - // Three alternation arms: - // 1) `[a-zA-Z_]...` — identifier starting with a letter or `_` - // (Clarity 6 admits leading `_`). - // 2) `[-+=/*]` — single-char operator name. - // 3) `[<>]=?` — comparison operator name. + // `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(); - // `LegacyClarityName` — the pre-Clarity-6 (narrow) rules. Identifiers - // must begin with a letter or be an operator name; leading `_` is - // forbidden. This type is used at wire positions that must statically - // reject `_`-prefixed names so that legacy variants in - // `TransactionPayload`, post-conditions, and tuple keys can't carry a - // value that would deserialize on updated nodes but not on - // un-updated ones (the consensus risk that motivated this refactor). - pub static ref LEGACY_CLARITY_NAME_REGEX_STRING: String = - "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$".into(); pub static ref CLARITY_NAME_REGEX: Regex = { Regex::new(CLARITY_NAME_REGEX_STRING.as_str()).unwrap() }; - pub static ref LEGACY_CLARITY_NAME_REGEX: Regex = - { - Regex::new(LEGACY_CLARITY_NAME_REGEX_STRING.as_str()).unwrap() - }; pub static ref CONTRACT_NAME_REGEX: Regex = { Regex::new(format!("^{}$|^__transient$", CONTRACT_NAME_REGEX_STRING.as_str()).as_str()) @@ -96,14 +78,6 @@ guarded_string!( ClarityTypeError::InvalidClarityName ); -guarded_string!( - LegacyClarityName, - LEGACY_CLARITY_NAME_REGEX, - MAX_STRING_LEN, - ClarityTypeError, - ClarityTypeError::InvalidClarityName -); - guarded_string!( ContractName, CONTRACT_NAME_REGEX, @@ -112,34 +86,6 @@ guarded_string!( ClarityTypeError::InvalidContractName ); -/// Widening from the narrow `LegacyClarityName` to the wide `ClarityName`. -/// Infallible: every string accepted by the legacy regex is also accepted -/// by the wide `ClarityName` regex. -impl From for ClarityName { - fn from(name: LegacyClarityName) -> Self { - // SAFETY: `name.0` already passed the narrow regex, which is a - // subset of the wide regex. Reconstructed via `try_from` so the - // wide invariant is enforced by its own constructor (defensive - // against any future tightening of the narrow regex relative to - // the wide one). - let raw: String = name.into(); - ClarityName::try_from(raw) - .expect("BUG: every LegacyClarityName must be a valid ClarityName") - } -} - -/// Narrowing from the wide `ClarityName` to the narrow `LegacyClarityName`. -/// Fallible: a name beginning with `_` (or the bare `_`) is admitted by -/// the wide regex but not by the legacy one. Use this at boundaries where -/// an AST/runtime value flows into a wire-narrow position. -impl TryFrom for LegacyClarityName { - type Error = ClarityTypeError; - fn try_from(name: ClarityName) -> Result { - let raw: String = name.into(); - LegacyClarityName::try_from(raw) - } -} - impl StacksMessageCodec for ClarityName { #[allow(clippy::needless_as_bytes)] // as_bytes isn't necessary, but verbosity is preferable in the codec impls fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { @@ -181,50 +127,6 @@ impl StacksMessageCodec for ClarityName { } } -impl StacksMessageCodec for LegacyClarityName { - #[allow(clippy::needless_as_bytes)] - fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { - // Error wording deliberately matches the historical - // `ClarityName::consensus_serialize` message so wire-level - // diagnostics — and any downstream tests that key off them — - // remain stable across this refactor. - if self.as_bytes().len() > MAX_STRING_LEN as usize { - return Err(codec_error::SerializeError( - "Failed to serialize clarity name: too long".to_string(), - )); - } - write_next(fd, &(self.as_bytes().len() as u8))?; - fd.write_all(self.as_bytes()) - .map_err(codec_error::WriteError)?; - Ok(()) - } - - fn consensus_deserialize(fd: &mut R) -> Result { - let len_byte: u8 = read_next(fd)?; - if len_byte > MAX_STRING_LEN { - return Err(codec_error::DeserializeError( - "Failed to deserialize clarity name: too long".to_string(), - )); - } - let mut bytes = vec![0u8; len_byte as usize]; - fd.read_exact(&mut bytes).map_err(codec_error::ReadError)?; - - let s = String::from_utf8(bytes).map_err(|_e| { - codec_error::DeserializeError( - "Failed to parse Clarity name: could not construct from utf8".to_string(), - ) - })?; - - // Narrow regex enforced here — bytes that decode to a `_`-prefixed - // name produce a `DeserializeError`, which is the exact behavior - // unmodified (pre-PR) nodes exhibit. Consensus preserved. - let name = LegacyClarityName::try_from(s).map_err(|e| { - codec_error::DeserializeError(format!("Failed to parse Clarity name: {e:?}")) - })?; - Ok(name) - } -} - impl StacksMessageCodec for ContractName { #[allow(clippy::needless_as_bytes)] // as_bytes isn't necessary, but verbosity is preferable in the codec impls fn consensus_serialize(&self, fd: &mut W) -> Result<(), codec_error> { diff --git a/clarity-types/src/tests/representations.rs b/clarity-types/src/tests/representations.rs index 3b1f6898092..f589093f277 100644 --- a/clarity-types/src/tests/representations.rs +++ b/clarity-types/src/tests/representations.rs @@ -17,8 +17,7 @@ use rstest::rstest; use crate::errors::ClarityTypeError; use crate::representations::{ - CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ContractName, - LegacyClarityName, MAX_STRING_LEN, + CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, ClarityName, ContractName, MAX_STRING_LEN, }; use crate::stacks_common::codec::StacksMessageCodec; @@ -217,126 +216,6 @@ fn test_contract_name_deserialization_errors(#[case] buffer: Vec, #[case] er assert_eq!(result.unwrap_err().to_string(), error_message); } -// `LegacyClarityName` — the narrow (pre-Clarity-6) name type used at -// wire positions that must reject `_`-prefixed names for consensus -// safety. Mirrors the historical `ClarityName` acceptance set. - -#[rstest] -#[case::plain_letter("hello")] -#[case::dash("hello-dash")] -#[case::interior_underscore("hello_underscore")] -#[case::numbers("test123")] -#[case::single_letter("a")] -#[case::exclamation_mark("set-token-uri!")] -#[case::question_mark("is-owner?")] -#[case::single_operator("*")] -#[case::less_than_or_equal_to("<=")] -fn test_legacy_clarity_name_valid(#[case] name: &str) { - let legacy = LegacyClarityName::try_from(name.to_string()) - .unwrap_or_else(|_| panic!("Should parse valid LegacyClarityName: {name}")); - assert_eq!(legacy.as_str(), name); -} - -/// The defining contract of `LegacyClarityName`: it MUST reject every -/// name that begins with `_`, including the bare `_`. These are the -/// names whose wire-level acceptance would split the chain. -#[rstest] -#[case::leading_underscore("_admin")] -#[case::bare_underscore("_")] -#[case::double_underscore("__")] -#[case::underscore_with_operators("_check!?")] -#[case::underscore_with_digits("_var123")] -#[case::underscore_then_dash("_-")] -fn test_legacy_clarity_name_rejects_leading_underscore(#[case] name: &str) { - let result = LegacyClarityName::try_from(name.to_string()); - assert!(result.is_err(), "expected {name:?} to be rejected by LegacyClarityName"); - assert!(matches!( - result.unwrap_err(), - ClarityTypeError::InvalidClarityName(_) - )); -} - -#[rstest] -#[case::empty("")] -#[case::starts_with_number("123abc")] -#[case::contains_space("hello world")] -#[case::contains_dot("hello.world")] -#[case::too_long(&"a".repeat(MAX_STRING_LEN as usize + 1))] -fn test_legacy_clarity_name_invalid(#[case] name: &str) { - let result = LegacyClarityName::try_from(name.to_string()); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ClarityTypeError::InvalidClarityName(_) - )); -} - -#[rstest] -#[case("hello")] -#[case("contract-call?")] -#[case("*")] -fn test_legacy_clarity_name_serialization(#[case] name: &str) { - let name = LegacyClarityName::try_from(name.to_string()).unwrap(); - - let mut buffer = Vec::new(); - name.consensus_serialize(&mut buffer) - .unwrap_or_else(|_| panic!("Serialization should succeed for name: {name}")); - - assert_eq!(buffer[0], name.len()); - assert_eq!(&buffer[1..], name.as_bytes()); - - let deserialized = LegacyClarityName::consensus_deserialize(&mut buffer.as_slice()).unwrap(); - assert_eq!(deserialized, name); -} - -/// The codec contract that makes this whole refactor consensus-safe: a -/// `_`-prefixed name MUST fail to deserialize at the wire layer. If this -/// test ever flips to passing, the chain-split risk that motivated the -/// `LegacyClarityName` introduction has silently reappeared. -#[test] -fn test_legacy_clarity_name_rejects_underscore_on_the_wire() { - let underscore_bytes = [4, b'_', b'f', b'o', b'o']; - let result = LegacyClarityName::consensus_deserialize(&mut underscore_bytes.as_slice()); - assert!(result.is_err(), "leading `_` must not deserialize as LegacyClarityName"); -} - -/// Widening (`LegacyClarityName` -> `ClarityName`) is infallible — every -/// legacy name is also a valid wide name. -#[rstest] -#[case("hello")] -#[case("contract-call?")] -#[case("set!")] -#[case("*")] -#[case("<=")] -fn test_legacy_to_wide_widening(#[case] name: &str) { - let legacy = LegacyClarityName::try_from(name.to_string()).unwrap(); - let wide: ClarityName = legacy.clone().into(); - assert_eq!(wide.as_str(), legacy.as_str()); -} - -/// Narrowing (`ClarityName` -> `LegacyClarityName`) succeeds for names -/// that also satisfy the legacy regex. -#[rstest] -#[case("hello")] -#[case("foo-bar")] -fn test_wide_to_legacy_ok(#[case] name: &str) { - let wide = ClarityName::try_from(name.to_string()).unwrap(); - let legacy = LegacyClarityName::try_from(wide).expect("should narrow"); - assert_eq!(legacy.as_str(), name); -} - -/// Narrowing fails for `_`-prefixed names — the type-system guarantee -/// that legacy wire positions can't hold a leading-`_` name. -#[rstest] -#[case("_foo")] -#[case("_")] -#[case("__transient_like")] -fn test_wide_to_legacy_underscore_rejected(#[case] name: &str) { - let wide = ClarityName::try_from(name.to_string()).unwrap(); - let narrowed = LegacyClarityName::try_from(wide); - assert!(narrowed.is_err(), "narrowing of {name:?} must fail"); -} - /// Regression test for the issue where some `try_*` calls might panic instead of /// returning an error as they should. See https://github.com/stacks-network/stacks-core/pull/7065 #[test] diff --git a/clarity-types/src/tests/types/mod.rs b/clarity-types/src/tests/types/mod.rs index a5693de35ca..8245e7ca189 100644 --- a/clarity-types/src/tests/types/mod.rs +++ b/clarity-types/src/tests/types/mod.rs @@ -906,10 +906,7 @@ fn test_sequence_try_retain_internal_error() { // `Value::find_invalid_tuple_key` — the recursive walker that backs the // transaction-admission epoch gate for `_`-prefixed tuple keys. -/// Helper: a one-key tuple Value with the given key name and `Int(0)` -/// payload. `ClarityName::try_from` is used (rather than `from_literal`) -/// because the test inputs aren't `'static`; the walker only inspects -/// the string content of the key, not its regex validity. +/// Build a one-key tuple Value with the given key name. fn tuple_with_key(key: &str) -> Value { Value::Tuple( TupleData::from_data(vec![( @@ -1012,13 +1009,10 @@ fn test_find_invalid_tuple_key_descends_into_compound_values() { Some("_buried"), ); - // Tuple inside a list. Clarity lists are homogeneous (all elements - // share a `TupleTypeSignature`), so we can't easily exercise the - // "offender is the second element" path with mixed schemas — the - // single-element form is sufficient to prove the walker descends - // into `Sequence(List)`. The for-loop in - // `find_invalid_tuple_key`'s `Sequence(List)` arm makes the - // second-element behavior identical. + // 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).as_deref(), diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index 671e0e44e7c..c88dd683676 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -970,28 +970,22 @@ 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`. Used by transaction admission - /// to keep `_`-prefixed tuple keys off the wire when the active - /// epoch would not accept them. + /// Walk this value recursively and return the first tuple key + /// that would not be accepted at `epoch`. Used by transaction + /// admission to keep `_`-prefixed tuple keys off the wire. /// /// Two rules: - /// 1. Bare `_` is reserved as the `let` / `match` discard marker - /// and is never a valid *tuple key* — rejected at every epoch. - /// 2. Any other leading-`_` key is rejected when `epoch < Epoch40` - /// (pre-Clarity-6). The narrow wire codec on un-upgraded nodes - /// already rejects every leading-`_` name; matching that - /// behavior on upgraded nodes pre-activation preserves - /// consensus during the upgrade window. Post-activation, - /// `_foo` tuple keys are permitted. + /// 1. Bare `_` is never a valid tuple key (reserved as the + /// `let` / `match` discard marker; rejected at every epoch). + /// 2. Any other leading-`_` key is rejected pre-Clarity-6 (epoch + /// `< Epoch40`) to match un-upgraded nodes' narrow wire codec. + /// Post-activation, `_foo` tuple keys are permitted. /// - /// NOTE: this walker is the consensus-critical companion to the - /// "value sanitization" routine flagged above the `Value` enum (see - /// `Value`'s definition). Any new compound `Value` variant added in - /// the future — one that can carry `_other_ values_` like `Tuple`, - /// `Optional`, `Response`, or `Sequence(List)` — must be handled - /// here too, or `_`-prefixed tuple keys could escape into the - /// wire-format payload of an admitted transaction. + /// NOTE: this is the consensus-critical companion to the "value + /// sanitization" routine flagged 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 { match self { Value::Tuple(data) => { diff --git a/clarity-types/src/types/serialization.rs b/clarity-types/src/types/serialization.rs index 5db6f98ef17..2aa1cd26268 100644 --- a/clarity-types/src/types/serialization.rs +++ b/clarity-types/src/types/serialization.rs @@ -24,7 +24,7 @@ use stacks_common::util::retry::BoundReader; use super::{ListTypeData, TupleTypeSignature}; use crate::errors::{ClarityTypeError, IncomparableError}; -use crate::representations::{ClarityName, ContractName, LegacyClarityName, MAX_STRING_LEN}; +use crate::representations::{ClarityName, ContractName, MAX_STRING_LEN}; use crate::types::{ BOUND_VALUE_SERIALIZATION_BYTES, BufferLength, CallableData, CharType, MAX_TYPE_DEPTH, MAX_VALUE_SIZE, OptionalData, PrincipalData, QualifiedContractIdentifier, SequenceData, @@ -248,7 +248,6 @@ macro_rules! serialize_guarded_string { } serialize_guarded_string!(ClarityName); -serialize_guarded_string!(LegacyClarityName); serialize_guarded_string!(ContractName); impl PrincipalData { diff --git a/clarity/src/vm/ast/underscore_checker.rs b/clarity/src/vm/ast/underscore_checker.rs index f0a18ef8822..de17367826f 100644 --- a/clarity/src/vm/ast/underscore_checker.rs +++ b/clarity/src/vm/ast/underscore_checker.rs @@ -13,19 +13,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! AST pass that rejects identifiers beginning with `_` for pre-`Clarity6` +//! AST pass that rejects identifiers beginning with `_` for pre-Clarity-6 //! contracts. //! //! The wide `ClarityName` regex and the v2 lexer accept underscore-led -//! names unconditionally so that the parser can produce a well-formed AST -//! and report a precise, version-aware diagnostic here rather than a -//! generic "illegal name" lexer error. The narrow `LegacyClarityName` -//! type, used at wire-narrow positions (`TransactionContractCall.function_name`, -//! `AssetInfo.asset_name`), separately enforces the pre-Clarity-6 rule at -//! the codec layer; together with this pass and the tuple-key admission -//! check in `StacksBlock::validate_transaction_static_epoch`, leading-`_` -//! names are kept out of pre-Clarity-6 contexts at every layer. Clarity 6 -//! permits the relaxation only from `ClarityVersion::Clarity6` onwards. +//! names unconditionally so the parser can produce a well-formed AST and +//! emit a precise version-aware diagnostic here. The admission walker in +//! `StacksBlock::validate_transaction_static_epoch` enforces the same +//! rule at the wire layer (rejecting leading-`_` `function_name`, +//! `asset_name`, and tuple keys in transactions whose active epoch is +//! pre-Clarity-6) so updated and un-updated nodes agree during the +//! upgrade window. use clarity_types::representations::ClarityName; use stacks_common::types::StacksEpochId; diff --git a/clarity/src/vm/representations.rs b/clarity/src/vm/representations.rs index 803c7403a1f..7f2c6370ed0 100644 --- a/clarity/src/vm/representations.rs +++ b/clarity/src/vm/representations.rs @@ -17,8 +17,7 @@ pub use clarity_types::representations::{ CLARITY_NAME_REGEX, CLARITY_NAME_REGEX_STRING, CONTRACT_MAX_NAME_LENGTH, CONTRACT_MIN_NAME_LENGTH, CONTRACT_NAME_REGEX, CONTRACT_NAME_REGEX_STRING, - CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, DISCARD_IDENTIFIER, - LEGACY_CLARITY_NAME_REGEX, LEGACY_CLARITY_NAME_REGEX_STRING, LegacyClarityName, MAX_STRING_LEN, + CONTRACT_PRINCIPAL_REGEX_STRING, ClarityName, ContractName, DISCARD_IDENTIFIER, MAX_STRING_LEN, PRINCIPAL_DATA_REGEX_STRING, PreSymbolicExpression, PreSymbolicExpressionType, STANDARD_PRINCIPAL_REGEX_STRING, Span, SymbolicExpression, SymbolicExpressionCommon, SymbolicExpressionType, TraitDefinition, depth_traverse, diff --git a/contrib/stacks-cli/src/main.rs b/contrib/stacks-cli/src/main.rs index 7be6b769d57..a26954a439d 100644 --- a/contrib/stacks-cli/src/main.rs +++ b/contrib/stacks-cli/src/main.rs @@ -24,7 +24,7 @@ use std::{env, fs, io}; use clarity::vm::ast::errors::ParseError; use clarity::vm::errors::{ClarityEvalError, ClarityTypeError, VmExecutionError}; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::PrincipalData; use clarity::vm::{ClarityVersion, ContractName, Value}; use clarity_cli::vm_execute; @@ -275,10 +275,7 @@ fn make_contract_call( let address = StacksAddress::from_string(&contract_address).ok_or("Failed to parse contract address")?; let contract_name = ContractName::try_from(contract_name)?; - // Wire-narrow `LegacyClarityName`. Calls to Clarity-6 `_`-prefixed - // functions are unsupported here until a versioned `ContractCall` - // payload is introduced. - let function_name = LegacyClarityName::try_from(function_name)?; + let function_name = ClarityName::try_from(function_name)?; Ok(TransactionContractCall { address, diff --git a/stacks-codec/src/strings.rs b/stacks-codec/src/strings.rs index 3502348f200..2f774c36d49 100644 --- a/stacks-codec/src/strings.rs +++ b/stacks-codec/src/strings.rs @@ -18,7 +18,7 @@ use std::fmt; use std::io::{Read, Write}; use std::ops::{Deref, DerefMut}; -use clarity_types::representations::{ClarityName, ContractName, LegacyClarityName}; +use clarity_types::representations::{ClarityName, ContractName}; use serde::{Deserialize, Serialize}; use stacks_common::codec::{ read_next, write_next, Error as codec_error, StacksMessageCodec, MAX_MESSAGE_LEN, @@ -91,14 +91,6 @@ impl From for StacksString { } } -impl From for StacksString { - fn from(legacy_name: LegacyClarityName) -> StacksString { - // .unwrap() is safe since StacksString is less strict than - // LegacyClarityName's narrow regex. - StacksString::from_str(&legacy_name).unwrap() - } -} - impl From for StacksString { fn from(contract_name: ContractName) -> StacksString { // .unwrap() is safe since StacksString is less strict diff --git a/stacks-codec/src/transaction.rs b/stacks-codec/src/transaction.rs index fc8964e96d1..d81298aade6 100644 --- a/stacks-codec/src/transaction.rs +++ b/stacks-codec/src/transaction.rs @@ -25,7 +25,7 @@ use std::fmt::{self, Display}; use std::hash::Hash; use std::io::{Read, Write}; -use clarity_types::representations::{ContractName, LegacyClarityName}; +use clarity_types::representations::{ClarityName, ContractName}; use clarity_types::types::{ PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, Value, }; @@ -2026,17 +2026,11 @@ impl TransactionAuth { } /// A transaction that calls into a smart contract. -/// -/// The `function_name` is held as a [`LegacyClarityName`] so the wire codec -/// statically rejects names beginning with `_`. Calls into Clarity-6 -/// `_`-prefixed functions would need a versioned variant of this payload -/// (analogous to [`TransactionPayloadID::VersionedSmartContract`]), which -/// is not yet introduced. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TransactionContractCall { pub address: StacksAddress, pub contract_name: ContractName, - pub function_name: LegacyClarityName, + pub function_name: ClarityName, pub function_args: Vec, } @@ -2082,7 +2076,7 @@ impl StacksMessageCodec for TransactionContractCall { fn consensus_deserialize(fd: &mut R) -> Result { let address: StacksAddress = read_next(fd)?; let contract_name: ContractName = read_next(fd)?; - let function_name: LegacyClarityName = read_next(fd)?; + let function_name: ClarityName = read_next(fd)?; let function_args: Vec = { let mut bound_read = BoundReader::from_reader(fd, u64::from(MAX_TRANSACTION_LEN)); read_next(&mut bound_read) @@ -2127,16 +2121,11 @@ impl StacksMessageCodec for TransactionSmartContract { } /// Encoding of an asset type identifier. -/// -/// `asset_name` is a [`LegacyClarityName`] so the codec rejects bytes -/// encoding a `_`-prefixed asset on deserialize. Post-conditions -/// referencing Clarity-6 `_`-prefixed assets are unsupported here until -/// a versioned `AssetInfo` variant is introduced alongside. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AssetInfo { pub contract_address: StacksAddress, pub contract_name: ContractName, - pub asset_name: LegacyClarityName, + pub asset_name: ClarityName, } impl StacksMessageCodec for AssetInfo { @@ -2150,7 +2139,7 @@ impl StacksMessageCodec for AssetInfo { fn consensus_deserialize(fd: &mut R) -> Result { let contract_address: StacksAddress = read_next(fd)?; let contract_name: ContractName = read_next(fd)?; - let asset_name: LegacyClarityName = read_next(fd)?; + let asset_name: ClarityName = read_next(fd)?; Ok(AssetInfo { contract_address, contract_name, @@ -2588,10 +2577,7 @@ impl TransactionPayload { } }; - // Wire-narrow `LegacyClarityName` constructor — rejects leading - // `_` names. Calls to Clarity-6 `_`-prefixed functions are - // unsupported here until a versioned `ContractCall` payload exists. - let function_name_str = match LegacyClarityName::try_from(function_name.to_string()) { + let function_name_str = match ClarityName::try_from(function_name.to_string()) { Ok(s) => s, Err(_) => { return None; diff --git a/stacks-node/src/event_dispatcher/tests.rs b/stacks-node/src/event_dispatcher/tests.rs index 2ec78d84024..4f417871c24 100644 --- a/stacks-node/src/event_dispatcher/tests.rs +++ b/stacks-node/src/event_dispatcher/tests.rs @@ -20,7 +20,7 @@ use std::thread; use std::time::{Instant, SystemTime}; use clarity::boot_util::boot_code_id; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::costs::ExecutionCost; use clarity::vm::events::SmartContractEventData; use clarity::vm::types::StacksAddressExtensions; @@ -65,7 +65,7 @@ fn test_post_condition_aborted_transaction_does_not_emit_events() { let addr = to_addr(&private_key); let contract_name = ContractName::from_literal("test"); - let function_name = LegacyClarityName::from_literal("test"); + let function_name = ClarityName::from_literal("test"); let payload = TransactionContractCall { address: addr.clone(), diff --git a/stacks-node/src/tests/integrations.rs b/stacks-node/src/tests/integrations.rs index f748885d041..993dca370ed 100644 --- a/stacks-node/src/tests/integrations.rs +++ b/stacks-node/src/tests/integrations.rs @@ -18,7 +18,6 @@ use std::collections::HashMap; use std::fmt::Write; use std::sync::Mutex; -use clarity::vm::representations::LegacyClarityName; use clarity::vm::analysis::contract_interface_builder::{ build_contract_interface, ContractInterface, }; @@ -986,7 +985,7 @@ fn integration_test_get_info() { let tx_payload = TransactionPayload::from(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("get-info"), - function_name: LegacyClarityName::from_literal("update-info"), + function_name: ClarityName::from_literal("update-info"), function_args: vec![], }); @@ -1036,7 +1035,7 @@ fn integration_test_get_info() { let tx_payload = TransactionPayload::from(TransactionContractCall { address: contract_addr, contract_name: ContractName::from_literal("get-info"), - function_name: LegacyClarityName::from_literal("update-info"), + function_name: ClarityName::from_literal("update-info"), function_args: vec![], }); diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 98193a896d0..b8aa0a05055 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -24,7 +24,6 @@ use std::time::{Duration, Instant}; use std::{env, thread}; use clarity::boot_util::boot_code_addr; -use clarity::vm::representations::LegacyClarityName; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::representations::ContractName; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StandardPrincipalData}; @@ -16193,7 +16192,7 @@ fn check_sip040_post_conditions() { AssetInfo { contract_address: sender_addr.clone(), contract_name: ContractName::from_literal(contract_name), - asset_name: LegacyClarityName::from_literal("asset"), + asset_name: ClarityName::from_literal("asset"), }, Value::UInt(1), NonfungibleConditionCode::MaybeSent, diff --git a/stacks-node/src/tests/neon_integrations.rs b/stacks-node/src/tests/neon_integrations.rs index 0ac5a3c40fe..9a0f09ac746 100644 --- a/stacks-node/src/tests/neon_integrations.rs +++ b/stacks-node/src/tests/neon_integrations.rs @@ -21,7 +21,7 @@ use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; use std::{cmp, env, fs, io, thread}; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::ast::stack_depth_checker::StackDepthLimits; use clarity::vm::costs::ExecutionCost; use clarity::vm::types::serialization::SerializationError; @@ -7362,7 +7362,7 @@ fn fuzzed_median_fee_rate_estimation_test(window_size: u64, expected_final_value let tx_payload = TransactionPayload::ContractCall(TransactionContractCall { address: spender_addr.clone(), contract_name: ContractName::from_literal("increment-contract"), - function_name: LegacyClarityName::from_literal("increment-many"), + function_name: ClarityName::from_literal("increment-many"), function_args: vec![], }); diff --git a/stackslib/src/chainstate/coordinator/tests.rs b/stackslib/src/chainstate/coordinator/tests.rs index 262335835b2..a8e823d0e3f 100644 --- a/stackslib/src/chainstate/coordinator/tests.rs +++ b/stackslib/src/chainstate/coordinator/tests.rs @@ -24,7 +24,7 @@ use clarity::vm::clarity::TransactionConnection; use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; use clarity::vm::database::BurnStateDB; use clarity::vm::errors::ClarityEvalError; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; use clarity::vm::{ContractName, Value}; use lazy_static::lazy_static; @@ -4730,7 +4730,7 @@ fn atlas_stop_start() { TransactionPayload::ContractCall(TransactionContractCall { address: signer_pk.clone(), contract_name: atlas_name.clone(), - function_name: LegacyClarityName::from_literal("make-attach"), + function_name: ClarityName::from_literal("make-attach"), function_args: vec![Value::buff_from(vec![ix; 20]).unwrap()], }), ), diff --git a/stackslib/src/chainstate/nakamoto/signer_set.rs b/stackslib/src/chainstate/nakamoto/signer_set.rs index 6aa874da2b0..d7925ea7dd1 100644 --- a/stackslib/src/chainstate/nakamoto/signer_set.rs +++ b/stackslib/src/chainstate/nakamoto/signer_set.rs @@ -18,7 +18,6 @@ use std::sync::{LazyLock, RwLock}; use clarity::vm::events::StacksTransactionEvent; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, TupleData}; -use clarity::vm::representations::LegacyClarityName; use clarity::vm::{ClarityName, SymbolicExpression, Value}; use stacks_common::types::chainstate::{StacksAddress, StacksBlockId}; use stacks_common::types::StacksEpochId; @@ -1129,7 +1128,7 @@ impl NakamotoSigners { }; if payload.contract_identifier() != boot_code_id(SIGNERS_VOTING_NAME, transaction.is_mainnet()) - || payload.function_name != LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME) + || payload.function_name != ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME) { // This is not a special cased transaction. return None; diff --git a/stackslib/src/chainstate/nakamoto/tests/mod.rs b/stackslib/src/chainstate/nakamoto/tests/mod.rs index 03adc23f200..b8677987e75 100644 --- a/stackslib/src/chainstate/nakamoto/tests/mod.rs +++ b/stackslib/src/chainstate/nakamoto/tests/mod.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use clarity::types::chainstate::{SortitionId, StacksBlockId}; use clarity::util::secp256k1::Secp256k1PrivateKey; use clarity::vm::costs::ExecutionCost; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::StacksAddressExtensions; use clarity::vm::{ContractName, Value}; use libstackerdb::StackerDBChunkData; @@ -2459,7 +2459,7 @@ fn parse_vote_for_aggregate_public_key_valid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2512,7 +2512,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { &StacksPublicKey::from_private(&signer_private_key), ), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2528,7 +2528,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("bad-signers-contract-name"), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2544,7 +2544,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal("some-other-function"), + function_name: ClarityName::from_literal("some-other-function"), function_args: valid_function_args, }), }; @@ -2560,7 +2560,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ aggregate_key_arg.clone(), aggregate_key_arg.clone(), @@ -2581,7 +2581,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), signer_index_arg.clone(), @@ -2602,7 +2602,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), aggregate_key_arg.clone(), @@ -2623,7 +2623,7 @@ fn parse_vote_for_aggregate_public_key_invalid() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg, aggregate_key_arg.clone(), @@ -2693,7 +2693,7 @@ fn valid_vote_transaction() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2767,7 +2767,7 @@ fn valid_vote_transaction_malformed_transactions() { &StacksPublicKey::from_private(&signer_private_key), ), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2783,7 +2783,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: ContractName::from_literal("bad-signers-contract-name"), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2799,7 +2799,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args.clone(), }), }; @@ -2815,7 +2815,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal("some-other-function"), + function_name: ClarityName::from_literal("some-other-function"), function_args: valid_function_args.clone(), }), }; @@ -2831,7 +2831,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ aggregate_key_arg.clone(), aggregate_key_arg.clone(), @@ -2852,7 +2852,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), signer_index_arg.clone(), @@ -2873,7 +2873,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg.clone(), aggregate_key_arg.clone(), @@ -2894,7 +2894,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: vec![ signer_index_arg, aggregate_key_arg.clone(), @@ -2915,7 +2915,7 @@ fn valid_vote_transaction_malformed_transactions() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: valid_function_args, }), }; @@ -2984,7 +2984,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3000,7 +3000,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3016,7 +3016,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3032,7 +3032,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3048,7 +3048,7 @@ fn filter_one_transaction_per_signer_multiple_addresses() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args, }), }; @@ -3115,7 +3115,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3131,7 +3131,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr.clone(), contract_name: contract_name.clone(), - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args: function_args.clone(), }), }; @@ -3147,7 +3147,7 @@ fn filter_one_transaction_per_signer_duplicate_nonces() { payload: TransactionPayload::ContractCall(TransactionContractCall { address: contract_addr, contract_name, - function_name: LegacyClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), + function_name: ClarityName::from_literal(SIGNERS_VOTING_FUNCTION_NAME), function_args, }), }; diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index 384bf8c5303..689ad74476d 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -631,30 +631,26 @@ impl StacksBlock { error!("Authentication mode not supported in Epoch {epoch_id}"); return false; } - // Reject transactions whose Values carry a tuple key that isn't - // accepted at this epoch: - // * bare `_` is never a valid tuple key - // * leading-`_` is rejected pre-Clarity-6 (Epoch40) to - // preserve consensus with un-upgraded nodes whose narrow - // wire codec rejects every leading-`_` name. The companion - // wire types `LegacyClarityName` (used by - // `TransactionContractCall.function_name` and - // `AssetInfo.asset_name`) already enforce the rule for - // those fields at deserialize time; this walk covers the - // embedded `Value` positions — - // `TransactionContractCall.function_args` and the - // `Nonfungible` post-condition's `asset_value` — that the - // codec can't gate without a versioned `Value` variant. - // - // Note: `TransactionPayload::SmartContract.code_body` is not - // walked here because the contract source is `StacksString` - // (raw bytes), not a structured `Value`. Source-level - // leading-`_` names are gated by the `UnderscoreIdentifierChecker` - // AST pass at analysis time, and the bytes themselves are - // version-blind on the wire (both upgraded and un-upgraded - // nodes accept the same source-text payload), so no codec - // divergence is possible. + // Reject leading-`_` names at every wire position when the + // active epoch is pre-Clarity-6 (Epoch40). This mirrors what + // un-upgraded nodes' narrow wire codec does and so preserves + // consensus during the upgrade window. Bare `_` is *also* + // rejected for tuple keys at every epoch (it would otherwise + // be referenceable via `(get _ …)`, contradicting its discard + // semantic). `SmartContract.code_body` is intentionally skipped: + // its bytes are version-blind `StacksString` (no `ClarityName` + // codec step), and source-level rejection happens in + // `UnderscoreIdentifierChecker`. if let TransactionPayload::ContractCall(ref cc) = &tx.payload { + if epoch_id < StacksEpochId::Epoch40 && cc.function_name.starts_with('_') { + error!( + "Disallowed leading-`_` function_name pre-Clarity-6"; + "txid" => %tx.txid(), + "epoch" => %epoch_id, + "function_name" => %cc.function_name, + ); + return false; + } for arg in &cc.function_args { if let Some(bad_key) = arg.find_invalid_tuple_key(epoch_id) { error!( @@ -668,6 +664,22 @@ impl StacksBlock { } } for post_condition in tx.post_conditions.iter() { + let asset_info_opt = match post_condition { + TransactionPostCondition::Fungible(_, ref ai, _, _) + | TransactionPostCondition::Nonfungible(_, ref ai, _, _) => Some(ai), + TransactionPostCondition::STX(..) => None, + }; + if let Some(ai) = asset_info_opt { + if epoch_id < StacksEpochId::Epoch40 && ai.asset_name.starts_with('_') { + error!( + "Disallowed leading-`_` asset_name pre-Clarity-6"; + "txid" => %tx.txid(), + "epoch" => %epoch_id, + "asset_name" => %ai.asset_name, + ); + return false; + } + } if let TransactionPostCondition::Nonfungible(_, _, ref asset_value, _) = post_condition { if let Some(bad_key) = asset_value.find_invalid_tuple_key(epoch_id) { @@ -851,7 +863,7 @@ impl StacksMicroblock { #[cfg(test)] mod test { use clarity::types::PublicKey; - use clarity::vm::representations::LegacyClarityName; + use clarity::vm::representations::ClarityName; use clarity::vm::types::TupleData; use rstest::rstest; use stacks_common::address::*; @@ -2101,7 +2113,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x22; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("asset").unwrap(), + asset_name: ClarityName::try_from("asset").unwrap(), }, Value::Int(1), NonfungibleConditionCode::MaybeSent, @@ -2142,7 +2154,7 @@ mod test { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - function_name: LegacyClarityName::try_from("do-thing").unwrap(), + function_name: ClarityName::try_from("do-thing").unwrap(), function_args: vec![single_key_tuple(key)], }), ) @@ -2168,7 +2180,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x22; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("asset").unwrap(), + asset_name: ClarityName::try_from("asset").unwrap(), }, single_key_tuple(key), NonfungibleConditionCode::Sent, @@ -2241,6 +2253,103 @@ mod test { )); } + /// Build a contract-call transaction whose `function_name` is the + /// supplied string. Bypasses `ClarityName`-style construction + /// so we can exercise admission rejection independently of any + /// type-system constraints on field construction. + fn admission_test_contract_call_with_function_name(name: &str) -> StacksTransaction { + let privk = StacksPrivateKey::random(); + StacksTransaction::new( + TransactionVersion::Testnet, + admission_test_auth(&privk), + TransactionPayload::ContractCall(TransactionContractCall { + address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), + contract_name: ContractName::try_from("hello-world").unwrap(), + function_name: ClarityName::try_from(name).unwrap(), + function_args: vec![], + }), + ) + } + + /// Token-transfer transaction carrying one NFT post-condition whose + /// `asset_name` is the supplied string. + fn admission_test_nft_post_condition_with_asset_name(name: &str) -> StacksTransaction { + let privk = StacksPrivateKey::random(); + let mut tx = StacksTransaction::new( + TransactionVersion::Testnet, + admission_test_auth(&privk), + TransactionPayload::TokenTransfer( + PrincipalData::from(StacksAddress::new(1, Hash160([0x11; 20])).unwrap()), + 1, + TokenTransferMemo([0u8; 34]), + ), + ); + tx.post_conditions + .push(TransactionPostCondition::Nonfungible( + PostConditionPrincipal::Origin, + AssetInfo { + contract_address: StacksAddress::new(1, Hash160([0x22; 20])).unwrap(), + contract_name: ContractName::try_from("hello-world").unwrap(), + asset_name: ClarityName::try_from(name).unwrap(), + }, + Value::Int(0), + NonfungibleConditionCode::Sent, + )); + tx + } + + /// Pre-Clarity-6 — leading-`_` scalar names are rejected at admission. + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + fn test_validate_transaction_static_epoch_rejects_leading_underscore_function_name_pre_clarity6( + #[case] epoch_id: StacksEpochId, + ) { + let cc = admission_test_contract_call_with_function_name("_admin"); + assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + } + + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + fn test_validate_transaction_static_epoch_rejects_leading_underscore_asset_name_pre_clarity6( + #[case] epoch_id: StacksEpochId, + ) { + let pc = admission_test_nft_post_condition_with_asset_name("_admin"); + assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + } + + /// At Clarity-6, leading-`_` scalar names are admissible. + #[test] + fn test_validate_transaction_static_epoch_admits_leading_underscore_scalars_in_clarity6() { + let cc = admission_test_contract_call_with_function_name("_admin"); + assert!(StacksBlock::validate_transaction_static_epoch( + &cc, + StacksEpochId::Epoch40, + )); + + let pc = admission_test_nft_post_condition_with_asset_name("_admin"); + assert!(StacksBlock::validate_transaction_static_epoch( + &pc, + StacksEpochId::Epoch40, + )); + } + + /// Plain (no leading `_`) scalar names are admissible at every epoch. + #[rstest] + #[case(StacksEpochId::Epoch33)] + #[case(StacksEpochId::Epoch34)] + #[case(StacksEpochId::Epoch40)] + fn test_validate_transaction_static_epoch_admits_plain_scalar_names( + #[case] epoch_id: StacksEpochId, + ) { + let cc = admission_test_contract_call_with_function_name("do-thing"); + assert!(StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + + let pc = admission_test_nft_post_condition_with_asset_name("asset"); + assert!(StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + } + // TODO: // * size limits } diff --git a/stackslib/src/chainstate/stacks/db/transactions.rs b/stackslib/src/chainstate/stacks/db/transactions.rs index 24aa99cc6a1..80d4b72cd09 100644 --- a/stackslib/src/chainstate/stacks/db/transactions.rs +++ b/stackslib/src/chainstate/stacks/db/transactions.rs @@ -772,9 +772,7 @@ impl StacksChainState { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - // Widen the wire-narrow LegacyClarityName into the - // runtime ClarityName carried by AssetIdentifier. - asset_name: asset_info.asset_name.clone().into(), + asset_name: asset_info.asset_name.clone(), }; let amount_sent = asset_map @@ -810,9 +808,7 @@ impl StacksChainState { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - // Widen the wire-narrow LegacyClarityName into the - // runtime ClarityName carried by AssetIdentifier. - asset_name: asset_info.asset_name.clone().into(), + asset_name: asset_info.asset_name.clone(), }; let empty_assets = vec![]; @@ -2089,7 +2085,7 @@ pub mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("asset").unwrap(), + asset_name: ClarityName::try_from("asset").unwrap(), }, Value::Int(1), NonfungibleConditionCode::MaybeSent, @@ -3787,13 +3783,13 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: LegacyClarityName::try_from("stackaroos").unwrap(), + asset_name: ClarityName::try_from("stackaroos").unwrap(), }; let name_asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: LegacyClarityName::try_from("names").unwrap(), + asset_name: ClarityName::try_from("names").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -4492,13 +4488,13 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: LegacyClarityName::try_from("stackaroos").unwrap(), + asset_name: ClarityName::try_from("stackaroos").unwrap(), }; let name_asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name: contract_name.clone(), - asset_name: LegacyClarityName::try_from("names").unwrap(), + asset_name: ClarityName::try_from("names").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -5147,7 +5143,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: addr_publisher.clone(), contract_name, - asset_name: LegacyClarityName::try_from("connect-token").unwrap(), + asset_name: ClarityName::try_from("connect-token").unwrap(), }; let mut tx_contract = StacksTransaction::new( @@ -5242,19 +5238,19 @@ pub mod test { let asset_info_1 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset-1").unwrap(), + asset_name: ClarityName::try_from("test-asset-1").unwrap(), }; let asset_info_2 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset-2").unwrap(), + asset_name: ClarityName::try_from("test-asset-2").unwrap(), }; let asset_info_3 = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset-3").unwrap(), + asset_name: ClarityName::try_from("test-asset-3").unwrap(), }; let asset_id_1 = AssetIdentifier { @@ -5262,7 +5258,7 @@ pub mod test { StandardPrincipalData::from(asset_info_1.contract_address.clone()), asset_info_1.contract_name.clone(), ), - asset_name: asset_info_1.asset_name.clone().into(), + asset_name: asset_info_1.asset_name.clone(), }; let asset_id_2 = AssetIdentifier { @@ -5270,7 +5266,7 @@ pub mod test { StandardPrincipalData::from(asset_info_2.contract_address.clone()), asset_info_2.contract_name.clone(), ), - asset_name: asset_info_2.asset_name.clone().into(), + asset_name: asset_info_2.asset_name.clone(), }; let _asset_id_3 = AssetIdentifier { @@ -5278,7 +5274,7 @@ pub mod test { StandardPrincipalData::from(asset_info_3.contract_address.clone()), asset_info_3.contract_name.clone(), ), - asset_name: asset_info_3.asset_name.clone().into(), + asset_name: asset_info_3.asset_name.clone(), }; // multi-ft @@ -7086,7 +7082,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset").unwrap(), + asset_name: ClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { @@ -7094,7 +7090,7 @@ pub mod test { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone().into(), + asset_name: asset_info.asset_name.clone(), }; // multi-nft transfer @@ -7514,7 +7510,7 @@ pub mod test { let asset_info = AssetInfo { contract_address: contract_addr.clone(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset").unwrap(), + asset_name: ClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { @@ -7522,7 +7518,7 @@ pub mod test { StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone().into(), + asset_name: asset_info.asset_name.clone(), }; let mut nft_sent_value_1 = AssetMap::new(); @@ -7684,14 +7680,14 @@ pub mod test { let asset_info = AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x01; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - asset_name: LegacyClarityName::try_from("test-asset").unwrap(), + asset_name: ClarityName::try_from("test-asset").unwrap(), }; let asset_id = AssetIdentifier { contract_identifier: QualifiedContractIdentifier::new( StandardPrincipalData::from(asset_info.contract_address.clone()), asset_info.contract_name.clone(), ), - asset_name: asset_info.asset_name.clone().into(), + asset_name: asset_info.asset_name.clone(), }; let mut asset_map = AssetMap::new(); diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index 536be34e466..921122f3c22 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -22,7 +22,7 @@ use clarity::vm::costs::{CostErrors, ExecutionCost}; use clarity::vm::errors::VmExecutionError; use clarity::vm::representations::ClarityName; #[cfg(test)] -use clarity::vm::representations::{ContractName, LegacyClarityName}; +use clarity::vm::representations::ContractName; use clarity::vm::types::{ PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, Value, }; @@ -509,7 +509,7 @@ pub mod test { epoch_id: StacksEpochId, ) -> Vec { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); + let asset_name = ClarityName::try_from("hello-asset").unwrap(); let asset_value = Value::buff_from(vec![0, 1, 2, 3]).unwrap(); let contract_name = ContractName::try_from("hello-world").unwrap(); let hello_contract_call = "hello contract call"; @@ -796,7 +796,7 @@ pub mod test { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(4, Hash160([0xfc; 20])).unwrap(), contract_name: ContractName::try_from("hello-contract-name").unwrap(), - function_name: LegacyClarityName::try_from("hello-contract-call").unwrap(), + function_name: ClarityName::try_from("hello-contract-call").unwrap(), function_args: vec![Value::Int(0)], }), TransactionPayload::SmartContract( diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs index c59acb2c701..0f0c2673caa 100644 --- a/stackslib/src/chainstate/stacks/transaction.rs +++ b/stackslib/src/chainstate/stacks/transaction.rs @@ -187,7 +187,7 @@ mod test { use std::io::{Read, Write}; use clarity::types::StacksEpochId; - use clarity::vm::representations::{ContractName, LegacyClarityName}; + use clarity::vm::representations::ContractName; use clarity::vm::tests::test_clarity_versions; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier}; use clarity::vm::{ClarityVersion, Value}; @@ -896,7 +896,7 @@ mod test { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from("hello-world").unwrap(), - function_name: LegacyClarityName::try_from("hello-function").unwrap(), + function_name: ClarityName::try_from("hello-function").unwrap(), function_args: vec![Value::Int(0)], }) } @@ -1037,7 +1037,7 @@ mod test { TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from("hello-contract-name").unwrap(), - function_name: LegacyClarityName::try_from("hello-function-name").unwrap(), + function_name: ClarityName::try_from("hello-function-name").unwrap(), function_args: vec![Value::Int(0)], } } @@ -2375,7 +2375,7 @@ mod test { let contract_call = TransactionContractCall { address: StacksAddress::new(1, Hash160([0xff; 20])).unwrap(), contract_name: ContractName::try_from(hello_contract_name).unwrap(), - function_name: LegacyClarityName::try_from(hello_function_name).unwrap(), + function_name: ClarityName::try_from(hello_function_name).unwrap(), function_args: vec![Value::Int(0)], }; @@ -2414,7 +2414,7 @@ mod test { // test invalid contract name let address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); let contract_name = "hello\x00contract-name"; - let function_name = LegacyClarityName::try_from("hello-function-name").unwrap(); + let function_name = ClarityName::try_from("hello-function-name").unwrap(); let function_args = vec![Value::Int(0)]; let mut contract_call_bytes = vec![]; @@ -2477,21 +2477,19 @@ mod test { ); } - /// The consensus contract that makes the LegacyClarityName refactor - /// safe: bytes encoding a leading-`_` `function_name` MUST fail to - /// deserialize as a `TransactionContractCall`. Un-upgraded nodes - /// already reject these bytes via their narrow `ClarityName` codec; - /// the `LegacyClarityName` codec on upgraded nodes is required to - /// reject them identically. If this test ever flips to passing, the - /// chain-split risk that motivated the refactor has silently - /// reappeared. + /// The wide `ClarityName` codec admits leading-`_` bytes — Clarity 6's + /// relaxation lives at the codec layer. Consensus during the upgrade + /// window is preserved by `StacksBlock::validate_transaction_static_epoch`, + /// which rejects the resulting `TransactionContractCall` at admission + /// when the active epoch is pre-Clarity-6; see the admission tests in + /// `chainstate/stacks/block.rs`. #[test] - fn tx_contract_call_function_name_rejects_leading_underscore_on_the_wire() { + fn tx_contract_call_function_name_with_leading_underscore_round_trips() { let address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); let contract_name = ContractName::try_from("hello-contract-name").unwrap(); - let bad_function_name = "_admin"; - let mut bad_function_name_bytes = vec![bad_function_name.len() as u8]; - bad_function_name_bytes.extend_from_slice(bad_function_name.as_bytes()); + let function_name_str = "_admin"; + let mut function_name_bytes = vec![function_name_str.len() as u8]; + function_name_bytes.extend_from_slice(function_name_str.as_bytes()); let function_args: Vec = vec![]; @@ -2502,7 +2500,7 @@ mod test { contract_name .consensus_serialize(&mut contract_call_bytes) .unwrap(); - contract_call_bytes.extend_from_slice(&bad_function_name_bytes); + contract_call_bytes.extend_from_slice(&function_name_bytes); function_args .consensus_serialize(&mut contract_call_bytes) .unwrap(); @@ -2510,23 +2508,23 @@ mod test { let mut tx_bytes = vec![TransactionPayloadID::ContractCall as u8]; tx_bytes.append(&mut contract_call_bytes); - let err = TransactionPayload::consensus_deserialize(&mut &tx_bytes[..]) - .expect_err("leading-`_` function_name must not deserialize"); - assert!( - err.to_string().find("Failed to parse Clarity name").is_some(), - "expected `Failed to parse Clarity name` in error, got: {err}", - ); + let payload = TransactionPayload::consensus_deserialize(&mut &tx_bytes[..]) + .expect("leading-`_` function_name should round-trip at the codec layer"); + let TransactionPayload::ContractCall(cc) = payload else { + panic!("expected ContractCall payload"); + }; + assert_eq!(cc.function_name.as_str(), "_admin"); } - /// Analogous to the function_name test: bytes encoding a leading-`_` - /// `asset_name` inside an `AssetInfo` MUST fail to deserialize. + /// Analogous to the function_name test: leading-`_` `asset_name` + /// round-trips at the `AssetInfo` codec layer; admission gates it. #[test] - fn tx_asset_info_asset_name_rejects_leading_underscore_on_the_wire() { + fn tx_asset_info_asset_name_with_leading_underscore_round_trips() { let contract_address = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); let contract_name = ContractName::try_from("hello-contract-name").unwrap(); - let bad_asset_name = "_admin"; - let mut bad_asset_name_bytes = vec![bad_asset_name.len() as u8]; - bad_asset_name_bytes.extend_from_slice(bad_asset_name.as_bytes()); + let asset_name_str = "_admin"; + let mut asset_name_bytes = vec![asset_name_str.len() as u8]; + asset_name_bytes.extend_from_slice(asset_name_str.as_bytes()); let mut asset_info_bytes = vec![]; contract_address @@ -2535,14 +2533,11 @@ mod test { contract_name .consensus_serialize(&mut asset_info_bytes) .unwrap(); - asset_info_bytes.extend_from_slice(&bad_asset_name_bytes); + asset_info_bytes.extend_from_slice(&asset_name_bytes); - let err = AssetInfo::consensus_deserialize(&mut &asset_info_bytes[..]) - .expect_err("leading-`_` asset_name must not deserialize"); - assert!( - err.to_string().find("Failed to parse Clarity name").is_some(), - "expected `Failed to parse Clarity name` in error, got: {err}", - ); + let info = AssetInfo::consensus_deserialize(&mut &asset_info_bytes[..]) + .expect("leading-`_` asset_name should round-trip at the codec layer"); + assert_eq!(info.asset_name.as_str(), "_admin"); } #[test] @@ -2555,7 +2550,7 @@ mod test { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, ]; - let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); + let asset_name = ClarityName::try_from("hello-asset").unwrap(); let mut asset_name_bytes = vec![ // length asset_name.len(), @@ -2605,7 +2600,7 @@ mod test { for tx_pcp in tx_post_condition_principals { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); + let asset_name = ClarityName::try_from("hello-asset").unwrap(); let contract_name = ContractName::try_from("contract-name").unwrap(); let stx_pc = @@ -2715,7 +2710,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), contract_name: ContractName::try_from("contract-name").unwrap(), - asset_name: LegacyClarityName::try_from("hello-asset").unwrap(), + asset_name: ClarityName::try_from("hello-asset").unwrap(), }, Value::buff_from(vec![0, 1, 2, 3]).unwrap(), NonfungibleConditionCode::MaybeSent, @@ -2775,7 +2770,7 @@ mod test { AssetInfo { contract_address: StacksAddress::new(1, Hash160([0x33; 20])).unwrap(), contract_name: ContractName::try_from("contract-name").unwrap(), - asset_name: LegacyClarityName::try_from("hello-asset").unwrap(), + asset_name: ClarityName::try_from("hello-asset").unwrap(), }, Value::buff_from(vec![4, 5, 6, 7]).unwrap(), NonfungibleConditionCode::MaybeSent, @@ -2821,7 +2816,7 @@ mod test { #[test] fn tx_stacks_postcondition_invalid() { let addr = StacksAddress::new(1, Hash160([0xff; 20])).unwrap(); - let asset_name = LegacyClarityName::try_from("hello-asset").unwrap(); + let asset_name = ClarityName::try_from("hello-asset").unwrap(); let contract_name = ContractName::try_from("hello-world").unwrap(); // can't parse a postcondition with an invalid condition code @@ -3045,7 +3040,7 @@ mod test { let hello_token_name = "hello-token"; let contract_name = ContractName::try_from(hello_contract_name).unwrap(); - let asset_name = LegacyClarityName::try_from(hello_asset_name).unwrap(); + let asset_name = ClarityName::try_from(hello_asset_name).unwrap(); let token_name = StacksString::from_str(hello_token_name).unwrap(); let asset_value = StacksString::from_str("asset-value").unwrap(); diff --git a/stackslib/src/chainstate/tests/consensus.rs b/stackslib/src/chainstate/tests/consensus.rs index dcbdc040ee5..6590bef6a21 100644 --- a/stackslib/src/chainstate/tests/consensus.rs +++ b/stackslib/src/chainstate/tests/consensus.rs @@ -24,7 +24,7 @@ use clarity::util::hash::{Hash160, MerkleTree, Sha512Trunc256Sum}; use clarity::util::secp256k1::MessageSignature; use clarity::vm::costs::ExecutionCost; use clarity::vm::types::{PrincipalData, ResponseData}; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::{ClarityVersion, ContractName, Value as ClarityValue}; use serde::{Deserialize, Serialize, Serializer}; use stacks_common::bitvec::BitVec; @@ -1869,7 +1869,7 @@ impl ConsensusUtils { CHAIN_ID_TESTNET, &to_addr(&FAUCET_PRIV_KEY), ContractName::try_from(contract_name.to_string()).unwrap(), - LegacyClarityName::try_from(funct_name.to_string()).unwrap(), + ClarityName::try_from(funct_name.to_string()).unwrap(), args, ) } @@ -1887,7 +1887,7 @@ impl ConsensusUtils { let payload = TransactionContractCall { address: FAUCET_ADDRESS.clone(), contract_name: ContractName::try_from(contract_name.to_string()).unwrap(), - function_name: LegacyClarityName::try_from(funct_name.to_string()).unwrap(), + function_name: ClarityName::try_from(funct_name.to_string()).unwrap(), function_args: args.to_vec(), }; diff --git a/stackslib/src/chainstate/tests/madhouse/commands/sip040.rs b/stackslib/src/chainstate/tests/madhouse/commands/sip040.rs index 03ff93eca6a..543096f9ba6 100644 --- a/stackslib/src/chainstate/tests/madhouse/commands/sip040.rs +++ b/stackslib/src/chainstate/tests/madhouse/commands/sip040.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::PrincipalData; use clarity::vm::{ClarityVersion, ContractName, Value}; use madhouse::{Command, CommandWrapper}; @@ -53,7 +53,7 @@ fn nft_asset_info() -> AssetInfo { AssetInfo { contract_address: FAUCET_ADDRESS.clone(), contract_name: ContractName::try_from("nft".to_string()).unwrap(), - asset_name: LegacyClarityName::try_from("asset".to_string()).unwrap(), + asset_name: ClarityName::try_from("asset".to_string()).unwrap(), } } diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index 972623fc71c..481ecba8188 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -2523,7 +2523,7 @@ mod tests { use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId, StacksAddress}; use clarity::vm::analysis::errors::RuntimeCheckErrorKind; use clarity::vm::database::{ClarityBackingStore, STXBalance, SqliteConnection}; - use clarity::vm::representations::LegacyClarityName; + use clarity::vm::representations::ClarityName; use clarity::vm::test_util::{TEST_BURN_STATE_DB, TEST_HEADER_DB}; use clarity::vm::types::{StandardPrincipalData, TupleData, Value}; @@ -3301,7 +3301,7 @@ mod tests { TransactionPayload::ContractCall(TransactionContractCall { address: sender.clone(), contract_name: ContractName::from_literal("hello-world"), - function_name: LegacyClarityName::from_literal("foo"), + function_name: ClarityName::from_literal("foo"), function_args: vec![], }), ); diff --git a/stackslib/src/clarity_vm/tests/ephemeral.rs b/stackslib/src/clarity_vm/tests/ephemeral.rs index f9fd9b439d1..733d0a6038b 100644 --- a/stackslib/src/clarity_vm/tests/ephemeral.rs +++ b/stackslib/src/clarity_vm/tests/ephemeral.rs @@ -15,7 +15,7 @@ use std::fs; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::StacksAddressExtensions; use clarity::vm::ContractName; use pinny::tag; @@ -681,7 +681,7 @@ fn test_ephemeral_nakamoto_block_replay_smart_contract() { let cc_payload = TransactionPayload::ContractCall(TransactionContractCall { address: addr.clone(), contract_name: ContractName::try_from("test-clarity-db").unwrap(), - function_name: LegacyClarityName::try_from("test-all").unwrap(), + function_name: ClarityName::try_from("test-all").unwrap(), function_args: vec![], }); diff --git a/stackslib/src/core/test_util.rs b/stackslib/src/core/test_util.rs index 1568eaf98fb..4117d56e1e1 100644 --- a/stackslib/src/core/test_util.rs +++ b/stackslib/src/core/test_util.rs @@ -23,7 +23,7 @@ use clarity::types::chainstate::{ use clarity::vm::costs::ExecutionCost; use clarity::vm::tests::BurnStateDB; use clarity::vm::types::PrincipalData; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::{ClarityVersion, ContractName, Value}; use crate::chainstate::stacks::db::StacksChainState; @@ -448,7 +448,7 @@ pub fn make_contract_call_tx( chain_id: u32, contract_addr: &StacksAddress, contract_name: ContractName, - function_name: LegacyClarityName, + function_name: ClarityName, function_args: &[Value], ) -> StacksTransaction { let payload = TransactionContractCall { @@ -497,7 +497,7 @@ pub fn make_contract_call_mblock_only( function_args: &[Value], ) -> Vec { let contract_name = ContractName::from_literal(contract_name); - let function_name = LegacyClarityName::from_literal(function_name); + let function_name = ClarityName::from_literal(function_name); let payload = TransactionContractCall { address: contract_addr.clone(), diff --git a/stackslib/src/cost_estimates/tests/cost_estimators.rs b/stackslib/src/cost_estimates/tests/cost_estimators.rs index 691c579a738..47fd848a076 100644 --- a/stackslib/src/cost_estimates/tests/cost_estimators.rs +++ b/stackslib/src/cost_estimates/tests/cost_estimators.rs @@ -16,7 +16,7 @@ use std::env; use clarity::vm::costs::ExecutionCost; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::{PrincipalData, StandardPrincipalData}; use clarity::vm::{ContractName, Value}; use rand::Rng; @@ -261,13 +261,13 @@ fn pessimistic_estimator_contract_owner_separation() { let cc_payload_0 = TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("contract-1"), - function_name: LegacyClarityName::from_literal("func1"), + function_name: ClarityName::from_literal("func1"), function_args: vec![], }); let cc_payload_1 = TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([1; 20])).unwrap(), contract_name: ContractName::from_literal("contract-1"), - function_name: LegacyClarityName::from_literal("func1"), + function_name: ClarityName::from_literal("func1"), function_args: vec![], }); diff --git a/stackslib/src/cost_estimates/tests/fee_medians.rs b/stackslib/src/cost_estimates/tests/fee_medians.rs index 1bf42622aff..81c6e22dc52 100644 --- a/stackslib/src/cost_estimates/tests/fee_medians.rs +++ b/stackslib/src/cost_estimates/tests/fee_medians.rs @@ -16,7 +16,7 @@ use std::env; use clarity::vm::costs::ExecutionCost; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::{ContractName, Value}; use rand::Rng; use stacks_common::types::chainstate::StacksAddress; @@ -76,7 +76,7 @@ fn make_dummy_cc_tx(fee: u64, execution_cost: &ExecutionCost) -> StacksTransacti TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("cc-dummy"), - function_name: LegacyClarityName::from_literal("func-name"), + function_name: ClarityName::from_literal("func-name"), function_args: vec![], }), ); diff --git a/stackslib/src/cost_estimates/tests/fee_scalar.rs b/stackslib/src/cost_estimates/tests/fee_scalar.rs index e75e1e93dc3..de686d00f29 100644 --- a/stackslib/src/cost_estimates/tests/fee_scalar.rs +++ b/stackslib/src/cost_estimates/tests/fee_scalar.rs @@ -16,7 +16,7 @@ use std::env; use clarity::vm::costs::ExecutionCost; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::{PrincipalData, StandardPrincipalData}; use clarity::vm::{ContractName, Value}; use rand::seq::SliceRandom; @@ -113,7 +113,7 @@ fn make_dummy_cc_tx(fee: u64) -> StacksTransactionReceipt { TransactionPayload::ContractCall(TransactionContractCall { address: StacksAddress::new(0, Hash160([0; 20])).unwrap(), contract_name: ContractName::from_literal("cc-dummy"), - function_name: LegacyClarityName::from_literal("func-name"), + function_name: ClarityName::from_literal("func-name"), function_args: vec![], }), ); diff --git a/stackslib/src/net/api/tests/blockreplay.rs b/stackslib/src/net/api/tests/blockreplay.rs index 839730b245c..aeba641375f 100644 --- a/stackslib/src/net/api/tests/blockreplay.rs +++ b/stackslib/src/net/api/tests/blockreplay.rs @@ -17,7 +17,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use clarity::types::chainstate::StacksPrivateKey; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::ContractName; use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::chainstate::StacksBlockId; @@ -257,7 +257,7 @@ fn replay_block_with_pc_failure() { let contract_call = { let contract_name = ContractName::from_literal("test"); - let function_name = LegacyClarityName::from_literal("test"); + let function_name = ClarityName::from_literal("test"); let payload = TransactionContractCall { address: addr.clone(), diff --git a/stackslib/src/net/api/tests/blocksimulate.rs b/stackslib/src/net/api/tests/blocksimulate.rs index f7ada61add3..92f25d33c1b 100644 --- a/stackslib/src/net/api/tests/blocksimulate.rs +++ b/stackslib/src/net/api/tests/blocksimulate.rs @@ -17,7 +17,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use clarity::types::chainstate::StacksPrivateKey; -use clarity::vm::representations::LegacyClarityName; +use clarity::vm::representations::ClarityName; use clarity::vm::types::PrincipalData; use clarity::vm::ContractName; use stacks_common::consts::CHAIN_ID_TESTNET; @@ -287,7 +287,7 @@ fn simulate_block_with_pc_failure() { let address = to_addr(&private_key); let contract_name = ContractName::from_literal("test"); - let function_name = LegacyClarityName::from_literal("test"); + let function_name = ClarityName::from_literal("test"); // Set up the RPC test with a contract, so that we can test a post-condition failure let rpc_test = From 042b1f411e745d63d9a95c7507e228b1aa8e28fd Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 10 Jun 2026 15:35:35 -0400 Subject: [PATCH 26/32] Minor comment fixes --- clarity-types/src/version.rs | 2 +- clarity/src/vm/types/signatures.rs | 2 +- stackslib/src/chainstate/stacks/block.rs | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/clarity-types/src/version.rs b/clarity-types/src/version.rs index 308a682e720..50c70686e3a 100644 --- a/clarity-types/src/version.rs +++ b/clarity-types/src/version.rs @@ -128,7 +128,7 @@ impl ClarityVersion { /// 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 + /// evaluated, but the result cannot be referenced. pub fn allows_underscore_prefix(&self) -> bool { self >= &ClarityVersion::Clarity6 } diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs index 7ab9762f21c..9459cdd1547 100644 --- a/clarity/src/vm/types/signatures.rs +++ b/clarity/src/vm/types/signatures.rs @@ -450,7 +450,7 @@ impl TypeSignatureExt for TypeSignature { .ok_or(CommonCheckErrorKind::DefineTraitBadSignature)?; // Clarity 6: bare `_` is reserved as a discard pattern and cannot // name a trait method. - if fn_name.as_str() == clarity_types::representations::DISCARD_IDENTIFIER { + if fn_name.as_str() == DISCARD_IDENTIFIER { return Err(CommonCheckErrorKind::BareUnderscoreReserved); } diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index 689ad74476d..1ae4688c07a 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -631,16 +631,16 @@ impl StacksBlock { error!("Authentication mode not supported in Epoch {epoch_id}"); return false; } - // Reject leading-`_` names at every wire position when the - // active epoch is pre-Clarity-6 (Epoch40). This mirrors what - // un-upgraded nodes' narrow wire codec does and so preserves - // consensus during the upgrade window. Bare `_` is *also* - // rejected for tuple keys at every epoch (it would otherwise - // be referenceable via `(get _ …)`, contradicting its discard - // semantic). `SmartContract.code_body` is intentionally skipped: - // its bytes are version-blind `StacksString` (no `ClarityName` - // codec step), and source-level rejection happens in - // `UnderscoreIdentifierChecker`. + // Reject leading-`_` names at every wire position when + // `epoch_id < Epoch40` (i.e., before Clarity 6 activates). + // This mirrors un-upgraded nodes' narrow wire codec and so + // preserves consensus during the upgrade window. Bare `_` is + // *also* rejected for tuple keys at every epoch (it would + // otherwise be referenceable via `(get _ …)`, contradicting + // its discard semantic). `SmartContract.code_body` is + // intentionally skipped: its bytes are version-blind + // `StacksString` (no `ClarityName` codec step), and + // source-level rejection happens in `UnderscoreIdentifierChecker`. if let TransactionPayload::ContractCall(ref cc) = &tx.payload { if epoch_id < StacksEpochId::Epoch40 && cc.function_name.starts_with('_') { error!( From 41d482ad02c0c4c987a5e57ed02790f7eb5aed17 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 10 Jun 2026 15:57:40 -0400 Subject: [PATCH 27/32] Run `cargo fmt-stacks` and add better error message if user tries to reference `_` --- clarity-types/src/tests/types/mod.rs | 18 +++++++--- clarity/src/vm/analysis/errors.rs | 5 ++- clarity/src/vm/analysis/tests/mod.rs | 15 ++++++++ stacks-node/src/event_dispatcher/tests.rs | 2 +- stacks-node/src/tests/neon_integrations.rs | 6 ++-- stackslib/src/chainstate/stacks/block.rs | 40 +++++++++++++++------ stackslib/src/chainstate/tests/consensus.rs | 2 +- stackslib/src/clarity_vm/clarity.rs | 1 - stackslib/src/core/test_util.rs | 2 +- 9 files changed, 68 insertions(+), 23 deletions(-) diff --git a/clarity-types/src/tests/types/mod.rs b/clarity-types/src/tests/types/mod.rs index 8245e7ca189..580c135a5db 100644 --- a/clarity-types/src/tests/types/mod.rs +++ b/clarity-types/src/tests/types/mod.rs @@ -937,7 +937,9 @@ fn test_find_invalid_tuple_key_rejects_bare_underscore(#[case] epoch: StacksEpoc 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).as_deref(), + value + .find_invalid_tuple_key(StacksEpochId::Epoch34) + .as_deref(), Some(key), ); } @@ -960,8 +962,12 @@ fn test_find_invalid_tuple_key_admits_leading_underscore_in_clarity6(#[case] key 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(), + 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", ); } @@ -1040,5 +1046,9 @@ fn test_find_invalid_tuple_key_descends_into_compound_values() { #[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()); + assert!( + none_value + .find_invalid_tuple_key(StacksEpochId::Epoch34) + .is_none() + ); } diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index 821e4dacabe..1d8416b05f1 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -17,7 +17,7 @@ use std::{error, fmt}; use clarity_types::errors::ClarityTypeError; -use clarity_types::representations::SymbolicExpression; +use clarity_types::representations::{DISCARD_IDENTIFIER, SymbolicExpression}; use clarity_types::types::{TraitIdentifier, TupleTypeSignature, TypeSignature}; use stacks_common::types::StacksEpochId; @@ -1245,6 +1245,9 @@ impl DiagnosableError for StaticCheckErrorKind { StaticCheckErrorKind::BadLetSyntax => "invalid syntax of 'let'".into(), StaticCheckErrorKind::BadSyntaxBinding(binding_error) => format!("invalid syntax binding: {}", &binding_error.message()), StaticCheckErrorKind::MaxContextDepthReached => "reached depth limit".into(), + StaticCheckErrorKind::UndefinedVariable(var_name) if var_name == DISCARD_IDENTIFIER => { + format!("{DISCARD_IDENTIFIER} is reserved as a discard pattern; it cannot be referenced as a variable") + } StaticCheckErrorKind::UndefinedVariable(var_name) => format!("use of unresolved variable '{var_name}'"), StaticCheckErrorKind::RequiresAtLeastArguments(expected, found) => format!("expecting >= {expected} arguments, got {found}"), StaticCheckErrorKind::RequiresAtMostArguments(expected, found) => format!("expecting < {expected} arguments, got {found}"), diff --git a/clarity/src/vm/analysis/tests/mod.rs b/clarity/src/vm/analysis/tests/mod.rs index 2597711e00a..a47bc98d244 100644 --- a/clarity/src/vm/analysis/tests/mod.rs +++ b/clarity/src/vm/analysis/tests/mod.rs @@ -362,6 +362,21 @@ fn test_unbound_variable() { assert!(format!("{}", err.diagnostic).contains("use of unresolved variable 'unicorn'")); } +/// Clarity 6: referencing `_` (the discard pattern) gets a specific +/// diagnostic instead of the generic "unresolved variable" message, +/// because the user's mistake is conceptually different — they tried to +/// read back a value that the language has explicitly discarded. +#[test] +fn test_unbound_variable_discard_pattern_has_specific_message() { + let snippet = "(let ((_ 1)) _)"; + let err = mem_type_check(snippet).unwrap_err(); + let formatted = format!("{}", err.diagnostic); + assert!( + formatted.contains("'_' is reserved as a discard pattern"), + "expected discard-specific diagnostic, got: {formatted}" + ); +} + #[test] fn test_variadic_needs_one_argument() { let snippet = "(begin)"; diff --git a/stacks-node/src/event_dispatcher/tests.rs b/stacks-node/src/event_dispatcher/tests.rs index 4f417871c24..ce0bd8ccb0b 100644 --- a/stacks-node/src/event_dispatcher/tests.rs +++ b/stacks-node/src/event_dispatcher/tests.rs @@ -20,9 +20,9 @@ use std::thread; use std::time::{Instant, SystemTime}; use clarity::boot_util::boot_code_id; -use clarity::vm::representations::ClarityName; use clarity::vm::costs::ExecutionCost; use clarity::vm::events::SmartContractEventData; +use clarity::vm::representations::ClarityName; use clarity::vm::types::StacksAddressExtensions; use clarity::vm::{ContractName, Value}; use rusqlite::Connection; diff --git a/stacks-node/src/tests/neon_integrations.rs b/stacks-node/src/tests/neon_integrations.rs index 9a0f09ac746..8dfcb73f216 100644 --- a/stacks-node/src/tests/neon_integrations.rs +++ b/stacks-node/src/tests/neon_integrations.rs @@ -21,14 +21,12 @@ use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; use std::{cmp, env, fs, io, thread}; -use clarity::vm::representations::ClarityName; use clarity::vm::ast::stack_depth_checker::StackDepthLimits; use clarity::vm::costs::ExecutionCost; +use clarity::vm::representations::ClarityName; use clarity::vm::types::serialization::SerializationError; use clarity::vm::types::PrincipalData; -use clarity::vm::{ - execute_with_parameters as execute, ClarityVersion, ContractName, Value, -}; +use clarity::vm::{execute_with_parameters as execute, ClarityVersion, ContractName, Value}; use rusqlite::params; use serde::Deserialize; use serde_json::json; diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index 1ae4688c07a..91b71fcc008 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -2198,10 +2198,14 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let cc = admission_test_contract_call_with_tuple_arg("foo"); - assert!(StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + assert!(StacksBlock::validate_transaction_static_epoch( + &cc, epoch_id + )); let pc = admission_test_nft_post_condition_with_tuple("foo"); - assert!(StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + assert!(StacksBlock::validate_transaction_static_epoch( + &pc, epoch_id + )); } /// Bare `_` is rejected at every epoch — it's reserved as the discard @@ -2214,10 +2218,14 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let cc = admission_test_contract_call_with_tuple_arg("_"); - assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &cc, epoch_id + )); let pc = admission_test_nft_post_condition_with_tuple("_"); - assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &pc, epoch_id + )); } /// Pre-Clarity-6 (pre-Epoch40) — leading-`_` tuple keys are rejected @@ -2230,10 +2238,14 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let cc = admission_test_contract_call_with_tuple_arg("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &cc, epoch_id + )); let pc = admission_test_nft_post_condition_with_tuple("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &pc, epoch_id + )); } /// At Clarity-6 (Epoch40) and beyond, leading-`_` tuple keys are @@ -2306,7 +2318,9 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let cc = admission_test_contract_call_with_function_name("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &cc, epoch_id + )); } #[rstest] @@ -2316,7 +2330,9 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let pc = admission_test_nft_post_condition_with_asset_name("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + assert!(!StacksBlock::validate_transaction_static_epoch( + &pc, epoch_id + )); } /// At Clarity-6, leading-`_` scalar names are admissible. @@ -2344,10 +2360,14 @@ mod test { #[case] epoch_id: StacksEpochId, ) { let cc = admission_test_contract_call_with_function_name("do-thing"); - assert!(StacksBlock::validate_transaction_static_epoch(&cc, epoch_id)); + assert!(StacksBlock::validate_transaction_static_epoch( + &cc, epoch_id + )); let pc = admission_test_nft_post_condition_with_asset_name("asset"); - assert!(StacksBlock::validate_transaction_static_epoch(&pc, epoch_id)); + assert!(StacksBlock::validate_transaction_static_epoch( + &pc, epoch_id + )); } // TODO: diff --git a/stackslib/src/chainstate/tests/consensus.rs b/stackslib/src/chainstate/tests/consensus.rs index 6590bef6a21..5dd1c2a349b 100644 --- a/stackslib/src/chainstate/tests/consensus.rs +++ b/stackslib/src/chainstate/tests/consensus.rs @@ -23,8 +23,8 @@ use clarity::types::{EpochList, StacksEpoch, StacksEpochId}; use clarity::util::hash::{Hash160, MerkleTree, Sha512Trunc256Sum}; use clarity::util::secp256k1::MessageSignature; use clarity::vm::costs::ExecutionCost; -use clarity::vm::types::{PrincipalData, ResponseData}; use clarity::vm::representations::ClarityName; +use clarity::vm::types::{PrincipalData, ResponseData}; use clarity::vm::{ClarityVersion, ContractName, Value as ClarityValue}; use serde::{Deserialize, Serialize, Serializer}; use stacks_common::bitvec::BitVec; diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index 481ecba8188..3733315a1c6 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -2526,7 +2526,6 @@ mod tests { use clarity::vm::representations::ClarityName; use clarity::vm::test_util::{TEST_BURN_STATE_DB, TEST_HEADER_DB}; use clarity::vm::types::{StandardPrincipalData, TupleData, Value}; - use stacks_common::consts::CHAIN_ID_TESTNET; use stacks_common::types::chainstate::ConsensusHash; use stacks_common::types::sqlite::NO_PARAMS; diff --git a/stackslib/src/core/test_util.rs b/stackslib/src/core/test_util.rs index 4117d56e1e1..51cec1962eb 100644 --- a/stackslib/src/core/test_util.rs +++ b/stackslib/src/core/test_util.rs @@ -21,9 +21,9 @@ use clarity::types::chainstate::{ BlockHeaderHash, ConsensusHash, StacksAddress, StacksPrivateKey, StacksPublicKey, }; use clarity::vm::costs::ExecutionCost; +use clarity::vm::representations::ClarityName; use clarity::vm::tests::BurnStateDB; use clarity::vm::types::PrincipalData; -use clarity::vm::representations::ClarityName; use clarity::vm::{ClarityVersion, ContractName, Value}; use crate::chainstate::stacks::db::StacksChainState; From bf147958a1c623f016b8a3b8ab70eaecda22a98f Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Wed, 10 Jun 2026 16:32:56 -0400 Subject: [PATCH 28/32] Address Claude's PR comments --- clarity-types/src/tests/types/mod.rs | 31 +-- clarity-types/src/types/mod.rs | 28 +-- clarity/src/vm/tests/simple_apply_eval.rs | 4 +- stackslib/src/chainstate/stacks/block.rs | 266 ++++++++++++---------- 4 files changed, 165 insertions(+), 164 deletions(-) diff --git a/clarity-types/src/tests/types/mod.rs b/clarity-types/src/tests/types/mod.rs index 580c135a5db..8f4fe71f47c 100644 --- a/clarity-types/src/tests/types/mod.rs +++ b/clarity-types/src/tests/types/mod.rs @@ -924,7 +924,7 @@ fn tuple_with_key(key: &str) -> Value { #[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).as_deref(), Some("_")); + assert_eq!(value.find_invalid_tuple_key(epoch), Some("_")); } /// Pre-Clarity-6 epochs reject *every* leading-`_` tuple key — matches @@ -937,9 +937,7 @@ fn test_find_invalid_tuple_key_rejects_bare_underscore(#[case] epoch: StacksEpoc 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) - .as_deref(), + value.find_invalid_tuple_key(StacksEpochId::Epoch34), Some(key), ); } @@ -996,34 +994,22 @@ fn test_find_invalid_tuple_key_descends_into_compound_values() { // tuple inside `(some ...)` let some_value = Value::some(tuple_with_key("_buried")).unwrap(); - assert_eq!( - some_value.find_invalid_tuple_key(epoch).as_deref(), - Some("_buried"), - ); + 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).as_deref(), - Some("_buried"), - ); + 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).as_deref(), - Some("_buried"), - ); + 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).as_deref(), - Some("_buried"), - ); + 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 @@ -1035,10 +1021,7 @@ fn test_find_invalid_tuple_key_descends_into_compound_values() { )]) .unwrap(), ); - assert_eq!( - nested.find_invalid_tuple_key(epoch).as_deref(), - Some("_buried"), - ); + assert_eq!(nested.find_invalid_tuple_key(epoch), Some("_buried")); } /// When the same value sits behind `(none)` (no payload), nothing is diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index c88dd683676..1ba72e62061 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -970,28 +970,22 @@ 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`. Used by transaction - /// admission to keep `_`-prefixed tuple keys off the wire. + /// 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. /// - /// Two rules: - /// 1. Bare `_` is never a valid tuple key (reserved as the - /// `let` / `match` discard marker; rejected at every epoch). - /// 2. Any other leading-`_` key is rejected pre-Clarity-6 (epoch - /// `< Epoch40`) to match un-upgraded nodes' narrow wire codec. - /// Post-activation, `_foo` tuple keys are permitted. - /// - /// NOTE: this is the consensus-critical companion to the "value - /// sanitization" routine flagged 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 { + /// 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().to_string()); + return Some(key.as_str()); } if let Some(found) = value.find_invalid_tuple_key(epoch) { return Some(found); diff --git a/clarity/src/vm/tests/simple_apply_eval.rs b/clarity/src/vm/tests/simple_apply_eval.rs index 9f162e8481f..dcaa4bb3967 100644 --- a/clarity/src/vm/tests/simple_apply_eval.rs +++ b/clarity/src/vm/tests/simple_apply_eval.rs @@ -255,8 +255,7 @@ fn test_match_opt_none_arm_with_discard_some() { assert_eq!(result, Value::Int(11)); } -/// Clarity 6: bare `_` is reserved as a discard pattern; it cannot name a -/// top-level definition. Rejected by the analyzer's `check_name_used`. +/// Rejected by the analyzer's `check_name_used`. #[test] fn test_bare_underscore_as_define_name_rejected_in_clarity6() { let program = "(define-constant _ 1)"; @@ -347,7 +346,6 @@ fn test_bare_underscore_as_tuple_type_key_rejected_in_clarity6() { ); } -/// Clarity 6: bare `_` cannot name a function argument either. #[test] fn test_bare_underscore_as_function_arg_rejected_in_clarity6() { let program = "(define-public (foo (_ uint)) (ok true)) (foo u1)"; diff --git a/stackslib/src/chainstate/stacks/block.rs b/stackslib/src/chainstate/stacks/block.rs index 91b71fcc008..2eb0262234c 100644 --- a/stackslib/src/chainstate/stacks/block.rs +++ b/stackslib/src/chainstate/stacks/block.rs @@ -2188,83 +2188,6 @@ mod test { tx } - /// Plain (no leading `_`) tuple keys are admissible at every epoch in - /// both function arguments and NFT post-condition payloads. - #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - #[case(StacksEpochId::Epoch40)] - fn test_validate_transaction_static_epoch_admits_plain_tuple_keys( - #[case] epoch_id: StacksEpochId, - ) { - let cc = admission_test_contract_call_with_tuple_arg("foo"); - assert!(StacksBlock::validate_transaction_static_epoch( - &cc, epoch_id - )); - - let pc = admission_test_nft_post_condition_with_tuple("foo"); - assert!(StacksBlock::validate_transaction_static_epoch( - &pc, epoch_id - )); - } - - /// Bare `_` is rejected at every epoch — it's reserved as the discard - /// marker and never a valid tuple key on the wire. - #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - #[case(StacksEpochId::Epoch40)] - fn test_validate_transaction_static_epoch_rejects_bare_underscore_tuple_key( - #[case] epoch_id: StacksEpochId, - ) { - let cc = admission_test_contract_call_with_tuple_arg("_"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &cc, epoch_id - )); - - let pc = admission_test_nft_post_condition_with_tuple("_"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &pc, epoch_id - )); - } - - /// Pre-Clarity-6 (pre-Epoch40) — leading-`_` tuple keys are rejected - /// to preserve consensus with un-upgraded nodes whose narrow wire - /// codec rejects every leading-`_` name. - #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - fn test_validate_transaction_static_epoch_rejects_leading_underscore_pre_clarity6( - #[case] epoch_id: StacksEpochId, - ) { - let cc = admission_test_contract_call_with_tuple_arg("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &cc, epoch_id - )); - - let pc = admission_test_nft_post_condition_with_tuple("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &pc, epoch_id - )); - } - - /// At Clarity-6 (Epoch40) and beyond, leading-`_` tuple keys are - /// admissible. Bare `_` is still rejected (covered by its own test). - #[test] - fn test_validate_transaction_static_epoch_admits_leading_underscore_in_clarity6() { - let cc = admission_test_contract_call_with_tuple_arg("_admin"); - assert!(StacksBlock::validate_transaction_static_epoch( - &cc, - StacksEpochId::Epoch40, - )); - - let pc = admission_test_nft_post_condition_with_tuple("_admin"); - assert!(StacksBlock::validate_transaction_static_epoch( - &pc, - StacksEpochId::Epoch40, - )); - } - /// Build a contract-call transaction whose `function_name` is the /// supplied string. Bypasses `ClarityName`-style construction /// so we can exercise admission rejection independently of any @@ -2310,64 +2233,167 @@ mod test { tx } - /// Pre-Clarity-6 — leading-`_` scalar names are rejected at admission. + /// Build a contract-call transaction whose single `function_args` + /// element is the supplied value. Used to exercise the admission + /// walker's descent through compound containers. + fn admission_test_contract_call_with_arg(arg: Value) -> StacksTransaction { + let privk = StacksPrivateKey::random(); + StacksTransaction::new( + TransactionVersion::Testnet, + admission_test_auth(&privk), + TransactionPayload::ContractCall(TransactionContractCall { + address: StacksAddress::new(1, Hash160([0x11; 20])).unwrap(), + contract_name: ContractName::try_from("hello-world").unwrap(), + function_name: ClarityName::try_from("do-thing").unwrap(), + function_args: vec![arg], + }), + ) + } + + /// Wrap a `_buried`-keyed tuple inside the supplied compound shape so + /// `find_invalid_tuple_key`'s recursion paths can be exercised + /// end-to-end through the admission walker. + fn buried_underscore_value(shape: &str) -> Value { + let bad_tuple = single_key_tuple("_buried"); + match shape { + "some" => Value::some(bad_tuple).unwrap(), + "ok" => Value::okay(bad_tuple).unwrap(), + "err" => Value::error(bad_tuple).unwrap(), + "list" => Value::list_from(vec![bad_tuple]).unwrap(), + "nested_tuple" => Value::Tuple( + TupleData::from_data(vec![(ClarityName::from_literal("outer"), bad_tuple)]) + .unwrap(), + ), + other => panic!("unknown shape: {other}"), + } + } + + /// Admission walker: tuple keys in `function_args` and NFT + /// `asset_value`. Plain keys are admissible at every epoch; bare + /// `_` is rejected at every epoch (reserved as the discard marker); + /// leading-`_` is rejected pre-Clarity-6 (to match un-upgraded + /// nodes' narrow wire codec) and admitted from Clarity-6 onward. #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - fn test_validate_transaction_static_epoch_rejects_leading_underscore_function_name_pre_clarity6( + #[case::plain_epoch33(StacksEpochId::Epoch33, "foo", true)] + #[case::plain_epoch34(StacksEpochId::Epoch34, "foo", true)] + #[case::plain_epoch40(StacksEpochId::Epoch40, "foo", true)] + #[case::bare_underscore_epoch33(StacksEpochId::Epoch33, "_", false)] + #[case::bare_underscore_epoch34(StacksEpochId::Epoch34, "_", false)] + #[case::bare_underscore_epoch40(StacksEpochId::Epoch40, "_", false)] + #[case::leading_underscore_epoch33(StacksEpochId::Epoch33, "_admin", false)] + #[case::leading_underscore_epoch34(StacksEpochId::Epoch34, "_admin", false)] + #[case::leading_underscore_epoch40(StacksEpochId::Epoch40, "_admin", true)] + fn test_validate_transaction_static_epoch_tuple_keys( #[case] epoch_id: StacksEpochId, + #[case] key: &str, + #[case] expected_admitted: bool, ) { - let cc = admission_test_contract_call_with_function_name("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &cc, epoch_id - )); + let cc = admission_test_contract_call_with_tuple_arg(key); + assert_eq!( + StacksBlock::validate_transaction_static_epoch(&cc, epoch_id), + expected_admitted, + "function_args tuple key {key:?} at {epoch_id}", + ); + + let pc = admission_test_nft_post_condition_with_tuple(key); + assert_eq!( + StacksBlock::validate_transaction_static_epoch(&pc, epoch_id), + expected_admitted, + "NFT post-condition tuple key {key:?} at {epoch_id}", + ); } + /// Admission walker: scalar names — `TransactionContractCall.function_name` + /// and `AssetInfo.asset_name`. Plain names admissible everywhere; + /// leading-`_` rejected pre-Clarity-6, admitted from Clarity-6. + /// + /// Bare `_` is unreachable on this path: `ClarityName::try_from` + /// accepts it but the analyzer's `BareUnderscoreReserved` blocks + /// any contract that would declare a function or asset called `_`, + /// so no such transaction can refer to a real deployable target. #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - fn test_validate_transaction_static_epoch_rejects_leading_underscore_asset_name_pre_clarity6( + #[case::plain_epoch33(StacksEpochId::Epoch33, "do-thing", "asset", true)] + #[case::plain_epoch34(StacksEpochId::Epoch34, "do-thing", "asset", true)] + #[case::plain_epoch40(StacksEpochId::Epoch40, "do-thing", "asset", true)] + #[case::leading_underscore_epoch33(StacksEpochId::Epoch33, "_admin", "_admin", false)] + #[case::leading_underscore_epoch34(StacksEpochId::Epoch34, "_admin", "_admin", false)] + #[case::leading_underscore_epoch40(StacksEpochId::Epoch40, "_admin", "_admin", true)] + fn test_validate_transaction_static_epoch_scalar_names( #[case] epoch_id: StacksEpochId, + #[case] function_name: &str, + #[case] asset_name: &str, + #[case] expected_admitted: bool, ) { - let pc = admission_test_nft_post_condition_with_asset_name("_admin"); - assert!(!StacksBlock::validate_transaction_static_epoch( - &pc, epoch_id - )); - } - - /// At Clarity-6, leading-`_` scalar names are admissible. - #[test] - fn test_validate_transaction_static_epoch_admits_leading_underscore_scalars_in_clarity6() { - let cc = admission_test_contract_call_with_function_name("_admin"); - assert!(StacksBlock::validate_transaction_static_epoch( - &cc, - StacksEpochId::Epoch40, - )); + let cc = admission_test_contract_call_with_function_name(function_name); + assert_eq!( + StacksBlock::validate_transaction_static_epoch(&cc, epoch_id), + expected_admitted, + "function_name {function_name:?} at {epoch_id}", + ); - let pc = admission_test_nft_post_condition_with_asset_name("_admin"); - assert!(StacksBlock::validate_transaction_static_epoch( - &pc, - StacksEpochId::Epoch40, - )); + let pc = admission_test_nft_post_condition_with_asset_name(asset_name); + assert_eq!( + StacksBlock::validate_transaction_static_epoch(&pc, epoch_id), + expected_admitted, + "asset_name {asset_name:?} at {epoch_id}", + ); } - /// Plain (no leading `_`) scalar names are admissible at every epoch. + /// Admission must descend into compound containers in `function_args` + /// to find buried `_`-prefixed tuple keys: rejected pre-Clarity-6, + /// admitted from Clarity-6 onward. Covers `Optional::Some`, + /// `Response` (ok / err), `Sequence(List)`, and nested `Tuple`. #[rstest] - #[case(StacksEpochId::Epoch33)] - #[case(StacksEpochId::Epoch34)] - #[case(StacksEpochId::Epoch40)] - fn test_validate_transaction_static_epoch_admits_plain_scalar_names( + #[case::some_pre_clarity6("some", StacksEpochId::Epoch34, false)] + #[case::some_clarity6("some", StacksEpochId::Epoch40, true)] + #[case::ok_pre_clarity6("ok", StacksEpochId::Epoch34, false)] + #[case::ok_clarity6("ok", StacksEpochId::Epoch40, true)] + #[case::err_pre_clarity6("err", StacksEpochId::Epoch34, false)] + #[case::err_clarity6("err", StacksEpochId::Epoch40, true)] + #[case::list_pre_clarity6("list", StacksEpochId::Epoch34, false)] + #[case::list_clarity6("list", StacksEpochId::Epoch40, true)] + #[case::nested_tuple_pre_clarity6("nested_tuple", StacksEpochId::Epoch34, false)] + #[case::nested_tuple_clarity6("nested_tuple", StacksEpochId::Epoch40, true)] + fn test_validate_transaction_static_epoch_descends_into_compound_function_args( + #[case] shape: &str, #[case] epoch_id: StacksEpochId, + #[case] expected_admitted: bool, ) { - let cc = admission_test_contract_call_with_function_name("do-thing"); - assert!(StacksBlock::validate_transaction_static_epoch( - &cc, epoch_id - )); + let arg = buried_underscore_value(shape); + let cc = admission_test_contract_call_with_arg(arg); + assert_eq!( + StacksBlock::validate_transaction_static_epoch(&cc, epoch_id), + expected_admitted, + "`_buried` inside {shape} at {epoch_id}", + ); + } - let pc = admission_test_nft_post_condition_with_asset_name("asset"); - assert!(StacksBlock::validate_transaction_static_epoch( - &pc, epoch_id - )); + /// Bare `_` tuple keys are rejected at every epoch — even when + /// buried inside compound containers — because the walker's + /// every-epoch rule short-circuits before the leading-`_` check. + #[rstest] + #[case::some_epoch34("some", StacksEpochId::Epoch34)] + #[case::some_epoch40("some", StacksEpochId::Epoch40)] + #[case::ok_epoch34("ok", StacksEpochId::Epoch34)] + #[case::ok_epoch40("ok", StacksEpochId::Epoch40)] + #[case::list_epoch34("list", StacksEpochId::Epoch34)] + #[case::list_epoch40("list", StacksEpochId::Epoch40)] + fn test_validate_transaction_static_epoch_rejects_bare_underscore_inside_compound( + #[case] shape: &str, + #[case] epoch_id: StacksEpochId, + ) { + let bare = single_key_tuple("_"); + let arg = match shape { + "some" => Value::some(bare).unwrap(), + "ok" => Value::okay(bare).unwrap(), + "list" => Value::list_from(vec![bare]).unwrap(), + other => panic!("unknown shape: {other}"), + }; + let cc = admission_test_contract_call_with_arg(arg); + assert!( + !StacksBlock::validate_transaction_static_epoch(&cc, epoch_id), + "bare `_` inside {shape} at {epoch_id} should be rejected", + ); } // TODO: From 54ae8614bac789598b5564db41f93044218d2366 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Thu, 11 Jun 2026 10:53:32 -0400 Subject: [PATCH 29/32] Fix error message --- clarity/src/vm/analysis/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clarity/src/vm/analysis/errors.rs b/clarity/src/vm/analysis/errors.rs index 1d8416b05f1..5ad09d4e7e9 100644 --- a/clarity/src/vm/analysis/errors.rs +++ b/clarity/src/vm/analysis/errors.rs @@ -1246,7 +1246,7 @@ impl DiagnosableError for StaticCheckErrorKind { StaticCheckErrorKind::BadSyntaxBinding(binding_error) => format!("invalid syntax binding: {}", &binding_error.message()), StaticCheckErrorKind::MaxContextDepthReached => "reached depth limit".into(), StaticCheckErrorKind::UndefinedVariable(var_name) if var_name == DISCARD_IDENTIFIER => { - format!("{DISCARD_IDENTIFIER} is reserved as a discard pattern; it cannot be referenced as a variable") + format!("'{DISCARD_IDENTIFIER}' is reserved as a discard pattern; it cannot be referenced as a variable") } StaticCheckErrorKind::UndefinedVariable(var_name) => format!("use of unresolved variable '{var_name}'"), StaticCheckErrorKind::RequiresAtLeastArguments(expected, found) => format!("expecting >= {expected} arguments, got {found}"), From db6992eb5239a40ec601cf05d7e008f9e381c23d Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Fri, 12 Jun 2026 11:14:43 -0400 Subject: [PATCH 30/32] Check for `_`-prefixed tuple keys in `from_consensus_buff()` --- clarity-types/src/types/serialization.rs | 5 +- clarity/src/vm/functions/conversions.rs | 12 ++++ clarity/src/vm/tests/conversions.rs | 85 +++++++++++++++++++++++- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/clarity-types/src/types/serialization.rs b/clarity-types/src/types/serialization.rs index 2aa1cd26268..9828bb4efaa 100644 --- a/clarity-types/src/types/serialization.rs +++ b/clarity-types/src/types/serialization.rs @@ -1164,10 +1164,7 @@ 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, - ) -> Result { + pub fn try_deserialize_bytes_untyped(bytes: &Vec) -> Result { Value::deserialize_read(&mut bytes.as_slice(), None, false) } diff --git a/clarity/src/vm/functions/conversions.rs b/clarity/src/vm/functions/conversions.rs index 8212405d349..60de5e285aa 100644 --- a/clarity/src/vm/functions/conversions.rs +++ b/clarity/src/vm/functions/conversions.rs @@ -349,6 +349,18 @@ pub fn from_consensus_buff( }; runtime_cost(ClarityCostFunction::FromConsensusBuff, exec_state, input)?; + // Reject epoch-invalid tuple keys before the typed pass: typed + // deserialization sanitizes (elides) keys not in the expected type, + // which would strip the evidence before any post-deserialize walker + // could see it. + if let Ok(unsanitized) = Value::try_deserialize_bytes_untyped(input_bytes) + && unsanitized + .find_invalid_tuple_key(*exec_state.epoch()) + .is_some() + { + return Ok(Value::none()); + } + // Perform the deserialization and check that it deserialized to the expected // type. A type mismatch at this point is an error that should be surfaced in // Clarity (as a none return). diff --git a/clarity/src/vm/tests/conversions.rs b/clarity/src/vm/tests/conversions.rs index 7dddd5bdfe5..b1c5e4664d2 100644 --- a/clarity/src/vm/tests/conversions.rs +++ b/clarity/src/vm/tests/conversions.rs @@ -30,9 +30,10 @@ use crate::vm::tests::test_clarity_versions; use crate::vm::types::SequenceSubtype::BufferType; use crate::vm::types::TypeSignature::SequenceType; use crate::vm::types::{ - ASCIIData, BuffData, BufferLength, CharType, SequenceData, TypeSignature, UTF8Data, Value, + ASCIIData, BuffData, BufferLength, CharType, SequenceData, TupleData, TypeSignature, UTF8Data, + Value, }; -use crate::vm::{ClarityVersion, execute_v2, execute_with_parameters}; +use crate::vm::{ClarityName, ClarityVersion, execute_v2, execute_with_parameters}; #[test] fn test_simple_buff_to_int_le() { @@ -625,6 +626,86 @@ fn test_from_consensus_buff_unexpected_serialization_epoch_gate( ); } +/// A `from-consensus-buff?` buffer encoding a tuple with a leading-`_` key +/// must deserialize to `none` pre-Clarity-6 and to the typed-sanitized tuple +/// from Clarity 6 onward. +#[apply(test_clarity_versions)] +fn test_from_consensus_buff_rejects_underscore_tuple_key_pre_clarity6( + version: ClarityVersion, + epoch: StacksEpochId, +) { + // `from-consensus-buff?` is only available in Clarity 2+. + if version < ClarityVersion::Clarity2 { + return; + } + + let tuple_with_underscore = Value::Tuple( + TupleData::from_data(vec![ + (ClarityName::from_literal("a"), Value::Int(456)), + (ClarityName::from_literal("_x"), Value::Int(123)), + ]) + .expect("construction of `_x` tuple should succeed with the relaxed regex"), + ); + let hex = tuple_with_underscore + .serialize_to_hex() + .expect("serialize tuple to hex"); + + let program = format!("(from-consensus-buff? {{a: int}} 0x{hex})"); + let result = execute_with_parameters(&program, version, epoch, false) + .expect("from-consensus-buff? must not crash") + .expect("from-consensus-buff? must produce a value"); + + if epoch < StacksEpochId::Epoch40 { + assert_eq!( + result, + Value::none(), + "pre-Clarity-6 epochs must reject `_`-prefixed tuple keys at runtime to match unpatched-node behavior" + ); + } else { + let expected = Value::some(Value::Tuple( + TupleData::from_data(vec![(ClarityName::from_literal("a"), Value::Int(456))]).unwrap(), + )) + .unwrap(); + assert_eq!( + result, expected, + "Clarity 6+ should accept the buffer and return the typed-sanitized tuple" + ); + } +} + +/// A `from-consensus-buff?` buffer encoding a tuple keyed by bare `_` must +/// always deserialize to `none`; bare `_` is an invalid tuple key at every +/// epoch (rejected by the codec pre-Clarity-6, reserved as the discard +/// pattern from Clarity 6 onward). +#[apply(test_clarity_versions)] +fn test_from_consensus_buff_rejects_bare_underscore_tuple_key( + version: ClarityVersion, + epoch: StacksEpochId, +) { + if version < ClarityVersion::Clarity2 { + return; + } + + let tuple_with_bare_underscore = Value::Tuple( + TupleData::from_data(vec![(ClarityName::from_literal("_"), Value::Int(7))]) + .expect("construction of bare-`_` tuple should succeed with the relaxed regex"), + ); + let hex = tuple_with_bare_underscore + .serialize_to_hex() + .expect("serialize tuple to hex"); + + let program = format!("(from-consensus-buff? {{a: int}} 0x{hex})"); + let result = execute_with_parameters(&program, version, epoch, false) + .expect("from-consensus-buff? must not crash") + .expect("from-consensus-buff? must produce a value"); + + assert_eq!( + result, + Value::none(), + "bare `_` is reserved as the discard pattern at every epoch and must never appear as a runtime tuple key" + ); +} + fn evaluate_to_ascii(snippet: &str) -> Value { execute_versioned(snippet, ClarityVersion::latest()) .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) From 252bfe9131d5ae88a1124c058f79a7842afb306a Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Thu, 18 Jun 2026 13:52:19 -0400 Subject: [PATCH 31/32] Address PR comment about deeply nested tuple keys --- clarity-types/src/types/serialization.rs | 29 ++++++++--- clarity/src/vm/functions/conversions.rs | 6 ++- clarity/src/vm/tests/conversions.rs | 51 +++++++++++++++++++ .../src/chainstate/stacks/transaction.rs | 2 +- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/clarity-types/src/types/serialization.rs b/clarity-types/src/types/serialization.rs index 9828bb4efaa..91e9a9f7895 100644 --- a/clarity-types/src/types/serialization.rs +++ b/clarity-types/src/types/serialization.rs @@ -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() { @@ -569,6 +575,7 @@ impl Value { r: &mut R, top_expected_type: Option<&TypeSignature>, sanitize: bool, + max_depth: usize, ) -> Result { use super::Value::*; @@ -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()); } @@ -1168,6 +1170,19 @@ impl Value { 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 { + 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 { diff --git a/clarity/src/vm/functions/conversions.rs b/clarity/src/vm/functions/conversions.rs index 60de5e285aa..da681b38522 100644 --- a/clarity/src/vm/functions/conversions.rs +++ b/clarity/src/vm/functions/conversions.rs @@ -352,8 +352,10 @@ pub fn from_consensus_buff( // Reject epoch-invalid tuple keys before the typed pass: typed // deserialization sanitizes (elides) keys not in the expected type, // which would strip the evidence before any post-deserialize walker - // could see it. - if let Ok(unsanitized) = Value::try_deserialize_bytes_untyped(input_bytes) + // could see it. Must use the full-depth untyped path: the typed pass + // reaches `MAX_TYPE_DEPTH`, so a shallower pre-scan would let bad keys + // nested past the legacy cap slip through. + if let Ok(unsanitized) = Value::try_deserialize_bytes_untyped_full_depth(input_bytes) && unsanitized .find_invalid_tuple_key(*exec_state.epoch()) .is_some() diff --git a/clarity/src/vm/tests/conversions.rs b/clarity/src/vm/tests/conversions.rs index b1c5e4664d2..79cccb7df4f 100644 --- a/clarity/src/vm/tests/conversions.rs +++ b/clarity/src/vm/tests/conversions.rs @@ -706,6 +706,57 @@ fn test_from_consensus_buff_rejects_bare_underscore_tuple_key( ); } +/// A `from-consensus-buff?` buffer whose offending `_`-prefixed tuple key sits +/// deeper than the legacy untyped depth cap must still deserialize to `none` +/// pre-Clarity-6; the typed pass reaches `MAX_TYPE_DEPTH`, so the pre-check +/// must reach it too or the bad key escapes detection. +#[apply(test_clarity_versions)] +fn test_from_consensus_buff_deeply_nested_underscore_tuple_key_pre_clarity6( + version: ClarityVersion, + epoch: StacksEpochId, +) { + // `from-consensus-buff?` is only available in Clarity 2+; the divergence is + // confined to the pre-Clarity-6 (pre-Epoch40) upgrade window. + if version < ClarityVersion::Clarity2 || epoch >= StacksEpochId::Epoch40 { + return; + } + + // Bury the offending tuple under enough `optional` layers that the + // leading-`_` key sits deeper than the untyped pre-check's depth-16 cap + // but within the typed pass's depth-32 cap. + const DEPTH: usize = 20; + + let mut value = Value::Tuple( + TupleData::from_data(vec![ + (ClarityName::from_literal("a"), Value::Int(456)), + (ClarityName::from_literal("_x"), Value::Int(123)), + ]) + .expect("construction of `_x` tuple should succeed with the relaxed regex"), + ); + for _ in 0..DEPTH { + value = Value::some(value).expect("nest value in optional"); + } + let hex = value.serialize_to_hex().expect("serialize value to hex"); + + let type_repr = format!( + "{}{{a: int}}{}", + "(optional ".repeat(DEPTH), + ")".repeat(DEPTH) + ); + let program = format!("(from-consensus-buff? {type_repr} 0x{hex})"); + let result = execute_with_parameters(&program, version, epoch, false) + .expect("from-consensus-buff? must not crash") + .expect("from-consensus-buff? must produce a value"); + + assert_eq!( + result, + Value::none(), + "pre-Clarity-6 epochs must reject a `_`-prefixed tuple key at ANY nesting \ + depth to match unpatched-node behavior, but a key below depth 16 escaped \ + the pre-check (got {result:?})" + ); +} + fn evaluate_to_ascii(snippet: &str) -> Value { execute_versioned(snippet, ClarityVersion::latest()) .unwrap_or_else(|e| panic!("Execution failed for snippet `{snippet}`: {e:?}")) diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs index 73d4e9d2f20..68ae026c372 100644 --- a/stackslib/src/chainstate/stacks/transaction.rs +++ b/stackslib/src/chainstate/stacks/transaction.rs @@ -187,8 +187,8 @@ mod test { use std::io::{Read, Write}; use clarity::types::StacksEpochId; - use clarity::vm::Value; use clarity::vm::representations::{ClarityName, ContractName}; + use clarity::vm::Value; use stacks_common::codec::{read_next, write_next, Error as codec_error, StacksMessageCodec}; use stacks_common::types::chainstate::StacksAddress; use stacks_common::util::hash::*; From 13ed408606f749b3ed2ab919dd51dc13d7317376 Mon Sep 17 00:00:00 2001 From: Jeff Bencin Date: Thu, 18 Jun 2026 15:29:07 -0400 Subject: [PATCH 32/32] Change error message in snapshot so tests pass --- ...__tests__parse_tests__lexer_unknown_symbol.snap | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/stackslib/src/chainstate/tests/snapshots/blockstack_lib__chainstate__tests__parse_tests__lexer_unknown_symbol.snap b/stackslib/src/chainstate/tests/snapshots/blockstack_lib__chainstate__tests__parse_tests__lexer_unknown_symbol.snap index e71f6cecf23..2326845590f 100644 --- a/stackslib/src/chainstate/tests/snapshots/blockstack_lib__chainstate__tests__parse_tests__lexer_unknown_symbol.snap +++ b/stackslib/src/chainstate/tests/snapshots/blockstack_lib__chainstate__tests__parse_tests__lexer_unknown_symbol.snap @@ -1,6 +1,8 @@ --- source: stackslib/src/chainstate/tests/parse_tests.rs -expression: result +assertion_line: 477 +expression: result.get_expected_results() +snapshot_kind: text --- [ Success(ExpectedBlockOutput( @@ -9,7 +11,7 @@ expression: result transactions: [ ExpectedTransactionOutput( tx: "SmartContract(name: my-contract-Epoch3_4-Clarity1, code_body: [..], clarity_version: Some(Clarity1))", - vm_error: "Some(unknown symbol, \'_\') [NON-CONSENSUS BREAKING]", + vm_error: "Some(:0:0: identifier \'_\' starts with \'_\', which requires Clarity 6 or later) [NON-CONSENSUS BREAKING]", return_type: Response(ResponseData( committed: false, data: Optional(OptionalData( @@ -39,7 +41,7 @@ expression: result transactions: [ ExpectedTransactionOutput( tx: "SmartContract(name: my-contract-Epoch3_4-Clarity2, code_body: [..], clarity_version: Some(Clarity2))", - vm_error: "Some(unknown symbol, \'_\') [NON-CONSENSUS BREAKING]", + vm_error: "Some(:0:0: identifier \'_\' starts with \'_\', which requires Clarity 6 or later) [NON-CONSENSUS BREAKING]", return_type: Response(ResponseData( committed: false, data: Optional(OptionalData( @@ -69,7 +71,7 @@ expression: result transactions: [ ExpectedTransactionOutput( tx: "SmartContract(name: my-contract-Epoch3_4-Clarity3, code_body: [..], clarity_version: Some(Clarity3))", - vm_error: "Some(unknown symbol, \'_\') [NON-CONSENSUS BREAKING]", + vm_error: "Some(:0:0: identifier \'_\' starts with \'_\', which requires Clarity 6 or later) [NON-CONSENSUS BREAKING]", return_type: Response(ResponseData( committed: false, data: Optional(OptionalData( @@ -99,7 +101,7 @@ expression: result transactions: [ ExpectedTransactionOutput( tx: "SmartContract(name: my-contract-Epoch3_4-Clarity4, code_body: [..], clarity_version: Some(Clarity4))", - vm_error: "Some(unknown symbol, \'_\') [NON-CONSENSUS BREAKING]", + vm_error: "Some(:0:0: identifier \'_\' starts with \'_\', which requires Clarity 6 or later) [NON-CONSENSUS BREAKING]", return_type: Response(ResponseData( committed: false, data: Optional(OptionalData( @@ -129,7 +131,7 @@ expression: result transactions: [ ExpectedTransactionOutput( tx: "SmartContract(name: my-contract-Epoch3_4-Clarity5, code_body: [..], clarity_version: Some(Clarity5))", - vm_error: "Some(unknown symbol, \'_\') [NON-CONSENSUS BREAKING]", + vm_error: "Some(:0:0: identifier \'_\' starts with \'_\', which requires Clarity 6 or later) [NON-CONSENSUS BREAKING]", return_type: Response(ResponseData( committed: false, data: Optional(OptionalData(