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/bin/openzoo.js +26 -0
- package/lib/dotenv.js +37 -0
- package/lib/pay.js +16 -2
- package/lib/proxy.js +1 -8
- package/lib/receipts.js +139 -0
- package/lib/sonar.js +1879 -0
- package/lib/voice.js +714 -0
- package/lib/voiceserve.js +84 -0
- package/lib/voicewatch.js +127 -0
- package/lib/x402.js +21 -4
- package/lib/xbot.js +553 -14
- package/package.json +1 -1
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
|
+
}
|