From 9fc505ac7b85ebfbee378963daa6351708c119d7 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 16:05:07 +0200 Subject: [PATCH 01/17] 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 +++++++++--------- .../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 ++++++-- 33 files changed, 584 insertions(+), 289 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/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 8adf703d..1b7ec6af 100644 --- a/numscript.go +++ b/numscript.go @@ -59,7 +59,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 b330b06e192e371caeff07bd1f8c7868a05d70a3 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 16:21:51 +0200 Subject: [PATCH 02/17] 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 f062a2a1da0f6006f34a52f05b4dad335878078c Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:01:24 +0200 Subject: [PATCH 03/17] 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 32f96fe060f6dd018f797e1234ded1a4b9944d5e Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:06:44 +0200 Subject: [PATCH 04/17] 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 50acc1496d0e7b51b0b752437216f76550fa7d89 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 17:27:53 +0200 Subject: [PATCH 05/17] 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 e564eae4eb7750eef18bef05f794b4fd734bbf7a Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 18:24:19 +0200 Subject: [PATCH 06/17] 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 c01efd433301117c5dc492c5e62fc4149f2979a5 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 18:25:53 +0200 Subject: [PATCH 07/17] 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, }, }, }, From b458b9502e376f059f6e2cdf9c4802c3b0dbc53f Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 00:24:14 +0200 Subject: [PATCH 08/17] feat: update schema --- internal/interpreter/interpreter.go | 2 +- .../scoped-function/simple.num.specs.json | 10 ++++ internal/specs_format/index.go | 48 ++++++++----------- specs.schema.json | 14 ++++-- 4 files changed, 43 insertions(+), 31 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 8f108468..cfb596e2 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -198,7 +198,7 @@ func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[ const accountSegmentRegex = "[a-zA-Z0-9_-]+" -var accountNameRegex = regexp.MustCompile("^@?" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*(?:/[a-z_]+)?$") +var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") // https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go func checkAccountName(addr string) bool { 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 index 869fbbcb..5066eb83 100644 --- 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 @@ -24,6 +24,16 @@ "amount": 10, "asset": "USD" } + ], + "expect.movements": [ + { + "source": "src", + "sourceScope": "x", + "destination": "dest", + "destinationScope": "y", + "amount": 10, + "asset": "USD" + } ] } ] diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index a716b85d..7d6b2235 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -359,25 +359,27 @@ type AssertionMismatch[T any] struct { } type Movement struct { - Source string `json:"source"` - Destination string `json:"destination"` - Asset string `json:"asset"` - Amount *big.Int `json:"amount"` - Color string `json:"color,omitempty"` + Source string `json:"source"` + SourceScope string `json:"sourceScope,omitempty"` + Destination string `json:"destination"` + DestinationScope string `json:"destinationScope,omitempty"` + Asset string `json:"asset"` + Amount *big.Int `json:"amount"` + Color string `json:"color,omitempty"` } type Movements = []Movement // Compare movements as a set: order does not matter. -// Each (source, destination, asset, color) tuple is unique within a Movements -// list, so we match on that tuple and compare amounts. +// Each (source, sourceScope, destination, destinationScope, asset, color) tuple +// is unique within a Movements list, so we match on that tuple and compare amounts. func compareMovements(expected Movements, got Movements) bool { if len(expected) != len(got) { return false } key := func(m Movement) string { - return m.Source + "\x00" + m.Destination + "\x00" + m.Asset + "\x00" + m.Color + return m.Source + "\x00" + m.SourceScope + "\x00" + m.Destination + "\x00" + m.DestinationScope + "\x00" + m.Asset + "\x00" + m.Color } byKey := make(map[string]*big.Int, len(got)) @@ -398,14 +400,13 @@ 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 == source && - m.Destination == destination && + if m.Source == posting.Source && + m.SourceScope == posting.SourceScope && + m.Destination == posting.Destination && + m.DestinationScope == posting.DestinationScope && m.Asset == posting.Asset && m.Color == posting.Color { m.Amount = new(big.Int).Add(m.Amount, posting.Amount) @@ -416,11 +417,13 @@ func getMovements(postings []interpreter.Posting) Movements { if !found { movements = append(movements, Movement{ - Source: source, - Destination: destination, - Asset: posting.Asset, - Color: posting.Color, - Amount: new(big.Int).Set(posting.Amount), + Source: posting.Source, + SourceScope: posting.SourceScope, + Destination: posting.Destination, + DestinationScope: posting.DestinationScope, + Asset: posting.Asset, + Color: posting.Color, + Amount: new(big.Int).Set(posting.Amount), }) } } @@ -428,15 +431,6 @@ 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, scope) for O(1)-ish lookups. balances := map[interpreter.AccountAddress][]interpreter.AccountBalance{} diff --git a/specs.schema.json b/specs.schema.json index a7c248e7..5976327e 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -162,7 +162,7 @@ "Movements": { "type": "array", - "description": "List of funds sent from an account to another. The (source, destination, asset, color) tuple of each entry must be unique within the list.", + "description": "List of funds sent from an account to another. The (source, sourceScope, destination, destinationScope, asset, color) tuple of each entry must be unique within the list.", "items": { "$ref": "#/definitions/Movement" } }, @@ -174,11 +174,19 @@ "properties": { "source": { "type": "string", - "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)(/[a-z0-9_]+)?$" + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + }, + "sourceScope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" }, "destination": { "type": "string", - "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)(/[a-z0-9_]+)?$" + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + }, + "destinationScope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" }, "asset": { "type": "string", From 735abc40bf2a43f86b1a3e090256f3d4c350fe63 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 00:31:16 +0200 Subject: [PATCH 09/17] feat: update postings output --- .../pretty_print_postings_test.snap | 16 ++++++++ internal/interpreter/interpreter.go | 40 ++++++++++++++----- .../interpreter/pretty_print_postings_test.go | 35 ++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) create mode 100755 internal/interpreter/__snapshots__/pretty_print_postings_test.snap create mode 100644 internal/interpreter/pretty_print_postings_test.go diff --git a/internal/interpreter/__snapshots__/pretty_print_postings_test.snap b/internal/interpreter/__snapshots__/pretty_print_postings_test.snap new file mode 100755 index 00000000..8275ac04 --- /dev/null +++ b/internal/interpreter/__snapshots__/pretty_print_postings_test.snap @@ -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 | +--- diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index cfb596e2..d57f82a7 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -1156,28 +1156,46 @@ func CalculateSafeWithdraw( } func PrettyPrintPostings(postings []Posting) string { - // the Color column is shown only when at least one posting has a color + // each optional column is shown only when at least one posting populates it + hasSourceScope := slices.ContainsFunc(postings, func(posting Posting) bool { + return posting.SourceScope != "" + }) + hasDestinationScope := slices.ContainsFunc(postings, func(posting Posting) bool { + return posting.DestinationScope != "" + }) hasColor := slices.ContainsFunc(postings, func(posting Posting) bool { return posting.Color != "" }) - var header []string + header := []string{"Source"} + if hasSourceScope { + header = append(header, "Source Scope") + } + header = append(header, "Destination") + if hasDestinationScope { + header = append(header, "Destination Scope") + } + header = append(header, "Asset") if hasColor { - header = []string{"Source", "Destination", "Asset", "Color", "Amount"} - } else { - header = []string{"Source", "Destination", "Asset", "Amount"} + header = append(header, "Color") } + header = append(header, "Amount") 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 + row := []string{posting.Source} + if hasSourceScope { + row = append(row, posting.SourceScope) + } + row = append(row, posting.Destination) + if hasDestinationScope { + row = append(row, posting.DestinationScope) + } + row = append(row, posting.Asset) if hasColor { - row = []string{source, destination, posting.Asset, posting.Color, posting.Amount.String()} - } else { - row = []string{source, destination, posting.Asset, posting.Amount.String()} + row = append(row, posting.Color) } + row = append(row, posting.Amount.String()) rows = append(rows, row) } return utils.CsvPretty(header, rows, false) diff --git a/internal/interpreter/pretty_print_postings_test.go b/internal/interpreter/pretty_print_postings_test.go new file mode 100644 index 00000000..2afd54b6 --- /dev/null +++ b/internal/interpreter/pretty_print_postings_test.go @@ -0,0 +1,35 @@ +package interpreter + +import ( + "math/big" + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestPrettyPrintPostings(t *testing.T) { + t.Run("no scope, no color (no optional columns)", func(t *testing.T) { + postings := []Posting{ + {Source: "world", Destination: "alice", Asset: "EUR/2", Amount: big.NewInt(100)}, + } + + snaps.MatchSnapshot(t, PrettyPrintPostings(postings)) + }) + + t.Run("only source scope (only Source Scope column shown)", func(t *testing.T) { + postings := []Posting{ + {Source: "src", SourceScope: "x", Destination: "dest", Asset: "USD", Amount: big.NewInt(10)}, + {Source: "world", Destination: "dest", Asset: "USD", Amount: big.NewInt(5)}, + } + + snaps.MatchSnapshot(t, PrettyPrintPostings(postings)) + }) + + t.Run("both scopes (both Scope columns shown)", func(t *testing.T) { + postings := []Posting{ + {Source: "src", SourceScope: "x", Destination: "dest", DestinationScope: "y", Asset: "USD", Amount: big.NewInt(10)}, + } + + snaps.MatchSnapshot(t, PrettyPrintPostings(postings)) + }) +} From 39e2b059d1cfd5c094db7570d693c899473d932c Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 08:33:12 +0200 Subject: [PATCH 10/17] refactor: always allow emtpy cols --- internal/interpreter/accounts_metadata.go | 21 ++------ internal/interpreter/balances.go | 23 ++------ internal/interpreter/interpreter.go | 52 +++++-------------- .../utils/__snapshots__/pretty_csv_test.snap | 12 +++++ internal/utils/pretty_csv.go | 42 +++++++++++++++ internal/utils/pretty_csv_test.go | 24 +++++++++ 6 files changed, 100 insertions(+), 74 deletions(-) diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 4ad9a23f..e02a0ffe 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -35,28 +35,15 @@ func (rows AccountsMetadata) FirstDuplicate() (AccountMetadataRow, bool) { } 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 { - return row.Scope != "" - }) - - var header []string - if hasScope { - header = []string{"Account", "Scope", "Name", "Value"} - } else { - 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 _, row := range m { - 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}) - } + rows = append(rows, []string{row.Account, row.Scope, row.Key, row.Value}) } - return utils.CsvPretty(header, rows, true) + return utils.CsvPrettyOmitEmptyCols(header, rows, true) } // CompareAccountsMetadata reports whether two metadata lists hold the same set diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 94dab1a0..2acad037 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -2,7 +2,6 @@ package interpreter import ( "math/big" - "slices" "github.com/formancehq/numscript/internal/utils" ) @@ -33,17 +32,9 @@ func (rows Balances) FirstDuplicate() (BalanceRow, bool) { } func (rows Balances) PrettyPrint() string { - // the Color column is shown only when at least one entry has a color - hasColor := slices.ContainsFunc(rows, func(row BalanceRow) bool { - return row.Color != "" - }) - - var header []string - if hasColor { - header = []string{"Account", "Asset", "Color", "Balance"} - } else { - header = []string{"Account", "Asset", "Balance"} - } + // the optional columns (scope, color) are dropped automatically when no entry + // populates them + header := []string{"Account", "Scope", "Asset", "Color", "Balance"} var tableRows [][]string for _, row := range rows { @@ -51,13 +42,9 @@ func (rows Balances) PrettyPrint() string { if row.Amount != nil { amount = row.Amount.String() } - if hasColor { - tableRows = append(tableRows, []string{row.Account, row.Asset, row.Color, amount}) - } else { - tableRows = append(tableRows, []string{row.Account, row.Asset, amount}) - } + tableRows = append(tableRows, []string{row.Account, row.Scope, row.Asset, row.Color, amount}) } - return utils.CsvPretty(header, tableRows, true) + return utils.CsvPrettyOmitEmptyCols(header, tableRows, true) } // findRow returns the amount for a given (account, asset, color, scope), if present. diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index d57f82a7..3b0878e5 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -1156,49 +1156,23 @@ func CalculateSafeWithdraw( } func PrettyPrintPostings(postings []Posting) string { - // each optional column is shown only when at least one posting populates it - hasSourceScope := slices.ContainsFunc(postings, func(posting Posting) bool { - return posting.SourceScope != "" - }) - hasDestinationScope := slices.ContainsFunc(postings, func(posting Posting) bool { - return posting.DestinationScope != "" - }) - hasColor := slices.ContainsFunc(postings, func(posting Posting) bool { - return posting.Color != "" - }) - - header := []string{"Source"} - if hasSourceScope { - header = append(header, "Source Scope") - } - header = append(header, "Destination") - if hasDestinationScope { - header = append(header, "Destination Scope") - } - header = append(header, "Asset") - if hasColor { - header = append(header, "Color") - } - header = append(header, "Amount") + // the optional columns (scopes, color) are dropped automatically when no + // posting populates them + header := []string{"Source", "Source Scope", "Destination", "Destination Scope", "Asset", "Color", "Amount"} var rows [][]string for _, posting := range postings { - row := []string{posting.Source} - if hasSourceScope { - row = append(row, posting.SourceScope) - } - row = append(row, posting.Destination) - if hasDestinationScope { - row = append(row, posting.DestinationScope) - } - row = append(row, posting.Asset) - if hasColor { - row = append(row, posting.Color) - } - row = append(row, posting.Amount.String()) - rows = append(rows, row) + rows = append(rows, []string{ + posting.Source, + posting.SourceScope, + posting.Destination, + posting.DestinationScope, + posting.Asset, + posting.Color, + posting.Amount.String(), + }) } - return utils.CsvPretty(header, rows, false) + return utils.CsvPrettyOmitEmptyCols(header, rows, false) } func PrettyPrintMeta(meta Metadata) string { diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap index 18e0527e..9ec14aef 100755 --- a/internal/utils/__snapshots__/pretty_csv_test.snap +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -12,3 +12,15 @@ | b | 12345 | | very-very-very-long-key | | --- + +[TestPrettyCsvOmitEmptyCols/drops_a_column_whose_cells_are_all_empty - 1] +| Account | Asset | Balance | +| alice | EUR/2 | 1 | +| bob | BTC | 3 | +--- + +[TestPrettyCsvOmitEmptyCols/keeps_a_column_when_at_least_one_cell_is_non-empty - 1] +| Account | Scope | Asset | Balance | +| alice | eu | EUR/2 | 1 | +| bob | | BTC | 3 | +--- diff --git a/internal/utils/pretty_csv.go b/internal/utils/pretty_csv.go index 1703b56f..f25af636 100644 --- a/internal/utils/pretty_csv.go +++ b/internal/utils/pretty_csv.go @@ -74,6 +74,48 @@ func CsvPretty( return strings.Join(allRows, "\n") } +// CsvPrettyOmitEmptyCols renders the table like CsvPretty, but omits any column +// whose data cells are all empty (its header is dropped along with it). This lets +// callers always pass the full set of columns and have the optional ones (e.g. a +// color or scope dimension that no row populates) disappear automatically. +// +// Fails if the header is shorter than any of the rows. +func CsvPrettyOmitEmptyCols( + header []string, + rows [][]string, + sortRows bool, +) string { + keep := make([]bool, len(header)) + for col := range header { + for _, row := range rows { + if row[col] != "" { + keep[col] = true + break + } + } + } + + filteredHeader := make([]string, 0, len(header)) + for col, name := range header { + if keep[col] { + filteredHeader = append(filteredHeader, name) + } + } + + filteredRows := make([][]string, len(rows)) + for i, row := range rows { + filtered := make([]string, 0, len(filteredHeader)) + for col := range header { + if keep[col] { + filtered = append(filtered, row[col]) + } + } + filteredRows[i] = filtered + } + + return CsvPretty(filteredHeader, filteredRows, sortRows) +} + func CsvPrettyMap(keyName string, valueName string, m map[string]string) string { var rows [][]string for k, v := range m { diff --git a/internal/utils/pretty_csv_test.go b/internal/utils/pretty_csv_test.go index 010456b7..4934dc83 100644 --- a/internal/utils/pretty_csv_test.go +++ b/internal/utils/pretty_csv_test.go @@ -19,6 +19,30 @@ func TestPrettyCsv(t *testing.T) { snaps.MatchSnapshot(t, out) } +func TestPrettyCsvOmitEmptyCols(t *testing.T) { + t.Run("drops a column whose cells are all empty", func(t *testing.T) { + out := utils.CsvPrettyOmitEmptyCols([]string{ + "Account", "Scope", "Asset", "Color", "Balance", + }, [][]string{ + {"alice", "", "EUR/2", "", "1"}, + {"bob", "", "BTC", "", "3"}, + }, false) + + snaps.MatchSnapshot(t, out) + }) + + t.Run("keeps a column when at least one cell is non-empty", func(t *testing.T) { + out := utils.CsvPrettyOmitEmptyCols([]string{ + "Account", "Scope", "Asset", "Color", "Balance", + }, [][]string{ + {"alice", "eu", "EUR/2", "", "1"}, + {"bob", "", "BTC", "", "3"}, + }, false) + + snaps.MatchSnapshot(t, out) + }) +} + func TestPrettyCsvMap(t *testing.T) { out := utils.CsvPrettyMap("Name", "Value", map[string]string{ "a": "0", From 31976763d7675f9edcf16744c49579936f544f9c Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 09:29:29 +0200 Subject: [PATCH 11/17] feat: update mcp balances --- internal/mcp_impl/handlers.go | 13 ++++++-- internal/mcp_impl/handlers_test.go | 50 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/internal/mcp_impl/handlers.go b/internal/mcp_impl/handlers.go index bf13838a..cdf01b4b 100644 --- a/internal/mcp_impl/handlers.go +++ b/internal/mcp_impl/handlers.go @@ -24,8 +24,8 @@ func addEvalTool(s *server.MCPServer) { ), mcp.WithArray("balances", mcp.Required(), - mcp.Description(`The accounts' balances. A list of entries, each an object with an "account", an "asset", an integer "amount", and an optional "color". - The (account, asset, color) triple of each entry must be unique within the list. + mcp.Description(`The accounts' balances. A list of entries, each an object with an "account", an "asset", an integer "amount", an optional "color", and an optional "scope". + The (account, asset, color, scope) tuple of each entry must be unique within the list. For example: [ { "account": "alice", "asset": "USD/2", "amount": 100 }, { "account": "alice", "asset": "EUR/2", "amount": -42 }, { "account": "bob", "asset": "BTC", "amount": 1 } ] `), ), @@ -73,7 +73,14 @@ func handleEvalTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.Call } if dup, ok := args.Balances.FirstDuplicate(); ok { - return mcp.NewToolResultError(fmt.Sprintf("balances must not contain duplicate entries: duplicate entry for account=%q asset=%q color=%q", dup.Account, dup.Asset, dup.Color)), nil + 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 mcp.NewToolResultError("balances must not contain duplicate entries: duplicate entry for " + key), nil } out, iErr := interpreter.RunProgram( diff --git a/internal/mcp_impl/handlers_test.go b/internal/mcp_impl/handlers_test.go index 7398d96e..05de57e7 100644 --- a/internal/mcp_impl/handlers_test.go +++ b/internal/mcp_impl/handlers_test.go @@ -26,3 +26,53 @@ func TestHandleEvalToolRejectsParseErrors(t *testing.T) { require.True(t, ok) require.Contains(t, text.Text, "mismatched input") } + +func TestHandleEvalToolRejectsDuplicateBalancesWithScope(t *testing.T) { + result, err := handleEvalTool(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "script": ` + send [USD/2 1] ( + source = @world + destination = @a + ) + `, + "balances": []any{ + map[string]any{"account": "alice", "asset": "USD/2", "amount": 1, "scope": "x"}, + map[string]any{"account": "alice", "asset": "USD/2", "amount": 2, "scope": "x"}, + }, + "vars": map[string]any{}, + }, + }, + }) + + require.NoError(t, err) + require.True(t, result.IsError) + text, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok) + require.Contains(t, text.Text, "must not contain duplicate entries") + require.Contains(t, text.Text, `scope="x"`) +} + +func TestHandleEvalToolAllowsSameBalanceKeyDifferentScope(t *testing.T) { + result, err := handleEvalTool(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "script": ` + send [USD/2 1] ( + source = @world + destination = @a + ) + `, + "balances": []any{ + map[string]any{"account": "alice", "asset": "USD/2", "amount": 1}, + map[string]any{"account": "alice", "asset": "USD/2", "amount": 2, "scope": "x"}, + }, + "vars": map[string]any{}, + }, + }, + }) + + require.NoError(t, err) + require.False(t, result.IsError) +} From 71a91d4cca59852ba28f337adb9f064918646873 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 09:43:49 +0200 Subject: [PATCH 12/17] test: add tests --- .../scoped-function/allotment.num | 7 ++++ .../scoped-function/allotment.num.specs.json | 26 ++++++++++++++ .../experimental/scoped-function/balance.num | 8 +++++ .../scoped-function/balance.num.specs.json | 26 ++++++++++++++ .../experimental/scoped-function/capped.num | 7 ++++ .../scoped-function/capped.num.specs.json | 26 ++++++++++++++ .../scoped-function/color-and-scope.num | 4 +++ .../color-and-scope.num.specs.json | 34 +++++++++++++++++++ .../scoped-function/overdraft.num | 4 +++ .../scoped-function/overdraft.num.specs.json | 21 ++++++++++++ .../experimental/scoped-function/save.num | 6 ++++ .../scoped-function/save.num.specs.json | 25 ++++++++++++++ 12 files changed, 194 insertions(+) create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num.specs.json diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num new file mode 100644 index 00000000..0ca09994 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = { + 60% from scoped(@a, "s") + 40% from @b + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num.specs.json new file mode 100644 index 00000000..682d0281 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/allotment.num.specs.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function", + "experimental-mid-script-function-call" + ], + "balances": [ + { "account": "a", "asset": "COIN", "amount": 60, "scope": "s" }, + { "account": "b", "asset": "COIN", "amount": 40 } + ], + "testCases": [ + { + "it": "a scoped account works as an allotment sub-source", + "expect.postings": [ + { + "source": "a", + "sourceScope": "s", + "destination": "dest", + "asset": "COIN", + "amount": 60 + }, + { "source": "b", "destination": "dest", "asset": "COIN", "amount": 40 } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num new file mode 100644 index 00000000..9d383e03 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num @@ -0,0 +1,8 @@ +vars { + monetary $b = balance(scoped(@treasury, "reserve"), EUR/2) +} + +send $b ( + source = @world + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num.specs.json new file mode 100644 index 00000000..c6ca85a3 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/balance.num.specs.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-scoped-function"], + "balances": [ + { "account": "treasury", "asset": "EUR/2", "amount": 42 }, + { + "account": "treasury", + "asset": "EUR/2", + "amount": 42, + "scope": "reserve" + } + ], + "testCases": [ + { + "it": "balance() reads the scoped balance (42), not the unscoped one (100)", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "asset": "EUR/2", + "amount": 42 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num new file mode 100644 index 00000000..495bef8b --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num @@ -0,0 +1,7 @@ +send [COIN 100] ( + source = { + max [COIN 40] from scoped(@a, "s") + @b + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num.specs.json new file mode 100644 index 00000000..8b8e35c8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/capped.num.specs.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function", + "experimental-mid-script-function-call" + ], + "balances": [ + { "account": "a", "asset": "COIN", "amount": 999, "scope": "s" }, + { "account": "b", "asset": "COIN", "amount": 60 } + ], + "testCases": [ + { + "it": "a scoped source can be capped with max", + "expect.postings": [ + { + "source": "a", + "sourceScope": "s", + "destination": "dest", + "asset": "COIN", + "amount": 40 + }, + { "source": "b", "destination": "dest", "asset": "COIN", "amount": 60 } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num new file mode 100644 index 00000000..02bc83af --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num @@ -0,0 +1,4 @@ +send [COIN *] ( + source = scoped(@a, "s") \ "RED" + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num.specs.json new file mode 100644 index 00000000..6bc25cc6 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/color-and-scope.num.specs.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function", + "experimental-mid-script-function-call", + "experimental-asset-colors" + ], + "balances": [ + { + "account": "a", + "asset": "COIN", + "amount": 50, + "scope": "s", + "color": "RED" + }, + { "account": "a", "asset": "COIN", "amount": 50, "scope": "s" }, + { "account": "a", "asset": "COIN", "amount": 50, "color": "RED" } + ], + "testCases": [ + { + "it": "color and scope are orthogonal: send-all draws exactly the (scope=s, color=RED) balance (50), not the scope-only or color-only 999s", + "expect.postings": [ + { + "source": "a", + "sourceScope": "s", + "destination": "dest", + "asset": "COIN", + "color": "RED", + "amount": 50 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num new file mode 100644 index 00000000..cd85542d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num @@ -0,0 +1,4 @@ +send [USD/2 100] ( + source = scoped(@empty, "s") allowing unbounded overdraft + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num.specs.json new file mode 100644 index 00000000..26b3b289 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/overdraft.num.specs.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function", + "experimental-mid-script-function-call" + ], + "testCases": [ + { + "it": "a scoped source can overdraft with no balance", + "expect.postings": [ + { + "source": "empty", + "sourceScope": "s", + "destination": "dest", + "asset": "USD/2", + "amount": 100 + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num b/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num new file mode 100644 index 00000000..5d5d810a --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num @@ -0,0 +1,6 @@ +save [COIN 30] from scoped(@a, "s") + +send [COIN *] ( + source = scoped(@a, "s") + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num.specs.json new file mode 100644 index 00000000..6468f2a8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/save.num.specs.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": [ + "experimental-scoped-function", + "experimental-mid-script-function-call" + ], + "balances": [ + { "account": "a", "asset": "COIN", "amount": 100, "scope": "s" }, + { "account": "a", "asset": "COIN", "amount": 999 } + ], + "testCases": [ + { + "it": "save protects 30 on the scoped balance; send-all then takes the remaining 70 (unscoped 999 untouched)", + "expect.postings": [ + { + "source": "a", + "sourceScope": "s", + "destination": "dest", + "asset": "COIN", + "amount": 70 + } + ] + } + ] +} From 422139240a26b1a9499ff19a2d588634d0d41c20 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 10:42:26 +0200 Subject: [PATCH 13/17] fix: print headers on empty cols --- internal/utils/__snapshots__/pretty_csv_test.snap | 4 ++++ internal/utils/pretty_csv.go | 6 ++++++ internal/utils/pretty_csv_test.go | 8 ++++++++ 3 files changed, 18 insertions(+) diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap index 9ec14aef..0204889b 100755 --- a/internal/utils/__snapshots__/pretty_csv_test.snap +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -24,3 +24,7 @@ | alice | eu | EUR/2 | 1 | | bob | | BTC | 3 | --- + +[TestPrettyCsvOmitEmptyCols/with_no_rows,_still_shows_every_header - 1] +| Account | Scope | Asset | Color | Balance | +--- diff --git a/internal/utils/pretty_csv.go b/internal/utils/pretty_csv.go index f25af636..035acd13 100644 --- a/internal/utils/pretty_csv.go +++ b/internal/utils/pretty_csv.go @@ -85,6 +85,12 @@ func CsvPrettyOmitEmptyCols( rows [][]string, sortRows bool, ) string { + // with no rows there's nothing to judge column emptiness from, so keep every + // column and still render the header + if len(rows) == 0 { + return CsvPretty(header, rows, sortRows) + } + keep := make([]bool, len(header)) for col := range header { for _, row := range rows { diff --git a/internal/utils/pretty_csv_test.go b/internal/utils/pretty_csv_test.go index 4934dc83..e204fbf3 100644 --- a/internal/utils/pretty_csv_test.go +++ b/internal/utils/pretty_csv_test.go @@ -31,6 +31,14 @@ func TestPrettyCsvOmitEmptyCols(t *testing.T) { snaps.MatchSnapshot(t, out) }) + t.Run("with no rows, still shows every header", func(t *testing.T) { + out := utils.CsvPrettyOmitEmptyCols([]string{ + "Account", "Scope", "Asset", "Color", "Balance", + }, [][]string{}, false) + + snaps.MatchSnapshot(t, out) + }) + t.Run("keeps a column when at least one cell is non-empty", func(t *testing.T) { out := utils.CsvPrettyOmitEmptyCols([]string{ "Account", "Scope", "Asset", "Color", "Balance", From d6b43fbac478b041bac62d05b7d9c64b3ca8f84a Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 10:47:36 +0200 Subject: [PATCH 14/17] feat: handle test init --- internal/cmd/test_init.go | 15 ++++++++------- internal/cmd/test_init_test.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/cmd/test_init.go b/internal/cmd/test_init.go index ec5159ac..5dc225a3 100644 --- a/internal/cmd/test_init.go +++ b/internal/cmd/test_init.go @@ -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) @@ -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{}{} diff --git a/internal/cmd/test_init_test.go b/internal/cmd/test_init_test.go index c75a5a3a..d694c7c2 100644 --- a/internal/cmd/test_init_test.go +++ b/internal/cmd/test_init_test.go @@ -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 } From e72767672ac9489b50b105533d441be36fe6c0fb Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 10:59:30 +0200 Subject: [PATCH 15/17] fix specs format comparision --- internal/interpreter/accounts_metadata.go | 18 ++++++--- .../interpreter/accounts_metadata_test.go | 39 +++++++++++++++++++ internal/interpreter/balances.go | 24 +++++++++++- internal/interpreter/balances_test.go | 13 +++++++ .../specs_format/compare_movements_test.go | 25 ++++++++++++ internal/specs_format/index.go | 22 +++++++---- 6 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 internal/specs_format/compare_movements_test.go diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index e02a0ffe..182c3a50 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -1,8 +1,6 @@ package interpreter import ( - "slices" - "github.com/formancehq/numscript/internal/utils" ) @@ -46,16 +44,26 @@ func (m AccountsMetadata) PrettyPrint() string { return utils.CsvPrettyOmitEmptyCols(header, rows, true) } -// CompareAccountsMetadata reports whether two metadata lists hold the same set -// of rows, ignoring order. +// CompareAccountsMetadata reports whether two metadata lists hold the same rows, +// ignoring order but respecting multiplicity. A duplicated row in one list must +// be matched by the same number of occurrences in the other, so e.g. [x, x] is +// not considered equal to [x, y]. func CompareAccountsMetadata(a AccountsMetadata, b AccountsMetadata) bool { if len(a) != len(b) { return false } + // AccountMetadataRow is an all-string (comparable) struct, so it can key the + // multiset directly. + counts := make(map[AccountMetadataRow]int, len(a)) for _, row := range a { - if !slices.Contains(b, row) { + counts[row]++ + } + for _, row := range b { + counts[row]-- + if counts[row] < 0 { return false } } + // equal lengths + every b row consumed a distinct a row => exact multiset match return true } diff --git a/internal/interpreter/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go index f3b572ff..2b79a03e 100644 --- a/internal/interpreter/accounts_metadata_test.go +++ b/internal/interpreter/accounts_metadata_test.go @@ -4,8 +4,47 @@ import ( "testing" "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" ) +func TestCompareAccountsMetadata(t *testing.T) { + x := AccountMetadataRow{Account: "a", Key: "k", Value: "1"} + y := AccountMetadataRow{Account: "a", Key: "k", Value: "2"} + + t.Run("equal regardless of order", func(t *testing.T) { + require.True(t, CompareAccountsMetadata( + AccountsMetadata{x, y}, + AccountsMetadata{y, x}, + )) + }) + + t.Run("different value is not equal", func(t *testing.T) { + require.False(t, CompareAccountsMetadata( + AccountsMetadata{x}, + AccountsMetadata{y}, + )) + }) + + t.Run("respects multiplicity: [x, x] != [x, y]", func(t *testing.T) { + require.False(t, CompareAccountsMetadata( + AccountsMetadata{x, x}, + AccountsMetadata{x, y}, + )) + // and the symmetric direction + require.False(t, CompareAccountsMetadata( + AccountsMetadata{x, y}, + AccountsMetadata{x, x}, + )) + }) + + t.Run("identical multisets are equal", func(t *testing.T) { + require.True(t, CompareAccountsMetadata( + AccountsMetadata{x, x}, + AccountsMetadata{x, x}, + )) + }) +} + func TestPrettyPrintAccountsMetadata(t *testing.T) { t.Run("without scope (no Scope column)", func(t *testing.T) { meta := AccountsMetadata{ diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 2acad037..6a9bf964 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -72,7 +72,29 @@ func CompareBalances(b1 Balances, b2 Balances) bool { if len(b1) != len(b2) { return false } - return CompareBalancesIncluding(b1, b2) + // multiset comparison, respecting multiplicity: a duplicated row in b1 must be + // matched by the same number of occurrences in b2 (so [x, x] != [x, y]). A + // plain subset check would wrongly report equality there. + type rowKey struct{ account, asset, color, scope, amount string } + mk := func(r BalanceRow) rowKey { + amount := "0" // amountsEqual treats nil as zero + if r.Amount != nil { + amount = r.Amount.String() + } + return rowKey{r.Account, r.Asset, r.Color, r.Scope, amount} + } + counts := make(map[rowKey]int, len(b1)) + for _, r := range b1 { + counts[mk(r)]++ + } + for _, r := range b2 { + k := mk(r) + counts[k]-- + if counts[k] < 0 { + return false + } + } + return true } // Returns whether the first value is a subset of the second one. diff --git a/internal/interpreter/balances_test.go b/internal/interpreter/balances_test.go index 3fe2388f..33e34e1b 100644 --- a/internal/interpreter/balances_test.go +++ b/internal/interpreter/balances_test.go @@ -30,6 +30,19 @@ func TestCmpMaps(t *testing.T) { require.Equal(t, false, CompareBalances(b1, b2)) } +func TestCompareBalancesMultiplicity(t *testing.T) { + x := BalanceRow{Account: "alice", Asset: "EUR", Amount: big.NewInt(1)} + y := BalanceRow{Account: "bob", Asset: "EUR", Amount: big.NewInt(1)} + + // [x, x] must not equal [x, y] just because each x is "contained" in the other + require.False(t, CompareBalances(Balances{x, x}, Balances{x, y})) + require.False(t, CompareBalances(Balances{x, y}, Balances{x, x})) + + // order-independent and multiplicity-exact equality still holds + require.True(t, CompareBalances(Balances{x, y}, Balances{y, x})) + require.True(t, CompareBalances(Balances{x, x}, Balances{x, x})) +} + func TestCmpMapsIncluding(t *testing.T) { t.Run("including (subset)", func(t *testing.T) { b2 := Balances{ diff --git a/internal/specs_format/compare_movements_test.go b/internal/specs_format/compare_movements_test.go new file mode 100644 index 00000000..cfee0034 --- /dev/null +++ b/internal/specs_format/compare_movements_test.go @@ -0,0 +1,25 @@ +package specs_format + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompareMovementsMultiplicity(t *testing.T) { + x := Movement{Source: "world", Destination: "a", Asset: "USD", Amount: big.NewInt(1)} + y := Movement{Source: "world", Destination: "b", Asset: "USD", Amount: big.NewInt(1)} + + // [x, x] must not equal [x, y] + require.False(t, compareMovements(Movements{x, x}, Movements{x, y})) + require.False(t, compareMovements(Movements{x, y}, Movements{x, x})) + + // order-independent and multiplicity-exact equality still holds + require.True(t, compareMovements(Movements{x, y}, Movements{y, x})) + require.True(t, compareMovements(Movements{x, x}, Movements{x, x})) + + // differing amount on the same key is not equal + z := Movement{Source: "world", Destination: "a", Asset: "USD", Amount: big.NewInt(2)} + require.False(t, compareMovements(Movements{x}, Movements{z})) +} diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 7d6b2235..5e8bd893 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -378,18 +378,24 @@ func compareMovements(expected Movements, got Movements) bool { return false } + // multiset comparison, respecting multiplicity (so [x, x] != [x, y]): the + // amount is part of the key, so a row matches only an identical row. key := func(m Movement) string { - return m.Source + "\x00" + m.SourceScope + "\x00" + m.Destination + "\x00" + m.DestinationScope + "\x00" + m.Asset + "\x00" + m.Color - } - - byKey := make(map[string]*big.Int, len(got)) - for _, m := range got { - byKey[key(m)] = m.Amount + amount := "0" + if m.Amount != nil { + amount = m.Amount.String() + } + return m.Source + "\x00" + m.SourceScope + "\x00" + m.Destination + "\x00" + m.DestinationScope + "\x00" + m.Asset + "\x00" + m.Color + "\x00" + amount } + counts := make(map[string]int, len(expected)) for _, m := range expected { - amount, ok := byKey[key(m)] - if !ok || m.Amount.Cmp(amount) != 0 { + counts[key(m)]++ + } + for _, m := range got { + k := key(m) + counts[k]-- + if counts[k] < 0 { return false } } From b128e8fab1f8fc4af3afe6680001e0a043a558ec Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 25 Jun 2026 11:22:24 +0200 Subject: [PATCH 16/17] test: add test --- internal/interpreter/interpreter_test.go | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index be95eb54..d9c0b722 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -276,6 +276,52 @@ func TestBadAssetInMeta(t *testing.T) { test(t, tc) } +// runScopedSend runs a scoped send once, with the scope passed in as a string +// variable, and returns the (possibly nil) error. +func runScopedSend(t *testing.T, scope string) interpreter.InterpreterError { + t.Helper() + parsed := parser.Parse(` + vars { string $scope } + send [COIN 1] ( + source = scoped(@a, $scope) allowing unbounded overdraft + destination = @dest + ) + `) + require.Empty(t, parsed.Errors) + + _, err := interpreter.RunProgram( + context.Background(), + parsed.Value, + map[string]string{"scope": scope}, + interpreter.StaticStore{}, + map[string]struct{}{ + flags.ExperimentalScopedFunction: {}, + flags.ExperimentalMidScriptFunctionCall: {}, + }, + ) + return err +} + +func TestScopedRejectsInvalidScope(t *testing.T) { + // scopes must match ^[a-z0-9_]*$ + invalid := []string{"UPPER", "Mixed", "with-dash", "with:colon", "with space", "with/slash", "with!bang", "with.dot"} + for _, scope := range invalid { + t.Run(scope, func(t *testing.T) { + require.Equal(t, interpreter.InvalidScope{Scope: scope}, runScopedSend(t, scope)) + }) + } +} + +func TestScopedAcceptsValidScope(t *testing.T) { + // lowercase, digits, underscores — and the empty string (means "no scope") + valid := []string{"reserve", "x", "a1", "with_underscore", "123", ""} + for _, scope := range valid { + t.Run("scope="+scope, func(t *testing.T) { + require.Nil(t, runScopedSend(t, scope)) + }) + } +} + func TestInvalidAllotInSendAll(t *testing.T) { tc := NewTestCase() tc.compile(t, `send [USD/2 *] ( From 4eb073170bd336722b96afc8a0f63919ba06d95e Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 30 Jun 2026 10:58:10 +0200 Subject: [PATCH 17/17] feat: reworked meta schema --- inputs.schema.json | 6 - .../__snapshots__/pretty_print_meta_test.snap | 12 ++ internal/interpreter/accounts_metadata.go | 24 --- .../interpreter/accounts_metadata_test.go | 36 ++--- internal/interpreter/append_scope_test.go | 10 -- internal/interpreter/evaluate_expr.go | 7 +- internal/interpreter/function_exprs.go | 10 +- internal/interpreter/function_statements.go | 2 +- internal/interpreter/interpreter.go | 9 +- internal/interpreter/interpreter_error.go | 17 +- internal/interpreter/interpreter_test.go | 29 ++++ .../interpreter/pretty_print_meta_test.go | 27 ++++ internal/interpreter/set_accounts_metadata.go | 99 ++++++++++++ .../experimental/meta-calc.num.specs.json | 2 +- .../script-tests/add-numbers.num.specs.json | 2 +- .../account-interp.num.specs.json | 2 +- .../get-amount-function.num.specs.json | 2 +- .../get-asset-function.num.specs.json | 2 +- .../set-account-meta.num.specs.json | 4 +- .../override-account-meta.num.specs.json | 4 +- .../set-account-meta.num.specs.json | 12 +- .../script-tests/set-tx-meta.num.specs.json | 10 +- .../sub-monetaries.num.specs.json | 2 +- .../script-tests/sub-numbers.num.specs.json | 2 +- .../variables-json.num.specs.json | 6 +- .../script-tests/variables.num.specs.json | 4 +- internal/interpreter/value.go | 147 ++++++++++++++++-- internal/interpreter/value_test.go | 49 +++++- .../__snapshots__/runner_test.snap | 2 +- internal/specs_format/index.go | 51 ++++-- numscript.go | 7 +- specs.schema.json | 94 ++++++++++- 32 files changed, 559 insertions(+), 133 deletions(-) create mode 100755 internal/interpreter/__snapshots__/pretty_print_meta_test.snap create mode 100644 internal/interpreter/pretty_print_meta_test.go create mode 100644 internal/interpreter/set_accounts_metadata.go diff --git a/inputs.schema.json b/inputs.schema.json index 122db931..5ac48162 100644 --- a/inputs.schema.json +++ b/inputs.schema.json @@ -90,12 +90,6 @@ "pattern": "^[a-z0-9_]*$" } } - }, - - "TxMetadata": { - "type": "object", - "description": "Map from a metadata's key to the transaction's metadata stringied value", - "additionalProperties": { "type": "string" } } } } diff --git a/internal/interpreter/__snapshots__/pretty_print_meta_test.snap b/internal/interpreter/__snapshots__/pretty_print_meta_test.snap new file mode 100755 index 00000000..2e9f3599 --- /dev/null +++ b/internal/interpreter/__snapshots__/pretty_print_meta_test.snap @@ -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") | +--- diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 182c3a50..c59cf8ab 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -43,27 +43,3 @@ func (m AccountsMetadata) PrettyPrint() string { return utils.CsvPrettyOmitEmptyCols(header, rows, true) } - -// CompareAccountsMetadata reports whether two metadata lists hold the same rows, -// ignoring order but respecting multiplicity. A duplicated row in one list must -// be matched by the same number of occurrences in the other, so e.g. [x, x] is -// not considered equal to [x, y]. -func CompareAccountsMetadata(a AccountsMetadata, b AccountsMetadata) bool { - if len(a) != len(b) { - return false - } - // AccountMetadataRow is an all-string (comparable) struct, so it can key the - // multiset directly. - counts := make(map[AccountMetadataRow]int, len(a)) - for _, row := range a { - counts[row]++ - } - for _, row := range b { - counts[row]-- - if counts[row] < 0 { - return false - } - } - // equal lengths + every b row consumed a distinct a row => exact multiset match - return true -} diff --git a/internal/interpreter/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go index 2b79a03e..2a750e1d 100644 --- a/internal/interpreter/accounts_metadata_test.go +++ b/internal/interpreter/accounts_metadata_test.go @@ -7,40 +7,40 @@ import ( "github.com/stretchr/testify/require" ) -func TestCompareAccountsMetadata(t *testing.T) { - x := AccountMetadataRow{Account: "a", Key: "k", Value: "1"} - y := AccountMetadataRow{Account: "a", Key: "k", Value: "2"} +func TestCompareSetAccountsMetadata(t *testing.T) { + x := SetAccountMetadataRow{Account: "a", Key: "k", Value: NewMonetaryInt(1)} + y := SetAccountMetadataRow{Account: "a", Key: "k", Value: NewMonetaryInt(2)} t.Run("equal regardless of order", func(t *testing.T) { - require.True(t, CompareAccountsMetadata( - AccountsMetadata{x, y}, - AccountsMetadata{y, x}, + require.True(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, y}, + SetAccountsMetadata{y, x}, )) }) t.Run("different value is not equal", func(t *testing.T) { - require.False(t, CompareAccountsMetadata( - AccountsMetadata{x}, - AccountsMetadata{y}, + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x}, + SetAccountsMetadata{y}, )) }) t.Run("respects multiplicity: [x, x] != [x, y]", func(t *testing.T) { - require.False(t, CompareAccountsMetadata( - AccountsMetadata{x, x}, - AccountsMetadata{x, y}, + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, x}, + SetAccountsMetadata{x, y}, )) // and the symmetric direction - require.False(t, CompareAccountsMetadata( - AccountsMetadata{x, y}, - AccountsMetadata{x, x}, + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, y}, + SetAccountsMetadata{x, x}, )) }) t.Run("identical multisets are equal", func(t *testing.T) { - require.True(t, CompareAccountsMetadata( - AccountsMetadata{x, x}, - AccountsMetadata{x, x}, + require.True(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, x}, + SetAccountsMetadata{x, x}, )) }) } diff --git a/internal/interpreter/append_scope_test.go b/internal/interpreter/append_scope_test.go index 0cf5f02c..68888a00 100644 --- a/internal/interpreter/append_scope_test.go +++ b/internal/interpreter/append_scope_test.go @@ -6,16 +6,6 @@ import ( "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("")) diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 5b0c575c..ec041f2a 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -246,9 +246,12 @@ func (st *programState) unaryNegOp(expr parser.ValueExpr) (Value, InterpreterErr func castToString(v Value, rng parser.Range) (string, InterpreterError) { switch v := v.(type) { case AccountAddress: - return v.String(), nil + if v.Scope != "" { + return "", CannotCastScopedAccountToString{Account: v.Name, Scope: v.Scope, Range: rng} + } + return v.Name, nil case String: - return v.String(), nil + return string(v), nil case MonetaryInt: return v.String(), nil diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 0c98ac03..60163920 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -73,7 +73,12 @@ func meta( value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, string(key)) if !ok { - return "", MetadataNotFound{Account: account.String(), Key: string(key), Range: rng} + return "", MetadataNotFound{ + Account: account.Name, + Scope: account.Scope, + Key: string(key), + Range: rng, + } } return value, nil @@ -103,7 +108,8 @@ func balance( if balance.Cmp(big.NewInt(0)) == -1 { return Monetary{}, NegativeBalanceError{ - Account: account.String(), + Account: account.Name, + Scope: account.Scope, Amount: *balance, } } diff --git a/internal/interpreter/function_statements.go b/internal/interpreter/function_statements.go index 2acfbbdb..6a0c1430 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -27,7 +27,7 @@ func setAccountMeta(st *programState, r parser.Range, args []Value) InterpreterE return err } - st.SetAccountsMeta.Set(account.Name, account.Scope, string(key), meta.String()) + st.SetAccountsMeta.Set(account.Name, account.Scope, string(key), meta) return nil } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 3b0878e5..22b8ea46 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -53,7 +53,7 @@ type ExecutionResult struct { Metadata Metadata `json:"txMeta"` - AccountsMetadata AccountsMetadata `json:"accountsMeta"` + AccountsMetadata SetAccountsMetadata `json:"accountsMeta"` } func parseMonetary(source string) (Monetary, InterpreterError) { @@ -244,7 +244,7 @@ func RunProgram( TxMeta: make(map[string]Value), CachedAccountsMeta: InternalAccountsMetadata{}, CachedBalances: InternalBalances{}, - SetAccountsMeta: InternalAccountsMetadata{}, + SetAccountsMeta: internalSetAccountsMeta{}, Store: store, Postings: make([]Posting, 0), fundsQueue: newFundsQueue(nil), @@ -330,7 +330,7 @@ type programState struct { Store Store - SetAccountsMeta InternalAccountsMetadata + SetAccountsMeta internalSetAccountsMeta CachedAccountsMeta InternalAccountsMetadata CachedBalances InternalBalances @@ -526,7 +526,8 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd if account.Name == "world" || overdraft == nil { return nil, InvalidUnboundedInSendAll{ - Name: account.String(), + Name: account.Name, + Scope: account.Scope, } } diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 7f43be16..46d1b0a5 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -53,6 +53,7 @@ func (e InvalidNumberLiteral) Error() string { type MetadataNotFound struct { parser.Range Account string + Scope string Key string } @@ -67,7 +68,7 @@ type TypeError struct { } func (e TypeError) Error() string { - return fmt.Sprintf("Invalid value received. Expecting value of type %s (got %s instead)", e.Expected, e.Value.String()) + return fmt.Sprintf("Invalid value received. Expecting value of type `%s` (got `%s` instead)", e.Expected, e.Value.String()) } type UnboundVariableErr struct { @@ -129,6 +130,7 @@ func (e InvalidTypeErr) Error() string { type NegativeBalanceError struct { parser.Range Account string + Scope string Amount big.Int } @@ -164,7 +166,8 @@ func (e DivideByZero) Error() string { type InvalidUnboundedInSendAll struct { parser.Range - Name string + Name string + Scope string } func (e InvalidUnboundedInSendAll) Error() string { @@ -226,6 +229,16 @@ func (e CannotCastToString) Error() string { return fmt.Sprintf("Cannot cast this value to string: %s", e.Value) } +type CannotCastScopedAccountToString struct { + parser.Range + Account string + Scope string +} + +func (e CannotCastScopedAccountToString) Error() string { + return fmt.Sprintf("Cannot cast a scoped account to string (account %q has scope %q)", e.Account, e.Scope) +} + type InvalidAccountName struct { parser.Range Name string diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index d9c0b722..88342947 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -322,6 +322,35 @@ func TestScopedAcceptsValidScope(t *testing.T) { } } +func TestCannotInterpolateScopedAccount(t *testing.T) { + parsed := parser.Parse(` + vars { + account $scoped = scoped(@a, "s") + } + send [COIN 1] ( + source = @foo:$scoped allowing unbounded overdraft + destination = @dest + ) + `) + require.Empty(t, parsed.Errors) + + _, err := interpreter.RunProgram( + context.Background(), + parsed.Value, + nil, + interpreter.StaticStore{}, + map[string]struct{}{ + flags.ExperimentalScopedFunction: {}, + flags.ExperimentalAccountInterpolationFlag: {}, + }, + ) + + var scopedErr interpreter.CannotCastScopedAccountToString + require.ErrorAs(t, err, &scopedErr) + require.Equal(t, "a", scopedErr.Account) + require.Equal(t, "s", scopedErr.Scope) +} + func TestInvalidAllotInSendAll(t *testing.T) { tc := NewTestCase() tc.compile(t, `send [USD/2 *] ( diff --git a/internal/interpreter/pretty_print_meta_test.go b/internal/interpreter/pretty_print_meta_test.go new file mode 100644 index 00000000..1667d6bf --- /dev/null +++ b/internal/interpreter/pretty_print_meta_test.go @@ -0,0 +1,27 @@ +package interpreter + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestPrettyPrintMeta(t *testing.T) { + t.Run("renders plain values", func(t *testing.T) { + meta := Metadata{ + "greeting": String("hello"), + "count": NewMonetaryInt(42), + } + + snaps.MatchSnapshot(t, PrettyPrintMeta(meta)) + }) + + t.Run("renders a scoped account value in its source form", func(t *testing.T) { + meta := Metadata{ + "greeting": String("hello"), + "owner": AccountAddress{Name: "alice", Scope: "reserve"}, + } + + snaps.MatchSnapshot(t, PrettyPrintMeta(meta)) + }) +} diff --git a/internal/interpreter/set_accounts_metadata.go b/internal/interpreter/set_accounts_metadata.go new file mode 100644 index 00000000..da696fbe --- /dev/null +++ b/internal/interpreter/set_accounts_metadata.go @@ -0,0 +1,99 @@ +package interpreter + +import ( + "encoding/json" + "sort" +) + +// SetAccountMetadataRow is a single piece of account metadata set by the script +// during execution. Unlike the input metadata (which is opaque and string-valued, +// since its serialization format isn't always known), the set value's type is +// known, so it is carried as a typed Value and serialized in the tagged form. +type SetAccountMetadataRow struct { + Account string `json:"account"` + Key string `json:"key"` + Value Value `json:"value"` + Scope string `json:"scope,omitempty"` +} + +// SetAccountsMetadata is the account metadata produced by the script (the +// execution result's accountsMeta, and a spec's expect.metadata). +type SetAccountsMetadata []SetAccountMetadataRow + +func (r *SetAccountMetadataRow) UnmarshalJSON(data []byte) error { + var raw struct { + Account string `json:"account"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + value, err := ParseTaggedValue(raw.Value) + if err != nil { + return err + } + r.Account, r.Key, r.Scope, r.Value = raw.Account, raw.Key, raw.Scope, value + return nil +} + +// CompareSetAccountsMetadata reports whether two lists hold the same rows, +// ignoring order but respecting multiplicity (so [x, x] != [x, y]). Values are +// compared on their canonical source form. +func CompareSetAccountsMetadata(a SetAccountsMetadata, b SetAccountsMetadata) bool { + if len(a) != len(b) { + return false + } + key := func(r SetAccountMetadataRow) string { + value := "" + if r.Value != nil { + value = r.Value.String() + } + return r.Account + "\x00" + r.Key + "\x00" + r.Scope + "\x00" + value + } + counts := make(map[string]int, len(a)) + for _, r := range a { + counts[key(r)]++ + } + for _, r := range b { + k := key(r) + counts[k]-- + if counts[k] < 0 { + return false + } + } + return true +} + +// internalSetAccountsMeta is the in-memory store of metadata set during +// execution, keyed for upserts. +type internalSetAccountsMeta map[metadataKey]Value + +func (m internalSetAccountsMeta) Set(account, scope, key string, value Value) { + m[metadataKey{Account: account, Scope: scope, Key: key}] = value +} + +// toRows flattens the set metadata into the external representation, sorted by +// (account, scope, key) for deterministic output. +func (m internalSetAccountsMeta) toRows() SetAccountsMetadata { + rows := make(SetAccountsMetadata, 0, len(m)) + for k, value := range m { + rows = append(rows, SetAccountMetadataRow{ + 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/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json b/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json index 8f5f92e2..13081b07 100644 --- a/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json +++ b/internal/interpreter/testdata/numscript-cookbook/experimental/meta-calc.num.specs.json @@ -9,7 +9,7 @@ { "it": "sets the expected meta", "expect.txMetadata": { - "key": "11/2" + "key": { "type": "portion", "value": "11/2" } } } ] diff --git a/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json b/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json index c651e29f..eb979bef 100644 --- a/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json +++ b/internal/interpreter/testdata/script-tests/add-numbers.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": { - "k": "3" + "k": { "type": "number", "value": "3" } } } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json index fe8165f8..974a107f 100644 --- a/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/account-interpolation/account-interp.num.specs.json @@ -11,7 +11,7 @@ "status": "pending" }, "expect.txMetadata": { - "k": "acc:42:pending:user:001" + "k": { "type": "account", "name": "acc:42:pending:user:001" } } } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json index 32d21063..78d6ff7a 100644 --- a/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/get-amount-function/get-amount-function.num.specs.json @@ -6,7 +6,7 @@ { "it": "-", "expect.txMetadata": { - "amt": "100" + "amt": { "type": "number", "value": "100" } } } ] diff --git a/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json index 3e83b43e..f0f963e0 100644 --- a/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json +++ b/internal/interpreter/testdata/script-tests/experimental/get-asset-function/get-asset-function.num.specs.json @@ -6,7 +6,7 @@ { "it": "-", "expect.txMetadata": { - "asset": "ABC" + "asset": { "type": "asset", "name": "ABC" } } } ] 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 index 45ed1da1..2aaf4e08 100644 --- 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 @@ -8,8 +8,8 @@ { "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" } + { "account": "acc", "key": "k", "value": { "type": "string", "value": "unscoped" } }, + { "account": "acc", "scope": "myscope", "key": "k", "value": { "type": "string", "value": "scoped" } } ] } ] 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 9c1aa29f..959bb872 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 @@ -7,8 +7,8 @@ { "account": "acc", "key": "overridden", "value": "1" } ], "expect.metadata": [ - { "account": "acc", "key": "new", "value": "2" }, - { "account": "acc", "key": "overridden", "value": "100" } + { "account": "acc", "key": "new", "value": { "type": "number", "value": "2" } }, + { "account": "acc", "key": "overridden", "value": { "type": "number", "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 6b187ba7..df30f658 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 @@ -3,12 +3,12 @@ { "it": "-", "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" } + { "account": "acc", "key": "account", "value": { "type": "account", "name": "acc" } }, + { "account": "acc", "key": "asset", "value": { "type": "asset", "name": "COIN" } }, + { "account": "acc", "key": "num", "value": { "type": "number", "value": "42" } }, + { "account": "acc", "key": "portion", "value": { "type": "portion", "value": "2/7" } }, + { "account": "acc", "key": "portion-perc", "value": { "type": "portion", "value": "1/100" } }, + { "account": "acc", "key": "str", "value": { "type": "string", "value": "abc" } } ] } ] diff --git a/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json b/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json index 4730b6d8..bc253deb 100644 --- a/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json +++ b/internal/interpreter/testdata/script-tests/set-tx-meta.num.specs.json @@ -3,11 +3,11 @@ { "it": "-", "expect.txMetadata": { - "account": "acc", - "asset": "COIN", - "num": "42", - "portion": "3/25", - "str": "abc" + "account": { "type": "account", "name": "acc" }, + "asset": { "type": "asset", "name": "COIN" }, + "num": { "type": "number", "value": "42" }, + "portion": { "type": "portion", "value": "3/25" }, + "str": { "type": "string", "value": "abc" } } } ] diff --git a/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json b/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json index 8dcb6396..7c97a3b5 100644 --- a/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json +++ b/internal/interpreter/testdata/script-tests/sub-monetaries.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": { - "k": "USD/2 7" + "k": { "type": "monetary", "asset": "USD/2", "amount": "7" } } } ] diff --git a/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json b/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json index 1bdb2e49..73ce4de0 100644 --- a/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json +++ b/internal/interpreter/testdata/script-tests/sub-numbers.num.specs.json @@ -3,7 +3,7 @@ { "it": "-", "expect.txMetadata": { - "k": "9" + "k": { "type": "number", "value": "9" } } } ] diff --git a/internal/interpreter/testdata/script-tests/variables-json.num.specs.json b/internal/interpreter/testdata/script-tests/variables-json.num.specs.json index d546d473..4456cc18 100644 --- a/internal/interpreter/testdata/script-tests/variables-json.num.specs.json +++ b/internal/interpreter/testdata/script-tests/variables-json.num.specs.json @@ -26,9 +26,9 @@ } ], "expect.txMetadata": { - "description": "midnight ride", - "por": "21/50", - "ride": "1" + "description": { "type": "string", "value": "midnight ride" }, + "por": { "type": "portion", "value": "21/50" }, + "ride": { "type": "number", "value": "1" } } } ] diff --git a/internal/interpreter/testdata/script-tests/variables.num.specs.json b/internal/interpreter/testdata/script-tests/variables.num.specs.json index 7dc6e073..c6e35eb4 100644 --- a/internal/interpreter/testdata/script-tests/variables.num.specs.json +++ b/internal/interpreter/testdata/script-tests/variables.num.specs.json @@ -25,8 +25,8 @@ } ], "expect.txMetadata": { - "description": "midnight ride", - "ride": "1" + "description": { "type": "string", "value": "midnight ride" }, + "ride": { "type": "number", "value": "1" } } } ] diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index 8a702ab6..e444aa7a 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -55,36 +55,161 @@ func NewAsset(src string) (Asset, InterpreterError) { return Asset(src), nil } +// A Value is (de)serialized as a tagged-JSON discriminated union, keyed by +// "type", so the on-wire form is type-explicit and unambiguous (e.g. the string +// "42" and the number 42 are distinguishable), rather than stringly-typed: +// +// string -> { "type": "string", "value": "abc" } +// number -> { "type": "number", "value": "42" } +// asset -> { "type": "asset", "name": "COIN" } +// account -> { "type": "account", "name": "x", "scope": "s" } // scope optional +// monetary -> { "type": "monetary", "asset": "COIN", "amount": "100" } +// portion -> { "type": "portion", "value": "1/2" } +const ( + valueTypeString = "string" + valueTypeNumber = "number" + valueTypeAsset = "asset" + valueTypeAccount = "account" + valueTypeMonetary = "monetary" + valueTypePortion = "portion" +) + +// The per-shape tagged-JSON structs below are each shared by their type's +// MarshalJSON and by ParseTaggedValue, so the layout is defined once. +// +// scalar (string/number/portion) -> { "type": ..., "value": "..." } +// asset -> { "type": "asset", "name": "COIN" } +// account -> { "type": "account", "name": "x", "scope": "s" } +// monetary -> { "type": "monetary", "asset": "COIN", "amount": "100" } +type ( + taggedScalar struct { + Type string `json:"type"` + Value string `json:"value"` + } + taggedAsset struct { + Type string `json:"type"` + Name string `json:"name"` + } + taggedAccount struct { + Type string `json:"type"` + Name string `json:"name"` + Scope string `json:"scope,omitempty"` + } + taggedMonetary struct { + Type string `json:"type"` + Asset string `json:"asset"` + Amount string `json:"amount"` + } +) + +// ParseTaggedValue decodes the tagged-JSON representation of a Value. It reads +// the "type" discriminator (json can't unmarshal directly into the Value +// interface), then decodes into the struct shared with that type's MarshalJSON. +func ParseTaggedValue(data []byte) (Value, error) { + var tag struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &tag); err != nil { + return nil, err + } + + switch tag.Type { + case valueTypeString: + var v taggedScalar + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return String(v.Value), nil + + case valueTypeAccount: + var v taggedAccount + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return AccountAddress{Name: v.Name, Scope: v.Scope}, nil + + case valueTypeAsset: + var v taggedAsset + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return Asset(v.Name), nil + + case valueTypeNumber: + var v taggedScalar + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + n, ok := new(big.Int).SetString(v.Value, 10) + if !ok { + return nil, fmt.Errorf("invalid number value: %q", v.Value) + } + return MonetaryInt(*n), nil + + case valueTypeMonetary: + var v taggedMonetary + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + n, ok := new(big.Int).SetString(v.Amount, 10) + if !ok { + return nil, fmt.Errorf("invalid monetary amount: %q", v.Amount) + } + return Monetary{Asset: Asset(v.Asset), Amount: MonetaryInt(*n)}, nil + + case valueTypePortion: + var v taggedScalar + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + r, ok := new(big.Rat).SetString(v.Value) + if !ok { + return nil, fmt.Errorf("invalid portion value: %q", v.Value) + } + return Portion(*r), nil + + case "": + return nil, fmt.Errorf("missing value type") + default: + return nil, fmt.Errorf("unknown value type: %q", tag.Type) + } +} + +func (v String) MarshalJSON() ([]byte, error) { + return json.Marshal(taggedScalar{valueTypeString, string(v)}) +} + +func (v Asset) MarshalJSON() ([]byte, error) { + return json.Marshal(taggedAsset{valueTypeAsset, string(v)}) +} + func (v AccountAddress) MarshalJSON() ([]byte, error) { - return json.Marshal(v.String()) + return json.Marshal(taggedAccount{valueTypeAccount, v.Name, v.Scope}) } func (v MonetaryInt) MarshalJSON() ([]byte, error) { - bigInt := big.Int(v) - s := fmt.Sprintf(`"%s"`, bigInt.String()) - return []byte(s), nil + bi := big.Int(v) + return json.Marshal(taggedScalar{valueTypeNumber, bi.String()}) } func (v Portion) MarshalJSON() ([]byte, error) { r := big.Rat(v) - s := fmt.Sprintf(`"%s"`, r.String()) - return []byte(s), nil + return json.Marshal(taggedScalar{valueTypePortion, r.String()}) } func (v Monetary) MarshalJSON() ([]byte, error) { - m := fmt.Sprintf("\"%s %s\"", v.Asset, v.Amount.String()) - return []byte(m), nil + return json.Marshal(taggedMonetary{valueTypeMonetary, string(v.Asset), v.Amount.String()}) } func (v String) String() string { - return string(v) + return fmt.Sprintf(`"%s"`, string(v)) } func (v AccountAddress) String() string { if v.Scope == "" { - return v.Name + return fmt.Sprintf(`@%s`, v.Name) } - return v.Name + "/" + v.Scope + return fmt.Sprintf(`scoped(%s, "%s")`, v.Name, v.Scope) } func (v MonetaryInt) String() string { diff --git a/internal/interpreter/value_test.go b/internal/interpreter/value_test.go index 3b83953c..b5d65c26 100644 --- a/internal/interpreter/value_test.go +++ b/internal/interpreter/value_test.go @@ -17,7 +17,7 @@ func TestMarshalMonetaryInt(t *testing.T) { j, err := json.Marshal(x) require.Nil(t, err) - require.Equal(t, string(j), `"42"`) + require.JSONEq(t, `{"type":"number","value":"42"}`, string(j)) } func TestMarshalString(t *testing.T) { @@ -27,7 +27,7 @@ func TestMarshalString(t *testing.T) { j, err := json.Marshal(x) require.Nil(t, err) - require.Equal(t, string(j), `"abc"`) + require.JSONEq(t, `{"type":"string","value":"abc"}`, string(j)) } func TestMarshalAsset(t *testing.T) { @@ -37,17 +37,19 @@ func TestMarshalAsset(t *testing.T) { j, err := json.Marshal(x) require.Nil(t, err) - require.Equal(t, string(j), `"EUR/2"`) + require.JSONEq(t, `{"type":"asset","name":"EUR/2"}`, string(j)) } func TestMarshalAddress(t *testing.T) { t.Parallel() - x := interpreter.AccountAddress{Name: "abc"} + j, err := json.Marshal(interpreter.AccountAddress{Name: "abc"}) + require.Nil(t, err) + require.JSONEq(t, `{"type":"account","name":"abc"}`, string(j)) - j, err := json.Marshal(x) + j, err = json.Marshal(interpreter.AccountAddress{Name: "abc", Scope: "s"}) require.Nil(t, err) - require.Equal(t, string(j), `"abc"`) + require.JSONEq(t, `{"type":"account","name":"abc","scope":"s"}`, string(j)) } func TestMarshalPortion(t *testing.T) { @@ -57,7 +59,7 @@ func TestMarshalPortion(t *testing.T) { j, err := json.Marshal(x) require.Nil(t, err) - require.Equal(t, string(j), `"2/3"`) + require.JSONEq(t, `{"type":"portion","value":"2/3"}`, string(j)) } func TestMarshalMonetary(t *testing.T) { @@ -70,5 +72,36 @@ func TestMarshalMonetary(t *testing.T) { j, err := json.Marshal(x) require.Nil(t, err) - require.Equal(t, string(j), `"USD/2 100"`) + require.JSONEq(t, `{"type":"monetary","asset":"USD/2","amount":"100"}`, string(j)) +} + +func TestParseTaggedValueRoundTrip(t *testing.T) { + t.Parallel() + + values := []interpreter.Value{ + interpreter.String("abc"), + interpreter.Asset("EUR/2"), + interpreter.AccountAddress{Name: "alice"}, + interpreter.AccountAddress{Name: "alice", Scope: "reserve"}, + interpreter.NewMonetaryInt(42), + interpreter.Monetary{Asset: "USD/2", Amount: interpreter.NewMonetaryInt(100)}, + interpreter.Portion(*big.NewRat(2, 3)), + } + + for _, v := range values { + j, err := json.Marshal(v) + require.Nil(t, err) + + parsed, err := interpreter.ParseTaggedValue(j) + require.Nil(t, err) + // compare on the canonical source form + require.Equal(t, v.String(), parsed.String()) + } +} + +func TestParseTaggedValueRejectsUnknownType(t *testing.T) { + t.Parallel() + + _, err := interpreter.ParseTaggedValue([]byte(`{"type":"bogus"}`)) + require.Error(t, err) } diff --git a/internal/specs_format/__snapshots__/runner_test.snap b/internal/specs_format/__snapshots__/runner_test.snap index 1bc746cc..19a6406a 100755 --- a/internal/specs_format/__snapshots__/runner_test.snap +++ b/internal/specs_format/__snapshots__/runner_test.snap @@ -148,7 +148,7 @@ Error: example.num:1:5  Error: example.num:1:29 -Invalid value received. Expecting value of type account (got ops! instead) +Invalid value received. Expecting value of type `account` (got `"ops!"` instead) 0 | send [USD/2 100] ( source = "ops!" destination = @world) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 5e8bd893..0dd1f550 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -2,6 +2,7 @@ package specs_format import ( "context" + "encoding/json" "fmt" "math/big" "reflect" @@ -12,6 +13,28 @@ import ( "github.com/formancehq/numscript/internal/parser" ) +// ExpectedTxMeta is the expected transaction metadata of a test case. Each value +// is written in the tagged value format (e.g. {"type":"account","name":"x"}) and +// decoded into the corresponding interpreter.Value. +type ExpectedTxMeta map[string]interpreter.Value + +func (m *ExpectedTxMeta) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + out := ExpectedTxMeta{} + for k, v := range raw { + value, err := interpreter.ParseTaggedValue(v) + if err != nil { + return err + } + out[k] = value + } + *m = out + return nil +} + // --- Specs: type Specs struct { Schema string `json:"$schema,omitempty"` @@ -38,12 +61,12 @@ type TestCase struct { ExpectMissingFunds bool `json:"expect.error.missingFunds,omitempty"` ExpectNegativeAmount bool `json:"expect.error.negativeAmount,omitempty"` - ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"` - ExpectTxMeta map[string]string `json:"expect.txMetadata,omitempty"` - ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.metadata,omitempty"` - ExpectEndBalances interpreter.Balances `json:"expect.endBalances,omitempty"` - ExpectEndBalancesInclude interpreter.Balances `json:"expect.endBalances.include,omitempty"` - ExpectMovements Movements `json:"expect.movements,omitempty"` + ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"` + ExpectTxMeta ExpectedTxMeta `json:"expect.txMetadata,omitempty"` + ExpectAccountsMeta interpreter.SetAccountsMetadata `json:"expect.metadata,omitempty"` + ExpectEndBalances interpreter.Balances `json:"expect.endBalances,omitempty"` + ExpectEndBalancesInclude interpreter.Balances `json:"expect.endBalances.include,omitempty"` + ExpectMovements Movements `json:"expect.movements,omitempty"` } type TestCaseResult struct { @@ -175,14 +198,20 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp } if testCase.ExpectTxMeta != nil { - metadata := map[string]string{} + // compare on the canonical source form of each value, so a string + // "42" and the number 42 are not conflated + got := map[string]string{} for k, v := range result.Metadata { - metadata[k] = v.String() + got[k] = v.String() + } + expected := map[string]string{} + for k, v := range testCase.ExpectTxMeta { + expected[k] = v.String() } failedAssertions = runAssertion[any](failedAssertions, "expect.txMetadata", - testCase.ExpectTxMeta, - metadata, + expected, + got, reflect.DeepEqual, ) } @@ -192,7 +221,7 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp "expect.metadata", testCase.ExpectAccountsMeta, result.AccountsMetadata, - interpreter.CompareAccountsMetadata, + interpreter.CompareSetAccountsMetadata, ) } diff --git a/numscript.go b/numscript.go index 1b7ec6af..3c377782 100644 --- a/numscript.go +++ b/numscript.go @@ -59,10 +59,13 @@ type ( Balances = interpreter.Balances BalanceRow = interpreter.BalanceRow + // Input account metadata (opaque, string-valued) read via meta() AccountMetadataRow = interpreter.AccountMetadataRow + AccountsMetadata = interpreter.AccountsMetadata - // The newly defined account metadata after the execution - AccountsMetadata = interpreter.AccountsMetadata + // The account metadata set during the execution (tagged, typed values) + SetAccountMetadataRow = interpreter.SetAccountMetadataRow + SetAccountsMetadata = interpreter.SetAccountsMetadata // The transaction metadata, set by set_tx_meta() Metadata = interpreter.Metadata diff --git a/specs.schema.json b/specs.schema.json index 5976327e..8fc65821 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -73,7 +73,7 @@ }, "expect.metadata": { - "$ref": "#/definitions/AccountsMetadata" + "$ref": "#/definitions/SetAccountsMetadata" }, "expect.error.missingFunds": { @@ -133,7 +133,7 @@ "AccountMetadataRow": { "type": "object", - "description": "A single metadata entry: the value of a given (account, key, scope)", + "description": "A single input metadata entry: the (opaque, string) value of a given (account, key, scope)", "additionalProperties": false, "required": ["account", "key", "value"], "properties": { @@ -154,10 +154,96 @@ } }, + "SetAccountsMetadata": { + "type": "array", + "description": "List of account metadata entries set during execution. Each value is a tagged value.", + "items": { "$ref": "#/definitions/SetAccountMetadataRow" } + }, + + "SetAccountMetadataRow": { + "type": "object", + "description": "A single set metadata entry: the tagged value of a given (account, key, scope)", + "additionalProperties": false, + "required": ["account", "key", "value"], + "properties": { + "account": { + "type": "string", + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + }, + "key": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Value" + }, + "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" } + "description": "Map from a metadata's key to the transaction's metadata value, in tagged form", + "additionalProperties": { "$ref": "#/definitions/Value" } + }, + + "Value": { + "type": "object", + "description": "A tagged value, discriminated by its \"type\"", + "oneOf": [ + { + "additionalProperties": false, + "required": ["type", "value"], + "properties": { + "type": { "const": "string" }, + "value": { "type": "string" } + } + }, + { + "additionalProperties": false, + "required": ["type", "value"], + "properties": { + "type": { "const": "number" }, + "value": { "type": "string", "pattern": "^-?[0-9]+$" } + } + }, + { + "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": { "const": "asset" }, + "name": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" } + } + }, + { + "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": { "const": "account" }, + "name": { "type": "string", "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" }, + "scope": { "type": "string", "pattern": "^[a-z0-9_]*$" } + } + }, + { + "additionalProperties": false, + "required": ["type", "asset", "amount"], + "properties": { + "type": { "const": "monetary" }, + "asset": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" }, + "amount": { "type": "string", "pattern": "^-?[0-9]+$" } + } + }, + { + "additionalProperties": false, + "required": ["type", "value"], + "properties": { + "type": { "const": "portion" }, + "value": { "type": "string" } + } + } + ] }, "Movements": {