Skip to content

Commit

Permalink
feat: add getVariable function
Browse files Browse the repository at this point in the history
This commit introduces a new function for mock contracts. With
`getVariable` you can view an internal variable's value.
  • Loading branch information
tonykogias committed Jul 5, 2022
1 parent 9cc9a77 commit ebdd00a
Show file tree
Hide file tree
Showing 7 changed files with 537 additions and 2 deletions.
15 changes: 14 additions & 1 deletion docs/source/mocks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,17 @@ Setting the value of multiple variables
[myKey]: 1234
}
})
Getting the value of an internal variable
********************

.. code-block:: typescript
const myUint256 = await myMock.getVariable('myUint256VariableName');
Getting the value of an internal mapping given the value's key
********************

.. code-block:: typescript
const myMappingValue = await myMock.getVariable('myMappingVariableName', mappingKey);
3 changes: 3 additions & 0 deletions src/factories/smock-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import { distinct, filter, map, share, withLatestFrom } from 'rxjs/operators';
import { EditableStorageLogic as EditableStorage } from '../logic/editable-storage-logic';
import { ProgrammableFunctionLogic, SafeProgrammableContract } from '../logic/programmable-function-logic';
import { ReadableStorageLogic as ReadableStorage } from '../logic/readable-storage-logic';
import { ObservableVM } from '../observable-vm';
import { Sandbox } from '../sandbox';
import { ContractCall, FakeContract, MockContractFactory, ProgrammableContractFunction, ProgrammedReturnValue } from '../types';
Expand Down Expand Up @@ -51,8 +52,10 @@ function mockifyContractFactory<T extends ContractFactory>(

// attach to every internal variable, all the editable logic
const editableStorage = new EditableStorage(await getStorageLayout(contractName), vm.getManager(), mock.address);
const readableStorage = new ReadableStorage(await getStorageLayout(contractName), vm.getManager(), mock.address);
mock.setVariable = editableStorage.setVariable.bind(editableStorage);
mock.setVariables = editableStorage.setVariables.bind(editableStorage);
mock.getVariable = readableStorage.getVariable.bind(readableStorage);

// We attach a wallet to the contract so that users can send transactions *from* a watchablecontract.
mock.wallet = await impersonate(mock.address);
Expand Down
37 changes: 37 additions & 0 deletions src/logic/readable-storage-logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { SmockVMManager } from '../types';
import { fromHexString, remove0x, toFancyAddress, toHexString } from '../utils';
import { decodeVariable, getVariableStorageSlots, StorageSlotKeyValuePair } from '../utils/storage';

export class ReadableStorageLogic {
private storageLayout: any;
private contractAddress: string;
private vmManager: SmockVMManager;

constructor(storageLayout: any, vmManager: SmockVMManager, contractAddress: string) {
this.storageLayout = storageLayout;
this.vmManager = vmManager;
this.contractAddress = contractAddress;
}

async getVariable(variableName: string, mappingKey?: string | number): Promise<any> {
const slots = await getVariableStorageSlots(this.storageLayout, variableName, this.vmManager, this.contractAddress, mappingKey);

let slotValueTypePairs: StorageSlotKeyValuePair[] = [];

for (const slotKeyPair of slots) {
slotValueTypePairs = slotValueTypePairs.concat({
value: remove0x(
toHexString(await this.vmManager.getContractStorage(toFancyAddress(this.contractAddress), fromHexString(slotKeyPair.key)))
),
type: slotKeyPair.type,
length: slotKeyPair.length,
label: slotKeyPair.label,
offset: slotKeyPair.offset,
});
}

const result = decodeVariable(slotValueTypePairs);

return result;
}
}
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Provider } from '@ethersproject/abstract-provider';
import { Signer } from '@ethersproject/abstract-signer';
import { BaseContract, ContractFactory, ethers } from 'ethers';
import { EditableStorageLogic } from './logic/editable-storage-logic';
import { ReadableStorageLogic } from './logic/readable-storage-logic';
import { WatchableFunctionLogic } from './logic/watchable-function-logic';

