From 1fea5ba0add8ca042f09a7926b540c6c5506e35e Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Mon, 18 May 2026 17:50:21 +0200 Subject: [PATCH 1/3] feat: add ResolveDependencies for concrete volume/metadata discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ParseResult.ResolveDependencies() which resolves all balance and metadata reads a script depends on, returning concrete values rather than expression trees. This enables consumers (e.g. ledger v3) to: - Preload exact volumes needed before FSM execution - Compute an input hash for optimistic concurrency control - Detect drift between admission and execution time Unlike GetInvolvedAccounts (which returns expression trees requiring consumer-side resolution), ResolveDependencies returns concrete (account, asset) → balance and (account, key) → value maps. The consumer doesn't need to walk expression trees or handle deferred resolution. All features are supported transparently: meta() chains, balance() in account addresses, mid-script function calls, oneof, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/interpreter/interpreter_error.go | 9 + internal/interpreter/recording_store.go | 58 ++++ internal/interpreter/resolve_dependencies.go | 114 ++++++++ .../interpreter/resolve_dependencies_test.go | 266 ++++++++++++++++++ numscript.go | 21 ++ 5 files changed, 468 insertions(+) create mode 100644 internal/interpreter/recording_store.go create mode 100644 internal/interpreter/resolve_dependencies.go create mode 100644 internal/interpreter/resolve_dependencies_test.go 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 6d11d1c6..04a02cc4 100644 --- a/numscript.go +++ b/numscript.go @@ -110,3 +110,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) +} From 90bcc0ed0ac8c16d293231a845772e7c4dbdf9b3 Mon Sep 17 00:00:00 2001 From: Alessandro Scandone Date: Thu, 11 Jun 2026 19:01:32 +0200 Subject: [PATCH 2/3] test: add test --- .../interpreter/resolve_dependencies_test.go | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index ac8039c5..62c22f2d 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -264,3 +264,64 @@ send balance(@acc, USD/2) ( require.ErrorAs(t, err, &forbiddenErr) require.Equal(t, flags.ExperimentalMidScriptFunctionCall, forbiddenErr.FlagName) } + +func TestResolveDependencies_Nested(t *testing.T) { + t.Parallel() + + script := `vars { + account $s1 + account $s2 = meta(@account_that_needs_meta, "k") + number $b = balance(@account_that_needs_balance, USD/2) +} + +send [COIN 100] ( + source = { + $s1 + $s2 + @source3 + @world + } + destination = @dest +) +` + + parsed := parser.Parse(script) + require.Empty(t, parsed.Errors) + + deps := resolveTest(t, + script, + map[string]string{"s1": "source1"}, + StaticStore{ + Balances: Balances{ + "source1": { + "COIN": big.NewInt(123), + }, + "source2": { + "COIN": big.NewInt(456), + }, + "source3": { + "COIN": big.NewInt(55), + }, + "account_that_needs_balance": { + "USD/2": big.NewInt(42), + }, + }, + Meta: AccountsMetadata{"account_that_needs_meta": {"k": "source2"}}, + }) + + require.Equal(t, deps.Volumes, map[string]map[string]*big.Int{ + "source1": { + "COIN": big.NewInt(123), + }, + "source2": { + "COIN": big.NewInt(456), + }, + "source3": { + "COIN": big.NewInt(55), + }, + "account_that_needs_balance": { + "USD/2": big.NewInt(42), + }, + }) + +} From a1f074bb1c1e53209e99dfb7bbbad6c101deba84 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Fri, 19 Jun 2026 13:08:58 +0200 Subject: [PATCH 3/3] refactor: replace ResolveDependencies execution with static analysis Walk the source/destination AST to collect touched (account, asset) pairs instead of fully running the script. Reads still come from phases 1-2 (parseVars + balance preloading), Writes are now a conservative over-approximation of every account mentioned as a source or destination. Also restructure the output as Reads / Writes and drop ForbiddenFlags (no longer needed now that we don't execute statements). --- internal/interpreter/interpreter_error.go | 9 - internal/interpreter/recording_store.go | 30 ++- internal/interpreter/resolve_dependencies.go | 255 ++++++++++++++---- .../interpreter/resolve_dependencies_test.go | 195 +++++++------- numscript.go | 18 +- 5 files changed, 346 insertions(+), 161 deletions(-) diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 261c4e28..3d8527c3 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -217,15 +217,6 @@ 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 index c5a85aa2..e12c735b 100644 --- a/internal/interpreter/recording_store.go +++ b/internal/interpreter/recording_store.go @@ -5,7 +5,9 @@ import ( "math/big" ) -// recordingStore wraps a Store and records all balance and metadata reads. +// recordingStore wraps a Store and records all balance and metadata reads, +// preserving the order in which the underlying store returned them. +// // It is used by ResolveDependencies to discover which data a script depends on. type recordingStore struct { inner Store @@ -27,13 +29,20 @@ func (r *recordingStore) GetBalances(ctx context.Context, query BalanceQuery) (B return nil, err } - for account, assets := range result { - if _, ok := r.balanceReads[account]; !ok { - r.balanceReads[account] = AccountBalance{} + for _, row := range result { + if r.balanceReads.hasRow(row.Account, row.Asset, row.Color) { + continue } - for asset, balance := range assets { - r.balanceReads[account][asset] = new(big.Int).Set(balance) + amount := new(big.Int) + if row.Amount != nil { + amount.Set(row.Amount) } + r.balanceReads = append(r.balanceReads, BalanceRow{ + Account: row.Account, + Asset: row.Asset, + Color: row.Color, + Amount: amount, + }) } return result, nil @@ -56,3 +65,12 @@ func (r *recordingStore) GetAccountsMetadata(ctx context.Context, query Metadata return result, nil } + +func (rows Balances) hasRow(account, asset, color string) bool { + for i := range rows { + if rows[i].Account == account && rows[i].Asset == asset && rows[i].Color == color { + return true + } + } + return false +} diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index ceb2a211..0d868ec7 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -3,46 +3,63 @@ package interpreter import ( "context" "maps" - "math/big" "slices" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" ) -// ResolvedDependencies holds the concrete data a script reads during resolution. -// The consumer can use this to preload volumes and detect input drift. +// ResolvedDependencies summarizes what a script reads from and writes to the +// store. The caller can use it to preload data and to detect input drift +// between successive runs. type ResolvedDependencies struct { - // Volumes contains all (account, asset) → balance pairs read during resolution. - Volumes map[string]map[string]*big.Int + // Reads contains the data the script read from the store while resolving. + Reads ResolvedReads - // Metadata contains all (account, key) → value pairs read during resolution. - Metadata map[string]map[string]string + // Writes contains the (account, asset, color) tuples whose balance can be + // impacted by a posting emitted by the script. + Writes ResolvedWrites +} + +// ResolvedReads holds the data read from the store while resolving the +// script's dependencies. +type ResolvedReads struct { + // Volumes contains every (account, asset, color) → balance row read from + // the store, in the order it was returned. + Volumes Balances + + // Metadata contains all (account, key) → value pairs read from the store. + Metadata AccountsMetadata +} + +// ResolvedWrites holds the data the script may write to the store. +type ResolvedWrites struct { + // Volumes lists every (account, asset, color) tuple that may be impacted + // by a posting emitted by the script. + Volumes BalanceQuery } // ResolveDependenciesOptions configures ResolveDependencies behavior. type ResolveDependenciesOptions struct { - // FeatureFlags enables additional experimental features (same as RunWithFeatureFlags). + // FeatureFlags enables additional experimental features + // (same semantics 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). +// ResolveDependencies discovers which data a script reads from the store and +// which (account, asset, color) tuples it may write to, without executing any +// posting. // -// 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. +// It performs variable resolution and source preloading — the two phases that +// RunProgram runs before executing statements — then walks the send statements +// to collect the touched accounts. No transfers are simulated, so the call is +// cheap and does not depend on the script's runtime semantics (allotments, +// overdraft, etc.). +// +// Store calls (GetBalances/GetAccountsMetadata) are issued in a deterministic +// order across runs with identical inputs, so the caller can hash them to +// detect input drift. func ResolveDependencies( ctx context.Context, program parser.Program, @@ -56,38 +73,27 @@ func ResolveDependencies( 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 { + if slices.Index(flags.AllFlags, flag.String) == -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), - + ParsedVars: make(map[string]Value), + TxMeta: make(map[string]Value), + CachedAccountsMeta: AccountsMetadata{}, + CachedBalances: InternalBalances{}, + 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 { @@ -96,19 +102,172 @@ func ResolveDependencies( } 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} } + writes := BalanceQuery{} + for _, statement := range program.Statements { + send, ok := statement.(*parser.SendStatement) + if !ok { + continue + } + if err := st.collectSendWrites(*send, &writes); err != nil { + return nil, err + } + } + return &ResolvedDependencies{ - Volumes: recorder.balanceReads, - Metadata: recorder.metadataReads, + Reads: ResolvedReads{ + Volumes: recorder.balanceReads, + Metadata: recorder.metadataReads, + }, + Writes: ResolvedWrites{Volumes: writes}, }, nil } + +func (st *programState) collectSendWrites( + send parser.SendStatement, + writes *BalanceQuery, +) InterpreterError { + asset, _, err := st.evaluateSentAmt(send.SentValue) + if err != nil { + return err + } + st.CurrentAsset = asset + + if err := st.collectSourceWrites(send.Source, writes); err != nil { + return err + } + return st.collectDestinationWrites(send.Destination, writes) +} + +func (st *programState) collectSourceWrites( + source parser.Source, + writes *BalanceQuery, +) InterpreterError { + switch source := source.(type) { + case *parser.SourceAccount: + return st.touchAccount(source.ValueExpr, source.Color, writes) + + case *parser.SourceOverdraft: + return st.touchAccount(source.Address, source.Color, writes) + + case *parser.SourceWithScaling: + return st.touchAccount(source.Address, nil, writes) + + case *parser.SourceInorder: + for _, sub := range source.Sources { + if err := st.collectSourceWrites(sub, writes); err != nil { + return err + } + } + return nil + + case *parser.SourceOneof: + for _, sub := range source.Sources { + if err := st.collectSourceWrites(sub, writes); err != nil { + return err + } + } + return nil + + case *parser.SourceCapped: + return st.collectSourceWrites(source.From, writes) + + case *parser.SourceAllotment: + for _, item := range source.Items { + if err := st.collectSourceWrites(item.From, writes); err != nil { + return err + } + } + return nil + + default: + utils.NonExhaustiveMatchPanic[any](source) + return nil + } +} + +func (st *programState) collectDestinationWrites( + dest parser.Destination, + writes *BalanceQuery, +) InterpreterError { + switch dest := dest.(type) { + case *parser.DestinationAccount: + return st.touchAccount(dest.ValueExpr, nil, writes) + + case *parser.DestinationInorder: + for _, clause := range dest.Clauses { + if err := st.collectKeptOrDestWrites(clause.To, writes); err != nil { + return err + } + } + return st.collectKeptOrDestWrites(dest.Remaining, writes) + + case *parser.DestinationOneof: + for _, clause := range dest.Clauses { + if err := st.collectKeptOrDestWrites(clause.To, writes); err != nil { + return err + } + } + return st.collectKeptOrDestWrites(dest.Remaining, writes) + + case *parser.DestinationAllotment: + for _, item := range dest.Items { + if err := st.collectKeptOrDestWrites(item.To, writes); err != nil { + return err + } + } + return nil + + default: + utils.NonExhaustiveMatchPanic[any](dest) + return nil + } +} + +func (st *programState) collectKeptOrDestWrites( + k parser.KeptOrDestination, + writes *BalanceQuery, +) InterpreterError { + switch k := k.(type) { + case *parser.DestinationKept: + return nil + case *parser.DestinationTo: + return st.collectDestinationWrites(k.Destination, writes) + default: + utils.NonExhaustiveMatchPanic[any](k) + return nil + } +} + +func (st *programState) touchAccount( + accountExpr parser.ValueExpr, + colorExpr parser.ValueExpr, + writes *BalanceQuery, +) InterpreterError { + account, err := evaluateExprAs(st, accountExpr, expectAccount) + if err != nil { + return err + } + color, err := evaluateOptExprAs(st, colorExpr, expectString) + if err != nil { + return err + } + + item := BalanceQueryItem{ + Account: string(account), + Asset: string(st.CurrentAsset), + Color: string(color), + } + if !slices.Contains(*writes, item) { + *writes = append(*writes, item) + } + return nil +} diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 62c22f2d..004c4f95 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -5,7 +5,6 @@ import ( "math/big" "testing" - "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" "github.com/stretchr/testify/require" ) @@ -22,6 +21,24 @@ func resolveTest(t *testing.T, script string, vars map[string]string, store Stor return deps } +func readVolume(b Balances, account, asset string) *big.Int { + for _, row := range b { + if row.Account == account && row.Asset == asset { + return row.Amount + } + } + return nil +} + +func hasWrite(q BalanceQuery, account, asset string) bool { + for _, item := range q { + if item.Account == account && item.Asset == asset { + return true + } + } + return false +} + func TestResolveDependencies_SimpleTransfer(t *testing.T) { t.Parallel() @@ -32,13 +49,14 @@ func TestResolveDependencies_SimpleTransfer(t *testing.T) { ) `, nil, StaticStore{ Balances: Balances{ - "alice": AccountBalance{"USD/2": big.NewInt(500)}, + {Account: "alice", Asset: "USD/2", Amount: 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"]) + require.Equal(t, big.NewInt(500), readVolume(deps.Reads.Volumes, "alice", "USD/2")) + + require.True(t, hasWrite(deps.Writes.Volumes, "alice", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "bob", "USD/2")) } func TestResolveDependencies_WorldSource(t *testing.T) { @@ -51,7 +69,9 @@ func TestResolveDependencies_WorldSource(t *testing.T) { ) `, nil, StaticStore{}) - require.Empty(t, deps.Volumes) + require.Empty(t, deps.Reads.Volumes) + require.True(t, hasWrite(deps.Writes.Volumes, "world", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "bob", "USD/2")) } func TestResolveDependencies_MetaCall(t *testing.T) { @@ -71,8 +91,8 @@ func TestResolveDependencies_MetaCall(t *testing.T) { }, }) - require.Contains(t, deps.Metadata, "config") - require.Equal(t, "treasury", deps.Metadata["config"]["default_dest"]) + require.Equal(t, "treasury", deps.Reads.Metadata["config"]["default_dest"]) + require.True(t, hasWrite(deps.Writes.Volumes, "treasury", "USD/2")) } func TestResolveDependencies_MultipleSources(t *testing.T) { @@ -88,13 +108,16 @@ func TestResolveDependencies_MultipleSources(t *testing.T) { ) `, nil, StaticStore{ Balances: Balances{ - "checking": AccountBalance{"USD/2": big.NewInt(50)}, - "savings": AccountBalance{"USD/2": big.NewInt(300)}, + {Account: "checking", Asset: "USD/2", Amount: big.NewInt(50)}, + {Account: "savings", Asset: "USD/2", Amount: big.NewInt(300)}, }, }) - require.Contains(t, deps.Volumes, "checking") - require.Contains(t, deps.Volumes, "savings") + require.NotNil(t, readVolume(deps.Reads.Volumes, "checking", "USD/2")) + require.NotNil(t, readVolume(deps.Reads.Volumes, "savings", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "checking", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "savings", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "merchant", "USD/2")) } func TestResolveDependencies_Variables(t *testing.T) { @@ -114,12 +137,13 @@ func TestResolveDependencies_Variables(t *testing.T) { "amount": "EUR/2 1000", }, StaticStore{ Balances: Balances{ - "users:alice": AccountBalance{"EUR/2": big.NewInt(5000)}, + {Account: "users:alice", Asset: "EUR/2", Amount: big.NewInt(5000)}, }, }) - require.Contains(t, deps.Volumes, "users:alice") - require.Contains(t, deps.Volumes["users:alice"], "EUR/2") + require.NotNil(t, readVolume(deps.Reads.Volumes, "users:alice", "EUR/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "users:alice", "EUR/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "dest", "EUR/2")) } func TestResolveDependencies_BalanceFunction(t *testing.T) { @@ -135,12 +159,13 @@ func TestResolveDependencies_BalanceFunction(t *testing.T) { ) `, nil, StaticStore{ Balances: Balances{ - "src": AccountBalance{"USD/2": big.NewInt(750)}, + {Account: "src", Asset: "USD/2", Amount: big.NewInt(750)}, }, }) - require.Contains(t, deps.Volumes, "src") - require.Equal(t, big.NewInt(750), deps.Volumes["src"]["USD/2"]) + require.Equal(t, big.NewInt(750), readVolume(deps.Reads.Volumes, "src", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "src", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "dest", "USD/2")) } func TestResolveDependencies_MultipleSends(t *testing.T) { @@ -157,12 +182,14 @@ func TestResolveDependencies_MultipleSends(t *testing.T) { ) `, nil, StaticStore{ Balances: Balances{ - "b": AccountBalance{"EUR/2": big.NewInt(200)}, + {Account: "b", Asset: "EUR/2", Amount: big.NewInt(200)}, }, }) - require.NotContains(t, deps.Volumes, "world") - require.Contains(t, deps.Volumes, "b") + require.Nil(t, readVolume(deps.Reads.Volumes, "world", "USD/2")) + require.NotNil(t, readVolume(deps.Reads.Volumes, "b", "EUR/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "a", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "c", "EUR/2")) } func TestResolveDependencies_SetAccountMeta(t *testing.T) { @@ -176,7 +203,8 @@ func TestResolveDependencies_SetAccountMeta(t *testing.T) { ) `, nil, StaticStore{}) - require.Empty(t, deps.Metadata, "set_account_meta should not produce metadata reads") + require.Empty(t, deps.Reads.Metadata, "set_account_meta should not produce metadata reads") + require.True(t, hasWrite(deps.Writes.Volumes, "alice", "USD/2")) } func TestResolveDependencies_MetaChain(t *testing.T) { @@ -198,10 +226,9 @@ func TestResolveDependencies_MetaChain(t *testing.T) { }, }) - 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"]) + require.Equal(t, "destination", deps.Reads.Metadata["config"]["key_name"]) + require.Equal(t, "treasury", deps.Reads.Metadata["routing"]["destination"]) + require.True(t, hasWrite(deps.Writes.Volumes, "treasury", "USD/2")) } func TestResolveDependencies_SendAll(t *testing.T) { @@ -214,12 +241,13 @@ func TestResolveDependencies_SendAll(t *testing.T) { ) `, nil, StaticStore{ Balances: Balances{ - "src": AccountBalance{"USD/2": big.NewInt(999)}, + {Account: "src", Asset: "USD/2", Amount: big.NewInt(999)}, }, }) - require.Contains(t, deps.Volumes, "src") - require.Equal(t, big.NewInt(999), deps.Volumes["src"]["USD/2"]) + require.Equal(t, big.NewInt(999), readVolume(deps.Reads.Volumes, "src", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "src", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "dest", "USD/2")) } func TestResolveDependencies_EmptyReads(t *testing.T) { @@ -232,37 +260,10 @@ func TestResolveDependencies_EmptyReads(t *testing.T) { ) `, 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) + require.Empty(t, deps.Reads.Volumes) + require.Empty(t, deps.Reads.Metadata) + require.True(t, hasWrite(deps.Writes.Volumes, "world", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "dest", "USD/2")) } func TestResolveDependencies_Nested(t *testing.T) { @@ -285,43 +286,57 @@ send [COIN 100] ( ) ` - parsed := parser.Parse(script) - require.Empty(t, parsed.Errors) - deps := resolveTest(t, script, map[string]string{"s1": "source1"}, StaticStore{ Balances: Balances{ - "source1": { - "COIN": big.NewInt(123), - }, - "source2": { - "COIN": big.NewInt(456), - }, - "source3": { - "COIN": big.NewInt(55), - }, - "account_that_needs_balance": { - "USD/2": big.NewInt(42), - }, + {Account: "source1", Asset: "COIN", Amount: big.NewInt(123)}, + {Account: "source2", Asset: "COIN", Amount: big.NewInt(456)}, + {Account: "source3", Asset: "COIN", Amount: big.NewInt(55)}, + {Account: "account_that_needs_balance", Asset: "USD/2", Amount: big.NewInt(42)}, + }, + Meta: AccountsMetadata{ + "account_that_needs_meta": {"k": "source2"}, }, - Meta: AccountsMetadata{"account_that_needs_meta": {"k": "source2"}}, }) - require.Equal(t, deps.Volumes, map[string]map[string]*big.Int{ - "source1": { - "COIN": big.NewInt(123), - }, - "source2": { - "COIN": big.NewInt(456), - }, - "source3": { - "COIN": big.NewInt(55), - }, - "account_that_needs_balance": { - "USD/2": big.NewInt(42), - }, - }) + require.Equal(t, big.NewInt(123), readVolume(deps.Reads.Volumes, "source1", "COIN")) + require.Equal(t, big.NewInt(456), readVolume(deps.Reads.Volumes, "source2", "COIN")) + require.Equal(t, big.NewInt(55), readVolume(deps.Reads.Volumes, "source3", "COIN")) + require.Equal(t, big.NewInt(42), readVolume(deps.Reads.Volumes, "account_that_needs_balance", "USD/2")) + + require.Equal(t, AccountsMetadata{ + "account_that_needs_meta": {"k": "source2"}, + }, deps.Reads.Metadata) + + // Writes is a conservative over-approximation: every account that appears + // as a source or destination is listed, even if the actual run would not + // touch all of them. + for _, acc := range []string{"source1", "source2", "source3", "world", "dest"} { + require.True(t, hasWrite(deps.Writes.Volumes, acc, "COIN"), "expected %s in writes", acc) + } +} + +func TestResolveDependencies_MidScriptBalance(t *testing.T) { + t.Parallel() + + deps := resolveTest(t, ` +#![feature("experimental-mid-script-function-call")] +send [USD/2 100] ( + source = @world + destination = @acc +) +send balance(@acc, USD/2) ( + source = @acc + destination = @dest +) +`, nil, StaticStore{}) + + // The balance call hits the store during preload, recording acc/USD/2 = 0. + require.Equal(t, big.NewInt(0), readVolume(deps.Reads.Volumes, "acc", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "world", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "acc", "USD/2")) + require.True(t, hasWrite(deps.Writes.Volumes, "dest", "USD/2")) } diff --git a/numscript.go b/numscript.go index 04a02cc4..49b52bdc 100644 --- a/numscript.go +++ b/numscript.go @@ -112,17 +112,19 @@ func (p ParseResult) GetInvolvedAccounts(vars VariablesMap) ([]accounts.Involved } type ( - ResolvedDependencies = interpreter.ResolvedDependencies - ResolveDependenciesOptions = interpreter.ResolveDependenciesOptions - ForbiddenFeatureErr = interpreter.ForbiddenFeature + ResolvedDependencies = interpreter.ResolvedDependencies + ResolvedReads = interpreter.ResolvedReads + ResolvedWrites = interpreter.ResolvedWrites + ResolveDependenciesOptions = interpreter.ResolveDependenciesOptions ) -// 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. +// ResolveDependencies executes the script in dry-run mode and returns the +// (account, asset) → balance and (account, key) → value pairs that were read +// from the store, together with the (account, asset) pairs touched by the +// resulting postings. // -// The caller can use this to preload volumes and compute an input hash -// for optimistic concurrency control. +// Store calls are issued in a deterministic order across runs with identical +// inputs, so the caller can hash them to detect input drift. func (p ParseResult) ResolveDependencies( ctx context.Context, vars VariablesMap,