-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecord-all-snapshots.mjs
More file actions
474 lines (430 loc) · 19.6 KB
/
record-all-snapshots.mjs
File metadata and controls
474 lines (430 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
#!/usr/bin/env node
/**
* Orbital Sentinel — Real CRE snapshot → on-chain proof bridge.
*
* Reads the 7 CRE snapshot JSON files produced by the Orbital orchestration
* and writes keccak256 proof hashes to SentinelRegistry on Sepolia.
*
* Only writes when a snapshot has changed (compares generated_at_utc).
* Encodes workflow type in riskLevel string: "treasury:critical", "feeds:ok", etc.
*
* Replaces record-health-cron.mjs (which used fake hardcoded scenarios).
*/
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { config } from 'dotenv';
import pg from 'pg';
import {
createWalletClient,
createPublicClient,
http,
keccak256,
encodeAbiParameters,
parseAbiParameters,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { sepolia } from 'viem/chains';
// Load .env from repo root
config({ path: new URL('../.env', import.meta.url).pathname });
// ── Single-EOA Warning ────────────────────────────────────────────────
if (!process.env.MULTISIG_ENABLED) {
console.warn('⚠️ WARNING: Running with single EOA. Use multisig for production.');
console.warn('⚠️ Set MULTISIG_ENABLED=true after migrating to Gnosis Safe.');
}
// ── Constants ──────────────────────────────────────────────────────────
const REGISTRY_ADDRESS = '0x35EFB15A46Fa63262dA1c4D8DE02502Dd8b6E3a5';
const DEPLOYER_KEY = process.env.PRIVATE_KEY;
if (!DEPLOYER_KEY) {
console.error('PRIVATE_KEY not set in .env');
process.exit(1);
}
const RPC_URLS = [
'https://ethereum-sepolia-rpc.publicnode.com',
'https://rpc.sepolia.org',
'https://sepolia.drpc.org',
'https://sepolia.gateway.tenderly.co',
];
const SNAPSHOT_DIR = `${process.env.HOME}/projects/orbital/clients/stake-link/sdl/orchestration/intelligence/data`;
const STATE_FILE = `${process.env.HOME}/orbital-sentinel/scripts/.last-write-state.json`;
const registryAbi = [
{
type: 'function',
name: 'recordHealth',
inputs: [
{ name: 'snapshotHash', type: 'bytes32' },
{ name: 'riskLevel', type: 'string' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'count',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
stateMutability: 'view',
},
];
// ── Workflow Definitions ───────────────────────────────────────────────
const WORKFLOWS = [
{
key: 'laa',
file: 'cre_laa_snapshot.json',
extractRisk: (d) => {
const signal = d.signal ?? 'wait';
if (signal === 'execute') return 'ok';
if (signal === 'unprofitable' || signal === 'pool_closed' || signal === 'no_stlink') return 'warning';
return 'ok'; // 'wait' is normal
},
hashFields: (d) => {
// CANONICAL ENCODING for 'laa' workflow (see CHAINLINK.md § Canonical Hash Encoding Per Workflow)
// premium = premiumBps raw; linkBal = raw wei from poolState.linkBalance
const ts = BigInt(Math.floor(new Date(d.generated_at_utc || d.metadata?.timestamp).getTime() / 1000));
const signal = d.signal ?? 'wait';
const premium = BigInt(d.premiumQuotes?.[0]?.premiumBps ?? 0);
const linkBal = BigInt(d.poolState?.linkBalance ?? '0');
const risk = signal === 'execute' ? 'ok' : signal === 'wait' ? 'ok' : 'warning';
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string signal, uint256 premium, uint256 linkBal'),
[ts, 'laa', risk, premium, linkBal],
);
},
},
{
key: 'treasury',
file: 'cre_treasury_snapshot.json',
extractRisk: (d) => d.overallRisk ?? 'ok',
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const risk = d.overallRisk ?? 'ok';
// Community pool
const communityStaked = BigInt(Math.round(Number(d.staking?.community?.staked ?? 0)));
const communityCap = BigInt(Math.round(Number(d.staking?.community?.cap ?? 0)));
const communityFillPct = BigInt(Math.round((d.staking?.community?.fillPct ?? 0) * 100));
// Operator pool
const operatorStaked = BigInt(Math.round(Number(d.staking?.operator?.staked ?? 0)));
const operatorCap = BigInt(Math.round(Number(d.staking?.operator?.cap ?? 0)));
const operatorFillPct = BigInt(Math.round((d.staking?.operator?.fillPct ?? 0) * 100));
// Priority pool queue
const queueLink = BigInt(Math.round(Number(d.queue?.queueLink ?? 0)));
// Rewards vault
const vaultBalance = BigInt(Math.round(Number(d.rewards?.vaultBalance ?? 0)));
const runwayDays = BigInt(Math.round((d.rewards?.runwayDays ?? 0) * 100));
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 communityStaked, uint256 communityCap, uint256 communityFillPct, uint256 operatorStaked, uint256 operatorCap, uint256 operatorFillPct, uint256 queueLink, uint256 vaultBalance, uint256 runwayDays'),
[ts, 'treasury', risk, communityStaked, communityCap, communityFillPct, operatorStaked, operatorCap, operatorFillPct, queueLink, vaultBalance, runwayDays],
);
},
},
{
key: 'feeds',
file: 'cre_feed_snapshot.json',
extractRisk: (d) => {
const status = d.monitor?.depegStatus;
if (status === 'healthy' || status === 'ok') return 'ok';
if (status === 'warning') return 'warning';
if (status === 'critical') return 'critical';
return 'ok';
},
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const risk = d.monitor?.depegStatus === 'healthy' ? 'ok' : (d.monitor?.depegStatus ?? 'ok');
// stLINK/LINK ratio (6 decimal precision)
const ratio = BigInt(Math.round((d.monitor?.stlinkLinkPriceRatio ?? 0) * 1e6));
const depegBps = BigInt(Math.round((d.monitor?.depegBps ?? 0) * 100));
// Oracle prices (8 decimal precision, matching Chainlink feed decimals)
const linkUsd = BigInt(Math.round((d.monitor?.linkUsd ?? 0) * 1e8));
const ethUsd = BigInt(Math.round((d.monitor?.ethUsd ?? 0) * 1e8));
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 ratio, uint256 depegBps, uint256 linkUsd, uint256 ethUsd'),
[ts, 'feeds', risk, ratio, depegBps, linkUsd, ethUsd],
);
},
},
{
key: 'governance',
file: 'cre_governance_snapshot.json',
extractRisk: (d) => (d.summary?.urgentProposals > 0 ? 'warning' : 'ok'),
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const active = BigInt(d.summary?.activeProposals ?? 0);
const urgent = BigInt(d.summary?.urgentProposals ?? 0);
const risk = d.summary?.urgentProposals > 0 ? 'warning' : 'ok';
// Extract 7 most recent SLURPs with vote outcomes
const proposals = (d.proposals ?? []);
const slurps = proposals
.filter((p) => /SLURP[- ]?\d+/i.test(p.title))
.slice(0, 7);
// Encode each SLURP: number, yesPct (basis points), votes, passed (1/0)
const slurpData = slurps.map((p) => {
const m = p.title.match(/SLURP[- ]?(\d+)/i);
const num = BigInt(m ? m[1] : 0);
const total = p.scores_total || 1;
const yesPct = BigInt(Math.round(((p.scores?.[0] ?? 0) / total) * 10000));
const votes = BigInt(p.votes ?? 0);
const passed = BigInt((p.scores?.[0] ?? 0) > (p.scores?.[1] ?? 0) ? 1 : 0);
return { num, yesPct, votes, passed };
});
// Pack SLURP numbers into a single uint256 (7 x 16-bit values)
let slurpNums = 0n;
let slurpYesPcts = 0n;
let slurpVotes = 0n;
let slurpOutcomes = 0n;
for (let i = 0; i < 7; i++) {
const s = slurpData[i] ?? { num: 0n, yesPct: 0n, votes: 0n, passed: 0n };
slurpNums |= (s.num & 0xFFFFn) << BigInt(i * 16);
slurpYesPcts |= (s.yesPct & 0xFFFFn) << BigInt(i * 16);
slurpVotes |= (s.votes & 0xFFFFn) << BigInt(i * 16);
slurpOutcomes |= (s.passed & 0x1n) << BigInt(i);
}
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 active, uint256 urgent, uint256 slurpNums, uint256 slurpYesPcts, uint256 slurpVotes, uint256 slurpOutcomes'),
[ts, 'governance', risk, active, urgent, slurpNums, slurpYesPcts, slurpVotes, slurpOutcomes],
);
},
},
{
key: 'morpho',
file: 'cre_morpho_snapshot.json',
extractRisk: (d) => {
const util = d.morphoMarket?.utilization ?? 0;
if (util > 0.95) return 'critical';
if (util > 0.85) return 'warning';
return 'ok';
},
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const util01 = d.morphoMarket?.utilization ?? 0;
const risk = util01 > 0.95 ? 'critical' : util01 > 0.85 ? 'warning' : 'ok';
// Utilization (6 decimal precision)
const util = BigInt(Math.round(util01 * 1e6));
// Supply & borrow (raw wei values, truncated to whole tokens for hash)
const totalSupplyAssets = BigInt(d.morphoMarket?.totalSupplyAssets ?? '0') / (10n ** 18n);
const totalBorrowAssets = BigInt(d.morphoMarket?.totalBorrowAssets ?? '0') / (10n ** 18n);
// Vault share price (6 decimal precision)
const sharePrice = BigInt(Math.round((d.vault?.sharePrice ?? 0) * 1e6));
// Vault total assets (whole tokens)
const vaultTotalAssets = BigInt(d.vault?.totalAssets ?? '0') / (10n ** 18n);
// APY from IRM (basis points, 1 bp = 0.01%)
const borrowApyBps = BigInt(Math.round((d.apy?.borrowApy ?? 0) * 100));
const supplyApyBps = BigInt(Math.round((d.apy?.supplyApy ?? 0) * 100));
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 util, uint256 totalSupply, uint256 totalBorrow, uint256 sharePrice, uint256 vaultAssets, uint256 borrowApyBps, uint256 supplyApyBps'),
[ts, 'morpho', risk, util, totalSupplyAssets, totalBorrowAssets, sharePrice, vaultTotalAssets, borrowApyBps, supplyApyBps],
);
},
},
{
key: 'curve',
file: 'cre_curve_pool_snapshot.json',
extractRisk: (d) => d.overallRisk ?? 'unknown',
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const risk = d.overallRisk ?? 'unknown';
// Pool composition (whole tokens)
const linkBalance = BigInt(Math.round(d.pool?.linkBalance ?? 0));
const stlinkBalance = BigInt(Math.round(d.pool?.stlinkBalance ?? 0));
const imbalancePct = BigInt(Math.round((d.pool?.imbalancePct ?? 0) * 100));
// Pool metrics (6 decimal precision for virtualPrice)
const virtualPrice = BigInt(Math.round((d.pool?.virtualPrice ?? 0) * 1e6));
const tvlUsd = BigInt(Math.round(d.pool?.tvlUsd ?? 0));
// LINK price from oracle (8 decimals)
const linkUsd = BigInt(Math.round((d.prices?.linkUsd ?? 0) * 1e8));
// Gauge incentives
const gaugeStaked = BigInt(d.gauge?.totalStaked ?? '0') / (10n ** 18n);
const gaugeRewardCount = BigInt(d.gauge?.rewardCount ?? 0);
// Sum all active reward rates (tokens/sec in wei)
const totalRewardRate = BigInt(
(d.gauge?.rewards ?? []).reduce((sum, r) => sum + BigInt(r.ratePerSecond || '0'), 0n)
);
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 linkBalance, uint256 stlinkBalance, uint256 imbalancePct, uint256 virtualPrice, uint256 tvlUsd, uint256 linkUsd, uint256 gaugeStaked, uint256 gaugeRewardCount, uint256 totalRewardRate'),
[ts, 'curve', risk, linkBalance, stlinkBalance, imbalancePct, virtualPrice, tvlUsd, linkUsd, gaugeStaked, gaugeRewardCount, totalRewardRate],
);
},
},
{
key: 'ccip',
file: 'cre_ccip_snapshot.json',
extractRisk: (d) => {
const paused = d.metadata?.pausedCount ?? 0;
const ok = d.metadata?.okCount ?? 0;
const total = d.metadata?.laneCount ?? 0;
const unconfigured = total - ok - paused;
return (paused > 0 || unconfigured > 0) ? 'warning' : 'ok';
},
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const ok = BigInt(d.metadata?.okCount ?? 0);
const total = BigInt(d.metadata?.laneCount ?? 0);
const paused = d.metadata?.pausedCount ?? 0;
const unconfigured = Number(total) - Number(ok) - paused;
const risk = (paused > 0 || unconfigured > 0) ? 'warning' : 'ok';
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 okLanes, uint256 totalLanes'),
[ts, 'ccip', risk, ok, total],
);
},
},
{
key: 'composite',
file: 'cre_composite_snapshot.json',
extractRisk: (d) => d.composite_risk ?? 'unknown',
hashFields: (d) => {
const ts = BigInt(Math.floor(new Date(d.generated_at_utc).getTime() / 1000));
const risk = d.composite_risk ?? 'unknown';
const m = d.metrics ?? {};
// Encode key metrics from all workflows into a single proof hash
const premiumBps = BigInt(m.laa_premium_bps ?? 0);
const linkUsd = BigInt(Math.round((m.link_usd ?? 0) * 1e8)); // 8 decimals like Chainlink
const communityFillPct = BigInt(Math.round((m.treasury_community_fill_pct ?? 0) * 100));
const queueLink = BigInt(Math.round(m.treasury_queue_link ?? 0));
const morphoUtil = BigInt(Math.round((m.morpho_utilization ?? 0) * 1e6));
const ccipOk = BigInt(m.ccip_ok_lanes ?? 0);
const curveImbalance = BigInt(Math.round((m.curve_imbalance_pct ?? 0) * 100));
const confidence = BigInt(Math.round((d.confidence ?? 0) * 100));
return encodeAbiParameters(
parseAbiParameters('uint256 ts, string wf, string risk, uint256 premiumBps, uint256 linkUsd, uint256 communityFillPct, uint256 queueLink, uint256 morphoUtil, uint256 ccipOk, uint256 curveImbalance, uint256 confidence'),
[ts, 'composite', risk, premiumBps, linkUsd, communityFillPct, queueLink, morphoUtil, ccipOk, curveImbalance, confidence],
);
},
},
];
// ── Helpers ────────────────────────────────────────────────────────────
const log = (msg) => console.log(`[${new Date().toISOString()}] ${msg}`);
async function loadState() {
try {
if (existsSync(STATE_FILE)) {
return JSON.parse(await readFile(STATE_FILE, 'utf-8'));
}
} catch { /* start fresh */ }
return {};
}
async function saveState(state) {
await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
}
async function readSnapshot(file) {
const path = `${SNAPSHOT_DIR}/${file}`;
const raw = await readFile(path, 'utf-8');
return JSON.parse(raw);
}
async function writeOnChain(snapshotHash, riskLevel) {
const account = privateKeyToAccount(DEPLOYER_KEY);
let lastErr = null;
for (const rpcUrl of RPC_URLS) {
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
try {
const txHash = await walletClient.writeContract({
address: REGISTRY_ADDRESS,
abi: registryAbi,
functionName: 'recordHealth',
args: [snapshotHash, riskLevel],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
return { txHash, blockNumber: receipt.blockNumber, status: receipt.status };
} catch (err) {
lastErr = err;
}
}
throw new Error(`All ${RPC_URLS.length} RPCs failed. Last: ${lastErr?.message || lastErr}`);
}
// ── Database ──────────────────────────────────────────────────────────
const DB_URL = process.env.DATABASE_URL || '';
let _pool = null;
function getPool() {
if (!DB_URL) return null;
if (!_pool) _pool = new pg.Pool({ connectionString: DB_URL, max: 2 });
return _pool;
}
async function insertRecord({ snapshotHash, riskLevel, blockTimestamp, blockNumber, txHash, recorder }) {
const pool = getPool();
if (!pool) { log('DB insert skipped (DATABASE_URL not set)'); return; }
await pool.query(
`INSERT INTO sentinel_records (protocol_id, snapshot_hash, risk_level, block_timestamp, block_number, tx_hash, recorder, registry_address)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT ON CONSTRAINT uq_sentinel_tx DO NOTHING`,
['stake.link', snapshotHash, riskLevel, blockTimestamp, blockNumber, txHash, recorder, REGISTRY_ADDRESS],
);
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
log('Starting real CRE snapshot → on-chain proof bridge');
const state = await loadState();
let successes = 0;
let skips = 0;
let failures = 0;
for (const wf of WORKFLOWS) {
try {
const data = await readSnapshot(wf.file);
const generatedAt = data.generated_at_utc;
if (!generatedAt) {
log(`[${wf.key}] SKIP — no generated_at_utc in snapshot`);
skips++;
continue;
}
// Check if snapshot changed since last write
if (state[wf.key] === generatedAt) {
log(`[${wf.key}] SKIP — unchanged (${generatedAt})`);
skips++;
continue;
}
// Compute hash and risk
const risk = wf.extractRisk(data);
const encoded = wf.hashFields(data);
const snapshotHash = keccak256(encoded);
const riskLevel = `${wf.key}:${risk}`;
log(`[${wf.key}] Writing — risk=${riskLevel} hash=${snapshotHash.slice(0, 16)}...`);
const result = await writeOnChain(snapshotHash, riskLevel);
log(`[${wf.key}] TX ${result.txHash} — block ${result.blockNumber} status=${result.status}`);
// Insert into dashboard DB
try {
const account = privateKeyToAccount(DEPLOYER_KEY);
await insertRecord({
snapshotHash,
riskLevel,
blockTimestamp: new Date(),
blockNumber: Number(result.blockNumber),
txHash: result.txHash,
recorder: account.address,
});
log(`[${wf.key}] DB record inserted`);
} catch (dbErr) {
log(`[${wf.key}] DB insert failed (non-critical): ${dbErr.message}`);
}
state[wf.key] = generatedAt;
// L-8: Save state incrementally so progress is not lost if a later workflow fails
await saveState(state);
successes++;
} catch (err) {
const msg = err.message || String(err);
if (msg.includes('AlreadyRecorded')) {
log(`[${wf.key}] SKIP — hash already recorded on-chain`);
skips++;
} else {
log(`[${wf.key}] FAIL — ${msg}`);
failures++;
}
}
}
// Save state after all workflows
await saveState(state);
// Read total count on-chain
try {
const publicClient = createPublicClient({ chain: sepolia, transport: http(RPC_URLS[0]) });
const count = await publicClient.readContract({
address: REGISTRY_ADDRESS,
abi: registryAbi,
functionName: 'count',
});
log(`Total on-chain records: ${count}`);
} catch { /* non-critical */ }
// Close DB pool
if (_pool) await _pool.end();
log(`Done — ${successes} written, ${skips} skipped, ${failures} failed`);
// Exit non-zero only if ALL workflows failed
if (successes === 0 && failures > 0) {
process.exit(1);
}
}
main();