A complete blockchain implementation featuring: - synord: Full node with GHOSTDAG consensus - explorer-web: Modern React blockchain explorer with 3D DAG visualization - CLI wallet and tools - Smart contract SDK and example contracts (DEX, NFT, token) - WASM crypto library for browser/mobile
553 lines
15 KiB
Rust
553 lines
15 KiB
Rust
//! Assertion macros and utilities for contract testing.
|
|
//!
|
|
//! Provides convenient macros for testing contract execution results,
|
|
//! storage state, and emitted events.
|
|
|
|
use synor_vm::ExecutionResult;
|
|
|
|
/// Result type returned from assertion checks.
|
|
pub type AssertionResult = Result<(), AssertionError>;
|
|
|
|
/// Errors from assertion checks.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AssertionError {
|
|
/// Expected success but got error.
|
|
#[error("Expected success but got error: {0}")]
|
|
ExpectedSuccess(String),
|
|
|
|
/// Expected revert but got success.
|
|
#[error("Expected revert but got success")]
|
|
ExpectedRevert,
|
|
|
|
/// Expected specific revert message.
|
|
#[error("Expected revert message '{expected}' but got '{actual}'")]
|
|
RevertMessageMismatch { expected: String, actual: String },
|
|
|
|
/// Storage value mismatch.
|
|
#[error("Storage mismatch for key {key}: expected {expected:?}, got {actual:?}")]
|
|
StorageMismatch {
|
|
key: String,
|
|
expected: String,
|
|
actual: String,
|
|
},
|
|
|
|
/// Storage key not found.
|
|
#[error("Storage key not found: {0}")]
|
|
StorageKeyNotFound(String),
|
|
|
|
/// Event not found.
|
|
#[error("Expected event '{0}' was not emitted")]
|
|
EventNotFound(String),
|
|
|
|
/// Event topic mismatch.
|
|
#[error("Event topic mismatch: expected {expected}, got {actual}")]
|
|
EventTopicMismatch { expected: String, actual: String },
|
|
|
|
/// Return data mismatch.
|
|
#[error("Return data mismatch: expected {expected:?}, got {actual:?}")]
|
|
ReturnDataMismatch { expected: Vec<u8>, actual: Vec<u8> },
|
|
|
|
/// Gas assertion failed.
|
|
#[error("Gas assertion failed: {0}")]
|
|
GasAssertion(String),
|
|
}
|
|
|
|
/// Asserts that a contract execution succeeded.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```rust,ignore
|
|
/// let result = env.call_contract(contract, "transfer", &args)?;
|
|
/// assert_success!(result);
|
|
/// ```
|
|
#[macro_export]
|
|
macro_rules! assert_success {
|
|
($result:expr) => {
|
|
match &$result {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
panic!("Expected success but got error: {:?}", e);
|
|
}
|
|
}
|
|
};
|
|
($result:expr, $msg:expr) => {
|
|
match &$result {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
panic!("{}: Expected success but got error: {:?}", $msg, e);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that a contract execution reverted.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```rust,ignore
|
|
/// let result = env.call_contract(contract, "transfer", &args);
|
|
/// assert_revert!(result);
|
|
/// assert_revert!(result, "insufficient balance");
|
|
/// ```
|
|
#[macro_export]
|
|
macro_rules! assert_revert {
|
|
($result:expr) => {
|
|
match &$result {
|
|
Ok(_) => {
|
|
panic!("Expected revert but got success");
|
|
}
|
|
Err($crate::synor_vm::VmError::Revert(_)) => {}
|
|
Err(e) => {
|
|
// Also accept other errors as "revert" in some sense
|
|
let _ = e;
|
|
}
|
|
}
|
|
};
|
|
($result:expr, $message:expr) => {
|
|
match &$result {
|
|
Ok(_) => {
|
|
panic!("Expected revert but got success");
|
|
}
|
|
Err($crate::synor_vm::VmError::Revert(msg)) => {
|
|
if !msg.contains($message) {
|
|
panic!(
|
|
"Expected revert message containing '{}' but got '{}'",
|
|
$message, msg
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
panic!(
|
|
"Expected Revert error with message '{}' but got: {:?}",
|
|
$message, e
|
|
);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that a storage value equals the expected value.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```rust,ignore
|
|
/// assert_storage_eq!(env, contract, "balance", 1000u64);
|
|
/// assert_storage_eq!(env, contract, key_bytes, expected_value);
|
|
/// ```
|
|
#[macro_export]
|
|
macro_rules! assert_storage_eq {
|
|
($env:expr, $contract:expr, $key:expr, $expected:expr) => {{
|
|
let storage_key = $crate::synor_vm::StorageKey::from_bytes($key.as_ref());
|
|
match $env.get_storage(&$contract, &storage_key) {
|
|
Some(value) => {
|
|
let expected_bytes = $crate::to_storage_bytes(&$expected);
|
|
if value.as_bytes() != expected_bytes.as_slice() {
|
|
panic!(
|
|
"Storage mismatch for key {:?}: expected {:?}, got {:?}",
|
|
$key,
|
|
expected_bytes,
|
|
value.as_bytes()
|
|
);
|
|
}
|
|
}
|
|
None => {
|
|
panic!("Storage key not found: {:?}", $key);
|
|
}
|
|
}
|
|
}};
|
|
}
|
|
|
|
/// Asserts that an event was emitted during contract execution.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```rust,ignore
|
|
/// let result = env.call_contract(contract, "transfer", &args)?;
|
|
/// assert_event_emitted!(result, "Transfer");
|
|
/// assert_event_emitted!(result, "Transfer", topic1, topic2);
|
|
/// ```
|
|
#[macro_export]
|
|
macro_rules! assert_event_emitted {
|
|
($result:expr, $event_type:expr) => {{
|
|
let event_selector = $crate::event_selector($event_type);
|
|
let found = $result.logs.iter().any(|log| {
|
|
log.topics
|
|
.first()
|
|
.map(|t| t.as_bytes()[..4] == event_selector)
|
|
.unwrap_or(false)
|
|
});
|
|
if !found {
|
|
panic!("Expected event '{}' was not emitted", $event_type);
|
|
}
|
|
}};
|
|
($result:expr, $event_type:expr, $($topic:expr),+) => {{
|
|
let event_selector = $crate::event_selector($event_type);
|
|
let expected_topics: Vec<[u8; 32]> = vec![$($crate::to_topic_bytes(&$topic)),+];
|
|
|
|
let found = $result.logs.iter().any(|log| {
|
|
if log.topics.first()
|
|
.map(|t| t.as_bytes()[..4] == event_selector)
|
|
.unwrap_or(false)
|
|
{
|
|
// Check additional topics
|
|
if log.topics.len() < expected_topics.len() + 1 {
|
|
return false;
|
|
}
|
|
expected_topics.iter().enumerate().all(|(i, expected)| {
|
|
log.topics.get(i + 1)
|
|
.map(|t| t.as_bytes() == expected)
|
|
.unwrap_or(false)
|
|
})
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
|
|
if !found {
|
|
panic!(
|
|
"Expected event '{}' with specified topics was not emitted",
|
|
$event_type
|
|
);
|
|
}
|
|
}};
|
|
}
|
|
|
|
/// Asserts that no events were emitted.
|
|
#[macro_export]
|
|
macro_rules! assert_no_events {
|
|
($result:expr) => {
|
|
if !$result.logs.is_empty() {
|
|
panic!(
|
|
"Expected no events but {} events were emitted",
|
|
$result.logs.len()
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that gas used is within a range.
|
|
#[macro_export]
|
|
macro_rules! assert_gas_used {
|
|
($result:expr, $min:expr, $max:expr) => {
|
|
let gas_used = $result.gas_used;
|
|
if gas_used < $min || gas_used > $max {
|
|
panic!(
|
|
"Gas used {} is outside expected range [{}, {}]",
|
|
gas_used, $min, $max
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that the return data matches expected value.
|
|
#[macro_export]
|
|
macro_rules! assert_return_data {
|
|
($result:expr, $expected:expr) => {
|
|
let expected_bytes: Vec<u8> = $crate::to_return_bytes(&$expected);
|
|
if $result.return_data != expected_bytes {
|
|
panic!(
|
|
"Return data mismatch: expected {:?}, got {:?}",
|
|
expected_bytes, $result.return_data
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that storage is empty for a contract.
|
|
#[macro_export]
|
|
macro_rules! assert_storage_empty {
|
|
($env:expr, $contract:expr) => {
|
|
if $env.has_storage(&$contract) {
|
|
panic!("Expected empty storage for contract {:?}", $contract);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Asserts that a storage key does not exist.
|
|
#[macro_export]
|
|
macro_rules! assert_storage_none {
|
|
($env:expr, $contract:expr, $key:expr) => {{
|
|
let storage_key = $crate::synor_vm::StorageKey::from_bytes($key.as_ref());
|
|
if $env.get_storage(&$contract, &storage_key).is_some() {
|
|
panic!("Expected no storage at key {:?}", $key);
|
|
}
|
|
}};
|
|
}
|
|
|
|
// Helper functions for the macros
|
|
|
|
/// Computes an event selector (first 4 bytes of blake3 hash).
|
|
pub fn event_selector(event_name: &str) -> [u8; 4] {
|
|
let hash: [u8; 32] = blake3::hash(event_name.as_bytes()).into();
|
|
[hash[0], hash[1], hash[2], hash[3]]
|
|
}
|
|
|
|
/// Converts a value to storage bytes.
|
|
pub fn to_storage_bytes<T: StorageEncodable>(value: &T) -> Vec<u8> {
|
|
value.to_storage_bytes()
|
|
}
|
|
|
|
/// Converts a value to topic bytes (32 bytes).
|
|
pub fn to_topic_bytes<T: TopicEncodable>(value: &T) -> [u8; 32] {
|
|
value.to_topic_bytes()
|
|
}
|
|
|
|
/// Converts a value to return bytes.
|
|
pub fn to_return_bytes<T: ReturnEncodable>(value: &T) -> Vec<u8> {
|
|
value.to_return_bytes()
|
|
}
|
|
|
|
/// Trait for types that can be encoded as storage values.
|
|
pub trait StorageEncodable {
|
|
fn to_storage_bytes(&self) -> Vec<u8>;
|
|
}
|
|
|
|
impl StorageEncodable for u64 {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
self.to_le_bytes().to_vec()
|
|
}
|
|
}
|
|
|
|
impl StorageEncodable for u128 {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
self.to_le_bytes().to_vec()
|
|
}
|
|
}
|
|
|
|
impl StorageEncodable for bool {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
vec![if *self { 1 } else { 0 }]
|
|
}
|
|
}
|
|
|
|
impl StorageEncodable for [u8; 32] {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
self.to_vec()
|
|
}
|
|
}
|
|
|
|
impl StorageEncodable for Vec<u8> {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
self.clone()
|
|
}
|
|
}
|
|
|
|
impl StorageEncodable for &[u8] {
|
|
fn to_storage_bytes(&self) -> Vec<u8> {
|
|
self.to_vec()
|
|
}
|
|
}
|
|
|
|
/// Trait for types that can be encoded as event topics.
|
|
pub trait TopicEncodable {
|
|
fn to_topic_bytes(&self) -> [u8; 32];
|
|
}
|
|
|
|
impl TopicEncodable for [u8; 32] {
|
|
fn to_topic_bytes(&self) -> [u8; 32] {
|
|
*self
|
|
}
|
|
}
|
|
|
|
impl TopicEncodable for u64 {
|
|
fn to_topic_bytes(&self) -> [u8; 32] {
|
|
let mut bytes = [0u8; 32];
|
|
bytes[24..32].copy_from_slice(&self.to_be_bytes());
|
|
bytes
|
|
}
|
|
}
|
|
|
|
impl TopicEncodable for synor_types::Address {
|
|
fn to_topic_bytes(&self) -> [u8; 32] {
|
|
*self.payload()
|
|
}
|
|
}
|
|
|
|
/// Trait for types that can be encoded as return data.
|
|
pub trait ReturnEncodable {
|
|
fn to_return_bytes(&self) -> Vec<u8>;
|
|
}
|
|
|
|
impl ReturnEncodable for () {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
impl ReturnEncodable for u64 {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
self.to_le_bytes().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ReturnEncodable for u128 {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
self.to_le_bytes().to_vec()
|
|
}
|
|
}
|
|
|
|
impl ReturnEncodable for bool {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
vec![if *self { 1 } else { 0 }]
|
|
}
|
|
}
|
|
|
|
impl ReturnEncodable for Vec<u8> {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
self.clone()
|
|
}
|
|
}
|
|
|
|
impl ReturnEncodable for &[u8] {
|
|
fn to_return_bytes(&self) -> Vec<u8> {
|
|
self.to_vec()
|
|
}
|
|
}
|
|
|
|
/// Assertion helper for checking multiple conditions.
|
|
pub struct AssertionChain<'a> {
|
|
result: &'a ExecutionResult,
|
|
errors: Vec<String>,
|
|
}
|
|
|
|
impl<'a> AssertionChain<'a> {
|
|
/// Creates a new assertion chain for a result.
|
|
pub fn new(result: &'a ExecutionResult) -> Self {
|
|
AssertionChain {
|
|
result,
|
|
errors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Asserts gas used is within range.
|
|
pub fn gas_in_range(mut self, min: u64, max: u64) -> Self {
|
|
if self.result.gas_used < min || self.result.gas_used > max {
|
|
self.errors.push(format!(
|
|
"Gas {} not in range [{}, {}]",
|
|
self.result.gas_used, min, max
|
|
));
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Asserts event count.
|
|
pub fn event_count(mut self, expected: usize) -> Self {
|
|
if self.result.logs.len() != expected {
|
|
self.errors.push(format!(
|
|
"Expected {} events, got {}",
|
|
expected,
|
|
self.result.logs.len()
|
|
));
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Asserts return data length.
|
|
pub fn return_data_len(mut self, expected: usize) -> Self {
|
|
if self.result.return_data.len() != expected {
|
|
self.errors.push(format!(
|
|
"Expected return data length {}, got {}",
|
|
expected,
|
|
self.result.return_data.len()
|
|
));
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Asserts storage change count.
|
|
pub fn storage_changes(mut self, expected: usize) -> Self {
|
|
if self.result.storage_changes.len() != expected {
|
|
self.errors.push(format!(
|
|
"Expected {} storage changes, got {}",
|
|
expected,
|
|
self.result.storage_changes.len()
|
|
));
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Checks if all assertions passed.
|
|
pub fn is_ok(&self) -> bool {
|
|
self.errors.is_empty()
|
|
}
|
|
|
|
/// Finishes the chain and returns result.
|
|
pub fn finish(self) -> Result<(), String> {
|
|
if self.errors.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(self.errors.join("\n"))
|
|
}
|
|
}
|
|
|
|
/// Panics if any assertion failed.
|
|
pub fn unwrap(self) {
|
|
if !self.errors.is_empty() {
|
|
panic!("Assertion failures:\n{}", self.errors.join("\n"));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use synor_types::Hash256;
|
|
use synor_vm::ContractLog;
|
|
|
|
fn mock_result() -> ExecutionResult {
|
|
ExecutionResult {
|
|
return_data: vec![1, 2, 3, 4],
|
|
gas_used: 1000,
|
|
logs: vec![ContractLog {
|
|
contract: synor_vm::ContractId::from_bytes([0x42; 32]),
|
|
topics: vec![Hash256::from_bytes({
|
|
let mut bytes = [0u8; 32];
|
|
let selector = event_selector("Transfer");
|
|
bytes[..4].copy_from_slice(&selector);
|
|
bytes
|
|
})],
|
|
data: vec![],
|
|
}],
|
|
storage_changes: vec![],
|
|
internal_calls: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_selector() {
|
|
let sel1 = event_selector("Transfer");
|
|
let sel2 = event_selector("Transfer");
|
|
assert_eq!(sel1, sel2);
|
|
|
|
let sel3 = event_selector("Approval");
|
|
assert_ne!(sel1, sel3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_storage_encodable() {
|
|
assert_eq!(to_storage_bytes(&42u64), 42u64.to_le_bytes().to_vec());
|
|
assert_eq!(to_storage_bytes(&true), vec![1]);
|
|
assert_eq!(to_storage_bytes(&false), vec![0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_assertion_chain() {
|
|
let result = mock_result();
|
|
|
|
AssertionChain::new(&result)
|
|
.gas_in_range(500, 1500)
|
|
.event_count(1)
|
|
.return_data_len(4)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "Gas")]
|
|
fn test_assertion_chain_failure() {
|
|
let result = mock_result();
|
|
|
|
AssertionChain::new(&result)
|
|
.gas_in_range(2000, 3000) // Will fail
|
|
.unwrap();
|
|
}
|
|
}
|