@prajwalghate/sourcetruth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/GUIDE.md +255 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/sourcetruth.mjs +188 -0
- package/examples/daml-lending/daml.yaml +7 -0
- package/examples/daml-lending/src/Lending.daml +92 -0
- package/examples/solidity-vault/foundry.toml +2 -0
- package/examples/solidity-vault/src/Strategy.sol +52 -0
- package/examples/solidity-vault/src/Vault.sol +67 -0
- package/package.json +20 -0
- package/src/adapters/daml.mjs +639 -0
- package/src/adapters/evm.mjs +1166 -0
- package/src/client/app.css +429 -0
- package/src/client/app.js +1074 -0
- package/src/layout.mjs +145 -0
- package/src/model.mjs +157 -0
- package/src/report.mjs +328 -0
- package/src/view.mjs +357 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
// Solidity / EVM adapter. Reads .sol source and emits the same neutral model as the Daml adapter.
|
|
2
|
+
//
|
|
3
|
+
// The model was written before this file existed, precisely so this adapter would have to FIT it
|
|
4
|
+
// rather than redefine it.
|
|
5
|
+
//
|
|
6
|
+
// neutral Solidity
|
|
7
|
+
// --------- ------------------------------------------------------------------
|
|
8
|
+
// Unit contract / abstract contract / interface / library
|
|
9
|
+
// Entry external or public function (internal/private are not entry points)
|
|
10
|
+
// authority an access check that ALWAYS runs: a modifier that checks the caller, or a top-level
|
|
11
|
+
// require/if-revert on msg.sender — in the function or in a helper it calls
|
|
12
|
+
// CREATE new C(...)
|
|
13
|
+
// CALL any call into another contract: typed variable, cast, library, `this.`, low-level
|
|
14
|
+
// DESTROY selfdestruct(...)
|
|
15
|
+
// Guard require(...), revert ..., assert(...), and modifiers that are not access checks
|
|
16
|
+
// Hole a call whose target cannot be named from source — never dropped, never guessed
|
|
17
|
+
//
|
|
18
|
+
// THE AUTHORITY INVERSION, and it matters for how you read the surface: in Daml a choice always
|
|
19
|
+
// declares a controller, so the risk signal is ONE principal acting alone. In Solidity the default
|
|
20
|
+
// is NO restriction — a function with no access check is callable by anyone. So an EMPTY authority
|
|
21
|
+
// list here is strictly more open than a one-element list, not less.
|
|
22
|
+
//
|
|
23
|
+
// HOW CALLS ARE FOUND. The first version recognised four shapes (selfdestruct, `new`, low-level
|
|
24
|
+
// `.call`, and casts like `IERC20(x).transfer()`) and silently ignored everything else — including
|
|
25
|
+
// the most common Solidity call there is, a method on a typed variable: `token.safeTransfer(...)`.
|
|
26
|
+
// Those were not holes; they were invisible, and "99% traced" measured only the calls it happened
|
|
27
|
+
// to see. Now every `name(` in a body is visited and classified: an edge with a named target, a
|
|
28
|
+
// hole, an internal call, or something that is not a call (a cast, an event, a builtin). A call that
|
|
29
|
+
// cannot be classified becomes a hole. There is no fifth outcome.
|
|
30
|
+
//
|
|
31
|
+
// HOW AUTHORITY IS FOUND. Checks are only credited when they run on every path. A real vault guards
|
|
32
|
+
// its admin functions by calling `onlyManager();`, a plain function two contracts up the inheritance
|
|
33
|
+
// chain — reading only modifiers showed eleven of them as callable by anyone. So top-level calls
|
|
34
|
+
// into helpers are followed, through inheritance and into vendored libraries. A check inside an
|
|
35
|
+
// `if` is NOT credited: claiming a guard that might not run hides an open function, which is the
|
|
36
|
+
// one mistake an access map must not make.
|
|
37
|
+
//
|
|
38
|
+
// Comments are stripped before anything is parsed, as in Daml, and string contents are blanked
|
|
39
|
+
// before any scan, so neither can be mistaken for code.
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
import { EDGE, EFFECT, edge, entry, unit, model } from "../model.mjs";
|
|
44
|
+
|
|
45
|
+
const SKIP_DIRS = new Set([
|
|
46
|
+
"node_modules", ".git", "out", "cache", "artifacts", "build", "dist", "broadcast", "coverage",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tests and deploy scripts are excluded by default, for the same reason `lib/` is. A Foundry test
|
|
51
|
+
* contract is public, unguarded and never deployed — so counted as code it tops the list of what
|
|
52
|
+
* ANYONE can call, and on real vaults it was more than half the files.
|
|
53
|
+
*/
|
|
54
|
+
// Liquity-style repos keep harnesses in `TestContracts/`; many keep doubles in `mocks/`.
|
|
55
|
+
const TEST_DIR = /^(tests?|scripts?|test[-_]?contracts?|mocks?)$/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find .sol sources. `lib/` is INCLUDED only when asked: a Foundry project vendors OpenZeppelin
|
|
59
|
+
* there, and reporting a dependency's contracts as the project's own is the same error as parsing
|
|
60
|
+
* Daml's `.daml/` build directory. (It is still READ, for inheritance — see parse().)
|
|
61
|
+
*/
|
|
62
|
+
export function findSources(root, { includeLibs = false, includeTests = false } = {}) {
|
|
63
|
+
const out = [];
|
|
64
|
+
// Only a project's DEPENDENCY directory is vendored: Foundry's `lib/`, beside foundry.toml or at the
|
|
65
|
+
// root you point at. Skipping every directory named `libs` threw away a vault's own
|
|
66
|
+
// `src/libs/WhitelistFilter.sol` — its access control — as if it were somebody else's code.
|
|
67
|
+
const vendorDir = (dir, name) => name === "lib"
|
|
68
|
+
&& (dir === rootDir || fs.existsSync(path.join(dir, "foundry.toml")) || fs.existsSync(path.join(dir, "remappings.txt")));
|
|
69
|
+
const rootDir = fs.statSync(root).isFile() ? path.dirname(root) : root;
|
|
70
|
+
const walk = (dir) => {
|
|
71
|
+
let items;
|
|
72
|
+
try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
73
|
+
for (const it of items) {
|
|
74
|
+
const p = path.join(dir, it.name);
|
|
75
|
+
if (it.isDirectory()) {
|
|
76
|
+
if (SKIP_DIRS.has(it.name)) continue;
|
|
77
|
+
if (!includeLibs && vendorDir(dir, it.name)) continue;
|
|
78
|
+
if (!includeTests && TEST_DIR.test(it.name)) continue;
|
|
79
|
+
walk(p);
|
|
80
|
+
} else if (it.isFile() && it.name.endsWith(".sol")) {
|
|
81
|
+
if (!includeTests && /\.(t|s)\.sol$/.test(it.name)) continue;
|
|
82
|
+
out.push(p);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const st = fs.statSync(root);
|
|
87
|
+
if (st.isFile()) return root.endsWith(".sol") ? [root] : [];
|
|
88
|
+
walk(root);
|
|
89
|
+
return out.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Strip // and /* *\/ comments, leaving string literals intact and line numbers unchanged. */
|
|
93
|
+
export function stripComments(src) {
|
|
94
|
+
let out = "";
|
|
95
|
+
let i = 0;
|
|
96
|
+
const n = src.length;
|
|
97
|
+
while (i < n) {
|
|
98
|
+
const c = src[i], d = src[i + 1];
|
|
99
|
+
if (c === '"' || c === "'") {
|
|
100
|
+
const q = c; out += c; i++;
|
|
101
|
+
while (i < n && src[i] !== q) { if (src[i] === "\\") { out += src[i]; i++; } out += src[i]; i++; }
|
|
102
|
+
out += src[i] ?? ""; i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === "/" && d === "/") { while (i < n && src[i] !== "\n") i++; continue; }
|
|
106
|
+
if (c === "/" && d === "*") {
|
|
107
|
+
i += 2;
|
|
108
|
+
// Preserve newlines so reported line numbers still match the original file.
|
|
109
|
+
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) { if (src[i] === "\n") out += "\n"; i++; }
|
|
110
|
+
i += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
out += c; i++;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Blank string CONTENTS (quotes and length kept), so no scan can match a brace or call inside one. */
|
|
119
|
+
function maskStrings(src) {
|
|
120
|
+
let out = "";
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < src.length) {
|
|
123
|
+
const c = src[i];
|
|
124
|
+
if (c === '"' || c === "'") {
|
|
125
|
+
out += c; i++;
|
|
126
|
+
while (i < src.length && src[i] !== c && src[i] !== "\n") {
|
|
127
|
+
if (src[i] === "\\" && i + 1 < src.length) { out += " "; i += 2; continue; }
|
|
128
|
+
out += " "; i++;
|
|
129
|
+
}
|
|
130
|
+
if (i < src.length) { out += src[i]; i++; }
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
out += c; i++;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Index of the closer matching the opener at `open`, or -1. */
|
|
139
|
+
function matchPair(s, open, a = "{", b = "}") {
|
|
140
|
+
let depth = 0;
|
|
141
|
+
for (let i = open; i < s.length; i++) {
|
|
142
|
+
if (s[i] === a) depth++;
|
|
143
|
+
else if (s[i] === b) { depth--; if (depth === 0) return i; }
|
|
144
|
+
}
|
|
145
|
+
return -1;
|
|
146
|
+
}
|
|
147
|
+
/** Index of the opener matching the closer at `close`, walking backwards, or -1. */
|
|
148
|
+
function matchBack(s, close, a, b) {
|
|
149
|
+
let depth = 0;
|
|
150
|
+
for (let i = close; i >= 0; i--) {
|
|
151
|
+
if (s[i] === b) depth++;
|
|
152
|
+
else if (s[i] === a) { depth--; if (depth === 0) return i; }
|
|
153
|
+
}
|
|
154
|
+
return -1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function lineStarts(src) {
|
|
158
|
+
const starts = [0];
|
|
159
|
+
for (let i = 0; i < src.length; i++) if (src[i] === "\n") starts.push(i + 1);
|
|
160
|
+
return starts;
|
|
161
|
+
}
|
|
162
|
+
function lineAt(starts, idx) {
|
|
163
|
+
let lo = 0, hi = starts.length - 1;
|
|
164
|
+
while (lo < hi) {
|
|
165
|
+
const mid = (lo + hi + 1) >> 1;
|
|
166
|
+
if (starts[mid] <= idx) lo = mid; else hi = mid - 1;
|
|
167
|
+
}
|
|
168
|
+
return lo + 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Split at a separator, ignoring any inside () [] {}. */
|
|
172
|
+
function splitTop(text, sep) {
|
|
173
|
+
const out = [];
|
|
174
|
+
let depth = 0, cur = "";
|
|
175
|
+
for (let i = 0; i < text.length; i++) {
|
|
176
|
+
const ch = text[i];
|
|
177
|
+
if ("([{".includes(ch)) depth++;
|
|
178
|
+
else if (")]}".includes(ch)) depth--;
|
|
179
|
+
if (depth === 0 && text.startsWith(sep, i)) { out.push(cur); cur = ""; i += sep.length - 1; continue; }
|
|
180
|
+
cur += ch;
|
|
181
|
+
}
|
|
182
|
+
out.push(cur);
|
|
183
|
+
return out.map((s) => s.trim()).filter((s) => s.length);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Drop parentheses that wrap the whole expression. */
|
|
187
|
+
function unwrap(t) {
|
|
188
|
+
let s = t.trim();
|
|
189
|
+
while (s.startsWith("(") && matchPair(s, 0, "(", ")") === s.length - 1) s = s.slice(1, -1).trim();
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Split a parameter list at top-level commas into { name, type }. */
|
|
194
|
+
function splitParams(text) {
|
|
195
|
+
return splitTop(text, ",").map((p) => {
|
|
196
|
+
const parts = p.replace(/\s+/g, " ").split(" ").filter((x) => !/^(memory|storage|calldata|indexed)$/.test(x));
|
|
197
|
+
const name = parts.length > 1 && /^[A-Za-z_]\w*$/.test(parts[parts.length - 1]) ? parts[parts.length - 1] : "";
|
|
198
|
+
const type = name ? parts.slice(0, -1).join(" ") : parts.join(" ");
|
|
199
|
+
return { name, type };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const KNOWN_MODIFIERS = new Set([
|
|
204
|
+
"public", "external", "internal", "private", "view", "pure", "payable", "virtual", "override",
|
|
205
|
+
"constant", "immutable", "returns", "memory", "storage", "calldata", "nonpayable",
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
/** Everything between the parameter list and the body: visibility, mutability, modifiers, returns. */
|
|
209
|
+
function parseSignatureTail(tail) {
|
|
210
|
+
const visibility = /\b(external|public|internal|private)\b/.exec(tail)?.[1] ?? "internal";
|
|
211
|
+
const mutability = /\b(view|pure|payable)\b/.exec(tail)?.[1] ?? null;
|
|
212
|
+
let returns = [];
|
|
213
|
+
let rest = tail;
|
|
214
|
+
const r = /\breturns\s*\(/.exec(tail);
|
|
215
|
+
if (r) {
|
|
216
|
+
const open = r.index + r[0].length - 1;
|
|
217
|
+
const close = matchPair(tail, open, "(", ")");
|
|
218
|
+
if (close > 0) {
|
|
219
|
+
returns = splitParams(tail.slice(open + 1, close));
|
|
220
|
+
rest = tail.slice(0, r.index) + " " + tail.slice(close + 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
rest = rest.replace(/\boverride\s*\([^)]*\)/g, " ");
|
|
224
|
+
const modifiers = [...rest.matchAll(/\b([A-Za-z_]\w*)\s*(\([^)]*\))?/g)]
|
|
225
|
+
.map((m) => m[1])
|
|
226
|
+
.filter((w) => !KNOWN_MODIFIERS.has(w));
|
|
227
|
+
return { visibility, mutability, returns, modifiers };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ELEMENTARY = /^(address|bool|string|bytes\d*|u?int\d*|u?fixed[\dx]*|byte)$/;
|
|
231
|
+
const LOCAL_KEYWORDS = new Set([
|
|
232
|
+
"return", "emit", "delete", "else", "new", "revert", "require", "assert", "unchecked", "if", "for",
|
|
233
|
+
"while", "do", "break", "continue", "throw", "case", "import", "using", "is", "try", "catch", "assembly",
|
|
234
|
+
]);
|
|
235
|
+
// Names that are followed by `(` but are not calls into code: control flow, builtins, casts.
|
|
236
|
+
const NOT_A_CALL = new Set([
|
|
237
|
+
"if", "for", "while", "return", "returns", "function", "modifier", "event", "error", "emit", "revert",
|
|
238
|
+
"require", "assert", "new", "delete", "catch", "try", "mapping", "unchecked", "assembly", "type",
|
|
239
|
+
"keccak256", "sha256", "ripemd160", "ecrecover", "addmod", "mulmod", "blockhash", "blobhash",
|
|
240
|
+
"gasleft", "payable", "address", "bool", "string", "bytes", "abi", "super", "this", "unicode", "hex", "else",
|
|
241
|
+
]);
|
|
242
|
+
const LOW_LEVEL = new Set(["call", "delegatecall", "staticcall", "callcode"]);
|
|
243
|
+
const YUL_CALLS = new Set(["call", "callcode", "delegatecall", "staticcall"]);
|
|
244
|
+
|
|
245
|
+
// ── the declaration index ────────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Every contract, interface and library in one file, with enough structure to resolve calls. */
|
|
248
|
+
function parseFile(file, root, reported) {
|
|
249
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
250
|
+
const src = stripComments(raw);
|
|
251
|
+
const mk = maskStrings(src);
|
|
252
|
+
const starts = lineStarts(src);
|
|
253
|
+
const rel = path.relative(root, file);
|
|
254
|
+
const decls = [];
|
|
255
|
+
const RE = /\b(abstract\s+)?(contract|interface|library)\s+([A-Za-z_]\w*)([^{;]*)\{/g;
|
|
256
|
+
let m;
|
|
257
|
+
while ((m = RE.exec(mk)) !== null) {
|
|
258
|
+
const open = m.index + m[0].length - 1;
|
|
259
|
+
const close = matchPair(mk, open);
|
|
260
|
+
if (close < 0) continue;
|
|
261
|
+
const isList = /\bis\b([\s\S]*)$/.exec(m[4]);
|
|
262
|
+
const inherits = isList
|
|
263
|
+
? splitTop(isList[1], ",").map((s) => /^([A-Za-z_][\w.]*)/.exec(s)?.[1]).filter(Boolean)
|
|
264
|
+
: [];
|
|
265
|
+
const d = {
|
|
266
|
+
name: m[3], kind: m[2], abstract: Boolean(m[1]), file, rel, raw, src, mk, starts, reported,
|
|
267
|
+
open, close, line: lineAt(starts, m.index), endLine: lineAt(starts, close),
|
|
268
|
+
inherits, usings: [], fields: new Map(), structs: new Map(), enums: new Set(),
|
|
269
|
+
functions: [], modifiers: [],
|
|
270
|
+
};
|
|
271
|
+
parseBody(d);
|
|
272
|
+
decls.push(d);
|
|
273
|
+
RE.lastIndex = close + 1;
|
|
274
|
+
}
|
|
275
|
+
return decls;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseBody(d) {
|
|
279
|
+
const { mk } = d;
|
|
280
|
+
// Top-level statements: state variables, using-directives. Blocks that belong to a function,
|
|
281
|
+
// modifier, struct or enum are skipped here and read by the passes below.
|
|
282
|
+
let stmt = "";
|
|
283
|
+
for (let k = d.open + 1; k < d.close; k++) {
|
|
284
|
+
const ch = mk[k];
|
|
285
|
+
if (ch === "{") {
|
|
286
|
+
const end = matchPair(mk, k);
|
|
287
|
+
if (end < 0) break;
|
|
288
|
+
if (/^\s*(function|modifier|constructor|receive|fallback|struct|enum)\b/.test(stmt)) stmt = "";
|
|
289
|
+
else stmt += mk.slice(k, end + 1);
|
|
290
|
+
k = end;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (ch === ";") { topStatement(d, stmt.trim()); stmt = ""; continue; }
|
|
294
|
+
stmt += ch;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const depth1 = (idx) => {
|
|
298
|
+
let dep = 0;
|
|
299
|
+
for (let k = d.open; k < idx; k++) { if (mk[k] === "{") dep++; else if (mk[k] === "}") dep--; }
|
|
300
|
+
return dep === 1;
|
|
301
|
+
};
|
|
302
|
+
const region = mk.slice(0, d.close);
|
|
303
|
+
|
|
304
|
+
const STRUCT = /\bstruct\s+([A-Za-z_]\w*)\s*\{/g;
|
|
305
|
+
STRUCT.lastIndex = d.open;
|
|
306
|
+
for (let s; (s = STRUCT.exec(region)) !== null;) {
|
|
307
|
+
const end = matchPair(mk, s.index + s[0].length - 1);
|
|
308
|
+
const fields = new Map();
|
|
309
|
+
for (const part of mk.slice(s.index + s[0].length, end).split(";")) {
|
|
310
|
+
const p = splitParams(part.trim())[0];
|
|
311
|
+
if (p?.name) fields.set(p.name, p.type);
|
|
312
|
+
}
|
|
313
|
+
d.structs.set(s[1], fields);
|
|
314
|
+
}
|
|
315
|
+
const ENUM = /\benum\s+([A-Za-z_]\w*)\s*\{/g;
|
|
316
|
+
ENUM.lastIndex = d.open;
|
|
317
|
+
for (let s; (s = ENUM.exec(region)) !== null;) d.enums.add(s[1]);
|
|
318
|
+
|
|
319
|
+
const MOD = /\bmodifier\s+([A-Za-z_]\w*)/g;
|
|
320
|
+
MOD.lastIndex = d.open;
|
|
321
|
+
for (let s; (s = MOD.exec(region)) !== null;) {
|
|
322
|
+
if (!depth1(s.index)) continue;
|
|
323
|
+
const brace = mk.indexOf("{", s.index);
|
|
324
|
+
const semi = mk.indexOf(";", s.index);
|
|
325
|
+
if (brace < 0 || (semi >= 0 && semi < brace)) continue;
|
|
326
|
+
const end = matchPair(mk, brace);
|
|
327
|
+
if (end > 0) d.modifiers.push({ name: s[1], decl: d, bodyOpen: brace, bodyClose: end });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const FN = /\b(function\s+([A-Za-z_]\w*)|constructor|receive|fallback)\s*\(/g;
|
|
331
|
+
FN.lastIndex = d.open;
|
|
332
|
+
for (let f; (f = FN.exec(region)) !== null;) {
|
|
333
|
+
if (!depth1(f.index)) continue;
|
|
334
|
+
const name = f[2] ?? f[1];
|
|
335
|
+
const paramOpen = f.index + f[0].length - 1;
|
|
336
|
+
const paramClose = matchPair(mk, paramOpen, "(", ")");
|
|
337
|
+
if (paramClose < 0) continue;
|
|
338
|
+
const brace = mk.indexOf("{", paramClose);
|
|
339
|
+
const semi = mk.indexOf(";", paramClose);
|
|
340
|
+
const declaredOnly = semi >= 0 && (brace < 0 || semi < brace);
|
|
341
|
+
const tail = mk.slice(paramClose + 1, declaredOnly ? semi : brace);
|
|
342
|
+
const sig = parseSignatureTail(tail);
|
|
343
|
+
const bodyClose = declaredOnly ? null : matchPair(mk, brace);
|
|
344
|
+
d.functions.push({
|
|
345
|
+
name, decl: d, kind: f[2] ? "function" : f[1],
|
|
346
|
+
params: splitParams(mk.slice(paramOpen + 1, paramClose)),
|
|
347
|
+
returns: sig.returns, visibility: sig.visibility, mutability: sig.mutability,
|
|
348
|
+
modifiers: sig.modifiers,
|
|
349
|
+
bodyOpen: declaredOnly ? null : brace, bodyClose,
|
|
350
|
+
line: lineAt(d.starts, f.index),
|
|
351
|
+
endLine: declaredOnly ? lineAt(d.starts, semi) : lineAt(d.starts, bodyClose),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function topStatement(d, s) {
|
|
357
|
+
if (!s) return;
|
|
358
|
+
const using = /^using\s+([A-Za-z_][\w.]*|\{[^}]*\})\s+for\s+([^;]+?)(\s+global)?$/.exec(s);
|
|
359
|
+
if (using) {
|
|
360
|
+
const libs = using[1].startsWith("{") ? [] : [using[1]];
|
|
361
|
+
for (const lib of libs) d.usings.push({ lib, type: using[2].trim() });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (/^(function|modifier|constructor|receive|fallback|event|error|pragma|import|struct|enum|type)\b/.test(s)) return;
|
|
365
|
+
const noInit = splitTop(s, "=")[0] ?? s;
|
|
366
|
+
let type, rest;
|
|
367
|
+
if (noInit.startsWith("mapping")) {
|
|
368
|
+
const po = noInit.indexOf("(");
|
|
369
|
+
const pc = matchPair(noInit, po, "(", ")");
|
|
370
|
+
if (pc < 0) return;
|
|
371
|
+
type = noInit.slice(0, pc + 1);
|
|
372
|
+
rest = noInit.slice(pc + 1);
|
|
373
|
+
} else {
|
|
374
|
+
const mm = /^([A-Za-z_][\w.]*(?:\s*\[[^\]]*\])*(?:\s+payable)?)\s+([\s\S]*)$/.exec(noInit);
|
|
375
|
+
if (!mm) return;
|
|
376
|
+
type = mm[1];
|
|
377
|
+
rest = mm[2];
|
|
378
|
+
}
|
|
379
|
+
const toks = rest.trim().split(/\s+/)
|
|
380
|
+
.filter((t) => !/^(public|private|internal|constant|immutable|override|transient)$/.test(t));
|
|
381
|
+
const name = toks[toks.length - 1];
|
|
382
|
+
if (name && /^[A-Za-z_]\w*$/.test(name)) d.fields.set(name, type.replace(/\s+/g, " ").trim());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── imports ──────────────────────────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/** Foundry remappings from remappings.txt and foundry.toml, longest prefix first. */
|
|
388
|
+
function loadRemappings(cfgDir) {
|
|
389
|
+
const maps = [];
|
|
390
|
+
const add = (line) => {
|
|
391
|
+
const m = /^\s*(?:[\w.-]+:)?([^=\s]+)\s*=\s*(\S+)\s*$/.exec(line);
|
|
392
|
+
if (m) maps.push([m[1], m[2]]);
|
|
393
|
+
};
|
|
394
|
+
try { fs.readFileSync(path.join(cfgDir, "remappings.txt"), "utf8").split("\n").forEach(add); } catch { /* none */ }
|
|
395
|
+
try {
|
|
396
|
+
const toml = fs.readFileSync(path.join(cfgDir, "foundry.toml"), "utf8");
|
|
397
|
+
const block = /remappings\s*=\s*\[([\s\S]*?)\]/.exec(toml);
|
|
398
|
+
if (block) for (const q of block[1].matchAll(/["']([^"']+)["']/g)) add(q[1]);
|
|
399
|
+
} catch { /* none */ }
|
|
400
|
+
return maps.sort((a, b) => b[0].length - a[0].length);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Every .sol under the project's dependency folders, by file name — built once, only when needed. */
|
|
404
|
+
const depIndexCache = new Map();
|
|
405
|
+
function depIndex(cfgDir) {
|
|
406
|
+
if (depIndexCache.has(cfgDir)) return depIndexCache.get(cfgDir);
|
|
407
|
+
const byName = new Map();
|
|
408
|
+
const walk = (dir, depth) => {
|
|
409
|
+
if (depth > 12) return;
|
|
410
|
+
let items;
|
|
411
|
+
try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
412
|
+
for (const it of items) {
|
|
413
|
+
const p = path.join(dir, it.name);
|
|
414
|
+
if (it.isDirectory()) {
|
|
415
|
+
if (it.name === ".git" || it.name === "out" || it.name === "cache" || TEST_DIR.test(it.name)) continue;
|
|
416
|
+
walk(p, depth + 1);
|
|
417
|
+
} else if (it.name.endsWith(".sol")) {
|
|
418
|
+
if (!byName.has(it.name)) byName.set(it.name, []);
|
|
419
|
+
byName.get(it.name).push(p);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
for (const dep of ["lib", "node_modules"]) walk(path.join(cfgDir, dep), 0);
|
|
424
|
+
depIndexCache.set(cfgDir, byName);
|
|
425
|
+
return byName;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* When a remapped path does not exist, find the same file inside a NESTED dependency — the way
|
|
430
|
+
* Foundry's auto-remapping does. A repo can remap `@openzeppelin/` to `lib/openzeppelin-contracts/`
|
|
431
|
+
* while the only copy on disk is `lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/`.
|
|
432
|
+
* The longest matching path tail wins, then the shortest path.
|
|
433
|
+
*/
|
|
434
|
+
function nestedResolve(spec, cfgDir) {
|
|
435
|
+
const parts = spec.split("/");
|
|
436
|
+
const cands = depIndex(cfgDir).get(parts[parts.length - 1]) ?? [];
|
|
437
|
+
for (let k = 0; k < parts.length - 1; k++) {
|
|
438
|
+
const tail = `/${parts.slice(k).join("/")}`;
|
|
439
|
+
const hit = cands.filter((p) => p.endsWith(tail)).sort((a, b) => a.length - b.length)[0];
|
|
440
|
+
if (hit) return hit;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function resolveImport(spec, fromFile, cfgDir, maps) {
|
|
446
|
+
if (spec.startsWith(".")) return path.resolve(path.dirname(fromFile), spec);
|
|
447
|
+
for (const [prefix, target] of maps) {
|
|
448
|
+
if (spec.startsWith(prefix)) return path.resolve(cfgDir, target + spec.slice(prefix.length));
|
|
449
|
+
}
|
|
450
|
+
for (let dir = path.dirname(fromFile); ; dir = path.dirname(dir)) {
|
|
451
|
+
const cand = path.join(dir, "node_modules", spec);
|
|
452
|
+
if (fs.existsSync(cand)) return cand;
|
|
453
|
+
if (dir === path.dirname(dir) || !dir.startsWith(cfgDir)) break;
|
|
454
|
+
}
|
|
455
|
+
for (const base of ["lib", "node_modules", ""]) {
|
|
456
|
+
const cand = path.resolve(cfgDir, base, spec);
|
|
457
|
+
if (fs.existsSync(cand)) return cand;
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The files to READ: what is reported, plus everything it imports, transitively, resolved the way
|
|
464
|
+
* the compiler would. Reading exactly the imports — not all of lib/ — finds a parent wherever it
|
|
465
|
+
* lives (lib/, node_modules, a remapped path), and tells us precisely which ones are MISSING: an
|
|
466
|
+
* uninitialised submodule is an empty directory, and an access check inside it cannot be read.
|
|
467
|
+
*/
|
|
468
|
+
function importClosure(files, root) {
|
|
469
|
+
let cfgDir = fs.statSync(root).isFile() ? path.dirname(root) : root;
|
|
470
|
+
for (let up = cfgDir, i = 0; i < 4; i++, up = path.dirname(up)) {
|
|
471
|
+
if (fs.existsSync(path.join(up, "foundry.toml")) || fs.existsSync(path.join(up, "remappings.txt"))) { cfgDir = up; break; }
|
|
472
|
+
if (up === path.dirname(up)) break;
|
|
473
|
+
}
|
|
474
|
+
const maps = loadRemappings(cfgDir);
|
|
475
|
+
const readable = new Set(files);
|
|
476
|
+
const missing = new Set();
|
|
477
|
+
const queue = [...files];
|
|
478
|
+
while (queue.length) {
|
|
479
|
+
const f = queue.shift();
|
|
480
|
+
let text;
|
|
481
|
+
try { text = stripComments(fs.readFileSync(f, "utf8")); } catch { continue; }
|
|
482
|
+
for (const m of text.matchAll(/\bimport\s+(?:[^"';]*?\s+from\s+)?["']([^"']+)["']/g)) {
|
|
483
|
+
let hit = resolveImport(m[1], f, cfgDir, maps);
|
|
484
|
+
if (!(hit && fs.existsSync(hit)) && !m[1].startsWith(".")) hit = nestedResolve(m[1], cfgDir);
|
|
485
|
+
if (hit && fs.existsSync(hit)) {
|
|
486
|
+
if (!readable.has(hit)) { readable.add(hit); queue.push(hit); }
|
|
487
|
+
} else missing.add(m[1]);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { readable: [...readable], missing: [...missing].sort() };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── resolution ───────────────────────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function makeResolver(index, reportedNames) {
|
|
496
|
+
const pick = (name) => {
|
|
497
|
+
const all = index.get(String(name).split(".").pop()) ?? [];
|
|
498
|
+
return all.find((d) => d.reported) ?? all[0] ?? null;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/** Most-derived first, then parents right to left, recursively — Solidity's lookup order, near enough. */
|
|
502
|
+
const chainCache = new Map();
|
|
503
|
+
const chainOf = (d) => {
|
|
504
|
+
if (chainCache.has(d)) return chainCache.get(d);
|
|
505
|
+
const out = [];
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
const visit = (x) => {
|
|
508
|
+
if (!x || seen.has(x)) return;
|
|
509
|
+
seen.add(x);
|
|
510
|
+
out.push(x);
|
|
511
|
+
for (const p of [...x.inherits].reverse()) visit(pick(p));
|
|
512
|
+
};
|
|
513
|
+
visit(d);
|
|
514
|
+
chainCache.set(d, out);
|
|
515
|
+
return out;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/** Implementations with this name in the most-derived contract that has one. */
|
|
519
|
+
const lookup = (chain, name, { skipFirst = false } = {}) => {
|
|
520
|
+
for (const c of skipFirst ? chain.slice(1) : chain) {
|
|
521
|
+
const hits = c.functions.filter((f) => f.name === name && f.bodyOpen != null);
|
|
522
|
+
if (hits.length) return hits;
|
|
523
|
+
}
|
|
524
|
+
return [];
|
|
525
|
+
};
|
|
526
|
+
const structFields = (typeName, chain) => {
|
|
527
|
+
const last = String(typeName).split(".").pop();
|
|
528
|
+
for (const c of chain) if (c.structs.has(last)) return c.structs.get(last);
|
|
529
|
+
for (const all of index.values()) for (const c of all) if (c.structs.has(last)) return c.structs.get(last);
|
|
530
|
+
return null;
|
|
531
|
+
};
|
|
532
|
+
const isEnum = (typeName, chain) => {
|
|
533
|
+
const last = String(typeName).split(".").pop();
|
|
534
|
+
if (chain.some((c) => c.enums.has(last))) return true;
|
|
535
|
+
for (const all of index.values()) for (const c of all) if (c.enums.has(last)) return true;
|
|
536
|
+
return false;
|
|
537
|
+
};
|
|
538
|
+
const elementType = (t) => {
|
|
539
|
+
const s = t.trim();
|
|
540
|
+
if (s.startsWith("mapping")) {
|
|
541
|
+
const inner = s.slice(s.indexOf("(") + 1, s.lastIndexOf(")"));
|
|
542
|
+
const parts = splitTop(inner, "=>");
|
|
543
|
+
return parts.length >= 2 ? parts.slice(1).join("=>").trim() : null;
|
|
544
|
+
}
|
|
545
|
+
const mm = /^([\s\S]*)\[[^\]]*\]$/.exec(s);
|
|
546
|
+
return mm ? mm[1].trim() : null;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/** What a type is, for the purpose of deciding what calling a method on it means. */
|
|
550
|
+
const classify = (t, chain) => {
|
|
551
|
+
if (!t) return "unknown";
|
|
552
|
+
const base = t.replace(/\s+payable$/, "").trim();
|
|
553
|
+
if (base === "address") return "address";
|
|
554
|
+
if (base.startsWith("mapping") || /\]$/.test(base)) return "collection";
|
|
555
|
+
if (ELEMENTARY.test(base)) return "value";
|
|
556
|
+
const last = base.split(".").pop();
|
|
557
|
+
if (!/^[A-Z]/.test(last)) return "unknown";
|
|
558
|
+
if (structFields(last, chain)) return "struct";
|
|
559
|
+
if (isEnum(last, chain)) return "value";
|
|
560
|
+
return "contract";
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const usingFor = (chain, type) => {
|
|
564
|
+
const base = String(type ?? "").replace(/\s+payable$/, "").trim();
|
|
565
|
+
for (const c of chain) {
|
|
566
|
+
for (const u of c.usings) if (u.type === "*" || u.type === base || u.type.split(".").pop() === base.split(".").pop()) return u.lib;
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
};
|
|
570
|
+
const libFunctions = (lib, method) => (pick(lib)?.functions ?? []).filter((f) => f.name === method);
|
|
571
|
+
const pureOnly = (fns) => fns.length > 0 && fns.every((f) => f.mutability === "pure" || f.mutability === "view");
|
|
572
|
+
|
|
573
|
+
/** Does a library function make a low-level or assembly call anywhere in its body? */
|
|
574
|
+
const lowCache = new Map();
|
|
575
|
+
const libLowLevel = (lib, method) => {
|
|
576
|
+
const key = `${lib}.${method}`;
|
|
577
|
+
if (!lowCache.has(key)) {
|
|
578
|
+
lowCache.set(key, false);
|
|
579
|
+
const fns = libFunctions(lib, method).filter((f) => f.bodyOpen != null);
|
|
580
|
+
lowCache.set(key, fns.some((f) => /\.\s*(call|delegatecall|staticcall)\s*[({]|\bassembly\b/.test(f.decl.mk.slice(f.bodyOpen, f.bodyClose))));
|
|
581
|
+
}
|
|
582
|
+
return lowCache.get(key);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
return { index, pick, chainOf, lookup, structFields, elementType, classify, usingFor, libFunctions, pureOnly, libLowLevel, reportedNames };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Local variable declarations in a body, as name -> type. */
|
|
589
|
+
function localsOf(body) {
|
|
590
|
+
const env = new Map();
|
|
591
|
+
const LOCAL = /(?:^|[;{}(,])\s*((?:mapping\s*\([^;]*?\))|[A-Za-z_][\w.]*(?:\s*\[[^\]]*\])*(?:\s+payable)?)\s+(?:(?:memory|storage|calldata)\s+)?([A-Za-z_]\w*)\s*(?==|;|,|\))/g;
|
|
592
|
+
for (let m; (m = LOCAL.exec(body)) !== null;) {
|
|
593
|
+
const type = m[1].trim();
|
|
594
|
+
if (LOCAL_KEYWORDS.has(type.split(/[\s[]/)[0])) continue;
|
|
595
|
+
env.set(m[2], type.replace(/\s+/g, " "));
|
|
596
|
+
LOCAL.lastIndex = m.index + m[0].length - 1;
|
|
597
|
+
}
|
|
598
|
+
return env;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Read the receiver expression that ends just before `end` (inclusive), walking backwards. */
|
|
602
|
+
function readReceiver(s, end) {
|
|
603
|
+
let i = end;
|
|
604
|
+
while (i >= 0 && /\s/.test(s[i])) i--;
|
|
605
|
+
const stop = i;
|
|
606
|
+
for (;;) {
|
|
607
|
+
if (i < 0) break;
|
|
608
|
+
if (s[i] === ")" || s[i] === "]") {
|
|
609
|
+
const o = matchBack(s, i, s[i] === ")" ? "(" : "[", s[i]);
|
|
610
|
+
if (o < 0) break;
|
|
611
|
+
i = o - 1;
|
|
612
|
+
let j = i;
|
|
613
|
+
while (j >= 0 && /\s/.test(s[j])) j--;
|
|
614
|
+
if (j >= 0 && /[\w]/.test(s[j])) { i = j; continue; }
|
|
615
|
+
} else if (/[\w]/.test(s[i])) {
|
|
616
|
+
while (i >= 0 && /[\w]/.test(s[i])) i--;
|
|
617
|
+
} else break;
|
|
618
|
+
let j = i;
|
|
619
|
+
while (j >= 0 && /\s/.test(s[j])) j--;
|
|
620
|
+
if (j >= 0 && s[j] === ".") { i = j - 1; while (i >= 0 && /\s/.test(s[i])) i--; continue; }
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
return s.slice(i + 1, stop + 1).trim();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Every call site in one function body, classified. The contract of this function is that nothing
|
|
628
|
+
* followed by `(` is dropped without a reason: it is an edge, a hole, an internal call, or named as
|
|
629
|
+
* not being a call at all.
|
|
630
|
+
*/
|
|
631
|
+
function scanBody(fn, R, ctx = fn.decl) {
|
|
632
|
+
// Text comes from where the function is written; names resolve against the contract that actually
|
|
633
|
+
// runs it. An inherited `deposit()` calling `_deposit(...)` reaches the child's OVERRIDE — that is
|
|
634
|
+
// virtual dispatch, and resolving against the base would trace code that never executes.
|
|
635
|
+
const d = fn.decl;
|
|
636
|
+
const chain = R.chainOf(ctx);
|
|
637
|
+
const { mk, src, starts } = d;
|
|
638
|
+
const open = fn.bodyOpen + 1, close = fn.bodyClose;
|
|
639
|
+
const body = mk.slice(open, close);
|
|
640
|
+
|
|
641
|
+
const env = new Map();
|
|
642
|
+
for (const c of [...chain].reverse()) for (const [n, t] of c.fields) env.set(n, t);
|
|
643
|
+
for (const p of fn.params) if (p.name) env.set(p.name, p.type);
|
|
644
|
+
for (const r of fn.returns) if (r.name) env.set(r.name, r.type);
|
|
645
|
+
for (const [n, t] of localsOf(body)) env.set(n, t);
|
|
646
|
+
|
|
647
|
+
// Yul inside `assembly { }` has its own call primitives, and they are the riskiest calls there are.
|
|
648
|
+
const yul = [];
|
|
649
|
+
for (let a, A = /\bassembly\b[^{]*\{/g; (a = A.exec(body)) !== null;) {
|
|
650
|
+
const o = a.index + a[0].length - 1;
|
|
651
|
+
yul.push([o, matchPair(body, o)]);
|
|
652
|
+
}
|
|
653
|
+
const inYul = (i) => yul.some(([o, c]) => i > o && i < c);
|
|
654
|
+
|
|
655
|
+
const typeOf = (expr) => {
|
|
656
|
+
const e = unwrap(expr);
|
|
657
|
+
if (!e) return null;
|
|
658
|
+
if (e === "this") return { self: true };
|
|
659
|
+
if (e === "super") return { super: true };
|
|
660
|
+
if (e === "msg.sender" || e === "tx.origin" || e === "block.coinbase") return { t: "address" };
|
|
661
|
+
const call = /^([A-Za-z_][\w.]*)\s*\(/.exec(e);
|
|
662
|
+
if (call && matchPair(e, call[0].length - 1, "(", ")") === e.length - 1) {
|
|
663
|
+
const name = call[1];
|
|
664
|
+
if (name === "address" || name === "payable") return { t: "address" };
|
|
665
|
+
if (ELEMENTARY.test(name)) return { t: name };
|
|
666
|
+
if (/^[A-Z]/.test(name.split(".").pop())) return { t: name };
|
|
667
|
+
const f = R.lookup(chain, name).find((x) => x.returns.length);
|
|
668
|
+
return f ? { t: f.returns[0].type } : null;
|
|
669
|
+
}
|
|
670
|
+
if (e.endsWith("]")) {
|
|
671
|
+
const o = matchBack(e, e.length - 1, "[", "]");
|
|
672
|
+
const base = o > 0 ? typeOf(e.slice(0, o)) : null;
|
|
673
|
+
const el = base?.t ? R.elementType(base.t) : null;
|
|
674
|
+
return el ? { t: el } : null;
|
|
675
|
+
}
|
|
676
|
+
if (/^[A-Za-z_]\w*$/.test(e)) return env.has(e) ? { t: env.get(e) } : null;
|
|
677
|
+
const dot = e.lastIndexOf(".");
|
|
678
|
+
if (dot > 0 && !/[)\]]/.test(e.slice(dot))) {
|
|
679
|
+
const left = typeOf(e.slice(0, dot));
|
|
680
|
+
const fields = left?.t ? R.structFields(left.t, chain) : null;
|
|
681
|
+
const right = e.slice(dot + 1).trim();
|
|
682
|
+
if (fields?.has(right)) return { t: fields.get(right) };
|
|
683
|
+
}
|
|
684
|
+
return null;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// Overloads share a name. Folding every overload into a call over-states what it reaches — a call
|
|
688
|
+
// to `_giveAllowances(asset)` was credited with the approvals of `_giveAllowances()` too. Match on
|
|
689
|
+
// argument count; only when that cannot decide are all candidates kept.
|
|
690
|
+
const byArity = (impls, parenAt) => {
|
|
691
|
+
if (impls.length < 2) return impls;
|
|
692
|
+
const close = matchPair(body, parenAt, "(", ")");
|
|
693
|
+
if (close < 0) return impls;
|
|
694
|
+
const argc = splitTop(body.slice(parenAt + 1, close), ",").length;
|
|
695
|
+
const hit = impls.filter((f) => f.params.filter((x) => x.type).length === argc);
|
|
696
|
+
return hit.length ? hit : impls;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const sites = [];
|
|
700
|
+
const rawAt = (i) => {
|
|
701
|
+
const ln = lineAt(starts, open + i);
|
|
702
|
+
return { line: ln, raw: src.slice(starts[ln - 1], (starts[ln] ?? src.length + 1) - 1).trim().slice(0, 240) };
|
|
703
|
+
};
|
|
704
|
+
const add = (i, site) => sites.push({ ...site, ...rawAt(i) });
|
|
705
|
+
const unitName = (t) => String(t).replace(/\s+payable$/, "").trim().split(".").pop();
|
|
706
|
+
|
|
707
|
+
const CALL = /([A-Za-z_]\w*)\s*(\{[^{}]*\}\s*)?\(/g;
|
|
708
|
+
for (let m; (m = CALL.exec(body)) !== null;) {
|
|
709
|
+
const name = m[1];
|
|
710
|
+
const at = m.index;
|
|
711
|
+
let p = at - 1;
|
|
712
|
+
while (p >= 0 && /\s/.test(body[p])) p--;
|
|
713
|
+
const prevWord = (() => { let q = p; while (q >= 0 && /\w/.test(body[q])) q--; return body.slice(q + 1, p + 1); })();
|
|
714
|
+
|
|
715
|
+
if (inYul(at)) {
|
|
716
|
+
if (YUL_CALLS.has(name)) add(at, { cls: "hole", kind: EDGE.CALL, meta: { lowLevel: name, assembly: true } });
|
|
717
|
+
else if (name === "create" || name === "create2") add(at, { cls: "hole", kind: EDGE.CREATE, meta: { assembly: true } });
|
|
718
|
+
else if (name === "selfdestruct") add(at, { cls: "hole", kind: EDGE.DESTROY, meta: { assembly: true } });
|
|
719
|
+
else add(at, { cls: "skip", why: "yul builtin", name });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (prevWord === "emit" || prevWord === "revert") { add(at, { cls: "skip", why: prevWord, name }); continue; }
|
|
723
|
+
if (prevWord === "new") {
|
|
724
|
+
add(at, { cls: "edge", kind: EDGE.CREATE, target: unitName(name), meta: {} });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (p >= 0 && body[p] === ".") {
|
|
728
|
+
const recv = readReceiver(body, p - 1);
|
|
729
|
+
classifyMember(at, name, recv, m[2] ?? "", m.index + m[0].length - 1);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
// A bare name(...)
|
|
733
|
+
if (name === "selfdestruct" || name === "suicide") { add(at, { cls: "hole", kind: EDGE.DESTROY, meta: {} }); continue; }
|
|
734
|
+
if (NOT_A_CALL.has(name) || ELEMENTARY.test(name)) { add(at, { cls: "skip", why: "builtin", name }); continue; }
|
|
735
|
+
if (/^[A-Z]/.test(name)) { add(at, { cls: "skip", why: "cast or struct", name }); continue; }
|
|
736
|
+
const impls = byArity(R.lookup(chain, name), at + m[0].length - 1);
|
|
737
|
+
if (impls.length) { add(at, { cls: "internal", name, impls }); continue; }
|
|
738
|
+
if (env.has(name)) { add(at, { cls: "hole", kind: EDGE.CALL, meta: { receiver: name, pointer: true } }); continue; }
|
|
739
|
+
add(at, { cls: "skip", why: "internal, not found in this code", name, unresolvedInternal: true });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function classifyMember(at, method, recv, opts, parenAt) {
|
|
743
|
+
const r = unwrap(recv);
|
|
744
|
+
if (/^[A-Z]/.test(method)) { add(at, { cls: "skip", why: "struct or type member", name: method }); return; }
|
|
745
|
+
if (/^(abi|bytes|string|block|tx|msg)$/.test(r) || r.startsWith("type(") || r.startsWith("type (")) {
|
|
746
|
+
add(at, { cls: "skip", why: "builtin", name: method });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (LOW_LEVEL.has(method)) {
|
|
750
|
+
add(at, { cls: "hole", kind: EDGE.CALL, meta: { lowLevel: method, value: /\bvalue\s*:/.test(opts), receiver: r } });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// `Name.method(` on a bare type name: a library call, or an explicit call into a base contract.
|
|
754
|
+
if (/^[A-Z]\w*(\.[A-Z]\w*)*$/.test(r) && !env.has(r)) {
|
|
755
|
+
const decl = R.pick(r);
|
|
756
|
+
if (decl?.kind === "library") {
|
|
757
|
+
if (R.pureOnly(R.libFunctions(r, method))) { add(at, { cls: "skip", why: "pure library function", name: method }); return; }
|
|
758
|
+
// `SafeERC20.safeTransfer(IERC20(token), …)` acts ON the token: when the first argument has a
|
|
759
|
+
// contract type, that is what is called. When it is a bare address, what the library does
|
|
760
|
+
// to it decides — an address handed to a low-level call is a target nobody can name.
|
|
761
|
+
const close = matchPair(body, parenAt, "(", ")");
|
|
762
|
+
const first = close > 0 ? splitTop(body.slice(parenAt + 1, close), ",")[0] : null;
|
|
763
|
+
const ft = first ? typeOf(first) : null;
|
|
764
|
+
const fk = R.classify(ft?.t, chain);
|
|
765
|
+
if (fk === "contract") { add(at, { cls: "edge", kind: EDGE.CALL, target: unitName(ft.t), meta: { library: r, method } }); return; }
|
|
766
|
+
if (fk === "address" && R.libLowLevel(r, method)) { add(at, { cls: "hole", kind: EDGE.CALL, meta: { library: r, method, lowLevel: "via library", receiver: first } }); return; }
|
|
767
|
+
add(at, { cls: "edge", kind: EDGE.CALL, target: unitName(r), meta: { library: r, method } });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (decl && chain.includes(decl)) { add(at, { cls: "internal", name: method, impls: byArity(R.lookup(R.chainOf(decl), method), parenAt) }); return; }
|
|
771
|
+
if (!decl) { add(at, { cls: "edge", kind: EDGE.CALL, target: unitName(r), meta: { method } }); return; }
|
|
772
|
+
add(at, { cls: "skip", why: "type member", name: method });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const t = typeOf(r);
|
|
776
|
+
if (t?.self) { add(at, { cls: "edge", kind: EDGE.CALL, target: ctx.name, meta: { method, selfCall: true } }); return; }
|
|
777
|
+
if (t?.super) { add(at, { cls: "internal", name: method, impls: byArity(R.lookup(chain, method, { skipFirst: true }), parenAt) }); return; }
|
|
778
|
+
const kind = R.classify(t?.t, chain);
|
|
779
|
+
const lib = t?.t ? R.usingFor(chain, t.t) : null;
|
|
780
|
+
if (kind === "address") {
|
|
781
|
+
if (method === "transfer" || method === "send") {
|
|
782
|
+
add(at, { cls: "hole", kind: EDGE.CALL, meta: { eth: true, receiver: r } });
|
|
783
|
+
} else {
|
|
784
|
+
add(at, { cls: "hole", kind: EDGE.CALL, meta: { lowLevel: method, library: lib, receiver: r } });
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (kind === "contract") {
|
|
789
|
+
add(at, { cls: "edge", kind: EDGE.CALL, target: unitName(t.t), meta: { method, library: lib && R.libFunctions(lib, method).length ? lib : (lib && !R.pick(lib) ? lib : null) } });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (kind === "collection" && (method === "push" || method === "pop")) { add(at, { cls: "skip", why: "array builtin", name: method }); return; }
|
|
793
|
+
if ((kind === "value" || kind === "collection") && (method === "concat" || lib)) {
|
|
794
|
+
add(at, { cls: "skip", why: "library computation on a value", name: method });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (kind === "struct" && lib) {
|
|
798
|
+
if (R.pureOnly(R.libFunctions(lib, method))) { add(at, { cls: "skip", why: "pure library function", name: method }); return; }
|
|
799
|
+
add(at, { cls: "edge", kind: EDGE.CALL, target: unitName(lib), meta: { library: lib, method } });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
add(at, { cls: "hole", kind: EDGE.CALL, meta: { receiver: r, method } });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return sites;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ── authority ────────────────────────────────────────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
const isSender = (x) => /^(msg\.sender|_msgSender\s*\(\s*\)|tx\.origin)$/.test(unwrap(x));
|
|
811
|
+
const principal = (x) => unwrap(x).replace(/^address\s*\(([\s\S]*)\)$/, "$1").trim().replace(/\(\s*\)$/, "");
|
|
812
|
+
|
|
813
|
+
/** `require(COND)`: the principal(s) COND lets through, or null if it is not purely an access check. */
|
|
814
|
+
function allowedBy(cond) {
|
|
815
|
+
const alts = splitTop(unwrap(cond), "||");
|
|
816
|
+
const names = alts.map(allowedTerm);
|
|
817
|
+
return names.every(Boolean) ? [...new Set(names)].join(" or ") : null;
|
|
818
|
+
}
|
|
819
|
+
function allowedTerm(term) {
|
|
820
|
+
const t = unwrap(term);
|
|
821
|
+
const conj = splitTop(t, "&&");
|
|
822
|
+
if (conj.length > 1) {
|
|
823
|
+
const hits = conj.map(allowedTerm).filter(Boolean);
|
|
824
|
+
return hits.length ? hits.join(" and ") : null;
|
|
825
|
+
}
|
|
826
|
+
let m;
|
|
827
|
+
if ((m = /^([\s\S]+?)\s*==\s*([\s\S]+)$/.exec(t))) {
|
|
828
|
+
if (isSender(m[1])) return principal(m[2]);
|
|
829
|
+
if (isSender(m[2])) return principal(m[1]);
|
|
830
|
+
}
|
|
831
|
+
if ((m = /^hasRole\s*\(([\s\S]*)\)$/.exec(t))) {
|
|
832
|
+
const args = splitTop(m[1], ",");
|
|
833
|
+
if (args.length === 2 && isSender(args[1])) return args[0];
|
|
834
|
+
}
|
|
835
|
+
if ((m = /^([A-Za-z_][\w.]*)\s*\[([\s\S]+)\]$/.exec(t)) && isSender(m[2])) return `${m[1]}[msg.sender]`;
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
/** `if (COND) revert`: COND is the FAILURE condition. */
|
|
839
|
+
function failureBy(cond) {
|
|
840
|
+
const c = unwrap(cond);
|
|
841
|
+
const conj = splitTop(c, "&&");
|
|
842
|
+
if (conj.length > 1) {
|
|
843
|
+
const names = conj.map(failTerm);
|
|
844
|
+
return names.every(Boolean) ? [...new Set(names)].join(" or ") : null;
|
|
845
|
+
}
|
|
846
|
+
const disj = splitTop(c, "||");
|
|
847
|
+
if (disj.length > 1) {
|
|
848
|
+
const hits = disj.map(failTerm).filter(Boolean);
|
|
849
|
+
return hits.length ? hits.join(" and ") : null;
|
|
850
|
+
}
|
|
851
|
+
return failTerm(c);
|
|
852
|
+
}
|
|
853
|
+
function failTerm(term) {
|
|
854
|
+
const t = unwrap(term);
|
|
855
|
+
let m;
|
|
856
|
+
if ((m = /^([\s\S]+?)\s*!=\s*([\s\S]+)$/.exec(t))) {
|
|
857
|
+
if (isSender(m[1])) return principal(m[2]);
|
|
858
|
+
if (isSender(m[2])) return principal(m[1]);
|
|
859
|
+
}
|
|
860
|
+
if ((m = /^!\s*([\s\S]+)$/.exec(t))) return allowedTerm(m[1]);
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The access checks a body makes on EVERY path, with where each one lives. Top-level only; a check
|
|
866
|
+
* nested in any block might be skipped. Helpers called at top level are followed, up to a depth.
|
|
867
|
+
*/
|
|
868
|
+
function checksOf(decl, open, close, R, ctxChain = null, seen = new Set(), depth = 0) {
|
|
869
|
+
const out = [];
|
|
870
|
+
if (open == null || depth > 5) return out;
|
|
871
|
+
const { mk, src, starts } = decl;
|
|
872
|
+
const chain = ctxChain ?? R.chainOf(decl);
|
|
873
|
+
let level = 0;
|
|
874
|
+
const lineText = (idx) => {
|
|
875
|
+
const ln = lineAt(starts, idx);
|
|
876
|
+
return src.slice(starts[ln - 1], (starts[ln] ?? src.length + 1) - 1).trim();
|
|
877
|
+
};
|
|
878
|
+
for (let i = open + 1; i < close; i++) {
|
|
879
|
+
const ch = mk[i];
|
|
880
|
+
if (ch === "{") { level++; continue; }
|
|
881
|
+
if (ch === "}") { level--; continue; }
|
|
882
|
+
if (level !== 0 || !/[A-Za-z_]/.test(ch) || /[\w.]/.test(mk[i - 1] ?? "")) continue;
|
|
883
|
+
const word = /^[A-Za-z_]\w*/.exec(mk.slice(i, i + 64))?.[0];
|
|
884
|
+
if (!word) continue;
|
|
885
|
+
let j = i + word.length;
|
|
886
|
+
while (/\s/.test(mk[j] ?? "")) j++;
|
|
887
|
+
// `emit statusChanged(...)` and `revert notAllowed(...)` name an event and an error, not helpers.
|
|
888
|
+
if (word === "emit" || word === "revert") {
|
|
889
|
+
const next = /^[A-Za-z_][\w.]*/.exec(mk.slice(j, j + 96))?.[0];
|
|
890
|
+
if (next && word === "revert" && mk[j] !== "(") { i = j + next.length - 1; continue; }
|
|
891
|
+
if (next && word === "emit") { i = j + next.length - 1; continue; }
|
|
892
|
+
}
|
|
893
|
+
if (mk[j] !== "(") { i += word.length - 1; continue; }
|
|
894
|
+
if (ELEMENTARY.test(word)) { i = matchPair(mk, j, "(", ")"); if (i < 0) break; continue; }
|
|
895
|
+
const pc = matchPair(mk, j, "(", ")");
|
|
896
|
+
if (pc < 0) break;
|
|
897
|
+
const inside = mk.slice(j + 1, pc);
|
|
898
|
+
let prev = i - 1;
|
|
899
|
+
while (prev > open && /\s/.test(mk[prev])) prev--;
|
|
900
|
+
const afterElse = /else$/.test(mk.slice(Math.max(open, prev - 4), prev + 1));
|
|
901
|
+
if (word === "require" || word === "assert") {
|
|
902
|
+
const who = allowedBy(splitTop(inside, ",")[0] ?? "");
|
|
903
|
+
if (who) out.push({ who, where: lineText(i) });
|
|
904
|
+
} else if (word === "if" && !afterElse) {
|
|
905
|
+
let k = pc + 1;
|
|
906
|
+
while (/\s/.test(mk[k] ?? "")) k++;
|
|
907
|
+
const reverts = /^revert\b/.test(mk.slice(k, k + 8))
|
|
908
|
+
|| (mk[k] === "{" && /^\{\s*revert\b/.test(mk.slice(k, k + 40)));
|
|
909
|
+
if (reverts) {
|
|
910
|
+
const who = failureBy(inside);
|
|
911
|
+
if (who) out.push({ who, where: lineText(i) });
|
|
912
|
+
}
|
|
913
|
+
} else if (!NOT_A_CALL.has(word) && !/^[A-Z]/.test(word)) {
|
|
914
|
+
// A top-level call to a helper: its own unconditional checks are this function's too.
|
|
915
|
+
const impls = R.lookup(chain, word);
|
|
916
|
+
if (!impls.length && depth === 0) (out.unread ??= []).push(`${word}()`);
|
|
917
|
+
const key = impls.map((f) => `${f.decl.rel}:${f.line}`).join(",");
|
|
918
|
+
if (impls.length && !seen.has(key)) {
|
|
919
|
+
seen.add(key);
|
|
920
|
+
// A helper guards by its body OR by its own modifiers — `_authorizeUpgrade() internal
|
|
921
|
+
// override onlyOwner {}` has an empty body and is the whole of an upgrade's access control.
|
|
922
|
+
const per = impls.map((f) => [
|
|
923
|
+
...checksOf(f.decl, f.bodyOpen, f.bodyClose, R, chain, seen, depth + 1),
|
|
924
|
+
...f.modifiers.filter((w) => !/^[A-Z]/.test(w)).flatMap((w) => {
|
|
925
|
+
const bodies = modifierBodies(w, chain, R.index);
|
|
926
|
+
const cs = bodies.map((mo) => checksOf(mo.decl, mo.bodyOpen, mo.bodyClose, R, chain, seen, depth + 1));
|
|
927
|
+
return bodies.length && cs.every((c) => c.length) ? cs[0].map((c) => ({ who: c.who, where: `${w} → ${c.where}` })) : [];
|
|
928
|
+
}),
|
|
929
|
+
]);
|
|
930
|
+
// Every overload must check, or the helper is not a guarantee.
|
|
931
|
+
if (per.every((c) => c.length)) for (const c of per[0]) out.push({ who: c.who, where: `${word}() → ${c.where}` });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
i = pc;
|
|
935
|
+
}
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function modifierBodies(name, chain, index) {
|
|
940
|
+
for (const c of chain) {
|
|
941
|
+
const hit = c.modifiers.filter((mo) => mo.name === name);
|
|
942
|
+
if (hit.length) return hit;
|
|
943
|
+
}
|
|
944
|
+
const all = [];
|
|
945
|
+
for (const decls of index.values()) for (const c of decls) for (const mo of c.modifiers) if (mo.name === name) all.push(mo);
|
|
946
|
+
return all;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ── assembly ─────────────────────────────────────────────────────────────────────────────────────
|
|
950
|
+
|
|
951
|
+
export function parse(root, opts = {}) {
|
|
952
|
+
const files = findSources(root, opts);
|
|
953
|
+
const base = fs.statSync(root).isFile() ? path.dirname(root) : root;
|
|
954
|
+
const reportedFiles = new Set(files);
|
|
955
|
+
|
|
956
|
+
// Everything is READ, lib/ included, whether or not it is REPORTED: `onlyOwner` and the helpers
|
|
957
|
+
// behind it live in vendored OpenZeppelin, and whether a modifier checks the caller is a fact
|
|
958
|
+
// about its body.
|
|
959
|
+
const { readable, missing } = importClosure(files, root);
|
|
960
|
+
const index = new Map();
|
|
961
|
+
const reported = [];
|
|
962
|
+
for (const f of readable) {
|
|
963
|
+
let decls;
|
|
964
|
+
try { decls = parseFile(f, base, reportedFiles.has(f)); } catch { continue; }
|
|
965
|
+
for (const d of decls) {
|
|
966
|
+
if (!index.has(d.name)) index.set(d.name, []);
|
|
967
|
+
index.get(d.name).push(d);
|
|
968
|
+
if (d.reported) reported.push(d);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const reportedNames = new Set(reported.map((d) => d.name));
|
|
972
|
+
const R = makeResolver(index, reportedNames);
|
|
973
|
+
|
|
974
|
+
const trace = { sites: 0, edges: 0, holes: 0, internal: 0, skipped: 0, unresolvedInternal: new Set() };
|
|
975
|
+
const siteCache = new Map();
|
|
976
|
+
const sitesOf = (fn, ctx) => {
|
|
977
|
+
const key = `${fn.decl.rel}:${fn.line}@${ctx.rel}:${ctx.name}`;
|
|
978
|
+
if (!siteCache.has(key)) siteCache.set(key, fn.bodyOpen == null ? [] : scanBody(fn, R, ctx));
|
|
979
|
+
return siteCache.get(key);
|
|
980
|
+
};
|
|
981
|
+
const toEdge = (s, through = null) => edge({
|
|
982
|
+
kind: s.kind, target: s.cls === "edge" ? s.target : null, raw: s.raw, through,
|
|
983
|
+
external: s.cls === "edge" ? !reportedNames.has(s.target) : false,
|
|
984
|
+
self: Boolean(s.meta?.selfCall), meta: s.meta ?? {},
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* An entry's own edges plus those of every helper it reaches, tagged with the first helper on the
|
|
989
|
+
* path. Helpers in this code are always followed. Vendored helpers are followed only for an entry
|
|
990
|
+
* that is itself vendored (an inherited `deposit()`), and only a few levels deep — enough to show
|
|
991
|
+
* the token pull inside OpenZeppelin's deposit, not enough to trace all of OpenZeppelin.
|
|
992
|
+
*/
|
|
993
|
+
const edgesOf = (fn, ctx, { vendored = false, seen = new Set(), through = null, depth = 0 } = {}) => {
|
|
994
|
+
const out = [];
|
|
995
|
+
for (const site of sitesOf(fn, ctx)) {
|
|
996
|
+
if (site.cls === "edge" || site.cls === "hole") out.push(toEdge(site, through));
|
|
997
|
+
else if (site.cls === "internal") {
|
|
998
|
+
for (const impl of site.impls ?? []) {
|
|
999
|
+
if (!impl.decl.reported && !(vendored && depth < 3)) continue;
|
|
1000
|
+
const key = `${impl.decl.rel}:${impl.line}`;
|
|
1001
|
+
if (seen.has(key)) continue;
|
|
1002
|
+
seen.add(key);
|
|
1003
|
+
out.push(...edgesOf(impl, ctx, { vendored, seen, through: through ?? impl.name, depth: depth + 1 }));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return out;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const buildEntry = (fn, ctx, inheritedFrom = null) => {
|
|
1011
|
+
const chain = R.chainOf(ctx);
|
|
1012
|
+
const declaredOnly = fn.bodyOpen == null;
|
|
1013
|
+
const text = fn.decl;
|
|
1014
|
+
|
|
1015
|
+
// Modifiers: a PascalCase name in the signature is a base-constructor call, not a modifier.
|
|
1016
|
+
const mods = fn.modifiers.filter((w) => !/^[A-Z]/.test(w) && !index.has(w));
|
|
1017
|
+
const access = [];
|
|
1018
|
+
const plain = [];
|
|
1019
|
+
for (const w of mods) {
|
|
1020
|
+
const bodies = modifierBodies(w, chain, index);
|
|
1021
|
+
const checks = bodies.map((mo) => checksOf(mo.decl, mo.bodyOpen, mo.bodyClose, R, chain));
|
|
1022
|
+
const checked = bodies.length ? checks.every((c) => c.length) : /^only[A-Z_]/.test(w);
|
|
1023
|
+
(checked ? access : plain).push(w);
|
|
1024
|
+
}
|
|
1025
|
+
const body = declaredOnly ? [] : checksOf(text, fn.bodyOpen, fn.bodyClose, R, chain);
|
|
1026
|
+
const authority = [...new Set([...access, ...body.map((c) => c.who)])];
|
|
1027
|
+
// Helpers and modifiers the function relies on whose code could not be read. An access check may
|
|
1028
|
+
// live there; the page says so instead of letting "no check found" read as "anyone".
|
|
1029
|
+
const unread = [...(body.unread ?? []), ...mods.filter((w) => !modifierBodies(w, chain, index).length && !access.includes(w)).map((w) => `modifier ${w}`)];
|
|
1030
|
+
|
|
1031
|
+
const edges = declaredOnly ? [] : edgesOf(fn, ctx, { vendored: Boolean(inheritedFrom), seen: new Set([`${text.rel}:${fn.line}`]) });
|
|
1032
|
+
const unique = [];
|
|
1033
|
+
const keys = new Set();
|
|
1034
|
+
for (const e of edges) {
|
|
1035
|
+
const k = `${e.kind}|${e.target}|${e.raw}|${e.through ?? ""}`;
|
|
1036
|
+
if (!keys.has(k)) { keys.add(k); unique.push(e); }
|
|
1037
|
+
}
|
|
1038
|
+
if (!inheritedFrom) {
|
|
1039
|
+
for (const site of sitesOf(fn, ctx)) {
|
|
1040
|
+
trace.sites++;
|
|
1041
|
+
if (site.cls === "edge") trace.edges++;
|
|
1042
|
+
else if (site.cls === "hole") trace.holes++;
|
|
1043
|
+
else if (site.cls === "internal") trace.internal++;
|
|
1044
|
+
else trace.skipped++;
|
|
1045
|
+
if (site.unresolvedInternal) trace.unresolvedInternal.add(site.name);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const textGuards = declaredOnly ? [] : text.src.slice(fn.bodyOpen + 1, fn.bodyClose).split("\n").map((l) => l.trim())
|
|
1050
|
+
.filter((l) => /^(require\s*\(|revert\b|assert\s*\(|if\s*\(.*\)\s*revert\b)/.test(l)).map((l) => l.slice(0, 200));
|
|
1051
|
+
const helperGuards = body.filter((c) => c.where.includes("→")).map((c) => c.where.slice(0, 200));
|
|
1052
|
+
|
|
1053
|
+
const built = entry({
|
|
1054
|
+
name: fn.name, unit: ctx.name, module: text.rel.replace(/\.sol$/, ""), path: text.rel,
|
|
1055
|
+
line: fn.line, endLine: fn.endLine,
|
|
1056
|
+
authority,
|
|
1057
|
+
args: fn.params.filter((x) => x.name || x.type),
|
|
1058
|
+
guards: [...plain.map((w) => `modifier ${w}`), ...helperGuards, ...textGuards],
|
|
1059
|
+
edges: unique,
|
|
1060
|
+
effect: fn.mutability === "view" || fn.mutability === "pure" ? EFFECT.NONE
|
|
1061
|
+
: unique.some((e) => e.kind === EDGE.DESTROY && !e.through) ? EFFECT.TERMINAL
|
|
1062
|
+
: EFFECT.TRANSITION,
|
|
1063
|
+
returns: fn.returns.map((r) => r.type).join(", ") || null,
|
|
1064
|
+
source: declaredOnly ? null : text.raw.split("\n").slice(fn.line - 1, fn.endLine).join("\n"),
|
|
1065
|
+
});
|
|
1066
|
+
// Declared but not implemented here — an interface or abstract signature. Not code anyone can
|
|
1067
|
+
// run, so never offered as a way in.
|
|
1068
|
+
built.declared = declaredOnly;
|
|
1069
|
+
// A constructor runs once, when the contract is deployed, by whoever deploys it.
|
|
1070
|
+
built.deployOnly = fn.kind === "constructor";
|
|
1071
|
+
// Written in a vendored parent (OpenZeppelin's `deposit`), but part of THIS contract's surface.
|
|
1072
|
+
built.inherited = inheritedFrom;
|
|
1073
|
+
built.unread = unread;
|
|
1074
|
+
return built;
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const arity = (fn) => `${fn.name}/${fn.params.filter((x) => x.type).length}`;
|
|
1078
|
+
const units = [];
|
|
1079
|
+
const modules = new Map();
|
|
1080
|
+
for (const d of reported) {
|
|
1081
|
+
const chain = R.chainOf(d);
|
|
1082
|
+
const entries = [];
|
|
1083
|
+
for (const fn of d.functions) {
|
|
1084
|
+
const isEntry = fn.visibility === "external" || fn.visibility === "public"
|
|
1085
|
+
|| fn.kind === "constructor" || fn.kind === "receive" || fn.kind === "fallback";
|
|
1086
|
+
if (isEntry) entries.push(buildEntry(fn, d));
|
|
1087
|
+
}
|
|
1088
|
+
// A deployable contract's public surface includes what it inherits from vendored code — a vault
|
|
1089
|
+
// built on OpenZeppelin's ERC4626 is deposited into through OpenZeppelin's `deposit()`, which then
|
|
1090
|
+
// runs this repo's `_deposit` override. Without this, the vault's main user flows were absent.
|
|
1091
|
+
// In-repo parents keep their functions on their own cards; only vendored ones are brought in.
|
|
1092
|
+
if (d.kind === "contract" && !d.abstract) {
|
|
1093
|
+
const seenSig = new Set();
|
|
1094
|
+
for (const anc of chain) {
|
|
1095
|
+
for (const fn of anc.functions) {
|
|
1096
|
+
const sig = arity(fn);
|
|
1097
|
+
if (seenSig.has(sig)) continue;
|
|
1098
|
+
seenSig.add(sig);
|
|
1099
|
+
if (anc === d || anc.reported || anc.kind === "interface" || fn.bodyOpen == null) continue;
|
|
1100
|
+
if (fn.kind !== "function" || !(fn.visibility === "public" || fn.visibility === "external")) continue;
|
|
1101
|
+
entries.push(buildEntry(fn, d, anc.name));
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const u = unit({
|
|
1106
|
+
name: d.name, module: d.rel.replace(/\.sol$/, ""), path: d.rel,
|
|
1107
|
+
line: d.line, endLine: d.endLine,
|
|
1108
|
+
signatories: [], observers: [], fields: [...d.fields].map(([name, type]) => ({ name, type })),
|
|
1109
|
+
invariants: [], keys: [], entries,
|
|
1110
|
+
});
|
|
1111
|
+
u.kind = d.kind;
|
|
1112
|
+
u.abstract = d.abstract;
|
|
1113
|
+
u.inherits = d.inherits;
|
|
1114
|
+
units.push(u);
|
|
1115
|
+
if (!modules.has(d.rel)) modules.set(d.rel, []);
|
|
1116
|
+
modules.get(d.rel).push(u);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const out = model({
|
|
1120
|
+
language: "solidity", root,
|
|
1121
|
+
modules: [...modules].map(([rel, us]) => ({
|
|
1122
|
+
module: rel.replace(/\.sol$/, ""), path: rel,
|
|
1123
|
+
units: us.map((u) => u.name), functions: us.reduce((n, u) => n + u.entries.length, 0),
|
|
1124
|
+
})),
|
|
1125
|
+
units, entries: units.flatMap((u) => u.entries),
|
|
1126
|
+
notes: [
|
|
1127
|
+
...(files.length === 0 ? ["no .sol source found under this path (pass --include-libs to include vendored dependencies)"] : []),
|
|
1128
|
+
...(missing.length ? [`${missing.length} imported file(s) are not on disk (e.g. ${missing[0]}). Whatever they define — inherited functions, modifiers, access checks — could not be read, so a function guarded there may be shown as open. Run \`forge install\` or \`git submodule update --init\` and re-run.`] : []),
|
|
1129
|
+
],
|
|
1130
|
+
});
|
|
1131
|
+
out.trace = { ...trace, unresolvedInternal: [...trace.unresolvedInternal].sort(), missingImports: missing };
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Every classified call site, for auditing the adapter itself. Not part of the model. */
|
|
1136
|
+
export function traceSites(root, opts = {}) {
|
|
1137
|
+
const files = findSources(root, opts);
|
|
1138
|
+
const base = fs.statSync(root).isFile() ? path.dirname(root) : root;
|
|
1139
|
+
const reportedFiles = new Set(files);
|
|
1140
|
+
const { readable } = importClosure(files, root);
|
|
1141
|
+
const index = new Map();
|
|
1142
|
+
const reported = [];
|
|
1143
|
+
for (const f of readable) {
|
|
1144
|
+
for (const d of parseFile(f, base, reportedFiles.has(f))) {
|
|
1145
|
+
if (!index.has(d.name)) index.set(d.name, []);
|
|
1146
|
+
index.get(d.name).push(d);
|
|
1147
|
+
if (d.reported) reported.push(d);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
const R = makeResolver(index, new Set(reported.map((d) => d.name)));
|
|
1151
|
+
const rows = [];
|
|
1152
|
+
for (const d of reported) {
|
|
1153
|
+
for (const fn of d.functions) {
|
|
1154
|
+
if (fn.bodyOpen == null) continue;
|
|
1155
|
+
for (const s of scanBody(fn, R, d)) {
|
|
1156
|
+
rows.push({ unit: d.name, fn: fn.name, fnLine: fn.line, visibility: fn.visibility, path: d.rel, line: s.line,
|
|
1157
|
+
cls: s.cls, kind: s.kind ?? null, target: s.target ?? null, name: s.name ?? null,
|
|
1158
|
+
why: s.why ?? null, meta: s.meta ?? null, raw: s.raw,
|
|
1159
|
+
body: d.mk.slice(fn.bodyOpen + 1, fn.bodyClose) });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return rows;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export default { parse, findSources, stripComments, language: "solidity" };
|