diff --git a/inputs.schema.json b/inputs.schema.json index 0a07930d..5ac48162 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,21 +64,32 @@ }, "AccountsMetadata": { + "type": "array", + "description": "List of account metadata entries. The (account, key, scope) tuple of each entry must be unique within the list.", + "items": { "$ref": "#/definitions/AccountMetadataRow" } + }, + + "AccountMetadataRow": { "type": "object", - "description": "Map of an account metadata to the account's metadata", + "description": "A single metadata entry: the value of a given (account, key, scope)", "additionalProperties": false, - "patternProperties": { - "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": { - "type": "object", - "additionalProperties": { "type": "string" } + "required": ["account", "key", "value"], + "properties": { + "account": { + "type": "string", + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" } } - }, - - "TxMetadata": { - "type": "object", - "description": "Map from a metadata's key to the transaction's metadata stringied value", - "additionalProperties": { "type": "string" } } } } diff --git a/internal/analysis/check.go b/internal/analysis/check.go index e77fb661..97531390 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.ExperimentalScopedFunction, + }, + }, + }, } type Diagnostic struct { 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{}{} diff --git a/internal/cmd/test_init.go b/internal/cmd/test_init.go index 57c9f27c..5dc225a3 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{}, }, } @@ -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 } 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/__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/__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/__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/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index b3b5ed95..c59cf8ab 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -4,50 +4,42 @@ import ( "github.com/formancehq/numscript/internal/utils" ) -type AccountMetadata = map[string]string -type AccountsMetadata map[string]AccountMetadata - -func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { - return utils.MapGetOrPutDefault(m, account, func() AccountMetadata { - return AccountMetadata{} - }) +type AccountMetadataRow struct { + Account string `json:"account"` + Key string `json:"key"` + Value string `json:"value"` + Scope string `json:"scope,omitempty"` } -func (m AccountsMetadata) DeepClone() AccountsMetadata { - cloned := make(AccountsMetadata) - for account, accountBalances := range m { - for asset, metadataValue := range accountBalances { - clonedAccountBalances := cloned.fetchAccountMetadata(account) - utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string { - return metadataValue - }) - } - } - return cloned -} - -func (m AccountsMetadata) Merge(update AccountsMetadata) { - for acc, accBalances := range update { - cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata { - return AccountMetadata{} - }) - - for curr, amt := range accBalances { - cachedAcc[curr] = amt +// AccountsMetadata is the external, serialized representation of account +// metadata. The runtime works with the in-memory InternalAccountsMetadata and +// converts to this at the boundaries (store queries, execution result). +type AccountsMetadata []AccountMetadataRow + +// FirstDuplicate returns the first row whose (account, key, scope) key already +// appeared earlier in the list, if any. That triple is the identity of a +// metadata entry and the value is its content, so a repeated key is an +// ambiguous, malformed input. +func (rows AccountsMetadata) FirstDuplicate() (AccountMetadataRow, bool) { + seen := make(map[[3]string]struct{}, len(rows)) + for _, row := range rows { + key := [3]string{row.Account, row.Key, row.Scope} + if _, ok := seen[key]; ok { + return row, true } + seen[key] = struct{}{} } + return AccountMetadataRow{}, false } func (m AccountsMetadata) PrettyPrint() string { - header := []string{"Account", "Name", "Value"} + // the Scope column is dropped automatically when no entry has a scope + header := []string{"Account", "Scope", "Name", "Value"} var rows [][]string - for account, accMetadata := range m { - for name, value := range accMetadata { - row := []string{account, name, value} - rows = append(rows, row) - } + for _, row := range m { + rows = append(rows, []string{row.Account, row.Scope, row.Key, row.Value}) } - return utils.CsvPretty(header, rows, true) + return utils.CsvPrettyOmitEmptyCols(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..2a750e1d --- /dev/null +++ b/internal/interpreter/accounts_metadata_test.go @@ -0,0 +1,67 @@ +package interpreter + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" +) + +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, CompareSetAccountsMetadata( + SetAccountsMetadata{x, y}, + SetAccountsMetadata{y, x}, + )) + }) + + t.Run("different value is not equal", func(t *testing.T) { + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x}, + SetAccountsMetadata{y}, + )) + }) + + t.Run("respects multiplicity: [x, x] != [x, y]", func(t *testing.T) { + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, x}, + SetAccountsMetadata{x, y}, + )) + // and the symmetric direction + require.False(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, y}, + SetAccountsMetadata{x, x}, + )) + }) + + t.Run("identical multisets are equal", func(t *testing.T) { + require.True(t, CompareSetAccountsMetadata( + SetAccountsMetadata{x, x}, + SetAccountsMetadata{x, x}, + )) + }) +} + +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()) + }) +} 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..68888a00 --- /dev/null +++ b/internal/interpreter/append_scope_test.go @@ -0,0 +1,26 @@ +package interpreter + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +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..6a9bf964 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" ) @@ -12,6 +11,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 +20,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 } @@ -32,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 { @@ -50,19 +42,15 @@ 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), 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 } } @@ -84,13 +72,35 @@ 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. 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/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/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/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 84bd6c1a..60163920 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -62,19 +62,23 @@ 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.Name, + Scope: account.Scope, + Key: string(key), + Range: rng, + } } return value, nil @@ -104,7 +108,8 @@ func balance( if balance.Cmp(big.NewInt(0)) == -1 { return Monetary{}, NegativeBalanceError{ - Account: string(account), + Account: account.Name, + Scope: account.Scope, Amount: *balance, } } @@ -157,3 +162,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..6a0c1430 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) 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..22b8ea46 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 { @@ -36,7 +53,7 @@ type ExecutionResult struct { Metadata Metadata `json:"txMeta"` - AccountsMetadata AccountsMetadata `json:"accountsMeta"` + AccountsMetadata SetAccountsMetadata `json:"accountsMeta"` } func parseMonetary(source string) (Monetary, InterpreterError) { @@ -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} @@ -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: internalSetAccountsMeta{}, 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 internalSetAccountsMeta - 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,10 @@ 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.Name, + Scope: account.Scope, } } @@ -572,7 +580,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 +681,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 +743,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 +866,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 +967,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: @@ -1149,29 +1157,23 @@ func CalculateSafeWithdraw( } func PrettyPrintPostings(postings []Posting) string { - // the Color column is shown only when at least one posting has a color - hasColor := slices.ContainsFunc(postings, func(posting Posting) bool { - return posting.Color != "" - }) - - var header []string - if hasColor { - header = []string{"Source", "Destination", "Asset", "Color", "Amount"} - } else { - header = []string{"Source", "Destination", "Asset", "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 { - var row []string - if hasColor { - row = []string{posting.Source, posting.Destination, posting.Asset, posting.Color, posting.Amount.String()} - } else { - row = []string{posting.Source, posting.Destination, posting.Asset, 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/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 3d8527c3..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 @@ -286,3 +299,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..88342947 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{ @@ -278,6 +276,81 @@ 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 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 *] ( @@ -526,7 +599,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 +739,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 +798,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 +865,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 +1050,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/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/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)) + }) +} 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/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/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/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/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 + } + ] + } + ] +} 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 + } + ] + } + ] +} 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..2aaf4e08 --- /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": { "type": "string", "value": "unscoped" } }, + { "account": "acc", "scope": "myscope", "key": "k", "value": { "type": "string", "value": "scoped" } } + ] + } + ] +} 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..5066eb83 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/scoped-function/simple.num.specs.json @@ -0,0 +1,40 @@ +{ + "$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" + } + ], + "expect.movements": [ + { + "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..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 @@ -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": { "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 5fa713ab..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 @@ -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": { "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 79e2bf00..e444aa7a 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,29 +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(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 { - return string(v) + if v.Scope == "" { + return fmt.Sprintf(`@%s`, v.Name) + } + return fmt.Sprintf(`scoped(%s, "%s")`, v.Name, v.Scope) } func (v MonetaryInt) String() string { @@ -150,7 +291,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..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("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/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) +} diff --git a/internal/specs_format/__snapshots__/runner_test.snap b/internal/specs_format/__snapshots__/runner_test.snap index f50e91d0..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) @@ -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/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 c4065041..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,24 +198,30 @@ 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, ) } if testCase.ExpectAccountsMeta != nil { - failedAssertions = runAssertion[any](failedAssertions, + failedAssertions = runAssertion(failedAssertions, "expect.metadata", testCase.ExpectAccountsMeta, result.AccountsMetadata, - reflect.DeepEqual, + interpreter.CompareSetAccountsMetadata, ) } @@ -264,10 +293,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 @@ -279,10 +325,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 } @@ -292,9 +344,20 @@ 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) } +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 { @@ -303,7 +366,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 { @@ -325,35 +388,43 @@ 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 } + // 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.Destination + "\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 } } @@ -368,7 +439,9 @@ func getMovements(postings []interpreter.Posting) Movements { for i := range movements { m := &movements[i] 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) @@ -379,11 +452,13 @@ func getMovements(postings []interpreter.Posting) Movements { if !found { movements = append(movements, Movement{ - Source: posting.Source, - Destination: posting.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), }) } } @@ -392,18 +467,19 @@ func getMovements(postings []interpreter.Posting) Movements { } 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 +490,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 +526,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/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 diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap index 18e0527e..0204889b 100755 --- a/internal/utils/__snapshots__/pretty_csv_test.snap +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -12,3 +12,19 @@ | 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 | +--- + +[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 1703b56f..035acd13 100644 --- a/internal/utils/pretty_csv.go +++ b/internal/utils/pretty_csv.go @@ -74,6 +74,54 @@ 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 { + // 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 { + 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..e204fbf3 100644 --- a/internal/utils/pretty_csv_test.go +++ b/internal/utils/pretty_csv_test.go @@ -19,6 +19,38 @@ 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("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", + }, [][]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", diff --git a/numscript.go b/numscript.go index 8adf703d..3c377782 100644 --- a/numscript.go +++ b/numscript.go @@ -59,10 +59,13 @@ type ( Balances = interpreter.Balances BalanceRow = interpreter.BalanceRow - AccountMetadata = interpreter.AccountMetadata + // 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/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..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": { @@ -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,26 +126,129 @@ }, "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 input metadata entry: the (opaque, string) 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_]*$" + } + } + }, + + "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": { "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" } }, @@ -155,10 +262,18 @@ "type": "string", "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_-]+)*)$" }, + "destinationScope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" + }, "asset": { "type": "string", "pattern": "^([A-Z]+(/[0-9]+)?)$" @@ -177,7 +292,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]+)?)$"