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
// Allow currently-unreferenced strategy helpers — they're called transitively
// (e.g. `return_type_strategy` calls `param_type_strategy`) and kept exposed
// at module scope for future test sites. Without `#[allow]` rustc warns on
// the leaf functions that aren't directly invoked by the proptest entry.
#![allow(dead_code)]

//! End-to-end pipeline coverage: compile -> NEF -> manifest -> deploy -> call.
//!
//! Existing fuzz targets either stop at the compile boundary (random UTF-8
//! into `compile_contracts`) or pin a specific feature with a hand-written
//! contract. Neither produces *structured* coverage of the full
//! user-observable pipeline. This proptest closes that gap by:
//!
//!   1. Generating a Solidity contract whose shape is fuzzed across three
//!      axes (state-var count, method count, method names) but whose method
//!      bodies and types are restricted to a known-good set so the contract
//!      compiles cleanly.
//!   2. Compiling via `compile_contracts(src, false, 2)`.
//!   3. Wrapping the produced bytecode in a real NEF (`build_nef_with_tokens`)
//!      and round-tripping it through `parse_nef` to assert the magic, the
//!      checksum, and the payload preservation invariants the compiler-
//!      output writer relies on.
//!   4. Round-tripping the produced manifest through `serde_json` and
//!      asserting every declared method is present in `abi.methods[]`.
//!   5. Spinning up a `NeoRuntime` and invoking each declared method with
//!      type-appropriate default arguments (`Integer(0)` for uints, zero-
//!      filled `byte_array` for `address`/`bytes32`/`bytes memory`,
//!      `Boolean(false)` for `bool`).
//!   6. Asserting that every call either succeeds OR surfaces a recognised
//!      revert / panic envelope — never a host-level error from
//!      `call_method`. A host error here means the runtime itself broke,
//!      which is a real bug we want to surface.
//!
//! `ProptestConfig::with_cases(20)` keeps the wall-clock cost bounded
//! (compile + deploy + call per case, ~0.5-1s each on a debug build).

use super::common::*;
use neo_devpack_solidity::cli::compile_contracts;
use neo_devpack_solidity::neo::{build_nef_with_tokens, parse_nef};
use neo_devpack_solidity::runtime::types::StackItem;
use neo_devpack_solidity::runtime::{NeoRuntime, RuntimeConfig};
use proptest::prelude::*;

/// Simple Solidity types we know compile cleanly and have a sensible
/// "default value" for runtime invocation. Multi-return tuples and
/// dynamic-tail multi-return shapes are excluded because they have known
/// limitations the prompt explicitly calls out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SimpleType {
    Uint256,
    Bool,
    Address,
    Bytes32,
    BytesMemory, // only valid as a return type / `bytes memory` parameter
}

impl SimpleType {
    /// Solidity source spelling when used as a parameter (with location
    /// suffix where required).
    fn solidity_param(self) -> &'static str {
        match self {
            SimpleType::Uint256 => "uint256",
            SimpleType::Bool => "bool",
            SimpleType::Address => "address",
            SimpleType::Bytes32 => "bytes32",
            SimpleType::BytesMemory => "bytes memory",
        }
    }

    /// Solidity source spelling when used as a return type.
    fn solidity_return(self) -> &'static str {
        // For returns, `bytes memory` is the canonical spelling; the others
        // are identical between parameter and return position.
        self.solidity_param()
    }

    /// A Solidity literal that has the same type as `self`, suitable for
    /// `return <lit>;` in a generated method body.
    fn default_return_literal(self) -> &'static str {
        match self {
            SimpleType::Uint256 => "42",
            SimpleType::Bool => "true",
            // address(0) is the canonical zero-address literal.
            SimpleType::Address => "address(0)",
            // 32-byte zero literal.
            SimpleType::Bytes32 => "bytes32(0)",
            // Empty `bytes memory` constructed via `new bytes(0)`.
            SimpleType::BytesMemory => "new bytes(0)",
        }
    }

    /// Type-appropriate default `StackItem` for runtime invocation.
    fn default_stack_item(self) -> StackItem {
        match self {
            SimpleType::Uint256 => StackItem::Integer(0i64),
            SimpleType::Bool => StackItem::Boolean(false),
            // 20-byte zero-filled address.
            SimpleType::Address => StackItem::byte_array(vec![0u8; 20]),
            // 32-byte zero-filled bytes32.
            SimpleType::Bytes32 => StackItem::byte_array(vec![0u8; 32]),
            // Empty bytes payload.
            SimpleType::BytesMemory => StackItem::byte_array(Vec::new()),
        }
    }
}