type Abi = ReadonlyArray<
Expand Down Expand Up @@ -71,6 +72,7 @@ export type MockContract<T extends BaseContract = BaseContract> = SmockContractB
connect: (...args: Parameters<T['connect']>) => MockContract<T>;
setVariable: EditableStorageLogic['setVariable'];
setVariables: EditableStorageLogic['setVariables'];
getVariable: ReadableStorageLogic['getVariable'];
} & {
[Property in keyof T['functions']]: ProgrammableContractFunction;
};
Expand Down
272 changes: 271 additions & 1 deletion src/utils/storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { BigNumber, ethers } from 'ethers';
import { artifacts } from 'hardhat';
import semver from 'semver';
import { bigNumberToHex, fromHexString, remove0x } from './hex-utils';
import { SmockVMManager } from '../types';
import { bigNumberToHex, fromHexString, remove0x, toFancyAddress, toHexString } from '../utils';

// Represents the JSON objects outputted by the Solidity compiler that describe the structure of
// state within the contract. See
Expand Down Expand Up @@ -42,6 +43,24 @@ interface StorageSlotPair {
val: string;
}

// This object represents the storage slot that a variable is stored (key)
// and the type of the variable (type).
interface StorageSlotKeyTypePair {
key: string;
type: SolidityStorageType;
length?: number; // used only for bytes type, helps during decoding
label?: string; // used for structs to get the members key
offset?: number; // used when we deal with packed variables
}

export interface StorageSlotKeyValuePair {
value: any;
type: SolidityStorageType;
length?: number; // used only for bytes type, helps during decoding
label?: string; // used for structs to get the members key
offset?: number; // used when we deal with packed variables
}

