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
20 changes: 19 additions & 1 deletion inputs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,31 @@
"additionalProperties": false,
"patternProperties": {
"^([A-Z]+(/[0-9]+)?)$": {
"type": "number"
"oneOf": [
{ "type": "number", "description": "Uncolored shorthand" },
{ "$ref": "#/definitions/BalanceEntry" },
{ "type": "array", "items": { "$ref": "#/definitions/BalanceEntry" } }
]
}
}
}
}
},

"BalanceEntry": {
"type": "object",
"additionalProperties": false,
"required": ["amount"],
"properties": {
"color": {
"type": "string",
"pattern": "^[A-Z]*$",
"description": "Color bucket. Omit (or set to \"\") for the uncolored bucket."
},
"amount": { "type": "number" }
}
},

"VariablesMap": {
"type": "object",
"description": "Map of variable name to variable stringified value",
Expand Down
19 changes: 13 additions & 6 deletions internal/cmd/test_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,25 @@ type TestInitStore struct {

func (s TestInitStore) GetBalances(_ context.Context, q interpreter.BalanceQuery) (interpreter.Balances, error) {
outputBalance := interpreter.Balances{}
for queriedAccount, queriedCurrencies := range q {

for _, curr := range queriedCurrencies {
amt := utils.NestedMapGetOrPutDefault(s.Balances, queriedAccount, curr, func() *big.Int {
for queriedAccount, queriedItems := range q {
for _, item := range queriedItems {
accBalance := utils.MapGetOrPutDefault(s.Balances, queriedAccount, func() interpreter.AccountBalance {
return interpreter.AccountBalance{}
})
colorMap := utils.MapGetOrPutDefault(accBalance, item.Asset, func() interpreter.ColorBalance {
return interpreter.ColorBalance{}
})
amt := utils.MapGetOrPutDefault(colorMap, item.Color, func() *big.Int {
return new(big.Int).Set(s.DefaultBalance)
})

outputAccountBalance := utils.MapGetOrPutDefault(outputBalance, queriedAccount, func() interpreter.AccountBalance {
return interpreter.AccountBalance{}
})

outputAccountBalance[curr] = new(big.Int).Set(amt)
outputColorMap := utils.MapGetOrPutDefault(outputAccountBalance, item.Asset, func() interpreter.ColorBalance {
return interpreter.ColorBalance{}
})
outputColorMap[item.Color] = new(big.Int).Set(amt)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/test_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestMakeSpecsFileRetryForMissingFunds(t *testing.T) {
require.Nil(t, err)
require.Equal(t, interpreter.Balances{
"alice": interpreter.AccountBalance{
"USD/2": big.NewInt(10000),
"USD/2": interpreter.Uncolored(big.NewInt(10000)),
},
}, out.Balances)
}
Expand Down
8 changes: 4 additions & 4 deletions internal/interpreter/__snapshots__/balances_test.snap
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

[TestPrettyPrintBalance - 1]
| Account | Asset  | Balance |
| alice | EUR/2 | 1 |
| alice | USD/1234 | 999999 |
| bob | BTC | 3 |
| Account | Asset  | Color | Balance |
| alice | EUR/2 | | 1 |
| alice | USD/1234 | | 999999 |
| bob | BTC | | 3 |
---
16 changes: 12 additions & 4 deletions internal/interpreter/asset_scaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ func getAssetScale(asset string) (string, int64) {
return asset, 0
}

// getAssets returns, for a given baseAsset, the per-scale uncolored balance.
// Asset scaling operates on the uncolored bucket only — colored funds are not
// implicitly converted across scales.
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
for asset, colorMap := range balance {
if !strings.HasPrefix(asset, baseAsset) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches any asset starting with baseAsset, so getAssets(balance, "USD") also includes assets like USDC or USDX/2 in the scaling pool. The match should be exact base asset or baseAsset + "/"; otherwise unrelated asset balances can be converted during asset scaling.

continue
}
amount, ok := colorMap[""]
if !ok {
continue
}
_, scale := getAssetScale(asset)
result[scale] = amount
Comment on lines +41 to +55
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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't drop the color dimension during scaling lookup.

getAssets now hardcodes colorMap[""], but scaling sources still accept source.Color. That means a colored scaling path can convert uncolored balances and then withdraw nothing from the requested color bucket, which is enough for send all to emit bogus intermediary conversion postings. Either make this helper color-aware or reject non-empty colors before calling it.

🤖 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 `@internal/interpreter/asset_scaling.go` around lines 41 - 55, getAssets is
dropping the color dimension by hardcoding colorMap[""], which allows colored
scaling paths to improperly use uncolored buckets; fix by making getAssets
color-aware: change its signature to accept a color string (e.g.,
getAssets(balance AccountBalance, baseAsset, color string) map[int64]*big.Int),
look up amount := colorMap[color] instead of colorMap[""], and propagate this
new parameter to all callers (or alternatively validate and reject non-empty
colors before calling getAssets if you prefer to keep the old signature); ensure
callers in scaling lookup pass source.Color so the correct color bucket is used.

Comment on lines +46 to +55
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use exact base-asset matching here.

strings.HasPrefix(asset, baseAsset) will treat unrelated assets like USDT as part of the USD scaling pool. Match only the base asset itself or baseAsset/… children.

Suggested fix
 	for asset, colorMap := range balance {
-		if !strings.HasPrefix(asset, baseAsset) {
+		if asset != baseAsset && !strings.HasPrefix(asset, baseAsset+"/") {
 			continue
 		}
 		amount, ok := colorMap[""]
 		if !ok {
 			continue
🤖 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 `@internal/interpreter/asset_scaling.go` around lines 46 - 55, The loop that
selects assets uses strings.HasPrefix(asset, baseAsset) which wrongly includes
assets like "USDT" for baseAsset "USD"; update the condition in the loop (the
block iterating over balance and referencing getAssetScale and result) to accept
only the exact base asset or children in the form baseAsset + "/" (i.e., asset
== baseAsset || strings.HasPrefix(asset, baseAsset + "/")), leaving the rest of
the logic (retrieving amount from colorMap, calling getAssetScale, and assigning
result[scale]) unchanged.

}
return result
}
Expand Down
110 changes: 62 additions & 48 deletions internal/interpreter/balances.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,110 +2,124 @@ package interpreter

import (
"math/big"
"strings"

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

// Uncolored builds a ColorBalance holding a single amount under the empty
// color key (the "no color" bucket). It is meant to keep test setup terse.
func Uncolored(amount *big.Int) ColorBalance {
return ColorBalance{"": amount}
}

func (b Balances) DeepClone() Balances {
cloned := make(Balances)
for account, accountBalances := range b {
for asset, amount := range accountBalances {
utils.NestedMapGetOrPutDefault(cloned, account, asset, func() *big.Int {
return new(big.Int).Set(amount)
})
clonedAcc := AccountBalance{}
cloned[account] = clonedAcc
for asset, colorMap := range accountBalances {
clonedColors := ColorBalance{}
clonedAcc[asset] = clonedColors
for color, amount := range colorMap {
clonedColors[color] = new(big.Int).Set(amount)
}
}
}
return cloned
}

func coloredAsset(asset string, color *string) string {
if color == nil || *color == "" {
return asset
}

// note: 1 <= len(parts) <= 2
parts := strings.Split(asset, "/")

coloredAsset := parts[0] + "_" + *color
if len(parts) > 1 {
coloredAsset += "/" + parts[1]
}
return coloredAsset
}

// Get the (account, asset) tuple from the Balances
// if the tuple is not present, it will write a big.NewInt(0) in it and return it
func (b Balances) fetchBalance(account string, uncoloredAsset string, color string) *big.Int {
return utils.NestedMapGetOrPutDefault(b, account, coloredAsset(uncoloredAsset, &color), func() *big.Int {
// Get the (account, asset, color) balance from Balances.
// If the entry is not present, it will write a big.NewInt(0) in it and return it.
func (b Balances) fetchBalance(account string, asset string, color string) *big.Int {
accBalance := utils.MapGetOrPutDefault(b, account, func() AccountBalance {
return AccountBalance{}
})
colorMap := utils.MapGetOrPutDefault(accBalance, asset, func() ColorBalance {
return ColorBalance{}
})
return utils.MapGetOrPutDefault(colorMap, color, func() *big.Int {
return new(big.Int)
})
}

func (b Balances) has(account string, asset string) bool {
accountBalances := utils.MapGetOrPutDefault(b, account, func() AccountBalance {
return AccountBalance{}
})

_, ok := accountBalances[asset]
func (b Balances) has(account string, asset string, color string) bool {
accountBalances, ok := b[account]
if !ok {
return false
}
colorMap, ok := accountBalances[asset]
if !ok {
return false
}
_, ok = colorMap[color]
return ok
}

// given a BalanceQuery, return a new query which only contains needed (asset, account) pairs
// given a BalanceQuery, return a new query which only contains needed (asset, color) pairs
// (that is, the ones that aren't already cached)
func (b Balances) filterQuery(q BalanceQuery) BalanceQuery {
filteredQuery := BalanceQuery{}
for accountName, queriedCurrencies := range q {
filteredCurrencies := utils.Filter(queriedCurrencies, func(currency string) bool {
return !b.has(accountName, currency)
for accountName, queriedItems := range q {
filteredItems := utils.Filter(queriedItems, func(item AssetColor) bool {
return !b.has(accountName, item.Asset, item.Color)
})

if len(filteredCurrencies) > 0 {
filteredQuery[accountName] = filteredCurrencies
if len(filteredItems) > 0 {
filteredQuery[accountName] = filteredItems
}

}
return filteredQuery
}

// Merge balances by adding balances in the "update" arg
func (b Balances) Merge(update Balances) {
// merge queried balance
for acc, accBalances := range update {
cachedAcc := utils.MapGetOrPutDefault(b, acc, func() AccountBalance {
return AccountBalance{}
})

for curr, amt := range accBalances {
cachedAcc[curr] = amt
for asset, colorMap := range accBalances {
cachedColors := utils.MapGetOrPutDefault(cachedAcc, asset, func() ColorBalance {
return ColorBalance{}
})
for color, amt := range colorMap {
cachedColors[color] = amt
}
}
}
}

func (b Balances) PrettyPrint() string {
header := []string{"Account", "Asset", "Balance"}
header := []string{"Account", "Asset", "Color", "Balance"}

var rows [][]string
for account, accBalances := range b {
for asset, balance := range accBalances {
row := []string{account, asset, balance.String()}
rows = append(rows, row)
for asset, colorMap := range accBalances {
for color, balance := range colorMap {
rows = append(rows, []string{account, asset, color, balance.String()})
}
}
}
return utils.CsvPretty(header, rows, true)
}

func CompareBalances(b1 Balances, b2 Balances) bool {
return utils.Map2Cmp(b1, b2, func(ab1, ab2 *big.Int) bool {
return ab1.Cmp(ab2) == 0
return utils.MapCmp(b1, b2, func(ab1, ab2 AccountBalance) bool {
return utils.MapCmp(ab1, ab2, func(cm1, cm2 ColorBalance) bool {
return utils.MapCmp(cm1, cm2, func(a1, a2 *big.Int) bool {
return a1.Cmp(a2) == 0
})
})
})
}

// Returns whether the first value is a subset of the second one
func CompareBalancesIncluding(b1 Balances, b2 Balances) bool {
return utils.MapIncludes(b2, b1, func(a2 AccountBalance, a1 AccountBalance) bool {
return utils.MapIncludes(a2, a1, func(a2 *big.Int, a1 *big.Int) bool {
return a2.Cmp(a1) == 0
return utils.MapIncludes(a2, a1, func(cm2, cm1 ColorBalance) bool {
return utils.MapIncludes(cm2, cm1, func(x2, x1 *big.Int) bool {
return x2.Cmp(x1) == 0
})
})
})
}
Loading