/// Strategy producing a parameter type. `BytesMemory` is allowed since
/// `bytes memory <name>` is a valid Solidity parameter form.
fn param_type_strategy() -> impl Strategy<Value = SimpleType> {
    prop_oneof![
        Just(SimpleType::Uint256),
        Just(SimpleType::Bool),
        Just(SimpleType::Address),
        Just(SimpleType::Bytes32),
        Just(SimpleType::BytesMemory),
    ]
}

/// Strategy producing a return type. Same set as parameters; the body
/// generator picks a literal that matches.
fn return_type_strategy() -> impl Strategy<Value = SimpleType> {
    param_type_strategy()
}

/// One declared method's structural shape: its name, its parameter types
/// (between 0 and 2 params), and its return type.
#[derive(Debug, Clone)]
struct MethodSpec {
    name: String,
    params: Vec<SimpleType>,
    ret: SimpleType,
}

impl MethodSpec {
    /// Render this method as Solidity source. Parameter names are stable
    /// (`p0`, `p1`) so they cannot collide with reserved keywords. The
    /// body always returns the type's canonical default literal — the
    /// focus is the pipeline shape, not method semantics.
    fn render(&self) -> String {
        let mut params = String::new();
        for (i, ty) in self.params.iter().enumerate() {
            if i > 0 {
                params.push_str(", ");
            }
            params.push_str(ty.solidity_param());
            params.push_str(&format!(" p{i}"));
        }
        format!(
            "    function {name}({params}) external pure returns ({ret}) {{\n\
             \x20       return {lit};\n\
             \x20   }}\n",
            name = self.name,
            params = params,
            ret = self.ret.solidity_return(),
            lit = self.ret.default_return_literal(),
        )
    }

    /// Default-value call-site arguments for runtime invocation.
    fn default_args(&self) -> Vec<StackItem> {
        self.params.iter().map(|t| t.default_stack_item()).collect()
    }
}

/// Strategy producing a `MethodSpec` with arity 0..=2 and a fuzzed return
/// type. Method-name uniqueness across the contract is enforced by the
/// outer test (we deduplicate after sampling).
fn method_spec_strategy(
    name_strategy: impl Strategy<Value = String> + Clone,
) -> impl Strategy<Value = MethodSpec> {
    (
        name_strategy,
        prop::collection::vec(param_type_strategy(), 0..=2),
        return_type_strategy(),
    )
        .prop_map(|(name, params, ret)| MethodSpec { name, params, ret })
}

/// Build the full contract source from a state-var count and a method
/// list. State variables are named `s0`, `s1`, ... so they cannot collide
/// with Solidity reserved words. Each is `uint256 public sX;` to keep the
/// shape minimal — the generator's job is not to test storage semantics
/// but to verify the pipeline accepts mixed contract layouts.
fn build_contract_source(n_state_vars: usize, methods: &[MethodSpec]) -> String {
    let mut src = String::new();
    src.push_str("// SPDX-License-Identifier: MIT\n");
    src.push_str("pragma solidity ^0.8.19;\n");
    src.push_str("contract C {\n");
    for i in 0..n_state_vars {
        src.push_str(&format!("    uint256 public s{i};\n"));
    }
    for m in methods {
        src.push_str(&m.render());
    }
    src.push_str("}\n");
    src
}

/// True if a runtime exception's message matches a known-shape revert
/// envelope (Panic selector, require failure, abi-decode failure, etc.).
/// Anything outside this set is reported as a real bug — the prompt's
/// failure-handling clause is explicit: "report the failing inputs, don't
/// weaken".
fn is_known_exception_shape(msg: &str) -> bool {
    // Solidity Panic envelope (THROW path or canonical Panic(uint256)
    // envelope — both surface "Panic" in the exception message; the
    // canonical envelope decoding path in `common::observe` already
    // covers the strict-shape check, this is a looser textual gate for
    // the roundtrip test where we don't care about the selector value.).
    if msg.contains("Panic") {
        return true;
    }
    // `require(false, "msg")` and `revert("msg")` both produce
    // "require failed" / "Error(string)" shaped messages depending on
    // the Solidity version; both flow through THROW, which the runtime
    // surfaces with one of these substrings.
    if msg.contains("require failed") || msg.contains("Error(string)") || msg.contains("revert") {
        return true;
    }
    // ABI decode failures (e.g. when a method expects a calldata-tail
    // argument the synthetic msg.data didn't supply) surface as
    // "abi.decode" or "Decode" prefixed messages. These are legitimate
    // outcomes for the default-args path on dynamic-tail signatures.
    if msg.contains("abi.decode")
        || msg.contains("Decode")
        || msg.contains("decode")
        || msg.contains("ABI")
    {
        return true;
    }
    // Generic THROW envelope from the runtime always surfaces the
    // "Execution failed" or "THROW" prefix; treat that as a known shape.
    if msg.contains("THROW") || msg.contains("Execution failed") {
        return true;
    }
    false
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(20))]

    /// Full pipeline roundtrip: generate -> compile -> NEF -> manifest ->
    /// deploy -> call. See module-level docs for the rationale.
    #[test]
    fn compile_runtime_roundtrip(
        n_state_vars in 0usize..=4,
        n_methods in 1usize..=4,
        // Method names are sampled by the proptest macro; per-method
        // params and returns are sampled inside the test body via the
        // strategy combinators above, since they depend on `n_methods`.
        method_name in identifier_strategy(),
    ) {
        // ---- 1. Generate per-method specs ---------------------------------
        //
        // We need exactly `n_methods` distinct names. Re-using
        // `method_name` for the first slot keeps the proptest seed
        // surface low; subsequent slots get deterministic suffixes
        // ("m1", "m2", ...) that are guaranteed not to collide with
        // Solidity reserved words (none of {m0,m1,m2,m3} are reserved).
        let mut method_specs: Vec<MethodSpec> = Vec::with_capacity(n_methods);
        // The first method takes the fuzzed name; if it happens to clash
        // with one of the synthetic suffixes ("m1"..="m3") or with one of
        // the state-variable getters (`s0..s{n-1}` are `public uint256`
        // and Solidity auto-generates a getter at each name), we prefix
        // the fuzzed name with `f_` to dodge the collision deterministically.
        // Without this, proptest can sample `method_name = "s0"` against
        // `n_state_vars >= 1` and the compiler rejects with
        // "duplicate function signature 's0()'".
        let collides_with_state_var = method_name
            .strip_prefix('s')
            .and_then(|tail| tail.parse::<usize>().ok())
            .map(|i| i < n_state_vars)
            .unwrap_or(false);
        let collides_with_synth_method = matches!(method_name.as_str(), "m1" | "m2" | "m3");
        let first_method_name = if collides_with_state_var || collides_with_synth_method {
            format!("f_{method_name}")
        } else {
            method_name.clone()
        };
        method_specs.push(MethodSpec {
            name: first_method_name,
            params: Vec::new(), // filled in below
            ret: SimpleType::Uint256, // filled in below
        });
        for i in 1..n_methods {
            let candidate = format!("m{i}");
            // Avoid colliding with the user-fuzzed name.
            let final_name = if candidate == method_name {
                format!("m_extra_{i}")
            } else {
                candidate
            };
            method_specs.push(MethodSpec {
                name: final_name,
                params: Vec::new(),
                ret: SimpleType::Uint256,
            });
        }

        // Now fill in arity + return type per method using a tiny
        // deterministic PRNG seeded by the method index. This gives us
        // structured coverage without needing nested `prop_*` strategies
        // (which would interact awkwardly with `n_methods` being itself
        // a fuzzed value).
        for (i, spec) in method_specs.iter_mut().enumerate() {
            let arity = i % 3; // cycles 0, 1, 2
            let ret_idx = (i + n_state_vars) % 5;
            let ret_type = match ret_idx {
                0 => SimpleType::Uint256,
                1 => SimpleType::Bool,
                2 => SimpleType::Address,
                3 => SimpleType::Bytes32,
                _ => SimpleType::BytesMemory,
            };
            let params: Vec<SimpleType> = (0..arity)
                .map(|j| {
                    let p_idx = (i * 7 + j * 3) % 5;
                    match p_idx {
                        0 => SimpleType::Uint256,
                        1 => SimpleType::Bool,
                        2 => SimpleType::Address,
                        3 => SimpleType::Bytes32,
                        _ => SimpleType::BytesMemory,
                    }
                })
                .collect();
            spec.params = params;
            spec.ret = ret_type;
        }

        // ---- 2. Compile -----------------------------------------------------
        let source = build_contract_source(n_state_vars, &method_specs);
        let arts = compile_contracts(&source, false, 2)
            .unwrap_or_else(|e| panic!(
                "compile failed for generated contract:\n{source}\nerror: {e:?}"
            ));
        prop_assert!(!arts.is_empty(), "compile produced no artifacts");
        let art = &arts[0];

        // ---- 3. NEF round-trip ---------------------------------------------
        //
        // The compiler stores the raw NeoVM script in `art.bytecode`;
        // the production output writer wraps it via `build_nef_with_tokens`
        // before persisting. We reproduce that wrap here so the round-trip
        // exercises exactly the production NEF path.
        let nef = build_nef_with_tokens(
            &art.bytecode,
            "neo-devpack-solidity-fuzz",
            "",
            &art.tokens,
        ).expect("NEF must build for compiled artifact");

        // Magic header invariant.
        prop_assert!(
            nef.starts_with(b"NEF3"),
            "NEF must start with NEF3 magic; got {:02x?}",
            &nef[..nef.len().min(4)]
        );

        // Full parse round-trip: the parser validates magic, checksum,
        // and every length-prefixed field. Any failure surfaces a real
        // serialization bug.
        let parsed = parse_nef(&nef)
            .unwrap_or_else(|e| panic!("parse_nef failed for generated artifact: {e}"));
        prop_assert_eq!(
            &parsed.script, &art.bytecode,
            "parsed NEF script payload differs from compiled bytecode"
        );
        prop_assert_eq!(
            parsed.tokens.len(),
            art.tokens.len(),
            "parsed NEF token count differs from compiled token count"
        );

        // ---- 4. Manifest is valid JSON --------------------------------------
        //
        // `art.manifest` is already a `serde_json::Value`, but the
        // production write path serializes it via `serde_json::to_string`
        // and downstream tools then re-parse. Round-trip both directions
        // to catch any non-canonical values that survived inside.
        let manifest_str = serde_json::to_string(&art.manifest)
            .expect("manifest must serialize to JSON");
        let reparsed: serde_json::Value = serde_json::from_str(&manifest_str)
            .expect("manifest JSON must reparse");
        prop_assert_eq!(&reparsed, &art.manifest, "manifest JSON round-trip was lossy");

        // ---- 5. abi.methods contains every declared method -----------------
        let methods = art.manifest
            .get("abi").expect("manifest.abi present")
            .get("methods").expect("manifest.abi.methods present")
            .as_array().expect("manifest.abi.methods must be an array");

        for spec in &method_specs {
            let found = methods.iter().any(|m| {
                m.get("name").and_then(serde_json::Value::as_str) == Some(spec.name.as_str())
            });
            prop_assert!(
                found,
                "declared method '{}' missing from manifest.abi.methods",
                spec.name
            );
        }

        // ---- 6. Deploy + invoke each declared method -----------------------
        //
        // A single `NeoRuntime` instance covers every method on this
        // contract: the first `call_method` call auto-fires `_deploy`,
        // subsequent calls observe the deployed state. This mirrors the
        // pattern used by all production-shape integration tests
        // (batches_116_120 etc.).
        let mut rt = NeoRuntime::new(RuntimeConfig::default())
            .expect("NeoRuntime construction must not fail");

        for spec in &method_specs {
            let args = spec.default_args();
            let result = rt.call_method(
                &art.bytecode,
                &art.tokens,
                &art.manifest,
                &spec.name,
                &args,
            );

            // Step 7: the call must NEVER produce a host-level error.
            // `Err(_)` here means `call_method` itself failed (manifest
            // lookup, runtime construction, etc.) — that's a host bug,
            // not a contract-level revert. Faults inside the script are
            // surfaced as `Ok(ExecutionResult { success: false, .. })`,
            // not `Err`.
            let exec = result.unwrap_or_else(|e| panic!(
                "host-level error from call_method('{}'); inputs n_state_vars={}, n_methods={}, method_name={}; err={:?}\nsource:\n{}",
                spec.name, n_state_vars, n_methods, method_name, e, source
            ));

            if !exec.success {
                // Either no exception populated, or the exception shape
                // is unrecognised. Both cases are bugs (the prompt is
                // explicit about not weakening this gate).
                let exc = exec.exception.as_ref().unwrap_or_else(|| panic!(
                    "method '{}' returned success=false but no exception was populated; inputs n_state_vars={}, n_methods={}, method_name={}\nsource:\n{}",
                    spec.name, n_state_vars, n_methods, method_name, source
                ));
                prop_assert!(
                    is_known_exception_shape(&exc.message),
                    "unexpected exception shape from method '{}': {:?}; inputs n_state_vars={}, n_methods={}, method_name={}; source:\n{}",
                    spec.name, exc.message, n_state_vars, n_methods, method_name, source
                );
            }
        }
    }
}