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 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user