diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 3d8527c3..261c4e28 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -217,6 +217,15 @@ func (e ExperimentalFeature) Error() string { return fmt.Sprintf("this feature is experimental. You need the '%s' feature flag to enable it", e.FlagName) } +type ForbiddenFeature struct { + parser.Range + FlagName string +} + +func (e ForbiddenFeature) Error() string { + return fmt.Sprintf("feature '%s' is forbidden by the caller", e.FlagName) +} + type CannotCastToString struct { parser.Range Value Value diff --git a/internal/interpreter/recording_store.go b/internal/interpreter/recording_store.go new file mode 100644 index 00000000..c5a85aa2 --- /dev/null +++ b/internal/interpreter/recording_store.go @@ -0,0 +1,58 @@ +package interpreter + +import ( + "context" + "math/big" +) + +// recordingStore wraps a Store and records all balance and metadata reads. +// It is used by ResolveDependencies to discover which data a script depends on. +type recordingStore struct { + inner Store + balanceReads Balances + metadataReads AccountsMetadata +} + +func newRecordingStore(inner Store) *recordingStore { + return &recordingStore{ + inner: inner, + balanceReads: Balances{}, + metadataReads: AccountsMetadata{}, + } +} + +func (r *recordingStore) GetBalances(ctx context.Context, query BalanceQuery) (Balances, error) { + result, err := r.inner.GetBalances(ctx, query) + if err != nil { + return nil, err + } + + for account, assets := range result { + if _, ok := r.balanceReads[account]; !ok { + r.balanceReads[account] = AccountBalance{} + } + for asset, balance := range assets { + r.balanceReads[account][asset] = new(big.Int).Set(balance) + } + } + + return result, nil +} + +func (r *recordingStore) GetAccountsMetadata(ctx context.Context, query MetadataQuery) (AccountsMetadata, error) { + result, err := r.inner.GetAccountsMetadata(ctx, query) + if err != nil { + return nil, err + } + + for account, meta := range result { + if _, ok := r.metadataReads[account]; !ok { + r.metadataReads[account] = AccountMetadata{} + } + for key, value := range meta { + r.metadataReads[account][key] = value + } + } + + return result, nil +} diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go new file mode 100644 index 00000000..ceb2a211 --- /dev/null +++ b/internal/interpreter/resolve_dependencies.go @@ -0,0 +1,114 @@ +package interpreter + +import ( + "context" + "maps" + "math/big" + "slices" + + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/parser" +) + +// ResolvedDependencies holds the concrete data a script reads during resolution. +// The consumer can use this to preload volumes and detect input drift. +type ResolvedDependencies struct { + // Volumes contains all (account, asset) → balance pairs read during resolution. + Volumes map[string]map[string]*big.Int + + // Metadata contains all (account, key) → value pairs read during resolution. + Metadata map[string]map[string]string +} + +// ResolveDependenciesOptions configures ResolveDependencies behavior. +type ResolveDependenciesOptions struct { + // FeatureFlags enables additional experimental features (same as RunWithFeatureFlags). + FeatureFlags map[string]struct{} + + // ForbiddenFlags rejects scripts that declare any of these features. + // This takes precedence over script-level #![feature("...")] directives. + // Use this to block features that ResolveDependencies cannot fully resolve + // (e.g. experimental-mid-script-function-call). + ForbiddenFlags map[string]struct{} +} + +// ResolveDependencies discovers which balances and metadata a script will read +// by performing variable resolution and balance preloading — the same two phases +// that RunProgram does before executing statements. It does NOT execute the +// statements themselves (no postings are produced). +// +// This covers all store reads for scripts that don't use +// experimental-mid-script-function-call. Scripts using that feature may trigger +// additional balance reads during execution (e.g. balance() called between two +// send statements, where the result depends on the first send's postings). +// Consumers that cannot tolerate incomplete dependency lists should forbid this +// feature via ForbiddenFlags. +func ResolveDependencies( + ctx context.Context, + program parser.Program, + vars map[string]string, + store Store, + opts ResolveDependenciesOptions, +) (*ResolvedDependencies, InterpreterError) { + recorder := newRecordingStore(store) + + featureFlags := maps.Clone(opts.FeatureFlags) + if featureFlags == nil { + featureFlags = make(map[string]struct{}, len(program.Flags)) + } + + for _, flag := range program.Flags { + index := slices.Index(flags.AllFlags, flag.String) + if index == -1 { + return nil, InvalidFeature{Feature: flag.String} + } + + if _, forbidden := opts.ForbiddenFlags[flag.String]; forbidden { + return nil, ForbiddenFeature{FlagName: flag.String} + } + + featureFlags[flag.String] = struct{}{} + } + + // Replicate the initialization and preload phases of RunProgram, + // but stop before statement execution. + st := programState{ + ParsedVars: make(map[string]Value), + TxMeta: make(map[string]Value), + CachedAccountsMeta: AccountsMetadata{}, + CachedBalances: Balances{}, + SetAccountsMeta: AccountsMetadata{}, + Store: recorder, + Postings: make([]Posting, 0), + fundsQueue: newFundsQueue(nil), + + CurrentBalanceQuery: BalanceQuery{}, + ctx: ctx, + FeatureFlags: featureFlags, + } + + // Phase 1: parse variables — resolves meta(), balance(), overdraft() origins. + st.varOriginPosition = true + if program.Vars != nil { + if err := st.parseVars(program.Vars.Declarations, vars); err != nil { + return nil, err + } + } + st.varOriginPosition = false + + // Phase 2: traverse statement ASTs to discover balance needs, then preload. + for _, statement := range program.Statements { + if err := st.findBalancesQueriesInStatement(statement); err != nil { + return nil, err + } + } + + if err := st.runBalancesQuery(); err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + + return &ResolvedDependencies{ + Volumes: recorder.balanceReads, + Metadata: recorder.metadataReads, + }, nil +} diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go new file mode 100644 index 00000000..ac8039c5 --- /dev/null +++ b/internal/interpreter/resolve_dependencies_test.go @@ -0,0 +1,266 @@ +package interpreter + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/parser" + "github.com/stretchr/testify/require" +) + +func resolveTest(t *testing.T, script string, vars map[string]string, store Store) *ResolvedDependencies { + t.Helper() + parsed := parser.Parse(script) + require.Empty(t, parsed.Errors, "script should parse without errors") + + deps, err := ResolveDependencies(context.Background(), parsed.Value, vars, store, ResolveDependenciesOptions{}) + require.NoError(t, err) + require.NotNil(t, deps) + + return deps +} + +func TestResolveDependencies_SimpleTransfer(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 100] ( + source = @alice + destination = @bob + ) + `, nil, StaticStore{ + Balances: Balances{ + "alice": AccountBalance{"USD/2": big.NewInt(500)}, + }, + }) + + require.Contains(t, deps.Volumes, "alice") + require.Contains(t, deps.Volumes["alice"], "USD/2") + require.Equal(t, big.NewInt(500), deps.Volumes["alice"]["USD/2"]) +} + +func TestResolveDependencies_WorldSource(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 100] ( + source = @world + destination = @bob + ) + `, nil, StaticStore{}) + + require.Empty(t, deps.Volumes) +} + +func TestResolveDependencies_MetaCall(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + vars { + account $dest = meta(@config, "default_dest") + } + send [USD/2 100] ( + source = @world + destination = $dest + ) + `, nil, StaticStore{ + Meta: AccountsMetadata{ + "config": AccountMetadata{"default_dest": "treasury"}, + }, + }) + + require.Contains(t, deps.Metadata, "config") + require.Equal(t, "treasury", deps.Metadata["config"]["default_dest"]) +} + +func TestResolveDependencies_MultipleSources(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 200] ( + source = { + @checking + @savings + } + destination = @merchant + ) + `, nil, StaticStore{ + Balances: Balances{ + "checking": AccountBalance{"USD/2": big.NewInt(50)}, + "savings": AccountBalance{"USD/2": big.NewInt(300)}, + }, + }) + + require.Contains(t, deps.Volumes, "checking") + require.Contains(t, deps.Volumes, "savings") +} + +func TestResolveDependencies_Variables(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + vars { + account $src + monetary $amount + } + send $amount ( + source = $src + destination = @dest + ) + `, map[string]string{ + "src": "users:alice", + "amount": "EUR/2 1000", + }, StaticStore{ + Balances: Balances{ + "users:alice": AccountBalance{"EUR/2": big.NewInt(5000)}, + }, + }) + + require.Contains(t, deps.Volumes, "users:alice") + require.Contains(t, deps.Volumes["users:alice"], "EUR/2") +} + +func TestResolveDependencies_BalanceFunction(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + vars { + monetary $bal = balance(@src, USD/2) + } + send $bal ( + source = @src + destination = @dest + ) + `, nil, StaticStore{ + Balances: Balances{ + "src": AccountBalance{"USD/2": big.NewInt(750)}, + }, + }) + + require.Contains(t, deps.Volumes, "src") + require.Equal(t, big.NewInt(750), deps.Volumes["src"]["USD/2"]) +} + +func TestResolveDependencies_MultipleSends(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 50] ( + source = @world + destination = @a + ) + send [EUR/2 100] ( + source = @b + destination = @c + ) + `, nil, StaticStore{ + Balances: Balances{ + "b": AccountBalance{"EUR/2": big.NewInt(200)}, + }, + }) + + require.NotContains(t, deps.Volumes, "world") + require.Contains(t, deps.Volumes, "b") +} + +func TestResolveDependencies_SetAccountMeta(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + set_account_meta(@alice, "status", "active") + send [USD/2 100] ( + source = @world + destination = @alice + ) + `, nil, StaticStore{}) + + require.Empty(t, deps.Metadata, "set_account_meta should not produce metadata reads") +} + +func TestResolveDependencies_MetaChain(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + vars { + string $key = meta(@config, "key_name") + account $dest = meta(@routing, $key) + } + send [USD/2 100] ( + source = @world + destination = $dest + ) + `, nil, StaticStore{ + Meta: AccountsMetadata{ + "config": AccountMetadata{"key_name": "destination"}, + "routing": AccountMetadata{"destination": "treasury"}, + }, + }) + + require.Contains(t, deps.Metadata, "config") + require.Equal(t, "destination", deps.Metadata["config"]["key_name"]) + require.Contains(t, deps.Metadata, "routing") + require.Equal(t, "treasury", deps.Metadata["routing"]["destination"]) +} + +func TestResolveDependencies_SendAll(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 *] ( + source = @src + destination = @dest + ) + `, nil, StaticStore{ + Balances: Balances{ + "src": AccountBalance{"USD/2": big.NewInt(999)}, + }, + }) + + require.Contains(t, deps.Volumes, "src") + require.Equal(t, big.NewInt(999), deps.Volumes["src"]["USD/2"]) +} + +func TestResolveDependencies_EmptyReads(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` + send [USD/2 100] ( + source = @world + destination = @dest + ) + `, nil, StaticStore{}) + + require.Empty(t, deps.Volumes) + require.Empty(t, deps.Metadata) +} + +func TestResolveDependencies_ForbiddenFlag(t *testing.T) { + t.Parallel() + + script := ` +#![feature("experimental-mid-script-function-call")] +send [USD/2 100] ( + source = @world + destination = @acc +) +send balance(@acc, USD/2) ( + source = @acc + destination = @dest +) +` + parsed := parser.Parse(script) + require.Empty(t, parsed.Errors) + + _, err := ResolveDependencies(context.Background(), parsed.Value, nil, StaticStore{}, ResolveDependenciesOptions{ + ForbiddenFlags: map[string]struct{}{ + flags.ExperimentalMidScriptFunctionCall: {}, + }, + }) + require.Error(t, err) + + var forbiddenErr ForbiddenFeature + require.ErrorAs(t, err, &forbiddenErr) + require.Equal(t, flags.ExperimentalMidScriptFunctionCall, forbiddenErr.FlagName) +} diff --git a/numscript.go b/numscript.go index e7fa0437..af9d5d3a 100644 --- a/numscript.go +++ b/numscript.go @@ -104,3 +104,24 @@ func (p ParseResult) GetSource() string { func (p ParseResult) GetInvolvedAccounts(vars VariablesMap) ([]accounts.InvolvedAccount, []accounts.InvolvedMeta, InterpreterError) { return interpreter.GetInvolvedAccounts(vars, p.parseResult.Value) } + +type ( + ResolvedDependencies = interpreter.ResolvedDependencies + ResolveDependenciesOptions = interpreter.ResolveDependenciesOptions + ForbiddenFeatureErr = interpreter.ForbiddenFeature +) + +// ResolveDependencies discovers which balances and metadata a script reads +// by resolving all dependencies against the provided store. Returns the +// concrete (account, asset) → balance and (account, key) → value pairs. +// +// The caller can use this to preload volumes and compute an input hash +// for optimistic concurrency control. +func (p ParseResult) ResolveDependencies( + ctx context.Context, + vars VariablesMap, + store Store, + opts ResolveDependenciesOptions, +) (*ResolvedDependencies, InterpreterError) { + return interpreter.ResolveDependencies(ctx, p.parseResult.Value, vars, store, opts) +}