Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions pkg/core/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 '\bValidateDecimalPrecision\s*\(' --glob '*.go'
rg -n -C 8 '\b(DecimalToUint256|DecimalToInt256|decimalToBigInt)\s*\(' --glob '*.go'
rg -n -C 8 'NewFromString|UnmarshalJSON|decimal\.Decimal' --glob '*.go'

Repository: layer-3/nitrolite

Length of output: 155


🏁 Script executed:

#!/usr/bin/env bash
set -ueo pipefail

echo "Go files:"
git ls-files '*.go' | sed -n '1,120p'

echo
echo "Locate utils.go:"
fd -a 'utils\.go$' . | sed 's#^\./##' | sed -n '1,120p'

echo
echo "Search ValidateDecimalPrecision:"
rg -n -C 8 '\bValidateDecimalPrecision\s*\(' . --glob '*.go' || true

echo
echo "Search decimal conversion helpers:"
rg -n -C 8 '\b(DecimalToUint256|DecimalToInt256|decimalToBigInt)\b' . --glob '*.go' || true

echo
echo "Search decimal inputs:"
rg -n -C 8 '\b(NewFromString|UnmarshalJSON|decimal\.Decimal)\b' . --glob '*.go' || true

Repository: layer-3/nitrolite

Length of output: 50374


🏁 Script executed:

#!/usr/bin/env bash
set -ueo pipefail

echo "pkg/core/utils.go relevant section:"
sed -n '100,150p' pkg/core/utils.go

echo
echo "pkg/core/utils_test.go relevant precision tests:"
sed -n '160,190p' pkg/core/utils_test.go
sed -n '215,245p' pkg/core/utils_test.go

echo
echo "Read-only behavioral probe of current validation logic with shopsspring/decimal v1.4.0 source semantics:"
python3 - <<'PY'
# This script only mirrors the source shape; it does not run repository code.
# Extract the expression string and classify it as rescaling/truncating.
path = "pkg/core/utils.go"
needles = []
with open(path) as f:
    for i,line in enumerate(f, start=1):
        if "amount.Truncate(int32(maxDecimals))" in line or "ValidateDecimalPrecision" in line:
            needles.append((i,line.rstrip()))
for needle in needles:
    print(f"{path}:{needle[0]}: {needle[1]}")
PY

echo
echo "Go.mod dependency:"
sed -n '1,80p' go.mod 2>/dev/null || cat go.mod | sed -n '1,80p'

Repository: layer-3/nitrolite

Length of output: 8633


🌐 Web query:

github.com/shopspring/decimal v1.4.0 Truncate rescale exponent 10^ power_of_10 decimal.go

💡 Result:

In the shopspring/decimal Go library, the Truncate and rescale methods are closely related, with Truncate relying on rescale to adjust the decimal's exponent [1][2][3]. 1. rescale: This internal method changes the exponent of a Decimal while keeping its value represented as an arbitrary-precision integer [1][4]. - It effectively multiplies or divides the underlying integer value by 10^(abs(diff)), where diff is the difference between the current exponent and the target exponent [1][4]. - When decreasing precision (increasing the exponent), it uses big.Int.Quo, which truncates the value toward zero [1][4]. - Documentation explicitly states: "NOTE: this will truncate, NOT round" [1][4]. 2. Truncate(precision int32): This method allows you to truncate digits from a number [5]. - Prior to recent updates (e.g., in version 1.4.0), the Truncate method typically required a non-negative precision (precision >= 0) and used rescale to shift the exponent [1][2][5]. - Developments subsequent to v1.4.0 (such as those discussed in pull request #426) have updated Truncate to support negative precision [3]. This allows for truncation of the integer part toward zero (e.g., truncating 5432 with a precision of -2 results in 5400) [3]. - The mechanism remains a call to rescale, which inherently performs the required truncation via big.Int.Quo [1][3]. If you are using version v1.4.0 specifically, note that the library was in a state where Truncate often enforced a non-negative precision check, which was later relaxed in development versions of the library to support negative precision [2][3].

Citations:


🌐 Web query:

github.com/shopspring/decimal v1.4.0 decimal.go rescale func(rescale)

💡 Result:

In the shopspring/decimal library version 1.4.0, the rescale function is a method on the Decimal type that returns a new Decimal with a specified exponent [1]. Key characteristics of the rescale function include: Functionality: It adjusts the decimal to the target exponent provided by the user [1]. If the target exponent is greater than the current exponent, the function performs a truncation (reducing precision); if the target exponent is smaller, it scales the coefficient up by the appropriate power of 10 [1]. Important Behavior: The function explicitly performs truncation rather than rounding when decreasing precision [1]. Implementation: - If the target exponent matches the current exponent, it returns a copy of the original Decimal [1]. - It utilizes math.Abs to calculate the difference between the current and target exponents to determine the required scaling factor [1]. - It uses big.Int operations (Quo or Mul) to perform the scaling [1]. Example Usage (as documented in the source): go d := decimal.New(12345, -4) // Represents 1.2345 d2 := d.rescale(-1) // Results in 1.2 d3 := d2.rescale(-4) // Results in 1.2000 Note: While rescale is a core internal method used for operations like addition and subtraction (often via the RescalePair helper), it is worth noting that it is not typically exported for direct package-level use in the same way as public API methods [1]. Users often rely on higher-level operations or Round for precision management [1].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -ueo pipefail

echo "Remote v1.4.0 Truncate and rescale implementations:"
curl -sSL 'https://raw.githubusercontent.com/shopspring/decimal/v1.4.0/decimal.go' | sed -n '240,285p;530,575p'

echo
echo "Local occurrences of amounts parsed/constructed before ValidateDecimalPrecision:"
rg -n -C 4 '\bValidateDecimalPrecision\s*\(' pkg --glob '*.go' || true

echo
echo "Search for decimal.NewFromString outside tests:"
rg -n '\bdecimal\.NewFromString\s*\(' . --glob '*.go' --glob '!**/*_test.go' | sed -n '1,200p'

Repository: layer-3/nitrolite

Length of output: 10867


🏁 Script executed:

#!/usr/bin/env bash
set -ueo pipefail

echo "pkg/core/state_advancer.go relevant validation":
sed -n '1,90p' pkg/core/state_advancer.go

echo
echo "pkg/core/state_advancer.go amount field/usages":
rg -n -C 4 'newTransition\.Amount|Amount\s+.*decimal|Decimal|Transition' pkg/core/state_advancer.go --glob '*.go' | sed -n '1,220p'

echo
echo "Remote shopspring decimal v1.4.0 rescale implementation:"
url='https://raw.githubusercontent.com/shopspring/decimal/v1.4.0/decimal.go'
curl -sSL "$url" | perl -nle '
  if (/^\s*(func|type|var|const)\b/ && /rescale|Decimal/) { $in=1; $start=$. }
  if ($in) { print "$.: $_"; if (/^\s*}/ && /rescale/){ $in=0 } }
'

Repository: layer-3/nitrolite

Length of output: 50373


Prevent unbounded rescaling for extreme exponents.

ValidateDecimalPrecision now depends on amount.Truncate(...), and shopspring/decimal v1.4.0 rescales by computing 10^diff from the exponent gap. String parsing accepts small scientific exponents before validation, so an amount such as 1e-2147483648 can force an enormous allocation before returning an error. Add an extreme-exponent limit before this rescaling, or implement the precision test with a bounded trailing-zero/coefficient check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/core/utils.go` at line 127, Update ValidateDecimalPrecision before the
amount.Truncate comparison to guard against exponent gaps that could trigger
unbounded rescaling, rejecting extreme exponents safely. Alternatively, replace
the Truncate-based precision check with a bounded coefficient/trailing-zero
calculation, while preserving existing validation behavior for normal decimal
values.

Source: MCP tools

return fmt.Errorf("amount exceeds maximum decimal precision: max %d decimals allowed, got %d", maxDecimals, -amount.Exponent())
}
return nil
Expand Down
30 changes: 30 additions & 0 deletions pkg/core/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down