Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Lexer.g4
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ PLUS: '+';
MINUS: '-';
DIV: '/';
RESTRICT: '\\';
WITH: 'with';
SCALING: 'scaling';
THROUGH: 'through';

PERCENTAGE_PORTION_LITERAL: [0-9]+ ('.' [0-9]+)? '%';

Expand Down
8 changes: 4 additions & 4 deletions Numscript.g4
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ allotment:

colorConstraint: RESTRICT valueExpr;

source:
address = valueExpr colorConstraint? ALLOWING UNBOUNDED OVERDRAFT # srcAccountUnboundedOverdraft
| address = valueExpr colorConstraint? ALLOWING OVERDRAFT UP TO maxOvedraft = valueExpr #
srcAccountBoundedOverdraft
source
: address = valueExpr colorConstraint? ALLOWING UNBOUNDED OVERDRAFT # srcAccountUnboundedOverdraft
| address = valueExpr colorConstraint? ALLOWING OVERDRAFT UP TO maxOvedraft = valueExpr #srcAccountBoundedOverdraft
| address = valueExpr WITH SCALING THROUGH swap=valueExpr # srcAccountWithScaling
| valueExpr colorConstraint? # srcAccount
| LBRACE allotmentClauseSrc+ RBRACE # srcAllotment
| LBRACE source* RBRACE # srcInorder
Expand Down
5 changes: 5 additions & 0 deletions internal/analysis/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,11 @@ func (res *CheckResult) checkSource(source parser.Source) {
res.unifyNodeWith(*source.Bounded, res.stmtType)
}

case *parser.SourceWithScaling:
res.checkExpression(source.Address, TypeAccount)
res.checkExpression(source.Through, TypeAccount)
res.checkExpression(source.Color, TypeString)

case *parser.SourceInorder:
for _, source := range source.Sources {
res.checkSource(source)
Expand Down
12 changes: 12 additions & 0 deletions internal/analysis/hover.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ func hoverOnSource(source parser.Source, position parser.Position) Hover {
return hover
}
}
case *parser.SourceWithScaling:
hover := hoverOnExpression(source.Address, position)
if hover != nil {
return hover
}

hover = hoverOnExpression(source.Through, position)
if hover != nil {
return hover
}

return nil

