Files
apex-mcp-server/packages/builder/test/gov-build.test.ts
FilipBl4gojevic 08d6954049 test: pin exact lovelace at every money call site, not just in the helper
apexToLovelace's own unit tests prove the helper is exact, but nothing
proved any of its 5 call sites actually invoke it - reverting
buildSendApex's call site to Math.floor(amountApex * 1_000_000) left the
full build suite at 26/26 green, because every existing amountApex/
apexAmount/stakeApex fixture is a whole integer.

Adds one call-site pin each for buildSendApex, buildSendTokens' apexAmount
path (both build.test.ts), and buildEndorse (gov-build.test.ts, the
simpler of the two stake fixtures), each using a fractional amount
confirmed via node -e to actually lose a lovelace under the old
Math.floor path, decoding the built tx and asserting the recipient/script
output's coin is the exact expected lovelace. buildSendTokens needed its
own defect value (1.005 AP3X undershoots this output's min-UTxO once it
carries a native asset, an unrelated ledger rule) - reused buildEndorse's
verified 8.03 AP3X instead. buildCritique and buildProposalLock's call
sites are structurally identical to buildEndorse's and already covered by
apexToLovelace's own exhaustive unit tests; one representative call-site
pin plus the two build.ts sites is the load-bearing set, not all five.

Proved each pin load-bearing by reverting its call site to Math.floor one
at a time: each mutation failed exactly the matching test (naming the lost
lovelace: 1004999 vs 1005000, 8029999 vs 8030000, 8029999 vs 8030000) and
no other test, then was reverted byte-identical (confirmed via git diff).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:09:40 +02:00

