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>
This commit is contained in:
FilipBl4gojevic
2026-07-31 18:09:40 +02:00
parent 71b3cc599c
commit 08d6954049
2 changed files with 76 additions and 0 deletions

View File

@@ -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)',
);
});
});

View File

@@ -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, {