diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 80446f8ff..1314d4459 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -115,9 +115,22 @@ func TransitionToIntent(transition Transition) uint8 { // FINALIZE_MIGRATION. } -// ValidateDecimalPrecision validates that an amount doesn't exceed the maximum allowed decimal places. +// ValidateDecimalPrecision returns an error when amount is not exactly +// representable with maxDecimals fractional digits. +// +// The rule is value-based, not scale-based: an amount carrying trailing +// zeros beyond maxDecimals (for example 0.030000000000000000 against a +// 6-decimal asset) is valid, because no significant digit is lost by +// representing it with maxDecimals. func ValidateDecimalPrecision(amount decimal.Decimal, maxDecimals uint8) error { - if amount.Exponent() < -int32(maxDecimals) { + // Compare against the truncated value rather than testing the exponent + // directly: a decimal's exponent reflects its *scale*, which can be widened + // by arithmetic or by storage round-trips without adding any significant + // digits. For example a balance loaded via SUM() over a NUMERIC column can + // come back as 0.030000000000000000 (exponent -18); subtracting from it + // propagates that scale, and the result would be rejected here even though + // the value is exactly representable in 6 decimals. + if !amount.Equal(amount.Truncate(int32(maxDecimals))) { return fmt.Errorf("amount exceeds maximum decimal precision: max %d decimals allowed, got %d", maxDecimals, -amount.Exponent()) } return nil diff --git a/pkg/core/utils_test.go b/pkg/core/utils_test.go index 7c731a7d6..13ef9dc9c 100644 --- a/pkg/core/utils_test.go +++ b/pkg/core/utils_test.go @@ -214,6 +214,36 @@ func TestValidateDecimalPrecision(t *testing.T) { func TestValidateDecimalPrecision_EdgeCases(t *testing.T) { t.Parallel() + // Regression: a value whose SCALE exceeds maxDecimals but whose significant + // digits do not. Balances loaded from storage (e.g. SUM() over a NUMERIC + // column) carry a wide scale, and decimal.Sub propagates it, so derived + // amounts such as the withdraw delta in handleWithdrawIntent hit this. + t.Run("trailing_zeros_within_precision", func(t *testing.T) { + t.Parallel() + amount, err := decimal.NewFromString("0.030000000000000000") + assert.NoError(t, err) + assert.NoError(t, ValidateDecimalPrecision(amount, 6), + "a value exactly representable in 6 decimals must be accepted regardless of its scale") + }) + + t.Run("trailing_zeros_from_subtraction", func(t *testing.T) { + t.Parallel() + current, err := decimal.NewFromString("0.060000000000000000") + assert.NoError(t, err) + incoming, err := decimal.NewFromString("0.03") + assert.NoError(t, err) + assert.NoError(t, ValidateDecimalPrecision(current.Sub(incoming), 6), + "a derived amount must not be rejected because the stored operand had a wider scale") + }) + + t.Run("wide_scale_with_real_excess_precision_still_rejected", func(t *testing.T) { + t.Parallel() + amount, err := decimal.NewFromString("0.030000010000000000") + assert.NoError(t, err) + assert.Error(t, ValidateDecimalPrecision(amount, 6), + "a value needing more than 6 decimals must still be rejected") + }) + t.Run("negative_amount", func(t *testing.T) { t.Parallel() amount := decimal.NewFromFloat(-1.123456)