Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: overdraft function #14

Merged
merged 7 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Numscript.g4
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ valueExpr:
| left = valueExpr op = ('+' | '-') right = valueExpr # infixExpr;

functionCallArgs: valueExpr ( COMMA valueExpr)*;
functionCall: IDENTIFIER LPARENS functionCallArgs? RPARENS;
functionCall:
fnName = (OVERDRAFT | IDENTIFIER) LPARENS functionCallArgs? RPARENS;

varOrigin: EQ functionCall;
varDeclaration:
Expand Down
6 changes: 6 additions & 0 deletions internal/analysis/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const FnSetTxMeta = "set_tx_meta"
const FnSetAccountMeta = "set_account_meta"
const FnVarOriginMeta = "meta"
const FnVarOriginBalance = "balance"
const FnVarOriginOverdraft = "overdraft"
ascandone marked this conversation as resolved.
Show resolved Hide resolved

var Builtins = map[string]FnCallResolution{
FnSetTxMeta: StatementFnCallResolution{
Expand All @@ -75,6 +76,11 @@ var Builtins = map[string]FnCallResolution{
Return: TypeMonetary,
Docs: "fetch account balance",
},
FnVarOriginOverdraft: VarOriginFnCallResolution{
Params: []string{TypeAccount, TypeAsset},
Return: TypeMonetary,
Docs: "get absolute amount of the overdraft of an account. Returns zero if balance is not negative",
},
}

type Diagnostic struct {
Expand Down
12 changes: 11 additions & 1 deletion internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
var runStdinFlag bool
var runOutFormatOpt string

var overdraftFeatureFlag bool

type inputOpts struct {
Script string `json:"script"`
Variables map[string]string `json:"variables"`
Expand Down Expand Up @@ -115,10 +117,15 @@
os.Exit(1)
}

featureFlags := map[string]struct{}{}
if overdraftFeatureFlag {
featureFlags[interpreter.ExperimentalOverdraftFunctionFeatureFlag] = struct{}{}
}

Check warning on line 123 in internal/cmd/run.go

View check run for this annotation

Codecov / codecov/patch

internal/cmd/run.go#L120-L123

Added lines #L120 - L123 were not covered by tests

result, err := interpreter.RunProgram(context.Background(), parseResult.Value, opt.Variables, interpreter.StaticStore{
Balances: opt.Balances,
Meta: opt.Meta,
}, nil)
}, featureFlags)

Check warning on line 128 in internal/cmd/run.go

View check run for this annotation

Codecov / codecov/patch

internal/cmd/run.go#L128

Added line #L128 was not covered by tests

if err != nil {
rng := err.GetRange()
Expand Down Expand Up @@ -192,6 +199,9 @@
cmd.Flags().StringVarP(&runRawOpt, "raw", "r", "", "Raw json input containing script, variables, balances, metadata")
cmd.Flags().BoolVar(&runStdinFlag, "stdin", false, "Take input from stdin (same format as the --raw option)")

// Feature flag
cmd.Flags().BoolVar(&overdraftFeatureFlag, interpreter.ExperimentalOverdraftFunctionFeatureFlag, false, "feature flag to enable the overdraft() function")

Check warning on line 204 in internal/cmd/run.go

View check run for this annotation

Codecov / codecov/patch

internal/cmd/run.go#L202-L204

Added lines #L202 - L204 were not covered by tests
// Output options
cmd.Flags().StringVar(&runOutFormatOpt, "output-format", OutputFormatPretty, "Set the output format. Available options: pretty, json.")

Expand Down
80 changes: 75 additions & 5 deletions internal/interpreter/interpreter.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@
}
return *monetary, nil

case analysis.FnVarOriginOverdraft:
monetary, err := overdraft(s, fnCall.Range, args)
if err != nil {
return nil, err
}
return *monetary, nil

default:
return nil, UnboundFunctionErr{Name: fnCall.Caller.Name}
}
Expand Down Expand Up @@ -170,6 +177,10 @@
return nil
}

type FeatureFlag = string

const ExperimentalOverdraftFunctionFeatureFlag FeatureFlag = "experimental-overdraft-function"

func RunProgram(
ctx context.Context,
program parser.Program,
Expand All @@ -189,6 +200,10 @@
ctx: ctx,
}

