Skip to content

Commit 11b5a6d

Browse files
Add consensus tests to RuntimeError and some unit tests to unreachable variants
Signed-off-by: Jacinta Ferrant <[email protected]>
1 parent 44fdf84 commit 11b5a6d

File tree

40 files changed

+31665
-27
lines changed

40 files changed

+31665
-27
lines changed

clarity-types/src/types/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,10 @@ impl PrincipalData {
14451445
literal: &str,
14461446
) -> Result<StandardPrincipalData, VmExecutionError> {
14471447
let (version, data) = c32::c32_address_decode(literal).map_err(|x| {
1448+
// This `TypeParseFailure` is unreachable in normal Clarity execution.
1449+
// - All principal literals are validated by the Clarity lexer *before* reaching `parse_standard_principal`.
1450+
// - The lexer rejects any literal containing characters outside the C32 alphabet.
1451+
// Therefore, only malformed input fed directly into low-level VM entry points can cause this branch to execute.
14481452
RuntimeError::TypeParseFailure(format!("Invalid principal literal: {x}"))
14491453
})?;
14501454
if data.len() != 20 {

clarity/src/vm/contexts.rs

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,37 +286,52 @@ impl AssetMap {
286286
}
287287
}
288288

289-
// This will get the next amount for a (principal, stx) entry in the stx table.
289+
/// This will get the next amount for a (principal, stx) entry in the stx table.
290290
fn get_next_stx_amount(
291291
&self,
292292
principal: &PrincipalData,
293293
amount: u128,
294294
) -> Result<u128, VmExecutionError> {
295+
// `ArithmeticOverflow` in this function is **unreachable** in normal Clarity execution because:
296+
// - Every `stx-transfer?` or `stx-burn?` is validated against the sender’s
297+
// **unlocked balance** before being queued in `AssetMap`.
298+
// - The unlocked balance is a subset of `stx-liquid-supply`.
299+
// - All balance updates in Clarity use the `+` operator **before** logging to `AssetMap`.
300+
// - `+` performs `checked_add` and returns `RuntimeError::ArithmeticOverflow` **first**.
295301
let current_amount = self.stx_map.get(principal).unwrap_or(&0);
296302
current_amount
297303
.checked_add(amount)
298304
.ok_or(RuntimeError::ArithmeticOverflow.into())
299305
}
300306

301-
// This will get the next amount for a (principal, stx) entry in the burn table.
307+
/// This will get the next amount for a (principal, stx) entry in the burn table.
302308
fn get_next_stx_burn_amount(
303309
&self,
304310
principal: &PrincipalData,
305311
amount: u128,
306312
) -> Result<u128, VmExecutionError> {
313+
// `ArithmeticOverflow` in this function is **unreachable** in normal Clarity execution because:
314+
// - Every `stx-burn?` is validated against the sender’s **unlocked balance** first.
315+
// - Unlocked balance is a subset of `stx-liquid-supply`, which is <= `u128::MAX`.
316+
// - All balance updates in Clarity use the `+` operator **before** logging to `AssetMap`.
317+
// - `+` performs `checked_add` and returns `RuntimeError::ArithmeticOverflow` **first**.
307318
let current_amount = self.burn_map.get(principal).unwrap_or(&0);
308319
current_amount
309320
.checked_add(amount)
310321
.ok_or(RuntimeError::ArithmeticOverflow.into())
311322
}
312323

313-
// This will get the next amount for a (principal, asset) entry in the asset table.
324+
/// This will get the next amount for a (principal, asset) entry in the asset table.
314325
fn get_next_amount(
315326
&self,
316327
principal: &PrincipalData,
317328
asset: &AssetIdentifier,
318329
amount: u128,
319330
) -> Result<u128, VmExecutionError> {
331+
// `ArithmeticOverflow` in this function is **unreachable** in normal Clarity execution because:
332+
// - The inner transaction must have **partially succeeded** to log any assets.
333+
// - All balance updates in Clarity use the `+` operator **before** logging to `AssetMap`.
334+
// - `+` performs `checked_add` and returns `RuntimeError::ArithmeticOverflow` **first**.
320335
let current_amount = self
321336
.token_map
322337
.get(principal)
@@ -1011,6 +1026,13 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> {
10111026
.expressions;
10121027

10131028
if parsed.is_empty() {
1029+
// `TypeParseFailure` is **unreachable** in standard Clarity VM execution.
1030+
// - `eval_read_only` parses a raw program string into an AST.
1031+
// - Any empty or invalid program would be rejected at publish/deploy time or earlier parsing stages.
1032+
// - Therefore, `parsed.is_empty()` cannot occur for programs originating from a valid contract
1033+
// or transaction.
1034+
// - Only malformed input fed directly to this internal method (e.g., in unit tests or
1035+
// artificial VM invocations) can trigger this error.
10141036
return Err(RuntimeError::TypeParseFailure(
10151037
"Expected a program of at least length 1".to_string(),
10161038
)
@@ -1060,6 +1082,12 @@ impl<'a, 'b, 'hooks> Environment<'a, 'b, 'hooks> {
10601082
.expressions;
10611083

10621084
if parsed.is_empty() {
1085+
// `TypeParseFailure` is **unreachable** in standard Clarity VM execution.
1086+
// - `eval_raw` parses a raw program string into an AST.
1087+
// - All programs deployed or called via the standard VM go through static parsing and validation first.
1088+
// - Any empty or invalid program would be rejected at publish/deploy time or earlier parsing stages.
1089+
// - Therefore, `parsed.is_empty()` cannot occur for a program that originates from a valid Clarity contract or transaction.
1090+
// Only malformed input directly fed to this internal method (e.g., in unit tests) can trigger this error.
10631091
return Err(RuntimeError::TypeParseFailure(
10641092
"Expected a program of at least length 1".to_string(),
10651093
)
@@ -1940,6 +1968,14 @@ impl<'a> LocalContext<'a> {
19401968

19411969
pub fn extend(&'a self) -> Result<LocalContext<'a>, VmExecutionError> {
19421970
if self.depth >= MAX_CONTEXT_DEPTH {
1971+
// `MaxContextDepthReached` in this function is **unreachable** in normal Clarity execution because:
1972+
// - Every function call in Clarity increments both the call stack depth and the local context depth.
1973+
// - The VM enforces `MAX_CALL_STACK_DEPTH` (currently 64) **before** `MAX_CONTEXT_DEPTH` (256).
1974+
// - This means no contract can create more than 64 nested function calls, preventing context depth from reaching 256.
1975+
// - Nested expressions (`let`, `begin`, `if`, etc.) increment context depth, but the Clarity parser enforces
1976+
// `ExpressionStackDepthTooDeep` long before MAX_CONTEXT_DEPTH nested contexts can be written.
1977+
// - As a result, `MaxContextDepthReached` can only occur in artificial Rust-level tests calling `LocalContext::extend()`,
1978+
// not in deployed contract execution.
19431979
Err(RuntimeError::MaxContextDepthReached.into())
19441980
} else {
19451981
Ok(LocalContext {
@@ -2292,4 +2328,108 @@ mod test {
22922328
TypeSignature::CallableType(CallableSubtype::Trait(trait_id))
22932329
);
22942330
}
2331+
2332+
#[test]
2333+
fn asset_map_arithmetic_overflows() {
2334+
let a_contract_id = QualifiedContractIdentifier::local("a").unwrap();
2335+
let b_contract_id = QualifiedContractIdentifier::local("b").unwrap();
2336+
let p1 = PrincipalData::Contract(a_contract_id.clone());
2337+
let p2 = PrincipalData::Contract(b_contract_id.clone());
2338+
let t1 = AssetIdentifier {
2339+
contract_identifier: a_contract_id,
2340+
asset_name: "a".into(),
2341+
};
2342+
2343+
let mut am1 = AssetMap::new();
2344+
let mut am2 = AssetMap::new();
2345+
2346+
// Token transfer: add u128::MAX followed by 1 to overflow
2347+
am1.add_token_transfer(&p1, t1.clone(), u128::MAX).unwrap();
2348+
assert!(matches!(
2349+
am1.add_token_transfer(&p1, t1.clone(), 1).unwrap_err(),
2350+
VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _)
2351+
));
2352+
2353+
// STX burn: add u128::MAX followed by 1 to overflow
2354+
am1.add_stx_burn(&p1, u128::MAX).unwrap();
2355+
assert!(matches!(
2356+
am1.add_stx_burn(&p1, 1).unwrap_err(),
2357+
VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _)
2358+
));
2359+
2360+
// STX transfer: add u128::MAX followed by 1 to overflow
2361+
am1.add_stx_transfer(&p1, u128::MAX).unwrap();
2362+
assert!(matches!(
2363+
am1.add_stx_transfer(&p1, 1).unwrap_err(),
2364+
VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _)
2365+
));
2366+
2367+
// commit_other: merge two maps where sum exceeds u128::MAX
2368+
am2.add_token_transfer(&p1, t1.clone(), u128::MAX).unwrap();
2369+
assert!(matches!(
2370+
am1.commit_other(am2).unwrap_err(),
2371+
VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _)
2372+
));
2373+
}
2374+
2375+
#[test]
2376+
fn eval_raw_empty_program() {
2377+
// Setup environment
2378+
let mut tl_env_factory = tl_env_factory();
2379+
let mut env = tl_env_factory.get_env(StacksEpochId::latest());
2380+
2381+
// Call eval_read_only with an empty program
2382+
let program = ""; // empty program triggers parsed.is_empty()
2383+
let err = env.eval_raw(program).unwrap_err();
2384+
2385+
assert!(
2386+
matches!(
2387+
err,
2388+
VmExecutionError::Runtime(RuntimeError::TypeParseFailure(msg), _) if msg.contains("Expected a program of at least length 1")),
2389+
"Expected a type parse failure"
2390+
);
2391+
}
2392+
2393+
#[test]
2394+
fn eval_read_only_empty_program() {
2395+
// Setup environment
2396+
let mut tl_env_factory = tl_env_factory();
2397+
let mut env = tl_env_factory.get_env(StacksEpochId::latest());
2398+
2399+
// Construct a dummy contract context
2400+
let contract_id = QualifiedContractIdentifier::local("dummy-contract").unwrap();
2401+
2402+
// Call eval_read_only with an empty program
2403+
let program = ""; // empty program triggers parsed.is_empty()
2404+
let err = env.eval_read_only(&contract_id, program).unwrap_err();
2405+
2406+
assert!(
2407+
matches!(
2408+
err,
2409+
VmExecutionError::Runtime(RuntimeError::TypeParseFailure(msg), _) if msg.contains("Expected a program of at least length 1")),
2410+
"Expected a type parse failure"
2411+
);
2412+
}
2413+
2414+
#[test]
2415+
fn max_context_depth_exceeded() {
2416+
let root = LocalContext {
2417+
function_context: None,
2418+
parent: None,
2419+
callable_contracts: HashMap::new(),
2420+
variables: HashMap::new(),
2421+
depth: MAX_CONTEXT_DEPTH - 1,
2422+
};
2423+
// We should be able to extend once successfully.
2424+
let result = root.extend().unwrap();
2425+
// We are now at the MAX_CONTEXT_DEPTH and should fail.
2426+
let result_2 = result.extend();
2427+
assert!(matches!(
2428+
result_2,
2429+
Err(VmExecutionError::Runtime(
2430+
RuntimeError::MaxContextDepthReached,
2431+
_
2432+
))
2433+
));
2434+
}
22952435
}

clarity/src/vm/costs/cost_functions.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ pub fn linear(n: u64, a: u64, b: u64) -> u64 {
171171
}
172172
pub fn logn(n: u64, a: u64, b: u64) -> Result<u64, VmExecutionError> {
173173
if n < 1 {
174+
// This branch is **unreachable** in standard Clarity execution:
175+
// - `logn` is only called from tuple access operations.
176+
// - Tuples must have at least one field, so `n >= 1` is always true (this is enforced via static checks).
177+
// - Hitting this branch requires manual VM manipulation or internal test harnesses.
174178
return Err(VmExecutionError::Runtime(
175179
RuntimeError::Arithmetic("log2 must be passed a positive integer".to_string()),
176180
Some(vec![]),

clarity/src/vm/database/clarity_db.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,10 @@ impl<'a> ClarityDatabase<'a> {
944944

945945
pub fn decrement_ustx_liquid_supply(&mut self, decr_by: u128) -> Result<(), VmExecutionError> {
946946
let current = self.get_total_liquid_ustx()?;
947+
// This `ArithmeticUnderflow` is **unreachable** in normal Clarity execution.
948+
// The sender's balance is always checked first (`amount <= sender_balance`),
949+
// and `sender_balance <= current_supply` always holds.
950+
// Thus, `decr_by > current_supply` cannot occur.
947951
let next = current.checked_sub(decr_by).ok_or_else(|| {
948952
error!("`stx-burn?` accepted that reduces `ustx-liquid-supply` below 0");
949953
RuntimeError::ArithmeticUnderflow
@@ -2091,6 +2095,10 @@ impl ClarityDatabase<'_> {
20912095
})?;
20922096

20932097
if amount > current_supply {
2098+
// `SupplyUnderflow` is **unreachable** in normal Clarity execution:
2099+
// the sender's balance is checked first (`amount <= sender_balance`),
2100+
// and `sender_balance <= current_supply` always holds.
2101+
// Thus, `amount > current_supply` cannot occur.
20942102
return Err(RuntimeError::SupplyUnderflow(current_supply, amount).into());
20952103
}
20962104

@@ -2411,3 +2419,142 @@ impl ClarityDatabase<'_> {
24112419
Ok(epoch.epoch_id)
24122420
}
24132421
}
2422+
2423+
#[test]
2424+
fn increment_ustx_liquid_supply_overflow() {
2425+
use crate::vm::database::MemoryBackingStore;
2426+
use crate::vm::errors::{RuntimeError, VmExecutionError};
2427+
2428+
let mut store = MemoryBackingStore::new();
2429+
let mut db = store.as_clarity_db();
2430+
2431+
db.begin();
2432+
// Set the liquid supply to one less than the max
2433+
db.set_ustx_liquid_supply(u128::MAX - 1)
2434+
.expect("Failed to set liquid supply");
2435+
// Trust but verify.
2436+
assert_eq!(
2437+
db.get_total_liquid_ustx().unwrap(),
2438+
u128::MAX - 1,
2439+
"Supply should now be u128::MAX - 1"
2440+
);
2441+
2442+
db.increment_ustx_liquid_supply(1)
2443+
.expect("Increment by 1 should succeed");
2444+
2445+
// Trust but verify.
2446+
assert_eq!(
2447+
db.get_total_liquid_ustx().unwrap(),
2448+
u128::MAX,
2449+
"Supply should now be u128::MAX"
2450+
);
2451+
2452+
// Attempt to overflow
2453+
let err = db.increment_ustx_liquid_supply(1).unwrap_err();
2454+
assert!(matches!(
2455+
err,
2456+
VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _)
2457+
));
2458+
2459+
// Verify adding 0 doesn't overflow
2460+
db.increment_ustx_liquid_supply(0)
2461+
.expect("Increment by 0 should succeed");
2462+
2463+
assert_eq!(db.get_total_liquid_ustx().unwrap(), u128::MAX);
2464+
2465+
db.commit().unwrap();
2466+
}
2467+
2468+
#[test]
2469+
fn checked_decrease_token_supply_underflow() {
2470+
use crate::vm::database::{MemoryBackingStore, StoreType};
2471+
use crate::vm::errors::{RuntimeError, VmExecutionError};
2472+
2473+
let mut store = MemoryBackingStore::new();
2474+
let mut db = store.as_clarity_db();
2475+
let contract_id = QualifiedContractIdentifier::transient();
2476+
let token_name = "token".to_string();
2477+
2478+
db.begin();
2479+
2480+
// Set initial supply to 1000
2481+
let key =
2482+
ClarityDatabase::make_key_for_trip(&contract_id, StoreType::CirculatingSupply, &token_name);
2483+
db.put_data(&key, &1000u128)
2484+
.expect("Failed to set initial token supply");
2485+
2486+
// Trust but verify.
2487+
let current_supply: u128 = db.get_data(&key).unwrap().unwrap();
2488+
assert_eq!(current_supply, 1000, "Initial supply should be 1000");
2489+
2490+
// Decrease by 500: should succeed
2491+
db.checked_decrease_token_supply(&contract_id, &token_name, 500)
2492+
.expect("Decreasing by 500 should succeed");
2493+
2494+
let new_supply: u128 = db.get_data(&key).unwrap().unwrap();
2495+
assert_eq!(new_supply, 500, "Supply should now be 500");
2496+
2497+
// Decrease by 0: should succeed (no change)
2498+
db.checked_decrease_token_supply(&contract_id, &token_name, 0)
2499+
.expect("Decreasing by 0 should succeed");
2500+
let supply_after_zero: u128 = db.get_data(&key).unwrap().unwrap();
2501+
assert_eq!(
2502+
supply_after_zero, 500,
2503+
"Supply should remain 500 after decreasing by 0"
2504+
);
2505+
2506+
// Attempt to decrease by 501; should trigger SupplyUnderflow
2507+
let err = db
2508+
.checked_decrease_token_supply(&contract_id, &token_name, 501)
2509+
.unwrap_err();
2510+
2511+
assert!(
2512+
matches!(
2513+
err,
2514+
VmExecutionError::Runtime(RuntimeError::SupplyUnderflow(500, 501), _)
2515+
),
2516+
"Expected SupplyUnderflow(500, 501), got: {err:?}"
2517+
);
2518+
2519+
// Supply should remain unchanged after failed underflow
2520+
let final_supply: u128 = db.get_data(&key).unwrap().unwrap();
2521+
assert_eq!(
2522+
final_supply, 500,
2523+
"Supply should not change after underflow error"
2524+
);
2525+
2526+
db.commit().unwrap();
2527+
}
2528+
2529+
#[test]
2530+
fn trigger_no_such_token_rust() {
2531+
use crate::vm::database::MemoryBackingStore;
2532+
use crate::vm::errors::{RuntimeError, VmExecutionError};
2533+
// Set up a memory backing store and Clarity database
2534+
let mut store = MemoryBackingStore::default();
2535+
let mut db = store.as_clarity_db();
2536+
2537+
db.begin();
2538+
// Define a fake contract identifier
2539+
let contract_id = QualifiedContractIdentifier::transient();
2540+
2541+
// Simulate querying a non-existent NFT
2542+
let asset_id = Value::Bool(false); // this token does not exist
2543+
let asset_name = "test-nft";
2544+
2545+
// Call get_nft_owner directly
2546+
let err = db
2547+
.get_nft_owner(
2548+
&contract_id,
2549+
asset_name,
2550+
&asset_id,
2551+
&TypeSignature::BoolType,
2552+
)
2553+
.unwrap_err();
2554+
2555+
// Assert that it produces NoSuchToken
2556+
assert!(
2557+
matches!(err, VmExecutionError::Runtime(RuntimeError::NoSuchToken, _)),
2558+
"Expected NoSuchToken. Got: {err}"
2559+
);
2560+
}

0 commit comments

Comments
 (0)