diff --git a/internal/analysis/check.go b/internal/analysis/check.go index 46994dd..bf5e38f 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 c40c5d0..8c76485 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 af1c58f..96ff916 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 04b1abc..7c5c7a3 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 667ef21..c47c115 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 edb77db..3b38e99 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -17,7 +17,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 ca82877..9f5e232 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 } @@ -186,3 +186,45 @@ func (s fundsStack) Clone() fundsStack { return fs } + +// Treat this stack as debts and use the sender to repay debt. +// Return the sender updated with the left amt (and the emitted postings) +func (s *fundsStack) RepayWithSender(asset string, credit Sender) ([]Posting, Sender) { + // clone the amount so that we can modify it + credit.Amount = new(big.Int).Set(credit.Amount) + + var postings []Posting + + // Take away the debt that the credit allows for + clearedDebt := s.PullColored(credit.Amount, credit.Color) + for _, receiver := range clearedDebt { + switch creditAccount := credit.Account.(type) { + case VirtualAccount: + + // pulled := creditAccount.Pull(asset, nil, credit) + // fmt.Printf("PULLED: %#v", credit) + + panic("TODO handle vacc in credit scenario") + + case AccountAddress: + credit.Amount.Sub(credit.Amount, receiver.Amount) + + switch receiverAccount := receiver.Account.(type) { + case AccountAddress: + postings = append(postings, Posting{ + Source: string(creditAccount), + Destination: string(receiverAccount), + Amount: receiver.Amount, + Asset: coloredAsset(asset, &credit.Color), + }) + + case VirtualAccount: + panic("TODO repay vacc") + } + } + + } + + return postings, credit + +} diff --git a/internal/interpreter/funds_stack_test.go b/internal/interpreter/funds_stack_test.go index a7a5d6a..f59a67f 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 944b448..23daaee 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -191,6 +191,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} @@ -216,6 +218,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 } } @@ -242,6 +252,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 @@ -306,46 +319,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 { + 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: postings := Posting{ - Source: sender.Name, + 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 + return } - destBalance := st.CachedBalances.fetchBalance(postings.Destination, st.CurrentAsset, sender.Color) destBalance.Add(destBalance, postings.Amount) - st.Postings = append(st.Postings, postings) + + 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, nil, Sender{ + AccountAddress(name), + sender.Amount, + sender.Color, + }) } } @@ -384,7 +435,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 } @@ -467,9 +518,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(), } } @@ -478,13 +529,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) @@ -574,7 +641,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 } @@ -583,18 +650,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() { @@ -700,12 +805,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 @@ -803,7 +913,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 3b79197..92e0bf5 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 ad42e73..487e152 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -233,7 +233,7 @@ func TestSetTxMeta(t *testing.T) { "num": machine.NewMonetaryInt(42), "str": machine.String("abc"), "asset": machine.Asset("COIN"), - "account": machine.AccountAddress("acc"), + "account": machine.Account{machine.AccountAddress("acc")}, "portion": machine.Portion(*big.NewRat(12, 100)), }, Error: nil, @@ -1953,7 +1953,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), }, } @@ -2221,7 +2221,7 @@ func TestVariableBalance(t *testing.T) { tc.setBalance("src", "USD/2", -40) tc.expected = CaseResult{ Error: machine.NegativeBalanceError{ - Account: "src", + Account: machine.Account{machine.AccountAddress("src")}, Amount: *big.NewInt(-40), }, } @@ -2541,7 +2541,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) @@ -2681,7 +2681,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) @@ -4008,7 +4008,7 @@ func TestAccountInterp(t *testing.T) { tc.expected = CaseResult{ Postings: []Posting{}, TxMetadata: map[string]machine.Value{ - "k": machine.AccountAddress("acc:42:pending:user:001"), + "k": machine.Account{machine.AccountAddress("acc:42:pending:user:001")}, }, } testWithFeatureFlag(t, tc, flags.ExperimentalAccountInterpolationFlag) @@ -4870,3 +4870,627 @@ func TestSafeWithdraft(t *testing.T) { }) } + +func TestVirtualAccountCreate(t *testing.T) { + script := ` + vars { + account $v = virtual() + } + send [USD 10] ( + source = @world + destination = $v + ) + send [USD 5] ( + source = $v + destination = @dest + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(5), Asset: "USD"}, + }, + } + test(t, tc) +} + +func TestVirtualAccountPreventDoubleSpending(t *testing.T) { + script := ` + vars { + account $v = virtual() + } + send [USD 5] ( + source = @world + destination = $v + ) + send [USD 10] ( + source = { + $v + $v + } + destination = @dest + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Error: machine.MissingFundsErr{ + Needed: *big.NewInt(10), + Available: *big.NewInt(5), + Asset: "USD", + }, + } + test(t, tc) +} + +func TestVirtualAccountPreventDoubleSpendingInSendAll(t *testing.T) { + script := ` + vars { + account $v = virtual() + } + send [USD 10] ( + source = @world + destination = $v + ) + send [USD *] ( + source = { + $v + $v + } + destination = @dest + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(10), Asset: "USD"}, + }, + } + test(t, tc) +} + +func TestExampleMinConstraintFailIfNotEnough(t *testing.T) { + script := ` + // 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 + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + t.Run("amt=100", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "100"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + // TODO merge those + {Source: "world", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + {Source: "world", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + + {Source: "world", Destination: "dest", Amount: big.NewInt(90), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=10", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "10"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "world", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + {Source: "world", Destination: "dest", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=6", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "6"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + + {Source: "world", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + {Source: "world", Destination: "dest", Amount: big.NewInt(1), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=5", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "5"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + + {Source: "world", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=3", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "3"}`) + + tc.expected = CaseResult{ + Error: machine.MissingFundsErr{ + Asset: "EUR", + Needed: *big.NewInt(5), + Available: *big.NewInt(3), + }, + } + test(t, tc) + }) + +} +func TestExampleMinConstraintNoCommissionsWithLowAmt(t *testing.T) { + script := ` + 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 + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + t.Run("amt=100", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "100"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "world", Destination: "fees", Amount: big.NewInt(10), Asset: "EUR"}, + {Source: "world", Destination: "dest", Amount: big.NewInt(90), Asset: "EUR"}, + }, + } + testWithFeatureFlag(t, tc, flags.ExperimentalOneofFeatureFlag) + }) + + t.Run("amt=10", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "10"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + // TODO merge those + {Source: "world", Destination: "dest", Amount: big.NewInt(1), Asset: "EUR"}, + {Source: "world", Destination: "dest", Amount: big.NewInt(9), Asset: "EUR"}, + }, + } + testWithFeatureFlag(t, tc, flags.ExperimentalOneofFeatureFlag) + }) + + t.Run("amt=6", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "6"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + + {Source: "world", Destination: "dest", Amount: big.NewInt(1), Asset: "EUR"}, + {Source: "world", Destination: "dest", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + testWithFeatureFlag(t, tc, flags.ExperimentalOneofFeatureFlag) + }) +} + +func TestExampleMinConstraintMerchantPaysFeesIfNeeded(t *testing.T) { + script := ` +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 +) + ` + + tc := NewTestCase() + tc.setBalance("merchant", "EUR", 99999) + tc.compile(t, script) + + t.Run("amt=100", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "100"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + // TODO merge those + {Source: "merchant", Destination: "dest", Amount: big.NewInt(90), Asset: "EUR"}, + {Source: "merchant", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + {Source: "merchant", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=10", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "10"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "merchant", Destination: "dest", Amount: big.NewInt(9), Asset: "EUR"}, + {Source: "merchant", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + test(t, tc) + }) + + t.Run("amt=6", func(t *testing.T) { + tc.setVarsFromJSON(t, `{"amt": "6"}`) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + {Source: "merchant", Destination: "dest", Amount: big.NewInt(5), Asset: "EUR"}, + {Source: "merchant", Destination: "fees", Amount: big.NewInt(5), Asset: "EUR"}, + }, + } + test(t, tc) + }) +} + +func TestSelfSendIsNoop(t *testing.T) { + script := ` +vars { account $v = virtual() } +send [EUR 100] ( + source = $v allowing unbounded overdraft + destination = $v +) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting(nil), + } + test(t, tc) + +} + +func TestWrongCurrencyVirtualAcc(t *testing.T) { + script := ` +vars { account $v = virtual() } +send [EUR 100] ( + source = @world + destination = $v +) +send [USD 20] ( + source = $v + destination = @dest +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Error: machine.MissingFundsErr{ + Asset: "USD", + Available: *big.NewInt(0), + Needed: *big.NewInt(20), + }, + } + test(t, tc) + +} + +func TestTransitiveVirtualAccount(t *testing.T) { + script := ` +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 +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(100), Asset: "USD"}, + }, + } + test(t, tc) + +} + +func TestOverdraftVirtual(t *testing.T) { + script := ` +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 +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(100), Asset: "USD"}, + }, + } + test(t, tc) + +} + +func TestBoundedOverdraftVirtualWhenFails(t *testing.T) { + script := ` +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD 100] ( + source = $v allowing overdraft up to [USD 1] + destination = @dest +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Error: machine.MissingFundsErr{ + Available: *big.NewInt(11), + Needed: *big.NewInt(100), + Asset: "USD", + }, + } + test(t, tc) +} + +func TestBoundedOverdraftVirtualWhenDoesNotFail(t *testing.T) { + script := ` +vars { + account $v = virtual() +} +send [USD 10] ( + source = @world + destination = $v +) +send [USD 100] ( + source = $v allowing overdraft up to [USD 9999] + destination = @dest +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(10), Asset: "USD"}, + }, + } + test(t, tc) +} + +func TestOverdraftVirtualLeftNegative(t *testing.T) { + script := ` +vars { + account $v = virtual() +} +send [USD 100] ( + source = $v allowing unbounded overdraft + destination = @dest +) +send [USD 200] ( + source = @world + destination = @dest +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "world", Destination: "dest", Amount: big.NewInt(200), Asset: "USD"}, + }, + } + test(t, tc) + +} + +func TestCreditorsStack(t *testing.T) { + script := ` +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 +) +` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "world", Destination: "d1", Amount: big.NewInt(100), Asset: "USD"}, + {Source: "world", Destination: "d2", Amount: big.NewInt(50), Asset: "USD"}, + }, + } + test(t, tc) + +} + +func TestRepaySelfWithVirtual(t *testing.T) { + script := ` +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 + } +) +` + + tc := NewTestCase() + tc.compile(t, script) + + t.Run("just enough balance", func(t *testing.T) { + // alice has just enough to give to @dest + tc.setBalance("alice", "USD/2", 5) + + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "alice", Destination: "dest", Amount: big.NewInt(5), Asset: "USD/2"}, + }, + } + test(t, tc) + }) + + t.Run("more than enough but less than 100%", func(t *testing.T) { + tc.setBalance("alice", "USD/2", 6) + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "alice", Destination: "dest", Amount: big.NewInt(5), Asset: "USD/2"}, + }, + } + test(t, tc) + }) + + t.Run("fail when less than the half", func(t *testing.T) { + t.Skip("this actually shouldn't fail. But is it what we want?") + + tc.setBalance("alice", "USD/2", 2) + tc.expected = CaseResult{ + Error: machine.MissingFundsErr{Asset: "USD/2", Needed: *big.NewInt(5), Available: *big.NewInt(2)}, + } + test(t, tc) + }) + + t.Run("more than 100%", func(t *testing.T) { + tc.setBalance("alice", "USD/2", 100) + tc.expected = CaseResult{ + Postings: []Posting{ + {Source: "alice", Destination: "dest", Amount: big.NewInt(5), Asset: "USD/2"}, + }, + } + test(t, tc) + }) + +} diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index 7ec95c4..04ec72a 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 0000000..ad0a232 --- /dev/null +++ b/internal/interpreter/virtual_account.go @@ -0,0 +1,129 @@ +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) 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 defaultMapGet(vacc.credits, asset, func() *fundsStack { + fs := newFundsStack(nil) + return &fs + }) +} + +func (vacc *VirtualAccount) getDebits(asset string) *fundsStack { + return defaultMapGet(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) + // TODO check debits first + // debits := vacc.getDebits(asset) + + debits := vacc.getDebits(asset) + + postings, sender := debits.RepayWithSender(asset, sender) + + credits := vacc.getCredits(asset) + credits.Push(sender) + + return postings +} + +// 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, overdraft *big.Int, receiver Sender) []Posting { + if overdraft == nil { + overdraft = new(big.Int).Set(receiver.Amount) + } + + 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 { + switch pulledSenderAccount := pulledSender.Account.(type) { + case VirtualAccount: + recPostings := pulledSenderAccount.Pull(asset, overdraft, receiver) + postings = append(postings, recPostings...) + continue + + case AccountAddress: + remainingAmt.Sub(remainingAmt, pulledSender.Amount) + switch receiverAccount := receiver.Account.(type) { + case AccountAddress: + postings = append(postings, Posting{ + Source: string(pulledSenderAccount), + Destination: string(receiverAccount), + Amount: pulledSender.Amount, + Asset: coloredAsset(asset, &receiver.Color), + }) + + case VirtualAccount: + // receiverAccount.Receive() + panic("TODO handle virtual account in Pull()") + } + } + + } + + allowedDebt := utils.MinBigInt(remainingAmt, overdraft) + if allowedDebt.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: allowedDebt, + }) + } + + return postings +} diff --git a/internal/interpreter/virtual_account_test.go b/internal/interpreter/virtual_account_test.go new file mode 100644 index 0000000..eb644fd --- /dev/null +++ b/internal/interpreter/virtual_account_test.go @@ -0,0 +1,311 @@ +package interpreter_test + +import ( + "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", big.NewInt(0), 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", big.NewInt(0), 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", nil, 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", nil, 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", nil, 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", nil, 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", nil, 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", nil, 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", nil, 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", nil, interpreter.Sender{ + Account: interpreter.AccountAddress("dest"), + Amount: amt, + })) + +}