case *parser.SourceAllotment:
for _, item := range source.Items {
Expand Down
29 changes: 29 additions & 0 deletions internal/analysis/hover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,32 @@ send [ASSET *] (
resolved := checkResult.ResolveVar(variableHover.Node)
require.NotNil(t, resolved)
}

func TestHover(t *testing.T) {

input := `vars {
account $x
}

send [EUR/2 *] (
source = {
@acc1
@acc2 with scaling through $x
}
destination = @dest
)

`

rng := parser.RangeOfIndexed(input, "$x", 1)
program := parser.Parse(input).Value
hover := analysis.HoverOn(program, rng.Start)

varHover, ok := hover.(*analysis.VariableHover)
if !ok {
t.Fatalf("Expected a VariableHover")
}

require.Equal(t, varHover.Node.Name, "x")
Comment on lines +545 to +552
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing nil check and incorrect require.Equal argument order.

  1. A nil check on hover before the type assertion would prevent a potential panic and provide a clearer failure message, consistent with all other tests in this file.
  2. The require.Equal arguments are reversed—testify convention is (t, expected, actual).
Proposed fix
 	rng := parser.RangeOfIndexed(input, "$x", 1)
 	program := parser.Parse(input).Value
 	hover := analysis.HoverOn(program, rng.Start)
+	require.NotNil(t, hover)
 
 	varHover, ok := hover.(*analysis.VariableHover)
 	if !ok {
 		t.Fatalf("Expected a VariableHover")
 	}
 
-	require.Equal(t, varHover.Node.Name, "x")
+	require.Equal(t, "x", varHover.Node.Name)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hover := analysis.HoverOn(program, rng.Start)
varHover, ok := hover.(*analysis.VariableHover)
if !ok {
t.Fatalf("Expected a VariableHover")
}
require.Equal(t, varHover.Node.Name, "x")
hover := analysis.HoverOn(program, rng.Start)
require.NotNil(t, hover)
varHover, ok := hover.(*analysis.VariableHover)
if !ok {
t.Fatalf("Expected a VariableHover")
}
require.Equal(t, "x", varHover.Node.Name)
🤖 Prompt for AI Agents
In @internal/analysis/hover_test.go around lines 545 - 552, The test is missing
a nil check on hover before the type assertion and has reversed require.Equal
arguments; first assert hover != nil (e.g., fail with a clear message consistent
with other tests) before doing the type assertion to avoid a panic, then perform
the type assertion to *analysis.VariableHover and use require.Equal(t, "x",
varHover.Node.Name) so the expected and actual are in the correct order;
reference the hover variable, the VariableHover type, varHover.Node.Name, and
the require.Equal call when making the changes.


}
2 changes: 2 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const (
ExperimentalAccountInterpolationFlag FeatureFlag = "experimental-account-interpolation"
ExperimentalMidScriptFunctionCall FeatureFlag = "experimental-mid-script-function-call"
ExperimentalAssetColors FeatureFlag = "experimental-asset-colors"
AssetScaling FeatureFlag = "experimental-asset-scaling"
)

var AllFlags []string = []string{
Expand All @@ -20,4 +21,5 @@ var AllFlags []string = []string{
ExperimentalAccountInterpolationFlag,
ExperimentalMidScriptFunctionCall,
ExperimentalAssetColors,
AssetScaling,
}
171 changes: 171 additions & 0 deletions internal/interpreter/asset_scaling.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package interpreter

import (
"fmt"
"math/big"
"slices"
"strconv"
"strings"

"github.com/formancehq/numscript/internal/utils"
)

func assetToScaledAsset(asset string) string {
parts := strings.Split(asset, "/")
if len(parts) == 1 {
return asset + "/*"
}
return parts[0] + "/*"
}

func buildScaledAsset(baseAsset string, scale int64) string {
if scale == 0 {
return baseAsset
}
return fmt.Sprintf("%s/%d", baseAsset, scale)
}

func getAssetScale(asset string) (string, int64) {
parts := strings.Split(asset, "/")
if len(parts) == 2 {
scale, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
return parts[0], scale
}
// fallback if parsing fails
return parts[0], 0
}
return asset, 0
}

func getAssets(balance AccountBalance, baseAsset string) map[int64]*big.Int {
result := make(map[int64]*big.Int)
for asset, amount := range balance {
if strings.HasPrefix(asset, baseAsset) {
_, scale := getAssetScale(asset)
result[scale] = amount
}
}
return result
}
Comment on lines +41 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prefix matching may cause false positives.

strings.HasPrefix(asset, baseAsset) will incorrectly match assets like "USDC" or "USDC/2" when searching for "USD". Consider checking for an exact match or ensuring the prefix is followed by / or end-of-string.

🔎 Proposed fix
 func getAssets(balance AccountBalance, baseAsset string) map[int64]*big.Int {
 	result := make(map[int64]*big.Int)
 	for asset, amount := range balance {
-		if strings.HasPrefix(asset, baseAsset) {
+		base, _ := getAssetScale(asset)
+		if base == baseAsset {
 			_, scale := getAssetScale(asset)
 			result[scale] = amount
 		}
 	}
 	return result
 }
🤖 Prompt for AI Agents
internal/interpreter/asset_scaling.go around lines 41 to 50: the current
strings.HasPrefix(asset, baseAsset) check can produce false positives (e.g.
"USDC" matching "USD"); change the condition to accept either an exact match
(asset == baseAsset) or a prefix followed by a separator (e.g.
strings.HasPrefix(asset, baseAsset+"/")) so only the base asset or variants like
"USD/..." match; update the if to use this combined check and keep the rest of
the function unchanged.


type scalePair struct {
scale int64
amount *big.Int
}

func getSortedAssets(scales map[int64]*big.Int) []scalePair {
var assets []scalePair
for k, v := range scales {
assets = append(assets, scalePair{
scale: k,
amount: v,
})
}

// Sort in DESC order (e.g. EUR/4, .., EUR/1, EUR)
slices.SortFunc(assets, func(p scalePair, other scalePair) int {
return int(other.scale - p.scale)
})

return assets
}

func getScalingFactor(neededAmtScale int64, currentScale int64) *big.Rat {
scaleDiff := neededAmtScale - currentScale

exp := big.NewInt(scaleDiff)
exp.Abs(exp)
exp.Exp(big.NewInt(10), exp, nil)

// scalingFactor := 10 ^ (neededAmtScale - p.scale)
// note that 10^0 == 1 and 10^(-n) == 1/(10^n)
scalingFactor := new(big.Rat).SetInt(exp)
if scaleDiff < 0 {
scalingFactor.Inv(scalingFactor)
}

return scalingFactor
}

func applyScaling(amt *big.Int, scalingFactor *big.Rat) *big.Int {
availableCurrencyScaled := new(big.Int)
availableCurrencyScaled.Mul(amt, scalingFactor.Num())
availableCurrencyScaled.Div(availableCurrencyScaled, scalingFactor.Denom())

return availableCurrencyScaled
}

func applyScalingInv(amt *big.Int, scalingFactor *big.Rat) *big.Int {
rem := new(big.Int)

availableCurrencyScaled := new(big.Int)
availableCurrencyScaled.Mul(amt, scalingFactor.Denom())
availableCurrencyScaled.QuoRem(availableCurrencyScaled, scalingFactor.Num(), rem)

if rem.Sign() != 0 {
availableCurrencyScaled.Add(availableCurrencyScaled, big.NewInt(1))
}

return availableCurrencyScaled
}

// Find a set of conversions from the available "scales", to
// [ASSET/$neededAmtScale $neededAmt], so that there's no rounding error
// and no spare amount
func findScalingSolution(
neededAmt *big.Int, // <- can be nil
neededAmtScale int64,
scales map[int64]*big.Int,
) ([]scalePair, *big.Int) {
if ownedAmt, ok := scales[neededAmtScale]; ok && neededAmt != nil {
// Note we don't mutate the input value
neededAmt = new(big.Int).Sub(neededAmt, ownedAmt)
}

var out []scalePair
totalSent := big.NewInt(0)

for _, p := range getSortedAssets(scales) {
if neededAmtScale == p.scale {
// We don't convert assets we already have
continue
}

if neededAmt != nil && totalSent.Cmp(neededAmt) != -1 {
break
}

scalingFactor := getScalingFactor(neededAmtScale, p.scale)

// scale the original amount to the current currency
// availableCurrencyScaled := floor(p.amount * scalingFactor)
availableCurrencyScaled := applyScaling(p.amount, scalingFactor)

var taken *big.Int // := min(availableCurrencyScaled, (neededAmt-totalSent) ?? ∞)
if neededAmt == nil {
taken = new(big.Int).Set(availableCurrencyScaled)
} else {
leftAmt := new(big.Int).Sub(neededAmt, totalSent)
taken = utils.MinBigInt(availableCurrencyScaled, leftAmt)
}

// intPart := floor(p.amount * 1/scalingFactor) == (p.amount * scalingFactor.Denom)/scalingFactor.Num)
intPart := applyScalingInv(taken, scalingFactor)

if intPart.Sign() == 0 {
continue
}

actuallyTaken := applyScaling(intPart, scalingFactor)

totalSent.Add(totalSent, actuallyTaken)

out = append(out, scalePair{
scale: p.scale,
amount: intPart,
})
}

return out, totalSent
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is returning an empty []scalePair, afaiu it should return [{scale: 0, amount: 1}]

sol, tot := findSolution(
		big.NewInt(2),
		2,
		map[int64]*big.Int{
			0: big.NewInt(1),
		})

Loading
Loading