From 08d69540490012ee956b9f397034412badd6756c Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:09:40 +0200 Subject: [PATCH] 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 --- packages/builder/test/build.test.ts | 51 +++++++++++++++++++++++++ packages/builder/test/gov-build.test.ts | 25 ++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/builder/test/build.test.ts b/packages/builder/test/build.test.ts index 82be9d3..63d2dab 100644 --- a/packages/builder/test/build.test.ts +++ b/packages/builder/test/build.test.ts @@ -133,6 +133,24 @@ describe('buildSendApex', () => { await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 0 }), /positive/); await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: -5 }), /positive/); }); + + test('pins exact lovelace at the call site for a fractional amount float math gets wrong', async () => { + // 1.005 * 1_000_000 === 1004999.9999999999 in IEEE-754 (node -e + // "console.log(Math.floor(1.005*1e6))" -> 1004999, one lovelace short of + // the correct 1005000; see packages/shared/test/tx.test.ts for the full + // empirical proof). apexToLovelace itself is already proven exact there - + // this test instead pins that buildSendApex's call site actually invokes + // it, so a future edit reverting to Math.floor(amountApex * 1_000_000) + // fails HERE, not just in an untouched helper's own unit tests. + const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 1.005 }); + const outs = decodeOutputs(r.txCbor); + const toRecipient = outs.filter((o) => o.address === FOREIGN_ADDRESS); + assert.equal(toRecipient.length, 1); + assert.equal( + toRecipient[0].lovelace, 1_005_000n, + 'lost a lovelace - buildSendApex must call apexToLovelace, not Math.floor(amountApex * 1_000_000)', + ); + }); }); describe('buildSendTokens', () => { @@ -181,4 +199,37 @@ describe('buildSendTokens', () => { /positive/, ); }); + + test('pins exact lovelace at the call site on the apexAmount path for a fractional amount float math gets wrong', async () => { + // Same defect class as buildSendApex's pin above, but 1.005 AP3X + // (1,005,000 lovelace) turned out to be BELOW this output's actual + // min-UTxO once it carries a native asset - Lucid silently bumped it to + // 1,043,020 during .complete(), which would have made this test measure + // ledger dust-floor behavior instead of the float-conversion bug it + // exists to pin (confirmed empirically: that bump reproduces regardless + // of amountApex's exactness, so it is not this defect). Reused the + // endorse test's already-verified defect value instead - comfortably + // above min-UTxO here too, since the existing 2,000,000n default (see + // 'moves the token...' above) is already sufficient for this same + // single-token output shape: + // 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 is the apexAmount branch's OWN call site + // (build.ts's buildSendTokens, not buildSendApex), so it needs its own + // pin; a shared helper test cannot prove either caller wires it up. + const r = await buildSendTokens(lucid, { + recipientAddress: FOREIGN_ADDRESS, + policyId: FIXTURE_TOKEN_UNIT.slice(0, 56), + assetName: FIXTURE_TOKEN_UNIT.slice(56), + amount: '50', + apexAmount: 8.03, + }); + const outs = decodeOutputs(r.txCbor); + const toRecipient = outs.find((o) => o.address === FOREIGN_ADDRESS); + assert.ok(toRecipient, 'no output to recipient'); + assert.equal( + toRecipient.lovelace, 8_030_000n, + 'lost a lovelace - buildSendTokens apexAmount path must call apexToLovelace, not Math.floor(apexAmount * 1_000_000)', + ); + }); }); diff --git a/packages/builder/test/gov-build.test.ts b/packages/builder/test/gov-build.test.ts index a2e1194..be5e3eb 100644 --- a/packages/builder/test/gov-build.test.ts +++ b/packages/builder/test/gov-build.test.ts @@ -326,6 +326,31 @@ describe('buildEndorse (offline, FixtureProvider)', () => { 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, {