Test cases
This reference implementation currently defines the following integration test cases.
Test cases are aggregated into the observeTraceTests
variable:
/**
* collection of descriptions of tests that compile+deploy Solidity code,
* then step through the machine trace of that code's execution, watching
* and recording a pointer's value over the course of that trace.
*
* tests are described in terms of an expected sequence of values which the
* list of observed values should contain by the end of the trace, allowing
* for additional unexpected values in between and around the expected values.
*/
export const observeTraceTests = {
"struct storage": structStorageTest,
"string storage": stringStorageTest,
"uint256[] memory": uint256ArrayMemoryTest,
};
This variable will be used to generate automated tests dynamically, as will be described on the next page.
Structs in storage
Solidity tightly packs struct storage words starting from the right-hand side.
This test ensures that relative offsets are computed properly for a struct that
defines a few small fields (struct Record { uint8 x; uint8 y; bytes4 salt; }
).
Test source
const structStorageTest: ObserveTraceTest<{
x: number;
y: number;
salt: string;
}> = {
pointer: findExamplePointer("struct-storage-contract-variable-slot"),
compileOptions: singleSourceCompilation({
path: "StructStorage.sol",
contractName: "StructStorage",
content: `contract StructStorage {
Record record;
uint8 step;
constructor() {
record = Record({
x: 5,
y: 8,
salt: 0xdeadbeef
});
// trick the optimizer maybe (otherwise the first record assignment
// will get optimized out)
//
// compiler might be smarter in the future and cause this test to fail
step = 1;
record = Record({
x: 1,
y: 2,
salt: 0xfeedface
});
step = 2;
}
}
struct Record {
uint8 x;
uint8 y;
bytes4 salt;
}
`
}),
expectedValues: [
{ x: 0, y: 0, salt: "0x" },
{ x: 5, y: 8, salt: "0xdeadbeef" },
{ x: 1, y: 2, salt: "0xfeedface" },
],
async observe({ regions, read }) {
const x = Number(
(await read(regions.lookup["x"])).asUint()
);
const y = Number(
(await read(regions.lookup["y"])).asUint()
);
const salt = (await read(regions.lookup["salt"])).toHex();
return { x, y, salt };
},
equals(a, b) {
return a.x === b.x && a.y === b.y && a.salt === b.salt;
}
};
Tested pointer
# example `struct Record { uint8 x; uint8 y; bytes4 salt; }` in storage
define:
"struct-storage-contract-variable-slot": 0
in:
group:
- name: "x"
location: storage
slot: "struct-storage-contract-variable-slot"
offset:
$difference:
- $wordsize
- .length: $this
length: 1 # uint8
- name: "y"
location: storage
slot: "struct-storage-contract-variable-slot"
offset:
$difference:
- .offset: "x"
- .length: $this
length: 1 # uint8
- name: "salt"
location: storage
slot: "struct-storage-contract-variable-slot"
offset:
$difference:
- .offset: "y"
- .length: $this
length: 4 # bytes4
Storage strings
Representing a Solidity string storage
using an ethdebug/format/pointer
requires the use of conditional logic to identify the one or more regions that
correspond to raw UTF-8 Solidity string data. The dereference()
function
should behave as expected for such a pointer and observe the changing string
value.
Test source
const stringStorageTest: ObserveTraceTest<string> = {
pointer: findExamplePointer("string-storage-contract-variable-slot"),
compileOptions: singleSourceCompilation({
path: "StringStorage.sol",
contractName: "StringStorage",
content: `contract StringStorage {
string storedString;
bool done;
event Done();
constructor() {
storedString = "hello world";
storedString = "solidity storage is a fun lesson in endianness";
done = true;
}
}
`
}),
expectedValues: [
"",
"hello world",
"solidity storage is a fun lesson in endianness"
],
async observe({ regions, read }: Cursor.View): Promise<string> {
// collect all the regions corresponding to string contents
const strings = regions.named("string");
// read each region and concatenate all the bytes
const stringData: Data = Data.zero()
.concat(...await Promise.all(strings.map(read)));
// decode into JS string
return new TextDecoder().decode(stringData);
},
};
Tested pointer
# example `string storage` allocation
define:
"string-storage-contract-variable-slot": 0
in:
group:
# for short strings, the length is stored as 2n in the last byte of slot
- name: "length-flag"
location: storage
slot: "string-storage-contract-variable-slot"
offset:
$difference: [ $wordsize, 1 ]
length: 1
# define the region representing the string data itself conditionally
# based on odd or even length data
- if:
$remainder:
- $sum:
- $read: "length-flag"
- 1
- 2
# short string case (flag is even)
then:
define:
"string-length":
$quotient: [ { $read: "length-flag" }, 2 ]
in:
name: "string"
location: storage
slot: "string-storage-contract-variable-slot"
offset: 0
length: "string-length"
# long string case (flag is odd)
else:
group:
# long strings may use full word to describe length as 2n+1
- name: "long-string-length-data"
location: storage
slot: "string-storage-contract-variable-slot"
offset: 0
length: $wordsize
- define:
"string-length":
$quotient:
- $difference:
- $read: "long-string-length-data"
- 1
- 2
"start-slot":
$keccak256:
- $wordsized: "string-storage-contract-variable-slot"
"total-slots":
# account for both zero and nonzero slot remainders by adding
# $wordsize-1 to the length before dividing
$quotient:
- $sum: [ "string-length", { $difference: [ $wordsize, 1 ] } ]
- $wordsize
in:
list:
count: "total-slots"
each: "i"
is:
define:
"current-slot":
$sum: [ "start-slot", "i" ]
"previous-length":
$product: [ "i", $wordsize ]
in:
# conditional based on whether this is the last slot:
# is the string length longer than the previous length
# plus this whole slot?
if:
$difference:
- "string-length"
- $sum: [ "previous-length", "$wordsize" ]
then:
# include the whole slot
name: "string"
location: storage
slot: "current-slot"
else:
# include only what's left in the string
name: "string"
location: storage
slot: "current-slot"
offset: 0
length:
$difference: [ "string-length", "previous-length" ]
Memory arrays of word-sized items
Memory arrays are primarily referenced using stack-located memory offset values, and so this test case ensures that stack slot indexes are properly adjusted over the course of the transaction.
Test source
const uint256ArrayMemoryTest: ObserveTraceTest<number[]> = {
pointer: findExamplePointer("uint256-array-memory-pointer-slot"),
compileOptions: singleSourceCompilation({
path: "Uint256Arraymemory.sol",
contractName: "Uint256ArrayMemory",
content: `contract Uint256ArrayMemory {
constructor() {
uint256[] memory values = new uint256[](0);
values = appendToArray(values, 1);
values = appendToArray(values, 2);
values = appendToArray(values, 3);
}
function appendToArray(
uint256[] memory arr,
uint256 value
)
private
pure
returns (uint256[] memory)
{
uint256[] memory newArray = new uint256[](arr.length + 1);
for (uint i = 0; i < arr.length; i++) {
newArray[i] = arr[i];
}
newArray[arr.length] = value;
return newArray;
}
}
`
}),
expectedValues: [
[],
[1],
[1, 2],
[1, 2, 3]
],
async observe({ regions, read }, state): Promise<number[]> {
const items = regions.named("array-item");
return (await Promise.all(
items.map(async (item) => {
const data = await read(item);
return Number(data.asUint());
})
));
},
equals(a, b) {
if (a.length !== b.length) {
return false;
}
for (const [index, value] of a.entries()) {
if (b[index] !== value) {
return false;
}
}
return true;
},
// this function uses observation of solc + viaIR behavior to determine
// that the memory array we're looking for is going to have a pointer at
// the bottom of the stack
//
// also include a check to exclude observation when that bottom stack value
// would have `cursor.view()` yield more regions than expected
async shouldObserve(state) {
const stackLength = await state.stack.length;
if (stackLength === 0n) {
return false;
}
// only consider the bottom of the stack
const arrayOffset = await state.stack.peek({ depth: stackLength - 1n });
const arrayCount = await state.memory.read({
slice: {
offset: arrayOffset.asUint(),
length: 32n
}
})
// the example code only appends three times
return arrayCount.asUint() < 4n;
}
};
Tested pointer
# example `uint256[] memory` allocation pointer
define:
"uint256-array-memory-pointer-slot": 0
in:
# this pointer composes an ordered list of other pointers
group:
# declare the first sub-pointer to be the "array-start" region of data
# corresponding to the first item in the stack (at time of observation)
- name: "array-start"
location: stack
slot: "uint256-array-memory-pointer-slot"
# declare the "array-count" region to be at the offset indicated by
# the value at "array-start"
- name: "array-count"
location: memory
offset:
$read: "array-start"
length: $wordsize
# thirdly, declare a sub-pointer that is a dynamic list whose size is
# indicated by the value at "array-count", where each "item-index"
# corresponds to a discrete "array-item" region
- list:
count:
$read: "array-count"
each: "item-index"
is:
name: "array-item"
location: "memory"
offset:
# array items are positioned so that the item with index 0
# immediately follows "array-count", and each subsequent item
# immediately follows the previous.
$sum:
- .offset: "array-count"
- .length: "array-count"
- $product:
- "item-index"
- .length: $this
length: $wordsize