feat(builder): keyless self-improvement core A - critique + endorse builds, deployed-state fixtures, audit-#10 golden pin

This commit is contained in:
FilipBl4gojevic
2026-07-30 14:53:24 +02:00
parent 98f9612619
commit e345e2e652
5 changed files with 1092 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
// 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);
}
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}`);
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}`);

View File

@@ -0,0 +1,496 @@
// Keyless build core for the self-improvement family (spec PR 8).
// Constructs UNSIGNED transactions only. The user's key never enters this
// package: signing happens on the local signer (packages/signer), submission
// via vector_submit_transaction. Constants, datum parsers, token-name
// derivation, and helpers here are transcribed from the (still custodial,
// pending Task 5) packages/builder/src/vector/self-improvement.ts, typed and
// with instanceof Constr guards added to the parsers - datum field indices
// and Data.to/Constr layouts are otherwise unchanged, since the deployed
// module validators parse these exact bytes.
import {
fromText, toText, Data, Constr, credentialToAddress, getAddressDetails, SLOT_CONFIG_NETWORK,
} from '@lucid-evolution/lucid';
import type { LucidEvolution } from '@lucid-evolution/lucid';
import { blake2b } from '@noble/hashes/blake2b';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import type { VectorBuildStakeResult } from '@apexfusion/vector-mcp-shared/types';
import { toBuildResult } from './build.js';
// ─── Module constants (env-overridable exactly as the custodial module) ────
// Agent Registry policy ID - this module's own copy (self-improvement.ts has
// always declared this independently of registry-build.ts's REGISTRY_POLICY_ID;
// preserved as-is, not unified, since that's a separate, out-of-scope change).
export const AGENT_REGISTRY_POLICY = process.env.AGENT_REGISTRY_POLICY || 'be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01';
// Module contract hashes (from deploy_state.json). GOV_* names are pre-existing
// deployed config keys - kept as-is (renaming them is a separate, out-of-scope
// change; see the PR body's env-var exemption note).
export const GOV_PROPOSAL_SPEND_HASH = process.env.GOV_PROPOSAL_SPEND_HASH || 'f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d';
export const GOV_PROPOSAL_MINT_HASH = process.env.GOV_PROPOSAL_MINT_HASH || 'e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565';
export const GOV_CRITIQUE_SPEND_HASH = process.env.GOV_CRITIQUE_SPEND_HASH || 'ced52074861af95e2082004d6061b0fc4bb30fded61f9605bfc20e55';
export const GOV_CRITIQUE_MINT_HASH = process.env.GOV_CRITIQUE_MINT_HASH || '2e252a89894d379ce5c0023a57de4627056e4a96da72bd8fedba04bd';
export const GOV_ENDORSEMENT_SPEND_HASH = process.env.GOV_ENDORSEMENT_SPEND_HASH || '5fc449848d85f30287e5bc0bd2b3e95d872ef97be27f1480c12f1a9d';
export const GOV_TREASURY_ADDRESS = process.env.GOV_TREASURY_ADDRESS || 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8';
// Reference script UTxOs (CIP-33) - for the validated proposal spend+mint (Task 4).
export const GOV_PROPOSAL_SPEND_REF = process.env.GOV_PROPOSAL_SPEND_REF || 'c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996#0';
export const GOV_PROPOSAL_MINT_REF = process.env.GOV_PROPOSAL_MINT_REF || 'c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a#0';
// Infrastructure UTxOs (module reference inputs).
export const GOV_PARAMS_UTXO = process.env.GOV_PARAMS_UTXO || '2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796#0';
export const GOV_ORACLE_UTXO = process.env.GOV_ORACLE_UTXO || '7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7#0';
export const GOV_CROSSREFS_UTXO = process.env.GOV_CROSSREFS_UTXO || '96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2#0';
// Proposal state CBOR constructor tags.
export const STATE_NAMES: Record<number, string> = {
0: 'Open',
1: 'Amended',
2: 'Adopted',
3: 'Rejected',
4: 'Expired',
5: 'Withdrawn',
};
export const TYPE_NAMES: Record<number, string> = {
0: 'ParameterChange',
1: 'TreasurySpend',
2: 'ProtocolUpgrade',
3: 'GameActivation',
4: 'GeneralSuggestion',
};
export const PRIORITY_NAMES: Record<number, string> = {
0: 'Standard',
1: 'Emergency',
};
export const CRITIQUE_TYPE_NAMES: Record<number, string> = {
0: 'Supportive',
1: 'Opposing',
2: 'Amendment',
};
// Wire-level stake minimums (mirrored in the future tool schemas, Task 5) -
// enforced here too so a direct caller of buildCritique/buildEndorse cannot
// bypass them.
export const MIN_CRITIQUE_STAKE_APEX = 10;
export const MIN_ENDORSE_STAKE_APEX = 5;
export const MIN_PROPOSAL_STAKE_APEX = 25;
// ─── Slot/time config ───────────────────────────────────────────────────────
// Minimal provider surface needed for slot/time queries. OgmiosProvider
// satisfies this at runtime; the base Lucid `Provider` interface does not
// declare either method (they are OgmiosProvider-specific additions), so
// build functions that only take a `lucid` recover this surface via
// slotClockOf() below rather than taking a redundant provider parameter.
export interface SlotClockProvider {
getSystemStartMs(): Promise<number>;
getNetworkTip?(): Promise<{ slot: number; hash?: string }>;
}
// Recovers the provider a LucidEvolution instance was built with (Lucid()'s
// own config closure returns the exact same reference it was constructed
// with - see @lucid-evolution/lucid's Lucid() factory). Lets build functions
// that only take `lucid` still reach slot/tip queries: callers (including
// offline tests) supply the extended provider once, at lucidForAddress() time.
export function slotClockOf(lucid: LucidEvolution): SlotClockProvider {
const provider = lucid.config().provider;
if (!provider) throw new Error('Lucid instance has no provider configured - build it via lucidForAddress first.');
return provider as unknown as SlotClockProvider;
}
// Vector slot config - resolved from chain via Ogmios queryNetwork/startTime.
// Cached after first query so SLOT_CONFIG_NETWORK is set before Lucid needs
// it. Module-global cache, same as the custodial original: callers (tests
// included) must use a consistent zeroTime across calls in one process.
let vectorZeroTime: number | null = null;
export async function ensureSlotConfig(provider: Pick<SlotClockProvider, 'getSystemStartMs'>): Promise<number> {
if (vectorZeroTime === null) {
vectorZeroTime = await provider.getSystemStartMs();
SLOT_CONFIG_NETWORK.Mainnet = { zeroTime: vectorZeroTime, zeroSlot: 0, slotLength: 1000 };
}
return vectorZeroTime;
}
// ─── Small helpers ───────────────────────────────────────────────────────────
export function lovelaceToApex(lovelace: number | bigint): string {
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
}
export function scriptHashToAddress(hash: string): string {
return credentialToAddress('Mainnet', { type: 'Script', hash });
}
export function parseUtxoRef(ref: string): { txHash: string; outputIndex: number } {
const [txHash, idx] = ref.split('#');
return { txHash, outputIndex: parseInt(idx, 10) };
}
// Address helper local to this family (mirrors registry-build.ts's
// paymentKeyHashOf exactly, kept as an independent copy rather than a
// cross-family import - each build-core file depends only on build.ts, not
// on a sibling family file; see task-3-report.md for the reasoning).
export function paymentKeyHashOf(address: string): string {
let details;
try {
details = getAddressDetails(address);
} catch (e) {
throw new Error(`Invalid address: ${e instanceof Error ? e.message : String(e)}`);
}
if (details.paymentCredential?.type !== 'Key' || !details.paymentCredential.hash) {
throw new Error('Address must have a verification key payment credential, not a script credential.');
}
return details.paymentCredential.hash;
}
// Derive token name: prefix + blake2b_256(CBOR(data))[0..27]
export function deriveTokenName(prefix: string, cborHex: string): string {
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;
}
// AS-IS, audit-#10-verified: this flat Constr(0, [txHash, idx]) through
// Lucid's own Data.to (indefinite-length CBOR arrays) reproduces the token
// name actually minted by the deployed bytecode - see
// packages/builder/test/gov-build.test.ts's golden-pin test and
// packages/builder/test/fixtures/gov-state.fixture.json's goldenTokenPin,
// both captured live 2026-07-30. Do not "fix" the encoding.
export function deriveProposalTokenName(txHash: string, outputIndex: number): string {
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
return deriveTokenName('prop_', outRefCbor);
}
export function deriveActivityTokenName(agentDid: string): string {
const didBytes = Buffer.from(agentDid, 'hex');
const hashBytes = blake2b(didBytes, { dkLen: 32 });
const prefixHex = Buffer.from('pact_', 'utf-8').toString('hex');
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54);
return prefixHex + hashSlice;
}
// ─── Filebase IPFS upload ────────────────────────────────────────────────────
const FILEBASE_ACCESS_KEY = process.env.FILEBASE_ACCESS_KEY || '';
const FILEBASE_SECRET_KEY = process.env.FILEBASE_SECRET_KEY || '';
const FILEBASE_BUCKET = process.env.FILEBASE_BUCKET || '';
// A network side effect that lives in the hosted builder by design (moved
// verbatim from the custodial module): no wallet key material ever enters
// this function, it only ever sees the already-resolved document text.
export async function uploadToFilebase(document: string, namePrefix: string): Promise<{ cid: string; hash: string }> {
if (!FILEBASE_ACCESS_KEY || !FILEBASE_SECRET_KEY || !FILEBASE_BUCKET) {
throw new Error('Filebase not configured. Set FILEBASE_ACCESS_KEY, FILEBASE_SECRET_KEY, and FILEBASE_BUCKET env vars.');
}
// Canonical JSON: parse then re-stringify for deterministic bytes
const parsed = JSON.parse(document);
const canonical = JSON.stringify(parsed);
const docBytes = new TextEncoder().encode(canonical);
// blake2b_256 hash
const hashBytes = blake2b(docBytes, { dkLen: 32 });
const hashHex = Buffer.from(hashBytes).toString('hex');
const key = `${namePrefix}-${hashHex.slice(0, 16)}.json`;
const s3 = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3.filebase.com',
credentials: { accessKeyId: FILEBASE_ACCESS_KEY, secretAccessKey: FILEBASE_SECRET_KEY },
forcePathStyle: true,
});
const resp = await s3.send(new PutObjectCommand({
Bucket: FILEBASE_BUCKET,
Key: key,
Body: canonical,
ContentType: 'application/json',
}));
// Filebase returns the CID in response metadata
const cid = resp.$metadata?.httpStatusCode === 200
? (resp as unknown as { VersionId?: string }).VersionId || ''
: '';
// If CID not in response, try HeadObject to get it from x-amz-meta-cid
let finalCid = cid;
if (!finalCid) {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({ Bucket: FILEBASE_BUCKET, Key: key }));
finalCid = head.Metadata?.cid || '';
}
if (!finalCid) {
throw new Error('Filebase upload succeeded but CID not returned. Check bucket is IPFS-enabled.');
}
return { cid: finalCid, hash: hashHex };
}
// ─── Datum parsers ───────────────────────────────────────────────────────────
export interface GovProposal {
proposerDid: string;
proposalHash: string;
proposalType: string;
storageUri: string;
stakeAmount: number;
submittedAt: number;
reviewWindow: number;
priority: string;
amendmentCount: number;
incorporatedCritiques: number;
state: string;
}
export interface GovCritique {
criticDid: string;
proposalRef: Constr<Data>;
critiqueHash: string;
storageUri: string;
critiqueType: string;
stakeAmount: number;
submittedAt: number;
incorporated: boolean;
}
export interface GovEndorsement {
endorserDid: string;
proposalRef: Constr<Data>;
stakeAmount: number;
createdAt: number;
}
// Parse ProposalDatum from CBOR. Field indices kept exactly as the custodial
// module (0-11); instanceof Constr guards added around the indexed-Constr
// fields, matching registry-build.ts's parseAgentDatum pattern.
export function parseProposalDatum(datumCbor: string): GovProposal | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 12) return null;
const proposerDid = c.fields[0];
const proposalHash = c.fields[2];
const typeField = c.fields[3];
const priorityField = c.fields[8];
const stateField = c.fields[11];
const incorporatedCritiques = c.fields[10];
if (typeof proposerDid !== 'string' || typeof proposalHash !== 'string') return null;
if (!(typeField instanceof Constr) || !(priorityField instanceof Constr) || !(stateField instanceof Constr)) return null;
return {
proposerDid,
proposalHash,
proposalType: TYPE_NAMES[Number(typeField.index)] || 'Unknown',
storageUri: toText(c.fields[4] as string),
stakeAmount: Number(c.fields[5]),
submittedAt: Number(c.fields[6]),
reviewWindow: Number(c.fields[7]),
priority: PRIORITY_NAMES[Number(priorityField.index)] || 'Standard',
amendmentCount: Number(c.fields[9]),
incorporatedCritiques: Array.isArray(incorporatedCritiques) ? incorporatedCritiques.length : 0,
state: STATE_NAMES[Number(stateField.index)] || 'Unknown',
};
} catch {
return null;
}
}
// Parse CritiqueDatum from CBOR. Field indices kept exactly as the custodial
// module (0-8).
export function parseCritiqueDatum(datumCbor: string): GovCritique | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 9) return null;
const criticDid = c.fields[0];
const proposalRef = c.fields[2];
const critiqueHash = c.fields[3];
const critiqueTypeField = c.fields[5];
const incorporatedField = c.fields[8];
if (typeof criticDid !== 'string' || typeof critiqueHash !== 'string') return null;
if (!(proposalRef instanceof Constr) || !(critiqueTypeField instanceof Constr) || !(incorporatedField instanceof Constr)) return null;
return {
criticDid,
proposalRef,
critiqueHash,
storageUri: toText(c.fields[4] as string),
critiqueType: CRITIQUE_TYPE_NAMES[Number(critiqueTypeField.index)] || 'Unknown',
stakeAmount: Number(c.fields[6]),
submittedAt: Number(c.fields[7]),
incorporated: Number(incorporatedField.index) === 1,
};
} catch {
return null;
}
}
// Parse EndorsementDatum from CBOR. Field indices kept exactly as the
// custodial module (0-4).
export function parseEndorsementDatum(datumCbor: string): GovEndorsement | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 5) return null;
const endorserDid = c.fields[0];
const proposalRef = c.fields[2];
if (typeof endorserDid !== 'string') return null;
if (!(proposalRef instanceof Constr)) return null;
return {
endorserDid,
proposalRef,
stakeAmount: Number(c.fields[3]),
createdAt: Number(c.fields[4]),
};
} catch {
return null;
}
}
// ─── Build: critique ─────────────────────────────────────────────────────────
export async function buildCritique(lucid: LucidEvolution, p: {
agentDid: string;
proposalTxHash: string;
proposalOutputIndex: number;
critiqueType: 'Supportive' | 'Opposing' | 'Amendment';
stakeApex: number;
critiqueDocument?: string;
critiqueHash?: string;
storageUri?: string;
}): Promise<VectorBuildStakeResult> {
const changeAddress = await lucid.wallet().address();
// Validation order: stake minimum -> document/hash resolution (Filebase
// upload if a document was given) -> datum build -> lock build.
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
}
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
let finalHash = p.critiqueHash;
let finalUri = p.storageUri;
let ipfsCid: string | undefined;
let documentHash: string | undefined;
if (p.critiqueDocument) {
const uploaded = await uploadToFilebase(p.critiqueDocument, 'critique');
finalHash = uploaded.hash;
finalUri = `ipfs://${uploaded.cid}`;
ipfsCid = uploaded.cid;
documentHash = uploaded.hash;
}
if (!finalHash || finalHash.length !== 64) {
throw new Error('critiqueHash must be 64 hex characters (32 bytes). Provide critiqueDocument for automatic hashing or critiqueHash manually.');
}
if (!finalUri) {
throw new Error('storageUri is required. Provide critiqueDocument for automatic upload or storageUri manually.');
}
const vkeyHash = paymentKeyHashOf(changeAddress);
let critiqueTypeDatum: Constr<Data>;
switch (p.critiqueType) {
case 'Supportive': critiqueTypeDatum = new Constr(0, []); break;
case 'Opposing': critiqueTypeDatum = new Constr(1, []); break;
case 'Amendment': critiqueTypeDatum = new Constr(2, [finalHash]); break;
default: {
const exhaustive: never = p.critiqueType;
throw new Error(`Unknown critiqueType: ${String(exhaustive)}`);
}
}
const provider = slotClockOf(lucid);
await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
const currentSlot = tip.slot || 0;
const critiqueDatum = Data.to(new Constr(0, [
p.agentDid, // critic_did
new Constr(0, [vkeyHash]), // critic_credential
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
finalHash, // critique_hash
fromText(finalUri), // storage_uri
critiqueTypeDatum, // critique_type
stakeLovelace, // stake_amount
BigInt(currentSlot), // submitted_at
new Constr(0, []), // incorporated = False
]));
const critiqueSpendAddress = scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH);
const completed = await lucid.newTx()
.pay.ToAddressWithData(
critiqueSpendAddress,
{ kind: 'inline', value: critiqueDatum },
{ lovelace: stakeLovelace + 2_000_000n },
)
.complete({ localUPLCEval: false });
return {
...toBuildResult(completed),
op: 'critique',
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
stakeLovelace: stakeLovelace.toString(),
scriptAddress: critiqueSpendAddress,
...(ipfsCid ? { ipfsCid, documentHash } : {}),
};
}
// ─── Build: endorse ──────────────────────────────────────────────────────────
export async function buildEndorse(lucid: LucidEvolution, p: {
agentDid: string;
proposalTxHash: string;
proposalOutputIndex: number;
stakeApex: number;
}): Promise<VectorBuildStakeResult> {
const changeAddress = await lucid.wallet().address();
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
}
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
const vkeyHash = paymentKeyHashOf(changeAddress);
const provider = slotClockOf(lucid);
await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
const currentSlot = tip.slot || 0;
const endorsementDatum = Data.to(new Constr(0, [
p.agentDid, // endorser_did
new Constr(0, [vkeyHash]), // endorser_credential
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
stakeLovelace, // stake_amount
BigInt(currentSlot), // created_at
]));
const endorsementSpendAddress = scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH);
const completed = await lucid.newTx()
.pay.ToAddressWithData(
endorsementSpendAddress,
{ kind: 'inline', value: endorsementDatum },
{ lovelace: stakeLovelace + 2_000_000n },
)
.complete({ localUPLCEval: false });
return {
...toBuildResult(completed),
op: 'endorse',
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
stakeLovelace: stakeLovelace.toString(),
scriptAddress: endorsementSpendAddress,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,290 @@
import { describe, test, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CML, Data, Constr } from '@lucid-evolution/lucid';
import type { LucidEvolution } from '@lucid-evolution/lucid';
import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS } from './fixtures/fixture-provider.ts';
import { lucidForAddress } from '../src/vector/build.ts';
import {
GOV_PROPOSAL_SPEND_HASH, GOV_CRITIQUE_SPEND_HASH, GOV_ENDORSEMENT_SPEND_HASH,
MIN_CRITIQUE_STAKE_APEX, MIN_ENDORSE_STAKE_APEX,
scriptHashToAddress, deriveProposalTokenName,
parseProposalDatum, parseCritiqueDatum, parseEndorsementDatum,
buildCritique, buildEndorse,
} from '../src/vector/gov-build.ts';
// Deployed-state fixture, captured live from Vector testnet by
// scripts/capture-gov-state.mjs (2026-07-30) - see that file for the capture
// and validation logic. `sampleProposal.datumCbor` is a REAL chain datum;
// `goldenTokenPin` is the audit-#10 evidence (recomputed at capture time from
// the sample's own producing transaction).
const FIXTURE_PATH = resolve(import.meta.dirname!, 'fixtures/gov-state.fixture.json');
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, 'utf-8')) as {
asOf: string;
refScripts: Array<{ name: string; txHash: string; index: number; scriptCborHex: string; hash: string }>;
infra: Array<{ name: string; txHash: string; index: number; address: string; valueLovelace: string; datumCbor: string | null }>;
sampleProposal: { txHash: string; index: number; datumCbor: string; tokenUnit: string };
goldenTokenPin: { lockTxHash: string; lockOutputIndex: number; expectedTokenName: string };
};
// A FixtureProvider extended with the 2 chain-clock methods OgmiosProvider
// carries but the base Provider interface (and FixtureProvider) do not:
// gov-build's ensureSlotConfig/slotClockOf need getSystemStartMs and
// getNetworkTip. Object.assign onto a real FixtureProvider instance keeps
// every other method (getUtxos, getUtxosByOutRef, ...) working normally -
// only these two are stand-ins. Same zeroTime in every call site so
// ensureSlotConfig's module-level cache can't make test order matter.
const ZERO_TIME_MS = 1_700_000_000_000;
const TIP_SLOT = 1_000_000;
function chainClockProvider(utxos: typeof FIXTURE_UTXOS) {
return Object.assign(new FixtureProvider(utxos), {
getSystemStartMs: async () => ZERO_TIME_MS,
getNetworkTip: async () => ({ slot: TIP_SLOT }),
});
}
const AGENT_DID = 'ab'.repeat(32);
const PROPOSAL_TX = '11'.repeat(32);
function decodeTx(cborHex: string) {
return CML.Transaction.from_cbor_hex(cborHex);
}
function findOutputTo(tx: ReturnType<typeof decodeTx>, address: string) {
const outs = tx.body().outputs();
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() === address) return o;
}
return null;
}
let lucid: LucidEvolution;
before(async () => {
lucid = await lucidForAddress(chainClockProvider(FIXTURE_UTXOS), OWN_ADDRESS);
});
describe('gov-build constants and helpers', () => {
test('family spend addresses derive to the recon-verified bech32 values', () => {
assert.equal(
scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH),
'addr1w8uptag6wcqz665h86planmq73288czq4nhgt333ln8pxngfzg955',
);
assert.equal(
scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH),
'addr1w88d2gr5scd0jh3qsgqy6crpkr7yhvc0mmtpl9s9hlpqu4gra6s0g',
);
assert.equal(
scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH),
'addr1w90ugjvy3kzlxq58uk7qh54na9wcwthe003879yqcyh348gvfdvx4',
);
});
// Audit #10 (closed 2026-07-30): deriveProposalTokenName - flat
// Constr(0, [txHash, idx]) through Lucid's own Data.to - reproduces a token
// name actually minted on deployed bytecode. Literals below are the full
// hex captured live by scripts/capture-gov-state.mjs from the producing
// transaction of a real submitted proposal (spend tx
// 01ac86ca226e1824762dec15a405bed9a67f99419f7ef283c3424964a23d2fb8, lock
// input 0b4015bd69ef...#0). Hardcoded (not read from the fixture) so this
// pin catches drift even if the fixture is ever regenerated incorrectly;
// the second half of the test cross-checks fixture/pin agreement.
test('deriveProposalTokenName golden pin matches deployed bytecode (audit #10)', () => {
const LOCK_TX_HASH = '0b4015bd69ef9e93a7eafdc778433ea1d1acb4f8ab7b2efaa52990b65415e919';
const LOCK_OUTPUT_INDEX = 0;
const EXPECTED_TOKEN_NAME = '70726f705f1a38676b49274bb694b9677f8b17af728bb4fca74bfd3572049bfe';
assert.equal(deriveProposalTokenName(LOCK_TX_HASH, LOCK_OUTPUT_INDEX), EXPECTED_TOKEN_NAME);
// fixture/pin agreement: the checked-in capture must still say the same thing.
assert.equal(fixture.goldenTokenPin.lockTxHash, LOCK_TX_HASH);
assert.equal(fixture.goldenTokenPin.lockOutputIndex, LOCK_OUTPUT_INDEX);
assert.equal(fixture.goldenTokenPin.expectedTokenName, EXPECTED_TOKEN_NAME);
});
test('parseProposalDatum parses the captured REAL chain datum into a populated object', () => {
const parsed = parseProposalDatum(fixture.sampleProposal.datumCbor);
assert.ok(parsed, 'fixture sample proposal datum failed to parse');
assert.ok(
['Open', 'Amended', 'Adopted', 'Rejected', 'Expired', 'Withdrawn'].includes(parsed!.state),
`unexpected state: ${parsed!.state}`,
);
assert.ok(
['ParameterChange', 'TreasurySpend', 'ProtocolUpgrade', 'GameActivation', 'GeneralSuggestion'].includes(parsed!.proposalType),
`unexpected proposalType: ${parsed!.proposalType}`,
);
assert.equal(typeof parsed!.proposerDid, 'string');
assert.ok(parsed!.proposerDid.length > 0);
assert.ok(parsed!.storageUri.startsWith('ipfs://') || parsed!.storageUri.length > 0);
assert.ok(parsed!.stakeAmount > 0);
});
test('parseProposalDatum returns null on garbage, never throws', () => {
assert.equal(parseProposalDatum('deadbeef'), null);
});
test('MIN_CRITIQUE_STAKE_APEX and MIN_ENDORSE_STAKE_APEX match the wire minimums', () => {
assert.equal(MIN_CRITIQUE_STAKE_APEX, 10);
assert.equal(MIN_ENDORSE_STAKE_APEX, 5);
});
});
describe('buildCritique (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the critique address, datum round-trips', async () => {
const r = await buildCritique(lucid, {
agentDid: AGENT_DID,
proposalTxHash: PROPOSAL_TX,
proposalOutputIndex: 0,
critiqueType: 'Supportive',
stakeApex: 12,
critiqueHash: 'cd'.repeat(32),
storageUri: 'ipfs://test-critique-doc',
});
assert.equal(r.op, 'critique');
assert.equal(r.stakeLovelace, '12000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH));
assert.equal(r.proposalRef, `${PROPOSAL_TX}#0`);
assert.equal(r.ipfsCid, undefined, 'no document was given - no upload should have happened');
const tx = decodeTx(r.txCbor);
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'critique build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const critiqueOut = findOutputTo(tx, r.scriptAddress);
assert.ok(critiqueOut, 'no output to the critique address');
assert.equal(critiqueOut!.amount().coin(), 14_000_000n, 'output must be stake (12 AP3X) + 2 AP3X');
assert.ok(critiqueOut!.datum() && critiqueOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = critiqueOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseCritiqueDatum(datumCbor);
assert.ok(parsed, 'built critique datum failed to parse back');
assert.equal(parsed!.criticDid, AGENT_DID);
assert.equal(parsed!.critiqueType, 'Supportive');
assert.equal(parsed!.critiqueHash, 'cd'.repeat(32));
assert.equal(parsed!.storageUri, 'ipfs://test-critique-doc');
assert.equal(parsed!.stakeAmount, 12_000_000);
assert.equal(parsed!.incorporated, false);
assert.equal(parsed!.proposalRef.fields[0], PROPOSAL_TX);
assert.equal(Number(parsed!.proposalRef.fields[1]), 0);
});
test('Amendment critiqueType embeds the critique hash in the type datum too', async () => {
const hash = 'ef'.repeat(32);
const r = await buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 2,
critiqueType: 'Amendment', stakeApex: 10, critiqueHash: hash, storageUri: 'ipfs://amend',
});
const tx = decodeTx(r.txCbor);
const critiqueOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = critiqueOut.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseCritiqueDatum(datumCbor)!;
assert.equal(parsed.critiqueType, 'Amendment');
// the parser only surfaces critique_type's constructor index (matches the
// custodial parser exactly) - decode the raw datum directly to confirm
// the hash is actually embedded in field 5's payload, not just labeled.
const raw = Data.from(datumCbor) as Constr<Data>;
const critiqueTypeField = raw.fields[5] as Constr<Data>;
assert.equal(critiqueTypeField.index, 2);
assert.equal(critiqueTypeField.fields[0], hash);
});
test('attempts a Filebase upload when critiqueDocument is given (fails clean - not configured in tests)', async () => {
// No FILEBASE_* env vars are set in this test environment, so a genuine
// upload attempt must surface the "not configured" error - proving the
// document path is actually wired up without needing to mock the AWS SDK.
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 12, critiqueDocument: '{"finding":"test"}',
}),
/Filebase not configured/,
);
});
test('stake minimum is validated BEFORE any Filebase upload attempt', async () => {
// If validation order were wrong, this would fail with the Filebase
// error instead (critiqueDocument is provided, stake is not).
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 1, critiqueDocument: '{"finding":"test"}',
}),
/at least 10/i,
);
});
test('rejects a stake below the minimum', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 9,
critiqueHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/at least 10/i,
);
});
test('rejects when neither critiqueDocument nor critiqueHash/storageUri is provided', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
}),
/critiqueHash|critiqueDocument/,
);
});
test('rejects a malformed critiqueHash (not 64 hex chars)', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
critiqueHash: 'nothex', storageUri: 'ipfs://x',
}),
/64 hex/,
);
});
});
describe('buildEndorse (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the endorsement address, datum round-trips', async () => {
const r = await buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 1, stakeApex: 7,
});
assert.equal(r.op, 'endorse');
assert.equal(r.stakeLovelace, '7000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH));
assert.equal(r.proposalRef, `${PROPOSAL_TX}#1`);
const tx = decodeTx(r.txCbor);
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'endorse build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const endorseOut = findOutputTo(tx, r.scriptAddress);
assert.ok(endorseOut, 'no output to the endorsement address');
assert.equal(endorseOut!.amount().coin(), 9_000_000n, 'output must be stake (7 AP3X) + 2 AP3X');
assert.ok(endorseOut!.datum() && endorseOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = endorseOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseEndorsementDatum(datumCbor);
assert.ok(parsed, 'built endorsement datum failed to parse back');
assert.equal(parsed!.endorserDid, AGENT_DID);
assert.equal(parsed!.stakeAmount, 7_000_000);
assert.equal(parsed!.proposalRef.fields[0], PROPOSAL_TX);
assert.equal(Number(parsed!.proposalRef.fields[1]), 1);
});
test('rejects a stake below the minimum', async () => {
await assert.rejects(
buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0, stakeApex: 4,
}),
/at least 5/i,
);
});
});