diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 00000000..7ebeac6e --- /dev/null +++ b/errors_test.go @@ -0,0 +1,359 @@ +package numscript_test + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/formancehq/numscript" + "github.com/formancehq/numscript/internal/flags" + "github.com/stretchr/testify/require" +) + +func requireAs[T error](t *testing.T, err error) T { + t.Helper() + var target T + require.True(t, errors.As(err, &target), "expected %T, got %T: %v", target, err, err) + return target +} + +type errorCase struct { + name string + script string + vars numscript.VariablesMap + store numscript.Store + flags []string + // Whether ResolveDependencies fails with the same error as Run. + resolve bool + check func(t *testing.T, err error) +} + +// Hosts classify interpreter failures with errors.As on the public aliases and +// persist their fields, so both must survive refactors of the internal types. +func TestInterpreterErrorsAreClassifiable(t *testing.T) { + cases := []errorCase{ + { + name: "missing variable", + script: `vars { account $a } send [USD 1] ( source = $a destination = @b )`, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "a", requireAs[numscript.MissingVariableErr](t, err).Name) + }, + }, + { + name: "invalid account name", + script: `vars { account $a } send [USD 1] ( source = $a destination = @b )`, + vars: numscript.VariablesMap{"a": "@world"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "@world", requireAs[numscript.InvalidAccountName](t, err).Name) + }, + }, + { + name: "invalid asset", + script: `vars { asset $x } send [$x 1] ( source = @world destination = @b )`, + vars: numscript.VariablesMap{"x": "Aa"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "Aa", requireAs[numscript.InvalidAsset](t, err).Name) + }, + }, + { + name: "invalid monetary literal", + script: `vars { monetary $m } send $m ( source = @world destination = @b )`, + vars: numscript.VariablesMap{"m": "USD"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "USD", requireAs[numscript.InvalidMonetaryLiteral](t, err).Source) + }, + }, + { + name: "invalid number literal", + script: `vars { number $n } send [USD $n] ( source = @world destination = @b )`, + vars: numscript.VariablesMap{"n": "abc"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "abc", requireAs[numscript.InvalidNumberLiteral](t, err).Source) + }, + }, + { + name: "bad portion", + script: `vars { portion $p }`, + vars: numscript.VariablesMap{"p": "abc"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "abc", requireAs[numscript.BadPortionParsingErr](t, err).Source) + }, + }, + { + name: "invalid variable type", + script: `vars { invalidt $x }`, + vars: numscript.VariablesMap{"x": "42"}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "invalidt", requireAs[numscript.InvalidTypeErr](t, err).Name) + }, + }, + { + name: "mismatched currency", + script: `vars { monetary $m = [USD 1] + [EUR 1] }`, + resolve: true, + check: func(t *testing.T, err error) { + e := requireAs[numscript.MismatchedCurrencyError](t, err) + require.Equal(t, "USD", e.Expected) + require.Equal(t, "EUR", e.Got) + }, + }, + { + name: "divide by zero", + script: `vars { portion $p = 1 / 0 }`, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, big.NewInt(1), requireAs[numscript.DivideByZero](t, err).Numerator) + }, + }, + { + name: "metadata not found", + script: `vars { number $n = meta(@acc, "k") }`, + resolve: true, + check: func(t *testing.T, err error) { + e := requireAs[numscript.MetadataNotFound](t, err) + require.Equal(t, "acc", e.Account) + require.Equal(t, "k", e.Key) + }, + }, + { + name: "negative balance", + script: `vars { monetary $m = balance(@acc, USD/2) }`, + store: numscript.StaticStore{Balances: numscript.Balances{ + {Account: "acc", Asset: "USD/2", Amount: big.NewInt(-5)}, + }}, + resolve: true, + check: func(t *testing.T, err error) { + e := requireAs[numscript.NegativeBalanceError](t, err) + require.Equal(t, "acc", e.Account) + require.Equal(t, "USD/2", e.Asset) + require.Equal(t, *big.NewInt(-5), e.Amount) + }, + }, + { + name: "unbound function", + script: `vars { number $x = unbound_fn(1, 2) }`, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "unbound_fn", requireAs[numscript.UnboundFunctionErr](t, err).Name) + }, + }, + { + name: "unbound variable", + script: `send [USD 1] ( source = $unbound destination = @b )`, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "unbound", requireAs[numscript.UnboundVariableErr](t, err).Name) + }, + }, + { + name: "bad arity", + script: `set_tx_meta()`, + check: func(t *testing.T, err error) { + e := requireAs[numscript.BadArityErr](t, err) + require.Equal(t, 2, e.ExpectedArity) + require.Equal(t, 0, e.GivenArguments) + }, + }, + { + name: "type error", + script: `set_tx_meta(@acc, "v")`, + check: func(t *testing.T, err error) { + require.Equal(t, "string", requireAs[numscript.TypeError](t, err).Expected) + }, + }, + { + name: "missing funds", + script: `send [USD 10] ( source = @a destination = @b )`, + check: func(t *testing.T, err error) { + e := requireAs[numscript.MissingFundsErr](t, err) + require.Equal(t, "USD", e.Asset) + require.Equal(t, *big.NewInt(10), e.Needed) + }, + }, + { + name: "negative amount", + script: `vars { monetary $m } send $m ( source = @world destination = @b )`, + vars: numscript.VariablesMap{"m": "USD -1"}, + check: func(t *testing.T, err error) { + require.Equal(t, "-1", requireAs[numscript.NegativeAmountErr](t, err).Amount.String()) + }, + }, + { + name: "invalid allotment sum", + script: `send [USD 10] ( + source = { 1/2 from @world 1/3 from @world } + destination = @b + )`, + check: func(t *testing.T, err error) { + require.Equal(t, *big.NewRat(5, 6), requireAs[numscript.InvalidAllotmentSum](t, err).ActualSum) + }, + }, + { + name: "negative portion", + script: `vars { number $n } send [USD 9] ( + source = { $n/3 from @world remaining from @world } + destination = @b + )`, + vars: numscript.VariablesMap{"n": "-1"}, + check: func(t *testing.T, err error) { + require.Equal(t, *big.NewRat(-1, 3), requireAs[numscript.NegativePortion](t, err).Portion) + }, + }, + { + name: "duplicate remaining allotment", + script: `send [USD 10] ( + source = { remaining from @world remaining from @world } + destination = @b + )`, + check: func(t *testing.T, err error) { requireAs[numscript.InvalidRemainingAllotment](t, err) }, + }, + { + name: "allotment in send all", + script: `send [USD *] ( + source = { 1/2 from @a remaining from @b } + destination = @c + )`, + check: func(t *testing.T, err error) { requireAs[numscript.InvalidAllotmentInSendAll](t, err) }, + }, + { + name: "unbounded source in send all", + script: `send [USD *] ( source = @world destination = @b )`, + check: func(t *testing.T, err error) { + require.Equal(t, "world", requireAs[numscript.InvalidUnboundedInSendAll](t, err).Name) + }, + }, + { + name: "unbounded source in scaling", + script: `send [EUR/2 *] ( source = @world with scaling through @swap destination = @b )`, + flags: []string{flags.AssetScaling}, + check: func(t *testing.T, err error) { + requireAs[numscript.InvalidUnboundedAddressInScalingAddress](t, err) + }, + }, + { + name: "nested meta", + script: `vars { number $x = 1 + meta(@acc, "k") }`, + flags: []string{flags.ExperimentalMidScriptFunctionCall}, + resolve: true, + check: func(t *testing.T, err error) { requireAs[numscript.InvalidNestedMeta](t, err) }, + }, + { + name: "cannot cast to string", + script: `vars { monetary $m } set_tx_meta("k", @acc:$m)`, + vars: numscript.VariablesMap{"m": "USD/2 10"}, + flags: []string{flags.ExperimentalAccountInterpolationFlag}, + check: func(t *testing.T, err error) { requireAs[numscript.CannotCastToString](t, err) }, + }, + { + name: "cannot cast scoped account to string", + script: `vars { account $s = scoped(@a, "s") } set_tx_meta("k", @foo:$s)`, + flags: []string{flags.ExperimentalScopedFunction, flags.ExperimentalAccountInterpolationFlag}, + check: func(t *testing.T, err error) { + e := requireAs[numscript.CannotCastScopedAccountToString](t, err) + require.Equal(t, "a", e.Account) + require.Equal(t, "s", e.Scope) + }, + }, + { + name: "cannot store scoped account in meta", + script: `vars { account $s = scoped(@a, "s") } set_tx_meta("k", $s)`, + flags: []string{flags.ExperimentalScopedFunction}, + check: func(t *testing.T, err error) { + e := requireAs[numscript.CannotStoreScopedAccountInMeta](t, err) + require.Equal(t, "a", e.Account) + require.Equal(t, "s", e.Scope) + }, + }, + { + name: "invalid scope", + script: `vars { account $s = scoped(@b, "not a scope!") }`, + flags: []string{flags.ExperimentalScopedFunction}, + resolve: true, + check: func(t *testing.T, err error) { + require.Equal(t, "not a scope!", requireAs[numscript.InvalidScope](t, err).Scope) + }, + }, + { + name: "invalid color", + script: `send [USD 1] ( source = @a \ "red" allowing unbounded overdraft destination = @b )`, + flags: []string{flags.ExperimentalAssetColors}, + check: func(t *testing.T, err error) { + require.Equal(t, "red", requireAs[numscript.InvalidColor](t, err).Color) + }, + }, + { + name: "experimental feature", + script: `vars { account $s = scoped(@b, "s") }`, + check: func(t *testing.T, err error) { + e := requireAs[numscript.ExperimentalFeature](t, err) + require.Equal(t, flags.ExperimentalScopedFunction, e.FlagName) + }, + }, + { + name: "invalid feature", + script: `#![feature("nope")]`, + check: func(t *testing.T, err error) { + require.Equal(t, "nope", requireAs[numscript.InvalidFeature](t, err).Feature) + }, + }, + { + name: "balance query failure", + script: `send [USD 1] ( source = @a destination = @b )`, + store: &ErrorStore{}, + check: func(t *testing.T, err error) { + require.EqualError(t, requireAs[numscript.QueryBalanceError](t, err).WrappedError, "Error while fetching balances") + }, + }, + { + name: "metadata query failure", + script: `vars { number $n = meta(@acc, "k") }`, + store: &ErrorStore{}, + resolve: true, + check: func(t *testing.T, err error) { + require.EqualError(t, requireAs[numscript.QueryMetadataError](t, err).WrappedError, "Error while fetching metadata") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed := numscript.Parse(tc.script) + require.Empty(t, parsed.GetParsingErrors()) + + store := tc.store + if store == nil { + store = numscript.StaticStore{} + } + featureFlags := map[string]struct{}{} + for _, f := range tc.flags { + featureFlags[f] = struct{}{} + } + + _, runErr := parsed.RunWithFeatureFlags(context.Background(), tc.vars, store, featureFlags) + require.NotNil(t, runErr) + tc.check(t, runErr) + + if tc.resolve { + _, resolveErr := parsed.ResolveDependencies(context.Background(), tc.vars, store) + require.Error(t, resolveErr) + tc.check(t, resolveErr) + } + }) + } +} + +func TestResolveDependenciesRejectsScaling(t *testing.T) { + parsed := numscript.Parse(`send [EUR/2 100] ( source = @a with scaling through @swap destination = @b )`) + require.Empty(t, parsed.GetParsingErrors()) + + _, err := parsed.ResolveDependencies(context.Background(), nil, numscript.StaticStore{}) + require.ErrorIs(t, err, numscript.ErrScalingNotSupported) +} diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 9fca0301..19664e88 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -156,6 +156,7 @@ func balance( return Monetary{}, NegativeBalanceError{ Account: account.Name, Scope: account.Scope, + Asset: string(asset), Amount: *balance, } } diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index fc301228..e18218e5 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -147,6 +147,7 @@ type NegativeBalanceError struct { parser.Range Account string Scope string + Asset string Amount big.Int } diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index cc40961c..6401cfb4 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -749,6 +749,7 @@ func TestNegativeBalance(t *testing.T) { tc.expected = CaseResult{ Error: interpreter.NegativeBalanceError{ Account: "a", + Asset: "EUR/2", Amount: *big.NewInt(-100), }, } diff --git a/numscript.go b/numscript.go index fee3a788..aa930ea4 100644 --- a/numscript.go +++ b/numscript.go @@ -78,15 +78,69 @@ type ( Value = interpreter.Value - InterpreterError = interpreter.InterpreterError - MissingFundsErr = interpreter.MissingFundsErr - NegativeAmountErr = interpreter.NegativeAmountErr + InterpreterError = interpreter.InterpreterError ResolvedDependencies = interpreter.ResolvedDependencies AccountDependency = interpreter.AccountDependency MetaDependency = interpreter.MetaDependency ) +// Interpreter error types, aliased so callers of Run and ResolveDependencies +// can classify failures with errors.As without reaching into internal packages. +// All are returned by value. Their exported fields, other than the embedded +// source range, are part of the public contract: hosts may persist them as +// structured facts, so they are only ever added to, never renamed or removed. +// +// These are caused by the script, its vars, or the state it runs against. +type ( + MissingFundsErr = interpreter.MissingFundsErr + NegativeAmountErr = interpreter.NegativeAmountErr + MissingVariableErr = interpreter.MissingVariableErr + InvalidAccountName = interpreter.InvalidAccountName + InvalidAsset = interpreter.InvalidAsset + InvalidColor = interpreter.InvalidColor + InvalidScope = interpreter.InvalidScope + InvalidMonetaryLiteral = interpreter.InvalidMonetaryLiteral + InvalidNumberLiteral = interpreter.InvalidNumberLiteral + BadPortionParsingErr = interpreter.BadPortionParsingErr + MismatchedCurrencyError = interpreter.MismatchedCurrencyError + DivideByZero = interpreter.DivideByZero + TypeError = interpreter.TypeError + MetadataNotFound = interpreter.MetadataNotFound + NegativeBalanceError = interpreter.NegativeBalanceError + InvalidAllotmentSum = interpreter.InvalidAllotmentSum + NegativePortion = interpreter.NegativePortion + InvalidRemainingAllotment = interpreter.InvalidRemainingAllotment + InvalidAllotmentInSendAll = interpreter.InvalidAllotmentInSendAll + InvalidUnboundedInSendAll = interpreter.InvalidUnboundedInSendAll + InvalidUnboundedAddressInScalingAddress = interpreter.InvalidUnboundedAddressInScalingAddress + InvalidNestedMeta = interpreter.InvalidNestedMeta + CannotCastToString = interpreter.CannotCastToString + CannotCastScopedAccountToString = interpreter.CannotCastScopedAccountToString + CannotStoreScopedAccountInMeta = interpreter.CannotStoreScopedAccountInMeta + UnboundVariableErr = interpreter.UnboundVariableErr + UnboundFunctionErr = interpreter.UnboundFunctionErr + BadArityErr = interpreter.BadArityErr + InvalidTypeErr = interpreter.InvalidTypeErr + ExperimentalFeature = interpreter.ExperimentalFeature + InvalidFeature = interpreter.InvalidFeature +) + +// Interpreter errors reporting that a Store call failed; Unwrap yields the +// store's own error. +type ( + QueryBalanceError = interpreter.QueryBalanceError + QueryMetadataError = interpreter.QueryMetadataError +) + +// Interpreter errors reporting defects in numscript itself, never caused by the +// script or its inputs. Their fields are diagnostic only and not part of the +// contract. +type ( + InternalError = interpreter.InternalError + UnhandledError = interpreter.UnhandledError +) + var ErrScalingNotSupported = interpreter.ErrScalingNotSupported func (p ParseResult) Run(ctx context.Context, vars VariablesMap, store Store) (ExecutionResult, InterpreterError) { @@ -218,8 +272,8 @@ var ( // VM execution error types, aliased so ExecVm callers can classify failures // with errors.As without reaching into internal packages — the same pattern as // the interpreter's error types above. The Vm prefix keeps them apart from the -// interpreter's MissingFundsErr/NegativeAmountErr, which are different types -// with different fields. +// interpreter's error types of the same name, which are different types with +// different fields. type ( VmMissingFundsError = vm.MissingFundsError VmNegativeAmountError = vm.NegativeAmountError