From c07d256f0389e469224070719d50fd676b2f5f45 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 16:05:07 +0200 Subject: [PATCH 1/7] feat: scoped accounts Model accounts as a struct carrying an optional scope instead of encoding it into the address string. Scope flows structured through balances, postings, the funds queue, balance/metadata queries and the store. Account metadata is now row-based externally (AccountsMetadata as a list of AccountMetadataRow, mirroring Balances) and indexed by a {account, scope, key} map internally (InternalAccountsMetadata) for fast lookups. --- inputs.schema.json | 35 ++++-- internal/analysis/check.go | 31 ++++-- internal/cmd/test_init.go | 2 +- internal/flags/flags.go | 2 + internal/interpreter/accounts_metadata.go | 63 +++++------ internal/interpreter/append_scope.go | 11 ++ internal/interpreter/append_scope_test.go | 36 ++++++ internal/interpreter/args_parser_test.go | 6 +- internal/interpreter/balances.go | 13 ++- internal/interpreter/batch_balances_query.go | 5 +- internal/interpreter/function_exprs.go | 40 ++++++- internal/interpreter/function_statements.go | 7 +- internal/interpreter/funds_queue.go | 26 ++--- internal/interpreter/funds_queue_test.go | 104 +++++++++--------- internal/interpreter/get_involved_accounts.go | 2 +- .../interpreter/internal_accounts_metadata.go | 60 ++++++++++ internal/interpreter/internal_balances.go | 24 ++-- .../interpreter/internal_balances_test.go | 12 +- internal/interpreter/interpreter.go | 97 ++++++++-------- internal/interpreter/interpreter_error.go | 9 ++ internal/interpreter/interpreter_test.go | 18 ++- internal/interpreter/store.go | 18 ++- .../experimental/scoped-function/simple.num | 4 + .../scoped-function/simple.num.specs.json | 30 +++++ .../script-tests/metadata.num.specs.json | 12 +- .../script-tests/neg-max-dest.num.specs.json | 2 +- .../override-account-meta.num.specs.json | 20 ++-- .../set-account-meta.num.specs.json | 18 ++- internal/interpreter/value.go | 26 ++++- internal/interpreter/value_test.go | 2 +- internal/specs_format/index.go | 83 ++++++++++---- numscript.go | 2 +- numscript_test.go | 8 +- specs.schema.json | 47 ++++++-- 34 files changed, 585 insertions(+), 290 deletions(-) create mode 100644 internal/interpreter/append_scope.go create mode 100644 internal/interpreter/append_scope_test.go create mode 100644 internal/interpreter/internal_accounts_metadata.go create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num.specs.json diff --git a/inputs.schema.json b/inputs.schema.json index 0a07930d..122db931 100644 --- a/inputs.schema.json +++ b/inputs.schema.json @@ -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": { @@ -46,6 +46,10 @@ "color": { "type": "string", "pattern": "^[A-Z]*$" + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" } } }, @@ -60,13 +64,30 @@ }, "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_]*$" } } }, diff --git a/internal/analysis/check.go b/internal/analysis/check.go index e77fb661..d8610dab 100644 --- a/internal/analysis/check.go +++ b/internal/analysis/check.go @@ -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{ @@ -114,6 +120,17 @@ var Builtins = map[string]FnCallResolution{ }, }, }, + FnVarOriginScoped: VarOriginFnCallResolution{ + 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{ + { + Version: parser.NewVersionInterpreter(0, 0, 25), + FeatureFlag: flags.ExperimentalGetAmountFunctionFeatureFlag, + }, + }, + }, } type Diagnostic struct { diff --git a/internal/cmd/test_init.go b/internal/cmd/test_init.go index 57c9f27c..ec5159ac 100644 --- a/internal/cmd/test_init.go +++ b/internal/cmd/test_init.go @@ -117,7 +117,7 @@ func makeSpecsFile( DefaultBalance: defaultBalance, StaticStore: interpreter.StaticStore{ Balances: interpreter.Balances{}, - Meta: make(interpreter.AccountsMetadata), + Meta: interpreter.AccountsMetadata{}, }, } diff --git a/internal/flags/flags.go b/internal/flags/flags.go index efbe6516..0b7e5319 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -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" @@ -17,6 +18,7 @@ var AllFlags []string = []string{ ExperimentalOverdraftFunctionFeatureFlag, ExperimentalGetAssetFunctionFeatureFlag, ExperimentalGetAmountFunctionFeatureFlag, + ExperimentalScopedFunction, ExperimentalOneofFeatureFlag, ExperimentalAccountInterpolationFlag, ExperimentalMidScriptFunctionCall, diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index b3b5ed95..1232dce6 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -1,53 +1,44 @@ package interpreter import ( + "slices" + "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{} - }) -} - -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 +type AccountMetadataRow struct { + Account string `json:"account"` + Key string `json:"key"` + Value string `json:"value"` + Scope string `json:"scope,omitempty"` } -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 func (m AccountsMetadata) PrettyPrint() string { header := []string{"Account", "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.Key, row.Value}) } return utils.CsvPretty(header, rows, true) } + +// CompareAccountsMetadata reports whether two metadata lists hold the same set +// of rows, ignoring order. +func CompareAccountsMetadata(a AccountsMetadata, b AccountsMetadata) bool { + if len(a) != len(b) { + return false + } + for _, row := range a { + if !slices.Contains(b, row) { + return false + } + } + return true +} diff --git a/internal/interpreter/append_scope.go b/internal/interpreter/append_scope.go new file mode 100644 index 00000000..4e0e1dbf --- /dev/null +++ b/internal/interpreter/append_scope.go @@ -0,0 +1,11 @@ +package interpreter + +import ( + "regexp" +) + +var scopeRegex = regexp.MustCompile(`^[a-z0-9_]*$`) + +func validateScope(scope string) bool { + return scopeRegex.MatchString(scope) +} diff --git a/internal/interpreter/append_scope_test.go b/internal/interpreter/append_scope_test.go new file mode 100644 index 00000000..0cf5f02c --- /dev/null +++ b/internal/interpreter/append_scope_test.go @@ -0,0 +1,36 @@ +package interpreter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccountAddressString(t *testing.T) { + t.Run("no scope", func(t *testing.T) { + require.Equal(t, AccountAddress{Name: "acc"}.String(), "acc") + }) + + t.Run("with scope", func(t *testing.T) { + require.Equal(t, AccountAddress{Name: "acc", Scope: "xyz"}.String(), "acc/xyz") + }) +} + +func TestScopeValidation(t *testing.T) { + t.Run("valid scopes", func(t *testing.T) { + require.True(t, validateScope("")) + require.True(t, validateScope("myscope")) + require.True(t, validateScope("x")) + require.True(t, validateScope("x1")) + require.True(t, validateScope("my_scope_with_underscores")) + }) + + t.Run("invalid scopes", func(t *testing.T) { + require.False(t, validateScope("!")) + require.False(t, validateScope("$")) + require.False(t, validateScope("UPPERCASE")) + require.False(t, validateScope("dash-case")) + require.False(t, validateScope("colons:within")) + }) + +} diff --git a/internal/interpreter/args_parser_test.go b/internal/interpreter/args_parser_test.go index 9e6d03a7..dc306df2 100644 --- a/internal/interpreter/args_parser_test.go +++ b/internal/interpreter/args_parser_test.go @@ -36,7 +36,7 @@ func TestParseValid(t *testing.T) { p := NewArgsParser([]Value{ NewMonetaryInt(42), - AccountAddress("user:001"), + AccountAddress{Name: "user:001"}, }) a1 := parseArg(p, parser.Range{}, expectNumber) a2 := parseArg(p, parser.Range{}, expectAccount) @@ -48,7 +48,7 @@ func TestParseValid(t *testing.T) { require.NotNil(t, a2, "a2 should not be nil") require.Equal(t, a1, MonetaryInt(*big.NewInt(42))) - require.Equal(t, a2, AccountAddress("user:001")) + require.Equal(t, a2, AccountAddress{Name: "user:001"}) } func TestParseBadType(t *testing.T) { @@ -56,7 +56,7 @@ func TestParseBadType(t *testing.T) { p := NewArgsParser([]Value{ NewMonetaryInt(42), - AccountAddress("user:001"), + AccountAddress{Name: "user:001"}, }) parseArg(p, parser.Range{}, expectMonetary) parseArg(p, parser.Range{}, expectAccount) diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index c99cbd81..94dab1a0 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -12,6 +12,7 @@ type BalanceRow struct { Asset string `json:"asset"` Amount *big.Int `json:"amount"` Color string `json:"color,omitempty"` + Scope string `json:"scope,omitempty"` } type Balances []BalanceRow @@ -20,9 +21,9 @@ type Balances []BalanceRow // entry and the amount is its value, so a repeated key is an ambiguous, // malformed input. func (rows Balances) FirstDuplicate() (BalanceRow, bool) { - seen := make(map[[3]string]struct{}, len(rows)) + seen := make(map[[4]string]struct{}, len(rows)) for _, row := range rows { - key := [3]string{row.Account, row.Asset, row.Color} + key := [4]string{row.Account, row.Asset, row.Color, row.Scope} if _, ok := seen[key]; ok { return row, true } @@ -59,10 +60,10 @@ func (rows Balances) PrettyPrint() string { return utils.CsvPretty(header, tableRows, true) } -// findRow returns the amount for a given (account, asset, color), if present. -func findRow(rows Balances, account, asset, color string) (*big.Int, bool) { +// findRow returns the amount for a given (account, asset, color, scope), if present. +func findRow(rows Balances, account, asset, color, scope string) (*big.Int, bool) { for i := range rows { - if rows[i].Account == account && rows[i].Asset == asset && rows[i].Color == color { + if rows[i].Account == account && rows[i].Asset == asset && rows[i].Color == color && rows[i].Scope == scope { return rows[i].Amount, true } } @@ -90,7 +91,7 @@ func CompareBalances(b1 Balances, b2 Balances) bool { // Returns whether the first value is a subset of the second one. func CompareBalancesIncluding(b1 Balances, b2 Balances) bool { for _, entry := range b1 { - amount2, ok := findRow(b2, entry.Account, entry.Asset, entry.Color) + amount2, ok := findRow(b2, entry.Account, entry.Asset, entry.Color, entry.Scope) if !ok || !amountsEqual(entry.Amount, amount2) { return false } diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index 21883ee5..15cb2070 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -51,14 +51,15 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen } func (st *programState) batchQuery(account AccountAddress, asset Asset, color String) { - if account == "world" { + if account.Name == "world" { return } item := BalanceQueryItem{ - Account: string(account), + Account: account.Name, Asset: string(asset), Color: string(color), + Scope: account.Scope, } if !slices.Contains(st.CurrentBalanceQuery, item) { diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 84bd6c1a..0c98ac03 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -62,19 +62,18 @@ func meta( } meta, fetchMetaErr := s.Store.GetAccountsMetadata(s.ctx, MetadataQuery{ - string(account): []string{string(key)}, + {Account: account.Name, Scope: account.Scope, Keys: []string{string(key)}}, }) if fetchMetaErr != nil { return "", QueryMetadataError{WrappedError: fetchMetaErr} } - s.CachedAccountsMeta = meta + s.CachedAccountsMeta = FromAccountsMetadataRows(meta) // body - accountMeta := s.CachedAccountsMeta[string(account)] - value, ok := accountMeta[string(key)] + value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, string(key)) if !ok { - return "", MetadataNotFound{Account: string(account), Key: string(key), Range: rng} + return "", MetadataNotFound{Account: account.String(), Key: string(key), Range: rng} } return value, nil @@ -104,7 +103,7 @@ func balance( if balance.Cmp(big.NewInt(0)) == -1 { return Monetary{}, NegativeBalanceError{ - Account: string(account), + Account: account.String(), Amount: *balance, } } @@ -157,3 +156,32 @@ func getAmount( return mon.Amount, nil } + +func scoped( + s *programState, + r parser.Range, + args []Value, +) (Value, InterpreterError) { + err := s.checkFeatureFlag(flags.ExperimentalScopedFunction) + if err != nil { + return nil, err + } + + p := NewArgsParser(args) + acc := parseArg(p, r, expectAccount) + scope := parseArg(p, r, expectString) + err = p.parse() + + scopeStr := string(scope) + + // Precondition: scope is valid idenfitier + if err != nil { + return nil, err + } + + if !validateScope(scopeStr) { + return nil, InvalidScope{Scope: scopeStr} + } + + return AccountAddress{Name: acc.Name, Scope: scopeStr}, nil +} diff --git a/internal/interpreter/function_statements.go b/internal/interpreter/function_statements.go index 334f1d7f..2acfbbdb 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -2,7 +2,6 @@ package interpreter import ( "github.com/formancehq/numscript/internal/parser" - "github.com/formancehq/numscript/internal/utils" ) func setTxMeta(st *programState, r parser.Range, args []Value) InterpreterError { @@ -28,11 +27,7 @@ func setAccountMeta(st *programState, r parser.Range, args []Value) InterpreterE return err } - accountMeta := utils.MapGetOrPutDefault(st.SetAccountsMeta, string(account), func() AccountMetadata { - return AccountMetadata{} - }) - - accountMeta[string(key)] = meta.String() + st.SetAccountsMeta.Set(account.Name, account.Scope, string(key), meta.String()) return nil } diff --git a/internal/interpreter/funds_queue.go b/internal/interpreter/funds_queue.go index 007c791a..d4ad6dd9 100644 --- a/internal/interpreter/funds_queue.go +++ b/internal/interpreter/funds_queue.go @@ -5,9 +5,9 @@ import ( ) type Sender struct { - Name string - Amount *big.Int - Color string + Account AccountAddress + Amount *big.Int + Color string } type queue[T any] struct { @@ -76,15 +76,15 @@ func (s *fundsQueue) compactTop() { continue } - if first.Name != second.Name || first.Color != second.Color { + if first.Account != second.Account || first.Color != second.Color { return } s.senders = &queue[Sender]{ Head: Sender{ - Name: first.Name, - Color: first.Color, - Amount: new(big.Int).Add(first.Amount, second.Amount), + Account: first.Account, + Color: first.Color, + Amount: new(big.Int).Add(first.Amount, second.Amount), }, Tail: s.senders.Tail.Tail, } @@ -152,9 +152,9 @@ func (s *fundsQueue) Pull(requiredAmount *big.Int, color *string) []Sender { case 1: // more than enough s.senders = &queue[Sender]{ Head: Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Sub(available.Amount, requiredAmount), + Account: available.Account, + Color: available.Color, + Amount: new(big.Int).Sub(available.Amount, requiredAmount), }, Tail: s.senders, } @@ -162,9 +162,9 @@ func (s *fundsQueue) Pull(requiredAmount *big.Int, color *string) []Sender { case 0: // exactly the same out = append(out, Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Set(requiredAmount), + Account: available.Account, + Color: available.Color, + Amount: new(big.Int).Set(requiredAmount), }) return out } diff --git a/internal/interpreter/funds_queue_test.go b/internal/interpreter/funds_queue_test.go index 87672be9..77e24c96 100644 --- a/internal/interpreter/funds_queue_test.go +++ b/internal/interpreter/funds_queue_test.go @@ -9,49 +9,49 @@ import ( func TestEnoughBalance(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(100)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(100)}, }) out := queue.PullAnything(big.NewInt(2)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, }, out) } func TestPush(t *testing.T) { queue := newFundsQueue(nil) - queue.Push(Sender{Name: "acc", Amount: big.NewInt(100)}) + queue.Push(Sender{Account: AccountAddress{Name: "acc"}, Amount: big.NewInt(100)}) out := queue.PullUncolored(big.NewInt(20)) require.Equal(t, []Sender{ - {Name: "acc", Amount: big.NewInt(20)}, + {Account: AccountAddress{Name: "acc"}, Amount: big.NewInt(20)}, }, out) } func TestSimple(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(10)}, }) out := queue.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(3)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(3)}, }, out) out = queue.PullAnything(big.NewInt(7)) require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(7)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(7)}, }, out) } func TestPullZero(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(10)}, }) out := queue.PullAnything(big.NewInt(0)) @@ -60,123 +60,123 @@ func TestPullZero(t *testing.T) { func TestCompactFunds(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, }) out := queue.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, }, out) } func TestCompactFunds3Times(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(3)}, - {Name: "s1", Amount: big.NewInt(1)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(3)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(1)}, }) out := queue.PullAnything(big.NewInt(6)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(6)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(6)}, }, out) } func TestCompactFundsWithEmptySender(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(0)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(0)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, }) out := queue.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, }, out) } func TestMissingFunds(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, }) out := queue.PullAnything(big.NewInt(300)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, }, out) } func TestNoZeroLeftovers(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(10)}, - {Name: "s2", Amount: big.NewInt(15)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(15)}, }) queue.PullAnything(big.NewInt(10)) out := queue.PullAnything(big.NewInt(15)) require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(15)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(15)}, }, out) } func TestReconcileColoredManyDestPerSender(t *testing.T) { queue := newFundsQueue([]Sender{ - {"src", big.NewInt(10), "X"}, + {AccountAddress{Name: "src"}, big.NewInt(10), "X"}, }) out := queue.PullColored(big.NewInt(5), "X") require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, + {Account: AccountAddress{Name: "src"}, Amount: big.NewInt(5), Color: "X"}, }, out) out = queue.PullColored(big.NewInt(5), "X") require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, + {Account: AccountAddress{Name: "src"}, Amount: big.NewInt(5), Color: "X"}, }, out) } func TestPullColored(t *testing.T) { queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - {Name: "s2", Amount: big.NewInt(1), Color: "red"}, - {Name: "s3", Amount: big.NewInt(10)}, - {Name: "s4", Amount: big.NewInt(2), Color: "red"}, - {Name: "s5", Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress{Name: "s3"}, Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(2), Color: "red"}, + {Account: AccountAddress{Name: "s5"}, Amount: big.NewInt(5)}, }) out := queue.PullColored(big.NewInt(2), "red") require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(1), Color: "red"}, - {Name: "s4", Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(1), Color: "red"}, }, out) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - {Name: "s3", Amount: big.NewInt(10)}, - {Name: "s4", Amount: big.NewInt(1), Color: "red"}, - {Name: "s5", Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(5)}, + {Account: AccountAddress{Name: "s3"}, Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s4"}, Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress{Name: "s5"}, Amount: big.NewInt(5)}, }, queue.PullAll()) } func TestPullColoredComplex(t *testing.T) { queue := newFundsQueue([]Sender{ - {"s1", big.NewInt(1), "c1"}, - {"s2", big.NewInt(1), "c2"}, + {AccountAddress{Name: "s1"}, big.NewInt(1), "c1"}, + {AccountAddress{Name: "s2"}, big.NewInt(1), "c2"}, }) out := queue.PullColored(big.NewInt(1), "c2") require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(1), Color: "c2"}, + {Account: AccountAddress{Name: "s2"}, Amount: big.NewInt(1), Color: "c2"}, }, out) } func TestClone(t *testing.T) { fq := newFundsQueue([]Sender{ - {"s1", big.NewInt(10), ""}, + {AccountAddress{Name: "s1"}, big.NewInt(10), ""}, }) cloned := fq.Clone() @@ -184,7 +184,7 @@ func TestClone(t *testing.T) { fq.PullAll() require.Equal(t, []Sender{ - {"s1", big.NewInt(10), ""}, + {AccountAddress{Name: "s1"}, big.NewInt(10), ""}, }, cloned.PullAll()) } @@ -193,20 +193,20 @@ func TestCompactFundsAndPush(t *testing.T) { noCol := "" queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(2)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(10)}, }) queue.Pull(big.NewInt(1), &noCol) queue.Push(Sender{ - Name: "pushed", - Amount: big.NewInt(42), + Account: AccountAddress{Name: "pushed"}, + Amount: big.NewInt(42), }) out := queue.PullAll() require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(11)}, - {Name: "pushed", Amount: big.NewInt(42)}, + {Account: AccountAddress{Name: "s1"}, Amount: big.NewInt(11)}, + {Account: AccountAddress{Name: "pushed"}, Amount: big.NewInt(42)}, }, out) } diff --git a/internal/interpreter/get_involved_accounts.go b/internal/interpreter/get_involved_accounts.go index d0aa392e..31c3ee2d 100644 --- a/internal/interpreter/get_involved_accounts.go +++ b/internal/interpreter/get_involved_accounts.go @@ -114,7 +114,7 @@ func parseVarToInvolvedAccount(type_ string, rawValue string, r parser.Range) (I case String: return StringLiteral{String: string(val)}, nil case AccountAddress: - return AccountLiteral{Account: string(val)}, nil + return AccountLiteral{Account: val.String()}, nil case Asset: return AssetLiteral{Asset: string(val)}, nil diff --git a/internal/interpreter/internal_accounts_metadata.go b/internal/interpreter/internal_accounts_metadata.go new file mode 100644 index 00000000..ee91705a --- /dev/null +++ b/internal/interpreter/internal_accounts_metadata.go @@ -0,0 +1,60 @@ +package interpreter + +import "sort" + +// metadataKey identifies a single account-metadata entry in the in-memory cache. +type metadataKey struct { + Account string + Scope string + Key string +} + +// InternalAccountsMetadata is the in-memory representation of account metadata, +// keyed for O(1) lookups. Whereas the external representation +// (interpreter.AccountsMetadata) is the user-facing, serialized contract, this +// one is used internally by the runtime and may change over time. +type InternalAccountsMetadata map[metadataKey]string + +// FromAccountsMetadataRows builds the in-memory cache from the external rows. +func FromAccountsMetadataRows(rows AccountsMetadata) InternalAccountsMetadata { + out := make(InternalAccountsMetadata, len(rows)) + for _, row := range rows { + out[metadataKey{Account: row.Account, Scope: row.Scope, Key: row.Key}] = row.Value + } + return out +} + +// Get returns the value for a given (account, scope, key), if present. +func (m InternalAccountsMetadata) Get(account, scope, key string) (string, bool) { + value, ok := m[metadataKey{Account: account, Scope: scope, Key: key}] + return value, ok +} + +// Set assigns the value for a given (account, scope, key). +func (m InternalAccountsMetadata) Set(account, scope, key, value string) { + m[metadataKey{Account: account, Scope: scope, Key: key}] = value +} + +// toRows flattens the cache back into the external representation, sorted by +// (account, scope, key) for deterministic output. +func (m InternalAccountsMetadata) toRows() AccountsMetadata { + rows := make(AccountsMetadata, 0, len(m)) + for k, value := range m { + rows = append(rows, AccountMetadataRow{ + Account: k.Account, + Scope: k.Scope, + Key: k.Key, + Value: value, + }) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Account != rows[j].Account { + return rows[i].Account < rows[j].Account + } + if rows[i].Scope != rows[j].Scope { + return rows[i].Scope < rows[j].Scope + } + return rows[i].Key < rows[j].Key + }) + return rows +} diff --git a/internal/interpreter/internal_balances.go b/internal/interpreter/internal_balances.go index ca75e5fc..6e45929b 100644 --- a/internal/interpreter/internal_balances.go +++ b/internal/interpreter/internal_balances.go @@ -6,7 +6,7 @@ import "math/big" // Whereas the external representation (interpreter.Balances) is user-facing and be a stable contract, // (for example, allowing more columns if we need an higher level of fungibility), this one is used internally by the runtime, and // could change over time, for example to add more indexes for faster lookups -type InternalBalances map[string][]AccountBalance +type InternalBalances map[AccountAddress][]AccountBalance // A single balance entry for an account: an (asset, color) pair and its amount. type AccountBalance struct { @@ -22,7 +22,10 @@ func FromBalancesRows(b Balances) InternalBalances { if row.Amount != nil { amount.Set(row.Amount) } - out[row.Account] = append(out[row.Account], AccountBalance{ + // the cache is keyed by the (account, scope) pair; the scope is part of the + // key, so entries don't repeat it as a field + key := AccountAddress{Name: row.Account, Scope: row.Scope} + out[key] = append(out[key], AccountBalance{ Asset: row.Asset, Color: row.Color, Amount: amount, @@ -50,16 +53,15 @@ func (b InternalBalances) DeepClone() InternalBalances { // Get the (account, asset, color) balance from the cache. // If it is not present, it writes a zero balance in it and returns it. func (b InternalBalances) fetchBalance(account AccountAddress, asset Asset, color String) *big.Int { - acc := string(account) - for i := range b[acc] { - entry := &b[acc][i] + for i := range b[account] { + entry := &b[account][i] if entry.Asset == string(asset) && entry.Color == string(color) { return entry.Amount } } amount := new(big.Int) - b[acc] = append(b[acc], AccountBalance{ + b[account] = append(b[account], AccountBalance{ Asset: string(asset), Color: string(color), Amount: amount, @@ -68,7 +70,7 @@ func (b InternalBalances) fetchBalance(account AccountAddress, asset Asset, colo } // Set assigns amount to the (account, asset, color) balance. -func (b InternalBalances) Set(account string, asset string, color string, amount *big.Int) { +func (b InternalBalances) Set(account AccountAddress, asset string, color string, amount *big.Int) { for i := range b[account] { if b[account][i].Asset == asset && b[account][i].Color == color { b[account][i].Amount = amount @@ -82,7 +84,7 @@ func (b InternalBalances) Set(account string, asset string, color string, amount }) } -func (b InternalBalances) has(account string, asset string, color string) bool { +func (b InternalBalances) has(account AccountAddress, asset string, color string) bool { for _, entry := range b[account] { if entry.Asset == asset && entry.Color == color { return true @@ -96,7 +98,8 @@ func (b InternalBalances) has(account string, asset string, color string) bool { func (b InternalBalances) filterQuery(q BalanceQuery) BalanceQuery { filteredQuery := BalanceQuery{} for _, item := range q { - if !b.has(item.Account, item.Asset, item.Color) { + key := AccountAddress{Name: item.Account, Scope: item.Scope} + if !b.has(key, item.Asset, item.Color) { filteredQuery = append(filteredQuery, item) } } @@ -106,6 +109,7 @@ func (b InternalBalances) filterQuery(q BalanceQuery) BalanceQuery { // Merge the queried balance rows into the cache func (b InternalBalances) Merge(update []BalanceRow) { for _, row := range update { - b.Set(row.Account, row.Asset, row.Color, row.Amount) + key := AccountAddress{Name: row.Account, Scope: row.Scope} + b.Set(key, row.Asset, row.Color, row.Amount) } } diff --git a/internal/interpreter/internal_balances_test.go b/internal/interpreter/internal_balances_test.go index 662a2c1d..e8e38735 100644 --- a/internal/interpreter/internal_balances_test.go +++ b/internal/interpreter/internal_balances_test.go @@ -9,11 +9,11 @@ import ( func TestFilterQuery(t *testing.T) { fullBalance := InternalBalances{ - "alice": { + AccountAddress{Name: "alice"}: { {Asset: "EUR/2", Amount: big.NewInt(1)}, {Asset: "USD/2", Amount: big.NewInt(2)}, }, - "bob": { + AccountAddress{Name: "bob"}: { {Asset: "BTC", Amount: big.NewInt(3)}, }, } @@ -54,11 +54,11 @@ func TestBalancesFirstDuplicate(t *testing.T) { func TestCloneBalances(t *testing.T) { fullBalance := InternalBalances{ - "alice": { + AccountAddress{Name: "alice"}: { {Asset: "EUR/2", Amount: big.NewInt(1)}, {Asset: "USD/2", Amount: big.NewInt(2)}, }, - "bob": { + AccountAddress{Name: "bob"}: { {Asset: "BTC", Amount: big.NewInt(3)}, }, } @@ -66,7 +66,7 @@ func TestCloneBalances(t *testing.T) { cloned := fullBalance.DeepClone() // USD/2 is the second entry for alice (index 1). - fullBalance["alice"][1].Amount.Set(big.NewInt(42)) + fullBalance[AccountAddress{Name: "alice"}][1].Amount.Set(big.NewInt(42)) - require.Equal(t, big.NewInt(2), cloned["alice"][1].Amount) + require.Equal(t, big.NewInt(2), cloned[AccountAddress{Name: "alice"}][1].Amount) } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index bd154144..8f108468 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -24,11 +24,28 @@ type InterpreterError interface { type Metadata = map[string]Value type Posting struct { - Source string `json:"source"` - Destination string `json:"destination"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Color string `json:"color,omitempty"` + Source string `json:"source"` + SourceScope string `json:"sourceScope,omitempty"` + Destination string `json:"destination"` + DestinationScope string `json:"destinationScope,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Color string `json:"color,omitempty"` +} + +// newPosting builds a Posting from the source and destination addresses, +// exposing each address's account and scope as the separate fields the posting +// contract uses. +func newPosting(source AccountAddress, destination AccountAddress, amount *big.Int, asset string, color string) Posting { + return Posting{ + Source: source.Name, + SourceScope: source.Scope, + Destination: destination.Name, + DestinationScope: destination.Scope, + Amount: amount, + Asset: asset, + Color: color, + } } type ExecutionResult struct { @@ -146,6 +163,8 @@ func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, return getAsset(s, fnCall.Range, args) case analysis.FnVarOriginGetAmount: return getAmount(s, fnCall.Range, args) + case analysis.FnVarOriginScoped: + return scoped(s, fnCall.Range, args) default: return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} @@ -179,7 +198,7 @@ func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[ const accountSegmentRegex = "[a-zA-Z0-9_-]+" -var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") +var accountNameRegex = regexp.MustCompile("^@?" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*(?:/[a-z_]+)?$") // https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go func checkAccountName(addr string) bool { @@ -223,9 +242,9 @@ func RunProgram( st := programState{ ParsedVars: make(map[string]Value), TxMeta: make(map[string]Value), - CachedAccountsMeta: AccountsMetadata{}, + CachedAccountsMeta: InternalAccountsMetadata{}, CachedBalances: InternalBalances{}, - SetAccountsMeta: AccountsMetadata{}, + SetAccountsMeta: InternalAccountsMetadata{}, Store: store, Postings: make([]Posting, 0), fundsQueue: newFundsQueue(nil), @@ -289,7 +308,7 @@ func RunProgram( res := &ExecutionResult{ Postings: st.Postings, Metadata: st.TxMeta, - AccountsMetadata: st.SetAccountsMeta, + AccountsMetadata: st.SetAccountsMeta.toRows(), } return res, nil } @@ -311,9 +330,9 @@ type programState struct { Store Store - SetAccountsMeta AccountsMetadata + SetAccountsMeta InternalAccountsMetadata - CachedAccountsMeta AccountsMetadata + CachedAccountsMeta InternalAccountsMetadata CachedBalances InternalBalances CurrentBalanceQuery BalanceQuery @@ -332,9 +351,9 @@ func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, co balance.Sub(balance, &monetaryBi) st.fundsQueue.Push(Sender{ - Name: string(name), - Amount: &monetaryBi, - Color: string(color), + Account: name, + Amount: &monetaryBi, + Color: string(color), }) } @@ -359,16 +378,10 @@ func (st *programState) forcePushPostingUncolored( destBalance := st.CachedBalances.fetchBalance(destination, asset, "") destBalance.Add(destBalance, &amtBi) - st.Postings = append(st.Postings, Posting{ - Source: string(source), - Destination: string(destination), - Amount: new(big.Int).Set(&amtBi), - Color: "", - Asset: string(asset), - }) + st.Postings = append(st.Postings, newPosting(source, destination, new(big.Int).Set(&amtBi), string(asset), "")) } -func (st *programState) pushReceiver(name string, monetary *big.Int) { +func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) { if monetary.Cmp(big.NewInt(0)) == 0 { return } @@ -376,26 +389,20 @@ func (st *programState) pushReceiver(name string, monetary *big.Int) { senders := st.fundsQueue.PullAnything(monetary) for _, sender := range senders { - postings := Posting{ - Source: sender.Name, - Destination: name, - Asset: string(st.CurrentAsset), - Amount: sender.Amount, - Color: sender.Color, - } + posting := newPosting(sender.Account, name, sender.Amount, string(st.CurrentAsset), sender.Color) - if name == KEPT_ADDR { + if name.Name == KEPT_ADDR { // If funds are kept, give them back to senders - srcBalance := st.CachedBalances.fetchBalance(AccountAddress(postings.Source), st.CurrentAsset, String(sender.Color)) - srcBalance.Add(srcBalance, postings.Amount) + srcBalance := st.CachedBalances.fetchBalance(sender.Account, st.CurrentAsset, String(sender.Color)) + srcBalance.Add(srcBalance, posting.Amount) continue } - destBalance := st.CachedBalances.fetchBalance(AccountAddress(postings.Destination), st.CurrentAsset, String(sender.Color)) - destBalance.Add(destBalance, postings.Amount) + destBalance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, String(sender.Color)) + destBalance.Add(destBalance, posting.Amount) - st.Postings = append(st.Postings, postings) + st.Postings = append(st.Postings, posting) } } @@ -517,9 +524,9 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd return nil, err } - if account == "world" || overdraft == nil { + if account.Name == "world" || overdraft == nil { return nil, InvalidUnboundedInSendAll{ - Name: string(account), + Name: account.String(), } } @@ -572,7 +579,7 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError } baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() - acc, ok := s.CachedBalances[string(account)] + acc, ok := s.CachedBalances[account] if !ok { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } @@ -673,7 +680,7 @@ func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amo if err != nil { return nil, err } - if account == "world" { + if account.Name == "world" { overdraft = nil } @@ -735,7 +742,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() - acc, ok := s.CachedBalances[string(account)] + acc, ok := s.CachedBalances[account] if !ok { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } @@ -858,7 +865,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I if err != nil { return err } - s.pushReceiver(string(account), amount) + s.pushReceiver(account, amount) return nil case *parser.DestinationAllotment: @@ -959,7 +966,7 @@ const KEPT_ADDR = "" func (s *programState) sendToKeptOrDest(keptOrDest parser.KeptOrDestination, amount *big.Int) InterpreterError { switch destinationTarget := keptOrDest.(type) { case *parser.DestinationKept: - s.pushReceiver(KEPT_ADDR, amount) + s.pushReceiver(AccountAddress{Name: KEPT_ADDR}, amount) return nil case *parser.DestinationTo: @@ -1163,11 +1170,13 @@ func PrettyPrintPostings(postings []Posting) string { var rows [][]string for _, posting := range postings { + source := AccountAddress{Name: posting.Source, Scope: posting.SourceScope}.String() + destination := AccountAddress{Name: posting.Destination, Scope: posting.DestinationScope}.String() var row []string if hasColor { - row = []string{posting.Source, posting.Destination, posting.Asset, posting.Color, posting.Amount.String()} + row = []string{source, destination, posting.Asset, posting.Color, posting.Amount.String()} } else { - row = []string{posting.Source, posting.Destination, posting.Asset, posting.Amount.String()} + row = []string{source, destination, posting.Asset, posting.Amount.String()} } rows = append(rows, row) } diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 3d8527c3..7f43be16 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -286,3 +286,12 @@ type InvalidOperatorErr struct { func (e InvalidOperatorErr) Error() string { return fmt.Sprintf("Invalid operator: %s", e.Operator) } + +type InvalidScope struct { + parser.Range + Scope string +} + +func (e InvalidScope) Error() string { + return fmt.Sprintf("Invalid scope syntax: %s", e.Scope) +} diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index 9d3a2dcf..be95eb54 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -264,9 +264,7 @@ func TestBadAssetInMeta(t *testing.T) { ) `) tc.meta = interpreter.AccountsMetadata{ - "acc": interpreter.AccountMetadata{ - "my-asset": "Aa", - }, + {Account: "acc", Key: "my-asset", Value: "Aa"}, } tc.expected = CaseResult{ @@ -526,7 +524,7 @@ func TestErrors(t *testing.T) { tc.expected = CaseResult{ Error: interpreter.TypeError{ Expected: "monetary", - Value: interpreter.AccountAddress("bad:type"), + Value: interpreter.AccountAddress{Name: "bad:type"}, }, } test(t, tc) @@ -666,7 +664,7 @@ func TestErrors(t *testing.T) { tc.expected = CaseResult{ Error: interpreter.TypeError{ Expected: "string", - Value: interpreter.AccountAddress("key_wrong_type"), + Value: interpreter.AccountAddress{Name: "key_wrong_type"}, }, } test(t, tc) @@ -725,13 +723,13 @@ func TestTrackBalancesTricky(t *testing.T) { `) tc.expected = CaseResult{ Postings: []interpreter.Posting{ - interpreter.Posting{ + { Source: "world", Destination: "src", Amount: big.NewInt(10), Asset: "GEM", }, - interpreter.Posting{ + { Source: "src", Destination: "dest", Amount: big.NewInt(15), @@ -792,7 +790,7 @@ func TestSaveFromAccount(t *testing.T) { t.Run("negative amount", func(t *testing.T) { script := ` - + save [USD -100] from @A` tc := NewTestCase() tc.compile(t, script) @@ -977,9 +975,7 @@ func TestInvalidNestedMetaCall(t *testing.T) { tc := NewTestCase() tc.meta = interpreter.AccountsMetadata{ - "acc": { - "k": "42", - }, + {Account: "acc", Key: "k", Value: "42"}, } tc.compile(t, script) diff --git a/internal/interpreter/store.go b/internal/interpreter/store.go index a89580ef..bbe081f2 100644 --- a/internal/interpreter/store.go +++ b/internal/interpreter/store.go @@ -11,12 +11,18 @@ type BalanceQueryItem struct { Account string Asset string Color string + Scope string +} + +type MetadataQueryItem = struct { + Account string + Scope string + Keys []string } type BalanceQuery []BalanceQueryItem -// For each account, list of the needed keys -type MetadataQuery map[string][]string +type MetadataQuery []MetadataQueryItem type Store interface { // Returns the batched balances for a given batched query. @@ -40,7 +46,7 @@ func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, e if isCatchAll { // return every stored asset (of the queried color) under the base asset for _, row := range s.Balances { - if row.Account != item.Account || row.Color != item.Color { + if row.Account != item.Account || row.Color != item.Color || row.Scope != item.Scope { continue } if row.Asset == baseAsset || strings.HasPrefix(row.Asset, baseAsset+"/") { @@ -48,6 +54,7 @@ func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, e Account: row.Account, Asset: row.Asset, Color: row.Color, + Scope: row.Scope, Amount: new(big.Int).Set(row.Amount), }) } @@ -55,10 +62,10 @@ func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, e continue } - // materialize the queried (account, asset, color), defaulting to a zero balance + // materialize the queried (account, asset, color, scope), defaulting to a zero balance amount := new(big.Int) for _, row := range s.Balances { - if row.Account == item.Account && row.Asset == item.Asset && row.Color == item.Color { + if row.Account == item.Account && row.Asset == item.Asset && row.Color == item.Color && row.Scope == item.Scope { amount.Set(row.Amount) break } @@ -67,6 +74,7 @@ func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, e Account: item.Account, Asset: item.Asset, Color: item.Color, + Scope: item.Scope, Amount: amount, }) } diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num new file mode 100644 index 00000000..6494bc30 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num @@ -0,0 +1,4 @@ +send [USD 10] ( + source = scoped(@src, "x") + destination = scoped(@dest, "y") +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num.specs.json new file mode 100644 index 00000000..869fbbcb --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num.specs.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "balances": [ + { + "account": "src", + "asset": "USD", + "amount": 999, + "scope": "x" + } + ], + "featureFlags": [ + "experimental-mid-script-function-call", + "experimental-scoped-function" + ], + "testCases": [ + { + "it": "scopes the accounts", + "expect.postings": [ + { + "source": "src", + "sourceScope": "x", + "destination": "dest", + "destinationScope": "y", + "amount": 10, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/metadata.num.specs.json b/internal/interpreter/testdata/script-tests/metadata.num.specs.json index c1cc2d83..ad893b9b 100644 --- a/internal/interpreter/testdata/script-tests/metadata.num.specs.json +++ b/internal/interpreter/testdata/script-tests/metadata.num.specs.json @@ -17,14 +17,10 @@ "variables": { "sale": "sales:042" }, - "metadata": { - "sales:042": { - "seller": "users:053" - }, - "users:053": { - "commission": "12.5%" - } - }, + "metadata": [ + { "account": "sales:042", "key": "seller", "value": "users:053" }, + { "account": "users:053", "key": "commission", "value": "12.5%" } + ], "expect.postings": [ { "source": "sales:042", diff --git a/internal/interpreter/testdata/script-tests/neg-max-dest.num.specs.json b/internal/interpreter/testdata/script-tests/neg-max-dest.num.specs.json index 643e5592..edfe0306 100644 --- a/internal/interpreter/testdata/script-tests/neg-max-dest.num.specs.json +++ b/internal/interpreter/testdata/script-tests/neg-max-dest.num.specs.json @@ -10,7 +10,7 @@ } ], "variables": {}, - "metadata": {}, + "metadata": [], "expect.postings": [ { "source": "memo:main", diff --git a/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json index d474d112..9c1aa29f 100644 --- a/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/override-account-meta.num.specs.json @@ -2,18 +2,14 @@ "testCases": [ { "it": "-", - "metadata": { - "acc": { - "initial": "0", - "overridden": "1" - } - }, - "expect.metadata": { - "acc": { - "new": "2", - "overridden": "100" - } - } + "metadata": [ + { "account": "acc", "key": "initial", "value": "0" }, + { "account": "acc", "key": "overridden", "value": "1" } + ], + "expect.metadata": [ + { "account": "acc", "key": "new", "value": "2" }, + { "account": "acc", "key": "overridden", "value": "100" } + ] } ] } diff --git a/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json index 5fa713ab..6b187ba7 100644 --- a/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/set-account-meta.num.specs.json @@ -2,16 +2,14 @@ "testCases": [ { "it": "-", - "expect.metadata": { - "acc": { - "account": "acc", - "asset": "COIN", - "num": "42", - "portion": "2/7", - "portion-perc": "1/100", - "str": "abc" - } - } + "expect.metadata": [ + { "account": "acc", "key": "account", "value": "acc" }, + { "account": "acc", "key": "asset", "value": "COIN" }, + { "account": "acc", "key": "num", "value": "42" }, + { "account": "acc", "key": "portion", "value": "2/7" }, + { "account": "acc", "key": "portion-perc", "value": "1/100" }, + { "account": "acc", "key": "str", "value": "abc" } + ] } ] } diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index 79e2bf00..8a702ab6 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -1,6 +1,7 @@ package interpreter import ( + "encoding/json" "fmt" "math/big" "strconv" @@ -18,7 +19,15 @@ type Value interface { type String string type Asset string type Portion big.Rat -type AccountAddress string + +// AccountAddress is an account, optionally partitioned by a scope. The scope is +// a separate dimension of the account rather than part of its name, so it is +// modeled as its own field instead of being encoded into the name string. +type AccountAddress struct { + Name string + Scope string +} + type MonetaryInt big.Int type Monetary struct { Amount MonetaryInt @@ -34,9 +43,9 @@ func (Asset) value() {} func NewAccountAddress(src string) (AccountAddress, InterpreterError) { if !checkAccountName(src) { - return AccountAddress(""), InvalidAccountName{Name: src} + return AccountAddress{}, InvalidAccountName{Name: src} } - return AccountAddress(src), nil + return AccountAddress{Name: src}, nil } func NewAsset(src string) (Asset, InterpreterError) { @@ -46,6 +55,10 @@ func NewAsset(src string) (Asset, InterpreterError) { return Asset(src), nil } +func (v AccountAddress) MarshalJSON() ([]byte, error) { + return json.Marshal(v.String()) +} + func (v MonetaryInt) MarshalJSON() ([]byte, error) { bigInt := big.Int(v) s := fmt.Sprintf(`"%s"`, bigInt.String()) @@ -68,7 +81,10 @@ func (v String) String() string { } func (v AccountAddress) String() string { - return string(v) + if v.Scope == "" { + return v.Name + } + return v.Name + "/" + v.Scope } func (v MonetaryInt) String() string { @@ -150,7 +166,7 @@ func expectAccount(v Value, r parser.Range) (AccountAddress, InterpreterError) { return v, nil default: - return "", TypeError{Expected: analysis.TypeAccount, Value: v, Range: r} + return AccountAddress{}, TypeError{Expected: analysis.TypeAccount, Value: v, Range: r} } } diff --git a/internal/interpreter/value_test.go b/internal/interpreter/value_test.go index b94d2d13..3b83953c 100644 --- a/internal/interpreter/value_test.go +++ b/internal/interpreter/value_test.go @@ -43,7 +43,7 @@ func TestMarshalAsset(t *testing.T) { func TestMarshalAddress(t *testing.T) { t.Parallel() - x := interpreter.AccountAddress("abc") + x := interpreter.AccountAddress{Name: "abc"} j, err := json.Marshal(x) require.Nil(t, err) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index c4065041..95208ea7 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -188,11 +188,11 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp } if testCase.ExpectAccountsMeta != nil { - failedAssertions = runAssertion[any](failedAssertions, + failedAssertions = runAssertion(failedAssertions, "expect.metadata", testCase.ExpectAccountsMeta, result.AccountsMetadata, - reflect.DeepEqual, + interpreter.CompareAccountsMetadata, ) } @@ -264,10 +264,27 @@ func mergeVars(v1 interpreter.VariablesMap, v2 interpreter.VariablesMap) interpr return out } -func mergeAccountsMeta(m1 interpreter.AccountsMetadata, m2 interpreter.AccountsMetadata) interpreter.AccountsMetadata { - out := m1.DeepClone() - out.Merge(m2) - return out +// Merge two account-metadata inputs, deduping by (account, key, scope). +// Entries in "inner" override matching entries in "outer". +func mergeAccountsMeta(outer interpreter.AccountsMetadata, inner interpreter.AccountsMetadata) interpreter.AccountsMetadata { + merged := interpreter.AccountsMetadata{} + indexByKey := map[string]int{} + + addAll := func(items interpreter.AccountsMetadata) { + for _, item := range items { + key := item.Account + "\x00" + item.Key + "\x00" + item.Scope + if i, ok := indexByKey[key]; ok { + merged[i] = item + } else { + indexByKey[key] = len(merged) + merged = append(merged, item) + } + } + } + + addAll(outer) + addAll(inner) + return merged } // validateSpecs rejects a malformed specs file before any test case is run. A @@ -292,6 +309,9 @@ func duplicateBalanceErr(dup interpreter.BalanceRow) error { if dup.Color != "" { key += fmt.Sprintf(" color=%q", dup.Color) } + if dup.Scope != "" { + key += fmt.Sprintf(" scope=%q", dup.Scope) + } return fmt.Errorf("balances must not contain duplicate entries: duplicate entry for %s", key) } @@ -303,7 +323,7 @@ func mergeBalances(outer interpreter.Balances, inner interpreter.Balances) inter addAll := func(items interpreter.Balances) { for _, item := range items { - key := item.Account + "\x00" + item.Asset + "\x00" + item.Color + key := item.Account + "\x00" + item.Asset + "\x00" + item.Color + "\x00" + item.Scope if i, ok := indexByKey[key]; ok { merged[i] = item } else { @@ -364,11 +384,14 @@ func getMovements(postings []interpreter.Posting) Movements { movements := Movements{} for _, posting := range postings { + source := joinScope(posting.Source, posting.SourceScope) + destination := joinScope(posting.Destination, posting.DestinationScope) + found := false for i := range movements { m := &movements[i] - if m.Source == posting.Source && - m.Destination == posting.Destination && + if m.Source == source && + m.Destination == destination && m.Asset == posting.Asset && m.Color == posting.Color { m.Amount = new(big.Int).Add(m.Amount, posting.Amount) @@ -379,8 +402,8 @@ func getMovements(postings []interpreter.Posting) Movements { if !found { movements = append(movements, Movement{ - Source: posting.Source, - Destination: posting.Destination, + Source: source, + Destination: destination, Asset: posting.Asset, Color: posting.Color, Amount: new(big.Int).Set(posting.Amount), @@ -391,19 +414,29 @@ func getMovements(postings []interpreter.Posting) Movements { return movements } +// joinScope re-encodes a separate (account, scope) pair into the scope-encoded +// "account/scope" address. An empty scope yields the bare account. +func joinScope(account, scope string) string { + if scope == "" { + return account + } + return account + "/" + scope +} + func getBalances(postings []interpreter.Posting, initialBalances interpreter.Balances) interpreter.Balances { - // Working set keyed by account for O(1)-ish lookups. - balances := map[string][]interpreter.AccountBalance{} + // Working set keyed by (account, scope) for O(1)-ish lookups. + balances := map[interpreter.AccountAddress][]interpreter.AccountBalance{} - getOrCreate := func(account, asset, color string) *big.Int { - entries := balances[account] + getOrCreate := func(account, asset, scope, color string) *big.Int { + key := interpreter.AccountAddress{Name: account, Scope: scope} + entries := balances[key] for i := range entries { if entries[i].Asset == asset && entries[i].Color == color { return entries[i].Amount } } amount := new(big.Int) - balances[account] = append(entries, interpreter.AccountBalance{ + balances[key] = append(entries, interpreter.AccountBalance{ Asset: asset, Color: color, Amount: amount, @@ -414,27 +447,32 @@ func getBalances(postings []interpreter.Posting, initialBalances interpreter.Bal // Seed from the initial balances. CLONE each amount (Set, not pointer copy) // so the Sub/Add below never mutate the caller's *big.Int values. for _, row := range initialBalances { - dst := getOrCreate(row.Account, row.Asset, row.Color) + dst := getOrCreate(row.Account, row.Asset, row.Scope, row.Color) if row.Amount != nil { dst.Set(row.Amount) } } for _, posting := range postings { - sourceBalance := getOrCreate(posting.Source, posting.Asset, posting.Color) + sourceBalance := getOrCreate(posting.Source, posting.Asset, posting.SourceScope, posting.Color) sourceBalance.Sub(sourceBalance, posting.Amount) - destinationBalance := getOrCreate(posting.Destination, posting.Asset, posting.Color) + destinationBalance := getOrCreate(posting.Destination, posting.Asset, posting.DestinationScope, posting.Color) destinationBalance.Add(destinationBalance, posting.Amount) } // Flatten back to []BalanceRow, sorted for deterministic output. out := make(interpreter.Balances, 0) - accounts := make([]string, 0, len(balances)) + accounts := make([]interpreter.AccountAddress, 0, len(balances)) for account := range balances { accounts = append(accounts, account) } - sort.Strings(accounts) + sort.Slice(accounts, func(i, j int) bool { + if accounts[i].Name != accounts[j].Name { + return accounts[i].Name < accounts[j].Name + } + return accounts[i].Scope < accounts[j].Scope + }) for _, account := range accounts { entries := balances[account] sort.Slice(entries, func(i, j int) bool { @@ -445,8 +483,9 @@ func getBalances(postings []interpreter.Posting, initialBalances interpreter.Bal }) for _, e := range entries { out = append(out, interpreter.BalanceRow{ - Account: account, + Account: account.Name, Asset: e.Asset, + Scope: account.Scope, Color: e.Color, Amount: e.Amount, }) diff --git a/numscript.go b/numscript.go index 6d11d1c6..09faed4b 100644 --- a/numscript.go +++ b/numscript.go @@ -60,7 +60,7 @@ type ( Balances = interpreter.Balances BalanceRow = interpreter.BalanceRow - AccountMetadata = interpreter.AccountMetadata + AccountMetadataRow = interpreter.AccountMetadataRow // The newly defined account metadata after the execution AccountsMetadata = interpreter.AccountsMetadata diff --git a/numscript_test.go b/numscript_test.go index 92347aca..e1ec7171 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -114,7 +114,7 @@ send [COIN 100] ( store := ObservableStore{ StaticStore: interpreter.StaticStore{ Balances: interpreter.Balances{}, - Meta: interpreter.AccountsMetadata{"account_that_needs_meta": {"k": "source2"}}, + Meta: interpreter.AccountsMetadata{{Account: "account_that_needs_meta", Key: "k", Value: "source2"}}, }, } _, err := parseResult.Run(context.Background(), numscript.VariablesMap{ @@ -127,7 +127,7 @@ send [COIN 100] ( require.Equal(t, []numscript.MetadataQuery{ { - "account_that_needs_meta": {"k"}, + {Account: "account_that_needs_meta", Keys: []string{"k"}}, }, }, store.GetMetadataCalls) @@ -486,9 +486,7 @@ send [USD/2 10] ( store := ObservableStore{ StaticStore: interpreter.StaticStore{ Meta: interpreter.AccountsMetadata{ - "a": interpreter.AccountMetadata{ - "k": "a2", - }, + {Account: "a", Key: "k", Value: "a2"}, }, Balances: interpreter.Balances{ {Account: "a", Asset: "USD/2", Amount: big.NewInt(100)}, diff --git a/specs.schema.json b/specs.schema.json index b16c52fc..a7c248e7 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -84,13 +84,13 @@ "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": { @@ -108,6 +108,10 @@ "color": { "type": "string", "pattern": "^[A-Z]*$" + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" } } }, @@ -122,13 +126,30 @@ }, "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_]*$" } } }, @@ -153,11 +174,11 @@ "properties": { "source": { "type": "string", - "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)(/[a-z0-9_]+)?$" }, "destination": { "type": "string", - "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)(/[a-z0-9_]+)?$" }, "asset": { "type": "string", @@ -177,7 +198,15 @@ "type": "object", "properties": { "source": { "type": "string" }, + "sourceScope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" + }, "destination": { "type": "string" }, + "destinationScope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" + }, "asset": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" From ff72060ef2126fa9270fd1bc1004f5b20f77e91a Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 16:21:51 +0200 Subject: [PATCH 2/7] fix: fix pprint --- .../__snapshots__/accounts_metadata_test.snap | 13 +++++++++ internal/interpreter/accounts_metadata.go | 18 ++++++++++-- .../interpreter/accounts_metadata_test.go | 28 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100755 internal/interpreter/__snapshots__/accounts_metadata_test.snap create mode 100644 internal/interpreter/accounts_metadata_test.go diff --git a/internal/interpreter/__snapshots__/accounts_metadata_test.snap b/internal/interpreter/__snapshots__/accounts_metadata_test.snap new file mode 100755 index 00000000..a61a75d4 --- /dev/null +++ b/internal/interpreter/__snapshots__/accounts_metadata_test.snap @@ -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 | +--- diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 1232dce6..76595550 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -19,11 +19,25 @@ type AccountMetadataRow struct { type AccountsMetadata []AccountMetadataRow func (m AccountsMetadata) PrettyPrint() string { - header := []string{"Account", "Name", "Value"} + // the Scope column is shown only when at least one entry has a scope + hasScope := slices.ContainsFunc(m, func(row AccountMetadataRow) bool { + return row.Scope != "" + }) + + var header []string + if hasScope { + header = []string{"Account", "Scope", "Name", "Value"} + } else { + header = []string{"Account", "Name", "Value"} + } var rows [][]string for _, row := range m { - rows = append(rows, []string{row.Account, row.Key, row.Value}) + if hasScope { + rows = append(rows, []string{row.Account, row.Scope, row.Key, row.Value}) + } else { + rows = append(rows, []string{row.Account, row.Key, row.Value}) + } } return utils.CsvPretty(header, rows, true) diff --git a/internal/interpreter/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go new file mode 100644 index 00000000..f3b572ff --- /dev/null +++ b/internal/interpreter/accounts_metadata_test.go @@ -0,0 +1,28 @@ +package interpreter + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestPrettyPrintAccountsMetadata(t *testing.T) { + t.Run("without scope (no Scope column)", func(t *testing.T) { + meta := AccountsMetadata{ + {Account: "alice", Key: "kyc", Value: "verified"}, + {Account: "bob", Key: "tier", Value: "gold"}, + } + + snaps.MatchSnapshot(t, meta.PrettyPrint()) + }) + + t.Run("with scope (Scope column shown)", func(t *testing.T) { + meta := AccountsMetadata{ + {Account: "alice", Key: "kyc", Value: "verified"}, + {Account: "alice", Scope: "eu", Key: "kyc", Value: "pending"}, + {Account: "bob", Key: "tier", Value: "gold"}, + } + + snaps.MatchSnapshot(t, meta.PrettyPrint()) + }) +} From f5a8ea628a6b6100783dc589de9497d3a13f86b8 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:01:24 +0200 Subject: [PATCH 3/7] add set_account_meta with scope test --- .../scoped-function/set-account-meta.num | 2 ++ .../set-account-meta.num.specs.json | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num new file mode 100644 index 00000000..8c0a22e1 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num @@ -0,0 +1,2 @@ +set_account_meta(@acc, "k", "unscoped") +set_account_meta(scoped(@acc, "myscope"), "k", "scoped") diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json new file mode 100644 index 00000000..45ed1da1 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/set-account-meta.num.specs.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-mid-script-function-call", + "experimental-scoped-function" + ], + "testCases": [ + { + "it": "sets metadata scoped by the account's scope, distinct from the unscoped entry", + "expect.metadata": [ + { "account": "acc", "key": "k", "value": "unscoped" }, + { "account": "acc", "scope": "myscope", "key": "k", "value": "scoped" } + ] + } + ] +} From 95ccd08b072f02d3b87994fa392f5aca4e0ab9b7 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:06:44 +0200 Subject: [PATCH 4/7] add scoped meta() read test --- .../scoped-function/read-account-meta.num | 8 +++++++ .../read-account-meta.num.specs.json | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num.specs.json diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num new file mode 100644 index 00000000..496a08f8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num @@ -0,0 +1,8 @@ +vars { + monetary $m = meta(scoped(@acc, "myscope"), "amt") +} + +send $m ( + source = @world + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num.specs.json new file mode 100644 index 00000000..9e723cef --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/read-account-meta.num.specs.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function" + ], + "metadata": [ + { "account": "acc", "key": "amt", "value": "EUR 1" }, + { "account": "acc", "scope": "myscope", "key": "amt", "value": "EUR 2" } + ], + "testCases": [ + { + "it": "reads metadata from the scoped account (amt=2), not the unscoped one (amt=1)", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "asset": "EUR", + "amount": 2 + } + ] + } + ] +} From a1f87955a00b5e845c9103509aac3fc9738b4362 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:27:53 +0200 Subject: [PATCH 5/7] reject invalid meta on test --- internal/interpreter/accounts_metadata.go | 16 ++++ .../__snapshots__/runner_test.snap | 16 ++++ internal/specs_format/index.go | 14 ++++ internal/specs_format/runner_test.go | 78 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 76595550..4ad9a23f 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -18,6 +18,22 @@ type AccountMetadataRow struct { // 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 { // the Scope column is shown only when at least one entry has a scope hasScope := slices.ContainsFunc(m, func(row AccountMetadataRow) bool { diff --git a/internal/specs_format/__snapshots__/runner_test.snap b/internal/specs_format/__snapshots__/runner_test.snap index f50e91d0..1bc746cc 100755 --- a/internal/specs_format/__snapshots__/runner_test.snap +++ b/internal/specs_format/__snapshots__/runner_test.snap @@ -179,3 +179,19 @@ Error: example.num.specs.json balances must not contain duplicate entries: duplicate entry for account="src" asset="USD" --- + +[TestDuplicateMetaInTestCaseErr - 1] + +Error: example.num.specs.json + +metadata must not contain duplicate entries: duplicate entry for account="acc" key="k" + +--- + +[TestDuplicateMetaInOuterErr - 1] + +Error: example.num.specs.json + +metadata must not contain duplicate entries: duplicate entry for account="acc" key="k" + +--- diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 95208ea7..a716b85d 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -296,10 +296,16 @@ func validateSpecs(specs Specs) error { if dup, ok := specs.Balances.FirstDuplicate(); ok { return duplicateBalanceErr(dup) } + if dup, ok := specs.Meta.FirstDuplicate(); ok { + return duplicateAccountMetaErr(dup) + } for _, testCase := range specs.TestCases { if dup, ok := testCase.Balances.FirstDuplicate(); ok { return duplicateBalanceErr(dup) } + if dup, ok := testCase.Meta.FirstDuplicate(); ok { + return duplicateAccountMetaErr(dup) + } } return nil } @@ -315,6 +321,14 @@ func duplicateBalanceErr(dup interpreter.BalanceRow) error { return fmt.Errorf("balances must not contain duplicate entries: duplicate entry for %s", key) } +func duplicateAccountMetaErr(dup interpreter.AccountMetadataRow) error { + key := fmt.Sprintf("account=%q key=%q", dup.Account, dup.Key) + if dup.Scope != "" { + key += fmt.Sprintf(" scope=%q", dup.Scope) + } + return fmt.Errorf("metadata must not contain duplicate entries: duplicate entry for %s", key) +} + // Merge two balance inputs, deduping by (account, asset, color). // Entries in "inner" override matching entries in "outer". func mergeBalances(outer interpreter.Balances, inner interpreter.Balances) interpreter.Balances { diff --git a/internal/specs_format/runner_test.go b/internal/specs_format/runner_test.go index c894d04f..c42c4dd5 100644 --- a/internal/specs_format/runner_test.go +++ b/internal/specs_format/runner_test.go @@ -220,6 +220,84 @@ func TestDuplicateBalanceInOuterErr(t *testing.T) { snaps.MatchSnapshot(t, out.String()) } +func TestDuplicateMetaInTestCaseErr(t *testing.T) { + var out bytes.Buffer + + specs := `{ + "testCases": [ + { + "it": "t1", + "metadata": [ + { "account": "acc", "key": "k", "value": "a" }, + { "account": "acc", "key": "k", "value": "b" } + ], + "expect.postings": null + } + ] + }` + + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "", + SpecsFileContent: []byte(specs), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestDuplicateMetaInOuterErr(t *testing.T) { + var out bytes.Buffer + + specs := `{ + "metadata": [ + { "account": "acc", "key": "k", "value": "a" }, + { "account": "acc", "key": "k", "value": "b" } + ], + "testCases": [ + { "it": "t1", "expect.postings": null } + ] + }` + + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "", + SpecsFileContent: []byte(specs), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +// A (account, key) pair that differs only by scope is NOT a duplicate. +func TestSameMetaKeyDifferentScopeIsNotDuplicate(t *testing.T) { + var out bytes.Buffer + + specs := `{ + "metadata": [ + { "account": "acc", "key": "k", "value": "a" }, + { "account": "acc", "key": "k", "value": "b", "scope": "myscope" } + ], + "testCases": [ + { "it": "t1", "expect.postings": null } + ] + }` + + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "", + SpecsFileContent: []byte(specs), + }, + }) + require.True(t, success) +} + func TestNumscriptParseErr(t *testing.T) { var out bytes.Buffer From 71a21212d28db04fb6ac87ac2642390f3ae1abfb Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 18:24:19 +0200 Subject: [PATCH 6/7] prevent bad data on input --- internal/cmd/run.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 98166109..8fef0907 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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{}{} From 9435a4b7ec62e89346a4427e02aedf3de84c258f Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 18:25:53 +0200 Subject: [PATCH 7/7] fix: fix wrong feature flag constraints --- internal/analysis/check.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/analysis/check.go b/internal/analysis/check.go index d8610dab..97531390 100644 --- a/internal/analysis/check.go +++ b/internal/analysis/check.go @@ -127,7 +127,7 @@ var Builtins = map[string]FnCallResolution{ VersionConstraints: []VersionClause{ { Version: parser.NewVersionInterpreter(0, 0, 25), - FeatureFlag: flags.ExperimentalGetAmountFunctionFeatureFlag, + FeatureFlag: flags.ExperimentalScopedFunction, }, }, },