if _, ok := featureFlags[ExperimentalOverdraftFunctionFeatureFlag]; ok {
st.OverdraftFunctionFeatureFlag = true
}

err := st.parseVars(program.Vars, vars)
if err != nil {
return nil, err
Expand Down Expand Up @@ -245,6 +260,8 @@
CachedBalances Balances

CurrentBalanceQuery BalanceQuery

OverdraftFunctionFeatureFlag bool
}

func (st *programState) pushSender(name string, monetary *big.Int) {
Expand Down Expand Up @@ -778,6 +795,22 @@
return value, nil
}

// Utility function to get the balance
func getBalance(
s *programState,
account string,
asset string,
) (*big.Int, InterpreterError) {
s.batchQuery(account, asset)
fetchBalanceErr := s.runBalancesQuery()
if fetchBalanceErr != nil {
return nil, QueryBalanceError{WrappedError: fetchBalanceErr}
}

Check warning on line 808 in internal/interpreter/interpreter.go

View check run for this annotation

Codecov / codecov/patch

internal/interpreter/interpreter.go#L807-L808

Added lines #L807 - L808 were not covered by tests
balance := s.getCachedBalance(account, asset)
return balance, nil

}

func balance(
s *programState,
r parser.Range,
Expand All @@ -793,13 +826,12 @@
}

