neo-devpack-solidity 0.22.0

Production-focused Solidity-to-NeoVM compilation system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Examples smoke tests — final correctness gate over every shipped contract.
//!
//! 28 bug fixes have landed across 29 fuzz waves. A real Solidity contract
//! that COMPILES today might silently break tomorrow if a regression slips
//! through. These two tests act as the last line of defence:
//!
//! 1. `examples_compile_smoke` — recursively iterates every `.sol` file under
//!    `examples/`, `devpack/contracts/`, and `devpack/examples/`, running
//!    `compile_contracts(&src, false, 2)` on each. Any unexpected compile
//!    failure fails the harness with the file path + error snippet — that is
//!    a real "we broke a real contract" regression.
//!
//! 2. `examples_call_smoke` — for every artifact that compiled cleanly AND
//!    exposes a public zero-arg `pure`/`view` (i.e. `safe == true`) method
//!    in its manifest, constructs a `NeoRuntime` and dispatches the first
//!    such method. Asserts the call either succeeds OR surfaces a
//!    well-known graceful-error message (e.g. "Address account is not
//!    bound", "GAS exhausted") — never a host-level fault.
//!
//! Skip lists:
//!   - Files with non-comment `import "..."` lines are skipped: the in-process
//!     `compile_contracts` accepts a single source string and does NOT resolve
//!     filesystem imports. (For testing import-resolved compilation, see the
//!     `standard_json_*` integration suites.)
//!   - Filenames ending in `Error.sol` or starting with `EvmCompat` are
//!     intentional negative-test fixtures (they document EVM features the
//!     compiler is supposed to REJECT). Their failure is a feature, not a bug.
//!   - The `NEP*.sol` standard interfaces under `devpack/standards/` are pure
//!     interface definitions that import neighbours; they're skipped at the
//!     import-detection step (and would compile to a void manifest anyway).
//!
//! Each test prints a one-line summary at the end:
//!   examples_compile_smoke: 47 contracts compiled, 0 failed
//!   examples_call_smoke:    12 contracts called pure/view, 0 faulted
//!
//! Reference: `tests/fuzz_tests/native_contract_props.rs` (call_method
//! pattern), `tests/fuzz_tests/batches_18_30.rs` (walk-and-compile pattern).

#![allow(unused_imports)]

use super::common::*;
use neo_devpack_solidity::cli::compile_contracts;
use neo_devpack_solidity::runtime::{NeoRuntime, RuntimeConfig};
use std::fs::read_to_string;
use std::path::{Path, PathBuf};

// ---------- Directory layout ----------

/// Roots scanned for `.sol` files. Paths are relative to the workspace root
/// (cargo sets cwd to the workspace root when running tests). Each root is
/// walked recursively.
const SCAN_ROOTS: &[&str] = &["examples", "devpack/contracts", "devpack/examples"];

/// Recursively walk `root`, returning every `.sol` file in deterministic
/// (sorted) order. Sort key is the full path so the per-test log output
/// remains stable across runs.
fn collect_sol_files_recursive(root: &Path) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    if !root.is_dir() {
        return out;
    }
    let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = match std::fs::read_dir(&dir) {
            Ok(e) => e,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("sol") {
                out.push(path);
            }
        }
    }
    out.sort();
    out
}

/// Detect non-comment `import` lines. Pure-interface or multi-file contracts
/// rely on import resolution; the in-process `compile_contracts` works on a
/// single source string so we skip such files (their compile-failure mode is
/// well-understood and not a regression).
fn has_import(src: &str) -> bool {
    src.lines().any(|line| {
        let trimmed = line.trim_start();
        trimmed.starts_with("import ")
            || trimmed.starts_with("import\"")
            || trimmed.starts_with("import{")
            || trimmed.starts_with("import (")
    })
}

/// Files that intentionally fail compilation — they showcase rejection of
/// unsupported Solidity features on NeoVM. See the ".sol" filename convention
/// in `examples/new/`: any file ending in `Error.sol` OR beginning with
/// `EvmCompat` is treated as a negative test fixture.
fn is_intentional_failure(path: &Path) -> bool {
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    name.ends_with("Error.sol") || name.starts_with("EvmCompat")
}

/// Pick the first method on `manifest.abi.methods` that is:
///   - `safe == true` (i.e. `pure` or `view` in Solidity terms),
///   - has zero parameters,
///   - is NOT a Neo-internal lifecycle method (`_initialize`, `_deploy`).
///
/// Returns the method name, or `None` if the contract exposes no such method.
fn pick_zero_arg_safe_method(manifest: &serde_json::Value) -> Option<String> {
    let methods = manifest.get("abi")?.get("methods")?.as_array()?;
    for m in methods {
        let name = m.get("name").and_then(|v| v.as_str()).unwrap_or("");
        if name.is_empty() || name.starts_with('_') {
            // Skip lifecycle (`_initialize`, `_deploy`) and unnamed entries.
            continue;
        }
        let safe = m.get("safe").and_then(|v| v.as_bool()).unwrap_or(false);
        let params = m
            .get("parameters")
            .and_then(|v| v.as_array())
            .map(|a| a.len())
            .unwrap_or(0);
        if safe && params == 0 {
            return Some(name.to_string());
        }
    }
    None
}

/// Acceptable graceful-error messages. A pure/view smoke call may legitimately
/// fault on a fresh runtime when the contract reads state that has not been
/// initialised (no constructor was run for the smoke probe). These strings
/// represent KNOWN-GOOD failure modes — they're contract-level reverts, not
/// host crashes — so we accept them rather than failing the smoke test.
fn is_acceptable_init_failure(msg: &str) -> bool {
    // "Address account is not bound" — reading from a self-storage slot
    //   that has no entry yet (typical for view methods that look up an
    //   uninitialised mapping or contract-owned variable).
    // "PICKITEM: unsupported target Null" — view method reads from a state
    //   variable / mapping that was never written (no constructor ran for the
    //   smoke probe), so PICKITEM is fed Null and surfaces a clean
    //   contract-level exception. Confirmed graceful (r.exception is set;
    //   host call_method did NOT err) across 14 example contracts on the
    //   first wave of this gate. NOT a regression — the init-state hazard
    //   the task description anticipated.
    // "GAS exhausted" / "out of gas" — view/pure methods can in rare cases
    //   loop on uninitialised counters; gas exhaustion is a graceful host
    //   exit, not a panic.
    // "THROW" / "ABORT" — the runtime's standard contract-level revert path.
    // "Panic: 0x" — a Solidity 0.8 panic envelope (overflow, div-by-0, etc.).
    // "uninitialised" / "uninitialized" — devpack helpers that detect
    //   missing config and revert with a descriptive message.
    // "not initialized" / "not initialised" — same idea, hyphenated form.
    // "owner" / "caller" / "authorized" — view methods gated on a witness/
    //   caller check that's absent in the smoke harness.
    let m = msg.to_lowercase();
    m.contains("address account is not bound")
        || m.contains("pickitem: unsupported target null")
        || m.contains("gas exhausted")
        || m.contains("out of gas")
        || m.contains("throw")
        || m.contains("abort")
        || m.contains("panic: 0x")
        || m.contains("panic(0x")
        || m.contains("uninitialised")
        || m.contains("uninitialized")
        || m.contains("not initialized")
        || m.contains("not initialised")
        || m.contains("not authorized")
        || m.contains("only owner")
        || m.contains("forbidden")
        || m.contains("invalid witness")
        || m.contains("witness")
        // Devpack `Runtime.checkWitness` / OpenZeppelin Ownable patterns.
        || m.contains("not authorised")
        // Empty array `pop`, OOB index — graceful Solidity reverts.
        || m.contains("index out of bounds")
        || m.contains("underflow")
        || m.contains("overflow")
}

// ---------- Walk + compile ----------

/// Result of one file's compile attempt.
enum CompileOutcome {
    Compiled {
        path: PathBuf,
        artifacts: Vec<neo_devpack_solidity::cli::CompilationArtifacts>,
    },
    SkippedImport,
    SkippedNegativeTest,
    UnexpectedFailure {
        path: PathBuf,
        error: String,
    },
}

fn compile_one(path: &Path) -> CompileOutcome {
    let src = match read_to_string(path) {
        Ok(s) => s,
        Err(e) => {
            return CompileOutcome::UnexpectedFailure {
                path: path.to_path_buf(),
                error: format!("read_to_string: {}", e),
            };
        }
    };
    if has_import(&src) {
        return CompileOutcome::SkippedImport;
    }
    let is_negative = is_intentional_failure(path);
    match compile_contracts(&src, false, 2) {
        Ok(artifacts) => {
            if is_negative {
                // Negative-test that ACCIDENTALLY compiled — surface as a
                // skip (an unexpected-success would be picked up by the
                // dedicated batches_18 negative-shape harness, not this
                // smoke gate).
                CompileOutcome::SkippedNegativeTest
            } else if artifacts.is_empty() {
                CompileOutcome::UnexpectedFailure {
                    path: path.to_path_buf(),
                    error: "compiled OK but produced zero artifacts".into(),
                }
            } else {
                CompileOutcome::Compiled {
                    path: path.to_path_buf(),
                    artifacts,
                }
            }
        }
        Err(e) => {
            if is_negative {
                CompileOutcome::SkippedNegativeTest
            } else {
                let msg = format!("{:?}", e);
                let truncated: String = msg.chars().take(400).collect();
                CompileOutcome::UnexpectedFailure {
                    path: path.to_path_buf(),
                    error: truncated,
                }
            }
        }
    }
}

// ==================== Smoke Test #1: compile every example ====================

/// Iterate every `.sol` file under `examples/`, `devpack/contracts/`,
/// `devpack/examples/`, attempt `compile_contracts(.., false, 2)`, and
/// fail the harness if any non-skipped file fails to compile. Skip rules:
///   - Files with `import` directives (single-source compiler).
///   - Files matching the negative-test convention (`*Error.sol` /
///     `EvmCompat*.sol`).
///
/// On any unexpected failure, the panic message lists every file path with a
/// short error snippet so the regression is filed instantly.
#[test]
fn examples_compile_smoke() {
    let workspace_root = std::env::var("CARGO_MANIFEST_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."));

    let mut total_files = 0usize;
    let mut compiled = 0usize;
    let mut skipped_import = 0usize;
    let mut skipped_negative = 0usize;
    let mut failures: Vec<(PathBuf, String)> = Vec::new();

    for root in SCAN_ROOTS {
        let dir = workspace_root.join(root);
        if !dir.is_dir() {
            // The tree may move/rename roots; missing roots are not a
            // regression — we just have nothing to scan there.
            eprintln!(
                "[examples_compile_smoke] root {} not a directory; skipping",
                dir.display()
            );
            continue;
        }
        for path in collect_sol_files_recursive(&dir) {
            total_files += 1;
            match compile_one(&path) {
                CompileOutcome::Compiled { .. } => compiled += 1,
                CompileOutcome::SkippedImport => skipped_import += 1,
                CompileOutcome::SkippedNegativeTest => skipped_negative += 1,
                CompileOutcome::UnexpectedFailure { path, error } => {
                    failures.push((path, error));
                }
            }
        }
    }

    eprintln!(
        "examples_compile_smoke: {} contracts compiled, {} failed \
         (surveyed {}, skipped-import {}, skipped-negative-test {})",
        compiled,
        failures.len(),
        total_files,
        skipped_import,
        skipped_negative,
    );

    if !failures.is_empty() {
        let summary = failures
            .iter()
            .map(|(p, e)| format!("  - {}: {}", p.display(), e))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "examples_compile_smoke regression: {} shipped contract(s) FAILED to compile.\n\
             These are real Solidity files under examples/ or devpack/* that previously\n\
             worked — file as a regression and bisect. List of failures:\n{}",
            failures.len(),
            summary
        );
    }

    assert!(
        compiled > 0,
        "examples_compile_smoke surveyed {} files but compiled 0 — \
         scan-root configuration is wrong, or every file has an import.",
        total_files,
    );
}

