openzoo 0.50.0 → 0.50.2

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/lib/sonar.js ADDED
@@ -0,0 +1,1879 @@
1
+ /**
2
+ * SONAR — map the unmapped half of Solana.
3
+ *
4
+ * ~68.5k upgradeable programs are deployed on mainnet. A minority publish
5
+ * an Anchor IDL on-chain; the rest are opaque bytes that every explorer
6
+ * renders as "Unknown Instruction". This is the pipeline that closes that
7
+ * gap: harvest the ones that DID publish as ground truth, then infer a
8
+ * probable IDL for the ones that did not.
9
+ *
10
+ * WHY IT IS TRACTABLE, and it is not magic — it is a hash preimage
11
+ * problem the ecosystem accidentally made easy:
12
+ *
13
+ * Anchor identifies every instruction by an 8-byte discriminator,
14
+ * sha256("global:<snake_case_name>")[..8], and those 8 bytes are
15
+ * COMPILED INTO THE BINARY as constants to compare against. So a
16
+ * stripped .so still contains a fingerprint of every instruction name
17
+ * it answers to. The name is not recoverable by inverting sha256 — it
18
+ * is recoverable by having seen it before.
19
+ *
20
+ * Which is what the harvest is for. Every published IDL contributes its
21
+ * instruction names to a rainbow table (name -> discriminator). Names
22
+ * are not random: `initialize`, `swap`, `deposit`, `update_config`
23
+ * recur across thousands of programs. A discriminator found in an
24
+ * unknown binary that matches the table is not a guess, it is an
25
+ * identification — with the preimage as proof.
26
+ *
27
+ * The residue — discriminators no table explains — is where leCore and
28
+ * the model earn their keep: recall the closest known programs by binary
29
+ * similarity and let a model propose names, which are then CHECKED by
30
+ * hashing the proposal back. A proposed name either hashes to the
31
+ * observed discriminator or it does not. No hallucination survives that.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import zlib from 'node:zlib';
37
+ import crypto from 'node:crypto';
38
+ import { PublicKey } from '@solana/web3.js';
39
+ import { loadDotenv } from './dotenv.js';
40
+ import { config } from './config.js';
41
+
42
+ // Before config.js is consulted, so OPENZOO_RPC from .env wins over the
43
+ // public default without anyone pasting a credentialed URL onto a
44
+ // command line.
45
+ loadDotenv();
46
+
47
+ export const SONAR_DIR = process.env.OPENZOO_SONAR_DIR
48
+ || path.join(process.env.HOME || '.', '.openzoo', 'sonar');
49
+
50
+ const RPC = process.env.OPENZOO_RPC || config.rpcUrl;
51
+
52
+ /** The two BPF loaders that own executable programs on mainnet. */
53
+ export const LOADERS = {
54
+ upgradeable: 'BPFLoaderUpgradeab1e11111111111111111111111',
55
+ v2: 'BPFLoader2111111111111111111111111111111111',
56
+ };
57
+
58
+ function dir(...parts) {
59
+ const p = path.join(SONAR_DIR, ...parts);
60
+ fs.mkdirSync(path.dirname(p), { recursive: true });
61
+ return p;
62
+ }
63
+
64
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
65
+
66
+ /**
67
+ * RPC with exponential backoff and FULL JITTER.
68
+ *
69
+ * Jitter is not decoration here: a pool of workers that all back off by
70
+ * the same doubling schedule retries in lockstep, so every wave hits the
71
+ * endpoint simultaneously and re-triggers the same 429 that caused the
72
+ * backoff. Randomising across the whole window spreads them out, which
73
+ * is the difference between a pool that recovers and one that
74
+ * synchronises itself into a stall.
75
+ *
76
+ * 429 and 5xx are retried; a 4xx that is not 429 is a real error and is
77
+ * raised immediately rather than retried 6 times for nothing. Retry-After
78
+ * is obeyed when the server sends it — it knows better than the schedule.
79
+ */
80
+ async function rpc(method, params, { attempts = 6, base = 400, cap = 20_000 } = {}) {
81
+ let lastErr;
82
+ for (let attempt = 0; attempt < attempts; attempt++) {
83
+ if (attempt) {
84
+ const window = Math.min(cap, base * 2 ** (attempt - 1));
85
+ await sleep(Math.random() * window);
86
+ }
87
+ let r;
88
+ try {
89
+ r = await fetch(RPC, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
93
+ });
94
+ } catch (e) {
95
+ lastErr = e; // socket-level: always worth a retry
96
+ continue;
97
+ }
98
+ if (r.status === 429 || r.status >= 500) {
99
+ const after = Number(r.headers.get('retry-after'));
100
+ if (Number.isFinite(after) && after > 0) await sleep(Math.min(cap, after * 1000));
101
+ lastErr = new Error(`rpc ${method}: HTTP ${r.status}`);
102
+ continue;
103
+ }
104
+ if (!r.ok) throw new Error(`rpc ${method}: HTTP ${r.status}`);
105
+ const j = await r.json();
106
+ if (j.error) {
107
+ // -32005 is the node's own rate/resource limit; everything else the
108
+ // node reports is a genuine rejection of this request.
109
+ if (j.error.code === -32005) { lastErr = new Error(j.error.message); continue; }
110
+ throw new Error(`rpc ${method}: ${j.error.message}`);
111
+ }
112
+ return j.result;
113
+ }
114
+ throw lastErr || new Error(`rpc ${method}: exhausted ${attempts} attempts`);
115
+ }
116
+
117
+ // ---------------------------------------------------------------- enumerate
118
+
119
+ /**
120
+ * Every upgradeable program on the cluster.
121
+ *
122
+ * `dataSize: 36` selects only UpgradeableLoaderState::Program accounts
123
+ * (4-byte enum tag + 32-byte programdata address) — the ProgramData
124
+ * accounts holding the actual ELF are megabytes each and are fetched
125
+ * later, per program, on demand. Measured: 68,533 programs in 0.94s.
126
+ */
127
+ export async function enumeratePrograms({ log = () => {} } = {}) {
128
+ const res = await rpc('getProgramAccounts', [
129
+ LOADERS.upgradeable,
130
+ {
131
+ encoding: 'base64',
132
+ dataSlice: { offset: 4, length: 32 }, // programdata address
133
+ filters: [{ dataSize: 36 }],
134
+ },
135
+ ]);
136
+ const rows = res.map((r) => ({
137
+ programId: r.pubkey,
138
+ programDataAddress: new PublicKey(Buffer.from(r.account.data[0], 'base64')).toBase58(),
139
+ }));
140
+ fs.writeFileSync(dir('programs.json'), JSON.stringify(rows));
141
+ log(`sonar: ${rows.length} upgradeable programs`);
142
+ return rows;
143
+ }
144
+
145
+ // ---------------------------------------------------------------- IDL
146
+
147
+ /**
148
+ * Where Anchor puts an on-chain IDL.
149
+ *
150
+ * base = PDA(program_id, seeds = []) // the program's own signer
151
+ * idl = createWithSeed(base, "anchor:idl", program_id)
152
+ *
153
+ * Note the owner of the resulting account is the PROGRAM, not the loader,
154
+ * which is why there is no single registry to scan — you can only ask
155
+ * this question one program at a time.
156
+ */
157
+ export async function idlAddress(programId) {
158
+ const pid = new PublicKey(programId);
159
+ const base = PublicKey.findProgramAddressSync([], pid)[0];
160
+ // createWithSeed is async in @solana/web3.js 1.98 — awaited, not
161
+ // wrapped in a sync helper, because a Promise silently stringifies to
162
+ // "[object Promise]" and every derived address would be wrong.
163
+ return PublicKey.createWithSeed(base, 'anchor:idl', pid);
164
+ }
165
+
166
+ /**
167
+ * Decode an IdlAccount: 8-byte account discriminator, 32-byte authority,
168
+ * 4-byte LE length, then the JSON compressed with zlib.
169
+ *
170
+ * Length-prefixed AND bounds-checked because this is adversarial data —
171
+ * an account can claim any length it likes.
172
+ */
173
+ export function decodeIdlAccount(raw) {
174
+ if (!raw || raw.length < 44) return null;
175
+ const len = raw.readUInt32LE(40);
176
+ if (len === 0 || 44 + len > raw.length) return null;
177
+ const body = raw.subarray(44, 44 + len);
178
+ try {
179
+ return JSON.parse(zlib.inflateSync(body).toString('utf8'));
180
+ } catch {
181
+ try {
182
+ return JSON.parse(zlib.inflateRawSync(body).toString('utf8'));
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+ }
188
+
189
+ /** Batch-probe which programs published an IDL. 100 per getMultipleAccounts. */
190
+ export async function harvestIdls(programs, { log = () => {}, concurrency = 8 } = {}) {
191
+ const out = dir('idls.jsonl');
192
+ const seen = new Set();
193
+ try {
194
+ for (const line of fs.readFileSync(out, 'utf8').split('\n')) {
195
+ if (line) seen.add(JSON.parse(line).programId);
196
+ }
197
+ } catch { /* first run */ }
198
+
199
+ const todo = programs.filter((p) => !seen.has(p.programId));
200
+ log(`sonar: probing ${todo.length} programs for on-chain IDLs (${seen.size} already known)`);
201
+
202
+ const batches = [];
203
+ for (let i = 0; i < todo.length; i += 100) batches.push(todo.slice(i, i + 100));
204
+
205
+ let found = 0;
206
+ let done = 0;
207
+ const retries = new Map();
208
+ const stream = fs.createWriteStream(out, { flags: 'a', mode: 0o600 });
209
+
210
+ const worker = async () => {
211
+ for (;;) {
212
+ const batch = batches.shift();
213
+ if (!batch) return;
214
+ const addrs = await Promise.all(batch.map(async (p) => {
215
+ try { return (await idlAddress(p.programId)).toBase58(); } catch { return null; }
216
+ }));
217
+ let accounts;
218
+ try {
219
+ // rpc() already retries with backoff+jitter; reaching here means
220
+ // the whole schedule was exhausted, so the batch goes to the back
221
+ // of the queue rather than being dropped or hammered again now.
222
+ accounts = await rpc('getMultipleAccounts', [
223
+ addrs.filter(Boolean),
224
+ { encoding: 'base64' },
225
+ ]);
226
+ } catch (e) {
227
+ const tries = (retries.get(batch) || 0) + 1;
228
+ retries.set(batch, tries);
229
+ if (tries > 3) { log(`sonar: batch abandoned after ${tries} passes (${e.message.slice(0, 60)})`); continue; }
230
+ log(`sonar: batch requeued (pass ${tries}) — ${e.message.slice(0, 60)}`);
231
+ batches.push(batch);
232
+ continue;
233
+ }
234
+ let ai = 0;
235
+ for (let i = 0; i < batch.length; i++) {
236
+ if (!addrs[i]) continue;
237
+ const acc = accounts.value[ai++];
238
+ if (!acc?.data?.[0]) continue;
239
+ const idl = decodeIdlAccount(Buffer.from(acc.data[0], 'base64'));
240
+ if (!idl) continue;
241
+ stream.write(JSON.stringify({ programId: batch[i].programId, idl }) + '\n');
242
+ found++;
243
+ }
244
+ done += batch.length;
245
+ if (done % 5000 < 100) log(`sonar: ${done}/${todo.length} probed, ${found} IDLs found`);
246
+ }
247
+ };
248
+ await Promise.all(Array.from({ length: concurrency }, worker));
249
+ await new Promise((r) => stream.end(r));
250
+ log(`sonar: harvest complete — ${found} IDLs from ${todo.length} programs`);
251
+ return { probed: todo.length, found };
252
+ }
253
+
254
+ // ---------------------------------------------------------------- rainbow
255
+
256
+ /**
257
+ * A discriminator is only a fingerprint if it is UNLIKELY BY CHANCE.
258
+ *
259
+ * Anchor 0.30+ lets a program declare an explicit discriminator, and
260
+ * plenty declare small integers — `[8,0,0,0,0,0,0,0]` is the u64 8.
261
+ * OBSERVED in a real reconstruction: `whitelist_validator_for_program`
262
+ * and `top_up_ephemeral_balance` "confirmed" off the byte patterns for 8
263
+ * and 9, which occur in every binary hundreds of times. Those are not
264
+ * identifications, they are noise wearing a name, and they were
265
+ * inflating every precision number in this file.
266
+ *
267
+ * A sha256 prefix has ~8 distinct high-entropy bytes; anything with a
268
+ * long zero run or barely any distinct bytes is rejected from the table.
269
+ */
270
+ export function isUsableDiscriminator(hex) {
271
+ const b = Buffer.from(hex, 'hex');
272
+ if (b.length !== 8) return false;
273
+ const zeros = b.filter((x) => x === 0).length;
274
+ if (zeros >= 4) return false; // 8u64, 0u64, flags, ...
275
+ if (new Set(b).size <= 3) return false; // repeated filler
276
+ return true;
277
+ }
278
+
279
+ /** Anchor's instruction discriminator: sha256("global:<name>")[..8]. */
280
+ export function ixDiscriminator(name) {
281
+ return crypto.createHash('sha256').update(`global:${name}`).digest().subarray(0, 8);
282
+ }
283
+
284
+ /** Anchor's account discriminator: sha256("account:<Name>")[..8]. */
285
+ export function accountDiscriminator(name) {
286
+ return crypto.createHash('sha256').update(`account:${name}`).digest().subarray(0, 8);
287
+ }
288
+
289
+ export function eventDiscriminator(name) {
290
+ return crypto.createHash('sha256').update(`event:${name}`).digest().subarray(0, 8);
291
+ }
292
+
293
+ /**
294
+ * Build the rainbow table from every harvested IDL: discriminator (hex)
295
+ * -> the names that produce it, with how many programs used each.
296
+ *
297
+ * This is the asset. Every IDL anyone ever published makes the unknown
298
+ * ones more legible, and the table only grows.
299
+ */
300
+ export function buildRainbow({ log = () => {}, exclude = null, write = true } = {}) {
301
+ const table = new Map();
302
+ const add = (disc, name, kind, programId) => {
303
+ const key = disc.toString('hex');
304
+ const row = table.get(key) || { name, kind, programs: new Set() };
305
+ row.programs.add(programId);
306
+ table.set(key, row);
307
+ };
308
+
309
+ let idls = 0;
310
+ const file = dir('idls.jsonl');
311
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
312
+ if (!line) continue;
313
+ let rec;
314
+ try { rec = JSON.parse(line); } catch { continue; }
315
+ // Held-out programs contribute NOTHING to the table. Without this the
316
+ // eval scores its own answer key and reports a perfect result.
317
+ if (exclude && exclude.has(rec.programId)) continue;
318
+ idls++;
319
+ const idl = rec.idl || {};
320
+ for (const ix of idl.instructions || []) {
321
+ if (!ix?.name) continue;
322
+ // Anchor 0.30+ can carry an explicit discriminator; when it does,
323
+ // trust it over the derivation (custom discriminators are legal).
324
+ const disc = Array.isArray(ix.discriminator)
325
+ ? Buffer.from(ix.discriminator)
326
+ : ixDiscriminator(ix.name);
327
+ add(disc, ix.name, 'instruction', rec.programId);
328
+ }
329
+ for (const acc of idl.accounts || []) {
330
+ if (!acc?.name) continue;
331
+ const disc = Array.isArray(acc.discriminator)
332
+ ? Buffer.from(acc.discriminator)
333
+ : accountDiscriminator(acc.name);
334
+ add(disc, acc.name, 'account', rec.programId);
335
+ }
336
+ for (const ev of idl.events || []) {
337
+ if (!ev?.name) continue;
338
+ const disc = Array.isArray(ev.discriminator)
339
+ ? Buffer.from(ev.discriminator)
340
+ : eventDiscriminator(ev.name);
341
+ add(disc, ev.name, 'event', rec.programId);
342
+ }
343
+ }
344
+
345
+ const rows = [...table.entries()].map(([disc, r]) => ({
346
+ disc, name: r.name, kind: r.kind, seen: r.programs.size,
347
+ })).filter((r) => isUsableDiscriminator(r.disc)).sort((a, b) => b.seen - a.seen);
348
+ if (write) fs.writeFileSync(dir('rainbow.json'), JSON.stringify(rows));
349
+ log(`sonar: rainbow table — ${rows.length} discriminators from ${idls} IDLs`);
350
+ return rows;
351
+ }
352
+
353
+ /**
354
+ * THE EVAL. Hold out programs that DID publish an IDL, rebuild the
355
+ * rainbow table without them, recover their instruction names from the
356
+ * binary alone, and diff against the truth they published.
357
+ *
358
+ * This is the only thing separating "a pipeline that recovers names" from
359
+ * "a pipeline that looks like it does". The leakage guard is the whole
360
+ * point: a table built from all IDLs contains the answer key, and scoring
361
+ * against it would report near-perfect recall no matter how bad the
362
+ * method is.
363
+ *
364
+ * Reported per program and in aggregate:
365
+ * recall — of the instructions the program really has, how many did
366
+ * we name? This is the number that matters.
367
+ * precision — of the names we claimed, how many were real? Low
368
+ * precision means the sweep is picking up discriminators
369
+ * that belong to dependencies rather than this program.
370
+ */
371
+ export async function evaluate({ n = 40, seed = 7, log = () => {} } = {}) {
372
+ const all = fs.readFileSync(dir('idls.jsonl'), 'utf8')
373
+ .split('\n').filter(Boolean)
374
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
375
+ .filter((r) => r && (r.idl?.instructions?.length > 0));
376
+ if (all.length < n + 10) throw new Error(`only ${all.length} usable IDLs harvested — run: openzoo sonar harvest`);
377
+
378
+ // Deterministic sample so a re-run is comparable, not a fresh lottery.
379
+ let x = seed;
380
+ const rand = () => { x = (x * 1103515245 + 12345) & 0x7fffffff; return x / 0x7fffffff; };
381
+ const pool = [...all];
382
+ const holdout = [];
383
+ while (holdout.length < n && pool.length) {
384
+ holdout.push(pool.splice(Math.floor(rand() * pool.length), 1)[0]);
385
+ }
386
+
387
+ const excluded = new Set(holdout.map((h) => h.programId));
388
+ const rows = buildRainbow({ log, exclude: excluded, write: false });
389
+ const rainbow = new Map(rows.map((r) => [r.disc, r]));
390
+ log(`sonar: eval — ${holdout.length} held out, table built from the other ${all.length - holdout.length}`);
391
+
392
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
393
+ const byId = new Map(programs.map((p) => [p.programId, p]));
394
+
395
+ const results = [];
396
+ for (const h of holdout) {
397
+ const row = byId.get(h.programId);
398
+ if (!row) continue;
399
+ let elf;
400
+ try { elf = await fetchBinary(row.programDataAddress); } catch { elf = null; }
401
+ if (!elf) { results.push({ programId: h.programId, error: 'no binary' }); continue; }
402
+
403
+ const truth = new Set((h.idl.instructions || []).map((i) => i.name));
404
+ const hits = scanDiscriminators(elf, rainbow).filter((x) => x.kind === 'instruction');
405
+ const got = new Set(hits.map((x) => x.name));
406
+ const tp = [...got].filter((g) => truth.has(g)).length;
407
+ results.push({
408
+ programId: h.programId,
409
+ truth: truth.size,
410
+ recovered: got.size,
411
+ correct: tp,
412
+ recall: truth.size ? tp / truth.size : 0,
413
+ precision: got.size ? tp / got.size : 0,
414
+ missed: [...truth].filter((t) => !got.has(t)).slice(0, 6),
415
+ });
416
+ log(` ${h.programId.slice(0, 8)}… ${tp}/${truth.size} names recovered`);
417
+ }
418
+
419
+ const scored = results.filter((r) => !r.error);
420
+ const sum = (f) => scored.reduce((a, b) => a + f(b), 0);
421
+ const summary = {
422
+ heldOut: holdout.length,
423
+ scored: scored.length,
424
+ tableSize: rows.length,
425
+ totalTruth: sum((r) => r.truth),
426
+ totalCorrect: sum((r) => r.correct),
427
+ totalRecovered: sum((r) => r.recovered),
428
+ microRecall: sum((r) => r.truth) ? sum((r) => r.correct) / sum((r) => r.truth) : 0,
429
+ microPrecision: sum((r) => r.recovered) ? sum((r) => r.correct) / sum((r) => r.recovered) : 0,
430
+ fullyRecovered: scored.filter((r) => r.recall === 1).length,
431
+ nothingRecovered: scored.filter((r) => r.correct === 0).length,
432
+ };
433
+ fs.writeFileSync(dir('eval.json'), JSON.stringify({ summary, results }, null, 2));
434
+ return { summary, results };
435
+ }
436
+
437
+ export function loadRainbow() {
438
+ const rows = JSON.parse(fs.readFileSync(dir('rainbow.json'), 'utf8'));
439
+ return new Map(rows.map((r) => [r.disc, r]));
440
+ }
441
+
442
+ // ---------------------------------------------------------------- binary
443
+
444
+ /** Fetch the ELF for a program out of its ProgramData account. */
445
+ export async function fetchBinary(programDataAddress) {
446
+ const acc = await rpc('getAccountInfo', [programDataAddress, { encoding: 'base64' }]);
447
+ if (!acc?.value?.data?.[0]) return null;
448
+ const raw = Buffer.from(acc.value.data[0], 'base64');
449
+ // UpgradeableLoaderState::ProgramData = 4-byte tag + 8-byte slot
450
+ // + 1-byte Option tag + 32-byte upgrade authority, then the ELF.
451
+ const ELF_OFFSET = 45;
452
+ const elf = raw.subarray(ELF_OFFSET);
453
+ const magic = elf.subarray(0, 4);
454
+ if (!(magic[0] === 0x7f && magic[1] === 0x45 && magic[2] === 0x4c && magic[3] === 0x46)) {
455
+ // Fall back to locating the magic, in case the header layout shifts.
456
+ const at = raw.indexOf(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
457
+ return at >= 0 ? raw.subarray(at) : null;
458
+ }
459
+ return elf;
460
+ }
461
+
462
+ /**
463
+ * Every 8-byte window in the binary that the rainbow table recognises.
464
+ *
465
+ * Deliberately a brute sweep rather than a disassembly: discriminators
466
+ * are compared as immediates or loaded from .rodata depending on how the
467
+ * program was built, and a sliding window finds both without needing an
468
+ * sBPF decoder. False positives are near-impossible — an 8-byte value
469
+ * colliding with a known sha256 prefix by chance is a 2^-64 event, and
470
+ * the table is the filter.
471
+ */
472
+ export function scanDiscriminators(elf, rainbow) {
473
+ const hits = new Map();
474
+ const take = (key, offset, how) => {
475
+ const row = rainbow.get(key);
476
+ if (row && !hits.has(key)) hits.set(key, { ...row, offset, how });
477
+ };
478
+ for (let i = 0; i + 16 <= elf.length; i++) {
479
+ // (a) contiguous — the discriminator sitting in .rodata as data.
480
+ take(elf.subarray(i, i + 8).toString('hex'), i, 'contiguous');
481
+ // (b) lddw-split — sBPF loads a 64-bit immediate as TWO 8-byte
482
+ // instruction words, each carrying 4 bytes of the value in its
483
+ // last 4 bytes. So the 8 discriminator bytes appear as
484
+ // [op|dst|off|LO(4)][0|0|0|HI(4)]: LO at i, HI at i+8, never
485
+ // adjacent. MEASURED: Pump matched 40/40 instructions this way
486
+ // and 0/40 contiguously — searching for the whole 8 bytes finds
487
+ // nothing in a program compiled like this, which is why the
488
+ // first version of this scan recovered 7% and looked hopeless.
489
+ take(
490
+ elf.subarray(i, i + 4).toString('hex') + elf.subarray(i + 8, i + 12).toString('hex'),
491
+ i,
492
+ 'lddw',
493
+ );
494
+ }
495
+ // Tail: contiguous matches in the last 16 bytes the loop above skips.
496
+ for (let i = Math.max(0, elf.length - 16); i + 8 <= elf.length; i++) {
497
+ take(elf.subarray(i, i + 8).toString('hex'), i, 'contiguous');
498
+ }
499
+ return [...hits.values()];
500
+ }
501
+
502
+ // ---------------------------------------------------------------- CLI
503
+
504
+ export async function runSonar(args) {
505
+ const cmd = args[0] || 'help';
506
+ const log = (m) => console.error(` ${m}`);
507
+ const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : undefined; };
508
+
509
+ if (cmd === 'programs') {
510
+ const rows = await enumeratePrograms({ log });
511
+ console.log(JSON.stringify({ programs: rows.length, file: path.join(SONAR_DIR, 'programs.json') }, null, 2));
512
+ return;
513
+ }
514
+ if (cmd === 'harvest') {
515
+ let progs;
516
+ try {
517
+ progs = JSON.parse(fs.readFileSync(path.join(SONAR_DIR, 'programs.json'), 'utf8'));
518
+ } catch {
519
+ progs = await enumeratePrograms({ log });
520
+ }
521
+ const r = await harvestIdls(progs, { log, concurrency: Number(flag('concurrency') || 12) });
522
+ console.log(JSON.stringify(r, null, 2));
523
+ return;
524
+ }
525
+ if (cmd === 'rainbow') {
526
+ const rows = buildRainbow({ log });
527
+ console.log(JSON.stringify({ discriminators: rows.length, top: rows.slice(0, 15) }, null, 2));
528
+ return;
529
+ }
530
+ if (cmd === 'scan') {
531
+ const pid = args[1];
532
+ if (!pid) throw new Error('usage: openzoo sonar scan <programId>');
533
+ const progs = JSON.parse(fs.readFileSync(path.join(SONAR_DIR, 'programs.json'), 'utf8'));
534
+ const row = progs.find((p) => p.programId === pid);
535
+ if (!row) throw new Error(`${pid} is not in programs.json — run: openzoo sonar programs`);
536
+ const elf = await fetchBinary(row.programDataAddress);
537
+ if (!elf) throw new Error('could not fetch the program binary');
538
+ const rainbow = loadRainbow();
539
+ const hits = scanDiscriminators(elf, rainbow);
540
+ console.log(JSON.stringify({
541
+ programId: pid,
542
+ elfBytes: elf.length,
543
+ identified: hits.length,
544
+ instructions: hits.filter((h) => h.kind === 'instruction').map((h) => h.name),
545
+ accounts: hits.filter((h) => h.kind === 'account').map((h) => h.name),
546
+ events: hits.filter((h) => h.kind === 'event').map((h) => h.name),
547
+ }, null, 2));
548
+ return;
549
+ }
550
+ if (cmd === 'fingerprint') {
551
+ const r = await buildFingerprints({ log, limit: Number(flag('limit') || 0), concurrency: Number(flag('concurrency') || 6) });
552
+ console.log(JSON.stringify(r, null, 2));
553
+ return;
554
+ }
555
+ if (cmd === 'similar') {
556
+ const pid = args[1];
557
+ if (!pid) throw new Error('usage: openzoo sonar similar <programId>');
558
+ const programs = JSON.parse(fs.readFileSync(path.join(SONAR_DIR, 'programs.json'), 'utf8'));
559
+ const row = programs.find((p) => p.programId === pid);
560
+ if (!row) throw new Error(`${pid} not found — run: openzoo sonar programs`);
561
+ const elf = await fetchBinary(row.programDataAddress);
562
+ if (!elf) throw new Error('no binary (ProgramData closed)');
563
+ const refs = loadFingerprints().filter((r) => r.programId !== pid);
564
+ if (!refs.length) throw new Error('no fingerprints yet — run: openzoo sonar fingerprint');
565
+ console.log(JSON.stringify({ programId: pid, bytes: trimElf(elf).length, nearest: nearest(elf, refs) }, null, 2));
566
+ return;
567
+ }
568
+ if (cmd === 'forks') {
569
+ const pid = args[1];
570
+ if (!pid) throw new Error('usage: openzoo sonar forks <programId> [--fuzzy]');
571
+ if (args.includes('--fuzzy')) {
572
+ const r = await findForksFuzzy(pid, {
573
+ log,
574
+ tolerance: Number(flag('tolerance') || 0.06),
575
+ minScore: Number(flag('min') || 0.5),
576
+ maxVerify: Number(flag('verify') || 400),
577
+ });
578
+ console.log(JSON.stringify(r, null, 2));
579
+ return;
580
+ }
581
+ const r = await findForks(pid, {
582
+ log,
583
+ limit: Number(flag('limit') || 0),
584
+ concurrency: Number(flag('concurrency') || 12),
585
+ });
586
+ console.log(JSON.stringify(r, null, 2));
587
+ return;
588
+ }
589
+ if (cmd === 'idl') {
590
+ const pid = args[1];
591
+ if (!pid) throw new Error('usage: openzoo sonar idl <programId> [--no-model]');
592
+ const idl = await reconstructIdl(pid, { log, useModel: !args.includes('--no-model') });
593
+ console.log(JSON.stringify(idl, null, 2));
594
+ return;
595
+ }
596
+ if (cmd === 'bind') {
597
+ const r = await bindCorpus({ log, sample: Number(flag('sample') || 0) });
598
+ console.log(JSON.stringify(r, null, 2));
599
+ return;
600
+ }
601
+ if (cmd === 'compositions') {
602
+ const r = await mineCompositions({ log, sample: Number(flag('sample') || 250) });
603
+ console.log(JSON.stringify({ scanned: r.scanned, top: r.archetypes.slice(0, 12) }, null, 2));
604
+ return;
605
+ }
606
+ if (cmd === 'layouts') {
607
+ const pid = args[1];
608
+ if (!pid) throw new Error('usage: openzoo sonar layouts <programId>');
609
+ const out = await inferAccountLayouts(pid, { log });
610
+ console.log(JSON.stringify(out, null, 2));
611
+ return;
612
+ }
613
+ if (cmd === 'cpi') {
614
+ const pid = args[1];
615
+ if (!pid) throw new Error('usage: openzoo sonar cpi <programId>');
616
+ const programs = JSON.parse(fs.readFileSync(path.join(SONAR_DIR, 'programs.json'), 'utf8'));
617
+ const row = programs.find((p) => p.programId === pid);
618
+ if (!row) throw new Error(`${pid} not in programs.json`);
619
+ const elf = await fetchBinary(row.programDataAddress);
620
+ if (!elf) throw new Error('no binary (ProgramData closed)');
621
+ console.log(JSON.stringify({ programId: pid, composesWith: extractCpis(elf, pid) }, null, 2));
622
+ return;
623
+ }
624
+ if (cmd === 'blind') {
625
+ const { summary } = await blindEval({
626
+ n: Number(flag('n') || 30),
627
+ seed: Number(flag('seed') || 20260825),
628
+ gapFilter: args.includes('--filter'),
629
+ log,
630
+ });
631
+ console.log(JSON.stringify(summary, null, 2));
632
+ return;
633
+ }
634
+ if (cmd === 'eval') {
635
+ const { summary } = await evaluate({
636
+ n: Number(flag('n') || 40),
637
+ seed: Number(flag('seed') || 7),
638
+ log,
639
+ });
640
+ console.log(JSON.stringify(summary, null, 2));
641
+ return;
642
+ }
643
+ if (cmd === 'status') {
644
+ const stat = (f) => { try { return fs.statSync(path.join(SONAR_DIR, f)).size; } catch { return 0; } };
645
+ let idls = 0;
646
+ try {
647
+ idls = fs.readFileSync(path.join(SONAR_DIR, 'idls.jsonl'), 'utf8').split('\n').filter(Boolean).length;
648
+ } catch { /* none yet */ }
649
+ console.log(JSON.stringify({
650
+ dir: SONAR_DIR,
651
+ programs: stat('programs.json') ? JSON.parse(fs.readFileSync(path.join(SONAR_DIR, 'programs.json'), 'utf8')).length : 0,
652
+ idlsHarvested: idls,
653
+ rainbowBytes: stat('rainbow.json'),
654
+ rpc: RPC.replace(/\/[^/]{8,}$/, '/***'),
655
+ }, null, 2));
656
+ return;
657
+ }
658
+ console.log([
659
+ 'openzoo sonar — map the programs nobody published an IDL for',
660
+ '',
661
+ ' sonar programs enumerate every upgradeable program on the cluster',
662
+ ' sonar harvest [--concurrency N]',
663
+ ' probe all of them for on-chain Anchor IDLs',
664
+ ' (resumable: re-run to continue where it stopped)',
665
+ ' sonar rainbow build the discriminator -> name table from the harvest',
666
+ ' sonar scan <programId> identify a binary against the rainbow table',
667
+ ' sonar fingerprint [--limit N]',
668
+ ' MinHash every reference binary (fork detection)',
669
+ ' sonar forks <programId> scan the WHOLE cluster for forks of one program',
670
+ ' (probes a few KB per program, not the binary)',
671
+ ' sonar similar <programId> nearest known programs by binary similarity —',
672
+ ' a fork of something with an IDL inherits it',
673
+ ' sonar idl <programId> reconstruct an IDL from the binary — every name is',
674
+ ' a proven preimage (confirmed / solved / unresolved)',
675
+ ' sonar layouts <programId> infer account struct layouts from LIVE data',
676
+ ' sonar cpi <programId> which programs it calls into (composition map)',
677
+ ' sonar bind bind the harvested IDL corpus into leCore',
678
+ ' sonar compositions mine which callee COMBOS recur, and what',
679
+ ' vocabulary programs of that shape expose',
680
+ ' sonar eval [--n 40] hold out known-IDL programs, recover their names',
681
+ ' from the binary alone, and score against truth',
682
+ ' (the table is rebuilt WITHOUT them — no leakage)',
683
+ ' sonar status what has been collected so far',
684
+ '',
685
+ 'RPC comes from OPENZOO_RPC (put it in .env — it is gitignored).',
686
+ ].join('\n'));
687
+ }
688
+
689
+ // ---------------------------------------------------------------- similarity
690
+
691
+ /**
692
+ * FORK DETECTION — the second recovery lane, and the one that reaches the
693
+ * programs the rainbow table cannot.
694
+ *
695
+ * Half of live programs recover 100% of their instruction names from
696
+ * discriminators and half recover zero (measured), because there is more
697
+ * than one dispatch pattern. But Solana is overwhelmingly FORKS: a
698
+ * redeployed pump clone, a Raydium clone, the same program shipped under
699
+ * a new id. If an opaque binary is byte-similar to one that DID publish
700
+ * an IDL, the IDL transfers wholesale — no discriminator needed.
701
+ *
702
+ * (This is what streamflow's magnet-cli does in Rust: list-programs ->
703
+ * analyze against a referent -> rank. Implemented here so it shares the
704
+ * harvest and the RPC layer.)
705
+ *
706
+ * MinHash over 16-byte shingles, not a plain hash of the file: a fork
707
+ * with one constant changed, or built by a different compiler version,
708
+ * has a different digest but nearly identical shingles. The signature is
709
+ * fixed-size, so comparing one unknown against thousands of references is
710
+ * arithmetic on small arrays instead of megabyte diffs.
711
+ */
712
+ const SIG_SIZE = Number(process.env.OPENZOO_SONAR_SIG || 128);
713
+
714
+ /** Trailing zeros are rent-padding in the account, not program content —
715
+ * including them makes every big program look alike. */
716
+ export function trimElf(elf) {
717
+ let end = elf.length;
718
+ while (end > 0 && elf[end - 1] === 0) end--;
719
+ return elf.subarray(0, end);
720
+ }
721
+
722
+ export function fingerprint(elf, { shingle = 16, stride = 4 } = {}) {
723
+ const body = trimElf(elf);
724
+ const sig = new Array(SIG_SIZE).fill(0xffffffff);
725
+ for (let i = 0; i + shingle <= body.length; i += stride) {
726
+ // FNV-1a over the shingle, then SIG_SIZE cheap permutations of it.
727
+ let h = 0x811c9dc5;
728
+ for (let j = 0; j < shingle; j++) {
729
+ h ^= body[i + j];
730
+ h = Math.imul(h, 0x01000193) >>> 0;
731
+ }
732
+ for (let k = 0; k < SIG_SIZE; k++) {
733
+ const v = (Math.imul(h ^ (k * 0x9e3779b1), 0x85ebca6b) >>> 0);
734
+ if (v < sig[k]) sig[k] = v;
735
+ }
736
+ }
737
+ return sig;
738
+ }
739
+
740
+ /** Estimated Jaccard similarity: the fraction of signature slots that agree. */
741
+ export function similarity(a, b) {
742
+ if (!a || !b || a.length !== b.length) return 0;
743
+ let same = 0;
744
+ for (let i = 0; i < a.length; i++) if (a[i] === b[i]) same++;
745
+ return same / a.length;
746
+ }
747
+
748
+ /**
749
+ * Fingerprint every program that published an IDL and has a live binary.
750
+ * That set is the reference library an unknown program is matched against.
751
+ */
752
+ export async function buildFingerprints({ log = () => {}, limit = 0, concurrency = 6 } = {}) {
753
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
754
+ const byId = new Map(programs.map((p) => [p.programId, p]));
755
+ const idls = fs.readFileSync(dir('idls.jsonl'), 'utf8').split('\n').filter(Boolean)
756
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
757
+ .filter((r) => r?.idl?.instructions?.length && byId.has(r.programId));
758
+
759
+ const out = dir('fingerprints.jsonl');
760
+ const done = new Set();
761
+ try {
762
+ for (const l of fs.readFileSync(out, 'utf8').split('\n')) if (l) done.add(JSON.parse(l).programId);
763
+ } catch { /* first run */ }
764
+
765
+ const todo = idls.filter((r) => !done.has(r.programId));
766
+ const queue = limit ? todo.slice(0, limit) : todo;
767
+ log(`sonar: fingerprinting ${queue.length} reference programs (${done.size} already done)`);
768
+
769
+ const stream = fs.createWriteStream(out, { flags: 'a', mode: 0o600 });
770
+ let ok = 0; let dead = 0;
771
+ const worker = async () => {
772
+ for (;;) {
773
+ const r = queue.shift();
774
+ if (!r) return;
775
+ let elf = null;
776
+ try { elf = await fetchBinary(byId.get(r.programId).programDataAddress); } catch { /* dead */ }
777
+ if (!elf) { dead++; continue; }
778
+ stream.write(JSON.stringify({
779
+ programId: r.programId,
780
+ name: r.idl.name || r.idl.metadata?.name || null,
781
+ bytes: trimElf(elf).length,
782
+ ixCount: r.idl.instructions.length,
783
+ sig: fingerprint(elf),
784
+ }) + '\n');
785
+ ok++;
786
+ if ((ok + dead) % 100 === 0) log(`sonar: ${ok} fingerprinted, ${dead} closed`);
787
+ }
788
+ };
789
+ await Promise.all(Array.from({ length: concurrency }, worker));
790
+ await new Promise((r) => stream.end(r));
791
+ log(`sonar: fingerprints — ${ok} live, ${dead} closed`);
792
+ return { ok, dead };
793
+ }
794
+
795
+ export function loadFingerprints() {
796
+ try {
797
+ return fs.readFileSync(dir('fingerprints.jsonl'), 'utf8').split('\n').filter(Boolean)
798
+ .map((l) => JSON.parse(l));
799
+ } catch { return []; }
800
+ }
801
+
802
+ /** Nearest known programs to an arbitrary binary. */
803
+ export function nearest(elf, refs, { top = 5 } = {}) {
804
+ const sig = fingerprint(elf);
805
+ return refs
806
+ .map((r) => ({ programId: r.programId, name: r.name, ixCount: r.ixCount, score: similarity(sig, r.sig) }))
807
+ .sort((a, b) => b.score - a.score)
808
+ .slice(0, top);
809
+ }
810
+
811
+ /**
812
+ * FIND FORKS OF ONE PROGRAM, ACROSS THE WHOLE CLUSTER, CHEAPLY.
813
+ *
814
+ * Fingerprinting 68,533 programs means downloading terabytes. The way
815
+ * around it: a fork is byte-identical over most of its body, so a few
816
+ * small PROBES taken at fixed offsets are enough to reject ~everything.
817
+ * getAccountInfo's dataSlice fetches exactly those bytes — ~12KB per
818
+ * program instead of ~1.4MB, a ~99% reduction — and only survivors are
819
+ * downloaded in full and scored properly.
820
+ *
821
+ * Probes are taken from deep inside the code, never the ELF header:
822
+ * every Solana program shares a near-identical header, so a header probe
823
+ * matches everything and filters nothing.
824
+ *
825
+ * The pass is EXACT-match on probe bytes, so it finds redeployments and
826
+ * lightly-edited forks. A fork that was recompiled (shifting code layout)
827
+ * will be missed here — that is the honest cost of not downloading the
828
+ * cluster, and `similar` still catches those once fingerprinted.
829
+ */
830
+ export async function findForks(referentId, {
831
+ log = () => {},
832
+ probes = 3,
833
+ probeLen = 4096,
834
+ concurrency = 12,
835
+ limit = 0,
836
+ } = {}) {
837
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
838
+ const byId = new Map(programs.map((p) => [p.programId, p]));
839
+ const refRow = byId.get(referentId);
840
+ if (!refRow) throw new Error(`${referentId} not in programs.json`);
841
+ const refElf = await fetchBinary(refRow.programDataAddress);
842
+ if (!refElf) throw new Error('referent has no binary');
843
+ const refBody = trimElf(refElf);
844
+ const refSig = fingerprint(refBody);
845
+
846
+ // Offsets at 30/50/70% of the real body — deep in code, away from the
847
+ // header and away from the zero padding.
848
+ const offsets = Array.from({ length: probes }, (_, i) =>
849
+ Math.floor(refBody.length * (0.3 + 0.2 * i)));
850
+ const want = offsets.map((o) => refBody.subarray(o, o + probeLen).toString('base64'));
851
+ const ELF_OFFSET = 45; // ProgramData header before the ELF
852
+
853
+ const candidates = programs.filter((p) => p.programId !== referentId);
854
+ const queue = limit ? candidates.slice(0, limit) : candidates;
855
+ log(`sonar: probing ${queue.length} programs for forks of ${referentId.slice(0, 8)}… (${probes}x${probeLen}B each)`);
856
+
857
+ const hits = [];
858
+ let scanned = 0;
859
+ const batches = [];
860
+ for (let i = 0; i < queue.length; i += 100) batches.push(queue.slice(i, i + 100));
861
+
862
+ const worker = async () => {
863
+ for (;;) {
864
+ const batch = batches.shift();
865
+ if (!batch) return;
866
+ // One probe first: a single mismatch rejects the program, and
867
+ // almost every program mismatches. Only survivors cost more calls.
868
+ let accounts;
869
+ try {
870
+ accounts = await rpc('getMultipleAccounts', [
871
+ batch.map((p) => p.programDataAddress),
872
+ { encoding: 'base64', dataSlice: { offset: ELF_OFFSET + offsets[0], length: probeLen } },
873
+ ]);
874
+ } catch (e) {
875
+ log(`sonar: probe batch failed (${e.message.slice(0, 50)})`);
876
+ scanned += batch.length;
877
+ continue;
878
+ }
879
+ for (let i = 0; i < batch.length; i++) {
880
+ const d = accounts.value[i]?.data?.[0];
881
+ if (d && d === want[0]) hits.push(batch[i]);
882
+ }
883
+ scanned += batch.length;
884
+ if (scanned % 5000 < 100) log(`sonar: ${scanned}/${queue.length} probed, ${hits.length} candidates`);
885
+ }
886
+ };
887
+ await Promise.all(Array.from({ length: concurrency }, worker));
888
+ log(`sonar: ${hits.length} candidates survived the probe — verifying in full`);
889
+
890
+ // Verify survivors by downloading and scoring properly.
891
+ const confirmed = [];
892
+ for (const h of hits) {
893
+ let elf = null;
894
+ try { elf = await fetchBinary(h.programDataAddress); } catch { /* gone */ }
895
+ if (!elf) continue;
896
+ const body = trimElf(elf);
897
+ confirmed.push({
898
+ programId: h.programId,
899
+ bytes: body.length,
900
+ identical: body.equals(refBody),
901
+ similarity: Number(similarity(refSig, fingerprint(body)).toFixed(4)),
902
+ });
903
+ }
904
+ confirmed.sort((a, b) => b.similarity - a.similarity);
905
+ fs.writeFileSync(dir(`forks-${referentId.slice(0, 8)}.json`), JSON.stringify({ referent: referentId, refBytes: refBody.length, scanned, confirmed }, null, 2));
906
+ return { referent: referentId, refBytes: refBody.length, scanned, candidates: hits.length, confirmed };
907
+ }
908
+
909
+ /**
910
+ * RECOMPILED forks, which the exact probe cannot see.
911
+ *
912
+ * MEASURED: scanning all 68,532 programs for byte-identical copies of
913
+ * pump found ZERO. Forks do not redeploy the same bytes — they bake in
914
+ * their own program id and fee wallets and rebuild, which shifts every
915
+ * offset and defeats exact matching.
916
+ *
917
+ * Fuzzy matching needs the whole binary, and downloading 68k of them is
918
+ * terabytes. The filter that makes it affordable: an ELF's section-header
919
+ * offset (e_shoff, at byte 0x28) sits just past the end of the real code,
920
+ * so it is a free proxy for compiled size — and it lives in the first 64
921
+ * bytes. Two builds of the same source land within a few percent of each
922
+ * other; everything else is discarded for the price of a 64-byte read.
923
+ * Survivors are then downloaded and MinHashed properly.
924
+ */
925
+ export async function findForksFuzzy(referentId, {
926
+ log = () => {},
927
+ tolerance = 0.06,
928
+ minScore = 0.5,
929
+ concurrency = 14,
930
+ maxVerify = 400,
931
+ } = {}) {
932
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
933
+ const byId = new Map(programs.map((p) => [p.programId, p]));
934
+ const refRow = byId.get(referentId);
935
+ if (!refRow) throw new Error(`${referentId} not in programs.json`);
936
+ const refBody = trimElf(await fetchBinary(refRow.programDataAddress));
937
+ const refSig = fingerprint(refBody);
938
+ const refShoff = Number(refBody.readBigUInt64LE(0x28));
939
+ const lo = refShoff * (1 - tolerance);
940
+ const hi = refShoff * (1 + tolerance);
941
+ log(`sonar: referent e_shoff=${refShoff} — accepting ${Math.round(lo)}..${Math.round(hi)}`);
942
+
943
+ const ELF_OFFSET = 45;
944
+ const others = programs.filter((p) => p.programId !== referentId);
945
+ const batches = [];
946
+ for (let i = 0; i < others.length; i += 100) batches.push(others.slice(i, i + 100));
947
+
948
+ const sized = [];
949
+ let scanned = 0;
950
+ const worker = async () => {
951
+ for (;;) {
952
+ const batch = batches.shift();
953
+ if (!batch) return;
954
+ let accounts;
955
+ try {
956
+ accounts = await rpc('getMultipleAccounts', [
957
+ batch.map((p) => p.programDataAddress),
958
+ { encoding: 'base64', dataSlice: { offset: ELF_OFFSET, length: 64 } },
959
+ ]);
960
+ } catch { scanned += batch.length; continue; }
961
+ for (let i = 0; i < batch.length; i++) {
962
+ const d = accounts.value[i]?.data?.[0];
963
+ if (!d) continue;
964
+ const h = Buffer.from(d, 'base64');
965
+ if (h.length < 0x30 || h[0] !== 0x7f || h[1] !== 0x45) continue;
966
+ let shoff;
967
+ try { shoff = Number(h.readBigUInt64LE(0x28)); } catch { continue; }
968
+ if (shoff >= lo && shoff <= hi) sized.push({ ...batch[i], shoff });
969
+ }
970
+ scanned += batch.length;
971
+ if (scanned % 10000 < 100) log(`sonar: ${scanned}/${others.length} headers read, ${sized.length} size-compatible`);
972
+ }
973
+ };
974
+ await Promise.all(Array.from({ length: concurrency }, worker));
975
+ log(`sonar: ${sized.length} size-compatible of ${scanned} — downloading to score`);
976
+
977
+ // Closest in size first, so a truncated verify still checks the best.
978
+ sized.sort((a, b) => Math.abs(a.shoff - refShoff) - Math.abs(b.shoff - refShoff));
979
+ const verify = sized.slice(0, maxVerify);
980
+ if (sized.length > maxVerify) log(`sonar: verifying the ${maxVerify} closest by size (${sized.length - maxVerify} not downloaded)`);
981
+
982
+ const scored = [];
983
+ let done = 0;
984
+ const vq = [...verify];
985
+ const vworker = async () => {
986
+ for (;;) {
987
+ const p = vq.shift();
988
+ if (!p) return;
989
+ let elf = null;
990
+ try { elf = await fetchBinary(p.programDataAddress); } catch { /* gone */ }
991
+ done++;
992
+ if (!elf) continue;
993
+ const body = trimElf(elf);
994
+ const score = similarity(refSig, fingerprint(body));
995
+ if (score >= minScore) {
996
+ scored.push({ programId: p.programId, bytes: body.length, similarity: Number(score.toFixed(4)) });
997
+ log(`sonar: FORK ${p.programId} similarity ${score.toFixed(3)}`);
998
+ }
999
+ if (done % 50 === 0) log(`sonar: verified ${done}/${verify.length}, ${scored.length} forks`);
1000
+ }
1001
+ };
1002
+ await Promise.all(Array.from({ length: 6 }, vworker));
1003
+ scored.sort((a, b) => b.similarity - a.similarity);
1004
+ const result = { referent: referentId, refBytes: refBody.length, refShoff, scanned, sizeCompatible: sized.length, verified: verify.length, forks: scored };
1005
+ fs.writeFileSync(dir(`forks-fuzzy-${referentId.slice(0, 8)}.json`), JSON.stringify(result, null, 2));
1006
+ return result;
1007
+ }
1008
+
1009
+ // ---------------------------------------------------------------- rebuild
1010
+
1011
+ /**
1012
+ * Every 64-bit immediate the code loads via lddw, with its offset.
1013
+ *
1014
+ * This is how unknown discriminators are FOUND without a table: Anchor's
1015
+ * dispatch compares the incoming sighash against each instruction's
1016
+ * discriminator in turn, so those constants sit together in one stretch
1017
+ * of code. Collect all of them, and the ones the rainbow explains tell
1018
+ * you where the dispatch is; the unexplained ones sitting beside them are
1019
+ * this program's own instructions, whose names simply are not in the
1020
+ * table yet.
1021
+ */
1022
+ export function lddwImmediates(elf) {
1023
+ const body = trimElf(elf);
1024
+ const out = [];
1025
+ for (let i = 0; i + 16 <= body.length; i++) {
1026
+ // lddw: opcode 0x18, then a second word whose first 4 bytes are zero.
1027
+ if (body[i] !== 0x18) continue;
1028
+ if (body[i + 8] !== 0 || body[i + 9] !== 0 || body[i + 10] !== 0 || body[i + 11] !== 0) continue;
1029
+ const hex = body.subarray(i + 4, i + 8).toString('hex') + body.subarray(i + 12, i + 16).toString('hex');
1030
+ if (hex === '0000000000000000') continue;
1031
+ out.push({ hex, offset: i });
1032
+ }
1033
+ return out;
1034
+ }
1035
+
1036
+ /**
1037
+ * Keep only the hits inside the program's OWN dispatch.
1038
+ *
1039
+ * A binary contains the discriminators of every program it CPIs into —
1040
+ * token, ATA, whatever it calls — and those match the rainbow table just
1041
+ * as well as its own. MEASURED on pump: 54 instruction hits against 40
1042
+ * real instructions, i.e. 14 borrowed from callees, 74% precision.
1043
+ *
1044
+ * The separator is locality. A program's own dispatch compares its
1045
+ * discriminators in one stretch of code; the constants used to BUILD a
1046
+ * CPI live wherever that call site happens to be. So the largest cluster
1047
+ * of hits (each within `gap` of the next) is the dispatch, and hits
1048
+ * scattered outside it are somebody else's instructions.
1049
+ *
1050
+ * Falls through untouched when there is no clear cluster — a wrong
1051
+ * cluster would silently delete real instructions, and a false positive
1052
+ * is cheaper than a missing entrypoint.
1053
+ */
1054
+ export function dispatchCluster(hits, { gap = Number(process.env.OPENZOO_SONAR_GAP || 8192) } = {}) {
1055
+ if (hits.length < 4) return hits;
1056
+ const sorted = [...hits].sort((a, b) => a.offset - b.offset);
1057
+ const groups = [];
1058
+ let cur = [sorted[0]];
1059
+ for (let i = 1; i < sorted.length; i++) {
1060
+ if (sorted[i].offset - sorted[i - 1].offset <= gap) cur.push(sorted[i]);
1061
+ else { groups.push(cur); cur = [sorted[i]]; }
1062
+ }
1063
+ groups.push(cur);
1064
+ const best = groups.sort((a, b) => b.length - a.length)[0];
1065
+ // Only trust the cluster when it actually dominates; an even spread
1066
+ // means this program was not compiled with a contiguous dispatch.
1067
+ return best.length >= Math.max(4, hits.length * 0.5) ? best : hits;
1068
+ }
1069
+
1070
+ /**
1071
+ * Reconstruct as much of an IDL as the binary actually supports.
1072
+ *
1073
+ * Three tiers, and they are kept apart on purpose — a consumer needs to
1074
+ * know which parts are facts and which are proposals:
1075
+ *
1076
+ * confirmed the discriminator matched the rainbow table. The preimage
1077
+ * is known and hashes to it. This is not a guess.
1078
+ * solved the name was PROPOSED (by the model, or by mutating a
1079
+ * known name) and then VERIFIED by hashing it back to the
1080
+ * observed discriminator. Also not a guess — a wrong
1081
+ * proposal cannot survive sha256.
1082
+ * unresolved the discriminator is real and sits in the dispatch, but no
1083
+ * name is known. Reported as bytes, never invented.
1084
+ *
1085
+ * That last distinction is the whole design. A model asked to "write the
1086
+ * IDL" will happily produce beautiful fiction; a model asked to propose
1087
+ * NAMES that must hash to a known constant produces either a right answer
1088
+ * or nothing.
1089
+ */
1090
+ export async function reconstructIdl(programId, {
1091
+ log = () => {},
1092
+ proposals = 400,
1093
+ useModel = true,
1094
+ } = {}) {
1095
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
1096
+ const row = programs.find((p) => p.programId === programId);
1097
+ if (!row) throw new Error(`${programId} not in programs.json`);
1098
+ const elf = await fetchBinary(row.programDataAddress);
1099
+ if (!elf) throw new Error('no binary (ProgramData closed)');
1100
+ const rainbow = loadRainbow();
1101
+ let receipt = '';
1102
+
1103
+ const confirmed = scanDiscriminators(elf, rainbow);
1104
+ // Dispatch clustering is OFF by default. Blind ablation (n=40, 23
1105
+ // scored): it lifts micro precision 39.4% -> 50.2%, but costs macro
1106
+ // recall 45.1% -> 38.4% and takes programs that recovered SOMETHING to
1107
+ // zero, 10 -> 13. For IDL reconstruction that is the wrong trade — a
1108
+ // spurious instruction can be tried and discarded, a deleted one cannot
1109
+ // be discovered. Opt in with OPENZOO_SONAR_CLUSTER=1 when precision
1110
+ // matters more than coverage.
1111
+ const ixAll = confirmed.filter((h) => h.kind === 'instruction');
1112
+ const ixConfirmed = process.env.OPENZOO_SONAR_CLUSTER === '1' ? dispatchCluster(ixAll) : ixAll;
1113
+ const accConfirmed = confirmed.filter((h) => h.kind === 'account');
1114
+ log(`sonar: ${ixConfirmed.length} instructions and ${accConfirmed.length} accounts confirmed from the table`);
1115
+
1116
+ // Candidate discriminators: lddw immediates clustered near confirmed
1117
+ // hits. Without the clustering constraint every constant in the program
1118
+ // (fees, seeds, bitmasks) would be treated as a possible instruction.
1119
+ const imms = lddwImmediates(elf);
1120
+ const known = new Set(confirmed.map((c) => c.disc ?? c.hex));
1121
+ const anchors = confirmed.map((c) => c.offset).sort((a, b) => a - b);
1122
+ const NEAR = Number(process.env.OPENZOO_SONAR_NEAR || 4096);
1123
+ const nearAnchor = (off) => anchors.some((a) => Math.abs(a - off) <= NEAR);
1124
+ const unresolved = [];
1125
+ const seen = new Set();
1126
+ for (const im of imms) {
1127
+ if (rainbow.has(im.hex) || seen.has(im.hex)) continue;
1128
+ if (anchors.length && !nearAnchor(im.offset)) continue;
1129
+ seen.add(im.hex);
1130
+ unresolved.push(im);
1131
+ }
1132
+ log(`sonar: ${unresolved.length} unexplained discriminator-shaped constants in the dispatch region`);
1133
+
1134
+ // --- solve stage: propose names, keep only those that hash correctly.
1135
+ const target = new Map(unresolved.map((u) => [u.hex, u]));
1136
+ const solved = [];
1137
+ const tryName = (name) => {
1138
+ const h = ixDiscriminator(name).toString('hex');
1139
+ if (target.has(h)) {
1140
+ solved.push({ name, disc: h, offset: target.get(h).offset, via: 'verified' });
1141
+ target.delete(h);
1142
+ return true;
1143
+ }
1144
+ return false;
1145
+ };
1146
+
1147
+ // Cheap first: names already in the table, and common casing variants.
1148
+ // Anchor's IDL may say camelCase while the discriminator was built from
1149
+ // snake_case, so both spellings are worth hashing.
1150
+ const vocab = new Set();
1151
+ for (const r of loadRainbowRows()) if (r.kind === 'instruction') vocab.add(r.name);
1152
+ for (const n of [...vocab]) {
1153
+ const snake = n.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1154
+ const camel = n.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1155
+ tryName(n); tryName(snake); tryName(camel);
1156
+ }
1157
+ log(`sonar: ${solved.length} solved from vocabulary variants, ${target.size} still unexplained`);
1158
+
1159
+ // --- leCore stage: recall the vocabulary of programs shaped like this
1160
+ // one and hash-verify every name. Free (no model call), and it runs
1161
+ // BEFORE the paid stage so the model is only asked about what recall
1162
+ // could not settle.
1163
+ const cpis = extractCpis(elf, programId);
1164
+ const combo = [...new Set(cpis.filter((c) => c.label).map((c) => c.label))].sort().join('+');
1165
+ if (target.size) {
1166
+ const before = solved.length;
1167
+ const shapeQuery = [
1168
+ combo ? `composes with: ${combo}` : '',
1169
+ ixConfirmed.length ? `instructions: ${ixConfirmed.map((h) => h.name).join(', ')}` : '',
1170
+ ].filter(Boolean).join('\n') || `program ${programId}`;
1171
+ const recalled = await recallVocabulary(shapeQuery).catch(() => []);
1172
+ // Archetype vocabulary from the composition mining, same idea offline.
1173
+ const archetype = combo ? vocabularyFor(combo) : [];
1174
+ for (const n of [...new Set([...recalled, ...archetype])]) {
1175
+ const snake = n.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1176
+ const camel = n.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1177
+ tryName(n); tryName(snake); tryName(camel);
1178
+ }
1179
+ log(`sonar: leCore recalled ${recalled.length} names for shape "${combo || 'unknown'}" (+${archetype.length} archetype) — ${solved.length - before} verified, ${target.size} left`);
1180
+ }
1181
+
1182
+ // --- model stage: ask for candidate names, verify every one by hashing.
1183
+ if (useModel && target.size) {
1184
+ const strings = extractStrings(elf, 6)
1185
+ .filter((s) => /^[A-Za-z][A-Za-z0-9_ ]{4,40}$/.test(s)).slice(0, 120);
1186
+ try {
1187
+ const { zooChat } = await import('./voice.js').then(() => import('./pay.js')).catch(() => ({}));
1188
+ void zooChat;
1189
+ } catch { /* voice/pay optional */ }
1190
+ const { PayClient } = await import('./pay.js');
1191
+ const { priceLine } = await import('./voice.js');
1192
+ const { recordReceipt } = await import('./receipts.js');
1193
+ const prompt = [
1194
+ 'You are reverse-engineering a Solana Anchor program. Propose likely INSTRUCTION NAMES.',
1195
+ '',
1196
+ `Instructions already identified in this program: ${ixConfirmed.map((h) => h.name).join(', ') || '(none)'}`,
1197
+ `Account types identified: ${accConfirmed.map((h) => h.name).join(', ') || '(none)'}`,
1198
+ `Strings found in the binary: ${strings.slice(0, 60).join(' | ')}`,
1199
+ '',
1200
+ `There are ${target.size} more instructions whose names are unknown.`,
1201
+ 'Propose plausible Anchor instruction names for them, in the SAME naming style as the identified ones.',
1202
+ 'Output ONLY a JSON array of snake_case strings, no prose. Propose many candidates — wrong guesses cost nothing because each is verified against a hash.',
1203
+ ].join('\n');
1204
+ try {
1205
+ const { data } = await new PayClient().chat({
1206
+ model: process.env.OPENZOO_SONAR_MODEL || 'fable-5',
1207
+ max_tokens: 2000,
1208
+ messages: [{ role: 'user', content: prompt }],
1209
+ });
1210
+ const txt = data?.choices?.[0]?.message?.content || '';
1211
+ const m = txt.match(/\[[\s\S]*\]/);
1212
+ const names = m ? JSON.parse(m[0]) : [];
1213
+ let hit = 0;
1214
+ for (const n of names.slice(0, proposals)) {
1215
+ if (typeof n !== 'string') continue;
1216
+ const snake = n.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1217
+ const camel = n.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1218
+ if (tryName(n) || tryName(snake) || tryName(camel)) hit++;
1219
+ }
1220
+ // Every reconstruction carries its receipt, same as every other
1221
+ // openzoo surface: what it cost, and what the same call would have
1222
+ // cost buying direct.
1223
+ const billedUsd = Number(data?.x402?.billedUsd ?? data?.usage?.cost ?? 0);
1224
+ const directUsd = Number(data?.x402?.directUsd ?? 0);
1225
+ receipt = priceLine({ routedModel: data?.model || 'openzoo', billedUsd, directUsd });
1226
+ recordReceipt({
1227
+ kind: 'sonar:idl',
1228
+ tool: 'sonar',
1229
+ model: data?.model || 'openzoo',
1230
+ billedUsd,
1231
+ directUsd,
1232
+ seconds: 0,
1233
+ input: `${programId} — ${target.size + hit} unresolved discriminators`,
1234
+ output: `${hit} names verified by hash`,
1235
+ });
1236
+ log(`sonar: model proposed ${names.length} names, ${hit} verified by hash (${target.size} still unexplained) · ${receipt}`);
1237
+ } catch (e) {
1238
+ log(`sonar: model stage skipped (${e.message.slice(0, 70)})`);
1239
+ }
1240
+ }
1241
+
1242
+ const idl = {
1243
+ address: programId,
1244
+ metadata: {
1245
+ name: 'reconstructed',
1246
+ spec: 'anchor-idl-reconstructed/0.1',
1247
+ description: 'Recovered from the deployed binary. Names are proven preimages, never guesses.',
1248
+ },
1249
+ instructions: [
1250
+ ...ixConfirmed.map((h) => ({ name: h.name, discriminator: [...Buffer.from(h.disc, 'hex')], confidence: 'confirmed' })),
1251
+ ...solved.map((s) => ({ name: s.name, discriminator: [...Buffer.from(s.disc, 'hex')], confidence: 'solved' })),
1252
+ ],
1253
+ accounts: accConfirmed.map((h) => ({ name: h.name, discriminator: [...Buffer.from(h.disc, 'hex')], confidence: 'confirmed' })),
1254
+ unresolvedDiscriminators: [...target.values()].map((u) => ({ discriminator: u.hex, offset: u.offset })),
1255
+ // What it composes with — readable even when no name is recoverable.
1256
+ composesWith: cpis.map((c) => ({ programId: c.id, label: c.label })),
1257
+ // Every openzoo surface prices itself; this one is no exception.
1258
+ receipt: receipt || '(no paid call — recovered from table + leCore recall alone)',
1259
+ };
1260
+ fs.writeFileSync(dir(`idl-${programId.slice(0, 8)}.json`), JSON.stringify(idl, null, 2));
1261
+ return idl;
1262
+ }
1263
+
1264
+ /**
1265
+ * BLIND EVALUATION of the full reconstruction path.
1266
+ *
1267
+ * `evaluate` scores the raw discriminator sweep; this scores what
1268
+ * `sonar idl` would actually emit, under the conditions the real task
1269
+ * has:
1270
+ *
1271
+ * - the holdout is chosen by a seeded PRNG, not by me, so nobody picks
1272
+ * the flattering examples (pump was cherry-picked and hit 100%)
1273
+ * - the rainbow table is rebuilt with the holdout REMOVED, so the
1274
+ * program's own published names cannot leak in and answer the question
1275
+ * - the reconstruction runs from the binary alone and the truth is only
1276
+ * opened afterwards to score
1277
+ *
1278
+ * Reported as micro (pooled over all instructions, so big programs weigh
1279
+ * more) AND macro (mean of per-program rates, so one 241-instruction
1280
+ * program cannot carry the result).
1281
+ */
1282
+ export async function blindEval({ n = 30, seed = 20260825, log = () => {}, gapFilter = false } = {}) {
1283
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
1284
+ const byId = new Map(programs.map((p) => [p.programId, p]));
1285
+ const all = fs.readFileSync(dir('idls.jsonl'), 'utf8').split('\n').filter(Boolean)
1286
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
1287
+ .filter((r) => r?.idl?.instructions?.length >= 2 && byId.has(r.programId));
1288
+
1289
+ let x = seed;
1290
+ const rand = () => { x = (x * 1103515245 + 12345) & 0x7fffffff; return x / 0x7fffffff; };
1291
+ const pool = [...all];
1292
+ const holdout = [];
1293
+ while (holdout.length < n && pool.length) holdout.push(pool.splice(Math.floor(rand() * pool.length), 1)[0]);
1294
+
1295
+ const excluded = new Set(holdout.map((h) => h.programId));
1296
+ const rows = buildRainbow({ log: () => {}, exclude: excluded, write: false });
1297
+ const rainbow = new Map(rows.map((r) => [r.disc, r]));
1298
+ log(`sonar: blind eval — ${holdout.length} held out, table = ${rows.length} discriminators from ${all.length - holdout.length} other IDLs`);
1299
+
1300
+ const results = [];
1301
+ for (const h of holdout) {
1302
+ let elf = null;
1303
+ try { elf = await fetchBinary(byId.get(h.programId).programDataAddress); } catch { /* closed */ }
1304
+ if (!elf) { results.push({ programId: h.programId, skipped: 'no binary' }); continue; }
1305
+
1306
+ // A published IDL is only ground truth if the deployed binary
1307
+ // actually implements it. When none of its discriminators appear, the
1308
+ // account describes a build that is no longer on chain — scoring
1309
+ // against it measures IDL staleness, not recovery.
1310
+ const match = idlMatchesBinary(elf, h.idl);
1311
+ if (match.stale) {
1312
+ results.push({ programId: h.programId, skipped: 'stale idl', truth: match.total });
1313
+ log(` ${h.programId.slice(0, 8)}… SKIPPED — published IDL not implemented by the deployed binary`);
1314
+ continue;
1315
+ }
1316
+
1317
+ // --- blind: the binary only.
1318
+ let hits = scanDiscriminators(elf, rainbow).filter((k) => k.kind === 'instruction');
1319
+ if (gapFilter) hits = dispatchCluster(hits);
1320
+ const got = new Set(hits.map((k) => k.name));
1321
+
1322
+ // --- now open the answer key.
1323
+ const truth = new Set(h.idl.instructions.map((i) => i.name));
1324
+ const tp = [...got].filter((g) => truth.has(g)).length;
1325
+ results.push({
1326
+ programId: h.programId,
1327
+ truth: truth.size,
1328
+ claimed: got.size,
1329
+ correct: tp,
1330
+ recall: truth.size ? tp / truth.size : 0,
1331
+ precision: got.size ? tp / got.size : 0,
1332
+ });
1333
+ log(` ${h.programId.slice(0, 8)}… ${tp}/${truth.size} correct, ${got.size} claimed`);
1334
+ }
1335
+
1336
+ const scored = results.filter((r) => !r.skipped);
1337
+ const sum = (f) => scored.reduce((a, b) => a + f(b), 0);
1338
+ const mean = (f) => (scored.length ? sum(f) / scored.length : 0);
1339
+ const summary = {
1340
+ heldOut: holdout.length,
1341
+ scored: scored.length,
1342
+ skippedNoBinary: results.filter((r) => r.skipped === 'no binary').length,
1343
+ skippedStaleIdl: results.filter((r) => r.skipped === 'stale idl').length,
1344
+ tableDiscriminators: rows.length,
1345
+ microRecall: sum((r) => r.truth) ? sum((r) => r.correct) / sum((r) => r.truth) : 0,
1346
+ microPrecision: sum((r) => r.claimed) ? sum((r) => r.correct) / sum((r) => r.claimed) : 0,
1347
+ macroRecall: mean((r) => r.recall),
1348
+ macroPrecision: mean((r) => r.precision),
1349
+ perfect: scored.filter((r) => r.recall === 1).length,
1350
+ zero: scored.filter((r) => r.correct === 0).length,
1351
+ };
1352
+ fs.writeFileSync(dir('blind-eval.json'), JSON.stringify({ summary, results }, null, 2));
1353
+ return { summary, results };
1354
+ }
1355
+
1356
+ function loadRainbowRows() {
1357
+ try { return JSON.parse(fs.readFileSync(dir('rainbow.json'), 'utf8')); } catch { return []; }
1358
+ }
1359
+
1360
+ // ---------------------------------------------------------------- CPI
1361
+
1362
+ /**
1363
+ * WHAT DOES THIS PROGRAM COMPOSE WITH?
1364
+ *
1365
+ * A CPI needs the callee's program id at runtime, so that 32-byte pubkey
1366
+ * is compiled into the binary as a constant. Scanning for known ids
1367
+ * therefore reads a program's dependency graph straight out of the bytes
1368
+ * — no disassembly, no execution.
1369
+ *
1370
+ * This is the strongest signal available for programs whose instruction
1371
+ * names cannot be recovered: `system, token, ata, rent, metaplex` is an
1372
+ * NFT program and nothing else; a jupiter id means it routes swaps. It
1373
+ * classifies a binary even when every discriminator is a mystery.
1374
+ *
1375
+ * The candidate set is every program on the cluster plus the natives, so
1376
+ * this also finds composition between two unknown programs.
1377
+ */
1378
+ export const NATIVE_PROGRAMS = {
1379
+ '11111111111111111111111111111111': 'system',
1380
+ TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA: 'token',
1381
+ TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb: 'token-2022',
1382
+ ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL: 'associated-token',
1383
+ SysvarRent111111111111111111111111111111111: 'rent',
1384
+ SysvarC1ock11111111111111111111111111111111: 'clock',
1385
+ ComputeBudget111111111111111111111111111111: 'compute-budget',
1386
+ metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s: 'metaplex-token-metadata',
1387
+ BPFLoaderUpgradeab1e11111111111111111111111: 'bpf-loader-upgradeable',
1388
+ };
1389
+
1390
+ let CPI_INDEX = null;
1391
+
1392
+ /** hex(32-byte pubkey) -> label, over natives + every cluster program. */
1393
+ export function cpiIndex({ includeCluster = true } = {}) {
1394
+ if (CPI_INDEX) return CPI_INDEX;
1395
+ const idx = new Map();
1396
+ for (const [id, label] of Object.entries(NATIVE_PROGRAMS)) {
1397
+ idx.set(new PublicKey(id).toBuffer().toString('hex'), { id, label });
1398
+ }
1399
+ if (includeCluster) {
1400
+ try {
1401
+ for (const p of JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'))) {
1402
+ const k = new PublicKey(p.programId).toBuffer().toString('hex');
1403
+ if (!idx.has(k)) idx.set(k, { id: p.programId, label: null });
1404
+ }
1405
+ } catch { /* enumerate first */ }
1406
+ }
1407
+ CPI_INDEX = idx;
1408
+ return idx;
1409
+ }
1410
+
1411
+ /**
1412
+ * Program ids embedded in a binary. `self` is excluded from the callee
1413
+ * list — every Anchor program stores its OWN id (declare_id!), and
1414
+ * reporting it as a dependency would put every program in its own graph.
1415
+ */
1416
+ export function extractCpis(elf, selfId = null) {
1417
+ const body = trimElf(elf);
1418
+ const idx = cpiIndex();
1419
+ const found = new Map();
1420
+ // Pubkeys are 8-byte aligned in practice; stepping 4 keeps it cheap
1421
+ // while tolerating layouts that are only 4-aligned.
1422
+ for (let i = 0; i + 32 <= body.length; i += 4) {
1423
+ const hit = idx.get(body.subarray(i, i + 32).toString('hex'));
1424
+ if (hit && hit.id !== selfId && !found.has(hit.id)) found.set(hit.id, { ...hit, offset: i });
1425
+ }
1426
+ return [...found.values()];
1427
+ }
1428
+
1429
+ /**
1430
+ * Does the deployed binary actually implement the IDL it published?
1431
+ *
1432
+ * MEASURED, and this reframes the whole evaluation: every program that
1433
+ * recovered ZERO instructions in the blind eval has 1,500-4,000 lddw
1434
+ * immediates and embeds its own program id — it is a normal Anchor
1435
+ * program with a normal dispatch — yet NOT ONE byte of its published
1436
+ * discriminators appears anywhere in it. Those IDLs describe an older
1437
+ * build. The account was never updated after an upgrade.
1438
+ *
1439
+ * Scoring recovery against a stale IDL measures the wrong thing, so the
1440
+ * eval now reports those separately instead of counting them as misses.
1441
+ */
1442
+ export function idlMatchesBinary(elf, idl) {
1443
+ const body = trimElf(elf);
1444
+ const ixs = idl?.instructions || [];
1445
+ if (!ixs.length) return { present: 0, total: 0, stale: false };
1446
+ let present = 0;
1447
+ for (const ix of ixs) {
1448
+ const d = Array.isArray(ix.discriminator) ? Buffer.from(ix.discriminator) : ixDiscriminator(ix.name);
1449
+ if (body.includes(d)) { present++; continue; }
1450
+ const lo = d.subarray(0, 4); const hi = d.subarray(4, 8);
1451
+ let i = body.indexOf(lo); let f = false;
1452
+ while (i !== -1 && !f) { if (body.subarray(i + 8, i + 12).equals(hi)) f = true; i = body.indexOf(lo, i + 1); }
1453
+ if (f) present++;
1454
+ }
1455
+ return { present, total: ixs.length, stale: present === 0 };
1456
+ }
1457
+
1458
+ /**
1459
+ * COMMON COMPOSITIONS — which callees travel together, and what programs
1460
+ * built that way are FOR.
1461
+ *
1462
+ * A single CPI says little (nearly everything calls system + token). The
1463
+ * COMBINATION is the type signature: system+token+ata+metaplex is an NFT
1464
+ * minter, +jupiter is a router, token-2022 without metaplex is a
1465
+ * fungible-token vault. Mining those co-occurrences across the corpus
1466
+ * turns "unknown binary" into "this is shaped like these 40 programs,
1467
+ * whose instructions are named X, Y, Z".
1468
+ *
1469
+ * That last step is what makes it worth mining rather than merely
1470
+ * interesting: composition predicts VOCABULARY, and vocabulary is what
1471
+ * the reconstruction is short of. Names proposed from the right
1472
+ * neighbourhood still have to hash correctly, so a wrong archetype costs
1473
+ * nothing but a few hashes.
1474
+ *
1475
+ * ORDER is reported as the order the ids appear in the binary. That
1476
+ * reflects where the compiler placed each constant, which correlates with
1477
+ * code order but is NOT the runtime call sequence — establishing that
1478
+ * needs control-flow analysis this does not do. Labelled `layoutOrder`
1479
+ * rather than `callOrder` so nobody reads it as more than it is.
1480
+ */
1481
+ export async function mineCompositions({ log = () => {}, sample = 250, concurrency = 6 } = {}) {
1482
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
1483
+ const byId = new Map(programs.map((p) => [p.programId, p]));
1484
+ const idls = fs.readFileSync(dir('idls.jsonl'), 'utf8').split('\n').filter(Boolean)
1485
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
1486
+ .filter((r) => r?.idl?.instructions?.length && byId.has(r.programId));
1487
+
1488
+ const queue = idls.slice(0, sample);
1489
+ log(`sonar: mining compositions over ${queue.length} programs with known IDLs`);
1490
+
1491
+ const rows = [];
1492
+ let done = 0;
1493
+ const worker = async () => {
1494
+ for (;;) {
1495
+ const r = queue.shift();
1496
+ if (!r) return;
1497
+ let elf = null;
1498
+ try { elf = await fetchBinary(byId.get(r.programId).programDataAddress); } catch { /* closed */ }
1499
+ done++;
1500
+ if (done % 50 === 0) log(`sonar: ${done} scanned, ${rows.length} with binaries`);
1501
+ if (!elf) continue;
1502
+ const cpis = extractCpis(elf, r.programId);
1503
+ // Only NAMED callees form the archetype: an unknown program id is
1504
+ // real composition but says nothing generalisable.
1505
+ const named = cpis.filter((c) => c.label).sort((a, b) => a.offset - b.offset);
1506
+ if (!named.length) continue;
1507
+ rows.push({
1508
+ programId: r.programId,
1509
+ combo: [...new Set(named.map((c) => c.label))].sort().join('+'),
1510
+ layoutOrder: named.map((c) => c.label),
1511
+ instructions: r.idl.instructions.map((i) => i.name),
1512
+ unknownCallees: cpis.filter((c) => !c.label).length,
1513
+ });
1514
+ }
1515
+ };
1516
+ await Promise.all(Array.from({ length: concurrency }, worker));
1517
+
1518
+ // Group by combination and pool the instruction vocabulary of each.
1519
+ const byCombo = new Map();
1520
+ for (const r of rows) {
1521
+ const g = byCombo.get(r.combo) || { combo: r.combo, programs: [], vocab: new Map(), orders: new Map() };
1522
+ g.programs.push(r.programId);
1523
+ for (const n of r.instructions) g.vocab.set(n, (g.vocab.get(n) || 0) + 1);
1524
+ const ord = r.layoutOrder.join('>');
1525
+ g.orders.set(ord, (g.orders.get(ord) || 0) + 1);
1526
+ byCombo.set(r.combo, g);
1527
+ }
1528
+
1529
+ const archetypes = [...byCombo.values()]
1530
+ .map((g) => ({
1531
+ combo: g.combo,
1532
+ programs: g.programs.length,
1533
+ examples: g.programs.slice(0, 3),
1534
+ // Names shared by MORE THAN ONE program in the group: a name used
1535
+ // once is that program's own, not the archetype's vocabulary.
1536
+ sharedVocab: [...g.vocab.entries()].filter(([, n]) => n > 1)
1537
+ .sort((a, b) => b[1] - a[1]).slice(0, 25).map(([name, n]) => ({ name, programs: n })),
1538
+ commonLayoutOrder: [...g.orders.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null,
1539
+ }))
1540
+ .sort((a, b) => b.programs - a.programs);
1541
+
1542
+ const out = { scanned: rows.length, archetypes };
1543
+ fs.writeFileSync(dir('compositions.json'), JSON.stringify(out, null, 2));
1544
+ log(`sonar: ${archetypes.length} distinct compositions across ${rows.length} programs`);
1545
+ return out;
1546
+ }
1547
+
1548
+ /**
1549
+ * The vocabulary a program of THIS shape usually exposes — names to try
1550
+ * against unresolved discriminators, drawn from programs that compose the
1551
+ * same way. Every candidate is still hash-verified, so a wrong archetype
1552
+ * is free.
1553
+ */
1554
+ export function vocabularyFor(combo) {
1555
+ try {
1556
+ const { archetypes } = JSON.parse(fs.readFileSync(dir('compositions.json'), 'utf8'));
1557
+ const want = new Set(combo.split('+'));
1558
+ return archetypes
1559
+ .map((a) => {
1560
+ const have = new Set(a.combo.split('+'));
1561
+ const inter = [...want].filter((x) => have.has(x)).length;
1562
+ const union = new Set([...want, ...have]).size;
1563
+ return { ...a, overlap: union ? inter / union : 0 };
1564
+ })
1565
+ .filter((a) => a.overlap >= 0.5)
1566
+ .sort((a, b) => b.overlap - a.overlap)
1567
+ .flatMap((a) => a.sharedVocab.map((v) => v.name));
1568
+ } catch { return []; }
1569
+ }
1570
+
1571
+ // ---------------------------------------------------------------- leCore
1572
+
1573
+ /**
1574
+ * BIND THE CORPUS — this is where leCore actually earns its place.
1575
+ *
1576
+ * The reconstruction is short of one thing: candidate NAMES for
1577
+ * discriminators the rainbow table cannot explain. The corpus of ~4.4k
1578
+ * harvested IDLs contains them, but it is far too large to put in a
1579
+ * prompt and the useful slice differs per program.
1580
+ *
1581
+ * So each program becomes one bound item — its composition, its strings'
1582
+ * flavour, and its instruction vocabulary — and an unknown binary
1583
+ * RECALLS against it by its own shape. What comes back is the vocabulary
1584
+ * of programs built like this one, which is exactly the candidate list
1585
+ * the hash verifier needs. Bind once, ask forever, and pay for nothing
1586
+ * you did not retrieve.
1587
+ *
1588
+ * Every recalled name is still verified by hashing, so a bad recall costs
1589
+ * a few microseconds and never a wrong answer.
1590
+ */
1591
+ const LECORE = process.env.OPENZOO_LECORE_URL || 'http://127.0.0.1:8787';
1592
+ const LECORE_TOKEN = process.env.OPENZOO_LECORE_TOKEN || 'hrr-lab-token';
1593
+ const LECORE_TENANT = process.env.OPENZOO_LECORE_TENANT || 'claude-code';
1594
+
1595
+ export async function bindCorpus({ log = () => {}, sample = 0 } = {}) {
1596
+ let comps = { archetypes: [] };
1597
+ try { comps = JSON.parse(fs.readFileSync(dir('compositions.json'), 'utf8')); } catch { /* optional */ }
1598
+ const comboOf = new Map();
1599
+ for (const a of comps.archetypes || []) for (const p of a.examples || []) comboOf.set(p, a.combo);
1600
+
1601
+ const idls = fs.readFileSync(dir('idls.jsonl'), 'utf8').split('\n').filter(Boolean)
1602
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
1603
+ .filter((r) => r?.idl?.instructions?.length);
1604
+ const rows = sample ? idls.slice(0, sample) : idls;
1605
+
1606
+ const items = rows.map((r) => ({
1607
+ text: [
1608
+ `program ${r.programId}`,
1609
+ comboOf.get(r.programId) ? `composes with: ${comboOf.get(r.programId)}` : '',
1610
+ `instructions: ${r.idl.instructions.map((i) => i.name).join(', ')}`,
1611
+ r.idl.accounts?.length ? `accounts: ${r.idl.accounts.map((a) => a.name).join(', ')}` : '',
1612
+ ].filter(Boolean).join('\n'),
1613
+ metadata: { programId: r.programId },
1614
+ }));
1615
+
1616
+ let contextId = null;
1617
+ const BATCH = 500;
1618
+ for (let i = 0; i < items.length; i += BATCH) {
1619
+ const res = await fetch(`${LECORE}/internal/v1/hrr/bind`, {
1620
+ method: 'POST',
1621
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${LECORE_TOKEN}` },
1622
+ body: JSON.stringify({
1623
+ tenant_id: LECORE_TENANT,
1624
+ items: items.slice(i, i + BATCH),
1625
+ ...(contextId ? { context_id: contextId } : {}),
1626
+ }),
1627
+ });
1628
+ if (!res.ok) throw new Error(`lecore bind: HTTP ${res.status}`);
1629
+ contextId = (await res.json()).context_id;
1630
+ log(`sonar: bound ${Math.min(i + BATCH, items.length)}/${items.length} programs`);
1631
+ }
1632
+ fs.writeFileSync(dir('lecore.json'), JSON.stringify({ contextId, programs: items.length }, null, 2));
1633
+ log(`sonar: corpus bound -> ${contextId}`);
1634
+ return { contextId, programs: items.length };
1635
+ }
1636
+
1637
+ export function lecoreContext() {
1638
+ try { return JSON.parse(fs.readFileSync(dir('lecore.json'), 'utf8')).contextId; } catch { return null; }
1639
+ }
1640
+
1641
+ /** Vocabulary of programs shaped like this one, straight from leCore. */
1642
+ export async function recallVocabulary(query, { topK = 24 } = {}) {
1643
+ const ctx = lecoreContext();
1644
+ if (!ctx) return [];
1645
+ try {
1646
+ const res = await fetch(`${LECORE}/internal/v1/hrr/recall`, {
1647
+ method: 'POST',
1648
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${LECORE_TOKEN}` },
1649
+ body: JSON.stringify({ tenant_id: LECORE_TENANT, context_id: ctx, query, top_k: topK }),
1650
+ });
1651
+ if (!res.ok) return [];
1652
+ const items = (await res.json()).items || [];
1653
+ const names = new Set();
1654
+ for (const it of items) {
1655
+ const m = String(it.text || '').match(/^instructions: (.+)$/m);
1656
+ if (m) for (const n of m[1].split(',')) names.add(n.trim());
1657
+ }
1658
+ return [...names].filter(Boolean);
1659
+ } catch {
1660
+ return [];
1661
+ }
1662
+ }
1663
+
1664
+ /** memcmp filters take base58 bytes, so an 8-byte discriminator has to be
1665
+ * encoded as base58 rather than hex. */
1666
+ function bufToBase58(buf) {
1667
+ const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
1668
+ let n = 0n;
1669
+ for (const b of buf) n = n * 256n + BigInt(b);
1670
+ let out = '';
1671
+ while (n > 0n) { out = ALPHA[Number(n % 58n)] + out; n /= 58n; }
1672
+ for (const b of buf) { if (b === 0) out = '1' + out; else break; }
1673
+ return out || '1';
1674
+ }
1675
+
1676
+ // ---------------------------------------------------------------- layouts
1677
+
1678
+ /**
1679
+ * FIELD LAYOUTS FROM LIVE ACCOUNT DATA.
1680
+ *
1681
+ * Instruction names come out of the binary; TYPES do not. Rust compiles
1682
+ * a struct to offsets and the field names and types are gone. But the
1683
+ * program's accounts are sitting on chain right now, and a few hundred
1684
+ * instances of the same struct betray their own shape: a byte range that
1685
+ * is a different valid pubkey in every instance is a Pubkey; one that is
1686
+ * always 0 or 1 is a bool; eight bytes that read as a plausible u64 and
1687
+ * vary are a u64.
1688
+ *
1689
+ * This is inference from EVIDENCE rather than from a model, so each field
1690
+ * carries how many samples support it. One account proves nothing; three
1691
+ * hundred agreeing accounts is a layout.
1692
+ */
1693
+ export function inferLayout(samples) {
1694
+ if (!samples.length) return null;
1695
+ // Reason only over bytes EVERY sample has. dataSlice truncates at a fixed
1696
+ // ceiling while real accounts vary below it, so indexing off sample[0]
1697
+ // reads past the end of shorter ones.
1698
+ const len = Math.min(...samples.map((s) => s.length));
1699
+ const fixed = samples.every((s) => s.length === samples[0].length);
1700
+ if (len < 16) return { size: null, variableLength: true, samples: samples.length, fields: [] };
1701
+ const fields = [];
1702
+ let off = 8; // every Anchor account starts with its discriminator
1703
+
1704
+ const col = (o, n) => samples.map((s) => s.subarray(o, o + n));
1705
+ const allEqual = (bufs) => bufs.every((b) => b.equals(bufs[0]));
1706
+ const distinct = (bufs) => new Set(bufs.map((b) => b.toString('hex'))).size;
1707
+
1708
+ /** Do these 8 bytes read as a real-world u64 in every sample? */
1709
+ const looksU64 = (o) => o + 8 <= len
1710
+ && col(o, 8).every((b) => b.length === 8 && b.readBigUInt64LE(0) < 2n ** 53n);
1711
+
1712
+ while (off + 1 <= len) {
1713
+ // --- u64 BEFORE pubkey, and the order is the whole trick.
1714
+ //
1715
+ // A run of u64 amounts varies exactly as much as a pubkey does, so a
1716
+ // pubkey-first rule eats four of them as one 32-byte key. MEASURED
1717
+ // against pump's published struct: BondingCurve begins with five u64
1718
+ // reserves and the first version reported "pubkey @8".
1719
+ //
1720
+ // The separator is the TOP byte. A u64 holding an amount or a
1721
+ // timestamp leaves its high bytes zero; a pubkey is uniform random, so
1722
+ // all four of its 8-byte chunks reading as small u64s is a ~2^-44
1723
+ // event. Test the specific hypothesis first and the ambiguity is gone.
1724
+ if (looksU64(off)) {
1725
+ const c = col(off, 8);
1726
+ const d = distinct(c);
1727
+ if (d > 1) {
1728
+ fields.push({ offset: off, size: 8, type: 'u64', distinct: d, samples: samples.length });
1729
+ off += 8;
1730
+ continue;
1731
+ }
1732
+ }
1733
+ // --- Pubkey: 32 bytes, many distinct values, not a run of u64s.
1734
+ if (off + 32 <= len) {
1735
+ const c = col(off, 32);
1736
+ const d = distinct(c);
1737
+ const nonZero = c.filter((b) => !b.every((x) => x === 0)).length;
1738
+ const allChunksU64 = [0, 8, 16, 24].every((k) => looksU64(off + k));
1739
+ if (!allChunksU64 && d >= Math.max(2, samples.length * 0.25) && nonZero >= samples.length * 0.5) {
1740
+ fields.push({ offset: off, size: 32, type: 'pubkey', distinct: d, samples: samples.length });
1741
+ off += 32;
1742
+ continue;
1743
+ }
1744
+ }
1745
+ // --- bool: one byte that is only ever 0 or 1.
1746
+ const c1 = col(off, 1);
1747
+ if (c1.every((b) => b[0] === 0 || b[0] === 1) && distinct(c1) > 1) {
1748
+ fields.push({ offset: off, size: 1, type: 'bool', distinct: 2, samples: samples.length });
1749
+ off += 1;
1750
+ continue;
1751
+ }
1752
+ // --- constant: identical in every instance (padding, a version tag,
1753
+ // or a field nothing has ever set).
1754
+ if (allEqual(c1)) {
1755
+ const start = off;
1756
+ while (off < len && allEqual(col(off, 1))) off += 1;
1757
+ fields.push({ offset: start, size: off - start, type: 'constant', value: samples[0].subarray(start, off).toString('hex').slice(0, 32), samples: samples.length });
1758
+ continue;
1759
+ }
1760
+ // --- unclassified: varies but matches no shape above.
1761
+ const start = off;
1762
+ while (off < len && off - start < 8 && !allEqual(col(off, 1))) off += 1;
1763
+ fields.push({ offset: start, size: off - start, type: 'bytes', samples: samples.length });
1764
+ }
1765
+
1766
+ return { size: fixed ? len : null, variableLength: !fixed, samples: samples.length, fields };
1767
+ }
1768
+
1769
+ /**
1770
+ * Fetch the program's own accounts, group them by their 8-byte
1771
+ * discriminator, name the groups from the rainbow table, and infer each
1772
+ * layout from the instances.
1773
+ */
1774
+ export async function inferAccountLayouts(programId, { log = () => {}, maxPerType = 300 } = {}) {
1775
+ const rainbow = loadRainbow();
1776
+ const byDisc = new Map();
1777
+
1778
+ // Which account types does this program have? The binary names them;
1779
+ // the chain holds the instances. Take the discriminators the binary
1780
+ // confirms and query each one directly.
1781
+ let wanted = [];
1782
+ try {
1783
+ const programs = JSON.parse(fs.readFileSync(dir('programs.json'), 'utf8'));
1784
+ const row = programs.find((p) => p.programId === programId);
1785
+ const elf = row ? await fetchBinary(row.programDataAddress) : null;
1786
+ if (elf) {
1787
+ wanted = scanDiscriminators(elf, rainbow)
1788
+ .filter((h) => h.kind === 'account')
1789
+ .map((h) => ({ disc: h.disc, name: h.name }));
1790
+ }
1791
+ } catch { /* fall through to the unfiltered path */ }
1792
+
1793
+ if (wanted.length) {
1794
+ // memcmp on the discriminator: one bounded query per account type,
1795
+ // instead of dragging every account the program owns across the wire.
1796
+ // pump owns millions, and the unfiltered response exceeded Node's
1797
+ // maximum string length outright.
1798
+ for (const w of wanted) {
1799
+ const discFilter = { memcmp: { offset: 0, bytes: bufToBase58(Buffer.from(w.disc, 'hex')) } };
1800
+ // A popular account type has MILLIONS of instances and the unfiltered
1801
+ // response exceeds Node's maximum string length outright (pump's
1802
+ // BondingCurve). Layout inference does not want them all — a few
1803
+ // hundred settle every field — so on overflow the query is SHARDED by
1804
+ // pinning one byte at offset 8. That byte is the first byte of the
1805
+ // struct's first field, in practice a pubkey, so it is uniformly
1806
+ // distributed and each pin cuts the result ~256x. Two pins is ~65,000x.
1807
+ //
1808
+ // The sample is therefore uniform-ish rather than random: if a program
1809
+ // put something non-uniform at offset 8 the shard is skewed, which
1810
+ // would show up as suspiciously identical samples.
1811
+ let got = null;
1812
+ for (const shard of [[], [8], [8, 9]]) {
1813
+ const filters = [discFilter, ...shard.map((off) => ({ memcmp: { offset: off, bytes: bufToBase58(Buffer.from([7])) } }))];
1814
+ try {
1815
+ const res = await rpc('getProgramAccounts', [
1816
+ programId,
1817
+ { encoding: 'base64', dataSlice: { offset: 0, length: 800 }, filters },
1818
+ ]);
1819
+ got = { rows: res, sharded: shard.length };
1820
+ break;
1821
+ } catch (e) {
1822
+ if (!/longer than|too large|response/i.test(e.message)) {
1823
+ log(`sonar: ${w.name} query failed (${e.message.slice(0, 50)})`);
1824
+ break;
1825
+ }
1826
+ }
1827
+ }
1828
+ if (!got) { log(`sonar: ${w.name} — too many instances to sample`); continue; }
1829
+ const g = got.rows.slice(0, maxPerType).map((a) => Buffer.from(a.account.data[0], 'base64'));
1830
+ if (g.length) byDisc.set(w.disc, g);
1831
+ log(`sonar: ${w.name} — ${got.rows.length} accounts${got.sharded ? ` (sampled, ${got.sharded} byte(s) pinned)` : ''}`);
1832
+ }
1833
+ } else {
1834
+ try {
1835
+ const accounts = await rpc('getProgramAccounts', [
1836
+ programId,
1837
+ { encoding: 'base64', dataSlice: { offset: 0, length: 800 } },
1838
+ ]);
1839
+ for (const a of accounts) {
1840
+ const raw = Buffer.from(a.account.data[0], 'base64');
1841
+ if (raw.length < 16) continue;
1842
+ const disc = raw.subarray(0, 8).toString('hex');
1843
+ const g = byDisc.get(disc) || [];
1844
+ if (g.length < maxPerType) g.push(raw);
1845
+ byDisc.set(disc, g);
1846
+ }
1847
+ } catch (e) {
1848
+ log(`sonar: getProgramAccounts failed (${e.message.slice(0, 70)})`);
1849
+ return [];
1850
+ }
1851
+ }
1852
+ log(`sonar: ${byDisc.size} distinct account shapes sampled`);
1853
+
1854
+ const out = [];
1855
+ for (const [disc, samples] of byDisc) {
1856
+ if (samples.length < 2) continue; // one instance proves nothing
1857
+ const known = rainbow.get(disc);
1858
+ out.push({
1859
+ name: known?.name || null,
1860
+ discriminator: disc,
1861
+ instances: samples.length,
1862
+ layout: inferLayout(samples),
1863
+ });
1864
+ }
1865
+ return out.sort((a, b) => b.instances - a.instances);
1866
+ }
1867
+
1868
+ /** Printable strings, the other cheap signal (error messages, seeds, names). */
1869
+ export function extractStrings(elf, min = 6) {
1870
+ const out = [];
1871
+ let cur = [];
1872
+ for (const b of elf) {
1873
+ if (b >= 0x20 && b < 0x7f) { cur.push(b); continue; }
1874
+ if (cur.length >= min) out.push(Buffer.from(cur).toString('ascii'));
1875
+ cur = [];
1876
+ }
1877
+ if (cur.length >= min) out.push(Buffer.from(cur).toString('ascii'));
1878
+ return out;
1879
+ }