// body
s.batchQuery(*account, *asset)
fetchBalanceErr := s.runBalancesQuery()
if fetchBalanceErr != nil {
return nil, QueryBalanceError{WrappedError: fetchBalanceErr}

balance, err := getBalance(s, *account, *asset)
if err != nil {
return nil, err

Check warning on line 832 in internal/interpreter/interpreter.go

View check run for this annotation

Codecov / codecov/patch

internal/interpreter/interpreter.go#L832

Added line #L832 was not covered by tests
}

balance := s.getCachedBalance(*account, *asset)
if balance.Cmp(big.NewInt(0)) == -1 {
return nil, NegativeBalanceError{
Account: *account,
Expand All @@ -816,6 +848,44 @@
return &m, nil
}

func overdraft(
s *programState,
r parser.Range,
args []Value,
) (*Monetary, InterpreterError) {
if !s.OverdraftFunctionFeatureFlag {
return nil, ExperimentalFeature{FlagName: ExperimentalOverdraftFunctionFeatureFlag}
}

// TODO more precise args range location
p := NewArgsParser(args)
account := parseArg(p, r, expectAccount)
asset := parseArg(p, r, expectAsset)
err := p.parse()
if err != nil {
return nil, err
}

Check warning on line 867 in internal/interpreter/interpreter.go

View check run for this annotation

Codecov / codecov/patch

internal/interpreter/interpreter.go#L866-L867

Added lines #L866 - L867 were not covered by tests

balance_, err := getBalance(s, *account, *asset)
if err != nil {
return nil, err
}

Check warning on line 872 in internal/interpreter/interpreter.go

View check run for this annotation

Codecov / codecov/patch

internal/interpreter/interpreter.go#L871-L872

Added lines #L871 - L872 were not covered by tests

balanceIsPositive := balance_.Cmp(big.NewInt(0)) == 1
if balanceIsPositive {
return &Monetary{
Amount: NewMonetaryInt(0),
Asset: Asset(*asset),
}, nil
}

overdraft := new(big.Int).Neg(balance_)
return &Monetary{
Amount: MonetaryInt(*overdraft),
Asset: Asset(*asset),
}, nil
}

func setTxMeta(st *programState, r parser.Range, args []Value) InterpreterError {
p := NewArgsParser(args)
key := parseArg(p, r, expectString)
Expand Down
9 changes: 9 additions & 0 deletions internal/interpreter/interpreter_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,12 @@
func (e QueryMetadataError) Error() string {
return e.WrappedError.Error()
}

type ExperimentalFeature struct {
parser.Range
FlagName string
}

func (e ExperimentalFeature) Error() string {
return fmt.Sprintf("this feature is experimental. You need the '%s' feature flag to enable it", e.FlagName)

Check warning on line 194 in internal/interpreter/interpreter_error.go

View check run for this annotation

Codecov / codecov/patch

internal/interpreter/interpreter_error.go#L193-L194

Added lines #L193 - L194 were not covered by tests
}
153 changes: 149 additions & 4 deletions internal/interpreter/interpreter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,50 @@ func (c *TestCase) setBalance(account string, asset string, amount int64) {
}

func test(t *testing.T, testCase TestCase) {
testWithFeatureFlag(t, testCase, "")
}

// A version of test() which tests code under a feature flag
// if the feature flag is the empty string, it behaves as test()
// otherwise, it tests the program under that feature flag and also tests that
// the same script, without the flag, yields the ExperimentalFeature{} error
func testWithFeatureFlag(t *testing.T, testCase TestCase, flagName string) {
t.Parallel()

prog := testCase.program

require.NotNil(t, prog)

execResult, err := machine.RunProgram(context.Background(), *prog, testCase.vars, machine.StaticStore{
testCase.balances,
testCase.meta,
}, nil)
featureFlags := map[string]struct{}{}
if flagName != "" {
featureFlags[flagName] = struct{}{}

_, err := machine.RunProgram(
context.Background(),
*prog,
testCase.vars,
machine.StaticStore{
testCase.balances,
testCase.meta,
},
nil,
)

require.Equal(t, machine.ExperimentalFeature{
FlagName: flagName,
}, removeRange(err))
}

execResult, err := machine.RunProgram(
context.Background(),
*prog,
testCase.vars,
machine.StaticStore{
testCase.balances,
testCase.meta,
},
featureFlags,
)

expected := testCase.expected
if expected.Error != nil {
Expand Down Expand Up @@ -3266,6 +3300,117 @@ func TestSaveFromAccount(t *testing.T) {
})
}

func TestOverdraftFunctionWhenNegative(t *testing.T) {
script := `
vars { monetary $amt = overdraft(@acc, EUR/2) }

send $amt (
source = @world
destination = @dest
)

`

tc := NewTestCase()
tc.compile(t, script)

tc.setBalance("acc", "EUR/2", -100)
tc.expected = CaseResult{
Postings: []Posting{
{
Asset: "EUR/2",
Amount: big.NewInt(100),
Source: "world",
Destination: "dest",
},
},
Error: nil,
}
testWithFeatureFlag(t, tc, machine.ExperimentalOverdraftFunctionFeatureFlag)
}

func TestOverdraftFunctionWhenZero(t *testing.T) {
script := `
vars { monetary $amt = overdraft(@acc, EUR/2) }

send $amt (
source = @world
destination = @dest
)

`

tc := NewTestCase()
tc.compile(t, script)

tc.expected = CaseResult{
Postings: []Posting{
// zero posting is omitted
},
Error: nil,
}
testWithFeatureFlag(t, tc, machine.ExperimentalOverdraftFunctionFeatureFlag)
}

func TestOverdraftFunctionWhenPositive(t *testing.T) {
script := `
vars { monetary $amt = overdraft(@acc, EUR/2) }

send $amt (
source = @world
destination = @dest
)
`

tc := NewTestCase()
tc.compile(t, script)

tc.setBalance("acc", "EUR/2", 100)

tc.expected = CaseResult{
Postings: []Posting{
// zero posting is omitted
},
Error: nil,
}
testWithFeatureFlag(t, tc, machine.ExperimentalOverdraftFunctionFeatureFlag)
}

func TestOverdraftFunctionUseCaseRemoveDebt(t *testing.T) {
script := `
vars { monetary $amt = overdraft(@user:001, USD/2) }


// we have at most 1000 USD/2 to remove user:001's debt
send [USD/2 1000] (
source = @world
destination = {
// but we send at most what we need to cancel the debt
max $amt to @user:001
remaining kept
}
)
`

tc := NewTestCase()
tc.compile(t, script)

tc.setBalance("user:001", "USD/2", -100)

tc.expected = CaseResult{
Postings: []Posting{
machine.Posting{
Asset: "USD/2",
Amount: big.NewInt(100),
Source: "world",
Destination: "user:001",
},
},
Error: nil,
}
testWithFeatureFlag(t, tc, machine.ExperimentalOverdraftFunctionFeatureFlag)
}

func TestAddMonetariesSameCurrency(t *testing.T) {
script := `
send [COIN 1] + [COIN 2] (
Expand Down
Loading
Loading