From 7f832fa635ae2c4274feac59a3fe8f3a388e13ed Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 10 Jun 2025 11:44:13 +0200 Subject: [PATCH 1/5] Prototype finally some tests passing other passing tests fix edge cases fix edge cases cleaned up remove comment feat: better dbg I guess we made it? refactor remove unused code adde test and removed comment fix test renamed var moved function refactor simplify code simplify code added tests WIP test test: migrated tests test: migrated test test: migrated test test: migrate tests test: migrated tests test: migrated tests test: removed leftover test fix after rebase --- internal/analysis/check.go | 13 + internal/interpreter/args_parser_test.go | 8 +- internal/interpreter/batch_balances_query.go | 16 +- internal/interpreter/evaluate_expr.go | 2 +- internal/interpreter/function_exprs.go | 12 +- internal/interpreter/function_statements.go | 2 +- internal/interpreter/funds_stack.go | 26 +- internal/interpreter/funds_stack_test.go | 104 ++--- internal/interpreter/interpreter.go | 196 +++++++-- internal/interpreter/interpreter_error.go | 2 +- internal/interpreter/interpreter_test.go | 6 +- .../virtual-account/account-create.num | 11 + .../account-create.num.specs.json | 17 + .../virtual-account/account-kept.num | 19 + .../account-kept.num.specs.json | 17 + .../bounded-overdraft-when-does-not-fail.num | 11 + ...verdraft-when-does-not-fail.num.specs.json | 17 + .../virtual-account/creditors-stack.num | 19 + .../creditors-stack.num.specs.json | 23 + .../virtual-account/half-using-virtual.num | 9 + .../half-using-virtual.num.specs.json | 21 + .../min-constraint-fail-if-not-enough.num | 35 ++ ...nstraint-fail-if-not-enough.num.specs.json | 83 ++++ ...onstraint-merchant-pays-fees-if-needed.num | 22 + ...erchant-pays-fees-if-needed.num.specs.json | 69 +++ ...straint-no-commissions-with-low-amount.num | 27 ++ ...commissions-with-low-amount.num.specs.json | 60 +++ .../overdraft-left-negative.num | 11 + .../overdraft-left-negative.num.specs.json | 17 + .../virtual-account/overdraft-when-fails.num | 11 + .../overdraft-when-fails.num.specs.json | 10 + .../virtual-account/overdraft.num | 12 + .../virtual-account/overdraft.num.specs.json | 17 + .../prevent-double-spending-send-all.num | 14 + ...nt-double-spending-send-all.num.specs.json | 17 + .../prevent-double-spending.num | 14 + .../prevent-double-spending.num.specs.json | 10 + .../virtual-account/repay-self.num | 14 + .../virtual-account/repay-self.num.specs.json | 54 +++ .../virtual-account/send-credit-around.num | 22 + .../send-credit-around.num.specs.json | 13 + .../test-send-self-is-noop.num | 5 + .../test-send-self-is-noop.num.specs.json | 5 + .../virtual-account/transitive.num | 16 + .../virtual-account/transitive.num.specs.json | 17 + .../virtual-account/wrong-currency.num | 9 + .../wrong-currency.num.specs.json | 5 + internal/interpreter/value.go | 54 ++- internal/interpreter/virtual_account.go | 184 ++++++++ internal/interpreter/virtual_account_test.go | 409 ++++++++++++++++++ 50 files changed, 1649 insertions(+), 138 deletions(-) create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num.specs.json create mode 100644 internal/interpreter/virtual_account.go create mode 100644 internal/interpreter/virtual_account_test.go diff --git a/internal/analysis/check.go b/internal/analysis/check.go index df7d570f..37dfe98b 100644 --- a/internal/analysis/check.go +++ b/internal/analysis/check.go @@ -61,6 +61,7 @@ const FnVarOriginBalance = "balance" const FnVarOriginOverdraft = "overdraft" const FnVarOriginGetAsset = "get_asset" const FnVarOriginGetAmount = "get_amount" +const FnVarOriginVirtual = "virtual" var Builtins = map[string]FnCallResolution{ FnSetTxMeta: StatementFnCallResolution{ @@ -114,6 +115,18 @@ var Builtins = map[string]FnCallResolution{ }, }, }, + FnVarOriginVirtual: VarOriginFnCallResolution{ + Params: []string{}, + Return: TypeAccount, + Docs: "create a virtual account", + VersionConstraints: []VersionClause{ + { + // TODO flag + Version: parser.NewVersionInterpreter(0, 0, 17), + // FeatureFlag: flags.ExperimentalGetAmountFunctionFeatureFlag, + }, + }, + }, } type Diagnostic struct { diff --git a/internal/interpreter/args_parser_test.go b/internal/interpreter/args_parser_test.go index c40c5d03..8c764857 100644 --- a/internal/interpreter/args_parser_test.go +++ b/internal/interpreter/args_parser_test.go @@ -36,7 +36,7 @@ func TestParseValid(t *testing.T) { p := NewArgsParser([]Value{ NewMonetaryInt(42), - AccountAddress("user:001"), + Account{Repr: AccountAddress("user:001")}, }) a1 := parseArg(p, parser.Range{}, expectNumber) a2 := parseArg(p, parser.Range{}, expectAccount) @@ -47,8 +47,8 @@ func TestParseValid(t *testing.T) { require.NotNil(t, a1, "a1 should not be nil") require.NotNil(t, a2, "a2 should not be nil") - require.Equal(t, *a1, *big.NewInt(42)) - require.Equal(t, *a2, "user:001") + require.Equal(t, *big.NewInt(42), *a1) + require.Equal(t, Account{AccountAddress("user:001")}, *a2) } func TestParseBadType(t *testing.T) { @@ -56,7 +56,7 @@ func TestParseBadType(t *testing.T) { p := NewArgsParser([]Value{ NewMonetaryInt(42), - AccountAddress("user:001"), + Account{Repr: AccountAddress("user:001")}, }) parseArg(p, parser.Range{}, expectMonetary) parseArg(p, parser.Range{}, expectAccount) diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index cedd25ec..67d46bf2 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -31,7 +31,11 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen if err != nil { return err } - st.batchQuery(*account, *asset, nil) + + if account, ok := account.Repr.(AccountAddress); ok { + st.batchQuery(string(account), *asset, nil) + } + return nil case *parser.SendStatement: @@ -95,7 +99,10 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return err } - st.batchQuery(*account, st.CurrentAsset, color) + if account, ok := account.Repr.(AccountAddress); ok { + st.batchQuery(string(account), st.CurrentAsset, color) + } + return nil case *parser.SourceOverdraft: @@ -113,7 +120,10 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return err } - st.batchQuery(*account, st.CurrentAsset, color) + if account, ok := account.Repr.(AccountAddress); ok { + st.batchQuery(string(account), st.CurrentAsset, color) + } + return nil case *parser.SourceInorder: diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 04b1abc9..7c5c7a3c 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -216,7 +216,7 @@ func (st *programState) divOp(rng parser.Range, left parser.ValueExpr, right par func castToString(v Value, rng parser.Range) (string, InterpreterError) { switch v := v.(type) { - case AccountAddress: + case Account: return v.String(), nil case String: return v.String(), nil diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 667ef21e..c47c115f 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -19,7 +19,7 @@ func overdraft( // TODO more precise args range location p := NewArgsParser(args) - account := parseArg(p, r, expectAccount) + account := parseArg(p, r, expectAccountAddress) // TODO also handle virtual account asset := parseArg(p, r, expectAsset) err = p.parse() if err != nil { @@ -53,7 +53,7 @@ func meta( ) (string, InterpreterError) { // TODO more precise location p := NewArgsParser(args) - account := parseArg(p, rng, expectAccount) + account := parseArg(p, rng, expectAccountAddress) key := parseArg(p, rng, expectString) err := p.parse() if err != nil { @@ -86,7 +86,7 @@ func balance( ) (*Monetary, InterpreterError) { // TODO more precise args range location p := NewArgsParser(args) - account := parseArg(p, r, expectAccount) + account := parseArg(p, r, expectAccountAddress) asset := parseArg(p, r, expectAsset) err := p.parse() if err != nil { @@ -102,7 +102,7 @@ func balance( if balance.Cmp(big.NewInt(0)) == -1 { return nil, NegativeBalanceError{ - Account: *account, + Account: Account{AccountAddress(*account)}, Amount: *balance, } } @@ -155,3 +155,7 @@ func getAmount( return mon.Amount, nil } + +func virtual() Value { + return Account{Repr: NewVirtualAccount()} +} diff --git a/internal/interpreter/function_statements.go b/internal/interpreter/function_statements.go index 89179b78..2c7e0fa7 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -20,7 +20,7 @@ func setTxMeta(st *programState, r parser.Range, args []Value) InterpreterError func setAccountMeta(st *programState, r parser.Range, args []Value) InterpreterError { p := NewArgsParser(args) - account := parseArg(p, r, expectAccount) + account := parseArg(p, r, expectAccountAddress) key := parseArg(p, r, expectString) meta := parseArg(p, r, expectAnything) err := p.parse() diff --git a/internal/interpreter/funds_stack.go b/internal/interpreter/funds_stack.go index ca82877c..7baf5d41 100644 --- a/internal/interpreter/funds_stack.go +++ b/internal/interpreter/funds_stack.go @@ -5,9 +5,9 @@ import ( ) type Sender struct { - Name string - Amount *big.Int - Color string + Account AccountValue + Amount *big.Int + Color string } type stack[T any] struct { @@ -76,15 +76,15 @@ func (s *fundsStack) compactTop() { continue } - if first.Name != second.Name || first.Color != second.Color { + if first.Account != second.Account || first.Color != second.Color { return } s.senders = &stack[Sender]{ Head: Sender{ - Name: first.Name, - Color: first.Color, - Amount: new(big.Int).Add(first.Amount, second.Amount), + Account: first.Account, + Color: first.Color, + Amount: new(big.Int).Add(first.Amount, second.Amount), }, Tail: s.senders.Tail.Tail, } @@ -152,9 +152,9 @@ func (s *fundsStack) Pull(requiredAmount *big.Int, color *string) []Sender { case 1: // more than enough s.senders = &stack[Sender]{ Head: Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Sub(available.Amount, requiredAmount), + Account: available.Account, + Color: available.Color, + Amount: new(big.Int).Sub(available.Amount, requiredAmount), }, Tail: s.senders, } @@ -162,9 +162,9 @@ func (s *fundsStack) Pull(requiredAmount *big.Int, color *string) []Sender { case 0: // exactly the same out = append(out, Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Set(requiredAmount), + Account: available.Account, + Color: available.Color, + Amount: new(big.Int).Set(requiredAmount), }) return out } diff --git a/internal/interpreter/funds_stack_test.go b/internal/interpreter/funds_stack_test.go index a7a5d6af..f59a67fa 100644 --- a/internal/interpreter/funds_stack_test.go +++ b/internal/interpreter/funds_stack_test.go @@ -9,49 +9,49 @@ import ( func TestEnoughBalance(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(100)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(100)}, }) out := stack.PullAnything(big.NewInt(2)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, }, out) } func TestPush(t *testing.T) { stack := newFundsStack(nil) - stack.Push(Sender{Name: "acc", Amount: big.NewInt(100)}) + stack.Push(Sender{Account: AccountAddress("acc"), Amount: big.NewInt(100)}) out := stack.PullUncolored(big.NewInt(20)) require.Equal(t, []Sender{ - {Name: "acc", Amount: big.NewInt(20)}, + {Account: AccountAddress("acc"), Amount: big.NewInt(20)}, }, out) } func TestSimple(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(10)}, }) out := stack.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(3)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(3)}, }, out) out = stack.PullAnything(big.NewInt(7)) require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(7)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(7)}, }, out) } func TestPullZero(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(10)}, }) out := stack.PullAnything(big.NewInt(0)) @@ -60,123 +60,123 @@ func TestPullZero(t *testing.T) { func TestCompactFunds(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(10)}, }) out := stack.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(5)}, }, out) } func TestCompactFunds3Times(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(3)}, - {Name: "s1", Amount: big.NewInt(1)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(3)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(1)}, }) out := stack.PullAnything(big.NewInt(6)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(6)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(6)}, }, out) } func TestCompactFundsWithEmptySender(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(0)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(0)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(10)}, }) out := stack.PullAnything(big.NewInt(5)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(5)}, }, out) } func TestMissingFunds(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, }) out := stack.PullAnything(big.NewInt(300)) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, }, out) } func TestNoZeroLeftovers(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(10)}, - {Name: "s2", Amount: big.NewInt(15)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(10)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(15)}, }) stack.PullAnything(big.NewInt(10)) out := stack.PullAnything(big.NewInt(15)) require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(15)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(15)}, }, out) } func TestReconcileColoredManyDestPerSender(t *testing.T) { stack := newFundsStack([]Sender{ - {"src", big.NewInt(10), "X"}, + {AccountAddress("src"), big.NewInt(10), "X"}, }) out := stack.PullColored(big.NewInt(5), "X") require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, + {Account: AccountAddress("src"), Amount: big.NewInt(5), Color: "X"}, }, out) out = stack.PullColored(big.NewInt(5), "X") require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, + {Account: AccountAddress("src"), Amount: big.NewInt(5), Color: "X"}, }, out) } func TestPullColored(t *testing.T) { stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - {Name: "s2", Amount: big.NewInt(1), Color: "red"}, - {Name: "s3", Amount: big.NewInt(10)}, - {Name: "s4", Amount: big.NewInt(2), Color: "red"}, - {Name: "s5", Amount: big.NewInt(5)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(5)}, + {Account: AccountAddress("s2"), Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress("s3"), Amount: big.NewInt(10)}, + {Account: AccountAddress("s4"), Amount: big.NewInt(2), Color: "red"}, + {Account: AccountAddress("s5"), Amount: big.NewInt(5)}, }) out := stack.PullColored(big.NewInt(2), "red") require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(1), Color: "red"}, - {Name: "s4", Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress("s2"), Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress("s4"), Amount: big.NewInt(1), Color: "red"}, }, out) require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - {Name: "s3", Amount: big.NewInt(10)}, - {Name: "s4", Amount: big.NewInt(1), Color: "red"}, - {Name: "s5", Amount: big.NewInt(5)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(5)}, + {Account: AccountAddress("s3"), Amount: big.NewInt(10)}, + {Account: AccountAddress("s4"), Amount: big.NewInt(1), Color: "red"}, + {Account: AccountAddress("s5"), Amount: big.NewInt(5)}, }, stack.PullAll()) } func TestPullColoredComplex(t *testing.T) { stack := newFundsStack([]Sender{ - {"s1", big.NewInt(1), "c1"}, - {"s2", big.NewInt(1), "c2"}, + {AccountAddress("s1"), big.NewInt(1), "c1"}, + {AccountAddress("s2"), big.NewInt(1), "c2"}, }) out := stack.PullColored(big.NewInt(1), "c2") require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(1), Color: "c2"}, + {Account: AccountAddress("s2"), Amount: big.NewInt(1), Color: "c2"}, }, out) } func TestClone(t *testing.T) { fs := newFundsStack([]Sender{ - {"s1", big.NewInt(10), ""}, + {AccountAddress("s1"), big.NewInt(10), ""}, }) cloned := fs.Clone() @@ -184,7 +184,7 @@ func TestClone(t *testing.T) { fs.PullAll() require.Equal(t, []Sender{ - {"s1", big.NewInt(10), ""}, + {AccountAddress("s1"), big.NewInt(10), ""}, }, cloned.PullAll()) } @@ -193,20 +193,20 @@ func TestCompactFundsAndPush(t *testing.T) { noCol := "" stack := newFundsStack([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(2)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(10)}, }) stack.Pull(big.NewInt(1), &noCol) stack.Push(Sender{ - Name: "pushed", - Amount: big.NewInt(42), + Account: AccountAddress("pushed"), + Amount: big.NewInt(42), }) out := stack.PullAll() require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(11)}, - {Name: "pushed", Amount: big.NewInt(42)}, + {Account: AccountAddress("s1"), Amount: big.NewInt(11)}, + {Account: AccountAddress("pushed"), Amount: big.NewInt(42)}, }, out) } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 570da1f4..678e0194 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -194,6 +194,8 @@ func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, return getAsset(s, fnCall.Range, args) case analysis.FnVarOriginGetAmount: return getAmount(s, fnCall.Range, args) + case analysis.FnVarOriginVirtual: + return virtual(), nil default: return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} @@ -219,6 +221,14 @@ func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[ if err != nil { return err } + + if acc, ok := value.(Account); ok { + if vacc, ok := acc.Repr.(VirtualAccount); ok { + vacc.Dbg = varsDecl.Name.Name + value = Account{vacc} + } + } + s.ParsedVars[varsDecl.Name.Name] = value } } @@ -245,6 +255,9 @@ func RunProgram( CurrentBalanceQuery: BalanceQuery{}, ctx: ctx, FeatureFlags: featureFlags, + + virtualAccountsCredits: make(map[string]map[string]*fundsStack), + virtualAccountsDebts: make(map[string]map[string]*fundsStack), } st.varOriginPosition = true @@ -309,46 +322,84 @@ type programState struct { CurrentBalanceQuery BalanceQuery FeatureFlags map[string]struct{} + + // An {accountid, asset}->fundsStack map + virtualAccountsCredits map[string]map[string]*fundsStack + virtualAccountsDebts map[string]map[string]*fundsStack } -func (st *programState) pushSender(name string, monetary *big.Int, color string) { - if monetary.Cmp(big.NewInt(0)) == 0 { - return +// Pushes sender to fs, and keeps track of the additional sent value by adding it (in-loco) to the given totalSent ptr. +// if totalSent is nil, it's considered as zero +func (st *programState) pushSender(sender Sender, totalSent *big.Int) *big.Int { + if totalSent == nil { + totalSent = big.NewInt(0) } - balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color) - balance.Sub(balance, monetary) + if sender.Amount.Cmp(big.NewInt(0)) == 0 { + return totalSent + } - st.fundsStack.Push(Sender{Name: name, Amount: monetary, Color: color}) -} + totalSent.Add(totalSent, sender.Amount) -func (st *programState) pushReceiver(name string, monetary *big.Int) { - if monetary.Cmp(big.NewInt(0)) == 0 { - return + switch account := sender.Account.(type) { + case VirtualAccount: + // No need to do anything + // we'll get this account's balance from the fundsStack + + case AccountAddress: + balance := st.CachedBalances.fetchBalance(string(account), st.CurrentAsset, sender.Color) + balance.Sub(balance, sender.Amount) } - senders := st.fundsStack.PullAnything(monetary) + st.fundsStack.Push(sender) + + return totalSent +} +func (st *programState) pushReceiver(account Account, amount *big.Int) { + senders := st.fundsStack.PullAnything(amount) for _, sender := range senders { - postings := Posting{ - Source: sender.Name, + switch acc := account.Repr.(type) { + case VirtualAccount: + st.pushVirtualReceiver(acc, sender) + case AccountAddress: + st.pushReceiverAddress(string(acc), sender) + } + } +} + +func (st *programState) pushVirtualReceiver(vacc VirtualAccount, sender Sender) { + postings := vacc.Receive(st.CurrentAsset, sender) + st.Postings = append(st.Postings, postings...) +} + +func (st *programState) pushReceiverAddress(name string, sender Sender) { + switch senderAccountAddress := sender.Account.(type) { + case AccountAddress: + posting := Posting{ + Source: string(senderAccountAddress), Destination: name, Asset: coloredAsset(st.CurrentAsset, &sender.Color), Amount: sender.Amount, } - if name == KEPT_ADDR { // If funds are kept, give them back to senders - srcBalance := st.CachedBalances.fetchBalance(postings.Source, st.CurrentAsset, sender.Color) - srcBalance.Add(srcBalance, postings.Amount) - - continue + srcBalance := st.CachedBalances.fetchBalance(posting.Source, st.CurrentAsset, sender.Color) + srcBalance.Add(srcBalance, posting.Amount) + return } - - destBalance := st.CachedBalances.fetchBalance(postings.Destination, st.CurrentAsset, sender.Color) - destBalance.Add(destBalance, postings.Amount) - - st.Postings = append(st.Postings, postings) + destBalance := st.CachedBalances.fetchBalance(posting.Destination, st.CurrentAsset, sender.Color) + destBalance.Add(destBalance, posting.Amount) + st.Postings = append(st.Postings, posting) + + case VirtualAccount: + // Here we have a debt from a virtual acc. + // we don't want to emit that as a posting (but TODO check how does it interact with kept) + senderAccountAddress.Pull(st.CurrentAsset, Sender{ + AccountAddress(name), + sender.Amount, + sender.Color, + }) } } @@ -387,7 +438,7 @@ func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) Int return err } - account, err := evaluateExprAs(st, saveStatement.Amount, expectAccount) + account, err := evaluateExprAs(st, saveStatement.Amount, expectAccountAddress) if err != nil { return err } @@ -470,9 +521,9 @@ func (s *programState) sendAllToAccount(accountLiteral parser.ValueExpr, overdra return nil, err } - if *account == "world" || overdraft == nil { + if account.Repr == AccountAddress("world") || overdraft == nil { return nil, InvalidUnboundedInSendAll{ - Name: *account, + Name: account.String(), } } @@ -481,13 +532,29 @@ func (s *programState) sendAllToAccount(accountLiteral parser.ValueExpr, overdra return nil, err } - balance := s.CachedBalances.fetchBalance(*account, s.CurrentAsset, *color) + switch account := account.Repr.(type) { + case AccountAddress: + balance := s.CachedBalances.fetchBalance(string(account), s.CurrentAsset, *color) - // we sent balance+overdraft - sentAmt := CalculateMaxSafeWithdraw(balance, overdraft) + // we sent balance+overdraft + sentAmt := CalculateMaxSafeWithdraw(balance, overdraft) + + return s.pushSender(Sender{account, sentAmt, *color}, nil), nil + + case VirtualAccount: + totalSent := big.NewInt(0) + + senders := account.PullCredits(s.CurrentAsset) + for _, sender := range senders { + s.pushSender(sender, totalSent) + } + + return totalSent, nil - s.pushSender(*account, sentAmt, *color) - return sentAmt, nil + default: + utils.NonExhaustiveMatchPanic[any](account) + return nil, nil + } } // Send as much as possible (and return the sent amt) @@ -577,7 +644,7 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou if err != nil { return nil, err } - if *account == "world" { + if account.Repr == AccountAddress("world") { overdraft = nil } @@ -586,18 +653,56 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou return nil, err } - var actuallySentAmt *big.Int - if overdraft == nil { - // unbounded overdraft: we send the required amount - actuallySentAmt = new(big.Int).Set(amount) - } else { - balance := s.CachedBalances.fetchBalance(*account, s.CurrentAsset, *color) + switch account := account.Repr.(type) { + case AccountAddress: + var actuallySentAmt *big.Int + if overdraft == nil { + // unbounded overdraft: we send the required amount + actuallySentAmt = new(big.Int).Set(amount) + } else { + balance := s.CachedBalances.fetchBalance(string(account), s.CurrentAsset, *color) + + // that's the amount we are allowed to send (balance + overdraft) + actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) + } + return s.pushSender(Sender{account, actuallySentAmt, *color}, nil), nil + + case VirtualAccount: + totalSent := big.NewInt(0) + + fs := account.getCredits(s.CurrentAsset) + pulledSenders := fs.PullColored(amount, *color) + + for _, sender := range pulledSenders { + s.pushSender(sender, totalSent) + } + + // if we didn't pull enough + if totalSent.Cmp(amount) == -1 { + + // invariant: missingAmt > 0 + // (we never pull more than required) + missingAmt := new(big.Int).Sub(amount, totalSent) - // that's the amount we are allowed to send (balance + overdraft) - actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) + var addionalSent *big.Int + if overdraft == nil { + addionalSent = new(big.Int).Set(missingAmt) + } else { + // TODO check this is the correct number to eventually send + // TODO test overdraft + addionalSent = utils.MinBigInt(overdraft, missingAmt) + } + + s.pushSender(Sender{account, addionalSent, *color}, totalSent) + } + + return totalSent, nil + + default: + utils.NonExhaustiveMatchPanic[any](account) + return nil, nil } - s.pushSender(*account, actuallySentAmt, *color) - return actuallySentAmt, nil + } func (s *programState) cloneState() func() { @@ -703,12 +808,17 @@ func (s *programState) trySendingUpTo(source parser.Source, amount *big.Int) (*b } func (s *programState) receiveFrom(destination parser.Destination, amount *big.Int) InterpreterError { + if amount.Cmp(big.NewInt(0)) == 0 { + return nil + } + switch destination := destination.(type) { case *parser.DestinationAccount: account, err := evaluateExprAs(s, destination.ValueExpr, expectAccount) if err != nil { return err } + s.pushReceiver(*account, amount) return nil @@ -806,7 +916,7 @@ const KEPT_ADDR = "" func (s *programState) receiveFromKeptOrDest(keptOrDest parser.KeptOrDestination, amount *big.Int) InterpreterError { switch destinationTarget := keptOrDest.(type) { case *parser.DestinationKept: - s.pushReceiver(KEPT_ADDR, amount) + s.pushReceiver(Account{AccountAddress(KEPT_ADDR)}, amount) return nil case *parser.DestinationTo: diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index 3b791976..92e0bf53 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -114,7 +114,7 @@ func (e InvalidTypeErr) Error() string { type NegativeBalanceError struct { parser.Range - Account string + Account Account Amount big.Int } diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index ba0f8cb3..94880b52 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -399,7 +399,7 @@ func TestNegativeBalance(t *testing.T) { tc.setBalance("a", "EUR/2", -100) tc.expected = CaseResult{ Error: machine.NegativeBalanceError{ - Account: "a", + Account: machine.Account{machine.AccountAddress("a")}, Amount: *big.NewInt(-100), }, } @@ -470,7 +470,7 @@ func TestErrors(t *testing.T) { tc.expected = CaseResult{ Error: machine.TypeError{ Expected: "monetary", - Value: machine.AccountAddress("bad:type"), + Value: machine.Account{machine.AccountAddress("bad:type")}, }, } test(t, tc) @@ -610,7 +610,7 @@ func TestErrors(t *testing.T) { tc.expected = CaseResult{ Error: machine.TypeError{ Expected: "string", - Value: machine.AccountAddress("key_wrong_type"), + Value: machine.Account{machine.AccountAddress("key_wrong_type")}, }, } test(t, tc) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num new file mode 100644 index 00000000..3a224255 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num @@ -0,0 +1,11 @@ +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD 5] ( + source = $v + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num.specs.json new file mode 100644 index 00000000..4180c476 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 5, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num new file mode 100644 index 00000000..132fbea2 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num @@ -0,0 +1,19 @@ +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD *] ( + source = $v + destination = { + max [USD 5] to @dest + remaining kept + } +) + +send [USD *] ( + source = $v + destination = @other +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num.specs.json new file mode 100644 index 00000000..4180c476 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 5, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num new file mode 100644 index 00000000..fde5aec0 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num @@ -0,0 +1,11 @@ +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD 100] ( + source = $v allowing overdraft up to [USD 9999] + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num.specs.json new file mode 100644 index 00000000..780eb1d4 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 10, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num new file mode 100644 index 00000000..b902258b --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num @@ -0,0 +1,19 @@ +vars { + account $v = virtual() +} +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @d1 +) +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @d2 +) +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @d3 +) +send [USD 150] ( + source = @world + destination = $v +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num.specs.json new file mode 100644 index 00000000..6323e41d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num.specs.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "d1", + "amount": 100, + "asset": "USD" + }, + { + "source": "world", + "destination": "d2", + "amount": 50, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num new file mode 100644 index 00000000..df0449a0 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num @@ -0,0 +1,9 @@ +vars { account $v = virtual() } + +send [USD/2 10] ( + source = { + 1/2 from @alice + remaining from $v allowing unbounded overdraft + } + destination = @interests +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num.specs.json new file mode 100644 index 00000000..054b6faf --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num.specs.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "balances": { + "alice": { "USD/2": 5 } + }, + "testCases": [ + { + "it": "-", + + "expect.postings": [ + { + "source": "alice", + "destination": "interests", + "amount": 5, + "asset": "USD/2" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num new file mode 100644 index 00000000..981d5139 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num @@ -0,0 +1,35 @@ +// say that we need to send 10%*$amt (up to 5) to @fees; the rest to @dest +// if the $amt wasn't at least 5 in the first place, we fail (as @fees isn't able to get 5) +vars { + number $amt + account $fees_hold = virtual() + account $dest_hold = virtual() +} +send [EUR $amt] ( + source = @world + destination = { + // we don't send anything to @fees or @dest just yet + // that's because we don't know how much @dest can get, yet + 10% to $fees_hold + remaining to $dest_hold + } +) +send [EUR 5] ( + source = { + $fees_hold // <- we try to take 5 here + $dest_hold // but if it's not enough, we'll take the rest from here + } // note that we fail if we don't reach [EUR 5] + destination = @fees + // $fees_hold and $dest_hold are virtual: therefore they won't show up in the postings + // they keep the @world allocation (the source in the first stm) so that's what the + // postings will show +) +// now we empty the rest +send [EUR *] ( + source = $fees_hold + destination = @fees +) +send [EUR *] ( + source = $dest_hold + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num.specs.json new file mode 100644 index 00000000..f06e5a50 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num.specs.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account"], + "testCases": [ + { + "it": "amt=100", + "variables": { "amt": "100" }, + "expect.postings": [ + { + "source": "world", + "destination": "fees", + "amount": 5, + "asset": "EUR" + }, + { + "source": "world", + "destination": "fees", + "amount": 5, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 90, + "asset": "EUR" + } + ] + }, + { + "it": "amt=10", + "variables": { "amt": "10" }, + "expect.postings": [ + { + "source": "world", + "destination": "fees", + "amount": 5, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 5, + "asset": "EUR" + } + ] + }, + { + "it": "amt=6", + "variables": { "amt": "6" }, + "expect.postings": [ + { + "source": "world", + "destination": "fees", + "amount": 5, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 1, + "asset": "EUR" + } + ] + }, + { + "it": "amt=5", + "variables": { "amt": "5" }, + "expect.postings": [ + { + "source": "world", + "destination": "fees", + "amount": 5, + "asset": "EUR" + } + ] + }, + { + "it": "amt=3", + "variables": { "amt": "3" }, + "expect.missingFunds": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num new file mode 100644 index 00000000..52270183 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num @@ -0,0 +1,22 @@ +vars { + number $amt + account $fees_hold = virtual() +} +send [EUR $amt] ( + source = @merchant + destination = { + 10% to $fees_hold + remaining to @dest + } +) +send [EUR 5] ( + source = { + $fees_hold + @merchant + } + destination = @fees +) +send [EUR *] ( + source = $fees_hold + destination = @fees +) \ No newline at end of file diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num.specs.json new file mode 100644 index 00000000..a436b446 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num.specs.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "balances": { + "merchant": { "EUR": 999999 } + }, + "testCases": [ + { + "it": "amt=100", + "variables": { "amt": "100" }, + "expect.postings": [ + { + "source": "merchant", + "destination": "dest", + "amount": 90, + "asset": "EUR" + }, + { + "source": "merchant", + "destination": "fees", + "amount": 5, + "asset": "EUR" + }, + { + "source": "merchant", + "destination": "fees", + "amount": 5, + "asset": "EUR" + } + ] + }, + { + "it": "amt=10", + "variables": { "amt": "10" }, + "expect.postings": [ + { + "source": "merchant", + "destination": "dest", + "amount": 9, + "asset": "EUR" + }, + { + "source": "merchant", + "destination": "fees", + "amount": 5, + "asset": "EUR" + } + ] + }, + { + "it": "amt=6", + "variables": { "amt": "6" }, + "expect.postings": [ + { + "source": "merchant", + "destination": "dest", + "amount": 5, + "asset": "EUR" + }, + { + "source": "merchant", + "destination": "fees", + "amount": 5, + "asset": "EUR" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num new file mode 100644 index 00000000..6229ff91 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num @@ -0,0 +1,27 @@ +vars { + number $amt + account $fees_hold = virtual() + account $dest_hold = virtual() +} +send [EUR $amt] ( + source = @world + destination = { + 10% to $fees_hold + remaining to $dest_hold + } +) +send [EUR *] ( + source = $fees_hold + destination = oneof { + max [EUR 5] to @dest + remaining to @fees + } +) +send [EUR *] ( + source = $fees_hold + destination = @fees +) +send [EUR *] ( + source = $dest_hold + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num.specs.json new file mode 100644 index 00000000..1a237909 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num.specs.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "amt=100", + "variables": { "amt": "100" }, + "expect.postings": [ + { + "source": "world", + "destination": "fees", + "amount": 10, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 90, + "asset": "EUR" + } + ] + }, + { + "it": "amt=10", + "variables": { "amt": "10" }, + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 1, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 9, + "asset": "EUR" + } + ] + }, + { + "it": "amt=6", + "variables": { "amt": "6" }, + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 1, + "asset": "EUR" + }, + { + "source": "world", + "destination": "dest", + "amount": 5, + "asset": "EUR" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num new file mode 100644 index 00000000..78c7af0d --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num @@ -0,0 +1,11 @@ +vars { + account $v = virtual() +} +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @dest +) +send [USD 200] ( + source = @world + destination = @dest +) \ No newline at end of file diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num.specs.json new file mode 100644 index 00000000..0a7601ec --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 200, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num new file mode 100644 index 00000000..bb7c9fc9 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num @@ -0,0 +1,11 @@ +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD 100] ( + source = $v allowing overdraft up to [USD 1] + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num.specs.json new file mode 100644 index 00000000..dce1dbd5 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num.specs.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.missingFunds": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num new file mode 100644 index 00000000..425f9ce7 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num @@ -0,0 +1,12 @@ +vars { + account $v = virtual() +} +// we get the same result we'd have by swapping the statements +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @dest +) +send [USD 200] ( + source = @world + destination = $v +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num.specs.json new file mode 100644 index 00000000..8f621ddf --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num new file mode 100644 index 00000000..c365d771 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num @@ -0,0 +1,14 @@ +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD *] ( + source = { + $v + $v + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num.specs.json new file mode 100644 index 00000000..8eeb098a --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 10, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num new file mode 100644 index 00000000..e76befe8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num @@ -0,0 +1,14 @@ +vars { + account $v = virtual() +} +send [USD 5] ( + source = @world + destination = $v +) +send [USD 10] ( + source = { + $v + $v + } + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num.specs.json new file mode 100644 index 00000000..2031cf66 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num.specs.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account"], + "testCases": [ + { + "it": "-", + "expect.missingFunds": true + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num new file mode 100644 index 00000000..43fe6468 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num @@ -0,0 +1,14 @@ +vars { + account $alice_virtual = virtual() +} +send [USD/2 *] ( + source = @alice + destination = $alice_virtual +) +send [USD/2 10] ( + source = $alice_virtual allowing unbounded overdraft + destination = { + 1/2 to @dest + remaining kept + } +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num.specs.json new file mode 100644 index 00000000..858cd5a7 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num.specs.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "just enough balance", + "balances": { + "alice": { "USD/2": 5 } + }, + "expect.postings": [ + { + "source": "alice", + "destination": "dest", + "amount": 5, + "asset": "USD/2" + } + ] + }, + { + "it": "more than enough but less than 100%", + "balances": { + "alice": { "USD/2": 6 } + }, + "expect.postings": [ + { + "source": "alice", + "destination": "dest", + "amount": 5, + "asset": "USD/2" + } + ] + }, + { + "it": "fail when less than the half TODO double check", + "balances": { + "alice": { "USD/2": 2 } + } + }, + { + "it": "more than 100%", + "balances": { + "alice": { "USD/2": 100 } + }, + "expect.postings": [ + { + "source": "alice", + "destination": "dest", + "amount": 5, + "asset": "USD/2" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num new file mode 100644 index 00000000..0c7e1ab9 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num @@ -0,0 +1,22 @@ +vars { + account $v1 = virtual() + account $v2 = virtual() +} + +send [USD 1] ( + source = $v1 allowing unbounded overdraft + destination = $v2 +) + +send [USD 1] ( + // here's we're sending the credit we have from $v1 + // so $v2 doesn't "owe" anything to @dest + source = $v2 + destination = @dest +) + +send [USD 1] ( + // that's why this doesn't output any postings + source = @world + destination = $v2 +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num.specs.json new file mode 100644 index 00000000..9b2eaaec --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num.specs.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "balances": { + "alice": { "USD/2": 5 } + }, + "testCases": [ + { + "it": "-", + "expect.postings": [] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num new file mode 100644 index 00000000..b4eab22c --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num @@ -0,0 +1,5 @@ +vars { account $v = virtual() } +send [EUR 100] ( + source = $v allowing unbounded overdraft + destination = $v +) \ No newline at end of file diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num.specs.json new file mode 100644 index 00000000..1dfd276c --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num.specs.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [{ "it": "-", "expect.postings": [] }] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num new file mode 100644 index 00000000..ab3baa32 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num @@ -0,0 +1,16 @@ +vars { + account $v1 = virtual() + account $v2 = virtual() +} +send [USD 100] ( + source = @world + destination = $v1 +) +send [USD 100] ( + source = $v1 + destination = $v2 +) +send [USD 100] ( + source = $v2 + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num.specs.json new file mode 100644 index 00000000..8f621ddf --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num.specs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [ + { + "it": "-", + "expect.postings": [ + { + "source": "world", + "destination": "dest", + "amount": 100, + "asset": "USD" + } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num b/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num new file mode 100644 index 00000000..9d78d693 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num @@ -0,0 +1,9 @@ +vars { account $v = virtual() } +send [EUR 100] ( + source = @world + destination = $v +) +send [USD 20] ( + source = $v + destination = @dest +) diff --git a/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num.specs.json new file mode 100644 index 00000000..8b413413 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num.specs.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/specs.schema.json", + "featureFlags": ["experimental-virtual-account", "experimental-oneof"], + "testCases": [{ "it": "-", "expect.missingFunds": true }] +} diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index 7ec95c48..04ec72aa 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -9,6 +9,16 @@ import ( "github.com/formancehq/numscript/internal/parser" ) +type AccountValue interface { + account() + String() string +} + +type AccountAddress string + +func (AccountAddress) account() {} +func (VirtualAccount) account() {} + type Value interface { value() String() string @@ -17,25 +27,25 @@ type Value interface { type String string type Asset string type Portion big.Rat -type AccountAddress string +type Account struct{ Repr AccountValue } type MonetaryInt big.Int type Monetary struct { Amount MonetaryInt Asset Asset } -func (String) value() {} -func (AccountAddress) value() {} -func (MonetaryInt) value() {} -func (Monetary) value() {} -func (Portion) value() {} -func (Asset) value() {} +func (String) value() {} +func (Account) value() {} +func (MonetaryInt) value() {} +func (Monetary) value() {} +func (Portion) value() {} +func (Asset) value() {} -func NewAccountAddress(src string) (AccountAddress, InterpreterError) { +func NewAccountAddress(src string) (Account, InterpreterError) { if !validateAddress(src) { - return AccountAddress(""), InvalidAccountName{Name: src} + return Account{AccountAddress("")}, InvalidAccountName{Name: src} } - return AccountAddress(src), nil + return Account{AccountAddress(src)}, nil } func (v MonetaryInt) MarshalJSON() ([]byte, error) { @@ -59,6 +69,10 @@ func (v String) String() string { return string(v) } +func (v Account) String() string { + return v.Repr.String() +} + func (v AccountAddress) String() string { return string(v) } @@ -139,16 +153,30 @@ func expectAsset(v Value, r parser.Range) (*string, InterpreterError) { } } -func expectAccount(v Value, r parser.Range) (*string, InterpreterError) { +func expectAccount(v Value, r parser.Range) (*Account, InterpreterError) { switch v := v.(type) { - case AccountAddress: - return (*string)(&v), nil + case Account: + return &v, nil default: return nil, TypeError{Expected: analysis.TypeAccount, Value: v, Range: r} } } +func expectAccountAddress(v Value, r parser.Range) (*string, InterpreterError) { + acc, err := expectAccount(v, r) + if err != nil { + return nil, err + } + switch acc := acc.Repr.(type) { + case AccountAddress: + s := string(acc) + return &s, nil + default: + return nil, TypeError{Expected: analysis.TypeAccount, Value: v, Range: r} + } +} + func expectPortion(v Value, r parser.Range) (*big.Rat, InterpreterError) { switch v := v.(type) { case Portion: diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go new file mode 100644 index 00000000..0bf9c875 --- /dev/null +++ b/internal/interpreter/virtual_account.go @@ -0,0 +1,184 @@ +package interpreter + +import ( + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/utils" +) + +type VirtualAccount struct { + Dbg string + credits map[string]*fundsStack + debits map[string]*fundsStack +} + +func (v VirtualAccount) WithDbg(dbg string) VirtualAccount { + v.Dbg = dbg + return v +} +func (v VirtualAccount) String() string { + var name string + if v.Dbg != "" { + name = v.Dbg + } else { + name = "anonymous" + } + + return fmt.Sprintf("#", name) +} + +func NewVirtualAccount() VirtualAccount { + return VirtualAccount{ + credits: map[string]*fundsStack{}, + debits: map[string]*fundsStack{}, + } +} + +func (vacc *VirtualAccount) getCredits(asset string) *fundsStack { + return utils.MapGetOrPutDefault(vacc.credits, asset, func() *fundsStack { + fs := newFundsStack(nil) + return &fs + }) +} + +func (vacc *VirtualAccount) getDebits(asset string) *fundsStack { + return utils.MapGetOrPutDefault(vacc.debits, asset, func() *fundsStack { + fs := newFundsStack(nil) + return &fs + }) +} + +// Send funds to virtual account and add them to the account's credits. +// When pulled, the account will return those funds (with a FIFO policy). +// +// If the account has debts (with the same asset), we'll repay the debt first. +// In this case, the operation will emit the corresponding postings (if any). +func (vacc *VirtualAccount) Receive(asset string, sender Sender) []Posting { + // when receiving funds, we need to use them to clear debts first (if any) + debits := vacc.getDebits(asset) + + postings, remainingAmount := repayWithSender(debits, asset, sender) + + credits := vacc.getCredits(asset) + + sender.Amount = remainingAmount + credits.Push(sender) + + return postings +} + +func send( + source AccountValue, + destination AccountValue, + amount *big.Int, + asset string, + color string, +) []Posting { + switch source := source.(type) { + case AccountAddress: + + switch destination := destination.(type) { + case AccountAddress: + return []Posting{{ + Source: string(source), + Destination: string(destination), + Amount: amount, + Asset: coloredAsset(asset, &color), + }} + case VirtualAccount: + panic("TODO2") + } + + case VirtualAccount: + + switch dest := destination.(type) { + case AccountAddress: + return source.Pull(asset, Sender{ + Account: dest, + Amount: amount, + Color: color, + }) + + case VirtualAccount: + panic("TODO4") + } + + } + + panic("non exhaustive match") +} + +// Treat this stack as debts and use the sender to repay debt. +// Return the emitted postings and the remaining amount +func repayWithSender(s *fundsStack, asset string, credit Sender) ([]Posting, *big.Int) { + remainingAmt := new(big.Int).Set(credit.Amount) + + var postings []Posting + + // Take away the debt that the credit allows for + pulled := s.PullColored(credit.Amount, credit.Color) + for _, pulledSender := range pulled { + newPostings := send( + credit.Account, + pulledSender.Account, + pulledSender.Amount, + asset, + credit.Color, + ) + postings = append(postings, newPostings...) + } + + for _, p := range postings { + remainingAmt.Sub(remainingAmt, p.Amount) + } + + return postings, remainingAmt + +} + +// Pull all the *immediately* available credits +func (vacc *VirtualAccount) PullCredits(asset string) []Sender { + return vacc.getCredits(asset).PullAll() +} + +// Pull funds from the virtual account. +// +// If the overdraft is bounded (overdraft==0 is no overdraft), it may be possible that we don't pull enough. +// In that case, the operation will still succeed, but the sum of sent amount will be lower than the requested amount. +// +// If the overdraft is higher than 0 or unbounded, is possible that the pulled amount is higher than the virtual account's credits. +// In this case, we'll add the pulled amount to the virtual account's debts. +func (vacc *VirtualAccount) Pull(asset string, receiver Sender) []Posting { + credits := vacc.getCredits(asset) + pulled := credits.PullColored(receiver.Amount, receiver.Color) + + remainingAmt := new(big.Int).Set(receiver.Amount) + + var postings []Posting + + for _, pulledSender := range pulled { + newPostings := send( + pulledSender.Account, + receiver.Account, + pulledSender.Amount, + asset, + receiver.Color, + ) + postings = append(postings, newPostings...) + } + + // TODO it looks like we aren't using overdraft now. How's that possible? + if remainingAmt.Cmp(big.NewInt(0)) == 1 { + // If we didn't pull enough and we're allowed to overdraft, + // push the amount to debts WITHOUT emitting the corresponding postings (yet) + debits := vacc.getDebits(asset) + debits.Push(Sender{ + Account: receiver.Account, + Color: receiver.Color, + Amount: remainingAmt, + }) + } + + return postings +} diff --git a/internal/interpreter/virtual_account_test.go b/internal/interpreter/virtual_account_test.go new file mode 100644 index 00000000..7f9f35bc --- /dev/null +++ b/internal/interpreter/virtual_account_test.go @@ -0,0 +1,409 @@ +package interpreter_test + +import ( + "fmt" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/stretchr/testify/require" +) + +func TestVirtualAccountReceiveAndThenPull(t *testing.T) { + + vacc := interpreter.NewVirtualAccount() + + postings := vacc.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: big.NewInt(10), + }) + require.Empty(t, postings) + + postings = vacc.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: big.NewInt(10), + }) + require.Equal(t, []Posting{ + { + Source: "src", + Destination: "dest", + Amount: big.NewInt(10), + Asset: "USD", + }, + }, postings) +} + +func TestVirtualAccountReceiveAndThenPullPartialAmount(t *testing.T) { + vacc := interpreter.NewVirtualAccount() + + postings := vacc.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: big.NewInt(10), + }) + require.Empty(t, postings) + + postings = vacc.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: big.NewInt(1), // <- we're only pulling 1 out of 10 + }) + require.Equal(t, []Posting{ + { + Source: "src", + Destination: "dest", + Amount: big.NewInt(1), + Asset: "USD", + }, + }, postings) +} + +func TestVirtualAccountPullFirst(t *testing.T) { + // -> @dest (10 USD) + // @src -> (10 USD) + // => [@src, @dest, 10 USD] + + vacc := interpreter.NewVirtualAccount() + + // Now we pull first. Note the unbounded overdraft + postings := vacc.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: big.NewInt(10), + }) + // As there are no funds, no postings are emitted (yet) + require.Empty(t, postings) + + // Now we that we're sending funds to the account, the postings of the previous ".Pull()" are emitted + postings = vacc.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: big.NewInt(10), + }) + require.Equal(t, []Posting{ + { + Source: "src", + Destination: "dest", + Amount: big.NewInt(10), + Asset: "USD", + }, + }, postings) +} + +func TestVirtualAccountPullFirstMixed(t *testing.T) { + vacc := interpreter.NewVirtualAccount() + + // 1 USD of debt + vacc.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("lender"), + Amount: big.NewInt(1), + }) + + // 10 USD of credits + postings := vacc.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: big.NewInt(10), + }) + require.Equal(t, []Posting{ + { + Source: "src", + Destination: "lender", + Amount: big.NewInt(1), + Asset: "USD", + }, + }, postings) + + // pull the rest + postings = vacc.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: big.NewInt(100), + }) + require.Equal(t, []Posting{ + { + Source: "src", + Destination: "dest", + Amount: big.NewInt(9), + Asset: "USD", + }, + }, postings) +} + +func TestVirtualAccountTransitiveWhenNotOverdraft(t *testing.T) { + amt := big.NewInt(10) + + // @src -> $v0 (10 USD) + // $v0 -> $v1 (10 USD) + // $v1 -> @dest (10 USD) + // => [{@src, @dest, 10}] + + v0 := interpreter.NewVirtualAccount() + v0.Dbg = "v0" + + v1 := interpreter.NewVirtualAccount() + v0.Dbg = "v1" + + // @src -> $v0 (10 USD) + require.Empty(t, v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + })) + + // $v0 -> $v1 + require.Empty(t, v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + })) + + // $v1 -> @dest (10 USD) + // => [{@src, @dest, 10}] + + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, + v1.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) +} + +func TestVirtualAccountTransitiveWhenOverdraft(t *testing.T) { + amt := big.NewInt(10) + + // $v0 -> $v1 (10 USD) + // @src -> $v0 (10 USD) + // $v1 -> @dest (10 USD) + // => [{@src, @dest, 10}] + + v0 := interpreter.NewVirtualAccount() + v1 := interpreter.NewVirtualAccount() + + // $v0 -> $v1 + require.Empty(t, v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + })) + // @src -> $v0 (10 USD) + require.Empty(t, v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + })) + + // $v1 -> @dest (10 USD) + // => [{@src, @dest, 10}] + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, v1.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) +} + +func TestVirtualAccountTransitiveWhenOverdraftAndPayLast(t *testing.T) { + amt := big.NewInt(10) + + // $v0 -> $v1 (10 USD) + // $v1 -> @dest (10 USD) + // @src -> $v0 (10 USD) + // => [{@src, @dest, 10}] + + v0 := interpreter.NewVirtualAccount() + v1 := interpreter.NewVirtualAccount() + + // $v0 -> $v1 + require.Empty(t, v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + })) + + // $v1 -> @dest (10 USD) + require.Empty(t, v1.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) + + // @src -> $v0 (10 USD) + // => [{@src, @dest, 10}] + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + })) +} + +func TestVirtualAccountTransitiveTwoSteps(t *testing.T) { + amt := big.NewInt(10) + + //amt=10USD + // $v0 -> $v1 + // $v1 -> $v2 + // $v2 -> @dest + + // @src -> $v0 + // => [{@src, @dest, 10}] + + v0 := interpreter.NewVirtualAccount() + v1 := interpreter.NewVirtualAccount() + v2 := interpreter.NewVirtualAccount() + + // $v0 -> $v1 + require.Empty(t, v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + })) + // $v1 -> $v2 + require.Empty(t, v2.Receive("USD", interpreter.Sender{ + Account: v1, + Amount: amt, + })) + + // $v2 -> @dest + require.Empty(t, v2.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) + + // @src -> $v0 + // => [{@src, @dest, 10}] + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + })) +} + +func TestVirtualAccountTransitiveTwoStepsPayFirst(t *testing.T) { + amt := big.NewInt(10) + + //amt=10USD + // @src -> $v0 + // $v0 -> $v1 + // $v1 -> $v2 + // $v2 -> @dest + // => [{@src, @dest, 10}] + + v0 := interpreter.NewVirtualAccount() + v1 := interpreter.NewVirtualAccount() + v2 := interpreter.NewVirtualAccount() + + // @src -> $v0 + require.Empty(t, v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + })) + + // $v0 -> $v1 + require.Empty(t, v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + })) + + // $v1 -> $v2 + require.Empty(t, v2.Receive("USD", interpreter.Sender{ + Account: v1, + Amount: amt, + })) + + // $v2 -> @dest + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, v2.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) + +} + +func TestCommutativeOrder(t *testing.T) { + amt := big.NewInt(10) + + //amt=10USD + // @src -> $v0 + // $v0 -> $v1 + // $v1 -> $v2 + // $v2 -> @dest + // => [{@src, @dest, 10}] + + var v0 interpreter.VirtualAccount + var v1 interpreter.VirtualAccount + var v2 interpreter.VirtualAccount + + ops := []func() []Posting{ + func() []Posting { + return v0.Receive("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("src"), + Amount: amt, + }) + }, + func() []Posting { + return v1.Receive("USD", interpreter.Sender{ + Account: v0, + Amount: amt, + }) + }, + func() []Posting { + return v2.Receive("USD", interpreter.Sender{ + Account: v1, + Amount: amt, + }) + }, + func() []Posting { + return v2.Pull("USD", interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + }) + }, + } + permutations := permute(len(ops)) + + for _, permutation := range permutations { + t.Run(fmt.Sprintf("permutation [%v]", permutation), func(t *testing.T) { + v0 = interpreter.NewVirtualAccount().WithDbg("v0") + v1 = interpreter.NewVirtualAccount().WithDbg("v1") + v2 = interpreter.NewVirtualAccount().WithDbg("v2") + + for permIndex, index := range permutation { + op := ops[index] + postings := op() + isLast := permIndex == len(ops)-1 + if isLast { + require.Equal(t, []Posting{ + {"src", "dest", amt, "USD"}, + }, postings) + } else { + require.Empty(t, postings) + + } + + } + + }) + } +} + +// gpt-generated (I was too lazy to write that) +func permute(n int) [][]int { + var res [][]int + used := make([]bool, n) + var path []int + + var backtrack func() + backtrack = func() { + if len(path) == n { + perm := make([]int, n) + copy(perm, path) + res = append(res, perm) + return + } + for i := 0; i < n; i++ { + if used[i] { + continue + } + used[i] = true + path = append(path, i) + backtrack() + path = path[:len(path)-1] + used[i] = false + } + } + + backtrack() + return res +} From ab4c4493446df0b08caa1895d65a685ddeb4685c Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 1 Sep 2025 08:53:32 +0200 Subject: [PATCH 2/5] enforce feature flag for virtual() --- internal/flags/flags.go | 2 ++ internal/interpreter/function_exprs.go | 10 ++++++++-- internal/interpreter/interpreter.go | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 0cc593d4..628c20b2 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -10,6 +10,7 @@ const ( ExperimentalAccountInterpolationFlag FeatureFlag = "experimental-account-interpolation" ExperimentalMidScriptFunctionCall FeatureFlag = "experimental-mid-script-function-call" ExperimentalAssetColors FeatureFlag = "experimental-asset-colors" + ExperimentalVirtualAccount FeatureFlag = "experimental-virtual-account" ) var AllFlags []string = []string{ @@ -20,4 +21,5 @@ var AllFlags []string = []string{ ExperimentalAccountInterpolationFlag, ExperimentalMidScriptFunctionCall, ExperimentalAssetColors, + ExperimentalVirtualAccount, } diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index c47c115f..60a8ac56 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -156,6 +156,12 @@ func getAmount( return mon.Amount, nil } -func virtual() Value { - return Account{Repr: NewVirtualAccount()} +func virtual( + s *programState, +) (Value, InterpreterError) { + err := s.checkFeatureFlag(flags.ExperimentalVirtualAccount) + if err != nil { + return nil, err + } + return Account{Repr: NewVirtualAccount()}, nil } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 678e0194..f9b1fcd8 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -195,7 +195,7 @@ func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, case analysis.FnVarOriginGetAmount: return getAmount(s, fnCall.Range, args) case analysis.FnVarOriginVirtual: - return virtual(), nil + return virtual(s) default: return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} From 44f703c99ccfb094df34d09708e1cd3ec3d3c2ea Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 1 Sep 2025 08:54:57 +0200 Subject: [PATCH 3/5] edit panic descriptions --- internal/interpreter/virtual_account.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index 0bf9c875..a7c5c5a9 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -87,7 +87,8 @@ func send( Asset: coloredAsset(asset, &color), }} case VirtualAccount: - panic("TODO2") + // should be unreachable + panic("Unhandled: send from addr to virtual account") } case VirtualAccount: @@ -101,7 +102,8 @@ func send( }) case VirtualAccount: - panic("TODO4") + // should be unreachable + panic("Unhandled: send from virtual account to virtual account") } } From 5103a0b36bb019e810d45d5cdf7954698b6b8166 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 1 Sep 2025 09:07:13 +0200 Subject: [PATCH 4/5] add flag to function doc --- internal/analysis/check.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/analysis/check.go b/internal/analysis/check.go index 37dfe98b..81f501f6 100644 --- a/internal/analysis/check.go +++ b/internal/analysis/check.go @@ -121,9 +121,8 @@ var Builtins = map[string]FnCallResolution{ Docs: "create a virtual account", VersionConstraints: []VersionClause{ { - // TODO flag - Version: parser.NewVersionInterpreter(0, 0, 17), - // FeatureFlag: flags.ExperimentalGetAmountFunctionFeatureFlag, + Version: parser.NewVersionInterpreter(0, 0, 20), + FeatureFlag: flags.ExperimentalVirtualAccount, }, }, }, From e69fbe38dfd84e9e3f8eaebc386d8278ab58e301 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 5 Sep 2025 13:28:11 +0200 Subject: [PATCH 5/5] implemented balance() for virtual account --- internal/interpreter/function_exprs.go | 6 ++-- internal/interpreter/interpreter.go | 40 +++++++++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 60a8ac56..eb8b61e1 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -26,7 +26,7 @@ func overdraft( return nil, err } - balance_, err := getBalance(s, *account, *asset) + balance_, err := getBalance(s, Account{Repr: AccountAddress(*account)}, *asset) if err != nil { return nil, err } @@ -86,7 +86,7 @@ func balance( ) (*Monetary, InterpreterError) { // TODO more precise args range location p := NewArgsParser(args) - account := parseArg(p, r, expectAccountAddress) + account := parseArg(p, r, expectAccount) asset := parseArg(p, r, expectAsset) err := p.parse() if err != nil { @@ -102,7 +102,7 @@ func balance( if balance.Cmp(big.NewInt(0)) == -1 { return nil, NegativeBalanceError{ - Account: Account{AccountAddress(*account)}, + Account: *account, Amount: *balance, } } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index f9b1fcd8..746bb942 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -990,16 +990,42 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment // Utility function to get the balance func getBalance( s *programState, - account string, + account Account, asset string, ) (*big.Int, InterpreterError) { - s.batchQuery(account, asset, nil) - fetchBalanceErr := s.runBalancesQuery() - if fetchBalanceErr != nil { - return nil, QueryBalanceError{WrappedError: fetchBalanceErr} + switch accountRepr := account.Repr.(type) { + case AccountAddress: + account := string(accountRepr) + s.batchQuery(account, asset, nil) + fetchBalanceErr := s.runBalancesQuery() + if fetchBalanceErr != nil { + return nil, QueryBalanceError{WrappedError: fetchBalanceErr} + } + balance := s.CachedBalances.fetchBalance(account, asset, "") + return balance, nil + + case VirtualAccount: + fs := accountRepr.credits[s.CurrentAsset] + if fs == nil { + return big.NewInt(0), nil + } + + lst := fs.senders + sum := big.NewInt(0) + + for lst != nil { + if lst.Head.Color == "" { + sum.Add(sum, lst.Head.Amount) + } + lst = lst.Tail + } + return sum, nil + + default: + utils.NonExhaustiveMatchPanic[any](account.Repr) + return nil, nil + } - balance := s.CachedBalances.fetchBalance(account, asset, "") - return balance, nil }