From 71b3cc599cb08873838e6000c02be59dc301cfef Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:52:30 +0200 Subject: [PATCH] fix: convert AP3X amounts to lovelace exactly, never through float multiplication Math.floor(amountApex * 1_000_000) silently drops a lovelace whenever the IEEE-754 product lands just under the intended integer (empirically confirmed on this Node build: 1.005, 2.05, and 8.11 AP3X all lose exactly 1 lovelace this way). Adds apexToLovelace(amountApex): bigint in packages/shared/src/tx.ts - a string-exact conversion via Number.prototype.toString() (not toFixed(), which reveals the same binary noise this function exists to avoid) that rejects non-finite, negative, and >6-decimal-place amounts instead of silently truncating. Replaces every AP3X-to-lovelace float multiply on a money path: buildSendApex and buildSendTokens' apexAmount path in build.ts, and the three stake conversions (critique/endorse/proposal-lock) in gov-build.ts. Co-Authored-By: Claude Opus 5 --- packages/builder/src/vector/build.ts | 6 ++-- packages/builder/src/vector/gov-build.ts | 7 ++-- packages/shared/src/tx.ts | 42 +++++++++++++++++++++++ packages/shared/test/tx.test.ts | 43 +++++++++++++++++++++++- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index a9ea847..c6965d7 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -10,7 +10,7 @@ import { validatorToScriptHash, getAddressDetails, } from '@lucid-evolution/lucid'; import type { LucidEvolution, Provider, SpendingValidator, TxSignBuilder } from '@lucid-evolution/lucid'; -import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx'; +import { lovelaceToAda, apexToLovelace } from '@apexfusion/vector-mcp-shared/tx'; import type { TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult, } from '@apexfusion/vector-mcp-shared/types'; @@ -63,7 +63,7 @@ export async function buildSendApex( if (typeof p.amountApex !== 'number' || !Number.isFinite(p.amountApex) || p.amountApex <= 0) { throw new Error('Amount must be a positive number'); } - const lovelace = BigInt(Math.floor(p.amountApex * 1_000_000)); + const lovelace = apexToLovelace(p.amountApex); let tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace }); if (p.metadataJson) { tx = tx.attachMetadata(674, JSON.parse(p.metadataJson)); @@ -87,7 +87,7 @@ export async function buildSendTokens( assetNameHex = fromText(p.assetName); } const unit = `${p.policyId}${assetNameHex}`; - const outputLovelace = p.apexAmount ? BigInt(Math.floor(p.apexAmount * 1_000_000)) : 2_000_000n; + const outputLovelace = p.apexAmount ? apexToLovelace(p.apexAmount) : 2_000_000n; const tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace: outputLovelace, [unit]: BigInt(p.amount), diff --git a/packages/builder/src/vector/gov-build.ts b/packages/builder/src/vector/gov-build.ts index 6c45d23..a6b73e9 100644 --- a/packages/builder/src/vector/gov-build.ts +++ b/packages/builder/src/vector/gov-build.ts @@ -14,6 +14,7 @@ import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid'; import { blake2b } from '@noble/hashes/blake2b'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider'; +import { apexToLovelace } from '@apexfusion/vector-mcp-shared/tx'; import type { VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult, } from '@apexfusion/vector-mcp-shared/types'; @@ -387,7 +388,7 @@ export async function buildCritique(lucid: LucidEvolution, p: { 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)); + const stakeLovelace = apexToLovelace(p.stakeApex); let finalHash = p.critiqueHash; let finalUri = p.storageUri; @@ -473,7 +474,7 @@ export async function buildEndorse(lucid: LucidEvolution, p: { 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 stakeLovelace = apexToLovelace(p.stakeApex); const vkeyHash = paymentKeyHashOf(changeAddress); @@ -532,7 +533,7 @@ export async function buildProposalLock(lucid: LucidEvolution, p: { if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) { throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`); } - const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000)); + const stakeLovelace = apexToLovelace(p.stakeApex); // Proposal-type datum. Same Constr indices as the custodial switch - the // deployed module validator parses these exact bytes. diff --git a/packages/shared/src/tx.ts b/packages/shared/src/tx.ts index 4eeb82d..4a8c7b3 100644 --- a/packages/shared/src/tx.ts +++ b/packages/shared/src/tx.ts @@ -2,6 +2,48 @@ // from the signer, which must have no network capability. import { blake2b } from '@noble/hashes/blake2b'; +/** + * AP3X (decimal) to lovelace (integer), exactly. + * + * Never multiply by 1e6 in floating point: e.g. 1.005 * 1_000_000 is + * 1004999.9999999999 in IEEE-754, and Math.floor then silently loses a + * lovelace (empirically confirmed - see tx.test.ts). Amounts are money; + * they get string-exact conversion instead. + * + * The string comes from Number.prototype.toString(), NOT toFixed(): toFixed + * expands the double's true binary value out to the requested digit count, + * which reintroduces the exact noise this function exists to avoid (e.g. + * (2.05).toFixed(20) is "2.04999999999999982236" - the wrong digits after + * the 2nd decimal place). toString() instead yields the shortest decimal + * string that round-trips back to the same double, which for a value typed + * or parsed from a decimal literal like "2.05" reliably reproduces "2.05". + * + * More than 6 decimal places is a caller error, not something to round + * away silently - AP3X divides no finer than 1 lovelace (1e-6 AP3X). + */ +export function apexToLovelace(amountApex: number): bigint { + if (typeof amountApex !== 'number' || !Number.isFinite(amountApex)) { + throw new Error(`Amount must be a finite number, got ${String(amountApex)}`); + } + if (amountApex < 0) { + throw new Error(`Amount must not be negative, got ${amountApex}`); + } + const str = amountApex.toString(); + if (/e/i.test(str)) { + // Only reachable outside 1e-6 <= amountApex < 1e21: below one lovelace + // or beyond any realistic AP3X amount either way. Reject explicitly + // rather than let the plain split-on-'.' below misparse the exponent. + throw new Error(`Amount is out of range: ${amountApex}`); + } + const [whole, frac = ''] = str.split('.'); + if (frac.length > 6) { + throw new Error( + `Amount has more than 6 decimal places (${amountApex}); AP3X is divisible to 6 places (1 lovelace).` + ); + } + return BigInt(whole) * 1_000_000n + BigInt(frac.padEnd(6, '0')); +} + export function lovelaceToAda(lovelace: string | number | bigint): string { if (lovelace === undefined || lovelace === null) return '0.000000'; try { diff --git a/packages/shared/test/tx.test.ts b/packages/shared/test/tx.test.ts index fe61fff..5f5a926 100644 --- a/packages/shared/test/tx.test.ts +++ b/packages/shared/test/tx.test.ts @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { deriveNftAssetName, lovelaceToAda, formatAssetName, metadataStr } from '../src/tx.ts'; +import { deriveNftAssetName, lovelaceToAda, formatAssetName, metadataStr, apexToLovelace } from '../src/tx.ts'; const VECTORS: Array<{ tx: string; idx: number; expected: string }> = [ { tx: '00'.repeat(32), idx: 0, expected: 'a2e5e227858e84f1a8f9b0c1246e6cbc9336d707d43ba43d0e1cb7c51c45f4c9' }, @@ -39,3 +39,44 @@ describe('metadataStr', () => { assert.deepEqual((out as string[]).map(s => s.length), [64, 64, 22]); }); }); + +describe('apexToLovelace', () => { + test('converts whole and fractional amounts exactly', () => { + assert.equal(apexToLovelace(1), 1_000_000n); + assert.equal(apexToLovelace(0.000001), 1n); + assert.equal(apexToLovelace(25), 25_000_000n); + assert.equal(apexToLovelace(3.14), 3_140_000n); + // 4.35 and 8.87 happen to be bit-exact under Math.floor(x * 1e6) on this + // Node/V8 build (verified empirically - see below for values that are + // NOT exact), but a string-exact helper must get them right regardless + // of platform, so they stay as correctness checks. + assert.equal(apexToLovelace(4.35), 4_350_000n); + assert.equal(apexToLovelace(8.87), 8_870_000n); + }); + + test('is exact where float math is not', () => { + // Empirically confirmed on this codebase's Node runtime (node -e): + // 2.05 * 1e6 === 2049999.9999999998 -> Math.floor === 2049999 (want 2050000) + // 8.11 * 1e6 === 8109999.999999999 -> Math.floor === 8109999 (want 8110000) + // 1.005 * 1e6 === 1004999.9999999999 -> Math.floor === 1004999 (want 1005000) + // Each of these silently drops exactly 1 lovelace under the old + // Math.floor(amountApex * 1_000_000) path. The string-exact helper must not. + assert.equal(apexToLovelace(2.05), 2_050_000n); + assert.equal(apexToLovelace(8.11), 8_110_000n); + assert.equal(apexToLovelace(1.005), 1_005_000n); + }); + + test('rejects more than 6 decimal places instead of truncating', () => { + assert.throws(() => apexToLovelace(1.0000001), /decimal/i); + }); + + test('rejects negative, NaN, and Infinity', () => { + assert.throws(() => apexToLovelace(-1), /positive|negative/i); + assert.throws(() => apexToLovelace(NaN), /finite|number/i); + assert.throws(() => apexToLovelace(Infinity), /finite|number/i); + }); + + test('handles large amounts without precision loss', () => { + assert.equal(apexToLovelace(1_000_000), 1_000_000_000_000n); + }); +});