diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index 15cb2070..33f5e1fe 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -15,7 +15,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen return nil case *parser.SaveStatement: - asset, _, err := st.evaluateSentAmt(statement.SentValue) + asset, _, err := evaluateSentAmt(&st.evalEnv, statement.SentValue) if err != nil { return err } @@ -27,7 +27,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen // // this would mean that the "save" statement was not needed in the first place, // so preventing this query would hardly be an useful optimization - account, err := evaluateExprAs(st, statement.Account, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, statement.Account, expectAccount) if err != nil { return err } @@ -35,7 +35,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen return nil case *parser.SendStatement: - asset, _, err := st.evaluateSentAmt(statement.SentValue) + asset, _, err := evaluateSentAmt(&st.evalEnv, statement.SentValue) if err != nil { return err } @@ -90,12 +90,12 @@ func (st *programState) runBalancesQuery() error { func (st *programState) findBalancesQueries(source parser.Source) InterpreterError { switch source := source.(type) { case *parser.SourceAccount: - account, err := evaluateExprAs(st, source.ValueExpr, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.ValueExpr, expectAccount) if err != nil { return err } - color, err := evaluateOptExprAs(st, source.Color, expectString) + color, err := evaluateOptExprAs(&st.evalEnv, source.Color, expectString) if err != nil { return err } @@ -104,7 +104,7 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return nil case *parser.SourceWithScaling: - account, err := evaluateExprAs(st, source.Address, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.Address, expectAccount) if err != nil { return err } @@ -119,11 +119,11 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return nil } - account, err := evaluateExprAs(st, source.Address, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.Address, expectAccount) if err != nil { return err } - color, err := evaluateOptExprAs(st, source.Color, expectString) + color, err := evaluateOptExprAs(&st.evalEnv, source.Color, expectString) if err != nil { return err } diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 5fb5c7d3..98e742b4 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -1,15 +1,106 @@ package interpreter import ( + "context" "math/big" "strings" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" - "github.com/formancehq/numscript/internal/utils" ) -func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterError) { +type evalEnv struct { + ctx context.Context + Store Store + FeatureFlags map[string]struct{} + + vars map[string]Value + CachedBalances InternalBalances + CachedAccountsMeta InternalAccountsMetadata +} + +func newEvalEnv(ctx context.Context, store Store, featureFlags map[string]struct{}, varDecls *parser.VarDeclarations, rawVars map[string]string) (evalEnv, InterpreterError) { + env := evalEnv{ + ctx: ctx, + Store: store, + FeatureFlags: featureFlags, + vars: map[string]Value{}, + CachedBalances: InternalBalances{}, + CachedAccountsMeta: InternalAccountsMetadata{}, + } + if err := bindVars(&env, varDecls, rawVars); err != nil { + return evalEnv{}, err + } + return env, nil +} + +func (env *evalEnv) checkFeatureFlag(flag string) InterpreterError { + // a nil set enables every feature (e.g. dependency resolution) + if env.FeatureFlags == nil { + return nil + } + if _, ok := env.FeatureFlags[flag]; ok { + return nil + } + return ExperimentalFeature{FlagName: flag} +} + +func (env *evalEnv) getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { + color := String("") + if !env.CachedBalances.has(account, string(asset), string(color)) { + rows, err := env.Store.GetBalances(env.ctx, BalanceQuery{ + {Account: account.Name, Asset: string(asset), Color: string(color), Scope: account.Scope}, + }) + if err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + env.CachedBalances.Merge(rows) + } + return env.CachedBalances.fetchBalance(account, asset, color), nil +} + +func (env *evalEnv) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { + if value, ok := env.CachedAccountsMeta.Get(account.Name, account.Scope, key); ok { + return value, true, nil + } + + rows, err := env.Store.GetAccountsMetadata(env.ctx, MetadataQuery{ + {Account: account.Name, Scope: account.Scope, Keys: []string{key}}, + }) + if err != nil { + return "", false, QueryMetadataError{WrappedError: err} + } + env.CachedAccountsMeta.Merge(rows) + + value, ok := env.CachedAccountsMeta.Get(account.Name, account.Scope, key) + return value, ok, nil +} + +func bindVars(env *evalEnv, varDecls *parser.VarDeclarations, rawVars map[string]string) InterpreterError { + if varDecls == nil { + return nil + } + for _, decl := range varDecls.Declarations { + var value Value + var err InterpreterError + if decl.Origin == nil { + raw, ok := rawVars[decl.Name.Name] + if !ok { + return MissingVariableErr{Name: decl.Name.Name} + } + value, err = parseVar(decl.Type.Name, raw, decl.Type.Range) + } else { + value, err = evaluateVarOrigin(env, decl.Type.Name, *decl.Origin) + } + if err != nil { + return err + } + env.vars[decl.Name.Name] = value + } + return nil +} + +func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) { switch expr := expr.(type) { case *parser.AssetLiteral: return Asset(expr.Asset), nil @@ -20,12 +111,12 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case parser.AccountTextPart: parts = append(parts, part.Name) case *parser.Variable: - err := st.checkFeatureFlag(flags.ExperimentalAccountInterpolationFlag) + err := env.checkFeatureFlag(flags.ExperimentalAccountInterpolationFlag) if err != nil { return nil, err } - value, err := st.evaluateExpr(part) + value, err := evaluateExpr(env, part) if err != nil { return nil, err } @@ -34,6 +125,9 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE return nil, err } parts = append(parts, strValue) + + default: + return nil, unhandledErr(part) } } name := strings.Join(parts, "") @@ -46,12 +140,12 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case *parser.NumberLiteral: return MonetaryInt(*expr.Number), nil case *parser.MonetaryLiteral: - asset, err := evaluateExprAs(st, expr.Asset, expectAsset) + asset, err := evaluateExprAs(env, expr.Asset, expectAsset) if err != nil { return nil, err } - amount, err := evaluateExprAs(st, expr.Amount, expectNumber) + amount, err := evaluateExprAs(env, expr.Amount, expectNumber) if err != nil { return nil, err } @@ -59,8 +153,8 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE return Monetary{Asset: asset, Amount: amount}, nil case *parser.Variable: - value, ok := st.ParsedVars[expr.Name] - if !ok { + value := env.vars[expr.Name] + if value == nil { return nil, UnboundVariableErr{ Name: expr.Name, Range: expr.Range, @@ -71,53 +165,47 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case *parser.BinaryInfix: switch expr.Operator { case parser.InfixOperatorPlus: - return st.plusOp(expr.Left, expr.Right) + return plusOp(env, expr.Left, expr.Right) case parser.InfixOperatorMinus: - return st.subOp(expr.Left, expr.Right) + return subOp(env, expr.Left, expr.Right) case parser.InfixOperatorDiv: - return st.divOp(expr.Range, expr.Left, expr.Right) + return divOp(env, expr.Range, expr.Left, expr.Right) default: - utils.NonExhaustiveMatchPanic[any](expr.Operator) - return nil, nil + return nil, unhandledErr(expr.Operator) } case *parser.Prefix: switch expr.Operator { case parser.PrefixOperatorMinus: - return st.unaryNegOp(expr.Expr) + return unaryNegOp(env, expr.Expr) default: - utils.NonExhaustiveMatchPanic[any](expr.Operator) - return nil, nil + return nil, unhandledErr(expr.Operator) } case *parser.FnCall: - - if err := st.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall); err != nil { - return nil, err - } - return st.handleFnCall(nil, *expr) + // nil type: not a direct var origin, hence a mid-script call. + return evaluateFnCall(env, nil, *expr) default: - utils.NonExhaustiveMatchPanic[any](expr) - return nil, nil + return nil, unhandledErr(expr) } } -func evaluateOptExprAs[T any](st *programState, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateOptExprAs[T any](env *evalEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var t T if expr == nil { return t, nil } - return evaluateExprAs(st, expr, expect) + return evaluateExprAs(env, expr, expect) } -func evaluateExprAs[T any](st *programState, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateExprAs[T any](env *evalEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var default_ T - value, err := st.evaluateExpr(expr) + value, err := evaluateExpr(env, expr) if err != nil { return default_, err } @@ -130,10 +218,10 @@ func evaluateExprAs[T any](st *programState, expr parser.ValueExpr, expect func( return res, nil } -func (st *programState) evaluateExpressions(literals []parser.ValueExpr) ([]Value, InterpreterError) { +func evaluateExpressions(env *evalEnv, literals []parser.ValueExpr) ([]Value, InterpreterError) { var values []Value for _, argLit := range literals { - value, err := st.evaluateExpr(argLit) + value, err := evaluateExpr(env, argLit) if err != nil { return nil, err } @@ -143,7 +231,7 @@ func (st *programState) evaluateExpressions(literals []parser.ValueExpr) ([]Valu } func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, InterpreterError) { - color, err := evaluateOptExprAs(s, colorExpr, expectString) + color, err := evaluateOptExprAs(&s.evalEnv, colorExpr, expectString) if err != nil { return "", err } @@ -159,9 +247,9 @@ func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, Interp return color, nil } -func (st *programState) plusOp(left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { +func plusOp(env *evalEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectOneOf( + leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opAdd { return m }), @@ -176,11 +264,11 @@ func (st *programState) plusOp(left parser.ValueExpr, right parser.ValueExpr) (V return nil, err } - return leftValue.evalAdd(st, right) + return leftValue.evalAdd(env, right) } -func (st *programState) subOp(left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectOneOf( +func subOp(env *evalEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { + leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opSub { return m }), @@ -193,16 +281,16 @@ func (st *programState) subOp(left parser.ValueExpr, right parser.ValueExpr) (Va return nil, err } - return leftValue.evalSub(st, right) + return leftValue.evalSub(env, right) } -func (st *programState) divOp(rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectNumber) +func divOp(env *evalEnv, rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { + leftValue, err := evaluateExprAs(env, left, expectNumber) if err != nil { return nil, err } - rightValue, err := evaluateExprAs(st, right, expectNumber) + rightValue, err := evaluateExprAs(env, right, expectNumber) if err != nil { return nil, err } @@ -221,8 +309,8 @@ func (st *programState) divOp(rng parser.Range, left parser.ValueExpr, right par return Portion(*rat), nil } -func (st *programState) unaryNegOp(expr parser.ValueExpr) (Value, InterpreterError) { - evExpr, err := evaluateExprAs(st, expr, expectOneOf( +func unaryNegOp(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) { + evExpr, err := evaluateExprAs(env, expr, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opNeg { return m }), @@ -237,7 +325,7 @@ func (st *programState) unaryNegOp(expr parser.ValueExpr) (Value, InterpreterErr return nil, err } - return evExpr.evalNeg(st) + return evExpr.evalNeg(env) } func castToString(v Value, rng parser.Range) (string, InterpreterError) { diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 8d523b40..73efeb11 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -3,16 +3,67 @@ package interpreter import ( "math/big" + "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" ) +func evaluateFnCall(env *evalEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { + if type_ == nil { + if err := env.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall); err != nil { + return nil, err + } + } + + args, err := evaluateExpressions(env, fnCall.Args) + if err != nil { + return nil, err + } + + switch fnCall.Caller.Name { + case analysis.FnVarOriginMeta: + if type_ == nil { + return nil, InvalidNestedMeta{} + } + + rawValue, err := meta(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return parseVar(*type_, rawValue, fnCall.Range) + + case analysis.FnVarOriginBalance: + monetary, err := balance(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return monetary, nil + + case analysis.FnVarOriginOverdraft: + monetary, err := overdraft(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return monetary, nil + + case analysis.FnVarOriginGetAsset: + return getAsset(env, fnCall.Range, args) + case analysis.FnVarOriginGetAmount: + return getAmount(env, fnCall.Range, args) + case analysis.FnVarOriginScoped: + return scoped(env, fnCall.Range, args) + + default: + return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} + } +} + func overdraft( - s *programState, + env *evalEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalOverdraftFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalOverdraftFunctionFeatureFlag) if err != nil { return Monetary{}, err } @@ -27,7 +78,7 @@ func overdraft( } // overdraft call doesn't handle colors - balance_, err := getBalance(s, account, asset) + balance_, err := env.getBalance(account, asset) if err != nil { return Monetary{}, err } @@ -48,7 +99,7 @@ func overdraft( } func meta( - s *programState, + env *evalEnv, rng parser.Range, args []Value, ) (string, InterpreterError) { @@ -61,16 +112,10 @@ func meta( return "", err } - meta, fetchMetaErr := s.Store.GetAccountsMetadata(s.ctx, MetadataQuery{ - {Account: account.Name, Scope: account.Scope, Keys: []string{string(key)}}, - }) - if fetchMetaErr != nil { - return "", QueryMetadataError{WrappedError: fetchMetaErr} + value, ok, err := env.getMetadata(account, string(key)) + if err != nil { + return "", err } - s.CachedAccountsMeta = FromAccountsMetadataRows(meta) - - // body - value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, string(key)) if !ok { return "", MetadataNotFound{ @@ -85,7 +130,7 @@ func meta( } func balance( - s *programState, + env *evalEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { @@ -101,7 +146,7 @@ func balance( // body // balance call doesn't handle colors - balance, err := getBalance(s, account, asset) + balance, err := env.getBalance(account, asset) if err != nil { return Monetary{}, err } @@ -124,11 +169,11 @@ func balance( } func getAsset( - s *programState, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalGetAssetFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalGetAssetFunctionFeatureFlag) if err != nil { return nil, err } @@ -144,11 +189,11 @@ func getAsset( } func getAmount( - s *programState, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalGetAmountFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalGetAmountFunctionFeatureFlag) if err != nil { return nil, err } @@ -164,11 +209,11 @@ func getAmount( } func scoped( - s *programState, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalScopedFunction) + err := env.checkFeatureFlag(flags.ExperimentalScopedFunction) if err != nil { return nil, err } diff --git a/internal/interpreter/infix_overloads.go b/internal/interpreter/infix_overloads.go index e7a47cf2..411f7e62 100644 --- a/internal/interpreter/infix_overloads.go +++ b/internal/interpreter/infix_overloads.go @@ -7,15 +7,15 @@ import ( ) type opAdd interface { - evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) + evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opAdd = (*MonetaryInt)(nil) var _ opAdd = (*Monetary)(nil) -func (m MonetaryInt) evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) - m2, err := evaluateExprAs(st, other, expectNumber) + m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { return nil, err } @@ -25,8 +25,8 @@ func (m MonetaryInt) evalAdd(st *programState, other parser.ValueExpr) (Value, I return MonetaryInt(*sum), nil } -func (m Monetary) evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) { - m2, err := evaluateExprAs(st, other, expectMonetary) +func (m Monetary) evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { + m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err } @@ -46,15 +46,15 @@ func (m Monetary) evalAdd(st *programState, other parser.ValueExpr) (Value, Inte } type opSub interface { - evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) + evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opSub = (*MonetaryInt)(nil) var _ opSub = (*Monetary)(nil) -func (m MonetaryInt) evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) - m2, err := evaluateExprAs(st, other, expectNumber) + m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { return nil, err } @@ -63,8 +63,8 @@ func (m MonetaryInt) evalSub(st *programState, other parser.ValueExpr) (Value, I return MonetaryInt(*sum), nil } -func (m Monetary) evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) { - m2, err := evaluateExprAs(st, other, expectMonetary) +func (m Monetary) evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { + m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err } @@ -84,19 +84,19 @@ func (m Monetary) evalSub(st *programState, other parser.ValueExpr) (Value, Inte } type opNeg interface { - evalNeg(st *programState) (Value, InterpreterError) + evalNeg(env *evalEnv) (Value, InterpreterError) } var _ opNeg = (*MonetaryInt)(nil) var _ opNeg = (*Monetary)(nil) -func (m MonetaryInt) evalNeg(st *programState) (Value, InterpreterError) { +func (m MonetaryInt) evalNeg(env *evalEnv) (Value, InterpreterError) { m1 := big.Int(m) neg := new(big.Int).Neg(&m1) return MonetaryInt(*neg), nil } -func (m Monetary) evalNeg(st *programState) (Value, InterpreterError) { +func (m Monetary) evalNeg(env *evalEnv) (Value, InterpreterError) { m1 := big.Int(m.Amount) neg := new(big.Int).Neg(&m1) return Monetary{ diff --git a/internal/interpreter/internal_accounts_metadata.go b/internal/interpreter/internal_accounts_metadata.go index 0112f827..8e861f81 100644 --- a/internal/interpreter/internal_accounts_metadata.go +++ b/internal/interpreter/internal_accounts_metadata.go @@ -28,3 +28,10 @@ func (m InternalAccountsMetadata) Get(account, scope, key string) (string, bool) value, ok := m[metadataKey{Account: account, Scope: scope, Key: key}] return value, ok } + +// Merge adds the given rows into the cache, keeping already-cached entries. +func (m InternalAccountsMetadata) Merge(rows AccountsMetadata) { + for _, row := range rows { + m[metadataKey{Account: row.Account, Scope: row.Scope, Key: row.Key}] = row.Value + } +} diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index df5bf5d1..ed6f8c50 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -111,81 +111,12 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter } -func (s *programState) evaluateVarOrigin(type_ string, expr parser.ValueExpr) (Value, InterpreterError) { +func evaluateVarOrigin(env *evalEnv, type_ string, expr parser.ValueExpr) (Value, InterpreterError) { if fnCall, ok := expr.(*parser.FnCall); ok { - return s.handleFnCall(&type_, *fnCall) + return evaluateFnCall(env, &type_, *fnCall) } - return s.evaluateExpr(expr) -} - -func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { - args, err := s.evaluateExpressions(fnCall.Args) - if err != nil { - return nil, err - } - - switch fnCall.Caller.Name { - case analysis.FnVarOriginMeta: - if type_ == nil { - return nil, InvalidNestedMeta{} - } - - rawValue, err := meta(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return parseVar(*type_, rawValue, fnCall.Range) - - case analysis.FnVarOriginBalance: - monetary, err := balance(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return monetary, nil - - case analysis.FnVarOriginOverdraft: - monetary, err := overdraft(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return monetary, nil - - case analysis.FnVarOriginGetAsset: - 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} - } - -} - -func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[string]string) InterpreterError { - for _, varsDecl := range varDeclrs { - if varsDecl.Origin == nil { - raw, ok := rawVars[varsDecl.Name.Name] - if !ok { - return MissingVariableErr{Name: varsDecl.Name.Name} - } - - parsed, err := parseVar(varsDecl.Type.Name, raw, varsDecl.Type.Range) - if err != nil { - return err - } - s.ParsedVars[varsDecl.Name.Name] = parsed - } else { - value, err := s.evaluateVarOrigin(varsDecl.Type.Name, *varsDecl.Origin) - if err != nil { - return err - } - s.ParsedVars[varsDecl.Name.Name] = value - } - } - return nil + return evaluateExpr(env, expr) } const accountSegmentRegex = "[a-zA-Z0-9_-]+" @@ -237,25 +168,10 @@ func RunProgram( featureFlags map[string]struct{}, ) (*ExecutionResult, InterpreterError) { - st := programState{ - ParsedVars: make(map[string]Value), - TxMeta: make(map[string]Value), - CachedAccountsMeta: InternalAccountsMetadata{}, - CachedBalances: InternalBalances{}, - SetAccountsMeta: internalSetAccountsMeta{}, - Store: store, - Postings: make([]Posting, 0), - fundsQueue: newFundsQueue(nil), - - CurrentBalanceQuery: BalanceQuery{}, - ctx: ctx, - FeatureFlags: maps.Clone(featureFlags), - } - - if st.FeatureFlags == nil { - st.FeatureFlags = make(map[string]struct{}, len(program.Flags)) + flagSet := maps.Clone(featureFlags) + if flagSet == nil { + flagSet = make(map[string]struct{}, len(program.Flags)) } - for _, flag := range program.Flags { index := slices.Index(flags.AllFlags, flag.String) if index == -1 { @@ -263,15 +179,21 @@ func RunProgram( Feature: flag.String, } } + flagSet[flag.String] = struct{}{} + } - st.FeatureFlags[flag.String] = struct{}{} + env, err := newEvalEnv(ctx, store, flagSet, program.Vars, vars) + if err != nil { + return nil, err } - if program.Vars != nil { - err := st.parseVars(program.Vars.Declarations, vars) - if err != nil { - return nil, err - } + st := programState{ + evalEnv: env, + TxMeta: make(map[string]Value), + SetAccountsMeta: internalSetAccountsMeta{}, + Postings: make([]Posting, 0), + fundsQueue: newFundsQueue(nil), + CurrentBalanceQuery: BalanceQuery{}, } // preload balances before executing the script @@ -310,28 +232,20 @@ func RunProgram( } type programState struct { - ctx context.Context + evalEnv // Asset of the send statement currently being executed. // // its value is undefined outside of send statements execution CurrentAsset Asset - ParsedVars map[string]Value TxMeta map[string]Value Postings []Posting fundsQueue fundsQueue - Store Store - SetAccountsMeta internalSetAccountsMeta - CachedAccountsMeta InternalAccountsMetadata - CachedBalances InternalBalances - CurrentBalanceQuery BalanceQuery - - FeatureFlags map[string]struct{} } func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) { @@ -403,7 +317,7 @@ func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) { func (st *programState) runStatement(statement parser.Statement) InterpreterError { switch statement := statement.(type) { case *parser.FnCall: - args, err := st.evaluateExpressions(statement.Args) + args, err := evaluateExpressions(&st.evalEnv, statement.Args) if err != nil { return err } @@ -430,12 +344,12 @@ func (st *programState) runStatement(statement parser.Statement) InterpreterErro } func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) InterpreterError { - asset, amt, err := st.evaluateSentAmt(saveStatement.SentValue) + asset, amt, err := evaluateSentAmt(&st.evalEnv, saveStatement.SentValue) if err != nil { return err } - account, err := evaluateExprAs(st, saveStatement.Account, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, saveStatement.Account, expectAccount) if err != nil { return err } @@ -469,7 +383,7 @@ func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) Int func (st *programState) runSendStatement(statement parser.SendStatement) InterpreterError { switch sentValue := statement.SentValue.(type) { case *parser.SentValueAll: - asset, err := evaluateExprAs(st, sentValue.Asset, expectAsset) + asset, err := evaluateExprAs(&st.evalEnv, sentValue.Asset, expectAsset) if err != nil { return err } @@ -481,7 +395,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return st.sendTo(statement.Destination, sentAmt) case *parser.SentValueLiteral: - monetary, err := evaluateExprAs(st, sentValue.Monetary, expectMonetary) + monetary, err := evaluateExprAs(&st.evalEnv, sentValue.Monetary, expectMonetary) if err != nil { return err } @@ -513,7 +427,7 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd } } - account, err := evaluateExprAs(s, accountLiteral, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount) if err != nil { return nil, err } @@ -548,7 +462,7 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError case *parser.SourceOverdraft: var cap *big.Int if source.Bounded != nil { - bounded, err := evaluateExprAs(s, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) + bounded, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -563,12 +477,12 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError return nil, err } - account, err := evaluateExprAs(s, source.Address, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount) if err != nil { return nil, err } - scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount) + scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount) if err != nil { return nil, err } @@ -625,7 +539,7 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError return s.takeAll(first) case *parser.SourceCapped: - monetary, err := evaluateExprAs(s, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + monetary, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -671,7 +585,7 @@ func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amo } } - account, err := evaluateExprAs(s, accountLiteral, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount) if err != nil { return nil, err } @@ -726,11 +640,11 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return nil, err } - account, err := evaluateExprAs(s, source.Address, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount) if err != nil { return nil, err } - scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount) + scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount) if err != nil { return nil, err } @@ -769,7 +683,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi case *parser.SourceOverdraft: var cap *big.Int if source.Bounded != nil { - upTo, err := evaluateExprAs(s, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) + upTo, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -836,7 +750,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return amount, nil case *parser.SourceCapped: - cap, err := evaluateExprAs(s, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -856,7 +770,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi func (s *programState) sendTo(destination parser.Destination, amount *big.Int) InterpreterError { switch destination := destination.(type) { case *parser.DestinationAccount: - account, err := evaluateExprAs(s, destination.ValueExpr, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, destination.ValueExpr, expectAccount) if err != nil { return err } @@ -908,7 +822,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I break } - cap, err := evaluateExprAs(s, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return err } @@ -933,7 +847,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I return err } for _, destinationClause := range destination.Clauses { - cap, err := evaluateExprAs(s, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return err } @@ -983,7 +897,7 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment for i, item := range items { switch allotment := item.(type) { case *parser.ValueExprAllotment: - rat, err := evaluateExprAs(s, allotment.Value, expectPortion) + rat, err := evaluateExprAs(&s.evalEnv, allotment.Value, expectPortion) if err != nil { return nil, err } @@ -1032,35 +946,17 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment return parts, nil } -// Utility function to get the balance -func getBalance( - s *programState, - account AccountAddress, - asset Asset, -) (*big.Int, InterpreterError) { - color := String("") - - s.batchQuery(account, asset, color) - fetchBalanceErr := s.runBalancesQuery() - if fetchBalanceErr != nil { - return nil, QueryBalanceError{WrappedError: fetchBalanceErr} - } - balance := s.CachedBalances.fetchBalance(account, asset, color) - return balance, nil - -} - -func (st *programState) evaluateSentAmt(sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) { +func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) { switch sentValue := sentValue.(type) { case *parser.SentValueAll: - asset, err := evaluateExprAs(st, sentValue.Asset, expectAsset) + asset, err := evaluateExprAs(env, sentValue.Asset, expectAsset) if err != nil { return "", nil, err } return asset, nil, nil case *parser.SentValueLiteral: - monetary, err := evaluateExprAs(st, sentValue.Monetary, expectMonetary) + monetary, err := evaluateExprAs(env, sentValue.Monetary, expectMonetary) if err != nil { return "", nil, err } @@ -1069,8 +965,7 @@ func (st *programState) evaluateSentAmt(sentValue parser.SentValue) (Asset, *big return monetary.Asset, &bi, nil default: - utils.NonExhaustiveMatchPanic[any](sentValue) - return "", nil, nil + return "", nil, unhandledErr(sentValue) } } @@ -1115,15 +1010,6 @@ func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) { return res, nil } -func (s programState) checkFeatureFlag(flag string) InterpreterError { - _, ok := s.FeatureFlags[flag] - if ok { - return nil - } else { - return ExperimentalFeature{FlagName: flag} - } -} - /* PRE: ovedraft != nil, balance != nil PRE: ovedraft >= 0 diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index bd03f042..e228ceea 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -7,6 +7,22 @@ import ( "github.com/formancehq/numscript/internal/parser" ) +// UnhandledError is returned instead of panicking when expression evaluation or +// dependency resolution meets an AST node it doesn't handle (e.g. a language +// construct added after this code was written). +type UnhandledError struct { + parser.Range + Node string +} + +func (e UnhandledError) Error() string { + return fmt.Sprintf("internal error: unhandled ast node (%s)", e.Node) +} + +func unhandledErr(node any) UnhandledError { + return UnhandledError{Node: fmt.Sprintf("%#v", node)} +} + type InternalError struct { parser.Range Posting Posting diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go new file mode 100644 index 00000000..b77eb300 --- /dev/null +++ b/internal/interpreter/resolve_dependencies.go @@ -0,0 +1,340 @@ +package interpreter + +import ( + "context" + "errors" + + "github.com/formancehq/numscript/internal/analysis" + "github.com/formancehq/numscript/internal/parser" +) + +var ErrScalingNotSupported = errors.New("scaling is not supported") + +type AccountDependency struct { + Account string + Scope string + Color string + Asset string +} + +type MetaDependency struct { + Account string + Scope string + Key string +} + +type ResolvedDependencies struct { + AccountsReads map[AccountDependency]struct{} + AccountsWrites map[AccountDependency]struct{} + MetaReads map[MetaDependency]struct{} + MetaWrites map[MetaDependency]struct{} + TxMetaWrites map[string]struct{} +} + +// recordingStore wraps a Store and records every balance/metadata it is queried +// for. Evaluating an expression through an evalEnv backed by this store is what +// makes balance()/overdraft()/meta() reads show up as dependencies. +type recordingStore struct { + inner Store + reads map[AccountDependency]struct{} + metaReads map[MetaDependency]struct{} +} + +func (s *recordingStore) GetBalances(ctx context.Context, q BalanceQuery) (Balances, error) { + for _, item := range q { + s.reads[AccountDependency{ + Account: item.Account, + Scope: item.Scope, + Color: item.Color, + Asset: item.Asset, + }] = struct{}{} + } + return s.inner.GetBalances(ctx, q) +} + +func (s *recordingStore) GetAccountsMetadata(ctx context.Context, q MetadataQuery) (AccountsMetadata, error) { + for _, item := range q { + for _, key := range item.Keys { + s.metaReads[MetaDependency{Account: item.Account, Scope: item.Scope, Key: key}] = struct{}{} + } + } + return s.inner.GetAccountsMetadata(ctx, q) +} + +type resolveDependenciesState struct { + env *evalEnv + deps ResolvedDependencies +} + +func ResolveDependencies(ctx context.Context, store Store, vars map[string]string, program parser.Program) (ResolvedDependencies, error) { + deps := ResolvedDependencies{ + AccountsReads: map[AccountDependency]struct{}{}, + AccountsWrites: map[AccountDependency]struct{}{}, + MetaReads: map[MetaDependency]struct{}{}, + MetaWrites: map[MetaDependency]struct{}{}, + TxMetaWrites: map[string]struct{}{}, + } + recording := &recordingStore{inner: store, reads: deps.AccountsReads, metaReads: deps.MetaReads} + + // binding the vars evaluates their origins, so balance()/overdraft()/meta() + // origins already get recorded through the store here. + env, err := newEvalEnv(ctx, recording, nil, program.Vars, vars) + if err != nil { + return ResolvedDependencies{}, err + } + + st := &resolveDependenciesState{env: &env, deps: deps} + for _, statement := range program.Statements { + if err := st.resolveStatement(statement); err != nil { + return ResolvedDependencies{}, err + } + } + + return st.deps, nil +} + +func addAccountDep(deps map[AccountDependency]struct{}, account AccountAddress, asset Asset, color String) { + deps[AccountDependency{ + Account: account.Name, + Scope: account.Scope, + Color: string(color), + Asset: string(asset), + }] = struct{}{} +} + +func addMetaDep(deps map[MetaDependency]struct{}, account AccountAddress, key string) { + deps[MetaDependency{ + Account: account.Name, + Scope: account.Scope, + Key: key, + }] = struct{}{} +} + +// eval evaluates an expression only for its side effects: any balance()/meta() +// inside it is recorded through the store. +func (st *resolveDependenciesState) eval(expr parser.ValueExpr) error { + _, err := evaluateExpr(st.env, expr) + return err +} + +func (st *resolveDependenciesState) resolveStatement(statement parser.Statement) error { + switch statement := statement.(type) { + case *parser.SendStatement: + asset, _, err := evaluateSentAmt(st.env, statement.SentValue) + if err != nil { + return err + } + // funds keep their source color all the way to the destination, so a + // destination can be credited in any of the send's source colors + srcColors := map[String]struct{}{} + if err := st.resolveSource(statement.Source, asset, srcColors); err != nil { + return err + } + return st.resolveDestination(statement.Destination, asset, srcColors) + + case *parser.SaveStatement: + asset, _, err := evaluateSentAmt(st.env, statement.SentValue) + if err != nil { + return err + } + account, err := evaluateExprAs(st.env, statement.Account, expectAccount) + if err != nil { + return err + } + addAccountDep(st.deps.AccountsReads, account, asset, "") + return nil + + case *parser.FnCall: + return st.resolveFnCallStatement(statement) + + default: + return unhandledErr(statement) + } +} + +func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall) error { + args, err := evaluateExpressions(st.env, fnCall.Args) + if err != nil { + return err + } + + switch fnCall.Caller.Name { + case analysis.FnSetAccountMeta: + p := NewArgsParser(args) + account := parseArg(p, fnCall.Range, expectAccount) + key := parseArg(p, fnCall.Range, expectString) + _ = parseArg(p, fnCall.Range, expectAnything) + if err := p.parse(); err != nil { + return err + } + addMetaDep(st.deps.MetaWrites, account, string(key)) + return nil + + case analysis.FnSetTxMeta: + p := NewArgsParser(args) + key := parseArg(p, fnCall.Range, expectString) + _ = parseArg(p, fnCall.Range, expectAnything) + if err := p.parse(); err != nil { + return err + } + st.deps.TxMetaWrites[string(key)] = struct{}{} + return nil + + default: + return UnboundFunctionErr{Name: fnCall.Caller.Name} + } +} + +func (st *resolveDependenciesState) resolveSource(source parser.Source, asset Asset, srcColors map[String]struct{}) error { + switch source := source.(type) { + case *parser.SourceAccount: + account, err := evaluateExprAs(st.env, source.ValueExpr, expectAccount) + if err != nil { + return err + } + color, err := evaluateOptExprAs(st.env, source.Color, expectString) + if err != nil { + return err + } + srcColors[color] = struct{}{} + st.recordSource(account, asset, color, account.Name == "world") + return nil + + case *parser.SourceOverdraft: + account, err := evaluateExprAs(st.env, source.Address, expectAccount) + if err != nil { + return err + } + color, err := evaluateOptExprAs(st.env, source.Color, expectString) + if err != nil { + return err + } + srcColors[color] = struct{}{} + st.recordSource(account, asset, color, source.Bounded == nil) + if source.Bounded != nil { + return st.eval(*source.Bounded) + } + return nil + + case *parser.SourceInorder: + for _, sub := range source.Sources { + if err := st.resolveSource(sub, asset, srcColors); err != nil { + return err + } + } + return nil + + case *parser.SourceOneof: + for _, sub := range source.Sources { + if err := st.resolveSource(sub, asset, srcColors); err != nil { + return err + } + } + return nil + + case *parser.SourceCapped: + if err := st.eval(source.Cap); err != nil { + return err + } + return st.resolveSource(source.From, asset, srcColors) + + case *parser.SourceAllotment: + for _, item := range source.Items { + if err := st.resolveAllotment(item.Allotment); err != nil { + return err + } + if err := st.resolveSource(item.From, asset, srcColors); err != nil { + return err + } + } + return nil + + case *parser.SourceWithScaling: + return ErrScalingNotSupported + + default: + return unhandledErr(source) + } +} + +// recordSource records a source account: always a write, and a read unless the +// account is unbounded (its balance is never consulted). +func (st *resolveDependenciesState) recordSource(account AccountAddress, asset Asset, color String, unbounded bool) { + if !unbounded { + addAccountDep(st.deps.AccountsReads, account, asset, color) + } + addAccountDep(st.deps.AccountsWrites, account, asset, color) +} + +func (st *resolveDependenciesState) resolveDestination(destination parser.Destination, asset Asset, srcColors map[String]struct{}) error { + switch destination := destination.(type) { + case *parser.DestinationAccount: + account, err := evaluateExprAs(st.env, destination.ValueExpr, expectAccount) + if err != nil { + return err + } + // the destination can be credited in any of the source colors + for color := range srcColors { + addAccountDep(st.deps.AccountsWrites, account, asset, color) + } + return nil + + case *parser.DestinationInorder: + for _, clause := range destination.Clauses { + if err := st.eval(clause.Cap); err != nil { + return err + } + if err := st.resolveKeptOrDestination(clause.To, asset, srcColors); err != nil { + return err + } + } + return st.resolveKeptOrDestination(destination.Remaining, asset, srcColors) + + case *parser.DestinationOneof: + for _, clause := range destination.Clauses { + if err := st.eval(clause.Cap); err != nil { + return err + } + if err := st.resolveKeptOrDestination(clause.To, asset, srcColors); err != nil { + return err + } + } + return st.resolveKeptOrDestination(destination.Remaining, asset, srcColors) + + case *parser.DestinationAllotment: + for _, item := range destination.Items { + if err := st.resolveAllotment(item.Allotment); err != nil { + return err + } + if err := st.resolveKeptOrDestination(item.To, asset, srcColors); err != nil { + return err + } + } + return nil + + default: + return unhandledErr(destination) + } +} + +func (st *resolveDependenciesState) resolveKeptOrDestination(kd parser.KeptOrDestination, asset Asset, srcColors map[String]struct{}) error { + switch kd := kd.(type) { + case *parser.DestinationKept: + return nil + case *parser.DestinationTo: + return st.resolveDestination(kd.Destination, asset, srcColors) + default: + return unhandledErr(kd) + } +} + +func (st *resolveDependenciesState) resolveAllotment(allotment parser.AllotmentValue) error { + switch allotment := allotment.(type) { + case *parser.ValueExprAllotment: + return st.eval(allotment.Value) + case *parser.RemainingAllotment: + return nil + default: + return unhandledErr(allotment) + } +} diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go new file mode 100644 index 00000000..8acd1985 --- /dev/null +++ b/internal/interpreter/resolve_dependencies_test.go @@ -0,0 +1,777 @@ +package interpreter_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/flags" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/stretchr/testify/require" +) + +// queryLog wraps a store and records every balance/metadata actually queried, +// so a test can assert that resolution is a superset of what execution touches. +type queryLog struct { + inner interpreter.Store + balances map[interpreter.AccountDependency]struct{} + meta map[interpreter.MetaDependency]struct{} +} + +func (q *queryLog) GetBalances(ctx context.Context, query interpreter.BalanceQuery) (interpreter.Balances, error) { + for _, item := range query { + q.balances[interpreter.AccountDependency{Account: item.Account, Scope: item.Scope, Color: item.Color, Asset: item.Asset}] = struct{}{} + } + return q.inner.GetBalances(ctx, query) +} + +func (q *queryLog) GetAccountsMetadata(ctx context.Context, query interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) { + for _, item := range query { + for _, key := range item.Keys { + q.meta[interpreter.MetaDependency{Account: item.Account, Scope: item.Scope, Key: key}] = struct{}{} + } + } + return q.inner.GetAccountsMetadata(ctx, query) +} + +// TestResolveDependenciesCoversRuntime checks the soundness property: for a +// script that executes successfully, every balance/metadata the interpreter +// queries is a resolved read, and every account a posting touches is a resolved +// write. Resolution may over-approximate (e.g. both branches of a oneof), so +// these are subset checks. +func TestResolveDependenciesCoversRuntime(t *testing.T) { + allFlags := map[string]struct{}{} + for _, f := range flags.AllFlags { + allFlags[f] = struct{}{} + } + + testCases := []struct { + name string + src string + vars map[string]string + balances interpreter.Balances + meta interpreter.AccountsMetadata + }{ + { + name: "simple send", + src: ` + send [USD 10] ( + source = @a + destination = @b + ) + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(100)}}, + }, + { + name: "balance var origin used as sent value", + src: ` + vars { monetary $x = balance(@t, USD) } + send $x ( + source = @t + destination = @o + ) + `, + balances: interpreter.Balances{{Account: "t", Asset: "USD", Amount: big.NewInt(100)}}, + }, + { + name: "bounded overdraft", + src: ` + send [USD 50] ( + source = @a allowing overdraft up to [USD 100] + destination = @b + ) + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(0)}}, + }, + { + name: "allotment source", + src: ` + send [USD 100] ( + source = { + 50% from @a + 50% from @b + } + destination = @c + ) + `, + balances: interpreter.Balances{ + {Account: "a", Asset: "USD", Amount: big.NewInt(50)}, + {Account: "b", Asset: "USD", Amount: big.NewInt(50)}, + }, + }, + { + name: "balance inside a cap", + src: ` + send [USD 5] ( + source = max balance(@r, USD) from @funder + destination = @out + ) + `, + balances: interpreter.Balances{ + {Account: "r", Asset: "USD", Amount: big.NewInt(10)}, + {Account: "funder", Asset: "USD", Amount: big.NewInt(100)}, + }, + }, + { + name: "meta-derived destination", + src: ` + vars { account $d = meta(@config, "recipient") } + send [USD 10] ( + source = @world + destination = $d + ) + `, + meta: interpreter.AccountsMetadata{{Account: "config", Key: "recipient", Value: "out"}}, + }, + { + name: "destination allotment", + src: ` + send [USD 100] ( + source = @world + destination = { + 50% to @a + remaining to @b + } + ) + `, + }, + { + name: "save", + src: ` + save [USD 10] from @a + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(100)}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.Parse(tc.src) + require.Empty(t, parsed.Errors) + + static := interpreter.StaticStore{Balances: tc.balances, Meta: tc.meta} + log := &queryLog{ + inner: static, + balances: map[interpreter.AccountDependency]struct{}{}, + meta: map[interpreter.MetaDependency]struct{}{}, + } + + result, runErr := interpreter.RunProgram(context.Background(), parsed.Value, tc.vars, log, allFlags) + require.Nil(t, runErr) + + deps, err := interpreter.ResolveDependencies(context.Background(), static, tc.vars, parsed.Value) + require.NoError(t, err) + + for dep := range log.balances { + require.Contains(t, deps.AccountsReads, dep, "queried balance not resolved as a read") + } + for m := range log.meta { + require.Contains(t, deps.MetaReads, m, "queried metadata not resolved as a meta read") + } + + // posting accounts must be resolved writes (color isn't statically known for destinations) + writes := map[[3]string]struct{}{} + for w := range deps.AccountsWrites { + writes[[3]string{w.Account, w.Scope, w.Asset}] = struct{}{} + } + for _, p := range result.Postings { + require.Contains(t, writes, [3]string{p.Source, p.SourceScope, p.Asset}, "posting source not resolved as a write") + require.Contains(t, writes, [3]string{p.Destination, p.DestinationScope, p.Asset}, "posting destination not resolved as a write") + } + }) + } +} + +func resolve(t *testing.T, src string, vars map[string]string, store interpreter.Store) interpreter.ResolvedDependencies { + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + deps, err := interpreter.ResolveDependencies(context.Background(), store, vars, parsed.Value) + require.NoError(t, err) + return deps +} + +func TestResolveSendSourceAndDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @alice + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) + + require.Empty(t, deps.MetaReads) +} + +func TestResolveWorldIsWrittenButNotRead(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @world + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceAndOverdraftAreReads(t *testing.T) { + deps := resolve(t, ` + vars { + monetary $b = balance(@alice, USD) + monetary $o = overdraft(@carol, EUR) + } + send $b ( + source = @world + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "carol", Asset: "EUR"}: {}, + }, deps.AccountsReads) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveMetaIsMetaRead(t *testing.T) { + deps := resolve(t, ` + vars { + account $dst = meta(@config, "recipient") + } + send [USD 10] ( + source = @alice + destination = @bob + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Key: "recipient", Value: "bank"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Key: "recipient"}: {}, + }, deps.MetaReads) +} + +func TestResolveMetaValueResolvesDependentAccount(t *testing.T) { + // the meta() value is fetched from the store, so a var bound to it can be + // used to resolve a downstream account dependency + deps := resolve(t, ` + vars { + account $dst = meta(@config, "recipient") + } + send [USD 10] ( + source = @world + destination = $dst + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Key: "recipient", Value: "treasury"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Key: "recipient"}: {}, + }, deps.MetaReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "treasury", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveInterpolatedAccountUsesVars(t *testing.T) { + deps := resolve(t, ` + vars { + string $id + } + send [USD 10] ( + source = @users:$id + destination = @world + ) + `, map[string]string{"id": "alice"}, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "users:alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "users:alice", Asset: "USD"}: {}, + {Account: "world", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveUnboundedOverdraftSourceIsWriteOnly(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @alice allowing unbounded overdraft + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + // unbounded overdraft: no balance read, but still a write + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveInorderSource(t *testing.T) { + deps := resolve(t, ` + send [USD 30] ( + source = { + @a + @b + } + destination = @c + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveAllotmentSourceAndDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = { + 50% from @a + remaining from @b + } + destination = { + 50% to @x + remaining to @y + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "x", Asset: "USD"}: {}, + {Account: "y", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceOfWorldIsRead(t *testing.T) { + deps := resolve(t, ` + vars { + monetary $w = balance(@world, USD) + } + send [USD 10] ( + source = @a + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + _, ok := deps.AccountsReads[interpreter.AccountDependency{Account: "world", Asset: "USD"}] + require.True(t, ok, "balance(@world) must be recorded as a read") +} + +func TestResolveBalanceInsideCap(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = max balance(@reserve, USD) from @a + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "reserve", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveOneofSource(t *testing.T) { + // oneof picks one branch at runtime, but statically every branch is a dependency + deps := resolve(t, ` + send [USD 10] ( + source = oneof { + @a + @b + } + destination = @c + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBoundedOverdraftIsRead(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @a allowing overdraft up to [USD 100] + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveOverdraftBoundBalanceIsRead(t *testing.T) { + // a balance() inside an overdraft bound is recorded through the store + deps := resolve(t, ` + send [USD 10] ( + source = @a allowing overdraft up to balance(@reserve, USD) + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) +} + +func TestResolveColoredSource(t *testing.T) { + deps := resolve(t, ` + send [COIN 100] ( + source = @src \ "RED" + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + }, deps.AccountsReads) + // the destination is credited in the source's color + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + {Account: "dest", Asset: "COIN", Color: "RED"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveMixedColorSourceWidensDestination(t *testing.T) { + // funds from a colored and an uncolored source can both reach the + // destination, so it's recorded as a write in every source color + deps := resolve(t, ` + send [COIN 10] ( + source = { + @s1 \ "C" + @s2 + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "s1", Asset: "COIN", Color: "C"}: {}, + {Account: "s2", Asset: "COIN"}: {}, + {Account: "dest", Asset: "COIN", Color: "C"}: {}, + {Account: "dest", Asset: "COIN"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveSameAccountDifferentColors(t *testing.T) { + // the same account with different colors are distinct dependencies + deps := resolve(t, ` + send [COIN 100] ( + source = { + @src \ "RED" + @src \ "BLUE" + @src + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + {Account: "src", Asset: "COIN", Color: "BLUE"}: {}, + {Account: "src", Asset: "COIN"}: {}, + }, deps.AccountsReads) +} + +func TestResolveDeeplyNestedSources(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = oneof { + max [USD 10] from @a + { + @b + @world + } + { + 50% from @c + remaining from @d allowing unbounded overdraft + } + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + // @world (unbounded) and @d (unbounded overdraft) are writes only + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "world", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + {Account: "d", Asset: "USD"}: {}, + {Account: "dest", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveDestinationInorder(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 20] to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveDestinationOneof(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = oneof { + max [USD 20] to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveDestinationRemainingKept(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 20] to @a + remaining kept + } + ) + `, nil, interpreter.StaticStore{}) + + // "remaining kept" holds no account + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceInsideDestinationCap(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @funder + destination = { + max balance(@reserve, USD) to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "funder", Asset: "USD"}: {}, + {Account: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "funder", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveNestedDestinations(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 50] to { + max [USD 10] to @a + remaining to @b + } + remaining to @c + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveScopedSource(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = scoped(@treasury, "reserve") + destination = @out + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + {Account: "out", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveScopedDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @world + destination = scoped(@out, "escrow") + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "out", Scope: "escrow", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveScopedBalanceRead(t *testing.T) { + deps := resolve(t, ` + vars { monetary $b = balance(scoped(@treasury, "reserve"), USD) } + send $b ( + source = @world + destination = @out + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) +} + +func TestResolveScopedMetaRead(t *testing.T) { + deps := resolve(t, ` + vars { account $d = meta(scoped(@config, "settings"), "recipient") } + send [USD 10] ( + source = @world + destination = @out + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Scope: "settings", Key: "recipient", Value: "out"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Scope: "settings", Key: "recipient"}: {}, + }, deps.MetaReads) +} + +func TestResolveSetAccountMetaIsMetaWrite(t *testing.T) { + deps := resolve(t, ` + set_account_meta(@alice, "kyc", "verified") + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "alice", Key: "kyc"}: {}, + }, deps.MetaWrites) + require.Empty(t, deps.MetaReads) +} + +func TestResolveSetTxMetaIsTxMetaWrite(t *testing.T) { + deps := resolve(t, ` + set_tx_meta("priority", "high") + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[string]struct{}{ + "priority": {}, + }, deps.TxMetaWrites) + require.Empty(t, deps.MetaWrites) +} + +func TestResolveUnknownStatementFnErrors(t *testing.T) { + parsed := parser.Parse(`unknown_fn(@a, "x")`) + require.Empty(t, parsed.Errors) + + _, err := interpreter.ResolveDependencies(context.Background(), interpreter.StaticStore{}, nil, parsed.Value) + require.IsType(t, interpreter.UnboundFunctionErr{}, err) +} + +func TestResolveScalingIsNotSupported(t *testing.T) { + parsed := parser.Parse(` + send [USD 10] ( + source = @alice with scaling through @pool + destination = @bob + ) + `) + require.Empty(t, parsed.Errors) + + _, err := interpreter.ResolveDependencies(context.Background(), interpreter.StaticStore{}, nil, parsed.Value) + require.ErrorIs(t, err, interpreter.ErrScalingNotSupported) +} + +func TestResolveSaveIsRead(t *testing.T) { + deps := resolve(t, ` + save [USD 10] from @alice + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Empty(t, deps.AccountsWrites) +} diff --git a/numscript.go b/numscript.go index 3c377782..eb4dc8d7 100644 --- a/numscript.go +++ b/numscript.go @@ -78,8 +78,14 @@ type ( InterpreterError = interpreter.InterpreterError MissingFundsErr = interpreter.MissingFundsErr + + ResolvedDependencies = interpreter.ResolvedDependencies + AccountDependency = interpreter.AccountDependency + MetaDependency = interpreter.MetaDependency ) +var ErrScalingNotSupported = interpreter.ErrScalingNotSupported + func (p ParseResult) Run(ctx context.Context, vars VariablesMap, store Store) (ExecutionResult, InterpreterError) { return p.RunWithFeatureFlags(ctx, vars, store, nil) } @@ -105,6 +111,17 @@ func (p ParseResult) RunWithFeatureFlags( return *res, nil } +// ResolveDependencies statically determines which accounts and metadata the +// script reads and writes, resolving account/asset/key expressions against the +// given vars and store. +func (p ParseResult) ResolveDependencies(ctx context.Context, vars VariablesMap, store Store) (ResolvedDependencies, error) { + if len(p.parseResult.Errors) != 0 { + return ResolvedDependencies{}, p.parseResult.Errors[0] + } + + return interpreter.ResolveDependencies(ctx, store, vars, p.parseResult.Value) +} + func (p ParseResult) GetSource() string { return p.parseResult.Source } diff --git a/numscript_test.go b/numscript_test.go index e1ec7171..fa24e76a 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -557,3 +557,23 @@ func (*ErrorStore) GetBalances(ctx context.Context, q interpreter.BalanceQuery) func (*ErrorStore) GetAccountsMetadata(ctx context.Context, q interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) { return nil, errors.New("Error while fetching metadata") } + +func TestResolveDependenciesPublicAPI(t *testing.T) { + parsed := numscript.Parse(` + send [USD 10] ( + source = @alice + destination = @bob + ) + `) + + deps, err := parsed.ResolveDependencies(context.Background(), nil, numscript.StaticStore{}) + require.NoError(t, err) + + require.Equal(t, map[numscript.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[numscript.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +}