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
//! Property tests for `constant` / `immutable` state variable semantics and
//! the upgradeable-contract storage-gap pattern.
//!
//! Solidity 0.8.x has three distinct kinds of "non-ordinary" state variables
//! that interact with code generation in subtle ways:
//!
//! * `uint256 constant MAX = 1000;` — value is baked into the bytecode at
//! compile time. No storage slot is allocated. References to `MAX`
//! should compile down to literal pushes (or post-folded constants),
//! not SLOAD instructions. There is no auto-getter unless declared
//! `public`, and even when public the getter inlines the literal.
//!
//! * `uint256 public immutable seed;` — value is set exactly once at
//! deployment time (declaration initializer or constructor body) and
//! thereafter is read-only. Functionally a one-time-write storage cell;
//! the public auto-getter returns the stored value. The compiler must
//! reject any non-constructor write ("Cannot write to immutable here").
//!
//! * `uint256[N] private __gap;` — a fixed-size array reserving N slots,
//! conventionally used by upgradeable contracts to leave room for
//! future state variables in subclasses without disturbing slot
//! layout. The gap is typically `private` so it has no auto-getter
//! and no manifest entry; it's purely a layout placeholder.
//!
//! Each subtype has under-tested corners — this module pins the observable
//! semantics with four proptests:
//!
//! a. `constant_state_var_inlined` — a `uint256 constant MAX = 1000` is
//! consumed inside `f() returns (uint256) { return MAX * 2; }`. The
//! callable `f()` returns 2000 and the manifest does NOT advertise
//! `MAX` as an external getter (state-var compile-folding regression).
//!
//! b. `immutable_state_var_set_in_ctor` — a fuzzed `_s` is threaded into
//! `constructor(uint256 _s) { seed = _s; }`. The public auto-getter
//! `seed()` is read post-deploy and asserted equal to the fuzzed
//! value (immutable-storage write regression / one-time-write
//! semantics regression).
//!
//! c. `immutable_only_settable_in_ctor` — a contract that initializes
//! `a = 7` from the constructor compiles cleanly. A second variant
//! adds `function setA(uint256 v) external { a = v; }`, which the
//! compiler MUST reject (Solidity spec §"Constant and Immutable State
//! Variables"). This pins the negative side of the immutable
//! semantics — without it, an immutable would silently degrade to
//! a regular storage cell.
//!
//! d. `storage_gap_pattern_compiles` — `contract C { uint256 public
//! counter; uint256[50] private __gap; uint256 public other; }`
//! compiles, the two public slots are independently writable, and
//! `__gap` does NOT appear in the manifest as a method (private +
//! array; no auto-getter). Drives random sequences of writes to
//! `counter` / `other` and asserts the values stay independent
//! across the 50-slot gap.
#![allow(unused_imports)]
use super::common::*;
use neo_devpack_solidity::cli::compile_contracts;
use neo_devpack_solidity::runtime::types::StackItem;
use neo_devpack_solidity::runtime::{NeoRuntime, RuntimeConfig};
use num_bigint::BigUint;
use proptest::prelude::*;
proptest! {
// 8 cases per test — each compiles a small contract and runs a few
// method calls. Bound at <100ms/case.
#![proptest_config(ProptestConfig::with_cases(8))]
/// **a. `uint256 constant MAX` inlined into a callable function.**
///
/// `MAX` is a compile-time constant. Solidity 0.8.x bakes its value
/// directly into the bytecode at every reference site, so `f()` should
/// compile to (effectively) `return 2000;` and the manifest should not
/// advertise `MAX` as a getter (constants do not synthesize an external
/// getter even when `public`-qualified — the spec defers that to a
/// literal-returning function, not a state-var getter).
///
/// Failure modes (each maps to a real bug):
/// - f() returns 0: constant inlining failed and a SLOAD-on-empty-slot
/// ran instead — the const folder did not substitute the literal at
/// the reference site.
/// - f() returns a non-2000 value: the constant binding leaked across
/// scopes (rare, but a deopt would surface here).
/// - manifest shows a "MAX" method with returntype Integer: the
/// compiler emitted a state-var entry for a constant, suggesting
/// a slot was allocated to it (storage-layout regression).
#[test]
fn constant_state_var_inlined(_seed in any::<u8>()) {
let src = r#"// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract C {
uint256 constant MAX = 1000;
function f() external pure returns (uint256) { return MAX * 2; }
}"#;
let arts = compile_contracts(src, false, 2)
.unwrap_or_else(|e| panic!("constant_state_var_inlined compile: {:?}", e));
prop_assert!(!arts.is_empty(), "constant_state_var_inlined produced no artifacts");
let art = &arts[0];
let mut rt = NeoRuntime::new(RuntimeConfig::default()).expect("rt");
// f() must return MAX * 2 = 2000.
let r = rt.call_method(
&art.bytecode, &art.tokens, &art.manifest, "f", &[],
).expect("f() host-level (a)");
prop_assert!(r.success,
"f() must succeed; exc={:?}. If this is a 'PICKITEM-Null' or \
SLOAD-on-empty fault, the constant was NOT inlined and the \
compiler emitted a storage read for a value that should have \
been baked into the bytecode at compile time.",
r.exception.as_ref().map(|e| &e.message));
let got = decode_uint_le(&r.return_data);
prop_assert_eq!(got.clone(), BigUint::from(2000u64),
"f() must return MAX * 2 = 2000; got {} (rd_hex={}). A 0 \
readback indicates constant-folding regression: `MAX` was \
read from an unwritten storage slot instead of inlined as \
the literal 1000.",
got, hex::encode(&r.return_data));
// Manifest sanity: `f` must be there as an external method.
let methods = art.manifest["abi"]["methods"]
.as_array()
.expect("abi.methods array");
let f_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("f")
});
prop_assert!(f_present,
"f() must appear in manifest abi.methods; got methods={:?}",
methods);
// Manifest negative: `MAX` (the constant) MUST NOT appear as a
// state-var getter. Constants do not allocate a slot and do not
// synthesize an auto-getter under Solidity 0.8.x semantics.
let max_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("MAX")
|| m.get("name").and_then(serde_json::Value::as_str) == Some("max")
});
prop_assert!(!max_present,
"constant `MAX` must NOT appear as a manifest state-var \
getter — constants are compile-time literals with no \
storage slot; got methods={:?}. If a 'MAX' method is \
present, the storage-layout pass allocated a slot for a \
constant, which is a regression.",
methods);
}
/// **b. `uint256 public immutable seed;` set in constructor — round-trip.**
///
/// The constructor takes `_s` and writes `seed = _s`. The public
/// auto-getter `seed()` should return the stored value. Each fuzzed
/// `_s` exercises a different storage write path: small values stay
/// inside one limb, large values span multiple LE bytes — both must
/// round-trip.
///
/// Failure modes:
/// - seed() returns 0: the immutable write was dropped (constructor
/// body never executed it, OR the value was written to the wrong
/// slot, OR the auto-getter reads a different slot than the
/// constructor wrote).
/// - seed() returns the correct lower bits but wrong upper bits:
/// truncation in the immutable encode/decode path.
#[test]
fn immutable_state_var_set_in_ctor(s in 0u64..=u32::MAX as u64) {
let src = r#"// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract C {
uint256 public immutable seed;
constructor(uint256 _s) { seed = _s; }
function get() external view returns (uint256) { return seed; }
}"#;
let arts = compile_contracts(src, false, 2)
.unwrap_or_else(|e| panic!("immutable_state_var_set_in_ctor compile: {:?}", e));
prop_assert!(!arts.is_empty(), "immutable_state_var_set_in_ctor produced no artifacts");
let art = &arts[0];
let mut rt = NeoRuntime::new(RuntimeConfig::default()).expect("rt");
// Deploy with the fuzzed _s, then read via the explicit `get()`.
let r = rt.call_method_with_deploy_args(
&art.bytecode, &art.tokens, &art.manifest,
"get", &[] as &[StackItem],
Some(&[StackItem::UnsignedInteger(s)]),
).expect("get() with deploy_args host-level (b)");
prop_assert!(r.success,
"get() post-immutable-deploy must succeed for _s={}; \
exc={:?}. A PICKITEM-Null fault here indicates the \
immutable write was dropped from the deploy prologue.",
s, r.exception.as_ref().map(|e| &e.message));
let got = decode_uint_le(&r.return_data);
prop_assert_eq!(got.clone(), BigUint::from(s),
"get() must equal the constructor _s ({}); got {} \
(rd_hex={}). Mismatch = immutable storage regression: the \
ctor wrote one slot and the auto-getter reads another, \
OR the ctor body didn't run, OR the immutable's value \
was truncated on the way out of `_deploy`.",
s, got, hex::encode(&r.return_data));
// Also exercise the public auto-getter `seed()` — for a public
// immutable, the manifest should expose it under the variable's
// own name. This guards against a regression where only the
// explicit `get()` works but the auto-getter is missing.
let methods = art.manifest["abi"]["methods"]
.as_array()
.expect("abi.methods array");
let seed_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("seed")
});
prop_assert!(seed_present,
"public immutable `seed` must surface an auto-getter \
named `seed` in the manifest; got methods={:?}. If \
missing, the public-auto-getter pass skipped the \
immutable, which is a regression: immutables ARE eligible \
for auto-getters (per Solidity spec §State-Variable-Visibility).",
methods);
}
/// **c. Immutables only writable inside the constructor.**
///
/// Half-test: a clean variant (immutable initialised in ctor only)
/// must compile. The other half: a variant that adds an external
/// `setA(uint256 v) external { a = v; }` writing to the immutable
/// from a non-constructor context MUST be rejected by the front-end
/// per Solidity's spec ("Cannot write to immutable here: Immutable
/// variables can only be initialized inline or assigned directly in
/// the constructor"). Without this rejection, an immutable silently
/// degrades to a regular storage cell — a serious semantics bug.
#[test]
fn immutable_only_settable_in_ctor(_seed in any::<u8>()) {
// Variant 1: clean — ctor-only assignment must compile.
let src_ok = r#"// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract C {
uint256 public immutable a;
constructor() { a = 7; }
}"#;
let res_ok = compile_contracts(src_ok, false, 2);
prop_assert!(res_ok.is_ok(),
"ctor-only immutable assignment must compile cleanly; \
got error: {:?}. If this rejects, the front-end is \
over-eager — immutable IS settable in the constructor \
body per spec.",
res_ok.err());
// Variant 2: invalid — assignment to immutable from a
// non-constructor function MUST be rejected.
let src_bad = r#"// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract C {
uint256 public immutable a;
constructor() { a = 7; }
function setA(uint256 v) external { a = v; }
}"#;
let res_bad = compile_contracts(src_bad, false, 2);
prop_assert!(res_bad.is_err(),
"writing to an immutable from a non-constructor function \
MUST be a compile error per Solidity spec (§Constant and \
Immutable State Variables: 'Immutable variables can only \
be initialized inline or assigned directly in the \
constructor'). The compiler accepted it — immutables are \
silently degraded to regular storage cells, which is a \
serious semantics regression. src=\n{}", src_bad);
}
/// **d. Storage-gap pattern compiles and slots stay independent.**
///
/// `uint256[50] private __gap;` is the upgradeable-contract idiom
/// for reserving 50 slots between two state-vars so future
/// subclasses can add fields without disturbing the layout. The
/// pattern requires:
///
/// 1. The contract compiles.
/// 2. The 50 reserved slots do NOT collide with `counter` or
/// `other` — a write to either auto-getter must round-trip
/// independently. (Slot collision would produce read-after-
/// write divergence on either side of the gap.)
/// 3. `__gap` is `private` and an array, so no auto-getter is
/// synthesized — the manifest abi.methods MUST NOT contain a
/// `__gap` entry.
#[test]
fn storage_gap_pattern_compiles(
c in 1u64..=1_000_000u64,
o in 1u64..=1_000_000u64,
) {
let src = r#"// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract C {
uint256 public counter;
uint256[50] private __gap;
uint256 public other;
function setCounter(uint256 v) external { counter = v; }
function setOther(uint256 v) external { other = v; }
}"#;
let arts = compile_contracts(src, false, 2)
.unwrap_or_else(|e| panic!("storage_gap_pattern_compiles compile: {:?}", e));
prop_assert!(!arts.is_empty(),
"storage_gap_pattern_compiles produced no artifacts");
let art = &arts[0];
// Manifest negative: `__gap` MUST NOT appear as a method. It's
// a private uint256[50]; auto-getters are emitted only for
// public state vars, and even when public, dynamic-array
// getters take an index — a fixed-size private array gets no
// method at all.
let methods = art.manifest["abi"]["methods"]
.as_array()
.expect("abi.methods array");
let gap_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("__gap")
});
prop_assert!(!gap_present,
"private `__gap` MUST NOT surface in manifest abi.methods \
— it has no public auto-getter; got methods={:?}. If \
present, the storage-layout pass leaked a private slot \
into the external ABI surface, a visibility regression.",
methods);
// counter() and other() must be present.
let counter_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("counter")
});
let other_present = methods.iter().any(|m| {
m.get("name").and_then(serde_json::Value::as_str) == Some("other")
});
prop_assert!(counter_present && other_present,
"public `counter` and `other` must surface auto-getters; \
counter_present={}, other_present={}. methods={:?}",
counter_present, other_present, methods);
// Drive a fuzzed sequence: write counter, write other, read
// both back. A slot collision through the 50-slot gap would
// surface as one write clobbering the other.
let mut rt = NeoRuntime::new(RuntimeConfig::default()).expect("rt");
let r1 = rt.call_method(
&art.bytecode, &art.tokens, &art.manifest,
"setCounter", &[StackItem::UnsignedInteger(c)],
).expect("setCounter host-level (d)");
prop_assert!(r1.success,
"setCounter({}) must succeed; exc={:?}.",
c, r1.exception.as_ref().map(|e| &e.message));
let r2 = rt.call_method(
&art.bytecode, &art.tokens, &art.manifest,
"setOther", &[StackItem::UnsignedInteger(o)],
).expect("setOther host-level (d)");
prop_assert!(r2.success,
"setOther({}) must succeed; exc={:?}.",
o, r2.exception.as_ref().map(|e| &e.message));
// Read counter — must equal `c`. If the gap collided with
// counter's slot, this would observe a different value.
let r_c = rt.call_method(
&art.bytecode, &art.tokens, &art.manifest, "counter", &[],
).expect("counter() host-level (d)");
prop_assert!(r_c.success,
"counter() must succeed; exc={:?}.",
r_c.exception.as_ref().map(|e| &e.message));
let got_c = decode_uint_le(&r_c.return_data);
prop_assert_eq!(got_c.clone(), BigUint::from(c),
"counter() must equal the last write {}; got {} \
(rd_hex={}). Mismatch = the 50-slot __gap collided with \
counter's slot, OR setCounter wrote a different slot than \
counter() reads — storage-layout regression around the \
fixed-size-array gap.",
c, got_c, hex::encode(&r_c.return_data));
// Read other — must equal `o`. If the gap collided with
// other's slot (post-gap), THIS is where it surfaces.
let r_o = rt.call_method(
&art.bytecode, &art.tokens, &art.manifest, "other", &[],
).expect("other() host-level (d)");
prop_assert!(r_o.success,
"other() must succeed; exc={:?}.",
r_o.exception.as_ref().map(|e| &e.message));
let got_o = decode_uint_le(&r_o.return_data);
prop_assert_eq!(got_o.clone(), BigUint::from(o),
"other() must equal the last write {}; got {} \
(rd_hex={}). Mismatch = setOther wrote into the gap \
instead of past it (slot index miscalculated as N+0 \
instead of N+50), OR the gap's 50-slot stride was \
miscounted in the storage-layout pass.",
o, got_o, hex::encode(&r_o.return_data));
}
}