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
41 changes: 28 additions & 13 deletions inputs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
"definitions": {
"Balances": {
"type": "array",
"description": "List of account balances. The (account, asset, color) triple of each entry must be unique within the list.",
"description": "List of account balances. The (account, asset, color, scope) tuple of each entry must be unique within the list.",
"items": { "$ref": "#/definitions/BalanceRow" }
},

"BalanceRow": {
"type": "object",
"description": "The balance of a given (account, asset, color)",
"description": "The balance of a given (account, asset, color, scope)",
"additionalProperties": false,
"required": ["account", "asset", "amount"],
"properties": {
Expand All @@ -46,6 +46,10 @@
"color": {
"type": "string",
"pattern": "^[A-Z]*$"
},
"scope": {
"type": "string",
"pattern": "^[a-z0-9_]*$"
}
}
},
Expand All @@ -60,21 +64,32 @@
},

"AccountsMetadata": {
"type": "array",
"description": "List of account metadata entries. The (account, key, scope) tuple of each entry must be unique within the list.",
"items": { "$ref": "#/definitions/AccountMetadataRow" }
},

"AccountMetadataRow": {
"type": "object",
"description": "Map of an account metadata to the account's metadata",
"description": "A single metadata entry: the value of a given (account, key, scope)",
"additionalProperties": false,
"patternProperties": {
"^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": {
"type": "object",
"additionalProperties": { "type": "string" }
"required": ["account", "key", "value"],
"properties": {
"account": {
"type": "string",
"pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$"
},
"key": {
"type": "string"
},
"value": {
"type": "string"
},
"scope": {
"type": "string",
"pattern": "^[a-z0-9_]*$"
}
}
},

"TxMetadata": {
"type": "object",
"description": "Map from a metadata's key to the transaction's metadata stringied value",
"additionalProperties": { "type": "string" }
}
}
}
31 changes: 24 additions & 7 deletions internal/analysis/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,19 @@ func (StatementFnCallResolution) fnCallResolution() {}
func (r VarOriginFnCallResolution) GetParams() []string { return r.Params }
func (r StatementFnCallResolution) GetParams() []string { return r.Params }

const FnSetTxMeta = "set_tx_meta"
const FnSetAccountMeta = "set_account_meta"
const FnVarOriginMeta = "meta"
const FnVarOriginBalance = "balance"
const FnVarOriginOverdraft = "overdraft"
const FnVarOriginGetAsset = "get_asset"
const FnVarOriginGetAmount = "get_amount"
const (
// Statemetn fns
FnSetTxMeta = "set_tx_meta"
FnSetAccountMeta = "set_account_meta"

// Expr fns
FnVarOriginMeta = "meta"
FnVarOriginBalance = "balance"
FnVarOriginOverdraft = "overdraft"
FnVarOriginGetAsset = "get_asset"
FnVarOriginGetAmount = "get_amount"
FnVarOriginScoped = "scoped"
)

var Builtins = map[string]FnCallResolution{
FnSetTxMeta: StatementFnCallResolution{
Expand Down Expand Up @@ -114,6 +120,17 @@ var Builtins = map[string]FnCallResolution{
},
},
},
FnVarOriginScoped: VarOriginFnCallResolution{
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.

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.

🟠 [major] GetInvolvedAccounts has no case for scoped(), returning UnboundFunctionErr

Adding scoped() as a supported account expression was not mirrored in the involved-account discovery evaluator. Any script using scoped(@src, "x") in a source, destination, balance, or metadata position returns UnboundFunctionErr from ParseResult.GetInvolvedAccounts, breaking the public preflight API for the new feature.

Suggestion: Add a FnVarOriginScoped (or equivalent) case to get_involved_accounts.evalExpr that returns the scoped account address.

Params: []string{TypeAccount, TypeString},
Return: TypeAccount,
Docs: "returns the scoped version of that account. Empty string means no scope. Overwrites the previous scope",
VersionConstraints: []VersionClause{

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.

🟠 [major] Mid-script function call flag not required for scoped() expression usage

When scoped() is used in mid-script expression positions (e.g., source = scoped(@src, "s") or balance(scoped(@src, "s"), USD)), the runtime's evaluateExpr requires experimental-mid-script-function-call in addition to experimental-scoped-function. The analyzer only checks for the scoped flag, so scripts accepted by numscript check can still fail at runtime unless both flags are enabled. The requirement should be made consistent between the checker and interpreter.

Suggestion: Either require both flags in the checker registration, or ensure the runtime does not require the mid-script flag for scoped() expressions, making the two consistent.

{
Version: parser.NewVersionInterpreter(0, 0, 25),
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
FeatureFlag: flags.ExperimentalScopedFunction,
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
ascandone marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.

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.

🟠 [major] Wrong feature flag gates scoped() in static analysis
reported by NumaryBot, codex

scoped() is registered in the static analyzer under experimental-get-amount-function instead of experimental-scoped-function. Scripts that enable the documented scoped flag still receive a missing-flag diagnostic from numscript check/LSP, while scripts enabling only the get-amount flag pass static analysis but fail at runtime because the interpreter checks ExperimentalScopedFunction. This analysis/runtime mismatch makes the feature unreliable for any user relying on static checking.

Suggestion: Change the feature-flag constraint for the scoped builtin in the checker to reference experimental-scoped-function / ExperimentalScopedFunction, matching what the interpreter enforces at runtime.

},
},
},
}

type Diagnostic struct {
Expand Down
14 changes: 13 additions & 1 deletion internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,27 @@ func run(scriptPath string, opts RunArgs) error {
}

// Reject a malformed inputs file before running anything: a balance list is a
// map keyed by (account, asset, color), so a repeated key is ambiguous.
// map keyed by (account, asset, color, scope), so a repeated key is ambiguous.
if dup, ok := inputs.Balances.FirstDuplicate(); ok {
key := fmt.Sprintf("account=%q asset=%q", dup.Account, dup.Asset)
if dup.Color != "" {
key += fmt.Sprintf(" color=%q", dup.Color)
}
if dup.Scope != "" {
key += fmt.Sprintf(" scope=%q", dup.Scope)
}
return fmt.Errorf("invalid inputs file '%s': balances must not contain duplicate entries: duplicate entry for %s", inputsPath, key)
}

// Likewise, a metadata list is keyed by (account, key, scope).
if dup, ok := inputs.Meta.FirstDuplicate(); ok {
key := fmt.Sprintf("account=%q key=%q", dup.Account, dup.Key)
if dup.Scope != "" {
key += fmt.Sprintf(" scope=%q", dup.Scope)
}
return fmt.Errorf("invalid inputs file '%s': metadata must not contain duplicate entries: duplicate entry for %s", inputsPath, key)
}

featureFlags := map[string]struct{}{}
for _, flag := range inputs.FeatureFlags {
featureFlags[flag] = struct{}{}
Expand Down
17 changes: 9 additions & 8 deletions internal/cmd/test_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func makeSpecsFile(
DefaultBalance: defaultBalance,
StaticStore: interpreter.StaticStore{
Balances: interpreter.Balances{},
Meta: make(interpreter.AccountsMetadata),
Meta: interpreter.AccountsMetadata{},
},
}

Expand Down Expand Up @@ -210,25 +210,25 @@ func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQu
return nil, err
}

type key struct{ account, asset, color string }
type key struct{ account, asset, color, scope string }

// StaticStore.GetBalances materializes a zero-amount row for every queried
// (account, asset, color), so its output can't tell a known account from an
// unknown one. Track what we've actually funded ourselves instead.
// (account, asset, color, scope), so its output can't tell a known account from
// an unknown one. Track what we've actually funded ourselves instead.
stored := make(map[key]struct{}, len(s.StaticStore.Balances))
for _, b := range s.StaticStore.Balances {
stored[key{b.Account, b.Asset, b.Color}] = struct{}{}
stored[key{b.Account, b.Asset, b.Color, b.Scope}] = struct{}{}
}

for i := range balances {
b := &balances[i]
k := key{b.Account, b.Asset, b.Color}
k := key{b.Account, b.Asset, b.Color, b.Scope}
if _, ok := stored[k]; ok {
continue
}

// Unknown (account, asset, color): fund it with the default balance, and
// remember it so later queries (and the generated specs file) see it.
// Unknown (account, asset, color, scope): fund it with the default balance,
// and remember it so later queries (and the generated specs file) see it.
amount := new(big.Int)
if s.DefaultBalance != nil {
amount.Set(s.DefaultBalance)
Expand All @@ -239,6 +239,7 @@ func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQu
Account: b.Account,
Asset: b.Asset,
Color: b.Color,
Scope: b.Scope,
Amount: new(big.Int).Set(amount),
})
stored[k] = struct{}{}
Expand Down
14 changes: 14 additions & 0 deletions internal/cmd/test_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ func TestMakeSpecsFileRetryForMissingFunds(t *testing.T) {
}, out.Balances)
}

func TestMakeSpecsFileFundsScopedBalance(t *testing.T) {
out, err := cmd.MakeSpecsFile(`
send [USD/2 10000] (
source = scoped(@alice, "reserve")
destination = @bob
)
`)

require.Nil(t, err)
require.Equal(t, interpreter.Balances{
{Account: "alice", Asset: "USD/2", Scope: "reserve", Amount: big.NewInt(10000)},
}, out.Balances)
}

func TestUnusedVars(t *testing.T) {
out, err := cmd.MakeSpecsFile(`
vars { monetary $m }
Expand Down
2 changes: 2 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
ExperimentalOverdraftFunctionFeatureFlag FeatureFlag = "experimental-overdraft-function"
ExperimentalGetAssetFunctionFeatureFlag FeatureFlag = "experimental-get-asset-function"
ExperimentalGetAmountFunctionFeatureFlag FeatureFlag = "experimental-get-amount-function"
ExperimentalScopedFunction FeatureFlag = "experimental-scoped-function"
ExperimentalOneofFeatureFlag FeatureFlag = "experimental-oneof"
ExperimentalAccountInterpolationFlag FeatureFlag = "experimental-account-interpolation"
ExperimentalMidScriptFunctionCall FeatureFlag = "experimental-mid-script-function-call"
Expand All @@ -17,6 +18,7 @@ var AllFlags []string = []string{
ExperimentalOverdraftFunctionFeatureFlag,
ExperimentalGetAssetFunctionFeatureFlag,
ExperimentalGetAmountFunctionFeatureFlag,
ExperimentalScopedFunction,
ExperimentalOneofFeatureFlag,
ExperimentalAccountInterpolationFlag,
ExperimentalMidScriptFunctionCall,
Expand Down
13 changes: 13 additions & 0 deletions internal/interpreter/__snapshots__/accounts_metadata_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

[TestPrettyPrintAccountsMetadata/without_scope_(no_Scope_column) - 1]
| Account | Name | Value  |
| alice | kyc | verified |
| bob | tier | gold |
---

[TestPrettyPrintAccountsMetadata/with_scope_(Scope_column_shown) - 1]
| Account | Scope | Name | Value  |
| alice | eu | kyc | pending |
| alice | | kyc | verified |
| bob | | tier | gold |
---
12 changes: 12 additions & 0 deletions internal/interpreter/__snapshots__/pretty_print_meta_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

[TestPrettyPrintMeta/renders_plain_values - 1]
| Name  | Value  |
| count | 42 |
| greeting | "hello" |
---

[TestPrettyPrintMeta/renders_a_scoped_account_value_in_its_source_form - 1]
| Name  | Value  |
| greeting | "hello" |
| owner | scoped(alice, "reserve") |
---
16 changes: 16 additions & 0 deletions internal/interpreter/__snapshots__/pretty_print_postings_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

[TestPrettyPrintPostings/no_scope,_no_color_(no_optional_columns) - 1]
| Source | Destination | Asset | Amount |
| world | alice | EUR/2 | 100 |
---

[TestPrettyPrintPostings/only_source_scope_(only_Source_Scope_column_shown) - 1]
| Source | Source Scope | Destination | Asset | Amount |
| src | x | dest | USD | 10 |
| world | | dest | USD | 5 |
---

[TestPrettyPrintPostings/both_scopes_(both_Scope_columns_shown) - 1]
| Source | Source Scope | Destination | Destination Scope | Asset | Amount |
| src | x | dest | y | USD | 10 |
---
62 changes: 27 additions & 35 deletions internal/interpreter/accounts_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,42 @@ import (
"github.com/formancehq/numscript/internal/utils"
)

type AccountMetadata = map[string]string
type AccountsMetadata map[string]AccountMetadata

func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata {
return utils.MapGetOrPutDefault(m, account, func() AccountMetadata {
return AccountMetadata{}
})
type AccountMetadataRow struct {
Account string `json:"account"`
Key string `json:"key"`
Value string `json:"value"`
Scope string `json:"scope,omitempty"`
}

func (m AccountsMetadata) DeepClone() AccountsMetadata {
cloned := make(AccountsMetadata)
for account, accountBalances := range m {
for asset, metadataValue := range accountBalances {
clonedAccountBalances := cloned.fetchAccountMetadata(account)
utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string {
return metadataValue
})
}
}
return cloned
}

func (m AccountsMetadata) Merge(update AccountsMetadata) {
for acc, accBalances := range update {
cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata {
return AccountMetadata{}
})

for curr, amt := range accBalances {
cachedAcc[curr] = amt
// AccountsMetadata is the external, serialized representation of account
// metadata. The runtime works with the in-memory InternalAccountsMetadata and
// converts to this at the boundaries (store queries, execution result).
type AccountsMetadata []AccountMetadataRow

// FirstDuplicate returns the first row whose (account, key, scope) key already
// appeared earlier in the list, if any. That triple is the identity of a
// metadata entry and the value is its content, so a repeated key is an
// ambiguous, malformed input.
func (rows AccountsMetadata) FirstDuplicate() (AccountMetadataRow, bool) {
seen := make(map[[3]string]struct{}, len(rows))
for _, row := range rows {
key := [3]string{row.Account, row.Key, row.Scope}
if _, ok := seen[key]; ok {
return row, true
}
seen[key] = struct{}{}
}
return AccountMetadataRow{}, false
}

func (m AccountsMetadata) PrettyPrint() string {
header := []string{"Account", "Name", "Value"}
// the Scope column is dropped automatically when no entry has a scope
header := []string{"Account", "Scope", "Name", "Value"}

var rows [][]string
for account, accMetadata := range m {
for name, value := range accMetadata {
row := []string{account, name, value}
rows = append(rows, row)
}
for _, row := range m {
rows = append(rows, []string{row.Account, row.Scope, row.Key, row.Value})

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.

🟡 [minor] Keep required metadata value column when values are empty

If account metadata entries all have an empty string value, CsvPrettyOmitEmptyCols drops the Value column because every cell is empty, even though only Scope is optional here. This makes pretty-printed metadata with explicit empty values indistinguishable from metadata without a value column, whereas the previous pretty printer always displayed the value field.

}

return utils.CsvPretty(header, rows, true)
return utils.CsvPrettyOmitEmptyCols(header, rows, true)
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.

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.

🟠 [major] CompareAccountsMetadata uses subset check instead of multiset comparison

slices.Contains only checks presence, not count. Two AccountsMetadata slices that differ only in duplicate rows can incorrectly compare as equal (e.g., expected [A, A] vs. actual [A, B] when lengths match), masking spec assertion failures where expected rows appear more times than actual rows.

Suggestion: Use a frequency-count map: increment counts for each row in slice a, decrement for each row in b, and return false if any count goes below zero.

}
Loading