diff --git a/compiler.test b/compiler.test new file mode 100755 index 00000000..66a4bb9b Binary files /dev/null and b/compiler.test differ diff --git a/internal/compiler/assemble.go b/internal/compiler/assemble.go new file mode 100644 index 00000000..72f75676 --- /dev/null +++ b/internal/compiler/assemble.go @@ -0,0 +1,445 @@ +package compiler + +import ( + "fmt" + "math" + "math/big" + + "github.com/formancehq/numscript/internal/vm" +) + +const maxReg = 0xFF + +type regPool struct { + indexByReg map[reg]byte + next int +} + +func newRegPool() regPool { + return regPool{ + indexByReg: map[reg]byte{}, + } +} + +type constPool[T any] struct { + indexByValue map[string]uint16 + items []T + toString func(T) string +} + +func newConstPool[T any](toString func(T) string) constPool[T] { + return constPool[T]{ + indexByValue: map[string]uint16{}, + toString: toString, + } +} + +func (p *constPool[T]) alloc(item T) (uint16, error) { + strValue := p.toString(item) + index, ok := p.indexByValue[strValue] + if !ok { + l := len(p.items) + if l > math.MaxUint16 { + return 0, fmt.Errorf("error: too many consts (overflowed the u16 len)") + } + index = uint16(l) + p.indexByValue[strValue] = index + p.items = append(p.items, item) + } + + return index, nil +} + +func (b *regPool) index(r reg) (byte, error) { + if idx, ok := b.indexByReg[r]; ok { + return idx, nil + } + if b.next >= maxReg { + return 0, fmt.Errorf("register bank overflow: more than %d registers in one bank (register allocation not implemented yet)", maxReg) + } + idx := byte(b.next) + b.next++ + b.indexByReg[r] = idx + return idx, nil +} + +type patch struct { + label label + index int + getInstruction func(labelIndex uint16) vm.Instruction +} + +// assembler lowers virtual instructions into a vm.Program. +type assembler struct { + instructions []vm.Instruction + + patches []patch + labels map[label]uint16 + + // one register bank per VM register bank + ints regPool + strings regPool + portions regPool + monetaries regPool + + intsPool constPool[big.Int] + stringsPool constPool[string] +} + +func Assemble(instrs []vInstr) (vm.Program, error) { + a := &assembler{ + ints: newRegPool(), + strings: newRegPool(), + portions: newRegPool(), + monetaries: newRegPool(), + + labels: map[label]uint16{}, + + intsPool: newConstPool(func(i big.Int) string { + return i.String() + }), + stringsPool: newConstPool(func(s string) string { + return s + }), + } + for _, instr := range instrs { + if err := instr.assemble(a); err != nil { + return vm.Program{}, err + } + } + + // now we run the patches + for _, patch := range a.patches { + labelIndex, ok := a.labels[patch.label] + if !ok { + return vm.Program{}, fmt.Errorf("Missing label declaration of `%s`", string(patch.label)) + } + + a.instructions[patch.index] = patch.getInstruction(labelIndex) + } + + return vm.Program{ + Instructions: a.instructions, + StringsPool: a.stringsPool.items, + IntsPool: a.intsPool.items, + }, nil +} + +func (as *assembler) intReg(r reg) (byte, error) { return as.ints.index(r) } +func (as *assembler) strReg(r reg) (byte, error) { return as.strings.index(r) } +func (as *assembler) portionReg(r reg) (byte, error) { return as.portions.index(r) } +func (as *assembler) monetaryReg(r reg) (byte, error) { return as.monetaries.index(r) } + +func (as *assembler) optionalReg( + regPool func(*assembler, reg) (byte, error), + reg *reg, +) (byte, error) { + if reg == nil { + return maxReg, nil + } else { + reg_, err := regPool(as, *reg) + if err != nil { + return 0, err + } + return reg_, nil + } + +} + +func (as *assembler) emit(op vm.Opcode, a, b, c byte) { + as.instructions = append(as.instructions, vm.Instruction{ + Opcode: byte(op), + A: a, + B: b, + C: c, + }) +} + +func (as *assembler) emitBC(op vm.Opcode, a byte, bc uint16) { + as.instructions = append(as.instructions, vm.NewBC(op, a, bc)) +} + +// regResolver maps a virtual register to a concrete bank index. Op sigs hold +// these as method expressions ((*assembler).intReg, ...) so that a sig is a +// static description of an op, independent of any assembler instance. +type regResolver = func(*assembler, reg) (byte, error) + +type unaryOpSig struct { + opcode vm.Opcode + dest regResolver + arg regResolver +} + +func (opIntCopy) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_IntCopy, + dest: (*assembler).intReg, + arg: (*assembler).intReg, + } +} +func (opPortionCopy) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_PortionCopy, + dest: (*assembler).portionReg, + arg: (*assembler).portionReg, + } +} +func (opGetAsset) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_GetAsset, + dest: (*assembler).strReg, + arg: (*assembler).monetaryReg, + } +} +func (opGetAmount) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_GetAmount, + dest: (*assembler).intReg, + arg: (*assembler).monetaryReg, + } +} + +func (i unaryOp) assemble(a *assembler) error { + sig := i.op.sig() + + dest, err := sig.dest(a, i.dest) + if err != nil { + return err + } + arg, err := sig.arg(a, i.arg) + if err != nil { + return err + } + + a.emit(sig.opcode, dest, arg, maxReg) + return nil +} + +type binaryOpSig struct { + opcode vm.Opcode + dest regResolver + left regResolver + right regResolver +} + +func (opMinInt) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_MinInt, + dest: (*assembler).intReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (opAddInt) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_AddInt, + dest: (*assembler).intReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (opSubInt) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_SubInt, + dest: (*assembler).intReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (opSubPortion) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_SubPortion, + dest: (*assembler).portionReg, + left: (*assembler).portionReg, + right: (*assembler).portionReg, + } +} +func (opMakePortion) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_MkPortion, + dest: (*assembler).portionReg, + left: (*assembler).intReg, + right: (*assembler).intReg, + } +} +func (opMakeMonetary) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_MkMonetary, + dest: (*assembler).monetaryReg, + left: (*assembler).strReg, + right: (*assembler).intReg, + } +} + +func (i binaryOp) assemble(a *assembler) error { + sig := i.op.sig() + + dest, err := sig.dest(a, i.dest) + if err != nil { + return err + } + left, err := sig.left(a, i.left) + if err != nil { + return err + } + right, err := sig.right(a, i.right) + if err != nil { + return err + } + + a.emit(sig.opcode, dest, left, right) + return nil +} + +func (i loadInt) assemble(a *assembler) error { + dest, err := a.intReg(i.dest) + if err != nil { + return err + } + + poolIndex, err := a.intsPool.alloc(i.value) + if err != nil { + return err + } + + a.emitBC(vm.Op_LoadInt, dest, poolIndex) + return nil +} + +func (i loadStr) assemble(a *assembler) error { + dest, err := a.strReg(i.dest) + if err != nil { + return err + } + + poolIndex, err := a.stringsPool.alloc(i.value) + if err != nil { + return err + } + + a.emitBC(vm.Op_LoadStr, dest, poolIndex) + return nil +} + +func (i checkEnoughFunds) assemble(a *assembler) error { + got, err := a.intReg(i.got) + if err != nil { + return err + } + + needed, err := a.intReg(i.needed) + if err != nil { + return err + } + + a.emit(vm.Op_CheckEnoughFunds, got, needed, maxReg) + return nil +} + +func (i setCurrentAsset) assemble(a *assembler) error { + assetReg, err := a.strReg(i.asset) + if err != nil { + return err + } + + a.emit(vm.Op_SetCurrentAsset, assetReg, maxReg, maxReg) + return nil +} + +func (i pullAccount) assemble(a *assembler) error { + dest, err := a.intReg(i.dest) + if err != nil { + return err + } + + account, err := a.strReg(i.account) + if err != nil { + return err + } + + cap, err := a.optionalReg((*assembler).intReg, i.cap) + if err != nil { + return err + } + + overdraft, err := a.optionalReg((*assembler).intReg, i.overdraft) + if err != nil { + return err + } + + color, err := a.optionalReg((*assembler).strReg, i.color) + if err != nil { + return err + } + + a.emit(vm.Op_PullAccount, dest, account, cap) + + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, // <- UNUSED + A: overdraft, // overdraft (int) + B: color, // color (str) + C: maxReg, // <- UNUSED + }) + + return nil +} + +func (i sendToAccount) assemble(a *assembler) error { + account, err := a.optionalReg((*assembler).strReg, i.account) + if err != nil { + return err + } + + cap, err := a.optionalReg((*assembler).intReg, i.cap) + if err != nil { + return err + } + + a.emit(vm.Op_SendToAccount, account, cap, maxReg) + return nil +} + +func (i checkEqCurrentAsset) assemble(a *assembler) error { + asset, err := a.strReg(i.got) + if err != nil { + return err + } + + a.emit(vm.Op_CheckEqCurrentAsset, asset, maxReg, maxReg) + + return nil +} + +func (i jmpIfZero) assemble(a *assembler) error { + cond, err := a.intReg(i.cond) + if err != nil { + return err + } + + a.patches = append(a.patches, patch{ + label: i.target, + index: len(a.instructions), + getInstruction: func(labelIndex uint16) vm.Instruction { + return vm.NewBC(vm.Op_JmpIfZero, cond, labelIndex) + }, + }) + + // Emit dummy instruction + a.emit(0, 0, 0, 0) + + return nil +} + +func (i makeAllotment) assemble(a *assembler) error { panic("TODO assemble makeAllotment") } +func (i fetchVariable) assemble(a *assembler) error { panic("TODO assemble fetchVariable") } + +func (i labelMarker) assemble(a *assembler) error { + l := len(a.instructions) + if l > math.MaxUint16 { + return fmt.Errorf("too many labels: overflown max safe uint16") + } + + a.labels[i.label] = uint16(l) + + return nil +} diff --git a/internal/compiler/assemble_test.go b/internal/compiler/assemble_test.go new file mode 100644 index 00000000..b54ac6d0 --- /dev/null +++ b/internal/compiler/assemble_test.go @@ -0,0 +1,65 @@ +package compiler + +import ( + "testing" + + "github.com/formancehq/numscript/internal/vm" +) + +func TestAssemble_AddInt(t *testing.T) { + // Three distinct virtual int registers map to the first three int-bank + // indices in first-use order. + prog, err := Assemble([]vInstr{ + binaryOp{op: opAddInt{}, dest: 10, left: 20, right: 30}, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + instrs := prog.Instructions + if len(instrs) != 1 { + t.Fatalf("got %d instructions, want 1", len(instrs)) + } + want := vm.Instruction{Opcode: byte(vm.Op_AddInt), A: 0, B: 1, C: 2} + if instrs[0] != want { + t.Errorf("got %+v, want %+v", instrs[0], want) + } +} + +func TestAssemble_AddInt_ReusesRegisterIndices(t *testing.T) { + // A virtual register reused across operands/instructions keeps the same + // bank index; new ones get fresh indices in first-use order. + prog, err := Assemble([]vInstr{ + // reg 7 -> 0, reg 8 -> 1 ; dest==left==7 + binaryOp{op: opAddInt{}, dest: 7, left: 7, right: 8}, + // reg 9 -> 2 ; reuses 7->0 and 8->1 + binaryOp{op: opAddInt{}, dest: 9, left: 7, right: 8}, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + got := prog.Instructions + want := []vm.Instruction{ + {Opcode: byte(vm.Op_AddInt), A: 0, B: 0, C: 1}, + {Opcode: byte(vm.Op_AddInt), A: 2, B: 0, C: 1}, + } + if len(got) != len(want) { + t.Fatalf("got %d instructions, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("instr[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestAssemble_Empty(t *testing.T) { + prog, err := Assemble(nil) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(prog.Instructions) != 0 { + t.Errorf("expected no instructions, got %d", len(prog.Instructions)) + } +} diff --git a/internal/compiler/bench_test.go b/internal/compiler/bench_test.go new file mode 100644 index 00000000..3c5a5a6d --- /dev/null +++ b/internal/compiler/bench_test.go @@ -0,0 +1,227 @@ +package compiler + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" +) + +// Both benchmarks run the SAME program with the same starting balance; only the +// per-iteration RUN is measured (parse/compile/assemble happen once, up front). +const benchSrc = `send [USD/2 10] ( + source = @src + destination = @dest +)` + +// BenchmarkTreeWalker measures the tree-walking interpreter on a pre-parsed AST. +func BenchmarkTreeWalker(b *testing.B) { + parsed := parser.Parse(benchSrc) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + store := interpreter.StaticStore{ + Balances: interpreter.Balances{ + {Account: "src", Asset: "USD/2", Amount: big.NewInt(100)}, + }, + } + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil) + if err != nil { + b.Fatalf("run: %v", err) + } + } +} + +// BenchmarkRuntimeBaseline is the floor: it drives runtime.RunState directly, +// performing exactly the funds operations the program lowers to — with no AST +// walk and no bytecode dispatch. It reuses one RunState (like the VM reuses its +// runstate) and hoists the constants (the compiler would pool them). The gap +// between this and BenchmarkCompiledVM is the VM's dispatch/register overhead; +// the gap to BenchmarkTreeWalker is the interpreter's front-end overhead. +func BenchmarkRuntimeBaseline(b *testing.B) { + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + rs := runtime.New(store) + + ten := big.NewInt(10) // the sent amount / pull cap + zero := big.NewInt(0) // bounded overdraft of 0 + pulled := new(big.Int) // reused output register + dest := "dest" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rs.Reset(store) + rs.SetCurrentAsset("USD/2") + rs.Pull(pulled, "src", ten, zero, "") + _ = pulled.Cmp(ten) // CheckEnoughFunds + rs.SendUncapped(&dest, nil) + _ = rs.GetPostings() + } +} + +// BenchmarkCompiledVM measures the compiled bytecode on the register VM, reusing +// a single Vm instance across iterations (its register banks are not realloc'd). +func BenchmarkCompiledVM(b *testing.B) { + parsed := parser.Parse(benchSrc) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + compiled, cErr := compileProgramToVirtual(parsed.Value) + if cErr != nil { + b.Fatalf("compile: %v", cErr) + } + program, aErr := Assemble(compiled.instructions) + if aErr != nil { + b.Fatalf("assemble: %v", aErr) + } + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) // reused across iterations + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := vm.Exec(machine, nil, store) + if err != nil { + b.Fatalf("exec: %v", err) + } + } +} + +// --- Capped inorder script: `{ @a ; max [USD/2 5] from @b ; @c }` ----------- +// Same methodology as above, on a more representative script (inorder traversal, +// a `max` cap with a min_int, running total, and an early-exit jump). Balances: +// a=3, b=100 (capped to 5), c=100 → pulls 3 / 5 / 2. +const benchSrcCapped = `send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest +)` + +func BenchmarkTreeWalkerCapped(b *testing.B) { + parsed := parser.Parse(benchSrcCapped) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + store := interpreter.StaticStore{ + Balances: interpreter.Balances{ + {Account: "a", Asset: "USD/2", Amount: big.NewInt(3)}, + {Account: "b", Asset: "USD/2", Amount: big.NewInt(100)}, + {Account: "c", Asset: "USD/2", Amount: big.NewInt(100)}, + }, + } + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil) + if err != nil { + b.Fatalf("run: %v", err) + } + } +} + +func cappedStore() e2eStore { + return e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(3), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} +} + +// BenchmarkRuntimeBaselineCapped is the floor: it drives runtime.RunState +// directly, performing the funds ops the capped-inorder script lowers to (with +// the cap/running-total/early-exit arithmetic done inline on reused big.Ints) — +// no AST walk, no bytecode dispatch. RunState reused across iterations. +func BenchmarkRuntimeBaselineCapped(b *testing.B) { + store := cappedStore() + rs := runtime.New(store) + + zero := big.NewInt(0) + ten := big.NewInt(10) + five := big.NewInt(5) + remaining := new(big.Int) + capB := new(big.Int) + pulled := new(big.Int) + total := new(big.Int) + dest := "dest" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rs.Reset(store) + rs.SetCurrentAsset("USD/2") + total.SetInt64(0) + remaining.Set(ten) // inorder cap = copy(amount) + + // @a (cap = remaining) + rs.Pull(pulled, "a", remaining, zero, "") + total.Add(total, pulled) + remaining.Sub(remaining, pulled) + + if remaining.Sign() != 0 { // jmp_if_zero(remaining) + // max [USD/2 5] from @b -> cap = min(5, remaining) + if five.Cmp(remaining) < 0 { + capB.Set(five) + } else { + capB.Set(remaining) + } + rs.Pull(pulled, "b", capB, zero, "") + total.Add(total, pulled) + remaining.Sub(remaining, pulled) + + if remaining.Sign() != 0 { + rs.Pull(pulled, "c", remaining, zero, "") // @c (cap = remaining) + total.Add(total, pulled) + } + } + + _ = total.Cmp(ten) // check_enough_funds + rs.SendUncapped(&dest, nil) + _ = rs.GetPostings() + } +} + +func BenchmarkCompiledVMCapped(b *testing.B) { + parsed := parser.Parse(benchSrcCapped) + if len(parsed.Errors) != 0 { + b.Fatalf("parse errors: %v", parsed.Errors) + } + compiled, cErr := compileProgramToVirtual(parsed.Value) + if cErr != nil { + b.Fatalf("compile: %v", cErr) + } + program, aErr := Assemble(compiled.instructions) + if aErr != nil { + b.Fatalf("assemble: %v", aErr) + } + store := cappedStore() + + machine := vm.NewVm(program) // reused across iterations + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := vm.Exec(machine, nil, store) + if err != nil { + b.Fatalf("exec: %v", err) + } + } +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go new file mode 100644 index 00000000..b01d1ece --- /dev/null +++ b/internal/compiler/compiler.go @@ -0,0 +1,451 @@ +package compiler + +import ( + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" +) + +type type_ string + +const ( + typeNumber type_ = "number" + typeString type_ = "string" + typeAsset type_ = "asset" + typeMonetary type_ = "monetary" + typeAccount type_ = "account" + typePortion type_ = "portion" +) + +type compiledProgramVirtual struct { + instructions []vInstr +} + +type state struct { + nextReg int + nextLabelId int + instructions []vInstr +} + +func (st *state) getFreshReg() reg { + id := st.nextReg + st.nextReg++ + return reg(id) +} + +func (st *state) pushInstruction(instr vInstr) { + st.instructions = append(st.instructions, instr) +} + +func (st *state) getFreshLabel(prefix string) label { + l := label(fmt.Sprintf("%s_%d", prefix, st.nextLabelId)) + st.nextLabelId++ + return l +} + +func (st *state) pushInstructionWithDest(getInstr func(dest reg) vInstr) reg { + dest := st.getFreshReg() + st.instructions = append(st.instructions, getInstr(dest)) + return dest +} + +func (st *state) pushInstructionWithDestErr(getInstr func(dest reg) vInstr) (reg, CompilerError) { + return st.pushInstructionWithDest(getInstr), nil +} + +func (st *state) compileExpr(expr parser.ValueExpr) (reg, CompilerError) { + switch expr := expr.(type) { + case *parser.AssetLiteral: + return st.pushInstructionWithDestErr(func(reg reg) vInstr { + return loadStr{ + value: expr.Asset, + dest: reg, + } + }) + + case *parser.StringLiteral: + return st.pushInstructionWithDestErr(func(reg reg) vInstr { + return loadStr{ + value: expr.String, + dest: reg, + } + }) + + case *parser.NumberLiteral: + return st.pushInstructionWithDestErr(func(reg reg) vInstr { + return loadInt{ + value: *expr.Number, + dest: reg, + } + }) + + case *parser.MonetaryLiteral: + assetReg, err := st.compileExpr(expr.Asset) + if err != nil { + return 0, err + } + + amtReg, err := st.compileExpr(expr.Amount) + if err != nil { + return 0, err + } + + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{ + op: opMakeMonetary{}, + left: assetReg, + right: amtReg, + dest: dest, + } + }) + + case *parser.AccountInterpLiteral: + var parts []reg + for _, part := range expr.Parts { + switch part := part.(type) { + case parser.AccountTextPart: + dest := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadStr{ + value: part.Name, + dest: dest, + } + }) + parts = append(parts, dest) + case *parser.Variable: + panic("TODO interp var") + } + } + + if len(parts) == 1 { + return parts[0], nil + } + + panic("TODO compileExpr interp of many segments") + + case *parser.Variable: + panic("TODO compileExpr") + + case *parser.PercentageLiteral: + panic("TODO compileExpr") + + case *parser.BinaryInfix: + panic("TODO compileExpr") + + case *parser.Prefix: + panic("TODO compileExpr") + + case *parser.FnCall: + panic("TODO compileExpr") + + default: + return utils.NonExhaustiveMatchPanic[reg](expr), nil + } +} + +// capReg is the register containing the current cap (or nil if context is uncapped) +// returns (when there's no err) the register where we store the pulled amount of this source +func (st *state) compileSource( + capReg *reg, + src parser.Source, +) (reg, CompilerError) { + switch src := src.(type) { + case *parser.SourceAccount: + if src.Color != nil { + panic("TODO impl color") + } + + accReg, err := st.compileExpr(src.ValueExpr) + if err != nil { + return 0, err + } + + overdraftReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{ + value: *big.NewInt(0), + dest: dest, + } + }) + + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return pullAccount{ + dest: dest, + account: accReg, + cap: capReg, + overdraft: &overdraftReg, + color: nil, + } + }) + + case *parser.SourceOverdraft: + if capReg == nil { + return 0, InvalidUncappedSource{ + Range: src.GetRange(), + } + } + + if src.Color != nil { + panic("TODO impl color") + } + + accReg, err := st.compileExpr(src.Address) + if err != nil { + return 0, err + } + + var overdraftReg *reg + if src.Bounded != nil { + *overdraftReg, err = st.compileExpr(*src.Bounded) + if err != nil { + return 0, err + } + } + + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return pullAccount{ + dest: dest, + account: accReg, + cap: capReg, + overdraft: overdraftReg, + color: nil, + } + }) + + case *parser.SourceCapped: + clauseCapMonetaryReg, err := st.compileExpr(src.Cap) + if err != nil { + return 0, err + } + clauseCapIntReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{ + op: opGetAmount{}, + arg: clauseCapMonetaryReg, + dest: dest, + } + }) + + var innerCapReg reg + if capReg == nil { + innerCapReg = clauseCapIntReg + } else { + minReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{ + op: opMinInt{}, + left: clauseCapIntReg, + right: *capReg, + dest: dest, + } + }) + innerCapReg = minReg + } + + return st.compileSource(&innerCapReg, src.From) + + case *parser.SourceInorder: + if capReg == nil { + panic("TODO unbounded inorder") + } + + inorderTotalReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{ + value: *big.NewInt(0), + dest: dest, + } + }) + + endLabel := st.getFreshLabel("inorder_end") + inorderCap := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{ + op: opIntCopy{}, + arg: *capReg, + dest: dest, + } + }) + + for idx, subSrc := range src.Sources { + innerPulledAmtReg, err := st.compileSource(&inorderCap, subSrc) + if err != nil { + return 0, err + } + + // inorderTotalReg += innerPulledAmtReg + st.pushInstruction(binaryOp{ + op: opAddInt{}, + dest: inorderTotalReg, + left: inorderTotalReg, + right: innerPulledAmtReg, + }) + + isLast := idx == len(src.Sources)-1 + if !isLast { + // inorderCap -= innerPulledAmtReg + st.pushInstruction(binaryOp{ + op: opSubInt{}, + dest: inorderCap, + left: inorderCap, + right: innerPulledAmtReg, + }) + st.pushInstruction(jmpIfZero{ + cond: inorderCap, + target: endLabel, + }) + } + } + st.pushInstruction(labelMarker{ + label: endLabel, + }) + return inorderTotalReg, nil + + case *parser.SourceOneof: + panic("TODO impl source") + case *parser.SourceAllotment: + panic("TODO impl source") + + case *parser.SourceWithScaling: + panic("TODO impl source") + + default: + return utils.NonExhaustiveMatchPanic[reg](src), nil + } +} + +func (st *state) compileSourceWithRequiredAmount( + capReg reg, + src parser.Source, +) (reg, CompilerError) { + got, err := st.compileSource(&capReg, src) + if err != nil { + return 0, err + } + st.pushInstruction(checkEnoughFunds{ + got: got, + needed: capReg, + }) + return got, nil +} + +func (st *state) compileDestination( + pulledAmtReg reg, + currentCap reg, + dest parser.Destination, +) CompilerError { + switch dest := dest.(type) { + case *parser.DestinationAccount: + accReg, err := st.compileExpr(dest.ValueExpr) + if err != nil { + return err + } + + var cap *reg + if pulledAmtReg != currentCap { + cap = &pulledAmtReg + } + st.pushInstruction(sendToAccount{ + account: &accReg, + cap: cap, + }) + + case *parser.DestinationInorder: + case *parser.DestinationOneof: + case *parser.DestinationAllotment: + + default: + utils.NonExhaustiveMatchPanic[any](dest) + } + + return nil +} + +func (st *state) compileKeptOrDestination(keptOrDest parser.KeptOrDestination) CompilerError { + switch keptOrDest := keptOrDest.(type) { + case *parser.DestinationKept: + case *parser.DestinationTo: + default: + utils.NonExhaustiveMatchPanic[any](keptOrDest) + } + + return nil +} + +func (st *state) compileSentValue( + sentValue parser.SentValue, + source parser.Source, +) (reg, CompilerError) { + switch sentValue := sentValue.(type) { + case *parser.SentValueLiteral: + monetaryReg, err := st.compileExpr(sentValue.Monetary) + if err != nil { + return 0, err + } + assetReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{ + op: opGetAsset{}, + arg: monetaryReg, + dest: dest, + } + }) + st.pushInstruction(setCurrentAsset{ + asset: assetReg, + }) + capReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{ + op: opGetAmount{}, + arg: monetaryReg, + dest: dest, + } + }) + + return st.compileSourceWithRequiredAmount(capReg, source) + + case *parser.SentValueAll: + assetReg, err := st.compileExpr(sentValue.Asset) + if err != nil { + return 0, err + } + st.pushInstruction(setCurrentAsset{ + asset: assetReg, + }) + return st.compileSource(nil, source) + + default: + return utils.NonExhaustiveMatchPanic[reg](sentValue), nil + } + +} + +func (st *state) compileStatements(stmt parser.Statement) CompilerError { + switch stmt := stmt.(type) { + case *parser.SendStatement: + pulledAmtReg, err := st.compileSentValue(stmt.SentValue, stmt.Source) + if err != nil { + return err + } + + err = st.compileDestination(pulledAmtReg, pulledAmtReg, stmt.Destination) + if err != nil { + return err + } + + return nil + + case *parser.SaveStatement: + panic("TODO save") + case *parser.FnCall: + panic("TODO fn call") + + default: + return utils.NonExhaustiveMatchPanic[CompilerError](stmt) + } +} + +func compileProgramToVirtual(program parser.Program) (compiledProgramVirtual, CompilerError) { + st := state{} + for _, stmt := range program.Statements { + st.compileStatements(stmt) + } + + return compiledProgramVirtual{ + instructions: st.instructions, + }, nil +} diff --git a/internal/compiler/compiler_error.go b/internal/compiler/compiler_error.go new file mode 100644 index 00000000..b91fb72f --- /dev/null +++ b/internal/compiler/compiler_error.go @@ -0,0 +1,35 @@ +package compiler + +import "github.com/formancehq/numscript/internal/parser" + +type ( + CompilerError interface { + parser.Ranged + compileError() + } + + UnboundVar struct { + parser.Range + Var string + } + + TypeMismatch struct { + parser.Range + Expected string + Got string + } + + InvalidUncappedSource struct { + parser.Range + } +) + +func (UnboundVar) compileError() {} +func (TypeMismatch) compileError() {} +func (InvalidUncappedSource) compileError() {} + +var ( + _ CompilerError = (*UnboundVar)(nil) + _ CompilerError = (*TypeMismatch)(nil) + _ CompilerError = (*InvalidUncappedSource)(nil) +) diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go new file mode 100644 index 00000000..fcd437dc --- /dev/null +++ b/internal/compiler/compiler_test.go @@ -0,0 +1,136 @@ +package compiler + +import ( + "testing" + + "github.com/formancehq/numscript/internal/parser" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" +) + +func getCompiledOutput(t *testing.T, source string) string { + program := parser.Parse(source) + require.Empty(t, program.Errors) + compiled, err := compileProgramToVirtual(program.Value) + require.Nil(t, err) + + out := dump(compiled.instructions) + return "\n" + out +} + +func TestSimpleProgram(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(10) + $r2 <- mk_monetary($r0, $r1) + $r3 <- get_asset($r2) + set_current_asset($r3) + $r4 <- get_amount($r2) + $r5 <- load_const("src") + $r6 <- load_const(0) + $r7 <- pull_account(account: $r5, cap: $r4, overdraft: $r6) + check_enough_funds($r7, $r4) + $r8 <- load_const("dest") + send_to_account($r8) +`)) +} + +func TestInorder(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = { + @a + @b + @c + } + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(10) + $r2 <- mk_monetary($r0, $r1) + $r3 <- get_asset($r2) + set_current_asset($r3) + $r4 <- get_amount($r2) + $r5 <- load_const(0) + $r6 <- int_copy($r4) + $r7 <- load_const("a") + $r8 <- load_const(0) + $r9 <- pull_account(account: $r7, cap: $r6, overdraft: $r8) + $r5 <- add_int($r5, $r9) + $r6 <- sub_int($r6, $r9) + jmp_if_zero($r6, #inorder_end_0) + $r10 <- load_const("b") + $r11 <- load_const(0) + $r12 <- pull_account(account: $r10, cap: $r6, overdraft: $r11) + $r5 <- add_int($r5, $r12) + $r6 <- sub_int($r6, $r12) + jmp_if_zero($r6, #inorder_end_0) + $r13 <- load_const("c") + $r14 <- load_const(0) + $r15 <- pull_account(account: $r13, cap: $r6, overdraft: $r14) + $r5 <- add_int($r5, $r15) +#inorder_end_0 + check_enough_funds($r5, $r4) + $r16 <- load_const("dest") + send_to_account($r16) +`)) +} + +func TestInorderWithCap(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(10) + $r2 <- mk_monetary($r0, $r1) + $r3 <- get_asset($r2) + set_current_asset($r3) + $r4 <- get_amount($r2) + $r5 <- load_const(0) + $r6 <- int_copy($r4) + $r7 <- load_const("a") + $r8 <- load_const(0) + $r9 <- pull_account(account: $r7, cap: $r6, overdraft: $r8) + $r5 <- add_int($r5, $r9) + $r6 <- sub_int($r6, $r9) + jmp_if_zero($r6, #inorder_end_0) + $r10 <- load_const("USD/2") + $r11 <- load_const(5) + $r12 <- mk_monetary($r10, $r11) + $r13 <- get_amount($r12) + $r14 <- min_int($r13, $r6) + $r15 <- load_const("b") + $r16 <- load_const(0) + $r17 <- pull_account(account: $r15, cap: $r14, overdraft: $r16) + $r5 <- add_int($r5, $r17) + $r6 <- sub_int($r6, $r17) + jmp_if_zero($r6, #inorder_end_0) + $r18 <- load_const("c") + $r19 <- load_const(0) + $r20 <- pull_account(account: $r18, cap: $r6, overdraft: $r19) + $r5 <- add_int($r5, $r20) +#inorder_end_0 + check_enough_funds($r5, $r4) + $r21 <- load_const("dest") + send_to_account($r21) +`)) +} diff --git a/internal/compiler/e2e_test.go b/internal/compiler/e2e_test.go new file mode 100644 index 00000000..737e6918 --- /dev/null +++ b/internal/compiler/e2e_test.go @@ -0,0 +1,181 @@ +package compiler + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +// e2eStore is a minimal vm.Store for the end-to-end test. +type e2eStore struct { + balances map[runtime.PairKey]*big.Int +} + +func (s e2eStore) GetBalance(account, asset, color string) *big.Int { + if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + return v + } + return new(big.Int) +} + +// TestE2E_CompileAssembleRun exercises the whole pipeline: source -> compiler +// (virtual instructions) -> assembler (vm.Program) -> VM execution -> postings. +func TestE2E_CompileAssembleRun(t *testing.T) { + src := ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + compiled, cErr := compileProgramToVirtual(parsed.Value) + require.Nil(t, cErr) + + program, aErr := Assemble(compiled.instructions) + require.NoError(t, aErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + postings, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + } + requirePostingsEqual(t, want, postings) +} + +// TestE2E_Inorder exercises an inorder source { @a @b @c } end-to-end, including +// the early-exit jump: @a has 6, @b has 10, @c has 100; sending 10 pulls 6 from +// @a (cap -> 4), then 4 from @b (cap -> 0 -> jump past @c). @c is never touched. +func TestE2E_Inorder(t *testing.T) { + src := ` + send [USD/2 10] ( + source = { + @a + @b + @c + } + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + compiled, cErr := compileProgramToVirtual(parsed.Value) + require.Nil(t, cErr) + + program, aErr := Assemble(compiled.instructions) + require.NoError(t, aErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(6), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + postings, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(6)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(4)}, + } + requirePostingsEqual(t, want, postings) +} + +// TestE2E_InorderWithCap exercises a capped (`max`) source inside an inorder +// end-to-end. @b holds 100 but is capped at 5, so the cap must bind: @a gives 3 +// (remaining 10->7), @b gives only 5 (not 7) -> remaining 2, @c gives 2. +func TestE2E_InorderWithCap(t *testing.T) { + src := ` + send [USD/2 10] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + compiled, cErr := compileProgramToVirtual(parsed.Value) + require.Nil(t, cErr) + + program, aErr := Assemble(compiled.instructions) + require.NoError(t, aErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(3), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + postings, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(3)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(5)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(2)}, + } + requirePostingsEqual(t, want, postings) +} + +// TestE2E_InsufficientFunds checks the failure path: when the source can't cover +// the sent amount, the VM's CheckEnoughFunds must report a MissingFundsError. +func TestE2E_InsufficientFunds(t *testing.T) { + src := ` + send [USD/2 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + compiled, cErr := compileProgramToVirtual(parsed.Value) + require.Nil(t, cErr) + + program, aErr := Assemble(compiled.instructions) + require.NoError(t, aErr) + + // src only has 4, but 10 is required. + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(4), + }} + + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, store) + require.IsType(t, vm.MissingFundsError{}, execErr) +} + +func requirePostingsEqual(t *testing.T, want, got []runtime.Posting) { + t.Helper() + require.Len(t, got, len(want)) + for i := range want { + w, g := want[i], got[i] + require.Equal(t, w.Source, g.Source, "posting[%d].Source", i) + require.Equal(t, w.Destination, g.Destination, "posting[%d].Destination", i) + require.Equal(t, w.Asset, g.Asset, "posting[%d].Asset", i) + require.Equal(t, w.Color, g.Color, "posting[%d].Color", i) + require.Zero(t, g.Amount.Cmp(w.Amount), "posting[%d].Amount: got %s want %s", i, g.Amount, w.Amount) + } +} diff --git a/internal/compiler/virtual_instruction.go b/internal/compiler/virtual_instruction.go new file mode 100644 index 00000000..a5f14cf3 --- /dev/null +++ b/internal/compiler/virtual_instruction.go @@ -0,0 +1,135 @@ +package compiler + +import ( + "fmt" + "math/big" +) + +type reg int + +type label string + +type binKind interface { + fmt.Stringer + sig() binaryOpSig +} + +type ( + opMinInt struct{} + opAddInt struct{} + opSubInt struct{} + opSubPortion struct{} + opMakePortion struct{} + opMakeMonetary struct{} +) + +type unKind interface { + fmt.Stringer + sig() unaryOpSig +} + +type ( + opIntCopy struct{} + opPortionCopy struct{} + opGetAsset struct{} + opGetAmount struct{} +) + +type ( + pullAccount struct { + dest reg // int: amount pulled + account reg // str + cap, overdraft, color *reg // int, int, str + } + sendToAccount struct { + account, cap *reg // str, int + } + makeAllotment struct { + dest []reg // int, len N + amount reg // int + portions []reg // portion, len N + } + checkEnoughFunds struct{ got, needed reg } // int + setCurrentAsset struct{ asset reg } // str + checkEqCurrentAsset struct{ got reg } // str + fetchVariable struct { + dest reg + index uint32 + } + jmpIfZero struct { + cond reg // int + target label + } + loadInt struct { + dest reg + value big.Int + } + loadStr struct { + dest reg + value string + } + binaryOp struct { + op binKind + dest, left, right reg + } + unaryOp struct { + op unKind + dest, arg reg + } + labelMarker struct{ label label } +) + +type vInstr interface { + dests() []reg // registers written + sources() []reg // registers read + assemble(a *assembler) error +} + +func (i pullAccount) dests() []reg { return []reg{i.dest} } +func (i pullAccount) sources() []reg { return present(&i.account, i.cap, i.overdraft, i.color) } + +func (i sendToAccount) dests() []reg { return nil } +func (i sendToAccount) sources() []reg { return present(i.account, i.cap) } + +func (i makeAllotment) dests() []reg { return i.dest } +func (i makeAllotment) sources() []reg { return append(append([]reg{}, i.portions...), i.amount) } + +func (i checkEnoughFunds) dests() []reg { return nil } +func (i checkEnoughFunds) sources() []reg { return []reg{i.got, i.needed} } + +func (i setCurrentAsset) dests() []reg { return nil } +func (i setCurrentAsset) sources() []reg { return []reg{i.asset} } + +func (i checkEqCurrentAsset) dests() []reg { return nil } +func (i checkEqCurrentAsset) sources() []reg { return []reg{i.got} } + +func (i fetchVariable) dests() []reg { return []reg{i.dest} } +func (i fetchVariable) sources() []reg { return nil } + +func (i jmpIfZero) dests() []reg { return nil } +func (i jmpIfZero) sources() []reg { return []reg{i.cond} } + +func (i loadInt) dests() []reg { return []reg{i.dest} } +func (i loadInt) sources() []reg { return nil } + +func (i loadStr) dests() []reg { return []reg{i.dest} } +func (i loadStr) sources() []reg { return nil } + +func (i binaryOp) dests() []reg { return []reg{i.dest} } +func (i binaryOp) sources() []reg { return []reg{i.left, i.right} } + +func (i unaryOp) dests() []reg { return []reg{i.dest} } +func (i unaryOp) sources() []reg { return []reg{i.arg} } + +func (i labelMarker) dests() []reg { return nil } +func (i labelMarker) sources() []reg { return nil } + +func present(regs ...*reg) []reg { + out := make([]reg, 0, len(regs)) + for _, r := range regs { + if r != nil { + out = append(out, *r) + } + } + return out +} diff --git a/internal/compiler/virtual_instruction_dump.go b/internal/compiler/virtual_instruction_dump.go new file mode 100644 index 00000000..04e53de7 --- /dev/null +++ b/internal/compiler/virtual_instruction_dump.go @@ -0,0 +1,126 @@ +package compiler + +import ( + "fmt" + "strings" +) + +func (r reg) String() string { return fmt.Sprintf("$r%d", int(r)) } +func (l label) String() string { return fmt.Sprintf("#%s", string(l)) } + +func (opMinInt) String() string { return "min_int" } +func (opAddInt) String() string { return "add_int" } +func (opSubInt) String() string { return "sub_int" } +func (opSubPortion) String() string { return "sub_portion" } +func (opMakePortion) String() string { return "mk_portion" } +func (opMakeMonetary) String() string { return "mk_monetary" } + +func (opIntCopy) String() string { return "int_copy" } +func (opPortionCopy) String() string { return "portion_copy" } +func (opGetAsset) String() string { return "get_asset" } +func (opGetAmount) String() string { return "get_amount" } + +func (i pullAccount) String() string { + opts := joinOpts( + optLabel("cap", i.cap), + optLabel("overdraft", i.overdraft), + optLabel("color", i.color), + ) + s := fmt.Sprintf("%s <- pull_account(account: %s", i.dest, i.account) + if opts != "" { + s += ", " + opts + } + return s + ")" +} + +func (i sendToAccount) String() string { + opts := joinOpts(optLabel("cap", i.cap)) + if i.account == nil { + return fmt.Sprintf("kept(%s)", opts) + } + s := fmt.Sprintf("send_to_account(%s", *i.account) + if opts != "" { + s += ", " + opts + } + return s + ")" +} + +func (i makeAllotment) String() string { + return fmt.Sprintf("[%s] <- mk_allot(%s, [%s])", regList(i.dest), i.amount, regList(i.portions)) +} + +func (i checkEnoughFunds) String() string { + return fmt.Sprintf("check_enough_funds(%s, %s)", i.got, i.needed) +} + +func (i setCurrentAsset) String() string { + return fmt.Sprintf("set_current_asset(%s)", i.asset) +} + +func (i checkEqCurrentAsset) String() string { + return fmt.Sprintf("check_eq_current_asset(%s)", i.got) +} + +func (i fetchVariable) String() string { + return fmt.Sprintf("%s <- fetch_var(%d)", i.dest, i.index) +} + +func (i jmpIfZero) String() string { + return fmt.Sprintf("jmp_if_zero(%s, %s)", i.cond, i.target) +} + +func (i loadInt) String() string { + return fmt.Sprintf("%s <- load_const(%s)", i.dest, i.value.String()) +} + +func (i loadStr) String() string { + return fmt.Sprintf("%s <- load_const(%q)", i.dest, i.value) +} + +func (i binaryOp) String() string { + return fmt.Sprintf("%s <- %s(%s, %s)", i.dest, i.op, i.left, i.right) +} + +func (i unaryOp) String() string { + return fmt.Sprintf("%s <- %s(%s)", i.dest, i.op, i.arg) +} + +func (i labelMarker) String() string { return i.label.String() } + +// dump renders a program: labels flush-left, instructions indented. +func dump(code []vInstr) string { + var b strings.Builder + for _, in := range code { + if _, ok := in.(labelMarker); ok { + fmt.Fprintf(&b, "%s\n", in) + } else { + fmt.Fprintf(&b, " %s\n", in) + } + } + return b.String() +} + +func optLabel(name string, r *reg) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s: %s", name, *r) +} + +func joinOpts(parts ...string) string { + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + kept = append(kept, p) + } + } + return strings.Join(kept, ", ") +} + +func regList(regs []reg) string { + parts := make([]string, len(regs)) + for i, r := range regs { + parts[i] = r.String() + } + return strings.Join(parts, ", ") +} diff --git a/internal/interpreter/asset_scaling.go b/internal/interpreter/asset_scaling.go index 2fd74fcf..d1a6b2df 100644 --- a/internal/interpreter/asset_scaling.go +++ b/internal/interpreter/asset_scaling.go @@ -6,6 +6,7 @@ import ( "slices" "strings" + "github.com/formancehq/numscript/internal/runtime" "github.com/formancehq/numscript/internal/utils" ) @@ -25,6 +26,25 @@ func buildScaledAsset(baseAsset string, scale int64) string { return fmt.Sprintf("%s/%d", baseAsset, scale) } +// AccountBalance is a single balance entry for an account: an (asset, color) +// pair and its amount. Used by the asset-scaling helpers to enumerate an +// account's holdings across scales. +type AccountBalance struct { + Asset string + Color string + Amount *big.Int +} + +// toAccountBalances adapts the runtime's account-balance snapshot to the +// interpreter's AccountBalance type used by the asset-scaling helpers. +func toAccountBalances(rs []runtime.AccountBalance) []AccountBalance { + out := make([]AccountBalance, len(rs)) + for i, b := range rs { + out[i] = AccountBalance{Asset: b.Asset, Color: b.Color, Amount: b.Amount} + } + return out +} + func getAssets(accountBalances []AccountBalance, baseAsset string) map[int64]*big.Int { result := make(map[int64]*big.Int) for _, accBalance := range accountBalances { diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index 21883ee5..83643b6e 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -1,9 +1,11 @@ package interpreter import ( + "math/big" "slices" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" "github.com/formancehq/numscript/internal/utils" ) @@ -67,7 +69,13 @@ func (st *programState) batchQuery(account AccountAddress, asset Asset, color St } func (st *programState) runBalancesQuery() error { - filteredQuery := st.CachedBalances.filterQuery(st.CurrentBalanceQuery) + // keep only triples the runtime hasn't already cached (prewarmed or touched) + var filteredQuery BalanceQuery + for _, item := range st.CurrentBalanceQuery { + if !st.rs.Has(item.Account, item.Asset, item.Color) { + filteredQuery = append(filteredQuery, item) + } + } // avoid updating balances if we don't need to fetch new data if len(filteredQuery) == 0 { @@ -81,7 +89,13 @@ func (st *programState) runBalancesQuery() error { // reset batch query st.CurrentBalanceQuery = BalanceQuery{} - st.CachedBalances.Merge(queriedBalances) + // seed the runtime's balance cache with the batch result, so its lazy + // per-key Store path is never hit for these triples + seed := make(map[runtime.PairKey]*big.Int, len(queriedBalances)) + for _, row := range queriedBalances { + seed[runtime.PairKey{Account: row.Account, Asset: row.Asset, Color: row.Color}] = row.Amount + } + st.rs.Prewarm(seed) return nil } diff --git a/internal/interpreter/funds_queue.go b/internal/interpreter/funds_queue.go deleted file mode 100644 index 007c791a..00000000 --- a/internal/interpreter/funds_queue.go +++ /dev/null @@ -1,188 +0,0 @@ -package interpreter - -import ( - "math/big" -) - -type Sender struct { - Name string - Amount *big.Int - Color string -} - -type queue[T any] struct { - Head T - Tail *queue[T] - - // Instead of keeping a single ref of the lastCell and updating the invariant on every push/pop operation, - // we keep a cache of the last cell on every cell. - // This makes code much easier and we don't risk breaking the invariant and producing wrong results and other subtle issues - // - // While, unlike keeping a single reference (like golang's queue `container/list` package does), this is not always O(1), - // the amortized time should still be O(1) (the number of steps of traversal while searching the last elem is not higher than the number of .Push() calls) - lastCell *queue[T] -} - -func (s *queue[T]) getLastCell() *queue[T] { - // check if this is the last cell without reading cache first - if s.Tail == nil { - return s - } - - // if not, check if cache is present - if s.lastCell != nil { - // even if it is, it may be a stale value (as more values could have been pushed), so we check the value recursively - lastCell := s.lastCell.getLastCell() - // we do path compression so that next time we get the path immediately - s.lastCell = lastCell - return lastCell - } - - // if no last value is cached, we traverse recursively to find it - s.lastCell = s.Tail.getLastCell() - return s.lastCell -} - -func fromSlice[T any](slice []T) *queue[T] { - var ret *queue[T] - // TODO use https://pkg.go.dev/slices#Backward in golang 1.23 - for i := len(slice) - 1; i >= 0; i-- { - ret = &queue[T]{ - Head: slice[i], - Tail: ret, - } - } - return ret -} - -type fundsQueue struct { - senders *queue[Sender] -} - -func newFundsQueue(senders []Sender) fundsQueue { - return fundsQueue{ - senders: fromSlice(senders), - } -} - -func (s *fundsQueue) compactTop() { - for s.senders != nil && s.senders.Tail != nil { - - first := s.senders.Head - second := s.senders.Tail.Head - - if second.Amount.Cmp(big.NewInt(0)) == 0 { - s.senders = &queue[Sender]{Head: first, Tail: s.senders.Tail.Tail} - continue - } - - if first.Name != second.Name || first.Color != second.Color { - return - } - - s.senders = &queue[Sender]{ - Head: Sender{ - Name: first.Name, - Color: first.Color, - Amount: new(big.Int).Add(first.Amount, second.Amount), - }, - Tail: s.senders.Tail.Tail, - } - } -} - -func (s *fundsQueue) PullAll() []Sender { - var senders []Sender - for s.senders != nil { - senders = append(senders, s.senders.Head) - s.senders = s.senders.Tail - } - return senders -} - -func (s *fundsQueue) Push(senders ...Sender) { - newTail := fromSlice(senders) - if s.senders == nil { - s.senders = newTail - } else { - cell := s.senders.getLastCell() - cell.Tail = newTail - } -} - -func (s *fundsQueue) PullAnything(requiredAmount *big.Int) []Sender { - return s.Pull(requiredAmount, nil) -} - -func (s *fundsQueue) PullColored(requiredAmount *big.Int, color string) []Sender { - return s.Pull(requiredAmount, &color) -} -func (s *fundsQueue) PullUncolored(requiredAmount *big.Int) []Sender { - return s.PullColored(requiredAmount, "") -} - -func (s *fundsQueue) Pull(requiredAmount *big.Int, color *string) []Sender { - // clone so that we can manipulate this arg - requiredAmount = new(big.Int).Set(requiredAmount) - - // TODO preallocate for perfs - var out []Sender - - for requiredAmount.Cmp(big.NewInt(0)) != 0 && s.senders != nil { - s.compactTop() - - available := s.senders.Head - s.senders = s.senders.Tail - - if color != nil && available.Color != *color { - out1 := s.Pull(requiredAmount, color) - s.senders = &queue[Sender]{ - Head: available, - Tail: s.senders, - } - out = append(out, out1...) - break - } - - switch available.Amount.Cmp(requiredAmount) { - case -1: // not enough: - out = append(out, available) - requiredAmount.Sub(requiredAmount, available.Amount) - - case 1: // more than enough - s.senders = &queue[Sender]{ - Head: Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Sub(available.Amount, requiredAmount), - }, - Tail: s.senders, - } - fallthrough - - case 0: // exactly the same - out = append(out, Sender{ - Name: available.Name, - Color: available.Color, - Amount: new(big.Int).Set(requiredAmount), - }) - return out - } - - } - - return out -} - -// Clone the queue so that you can safely mutate one without mutating the other -func (s fundsQueue) Clone() fundsQueue { - fq := newFundsQueue(nil) - - senders := s.senders - for senders != nil { - fq.Push(senders.Head) - senders = senders.Tail - } - - return fq -} diff --git a/internal/interpreter/funds_queue_test.go b/internal/interpreter/funds_queue_test.go deleted file mode 100644 index 87672be9..00000000 --- a/internal/interpreter/funds_queue_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package interpreter - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestEnoughBalance(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(100)}, - }) - - out := queue.PullAnything(big.NewInt(2)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - }, out) - -} - -func TestPush(t *testing.T) { - queue := newFundsQueue(nil) - queue.Push(Sender{Name: "acc", Amount: big.NewInt(100)}) - - out := queue.PullUncolored(big.NewInt(20)) - require.Equal(t, []Sender{ - {Name: "acc", Amount: big.NewInt(20)}, - }, out) - -} - -func TestSimple(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(3)}, - }, out) - - out = queue.PullAnything(big.NewInt(7)) - require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(7)}, - }, out) -} - -func TestPullZero(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(0)) - require.Equal(t, []Sender(nil), out) -} - -func TestCompactFunds(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - }, out) -} - -func TestCompactFunds3Times(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(3)}, - {Name: "s1", Amount: big.NewInt(1)}, - }) - - out := queue.PullAnything(big.NewInt(6)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(6)}, - }, out) -} - -func TestCompactFundsWithEmptySender(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s2", Amount: big.NewInt(0)}, - {Name: "s1", Amount: big.NewInt(10)}, - }) - - out := queue.PullAnything(big.NewInt(5)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(5)}, - }, out) -} - -func TestMissingFunds(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - }) - - out := queue.PullAnything(big.NewInt(300)) - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - }, out) -} - -func TestNoZeroLeftovers(t *testing.T) { - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(10)}, - {Name: "s2", Amount: big.NewInt(15)}, - }) - - queue.PullAnything(big.NewInt(10)) - - out := queue.PullAnything(big.NewInt(15)) - require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(15)}, - }, out) -} - -func TestReconcileColoredManyDestPerSender(t *testing.T) { - queue := newFundsQueue([]Sender{ - {"src", big.NewInt(10), "X"}, - }) - - out := queue.PullColored(big.NewInt(5), "X") - require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, - }, out) - - out = queue.PullColored(big.NewInt(5), "X") - require.Equal(t, []Sender{ - {Name: "src", Amount: big.NewInt(5), Color: "X"}, - }, out) - -} - -func TestPullColored(t *testing.T) { - queue := newFundsQueue([]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)}, - }) - - out := queue.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"}, - }, 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)}, - }, queue.PullAll()) -} - -func TestPullColoredComplex(t *testing.T) { - queue := newFundsQueue([]Sender{ - {"s1", big.NewInt(1), "c1"}, - {"s2", big.NewInt(1), "c2"}, - }) - - out := queue.PullColored(big.NewInt(1), "c2") - require.Equal(t, []Sender{ - {Name: "s2", Amount: big.NewInt(1), Color: "c2"}, - }, out) -} - -func TestClone(t *testing.T) { - - fq := newFundsQueue([]Sender{ - {"s1", big.NewInt(10), ""}, - }) - - cloned := fq.Clone() - - fq.PullAll() - - require.Equal(t, []Sender{ - {"s1", big.NewInt(10), ""}, - }, cloned.PullAll()) - -} - -func TestCompactFundsAndPush(t *testing.T) { - noCol := "" - - queue := newFundsQueue([]Sender{ - {Name: "s1", Amount: big.NewInt(2)}, - {Name: "s1", Amount: big.NewInt(10)}, - }) - - queue.Pull(big.NewInt(1), &noCol) - - queue.Push(Sender{ - Name: "pushed", - Amount: big.NewInt(42), - }) - - out := queue.PullAll() - require.Equal(t, []Sender{ - {Name: "s1", Amount: big.NewInt(11)}, - {Name: "pushed", Amount: big.NewInt(42)}, - }, out) -} diff --git a/internal/interpreter/internal_balances.go b/internal/interpreter/internal_balances.go deleted file mode 100644 index ca75e5fc..00000000 --- a/internal/interpreter/internal_balances.go +++ /dev/null @@ -1,111 +0,0 @@ -package interpreter - -import "math/big" - -// An internal representation of the balances. Used to cache balances we get from external store. -// Whereas the external representation (interpreter.Balances) is user-facing and be a stable contract, -// (for example, allowing more columns if we need an higher level of fungibility), this one is used internally by the runtime, and -// could change over time, for example to add more indexes for faster lookups -type InternalBalances map[string][]AccountBalance - -// A single balance entry for an account: an (asset, color) pair and its amount. -type AccountBalance struct { - Asset string - Color string - Amount *big.Int -} - -func FromBalancesRows(b Balances) InternalBalances { - out := make(InternalBalances, len(b)) - for _, row := range b { - amount := new(big.Int) // clone so the map doesn't alias the slice's *big.Int - if row.Amount != nil { - amount.Set(row.Amount) - } - out[row.Account] = append(out[row.Account], AccountBalance{ - Asset: row.Asset, - Color: row.Color, - Amount: amount, - }) - } - return out -} - -func (b InternalBalances) DeepClone() InternalBalances { - cloned := make(InternalBalances, len(b)) - for account, entries := range b { - clonedEntries := make([]AccountBalance, len(entries)) - for i, e := range entries { - clonedEntries[i] = AccountBalance{ - Asset: e.Asset, - Color: e.Color, - Amount: new(big.Int).Set(e.Amount), - } - } - cloned[account] = clonedEntries - } - return cloned -} - -// Get the (account, asset, color) balance from the cache. -// If it is not present, it writes a zero balance in it and returns it. -func (b InternalBalances) fetchBalance(account AccountAddress, asset Asset, color String) *big.Int { - acc := string(account) - for i := range b[acc] { - entry := &b[acc][i] - if entry.Asset == string(asset) && entry.Color == string(color) { - return entry.Amount - } - } - - amount := new(big.Int) - b[acc] = append(b[acc], AccountBalance{ - Asset: string(asset), - Color: string(color), - Amount: amount, - }) - return amount -} - -// Set assigns amount to the (account, asset, color) balance. -func (b InternalBalances) Set(account string, asset string, color string, amount *big.Int) { - for i := range b[account] { - if b[account][i].Asset == asset && b[account][i].Color == color { - b[account][i].Amount = amount - return - } - } - b[account] = append(b[account], AccountBalance{ - Asset: asset, - Color: color, - Amount: amount, - }) -} - -func (b InternalBalances) has(account string, asset string, color string) bool { - for _, entry := range b[account] { - if entry.Asset == asset && entry.Color == color { - return true - } - } - return false -} - -// given a BalanceQuery, return a new query which only contains needed -// (account, asset, color) tuples (that is, the ones that aren't already cached) -func (b InternalBalances) filterQuery(q BalanceQuery) BalanceQuery { - filteredQuery := BalanceQuery{} - for _, item := range q { - if !b.has(item.Account, item.Asset, item.Color) { - filteredQuery = append(filteredQuery, item) - } - } - return filteredQuery -} - -// Merge the queried balance rows into the cache -func (b InternalBalances) Merge(update []BalanceRow) { - for _, row := range update { - b.Set(row.Account, row.Asset, row.Color, row.Amount) - } -} diff --git a/internal/interpreter/internal_balances_test.go b/internal/interpreter/internal_balances_test.go deleted file mode 100644 index 662a2c1d..00000000 --- a/internal/interpreter/internal_balances_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package interpreter - -import ( - "math/big" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFilterQuery(t *testing.T) { - fullBalance := InternalBalances{ - "alice": { - {Asset: "EUR/2", Amount: big.NewInt(1)}, - {Asset: "USD/2", Amount: big.NewInt(2)}, - }, - "bob": { - {Asset: "BTC", Amount: big.NewInt(3)}, - }, - } - - filteredQuery := fullBalance.filterQuery(BalanceQuery{ - {Account: "alice", Asset: "GBP/2"}, - {Account: "alice", Asset: "YEN"}, - {Account: "alice", Asset: "EUR/2"}, - {Account: "bob", Asset: "BTC"}, - {Account: "charlie", Asset: "ETH"}, - }) - - require.Equal(t, BalanceQuery{ - {Account: "alice", Asset: "GBP/2"}, - {Account: "alice", Asset: "YEN"}, - {Account: "charlie", Asset: "ETH"}, - }, filteredQuery) -} - -func TestBalancesFirstDuplicate(t *testing.T) { - // no duplicate: same account/asset but different color are distinct keys - _, ok := Balances{ - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(1)}, - {Account: "alice", Asset: "EUR/2", Amount: big.NewInt(2)}, - {Account: "alice", Asset: "USD/2", Color: "X", Amount: big.NewInt(3)}, - {Account: "bob", Asset: "USD/2", Amount: big.NewInt(4)}, - }.FirstDuplicate() - require.False(t, ok) - - // duplicate (account, asset, color), even with a different amount - dup, ok := Balances{ - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(1)}, - {Account: "alice", Asset: "USD/2", Amount: big.NewInt(99)}, - }.FirstDuplicate() - require.True(t, ok) - require.Equal(t, BalanceRow{Account: "alice", Asset: "USD/2", Amount: big.NewInt(99)}, dup) -} - -func TestCloneBalances(t *testing.T) { - fullBalance := InternalBalances{ - "alice": { - {Asset: "EUR/2", Amount: big.NewInt(1)}, - {Asset: "USD/2", Amount: big.NewInt(2)}, - }, - "bob": { - {Asset: "BTC", Amount: big.NewInt(3)}, - }, - } - - cloned := fullBalance.DeepClone() - - // USD/2 is the second entry for alice (index 1). - fullBalance["alice"][1].Amount.Set(big.NewInt(42)) - - require.Equal(t, big.NewInt(2), cloned["alice"][1].Amount) -} diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index bd154144..84291c99 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -11,9 +11,18 @@ import ( "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" "github.com/formancehq/numscript/internal/utils" ) +// zeroStore backs the runtime.RunState's lazy balance fallback. The interpreter +// pre-fetches every needed balance in one batched query (runBalancesQuery) and +// Prewarms it into the runtime, and treats any un-fetched (account, asset, +// color) as zero — exactly the semantics this store provides. +type zeroStore struct{} + +func (zeroStore) GetBalance(account, asset, color string) *big.Int { return new(big.Int) } + type VariablesMap map[string]string type InterpreterError interface { @@ -23,13 +32,10 @@ type InterpreterError interface { type Metadata = map[string]Value -type Posting struct { - Source string `json:"source"` - Destination string `json:"destination"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Color string `json:"color,omitempty"` -} +// Posting is an alias for runtime.Posting, which owns the definition (and the +// json serialization contract). Kept as an alias so the public name +// interpreter.Posting / numscript.Posting is preserved. +type Posting = runtime.Posting type ExecutionResult struct { Postings []Posting `json:"postings"` @@ -224,11 +230,9 @@ func RunProgram( ParsedVars: make(map[string]Value), TxMeta: make(map[string]Value), CachedAccountsMeta: AccountsMetadata{}, - CachedBalances: InternalBalances{}, SetAccountsMeta: AccountsMetadata{}, Store: store, - Postings: make([]Posting, 0), - fundsQueue: newFundsQueue(nil), + rs: runtime.New(zeroStore{}), CurrentBalanceQuery: BalanceQuery{}, ctx: ctx, @@ -279,7 +283,9 @@ func RunProgram( } } - for _, posting := range st.Postings { + // GetPostings returns []runtime.Posting, which is []Posting (alias). + postings := st.rs.GetPostings() + for _, posting := range postings { err := checkPostingInvariants(posting) if err != nil { return nil, err @@ -287,7 +293,7 @@ func RunProgram( } res := &ExecutionResult{ - Postings: st.Postings, + Postings: postings, Metadata: st.TxMeta, AccountsMetadata: st.SetAccountsMeta, } @@ -306,38 +312,23 @@ type programState struct { ParsedVars map[string]Value TxMeta map[string]Value - Postings []Posting - fundsQueue fundsQueue + + // rs owns the funds state: balances (write-through cache over the batched + // Store fetch, seeded via Prewarm), the FIFO funding-source queue, and the + // emitted postings. Replaces the former fundsQueue + CachedBalances + Postings. + rs *runtime.RunState Store Store SetAccountsMeta AccountsMetadata CachedAccountsMeta AccountsMetadata - CachedBalances InternalBalances CurrentBalanceQuery BalanceQuery FeatureFlags map[string]struct{} } -func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) { - monetaryBi := big.Int(monetary) - - if monetaryBi.Cmp(big.NewInt(0)) == 0 { - return - } - - balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color) - balance.Sub(balance, &monetaryBi) - - st.fundsQueue.Push(Sender{ - Name: string(name), - Amount: &monetaryBi, - Color: string(color), - }) -} - // Append a posting without checking if account has enough balance. // Updates both source and destination balances. // Noop if the amount is zero @@ -348,55 +339,19 @@ func (st *programState) forcePushPostingUncolored( asset Asset, ) { amtBi := big.Int(amount) - - if amtBi.Sign() == 0 { - return - } - - srcBalance := st.CachedBalances.fetchBalance(source, asset, "") - srcBalance.Sub(srcBalance, &amtBi) - - destBalance := st.CachedBalances.fetchBalance(destination, asset, "") - destBalance.Add(destBalance, &amtBi) - - st.Postings = append(st.Postings, Posting{ - Source: string(source), - Destination: string(destination), - Amount: new(big.Int).Set(&amtBi), - Color: "", - Asset: string(asset), - }) + st.rs.ForcePosting(string(source), string(destination), string(asset), "", &amtBi) } func (st *programState) pushReceiver(name string, monetary *big.Int) { - if monetary.Cmp(big.NewInt(0)) == 0 { + // color == nil: drain the queue regardless of color (PullAnything), each + // posting keeping its source fund's own color. + if name == KEPT_ADDR { + // kept funds are refunded to their sources, emitting no posting + st.rs.Send(nil, monetary, nil) return } - - senders := st.fundsQueue.PullAnything(monetary) - - for _, sender := range senders { - postings := Posting{ - Source: sender.Name, - Destination: name, - Asset: string(st.CurrentAsset), - Amount: sender.Amount, - Color: sender.Color, - } - - if name == KEPT_ADDR { - // If funds are kept, give them back to senders - srcBalance := st.CachedBalances.fetchBalance(AccountAddress(postings.Source), st.CurrentAsset, String(sender.Color)) - srcBalance.Add(srcBalance, postings.Amount) - - continue - } - - destBalance := st.CachedBalances.fetchBalance(AccountAddress(postings.Destination), st.CurrentAsset, String(sender.Color)) - destBalance.Add(destBalance, postings.Amount) - - st.Postings = append(st.Postings, postings) - } + dest := name + st.rs.Send(&dest, monetary, nil) } func (st *programState) runStatement(statement parser.Statement) InterpreterError { @@ -439,29 +394,16 @@ func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) Int return err } - balance := st.CachedBalances.fetchBalance(account, asset, "") - - if amt == nil { - if balance.Sign() > 0 { - balance.Set(big.NewInt(0)) - } - } else { - // Do not allow negative saves - if amt.Cmp(big.NewInt(0)) == -1 { - return NegativeAmountErr{ - Range: saveStatement.SentValue.GetRange(), - Amount: MonetaryInt(*amt), - } - } - - // we decrease the balance by "amt" - balance.Sub(balance, amt) - // without going under 0 - if balance.Cmp(big.NewInt(0)) == -1 { - balance.Set(big.NewInt(0)) + // Do not allow negative saves + if amt != nil && amt.Cmp(big.NewInt(0)) == -1 { + return NegativeAmountErr{ + Range: saveStatement.SentValue.GetRange(), + Amount: MonetaryInt(*amt), } } + // amt == nil -> "save all"; otherwise reduce by amt, floored at 0 + st.rs.Save(string(account), string(asset), "", amt) return nil } @@ -473,6 +415,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return err } st.CurrentAsset = asset + st.rs.SetCurrentAsset(string(asset)) sentAmt, err := st.takeAll(statement.Source) if err != nil { return err @@ -485,6 +428,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return err } st.CurrentAsset = monetary.Asset + st.rs.SetCurrentAsset(string(monetary.Asset)) amtBi := big.Int(monetary.Amount) if amtBi.Sign() == -1 { @@ -528,12 +472,10 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd return nil, err } - balance := s.CachedBalances.fetchBalance(account, s.CurrentAsset, color) - - // we sent balance+overdraft - sentAmt := CalculateMaxSafeWithdraw(balance, overdraft) - - s.pushSender(account, MonetaryInt(*sentAmt), color) + // PullUncapped queues balance+overdraft (== CalculateMaxSafeWithdraw), + // debiting the (account, currentAsset, color) balance. + sentAmt := new(big.Int) + s.rs.PullUncapped(sentAmt, string(account), overdraft, string(color)) return sentAmt, nil } @@ -572,8 +514,8 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError } baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() - acc, ok := s.CachedBalances[string(account)] - if !ok { + acc := toAccountBalances(s.rs.AccountBalances(string(account))) + if len(acc) == 0 { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } @@ -682,27 +624,22 @@ func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amo 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) - - // that's the amount we are allowed to send (balance + overdraft) - actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) - } - s.pushSender(account, MonetaryInt(*actuallySentAmt), color) + // Pull computes the available amount (min(max(0, balance+overdraft), amount) + // == CalculateSafeWithdraw; unbounded for world/overdraft==nil), debits the + // (account, currentAsset, color) balance, and queues the funds. The + // interpreter's overdraft convention (nil == unbounded) is exactly Pull's. + actuallySentAmt := new(big.Int) + s.rs.Pull(actuallySentAmt, string(account), amount, overdraft, string(color)) return actuallySentAmt, nil } +// cloneState returns an undo function for speculative source evaluation (oneof). +// Backtracking is a cheap source-queue snapshot: on undo, the runtime repays the +// funds pulled since the mark and truncates the queue — no map cloning. func (s *programState) cloneState() func() { - fqBackup := s.fundsQueue.Clone() - balancesBackup := s.CachedBalances.DeepClone() - + mark := s.rs.Snapshot() return func() { - s.fundsQueue = fqBackup - s.CachedBalances = balancesBackup + s.rs.Restore(mark) } } @@ -735,8 +672,8 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale() - acc, ok := s.CachedBalances[string(account)] - if !ok { + acc := toAccountBalances(s.rs.AccountBalances(string(account))) + if len(acc) == 0 { return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range} } @@ -826,7 +763,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return nil, err } for i, allotmentItem := range source.Items { - err := s.tryTakingExact(allotmentItem.From, MonetaryInt(*allot[i])) + err := s.tryTakingExact(allotmentItem.From, MonetaryInt(allot[i])) if err != nil { return nil, err } @@ -874,7 +811,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I receivedTotal := big.NewInt(0) for i, allotmentItem := range destination.Items { - amtToReceive := allot[i] + amtToReceive := &allot[i] err := s.sendToKeptOrDest(allotmentItem.To, amtToReceive) if err != nil { return err @@ -972,9 +909,9 @@ func (s *programState) sendToKeptOrDest(keptOrDest parser.KeptOrDestination, amo } -func (s *programState) makeAllotment(monetary *big.Int, items []parser.AllotmentValue) ([]*big.Int, InterpreterError) { +func (s *programState) makeAllotment(monetary *big.Int, items []parser.AllotmentValue) ([]big.Int, InterpreterError) { totalAllotment := big.NewRat(0, 1) - var allotments []*big.Rat + allotments := make([]big.Rat, 0, len(items)) remainingAllotmentIndex := -1 @@ -987,46 +924,25 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment } totalAllotment.Add(totalAllotment, rat) - allotments = append(allotments, rat) + allotments = append(allotments, *rat) case *parser.RemainingAllotment: remainingAllotmentIndex = i - allotments = append(allotments, new(big.Rat)) + allotments = append(allotments, big.Rat{}) // TODO check there are not duplicate remaining clause } } if remainingAllotmentIndex != -1 { - allotments[remainingAllotmentIndex] = new(big.Rat).Sub(big.NewRat(1, 1), totalAllotment) + allotments[remainingAllotmentIndex] = *new(big.Rat).Sub(big.NewRat(1, 1), totalAllotment) } else if totalAllotment.Cmp(big.NewRat(1, 1)) != 0 { return nil, InvalidAllotmentSum{ActualSum: *totalAllotment} } - parts := make([]*big.Int, len(allotments)) - - totalAllocated := big.NewInt(0) - - for i, allot := range allotments { - monetaryRat := new(big.Rat).SetInt(monetary) - product := new(big.Rat).Mul(allot, monetaryRat) - - floored := new(big.Int).Div(product.Num(), product.Denom()) - - parts[i] = floored - totalAllocated.Add(totalAllocated, floored) - - } - - for i := range parts { - if /* totalAllocated >= monetary */ totalAllocated.Cmp(monetary) != -1 { - break - } - - parts[i].Add(parts[i], big.NewInt(1)) - // totalAllocated++ - totalAllocated.Add(totalAllocated, big.NewInt(1)) - } - + // portions are resolved (remaining computed, sum validated) — delegate the + // floor-then-distribute split to the runtime, filling one contiguous buffer. + parts := make([]big.Int, len(allotments)) + runtime.MakeAllotment(parts, monetary, allotments) return parts, nil } @@ -1043,7 +959,7 @@ func getBalance( if fetchBalanceErr != nil { return nil, QueryBalanceError{WrappedError: fetchBalanceErr} } - balance := s.CachedBalances.fetchBalance(account, asset, color) + balance := s.rs.GetAccountBalance(string(account), string(asset), string(color)) return balance, nil } diff --git a/internal/runtime/allotment.go b/internal/runtime/allotment.go new file mode 100644 index 00000000..fda08f3d --- /dev/null +++ b/internal/runtime/allotment.go @@ -0,0 +1,49 @@ +package runtime + +import "math/big" + +// MakeAllotment splits amount across portions, writing one integer amount per +// portion into out (out[i] for portions[i]) such that the written parts sum +// exactly to amount. +// +// out must have the same length as portions; its elements are overwritten. They +// are big.Int values, not pointers: MakeAllotment mutates them in place through +// the slice (out[i] is addressable, so out[i].Div(...) writes the element). This +// lets the caller allocate the whole result as one contiguous []big.Int rather +// than len(portions) separate *big.Int. +// +// Portions are fractions of the whole (big.Rat) and are expected to sum to 1; +// any "remaining" portion must already be resolved by the caller (i.e. computed +// as 1 minus the others). MakeAllotment does not validate the sum. +// +// Algorithm (matching the interpreter's allotment logic): +// 1. each part is floor(portion * amount); +// 2. the leftover from flooring — amount minus the sum of the floored parts — +// is handed out one unit at a time to the earliest portions, until the parts +// sum exactly to amount. +// +// Because flooring loses strictly less than 1 unit per portion, the leftover is +// strictly less than len(portions), so a single front-to-back pass distributes +// it fully (given portions that sum to 1). +// +// Inputs amount and portions are not mutated. +func MakeAllotment(out []big.Int, amount *big.Int, portions []big.Rat) { + totalAllocated := new(big.Int) + amountRat := new(big.Rat).SetInt(amount) + + for i := range portions { + product := new(big.Rat).Mul(&portions[i], amountRat) + // floor into out[i] in place; Denom() is always positive (matches Div) + out[i].Div(product.Num(), product.Denom()) + totalAllocated.Add(totalAllocated, &out[i]) + } + + one := big.NewInt(1) + for i := range out { + if totalAllocated.Cmp(amount) >= 0 { + break + } + out[i].Add(&out[i], one) + totalAllocated.Add(totalAllocated, one) + } +} diff --git a/internal/runtime/allotment_test.go b/internal/runtime/allotment_test.go new file mode 100644 index 00000000..bcfd8107 --- /dev/null +++ b/internal/runtime/allotment_test.go @@ -0,0 +1,127 @@ +package runtime_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" +) + +func rat(num, denom int64) big.Rat { return *big.NewRat(num, denom) } + +// allot fills a fresh buffer via MakeAllotment and returns it, for ergonomics. +func allot(amount int64, portions []big.Rat) []big.Int { + out := make([]big.Int, len(portions)) + runtime.MakeAllotment(out, big.NewInt(amount), portions) + return out +} + +func wantParts(t *testing.T, got []big.Int, want []int64) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range got { + if got[i].Cmp(big.NewInt(want[i])) != 0 { + t.Errorf("part[%d] = %s, want %d", i, got[i].String(), want[i]) + } + } +} + +func TestMakeAllotment_EvenSplit(t *testing.T) { + wantParts(t, allot(100, []big.Rat{rat(1, 2), rat(1, 2)}), []int64{50, 50}) +} + +func TestMakeAllotment_UnevenSplit(t *testing.T) { + wantParts(t, allot(100, []big.Rat{rat(1, 4), rat(3, 4)}), []int64{25, 75}) +} + +func TestMakeAllotment_RemainderGoesToEarliest_Thirds(t *testing.T) { + // 1/3 of 100 floors to 33 each (sum 99); the leftover 1 goes to the first. + wantParts(t, allot(100, []big.Rat{rat(1, 3), rat(1, 3), rat(1, 3)}), []int64{34, 33, 33}) +} + +func TestMakeAllotment_RemainderTwoUnits(t *testing.T) { + // 1/6,1/6,4/6 of 100 -> 16,16,66 (sum 98); leftover 2 -> first two get +1. + wantParts(t, allot(100, []big.Rat{rat(1, 6), rat(1, 6), rat(4, 6)}), []int64{17, 17, 66}) +} + +func TestMakeAllotment_HalvesOfOddAmount(t *testing.T) { + // 7 split in half -> 3,3 (sum 6); leftover 1 -> first. + wantParts(t, allot(7, []big.Rat{rat(1, 2), rat(1, 2)}), []int64{4, 3}) +} + +func TestMakeAllotment_SinglePortionWhole(t *testing.T) { + wantParts(t, allot(100, []big.Rat{rat(1, 1)}), []int64{100}) +} + +func TestMakeAllotment_ZeroAmount(t *testing.T) { + wantParts(t, allot(0, []big.Rat{rat(1, 3), rat(2, 3)}), []int64{0, 0}) +} + +func TestMakeAllotment_EmptyPortions(t *testing.T) { + out := []big.Int{} + runtime.MakeAllotment(out, big.NewInt(100), []big.Rat{}) + if len(out) != 0 { + t.Errorf("len = %d, want 0", len(out)) + } +} + +func TestMakeAllotment_PercentageLikePortions(t *testing.T) { + // 19% / 81% of 10_000 -> 1900 / 8100 exactly. + wantParts(t, allot(10_000, []big.Rat{rat(19, 100), rat(81, 100)}), []int64{1900, 8100}) +} + +func TestMakeAllotment_PartsAlwaysSumToAmount(t *testing.T) { + // A spread that floors awkwardly must still sum exactly to the amount. + amount := big.NewInt(1001) + out := make([]big.Int, 3) + runtime.MakeAllotment(out, amount, []big.Rat{rat(1, 7), rat(2, 7), rat(4, 7)}) + sum := new(big.Int) + for i := range out { + sum.Add(sum, &out[i]) + } + if sum.Cmp(amount) != 0 { + t.Errorf("parts sum to %s, want %s (parts=%v)", sum, amount, out) + } +} + +func TestMakeAllotment_BeyondInt64(t *testing.T) { + amount, _ := new(big.Int).SetString("1000000000000000000000000001", 10) // ~1e27 + 1, odd + out := make([]big.Int, 2) + runtime.MakeAllotment(out, amount, []big.Rat{rat(1, 2), rat(1, 2)}) + // floor halves are equal; the odd unit goes to the first + half := new(big.Int).Div(amount, big.NewInt(2)) // floor(amount/2) + first := new(big.Int).Add(half, big.NewInt(1)) + if out[0].Cmp(first) != 0 || out[1].Cmp(half) != 0 { + t.Errorf("got [%s %s], want [%s %s]", out[0].String(), out[1].String(), first, half) + } + sum := new(big.Int).Add(&out[0], &out[1]) + if sum.Cmp(amount) != 0 { + t.Errorf("sum = %s, want %s", sum, amount) + } +} + +func TestMakeAllotment_ModifiesCallerSliceAndOverwritesStale(t *testing.T) { + // Pre-fill the buffer with garbage to prove MakeAllotment overwrites it + // (Div fully replaces each element) and writes through to the caller's slice. + out := make([]big.Int, 2) + out[0].SetInt64(999) + out[1].SetInt64(-7) + runtime.MakeAllotment(out, big.NewInt(100), []big.Rat{rat(1, 4), rat(3, 4)}) + wantParts(t, out, []int64{25, 75}) +} + +func TestMakeAllotment_DoesNotMutateInputs(t *testing.T) { + portions := []big.Rat{rat(1, 3), rat(2, 3)} + p0, p1 := rat(1, 3), rat(2, 3) + amount := big.NewInt(100) + out := make([]big.Int, 2) + runtime.MakeAllotment(out, amount, portions) + if portions[0].Cmp(&p0) != 0 || portions[1].Cmp(&p1) != 0 { + t.Errorf("portions mutated: %v %v", portions[0].String(), portions[1].String()) + } + if amount.Cmp(big.NewInt(100)) != 0 { + t.Errorf("amount mutated: %s", amount) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 00000000..e4df0889 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,485 @@ +// Package runtime is a Go port of the OCaml run_state module, extended with +// color (sub-asset fungibility) support to match the interpreter's fundsQueue. +// +// It tracks per-(account, asset, color) balances, an ordered FIFO queue of +// funding sources produced by Pull/PullUncapped, and the list of postings +// produced by Send/SendUncapped. It is the state layer the VM's PullAccount / +// SendToAccount / CheckEnoughFunds opcodes call into. +// +// Balances are sourced lazily from a Store and then cached write-through: the +// first read of an (account, asset, color) triple fetches from the Store and +// caches the result; every subsequent read and every debit/credit operates on +// the cached value. So once @acc is fetched and decreased, later reads see the +// decreased balance without consulting the Store again. +// +// Color is a plain string; the empty string "" means "uncolored". Pull tags the +// funds it queues with a color, and Send drains only the sources whose color +// matches the requested one, skipping (but preserving the position of) +// non-matching funds — exactly like the interpreter's fundsQueue. +// +// Concurrency: a *RunState is mutable and NOT safe for concurrent use. Use one +// per execution. +// +// Numeric model: all amounts are *big.Int (arbitrary precision), matching the +// numscript interpreter. Because *big.Int is a mutable reference type, this +// package is careful about aliasing: it clones values it ingests from the Store +// and clones caller-supplied amounts it intends to mutate, it only mutates +// big.Ints it privately owns (queued source amounts), and it never hands out a +// live reference to its internal state (GetAccountBalance / GetPostings return +// copies). +package runtime + +import "math/big" + +// Store supplies the authoritative starting balance for an (account, asset, +// color) triple. A triple never seen by the ledger is fetched once, then cached. +// Implementations should return 0 (or nil, treated as 0) for unknown triples, +// not an error. The returned *big.Int is cloned on ingest, so the Store may +// safely reuse it. +type Store interface { + GetBalance(account, asset, color string) *big.Int +} + +// Posting is a recorded movement of Amount units of Asset (of the given Color) +// from Source to Destination. It is the single source of truth for the +// interpreter's public Posting type (aliased there), hence the json tags: field +// names and order define the public ledger serialization contract — keep them +// stable. +type Posting struct { + Source string `json:"source"` + Destination string `json:"destination"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Color string `json:"color,omitempty"` +} + +// PairKey identifies a balance slot. Exported so a Store mock/adapter can build +// the same keys. Despite the name it is an (account, asset, color) triple. +type PairKey struct { + Account string + Asset string + Color string +} + +// source is an internal funding entry queued by Pull / PullUncapped. It carries +// the color of the funds so Send can filter and so postings/refunds land on the +// right (asset, color) balance. The amount is privately owned by the queue and +// may be mutated in place. +type source struct { + account string + amount *big.Int + color string +} + +// RunState is the Go port of the OCaml run_state. The zero value is not usable; +// call New. All fields are unexported to preserve the .mli interface boundary. +type RunState struct { + store Store + balances map[PairKey]*big.Int // write-through cache over store + sources []source // FIFO: front = index 0 + postings []Posting + currentAsset string +} + +// New creates an empty RunState backed by store. +func New(store Store) *RunState { + return &RunState{ + store: store, + balances: make(map[PairKey]*big.Int), + } +} + +// SetCurrentAsset sets the asset used when an operation omits one. +func (s *RunState) SetCurrentAsset(asset string) { + s.currentAsset = asset +} + +// Reset clears all per-execution state — the balance cache, the source queue, +// the postings, and the current asset — and rebinds the store, while retaining +// the underlying map/slice capacity. This lets a single RunState be reused +// across executions without reallocating its containers (the balances map and +// the sources/postings slices keep their backing storage). +// +// Note: GetPostings returns deep copies, so a result obtained before Reset stays +// valid afterward. +func (s *RunState) Reset(store Store) { + s.store = store + clear(s.balances) + s.sources = s.sources[:0] + s.postings = s.postings[:0] + s.currentAsset = "" +} + +// Prewarm seeds the balance cache with balances fetched in bulk, so runtime's +// lazy per-key Store.GetBalance path is never hit for them. This lets a caller +// keep a single batched balance round-trip (e.g. the interpreter's pre-pass that +// collects every needed (account, asset, color) and fetches them in one query) +// instead of paying one Store call per triple. +// +// Call it once, before any Pull/Send/Save/ForcePosting. Amounts are cloned, so +// the caller may reuse them. A key that is already cached is left untouched (the +// live value wins), so a stray double-call can never clobber computed state. +func (s *RunState) Prewarm(balances map[PairKey]*big.Int) { + for key, amount := range balances { + if _, ok := s.balances[key]; ok { + continue + } + cloned := new(big.Int) + if amount != nil { + cloned.Set(amount) + } + s.balances[key] = cloned + } +} + +// Has reports whether (account, asset, color) is already in the balance cache +// (prewarmed or touched). Lets a caller skip re-fetching balances it already +// holds, without triggering a Store load. +func (s *RunState) Has(account, asset, color string) bool { + _, ok := s.balances[PairKey{account, asset, color}] + return ok +} + +// AccountBalance is a single cached (asset, color, amount) entry for an account. +type AccountBalance struct { + Asset string + Color string + Amount *big.Int +} + +// AccountBalances returns copies of every cached balance entry for account. It +// only reports entries already in the cache (it does not consult the Store), so +// an account that was never prewarmed/touched yields an empty slice. Used by +// asset scaling, which must enumerate an account's holdings across scales. +func (s *RunState) AccountBalances(account string) []AccountBalance { + var out []AccountBalance + for key, amount := range s.balances { + if key.Account == account { + out = append(out, AccountBalance{ + Asset: key.Asset, + Color: key.Color, + Amount: new(big.Int).Set(amount), + }) + } + } + return out +} + +// GetAccountBalance returns the balance for (account, asset, color). An empty +// asset means "use currentAsset" (the OCaml ?asset default). The value is +// fetched from the Store on first access and cached thereafter. The returned +// *big.Int is a fresh copy: callers may keep or mutate it freely without +// affecting runtime state. +// +// Note: "" is the unset sentinel for asset, consistent with currentAsset +// starting as "". A real asset must never be the empty string. For color, "" +// is a legitimate value meaning "uncolored". +func (s *RunState) GetAccountBalance(account, asset, color string) *big.Int { + if asset == "" { + asset = s.currentAsset + } + return new(big.Int).Set(s.cachedBalance(account, asset, color)) +} + +// Pull mirrors the OCaml `pull`. It debits up to cap from src's (currentAsset, +// color) balance (clamped to non-negative), honoring the overdraft policy, +// queues the pulled amount as a funding source tagged with color, and writes the +// amount made available into out. The overdraft bound is an optional *big.Int +// (the OCaml `int64 option`): +// +// overdraft == nil -> unbounded: available = max(0, cap) +// overdraft == b -> available = min(max(0, balance + max(0,b)), max(0, cap)) +// (pass big.NewInt(0) for the "balance only" default) +// +// The result is written into the caller-provided out (overwritten), avoiding a +// return allocation; out may be any addressable *big.Int (e.g. a VM register). +// Inputs cap and overdraft are not mutated. The only allocation per call is the +// queued source's own copy of the amount (it must outlive out and is mutated in +// place by compactAt/Send); the balance is debited in place on the cached value. +func (s *RunState) Pull(out *big.Int, src string, cap *big.Int, overdraft *big.Int, color string) { + currentBal := s.cachedBalance(src, s.currentAsset, color) + + if overdraft == nil { + out.Set(cap) // unbounded; clamped to >= 0 below + } else { + // eff = max(0, currentBal + max(0, overdraft)) + out.Set(currentBal) + if overdraft.Sign() > 0 { + out.Add(out, overdraft) + } + if out.Sign() < 0 { + out.SetInt64(0) + } + // available = min(eff, cap); a cap < eff (incl. negative) wins here and + // is clamped to >= 0 below + if cap.Cmp(out) < 0 { + out.Set(cap) + } + } + if out.Sign() < 0 { + out.SetInt64(0) + } + + // queue the pulled funds — an independent copy (out stays the caller's; the + // queued amount is mutated in place by compactAt/Send) + amt := new(big.Int).Set(out) + s.sources = append(s.sources, source{src, amt, color}) + + // debit the source balance in place; the cache keeps the same *big.Int + currentBal.Sub(currentBal, out) +} + +// PullUncapped mirrors the OCaml `pull_uncapped`: makes available +// max(0, balance + overdraftBound) of src's (currentAsset, color) balance, +// queuing it only when positive, and writes the available amount into out. +// +// Like Pull, the result is written into the caller-provided out (no return +// allocation; out may be any addressable *big.Int). overdraftBound is not +// mutated. When the available amount is positive it costs one allocation (the +// queued source's own copy) and debits the balance in place; when it is zero +// nothing is queued, nothing is debited, and no allocation occurs. +func (s *RunState) PullUncapped(out *big.Int, src string, overdraftBound *big.Int, color string) { + currentBal := s.cachedBalance(src, s.currentAsset, color) + + // available = max(0, currentBal + overdraftBound) + out.Add(currentBal, overdraftBound) + if out.Sign() < 0 { + out.SetInt64(0) + } + + if out.Sign() > 0 { + amt := new(big.Int).Set(out) + s.sources = append(s.sources, source{src, amt, color}) + currentBal.Sub(currentBal, out) // debit in place; cache keeps the pointer + } +} + +// Send mirrors the OCaml `send`, extended with a color filter. It drains queued +// funding sources in FIFO order until cap is satisfied or eligible sources run +// out, and each emitted posting carries the *consumed source's* own color. +// +// The color filter selects which sources are eligible: +// +// color == nil -> match anything (fundsQueue.PullAnything); a single drain +// may consume and emit funds of several colors at once. This +// is the mode the interpreter's destinations use. +// color != nil -> only sources whose color == *color are consumed; others +// are skipped and left in place (fundsQueue.PullColored / +// PullUncolored, with *color == "" meaning uncolored). +// +// dest == nil is the "keep/refund" path: the source is credited back and no +// posting is emitted. A partially consumed source's remainder stays at its +// position. +func (s *RunState) Send(dest *string, cap *big.Int, color *string) { + cap = new(big.Int).Set(cap) // clone: we decrement it as sources are consumed + asset := s.currentAsset + i := 0 + for cap.Sign() > 0 && i < len(s.sources) { + s.compactAt(i) // merge the run of adjacent same-(account,color) funds at i + src := s.sources[i] + if color != nil && src.color != *color { + i++ // filtered out: skip, leave in place + continue + } + if src.amount.Cmp(cap) >= 0 { + s.credit(dest, src, asset, cap) + if diff := new(big.Int).Sub(src.amount, cap); diff.Sign() > 0 { + s.sources[i].amount = diff // remainder stays at this position + } else { + s.removeAt(i) + } + return // cap fully satisfied + } + s.credit(dest, src, asset, src.amount) + cap.Sub(cap, src.amount) + s.removeAt(i) // do not advance i; the next source shifts into position i + } +} + +// SendUncapped mirrors the OCaml `send_uncapped`, extended with the same color +// filter as Send: color == nil drains every queued source (each posting keeping +// its own color); color != nil drains only matching ones, leaving others in +// place. +func (s *RunState) SendUncapped(dest *string, color *string) { + asset := s.currentAsset + i := 0 + for i < len(s.sources) { + s.compactAt(i) // merge the run of adjacent same-(account,color) funds at i + src := s.sources[i] + if color != nil && src.color != *color { + i++ // filtered out: skip, leave in place + continue + } + s.credit(dest, src, asset, src.amount) + s.removeAt(i) + } +} + +// ForcePosting records a direct movement of amount (of asset/color) from src to +// dst, bypassing the funding queue: it debits src, credits dst, and appends the +// posting. It is for movements the queue does not model — e.g. asset-scaling +// conversions (interpreter.forcePushPostingUncolored). Unlike Send it uses the +// explicit asset argument, which may differ from the current asset (a scaled +// asset). A non-positive amount is a no-op. PRE: the caller has already checked +// invariants (e.g. amount sign); no balance sufficiency check is performed. +func (s *RunState) ForcePosting(src, dst, asset, color string, amount *big.Int) { + if amount.Sign() <= 0 { + return + } + s.addToBalance(src, asset, color, new(big.Int).Neg(amount)) + s.addPosting(src, dst, asset, color, amount) // appends the posting and credits dst +} + +// Save mirrors the numscript `save` statement: it protects funds from being +// pulled later by reducing the (account, asset, color) balance, floored at zero. +// +// amount != nil -> balance = max(0, balance - amount) (PRE: amount >= 0) +// amount == nil -> "save all": a positive balance becomes 0; a negative +// balance is left unchanged (= min(balance, 0)) +func (s *RunState) Save(account, asset, color string, amount *big.Int) { + cur := s.cachedBalance(account, asset, color) + var next *big.Int + if amount == nil { + if cur.Sign() <= 0 { + return // negative/zero balance left unchanged + } + next = new(big.Int) // floor positive to zero + } else { + next = new(big.Int).Sub(cur, amount) + if next.Sign() < 0 { + next.SetInt64(0) + } + } + s.balances[PairKey{account, asset, color}] = next +} + +// Snapshot returns a cheap marker of the current source-queue depth, for +// backtracking a speculative source evaluation (e.g. a `oneof` branch). It is +// just the queue length: O(1), no allocation, no map cloning. +func (s *RunState) Snapshot() int { + return len(s.sources) +} + +// Restore undoes every Pull/PullUncapped performed since the matching Snapshot: +// it repays each source queued after the mark back to the (account, color) +// balance it was debited from, then truncates the queue to the mark. Balances +// are restored exactly without cloning maps — repaying the queued amounts is the +// exact inverse of the debits Pull made. +// +// PRECONDITION: nothing queued after the mark has been sent, and the current +// asset is unchanged since the Snapshot. Both hold during source evaluation, +// which is the only place backtracking happens — Send runs later, in the +// destination phase. (compactAt may have folded same-(account,color) funds, but +// the fold preserves both per the merge key, so the repay still lands correctly.) +func (s *RunState) Restore(mark int) { + for i := mark; i < len(s.sources); i++ { + src := s.sources[i] + s.addToBalance(src.account, s.currentAsset, src.color, src.amount) + } + s.sources = s.sources[:mark] +} + +// GetPostings returns a copy of the recorded postings: a fresh slice, so callers +// cannot alter the internal queue's length/order. Posting amounts are write-once +// (addPosting appends a freshly-cloned Amount and never mutates an existing +// posting), so the *big.Int values are shared rather than deep-cloned — safe, +// and it avoids an allocation per posting. +func (s *RunState) GetPostings() []Posting { + out := make([]Posting, len(s.postings)) + copy(out, s.postings) + return out +} + +// --- internal helpers --- + +// credit routes a consumed source amount either into a posting (dest != nil) or +// back to the source as a refund (dest == nil). The funds keep their color, so +// both the posting and the destination/source balance land on (asset, color). +// amount is treated as read-only. +func (s *RunState) credit(dest *string, src source, asset string, amount *big.Int) { + if dest != nil { + s.addPosting(src.account, *dest, asset, src.color, amount) + } else if amount.Sign() > 0 { + // refund the source: consume funding, emit no posting + s.addToBalance(src.account, asset, src.color, amount) + } +} + +// cachedBalance returns the cached balance for (account, asset, color), fetching +// from the Store and caching on first access. Presence in the map distinguishes +// "already fetched (possibly 0)" from "not yet fetched". The Store's value is +// cloned on ingest so runtime never mutates a pointer the Store owns. The +// returned pointer is the live cache entry — internal callers must not mutate it +// in place; they replace the map entry with a freshly allocated value instead. +func (s *RunState) cachedBalance(account, asset, color string) *big.Int { + key := PairKey{account, asset, color} + if v, ok := s.balances[key]; ok { + return v + } + fromStore := s.store.GetBalance(account, asset, color) + cached := new(big.Int) + if fromStore != nil { + cached.Set(fromStore) + } + s.balances[key] = cached + return cached +} + +// addToBalance applies delta to (account, asset, color), loading the base value +// through the cache first so an un-fetched account is not treated as 0. The +// cached value is mutated in place (no realloc): it is runtime-owned and never +// aliased externally — GetAccountBalance hands out copies — so this is safe, and +// it mirrors Pull's in-place debit. delta is read-only. +func (s *RunState) addToBalance(account, asset, color string, delta *big.Int) { + cur := s.cachedBalance(account, asset, color) + cur.Add(cur, delta) +} + +// addPosting appends a posting verbatim and credits the destination balance. +// Non-positive amounts are ignored. Postings are never merged here: same-source +// funds are instead coalesced upstream in the source queue by compactAt, so a +// posting can only ever fuse adjacent funds *within* one drain — never across +// separate sends. This mirrors the interpreter's fundsQueue, which merges in the +// queue (compactTop), not in the posting list. amount is cloned into the posting. +func (s *RunState) addPosting(src, dst, asset, color string, amount *big.Int) { + if amount.Sign() <= 0 { + return + } + s.postings = append(s.postings, Posting{ + Source: src, + Destination: dst, + Asset: asset, + Color: color, + Amount: new(big.Int).Set(amount), + }) + s.addToBalance(dst, asset, color, amount) +} + +// compactAt coalesces the maximal run of funds at index i that share i's +// (account, color), folding each into s.sources[i], and drops any zero-amount +// entries it passes. This is the slice analogue of fundsQueue.compactTop: it +// merges adjacent same-source funds in the queue before they are drained, so +// one drain over them yields a single posting. Because it operates on the queue +// (which each send fully consumes) and never on the posting list, it cannot fuse +// funds belonging to different sends. The fold mutates s.sources[i].amount in +// place, which is safe because queued amounts are privately owned. +func (s *RunState) compactAt(i int) { + for i+1 < len(s.sources) { + next := s.sources[i+1] + if next.amount.Sign() == 0 { + s.removeAt(i + 1) + continue + } + if next.account != s.sources[i].account || next.color != s.sources[i].color { + return + } + s.sources[i].amount.Add(s.sources[i].amount, next.amount) + s.removeAt(i + 1) + } +} + +// removeAt deletes the source at index i, preserving the order of the rest. +func (s *RunState) removeAt(i int) { + s.sources = append(s.sources[:i], s.sources[i+1:]...) +} + diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 00000000..e21cb47a --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,852 @@ +package runtime_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" +) + +// --- test helpers --------------------------------------------------------- + +// mockStore is a Store that returns preset balances and counts how many times +// each (account, asset, color) triple is fetched, so tests can assert +// lazy/cached reads. +type mockStore struct { + balances map[runtime.PairKey]*big.Int + calls map[runtime.PairKey]int +} + +func newMockStore(initial map[runtime.PairKey]int64) *mockStore { + b := make(map[runtime.PairKey]*big.Int, len(initial)) + for k, v := range initial { + b[k] = big.NewInt(v) + } + return &mockStore{balances: b, calls: make(map[runtime.PairKey]int)} +} + +func (m *mockStore) GetBalance(account, asset, color string) *big.Int { + k := runtime.PairKey{account, asset, color} + m.calls[k]++ + if v, ok := m.balances[k]; ok { + return v + } + return new(big.Int) // 0 if absent +} + +func (m *mockStore) callCount(account, asset string) int { + return m.calls[runtime.PairKey{account, asset, ""}] +} + +const usd = "USD" + +func newRS(initial map[runtime.PairKey]int64) (*runtime.RunState, *mockStore) { + store := newMockStore(initial) + rs := runtime.New(store) + rs.SetCurrentAsset(usd) + return rs, store +} + +func strptr(s string) *string { return &s } + +// pull adapts the out-param Pull to a value-returning form for test ergonomics. +func pull(rs *runtime.RunState, src string, cap, overdraft *big.Int, color string) *big.Int { + out := new(big.Int) + rs.Pull(out, src, cap, overdraft, color) + return out +} + +// pullUncapped adapts the out-param PullUncapped to a value-returning form. +func pullUncapped(rs *runtime.RunState, src string, overdraftBound *big.Int, color string) *big.Int { + out := new(big.Int) + rs.PullUncapped(out, src, overdraftBound, color) + return out +} + +func wantBalance(t *testing.T, rs *runtime.RunState, account string, want int64) { + t.Helper() + if got := rs.GetAccountBalance(account, usd, ""); got.Cmp(big.NewInt(want)) != 0 { + t.Errorf("balance(%s) = %s, want %d", account, got, want) + } +} + +func wantReturn(t *testing.T, label string, got *big.Int, want int64) { + t.Helper() + if got.Cmp(big.NewInt(want)) != 0 { + t.Errorf("%s = %s, want %d", label, got, want) + } +} + +func wantPostings(t *testing.T, rs *runtime.RunState, want []runtime.Posting) { + t.Helper() + got := rs.GetPostings() + mismatch := len(got) != len(want) + for i := 0; !mismatch && i < len(got); i++ { + g, w := got[i], want[i] + if g.Source != w.Source || g.Destination != w.Destination || + g.Asset != w.Asset || g.Color != w.Color || g.Amount.Cmp(w.Amount) != 0 { + mismatch = true + } + } + if mismatch { + t.Errorf("postings mismatch\n got: %s\nwant: %s", fmtPostings(got), fmtPostings(want)) + } +} + +func fmtPostings(ps []runtime.Posting) string { + out := "[" + for _, p := range ps { + out += "{" + p.Source + "->" + p.Destination + " " + p.Amount.String() + " " + p.Asset + if p.Color != "" { + out += " " + p.Color + } + out += "}" + } + return out + "]" +} + +// --- GetAccountBalance / caching ----------------------------------------- + +func TestGetAccountBalance_FetchesFromStore(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + wantBalance(t, rs, "A", 100) + if store.callCount("A", usd) != 1 { + t.Errorf("expected 1 store fetch, got %d", store.callCount("A", usd)) + } +} + +func TestGetAccountBalance_EmptyAssetUsesCurrent(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 42}) + if got := rs.GetAccountBalance("A", "", ""); got.Cmp(big.NewInt(42)) != 0 { + t.Errorf("got %d, want 42 (empty asset should resolve to currentAsset)", got) + } +} + +func TestGetAccountBalance_MissingIsZeroAndCached(t *testing.T) { + rs, store := newRS(nil) + if got := rs.GetAccountBalance("ghost", usd, ""); got.Cmp(big.NewInt(0)) != 0 { + t.Errorf("missing account = %d, want 0", got) + } + // second read must not re-hit the store even though value is 0 + _ = rs.GetAccountBalance("ghost", usd, "") + if c := store.callCount("ghost", usd); c != 1 { + t.Errorf("zero balance not cached: store called %d times, want 1", c) + } +} + +func TestCaching_FetchedOnlyOnce(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + for i := 0; i < 5; i++ { + rs.GetAccountBalance("A", usd, "") + } + if c := store.callCount("A", usd); c != 1 { + t.Errorf("store called %d times, want 1", c) + } +} + +func TestCaching_WriteThroughCompounds(t *testing.T) { + // Pull decreases the balance; the next read must see the decreased value + // without consulting the store again. + rs, store := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // A -> 70 + wantBalance(t, rs, "A", 70) + pull(rs, "A", big.NewInt(20), big.NewInt(0), "") // A -> 50 + wantBalance(t, rs, "A", 50) + if c := store.callCount("A", usd); c != 1 { + t.Errorf("store consulted %d times across pulls, want 1", c) + } +} + +// --- Pull (bounded) ------------------------------------------------------- + +func TestPull_BoundedClampedByBalance(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(200), big.NewInt(0), "") // min(max(0,100+0),200)=100 + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPull_BoundedClampedByCap(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // min(100,30)=30 + wantReturn(t, "Pull", got, 30) + wantBalance(t, rs, "A", 70) +} + +func TestPull_BoundedWithOverdraftBound(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + // eff = max(0, 100+50) = 150 ; available = min(150, 200) = 150 + got := pull(rs, "A", big.NewInt(200), big.NewInt(50), "") + wantReturn(t, "Pull", got, 150) + wantBalance(t, rs, "A", -50) // overdraft used +} + +func TestPull_NegativeCapClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + got := pull(rs, "A", big.NewInt(-5), big.NewInt(0), "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", 100) +} + +func TestPull_NegativeOverdraftBoundClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + // bound clamped to 0 -> eff = 100 -> available = min(100, 200) = 100 + got := pull(rs, "A", big.NewInt(200), big.NewInt(-1000), "") + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPull_NegativeStoreBalanceBounded(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: -20}) + // eff = max(0, -20+0) = 0 -> available = min(0, cap) = 0 + got := pull(rs, "A", big.NewInt(50), big.NewInt(0), "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", -20) +} + +func TestPull_WritesIntoOutAndDoesNotAliasQueue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + out := new(big.Int) + rs.Pull(out, "A", big.NewInt(60), big.NewInt(0), "") + if out.Cmp(big.NewInt(60)) != 0 { + t.Fatalf("out written = %s, want 60", out) + } + // Mutating out afterwards must not corrupt the queued source (it's a copy). + out.SetInt64(999) + rs.Send(strptr("X"), big.NewInt(60), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(60)}, + }) +} + +func TestPull_OutCanBeReused(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 100}) + out := new(big.Int) + rs.Pull(out, "A", big.NewInt(30), big.NewInt(0), "") + if out.Cmp(big.NewInt(30)) != 0 { + t.Fatalf("first = %s, want 30", out) + } + rs.Pull(out, "B", big.NewInt(45), big.NewInt(0), "") // same buffer + if out.Cmp(big.NewInt(45)) != 0 { + t.Fatalf("second = %s, want 45", out) + } + // both pulls landed in the queue independently + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(45)}, + }) +} + +func TestPull_DoesNotMutateCapOrOverdraft(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 10}) + cap := big.NewInt(200) + ovd := big.NewInt(50) + out := new(big.Int) + rs.Pull(out, "A", cap, ovd, "") // eff = 10+50 = 60 < 200 -> available 60 + if out.Cmp(big.NewInt(60)) != 0 { + t.Errorf("available = %s, want 60", out) + } + if cap.Cmp(big.NewInt(200)) != 0 { + t.Errorf("cap mutated: %s", cap) + } + if ovd.Cmp(big.NewInt(50)) != 0 { + t.Errorf("overdraft mutated: %s", ovd) + } +} + +// --- Pull (unbounded) ----------------------------------------------------- + +func TestPull_UnboundedTakesFullCap(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 30}) + got := pull(rs, "A", big.NewInt(100), nil, "") + wantReturn(t, "Pull", got, 100) + wantBalance(t, rs, "A", -70) // balance can go negative +} + +func TestPull_UnboundedNegativeCapClampedToZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 30}) + got := pull(rs, "A", big.NewInt(-10), nil, "") + wantReturn(t, "Pull", got, 0) + wantBalance(t, rs, "A", 30) +} + +// --- PullUncapped --------------------------------------------------------- + +func TestPullUncapped_Basic(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + got := pullUncapped(rs, "A", big.NewInt(0), "") + wantReturn(t, "PullUncapped", got, 100) + wantBalance(t, rs, "A", 0) +} + +func TestPullUncapped_WithOverdraft(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + got := pullUncapped(rs, "A", big.NewInt(50), "") + wantReturn(t, "PullUncapped", got, 150) + wantBalance(t, rs, "A", -50) +} + +func TestPullUncapped_WritesIntoOutAndDoesNotAliasQueue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + out := new(big.Int) + rs.PullUncapped(out, "A", big.NewInt(0), "") + if out.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("out = %s, want 100", out) + } + out.SetInt64(999) // mutate after: queued source must be an independent copy + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + }) +} + +func TestPullUncapped_ZeroNotQueuedNorDebited(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 0}) + got := pullUncapped(rs, "A", big.NewInt(0), "") + wantReturn(t, "PullUncapped", got, 0) + wantBalance(t, rs, "A", 0) + // nothing queued -> a subsequent drain produces no postings + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestPullUncapped_NegativeEffectiveNotQueued(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 10}) + got := pullUncapped(rs, "A", big.NewInt(-50), "") // max(0, 10-50) = 0 + wantReturn(t, "PullUncapped", got, 0) + wantBalance(t, rs, "A", 10) + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Send: FIFO, partial requeue, posting creation ----------------------- + +func TestSend_PartialConsumeRequeuesFront(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // source A:100 + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") // source B:50 + + rs.Send(strptr("X"), big.NewInt(30), nil) // takes 30 from A, requeues A:70 at front + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}}) + + rs.Send(strptr("Y"), big.NewInt(200), nil) // A:70 then B:50, both fully + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(30)}, + {Source: "A", Destination: "Y", Asset: usd, Amount: big.NewInt(70)}, + {Source: "B", Destination: "Y", Asset: usd, Amount: big.NewInt(50)}, + }) +} + +func TestSend_FIFOOrder(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 10, {"B", usd, ""}: 10, {"C", usd, ""}: 10}) + pull(rs, "A", big.NewInt(10), big.NewInt(0), "") + pull(rs, "B", big.NewInt(10), big.NewInt(0), "") + pull(rs, "C", big.NewInt(10), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(30), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + {Source: "C", Destination: "X", Asset: usd, Amount: big.NewInt(10)}, + }) +} + +func TestSend_ExactMatchNoRequeue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 50}) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(50), nil) // exact + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(50)}}) + // nothing left + rs.SendUncapped(strptr("Y"), nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(50)}}) +} + +func TestSend_CapExceedsAvailableDrains(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(500), nil) // more than available; drains 100, no leftover + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_ZeroCapIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(0), nil) + wantPostings(t, rs, []runtime.Posting{}) + // source remains -> uncapped drain still sees it + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_NegativeCapIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(-5), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestSend_NoSourcesIsNoOp(t *testing.T) { + rs, _ := newRS(nil) + rs.Send(strptr("X"), big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Send: posting merge -------------------------------------------------- + +func TestSend_MergesWithinSingleDrain(t *testing.T) { + // Two same-source funds drained by ONE Send to the same destination merge + // into a single posting (mirrors fundsQueue.compactTop within one Pull). + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(60), big.NewInt(0), "") // source A:60 + pull(rs, "A", big.NewInt(40), big.NewInt(0), "") // source A:40 + rs.Send(strptr("X"), big.NewInt(100), nil) // drains both A:60 then A:40 -> one posting + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}}) +} + +func TestSend_DoesNotMergeAcrossSeparateSends(t *testing.T) { + // Two separate Send calls, same src->dst->asset, are NOT merged. This + // matches the interpreter (fundsQueue), which only merges adjacent funds + // within a single Pull, never across send statements. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(40), nil) // posting A->X 40, requeue A:60 + rs.Send(strptr("X"), big.NewInt(40), nil) // separate send: NOT merged, requeue A:20 + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +func TestSend_DoesNotMergeDifferentDestination(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(40), nil) + rs.Send(strptr("Y"), big.NewInt(40), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + {Source: "A", Destination: "Y", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +// --- Send: destination balance credit (the cache-bug fix) ----------------- + +func TestSend_CreditsDestinationOverExistingStoreBalance(t *testing.T) { + // X already has 500 in the store. Crediting must fetch that first, not + // treat X as 0. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"X", usd, ""}: 500}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(100), nil) + wantBalance(t, rs, "X", 600) +} + +// --- Send: refund path (dest == nil) ------------------------------------- + +func TestSend_RefundCreditsSourceNoPosting(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // A -> 0, source A:100 + rs.Send(nil, big.NewInt(60), nil) // refund 60 to A, requeue A:40 + wantBalance(t, rs, "A", 60) + wantPostings(t, rs, []runtime.Posting{}) + // remaining 40 still queued + rs.Send(strptr("X"), big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{{Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}}) +} + +// --- SendUncapped --------------------------------------------------------- + +func TestSendUncapped_DrainsAllToDestination(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(50)}, + }) +} + +func TestSendUncapped_RefundsAll(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 50}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") // A -> 0 + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") // B -> 0 + rs.SendUncapped(nil, nil) // refund both + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "B", 50) + wantPostings(t, rs, []runtime.Posting{}) +} + +func TestSendUncapped_NoSourcesIsNoOp(t *testing.T) { + rs, _ := newRS(nil) + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- GetPostings returns a defensive copy -------------------------------- + +func TestGetPostings_ReturnsCopy(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + rs.Send(strptr("X"), big.NewInt(100), nil) + + p := rs.GetPostings() + if len(p) != 1 { + t.Fatalf("expected 1 posting, got %d", len(p)) + } + p[0].Amount = big.NewInt(999999) // mutate the returned slice + + p2 := rs.GetPostings() + if p2[0].Amount.Cmp(big.NewInt(100)) != 0 { + t.Errorf("internal posting was mutated via returned slice: amount=%d", p2[0].Amount) + } +} + +// --- big.Int precision (beyond int64) ------------------------------------ + +func TestBigInt_AmountsBeyondInt64(t *testing.T) { + // 10^30 is far beyond int64's ~9.2*10^18 ceiling; the whole pipeline + // (store -> Pull -> Send -> posting + balances) must carry it losslessly. + huge, _ := new(big.Int).SetString("1000000000000000000000000000000", 10) // 1e30 + store := newMockStore(nil) + store.balances[runtime.PairKey{"A", usd, ""}] = new(big.Int).Set(huge) + rs := runtime.New(store) + rs.SetCurrentAsset(usd) + + got := pull(rs, "A", new(big.Int).Set(huge), big.NewInt(0), "") + if got.Cmp(huge) != 0 { + t.Fatalf("Pull returned %s, want %s", got, huge) + } + rs.Send(strptr("X"), new(big.Int).Set(huge), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: new(big.Int).Set(huge)}, + }) + if bal := rs.GetAccountBalance("X", usd, ""); bal.Cmp(huge) != 0 { + t.Errorf("X balance = %s, want %s", bal, huge) + } + if bal := rs.GetAccountBalance("A", usd, ""); bal.Sign() != 0 { + t.Errorf("A balance = %s, want 0", bal) + } +} + +func TestBigInt_GetAccountBalanceReturnsCopy(t *testing.T) { + // Mutating the returned balance must not corrupt the cache. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + b := rs.GetAccountBalance("A", usd, "") + b.SetInt64(999999) + wantBalance(t, rs, "A", 100) +} + +// --- Prewarm (batched balance seeding) ----------------------------------- + +func TestPrewarm_SeedsCacheAndSkipsStore(t *testing.T) { + rs, store := newRS(nil) // store has nothing + rs.Prewarm(map[runtime.PairKey]*big.Int{ + {"A", usd, ""}: big.NewInt(100), + {"B", usd, "red"}: big.NewInt(40), + }) + wantBalance(t, rs, "A", 100) + if b := rs.GetAccountBalance("B", usd, "red"); b.Cmp(big.NewInt(40)) != 0 { + t.Errorf("B red = %s, want 40", b) + } + // nothing was fetched lazily — the batch seed covered it + if c := store.callCount("A", usd); c != 0 { + t.Errorf("store consulted %d times for A, want 0", c) + } +} + +func TestPrewarm_ClonesValues(t *testing.T) { + rs, _ := newRS(nil) + seed := big.NewInt(100) + rs.Prewarm(map[runtime.PairKey]*big.Int{{"A", usd, ""}: seed}) + seed.SetInt64(999) // mutate caller's value after seeding + wantBalance(t, rs, "A", 100) +} + +func TestPrewarm_DoesNotClobberLiveValue(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + pull(rs, "A", big.NewInt(30), big.NewInt(0), "") // A -> 70 + rs.Prewarm(map[runtime.PairKey]*big.Int{{"A", usd, ""}: big.NewInt(100)}) // must NOT reset to 100 + wantBalance(t, rs, "A", 70) +} + +// --- ForcePosting (direct src->dst, bypassing the queue) ----------------- + +func TestForcePosting_DebitsSourceCreditsDestAndRecords(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 10}) + rs.ForcePosting("A", "B", usd, "", big.NewInt(30)) + wantBalance(t, rs, "A", 70) + wantBalance(t, rs, "B", 40) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "B", Asset: usd, Amount: big.NewInt(30)}, + }) +} + +func TestForcePosting_UsesExplicitAssetNotCurrent(t *testing.T) { + // asset-scaling emits postings on a scaled asset, distinct from currentAsset. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "USD/2", ""}: 500}) + rs.SetCurrentAsset(usd) // current asset is USD, but we post on USD/2 + rs.ForcePosting("A", "B", "USD/2", "", big.NewInt(500)) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "B", Asset: "USD/2", Amount: big.NewInt(500)}, + }) + if b := rs.GetAccountBalance("A", "USD/2", ""); b.Sign() != 0 { + t.Errorf("A USD/2 = %s, want 0", b) + } +} + +func TestForcePosting_ZeroIsNoOp(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + rs.ForcePosting("A", "B", usd, "", big.NewInt(0)) + wantBalance(t, rs, "A", 100) + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- Save (numscript `save` statement) ----------------------------------- + +func TestSave_ReducesByAmount(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + rs.Save("A", usd, "", big.NewInt(30)) + wantBalance(t, rs, "A", 70) +} + +func TestSave_FlooredAtZero(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 20}) + rs.Save("A", usd, "", big.NewInt(50)) // would be -30, floored to 0 + wantBalance(t, rs, "A", 0) +} + +func TestSave_AllZeroesPositiveBalance(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 80}) + rs.Save("A", usd, "", nil) // save all + wantBalance(t, rs, "A", 0) +} + +func TestSave_AllLeavesNegativeUntouched(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: -40}) + rs.Save("A", usd, "", nil) + wantBalance(t, rs, "A", -40) +} + +func TestSave_ThenPullSeesProtectedBalance(t *testing.T) { + // after saving, a bounded Pull can only take what's left + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100}) + rs.Save("A", usd, "", big.NewInt(70)) // A -> 30 available + got := pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "Pull", got, 30) + wantBalance(t, rs, "A", 0) +} + +// --- Snapshot / Restore (cheap oneof backtracking) ----------------------- + +func TestSnapshotRestore_UndoesPullsAndBalances(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 80}) + + mark := rs.Snapshot() // == 0 + pull(rs, "A", big.NewInt(60), big.NewInt(0), "") + pull(rs, "B", big.NewInt(50), big.NewInt(0), "") + // balances debited, two sources queued + wantBalance(t, rs, "A", 40) + wantBalance(t, rs, "B", 30) + + rs.Restore(mark) + // balances repaid, queue emptied + wantBalance(t, rs, "A", 100) + wantBalance(t, rs, "B", 80) + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{}) // nothing left to send +} + +func TestSnapshotRestore_OneofFailedBranchThenRealBranch(t *testing.T) { + // Models `oneof`: try branch 1 (snapshot, pull, falls short -> restore), + // then commit branch 2 from the restored state. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 30, {"B", usd, ""}: 100}) + + // branch 1: @A can only provide 30 of the needed 100 -> abandon + mark := rs.Snapshot() + got := pull(rs, "A", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "branch1 pull", got, 30) // short + rs.Restore(mark) + wantBalance(t, rs, "A", 30) // A untouched after backtrack + + // branch 2: @B covers it + got = pull(rs, "B", big.NewInt(100), big.NewInt(0), "") + wantReturn(t, "branch2 pull", got, 100) + rs.Send(strptr("X"), big.NewInt(100), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "B", Destination: "X", Asset: usd, Amount: big.NewInt(100)}, + }) + wantBalance(t, rs, "A", 30) + wantBalance(t, rs, "B", 0) +} + +func TestSnapshotRestore_PartialMarkKeepsEarlierSources(t *testing.T) { + // A snapshot taken mid-stream must only undo what came after it. + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, ""}: 100, {"B", usd, ""}: 100}) + pull(rs, "A", big.NewInt(40), big.NewInt(0), "") // kept + + mark := rs.Snapshot() + pull(rs, "B", big.NewInt(70), big.NewInt(0), "") // undone + rs.Restore(mark) + + wantBalance(t, rs, "A", 60) // still debited + wantBalance(t, rs, "B", 100) // repaid + rs.SendUncapped(strptr("X"), nil) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Amount: big.NewInt(40)}, + }) +} + +// --- color ---------------------------------------------------------------- + +func TestColor_BalancesTrackedSeparatelyPerColor(t *testing.T) { + // Same account+asset, two colors: each (account, asset, color) is its own + // balance slot, fetched from the store independently. + rs, store := newRS(map[runtime.PairKey]int64{ + {"A", usd, "red"}: 100, + {"A", usd, "blue"}: 40, + }) + if got := rs.GetAccountBalance("A", usd, "red"); got.Cmp(big.NewInt(100)) != 0 { + t.Errorf("red balance = %d, want 100", got) + } + if got := rs.GetAccountBalance("A", usd, "blue"); got.Cmp(big.NewInt(40)) != 0 { + t.Errorf("blue balance = %d, want 40", got) + } + // uncolored slot is independent and absent -> 0 + if got := rs.GetAccountBalance("A", usd, ""); got.Cmp(big.NewInt(0)) != 0 { + t.Errorf("uncolored balance = %d, want 0", got) + } + if c := store.calls[runtime.PairKey{"A", usd, "red"}]; c != 1 { + t.Errorf("red fetched %d times, want 1", c) + } +} + +func TestColor_PullTagsSourceAndPostingCarriesColor(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, "red"}: 100}) + pull(rs, "A", big.NewInt(60), big.NewInt(0), "red") + rs.Send(strptr("X"), big.NewInt(60), strptr("red")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(60)}, + }) + // destination credited on the colored slot, source debited on it + if got := rs.GetAccountBalance("X", usd, "red"); got.Cmp(big.NewInt(60)) != 0 { + t.Errorf("X red = %d, want 60", got) + } + if got := rs.GetAccountBalance("A", usd, "red"); got.Cmp(big.NewInt(40)) != 0 { + t.Errorf("A red = %d, want 40", got) + } +} + +func TestColor_SendSkipsNonMatchingColorLeavingItQueued(t *testing.T) { + // Queue order: red, blue, red. A red Send must drain the two red sources + // (skipping blue, leaving it queued), exactly like fundsQueue.Pull's + // color-skip. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", usd, "red"}: 50, + {"B", usd, "blue"}: 30, + {"C", usd, "red"}: 40, + }) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "red") + pull(rs, "B", big.NewInt(30), big.NewInt(0), "blue") + pull(rs, "C", big.NewInt(40), big.NewInt(0), "red") + + rs.Send(strptr("X"), big.NewInt(100), strptr("red")) // only 90 red available; blue stays put + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "C", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + }) + + // the skipped blue source is still queued and drains on a blue send + rs.Send(strptr("Y"), big.NewInt(100), strptr("blue")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "C", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + {Source: "B", Destination: "Y", Asset: usd, Color: "blue", Amount: big.NewInt(30)}, + }) +} + +func TestColor_SendDoesNotMergeAcrossColors(t *testing.T) { + // Same src->dst->asset but different colors are distinct postings even + // within consecutive drains. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", usd, "red"}: 40, + {"A", usd, "blue"}: 40, + }) + pull(rs, "A", big.NewInt(40), big.NewInt(0), "red") + pull(rs, "A", big.NewInt(40), big.NewInt(0), "blue") + rs.SendUncapped(strptr("X"), strptr("red")) + rs.SendUncapped(strptr("X"), strptr("blue")) + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(40)}, + {Source: "A", Destination: "X", Asset: usd, Color: "blue", Amount: big.NewInt(40)}, + }) +} + +func TestColor_MatchAnyDrainsMixedColorsPreservingEach(t *testing.T) { + // This is the mode the interpreter's destinations use (fundsQueue.PullAnything): + // one drain (color == nil) consumes funds of several colors at once, and each + // posting keeps its source fund's own color. + rs, _ := newRS(map[runtime.PairKey]int64{ + {"A", usd, "red"}: 50, + {"B", usd, "blue"}: 30, + {"C", usd, ""}: 20, + }) + pull(rs, "A", big.NewInt(50), big.NewInt(0), "red") + pull(rs, "B", big.NewInt(30), big.NewInt(0), "blue") + pull(rs, "C", big.NewInt(20), big.NewInt(0), "") + + rs.Send(strptr("X"), big.NewInt(100), nil) // nil = match anything + wantPostings(t, rs, []runtime.Posting{ + {Source: "A", Destination: "X", Asset: usd, Color: "red", Amount: big.NewInt(50)}, + {Source: "B", Destination: "X", Asset: usd, Color: "blue", Amount: big.NewInt(30)}, + {Source: "C", Destination: "X", Asset: usd, Color: "", Amount: big.NewInt(20)}, + }) + // destination credited on each respective color slot + if b := rs.GetAccountBalance("X", usd, "red"); b.Cmp(big.NewInt(50)) != 0 { + t.Errorf("X red = %s, want 50", b) + } + if b := rs.GetAccountBalance("X", usd, "blue"); b.Cmp(big.NewInt(30)) != 0 { + t.Errorf("X blue = %s, want 30", b) + } +} + +func TestColor_RefundUsesSourceColor(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", usd, "red"}: 100}) + pull(rs, "A", big.NewInt(100), big.NewInt(0), "red") // A red -> 0 + rs.Send(nil, big.NewInt(60), strptr("red")) // refund 60 to A's red slot + if got := rs.GetAccountBalance("A", usd, "red"); got.Cmp(big.NewInt(60)) != 0 { + t.Errorf("A red after refund = %d, want 60", got) + } + wantPostings(t, rs, []runtime.Posting{}) +} + +// --- end-to-end flow ------------------------------------------------------ + +func TestEndToEnd_TwoSourcesSplitAcrossDestinations(t *testing.T) { + rs, store := newRS(map[runtime.PairKey]int64{ + {"alice", usd, ""}: 100, + {"bob", usd, ""}: 100, + {"carol", usd, ""}: 0, + {"dave", usd, ""}: 0, + }) + pull(rs, "alice", big.NewInt(100), big.NewInt(0), "") + pull(rs, "bob", big.NewInt(100), big.NewInt(0), "") + + rs.Send(strptr("carol"), big.NewInt(150), nil) // alice:100 fully, bob:50 partial (requeue bob:50) + rs.Send(strptr("dave"), big.NewInt(50), nil) // bob:50 fully + + wantPostings(t, rs, []runtime.Posting{ + {Source: "alice", Destination: "carol", Asset: usd, Amount: big.NewInt(100)}, + {Source: "bob", Destination: "carol", Asset: usd, Amount: big.NewInt(50)}, + {Source: "bob", Destination: "dave", Asset: usd, Amount: big.NewInt(50)}, + }) + wantBalance(t, rs, "alice", 0) + wantBalance(t, rs, "bob", 0) + wantBalance(t, rs, "carol", 150) + wantBalance(t, rs, "dave", 50) + + // each account fetched from store exactly once + for _, acct := range []string{"alice", "bob", "carol", "dave"} { + if c := store.callCount(acct, usd); c != 1 { + t.Errorf("%s fetched %d times, want 1", acct, c) + } + } +} diff --git a/internal/vm/execution_err.go b/internal/vm/execution_err.go new file mode 100644 index 00000000..3bf98c0d --- /dev/null +++ b/internal/vm/execution_err.go @@ -0,0 +1,34 @@ +package vm + +import "math/big" + +type ( + ExecutionError interface { + execErr() + } + + MissingFundsError struct { + Asset string + Needed *big.Int + Got *big.Int + } + + AssetMismatchError struct { + Expected string + Got string + } + + InvalidUncappedSource struct { + Account string + } +) + +func (MissingFundsError) execErr() {} +func (AssetMismatchError) execErr() {} +func (InvalidUncappedSource) execErr() {} + +var ( + _ ExecutionError = (*MissingFundsError)(nil) + _ ExecutionError = (*AssetMismatchError)(nil) + _ ExecutionError = (*InvalidUncappedSource)(nil) +) diff --git a/internal/vm/instruction.go b/internal/vm/instruction.go new file mode 100644 index 00000000..a9e85309 --- /dev/null +++ b/internal/vm/instruction.go @@ -0,0 +1,91 @@ +package vm + +import "encoding/binary" + +type Instruction struct { + Opcode byte + A byte + B byte + C byte +} + +// Little endian view of the b and c fields +func (i Instruction) GetBC() uint16 { + return uint16(i.B) | uint16(i.C)<<8 +} + +func NewBC( + opcode Opcode, + a byte, + bc uint16, +) Instruction { + var bcBytes [2]byte + binary.LittleEndian.PutUint16(bcBytes[:], bc) + + return Instruction{ + Opcode: byte(opcode), + A: a, + B: bcBytes[0], + C: bcBytes[1], + } +} + +type Opcode byte + +const ( + // --- misc / state --- + Op_SetCurrentAsset Opcode = iota + + Op_CheckEqCurrentAsset + + // --- variables / constants --- + Op_FetchVariable + + // may split into one opcode per expr_typ later + Op_LoadInt // LoadConst (`Int) -> b_c = const-pool index + Op_LoadStr // LoadConst (`String) -> b_c = const-pool index + + // --- funds --- + Op_CheckEnoughFunds + + // --- PullAccount (cap? × overdraft) --- + + // The most general form: + // account,cap,overdraft,color + // The 0xFF special register means NULL for cap,overdraft and color + Op_PullAccount + + // // cap=None, overdraft=BoundedZero + // Op_PullAccountBoundedZero + // // cap=None, overdraft=Bounded r + // Op_PullAccountOverdraft + // // cap=Some, overdraft=BoundedZero + // Op_PullAccountCap + + // // cap=Some, overdraft=Unbounded + // Op_PullAccountUnboundedOverdraft + + // dest_start,inp_arr_start,inp_arr_size|amt + Op_MkAllotment + + // account?, cap?, color? + Op_SendToAccount + + // --- control flow --- + Op_JmpIfZero // b_c = resolved instruction offset + // note: Label emits no instruction; it only feeds the symbol table at assemble time + + // --- UnaryOp --- + Op_GetAmount + Op_GetAsset + Op_IntCopy + Op_PortionCopy + + // --- BinaryOp --- + Op_MinInt + Op_AddInt + Op_SubInt + Op_SubPortion + Op_MkPortion + Op_MkMonetary +) diff --git a/internal/vm/program.go b/internal/vm/program.go new file mode 100644 index 00000000..64829fce --- /dev/null +++ b/internal/vm/program.go @@ -0,0 +1,167 @@ +package vm + +import ( + "encoding/binary" + "fmt" + "math/big" +) + +type Program struct { + Instructions []Instruction + + StringsPool []string + IntsPool []big.Int +} + +var le = binary.LittleEndian + +func readArr[T any]( + segmentName string, + buf []byte, + idx *int, + + parse func(buf []byte) (T, error), +) (T, error) { + if *idx+8 > len(buf) { + var def_ T + return def_, fmt.Errorf("header truncated at offset %d (while reading %s)", *idx, segmentName) + } + + arrStart := le.Uint32(buf[*idx:]) + *idx += 4 + arrLen := le.Uint32(buf[*idx:]) + *idx += 4 + + arrEnd := uint64(arrStart) + uint64(arrLen) + if arrEnd > uint64(len(buf)) { + var def_ T + return def_, fmt.Errorf("section [%d:%d] exceeds buffer %d (while reading %s)", arrStart, arrEnd, len(buf), segmentName) + } + + return parse(buf[arrStart:arrEnd]) +} + +func id(buf []byte) ([]byte, error) { + return buf, nil +} + +func parseInstructions(buf []byte) ([]Instruction, error) { + // TODO check len is multiple of 4 + instructions := make([]Instruction, len(buf)/4) + for i := range instructions { + off := i * 4 + instructions[i] = Instruction{ + buf[off], + buf[off+1], + buf[off+2], + buf[off+3], + } + } + return instructions, nil +} + +// TODO this is claude-generated: double check +func parseStringsPool(poolBuf []byte, dataSegment []byte) ([]string, error) { + if len(poolBuf)%4 != 0 { + return nil, fmt.Errorf("string pool size %d not a multiple of 4", len(poolBuf)) + } + dsLen := uint64(len(dataSegment)) + n := len(poolBuf) / 4 + out := make([]string, n) + + for i := range n { + offset := uint64(le.Uint32(poolBuf[i*4:])) + + // length prefix + if offset+4 > dsLen { + return nil, fmt.Errorf("string %d: length prefix at %d out of bounds (data %d)", i, offset, dsLen) + } + strLen := uint64(le.Uint32(dataSegment[offset:])) + + start := offset + 4 + end := start + strLen // safe: each operand <= ~4.3e9, sum fits in uint64 + if end > dsLen { + return nil, fmt.Errorf("string %d: body [%d:%d] out of bounds (data %d)", i, start, end, dsLen) + } + + out[i] = string(dataSegment[start:end]) // copies; Program no longer references buf + } + return out, nil +} + +// TODO this is claude-generated: double check +func parseIntsPool(poolBuf []byte, dataSegment []byte) ([]big.Int, error) { + if len(poolBuf)%4 != 0 { + return nil, fmt.Errorf("int pool size %d not a multiple of 4", len(poolBuf)) + } + dsLen := uint64(len(dataSegment)) + n := len(poolBuf) / 4 + out := make([]big.Int, n) + + for i := range n { + offset := uint64(le.Uint32(poolBuf[i*4:])) + + // header: sign byte + u32 magnitude length + if offset+5 > dsLen { + return nil, fmt.Errorf("int %d: header at %d out of bounds (data %d)", i, offset, dsLen) + } + sign := dataSegment[offset] + magLen := uint64(le.Uint32(dataSegment[offset+1:])) + + start := offset + 5 + end := start + magLen + if end > dsLen { + return nil, fmt.Errorf("int %d: magnitude [%d:%d] out of bounds (data %d)", i, start, end, dsLen) + } + + out[i].SetBytes(dataSegment[start:end]) // big-endian, unsigned magnitude + switch sign { + case 0: + // non-negative + case 1: + out[i].Neg(&out[i]) + default: + return nil, fmt.Errorf("int %d: invalid sign byte %d", i, sign) + } + } + return out, nil +} + +func DecodeProgram(buf []byte) (Program, error) { + // 0..4 reserved for magic word + if len(buf) < 4 || string(buf[0:4]) != "NUMB" { + return Program{}, fmt.Errorf("bad magic") + } + + idx := 4 + + instructions, err := readArr("instructions", buf, &idx, parseInstructions) // <- TODO copy into instructions + if err != nil { + return Program{}, err + } + + dataSegment, err := readArr("data segment", buf, &idx, id) + if err != nil { + return Program{}, err + } + + stringsPool, err := readArr("strings pool", buf, &idx, func(buf []byte) ([]string, error) { + return parseStringsPool(buf, dataSegment) + }) + if err != nil { + return Program{}, err + } + + intsPool, err := readArr("ints", buf, &idx, func(buf []byte) ([]big.Int, error) { + return parseIntsPool(buf, dataSegment) + }) + if err != nil { + return Program{}, err + } + + return Program{ + Instructions: instructions, + StringsPool: stringsPool, + IntsPool: intsPool, + }, nil +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go new file mode 100644 index 00000000..89d9ee6a --- /dev/null +++ b/internal/vm/vm.go @@ -0,0 +1,247 @@ +package vm + +import ( + "math/big" + + "github.com/formancehq/numscript/internal/runtime" +) + +type monetary struct { + asset string + amount big.Int +} + +const nilReg byte = 0xFF +const worldAccount = "world" + +type Vm struct { + program Program + runstate *runtime.RunState + + stringsRegs [256]string // asset,string,account + intsRegs [256]big.Int + portionsRegs [256]big.Rat + monetariesRegs [256]monetary +} + +func NewVm( + program Program, +) *Vm { + return &Vm{ + program: program, + } +} + +type Store interface { + GetBalance( + account string, + asset string, + color string, + ) *big.Int +} + +func Exec[S Store]( + vm *Vm, + vars any, + store S, // a generic S should allow monomorphisation of the Store +) ([]runtime.Posting, ExecutionError) { + if vm.runstate == nil { + vm.runstate = runtime.New(store) + } else { + vm.runstate.Reset(store) + } + runstate := vm.runstate + + instrs := vm.program.Instructions + instructionsLen := len(instrs) + + var currentAsset string + pc := 0 + + for pc < instructionsLen { + instr := instrs[pc] + pc++ + + switch Opcode(instr.Opcode) { + // --- Domain-specific ops + case Op_PullAccount: + instrExt := instrs[pc] + pc++ + + account := vm.stringsRegs[instr.B] + + var cap *big.Int + if instr.C != nilReg { + cap = &vm.intsRegs[instr.C] + } + + var overdraft *big.Int + if account != worldAccount && instrExt.A != nilReg { + overdraft = &vm.intsRegs[instrExt.A] + } + + if overdraft == nil && cap == nil { + return nil, InvalidUncappedSource{ + Account: account, + } + } + + var color string + if instrExt.B != nilReg { + color = vm.stringsRegs[instrExt.B] + } + + runstate.Pull( + &vm.intsRegs[instr.A], + account, + cap, + overdraft, + color, + ) + + case Op_SendToAccount: + var dest *string + if instr.A != nilReg { + s := vm.stringsRegs[instr.A] + dest = &s + } + + var cap *big.Int + if instr.B != nilReg { + cap = &vm.intsRegs[instr.B] + } + + var color *string + if instr.C != nilReg { + color = &vm.stringsRegs[instr.C] + } + + if cap == nil { + runstate.SendUncapped(dest, color) + } else { + runstate.Send(dest, cap, color) + } + + case Op_MkAllotment: + instrExt := instrs[pc] + pc++ + + destArrStartReg := vm.intsRegs[instr.A : instr.A+instr.C] + inpArrStartReg := vm.portionsRegs[instr.B : instr.B+instr.C] + + amt := &vm.intsRegs[instrExt.A] + + runtime.MakeAllotment( + destArrStartReg, + amt, + inpArrStartReg, + ) + + case Op_CheckEnoughFunds: + got := &vm.intsRegs[instr.A] + needed := &vm.intsRegs[instr.B] + if got.Cmp(needed) == -1 { + return nil, MissingFundsError{ + Asset: currentAsset, + Got: got, + Needed: needed, + } + } + + case Op_SetCurrentAsset: + currentAsset = vm.stringsRegs[instr.A] + runstate.SetCurrentAsset(currentAsset) + + case Op_CheckEqCurrentAsset: + got := vm.stringsRegs[instr.A] + if got != currentAsset { + return nil, AssetMismatchError{ + Got: got, + Expected: currentAsset, + } + } + + // --- Vars + case Op_FetchVariable: + // TODO we need to check if we'll use FetchVarNumber, FetchVarString, .. + // or if we have a vars table that has this info + panic("TODO fetch vars") + + // --- Jumps + case Op_JmpIfZero: + arg := &vm.intsRegs[instr.A] + if arg.Sign() == 0 { + pc = int(instr.GetBC()) + } + + // --- consts + case Op_LoadInt: + const_ := &vm.program.IntsPool[instr.GetBC()] + vm.intsRegs[instr.A].Set(const_) + + case Op_LoadStr: + const_ := vm.program.StringsPool[instr.GetBC()] + vm.stringsRegs[instr.A] = const_ + + // --- Binary ops + case Op_MinInt: + left := &vm.intsRegs[instr.B] + right := &vm.intsRegs[instr.C] + if left.Cmp(right) == -1 { + vm.intsRegs[instr.A].Set(left) + } else { + vm.intsRegs[instr.A].Set(right) + } + + case Op_AddInt: + left := &vm.intsRegs[instr.B] + right := &vm.intsRegs[instr.C] + vm.intsRegs[instr.A].Add(left, right) + + case Op_SubInt: + left := &vm.intsRegs[instr.B] + right := &vm.intsRegs[instr.C] + vm.intsRegs[instr.A].Sub(left, right) + + case Op_SubPortion: + left := &vm.portionsRegs[instr.B] + right := &vm.portionsRegs[instr.C] + vm.portionsRegs[instr.A].Sub(left, right) + + case Op_MkPortion: + num := &vm.intsRegs[instr.B] + den := &vm.intsRegs[instr.C] + vm.portionsRegs[instr.A].SetFrac(num, den) + + case Op_MkMonetary: + asset := vm.stringsRegs[instr.B] + amt := &vm.intsRegs[instr.C] + + dest := &vm.monetariesRegs[instr.A] + dest.asset = asset + dest.amount.Set(amt) + + // --- Unary ops + case Op_IntCopy: + arg := &vm.intsRegs[instr.B] + vm.intsRegs[instr.A].Set(arg) + + case Op_PortionCopy: + arg := &vm.portionsRegs[instr.B] + vm.portionsRegs[instr.A].Set(arg) + + case Op_GetAsset: + arg := &vm.monetariesRegs[instr.B] + vm.stringsRegs[instr.A] = arg.asset + + case Op_GetAmount: + arg := &vm.monetariesRegs[instr.B] + vm.intsRegs[instr.A].Set(&arg.amount) + + default: + panic("Invalid operation") + } + } + + return runstate.GetPostings(), nil +} diff --git a/internal/vm/vm_test.go b/internal/vm/vm_test.go new file mode 100644 index 00000000..e562ec12 --- /dev/null +++ b/internal/vm/vm_test.go @@ -0,0 +1,136 @@ +package vm + +// White-box test (package vm) so it can build a Program/Vm from struct +// literals. It encodes the "inorder" send example into our low-level +// Instruction stream, with manual (non-optimal, per-bank) register allocation, +// runs Exec, and asserts the resulting postings. +// +// HARNESS ASSUMPTIONS (adjust to your actual API): +// - Program has fields {instructions []Instruction; stringsPool []string; +// intsPool []big.Int}. +// - Instruction has exported {Opcode, A, B, C byte} and GetBC() uint16. +// - nilReg (==0xFF) and worldAccount are package-level identifiers. +// - Vm has a `program Program` and a `runstate *runtime.RunState`. +// - One Store interface, GetBalance(account, asset string) int64, shared by +// the generic Exec constraint and runtime.New. +// +// REQUIRED FIXES for this to PASS (see notes at bottom): SetCurrentAsset must +// propagate to vm.runstate; CheckEnoughFunds comparison is inverted; +// SendToAccount uses invalid `new(value)`. + +import ( + "math/big" + "reflect" + "testing" + + "github.com/formancehq/numscript/internal/runtime" +) + +// --- register allocation: one $rN namespace -> typed banks ---------------- +// +// $r0 "USD/2" -> strings[0] (sUSD) $r6 remaining -> ints[3] (iRem) +// $r1 10 -> ints[0] (iTen) $r7 "s1" -> strings[2] (sS1) +// $r2 monetary -> monetary[0] (mMon) $r8 pulled1 -> ints[4] (iPulled1) +// $r3 asset -> strings[1] (sAsset) $r9 "s2" -> strings[3] (sS2) +// $r4 amount -> ints[1] (iAmount) $r10 pulled2 -> ints[5] (iPulled2) +// $r5 sum=0 -> ints[2] (iSum) $r11 "dest" -> strings[4] (sDest) +// (added) zero overdraft bound -> ints[6] (iZero) -- gives BoundedZero +const ( + sUSD, sAsset, sS1, sS2, sDest = 0, 1, 2, 3, 4 + iTen, iAmount, iSum, iRem, iPulled1 = 0, 1, 2, 3, 4 + iPulled2, iZero = 5, 6 + mMon = 0 +) + +// pool indices +const ( + pUSD, pS1, pS2, pDest = 0, 1, 2, 3 // strings pool + cTen, cZero = 0, 1 // ints pool +) + +func abc(op Opcode, a, b, c byte) Instruction { + return Instruction{Opcode: byte(op), A: a, B: b, C: c} +} + +func bc(op Opcode, a byte, v uint16) Instruction { + return Instruction{Opcode: byte(op), A: a, B: byte(v), C: byte(v >> 8)} +} + +func inorderProgram() Program { + // Index of #inorder_end in the ENCODED stream. Note PullAccount occupies + // two words each, so this is not the count of source lines. + const inorderEnd = 19 + + instrs := []Instruction{ + /* 0 */ bc(Op_LoadStr, sUSD, pUSD), // r0 = load_const("USD/2") + /* 1 */ bc(Op_LoadInt, iTen, cTen), // r1 = load_const(10) + /* 2 */ abc(Op_MkMonetary, mMon, sUSD, iTen), // r2 = mk_monetary(r0, r1) + /* 3 */ abc(Op_GetAsset, sAsset, mMon, 0), // r3 = get_asset(r2) + /* 4 */ abc(Op_SetCurrentAsset, sAsset, 0, 0), // set_current_asset(r3) + /* 5 */ abc(Op_GetAmount, iAmount, mMon, 0), // r4 = get_amount(r2) + /* 6 */ bc(Op_LoadInt, iSum, cZero), // r5 = load_const(0) + /* 7 */ abc(Op_IntCopy, iRem, iAmount, 0), // r6 = int_copy(r4) + /* 8 */ bc(Op_LoadInt, iZero, cZero), // (added) overdraft bound = 0 -> BoundedZero + /* 9 */ bc(Op_LoadStr, sS1, pS1), // r7 = load_const("s1") + /* 10 */ abc(Op_PullAccount, iPulled1, sS1, iRem), // r8 = pull(account r7, cap r6) [word 1] + /* 11 */ abc(0, iZero, nilReg, 0), // ext: overdraft=iZero, color=nil [word 2] + /* 12 */ abc(Op_AddInt, iSum, iSum, iPulled1), // r5 = add_int(r5, r8) + /* 13 */ abc(Op_SubInt, iRem, iRem, iPulled1), // r6 = sub_int(r6, r8) + /* 14 */ bc(Op_JmpIfZero, iRem, inorderEnd), // jmp_if_zero(r6, #inorder_end) + /* 15 */ bc(Op_LoadStr, sS2, pS2), // r9 = load_const("s2") + /* 16 */ abc(Op_PullAccount, iPulled2, sS2, iRem), // r10 = pull(account r9, cap r6) [word 1] + /* 17 */ abc(0, iZero, nilReg, 0), // ext: overdraft=iZero, color=nil [word 2] + /* 18 */ abc(Op_AddInt, iSum, iSum, iPulled2), // r5 = add_int(r5, r10) + /* 19 */ abc(Op_CheckEnoughFunds, iSum, iAmount, 0), // #inorder_end: check_enough_funds(r5, r4) + /* 20 */ bc(Op_LoadStr, sDest, pDest), // r11 = load_const("dest") + /* 21 */ abc(Op_SendToAccount, sDest, nilReg, nilReg), // send_to_account(r11) (no cap, no color) + } + + return Program{ + Instructions: instrs, + StringsPool: []string{"USD/2", "s1", "s2", "dest"}, + IntsPool: []big.Int{*big.NewInt(10), *big.NewInt(0)}, + } +} + +// --- mock store ----------------------------------------------------------- + +type mockStore struct { + bal map[runtime.PairKey]int64 +} + +func (m mockStore) GetBalance(account, asset string, color string) *big.Int { + return big.NewInt(m.bal[runtime.PairKey{Account: account, Asset: asset}]) +} + +var _ Store = (*mockStore)(nil) + +// --- the test ------------------------------------------------------------- + +func TestInorderSend(t *testing.T) { + t.Skip() + + prog := inorderProgram() + + // s1 has 6, s2 has 10; sending 10 USD/2 => s1 gives 6, s2 gives 4. + store := mockStore{bal: map[runtime.PairKey]int64{ + {Account: "s1", Asset: "USD/2"}: 6, + {Account: "s2", Asset: "USD/2"}: 10, + }} + + vm := NewVm(prog) + + got, err := Exec(vm, nil, store) + if err != nil { + t.Fatalf("Exec returned error: %v", err) + } + + + want := []runtime.Posting{ + {Source: "s1", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(6)}, + {Source: "s2", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(4)}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("postings mismatch\n got: %+v\nwant: %+v", got, want) + } +}