/**
* Retrieves the storageLayout portion of the compiler artifact for a given contract by name. This
* function is hardhat specific.
Expand Down Expand Up @@ -393,3 +412,254 @@ function encodeVariable(

throw new Error(`unknown unsupported type ${variableType.encoding} ${variableType.label}`);
}

/**
* Computes the slot keys and types of the storage slots that a variable lives
*
* @param storageLayout Solidity storage layout to use as a template for determining storage slots.
* @param variableName Variable name to find against the given storage layout.
* @param vmManager SmockVMManager is used to get certain storage values given a specific slot key and a contract address
* @param contractAddress Contract address to use for vmManager
* @param mappingKey Only used for mappings, represents they key of a mapping value
* @returns An array of storage slot key/type pair that would result in the value of the variable.
*/
export async function getVariableStorageSlots(
storageLayout: SolidityStorageLayout,
variableName: string,
vmManager: SmockVMManager,
contractAddress: string,
mappingKey?: string | number
): Promise<StorageSlotKeyTypePair[]> {
// Find the entry in the storage layout that corresponds to this variable name.
const storageObj = storageLayout.storage.find((entry) => {
return entry.label === variableName;
});

// Complain very loudly if attempting to set a variable that doesn't exist within this layout.
if (!storageObj) {
throw new Error(`Variable name not found in storage layout: ${variableName}`);
}

const storageObjectType: SolidityStorageType = storageLayout.types[storageObj.type];

// Here we will store all the key/type pairs that we need to get the variable's value
let slotKeysTypes: StorageSlotKeyTypePair[] = [];
let key: string =
'0x' +
remove0x(
BigNumber.from(0)
.add(BigNumber.from(parseInt(storageObj.slot, 10)))
.toHexString()
).padStart(64, '0');

if (storageObjectType.encoding === 'inplace') {
// For `inplace` encoding we only need to be aware of structs where they take more slots to store a variable
if (storageObjectType.label.startsWith('struct')) {
if (storageObjectType.members === undefined) {
throw new Error(`There are no members in object type ${storageObjectType}`);
}
// Slot key that represents the struct
slotKeysTypes = slotKeysTypes.concat({
key: key,
type: storageObjectType,
offset: storageObj.offset,
});
// These slots are for the members of the struct
for (let i = 0; i < storageObjectType.members.length; i++) {
// We calculate the slot key for each member
key = '0x' + remove0x(BigNumber.from(key).add(BigNumber.from(storageObjectType.members[i].slot)).toHexString()).padStart(64, '0');
slotKeysTypes = slotKeysTypes.concat({
key: key,
type: storageLayout.types[storageObjectType.members[i].type],
label: storageObjectType.members[i].label,
offset: storageObjectType.members[i].offset,
});
}
} else {
// In cases we deal with other types than structs we already know the slot key and type
slotKeysTypes = slotKeysTypes.concat({
key: key,
type: storageObjectType,
offset: storageObj.offset,
});
}
} else if (storageObjectType.encoding === 'bytes') {
// The last 2 bytes of the slot represent the length of the string/bytes variable
// If it's bigger than 31 then we have to deal with a long string/bytes array
const bytesValue = toHexString(await vmManager.getContractStorage(toFancyAddress(contractAddress), fromHexString(key)));
// It is known that if the last byte is set then we are dealing with a long string
// if it is 0 then we are dealing with a short string, you can find more details here (https://docs.soliditylang.org/en/v0.8.15/internals/layout_in_storage.html#bytes-and-string)
if (bytesValue.slice(-1) === '1') {
// We calculate the total number of slots that this long string/bytes use
const numberOfSlots = Math.ceil((parseInt(bytesValue, 16) - 1) / 32);
// Since we are dealing with bytes, their values are stored contiguous
// we are storing their slotkeys, type and the length which will help us in `decodeVariable`
for (let i = 0; i < numberOfSlots; i++) {
slotKeysTypes = slotKeysTypes.concat({
key: ethers.utils.keccak256(key) + i,
type: storageObjectType,
length: i + 1 <= numberOfSlots ? 32 : (parseInt(bytesValue, 16) - 1) % 32,
});
}
} else {
// If we are dealing with a short string/bytes then we already know the slotkey, type & length
slotKeysTypes = slotKeysTypes.concat({
key: key,
type: storageObjectType,
length: parseInt(bytesValue.slice(-2), 16),
offset: storageObj.offset,
});
}
} else if (storageObjectType.encoding === 'mapping') {
if (storageObjectType.key === undefined || storageObjectType.value === undefined) {
// Should never happen in practice but required to maintain proper typing.
throw new Error(`variable is a mapping but has no key field or has no value field: ${storageObjectType}`);
}

if (mappingKey === undefined) {
// Throw an error if the user didn't provide a mappingKey
throw new Error(`You need to pass a mapping key to get it's value.`);
}

// In order to find the value's storage slot we need to calculate the slot key
// The slot key for a mapping is calculated like `keccak256(h(k) . p)` for more information (https://docs.soliditylang.org/en/v0.8.15/internals/layout_in_storage.html#mappings-and-dynamic-arrays)
// In this part we calculate the `h(k)` where k is the mapping key the user provided and h is a function that is applied to the key depending on its type
let mappKey: string;
if (storageObjectType.key.startsWith('t_uint')) {
mappKey = BigNumber.from(mappingKey).toHexString();
} else if (storageObjectType.key.startsWith('t_bytes')) {
mappKey = '0x' + remove0x(mappingKey as string).padEnd(64, '0');
} else {
// Seems to work for everything else.
mappKey = mappingKey as string;
}

// Since we have `h(k) = mappKey` and `p = key` now we can calculate the slot key
let slotKey = ethers.utils.keccak256(padNumHexSlotValue(mappKey, 0) + remove0x(key));
// As type we have to provide the type of the mapping value has
slotKeysTypes = slotKeysTypes.concat({
key: slotKey,
type: storageLayout.types[storageObjectType.value],
});
} else if (storageObjectType.encoding === 'dynamic_array') {
// We know that the array length is stored in position `key`
let arrayLength = parseInt(toHexString(await vmManager.getContractStorage(toFancyAddress(contractAddress), fromHexString(key))), 16);

// The values of the array are stored in `keccak256(key)` where key is the storage location of the array
key = ethers.utils.keccak256(key);
for (let i = 0; i < arrayLength; i++) {
// Array values are stored contiguous so we need to calculate the new slot keys in each iteration
let slotKey = BigNumber.from(key)
.add(BigNumber.from(i.toString(16)))
.toHexString();
slotKeysTypes = slotKeysTypes.concat({
key: slotKey,
type: storageObjectType,
});
}
}

return slotKeysTypes;
}

