@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/src/view.mjs ADDED
@@ -0,0 +1,357 @@
1
+ // What the page draws — computed here and embedded as data.
2
+ //
3
+ // The browser only RENDERS. Every fact the map shows — who sits outside the protocol, which action
4
+ // carries whose authority, what a contract's life looks like, where each card sits — is decided in
5
+ // Node, from the parsed model, where it can be tested. A page that worked facts out at open time
6
+ // would show a picture nobody had checked.
7
+
8
+ import { EDGE, EFFECT } from "./model.mjs";
9
+ import { layout, CARD } from "./layout.mjs";
10
+
11
+ /**
12
+ * What an action does to the contract it is ON. Never "reads only": a nonconsuming Daml choice can
13
+ * mint and move value — it just does not consume itself — and a real delegated-mint choice was
14
+ * once labelled exactly that, next to a cell saying it creates a Holding.
15
+ */
16
+ export const EFFECT_LABEL = Object.freeze({
17
+ [EFFECT.NONE]: "repeatable",
18
+ [EFFECT.TRANSITION]: "changes it",
19
+ [EFFECT.TERMINAL]: "ends it",
20
+ });
21
+
22
+ const bare = (p) => String(p).trim().replace(/^\?/, "");
23
+
24
+ /** `"protocol, owner"` -> ["protocol", "owner"]. Daml signatories arrive as one source string. */
25
+ export function splitParties(list) {
26
+ return [...new Set((list ?? [])
27
+ .flatMap((s) => String(s).split(/,|::/))
28
+ .map((s) => s.trim().replace(/^[([]+|[)\]]+$/g, ""))
29
+ .filter((s) => /^[a-z_][\w']*$/.test(s)))];
30
+ }
31
+
32
+ /**
33
+ * Who runs the protocol, and who uses it — derived from the code, never from a list of names.
34
+ *
35
+ * A hard-coded operator list was tried and it was wrong in the most damaging direction: it treated
36
+ * `owner` as an operator because Solidity's `onlyOwner` means admin. In a Daml CDP, `owner` is the
37
+ * borrower — the protocol's main outside user — so every one of the borrower's actions vanished
38
+ * from "where outsiders get in".
39
+ *
40
+ * Daml: the parties who sign the contracts nothing in this code creates are INSIDE; so are the
41
+ * signers of any contract that is only ever created with an inside party's consent. Everyone else
42
+ * who can act without an inside party is OUTSIDE. A party who only ever acts alongside an inside
43
+ * one (an oracle pusher, a mint recipient) cannot do anything alone, so is grouped inside.
44
+ *
45
+ * Solidity: there are no signatories. A named access check is a privilege; no check is `anyone`.
46
+ */
47
+ export function sides(m) {
48
+ const daml = m.language === "daml";
49
+ const byName = new Map();
50
+ for (const u of m.units) if (!byName.has(u.name)) byName.set(u.name, u);
51
+ const signers = (name) => splitParties(byName.get(name)?.signatories);
52
+ const inside = new Set();
53
+
54
+ if (daml) {
55
+ const makers = new Map();
56
+ for (const e of m.entries) {
57
+ for (const x of e.edges) {
58
+ if (x.kind !== EDGE.CREATE || !x.resolved || x.target === e.unit || !byName.has(x.target)) continue;
59
+ if (!makers.has(x.target)) makers.set(x.target, []);
60
+ makers.get(x.target).push(e);
61
+ }
62
+ }
63
+ for (const u of m.units) if (!makers.has(u.name)) for (const p of signers(u.name)) inside.add(p);
64
+ for (let grew = true; grew;) {
65
+ grew = false;
66
+ for (const [target, es] of makers) {
67
+ // EVERY maker needs an inside party. With `some`, one admin migration path would make the
68
+ // borrower an operator.
69
+ if (!es.every((e) => e.authority.some((p) => inside.has(bare(p))))) continue;
70
+ for (const p of signers(target)) if (!inside.has(p)) { inside.add(p); grew = true; }
71
+ }
72
+ }
73
+ } else {
74
+ for (const e of m.entries) for (const p of e.authority) inside.add(bare(p));
75
+ }
76
+
77
+ const actors = new Map();
78
+ const touch = (name, i, optional) => {
79
+ // `expr`: the source names no party at all — `Set.toList (controllersOf x)`, `signatory this` —
80
+ // so who acts is decided at run time. Drawn differently, never with a person's initial.
81
+ if (!actors.has(name)) actors.set(name, { name, entries: [], optional: true, unseen: false, anyone: false,
82
+ expr: !/^[A-Za-z_][\w']*$/.test(name) });
83
+ const a = actors.get(name);
84
+ a.entries.push(i);
85
+ a.optional &&= optional;
86
+ };
87
+ m.entries.forEach((e, i) => {
88
+ // A declaration is not code, and a constructor runs once at deployment: neither is an action
89
+ // anyone takes, so neither may put a name in "who can act".
90
+ if (e.declared || e.deployOnly) return;
91
+ if (e.authority.length === 0) {
92
+ // In Solidity an empty authority IS the fact: anyone. In Daml every choice names a
93
+ // controller, so an empty list is a parse gap — and saying "anyone" would be inventing it.
94
+ if (daml) { touch("(unreadable)", i, false); actors.get("(unreadable)").unseen = true; }
95
+ else { touch("anyone", i, false); actors.get("anyone").anyone = true; }
96
+ return;
97
+ }
98
+ for (const p of e.authority) touch(bare(p), i, String(p).trim().startsWith("?"));
99
+ });
100
+ const alone = (e) => e.authority.length > 0 && e.authority.every((p) => !inside.has(bare(p)));
101
+ for (const a of actors.values()) {
102
+ // Decided by the flag, never by the name: Canton's own code has a Daml party literally called
103
+ // `anyone`, and styling it as "no access check" would have been this page inventing a fact.
104
+ const outside = a.anyone || a.unseen
105
+ || (!inside.has(a.name) && a.entries.some((i) => alone(m.entries[i])));
106
+ a.side = outside ? "outside" : "inside";
107
+ }
108
+ return { inside, actors: [...actors.values()], alone, signers };
109
+ }
110
+
111
+ /** One step of an action, as drawn: what it touches and how. Duplicates collapse to a count. */
112
+ function stepsOf(e, nodeOf) {
113
+ const out = [];
114
+ const idx = new Map();
115
+ for (const x of e.edges) {
116
+ const kind = x.resolved ? x.kind : "blind";
117
+ const to = x.resolved ? nodeOf(x.target) : null;
118
+ const key = `${kind}|${to}|${x.through ?? ""}|${x.resolved ? "" : x.raw}`;
119
+ if (idx.has(key)) { out[idx.get(key)].n++; continue; }
120
+ idx.set(key, out.length);
121
+ out.push({
122
+ k: kind, to, name: x.resolved ? x.target : null, raw: x.raw,
123
+ thr: x.through ?? null, self: Boolean(x.self), n: 1,
124
+ eth: Boolean(x.meta?.eth), low: x.meta?.lowLevel ?? null, lib: x.meta?.library ?? null,
125
+ recv: x.resolved ? null : (x.meta?.receiver ?? null),
126
+ });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** Examples for the "how to read this" tiles, picked from THIS codebase rather than invented. */
132
+ function concepts(m, entries, units) {
133
+ const daml = m.language === "daml";
134
+ const risky = (a, b) => b.risk - a.risk || a.i - b.i;
135
+ // Prefer an entry no earlier tile used: eight tiles all illustrated by the same liquidation teach
136
+ // one example eight times. Falls back to a repeat rather than to nothing.
137
+ const used = new Set();
138
+ const pick = (pred) => {
139
+ const all = [...entries].filter((e) => !e.declared && !e.deployOnly && pred(e)).sort(risky);
140
+ const hit = all.find((e) => !used.has(e.i)) ?? all[0];
141
+ if (hit) used.add(hit.i);
142
+ return hit?.i ?? null;
143
+ };
144
+ const hasEdge = (e, kind) => e.steps.some((s) => s.k === kind);
145
+ const COPY = {
146
+ contract: daml
147
+ ? ["Contract", "A record on the ledger. The stamps are who signed it.",
148
+ "Signers are bound by it — and lend their authority to every action taken on it."]
149
+ : ["Contract", "Code with its own storage. Calls change that storage in place.",
150
+ "There is no history on-chain of what a value used to be — read what each function writes."],
151
+ action: ["Action", "A named step on a contract. Only the listed party can take it.",
152
+ "One name means that party acts alone. Two means both must agree."],
153
+ changes: daml
154
+ ? ["Changes it", "Nothing is edited in place: the old contract is archived and a new one made.",
155
+ "Compare old and new fields. A value carried over unchanged is where stale state hides."]
156
+ : ["Changes it", "Writes to the contract's own storage.",
157
+ "Check every value written against every value later read — in every order of calls."],
158
+ ends: ["Ends it", "Archived, with nothing made in its place.",
159
+ "Follow what leaves when it ends, and who receives it."],
160
+ repeatable: ["Repeatable", "The contract stays, so the action can run again and again.",
161
+ "Nothing limits repeats unless the code does. Check totals and rates."],
162
+ borrowed: ["Borrowed authority", "An action also carries the authority of everyone who signed its contract.",
163
+ "The caller's reach is not their own. It is the most-missed rule in Daml."],
164
+ cid: ["Ids passed in", "A contract id in the arguments is chosen by whoever calls.",
165
+ "If the action doesn't check it received the contract it expected, the caller can swap it."],
166
+ anyone: ["Anyone", "No access check at all: anyone can call it.",
167
+ "This is Solidity's default, not an oversight to assume away. Start here."],
168
+ guarded: ["Guarded", "An access modifier or caller check limits who can call.",
169
+ "Find who holds that role. They can do everything listed under it."],
170
+ calls: ["Calls out", "The action calls into another contract.",
171
+ "State written after an external call is the reentrancy pattern."],
172
+ creates: ["Creates", "The action brings a new contract into existence.",
173
+ "Check who controls the new contract once it exists."],
174
+ blind: ["Blind spot", "The tool couldn't tell what this points at, so it doesn't guess.",
175
+ daml ? "A missing arrow means unproven — never 'nothing there'. Read these yourself."
176
+ : "Low-level call and delegatecall targets are always blind. delegatecall to a caller-chosen address hands over the contract."],
177
+ type: ["Two typefaces", "Monospace is copied from the source. Sans is the tool talking.", null],
178
+ };
179
+ const tile = (key, example) => ({ key, title: COPY[key][0], line: COPY[key][1], note: COPY[key][2], ...example });
180
+ const widest = [...units].sort((a, b) => b.signers.length - a.signers.length || b.actions - a.actions)[0];
181
+ const busiest = [...units].filter((u) => !u.ghost).sort((a, b) => b.actions - a.actions)[0];
182
+
183
+ if (daml) {
184
+ return [
185
+ tile("contract", { unit: widest?.i ?? null }),
186
+ tile("action", { entry: pick((e) => e.alone && e.unit === widest?.i) ?? pick((e) => e.alone) }),
187
+ tile("changes", { entry: pick((e) => e.effect === EFFECT.TRANSITION && e.alone) ?? pick((e) => e.effect === EFFECT.TRANSITION) }),
188
+ tile("ends", { entry: pick((e) => e.effect === EFFECT.TERMINAL && e.alone) ?? pick((e) => e.effect === EFFECT.TERMINAL) }),
189
+ tile("repeatable", { entry: pick((e) => e.effect === EFFECT.NONE && hasEdge(e, EDGE.CREATE)) }),
190
+ tile("borrowed", { entry: pick((e) => e.borrowed.length > 0 && e.alone && hasEdge(e, EDGE.CREATE)) ?? pick((e) => e.borrowed.length > 0) }),
191
+ tile("cid", { entry: pick((e) => e.cidArgs.length > 0 && e.alone) ?? pick((e) => e.cidArgs.length > 0) }),
192
+ tile("blind", { entry: pick((e) => e.holes > 0) }),
193
+ tile("type", {}),
194
+ ];
195
+ }
196
+ return [
197
+ tile("contract", { unit: busiest?.i ?? null }),
198
+ tile("anyone", { entry: pick((e) => e.anyone && e.effect !== EFFECT.NONE) }),
199
+ tile("guarded", { entry: pick((e) => !e.anyone && e.effect !== EFFECT.NONE) }),
200
+ tile("calls", { entry: pick((e) => hasEdge(e, EDGE.CALL)) }),
201
+ tile("creates", { entry: pick((e) => hasEdge(e, EDGE.CREATE)) }),
202
+ tile("blind", { entry: pick((e) => e.holes > 0) }),
203
+ tile("type", {}),
204
+ ];
205
+ }
206
+
207
+ export function viewData(m, { title = "sourcetruth" } = {}) {
208
+ const daml = m.language === "daml";
209
+ const who = sides(m);
210
+
211
+ // ── nodes: every unit, plus a ghost for anything created or called that is not defined here ──
212
+ const unitIndex = new Map();
213
+ m.units.forEach((u, i) => { if (!unitIndex.has(u.name)) unitIndex.set(u.name, i); });
214
+ const ghosts = new Map();
215
+ const nodeOf = (name) => {
216
+ if (name == null) return null;
217
+ if (unitIndex.has(name)) return `u${unitIndex.get(name)}`;
218
+ return `g:${name}`;
219
+ };
220
+
221
+ const entryIndexOf = new Map(m.entries.map((e, i) => [e, i]));
222
+ const entries = m.entries.map((e, i) => {
223
+ const own = unitIndex.get(e.unit);
224
+ const borrowed = daml ? who.signers(e.unit).filter((p) => !e.authority.map(bare).includes(p)) : [];
225
+ const steps = stepsOf(e, nodeOf);
226
+ const anyone = !daml && e.authority.length === 0;
227
+ const alone = anyone || who.alone(e);
228
+ const moves = e.edges.filter((x) => x.kind === EDGE.CREATE || x.kind === EDGE.DESTROY).length;
229
+ const changesSomething = e.effect !== EFFECT.NONE
230
+ || (daml && e.edges.some((x) => x.kind === EDGE.CREATE || x.kind === EDGE.DESTROY || x.kind === EDGE.CALL));
231
+ const risk = (anyone && changesSomething ? 1000 : 0)
232
+ + (alone && borrowed.some((p) => who.inside.has(p)) ? 400 : 0)
233
+ + (e.effect === EFFECT.TERMINAL ? 120 : e.effect === EFFECT.TRANSITION ? 80 : 0)
234
+ + 20 * Math.min(moves, 10) + 30 * e.holes
235
+ + (e.edges.some((x) => x.resolved && !unitIndex.has(x.target) && x.kind !== EDGE.READ) ? 50 : 0);
236
+ return {
237
+ i, unit: own, name: e.name, auth: e.authority, alone, anyone,
238
+ unseen: daml && e.authority.length === 0,
239
+ effect: e.effect, holes: e.holes, path: e.path, line: e.line,
240
+ guards: e.guards, borrowed, steps, risk,
241
+ door: !e.declared && !e.deployOnly && alone && changesSomething,
242
+ declared: Boolean(e.declared), deployOnly: Boolean(e.deployOnly),
243
+ inherited: e.inherited ?? null, unread: e.unread ?? [],
244
+ // A controller that is one of the choice's own ARGUMENTS: whoever exercises it names who acts.
245
+ argParties: daml ? e.authority.map(bare).filter((p) => (e.args ?? []).some((a) => a.name === p)) : [],
246
+ cidArgs: (e.args ?? []).filter((a) => /ContractId\b/.test(a.type ?? "")).map((a) => a.name),
247
+ anchor: `e-${e.unit}-${e.name}`.replace(/[^\w-]/g, "_"),
248
+ };
249
+ });
250
+ void entryIndexOf;
251
+
252
+ // ── links between nodes: who makes or calls whom. Reads are left for the per-action view ──────
253
+ const linkMap = new Map();
254
+ entries.forEach((e) => {
255
+ const from = `u${e.unit}`;
256
+ for (const s of e.steps) {
257
+ if ((s.k !== EDGE.CREATE && s.k !== EDGE.CALL) || !s.to || s.to === from) continue;
258
+ if (s.to.startsWith("g:") && !ghosts.has(s.to)) ghosts.set(s.to, { id: s.to, name: s.name, ghost: true });
259
+ const key = `${from}|${s.to}|${s.k}`;
260
+ if (!linkMap.has(key)) linkMap.set(key, { from, to: s.to, k: s.k, via: [] });
261
+ const l = linkMap.get(key);
262
+ if (!l.via.includes(e.i)) l.via.push(e.i);
263
+ }
264
+ });
265
+ // Ghosts reached only by reads still need a place to point at when an action is played.
266
+ entries.forEach((e) => e.steps.forEach((s) => {
267
+ if (s.to && s.to.startsWith("g:") && !ghosts.has(s.to)) ghosts.set(s.to, { id: s.to, name: s.name, ghost: true, readOnly: true });
268
+ }));
269
+
270
+ // ── lifecycle of each unit ────────────────────────────────────────────────────────────────────
271
+ const life = m.units.map(() => ({ made: [], changed: [], replaced: [], ended: [], read: [], archived: [] }));
272
+ const add = (arr, i, thr) => { if (!arr.some((x) => x.e === i && x.thr === thr)) arr.push({ e: i, thr }); };
273
+ entries.forEach((e) => {
274
+ if (e.effect === EFFECT.TRANSITION) add(life[e.unit].changed, e.i, null);
275
+ if (e.effect === EFFECT.TERMINAL) add(life[e.unit].ended, e.i, null);
276
+ for (const s of e.steps) {
277
+ if (!s.to || !s.to.startsWith("u")) continue;
278
+ const t = Number(s.to.slice(1));
279
+ if (s.k === EDGE.CREATE && !(s.self && t === e.unit)) add(life[t].made, e.i, s.thr);
280
+ else if (s.k === EDGE.READ) add(life[t].read, e.i, s.thr);
281
+ else if (s.k === EDGE.DESTROY && t !== e.unit) add(life[t].archived, e.i, s.thr);
282
+ }
283
+ });
284
+
285
+ // An action on ANOTHER contract that both archives this one and creates it is replacing it — how a
286
+ // Daml position is updated from its pool. Counting only this contract's own choices told a
287
+ // reader that a deposit position "stays as it is" while four pool actions rewrite it.
288
+ //
289
+ // `replaced` is kept apart from `archived` because it is AMBIGUOUS, not because it is safe: the
290
+ // parser cannot see branches, and a real pool's withdraw archives the position always but
291
+ // creates one only `if createResidual`. So a replacement may also be an ending; the page shows it
292
+ // under both, and never lets it support the claim that nothing ends a contract.
293
+ for (const l of life) {
294
+ const makers = new Set(l.made.map((x) => x.e));
295
+ l.replaced = l.archived.filter((x) => makers.has(x.e));
296
+ l.archived = l.archived.filter((x) => !makers.has(x.e));
297
+ }
298
+
299
+ // ── cards ─────────────────────────────────────────────────────────────────────────────────────
300
+ const units = m.units.map((u, i) => {
301
+ const own = entries.filter((e) => e.unit === i && !e.declared && !e.deployOnly);
302
+ return {
303
+ i, id: `u${i}`, name: u.name, module: u.module, path: u.path, line: u.line,
304
+ kind: u.abstract ? "abstract contract" : (u.kind ?? (daml ? "template" : "contract")),
305
+ signers: splitParties(u.signatories),
306
+ actions: own.length,
307
+ holes: own.reduce((n, e) => n + e.holes, 0),
308
+ anchor: `u-${u.name}`,
309
+ };
310
+ });
311
+ const ghostList = [...ghosts.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
312
+ const links = [...linkMap.values()];
313
+ const placed = layout(
314
+ [...units.map((u) => ({ id: u.id, sort: `${u.module}/${u.name}` })),
315
+ ...ghostList.filter((g) => !g.readOnly).map((g) => ({ id: g.id, sort: `~${g.name}` }))],
316
+ links,
317
+ );
318
+ for (const n of [...units, ...ghostList]) {
319
+ const p = placed.pos.get(n.id);
320
+ if (p) Object.assign(n, { x: p.x, y: p.y, standalone: p.standalone });
321
+ }
322
+
323
+ const actors = who.actors
324
+ .map((a) => ({
325
+ name: a.name, side: a.side, optional: a.optional, unseen: a.unseen, anyone: a.anyone, expr: a.expr,
326
+ entries: a.entries,
327
+ doors: a.entries.filter((i) => entries[i].door).length,
328
+ borrows: a.entries.some((i) => entries[i].alone && entries[i].borrowed.length > 0),
329
+ }))
330
+ .sort((a, b) =>
331
+ (a.side === b.side ? 0 : a.side === "outside" ? -1 : 1)
332
+ || Number(b.anyone) - Number(a.anyone)
333
+ || b.doors - a.doors || b.entries.length - a.entries.length
334
+ || (a.name < b.name ? -1 : 1));
335
+
336
+ const view = {
337
+ title, language: m.language, daml,
338
+ stats: m.stats, notes: m.notes,
339
+ card: CARD, size: { w: placed.width, h: placed.height, standaloneTop: placed.standaloneTop },
340
+ labels: EFFECT_LABEL,
341
+ units, ghosts: ghostList, links, entries, actors, life,
342
+ doors: entries.filter((e) => e.door).sort((a, b) => b.risk - a.risk || a.i - b.i).map((e) => e.i),
343
+ };
344
+ view.concepts = concepts(m, entries, units);
345
+ return view;
346
+ }
347
+
348
+ /** Embed as a JSON script block that cannot close itself early, whatever the source text says. */
349
+ export function embed(view) {
350
+ // JSON may legally contain U+2028/U+2029; older script parsers treat them as line breaks. And a
351
+ // raw `</script>` inside any source string would end the block and spill the rest into the page.
352
+ return JSON.stringify(view)
353
+ .replace(/</g, "\\u003c")
354
+ .replace(/>/g, "\\u003e")
355
+ .replace(/\u2028/g, "\\u2028")
356
+ .replace(/\u2029/g, "\\u2029");
357
+ }