981 lines
47 KiB
TypeScript

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, toText, getAddressDetails } from '@lucid-evolution/lucid';
import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS } from './fixtures/fixture-provider.ts';
import { lucidForAddress } from '../src/vector/build.ts';
import { getRegistryAddress, buildAgentDatum, MIN_AP3X_DEPOSIT } from '../src/vector/registry-build.ts';
import {
GOV_PROPOSAL_SPEND_HASH, GOV_CRITIQUE_SPEND_HASH, GOV_ENDORSEMENT_SPEND_HASH,
GOV_PROPOSAL_MINT_HASH, AGENT_REGISTRY_POLICY,
MIN_CRITIQUE_STAKE_APEX, MIN_ENDORSE_STAKE_APEX, MIN_PROPOSAL_STAKE_APEX,
scriptHashToAddress, deriveProposalTokenName, deriveActivityTokenName,
parseProposalDatum, parseCritiqueDatum, parseEndorsementDatum,
buildCritique, buildEndorse, buildProposalLock, buildProposalSpend,
} 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);
const OWN_VKEY_HASH = getAddressDetails(OWN_ADDRESS).paymentCredential!.hash!;
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;
}
// Shared by the mint-content and continuing-output-asset checks below (same
// helper as registry-build.test.ts - Mint's as_positive_multiasset() and
// Value's multi_asset() both return the same MultiAsset type).
function findAssetQuantity(ma: any, policyIdHex: string, assetNameHex: string): bigint | undefined {
const policies = ma.keys();
for (let i = 0; i < policies.len(); i++) {
const policy = policies.get(i);
if (policy.to_hex() !== policyIdHex) continue;
const assets = ma.get_assets(policy);
if (!assets) continue;
const names = assets.keys();
for (let n = 0; n < names.len(); n++) {
const name = names.get(n);
if (name.to_hex() === assetNameHex) return assets.get(name);
}
}
return undefined;
}
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, MIN_ENDORSE_STAKE_APEX, and MIN_PROPOSAL_STAKE_APEX match the wire minimums', () => {
assert.equal(MIN_CRITIQUE_STAKE_APEX, 10);
assert.equal(MIN_ENDORSE_STAKE_APEX, 5);
assert.equal(MIN_PROPOSAL_STAKE_APEX, 25);
});
});
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('succeeds at exactly the minimum stake (10 AP3X) - the boundary is inclusive', async () => {
const r = await buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
critiqueHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
assert.equal(r.stakeLovelace, '10000000');
const tx = decodeTx(r.txCbor);
const critiqueOut = findOutputTo(tx, r.scriptAddress);
assert.ok(critiqueOut, 'no output to the critique address');
assert.equal(critiqueOut!.amount().coin(), 12_000_000n, 'exactly-minimum stake (10 AP3X) must still build: 10 + 2 AP3X');
});
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('succeeds at exactly the minimum stake (5 AP3X) - the boundary is inclusive', async () => {
const r = await buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0, stakeApex: 5,
});
assert.equal(r.stakeLovelace, '5000000');
const tx = decodeTx(r.txCbor);
const endorseOut = findOutputTo(tx, r.scriptAddress);
assert.ok(endorseOut, 'no output to the endorsement address');
assert.equal(endorseOut!.amount().coin(), 7_000_000n, 'exactly-minimum stake (5 AP3X) must still build: 5 + 2 AP3X');
});
test('pins exact lovelace at the call site for a fractional stake float math gets wrong', async () => {
// 10.005 and 5.005 (the "obvious" values at each family's minimum) both
// happen to be bit-exact under Math.floor(x * 1e6) on this Node/V8 build
// (verified: node -e "console.log(Math.floor(10.005*1e6), Math.floor(5.005*1e6))"
// -> 10005000 5005000, both correct - neither reproduces the defect).
// Scanned upward from the 5 AP3X endorse minimum
// for a real loser instead: 8.03 * 1_000_000 === 8029999.999999999 in
// IEEE-754 (node -e "console.log(Math.floor(8.03*1e6))" -> 8029999, one
// lovelace short of the correct 8030000). This pins buildEndorse's OWN
// call site - apexToLovelace itself is already proven exact in
// packages/shared/test/tx.test.ts; this test instead catches a future
// revert of THIS call site back to Math.floor(stakeApex * 1_000_000).
const r = await buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 3, stakeApex: 8.03,
});
assert.equal(r.stakeLovelace, '8030000');
const tx = decodeTx(r.txCbor);
const endorseOut = findOutputTo(tx, r.scriptAddress);
assert.ok(endorseOut, 'no output to the endorsement address');
assert.equal(
endorseOut!.amount().coin(), 10_030_000n,
'lost a lovelace - buildEndorse must call apexToLovelace, not Math.floor(stakeApex * 1_000_000): stake (8.03 AP3X = 8,030,000 lovelace) + 2 AP3X rider (2,000,000) = 10,030,000',
);
});
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,
);
});
});
describe('buildProposalLock (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the proposal address, datum round-trips (GeneralSuggestion)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID,
proposalType: 'GeneralSuggestion',
stakeApex: 25,
proposalHash: '22'.repeat(32),
storageUri: 'ipfs://test-proposal-doc',
});
assert.equal(r.stakeLovelace, '25000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH));
assert.equal(r.proposalHash, '22'.repeat(32));
assert.equal(r.storageUri, 'ipfs://test-proposal-doc');
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, 'lock build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const lockOut = findOutputTo(tx, r.scriptAddress);
assert.ok(lockOut, 'no output to the proposal address');
assert.equal(lockOut!.amount().coin(), 27_000_000n, 'output must be stake (25 AP3X) + 2 AP3X');
assert.ok(lockOut!.datum() && lockOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = lockOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseProposalDatum(datumCbor);
assert.ok(parsed, 'built proposal datum failed to parse back');
assert.equal(parsed!.proposerDid, AGENT_DID);
assert.equal(parsed!.proposalType, 'GeneralSuggestion');
assert.equal(parsed!.stakeAmount, 25_000_000);
assert.equal(parsed!.storageUri, 'ipfs://test-proposal-doc');
assert.equal(parsed!.proposalHash, '22'.repeat(32));
assert.equal(parsed!.state, 'Open');
assert.equal(parsed!.priority, 'Standard');
assert.equal(parsed!.amendmentCount, 0);
assert.equal(parsed!.reviewWindow, 604_800_000);
});
test('succeeds at exactly the minimum stake (25 AP3X) - the boundary is inclusive', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
assert.equal(r.stakeLovelace, '25000000');
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress);
assert.equal(lockOut!.amount().coin(), 27_000_000n, 'exactly-minimum stake (25 AP3X) must still build: 25 + 2 AP3X');
});
test('rejects a stake below the minimum (24 AP3X)', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 24,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/at least 25/i,
);
});
test('ParameterChange without typeParams rejects', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ParameterChange', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/ParameterChange requires/,
);
});
test('ParameterChange with typeParams embeds paramName/currentValue/proposedValue in the type datum', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ParameterChange', stakeApex: 25,
typeParams: { paramName: 'MIN_PROPOSAL_STAKE', currentValue: 25, proposedValue: 30 },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 0);
assert.equal(toText(typeField.fields[0] as string), 'MIN_PROPOSAL_STAKE');
assert.equal(Number(typeField.fields[1]), 25);
assert.equal(Number(typeField.fields[2]), 30);
});
test('TreasurySpend without typeParams rejects', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'TreasurySpend', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/TreasurySpend requires/,
);
});
// The three Constr indices below are the on-chain contract's own layout
// (0=ParameterChange, 1=TreasurySpend, 2=ProtocolUpgrade, 3=GameActivation,
// 4=GeneralSuggestion - see the custodial switch this was transcribed
// from) - an index swap between any two of these builds a datum the
// deployed validator would parse as the WRONG proposal type. Each test
// decodes the raw type-datum Constr directly (not through the parser,
// which only round-trips the type NAME back from the index) to pin the
// literal on-wire index.
test('TreasurySpend with typeParams builds with type datum index 1 (amount + recipientDescription)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'TreasurySpend', stakeApex: 25,
typeParams: { amount: 500, recipientDescription: 'research grant pool' },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 1, "TreasurySpend must encode as Constr index 1 - the on-chain contract's layout, not renumbered");
assert.equal(Number(typeField.fields[0]), 500);
assert.equal(toText(typeField.fields[1] as string), 'research grant pool');
});
test('ProtocolUpgrade builds with type datum index 2 (upgradeHash embedded)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ProtocolUpgrade', stakeApex: 25,
typeParams: { upgradeHash: 'cc'.repeat(32) },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 2, "ProtocolUpgrade must encode as Constr index 2 - the on-chain contract's layout, not renumbered");
assert.equal(typeField.fields[0], 'cc'.repeat(32));
});
test('GameActivation builds with type datum index 3 (gameId embedded)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GameActivation', stakeApex: 25,
typeParams: { gameId: 6 },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 3, "GameActivation must encode as Constr index 3 - the on-chain contract's layout, not renumbered");
assert.equal(Number(typeField.fields[0]), 6);
});
test('manual proposalHash + storageUri path works without a document', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'bb'.repeat(32), storageUri: 'ipfs://manual',
});
assert.equal(r.proposalHash, 'bb'.repeat(32));
assert.equal(r.storageUri, 'ipfs://manual');
});
test('rejects a malformed proposalHash (not 64 hex chars)', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'nothex', storageUri: 'ipfs://x',
}),
/64 hex/,
);
});
test('rejects when neither proposalDocument nor proposalHash/storageUri is provided', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
}),
/proposalHash|proposalDocument/,
);
});
test('stake minimum is validated BEFORE any Filebase upload attempt', async () => {
// If validation order were wrong, this would fail with the Filebase
// error instead (proposalDocument is provided, stake is not).
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 1,
proposalDocument: '{"proposal":"test"}',
}),
/at least 25/i,
);
});
test('attempts a Filebase upload when proposalDocument 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(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalDocument: '{"proposal":"test"}',
}),
/Filebase not configured/,
);
});
});
describe('buildProposalSpend (offline, FixtureProvider)', () => {
const LOCK_TX_HASH = 'aa'.repeat(32);
const PRESERVE_LOCK_TX_HASH = 'bb'.repeat(32);
const NFT_TX_HASH = 'cc'.repeat(32);
const NFT_UNIT = AGENT_REGISTRY_POLICY + AGENT_DID;
let proposalSpendAddress: string;
let lockDatumCbor: string;
let lockedUtxoFixture: UTxO;
let preserveLockedUtxoFixture: UTxO;
let spendLucid: LucidEvolution;
let spendProvider: ReturnType<typeof chainClockProvider>;
function nftFixtureUtxo(): UTxO {
return {
txHash: NFT_TX_HASH, outputIndex: 0, address: getRegistryAddress(),
assets: { lovelace: MIN_AP3X_DEPOSIT, [NFT_UNIT]: 1n },
datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', ''),
};
}
function refScriptFixtureUtxos(): UTxO[] {
return fixture.refScripts.map((s) => ({
txHash: s.txHash, outputIndex: s.index, address: proposalSpendAddress,
assets: { lovelace: 2_000_000n },
scriptRef: { type: 'PlutusV3', script: s.scriptCborHex },
}));
}
function infraFixtureUtxos(): UTxO[] {
return fixture.infra.map((i) => ({
txHash: i.txHash, outputIndex: i.index, address: i.address,
assets: { lovelace: BigInt(i.valueLovelace) },
datum: i.datumCbor,
}));
}
// Assembles the offline chain state buildProposalSpend needs, with the
// ability to omit one category per negative test (missing-NFT,
// missing-ref-scripts).
function baseSpendUtxos(opts?: { skipNft?: boolean; skipRefScripts?: boolean }): UTxO[] {
return [
...FIXTURE_UTXOS, lockedUtxoFixture, preserveLockedUtxoFixture,
...(opts?.skipNft ? [] : [nftFixtureUtxo()]),
...(opts?.skipRefScripts ? [] : refScriptFixtureUtxos()),
...infraFixtureUtxos(),
];
}
// buildProposalSpend's PRODUCTION path (opts omitted - same as Task 5's
// callers) completes with `localUPLCEval: false`, which asks the PROVIDER
// to evaluate the transaction's script redeemers (see gov-build.ts's
// module doc). FixtureProvider deliberately has no evaluateTx (offline
// only - see fixture-provider.ts), and the REAL deployed validators
// legitimately reject this test's synthetic chain state - captured
// honestly, verbatim, in the dedicated native-eval-attempt describe block
// below, not swept under the rug here.
//
// This wrapper stubs evaluateTx with generous-but-harmless execution units
// (well inside the fixture protocol params' per-tx maximum) purely so
// Lucid can finish ASSEMBLING a well-formed, decodable transaction for the
// structural assertions in this describe block. It does NOT assert or
// imply the deployed validator would accept the result - that question is
// answered separately, honestly, by the native-eval-attempt test (which
// never calls evaluateTx at all, so this stub is simply unused there).
//
// Two things this stub deliberately does NOT cover, each carried by a
// different test instead: (1) it fabricates canned ex-units, not real
// ones, so the structural tests' fees are fictitious - nothing here or
// elsewhere asserts on `fee`/`feeAda`; (2) it never inspects or
// constrains REDEEMER CONTENT (only the units Lucid attaches to whichever
// redeemers already exist in the draft tx) - the structural test's
// literal 'd87980' redeemer-data pin, the native-eval-attempt test, and
// the (manual, gated) E2E integration test are what actually exercise
// redeemer correctness; a corrupted redeemer would sail through this stub
// unnoticed.
function stubEvalProvider(utxos: UTxO[]) {
const provider = chainClockProvider(utxos);
return Object.assign(provider, {
evaluateTx: async (txCborHex: string) => {
const draft = CML.Transaction.from_cbor_hex(txCborHex);
const legacy = draft.witness_set().redeemers()?.as_arr_legacy_redeemer();
const tagMap: Record<number, 'spend' | 'mint' | 'publish' | 'withdraw' | 'vote' | 'propose'> = {
0: 'spend', 1: 'mint', 2: 'publish', 3: 'withdraw', 4: 'vote', 5: 'propose',
};
const out: { ex_units: { mem: number; steps: number }; redeemer_index: number; redeemer_tag: typeof tagMap[number] }[] = [];
if (legacy) {
for (let i = 0; i < legacy.len(); i++) {
const r = legacy.get(i);
out.push({
ex_units: { mem: 2_000_000, steps: 500_000_000 },
redeemer_index: Number(r.index()),
redeemer_tag: tagMap[r.tag()] ?? 'spend',
});
}
}
return out;
},
});
}
before(async () => {
proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
// Build a lock offline, then decode ITS OWN output to extract the real
// inline datum bytes - buildProposalSpend must reuse these verbatim, not
// reconstruct them, so the fixture has to carry genuine built-datum CBOR.
const lockResult = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: '33'.repeat(32), storageUri: 'ipfs://spend-fixture-doc',
});
const lockTx = decodeTx(lockResult.txCbor);
const lockOut = findOutputTo(lockTx, proposalSpendAddress)!;
lockDatumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
lockedUtxoFixture = {
txHash: LOCK_TX_HASH, outputIndex: 0, address: proposalSpendAddress,
assets: { lovelace: 27_000_000n }, datum: lockDatumCbor,
};
// A second locked UTxO with an off-constant lovelace amount, used ONLY by
// the value-preservation test below: if buildProposalSpend recomputed
// stake+2 AP3X instead of carrying the locked input's own coin through,
// 27_000_001n (not 27_000_000n) could never appear in the continuing
// output.
preserveLockedUtxoFixture = {
txHash: PRESERVE_LOCK_TX_HASH, outputIndex: 0, address: proposalSpendAddress,
assets: { lovelace: 27_000_001n }, datum: lockDatumCbor,
};
spendProvider = stubEvalProvider(baseSpendUtxos());
spendLucid = await lucidForAddress(spendProvider, OWN_ADDRESS);
});
test('spends the locked UTxO, mints prop_+pact_ tokens, preserves the locked datum byte-identically', async () => {
// No opts: this is the PRODUCTION path (localUPLCEval: false internally)
// - see stubEvalProvider's comment above for why that still completes
// offline. Structural assertions only; the honesty-gate test below is
// what actually asks the real validators anything.
const r = await buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
});
const expectedPropName = deriveProposalTokenName(LOCK_TX_HASH, 0);
const expectedActName = deriveActivityTokenName(AGENT_DID);
assert.equal(r.proposalTokenName, expectedPropName);
assert.equal(r.activityTokenName, expectedActName);
// THE PIN (Task 3 review mandate): the activity token name must come
// from deriveActivityTokenName's 'pact_' prefix - a 'pact_' -> 'act_'
// mutation in the implementation must fail this assertion by name.
assert.equal(
r.activityTokenName.slice(0, 10), Buffer.from('pact_', 'utf-8').toString('hex'),
"activityTokenName must be derived via deriveActivityTokenName's 'pact_' prefix",
);
assert.equal(r.scriptAddress, proposalSpendAddress);
const tx = decodeTx(r.txCbor);
// unsigned
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'spend build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
// the locked UTxO is actually spent
const ins = tx.body().inputs();
let sawLockedInput = false;
for (let i = 0; i < ins.len(); i++) {
if (ins.get(i).transaction_id().to_hex() === LOCK_TX_HASH) sawLockedInput = true;
}
assert.ok(sawLockedInput, 'the locked UTxO must be a spent input');
// mints exactly prop:1 + act:1, with the exact derived unit names
const mint = tx.body().mint()!;
assert.ok(mint, 'no mint field');
const ma = mint.as_positive_multiasset();
assert.equal(findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedPropName), 1n, 'must mint exactly 1 prop_ token of the derived name');
assert.equal(findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedActName), 1n, 'must mint exactly 1 pact_ token of the derived name');
// Redeemer content, not just mint/output shape - stubEvalProvider only
// fabricates execution UNITS, it never inspects or constrains redeemer
// DATA, so nothing above this point would catch a corrupted or
// mismatched redeemer. Both the Spend and Mint redeemers must be
// SubmitRedeemer = Data.to(new Constr(0, [])), which CBOR-encodes to the
// fixed 3-byte 'd87980' (tag 121 = alternative 0, empty field array) -
// pinned as a literal, not by re-deriving from Constr, so a redeemer
// corruption can't hide behind a mutated encoder agreeing with itself.
const legacyRedeemers = tx.witness_set().redeemers()?.as_arr_legacy_redeemer();
assert.ok(legacyRedeemers, 'no redeemers in the witness set');
assert.equal(legacyRedeemers!.len(), 2, 'must carry exactly 2 redeemers (Spend + Mint)');
for (let i = 0; i < legacyRedeemers!.len(); i++) {
const redeemer = legacyRedeemers!.get(i);
assert.equal(
redeemer.data().to_cbor_hex(), 'd87980',
`redeemer ${i} (tag ${redeemer.tag()}) must be the empty SubmitRedeemer Constr(0, [])`,
);
}
// exactly two continuing outputs at the proposal address
const outs = tx.body().outputs();
const atSpendAddr: any[] = [];
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() === proposalSpendAddress) atSpendAddr.push(o);
}
assert.equal(atSpendAddr.length, 2, 'must produce exactly 2 continuing outputs at the proposal address');
// Positional pin, matching the mainnet-proven custodial output order
// exactly (the proposal-continuing pay.ToAddressWithData call comes
// before the activity one in both the custodial module and this build):
// output 0 = the proposal-datum continuing output (prop token), output
// 1 = the activity output (act token). Verified empirically against a
// real decoded build before pinning this, not assumed - output 2 (not
// asserted here) is Lucid's own wallet-change output from fee coin
// selection, which is incidental to this build's own logic.
const output0Assets = outs.get(0).amount().multi_asset();
const output1Assets = outs.get(1).amount().multi_asset();
assert.equal(
output0Assets && findAssetQuantity(output0Assets, GOV_PROPOSAL_MINT_HASH, expectedPropName), 1n,
'output 0 must be the proposal-continuing output (carries the prop_ token)',
);
assert.equal(
output1Assets && findAssetQuantity(output1Assets, GOV_PROPOSAL_MINT_HASH, expectedActName), 1n,
'output 1 must be the activity output (carries the pact_ token)',
);
const propOut = atSpendAddr.find((o) => findAssetQuantity(o.amount().multi_asset()!, GOV_PROPOSAL_MINT_HASH, expectedPropName) === 1n);
assert.ok(propOut, 'no continuing output carries the prop_ token');
assert.equal(
propOut.datum()!.as_datum()!.to_cbor_hex(), lockDatumCbor,
'the continuing proposal datum must be byte-identical to the locked input datum',
);
assert.equal(propOut.amount().coin(), 27_000_000n, 'the continuing proposal output must preserve the locked UTxO coin');
const actOut = atSpendAddr.find((o) => findAssetQuantity(o.amount().multi_asset()!, GOV_PROPOSAL_MINT_HASH, expectedActName) === 1n);
assert.ok(actOut, 'no continuing output carries the pact_ token');
assert.equal(actOut.amount().coin(), 2_000_000n);
const activityRaw = Data.from(actOut.datum()!.as_datum()!.to_cbor_hex()) as Constr<Data>;
assert.equal(activityRaw.fields[0], AGENT_DID, 'activity datum agent_did must match');
assert.equal(Number(activityRaw.fields[2]), 1, 'activity datum active_proposal_count must be 1 (first proposal)');
assert.equal(Number(activityRaw.fields[3]), r.validToMs - 360_000, 'activity datum last_proposal_slot must equal validFromMs');
// validity window: exactly 360 slots (360_000ms at the test config's 1000ms/slot)
const start = tx.body().validity_interval_start();
const ttl = tx.body().ttl();
assert.ok(start !== undefined && ttl !== undefined, 'validity interval must be set');
assert.equal(Number(ttl! - start!), 360, 'validity window must be exactly 360 slots (360_000 ms)');
assert.equal(r.validToMs, ZERO_TIME_MS + TIP_SLOT * 1000 + 360_000, 'validToMs must be validFromMs + 360_000');
// required signer = the derived wallet address's payment hash
const signers = tx.body().required_signers();
assert.ok(signers && signers.len() >= 1, 'no required_signers');
let foundSigner = false;
for (let i = 0; i < signers!.len(); i++) {
if (signers!.get(i).to_hex() === OWN_VKEY_HASH) foundSigner = true;
}
assert.ok(foundSigner, 'wallet vkey hash missing from required_signers');
// reference inputs: 3 infra + 2 ref-script + 1 NFT = 6
const refIns = tx.body().reference_inputs();
assert.ok(refIns, 'no reference_inputs on the spend tx');
assert.equal(refIns!.len(), 6, 'must read exactly 3 infra + 2 ref-script + 1 NFT UTxOs');
});
test('value-preservation: continuing proposal output carries the LOCKED UTxO coin, not a recomputed constant', async () => {
const r = await buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: PRESERVE_LOCK_TX_HASH, lockOutputIndex: 0,
});
const expectedPropName = deriveProposalTokenName(PRESERVE_LOCK_TX_HASH, 0);
const tx = decodeTx(r.txCbor);
const outs = tx.body().outputs();
let propOut: any = null;
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() !== proposalSpendAddress) continue;
const ma = o.amount().multi_asset();
if (ma && findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedPropName) === 1n) propOut = o;
}
assert.ok(propOut, 'no continuing output carries the prop_ token');
assert.equal(
propOut.amount().coin(), 27_000_001n,
'must equal the locked input coin exactly, not the recomputed stake+2 constant (27_000_000n)',
);
});
test('absent locked UTxO (random hash) - await-first error', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: 'ff'.repeat(32), lockOutputIndex: 0,
}),
/vector_await_transaction/,
);
});
test('locked UTxO exists but is not at the proposal script address - fail-fast error', async () => {
// A UTxO that resolves fine by outref but sits at the WALLET's own
// address, not the proposal script address - simulates a caller passing
// an unrelated txHash. Must be rejected before any datum parsing is
// attempted (the datum content here is irrelevant to this check).
const WRONG_ADDR_TX_HASH = 'dd'.repeat(32);
const wrongAddressUtxo: UTxO = {
txHash: WRONG_ADDR_TX_HASH, outputIndex: 0, address: OWN_ADDRESS,
assets: { lovelace: 27_000_000n }, datum: lockDatumCbor,
};
const providerWrongAddr = chainClockProvider([...baseSpendUtxos(), wrongAddressUtxo]);
const lucidWrongAddr = await lucidForAddress(providerWrongAddr, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidWrongAddr, providerWrongAddr, {
agentDid: AGENT_DID, lockTxHash: WRONG_ADDR_TX_HASH, lockOutputIndex: 0,
}),
/not at the proposal script address/,
);
});
test('DID mismatch between the locked datum and the caller agentDid - clear error', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: 'ee'.repeat(32), lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/DID mismatch/,
);
});
test('missing agent registry NFT - re-register error', async () => {
const providerNoNft = chainClockProvider(baseSpendUtxos({ skipNft: true }));
const lucidNoNft = await lucidForAddress(providerNoNft, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidNoNft, providerNoNft, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/re-register/,
);
});
test('missing reference scripts (omitted from fixtures) - the verbatim redeploy-hint error', async () => {
const providerNoRefs = chainClockProvider(baseSpendUtxos({ skipRefScripts: true }));
const lucidNoRefs = await lucidForAddress(providerNoRefs, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidNoRefs, providerNoRefs, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/redeploy_ref_scripts\.py/,
);
});
describe('native-eval attempt (honesty gate)', () => {
// Spec PR 8 Task 4's eval strategy (plan's Global Constraints section,
// "Eval strategy"): attempt NATIVE UPLC evaluation of the REAL deployed
// proposal spend+mint validators (captured bytecode,
// gov-state.fixture.json) against this test file's synthetic chain
// state, calling the SAME buildProposalSpend build used throughout this
// describe block - just with the opts override flipped to
// localUPLCEval: true instead of the structural tests' stubbed-provider
// production path.
//
// OUTCOME (captured 2026-07-30, verbatim - see task-4-report.md): FAILS.
// The validator trace shows it entered proposal_spend and successfully
// read PARAMS, then crashed - a genuine Plutus evaluation rejection, not
// a Lucid-level "couldn't build this" error:
//
// TxBuilderError: { Complete: "failed script execution Spend[0] the
// validator crashed / exited prematurely Trace ENTER:proposal_spend
// Trace OK:read_params" }
//
// What this trace DOES establish: the crash is a genuine chain-logic
// rejection inside the deployed validator, not a Lucid-side "couldn't
// even assemble this transaction" failure - the crash happens in a
// LATER check the plan explicitly named as chain-state-dependent
// (oracle freshness, or a DID/NFT cross-check needing exact real values
// this fixture's synthetic ORACLE/NFT data cannot satisfy offline).
//
// What it does NOT establish: that everything up to and including
// read_params is correct. "OK:read_params" is ONE-DIRECTIONAL evidence
// - an EARLIER failure (crashing before ENTER:proposal_spend, or before
// OK:read_params) WOULD imply a structural break, but reaching this
// point proves nothing about checks that come AFTER it. Confirmed
// directly, not assumed: deliberately dropping the NFT reference input
// from buildProposalSpend's readFrom calls (a real structural bug)
// reproduces this EXACT SAME trace, since the validator doesn't touch
// the NFT until a check that comes later. That specific defect class is
// instead caught by the structural test's reference_inputs-length pin
// (== 6) above, not by this trace - which is why that pin, the
// redeemer-content pin, and the output-order pin all live in the
// structural describe block rather than being inferred from this one
// eval attempt.
//
// This is the expected fork, not a structural defect: production keeps
// `localUPLCEval: false` and lets the PROVIDER evaluate (tier-1/E2E
// integration tests exercise that against the live network); this
// describe block's sibling tests above cover the offline-testable half
// (the transaction's own shape) with a stubbed evaluateTx that makes no
// claim about validator acceptance. NEVER weakened to assert.doesNotReject
// or deleted - the failure is pinned so a change in this behavior (pass
// OR a different failure) shows up here, not silently.
test('ATTEMPT: native UPLC eval of the deployed proposal spend+mint validators against fixture chain state (EXPECTED TO FAIL - chain-state-dependent, not a structural defect)', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}, { localUPLCEval: true }),
(err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
// Must be a genuine Plutus validator rejection reached AFTER
// read_params, not a Lucid-side build failure and not a crash
// earlier than this. This is one-directional: it rules OUT an
// earlier-than-expected structural break, it does not rule IN
// that every later check is correct (see the describe-level
// comment above for why, and what does cover that).
assert.match(message, /proposal_spend/, 'expected a proposal_spend validator trace, not a build-level error');
assert.match(message, /OK:read_params/, 'expected the validator to have gotten PAST reading params before crashing - anything earlier would suggest a structural wiring defect, not a chain-state one');
return true;
},
);
});
});
});