/**
* Decodes a single variable from a series of key/value storage slot pairs. Using some storage layout
* as instructions for how to perform this decoding. Works recursively with struct and array types.
* ref: https://docs.soliditylang.org/en/v0.8.4/internals/layout_in_storage.html#layout-of-state-variables-in-storage
*
* @param slotValueTypePairs StorageSlotKeyValuePairs to decode.
* @returns Variable decoded.
*/
export function decodeVariable(slotValueTypePairs: StorageSlotKeyValuePair | StorageSlotKeyValuePair[]): any {
slotValueTypePairs = slotValueTypePairs instanceof Array ? slotValueTypePairs : [slotValueTypePairs];
let result: string | any = '';
const numberOfBytes = parseInt(slotValueTypePairs[0].type.numberOfBytes) * 2;
if (slotValueTypePairs[0].type.encoding === 'inplace') {
if (slotValueTypePairs[0].type.label === 'address' || slotValueTypePairs[0].type.label.startsWith('contract')) {
result = ethers.utils.getAddress('0x' + slotValueTypePairs[0].value.slice(0, numberOfBytes));
} else if (slotValueTypePairs[0].type.label === 'bool') {
result = slotValueTypePairs[0].value.slice(0, numberOfBytes) === '01' ? true : false;
} else if (slotValueTypePairs[0].type.label.startsWith('bytes')) {
result = '0x' + slotValueTypePairs[0].value.slice(0, numberOfBytes);
} else if (slotValueTypePairs[0].type.label.startsWith('uint')) {
let value = slotValueTypePairs[0].value;
if (slotValueTypePairs[0].offset !== 0 && slotValueTypePairs[0].offset !== undefined) {
value = value.slice(-slotValueTypePairs[0].type.numberOfBytes * 2 - slotValueTypePairs[0].offset * 2, -slotValueTypePairs[0].offset * 2);
}
// When we deal with uint we can just return the number
result = BigNumber.from('0x' + value);
} else if (slotValueTypePairs[0].type.label.startsWith('int')) {
// When we deal with signed integers we have to convert the value from signed hex to decimal
// Doesn't work for negative numbers
// TODO: convert 2's complement hex to decimal to make it work properly
result = parseInt(slotValueTypePairs[0].value, 16).toString();
} else if (slotValueTypePairs[0].type.label.startsWith('struct')) {
// We remove the first pair since we only need the members now
slotValueTypePairs.shift();
let structObject = {};
for (const member of slotValueTypePairs) {
if (member.label === undefined) {
// Should never happen in practice but required to maintain proper typing.
throw new Error(`label for ${member} is undefined`);
}

if (member.offset === undefined) {
// Should never happen in practice but required to maintain proper typing.
throw new Error(`offset for ${member} is undefined`);
}

let value;
// If we are dealing with string/bytes we need to decode based on big endian
// otherwise values are stored as little endian so we have to decode based on that
// We use the `offset` and `numberOfBytes` to deal with packed variables
if (member.type.label.startsWith('bytes')) {
value = member.value.slice(member.offset * 2, parseInt(member.type.numberOfBytes) * 2 + member.offset * 2);
} else {
if (member.offset === 0) value = member.value.slice(-member.type.numberOfBytes * 2);
else value = member.value.slice(-member.type.numberOfBytes * 2 - member.offset * 2, -member.offset * 2);
}

structObject = Object.assign(structObject, {
[member.label]: decodeVariable({
value: value,
type: member.type,
} as StorageSlotKeyValuePair),
});
result = structObject;
}
}
} else if (slotValueTypePairs[0].type.encoding === 'bytes') {
for (const slotKeyPair of slotValueTypePairs) {
if (slotKeyPair.length === undefined) {
// Should never happen in practice but required to maintain proper typing.
throw new Error(`length is undefined for bytes: ${slotValueTypePairs[0]}`);
}
if (slotKeyPair.length < 32) {
result = '0x' + result.concat(slotKeyPair.value.slice(0, slotKeyPair.length));
} else {
result = remove0x(result);
result = '0x' + result.concat(slotKeyPair.value.slice(0, 32));
}
}
} else if (slotValueTypePairs[0].type.encoding === 'mapping') {
// Should never happen in practise since mappings are handled based on a certain mapping key
throw new Error(`Error in decodeVariable. Encoding: mapping.`);
} else if (slotValueTypePairs[0].type.encoding === 'dynamic_array') {
let arr: any[] = [];
for (let i = 0; i < slotValueTypePairs.length; i++) {
arr = arr.concat(
decodeVariable({
value: slotValueTypePairs[i].value,
type: {
encoding: 'inplace',
label: slotValueTypePairs[i].type.label,
numberOfBytes: slotValueTypePairs[i].type.numberOfBytes,
},
})
);
}
result = arr;
}

return result;
}
Loading

0 comments on commit ebdd00a

Please sign in to comment.