From 63b6dbbb7849ec100da301a86a2f7efa371a3fff Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 10 Jun 2025 11:44:13 +0200 Subject: [PATCH 01/14] Prototype finally some tests passing other passing tests fix edge cases fix edge cases cleaned up remove comment --- 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 | 101 +++- internal/interpreter/funds_stack_test.go | 104 ++-- internal/interpreter/interpreter.go | 171 ++++-- internal/interpreter/interpreter_error.go | 2 +- internal/interpreter/interpreter_test.go | 584 ++++++++++++++++++- internal/interpreter/value.go | 54 +- internal/interpreter/virtual_account.go | 121 ++++ internal/interpreter/virtual_account_test.go | 311 ++++++++++ 14 files changed, 1367 insertions(+), 134 deletions(-) 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 46994ddc..bf5e38fb 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 af1c58f3..96ff916e 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 edb77dba..3b38e990 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 ca82877c..01ad5df7 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,78 @@ func (s fundsStack) Clone() fundsStack { return fs } + +// Treat this stack as debts and filter out senders by "repaying" debts +func (s *fundsStack) RepayWith(credits *fundsStack, asset string) []Posting { + var postings []Posting + + // for s.senders != nil { + // // Peek head from debts and try to pull that much + // hd := s.senders.Head + + // senders := credits.Pull(hd.Amount, &hd.Color) + // totalRepayed := big.NewInt(0) + // for _, sender := range senders { + // totalRepayed.Add(totalRepayed, sender.Amount) + // postings = append(postings, Posting{ + // Source: sender.Account, + // Destination: hd.Account, + // Amount: sender.Amount, + // Asset: coloredAsset(asset, &sender.Color), + // }) + // } + + // pulled := s.Pull(totalRepayed, &hd.Color) + // if len(pulled) == 0 { + // break + // } + + // // careful: infinite loops possible with different colors + // // break + // } + + return postings +} + +// 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) + + // Take away the debt that the credit allows for + clearedDebt := s.PullColored(credit.Amount, credit.Color) + + var postings []Posting + + 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 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 944b4487..d04a5ddf 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} @@ -242,6 +244,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 +311,74 @@ 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 { +func (st *programState) pushSender(sender Sender) { + if sender.Amount.Cmp(big.NewInt(0)) == 0 { return } - balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color) - balance.Sub(balance, monetary) + 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) + } - st.fundsStack.Push(Sender{Name: name, Amount: monetary, Color: color}) + st.fundsStack.Push(sender) } -func (st *programState) pushReceiver(name string, monetary *big.Int) { - if monetary.Cmp(big.NewInt(0)) == 0 { - return +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) + } } +} - senders := st.fundsStack.PullAnything(monetary) +func (st *programState) pushVirtualReceiver(vacc VirtualAccount, sender Sender) { + postings := vacc.Receive(st.CurrentAsset, sender) + st.Postings = append(st.Postings, postings...) +} - for _, sender := range senders { +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 +417,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 +500,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 +511,28 @@ 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) - s.pushSender(*account, sentAmt, *color) - return sentAmt, nil + s.pushSender(Sender{account, sentAmt, *color}) + + return sentAmt, nil + + case VirtualAccount: + senders := account.PullCredits(s.CurrentAsset) + for _, sender := range senders { + s.pushSender(sender) + } + return sumSendersAmount(senders), nil + + default: + utils.NonExhaustiveMatchPanic[any](account) + return nil, nil + } } // Send as much as possible (and return the sent amt) @@ -574,7 +622,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 +631,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) + } + s.pushSender(Sender{account, actuallySentAmt, *color}) + return actuallySentAmt, nil + + case VirtualAccount: + fs := account.getCredits(s.CurrentAsset) + pulledSenders := fs.PullColored(amount, *color) + + for _, sender := range pulledSenders { + s.pushSender(sender) + } + + pulledAmt := sumSendersAmount(pulledSenders) + // if we didn't pull enough + if pulledAmt.Cmp(amount) == -1 { + // invariant: leftAmt > 0 + // (we never pull more than required) + leftAmt := new(big.Int).Sub(amount, pulledAmt) + + var addionalSent *big.Int + if overdraft == nil { + addionalSent = new(big.Int).Set(leftAmt) + } else { + addionalSent = utils.MinBigInt(overdraft, leftAmt) + } + // TODO check this is the correct number to eventually send + // TODO test overdraft + pulledAmt.Add(pulledAmt, addionalSent) + + s.pushSender(Sender{account, pulledAmt, *color}) + } + + return pulledAmt, nil - // that's the amount we are allowed to send (balance + overdraft) - actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) + default: + utils.NonExhaustiveMatchPanic[any](account) + return nil, nil } - s.pushSender(*account, actuallySentAmt, *color) - return actuallySentAmt, nil + } func (s *programState) cloneState() func() { @@ -700,12 +786,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 +894,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: @@ -964,6 +1055,14 @@ func (s programState) checkFeatureFlag(flag string) InterpreterError { } } +func sumSendersAmount(senders []Sender) *big.Int { + tot := big.NewInt(0) + for _, sender := range senders { + tot.Add(tot, sender.Amount) + } + return tot +} + /* PRE: ovedraft != nil, balance != nil PRE: ovedraft >= 0 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 e8a39a4d..98184778 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) @@ -4943,3 +4943,575 @@ 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 TestExampleMinConstraintFailIfNotEnough(t *testing.T) { + t.Skip() + + 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) { + t.Skip("TODO") + + 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) { + t.Skip("TODO") + 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) + }) + +} + +// TODO test double spending virtual 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..c8716417 --- /dev/null +++ b/internal/interpreter/virtual_account.go @@ -0,0 +1,121 @@ +package interpreter + +import ( + "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 { + return "#" +} + +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 00000000..eb644fdd --- /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, + })) + +} From 933c2d954d367d20705720da252544aa1dde1561 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 16 Jun 2025 17:03:40 +0200 Subject: [PATCH 02/14] feat: better dbg --- internal/interpreter/interpreter.go | 27 +++++++++++++++++-------- internal/interpreter/virtual_account.go | 10 ++++++++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index d04a5ddf..61102bb1 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -218,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 } } @@ -655,23 +663,26 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou } pulledAmt := sumSendersAmount(pulledSenders) + // if we didn't pull enough if pulledAmt.Cmp(amount) == -1 { - // invariant: leftAmt > 0 + + // invariant: missingAmt > 0 // (we never pull more than required) - leftAmt := new(big.Int).Sub(amount, pulledAmt) + missingAmt := new(big.Int).Sub(amount, pulledAmt) var addionalSent *big.Int if overdraft == nil { - addionalSent = new(big.Int).Set(leftAmt) + addionalSent = new(big.Int).Set(missingAmt) } else { - addionalSent = utils.MinBigInt(overdraft, leftAmt) + // TODO check this is the correct number to eventually send + // TODO test overdraft + addionalSent = utils.MinBigInt(overdraft, missingAmt) } - // TODO check this is the correct number to eventually send - // TODO test overdraft - pulledAmt.Add(pulledAmt, addionalSent) - s.pushSender(Sender{account, pulledAmt, *color}) + s.pushSender(Sender{account, addionalSent, *color}) + + pulledAmt.Add(pulledAmt, addionalSent) } return pulledAmt, nil diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index c8716417..ad0a2325 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -1,6 +1,7 @@ package interpreter import ( + "fmt" "math/big" "github.com/formancehq/numscript/internal/utils" @@ -13,7 +14,14 @@ type VirtualAccount struct { } func (v VirtualAccount) String() string { - return "#" + var name string + if v.Dbg != "" { + name = v.Dbg + } else { + name = "anonymous" + } + + return fmt.Sprintf("#", name) } func NewVirtualAccount() VirtualAccount { From 3a5ae640eed7e879d21e7c2bb24424a2b3b8113a Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 16 Jun 2025 17:03:48 +0200 Subject: [PATCH 03/14] I guess we made it? --- internal/interpreter/interpreter_test.go | 68 +++++++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index 98184778..11e68b24 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -4970,9 +4970,67 @@ func TestVirtualAccountCreate(t *testing.T) { test(t, tc) } -func TestExampleMinConstraintFailIfNotEnough(t *testing.T) { - t.Skip() +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) @@ -5034,7 +5092,6 @@ func TestExampleMinConstraintFailIfNotEnough(t *testing.T) { 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"}, }, @@ -5082,8 +5139,6 @@ func TestExampleMinConstraintFailIfNotEnough(t *testing.T) { } func TestExampleMinConstraintNoCommissionsWithLowAmt(t *testing.T) { - t.Skip("TODO") - script := ` vars { number $amt @@ -5157,7 +5212,6 @@ func TestExampleMinConstraintNoCommissionsWithLowAmt(t *testing.T) { } func TestExampleMinConstraintMerchantPaysFeesIfNeeded(t *testing.T) { - t.Skip("TODO") script := ` vars { number $amt @@ -5513,5 +5567,3 @@ send [USD/2 10] ( }) } - -// TODO test double spending virtual From 4437f9510de95ce13347dbfb11d9adeabd691795 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 16 Jun 2025 17:11:52 +0200 Subject: [PATCH 04/14] refactor --- internal/interpreter/interpreter.go | 52 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 61102bb1..23daaeee 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -325,11 +325,19 @@ type programState struct { virtualAccountsDebts map[string]map[string]*fundsStack } -func (st *programState) pushSender(sender Sender) { +// 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) + } + if sender.Amount.Cmp(big.NewInt(0)) == 0 { - return + return totalSent } + totalSent.Add(totalSent, sender.Amount) + switch account := sender.Account.(type) { case VirtualAccount: // No need to do anything @@ -341,6 +349,8 @@ func (st *programState) pushSender(sender Sender) { } st.fundsStack.Push(sender) + + return totalSent } func (st *programState) pushReceiver(account Account, amount *big.Int) { @@ -526,16 +536,17 @@ func (s *programState) sendAllToAccount(accountLiteral parser.ValueExpr, overdra // we sent balance+overdraft sentAmt := CalculateMaxSafeWithdraw(balance, overdraft) - s.pushSender(Sender{account, sentAmt, *color}) - - return sentAmt, nil + 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) + s.pushSender(sender, totalSent) } - return sumSendersAmount(senders), nil + + return totalSent, nil default: utils.NonExhaustiveMatchPanic[any](account) @@ -651,25 +662,24 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou // that's the amount we are allowed to send (balance + overdraft) actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) } - s.pushSender(Sender{account, actuallySentAmt, *color}) - return actuallySentAmt, nil + 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) + s.pushSender(sender, totalSent) } - pulledAmt := sumSendersAmount(pulledSenders) - // if we didn't pull enough - if pulledAmt.Cmp(amount) == -1 { + if totalSent.Cmp(amount) == -1 { // invariant: missingAmt > 0 // (we never pull more than required) - missingAmt := new(big.Int).Sub(amount, pulledAmt) + missingAmt := new(big.Int).Sub(amount, totalSent) var addionalSent *big.Int if overdraft == nil { @@ -680,12 +690,10 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou addionalSent = utils.MinBigInt(overdraft, missingAmt) } - s.pushSender(Sender{account, addionalSent, *color}) - - pulledAmt.Add(pulledAmt, addionalSent) + s.pushSender(Sender{account, addionalSent, *color}, totalSent) } - return pulledAmt, nil + return totalSent, nil default: utils.NonExhaustiveMatchPanic[any](account) @@ -1066,14 +1074,6 @@ func (s programState) checkFeatureFlag(flag string) InterpreterError { } } -func sumSendersAmount(senders []Sender) *big.Int { - tot := big.NewInt(0) - for _, sender := range senders { - tot.Add(tot, sender.Amount) - } - return tot -} - /* PRE: ovedraft != nil, balance != nil PRE: ovedraft >= 0 From ed7cda812b95348532ff7b96ef8a7641b1087a79 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 16 Jun 2025 17:21:35 +0200 Subject: [PATCH 05/14] remove unused code --- internal/interpreter/funds_stack.go | 37 ++--------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/internal/interpreter/funds_stack.go b/internal/interpreter/funds_stack.go index 01ad5df7..9f5e2326 100644 --- a/internal/interpreter/funds_stack.go +++ b/internal/interpreter/funds_stack.go @@ -187,49 +187,16 @@ func (s fundsStack) Clone() fundsStack { return fs } -// Treat this stack as debts and filter out senders by "repaying" debts -func (s *fundsStack) RepayWith(credits *fundsStack, asset string) []Posting { - var postings []Posting - - // for s.senders != nil { - // // Peek head from debts and try to pull that much - // hd := s.senders.Head - - // senders := credits.Pull(hd.Amount, &hd.Color) - // totalRepayed := big.NewInt(0) - // for _, sender := range senders { - // totalRepayed.Add(totalRepayed, sender.Amount) - // postings = append(postings, Posting{ - // Source: sender.Account, - // Destination: hd.Account, - // Amount: sender.Amount, - // Asset: coloredAsset(asset, &sender.Color), - // }) - // } - - // pulled := s.Pull(totalRepayed, &hd.Color) - // if len(pulled) == 0 { - // break - // } - - // // careful: infinite loops possible with different colors - // // break - // } - - return postings -} - // 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) - // Take away the debt that the credit allows for - clearedDebt := s.PullColored(credit.Amount, credit.Color) - 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: From e64fadae563e43db160718dc2578d30e7fed25c6 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 17 Jun 2025 12:14:58 +0200 Subject: [PATCH 06/14] adde test and removed comment --- internal/interpreter/interpreter_test.go | 34 ++++++++++++++++++++++++ internal/interpreter/virtual_account.go | 13 ++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index 11e68b24..ced682ae 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -5030,6 +5030,40 @@ func TestVirtualAccountPreventDoubleSpendingInSendAll(t *testing.T) { test(t, tc) } +func TestVirtualAccountKept(t *testing.T) { + script := ` + 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 + ) + ` + + 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 TestExampleMinConstraintFailIfNotEnough(t *testing.T) { script := ` // say that we need to send 10%*$amt (up to 5) to @fees; the rest to @dest diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index ad0a2325..cd711eea 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -52,9 +52,6 @@ func (vacc *VirtualAccount) getDebits(asset string) *fundsStack { // 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) @@ -106,13 +103,17 @@ func (vacc *VirtualAccount) Pull(asset string, overdraft *big.Int, receiver Send }) case VirtualAccount: - // receiverAccount.Receive() - panic("TODO handle virtual account in Pull()") + panic("UNREACHABED") + return receiverAccount.Receive(asset, Sender{ + vacc, + pulledSender.Amount, + receiver.Color, + }) } } - } + // TODO it looks like we aren't using overdraft now. How's that possible? allowedDebt := utils.MinBigInt(remainingAmt, overdraft) if allowedDebt.Cmp(big.NewInt(0)) == 1 { // If we didn't pull enough and we're allowed to overdraft, From 9a83cb535281c7d6913e536bf463092f388f485d Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 17 Jun 2025 13:21:36 +0200 Subject: [PATCH 07/14] fix test --- internal/interpreter/funds_stack.go | 8 +- internal/interpreter/virtual_account.go | 8 +- internal/interpreter/virtual_account_test.go | 98 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/interpreter/funds_stack.go b/internal/interpreter/funds_stack.go index 9f5e2326..d0c44a6b 100644 --- a/internal/interpreter/funds_stack.go +++ b/internal/interpreter/funds_stack.go @@ -200,13 +200,11 @@ func (s *fundsStack) RepayWithSender(asset string, credit Sender) ([]Posting, Se 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") + pulled := creditAccount.Pull(asset, nil, receiver) + postings = append(postings, pulled...) case AccountAddress: + // TODO do we need this in the other case? credit.Amount.Sub(credit.Amount, receiver.Amount) switch receiverAccount := receiver.Account.(type) { diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index cd711eea..e06cd55a 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -13,6 +13,10 @@ type VirtualAccount struct { 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 != "" { @@ -103,7 +107,9 @@ func (vacc *VirtualAccount) Pull(asset string, overdraft *big.Int, receiver Send }) case VirtualAccount: - panic("UNREACHABED") + // TODO either include in coverage or simply this + panic("UNRECHED") + return receiverAccount.Receive(asset, Sender{ vacc, pulledSender.Amount, diff --git a/internal/interpreter/virtual_account_test.go b/internal/interpreter/virtual_account_test.go index eb644fdd..ea4dbdda 100644 --- a/internal/interpreter/virtual_account_test.go +++ b/internal/interpreter/virtual_account_test.go @@ -1,6 +1,7 @@ package interpreter_test import ( + "fmt" "math/big" "testing" @@ -309,3 +310,100 @@ func TestVirtualAccountTransitiveTwoStepsPayFirst(t *testing.T) { })) } + +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", nil, 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 6815f17c31c456b48e54fb61ff2e436a2f121b67 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 17 Jun 2025 15:48:48 +0200 Subject: [PATCH 08/14] renamed var --- internal/interpreter/interpreter.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 23daaeee..38df00af 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -373,7 +373,7 @@ func (st *programState) pushVirtualReceiver(vacc VirtualAccount, sender Sender) func (st *programState) pushReceiverAddress(name string, sender Sender) { switch senderAccountAddress := sender.Account.(type) { case AccountAddress: - postings := Posting{ + posting := Posting{ Source: string(senderAccountAddress), Destination: name, Asset: coloredAsset(st.CurrentAsset, &sender.Color), @@ -381,13 +381,13 @@ func (st *programState) pushReceiverAddress(name string, sender Sender) { } 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) + 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. From ed83cd640931e9380823eecf254ba05c02871c1b Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 18 Jun 2025 09:24:23 +0200 Subject: [PATCH 09/14] moved function --- internal/interpreter/funds_stack.go | 40 ----------------------- internal/interpreter/virtual_account.go | 42 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/internal/interpreter/funds_stack.go b/internal/interpreter/funds_stack.go index d0c44a6b..7baf5d41 100644 --- a/internal/interpreter/funds_stack.go +++ b/internal/interpreter/funds_stack.go @@ -186,43 +186,3 @@ 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, receiver) - postings = append(postings, pulled...) - - case AccountAddress: - // TODO do we need this in the other case? - 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/virtual_account.go b/internal/interpreter/virtual_account.go index e06cd55a..4f0fa67f 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -58,7 +58,7 @@ 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, sender := debits.RepayWithSender(asset, sender) + postings, sender := repayWithSender(debits, asset, sender) credits := vacc.getCredits(asset) credits.Push(sender) @@ -66,6 +66,46 @@ func (vacc *VirtualAccount) Receive(asset string, sender Sender) []Posting { return postings } +// 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 repayWithSender(s *fundsStack, 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, receiver) + postings = append(postings, pulled...) + + case AccountAddress: + // TODO do we need this in the other case? + 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 + +} + // Pull all the *immediately* available credits func (vacc *VirtualAccount) PullCredits(asset string) []Sender { return vacc.getCredits(asset).PullAll() From f57629ad3c0f13fb9365b88cb21d10e6d38adb59 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 18 Jun 2025 09:35:59 +0200 Subject: [PATCH 10/14] refactor --- internal/interpreter/virtual_account.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index 4f0fa67f..fb586f0c 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -58,24 +58,25 @@ 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, sender := repayWithSender(debits, asset, sender) + postings, remainingAmount := repayWithSender(debits, asset, sender) credits := vacc.getCredits(asset) + + sender.Amount = remainingAmount credits.Push(sender) return postings } // 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 repayWithSender(s *fundsStack, asset string, credit Sender) ([]Posting, Sender) { - // clone the amount so that we can modify it - credit.Amount = new(big.Int).Set(credit.Amount) +// 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 - clearedDebt := s.PullColored(credit.Amount, credit.Color) + clearedDebt := s.PullColored(remainingAmt, credit.Color) for _, receiver := range clearedDebt { switch creditAccount := credit.Account.(type) { case VirtualAccount: @@ -84,7 +85,7 @@ func repayWithSender(s *fundsStack, asset string, credit Sender) ([]Posting, Sen case AccountAddress: // TODO do we need this in the other case? - credit.Amount.Sub(credit.Amount, receiver.Amount) + remainingAmt.Sub(remainingAmt, receiver.Amount) switch receiverAccount := receiver.Account.(type) { case AccountAddress: @@ -102,7 +103,7 @@ func repayWithSender(s *fundsStack, asset string, credit Sender) ([]Posting, Sen } - return postings, credit + return postings, remainingAmt } From 0fcad45998528d9ce120e7ddae6a8d161bb47037 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 18 Jun 2025 10:16:38 +0200 Subject: [PATCH 11/14] simplify code --- internal/interpreter/virtual_account.go | 116 +++++++++++++----------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index fb586f0c..1c26c1ba 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -68,6 +68,47 @@ func (vacc *VirtualAccount) Receive(asset string, sender Sender) []Posting { 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, nil, 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) { @@ -76,31 +117,20 @@ func repayWithSender(s *fundsStack, asset string, credit Sender) ([]Posting, *bi var postings []Posting // Take away the debt that the credit allows for - clearedDebt := s.PullColored(remainingAmt, credit.Color) - for _, receiver := range clearedDebt { - switch creditAccount := credit.Account.(type) { - case VirtualAccount: - pulled := creditAccount.Pull(asset, nil, receiver) - postings = append(postings, pulled...) - - case AccountAddress: - // TODO do we need this in the other case? - remainingAmt.Sub(remainingAmt, 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") - } - } + 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 @@ -128,36 +158,18 @@ func (vacc *VirtualAccount) Pull(asset string, overdraft *big.Int, receiver Send 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: - // TODO either include in coverage or simply this - panic("UNRECHED") - - return receiverAccount.Receive(asset, Sender{ - vacc, - pulledSender.Amount, - receiver.Color, - }) - } - } + 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? From a3051d6daa636d85e4cb54a3317b53402f866f62 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 18 Jun 2025 10:18:02 +0200 Subject: [PATCH 12/14] simplify code --- internal/interpreter/interpreter.go | 2 +- internal/interpreter/virtual_account.go | 15 ++++--------- internal/interpreter/virtual_account_test.go | 22 ++++++++++---------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 38df00af..8efdb695 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -392,7 +392,7 @@ func (st *programState) pushReceiverAddress(name string, sender Sender) { 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{ + senderAccountAddress.Pull(st.CurrentAsset, Sender{ AccountAddress(name), sender.Amount, sender.Color, diff --git a/internal/interpreter/virtual_account.go b/internal/interpreter/virtual_account.go index 1c26c1ba..07f57efb 100644 --- a/internal/interpreter/virtual_account.go +++ b/internal/interpreter/virtual_account.go @@ -3,8 +3,6 @@ package interpreter import ( "fmt" "math/big" - - "github.com/formancehq/numscript/internal/utils" ) type VirtualAccount struct { @@ -94,7 +92,7 @@ func send( switch dest := destination.(type) { case AccountAddress: - return source.Pull(asset, nil, Sender{ + return source.Pull(asset, Sender{ Account: dest, Amount: amount, Color: color, @@ -149,11 +147,7 @@ func (vacc *VirtualAccount) PullCredits(asset string) []Sender { // // 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) - } - +func (vacc *VirtualAccount) Pull(asset string, receiver Sender) []Posting { credits := vacc.getCredits(asset) pulled := credits.PullColored(receiver.Amount, receiver.Color) @@ -173,15 +167,14 @@ func (vacc *VirtualAccount) Pull(asset string, overdraft *big.Int, receiver Send } // TODO it looks like we aren't using overdraft now. How's that possible? - allowedDebt := utils.MinBigInt(remainingAmt, overdraft) - if allowedDebt.Cmp(big.NewInt(0)) == 1 { + 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: allowedDebt, + Amount: remainingAmt, }) } diff --git a/internal/interpreter/virtual_account_test.go b/internal/interpreter/virtual_account_test.go index ea4dbdda..7f9f35bc 100644 --- a/internal/interpreter/virtual_account_test.go +++ b/internal/interpreter/virtual_account_test.go @@ -19,7 +19,7 @@ func TestVirtualAccountReceiveAndThenPull(t *testing.T) { }) require.Empty(t, postings) - postings = vacc.Pull("USD", big.NewInt(0), interpreter.Sender{ + postings = vacc.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: big.NewInt(10), }) @@ -42,7 +42,7 @@ func TestVirtualAccountReceiveAndThenPullPartialAmount(t *testing.T) { }) require.Empty(t, postings) - postings = vacc.Pull("USD", big.NewInt(0), interpreter.Sender{ + postings = vacc.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: big.NewInt(1), // <- we're only pulling 1 out of 10 }) @@ -64,7 +64,7 @@ func TestVirtualAccountPullFirst(t *testing.T) { vacc := interpreter.NewVirtualAccount() // Now we pull first. Note the unbounded overdraft - postings := vacc.Pull("USD", nil, interpreter.Sender{ + postings := vacc.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: big.NewInt(10), }) @@ -90,7 +90,7 @@ func TestVirtualAccountPullFirstMixed(t *testing.T) { vacc := interpreter.NewVirtualAccount() // 1 USD of debt - vacc.Pull("USD", nil, interpreter.Sender{ + vacc.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("lender"), Amount: big.NewInt(1), }) @@ -110,7 +110,7 @@ func TestVirtualAccountPullFirstMixed(t *testing.T) { }, postings) // pull the rest - postings = vacc.Pull("USD", nil, interpreter.Sender{ + postings = vacc.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: big.NewInt(100), }) @@ -156,7 +156,7 @@ func TestVirtualAccountTransitiveWhenNotOverdraft(t *testing.T) { require.Equal(t, []Posting{ {"src", "dest", amt, "USD"}, }, - v1.Pull("USD", nil, interpreter.Sender{ + v1.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, })) @@ -188,7 +188,7 @@ func TestVirtualAccountTransitiveWhenOverdraft(t *testing.T) { // => [{@src, @dest, 10}] require.Equal(t, []Posting{ {"src", "dest", amt, "USD"}, - }, v1.Pull("USD", nil, interpreter.Sender{ + }, v1.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, })) @@ -212,7 +212,7 @@ func TestVirtualAccountTransitiveWhenOverdraftAndPayLast(t *testing.T) { })) // $v1 -> @dest (10 USD) - require.Empty(t, v1.Pull("USD", nil, interpreter.Sender{ + require.Empty(t, v1.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, })) @@ -254,7 +254,7 @@ func TestVirtualAccountTransitiveTwoSteps(t *testing.T) { })) // $v2 -> @dest - require.Empty(t, v2.Pull("USD", nil, interpreter.Sender{ + require.Empty(t, v2.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, })) @@ -304,7 +304,7 @@ func TestVirtualAccountTransitiveTwoStepsPayFirst(t *testing.T) { // $v2 -> @dest require.Equal(t, []Posting{ {"src", "dest", amt, "USD"}, - }, v2.Pull("USD", nil, interpreter.Sender{ + }, v2.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, })) @@ -345,7 +345,7 @@ func TestCommutativeOrder(t *testing.T) { }) }, func() []Posting { - return v2.Pull("USD", nil, interpreter.Sender{ + return v2.Pull("USD", interpreter.Sender{ Account: interpreter.AccountAddress("dest"), Amount: amt, }) From 53ae0b7bba3bc1386f229b2cb4f4c3f8d6d1819a Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 18 Jun 2025 17:34:46 +0200 Subject: [PATCH 13/14] added tests --- internal/interpreter/interpreter_test.go | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index ced682ae..ed387a72 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -5601,3 +5601,68 @@ send [USD/2 10] ( }) } + +func TestSendHalfUsingVirtual(t *testing.T) { + script := ` + vars { account $v = virtual() } + + send [USD/2 10] ( + source = { + 1/2 from @alice + remaining from $v allowing unbounded overdraft + } + destination = @interests + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + tc.setBalance("alice", "USD/2", 5) + + tc.expected = CaseResult{ + Postings: []machine.Posting{ + { + Source: "alice", + Destination: "interests", + Asset: "USD/2", + Amount: big.NewInt(5), + }, + }, + } + test(t, tc) +} + +func TestVirtualSendCreditAround(t *testing.T) { + script := ` + 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 + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting{}, + } + test(t, tc) +} From 72e72f1eee3711f335b799f2334986996212765f Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 19 Jun 2025 13:59:34 +0200 Subject: [PATCH 14/14] WIP test --- internal/interpreter/interpreter_test.go | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal/interpreter/interpreter_test.go b/internal/interpreter/interpreter_test.go index ed387a72..113a051c 100644 --- a/internal/interpreter/interpreter_test.go +++ b/internal/interpreter/interpreter_test.go @@ -5666,3 +5666,72 @@ func TestVirtualSendCreditAround(t *testing.T) { } test(t, tc) } + +func TestVirtualKept(t *testing.T) { + script := ` + vars { + account $v1 = virtual() + } + + send [USD 1] ( + source = @v1 + destination = $v1 + ) + + send [USD 2] ( + source = $v2 + destination = @dest + ) + + send [USD 1] ( + // that's why this doesn't output any postings + source = @world + destination = $v2 + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting{}, + } + test(t, tc) +} + +func TestVirtualSendCreditAroundMixed(t *testing.T) { + t.Skip("TODO impl test") + + script := ` + vars { + account $v1 = virtual() + account $v2 = virtual() + } + + send [USD 1] ( + source = $v1 allowing unbounded overdraft + destination = $v2 + ) + + send [USD 2] ( + // 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 + ) + ` + + tc := NewTestCase() + tc.compile(t, script) + + tc.expected = CaseResult{ + Postings: []machine.Posting{}, + } + test(t, tc) +}