238 lines
11 KiB
JavaScript
238 lines
11 KiB
JavaScript
// packages/builder/scripts/capture-gov-state.mjs
|
|
//
|
|
// One-time capture: deployed-state fixture for the self-improvement family's
|
|
// keyless build core (gov-build.ts). Pulls the 2 reference-script UTxOs, the
|
|
// 3 module infrastructure UTxOs, and one live sample proposal UTxO straight
|
|
// from Ogmios (raw JSON-RPC, queryLedgerState/utxo), then derives the
|
|
// audit-#10 golden token-name pin from the sample's own producing
|
|
// transaction (via Koios tx_utxos). Validates every piece before writing so
|
|
// the fixture can never silently drift from deployed bytecode - a failed
|
|
// validation exits non-zero instead of writing a bad file.
|
|
//
|
|
// deriveProposalTokenName is inlined below rather than imported from
|
|
// gov-build.ts: this script is step 1 of the TDD sequence and runs before
|
|
// gov-build.ts exists. Keep this copy byte-for-byte identical to
|
|
// gov-build.ts's exported version (both trace to the custodial
|
|
// self-improvement.ts original, audit-#10 verified against deployed
|
|
// bytecode).
|
|
//
|
|
// Resolves its own location via import.meta.dirname, so it behaves
|
|
// identically regardless of invocation cwd (same convention as
|
|
// regen-tool-schemas.mjs). Never runs in CI - unit tests read the
|
|
// checked-in JSON at test/fixtures/gov-state.fixture.json.
|
|
//
|
|
// Run manually (reaches the live testnet):
|
|
// node packages/builder/scripts/capture-gov-state.mjs
|
|
import { writeFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { Data, Constr, credentialToAddress, validatorToScriptHash } from '@lucid-evolution/lucid';
|
|
import { blake2b } from '@noble/hashes/blake2b';
|
|
|
|
const OGMIOS = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org';
|
|
const KOIOS = (process.env.VECTOR_KOIOS_URL || 'https://v2.koios.vector.testnet.apexfusion.org/').replace(/\/+$/, '') + '/api/v1';
|
|
|
|
const GOV_PROPOSAL_SPEND_HASH = process.env.GOV_PROPOSAL_SPEND_HASH || 'f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d';
|
|
const GOV_PROPOSAL_MINT_HASH = process.env.GOV_PROPOSAL_MINT_HASH || 'e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565';
|
|
|
|
function parseRef(ref) {
|
|
const [txHash, idx] = ref.split('#');
|
|
return { txHash, index: Number(idx) };
|
|
}
|
|
|
|
const SPEND_REF = parseRef(process.env.GOV_PROPOSAL_SPEND_REF || 'c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996#0');
|
|
const MINT_REF = parseRef(process.env.GOV_PROPOSAL_MINT_REF || 'c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a#0');
|
|
const INFRA = [
|
|
['PARAMS', process.env.GOV_PARAMS_UTXO || '2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796#0'],
|
|
['ORACLE', process.env.GOV_ORACLE_UTXO || '7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7#0'],
|
|
['CROSSREFS', process.env.GOV_CROSSREFS_UTXO || '96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2#0'],
|
|
].map(([name, ref]) => ({ name, ...parseRef(ref) }));
|
|
|
|
async function ogmios(method, params) {
|
|
const r = await fetch(OGMIOS, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 }),
|
|
});
|
|
if (!r.ok) throw new Error(`Ogmios HTTP error (${r.status}) calling ${method}: ${await r.text()}`);
|
|
const j = await r.json();
|
|
if (j.error) throw new Error(`Ogmios RPC error calling ${method}: ${JSON.stringify(j.error)}`);
|
|
return j.result;
|
|
}
|
|
|
|
// Same shape OgmiosProvider.ogmiosUtxosToLucid checks (packages/shared/src/provider.ts):
|
|
// inline datum arrives as a plain CBOR-hex string on `output.datum` for this
|
|
// Ogmios version; only fall through to the object forms defensively.
|
|
function extractDatum(output) {
|
|
if (!output.datum) return null;
|
|
if (typeof output.datum === 'string') return output.datum;
|
|
if (output.datum.value || output.datum.bytes) return output.datum.value || output.datum.bytes;
|
|
return null;
|
|
}
|
|
|
|
function deriveTokenName(prefix, cborHex) {
|
|
const hashBytes = blake2b(Buffer.from(cborHex, 'hex'), { dkLen: 32 });
|
|
const prefixHex = Buffer.from(prefix, 'utf-8').toString('hex');
|
|
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54); // 27 bytes = 54 hex chars
|
|
return prefixHex + hashSlice;
|
|
}
|
|
|
|
function deriveProposalTokenName(txHash, outputIndex) {
|
|
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
|
|
return deriveTokenName('prop_', outRefCbor);
|
|
}
|
|
|
|
const failures = [];
|
|
const fail = (msg) => failures.push(msg);
|
|
|
|
console.log(`Ogmios: ${OGMIOS}`);
|
|
console.log(`Koios: ${KOIOS}`);
|
|
|
|
console.log('\nFetching reference-script UTxOs...');
|
|
const refUtxoResults = await ogmios('queryLedgerState/utxo', {
|
|
outputReferences: [
|
|
{ transaction: { id: SPEND_REF.txHash }, index: SPEND_REF.index },
|
|
{ transaction: { id: MINT_REF.txHash }, index: MINT_REF.index },
|
|
],
|
|
});
|
|
const refScripts = [];
|
|
for (const [label, ref, expectedHash] of [
|
|
['spend', SPEND_REF, GOV_PROPOSAL_SPEND_HASH],
|
|
['mint', MINT_REF, GOV_PROPOSAL_MINT_HASH],
|
|
]) {
|
|
const u = refUtxoResults.find((x) => x.transaction.id === ref.txHash && x.index === ref.index);
|
|
if (!u) { fail(`${label} reference script UTxO not found: ${ref.txHash}#${ref.index}`); continue; }
|
|
const lang = u.script?.language;
|
|
const cbor = u.script?.cbor || '';
|
|
if (lang !== 'plutus:v3') { fail(`${label} ref script language is "${lang}", want "plutus:v3"`); continue; }
|
|
if (!cbor) { fail(`${label} ref script UTxO ${ref.txHash}#${ref.index} has no script CBOR`); continue; }
|
|
let computedHash = '';
|
|
try {
|
|
computedHash = validatorToScriptHash({ type: 'PlutusV3', script: cbor });
|
|
} catch (e) {
|
|
fail(`${label} script hash computation failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
continue;
|
|
}
|
|
if (computedHash !== expectedHash) {
|
|
fail(`${label} computed hash ${computedHash} does not match the constant ${expectedHash}`);
|
|
continue;
|
|
}
|
|
console.log(` ${label}: ${ref.txHash}#${ref.index} - ${cbor.length / 2} bytes - hash ${computedHash} (matches constant)`);
|
|
refScripts.push({ name: label, txHash: ref.txHash, index: ref.index, scriptCborHex: cbor, hash: computedHash });
|
|
}
|
|
|
|
console.log('\nFetching infrastructure UTxOs...');
|
|
const infraUtxoResults = await ogmios('queryLedgerState/utxo', {
|
|
outputReferences: INFRA.map((i) => ({ transaction: { id: i.txHash }, index: i.index })),
|
|
});
|
|
const infra = [];
|
|
for (const i of INFRA) {
|
|
const u = infraUtxoResults.find((x) => x.transaction.id === i.txHash && x.index === i.index);
|
|
if (!u) { fail(`infra UTxO ${i.name} not found: ${i.txHash}#${i.index}`); continue; }
|
|
const lovelace = u.value?.ada?.lovelace;
|
|
console.log(` ${i.name}: ${i.txHash}#${i.index} - ALIVE - ${lovelace ?? '?'} lovelace`);
|
|
infra.push({
|
|
name: i.name,
|
|
txHash: i.txHash,
|
|
index: i.index,
|
|
address: u.address,
|
|
valueLovelace: lovelace !== undefined ? String(lovelace) : '0',
|
|
datumCbor: extractDatum(u),
|
|
});
|
|
}
|
|
|
|
console.log('\nFetching sample proposal UTxO (first prop_-token holder)...');
|
|
const proposalSpendAddress = credentialToAddress('Mainnet', { type: 'Script', hash: GOV_PROPOSAL_SPEND_HASH });
|
|
const proposalUtxoResults = await ogmios('queryLedgerState/utxo', { addresses: [proposalSpendAddress] });
|
|
const propPrefix = Buffer.from('prop_', 'utf-8').toString('hex');
|
|
let sample = null;
|
|
let sampleTokenName = '';
|
|
for (const u of proposalUtxoResults) {
|
|
const assets = u.value?.[GOV_PROPOSAL_MINT_HASH];
|
|
if (!assets) continue;
|
|
const name = Object.keys(assets).find((n) => n.startsWith(propPrefix));
|
|
if (name) { sample = u; sampleTokenName = name; break; }
|
|
}
|
|
|
|
let sampleProposal = null;
|
|
let goldenTokenPin = null;
|
|
|
|
if (!sample) {
|
|
fail(`no prop_-token UTxO found at ${proposalSpendAddress} among ${proposalUtxoResults.length} UTxOs - need at least one submitted proposal to capture a sample and the golden token-name pin`);
|
|
} else {
|
|
console.log(` sample: ${sample.transaction.id}#${sample.index} token=${sampleTokenName.slice(0, 24)}...`);
|
|
const datumCbor = extractDatum(sample);
|
|
if (!datumCbor) {
|
|
fail(`sample proposal UTxO ${sample.transaction.id}#${sample.index} has no inline datum`);
|
|
} else {
|
|
sampleProposal = {
|
|
txHash: sample.transaction.id,
|
|
index: sample.index,
|
|
datumCbor,
|
|
tokenUnit: GOV_PROPOSAL_MINT_HASH + sampleTokenName,
|
|
};
|
|
|
|
console.log('\nDeriving the golden token-name pin from the producing transaction (Koios)...');
|
|
const kr = await fetch(`${KOIOS}/tx_utxos`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
|
|
body: JSON.stringify({ _tx_hashes: [sample.transaction.id] }),
|
|
});
|
|
if (!kr.ok) {
|
|
fail(`Koios tx_utxos query failed (${kr.status}): ${await kr.text()}`);
|
|
} else {
|
|
const kj = await kr.json();
|
|
const inputs = kj?.[0]?.inputs || [];
|
|
console.log(` producing tx ${sample.transaction.id.slice(0, 12)}... has ${inputs.length} input(s)`);
|
|
for (const inp of inputs) {
|
|
const inHash = inp.tx_hash;
|
|
const inIdx = Number(inp.tx_index ?? inp.index ?? 0);
|
|
const recomputed = deriveProposalTokenName(inHash, inIdx);
|
|
if (recomputed === sampleTokenName) {
|
|
goldenTokenPin = { lockTxHash: inHash, lockOutputIndex: inIdx, expectedTokenName: sampleTokenName };
|
|
console.log(` MATCH: input ${inHash}#${inIdx} reproduces the on-chain token name via deriveProposalTokenName`);
|
|
break;
|
|
}
|
|
}
|
|
if (!goldenTokenPin) {
|
|
fail(`no input of spend tx ${sample.transaction.id} reproduces the on-chain token name ${sampleTokenName} via deriveProposalTokenName - audit-#10 regression, STOP`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('\nCAPTURE VALIDATION FAILED:');
|
|
for (const f of failures) console.error(` - ${f}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Deliberately dereference sampleProposal/goldenTokenPin's fields here,
|
|
// BEFORE writeFileSync, rather than only embedding them in the fixture
|
|
// object below: an object literal doesn't dereference its values, so if a
|
|
// future success path ever left either variable null without also pushing a
|
|
// fail() (the failures.length check above would then wrongly let a null
|
|
// slip through), `const fixture = {...}` and JSON.stringify would happily
|
|
// write "sampleProposal": null / "goldenTokenPin": null to disk with no
|
|
// error at all. Real property access (.txHash, .lockTxHash, ...) throws a
|
|
// TypeError on null, so doing it here makes the STOP-before-write guarantee
|
|
// a structural crash-before-write instead of resting solely on every
|
|
// success path remembering to keep the failures array in sync.
|
|
console.log('\nCapture validated, about to write:');
|
|
console.log(` refScripts: ${refScripts.length}/2, infra: ${infra.length}/3`);
|
|
console.log(` sampleProposal: ${sampleProposal.txHash}#${sampleProposal.index}`);
|
|
console.log(` goldenTokenPin.lockTxHash: ${goldenTokenPin.lockTxHash}`);
|
|
console.log(` goldenTokenPin.lockOutputIndex: ${goldenTokenPin.lockOutputIndex}`);
|
|
console.log(` goldenTokenPin.expectedTokenName: ${goldenTokenPin.expectedTokenName}`);
|
|
|
|
const fixture = {
|
|
asOf: new Date().toISOString(),
|
|
refScripts,
|
|
infra,
|
|
sampleProposal,
|
|
goldenTokenPin,
|
|
};
|
|
|
|
const outPath = resolve(import.meta.dirname, '../test/fixtures/gov-state.fixture.json');
|
|
writeFileSync(outPath, JSON.stringify(fixture, null, 2) + '\n');
|
|
console.log(`\nwrote ${outPath}`);
|