From e345e2e652eaeea8b903ec7beac97266a08b14a0 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:53:24 +0200 Subject: [PATCH] feat(builder): keyless self-improvement core A - critique + endorse builds, deployed-state fixtures, audit-#10 golden pin --- .../builder/scripts/capture-gov-state.mjs | 224 ++++++++ packages/builder/src/vector/gov-build.ts | 496 ++++++++++++++++++ .../test/fixtures/gov-state.fixture.json | 56 ++ packages/builder/test/gov-build.test.ts | 290 ++++++++++ packages/shared/src/types.ts | 26 + 5 files changed, 1092 insertions(+) create mode 100644 packages/builder/scripts/capture-gov-state.mjs create mode 100644 packages/builder/src/vector/gov-build.ts create mode 100644 packages/builder/test/fixtures/gov-state.fixture.json create mode 100644 packages/builder/test/gov-build.test.ts diff --git a/packages/builder/scripts/capture-gov-state.mjs b/packages/builder/scripts/capture-gov-state.mjs new file mode 100644 index 0000000..95809e3 --- /dev/null +++ b/packages/builder/scripts/capture-gov-state.mjs @@ -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}`); diff --git a/packages/builder/src/vector/gov-build.ts b/packages/builder/src/vector/gov-build.ts new file mode 100644 index 0000000..85065f3 --- /dev/null +++ b/packages/builder/src/vector/gov-build.ts @@ -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 = { + 0: 'Open', + 1: 'Amended', + 2: 'Adopted', + 3: 'Rejected', + 4: 'Expired', + 5: 'Withdrawn', +}; + +export const TYPE_NAMES: Record = { + 0: 'ParameterChange', + 1: 'TreasurySpend', + 2: 'ProtocolUpgrade', + 3: 'GameActivation', + 4: 'GeneralSuggestion', +}; + +export const PRIORITY_NAMES: Record = { + 0: 'Standard', + 1: 'Emergency', +}; + +export const CRITIQUE_TYPE_NAMES: Record = { + 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; + 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): Promise { + 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; + critiqueHash: string; + storageUri: string; + critiqueType: string; + stakeAmount: number; + submittedAt: number; + incorporated: boolean; +} + +export interface GovEndorsement { + endorserDid: string; + proposalRef: Constr; + 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 { + 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; + 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 { + 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, + }; +} diff --git a/packages/builder/test/fixtures/gov-state.fixture.json b/packages/builder/test/fixtures/gov-state.fixture.json new file mode 100644 index 0000000..a0cb69b --- /dev/null +++ b/packages/builder/test/fixtures/gov-state.fixture.json @@ -0,0 +1,56 @@ +{ + "asOf": "2026-07-30T12:39:45.835Z", + "refScripts": [ + { + "name": "spend", + "txHash": "c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996", + "index": 0, + "scriptCborHex": "5927c90101003229800aba2aba1aba0aab9faab9eaab9dab9cab9a4888888896600264653001300900198049805000cdc3a400530090024888966002600460126ea800e33001300a3754007223233001001003223300300130020029b87480026e1d20069b87480226e1d200a9b874801260126ea8011222222229800980a8044c054c05802244b300130083013375400513232332259800980e001c0162c80c8dd6980c8009bae3019002301900130143754005164044911192cc004c0280162b3001301637540210018b202e8acc004c0380162b3001301637540210018b202e8acc004c018016264653001375c6036003301c0019bae301b002488966002603e0051330103758603c00444b300100280444cc024c0800084c004c08400901e45901c0c06c004c058dd50084566002601200b13232332259800980f001c0162c80d8dd6980d8009bae301b002301b001301637540211598009804002c4c8c96600260380050038b2032375c6034002602c6ea80422b300130070058acc004c058dd500840062c80ba2b30013370e9006002c566002602c6ea804200316405d15980099b874803801626464b3001301c002801c590191bad301a0013016375402115980099b87480400162b3001301637540210018b202e8b2026404c80990132026404c80990132026153301349114454e5445523a70726f706f73616c5f7370656e640019800911919800800801912cc0040062980103d87a80008992cc004c010006266e9520003301c0014bd7044cc00c00cc078009017180e000a0349180c980d180d000c8c064c068c068c068c068c068c068c06800646032603400291111929980c2490e4f4b3a726561645f706172616d730019800911919800800801912cc00400629422b300130033758604200314a31330020023022001406c80fa4646600200200444b30010018a5eb82264664466446600400400244b30010018801c4c8cc094dd3998129ba90053302530220013302530230014bd701980180198138011812800a046375660400066eb8c074004cc00c00cc088008c08000501e4dc4240009111929980e249114f4b3a6765745f63726f73735f7265667300132598009809980f1baa00189929980f24810b4f4b3a6765745f736c6f740019800911919800800801912cc00400629422b3001300330270018a518998010011814000a0424095301f3754035223259800980b800c4c8c96600260520050048b204c375c604e00260466ea800e2b3001301b001899192cc004c0a400a0091640986eb8c09c004c08cdd5001c590202040302137540052302430253025302530253025302530253025302530253025001918121812981298129812981298129812981298129812800c8c090c094c094c094c094c094c094c094c094c09400644646600200200644b30010018a508acc004c00cdd71813800c528c4cc008008c0a0005021204a91812181298129812981298129812800cdc62400123024302530253025302530253025302530250019181218129812981298129812800c8c090c094c094c09400646048604a604a604a604a003223232330010010042259800800c00e2646644b30013372200e00515980099b8f0070028800c01902544cc014014c0b00110251bae30250013756604c0026050002813052f5bded8c1370e90204dc02400491111111111111114c0048966002604c60626ea800a2646464646464646464646465300130410019bae304100c9bae304100a9820804cdd718208044dd69820803cdd698208034dd69820802cc1040126eb4c10400e6eb0c1040092222222222259800982680644cc09cc1300584cc8966002607e003132323298009bad30510019bae30510039bad305100248896600260aa0090088b20a41828800982800098259baa00a8acc004c10c006264646644b300130530038034590501bae3050001375a60a000460a000260966ea802a2b3001303b001899192cc004c14400a0091641386eb8c13c004c12cdd50054566002607c0031323259800982880140122c8270dd6982780098259baa00a8acc004c0f40062b3001304b37540150028b20988b20904120824104820901332259800981f800c56600260966ea80160051641311598009821800c56600260966ea801600516413116412082404cc0f80048966002005132598009820000c56600260986ea803a0371641351598009822000c4c8c96600260a400501d8b209e375c60a000260986ea803a2b3001303c001899192cc004c14800a03b16413c6eb8c140004c130dd50074566002607e0031323259800982900140762c8278dd7182800098261baa00e8acc004c0f80062b3001304c375401d01b8b209a8acc004c0f40062b3001304c375401d01b8b209a8b2092412482490492092412460946ea803626606e609c00426002609e0048260c120dd500198241baa0088b209418208009820000981f800981f000981e800981e000981d800981d000981c800981c000981b80098191baa0028b205e912cc004c098c0c4dd500144c8cc0340088cdc78008011bae30353032375400514a0817a4466e2922105706163745f003300a480d8dca000c8966002604c60626ea800a2646464653001375a6072003375c6072009375a60720049112cc004c0f401226602e607800c0111640e8303900130380013037001303237540051640bd222323322330020020012259800800c00e2646644b30013372200e00515980099b8f0070028800c01903644cc014014c0f40110361bae3036001375a606e002607200281b8cc01c01000c52000488888c966002605402b15330344901114f4b3a72656465656d65725f6d61746368001598009815181a9baa02389929981aa4810d4f4b3a646174756d5f736f6d6500133007001153303549010e4f4b3a646174756d5f706172736500198009181d981e181e181e181e181e181e181e181e181e181e181e181e181e181e181e181e000cdd6180f981b9baa02e9bac303a3037375405d37586042606e6ea80b9222259800cc00660026eb8c0f8c0ecdd5002cdd71808181d9baa0419bae3025303b37540829111980f00312cc004c9660026070607e6ea8006266e3cdd7182198201baa0010048a5040f46084607e6ea8c108c0fcdd51813981f9baa0018981bcc004dd59813981f9baa3027303f375400300280220168a5040f0a50a5140e1153303949010e4641494c3a6469645f76616c696400168acc0066002b3001302f303a3754602460766ea8016266e24dd6981f181d9baa022375a602260766ea80162b30019800992cc004c0c000629462b3001302c0018a518a5040e481c8c0e8dd51808181d9baa005a50a5140e11533039491134641494c3a656d657267656e63795f7479706500168acc004cdc41bad3011303b375400a66e0ccdc11bad303e303b37540446eb4c0f8c0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0fcc0ecdd5011241a01f15330394901144641494c3a656d657267656e63795f7374616b6500168acc006600266e1cdd69812181d9baa005375a600860766ea808a942945038454cc0e52401154641494c3a656d657267656e63795f77696e646f7700168acc006600264b30013371e6eb8c040c0f0dd50212451c00000000000000000000000000000000000000000000000000000000008a5189980e00212cc004c966002606c607a6ea8006266e3cdd71820981f1baa001375c6024607c6ea8112294103b1820181e9baa3040303d3754604a607a6ea80062b30013302330223756604a607a6ea8c094c0f4dd500092cc004cdc79bae3041001375c6024607c6ea8112266e3ccc059200a375c60826084002911057273746b5f008a5040ed13371290404257af98041bab3025303d3754604a607a6ea8006294103a4528207440e46eb8c0f8c0ecdd5002d28528a0708a9981ca49194641494c3a656d657267656e63795f72657075746174696f6e00168a5140e081c1038207040e14a14a281c22a660729201104641494c3a7374616b655f76616c696400168acc0066002601a6e34dd71812981d9baa005a50a5140e1153303949010f4641494c3a686173685f76616c696400168acc00660026464b300130310018992cc004cdc7800a450f4d494e5f434c41494d5f5354414b45008a518acc004cdc7800a441144d494e5f4348414c4c454e47455f57494e444f57008a518acc004cdc7800a441094a5552595f53495a45008a518acc004cdc7800a4410d4a5552595f4645455f52415445008a518acc004cdc7800a4410e4d494e5f53454c465f5354414b45008a518acc004cdc7800a4410f4d494e5f454e444f5253454d454e54008a518acc004cdc7800a4410a44454341595f52415445008a518acc004cdc7800a4411a4d41585f454e444f5253454d454e545f4d554c5449504c494552008a518acc004cdc7800a441124d494e5f50524f504f53414c5f5354414b45008a518acc004cdc7800a441124d494e5f43524954495155455f5354414b45008a518acc004cdc7800a4411a4d494e5f474f5645524e414e43455f454e444f5253454d454e54008a518acc004cdc7800a441114d494e5f5245564945575f57494e444f57008a518acc004cdc7800a441114d41585f5245564945575f57494e444f57008a518acc004cdc7800a4410e4d41585f414d454e444d454e5453008a518acc004cdc7800a441144d41585f4143544956455f50524f504f53414c53008a518acc004cdc7800a4411a434f4f4c444f574e5f4245545745454e5f50524f504f53414c53008a518acc004cdc7800a4411550524f504f5345525f5245574152445f5348415245008a518acc004cdc7800a441134352495449435f5245574152445f5348415245008a518acc004cdc7800a4411150524f544f434f4c5f4645455f52415445008a518acc004cdc7800a441134d494e5f41444f5054494f4e5f524557415244008a518acc004cdc7800a441134d41585f41444f5054494f4e5f524557415244008a518acc004cdc7800a4411a4d41585f494e434f52504f52415445445f435249544951554553008a518acc004cdc7800a441144d41585f54524541535552595f52455155455354008a518acc004cdc7800a4411a454d455247454e43595f5354414b455f4d554c5449504c494552008a518acc004cdc7800a44117454d455247454e43595f5245564945575f57494e444f57008a518acc004cdc7800a4411354524541535552595f42415443485f53495a45008a518acc004cdc7800a441144d494e5f54524541535552595f42415443484553008a518acc004cdc7800a4411441444f5054494f4e5f5245574152445f42415345008a51899b8f0014890f47414d455f41435449564154494f4e0040ec81d903b207640ec81d903b207640ec81d903b207640ec81d903b207640ec81d903b207640ec81d903b207640ec81d903b2076375c6080607a6ea800a2b300130350018992cc004c088006266e24004dd6982098211821182118211821182118211821182118211821182118211821181f1baa0258a5040ec6eb4c100c0f4dd50014566002605a0031300f371a6eb8c100c0f4dd50014566002606000313021375a6080607a6ea800a294503a207440e881d0c0ecdd50009808181d9baa005a50a5140e1153303949010f4641494c3a747970655f76616c696400168acc0066002603e6e34dd71807981d9baa005a50a5140e1153303949010e4641494c3a7572695f76616c696400168acc0066002b3001302f303a3754602460766ea80162b3001337126eb4c040c0ecdd50111bad3024303b375400b1337126eb4c090c0ecdd50029bad300f303b375404514a081c2266e1cdd69812181d9baa005375a600860766ea8089038528528a0708a9981ca49114641494c3a77696e646f775f76616c696400168acc006600266ebcc060c0ecdd5002a60103d8798000a50a5140e11533039491104641494c3a73746174655f76616c696400168acc0066002b3001302f375a602c60766ea8016266ebcc05cc0ecdd5002a610180008a5040e14a14a281c22a660729210f4641494c3a696e69745f76616c696400168acc006600266014604660766ea8014dd61809181d9baa032a50a5140e115330394901114641494c3a7369676e65725f76616c696400168acc0066002646464b30013032303d3754003132598009817981f1baa001899198068008acc004cdc499b80375a602a60806ea8004dd6981498201baa0270218acc004cdc41bad302a304037540026eb4c064c100dd5013c4cc080018966002607330013756605260826ea80066eb8c0acc104dd5012401500d44c966002606460826ea8006264660200022b30013371e6eb8c118c10cdd5000804456600266e1cdd6981698219baa0013014375a605a60866ea8012266e1cdd6980c18219baa0010248a50410114a08200c114c108dd5000c528207e302b3041375400314a081f2294103d4528207a3042303f37540031640f06050607c6ea8c098c0f8dd51820981f1baa0018acc004c0c8ca6002003005a400080088896600200510018cc00400e608a00559800981ccc004dd5981498209baa30440029bae302b304137540490054035130120018800a07c400c82122a6607892125524154453a61637469766974795f746f6b656e5f6e6f745f696e5f616e795f6f757470757400168acc004cc078010966002606f30013756604e607e6ea80066eb8c0a4c0fcdd5011400d00b44c9660026060607e6ea80062646601c0022b3001980099b8f375c608860826ea800401a94294503e454cc0fd240111524154453a6469645f6d69736d6174636800168acc006600260726eb4c0acc104dd5000d28528a07c8a9981fa48110524154453a636f756e745f6e6f745f3100168acc006600266e1cdd6980b18209baa001022a50a5140f9153303f490112524154453a736c6f745f6d69736d6174636800168a5140f881f103e182198201baa0018a9981f24914524154453a6e6f5f696e6c696e655f646174756d001640f46052607e6ea8006294103c4528c54cc0f1240127524154453a646174756d5f636865636b5f6661696c65645f6275745f746f6b656e5f666f756e64001640ec81d903b1981400212cc004c966002606e607c6ea8006266e3cdd71821181f9baa001375c6084607e6ea808a294103c1820981f1baa3041303e3754604c607c6ea80062606d30013756604c607c6ea8c098c0f8dd5000cdd71814181f1baa02180120148a5040ec660140840026eb8c0f8c0ecdd5002d28528a0708a9981ca4810f4641494c3a726174655f76616c696400168acc0066002606730013756601e60766ea80ca6eb8c094c0ecdd500f4cdc52450570726f705f0033013480d8dca1bb3029401d4a14a281c22a66072921114641494c3a746f6b656e5f6d696e74656400168acc006600266036002464b30013035303c375400315980099b8f375c6080607a6ea8004dd71820181e9baa020899b89375a6026607a6ea801cc020dd59812981e9baa0028a5040e914a081d0c0fcc0f0dd5181f981e1baa001a50a5140e115330394901184641494c3a6f75747075745f61745f76616c696461746f7200168a5140e081c1038207040e081c1038207040e081c10382070181c981b1baa0238b20668cc0048c966002605800314a31598009818000c528c528206a40d4606c6ea8006444660320044b300132598009819981d1baa001899b8f375c607c60766ea8004dd7181f181d9baa0058a5040e0607a60746ea8c0f4c0e8dd51811181d1baa0018994c0040066601c6eacc08cc0ecdd51811981d9baa002375c604a60766ea80169410011112cc00400a200313298008024c10800e6644b30010048a518acc004cdc79980c2401400491010570726f705f00899b8798008044dd7181518201baa00a801201848006294103d207a375c607a0026eb4c0f80050041820001207c8a5040dd222223232598009818981e1baa0018992cc004c0b8c0f4dd5000c4c8cc0300044cc07c014966002607130013756605060806ea80066eb8c0a8c100dd5004c01500c44c966002606260806ea80062646601e0022b30013371e6eb8c114c108dd5000805456600266e1cdd6981618211baa001337006eb4c0b0c108dd50022400313371290001bad302c3042375400314a081fa294103f182218209baa0018a5040f8605460806ea8006294103d1820981f1baa0018b20763027303d3754604a607a6ea8c100c0f4dd5000c5903a1981380192cc004c966002606c607a6ea8006266e3cdd71820981f1baa001375c6082607c6ea801e294103b1820181e9baa3040303d3754604a607a6ea80062606b30013756604a607a6ea8c094c0f4dd5000cdd71813981e9baa00680120128a5040e86601200a0072223301900325980099baf303d303a3754607a60746ea800400e266e24008c014dd59811181d1baa0018a5040dc91112cc004c0c80662b3001302e3039375404f1323300b0011332259800cc004c01cc068c0f4dd5001d28528a0748a9981da49134641494c3a77697468647261775f737461746500168acc006600266018604a607a6ea800cdd6180a181e9baa034a50a5140e9153303b4901144641494c3a77697468647261775f7369676e657200168acc006600330010208014dd59808981e9baa03440194a14a281d22a660769211a4641494c3a77697468647261775f746f6b656e5f6275726e656400168acc006600330010438104dd71820181e9baa0038014005005528528a0748a9981da49164641494c3a77697468647261775f616374697669747900168acc006600330010019812981e9baa0039bad3013303d3754006802294294503a454cc0ed2411c4641494c3a77697468647261775f7374616b655f72657475726e656400168a5140e881d103a207440e86eb0c0f8c0ecdd50191bac3025303b3754064607a60746ea809e2c81ba26464b3001302c01b899194c004dd71820800cdd618209821000cdd718208012444b300130353040375405d1323301200113322598009807181098221baa0038acc00566002602c6e34012330013371e0086eb8c0b8c110dd5001d28528a0828a50410515980098141b8d0068acc004cdc499b8000130093758604060886ea800cdd69823982418241824182418241824182418241824182418241824182418221baa02b8acc004cdc4980a9bad301f304437540066eb4c068c110dd5015c56600266026605860886ea800cdd6180d98221baa03b8acc004c8cc004004018896600200314a315980099198138029192cc004c104c120dd5000c56600266e3cdd7182618249baa001375c606260926ea80b2264b3001303a304937540031323259800982018259baa00289919191919191919194c004c96600260ac00315980099b8948010c1540062d1304e3055001414d16415c6ea8c1600066eb8c16002660b000f375c60b000d375c60b000b30580049bad30580039bad30580024888888896600260c201313303b3060010133049006132598009829000c56600260bc6ea801202716417d159800982b000c56600260bc6ea801202716417d1598009827000c4c8c96600260c80050158b20c2375c60c400260bc6ea80122c82d905b20b6305c375400716417830580013057001305600130550013054001305300130520013051001304c37540051641242608660946ea8c088c12cdd5000982698251baa0018a50411c606660926ea800a29410464528208c304b30483754609660906ea8004c124006266004004609400314a0821904744cc0900088c966002606c608a6ea80062646602e0022b30013375e6048608e6ea8004cdd2a4004660926062608e6ea80192f5c115980099b8f375c6062608e6ea800401e2b30013371e6eb8c06cc11cdd5000804c56600266e1cdd6981118239baa0013018375a6044608e6ea801a2b30015980099b8f375c6094608e6ea8004dd7182518239baa0068acc004cdd7981798239baa001302f3047375400d13370e6eb4c074c11cdd50009bad301d3047375400d14a08222294104444cdc398061bac30233047375400266e00c030dd6181198239baa0060048a50411114a082222941044452820888a5041106092608c6ea80062941043181798229baa0018a50410514a0820a2941041452820828a50410514a0820a29410411bac302c30423754072600e006608860826ea80ba2c81f0608200260786ea80da264b3001303001c89919912cc004c0d0c0fcdd5016c4c8cc044004660026eb0c0a4c104dd501c4dd6182218209baa0389bac302b304137540713758603060826ea80e26eacc054c104dd501c244444b30019800826401600480522b3001301030233046375400d159800acc004cdc49bad30233046375405a00f13371200e6eb4c124c128c128c128c128c128c128c128c128c128c128c128c128c118dd5016c52820868acc004c060dc680445660033001029802400500f4566003300104c814cdd7182498231baa006802400d00e456600266e3cdd7180d18231baa04c48811c00000000000000000000000000000000000000000000000000000000008a518acc004cc09800c8c9660026080608e6ea8006266e3cdd7182598241baa001375c603860906ea813a2941045182518239baa304a3047375400313302c302b0012325980099b8f375c60960046eb8c070c120dd5027456600266e3ccc081200e375c609600291010768626f6e75735f00898161bad304b304c0018a50411514a08228c12c006294104320868a50410d14a0821a2941043452820868a50410d14a08218608660806ea80b62c81e8dd718208009bad30413042001303d375406f159800981780e44c9660026064607a6ea80ae2646601e002330013758604e607e6ea80da6eb0c108c0fcdd501b4dd61814981f9baa0364889660033001048801cdd6180c98211baa03940191598009806180f98211baa0048acc004c050dc6802c56600330010258014dd5980b18211baa039402d159800cc00412204b375c608a60846ea8012005001402919800800cc0a8c108dd50024dd6980c18211baa004402514a081fa294103f4528207e8a5040fd14a081f86082607c6ea80ae2c81d8dd71820181e9baa0378acc004c0b80722b30013031303c37540551323300e00113322598009805180e98201baa0038acc004cdc419b80375a603260806ea800cdd6981498201baa0030218acc00660020470029bab30143040375406e804a2b30019800823408e6eb8c10cc100dd5001c00a002804233001001981418201baa0039bad301630403754006803a294103d4528207a8a5040f514a081e8dd61820981f1baa03537586050607c6ea80d4c100c0f4dd501545903a456600266e1d200c01c8acc004c0c4c0f0dd501544c8cc038004660026eb0c098c0f8dd501acdd61820981f1baa0359bac3028303e375406a9112cc004c02cc078c104dd50024566002b3001303530403754602c60826ea80122946294103e456600266042006464b3001303b3042375400313371e6eb8c118c10cdd50009bae301f3043375409314a08200c114c108dd5182298211baa302a30423754003159800cc0040920053756602a60826ea80e100a456600330010478124dd7182218209baa0048014005009466002003302930413754009375a602e60826ea80110084528207c8a5040f914a081f2294103e4528207c1820181e9baa02a8b20748acc004cdc3a401c039132598009819181e9baa02b899198078008992cc006600208d3758605060806ea80de6eb0c05cc100dd501ba0088acc004c028c074c100dd50014566002604800715980099b89001375a602860806ea809e2660406eb0c0a8c100dd501b9192cc004c0c8c104dd5000c4c8cc04c00456600266e1cdd6981618219baa0010048acc004cdc79bae3046304337540026eb8c118c10cdd5002c56600266e3cdd7181698219baa001375c605a60866ea8016266ebcc080c10cdd5000981018219baa0058a50410114a082022941040182298211baa0018a5040fc605660826ea8006294103d4528207a8a5040f514a081e8cdc01bad3028303f37540020046082607c6ea80ae2c81d8dd69820181e9baa03789980e9bac3040303d37540684b3001980099baf3041303e37540020594a14a281da264b30013037303e375400313371e6eb8c108c0fcdd50009bae3042303f375404514a081e0c104c0f8dd51820981f1baa3026303e375400314a081d903a207440e881d103a111191981000192cc004c966002607460826ea8006266e3cdd7182298211baa0010038a5040fc608860826ea8c110c104dd5181498209baa0018992cc004c0c8c104dd5000c4c966002606c60106eb0c10cc8cdd81ba83043001374e60880026ea80062b300130373042375400313232323322598009826001c4cc098c12c0145660026080608e6ea8c0c8c120dd500344c966002607a60906ea800626466048018466e3c004008dd7182618249baa0018a514118609660906ea801a2941045459049192cc004c11c0062b30013371290021823000c5a2607e608c00282222c8240dd518248009bae3049002304900130480013043375400316410114a08200c114c108dd5000c528207e302b30413754605260826ea8006294103e1bae3018303f375400681c8c00400489660020031480022601a66004004608000281e9037103314c00400691100a4410040081bad3022301f375400316407064b30013017301e375400313374a9000198109811180f9baa0014bd7045300103d87a800040706042603c6ea8c084c078dd51803980f1baa01532598009809180e9baa0018992cc004c03cc078dd5000c4c9660026028603e6ea80062646464653001375c604e003375c604e009375c604e007375c604e00491112cc004c0b001620131640a4302700130260013025001302037540031640746044603e6ea80062c80e0c020c078dd51803180f1baa3021301e375400316406c660106eb0c014c074dd500a11980218019bab3006301e3754600c603c6ea800496600266e3cdd718110009bae3022301f375404b13003375a60446046604600314a080e064b3001300e30193754003132598009805980d1baa0018992cc004c040c06cdd5000c4c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8ca60026eb4c0d00066eb4c0d00566eb4c0d00526eb4c0d004e6eb4c0d004a6eb4c0d00466eb4c0d00426eb4c0d003e6eb4c0d003a6eb4c0d00366eb4c0d00326eb4c0d002e6eb4c0d002a6eb4c0d00266eb4c0d00226eb4c0d001e6eb4c0d001a6eb4c0d00166eb4c0d00126eb4c0d000e6eb4c0d000922222222222222222222259800982500b440ae2c823860680026066002606400260620026060002605e002605c002605a00260580026056002605400260520026050002604e002604c002604a002604800260460026044002604200260386ea80062c80c8c078c06cdd5000c590181802180d1baa3002301a3754603a60346ea80062c80b8cc010dd61800980c9baa010232598009809980d1baa001899b8f375c603c60366ea8004dd71802180d9baa0218a504060603a60346ea8c074c068dd51801180d1baa001116401c300900130043754013149a26cac80089801ffd8798b581c96ab6199348249e31bbf6335d50f494eaabe9f142043a8bdcb3c9dba581c00000000000000000000000000000000000000000000000000000000581cbe1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01581cbe1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01581c00000000000000000000000000000000000000000000000000000000581c00000000000000000000000000000000000000000000000000000000581c0611cf74214ef8fdd88a9e521cd9dae2a5269b4d1f0beb8d0133763e581cf98f1dace1ac805615ccc0357b4ecb363a43b947fc99f1a661850867581cab1aad52c4774e5da9f24ec0fa1a4d07220a0bdd57ee3dce9be860dac6581c00000000000000000000000000000000000000000000000000000000581c000000000000000000000000000000000000000000000000000000000001", + "hash": "f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d" + }, + { + "name": "mint", + "txHash": "c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a", + "index": 0, + "scriptCborHex": "59051a0101003229800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400130080024888966002600460106ea800e33001375c601860126ea800e2646464b30013005300b375400f10018800a01432330010013758601e60186ea8010896600200314a1159800992cc004cdc3a4004601a6ea8006266e3cdd7180898071baa001375c6022601c6ea8012294100c180818069baa3010300d37546008601a6ea8c04000629462660040046022002805900e192cc004c010c028dd5000c4c96600266e1d2004300b375400313259800980318061baa0018991919194c004dd7180a000cdd7180a0024dd7180a001cdd7180a00124444b300130190058804c590160c050004c04c004c048004c034dd5000c5900b180798061baa0018b2014300e300f300f300b3754600460166ea8c038c02cdd5000c5900919198008009bac3002300b375400644b30010018a6103d87a80008992cc004c8cc004004c8cc004004dd5980318079baa3006300f375400644b30010018a5eb82264664466446600400400244b30010018801c4c8cc060dd39980c1ba90053301830150013301830160014bd7019801801980d001180c000a02c375660260066eb8c040004cc00c00cc054008c04c005011112cc00400629422b3001325980099b8f375c60260026eb8c04cc040dd500b44cdc4240006eb4c04cc050c050006294100e1bac30120018a518998010011809800a01a404113374a900019807800a5eb8226600600660220048058c03c00500d118069807000cc020dd50022444b300130050018acc004c030dd5003c00a2c806a2b30013370e9001000c56600260186ea801e00516403515980099b8748010006264653001375c602200330120019bae3011002488966002602a005132330010013758602a00644b3001001804c4c966002601a60266ea8006264646644b3001301c003899803803980e0034590191bad3019001375c6032004603200260286ea80062c8090c0580050144590120c044004c030dd5003c56600266e1d20060018991919912cc004c05000e00d1640446eb4c044004dd71808801180880098061baa0078acc004cdc3a40100031323259800980900140122c8078dd7180800098061baa0078acc004cdc3a401400315980098061baa00780145900d456600266e1d200c0018acc004c030dd5003c00a2c806a2b30013370e9007000c4c8c96600260240050048b201e375a602000260186ea801e2b30013370e9008000c56600260186ea801e005164035164028805100a20144028805100a201440288b200e180400098019baa0088a4d1365640044c01ffd8798b581c96ab6199348249e31bbf6335d50f494eaabe9f142043a8bdcb3c9dba581c00000000000000000000000000000000000000000000000000000000581cbe1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01581cbe1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01581c00000000000000000000000000000000000000000000000000000000581c00000000000000000000000000000000000000000000000000000000581c0611cf74214ef8fdd88a9e521cd9dae2a5269b4d1f0beb8d0133763e581cf98f1dace1ac805615ccc0357b4ecb363a43b947fc99f1a661850867581cab1aad52c4774e5da9f24ec0fa1a4d07220a0bdd57ee3dce9be860dac6581c00000000000000000000000000000000000000000000000000000000581c000000000000000000000000000000000000000000000000000000000001", + "hash": "e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565" + } + ], + "infra": [ + { + "name": "PARAMS", + "txHash": "2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796", + "index": 0, + "address": "addr1w8uc78dvuxkgq4s4enqr276wevmr5saegl7fnudxvxzssecjx7n6t", + "valueLovelace": "3000000", + "datumCbor": "d8799f1a017d78401a004c4b401a009896801a0f7314001a9032100005031a05265c00191b581907d01903e81a02faf0801a1dcd65000a1b00000002540be4001913881a02932e001a240c84001a05f5e1001a1dcd65001a05f5e100ff" + }, + { + "name": "ORACLE", + "txHash": "7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7", + "index": 0, + "address": "addr1wyrprnm5y9803lwc3209y8xemt322f5mf50sh6udqyehv0s9kk34m", + "valueLovelace": "3000000", + "datumCbor": "d8799fd8799f581c965b37a41714fe1252e57787e4836e8ed9f1aec928e9f78040dc566cff581cab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac6d87a80ff" + }, + { + "name": "CROSSREFS", + "txHash": "96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2", + "index": 0, + "address": "addr1wyrprnm5y9803lwc3209y8xemt322f5mf50sh6udqyehv0s9kk34m", + "valueLovelace": "3000000", + "datumCbor": "d8799f581cf815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d581cced52074861af95e2082004d6061b0fc4bb30fded61f9605bfc20e55581ce8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565581c2e252a89894d379ce5c0023a57de4627056e4a96da72bd8fedba04bdff" + } + ], + "sampleProposal": { + "txHash": "01ac86ca226e1824762dec15a405bed9a67f99419f7ef283c3424964a23d2fb8", + "index": 0, + "datumCbor": "d8799f5820beff8204648e253cf84e71c9db1b04e717acac3e76630ad10e7215fb7362ee42d8799f581ce386a052aa32db44cf3224283503c617baff37304da0de7fdbf998d5ff5820c1c8d626ec01ed2ca8c469ef4c293d557546454cdab77f2b1786eb4eea12c292d87d805835697066733a2f2f516d5865726965697535765733554c62673272395241465254364a694b3545636f3532416a646734436a55655a7a1a017d78401b0000019dc115bea81a240c8400d879800080d87980ff", + "tokenUnit": "e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d56570726f705f1a38676b49274bb694b9677f8b17af728bb4fca74bfd3572049bfe" + }, + "goldenTokenPin": { + "lockTxHash": "0b4015bd69ef9e93a7eafdc778433ea1d1acb4f8ab7b2efaa52990b65415e919", + "lockOutputIndex": 0, + "expectedTokenName": "70726f705f1a38676b49274bb694b9677f8b17af728bb4fca74bfd3572049bfe" + } +} diff --git a/packages/builder/test/gov-build.test.ts b/packages/builder/test/gov-build.test.ts new file mode 100644 index 0000000..3930e5f --- /dev/null +++ b/packages/builder/test/gov-build.test.ts @@ -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, 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; + const critiqueTypeField = raw.fields[5] as Constr; + 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, + ); + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 037f60f..fd05553 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -154,3 +154,29 @@ export interface UnsignedTxResult { fee: string; feeAda: string; } + +// --- Keyless self-improvement builds (spec PR 8) --- + +export interface VectorBuildStakeResult extends VectorUnsignedBuildResult { + op: 'critique' | 'endorse'; + proposalRef: string; // "txHash#index" + stakeLovelace: string; + scriptAddress: string; + ipfsCid?: string; + documentHash?: string; +} + +export interface VectorBuildProposalLockResult extends VectorUnsignedBuildResult { + scriptAddress: string; + stakeLovelace: string; + storageUri: string; + proposalHash: string; + ipfsCid?: string; +} + +export interface VectorBuildProposalSpendResult extends VectorUnsignedBuildResult { + proposalTokenName: string; + activityTokenName: string; + scriptAddress: string; + validToMs: number; // the signing deadline the agent must beat +}