MoneyPHP loses largest-remainder ordering above float precision
An accepted 7-quadrillion-unit amount makes two exact residuals collapse to zero in binary64, assigning the final unit to the wrong share.
Finding
Money::allocate() computes its initial integer shares with exact arithmetic, but converts the amount and ratios to binary float when ranking fractional remainders. At sufficiently large accepted amounts, distinct exact remainders collapse to the same floating-point value and the residual minor unit is given to the wrong share.
Minimal counterexample
$parts = (new Money\Money(
'7000000000000000',
new Money\Currency('USD')
))->allocate([1, 2]);
Observed amounts:
[2333333333333334, 4666666666666666]
Exact largest-remainder result:
[2333333333333333, 4666666666666667]
The exact fractional remainders are 1/3 and 2/3. The single residual minor unit must therefore go to the second share. In the implementation both floating-point fractions become 0.0, so the tie-breaking order gives it to the first share instead.
Reproducer
<?php
require 'vendor/autoload.php';
use Money\Currency;
use Money\Money;
$actual = array_map(
static fn (Money $part): string => $part->getAmount(),
(new Money('7000000000000000', new Currency('USD')))->allocate([1, 2]),
);
$expected = ['2333333333333333', '4666666666666667'];
var_export(['actual' => $actual, 'expected' => $expected]);
assert($actual === $expected);
Verification
- Reproduced on PHP
8.5.10with BCMath. - Reproduced at release
v4.9.0, commitd49ee625c6ba79b9d7a228ce153b02fc1032152b. - Reproduced on
master, commita5199443bfd20c3d3644b8445b09054a4c014191. - The allocated parts still sum to the original amount; the defect is the proportional ranking of the residual unit.
- For ratios
[1, 2], the first failing region begins when the largest share reaches the binary64 spacing boundary near2^52. A scanned failing amount starts at6755399441055744minor units; no mismatch was found in sampled windows around10^14and4 * 10^15. - The counterexample amount
7 * 10^15is itself below2^53and is exactly representable as binary64. The loss occurs when ranking the fractional shares, not when storing the original amount. - Targeted issue and pull-request searches found no exact duplicate. Related issue
#506and pull request#509introduced the largest-remainder policy, but do not report this loss of precision. - A common-scale integer-weight correction and portable regression test were submitted upstream as moneyphp/money#832.
Proposed correction
Normalize the ratios to common-scale integer weights, compute each exact residual numerator with MoneyPHP's configured Calculator, and compare those numeric strings directly. This removes binary float from the remainder ranking while preserving input order for exact ties.
Boundary
This is an ordinary correctness defect in a public library method. It is not evidence that a bank or payment processor transferred an incorrect amount, and no production system was tested. Cases with decimal ratios can also expose floating-point tie-breaking, but a true exact tie does not establish a unique wrong allocation and is intentionally excluded from this finding.
Upstream status
Correction submitted as moneyphp/money#832. Following maintainer review, the implementation was reduced from three passes to two and a compatibility regression was added for an ordinary 1:2 allocation. All CI, static-analysis, documentation and benchmark jobs pass. The exact remainder ordering intentionally changes only the large-value case in which binary64 had collapsed distinct 1/3 and 2/3 remainders into a false tie.
