Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit 181613c

Browse files
committed
feat(rpc): add rpc connection
1 parent ba0abd7 commit 181613c

10 files changed

Lines changed: 599 additions & 175 deletions

File tree

.github/workflows/foundry-storage-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ jobs:
2828
version: nightly
2929

3030
- name: Check storage layout
31-
uses: Rubilmax/foundry-storage-check@v2.1
31+
uses: Rubilmax/foundry-storage-check@v3
3232
with:
3333
contract: contracts/Example.sol:Example

README.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,11 @@
44

55
## Live Example
66

7-
Checkout [PR #21](/pulls/21) for a live example:
7+
Check out [PR #21](/pulls/21) for a live example:
88

99
- Action is ran on [contracts/Example.sol:Example](./contracts/Example.sol)
1010
- Warnings & errors appear on the [Pull Request changes](https://github.com/Rubilmax/foundry-storage-check/pull/21/files)
1111

12-
## How it works
13-
14-
Everytime somebody opens a Pull Request, the action runs [Foundry](https://github.com/foundry-rs/foundry) `forge` to generate the storage layout of the Smart Contract you want to check.
15-
16-
Once generated, the action will fetch the comparative storage layout stored as an artifact from previous runs; parse & compare them, pinning warnings and errors on the Pull Request.
17-
1812
## Getting started
1913

2014
### Automatically generate & compare to the previous storage layout on every PR
@@ -52,15 +46,35 @@ jobs:
5246
version: nightly
5347

5448
- name: Check storage layout
55-
uses: Rubilmax/foundry-storage-check@v2.1
49+
uses: Rubilmax/foundry-storage-check@v3
5650
with:
5751
contract: src/Contract.sol:Contract
52+
# settings below are optional, but allows to check whether the added storage slots are empty on the deployed contract
53+
rpcUrl: wss://eth-mainnet.g.alchemy.com/v2/<YOUR_ALCHEMY_KEY> # the RPC url to use to query the deployed contract's storage slots
54+
address: 0x0000000000000000000000000000000000000000 # the address at which the contract check is deployed
55+
failOnRemoval: true # fail the CI when removing storage slots (default: false)
5856
```
5957
6058
> :information_source: **An error will appear at first run!**<br/>
6159
> 🔴 <em>**Error:** No workflow run found with an artifact named "..."</em><br/>
6260
> As the action is expecting a comparative file stored on the base branch and cannot find it (because the action never ran on the target branch and thus has never uploaded any storage layout)
6361
62+
---
63+
64+
## How it works
65+
66+
Everytime somebody opens a Pull Request, the action runs [Foundry](https://github.com/foundry-rs/foundry) `forge` to generate the storage layout of the Smart Contract you want to check.
67+
68+
Once generated, the action will fetch the comparative storage layout stored as an artifact from previous runs and compare them, to perform a series of checks at each storage byte, and raise a notice accordingly:
69+
70+
- Variable changed: `error`
71+
- Type definition changed: `error`
72+
- Type definition removed: `warning`
73+
- Different variable naming: `warning`
74+
- Variable removed (optional): `error`
75+
76+
---
77+
6478
## Options
6579

6680
### `contract` _{string}_
@@ -69,6 +83,20 @@ The path and name of the contract of which to inspect storage layout (e.g. src/C
6983

7084
_Required_
7185

86+
### `address` _{string}_
87+
88+
The address at which the contract is deployed on the EVM-compatible chain queried via `rpcUrl`.
89+
90+
### `rpcUrl` _{string}_
91+
92+
The HTTP/WS url used to query the EVM-compatible chain for storage slots to check for clashing.
93+
94+
### `failOnRemoval` _{string}_
95+
96+
Whether to fail the CI when removing a storage slot (to only allow added or renamed storage slots).
97+
98+
_Defaults to: `false`_
99+
72100
### `base` _{string}_
73101

74102
The gas diff reference branch name, used to fetch the previous gas report to compare the freshly generated gas report to.

action.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ inputs:
99
contract:
1010
description: The path and name of the contract of which to inspect storage layout (e.g. src/Contract.sol:Contract).
1111
required: true
12+
address:
13+
description: The address at which the contract is deployed on the EVM-compatible chain queried via rpcUrl.
14+
required: false
15+
rpcUrl:
16+
description: The HTTP/WS url used to query the EVM-compatible chain for storage slots to check for clashing.
17+
required: false
18+
failOnRemoval:
19+
description: Whether to fail the CI when removing a storage slot (to only allow added or renamed storage slots).
20+
required: false
21+
default: false
1222
token:
1323
description: The repository's github token.
1424
default: ${{ github.token }}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "foundry-storage-check",
3-
"version": "2.1.0",
3+
"version": "3.0.0",
44
"description": "Github Action checking the storage layout diff from Foundry storage layout reports",
55
"author": {
66
"name": "Romain (Rubilmax) Milon",
@@ -43,9 +43,11 @@
4343
"@actions/artifact": "^1.1.0",
4444
"@actions/core": "^1.10.0",
4545
"@actions/github": "^5.1.1",
46+
"@ethersproject/providers": "^5.7.2",
47+
"@ethersproject/solidity": "^5.7.0",
4648
"@octokit/core": "^4.1.0",
4749
"@solidity-parser/parser": "^0.14.5",
48-
"keccak256": "^1.0.6",
50+
"js-sha3": "^0.8.0",
4951
"lodash": "^4.17.21"
5052
},
5153
"devDependencies": {

src/check.ts

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import keccak256 from "keccak256";
21
import _isEqual from "lodash/isEqual";
32
import _range from "lodash/range";
3+
import _sortBy from "lodash/sortBy";
44
import _uniqWith from "lodash/uniqWith";
55

6+
import { Provider } from "@ethersproject/providers";
7+
import { keccak256 } from "@ethersproject/solidity";
8+
69
import {
10+
StorageLayoutDiffAdded,
711
StorageLayoutDiff,
812
StorageLayoutDiffType,
913
StorageLayoutReportExact,
1014
StorageVariableDetails,
1115
StorageVariableExact,
16+
StorageLayoutDiffAddedNonZeroSlot,
1217
} from "./types";
1318

1419
export const STORAGE_WORD_SIZE = 32n;
15-
export const ZERO_BYTE = "".padStart(64, "0");
1620

1721
interface StorageBytesMapping {
1822
[byte: string]: StorageVariableDetails;
@@ -29,7 +33,7 @@ const getStorageVariableBytesMapping = (
2933
let example = {};
3034
switch (varType.encoding) {
3135
case "dynamic_array":
32-
slot = BigInt("0x" + keccak256("0x" + variable.slot.toString(16)).toString("hex")); // slot of the element at index 0
36+
slot = BigInt(keccak256(["uint256"], [variable.slot])); // slot of the element at index 0
3337
example = getStorageVariableBytesMapping(
3438
layout,
3539
{
@@ -39,13 +43,11 @@ const getStorageVariableBytesMapping = (
3943
type: varType.base!,
4044
label: variable.label.replace("[]", "[0]"),
4145
},
42-
startByte + slot * STORAGE_WORD_SIZE
46+
slot * STORAGE_WORD_SIZE
4347
);
4448
break;
4549
case "mapping":
46-
slot = BigInt(
47-
"0x" + keccak256("0x" + ZERO_BYTE + variable.slot.toString(16)).toString("hex")
48-
); // slot of the element at key 0
50+
slot = BigInt(keccak256(["uint256", "uint256"], [0, variable.slot])); // slot of the element at key 0
4951
example = getStorageVariableBytesMapping(
5052
layout,
5153
{
@@ -55,7 +57,7 @@ const getStorageVariableBytesMapping = (
5557
type: varType.value!,
5658
label: `${variable.label}[0]`,
5759
},
58-
startByte + slot * STORAGE_WORD_SIZE
60+
slot * STORAGE_WORD_SIZE
5961
);
6062
break;
6163
default:
@@ -65,7 +67,7 @@ const getStorageVariableBytesMapping = (
6567
const details: StorageVariableDetails = {
6668
...variable,
6769
fullLabel: variable.parent
68-
? `(${variable.parent.typeLabel})${variable.parent.label}.${variable.label}`
70+
? `(${variable.parent.typeLabel} ${variable.parent.label}).${variable.label}`
6971
: variable.label,
7072
typeLabel: varType.label.replace(/struct /, ""),
7173
startByte,
@@ -112,11 +114,17 @@ const getStorageBytesMapping = (layout: StorageLayoutReportExact): StorageBytesM
112114
{}
113115
);
114116

115-
export const checkLayouts = (
117+
export const checkLayouts = async (
116118
srcLayout: StorageLayoutReportExact,
117-
cmpLayout: StorageLayoutReportExact
118-
): StorageLayoutDiff[] => {
119+
cmpLayout: StorageLayoutReportExact,
120+
{
121+
address,
122+
provider,
123+
checkRemovals,
124+
}: { address?: string; provider?: Provider; checkRemovals?: boolean } = {}
125+
): Promise<StorageLayoutDiff[]> => {
119126
const diffs: StorageLayoutDiff[] = [];
127+
const added: StorageLayoutDiffAdded[] = [];
120128

121129
const srcMapping = getStorageBytesMapping(srcLayout);
122130
const cmpMapping = getStorageBytesMapping(cmpLayout);
@@ -125,7 +133,21 @@ export const checkLayouts = (
125133
const srcSlotVar = srcMapping[byte];
126134
const cmpSlotVar = cmpMapping[byte];
127135

128-
if (!srcSlotVar) continue; // source byte was unused
136+
const byteIndex = BigInt(byte);
137+
const location = {
138+
slot: byteIndex / STORAGE_WORD_SIZE,
139+
offset: byteIndex % STORAGE_WORD_SIZE,
140+
};
141+
142+
if (!srcSlotVar) {
143+
added.push({
144+
location,
145+
cmp: cmpSlotVar,
146+
});
147+
148+
continue; // source byte was unused
149+
}
150+
129151
if (
130152
cmpSlotVar.type === srcSlotVar.type &&
131153
cmpSlotVar.fullLabel === srcSlotVar.fullLabel &&
@@ -134,18 +156,30 @@ export const checkLayouts = (
134156
cmpSlotVar.startByte === srcSlotVar.startByte
135157
)
136158
continue; // variable did not change
137-
if (srcSlotVar.label === "__gap" || cmpSlotVar.label === "__gap") continue; // source byte was part of a gap slot or is replaced with a gap slot
159+
160+
if (srcSlotVar.label === "__gap" || cmpSlotVar.label === "__gap") {
161+
added.push({
162+
location,
163+
cmp: cmpSlotVar,
164+
});
165+
166+
continue; // source byte was part of a gap slot or is replaced with a gap slot
167+
}
138168

139169
if (cmpSlotVar.fullLabel !== srcSlotVar.fullLabel) {
140-
if (cmpSlotVar.fullLabel.startsWith(`(${srcSlotVar.typeLabel})${srcSlotVar.label}`)) continue; // variable is a member of source struct, in empty bytes
170+
if (cmpSlotVar.fullLabel.startsWith(`(${srcSlotVar.typeLabel} ${srcSlotVar.label})`)) {
171+
added.push({
172+
location,
173+
cmp: cmpSlotVar,
174+
});
175+
176+
continue; // variable is a member of source struct, in empty bytes
177+
}
141178

142179
if (cmpSlotVar.type === srcSlotVar.type) {
143180
if (cmpSlotVar.label !== srcSlotVar.label)
144181
diffs.push({
145-
location: {
146-
slot: srcSlotVar.slot,
147-
offset: srcSlotVar.offset,
148-
},
182+
location,
149183
type: StorageLayoutDiffType.LABEL,
150184
src: srcSlotVar,
151185
cmp: cmpSlotVar,
@@ -155,10 +189,7 @@ export const checkLayouts = (
155189
}
156190

157191
diffs.push({
158-
location: {
159-
slot: srcSlotVar.slot,
160-
offset: srcSlotVar.offset,
161-
},
192+
location,
162193
type: StorageLayoutDiffType.VARIABLE,
163194
src: srcSlotVar,
164195
cmp: cmpSlotVar,
@@ -202,10 +233,7 @@ export const checkLayouts = (
202233
}
203234

204235
diffs.push({
205-
location: {
206-
slot: srcSlotVar.slot,
207-
offset: srcSlotVar.offset,
208-
},
236+
location,
209237
type: StorageLayoutDiffType.VARIABLE_TYPE,
210238
src: srcSlotVar,
211239
cmp: cmpSlotVar,
@@ -215,5 +243,64 @@ export const checkLayouts = (
215243
}
216244
}
217245

218-
return _uniqWith(diffs, _isEqual);
246+
if (checkRemovals) {
247+
for (const byte of Object.keys(srcMapping)) {
248+
const srcSlotVar = srcMapping[byte];
249+
const cmpSlotVar = cmpMapping[byte];
250+
251+
const byteIndex = BigInt(byte);
252+
const location = {
253+
slot: byteIndex / STORAGE_WORD_SIZE,
254+
offset: byteIndex % STORAGE_WORD_SIZE,
255+
};
256+
257+
if (!cmpSlotVar)
258+
diffs.push({
259+
location,
260+
type: StorageLayoutDiffType.VARIABLE_REMOVED,
261+
src: srcSlotVar,
262+
});
263+
}
264+
}
265+
266+
return _uniqWith(
267+
_sortBy(diffs, ["location.slot", "location.offset"]), // make sure it's ordered by storage byte order
268+
({ location: location1, ...diff1 }, { location: location2, ...diff2 }) => _isEqual(diff1, diff2) // only keep first byte diff of a variable, which corresponds to the start byte
269+
).concat(address && provider ? await checkAddedStorageSlots(added, address, provider) : []);
270+
};
271+
272+
const checkAddedStorageSlots = async (
273+
added: StorageLayoutDiffAdded[],
274+
address: string,
275+
provider: Provider
276+
): Promise<StorageLayoutDiffAddedNonZeroSlot[]> => {
277+
const storage: { [slot: string]: string } = {};
278+
const diffs: StorageLayoutDiffAddedNonZeroSlot[] = [];
279+
280+
for (const diff of _sortBy(added, ["location.slot", "location.offset"])) {
281+
const slot = diff.location.slot.toString();
282+
283+
const memoized = storage[slot];
284+
let value = memoized ?? (await provider.getStorageAt(address, slot));
285+
if (!memoized) storage[slot] = value;
286+
287+
const byteIndex = value.length - Number((diff.location.offset + 1n) * 2n);
288+
value = value.substring(byteIndex, byteIndex + 2);
289+
290+
if (value === "00") continue;
291+
292+
diffs.push({
293+
...diff,
294+
type: StorageLayoutDiffType.NON_ZERO_ADDED_SLOT,
295+
value,
296+
});
297+
}
298+
299+
return _uniqWith(
300+
diffs,
301+
(
302+
{ location: location1, value: value1, ...diff1 },
303+
{ location: location2, value: value2, ...diff2 }
304+
) => _isEqual(diff1, diff2) // only keep first byte diff of a variable, which corresponds to the start byte
305+
);
219306
};

0 commit comments

Comments
 (0)