feat(builder): keyless self-improvement core A - critique + endorse builds, deployed-state fixtures, audit-#10 golden pin
This commit is contained in:
224
packages/builder/scripts/capture-gov-state.mjs
Normal file
224
packages/builder/scripts/capture-gov-state.mjs
Normal 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}`);
|
||||
496
packages/builder/src/vector/gov-build.ts
Normal file
496
packages/builder/src/vector/gov-build.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
packages/builder/test/fixtures/gov-state.fixture.json
vendored
Normal file
56
packages/builder/test/fixtures/gov-state.fixture.json
vendored
Normal file
File diff suppressed because one or more lines are too long
290
packages/builder/test/gov-build.test.ts
Normal file
290
packages/builder/test/gov-build.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user