@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-16
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- **Daml and Solidity** source, detected automatically.
|
|
8
|
+
- **The map** (`--html`): who can act, split into outside and inside the protocol; each action's
|
|
9
|
+
archives, creates and calls in code order, with **Play**; whose authority an action carries beyond
|
|
10
|
+
its caller; which contract ids the caller chooses; each contract's life; blind spots in amber.
|
|
11
|
+
One self-contained file, deterministic, with a guided tour and a Learn view drawn from your code.
|
|
12
|
+
- **Solidity tracing**: every call site classified — typed variables, casts, libraries, `this.`, ETH
|
|
13
|
+
and low-level calls — with nothing dropped. Access checks followed through helpers, inheritance
|
|
14
|
+
and vendored modifiers, credited only when they run on every path. Inherited public functions from
|
|
15
|
+
vendored parents appear on the contracts that run them. Imports resolved through remappings,
|
|
16
|
+
node_modules and nested dependencies; missing ones reported.
|
|
17
|
+
- **Daml tracing**: controllers, signatories, consuming effects, helper functions folded into the
|
|
18
|
+
choices that call them, test packages excluded.
|
|
19
|
+
- `--surface`, `--holes`, `--graph`, `--json` (schemaVersion 1), `--min-resolution` for CI,
|
|
20
|
+
`--demo`, `-o`.
|
|
21
|
+
- **GitHub Action** (`action.yml`): builds the map, uploads it, writes a run summary, and can fail
|
|
22
|
+
below a resolution floor.
|
package/GUIDE.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# How to use sourcetruth
|
|
2
|
+
|
|
3
|
+
The same guide, with a live demo, is at **https://prajwalghate.github.io/sourcetruth/**.
|
|
4
|
+
|
|
5
|
+
sourcetruth reads Daml or Solidity source and draws what it does: who can act, what each action
|
|
6
|
+
changes, and what could not be traced. You do the judging. This guide gets you from install to a
|
|
7
|
+
first audit, then covers every option.
|
|
8
|
+
|
|
9
|
+
- [1. Install](#1-install)
|
|
10
|
+
- [2. First look: the demo](#2-first-look-the-demo)
|
|
11
|
+
- [3. A first audit, step by step](#3-a-first-audit-step-by-step)
|
|
12
|
+
- [4. Reading the map](#4-reading-the-map)
|
|
13
|
+
- [5. Pointing it at your code](#5-pointing-it-at-your-code)
|
|
14
|
+
- [6. Command-line reference](#6-command-line-reference)
|
|
15
|
+
- [7. In CI](#7-in-ci)
|
|
16
|
+
- [8. What it cannot tell you](#8-what-it-cannot-tell-you)
|
|
17
|
+
- [9. Troubleshooting](#9-troubleshooting)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Install
|
|
22
|
+
|
|
23
|
+
Node 22 or newer. Nothing else.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g @prajwalghate/sourcetruth
|
|
27
|
+
sourcetruth --version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or without installing, from a clone:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone git@github.com:prajwalghate/sourcetruth.git
|
|
34
|
+
node sourcetruth/bin/sourcetruth.mjs --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. First look: the demo
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
sourcetruth --demo -o demo.html # a small Solidity vault
|
|
43
|
+
sourcetruth --demo daml -o demo-daml.html # a small Daml lending protocol
|
|
44
|
+
open demo.html
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first time the map opens, a short tour clicks through it for you. The two demos are written to
|
|
48
|
+
show everything the map can draw — an open function, a guard hidden in a helper, borrowed authority,
|
|
49
|
+
a caller-chosen contract id, and a blind spot — so they are the fastest way to learn to read it.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 3. A first audit, step by step
|
|
54
|
+
|
|
55
|
+
This walks the Solidity demo. Every step is one click.
|
|
56
|
+
|
|
57
|
+
1. **Look at the top bar.** `94% traced · 1 blind spot`. Nearly everything the code does could be
|
|
58
|
+
followed. Click **1 blind spot**: `Vault.execute` does a `delegatecall` to an address the caller
|
|
59
|
+
passes in. That is a line to read by hand.
|
|
60
|
+
|
|
61
|
+
2. **Look at who can act.** On the left: `anyone` is *outside*; `onlyOwner`, `vault` and
|
|
62
|
+
`keepers[msg.sender]` are *inside*. Anything under `anyone` is callable with no access check.
|
|
63
|
+
|
|
64
|
+
3. **Start where outsiders get in.** The panel on the right lists the actions an outsider can take,
|
|
65
|
+
riskiest first. `takeFee` is there — no check, and it moves tokens.
|
|
66
|
+
|
|
67
|
+
4. **Open it and press Play.** The map shows `takeFee` writing the vault's storage and calling
|
|
68
|
+
`IERC20` — a transfer to the owner, of an amount the caller chooses.
|
|
69
|
+
|
|
70
|
+
5. **Check a guarded one.** Click `harvest`. It is guarded by `keepers[msg.sender]`, and the panel
|
|
71
|
+
shows where that check lives: `_onlyKeeper() → require(keepers[msg.sender])`. The guard is a
|
|
72
|
+
helper call, not a modifier — sourcetruth follows those.
|
|
73
|
+
|
|
74
|
+
6. **Follow the flow.** In `harvest`, press Play: `IStrategy.harvest`, then the fee transfer, then
|
|
75
|
+
`_invest` sending funds back to the strategy. Click `IStrategy` to see the strategy's side.
|
|
76
|
+
|
|
77
|
+
7. **Read the code.** Every action has a **Code** button that opens its source, comments intact for
|
|
78
|
+
reading — though nothing on the map was derived from them.
|
|
79
|
+
|
|
80
|
+
The Daml demo reads the same way. There, open `Position.Liquidate`: the liquidator **names
|
|
81
|
+
themselves**, passes in the `priceCid` it is checked against, and the action runs with the
|
|
82
|
+
authority of `operator` **and** `owner` — the signers of the position, not just the caller.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 4. Reading the map
|
|
87
|
+
|
|
88
|
+
| On screen | What it shows | What to do with it |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| **Outside / Inside** (left, or across the top on a small screen) | Every party the code lets act. *Outside* can act without the protocol's own parties. | Click one: the map lights up every contract they can reach. |
|
|
91
|
+
| **The map** | Contracts as cards. Solid arrows *create*, dashed arrows *call*. Dashed cards are defined outside the code you pointed at. | Drag to pan, pinch or ⌘-scroll to zoom, **Fit** to see everything. |
|
|
92
|
+
| **Where can someone outside get in?** | Actions an outsider can take alone, riskiest first. | Start at the top. |
|
|
93
|
+
| **An action** | Who fires it; what it does to its own contract; **whose authority it also carries**; which contract ids the caller picks; every archive, create and call in code order. | Press **Play** (or `P`). |
|
|
94
|
+
| **A contract** | Its life: made by → replaced by → ended by, and who reads it. | Look for *Nothing in this code ends it* on anything that holds value. |
|
|
95
|
+
| **Blind spots** (amber) | Calls whose target can't be named from source. | Read them before trusting the rest. |
|
|
96
|
+
| **Learn** | Each idea of the language, drawn with contracts from your code. | Read once if the language is new to you. |
|
|
97
|
+
| **Code** | The plain listing: every contract, action, authority, link and source body. | Look things up. It is also the whole page with JavaScript off. |
|
|
98
|
+
|
|
99
|
+
**Colours.** Green made · red archived or ended · blue called or selected · violet authority that is
|
|
100
|
+
not the caller's own (a signer's, or no access check at all) · amber could not trace.
|
|
101
|
+
|
|
102
|
+
**Typefaces.** Monospace is copied from the source. Sans-serif is the tool talking.
|
|
103
|
+
|
|
104
|
+
`Esc` goes back a step. Links open the map at a place, on load or while it is open: `#e-Vault-takeFee` an action, `#u-Vault` a contract, `#a-anyone` a party, `#learn` the Learn view, `#map` the start — so a runbook or a ticket can point straight at what it means.
|
|
105
|
+
|
|
106
|
+
### How "outside" is decided
|
|
107
|
+
|
|
108
|
+
From the code, never from names.
|
|
109
|
+
|
|
110
|
+
- **Daml:** the parties who sign the contracts nothing in the code creates are *inside*, and so are
|
|
111
|
+
the signers of any contract only ever created with an inside party's consent. A party who can act
|
|
112
|
+
without any of them is *outside*.
|
|
113
|
+
- **Solidity:** a function with no access check is callable by *anyone*. A named check is a
|
|
114
|
+
privilege, and whoever holds it is *inside*.
|
|
115
|
+
|
|
116
|
+
### How Solidity access checks are found
|
|
117
|
+
|
|
118
|
+
A check counts when it runs on every path: in a modifier that checks the caller, or as a top-level
|
|
119
|
+
`require` / `if (...) revert` on `msg.sender` — in the function itself or in a helper it calls,
|
|
120
|
+
through inheritance and into vendored libraries. A check inside an `if` does **not** count: claiming a
|
|
121
|
+
guard that might not run would hide an open function. `a || b` means either may call.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 5. Pointing it at your code
|
|
126
|
+
|
|
127
|
+
Point it at a repository root, or at the folder that holds the source. The language is detected.
|
|
128
|
+
|
|
129
|
+
**Daml.** `.daml` files are read recursively. Skipped: `.daml/` build output, and test packages —
|
|
130
|
+
any package whose `daml.yaml` name ends in `-test` or `-tests`, and directories named `test`/`tests`.
|
|
131
|
+
|
|
132
|
+
**Solidity.** `.sol` files are read recursively. Skipped by default:
|
|
133
|
+
|
|
134
|
+
- your dependencies in `lib/` (Foundry) and `node_modules/` — still **read**, to follow inheritance
|
|
135
|
+
and access checks, but not drawn as your code (`--include-libs` draws them);
|
|
136
|
+
- tests and scripts: `test/`, `tests/`, `script/`, `scripts/`, `TestContracts/`, `mocks/`, `*.t.sol`,
|
|
137
|
+
`*.s.sol` (`--include-tests` brings them back).
|
|
138
|
+
|
|
139
|
+
Imports are followed the way the compiler would: relative paths, `remappings.txt` and `foundry.toml`
|
|
140
|
+
remappings, `node_modules`, and nested dependencies. Public functions a contract inherits from a
|
|
141
|
+
vendored parent — `deposit()` from an ERC4626 base, say — appear on that contract, running the
|
|
142
|
+
contract's own overrides.
|
|
143
|
+
|
|
144
|
+
**If dependencies are missing** — typically a git submodule that was never initialised — the map
|
|
145
|
+
says so at the top, and every function whose access might depend on the missing code is marked. Run
|
|
146
|
+
`forge install` or `git submodule update --init` and generate the map again.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 6. Command-line reference
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
sourcetruth <dir> [mode] [options]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Mode | Output |
|
|
157
|
+
|---|---|
|
|
158
|
+
| *(none)* | Summary and blind spots in the terminal |
|
|
159
|
+
| `--html` | The interactive map, one self-contained file |
|
|
160
|
+
| `--surface` | State-changing actions at most one party can take alone |
|
|
161
|
+
| `--holes` | Only the calls that could not be traced |
|
|
162
|
+
| `--graph` | Contract-to-contract creates and calls |
|
|
163
|
+
| `--json` | The full model (see below) |
|
|
164
|
+
|
|
165
|
+
| Option | |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `-o, --out <file>` | Write to a file instead of stdout |
|
|
168
|
+
| `--title <text>` | Name the map (default: the directory name) |
|
|
169
|
+
| `--lang daml\|evm` | Force the language |
|
|
170
|
+
| `--min-resolution N` | Exit 1 if less than N% of links could be traced |
|
|
171
|
+
| `--include-tests` | Include test packages and test contracts |
|
|
172
|
+
| `--include-libs` | Draw vendored dependencies as if they were your code |
|
|
173
|
+
| `--dated` | Stamp today's date on the map (off by default, so maps from the same commit are identical) |
|
|
174
|
+
| `--demo [daml\|solidity]` | Render a bundled example |
|
|
175
|
+
| `-v, --version` · `-h, --help` | |
|
|
176
|
+
|
|
177
|
+
**Exit codes.** `0` success · `1` nothing found, or below `--min-resolution` · `2` bad usage.
|
|
178
|
+
Blind spots alone never fail a run.
|
|
179
|
+
|
|
180
|
+
**JSON.** `--json` prints `{ "schemaVersion": 1, "tool": {...}, "units": [...], "entries": [...], ... }`.
|
|
181
|
+
`schemaVersion` changes only when a field changes meaning or is removed.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# every state-changing function with no access check
|
|
185
|
+
sourcetruth ./src --json | jq -r '.entries[] | select(.authority == [] and .effect != "none" and (.declared|not)) | "\(.unit).\(.name)"'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 7. In CI
|
|
191
|
+
|
|
192
|
+
### GitHub Actions
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
name: sourcetruth
|
|
196
|
+
on: [pull_request]
|
|
197
|
+
jobs:
|
|
198
|
+
map:
|
|
199
|
+
runs-on: ubuntu-latest
|
|
200
|
+
steps:
|
|
201
|
+
- uses: actions/checkout@v4
|
|
202
|
+
with:
|
|
203
|
+
submodules: recursive # so inherited code and access checks can be read
|
|
204
|
+
- uses: prajwalghate/sourcetruth@v0
|
|
205
|
+
with:
|
|
206
|
+
path: ./contracts
|
|
207
|
+
min-resolution: 90
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Inputs: `path`, `title`, `min-resolution` (0 = off), `output` (default `sourcetruth-map.html`),
|
|
211
|
+
`include-tests`, `upload`. Outputs: `resolution`, `blind-spots`. Each run uploads the map as the
|
|
212
|
+
`sourcetruth-map` artifact and writes a summary table.
|
|
213
|
+
|
|
214
|
+
### Anywhere else
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
npx @prajwalghate/sourcetruth ./contracts --min-resolution 90
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Pick the floor from today's number, not from 100: if you are at 94%, set 90. When a change makes the
|
|
221
|
+
number drop, a construct the tool can't follow has arrived — worth knowing on the pull request.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 8. What it cannot tell you
|
|
226
|
+
|
|
227
|
+
- **Whether anything is a bug.** It draws; you judge.
|
|
228
|
+
- **Runtime facts.** Which contract id a caller will actually pass, who holds a role, what a proxy
|
|
229
|
+
currently points at, what value a price has.
|
|
230
|
+
- **Branches.** It cannot tell which path runs. An action that archives a contract and creates one
|
|
231
|
+
is shown as possibly replacing it *and* possibly ending it.
|
|
232
|
+
- **Arithmetic.** Overflow, rounding, decimals — none of it.
|
|
233
|
+
- **Inside a blind spot.** A `delegatecall` to a caller's address, an assembly `call`, a receiver
|
|
234
|
+
whose type can't be worked out: shown, never followed.
|
|
235
|
+
- **Code that isn't on disk.** Missing imports are reported, not guessed.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 9. Troubleshooting
|
|
240
|
+
|
|
241
|
+
**"0 contracts found."** You pointed at build output or an empty folder. Point at the repository
|
|
242
|
+
root or the source folder.
|
|
243
|
+
|
|
244
|
+
**A function you know is guarded shows as `anyone`.** Check the top of the map for a *not on disk*
|
|
245
|
+
note — the guard may live in a dependency that isn't installed. If everything resolved, the check
|
|
246
|
+
may be inside an `if`, which is deliberately not credited. The **Code** button shows the source.
|
|
247
|
+
|
|
248
|
+
**A contract is missing.** It may be in a skipped folder (`test/`, `mocks/`, `TestContracts/`,
|
|
249
|
+
`lib/`). Use `--include-tests` or `--include-libs`.
|
|
250
|
+
|
|
251
|
+
**Resolution is lower than you expected.** Run `sourcetruth <dir> --holes` and look at the lines:
|
|
252
|
+
they share a pattern, and that pattern is what the tool couldn't follow.
|
|
253
|
+
|
|
254
|
+
**The map looks empty.** Click **Fit** (bottom right). On very large codebases the opening view
|
|
255
|
+
zooms in on the riskiest action so the cards are readable.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prajwal Ghate
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# sourcetruth
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@prajwalghate/sourcetruth)
|
|
4
|
+
[](https://github.com/prajwalghate/sourcetruth/actions/workflows/test.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**[Documentation](https://prajwalghate.github.io/sourcetruth/)** · **[Live demo](https://prajwalghate.github.io/sourcetruth/demo)** · [Guide](GUIDE.md)
|
|
8
|
+
|
|
9
|
+
See what smart-contract code **does** — who can act, and what each action changes — straight from
|
|
10
|
+
the source. And see, just as plainly, what it **could not trace**.
|
|
11
|
+
|
|
12
|
+
Works on **Daml** (Canton) and **Solidity**. One command, one self-contained HTML file, no server.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
sourcetruth --demo -o demo.html # a first look, on a bundled example
|
|
16
|
+
sourcetruth ./contracts --html -o map.html
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
A map of the protocol you can click through:
|
|
22
|
+
|
|
23
|
+
- **Who can act** — every party the code lets take an action, split into those *outside* the
|
|
24
|
+
protocol and those running it. Worked out from the code, not from what things are called.
|
|
25
|
+
- **What an action does** — pick one and press **Play**: what it archives, what it creates, what it
|
|
26
|
+
calls, in the order the code does them, and whose authority it carries beyond the caller's own.
|
|
27
|
+
- **Each contract's life** — what makes it, what replaces it, what ends it.
|
|
28
|
+
- **Blind spots** — every call whose target can't be named from source is shown in amber. The tool
|
|
29
|
+
never guesses one and never drops one.
|
|
30
|
+
|
|
31
|
+
Comments are stripped before anything is read. If a comment says a function is admin-only and the
|
|
32
|
+
code says otherwise, the map shows the code.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Node 22 or newer. No dependencies.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @prajwalghate/sourcetruth
|
|
40
|
+
sourcetruth --version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Use it
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sourcetruth <dir> # summary + blind spots in the terminal
|
|
47
|
+
sourcetruth <dir> --html -o map.html # the interactive map
|
|
48
|
+
sourcetruth <dir> --surface # actions one party can take alone
|
|
49
|
+
sourcetruth <dir> --json # the full model, for your own tooling
|
|
50
|
+
sourcetruth <dir> --min-resolution 90 # for CI: fail if too little can be traced
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**[GUIDE.md](GUIDE.md)** walks through a first audit, reading the map, every option, CI, and what
|
|
54
|
+
the tool cannot tell you.
|
|
55
|
+
|
|
56
|
+
## In CI
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
- uses: prajwalghate/sourcetruth@v0
|
|
60
|
+
with:
|
|
61
|
+
path: ./contracts
|
|
62
|
+
min-resolution: 90
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The map is uploaded as a workflow artifact and a summary is written to the run.
|
|
66
|
+
|
|
67
|
+
## Contributing
|
|
68
|
+
|
|
69
|
+
Bug reports with a small piece of code that shows the problem are the most useful thing you can send
|
|
70
|
+
— unfamiliar code is how most of this tool's bugs have been found. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
71
|
+
|
|
72
|
+
## What it is not
|
|
73
|
+
|
|
74
|
+
Not a bug finder. It has no vulnerability patterns and makes no judgement about whether code is
|
|
75
|
+
wrong. It draws what the code does so a person can decide — and it stores no findings.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sourcetruth — read what contract code does, from source, and say what could not be determined.
|
|
3
|
+
//
|
|
4
|
+
// Exit codes: 0 parsed, 1 nothing found or below --min-resolution, 2 bad usage. Holes are NOT a
|
|
5
|
+
// failure — an honest 80% is the output, not an error.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS IS A FUNCTION WITH RETURNS, and never calls process.exit():
|
|
8
|
+
// stdout to a pipe is ASYNCHRONOUS. `process.exit()` tears the process down before the buffer
|
|
9
|
+
// drains, and `--json` on a real codebase was truncating at exactly 65282 bytes — a 64 KB pipe
|
|
10
|
+
// buffer — emitting JSON that ended mid-string. It looked perfect in a terminal, where stdout is
|
|
11
|
+
// synchronous, and broke the instant anyone piped it to jq. Set `process.exitCode` and return;
|
|
12
|
+
// Node then exits on its own once the buffer has flushed.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import daml from "../src/adapters/daml.mjs";
|
|
18
|
+
import evm from "../src/adapters/evm.mjs";
|
|
19
|
+
import { graph, attackSurface } from "../src/model.mjs";
|
|
20
|
+
import { render as renderHtml } from "../src/report.mjs";
|
|
21
|
+
|
|
22
|
+
const PKG = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
23
|
+
const ADAPTERS = { daml, evm, solidity: evm };
|
|
24
|
+
|
|
25
|
+
/** The JSON shape's version. Bump it when a field changes meaning or goes away — tools key off it. */
|
|
26
|
+
const SCHEMA_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
const USAGE = `sourcetruth ${PKG.version} — see what contract code does, from source
|
|
29
|
+
|
|
30
|
+
Usage
|
|
31
|
+
sourcetruth <dir> [mode] [options]
|
|
32
|
+
|
|
33
|
+
Modes (pick one; the default prints a summary and the blind spots)
|
|
34
|
+
--html the interactive map, as one self-contained HTML file
|
|
35
|
+
--surface state-changing actions that at most one party can take alone
|
|
36
|
+
--holes only what could not be traced
|
|
37
|
+
--graph contract-to-contract calls and creates
|
|
38
|
+
--json the full model, for your own tooling
|
|
39
|
+
|
|
40
|
+
Options
|
|
41
|
+
-o, --out <file> write the output to a file instead of stdout
|
|
42
|
+
--title <text> name the map (default: the directory name)
|
|
43
|
+
--lang daml|evm force the language (default: detected)
|
|
44
|
+
--min-resolution N exit 1 if less than N% of links could be traced (for CI)
|
|
45
|
+
--include-tests include test packages, TestContracts/, mocks/, *.t.sol
|
|
46
|
+
--include-libs report vendored dependencies (lib/) as if they were this code
|
|
47
|
+
--dated stamp today's date on the map (off by default, so maps diff cleanly)
|
|
48
|
+
--demo [daml|solidity] render a bundled example instead of <dir> (default: solidity)
|
|
49
|
+
-v, --version print the version
|
|
50
|
+
-h, --help print this
|
|
51
|
+
|
|
52
|
+
Examples
|
|
53
|
+
sourcetruth --demo -o demo.html
|
|
54
|
+
sourcetruth ./contracts --html -o map.html
|
|
55
|
+
sourcetruth ./daml --surface
|
|
56
|
+
sourcetruth . --json | jq '.entries[] | select(.authority == []) | .name'
|
|
57
|
+
sourcetruth ./src --min-resolution 90`;
|
|
58
|
+
|
|
59
|
+
/** Pick an adapter by what the tree contains. Ties go to whichever has more files. */
|
|
60
|
+
function detectLanguage(root) {
|
|
61
|
+
try {
|
|
62
|
+
const nDaml = daml.findSources(root).length;
|
|
63
|
+
const nSol = evm.findSources(root).length;
|
|
64
|
+
if (nSol > nDaml) return "evm";
|
|
65
|
+
if (nDaml > 0) return "daml";
|
|
66
|
+
return nSol > 0 ? "evm" : "daml";
|
|
67
|
+
} catch { return "daml"; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function main(input) {
|
|
71
|
+
let argv = [...input];
|
|
72
|
+
const flag = (...names) => names.some((n) => argv.includes(n));
|
|
73
|
+
const opt = (names, d = null) => {
|
|
74
|
+
for (const n of [].concat(names)) { const i = argv.indexOf(n); if (i >= 0) return argv[i + 1] ?? d; }
|
|
75
|
+
return d;
|
|
76
|
+
};
|
|
77
|
+
// A bare word that is not the value of an option that takes one.
|
|
78
|
+
const VALUED = new Set(["--min-resolution", "--lang", "--title", "-o", "--out", "--demo"]);
|
|
79
|
+
let target = argv.find((a, i) => !a.startsWith("-") && !VALUED.has(argv[i - 1]));
|
|
80
|
+
|
|
81
|
+
if (flag("--help", "-h")) { console.log(USAGE); return 0; }
|
|
82
|
+
if (flag("--version", "-v")) { console.log(PKG.version); return 0; }
|
|
83
|
+
// A first look before pointing it at real code: render one of the bundled examples.
|
|
84
|
+
if (flag("--demo")) {
|
|
85
|
+
const which = String(opt("--demo", "solidity"));
|
|
86
|
+
const dir = /^(daml)$/i.test(which) ? "daml-lending" : "solidity-vault";
|
|
87
|
+
target = fileURLToPath(new URL(`../examples/${dir}`, import.meta.url));
|
|
88
|
+
if (!flag("--html", "--json", "--surface", "--holes", "--graph")) argv = [...argv, "--html"];
|
|
89
|
+
if (!argv.includes("--title")) argv = [...argv, "--title", `sourcetruth demo — ${dir}`];
|
|
90
|
+
}
|
|
91
|
+
if (!target) { console.error(USAGE); return 2; }
|
|
92
|
+
if (!fs.existsSync(target)) { console.error(`sourcetruth: no such file or directory: ${target}`); return 2; }
|
|
93
|
+
|
|
94
|
+
const lang = opt("--lang") ?? detectLanguage(path.resolve(target));
|
|
95
|
+
const adapter = ADAPTERS[lang];
|
|
96
|
+
if (!adapter) {
|
|
97
|
+
console.error(`no adapter for "${lang}" (have: ${Object.keys(ADAPTERS).join(", ")})`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let m;
|
|
102
|
+
try { m = adapter.parse(path.resolve(target), { includeLibs: flag("--include-libs"), includeTests: flag("--include-tests") }); }
|
|
103
|
+
catch (e) { console.error(`parse failed: ${e.message}`); return 1; }
|
|
104
|
+
|
|
105
|
+
// Output goes to stdout, or to --out. Either way it is written once, whole.
|
|
106
|
+
const outFile = opt(["-o", "--out"]);
|
|
107
|
+
const lines = [];
|
|
108
|
+
const say = (s = "") => lines.push(s);
|
|
109
|
+
const flush = () => {
|
|
110
|
+
const text = `${lines.join("\n")}\n`;
|
|
111
|
+
if (outFile) {
|
|
112
|
+
fs.writeFileSync(outFile, text);
|
|
113
|
+
console.error(`wrote ${outFile} (${Math.max(1, Math.round(Buffer.byteLength(text) / 1024))} KB)`);
|
|
114
|
+
} else process.stdout.write(text);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (flag("--json")) {
|
|
118
|
+
say(JSON.stringify({ schemaVersion: SCHEMA_VERSION, tool: { name: "sourcetruth", version: PKG.version }, ...m }, null, 2));
|
|
119
|
+
flush();
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (flag("--html")) {
|
|
124
|
+
// No timestamp unless asked: a report that differs every run cannot be diffed between commits,
|
|
125
|
+
// and "what changed since last week" is the question this format is for.
|
|
126
|
+
say(renderHtml(m, {
|
|
127
|
+
title: opt("--title", `sourcetruth — ${path.basename(path.resolve(target))}`),
|
|
128
|
+
generatedAt: flag("--dated") ? new Date().toISOString().slice(0, 10) : null,
|
|
129
|
+
}));
|
|
130
|
+
flush();
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (flag("--holes")) {
|
|
135
|
+
for (const h of m.holes) say(`${h.path} ${h.owner} ${h.kind} ${h.raw}`);
|
|
136
|
+
flush();
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (flag("--graph")) {
|
|
140
|
+
for (const e of graph(m).edges) {
|
|
141
|
+
say(`${e.from} --${e.kind}--> ${e.to}${e.external ? " [external]" : ""} (${e.via.join(", ")})`);
|
|
142
|
+
}
|
|
143
|
+
flush();
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
if (flag("--surface")) {
|
|
147
|
+
for (const e of attackSurface(m)) {
|
|
148
|
+
say(`${String(e.unguarded ? "ANYONE" : e.authority).padEnd(18)} ${`${e.unit}.${e.name}`.padEnd(38)} ${e.effect.padEnd(11)} moves:${e.moves} holes:${e.holes} ${e.path}:${e.line}`);
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Default: the summary an auditor wants first.
|
|
155
|
+
const s = m.stats;
|
|
156
|
+
say(`\n ${m.language} ${m.root}`);
|
|
157
|
+
say(` ${s.modules} module(s) ${s.units} unit(s) ${s.entries} entr(ies) ${s.edges} edge(s)`);
|
|
158
|
+
if (s.resolution !== null) {
|
|
159
|
+
say(`\n resolution ${s.resolution}% (${s.holes} edge(s) could not be resolved from source)`);
|
|
160
|
+
}
|
|
161
|
+
say(` solo-authority entries: ${s.soloAuthority} terminal entries: ${s.terminal}`);
|
|
162
|
+
for (const n of m.notes) say(` note: ${n}`);
|
|
163
|
+
|
|
164
|
+
if (m.holes.length) {
|
|
165
|
+
say(`\n HOLES — what this tool could not determine. Not guessed, not omitted:`);
|
|
166
|
+
const byOwner = new Map();
|
|
167
|
+
for (const h of m.holes) {
|
|
168
|
+
if (!byOwner.has(h.owner)) byOwner.set(h.owner, []);
|
|
169
|
+
byOwner.get(h.owner).push(h);
|
|
170
|
+
}
|
|
171
|
+
for (const [owner, hs] of [...byOwner].slice(0, 20)) {
|
|
172
|
+
say(` ${owner}`);
|
|
173
|
+
for (const h of hs.slice(0, 4)) say(` ${h.kind.padEnd(8)} ${h.raw.slice(0, 84)}`);
|
|
174
|
+
if (hs.length > 4) say(` … ${hs.length - 4} more`);
|
|
175
|
+
}
|
|
176
|
+
if (byOwner.size > 20) say(` … ${byOwner.size - 20} more entr(ies) with holes`);
|
|
177
|
+
}
|
|
178
|
+
flush();
|
|
179
|
+
|
|
180
|
+
const floor = opt("--min-resolution");
|
|
181
|
+
if (floor !== null && s.resolution !== null && s.resolution < Number(floor)) {
|
|
182
|
+
console.error(` resolution ${s.resolution}% is below the required ${floor}%`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
return s.units === 0 ? 1 : 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
-- A small lending protocol, written to show what sourcetruth draws. Not production code.
|
|
2
|
+
module Lending where
|
|
3
|
+
|
|
4
|
+
template Pool
|
|
5
|
+
with
|
|
6
|
+
operator : Party
|
|
7
|
+
rateBps : Int
|
|
8
|
+
where
|
|
9
|
+
signatory operator
|
|
10
|
+
|
|
11
|
+
nonconsuming choice OpenPosition : ContractId Position
|
|
12
|
+
with
|
|
13
|
+
owner : Party
|
|
14
|
+
collateral : Decimal
|
|
15
|
+
controller owner
|
|
16
|
+
do
|
|
17
|
+
assertMsg "collateral must be positive" (collateral > 0.0)
|
|
18
|
+
create Position with operator; owner; collateral; debt = 0.0
|
|
19
|
+
|
|
20
|
+
choice SetRate : ContractId Pool
|
|
21
|
+
with
|
|
22
|
+
newRate : Int
|
|
23
|
+
controller operator
|
|
24
|
+
do
|
|
25
|
+
create this with rateBps = newRate
|
|
26
|
+
|
|
27
|
+
template Position
|
|
28
|
+
with
|
|
29
|
+
operator : Party
|
|
30
|
+
owner : Party
|
|
31
|
+
collateral : Decimal
|
|
32
|
+
debt : Decimal
|
|
33
|
+
where
|
|
34
|
+
signatory operator, owner
|
|
35
|
+
|
|
36
|
+
choice Borrow : ContractId Position
|
|
37
|
+
with
|
|
38
|
+
amount : Decimal
|
|
39
|
+
priceCid : ContractId Price
|
|
40
|
+
controller owner
|
|
41
|
+
do
|
|
42
|
+
price <- fetch priceCid
|
|
43
|
+
assertMsg "undercollateralised" (collateral * price.value >= (debt + amount) * 1.5)
|
|
44
|
+
create this with debt = debt + amount
|
|
45
|
+
|
|
46
|
+
choice Repay : ContractId Position
|
|
47
|
+
with
|
|
48
|
+
amount : Decimal
|
|
49
|
+
controller owner
|
|
50
|
+
do
|
|
51
|
+
create this with debt = debt - amount
|
|
52
|
+
|
|
53
|
+
choice Close : ()
|
|
54
|
+
controller owner
|
|
55
|
+
do
|
|
56
|
+
assertMsg "debt outstanding" (debt == 0.0)
|
|
57
|
+
pure ()
|
|
58
|
+
|
|
59
|
+
choice Liquidate : ContractId Receipt
|
|
60
|
+
with
|
|
61
|
+
liquidator : Party
|
|
62
|
+
priceCid : ContractId Price
|
|
63
|
+
evidence : [ContractId Price]
|
|
64
|
+
controller liquidator
|
|
65
|
+
do
|
|
66
|
+
price <- fetch priceCid
|
|
67
|
+
let (primary, backup) = (priceCid, head evidence)
|
|
68
|
+
second <- fetch backup
|
|
69
|
+
assertMsg "position is healthy" (collateral * price.value < debt * 1.1 && price.value == second.value)
|
|
70
|
+
create Receipt with operator; liquidator; seized = collateral
|
|
71
|
+
|
|
72
|
+
template Receipt
|
|
73
|
+
with
|
|
74
|
+
operator : Party
|
|
75
|
+
liquidator : Party
|
|
76
|
+
seized : Decimal
|
|
77
|
+
where
|
|
78
|
+
signatory operator
|
|
79
|
+
|
|
80
|
+
template Price
|
|
81
|
+
with
|
|
82
|
+
oracle : Party
|
|
83
|
+
value : Decimal
|
|
84
|
+
where
|
|
85
|
+
signatory oracle
|
|
86
|
+
|
|
87
|
+
choice Update : ContractId Price
|
|
88
|
+
with
|
|
89
|
+
newValue : Decimal
|
|
90
|
+
controller oracle
|
|
91
|
+
do
|
|
92
|
+
create this with value = newValue
|