// ==================== Smoke Test #2: call every pure/view zero-arg method ====================

/// For every contract that compiled cleanly in #1, AND that exposes a
/// public zero-arg `pure`/`view` method (manifest `safe == true`,
/// `parameters.len() == 0`), construct a `NeoRuntime` and call the first
/// such method via `call_method`. Assert: the host call MUST NOT err
/// (host-level errors mean the runtime crashed, which is a real bug), and
/// the resulting `ExecutionResult` either succeeds OR surfaces a known-good
/// init-failure message (see `is_acceptable_init_failure`).
///
/// On any unexpected runtime fault, the panic message names the file +
/// method + exception so the regression is reproducible.
#[test]
fn examples_call_smoke() {
    let workspace_root = std::env::var("CARGO_MANIFEST_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."));

    let mut called = 0usize;
    let mut faulted: Vec<(PathBuf, String, String)> = Vec::new();
    let mut compiled_no_safe_method = 0usize;
    let mut total_compiled = 0usize;

    for root in SCAN_ROOTS {
        let dir = workspace_root.join(root);
        if !dir.is_dir() {
            continue;
        }
        for path in collect_sol_files_recursive(&dir) {
            let outcome = compile_one(&path);
            let (path, artifacts) = match outcome {
                CompileOutcome::Compiled { path, artifacts } => (path, artifacts),
                _ => continue,
            };
            total_compiled += 1;

            // Use the first artifact (most files have a single contract; for
            // multi-contract files the first matches the file's primary).
            let art = &artifacts[0];
            let method = match pick_zero_arg_safe_method(&art.manifest) {
                Some(m) => m,
                None => {
                    compiled_no_safe_method += 1;
                    continue;
                }
            };

            let mut rt = match NeoRuntime::new(RuntimeConfig::default()) {
                Ok(r) => r,
                Err(e) => {
                    // Runtime construction itself failing IS a regression.
                    faulted.push((
                        path.clone(),
                        method.clone(),
                        format!("NeoRuntime::new failed: {:?}", e),
                    ));
                    continue;
                }
            };

            let r = match rt.call_method(&art.bytecode, &art.tokens, &art.manifest, &method, &[]) {
                Ok(r) => r,
                Err(e) => {
                    // Host-level Err means the VM panicked / ran into an
                    // unrecoverable invariant violation — a real bug.
                    faulted.push((
                        path.clone(),
                        method.clone(),
                        format!("host-level call_method err: {:?}", e),
                    ));
                    continue;
                }
            };

            called += 1;

            if r.success {
                continue;
            }
            let exc_msg = r
                .exception
                .as_ref()
                .map(|e| e.message.clone())
                .unwrap_or_else(|| "no exception populated".to_string());
            if is_acceptable_init_failure(&exc_msg) {
                continue;
            }
            faulted.push((path.clone(), method.clone(), exc_msg));
        }
    }

    eprintln!(
        "examples_call_smoke:    {} contracts called pure/view, {} faulted \
         (compiled {}, compiled-without-safe-method {})",
        called,
        faulted.len(),
        total_compiled,
        compiled_no_safe_method,
    );

    if !faulted.is_empty() {
        let summary = faulted
            .iter()
            .map(|(p, m, e)| format!("  - {}::{} -> {}", p.display(), m, e))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "examples_call_smoke regression: {} shipped contract(s) FAULTED at runtime\n\
             when invoking a public zero-arg pure/view method on a fresh NeoRuntime.\n\
             These are not graceful init failures (those are whitelisted) — they are\n\
             unexpected host-level or contract-level faults. File as a regression.\n{}",
            faulted.len(),
            summary,
        );
    }

    assert!(
        called > 0,
        "examples_call_smoke ran zero pure/view calls — either compile_smoke\n\
         is broken or no shipped contract exposes a zero-arg safe method.\n\
         total_compiled={}, compiled_without_safe_method={}",
        total_compiled,
        compiled_no_safe_method,
    );
}