From 93a17fdf82b04fc29b3eeae75500f758a2ede91a Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:16:48 +0200 Subject: [PATCH] test(builder): pin exact stake minimums; harden capture-script write ordering --- .../builder/scripts/capture-gov-state.mjs | 23 ++++++++++++++---- packages/builder/test/gov-build.test.ts | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/builder/scripts/capture-gov-state.mjs b/packages/builder/scripts/capture-gov-state.mjs index 95809e3..737a538 100644 --- a/packages/builder/scripts/capture-gov-state.mjs +++ b/packages/builder/scripts/capture-gov-state.mjs @@ -206,6 +206,24 @@ if (failures.length > 0) { process.exit(1); } +// Deliberately dereference sampleProposal/goldenTokenPin's fields here, +// BEFORE writeFileSync, rather than only embedding them in the fixture +// object below: an object literal doesn't dereference its values, so if a +// future success path ever left either variable null without also pushing a +// fail() (the failures.length check above would then wrongly let a null +// slip through), `const fixture = {...}` and JSON.stringify would happily +// write "sampleProposal": null / "goldenTokenPin": null to disk with no +// error at all. Real property access (.txHash, .lockTxHash, ...) throws a +// TypeError on null, so doing it here makes the STOP-before-write guarantee +// a structural crash-before-write instead of resting solely on every +// success path remembering to keep the failures array in sync. +console.log('\nCapture validated, about to write:'); +console.log(` refScripts: ${refScripts.length}/2, infra: ${infra.length}/3`); +console.log(` sampleProposal: ${sampleProposal.txHash}#${sampleProposal.index}`); +console.log(` goldenTokenPin.lockTxHash: ${goldenTokenPin.lockTxHash}`); +console.log(` goldenTokenPin.lockOutputIndex: ${goldenTokenPin.lockOutputIndex}`); +console.log(` goldenTokenPin.expectedTokenName: ${goldenTokenPin.expectedTokenName}`); + const fixture = { asOf: new Date().toISOString(), refScripts, @@ -217,8 +235,3 @@ const fixture = { 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/test/gov-build.test.ts b/packages/builder/test/gov-build.test.ts index 3930e5f..7e9a139 100644 --- a/packages/builder/test/gov-build.test.ts +++ b/packages/builder/test/gov-build.test.ts @@ -216,6 +216,19 @@ describe('buildCritique (offline, FixtureProvider)', () => { ); }); + 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, { @@ -279,6 +292,17 @@ describe('buildEndorse (offline, FixtureProvider)', () => { 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('rejects a stake below the minimum', async () => { await assert.rejects( buildEndorse(lucid, {