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,
|
validatorToScriptHash, getAddressDetails,
|
||||||
} from '@lucid-evolution/lucid';
|
} from '@lucid-evolution/lucid';
|
||||||
import type { LucidEvolution, Provider, SpendingValidator, TxSignBuilder } 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 {
|
import type {
|
||||||
TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult,
|
TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult,
|
||||||
} from '@apexfusion/vector-mcp-shared/types';
|
} 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) {
|
if (typeof p.amountApex !== 'number' || !Number.isFinite(p.amountApex) || p.amountApex <= 0) {
|
||||||
throw new Error('Amount must be a positive number');
|
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 });
|
let tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace });
|
||||||
if (p.metadataJson) {
|
if (p.metadataJson) {
|
||||||
tx = tx.attachMetadata(674, JSON.parse(p.metadataJson));
|
tx = tx.attachMetadata(674, JSON.parse(p.metadataJson));
|
||||||
@@ -87,7 +87,7 @@ export async function buildSendTokens(
|
|||||||
assetNameHex = fromText(p.assetName);
|
assetNameHex = fromText(p.assetName);
|
||||||
}
|
}
|
||||||
const unit = `${p.policyId}${assetNameHex}`;
|
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, {
|
const tx = lucid.newTx().pay.ToAddress(p.recipientAddress, {
|
||||||
lovelace: outputLovelace,
|
lovelace: outputLovelace,
|
||||||
[unit]: BigInt(p.amount),
|
[unit]: BigInt(p.amount),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
|
|||||||
import { blake2b } from '@noble/hashes/blake2b';
|
import { blake2b } from '@noble/hashes/blake2b';
|
||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
||||||
|
import { apexToLovelace } from '@apexfusion/vector-mcp-shared/tx';
|
||||||
import type {
|
import type {
|
||||||
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
||||||
} from '@apexfusion/vector-mcp-shared/types';
|
} from '@apexfusion/vector-mcp-shared/types';
|
||||||
@@ -387,7 +388,7 @@ export async function buildCritique(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
||||||
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
|
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 finalHash = p.critiqueHash;
|
||||||
let finalUri = p.storageUri;
|
let finalUri = p.storageUri;
|
||||||
@@ -473,7 +474,7 @@ export async function buildEndorse(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
||||||
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
|
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);
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
||||||
|
|
||||||
@@ -532,7 +533,7 @@ export async function buildProposalLock(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
||||||
throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`);
|
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
|
// Proposal-type datum. Same Constr indices as the custodial switch - the
|
||||||
// deployed module validator parses these exact bytes.
|
// deployed module validator parses these exact bytes.
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
// from the signer, which must have no network capability.
|
// from the signer, which must have no network capability.
|
||||||
import { blake2b } from '@noble/hashes/blake2b';
|
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 {
|
export function lovelaceToAda(lovelace: string | number | bigint): string {
|
||||||
if (lovelace === undefined || lovelace === null) return '0.000000';
|
if (lovelace === undefined || lovelace === null) return '0.000000';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test } from 'node:test';
|
import { describe, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 }> = [
|
const VECTORS: Array<{ tx: string; idx: number; expected: string }> = [
|
||||||
{ tx: '00'.repeat(32), idx: 0, expected: 'a2e5e227858e84f1a8f9b0c1246e6cbc9336d707d43ba43d0e1cb7c51c45f4c9' },
|
{ 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]);
|
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