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/builtins/builtins.go b/internal/builtins/builtins.go new file mode 100644 index 00000000..c12ef614 --- /dev/null +++ b/internal/builtins/builtins.go @@ -0,0 +1,11 @@ +package builtins + +const ( + SetTxMeta = "set_tx_meta" + SetAccountMeta = "set_account_meta" + Meta = "meta" + Balance = "balance" + Overdraft = "overdraft" + GetAsset = "get_asset" + GetAmount = "get_amount" +) diff --git a/internal/compiler/assemble.go b/internal/compiler/assemble.go new file mode 100644 index 00000000..a9c22ec0 --- /dev/null +++ b/internal/compiler/assemble.go @@ -0,0 +1,723 @@ +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 +} + +// reserveContiguous reserves n consecutive slots (scratch, not bound to any reg) +// and returns the first index. Used for the contiguous arrays Op_MkAllotment +// requires (portionsRegs[B:B+C]). +func (b *regPool) reserveContiguous(n int) (byte, error) { + if b.next+n > maxReg { + return 0, fmt.Errorf("register bank overflow: more than %d registers in one bank (register allocation not implemented yet)", maxReg) + } + start := byte(b.next) + b.next += n + return start, nil +} + +// bindContiguous reserves len(regs) consecutive slots and binds each reg to one, +// so later references to those regs resolve to the contiguous block. Used for +// Op_MkAllotment's output array (intsRegs[A:A+C]), which the following sends read. +func (b *regPool) bindContiguous(regs []reg) (byte, error) { + start, err := b.reserveContiguous(len(regs)) + if err != nil { + return 0, err + } + for i, r := range regs { + b.indexByReg[r] = start + byte(i) + } + return start, 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 assembleProgram(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 (opNegInt) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_NegInt, + dest: (*assembler).intReg, + arg: (*assembler).intReg, + } +} +func (opIntToString) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_IntToString, + dest: (*assembler).strReg, + arg: (*assembler).intReg, + } +} +func (opPortionToString) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_PortionToString, + dest: (*assembler).strReg, + arg: (*assembler).portionReg, + } +} +func (opMonetaryToString) sig() unaryOpSig { + return unaryOpSig{ + opcode: vm.Op_MonetaryToString, + dest: (*assembler).strReg, + 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 (opAddString) sig() binaryOpSig { + return binaryOpSig{ + opcode: vm.Op_AddString, + dest: (*assembler).strReg, + left: (*assembler).strReg, + right: (*assembler).strReg, + } +} +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 save) assemble(a *assembler) error { + account, err := a.strReg(i.account) + if err != nil { + return err + } + asset, err := a.strReg(i.asset) + if err != nil { + return err + } + amount, err := a.optionalReg((*assembler).intReg, i.amount) + if err != nil { + return err + } + a.emit(vm.Op_Save, account, asset, amount) + return nil +} + +func (i assertLeftover) assemble(a *assembler) error { + portion, err := a.portionReg(i.portion) + if err != nil { + return err + } + var exact byte + if i.exact { + exact = 1 + } + a.emit(vm.Op_AssertLeftover, portion, exact, 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 assertSameAsset) assemble(a *assembler) error { + left, err := a.strReg(i.left) + if err != nil { + return err + } + right, err := a.strReg(i.right) + if err != nil { + return err + } + + a.emit(vm.Op_AssertSameAsset, left, right, maxReg) + + return nil +} + +func (i assertValidAccount) assemble(a *assembler) error { + account, err := a.strReg(i.account) + if err != nil { + return err + } + + a.emit(vm.Op_AssertValidAccount, account, maxReg, maxReg) + + return nil +} + +func (i assertNonNegativeBalance) assemble(a *assembler) error { + balance, err := a.monetaryReg(i.balance) + if err != nil { + return err + } + account, err := a.strReg(i.account) + if err != nil { + return err + } + + a.emit(vm.Op_AssertNonNegativeBalance, balance, account, maxReg) + + return nil +} + +func (i setTxMeta) assemble(a *assembler) error { + key, err := a.strReg(i.key) + if err != nil { + return err + } + value, err := a.strReg(i.value) + if err != nil { + return err + } + + a.emit(vm.Op_SetTxMeta, key, value, maxReg) + + return nil +} + +func (a *assembler) emitMeta(opcode vm.Opcode, dest byte, account, key reg) error { + acc, err := a.strReg(account) + if err != nil { + return err + } + k, err := a.strReg(key) + if err != nil { + return err + } + a.emit(opcode, dest, acc, k) + return nil +} + +func (metaStr) assembleMeta(a *assembler, dest, account, key reg) error { + d, err := a.strReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaStr, d, account, key) +} + +func (metaInt) assembleMeta(a *assembler, dest, account, key reg) error { + d, err := a.intReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaInt, d, account, key) +} + +func (metaPortion) assembleMeta(a *assembler, dest, account, key reg) error { + d, err := a.portionReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaPortion, d, account, key) +} + +func (metaMonetary) assembleMeta(a *assembler, dest, account, key reg) error { + d, err := a.monetaryReg(dest) + if err != nil { + return err + } + return a.emitMeta(vm.Op_MetaMonetary, d, account, key) +} + +func (i metaVar) assemble(a *assembler) error { + return i.typ.assembleMeta(a, i.dest, i.account, i.key) +} + +func (i setAccountMeta) assemble(a *assembler) error { + account, err := a.strReg(i.account) + if err != nil { + return err + } + key, err := a.strReg(i.key) + if err != nil { + return err + } + value, err := a.strReg(i.value) + if err != nil { + return err + } + + a.emit(vm.Op_SetAccountMeta, account, key, value) + + return nil +} + +func (i fetchBalance) assemble(a *assembler) error { + dest, err := a.monetaryReg(i.dest) + if err != nil { + return err + } + account, err := a.strReg(i.account) + if err != nil { + return err + } + asset, err := a.strReg(i.asset) + if err != nil { + return err + } + + a.emit(vm.Op_Balance, dest, account, asset) + + 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 { + n := len(i.portions) // == len(i.dest) + + amt, err := a.intReg(i.amount) + if err != nil { + return err + } + + portionStart, err := a.portions.reserveContiguous(n) + if err != nil { + return err + } + for j, p := range i.portions { + src, err := a.portions.index(p) + if err != nil { + return err + } + a.emit(vm.Op_PortionCopy, portionStart+byte(j), src, maxReg) + } + + destStart, err := a.ints.bindContiguous(i.dest) + if err != nil { + return err + } + + a.emit(vm.Op_MkAllotment, destStart, portionStart, byte(n)) + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: amt, + B: maxReg, + C: maxReg, + }) + return nil +} + +func (varInt) assembleLoad(a *assembler, dest reg, index uint16) error { + d, err := a.intReg(dest) + if err != nil { + return err + } + a.emitBC(vm.Op_LoadVarInt, d, index) + return nil +} + +func (varStr) assembleLoad(a *assembler, dest reg, index uint16) error { + d, err := a.strReg(dest) + if err != nil { + return err + } + a.emitBC(vm.Op_LoadVarStr, d, index) + return nil +} + +func (i loadVar) assemble(a *assembler) error { + return i.typ.assembleLoad(a, i.dest, i.index) +} + +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..5e943a27 --- /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 := assembleProgram([]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 := assembleProgram([]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 := assembleProgram(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..fee83630 --- /dev/null +++ b/internal/compiler/bench_test.go @@ -0,0 +1,234 @@ +package compiler_test + +import ( + "context" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" +) + +// benchStore is a minimal vm.Store for the benchmarks. +type benchStore struct { + balances map[runtime.PairKey]*big.Int +} + +func (s benchStore) 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) +} + +func (benchStore) GetMetadata(string, string) (string, bool) { return "", false } + +// 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 := benchStore{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) + } + _, program, err := compiler.Compile(parsed.Value) + if err != nil { + b.Fatalf("compile: %v", err) + } + store := benchStore{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() benchStore { + return benchStore{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) + } + _, program, err := compiler.Compile(parsed.Value) + if err != nil { + b.Fatalf("compile: %v", err) + } + 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/bytecode_typecheck.go b/internal/compiler/bytecode_typecheck.go new file mode 100644 index 00000000..37c48925 --- /dev/null +++ b/internal/compiler/bytecode_typecheck.go @@ -0,0 +1,247 @@ +package compiler + +import "fmt" + +// regType is the type of a virtual register. It mirrors the four VM register +// banks; every register has exactly one type for its whole life. +type regType int + +const ( + regInt regType = iota + regStr + regPortion + regMonetary +) + +func (t regType) String() string { + switch t { + case regInt: + return "int" + case regStr: + return "string" + case regPortion: + return "portion" + case regMonetary: + return "monetary" + default: + return "?" + } +} + +// bytecodeTypechecker validates a virtual-instruction stream one instruction at +// a time. It remembers the type each register was written with; a later read of +// that register with a different type, or a read before any write, is a bug in +// the code that produced the instructions. The state is updated as each +// instruction checks out. +type bytecodeTypechecker struct { + types map[reg]regType +} + +func newBytecodeTypechecker() *bytecodeTypechecker { + return &bytecodeTypechecker{types: map[reg]regType{}} +} + +// use asserts r was already written with type want. +func (tc *bytecodeTypechecker) use(r reg, want regType) error { + got, ok := tc.types[r] + if !ok { + return fmt.Errorf("register %s read as %s before being written", r, want) + } + if got != want { + return fmt.Errorf("register %s read as %s but holds %s", r, want, got) + } + return nil +} + +func (tc *bytecodeTypechecker) useOpt(r *reg, want regType) error { + if r == nil { + return nil + } + return tc.use(*r, want) +} + +// def records that r now holds type t, rejecting a write that changes its type. +func (tc *bytecodeTypechecker) def(r reg, t regType) error { + if got, ok := tc.types[r]; ok && got != t { + return fmt.Errorf("register %s written as %s but already holds %s", r, t, got) + } + tc.types[r] = t + return nil +} + +// check typechecks a single instruction, updating the state on success. +func (tc *bytecodeTypechecker) check(instr vInstr) error { + switch i := instr.(type) { + case loadInt: + return tc.def(i.dest, regInt) + case loadStr: + return tc.def(i.dest, regStr) + case loadVar: + t, err := varRegType(i.typ) + if err != nil { + return err + } + return tc.def(i.dest, t) + + case unaryOp: + dest, arg, err := unOpRegTypes(i.op) + if err != nil { + return err + } + return firstErr(tc.use(i.arg, arg), tc.def(i.dest, dest)) + case binaryOp: + dest, left, right, err := binOpRegTypes(i.op) + if err != nil { + return err + } + return firstErr(tc.use(i.left, left), tc.use(i.right, right), tc.def(i.dest, dest)) + + case pullAccount: + return firstErr( + tc.use(i.account, regStr), + tc.useOpt(i.cap, regInt), + tc.useOpt(i.overdraft, regInt), + tc.useOpt(i.color, regStr), + tc.def(i.dest, regInt), + ) + case sendToAccount: + return firstErr(tc.useOpt(i.account, regStr), tc.useOpt(i.cap, regInt)) + case save: + return firstErr(tc.use(i.account, regStr), tc.use(i.asset, regStr), tc.useOpt(i.amount, regInt)) + + case makeAllotment: + if err := tc.use(i.amount, regInt); err != nil { + return err + } + for _, p := range i.portions { + if err := tc.use(p, regPortion); err != nil { + return err + } + } + for _, d := range i.dest { + if err := tc.def(d, regInt); err != nil { + return err + } + } + return nil + + case checkEnoughFunds: + return firstErr(tc.use(i.got, regInt), tc.use(i.needed, regInt)) + case assertLeftover: + return tc.use(i.portion, regPortion) + case setCurrentAsset: + return tc.use(i.asset, regStr) + case assertSameAsset: + return firstErr(tc.use(i.left, regStr), tc.use(i.right, regStr)) + case assertValidAccount: + return tc.use(i.account, regStr) + case assertNonNegativeBalance: + return firstErr(tc.use(i.balance, regMonetary), tc.use(i.account, regStr)) + + case setTxMeta: + return firstErr(tc.use(i.key, regStr), tc.use(i.value, regStr)) + case setAccountMeta: + return firstErr(tc.use(i.account, regStr), tc.use(i.key, regStr), tc.use(i.value, regStr)) + case metaVar: + t, err := metaRegType(i.typ) + if err != nil { + return err + } + return firstErr(tc.use(i.account, regStr), tc.use(i.key, regStr), tc.def(i.dest, t)) + case fetchBalance: + return firstErr(tc.use(i.account, regStr), tc.use(i.asset, regStr), tc.def(i.dest, regMonetary)) + + case jmpIfZero: + return tc.use(i.cond, regInt) + case labelMarker: + return nil + + default: + return fmt.Errorf("bytecode typechecker: unhandled instruction %T", instr) + } +} + +func typecheckInstructions(instrs []vInstr) error { + tc := newBytecodeTypechecker() + for pos, instr := range instrs { + if err := tc.check(instr); err != nil { + return fmt.Errorf("at instruction %d (%s): %w", pos, instr, err) + } + } + return nil +} + +func firstErr(errs ...error) error { + for _, e := range errs { + if e != nil { + return e + } + } + return nil +} + +func varRegType(t varType) (regType, error) { + switch t.(type) { + case varInt: + return regInt, nil + case varStr: + return regStr, nil + default: + return 0, fmt.Errorf("bytecode typechecker: unknown var type %T", t) + } +} + +func metaRegType(t metaType) (regType, error) { + switch t.(type) { + case metaStr: + return regStr, nil + case metaInt: + return regInt, nil + case metaPortion: + return regPortion, nil + case metaMonetary: + return regMonetary, nil + default: + return 0, fmt.Errorf("bytecode typechecker: unknown meta type %T", t) + } +} + +func unOpRegTypes(op unKind) (dest, arg regType, err error) { + switch op.(type) { + case opIntCopy: + return regInt, regInt, nil + case opPortionCopy: + return regPortion, regPortion, nil + case opGetAsset: + return regStr, regMonetary, nil + case opGetAmount: + return regInt, regMonetary, nil + case opNegInt: + return regInt, regInt, nil + case opIntToString: + return regStr, regInt, nil + case opPortionToString: + return regStr, regPortion, nil + case opMonetaryToString: + return regStr, regMonetary, nil + default: + return 0, 0, fmt.Errorf("bytecode typechecker: unknown unary op %T", op) + } +} + +func binOpRegTypes(op binKind) (dest, left, right regType, err error) { + switch op.(type) { + case opMinInt, opAddInt, opSubInt: + return regInt, regInt, regInt, nil + case opAddString: + return regStr, regStr, regStr, nil + case opSubPortion: + return regPortion, regPortion, regPortion, nil + case opMakePortion: + return regPortion, regInt, regInt, nil + case opMakeMonetary: + return regMonetary, regStr, regInt, nil + default: + return 0, 0, 0, fmt.Errorf("bytecode typechecker: unknown binary op %T", op) + } +} diff --git a/internal/compiler/bytecode_typecheck_test.go b/internal/compiler/bytecode_typecheck_test.go new file mode 100644 index 00000000..9b132f9d --- /dev/null +++ b/internal/compiler/bytecode_typecheck_test.go @@ -0,0 +1,44 @@ +package compiler + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBytecodeTypecheck_Valid(t *testing.T) { + // $0 = 1; $1 = 2; $2 = $0 + $1 (all int) + instrs := []vInstr{ + loadInt{dest: 0, value: *big.NewInt(1)}, + loadInt{dest: 1, value: *big.NewInt(2)}, + binaryOp{op: opAddInt{}, dest: 2, left: 0, right: 1}, + } + require.NoError(t, typecheckInstructions(instrs)) +} + +func TestBytecodeTypecheck_UseBeforeWrite(t *testing.T) { + // reads $0 as int before it is ever written + instrs := []vInstr{ + unaryOp{op: opNegInt{}, dest: 1, arg: 0}, + } + require.Error(t, typecheckInstructions(instrs)) +} + +func TestBytecodeTypecheck_WrongType(t *testing.T) { + // $0 is a string, then used where an int is expected + instrs := []vInstr{ + loadStr{dest: 0, value: "USD/2"}, + unaryOp{op: opNegInt{}, dest: 1, arg: 0}, + } + require.Error(t, typecheckInstructions(instrs)) +} + +func TestBytecodeTypecheck_RedefinedWithDifferentType(t *testing.T) { + // $0 written as int, then overwritten as string + instrs := []vInstr{ + loadInt{dest: 0, value: *big.NewInt(1)}, + loadStr{dest: 0, value: "x"}, + } + require.Error(t, typecheckInstructions(instrs)) +} diff --git a/internal/compiler/compile_error_test.go b/internal/compiler/compile_error_test.go new file mode 100644 index 00000000..dda59f17 --- /dev/null +++ b/internal/compiler/compile_error_test.go @@ -0,0 +1,72 @@ +package compiler + +// White-box tests asserting the concrete CompilerError produced for invalid +// programs. They call compileProgramToVirtual directly, since the public Compile +// stringifies the error and would lose the type. + +import ( + "testing" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/stretchr/testify/require" +) + +func TestE2E_RejectsUnboundVariable(t *testing.T) { + parsed := parser.Parse(`send [C 10] (source = $undeclared destination = @d)`) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToVirtual(parsed.Value) + require.IsType(t, TypeError{}, cErr) + require.IsType(t, typecheck.UnboundVariable{}, cErr.(TypeError).Kind) +} + +func TestE2E_RejectsTypeMismatch(t *testing.T) { + parsed := parser.Parse(`vars { string $s } send [C 10] (source = $s destination = @d)`) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToVirtual(parsed.Value) + require.IsType(t, TypeError{}, cErr) + require.IsType(t, typecheck.TypeMismatch{}, cErr.(TypeError).Kind) +} + +func TestE2E_RejectsMetaOutsideVarOrigin(t *testing.T) { + // meta() is only supported as a direct variable origin; nested in an + // expression it must be a compile error, not a panic. + parsed := parser.Parse(` + vars { + account $a + number $n = meta($a, "k") + 1 + } + send [C $n] (source = @world destination = @d) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToVirtual(parsed.Value) + require.IsType(t, InvalidMetaPosition{}, cErr) +} + +func TestE2E_RejectsNonCastableInterpVar(t *testing.T) { + // a monetary var has no string form: interpolating it must be a compile + // error (matching the interpreter's runtime CannotCastToString), not a panic. + parsed := parser.Parse(` + vars { monetary $m } + set_tx_meta("k", @acc:$m) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToVirtual(parsed.Value) + require.IsType(t, CannotCastToString{}, cErr) + require.Equal(t, typecheck.TypeMonetary, cErr.(CannotCastToString).Type) +} + +func TestE2E_AllotmentDuplicateRemaining(t *testing.T) { + parsed := parser.Parse(` + send [USD/2 100] ( + source = @world + destination = { + remaining to @a + remaining to @b + } + ) + `) + require.Empty(t, parsed.Errors) + _, cErr := compileProgramToVirtual(parsed.Value) + require.IsType(t, DuplicateRemaining{}, cErr) +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go new file mode 100644 index 00000000..a8d3021e --- /dev/null +++ b/internal/compiler/compiler.go @@ -0,0 +1,1059 @@ +package compiler + +import ( + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/builtins" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/formancehq/numscript/internal/utils" + "github.com/formancehq/numscript/internal/vm" +) + +// Compile lowers a parsed program to the VarsEncoder that turns a json var +// payload into the vm.Vars the program expects, plus the vm.Program itself. +func Compile(program parser.Program) (VarsEncoder, vm.Program, error) { + compiled, cErr := compileProgramToVirtual(program) + if cErr != nil { + return VarsEncoder{}, vm.Program{}, fmt.Errorf("%v", cErr) + } + + if err := typecheckInstructions(compiled.instructions); err != nil { + return VarsEncoder{}, vm.Program{}, err + } + + prog, err := assembleProgram(compiled.instructions) + if err != nil { + return VarsEncoder{}, vm.Program{}, err + } + + return compiled.varsEncoder, prog, nil +} + +type compiledProgramVirtual struct { + instructions []vInstr + varsEncoder VarsEncoder +} + +type state struct { + nextReg int + nextLabelId int + instructions []vInstr + vars map[string]reg + exprTypes map[parser.ValueExpr]typecheck.Type + currentAssetReg reg + + nextIntVar int + nextStrVar int + varDecls []varDecl +} + +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) compileAllot(amount reg, allotments []parser.AllotmentValue) ([]reg, CompilerError) { + n := len(allotments) + portions := make([]reg, n) + remainingIdx := -1 + for i, al := range allotments { + switch al := al.(type) { + case *parser.ValueExprAllotment: + p, err := st.compileExpr(al.Value) + if err != nil { + return nil, err + } + portions[i] = p + case *parser.RemainingAllotment: + if remainingIdx != -1 { + return nil, DuplicateRemaining{Range: al.Range} + } + remainingIdx = i + default: + utils.NonExhaustiveMatchPanic[any](al) + } + } + + leftover := st.compilePortionOne() + for i := range allotments { + if i == remainingIdx { + continue + } + prev, pi := leftover, portions[i] + leftover = st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opSubPortion{}, left: prev, right: pi, dest: dest} + }) + } + + st.pushInstruction(assertLeftover{portion: leftover, exact: remainingIdx == -1}) + if remainingIdx != -1 { + portions[remainingIdx] = leftover + } + + dest := make([]reg, n) + for i := range dest { + dest[i] = st.getFreshReg() + } + st.pushInstruction(makeAllotment{ + dest: dest, + amount: amount, + portions: portions, + }) + return dest, nil +} + +func (st *state) compileCapAmount(monExpr parser.ValueExpr) (reg, CompilerError) { + monReg, err := st.compileExpr(monExpr) + if err != nil { + return 0, err + } + assetReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: monReg, dest: dest} + }) + st.pushInstruction(assertSameAsset{left: assetReg, right: st.currentAssetReg}) + return st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: monReg, dest: dest} + }), nil +} + +func (st *state) compilePortionOne() reg { + one := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{value: *big.NewInt(1), dest: dest} + }) + return st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opMakePortion{}, left: one, right: one, dest: dest} + }) +} + +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 + hasVar := false + 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: + hasVar = true + r, err := st.compileExpr(part) + if err != nil { + return 0, err + } + switch t := st.exprTypes[part]; t { + case typecheck.TypeAccount, typecheck.TypeString: + parts = append(parts, r) + case typecheck.TypeNumber: + parts = append(parts, st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opIntToString{}, arg: r, dest: dest} + })) + default: + return 0, CannotCastToString{Range: part.GetRange(), Type: t} + } + } + } + + acc := parts[0] + for _, part := range parts[1:] { + left, right := acc, part + acc = st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opAddString{}, left: left, right: right, dest: dest} + }) + } + // an interpolated var can inject chars that make the name ill-formed; + // all-text literals are valid by construction, so skip the check + if hasVar { + st.pushInstruction(assertValidAccount{account: acc}) + } + return acc, nil + + case *parser.Variable: + r, ok := st.vars[expr.Name] + if !ok { + return 0, UnboundVar{Range: expr.Range, Var: expr.Name} + } + return r, nil + + case *parser.PercentageLiteral: + // e.g. 50% -> portion 50/100; mk_portion reduces via SetFrac + ratio := expr.ToRatio() + numReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{value: *ratio.Num(), dest: dest} + }) + denReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{value: *ratio.Denom(), dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakePortion{}, left: numReg, right: denReg, dest: dest} + }) + + case *parser.BinaryInfix: + leftReg, err := st.compileExpr(expr.Left) + if err != nil { + return 0, err + } + rightReg, err := st.compileExpr(expr.Right) + if err != nil { + return 0, err + } + + switch expr.Operator { + case parser.InfixOperatorDiv: + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakePortion{}, left: leftReg, right: rightReg, dest: dest} + }) + + case parser.InfixOperatorPlus: + switch st.exprTypes[expr.Left] { + case typecheck.TypeNumber: + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opAddInt{}, left: leftReg, right: rightReg, dest: dest} + }) + + case typecheck.TypeMonetary: + lAsset := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: leftReg, dest: dest} + }) + rAsset := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: rightReg, dest: dest} + }) + st.pushInstruction(assertSameAsset{left: lAsset, right: rAsset}) + + lAmt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: leftReg, dest: dest} + }) + rAmt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: rightReg, dest: dest} + }) + sum := st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opAddInt{}, left: lAmt, right: rAmt, dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakeMonetary{}, left: lAsset, right: sum, dest: dest} + }) + + default: + panic("TODO compileExpr + for unexpected type") + + } + + case parser.InfixOperatorMinus: + switch st.exprTypes[expr.Left] { + case typecheck.TypeNumber: + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opSubInt{}, left: leftReg, right: rightReg, dest: dest} + }) + + case typecheck.TypeMonetary: + lAsset := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: leftReg, dest: dest} + }) + rAsset := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: rightReg, dest: dest} + }) + st.pushInstruction(assertSameAsset{left: lAsset, right: rAsset}) + + lAmt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: leftReg, dest: dest} + }) + rAmt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: rightReg, dest: dest} + }) + diff := st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opSubInt{}, left: lAmt, right: rAmt, dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakeMonetary{}, left: lAsset, right: diff, dest: dest} + }) + + default: + panic("TODO compileExpr - for unexpected type") + + } + + default: + panic("TODO compileExpr binary op " + string(expr.Operator)) + } + + case *parser.Prefix: + switch expr.Operator { + case parser.PrefixOperatorMinus: + argReg, err := st.compileExpr(expr.Expr) + if err != nil { + return 0, err + } + switch st.exprTypes[expr.Expr] { + case typecheck.TypeNumber: + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return unaryOp{op: opNegInt{}, arg: argReg, dest: dest} + }) + + case typecheck.TypeMonetary: + amt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: argReg, dest: dest} + }) + negAmt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opNegInt{}, arg: amt, dest: dest} + }) + asset := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: argReg, dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakeMonetary{}, left: asset, right: negAmt, dest: dest} + }) + + default: + panic("TODO compileExpr prefix - for unexpected type") + } + + default: + panic("TODO compileExpr prefix op " + string(expr.Operator)) + } + + case *parser.FnCall: + switch expr.Caller.Name { + case builtins.GetAmount: + argReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return 0, err + } + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: argReg, dest: dest} + }) + + case builtins.GetAsset: + argReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return 0, err + } + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: argReg, dest: dest} + }) + + case builtins.Balance: + accountReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return 0, err + } + assetReg, err := st.compileExpr(expr.Args[1]) + if err != nil { + return 0, err + } + balReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return fetchBalance{dest: dest, account: accountReg, asset: assetReg} + }) + st.pushInstruction(assertNonNegativeBalance{balance: balReg, account: accountReg}) + return balReg, nil + + case builtins.Overdraft: + accountReg, err := st.compileExpr(expr.Args[0]) + if err != nil { + return 0, err + } + assetReg, err := st.compileExpr(expr.Args[1]) + if err != nil { + return 0, err + } + balReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return fetchBalance{dest: dest, account: accountReg, asset: assetReg} + }) + amtReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: balReg, dest: dest} + }) + zeroReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{value: *big.NewInt(0), dest: dest} + }) + minReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opMinInt{}, left: amtReg, right: zeroReg, dest: dest} + }) + // overdraft = max(0, -balance) = -min(balance, 0) + negReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opNegInt{}, arg: minReg, dest: dest} + }) + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return binaryOp{op: opMakeMonetary{}, left: assetReg, right: negReg, dest: dest} + }) + + case builtins.Meta: + return 0, InvalidMetaPosition{Range: expr.Range} + + default: + panic("TODO compileExpr fn call " + expr.Caller.Name) + } + + 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 src.Color != nil { + panic("TODO impl color") + } + + if src.Bounded == nil && capReg == nil { + return 0, InvalidUncappedSource{ + Range: src.GetRange(), + } + } + + accReg, err := st.compileExpr(src.Address) + if err != nil { + return 0, err + } + + var overdraftReg *reg + if src.Bounded != nil { + amtReg, err := st.compileCapAmount(*src.Bounded) + if err != nil { + return 0, err + } + overdraftReg = &amtReg + } + + return st.pushInstructionWithDestErr(func(dest reg) vInstr { + return pullAccount{ + dest: dest, + account: accReg, + cap: capReg, + overdraft: overdraftReg, + color: nil, + } + }) + + case *parser.SourceCapped: + clauseCapIntReg, err := st.compileCapAmount(src.Cap) + if err != nil { + return 0, err + } + + 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 { + inorderTotalReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return loadInt{ + value: *big.NewInt(0), + dest: dest, + } + }) + for _, subSrc := range src.Sources { + innerPulledAmtReg, err := st.compileSource(nil, subSrc) + if err != nil { + return 0, err + } + // inorderTotalReg += innerPulledAmtReg + st.pushInstruction(binaryOp{ + op: opAddInt{}, + dest: inorderTotalReg, + left: inorderTotalReg, + right: innerPulledAmtReg, + }) + } + return inorderTotalReg, nil + } + + 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: + // an allotment source splits the cap among sub-sources, so it needs one + if capReg == nil { + return 0, InvalidUncappedSource{Range: src.GetRange()} + } + allotments := make([]parser.AllotmentValue, len(src.Items)) + for i, item := range src.Items { + allotments[i] = item.Allotment + } + shares, err := st.compileAllot(*capReg, allotments) + if err != nil { + return 0, err + } + // pull exactly its share from each sub-source (tryTakingExact) + for i, item := range src.Items { + if _, err := st.compileSourceWithRequiredAmount(shares[i], item.From); err != nil { + return 0, err + } + } + return *capReg, nil + + 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.DestinationAllotment: + allotments := make([]parser.AllotmentValue, len(dest.Items)) + for i, item := range dest.Items { + allotments[i] = item.Allotment + } + // split the amount routed to this destination across the portions + shares, err := st.compileAllot(currentCap, allotments) + if err != nil { + return err + } + // send each computed share to its target (capped by that exact amount) + for i, item := range dest.Items { + if err := st.compileKeptOrDestination(item.To, pulledAmtReg, shares[i]); err != nil { + return err + } + } + return nil + + case *parser.DestinationOneof: + panic("TODO unimplemented") + + case *parser.DestinationAccount: + accReg, err := st.compileExpr(dest.ValueExpr) + if err != nil { + return err + } + + var cap *reg + if pulledAmtReg != currentCap { + cap = ¤tCap + } + st.pushInstruction(sendToAccount{ + account: &accReg, + cap: cap, + }) + + case *parser.DestinationInorder: + remaining := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opIntCopy{}, arg: currentCap, dest: dest} + }) + for _, clause := range dest.Clauses { + capAmtReg, err := st.compileCapAmount(clause.Cap) + if err != nil { + return err + } + amtReg := st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opMinInt{}, left: remaining, right: capAmtReg, dest: dest} + }) + if err := st.compileKeptOrDestination(clause.To, pulledAmtReg, amtReg); err != nil { + return err + } + st.pushInstruction(binaryOp{op: opSubInt{}, dest: remaining, left: remaining, right: amtReg}) + } + + return st.compileKeptOrDestination(dest.Remaining, pulledAmtReg, remaining) + + default: + utils.NonExhaustiveMatchPanic[any](dest) + } + + return nil +} + +func (st *state) compileKeptOrDestination( + keptOrDest parser.KeptOrDestination, + pulledAmtReg reg, + currentCap reg, +) CompilerError { + switch keptOrDest := keptOrDest.(type) { + case *parser.DestinationTo: + return st.compileDestination(pulledAmtReg, currentCap, keptOrDest.Destination) + + case *parser.DestinationKept: + var cap *reg + if pulledAmtReg != currentCap { + cap = ¤tCap + } + st.pushInstruction(sendToAccount{ + account: nil, + cap: cap, + }) + return nil + + 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, + }) + st.currentAssetReg = 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, + }) + st.currentAssetReg = 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: + var assetReg reg + var amountReg *reg + switch sv := stmt.SentValue.(type) { + case *parser.SentValueLiteral: + monReg, err := st.compileExpr(sv.Monetary) + if err != nil { + return err + } + assetReg = st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAsset{}, arg: monReg, dest: dest} + }) + amt := st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opGetAmount{}, arg: monReg, dest: dest} + }) + amountReg = &amt + case *parser.SentValueAll: + r, err := st.compileExpr(sv.Asset) + if err != nil { + return err + } + assetReg = r + default: + utils.NonExhaustiveMatchPanic[any](stmt.SentValue) + } + + accReg, err := st.compileExpr(stmt.Account) + if err != nil { + return err + } + st.pushInstruction(save{account: accReg, asset: assetReg, amount: amountReg}) + return nil + case *parser.FnCall: + switch stmt.Caller.Name { + case builtins.SetTxMeta: + key, err := st.compileExpr(stmt.Args[0]) + if err != nil { + return err + } + value, err := st.compileMetaValue(stmt.Args[1]) + if err != nil { + return err + } + st.pushInstruction(setTxMeta{key: key, value: value}) + return nil + + case builtins.SetAccountMeta: + account, err := st.compileExpr(stmt.Args[0]) + if err != nil { + return err + } + key, err := st.compileExpr(stmt.Args[1]) + if err != nil { + return err + } + value, err := st.compileMetaValue(stmt.Args[2]) + if err != nil { + return err + } + st.pushInstruction(setAccountMeta{account: account, key: key, value: value}) + return nil + + default: + panic("TODO fn call statement: " + stmt.Caller.Name) + } + + default: + return utils.NonExhaustiveMatchPanic[CompilerError](stmt) + } +} + +// compileMetaValue compiles a value into a string register (metadata is stored +// stringified). Strings/accounts/assets already live in string registers; +// numbers go through int_to_string. +func (st *state) compileMetaValue(expr parser.ValueExpr) (reg, CompilerError) { + r, err := st.compileExpr(expr) + if err != nil { + return 0, err + } + + switch st.exprTypes[expr] { + case typecheck.TypeString, typecheck.TypeAccount, typecheck.TypeAsset: + return r, nil + case typecheck.TypeNumber: + return st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opIntToString{}, arg: r, dest: dest} + }), nil + case typecheck.TypePortion: + return st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opPortionToString{}, arg: r, dest: dest} + }), nil + case typecheck.TypeMonetary: + return st.pushInstructionWithDest(func(dest reg) vInstr { + return unaryOp{op: opMonetaryToString{}, arg: r, dest: dest} + }), nil + default: + panic("TODO meta value of type " + st.exprTypes[expr]) + } +} + +func compileProgramToVirtual(program parser.Program) (compiledProgramVirtual, CompilerError) { + tc := typecheck.Check(program) + if len(tc.Errors) > 0 { + return compiledProgramVirtual{}, TypeError{Range: tc.Errors[0].Range, Kind: tc.Errors[0].Kind} + } + + st := state{vars: map[string]reg{}, exprTypes: tc.ExprTypes} + + if program.Vars != nil { + for _, decl := range program.Vars.Declarations { + if err := st.compileVarDeclaration(decl); err != nil { + return compiledProgramVirtual{}, err + } + } + } + + for _, stmt := range program.Statements { + if err := st.compileStatements(stmt); err != nil { + return compiledProgramVirtual{}, err + } + } + + return compiledProgramVirtual{ + instructions: st.instructions, + varsEncoder: VarsEncoder{ + decls: st.varDecls, + nStr: st.nextStrVar, + nInt: st.nextIntVar, + }, + }, nil +} + +func (st *state) compileVarDeclaration(decl parser.VarDeclaration) CompilerError { + if decl.Origin == nil { + st.compileExternalVar(decl) + return nil + } + // meta() is only supported as a variable origin, statically dispatched on + // the declared type; elsewhere compileExpr reports InvalidMetaPosition. + if fnCall, ok := (*decl.Origin).(*parser.FnCall); ok && fnCall.Caller.Name == builtins.Meta { + return st.compileMetaVar(decl, fnCall) + } + r, err := st.compileExpr(*decl.Origin) + if err != nil { + return err + } + st.vars[decl.Name.Name] = r + return nil +} + +func (st *state) compileMetaVar(decl parser.VarDeclaration, fnCall *parser.FnCall) CompilerError { + account, err := st.compileExpr(fnCall.Args[0]) + if err != nil { + return err + } + key, err := st.compileExpr(fnCall.Args[1]) + if err != nil { + return err + } + + var typ metaType + switch decl.Type.Name { + case typecheck.TypeString, typecheck.TypeAccount, typecheck.TypeAsset: + typ = metaStr{} + case typecheck.TypeNumber: + typ = metaInt{} + case typecheck.TypePortion: + typ = metaPortion{} + case typecheck.TypeMonetary: + typ = metaMonetary{} + default: + panic("unexpected meta var type: " + decl.Type.Name) + } + + st.vars[decl.Name.Name] = st.pushInstructionWithDest(func(dest reg) vInstr { + return metaVar{dest: dest, account: account, key: key, typ: typ} + }) + return nil +} + +// TODO review AI blob +func (st *state) compileExternalVar(decl parser.VarDeclaration) { + name := decl.Name.Name + st.varDecls = append(st.varDecls, varDecl{name: name, typ: decl.Type.Name}) + + switch decl.Type.Name { + case typecheck.TypeNumber: + st.vars[name] = st.loadIntVar() + + case typecheck.TypeString, typecheck.TypeAsset, typecheck.TypeAccount: + st.vars[name] = st.loadStrVar() + + case typecheck.TypePortion: + num := st.loadIntVar() + den := st.loadIntVar() + st.vars[name] = st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opMakePortion{}, left: num, right: den, dest: dest} + }) + + case typecheck.TypeMonetary: + asset := st.loadStrVar() + amount := st.loadIntVar() + st.vars[name] = st.pushInstructionWithDest(func(dest reg) vInstr { + return binaryOp{op: opMakeMonetary{}, left: asset, right: amount, dest: dest} + }) + + default: + panic("unexpected var type: " + decl.Type.Name) + } +} + +func (st *state) loadIntVar() reg { + index := uint16(st.nextIntVar) + st.nextIntVar++ + return st.pushInstructionWithDest(func(dest reg) vInstr { + return loadVar{dest: dest, typ: varInt{}, index: index} + }) +} + +func (st *state) loadStrVar() reg { + index := uint16(st.nextStrVar) + st.nextStrVar++ + return st.pushInstructionWithDest(func(dest reg) vInstr { + return loadVar{dest: dest, typ: varStr{}, index: index} + }) +} diff --git a/internal/compiler/compiler_error.go b/internal/compiler/compiler_error.go new file mode 100644 index 00000000..1678bb18 --- /dev/null +++ b/internal/compiler/compiler_error.go @@ -0,0 +1,68 @@ +package compiler + +import ( + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" +) + +type ( + CompilerError interface { + parser.Ranged + compileError() + } + + UnboundVar struct { + parser.Range + Var string + } + + TypeError struct { + parser.Range + Kind typecheck.ErrorKind + } + + InvalidUncappedSource struct { + parser.Range + } + + DuplicateRemaining struct { + parser.Range + } + + // InvalidMetaPosition is reported when meta() appears anywhere other than as + // a top-level variable origin (the only place it's supported). + InvalidMetaPosition struct { + parser.Range + } + + // CannotCastToString is reported for an interpolation part whose type has no + // string form (monetary, asset, portion). + CannotCastToString struct { + parser.Range + Type typecheck.Type + } +) + +func (UnboundVar) compileError() {} +func (TypeError) compileError() {} +func (InvalidUncappedSource) compileError() {} +func (DuplicateRemaining) compileError() {} +func (InvalidMetaPosition) compileError() {} +func (CannotCastToString) compileError() {} + +func (e TypeError) Error() string { return e.Kind.Message() } +func (InvalidMetaPosition) Error() string { + return "meta() is only allowed as a variable origin" +} +func (e CannotCastToString) Error() string { + return "cannot cast a value of type " + string(e.Type) + " to string" +} + +var ( + _ CompilerError = (*UnboundVar)(nil) + _ CompilerError = (*TypeError)(nil) + _ CompilerError = (*InvalidUncappedSource)(nil) + _ CompilerError = (*DuplicateRemaining)(nil) + _ CompilerError = (*InvalidMetaPosition)(nil) + _ CompilerError = (*CannotCastToString)(nil) +) diff --git a/internal/compiler/compiler_example_test.go b/internal/compiler/compiler_example_test.go new file mode 100644 index 00000000..41f854d4 --- /dev/null +++ b/internal/compiler/compiler_example_test.go @@ -0,0 +1,84 @@ +package compiler_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript" + "github.com/stretchr/testify/require" +) + +func TestCompilerExample(t *testing.T) { + script := ` + vars { + account $acc + } + + send [USD/2 10] ( + source = $acc + destination = @dest + ) + ` + + varsEncoder, compiledProgram, compilationErr := numscript.Compile(script) + require.NoError(t, compilationErr) // e.g. parsing errors or type errors or any other kind of compile-time errors + + { + // The compiledProgram represents the compiled version of the program. + // We can serialize it into a []byte sequence and decode it back to the same data structure. + // the serialised []byte format is meant to be used to send it over the wire + bytecode := compiledProgram.Encode() // <- cast to []byte + + decodedCompiledProgram, decodingErr := numscript.DecodeCompiledProgram(bytecode) // <- decode it back + require.NoError(t, decodingErr) + require.Equal(t, decodedCompiledProgram, compiledProgram) + } + + // the vars encoder must be stored by the leader, so that it can encode the vars payload + // in a way that can be consumed by the vm + vars, err := varsEncoder.Encode(map[string]string{ + "acc": "src_account", + }) + require.NoError(t, err) + + { + // just like the compiledProgram. the Vars can be serialised and deserialised into/from []byte + serialisedVars := vars.Encode() // <- []byte to be sent over the wire from leader to nodes + + decodedVars, decodingErr := numscript.DecodeVars(serialisedVars) // <- turning []byte into Vars + require.NoError(t, decodingErr) + require.Equal(t, decodedVars, vars) + } + + // We can initialise the vm by passing the numscript.CompiledProgram value. + // Not only it's valid to re-use the same instance of the VM from many script runs, + // it's actually best to keep that in memory instead of the keeping the program and re-creating the vm each time + // this way we can avoid allocating/deallocating the registers and vm state each time + vm := numscript.NewVm(compiledProgram) + + // mock store (repr'd as map) + store := testStore{ + "src_account": 100, + } + + result, execErr := numscript.ExecVm(vm, &vars, store) + require.NoError(t, execErr) // e.g. missing funds, or any other runtime error + require.Equal(t, []numscript.Posting{ + { + Source: "src_account", + Destination: "dest", + Asset: "USD/2", + Amount: big.NewInt(10), + }, + }, result.Postings) +} + +type testStore map[string]int64 + +func (s testStore) GetBalance(account, asset, color string) *big.Int { + return big.NewInt(s[account]) +} + +func (testStore) GetMetadata(account, key string) (string, bool) { + return "", false +} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go new file mode 100644 index 00000000..c0fad054 --- /dev/null +++ b/internal/compiler/compiler_test.go @@ -0,0 +1,496 @@ +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(account: $r8) +`)) +} + +func TestIntAddition(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 4 + 6] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(4) + $r2 <- load_const(6) + $r3 <- add_int($r1, $r2) + $r4 <- mk_monetary($r0, $r3) + $r5 <- get_asset($r4) + set_current_asset($r5) + $r6 <- get_amount($r4) + $r7 <- load_const("src") + $r8 <- load_const(0) + $r9 <- pull_account(account: $r7, cap: $r6, overdraft: $r8) + check_enough_funds($r9, $r6) + $r10 <- load_const("dest") + send_to_account(account: $r10) +`)) +} + +func TestIntSubtraction(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 16 - 6] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(16) + $r2 <- load_const(6) + $r3 <- sub_int($r1, $r2) + $r4 <- mk_monetary($r0, $r3) + $r5 <- get_asset($r4) + set_current_asset($r5) + $r6 <- get_amount($r4) + $r7 <- load_const("src") + $r8 <- load_const(0) + $r9 <- pull_account(account: $r7, cap: $r6, overdraft: $r8) + check_enough_funds($r9, $r6) + $r10 <- load_const("dest") + send_to_account(account: $r10) +`)) +} + +func TestMonetaryAddition(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $a = [USD/2 3] + monetary $b = [USD/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(3) + $r2 <- mk_monetary($r0, $r1) + $r3 <- load_const("USD/2") + $r4 <- load_const(7) + $r5 <- mk_monetary($r3, $r4) + $r6 <- get_asset($r2) + $r7 <- get_asset($r5) + assert_same_asset($r6, $r7) + $r8 <- get_amount($r2) + $r9 <- get_amount($r5) + $r10 <- add_int($r8, $r9) + $r11 <- mk_monetary($r6, $r10) + $r12 <- get_asset($r11) + set_current_asset($r12) + $r13 <- get_amount($r11) + $r14 <- load_const("src") + $r15 <- load_const(0) + $r16 <- pull_account(account: $r14, cap: $r13, overdraft: $r15) + check_enough_funds($r16, $r13) + $r17 <- load_const("dest") + send_to_account(account: $r17) +`)) +} + +func TestMonetarySubtraction(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $a = [USD/2 30] + monetary $b = [USD/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(30) + $r2 <- mk_monetary($r0, $r1) + $r3 <- load_const("USD/2") + $r4 <- load_const(20) + $r5 <- mk_monetary($r3, $r4) + $r6 <- get_asset($r2) + $r7 <- get_asset($r5) + assert_same_asset($r6, $r7) + $r8 <- get_amount($r2) + $r9 <- get_amount($r5) + $r10 <- sub_int($r8, $r9) + $r11 <- mk_monetary($r6, $r10) + $r12 <- get_asset($r11) + set_current_asset($r12) + $r13 <- get_amount($r11) + $r14 <- load_const("src") + $r15 <- load_const(0) + $r16 <- pull_account(account: $r14, cap: $r13, overdraft: $r15) + check_enough_funds($r16, $r13) + $r17 <- load_const("dest") + send_to_account(account: $r17) +`)) +} + +func TestGetAmount(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $m = [USD/2 42] + number $n = get_amount($m) + } + send [USD/2 $n] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(42) + $r2 <- mk_monetary($r0, $r1) + $r3 <- get_amount($r2) + $r4 <- load_const("USD/2") + $r5 <- mk_monetary($r4, $r3) + $r6 <- get_asset($r5) + set_current_asset($r6) + $r7 <- get_amount($r5) + $r8 <- load_const("src") + $r9 <- load_const(0) + $r10 <- pull_account(account: $r8, cap: $r7, overdraft: $r9) + check_enough_funds($r10, $r7) + $r11 <- load_const("dest") + send_to_account(account: $r11) +`)) +} + +func TestGetAsset(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $m = [USD/2 42] + asset $a = get_asset($m) + } + send [$a 10] ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(42) + $r2 <- mk_monetary($r0, $r1) + $r3 <- get_asset($r2) + $r4 <- load_const(10) + $r5 <- mk_monetary($r3, $r4) + $r6 <- get_asset($r5) + set_current_asset($r6) + $r7 <- get_amount($r5) + $r8 <- load_const("src") + $r9 <- load_const(0) + $r10 <- pull_account(account: $r8, cap: $r7, overdraft: $r9) + check_enough_funds($r10, $r7) + $r11 <- load_const("dest") + send_to_account(account: $r11) +`)) +} + +func TestPrefixMinusMonetary(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $neg_mon = [USD/2 -10] + monetary $pos_mon = -$neg_mon + } + send $pos_mon ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("USD/2") + $r1 <- load_const(10) + $r2 <- neg_int($r1) + $r3 <- mk_monetary($r0, $r2) + $r4 <- get_amount($r3) + $r5 <- neg_int($r4) + $r6 <- get_asset($r3) + $r7 <- mk_monetary($r6, $r5) + $r8 <- get_asset($r7) + set_current_asset($r8) + $r9 <- get_amount($r7) + $r10 <- load_const("src") + $r11 <- load_const(0) + $r12 <- pull_account(account: $r10, cap: $r9, overdraft: $r11) + check_enough_funds($r12, $r9) + $r13 <- load_const("dest") + send_to_account(account: $r13) +`)) +} + +func TestBalance(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + monetary $bal = balance(@src, USD/2) + } + send $bal ( + source = @src + destination = @dest + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("src") + $r1 <- load_const("USD/2") + $r2 <- balance($r0, $r1) + assert_non_negative_balance($r2, $r0) + $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(account: $r8) +`)) +} + +func TestAccountInterpolation(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + string $id = "alice" + } + send [USD/2 10] ( + source = @world + destination = @users:$id:wallet + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const("alice") + $r1 <- load_const("USD/2") + $r2 <- load_const(10) + $r3 <- mk_monetary($r1, $r2) + $r4 <- get_asset($r3) + set_current_asset($r4) + $r5 <- get_amount($r3) + $r6 <- load_const("world") + $r7 <- load_const(0) + $r8 <- pull_account(account: $r6, cap: $r5, overdraft: $r7) + check_enough_funds($r8, $r5) + $r9 <- load_const("users") + $r10 <- load_const(":") + $r11 <- load_const(":") + $r12 <- load_const("wallet") + $r13 <- add_string($r9, $r10) + $r14 <- add_string($r13, $r0) + $r15 <- add_string($r14, $r11) + $r16 <- add_string($r15, $r12) + assert_valid_account($r16) + send_to_account(account: $r16) +`)) +} + +func TestAccountInterpolationInt(t *testing.T) { + out := getCompiledOutput(t, ` + vars { + number $n = 42 + } + send [USD/2 10] ( + source = @world + destination = @account:$n + ) + `) + + snaps.MatchInlineSnapshot(t, out, snaps.Inline(` + $r0 <- load_const(42) + $r1 <- load_const("USD/2") + $r2 <- load_const(10) + $r3 <- mk_monetary($r1, $r2) + $r4 <- get_asset($r3) + set_current_asset($r4) + $r5 <- get_amount($r3) + $r6 <- load_const("world") + $r7 <- load_const(0) + $r8 <- pull_account(account: $r6, cap: $r5, overdraft: $r7) + check_enough_funds($r8, $r5) + $r9 <- load_const("account") + $r10 <- load_const(":") + $r11 <- int_to_string($r0) + $r12 <- add_string($r9, $r10) + $r13 <- add_string($r12, $r11) + assert_valid_account($r13) + send_to_account(account: $r13) +`)) +} + +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(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_asset($r12) + assert_same_asset($r13, $r3) + $r14 <- get_amount($r12) + $r15 <- min_int($r14, $r6) + $r16 <- load_const("b") + $r17 <- load_const(0) + $r18 <- pull_account(account: $r16, cap: $r15, overdraft: $r17) + $r5 <- add_int($r5, $r18) + $r6 <- sub_int($r6, $r18) + jmp_if_zero($r6, #inorder_end_0) + $r19 <- load_const("c") + $r20 <- load_const(0) + $r21 <- pull_account(account: $r19, cap: $r6, overdraft: $r20) + $r5 <- add_int($r5, $r21) +#inorder_end_0 + check_enough_funds($r5, $r4) + $r22 <- load_const("dest") + send_to_account(account: $r22) +`)) +} + +func TestDestInorder(t *testing.T) { + out := getCompiledOutput(t, ` + send [USD/2 10] ( + source = @world + destination = { + max [USD/2 4] to @d1 + remaining to @d2 + } + ) + `) + + 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("world") + $r6 <- load_const(0) + $r7 <- pull_account(account: $r5, cap: $r4, overdraft: $r6) + check_enough_funds($r7, $r4) + $r8 <- int_copy($r7) + $r9 <- load_const("USD/2") + $r10 <- load_const(4) + $r11 <- mk_monetary($r9, $r10) + $r12 <- get_asset($r11) + assert_same_asset($r12, $r3) + $r13 <- get_amount($r11) + $r14 <- min_int($r8, $r13) + $r15 <- load_const("d1") + send_to_account(account: $r15, cap: $r14) + $r8 <- sub_int($r8, $r14) + $r16 <- load_const("d2") + send_to_account(account: $r16, cap: $r8) +`)) +} diff --git a/internal/compiler/e2e_test.go b/internal/compiler/e2e_test.go new file mode 100644 index 00000000..6a458b2e --- /dev/null +++ b/internal/compiler/e2e_test.go @@ -0,0 +1,963 @@ +package compiler_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "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 + metadata map[string]map[string]string +} + +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) +} + +func (s e2eStore) GetMetadata(account, key string) (string, bool) { + v, ok := s.metadata[account][key] + return v, ok +} + +// 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) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + machine := vm.NewVm(program) + res, 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, res.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) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + 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) + res, 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, res.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) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + 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) + res, 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, res.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) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + // 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) +} + +// TestE2E_DestinationInorder exercises a destination-inorder split end-to-end: +// 100 pulled from @world is distributed as `max [USD/2 30] to @x; remaining to +// @y`, so @x must get 30 and @y the remaining 70. +func TestE2E_DestinationInorder(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + max [USD/2 30] to @x + remaining to @y + } + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{}} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "x", Asset: "USD/2", Amount: big.NewInt(30)}, + {Source: "world", Destination: "y", Asset: "USD/2", Amount: big.NewInt(70)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_DestinationKept exercises a `kept` clause: of 100 pulled from @world, +// 30 is kept (refunded, no posting) and the remaining 70 goes to @y. +func TestE2E_DestinationKept(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + max [USD/2 30] kept + remaining to @y + } + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{}} + + machine := vm.NewVm(program) + res, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + + // only the remaining 70 is posted; the kept 30 produces no posting + want := []runtime.Posting{ + {Source: "world", Destination: "y", Asset: "USD/2", Amount: big.NewInt(70)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +// TestE2E_DestinationAllotment splits the pulled amount by portions. 100 from +// @world with { 1/2 to @a; remaining to @b } => a=50, b=50. +func TestE2E_DestinationAllotment(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/2 to @a + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(50)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(50)}, + }, postings) +} + +// TestE2E_DestinationAllotmentThirds exercises the floor-then-distribute-leftover +// rounding: 100 by thirds => 34, 33, 33 (the leftover unit goes to the earliest). +func TestE2E_DestinationAllotmentThirds(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/3 to @a + 1/3 to @b + remaining to @c + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(34)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(33)}, + {Source: "world", Destination: "c", Asset: "USD/2", Amount: big.NewInt(33)}, + }, postings) +} + +// TestE2E_SourceAllotment splits the requested amount across sub-sources, pulling +// each exactly. 100 with { 1/4 from @s1; remaining from @s2 } => 25 from s1, 75 +// from s2. +func TestE2E_SourceAllotment(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/4 from @s1 + remaining from @s2 + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s1", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "s2", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "s1", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(25)}, + {Source: "s2", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(75)}, + }, postings) +} + +// TestE2E_SourceAllotmentThirds checks the rounding split on the source side too: +// 100 by thirds => 34, 33, 33. +func TestE2E_SourceAllotmentThirds(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/3 from @a + 1/3 from @b + remaining from @c + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(1000), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(34)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(33)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(33)}, + }, postings) +} + +// TestE2E_SourceAllotmentInsufficient: a sub-source must provide its exact share, +// else MissingFunds. s1 only has 10 but its 1/2 share of 100 is 50. +func TestE2E_SourceAllotmentInsufficient(t *testing.T) { + src := ` + send [USD/2 100] ( + source = { + 1/2 from @s1 + remaining from @s2 + } + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s1", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "s2", Asset: "USD/2", Color: ""}: big.NewInt(1000), + }} + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, store) + require.IsType(t, vm.MissingFundsError{}, execErr) +} + +// TestE2E_AllotmentOverSum: portions summing to > 1 must error (leftover < 0). +func TestE2E_AllotmentOverSum(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 2/3 to @a + 2/3 to @b + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) + allotErr := execErr.(vm.InvalidAllotmentSum) + require.Equal(t, "4/3", allotErr.ActualSum.String()) + require.EqualError(t, allotErr, "invalid allotment: portions must sum to 1, got 4/3") +} + +// TestE2E_AllotmentUnderSum: without a `remaining` clause the portions must sum +// to exactly 1, so 1/3 + 1/3 = 2/3 must error. +func TestE2E_AllotmentUnderSum(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/3 to @a + 1/3 to @b + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.InvalidAllotmentSum{}, execErr) +} + +// TestE2E_AllotmentExactNoRemaining: a no-remaining allotment summing to exactly +// 1 is valid. +func TestE2E_AllotmentExactNoRemaining(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/4 to @a + 3/4 to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(25)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(75)}, + }, postings) +} + +// TestE2E_AllotmentRemainingOnly: `{ remaining to @dest }` is 100% (leftover = 1), +// which must remain valid (a `< 1` check would wrongly reject it). +func TestE2E_AllotmentRemainingOnly(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + remaining to @dest + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) +} + +func TestE2E_IntAddition(t *testing.T) { + src := ` + send [USD/2 4 + 6] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_IntSubtraction(t *testing.T) { + src := ` + send [USD/2 16 - 6] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_MonetaryAddition(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 3] + monetary $b = [USD/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_MonetarySubtraction(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 30] + monetary $b = [USD/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_MonetarySubtractionAssetMismatch(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 30] + monetary $b = [EUR/2 20] + } + send $a - $b ( + source = @src + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + _, execErr := vm.Exec(vm.NewVm(program), nil, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_MonetaryAdditionAssetMismatch(t *testing.T) { + src := ` + vars { + monetary $a = [USD/2 3] + monetary $b = [EUR/2 7] + } + send $a + $b ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + _, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_GetAmount(t *testing.T) { + src := ` + vars { + monetary $m = [USD/2 42] + number $n = get_amount($m) + } + send [USD/2 $n] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + }, res.Postings) +} + +func TestE2E_GetAsset(t *testing.T) { + src := ` + vars { + monetary $m = [USD/2 42] + asset $a = get_asset($m) + } + send [$a 10] ( + source = @src + destination = @dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + store := e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }} + + res, execErr := vm.Exec(vm.NewVm(program), nil, store) + require.Nil(t, execErr) + + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, res.Postings) +} + +func TestE2E_PrefixMinusNumber(t *testing.T) { + // $neg = -10 (prefix on literal), $pos = -$neg = 10 (prefix on var) + src := ` + vars { + number $neg = -10 + number $pos = -$neg + } + send [USD/2 $pos] ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_PrefixMinusMonetary(t *testing.T) { + // $neg_mon = [USD/2 -10], -$neg_mon = [USD/2 10] + src := ` + vars { + monetary $neg_mon = [USD/2 -10] + monetary $pos_mon = -$neg_mon + } + send $pos_mon ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_Balance(t *testing.T) { + // $bal = balance(@src, USD/2) reads @src's balance (100), then sends it all + src := ` + vars { + monetary $bal = balance(@src, USD/2) + } + send $bal ( + source = @src + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) +} + +func TestE2E_AccountInterpolation(t *testing.T) { + // destination = @users:<$id>:wallet, with $id = "alice" + src := ` + vars { + string $id = "alice" + } + send [USD/2 10] ( + source = @world + destination = @users:$id:wallet + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "users:alice:wallet", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_AccountInterpolationInt(t *testing.T) { + // destination = @account:<$n>, with $n = 42 + src := ` + vars { + number $n = 42 + } + send [USD/2 10] ( + source = @world + destination = @account:$n + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "account:42", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_BoundedOverdraft(t *testing.T) { + src := ` + send [USD/2 42] ( + source = @a allowing overdraft up to [USD/2 5] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(40), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + }, postings) +} + +func TestE2E_NestedDestination(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/2 to { + max [USD/2 10] to @x + remaining to @a + } + remaining to @b + } + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "x", Asset: "USD/2", Amount: big.NewInt(10)}, + {Source: "world", Destination: "a", Asset: "USD/2", Amount: big.NewInt(40)}, + {Source: "world", Destination: "b", Asset: "USD/2", Amount: big.NewInt(50)}, + }, postings) +} + +func TestE2E_SendAll(t *testing.T) { + src := `send [USD/2 *] (source = @a destination = @dest)` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(30), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(30)}, + }, postings) +} + +func TestE2E_UncappedBoundedOverdraft(t *testing.T) { + src := ` + send [USD/2 *] ( + source = @a allowing overdraft up to [USD/2 5] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(40), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(45)}, + }, postings) +} + +func TestE2E_SendAllMultiSource(t *testing.T) { + // unbounded inorder: pull everything from each source in order and sum it + src := ` + send [USD/2 *] ( + source = { + @a + max [USD/2 5] from @b + @c + } + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(10), + {Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100), + {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(7), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + {Source: "b", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(5)}, + {Source: "c", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(7)}, + }, postings) +} + +func TestE2E_SendAllNegativeOverdraftBoundClamped(t *testing.T) { + // a negative overdraft bound is clamped to 0 in the unbounded path, so only + // the positive balance is sent (mirrors the interpreter's NonNeg). + src := ` + send [COIN *] ( + source = @s allowing overdraft up to [COIN -10] + destination = @dest + ) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "s", Asset: "COIN", Color: ""}: big.NewInt(1), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "s", Destination: "dest", Asset: "COIN", Amount: big.NewInt(1)}, + }, postings) +} + +func TestE2E_CapAssetMismatch(t *testing.T) { + src := ` + send [USD/2 100] ( + source = max [EUR/2 5] from @a + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_OverdraftAssetMismatch(t *testing.T) { + src := ` + send [USD/2 42] ( + source = @a allowing overdraft up to [EUR/2 5] + destination = @dest + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.AssetMismatchError{}, execErr) +} + +func TestE2E_Save(t *testing.T) { + // save 30 of @a's 100, so the send-all only takes the remaining 70 + src := ` + save [USD/2 30] from @a + send [USD/2 *] (source = @a destination = @dest) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "a", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(70)}, + }, postings) +} + +func TestE2E_InternalVar(t *testing.T) { + src := ` + vars { account $acc = @src } + send [USD/2 10] (source = $acc destination = @dest) + ` + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "src", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(10)}, + }, postings) +} + +func TestE2E_OverdraftFunction(t *testing.T) { + src := ` + vars { monetary $od = overdraft(@acc, USD/2) } + send $od (source = @world destination = @dest) + ` + // negative balance -> overdraft is the debt + postings := runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(-100), + }}) + requirePostingsEqual(t, []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(100)}, + }, postings) + + // positive balance -> overdraft is 0, nothing sent + postings = runE2E(t, src, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(100), + }}) + requirePostingsEqual(t, []runtime.Posting{}, postings) +} + +func TestE2E_BalanceNegativeErrors(t *testing.T) { + src := ` + vars { monetary $b = balance(@acc, USD/2) } + send $b (source = @world destination = @dest) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{ + {Account: "acc", Asset: "USD/2", Color: ""}: big.NewInt(-1), + }}) + require.IsType(t, vm.NegativeBalanceError{}, execErr) +} + +func TestE2E_DivideByZero(t *testing.T) { + src := ` + send [USD/2 100] ( + source = @world + destination = { + 1/0 to @a + remaining kept + } + ) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, nil, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.IsType(t, vm.DivideByZeroError{}, execErr) +} + +func runE2E(t *testing.T, src string, store e2eStore) []runtime.Posting { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + _, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + machine := vm.NewVm(program) + res, execErr := vm.Exec(machine, nil, store) + require.Nil(t, execErr) + return res.Postings +} + +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/scripts_test.go b/internal/compiler/scripts_test.go new file mode 100644 index 00000000..73f7dfd6 --- /dev/null +++ b/internal/compiler/scripts_test.go @@ -0,0 +1,158 @@ +package compiler_test + +import ( + "encoding/json" + "maps" + "math/big" + "path/filepath" + "slices" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/specs_format" + "github.com/formancehq/numscript/internal/vm" + + "github.com/stretchr/testify/require" +) + +const scriptsFolder = "../interpreter/testdata/script-tests" + +// scriptsBlacklist lists spec files the compiler+VM can't run yet: unimplemented +// core features (variables, metadata, ...) and everything under a feature flag +// (all in experimental/). Delete entries as features land, until it's empty. +var scriptsBlacklist = []string{ + // feature-flagged (experimental) — not core numscript + "experimental/scoped-function/allotment.num", + "experimental/scoped-function/balance.num", + "experimental/scoped-function/capped.num", + "experimental/scoped-function/color-and-scope.num", + "experimental/scoped-function/overdraft.num", + "experimental/scoped-function/read-account-meta.num", + "experimental/scoped-function/save.num", + "experimental/scoped-function/set-account-meta.num", + "experimental/scoped-function/simple.num", + "experimental/asset-colors/color-inorder-send-all.num", + "experimental/asset-colors/color-inorder.num", + "experimental/asset-colors/color-restrict-balance-when-missing-funds.num", + "experimental/asset-colors/color-restrict-balance.num", + "experimental/asset-colors/color-restriction-in-send-all.num", + "experimental/asset-colors/color-send-overdrat.num", + "experimental/asset-colors/color-send.num", + "experimental/asset-colors/color-with-asset-precision.num", + "experimental/asset-colors/empty-color.num", + "experimental/asset-colors/no-double-spending-in-colored-send-all.num", + "experimental/asset-colors/no-double-spending-in-colored-send.num", + "experimental/asset-scaling/no-solution.num", + "experimental/asset-scaling/scaling-all-allotment.num", + "experimental/asset-scaling/scaling-allotment.num", + "experimental/asset-scaling/scaling-kept.num", + "experimental/asset-scaling/scaling-send-all.num", + "experimental/asset-scaling/scaling-with-oneof.num", + "experimental/asset-scaling/scaling.num", + "experimental/asset-scaling/update-swap-account-balance.num", + "experimental/oneof/oneof-all-failing.num", + "experimental/oneof/oneof-destination-first-clause.num", + "experimental/oneof/oneof-destination-remaining-clause.num", + "experimental/oneof/oneof-destination-second-clause.num", + "experimental/oneof/oneof-in-send-all.num", + "experimental/oneof/oneof-in-source-send-first-branch.num", + "experimental/oneof/oneof-in-source.num", + "experimental/oneof/oneof-singleton.num", + "experimental/oneof/update-balances-with-oneof.num", + // unimplemented core features + "feature-flag-syntax.num", +} + +func TestCompilerScripts(t *testing.T) { + rawSpecs, err := specs_format.ReadSpecsFiles([]string{scriptsFolder}) + require.NoError(t, err) + + for _, rawSpec := range rawSpecs { + rel, err := filepath.Rel(scriptsFolder, rawSpec.NumscriptPath) + require.NoError(t, err) + + t.Run(rel, func(t *testing.T) { + if slices.Contains(scriptsBlacklist, rel) { + t.Skip("blacklisted: not supported yet") + } + + var specs specs_format.Specs + require.NoError(t, json.Unmarshal(rawSpec.SpecsFileContent, &specs)) + + defer func() { + if r := recover(); r != nil { + t.Errorf("panic: %v", r) + } + }() + + runScriptSpec(t, specs, rawSpec.NumscriptContent) + }) + } +} + +func runScriptSpec(t *testing.T, specs specs_format.Specs, src string) { + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + enc, program, cErr := compiler.Compile(parsed.Value) + require.Nil(t, cErr) + + for _, tc := range specs.TestCases { + if tc.Skip { + continue + } + caseVars := map[string]string{} + maps.Copy(caseVars, specs.Vars) + maps.Copy(caseVars, tc.Vars) + vars, encErr := enc.Encode(caseVars) + require.NoError(t, encErr, "case %q: encode vars", tc.It) + + machine := vm.NewVm(program) + store := scriptStore(specs.Balances, tc.Balances, specs.Meta, tc.Meta) + res, execErr := vm.Exec(machine, &vars, store) + + if tc.ExpectMissingFunds { + require.IsType(t, vm.MissingFundsError{}, execErr, "case %q", tc.It) + continue + } + require.Nil(t, execErr, "case %q: unexpected error: %v", tc.It, execErr) + + if tc.ExpectPostings != nil { + got := make([]interpreter.Posting, len(res.Postings)) + for i, p := range res.Postings { + got[i] = interpreter.Posting{ + Source: p.Source, + Destination: p.Destination, + Amount: p.Amount, + Asset: p.Asset, + Color: p.Color, + } + } + require.Equal(t, tc.ExpectPostings, got, "case %q", tc.It) + } + // VM metadata output is stringified and not yet mapped to the typed spec + // contract, so metadata assertions are covered by the vm package tests. + } +} + +func scriptStore(balancesOuter, balancesInner interpreter.Balances, metaOuter, metaInner interpreter.AccountsMetadata) e2eStore { + m := map[runtime.PairKey]*big.Int{} + for _, b := range append(append(interpreter.Balances{}, balancesOuter...), balancesInner...) { + m[runtime.PairKey{Account: b.Account, Asset: b.Asset, Color: b.Color}] = b.Amount + } + + meta := map[string]map[string]string{} + for _, src := range []interpreter.AccountsMetadata{metaOuter, metaInner} { + for _, row := range src { + if meta[row.Account] == nil { + meta[row.Account] = map[string]string{} + } + meta[row.Account][row.Key] = row.Value + } + } + + return e2eStore{balances: m, metadata: meta} +} diff --git a/internal/compiler/vars_e2e_test.go b/internal/compiler/vars_e2e_test.go new file mode 100644 index 00000000..be31ceb3 --- /dev/null +++ b/internal/compiler/vars_e2e_test.go @@ -0,0 +1,118 @@ +package compiler_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/compiler" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/vm" + "github.com/stretchr/testify/require" +) + +func TestE2E_ExternalVars(t *testing.T) { + src := ` + vars { + account $dest + monetary $m + } + send $m ( + source = @world + destination = $dest + ) + ` + + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + enc, program, err := compiler.Compile(parsed.Value) + require.NoError(t, err) + + vars, err := enc.Encode(map[string]string{ + "dest": "alice", + "m": "USD/2 100", + }) + require.NoError(t, err) + + machine := vm.NewVm(program) + res, execErr := vm.Exec(machine, &vars, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "alice", Asset: "USD/2", Amount: big.NewInt(100)}, + } + requirePostingsEqual(t, want, res.Postings) +} + +func TestE2E_InvalidInterpolatedAccount(t *testing.T) { + src := ` + vars { string $status } + set_tx_meta("k", @user:$status) + ` + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + + enc, program, err := compiler.Compile(parsed.Value) + require.NoError(t, err) + + vars, err := enc.Encode(map[string]string{"status": "!invalid acc.."}) + require.NoError(t, err) + + machine := vm.NewVm(program) + _, execErr := vm.Exec(machine, &vars, e2eStore{balances: map[runtime.PairKey]*big.Int{}}) + require.Equal(t, vm.InvalidAccountName{Name: "user:!invalid acc.."}, execErr) +} + +func compileEncoder(t *testing.T, src string) compiler.VarsEncoder { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + enc, _, err := compiler.Compile(parsed.Value) + require.NoError(t, err) + return enc +} + +// A var of each type decomposes into its int/string slots, in declaration order. +func TestVarsEncoder_AllTypes(t *testing.T) { + enc := compileEncoder(t, ` + vars { + number $n + account $acc + portion $p + monetary $m + asset $a + string $s + } + send [COIN 0] (source = @world destination = @world) + `) + + vars, err := enc.Encode(map[string]string{ + "n": "42", + "acc": "alice", + "p": "1/4", + "m": "USD/2 100", + "a": "EUR", + "s": "hello", + }) + require.NoError(t, err) + + // str slots: acc, m.asset, a, s int slots: n, p.num, p.den, m.amount + require.Equal(t, []string{"alice", "USD/2", "EUR", "hello"}, vars.StringsPool) + require.Equal(t, []big.Int{ + *big.NewInt(42), *big.NewInt(1), *big.NewInt(4), *big.NewInt(100), + }, vars.IntsPool) +} + +func TestVarsEncoder_Errors(t *testing.T) { + enc := compileEncoder(t, ` + vars { number $n account $acc } + send [COIN 0] (source = @world destination = @world) + `) + + _, err := enc.Encode(map[string]string{"n": "1"}) + require.ErrorContains(t, err, "missing variable: $acc") + + _, err = enc.Encode(map[string]string{"n": "not-a-number", "acc": "alice"}) + require.ErrorContains(t, err, "variable $n") +} diff --git a/internal/compiler/vars_encoder.go b/internal/compiler/vars_encoder.go new file mode 100644 index 00000000..29f06220 --- /dev/null +++ b/internal/compiler/vars_encoder.go @@ -0,0 +1,89 @@ +package compiler + +import ( + "fmt" + "math/big" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/formancehq/numscript/internal/typecheck" + "github.com/formancehq/numscript/internal/vm" +) + +type VarsEncoder struct { + decls []varDecl + nStr int + nInt int +} + +type varDecl struct { + name string + typ typecheck.Type +} + +// TODO review AI blob +func (e VarsEncoder) Encode(vars map[string]string) (vm.Vars, error) { + strs := make([]string, 0, e.nStr) + ints := make([]big.Int, 0, e.nInt) + + for _, d := range e.decls { + raw, ok := vars[d.name] + if !ok { + return vm.Vars{}, fmt.Errorf("missing variable: $%s", d.name) + } + + var err error + strs, ints, err = appendVar(strs, ints, d.typ, raw) + if err != nil { + return vm.Vars{}, fmt.Errorf("variable $%s: %w", d.name, err) + } + } + + return vm.Vars{StringsPool: strs, IntsPool: ints}, nil +} + +// TODO review AI blob +func appendVar(strs []string, ints []big.Int, typ typecheck.Type, raw string) ([]string, []big.Int, error) { + switch typ { + case typecheck.TypeNumber: + n, ok := runtime.ParseNumber(raw) + if !ok { + return strs, ints, fmt.Errorf("invalid number: %q", raw) + } + ints = append(ints, *n) + + case typecheck.TypeString: + strs = append(strs, raw) + + case typecheck.TypeAccount: + if !runtime.ValidateAccount(raw) { + return strs, ints, fmt.Errorf("invalid account: %q", raw) + } + strs = append(strs, raw) + + case typecheck.TypeAsset: + if !runtime.ValidateAsset(raw) { + return strs, ints, fmt.Errorf("invalid asset: %q", raw) + } + strs = append(strs, raw) + + case typecheck.TypePortion: + r, err := runtime.ParsePortion(raw) + if err != nil { + return strs, ints, err + } + ints = append(ints, *r.Num(), *r.Denom()) + + case typecheck.TypeMonetary: + asset, amount, err := runtime.ParseMonetary(raw) + if err != nil { + return strs, ints, err + } + strs = append(strs, asset) + ints = append(ints, *amount) + + default: + panic("unexpected var type: " + typ) + } + + return strs, ints, nil +} diff --git a/internal/compiler/virtual_instruction.go b/internal/compiler/virtual_instruction.go new file mode 100644 index 00000000..7de30f2f --- /dev/null +++ b/internal/compiler/virtual_instruction.go @@ -0,0 +1,215 @@ +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{} + opAddString struct{} + opSubPortion struct{} + opMakePortion struct{} + opMakeMonetary struct{} +) + +type unKind interface { + fmt.Stringer + sig() unaryOpSig +} + +type ( + opIntCopy struct{} + opPortionCopy struct{} + opGetAsset struct{} + opGetAmount struct{} + opNegInt struct{} + opIntToString struct{} + opPortionToString struct{} + opMonetaryToString struct{} +) + +type varType interface { + fmt.Stringer + assembleLoad(a *assembler, dest reg, index uint16) error +} + +type ( + varInt struct{} + varStr struct{} +) + +type metaType interface { + fmt.Stringer + assembleMeta(a *assembler, dest, account, key reg) error +} + +type ( + metaStr struct{} + metaInt struct{} + metaPortion struct{} + metaMonetary 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 + } + save struct { + account reg // str + asset reg // str + amount *reg // int; nil = save all + } + makeAllotment struct { + dest []reg // int, len N + amount reg // int + portions []reg // portion, len N + } + checkEnoughFunds struct{ got, needed reg } // int + assertLeftover struct { + portion reg // the allotment leftover (1 - sum of the given portions) + exact bool // no `remaining` clause: leftover must be exactly 0, else >= 0 + } + setCurrentAsset struct{ asset reg } // str + assertSameAsset struct{ left, right reg } // str, str + assertValidAccount struct{ account reg } // str + assertNonNegativeBalance struct{ balance, account reg } // monetary, str + setTxMeta struct{ key, value reg } // str, str + setAccountMeta struct{ account, key, value reg } // str, str, str + metaVar struct { + dest reg + account, key reg // str, str + typ metaType + } + fetchBalance struct { + dest reg // monetary + account, asset reg // str, str + } // reads the run-state (impure) + loadVar struct { + dest reg + typ varType + index uint16 + } + 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 save) dests() []reg { return nil } +func (i save) sources() []reg { + regs := []reg{i.account, i.asset} + if i.amount != nil { + regs = append(regs, *i.amount) + } + return regs +} + +func (i assertLeftover) dests() []reg { return nil } +func (i assertLeftover) sources() []reg { return []reg{i.portion} } + +func (i setCurrentAsset) dests() []reg { return nil } +func (i setCurrentAsset) sources() []reg { return []reg{i.asset} } + +func (i assertSameAsset) dests() []reg { return nil } +func (i assertSameAsset) sources() []reg { return []reg{i.left, i.right} } + +func (i assertValidAccount) dests() []reg { return nil } +func (i assertValidAccount) sources() []reg { return []reg{i.account} } + +func (i assertNonNegativeBalance) dests() []reg { return nil } +func (i assertNonNegativeBalance) sources() []reg { return []reg{i.balance, i.account} } + +func (i setTxMeta) dests() []reg { return nil } +func (i setTxMeta) sources() []reg { return []reg{i.key, i.value} } + +func (i setAccountMeta) dests() []reg { return nil } +func (i setAccountMeta) sources() []reg { return []reg{i.account, i.key, i.value} } + +func (i metaVar) dests() []reg { return []reg{i.dest} } +func (i metaVar) sources() []reg { return []reg{i.account, i.key} } + +func (i fetchBalance) dests() []reg { return []reg{i.dest} } +func (i fetchBalance) sources() []reg { return []reg{i.account, i.asset} } + +func (i loadVar) dests() []reg { return []reg{i.dest} } +func (i loadVar) 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..013a032c --- /dev/null +++ b/internal/compiler/virtual_instruction_dump.go @@ -0,0 +1,170 @@ +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 (opAddString) String() string { return "add_string" } +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 (opNegInt) String() string { return "neg_int" } +func (opIntToString) String() string { return "int_to_string" } +func (opPortionToString) String() string { return "portion_to_string" } +func (opMonetaryToString) String() string { return "monetary_to_string" } + +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("account", i.account), optLabel("cap", i.cap)) + return fmt.Sprintf("send_to_account(%s)", opts) +} + +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 save) String() string { + if i.amount == nil { + return fmt.Sprintf("save(account: %s, asset: %s)", i.account, i.asset) + } + return fmt.Sprintf("save(account: %s, asset: %s, amount: %s)", i.account, i.asset, *i.amount) +} + +func (i assertLeftover) String() string { + if i.exact { + return fmt.Sprintf("assert_leftover(%s, exact: true)", i.portion) + } + return fmt.Sprintf("assert_leftover(%s)", i.portion) +} + +func (i setCurrentAsset) String() string { + return fmt.Sprintf("set_current_asset(%s)", i.asset) +} + +func (i assertSameAsset) String() string { + return fmt.Sprintf("assert_same_asset(%s, %s)", i.left, i.right) +} + +func (i assertValidAccount) String() string { + return fmt.Sprintf("assert_valid_account(%s)", i.account) +} + +func (i assertNonNegativeBalance) String() string { + return fmt.Sprintf("assert_non_negative_balance(%s, %s)", i.balance, i.account) +} + +func (i setTxMeta) String() string { + return fmt.Sprintf("set_tx_meta(%s, %s)", i.key, i.value) +} + +func (i setAccountMeta) String() string { + return fmt.Sprintf("set_account_meta(%s, %s, %s)", i.account, i.key, i.value) +} + +func (i metaVar) String() string { + return fmt.Sprintf("%s <- meta<%s>(%s, %s)", i.dest, i.typ, i.account, i.key) +} + +func (metaStr) String() string { return "str" } +func (metaInt) String() string { return "int" } +func (metaPortion) String() string { return "portion" } +func (metaMonetary) String() string { return "monetary" } + +func (i fetchBalance) String() string { + return fmt.Sprintf("%s <- balance(%s, %s)", i.dest, i.account, i.asset) +} + +func (i loadVar) String() string { + return fmt.Sprintf("%s <- load_var<%s>(%d)", i.dest, i.typ, i.index) +} + +func (varInt) String() string { return "int" } +func (varStr) String() string { return "str" } + +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/accounts_metadata_test.go b/internal/interpreter/accounts_metadata_test.go index 91d0f5ee..f19d4319 100644 --- a/internal/interpreter/accounts_metadata_test.go +++ b/internal/interpreter/accounts_metadata_test.go @@ -3,6 +3,7 @@ package interpreter import ( "testing" + "github.com/formancehq/numscript/internal/runtime" "github.com/gkampitakis/go-snaps/snaps" "github.com/stretchr/testify/require" ) @@ -47,19 +48,19 @@ func TestCompareSetAccountsMetadata(t *testing.T) { func TestScopeValidation(t *testing.T) { t.Run("valid scopes", func(t *testing.T) { - require.True(t, checkScopeName("")) - require.True(t, checkScopeName("myscope")) - require.True(t, checkScopeName("x")) - require.True(t, checkScopeName("x1")) - require.True(t, checkScopeName("my_scope_with_underscores")) + require.True(t, runtime.ValidateScope("")) + require.True(t, runtime.ValidateScope("myscope")) + require.True(t, runtime.ValidateScope("x")) + require.True(t, runtime.ValidateScope("x1")) + require.True(t, runtime.ValidateScope("my_scope_with_underscores")) }) t.Run("invalid scopes", func(t *testing.T) { - require.False(t, checkScopeName("!")) - require.False(t, checkScopeName("$")) - require.False(t, checkScopeName("UPPERCASE")) - require.False(t, checkScopeName("dash-case")) - require.False(t, checkScopeName("colons:within")) + require.False(t, runtime.ValidateScope("!")) + require.False(t, runtime.ValidateScope("$")) + require.False(t, runtime.ValidateScope("UPPERCASE")) + require.False(t, runtime.ValidateScope("dash-case")) + require.False(t, runtime.ValidateScope("colons:within")) }) } diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 3b2b92aa..7f1b570c 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -7,6 +7,7 @@ import ( "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" ) type evalEnv struct { @@ -234,7 +235,7 @@ func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, Interp return "", err } - isValidColor := colorRe.Match([]byte(string(color))) + isValidColor := runtime.ValidateColor(string(color)) if !isValidColor { return "", InvalidColor{ Range: colorExpr.GetRange(), diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 73efeb11..0079f2ab 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -6,6 +6,7 @@ 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" ) func evaluateFnCall(env *evalEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { @@ -230,7 +231,7 @@ func scoped( return nil, err } - if !checkScopeName(scopeStr) { + if !runtime.ValidateScope(scopeStr) { return nil, InvalidScope{Scope: scopeStr} } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index ed6f8c50..0fa929d6 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -4,13 +4,13 @@ import ( "context" "maps" "math/big" - "regexp" "slices" "strings" "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" ) @@ -23,15 +23,7 @@ type InterpreterError interface { type Metadata = map[string]Value -type Posting struct { - Source string `json:"source"` - SourceScope string `json:"sourceScope,omitempty"` - Destination string `json:"destination"` - DestinationScope string `json:"destinationScope,omitempty"` - Amount *big.Int `json:"amount"` - Asset string `json:"asset"` - Color string `json:"color,omitempty"` -} +type Posting = runtime.Posting // newPosting builds a Posting from the source and destination addresses, // exposing each address's account and scope as the separate fields the posting @@ -65,7 +57,7 @@ func parseMonetary(source string) (Monetary, InterpreterError) { asset := parts[0] rawAmount := parts[1] - n, ok := new(big.Int).SetString(rawAmount, 10) + n, ok := runtime.ParseNumber(rawAmount) if !ok { return Monetary{}, InvalidNumberLiteral{Source: rawAmount} } @@ -98,7 +90,7 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter case analysis.TypeAsset: return NewAsset(rawValue) case analysis.TypeNumber: - n, ok := new(big.Int).SetString(rawValue, 10) + n, ok := runtime.ParseNumber(rawValue) if !ok { return nil, InvalidNumberLiteral{Source: rawValue} } @@ -119,28 +111,6 @@ func evaluateVarOrigin(env *evalEnv, type_ string, expr parser.ValueExpr) (Value return evaluateExpr(env, expr) } -const accountSegmentRegex = "[a-zA-Z0-9_-]+" - -var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") - -// https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go -func checkAccountName(addr string) bool { - return accountNameRegex.Match([]byte(addr)) -} - -var assetNameRegexp = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,16}(_[A-Z]{1,16})?(\/\d{1,6})?$`) - -// https://github.com/formancehq/ledger/blob/main/pkg/assets/asset.go -func checkAssetName(v string) bool { - return assetNameRegexp.Match([]byte(v)) -} - -var scopeRegex = regexp.MustCompile(`^[a-z0-9_]*$`) - -func checkScopeName(scope string) bool { - return scopeRegex.MatchString(scope) -} - // Check the following invariants: // - no negative postings // - no invalid account names @@ -149,9 +119,9 @@ func checkPostingInvariants(posting Posting) InterpreterError { isAmtNegative := posting.Amount.Cmp(big.NewInt(0)) == -1 isInvalidPosting := (isAmtNegative || - !checkAssetName(posting.Asset) || - !checkAccountName(posting.Source) || - !checkAccountName(posting.Destination)) + !runtime.ValidateAsset(posting.Asset) || + !runtime.ValidateAccount(posting.Source) || + !runtime.ValidateAccount(posting.Destination)) if isInvalidPosting { return InternalError{Posting: posting} @@ -574,7 +544,6 @@ func (s *programState) tryTakingExact(source parser.Source, amount MonetaryInt) return nil } -var colorRe = regexp.MustCompile("^[A-Z]*$") // PRE: overdraft >= 0 func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amount *big.Int, overdraft *big.Int, colorExpr parser.ValueExpr) (*big.Int, InterpreterError) { @@ -969,42 +938,10 @@ func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, } } -var percentRegex = regexp.MustCompile(`^([0-9]+)(?:[.]([0-9]+))?[%]$`) -var fractionRegex = regexp.MustCompile(`^([0-9]+)\s?[/]\s?([0-9]+)$`) - -// slightly edited copy-paste from: -// https://github.com/formancehq/ledger/blob/b188d0c80eadaab5024d74edc967c7005e155f7c/internal/machine/portion.go#L57 - func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) { - var res *big.Rat - var ok bool - - percentMatch := percentRegex.FindStringSubmatch(input) - if len(percentMatch) != 0 { - integral := percentMatch[1] - fractional := percentMatch[2] - res, ok = new(big.Rat).SetString(integral + "." + fractional) - if !ok { - return nil, BadPortionParsingErr{Reason: "invalid percent format", Source: input} - } - res.Mul(res, big.NewRat(1, 100)) - } else { - fractionMatch := fractionRegex.FindStringSubmatch(input) - if len(fractionMatch) != 0 { - numerator := fractionMatch[1] - denominator := fractionMatch[2] - res, ok = new(big.Rat).SetString(numerator + "/" + denominator) - if !ok { - return nil, BadPortionParsingErr{Reason: "invalid fractional format", Source: input} - } - } - } - if res == nil { - return nil, BadPortionParsingErr{Reason: "invalid format", Source: input} - } - - if res.Cmp(big.NewRat(0, 1)) == -1 || res.Cmp(big.NewRat(1, 1)) == 1 { - return nil, BadPortionParsingErr{Reason: "portion must be between 0% and 100% inclusive", Source: input} + res, err := runtime.ParsePortion(input) + if err != nil { + return nil, BadPortionParsingErr{Reason: err.Error(), Source: input} } return res, nil diff --git a/internal/interpreter/value.go b/internal/interpreter/value.go index e0c13507..e88911cc 100644 --- a/internal/interpreter/value.go +++ b/internal/interpreter/value.go @@ -9,6 +9,7 @@ import ( "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/runtime" ) type Value interface { @@ -42,14 +43,14 @@ func (Portion) value() {} func (Asset) value() {} func NewAccountAddress(src string) (AccountAddress, InterpreterError) { - if !checkAccountName(src) { + if !runtime.ValidateAccount(src) { return AccountAddress{}, InvalidAccountName{Name: src} } return AccountAddress{Name: src}, nil } func NewAsset(src string) (Asset, InterpreterError) { - if !checkAssetName(src) { + if !runtime.ValidateAsset(src) { return Asset(""), InvalidAsset{Name: src} } return Asset(src), nil @@ -132,10 +133,10 @@ func ParseTaggedValue(data []byte) (Value, error) { if err := json.Unmarshal(data, &v); err != nil { return nil, err } - if !checkAccountName(v.Name) { + if !runtime.ValidateAccount(v.Name) { return nil, fmt.Errorf("invalid account name: %q", v.Name) } - if !checkScopeName(v.Scope) { + if !runtime.ValidateScope(v.Scope) { return nil, fmt.Errorf("invalid account scope: %q", v.Scope) } return AccountAddress{Name: v.Name, Scope: v.Scope}, 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/metadata.go b/internal/runtime/metadata.go new file mode 100644 index 00000000..e7603936 --- /dev/null +++ b/internal/runtime/metadata.go @@ -0,0 +1,53 @@ +package runtime + +import ( + "github.com/formancehq/numscript/internal/utils" +) + +type AccountMetadata = map[string]string +type AccountsMetadata map[string]AccountMetadata + +func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { + return utils.MapGetOrPutDefault(m, account, func() AccountMetadata { + return AccountMetadata{} + }) +} + +func (m AccountsMetadata) DeepClone() AccountsMetadata { + cloned := make(AccountsMetadata) + for account, accountBalances := range m { + for asset, metadataValue := range accountBalances { + clonedAccountBalances := cloned.fetchAccountMetadata(account) + utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string { + return metadataValue + }) + } + } + return cloned +} + +func (m AccountsMetadata) Merge(update AccountsMetadata) { + for acc, accBalances := range update { + cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata { + return AccountMetadata{} + }) + + for curr, amt := range accBalances { + cachedAcc[curr] = amt + } + } +} + +func (m AccountsMetadata) PrettyPrint() string { + header := []string{"Account", "Name", "Value"} + + var rows [][]string + for account, accMetadata := range m { + for name, value := range accMetadata { + row := []string{account, name, value} + rows = append(rows, row) + } + } + + return utils.CsvPretty(header, rows, true) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 00000000..3b9b4e76 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,506 @@ +// 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. The VM leaves the scope fields empty; the interpreter fills them. +type Posting struct { + Source string `json:"source"` + SourceScope string `json:"sourceScope,omitempty"` + Destination string `json:"destination"` + DestinationScope string `json:"destinationScope,omitempty"` + Amount *big.Int `json:"amount"` + Asset string `json:"asset"` + Color string `json:"color,omitempty"` +} + +type ExecutionResult struct { + Postings []Posting `json:"postings"` + Metadata map[string]string `json:"txMeta"` + AccountsMetadata AccountsMetadata `json:"accountsMeta"` +} + +// PairKey identifies a balance slot: an (account, scope, asset, color) tuple. +// Exported so a Store mock/adapter can build the same keys. Scope is a second +// dimension of the account (scoped accounts hold separate balances); the VM +// leaves it empty. +type PairKey struct { + Account string + Scope string + Asset string + Color string +} + +// source is an internal funding entry queued by Pull / PullUncapped. It carries +// the scope and color of the funds so Send can filter and so postings/refunds +// land on the right (scope, asset, color) balance. The amount is privately owned +// by the queue and may be mutated in place. +type source struct { + account string + scope 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, scope, asset, color string) bool { + _, ok := s.balances[PairKey{account, scope, 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, scope string) []AccountBalance { + var out []AccountBalance + for key, amount := range s.balances { + if key.Account == account && key.Scope == scope { + 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, scope, asset, color string) *big.Int { + if asset == "" { + asset = s.currentAsset + } + return new(big.Int).Set(s.cachedBalance(account, scope, 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, scope string, cap *big.Int, overdraft *big.Int, color string) { + currentBal := s.cachedBalance(src, scope, 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, scope, 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 + max(0, overdraftBound)) of src's (currentAsset, color) +// balance, queuing it only when positive, and writes the available amount into +// out. As in Pull, a negative overdraftBound is clamped to 0 (a nonsensical +// negative bound never eats into the positive balance); pass big.NewInt(0) for +// the "balance only" default. +// +// 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, scope string, overdraftBound *big.Int, color string) { + currentBal := s.cachedBalance(src, scope, s.currentAsset, color) + + // available = max(0, currentBal + max(0, overdraftBound)) + out.Set(currentBal) + if overdraftBound.Sign() > 0 { + out.Add(out, 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, scope, 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, destScope 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,scope,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, destScope, 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, destScope, 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, destScope string, color *string) { + asset := s.currentAsset + i := 0 + for i < len(s.sources) { + s.compactAt(i) // merge the run of adjacent same-(account,scope,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, destScope, 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, srcScope, dst, dstScope, asset, color string, amount *big.Int) { + if amount.Sign() <= 0 { + return + } + s.addToBalance(src, srcScope, asset, color, new(big.Int).Neg(amount)) + s.addPosting(src, srcScope, dst, dstScope, 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, scope, asset, color string, amount *big.Int) { + cur := s.cachedBalance(account, scope, 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, scope, 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, src.scope, 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, destScope string, src source, asset string, amount *big.Int) { + if dest != nil { + s.addPosting(src.account, src.scope, *dest, destScope, asset, src.color, amount) + } else if amount.Sign() > 0 { + // refund the source: consume funding, emit no posting + s.addToBalance(src.account, src.scope, 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, scope, asset, color string) *big.Int { + key := PairKey{account, scope, asset, color} + if v, ok := s.balances[key]; ok { + return v + } + // the Store is scope-agnostic; scoped balances are seeded via Prewarm, and + // the VM (the only path hitting the Store) never uses scopes + 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, scope, asset, color string, delta *big.Int) { + cur := s.cachedBalance(account, scope, 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, srcScope, dst, dstScope, asset, color string, amount *big.Int) { + if amount.Sign() <= 0 { + return + } + s.postings = append(s.postings, Posting{ + Source: src, + SourceScope: srcScope, + Destination: dst, + DestinationScope: dstScope, + Asset: asset, + Color: color, + Amount: new(big.Int).Set(amount), + }) + s.addToBalance(dst, dstScope, 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.scope != s.sources[i].scope || 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..9bd0b89d --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,860 @@ +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_NegativeOverdraftBoundClamped(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: 10}) + // bound clamped to 0 -> effective = max(0, 10+0) = 10 + got := pullUncapped(rs, "A", big.NewInt(-50), "") + wantReturn(t, "PullUncapped", got, 10) + wantBalance(t, rs, "A", 0) +} + +func TestPullUncapped_NegativeEffectiveNotQueued(t *testing.T) { + rs, _ := newRS(map[runtime.PairKey]int64{{"A", "", usd, ""}: -5}) + got := pullUncapped(rs, "A", big.NewInt(0), "") // max(0, -5+0) = 0 + wantReturn(t, "PullUncapped", got, 0) + wantBalance(t, rs, "A", -5) + 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/runtime/values.go b/internal/runtime/values.go new file mode 100644 index 00000000..c8422d36 --- /dev/null +++ b/internal/runtime/values.go @@ -0,0 +1,94 @@ +package runtime + +import ( + "errors" + "fmt" + "math/big" + "regexp" + "strings" +) + +// Scalar value parsing / validation: the single source of truth for the textual +// form of numscript scalar values, shared by both runtimes (the tree-walking +// interpreter and the VM) and the compiler's vars encoder. Returns primitives +// (string / *big.Int / *big.Rat) and plain errors, so each caller adapts them +// into its own value/error types. + +const accountSegmentRegex = "[a-zA-Z0-9_-]+" + +// https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go +var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") + +// https://github.com/formancehq/ledger/blob/main/pkg/assets/asset.go +var assetNameRegex = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,16}(_[A-Z]{1,16})?(\/\d{1,6})?$`) + +var percentRegex = regexp.MustCompile(`^([0-9]+)(?:[.]([0-9]+))?[%]$`) +var fractionRegex = regexp.MustCompile(`^([0-9]+)\s?[/]\s?([0-9]+)$`) + +var colorNameRegex = regexp.MustCompile("^[A-Z]*$") +var scopeNameRegex = regexp.MustCompile(`^[a-z0-9_]*$`) + +func ValidateAccount(addr string) bool { return accountNameRegex.MatchString(addr) } +func ValidateAsset(v string) bool { return assetNameRegex.MatchString(v) } +func ValidateColor(v string) bool { return colorNameRegex.MatchString(v) } +func ValidateScope(v string) bool { return scopeNameRegex.MatchString(v) } + +// ParseNumber parses a base-10 integer (arbitrary precision). +func ParseNumber(s string) (*big.Int, bool) { + return new(big.Int).SetString(s, 10) +} + +// ParsePortion parses a portion given as a percentage ("12%") or a fraction +// ("1/3"), returning it as a reduced ratio in [0, 1]. The error message is the +// reason (callers may wrap it into their own error type). +func ParsePortion(input string) (*big.Rat, error) { + var res *big.Rat + var ok bool + + percentMatch := percentRegex.FindStringSubmatch(input) + if len(percentMatch) != 0 { + integral := percentMatch[1] + fractional := percentMatch[2] + res, ok = new(big.Rat).SetString(integral + "." + fractional) + if !ok { + return nil, errors.New("invalid percent format") + } + res.Mul(res, big.NewRat(1, 100)) + } else { + fractionMatch := fractionRegex.FindStringSubmatch(input) + if len(fractionMatch) != 0 { + numerator := fractionMatch[1] + denominator := fractionMatch[2] + res, ok = new(big.Rat).SetString(numerator + "/" + denominator) + if !ok { + return nil, errors.New("invalid fractional format") + } + } + } + if res == nil { + return nil, errors.New("invalid format") + } + + if res.Cmp(big.NewRat(0, 1)) == -1 || res.Cmp(big.NewRat(1, 1)) == 1 { + return nil, errors.New("portion must be between 0% and 100% inclusive") + } + + return res, nil +} + +// ParseMonetary parses "ASSET AMOUNT" (e.g. "USD/2 100") into its asset and +// amount. +func ParseMonetary(source string) (asset string, amount *big.Int, err error) { + parts := strings.Split(source, " ") + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid monetary: %q", source) + } + if !ValidateAsset(parts[0]) { + return "", nil, fmt.Errorf("invalid asset: %q", parts[0]) + } + n, ok := ParseNumber(parts[1]) + if !ok { + return "", nil, fmt.Errorf("invalid monetary amount: %q", parts[1]) + } + return parts[0], n, nil +} diff --git a/internal/typecheck/typecheck.go b/internal/typecheck/typecheck.go new file mode 100644 index 00000000..afbcba8e --- /dev/null +++ b/internal/typecheck/typecheck.go @@ -0,0 +1,447 @@ +// Package typecheck is the shared, side-effect-free type checker for numscript. +// It synthesizes the (base) type of every expression, resolves variable types +// from their declarations, and reports type/name/arity errors — fault-tolerantly +// (it collects all errors instead of bailing, using TypeAny to avoid cascades). +// +// It is meant to be the single source of truth for typing, consumed both by the +// analysis module (LSP/CI) and by the compiler. It intentionally does NOT do +// asset-identity inference, feature-version gating, or lint-style warnings — +// those stay in the analysis module. +package typecheck + +import ( + "fmt" + "slices" + "strings" + + "github.com/formancehq/numscript/internal/builtins" + "github.com/formancehq/numscript/internal/parser" +) + +type Type = string + +const ( + TypeNumber Type = "number" + TypeString Type = "string" + TypeAsset Type = "asset" + TypeMonetary Type = "monetary" + TypeAccount Type = "account" + TypePortion Type = "portion" + + // TypeAny is the type of an expression whose type couldn't be determined + // (e.g. an unbound variable or an unknown function). It's compatible with + // everything, so it suppresses cascading errors. + TypeAny Type = "any" +) + +// order mirrors analysis.AllowedTypes so the InvalidType message reads identically +var allowedTypes = []Type{TypeMonetary, TypeAccount, TypePortion, TypeAsset, TypeNumber, TypeString} + +func isTypeAllowed(t string) bool { return slices.Contains(allowedTypes, t) } + +// --- errors + +// Severity mirrors the LSP DiagnosticSeverity spec (and analysis.Severity, an +// alias of byte too) so an ErrorKind directly satisfies analysis.DiagnosticKind. +type Severity = byte + +const severityError Severity = 1 + +// ErrorKind is both a typecheck error and a renderable diagnostic (Message + +// Severity), so callers can push it as a diagnostic without a translation layer. +type ErrorKind interface { + errorKind() + Message() string + Severity() Severity +} + +type ( + TypeMismatch struct{ Expected, Got string } + UnboundVariable struct{ Name, Type string } + InvalidType struct{ Name string } + BadArity struct{ Expected, Actual int } + // UnknownFunction is either a truly-unknown name (WrongContext == "") or a + // known builtin used in the wrong context (WrongContext is the context it + // belongs to, e.g. "statement"). typecheck only ever emits the former. + UnknownFunction struct{ Name, WrongContext string } + DuplicateVariable struct{ Name string } +) + +func (TypeMismatch) errorKind() {} +func (UnboundVariable) errorKind() {} +func (InvalidType) errorKind() {} +func (BadArity) errorKind() {} +func (UnknownFunction) errorKind() {} +func (DuplicateVariable) errorKind() {} + +func (e TypeMismatch) Message() string { + return fmt.Sprintf("Type mismatch (expected '%s', got '%s' instead)", e.Expected, e.Got) +} + +func (e UnboundVariable) Message() string { + return fmt.Sprintf("The variable '$%s' was not declared", e.Name) +} + +func (e InvalidType) Message() string { + return fmt.Sprintf("'%s' is not a valid type. Allowed types are: %s", e.Name, strings.Join(allowedTypes, ", ")) +} + +func (e BadArity) Message() string { + return fmt.Sprintf("Wrong number of arguments (expected %d, got %d instead)", e.Expected, e.Actual) +} + +func (e UnknownFunction) Message() string { + if e.WrongContext != "" { + return fmt.Sprintf("You cannot use this function here (try to use it in a %s context)", e.WrongContext) + } + return fmt.Sprintf("The function '%s' does not exist", e.Name) +} + +func (e DuplicateVariable) Message() string { + return fmt.Sprintf("A variable with the name '$%s' was already declared", e.Name) +} + +func (TypeMismatch) Severity() Severity { return severityError } +func (UnboundVariable) Severity() Severity { return severityError } +func (InvalidType) Severity() Severity { return severityError } +func (BadArity) Severity() Severity { return severityError } +func (UnknownFunction) Severity() Severity { return severityError } +func (DuplicateVariable) Severity() Severity { return severityError } + +type Error struct { + Range parser.Range + Kind ErrorKind +} + +// --- builtin function signatures + +type fnSig struct { + params []Type + ret Type // "" for statement functions (no return) +} + +var builtinSigs = map[string]fnSig{ + builtins.SetTxMeta: {params: []Type{TypeString, TypeAny}}, + builtins.SetAccountMeta: {params: []Type{TypeAccount, TypeString, TypeAny}}, + builtins.Meta: {params: []Type{TypeAccount, TypeString}, ret: TypeAny}, + builtins.Balance: {params: []Type{TypeAccount, TypeAsset}, ret: TypeMonetary}, + builtins.Overdraft: {params: []Type{TypeAccount, TypeAsset}, ret: TypeMonetary}, + builtins.GetAsset: {params: []Type{TypeMonetary}, ret: TypeAsset}, + builtins.GetAmount: {params: []Type{TypeMonetary}, ret: TypeNumber}, +} + +// --- Result / entrypoint + +type Result struct { + ExprTypes map[parser.ValueExpr]Type + VarTypes map[string]Type + Errors []Error +} + +func Check(program parser.Program) Result { + c := checker{ + exprTypes: map[parser.ValueExpr]Type{}, + varTypes: map[string]Type{}, + declared: map[string]struct{}{}, + } + c.checkProgram(program) + return Result{ExprTypes: c.exprTypes, VarTypes: c.varTypes, Errors: c.errors} +} + +type checker struct { + exprTypes map[parser.ValueExpr]Type + varTypes map[string]Type + declared map[string]struct{} + errors []Error +} + +func (c *checker) push(rng parser.Range, kind ErrorKind) { + c.errors = append(c.errors, Error{Range: rng, Kind: kind}) +} + +func (c *checker) checkProgram(program parser.Program) { + if program.Vars != nil { + for _, varDecl := range program.Vars.Declarations { + if varDecl.Type != nil && !isTypeAllowed(varDecl.Type.Name) { + c.push(varDecl.Type.Range, InvalidType{Name: varDecl.Type.Name}) + } + + if varDecl.Name != nil { + if _, dup := c.declared[varDecl.Name.Name]; dup { + c.push(varDecl.Name.Range, DuplicateVariable{Name: varDecl.Name.Name}) + } else { + c.declared[varDecl.Name.Name] = struct{}{} + if varDecl.Type != nil && isTypeAllowed(varDecl.Type.Name) { + c.varTypes[varDecl.Name.Name] = varDecl.Type.Name + } + } + } + + if varDecl.Origin != nil && varDecl.Type != nil { + c.checkExpr(*varDecl.Origin, varDecl.Type.Name) + } + } + } + + for _, statement := range program.Statements { + c.checkStatement(statement) + } +} + +func (c *checker) checkStatement(statement parser.Statement) { + switch statement := statement.(type) { + case *parser.SaveStatement: + c.checkSentValue(statement.SentValue) + c.checkExpr(statement.Account, TypeAccount) + + case *parser.SendStatement: + c.checkSentValue(statement.SentValue) + c.checkSource(statement.Source) + c.checkDestination(statement.Destination) + + case *parser.FnCall: + c.checkFnCallArity(statement) + } +} + +func (c *checker) checkSentValue(sentValue parser.SentValue) { + switch sentValue := sentValue.(type) { + case *parser.SentValueAll: + c.checkExpr(sentValue.Asset, TypeAsset) + case *parser.SentValueLiteral: + c.checkExpr(sentValue.Monetary, TypeMonetary) + } +} + +func (c *checker) checkSource(source parser.Source) { + if source == nil { + return + } + switch source := source.(type) { + case *parser.SourceAccount: + c.checkExpr(source.ValueExpr, TypeAccount) + c.checkExpr(source.Color, TypeString) + + case *parser.SourceOverdraft: + c.checkExpr(source.Address, TypeAccount) + c.checkExpr(source.Color, TypeString) + if source.Bounded != nil { + c.checkExpr(*source.Bounded, TypeMonetary) + } + + case *parser.SourceWithScaling: + c.checkExpr(source.Address, TypeAccount) + c.checkExpr(source.Through, TypeAccount) + + case *parser.SourceInorder: + for _, sub := range source.Sources { + c.checkSource(sub) + } + + case *parser.SourceOneof: + for _, sub := range source.Sources { + c.checkSource(sub) + } + + case *parser.SourceCapped: + c.checkExpr(source.Cap, TypeMonetary) + c.checkSource(source.From) + + case *parser.SourceAllotment: + for _, item := range source.Items { + if al, ok := item.Allotment.(*parser.ValueExprAllotment); ok { + c.checkExpr(al.Value, TypePortion) + } + c.checkSource(item.From) + } + } +} + +func (c *checker) checkDestination(destination parser.Destination) { + if destination == nil { + return + } + switch destination := destination.(type) { + case *parser.DestinationAccount: + c.checkExpr(destination.ValueExpr, TypeAccount) + + case *parser.DestinationInorder: + for _, clause := range destination.Clauses { + c.checkExpr(clause.Cap, TypeMonetary) + c.checkKeptOrDestination(clause.To) + } + c.checkKeptOrDestination(destination.Remaining) + + case *parser.DestinationOneof: + for _, clause := range destination.Clauses { + c.checkExpr(clause.Cap, TypeMonetary) + c.checkKeptOrDestination(clause.To) + } + c.checkKeptOrDestination(destination.Remaining) + + case *parser.DestinationAllotment: + for _, item := range destination.Items { + if al, ok := item.Allotment.(*parser.ValueExprAllotment); ok { + c.checkExpr(al.Value, TypePortion) + } + c.checkKeptOrDestination(item.To) + } + } +} + +func (c *checker) checkKeptOrDestination(keptOrDest parser.KeptOrDestination) { + if dest, ok := keptOrDest.(*parser.DestinationTo); ok { + c.checkDestination(dest.Destination) + } +} + +// checkExpr synthesizes lit's type, records it, and asserts it matches want. +func (c *checker) checkExpr(lit parser.ValueExpr, want Type) { + got := c.synthType(lit, want) + if want != TypeAny && got != TypeAny && want != got { + c.push(lit.GetRange(), TypeMismatch{Expected: want, Got: got}) + } +} + +// synthType synthesizes lit's type. hint is the type expected by the context; it +// is only used to annotate an unbound-variable error (matching the interpreter's +// diagnostic), never to influence the synthesized type. +func (c *checker) synthType(lit parser.ValueExpr, hint Type) Type { + if lit == nil { + return TypeAny + } + t := c.synthTypeInner(lit, hint) + c.exprTypes[lit] = t + return t +} + +func (c *checker) synthTypeInner(lit parser.ValueExpr, hint Type) Type { + switch lit := lit.(type) { + case *parser.Variable: + t, ok := c.varTypes[lit.Name] + if !ok { + if _, declared := c.declared[lit.Name]; !declared { + c.push(lit.Range, UnboundVariable{Name: lit.Name, Type: hint}) + } + return TypeAny + } + return t + + case *parser.MonetaryLiteral: + c.checkExpr(lit.Asset, TypeAsset) + c.checkExpr(lit.Amount, TypeNumber) + return TypeMonetary + + case *parser.BinaryInfix: + switch lit.Operator { + case parser.InfixOperatorPlus, parser.InfixOperatorMinus: + return c.checkInfixOverload(lit, []Type{TypeNumber, TypeMonetary}) + case parser.InfixOperatorDiv: + c.checkExpr(lit.Left, TypeNumber) + c.checkExpr(lit.Right, TypeNumber) + return TypePortion + default: + c.checkExpr(lit.Left, TypeAny) + c.checkExpr(lit.Right, TypeAny) + return TypeAny + } + + case *parser.Prefix: + switch lit.Operator { + case parser.PrefixOperatorMinus: + return c.checkHasOneOfTypes(lit.Expr, []Type{TypeNumber, TypeMonetary}) + default: + return TypeAny + } + + case *parser.AccountInterpLiteral: + for _, part := range lit.Parts { + if v, ok := part.(*parser.Variable); ok { + c.checkExpr(v, TypeAny) + } + } + return TypeAccount + + case *parser.PercentageLiteral: + return TypePortion + case *parser.AssetLiteral: + return TypeAsset + case *parser.NumberLiteral: + return TypeNumber + case *parser.StringLiteral: + return TypeString + + case *parser.FnCall: + return c.checkFnCall(lit) + + default: + return TypeAny + } +} + +func (c *checker) checkInfixOverload(bin *parser.BinaryInfix, allowed []Type) Type { + leftType := c.synthType(bin.Left, allowed[0]) + if leftType == TypeAny || slices.Contains(allowed, leftType) { + c.checkExpr(bin.Right, leftType) + return leftType + } + c.push(bin.Left.GetRange(), TypeMismatch{Expected: strings.Join(allowed, "|"), Got: leftType}) + return TypeAny +} + +func (c *checker) checkHasOneOfTypes(expr parser.ValueExpr, allowed []Type) Type { + exprType := c.synthType(expr, allowed[0]) + if exprType == TypeAny || slices.Contains(allowed, exprType) { + return exprType + } + c.push(expr.GetRange(), TypeMismatch{Expected: strings.Join(allowed, "|"), Got: exprType}) + return TypeAny +} + +func (c *checker) checkFnCall(fnCall *parser.FnCall) Type { + ret := TypeAny + if sig, ok := builtinSigs[fnCall.Caller.Name]; ok { + ret = sig.ret + if ret == "" { + ret = TypeAny + } + } + c.checkFnCallArity(fnCall) + return ret +} + +func (c *checker) checkFnCallArity(fnCall *parser.FnCall) { + var validArgs []parser.ValueExpr + for _, arg := range fnCall.Args { + if arg != nil { + validArgs = append(validArgs, arg) + } + } + + sig, resolved := builtinSigs[fnCall.Caller.Name] + if !resolved { + for _, arg := range validArgs { + c.checkExpr(arg, TypeAny) + } + c.push(fnCall.Caller.Range, UnknownFunction{Name: fnCall.Caller.Name}) + return + } + + expected := len(sig.params) + actual := len(validArgs) + if actual < expected { + c.push(fnCall.Range, BadArity{Expected: expected, Actual: actual}) + } else if actual > expected { + first := validArgs[expected] + last := validArgs[len(validArgs)-1] + c.push(parser.Range{Start: first.GetRange().Start, End: last.GetRange().End}, + BadArity{Expected: expected, Actual: actual}) + } + + for i, arg := range validArgs { + if i >= len(sig.params) { + break + } + c.checkExpr(arg, sig.params[i]) + } +} diff --git a/internal/typecheck/typecheck_test.go b/internal/typecheck/typecheck_test.go new file mode 100644 index 00000000..5bdd17b8 --- /dev/null +++ b/internal/typecheck/typecheck_test.go @@ -0,0 +1,81 @@ +package typecheck_test + +import ( + "testing" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/typecheck" + + "github.com/stretchr/testify/require" +) + +func check(t *testing.T, src string) typecheck.Result { + t.Helper() + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + return typecheck.Check(parsed.Value) +} + +func kinds(res typecheck.Result) []typecheck.ErrorKind { + out := make([]typecheck.ErrorKind, len(res.Errors)) + for i, e := range res.Errors { + out[i] = e.Kind + } + return out +} + +func TestValidProgram(t *testing.T) { + res := check(t, ` + vars { account $acc = @src } + send [USD/2 10] (source = $acc destination = @dest) + `) + require.Empty(t, res.Errors) + require.Equal(t, typecheck.TypeAccount, res.VarTypes["acc"]) +} + +func TestInvalidType(t *testing.T) { + res := check(t, `vars { invalid $x }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.InvalidType{Name: "invalid"}}, kinds(res)) +} + +func TestDuplicateVariable(t *testing.T) { + res := check(t, `vars { account $x account $x }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.DuplicateVariable{Name: "x"}}, kinds(res)) +} + +func TestUnboundVariable(t *testing.T) { + res := check(t, `send [C 10] (source = $nope destination = @d)`) + require.Equal(t, []typecheck.ErrorKind{typecheck.UnboundVariable{Name: "nope", Type: typecheck.TypeAccount}}, kinds(res)) +} + +func TestTypeMismatch(t *testing.T) { + // a string var used where an account is expected + res := check(t, `vars { string $s } send [C 10] (source = $s destination = @d)`) + require.Equal(t, []typecheck.ErrorKind{ + typecheck.TypeMismatch{Expected: typecheck.TypeAccount, Got: typecheck.TypeString}, + }, kinds(res)) +} + +func TestUnknownFunction(t *testing.T) { + res := check(t, `vars { number $n = nope() }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.UnknownFunction{Name: "nope"}}, kinds(res)) +} + +func TestBadArity(t *testing.T) { + res := check(t, `vars { monetary $m = balance(@a) }`) + require.Equal(t, []typecheck.ErrorKind{typecheck.BadArity{Expected: 2, Actual: 1}}, kinds(res)) +} + +func TestExprTypes(t *testing.T) { + res := check(t, `send [USD/2 10] (source = @a destination = @b)`) + // the monetary literal is typed + send := res // just assert no errors + monetary present via a scan + require.Empty(t, send.Errors) + found := false + for _, ty := range res.ExprTypes { + if ty == typecheck.TypeMonetary { + found = true + } + } + require.True(t, found, "expected a monetary-typed expr") +} diff --git a/internal/vm/execution_err.go b/internal/vm/execution_err.go new file mode 100644 index 00000000..4afdefb1 --- /dev/null +++ b/internal/vm/execution_err.go @@ -0,0 +1,128 @@ +package vm + +import ( + "fmt" + "math/big" +) + +type ( + ExecutionError interface { + error + execErr() + } + + MissingFundsError struct { + Asset string + Needed *big.Int + Got *big.Int + } + + AssetMismatchError struct { + Expected string + Got string + } + + InvalidUncappedSource struct { + Account string + } + + InvalidAllotmentSum struct { + ActualSum big.Rat + } + + MetadataNotFoundError struct { + Account string + Key string + } + + BadMetaValueError struct { + Account string + Key string + Raw string + } + + InvalidAccountName struct { + Name string + } + + NegativeBalanceError struct { + Account string + Amount big.Int + } + + DivideByZeroError struct { + Numerator big.Int + } + + // InternalError signals a malformed program the VM cannot execute (e.g. an + // unknown opcode). It is a bug in whatever produced the bytecode, never a + // user-script error, but it is returned rather than panicked so the VM never + // crashes its host. + InternalError struct { + Opcode byte + } +) + +func (e MissingFundsError) Error() string { + return fmt.Sprintf("missing funds for asset %s: needed %s, got %s", e.Asset, e.Needed, e.Got) +} + +func (e AssetMismatchError) Error() string { + return fmt.Sprintf("asset mismatch: expected %s, got %s", e.Expected, e.Got) +} + +func (e InvalidUncappedSource) Error() string { + return fmt.Sprintf("unbounded source is not allowed here: @%s", e.Account) +} + +func (e InternalError) Error() string { + return fmt.Sprintf("internal error: unknown opcode %d", e.Opcode) +} + +func (e DivideByZeroError) Error() string { + return fmt.Sprintf("cannot divide by zero (in %s/0)", e.Numerator.String()) +} + +func (e InvalidAccountName) Error() string { + return fmt.Sprintf("invalid account name: %q", e.Name) +} + +func (e NegativeBalanceError) Error() string { + return fmt.Sprintf("cannot fetch negative balance from account @%s", e.Account) +} + +func (e InvalidAllotmentSum) Error() string { + return fmt.Sprintf("invalid allotment: portions must sum to 1, got %s", e.ActualSum.String()) +} + +func (e MetadataNotFoundError) Error() string { + return fmt.Sprintf("metadata not found: %s[%q]", e.Account, e.Key) +} + +func (e BadMetaValueError) Error() string { + return fmt.Sprintf("invalid metadata value for %s[%q]: %q", e.Account, e.Key, e.Raw) +} + +func (MissingFundsError) execErr() {} +func (AssetMismatchError) execErr() {} +func (InvalidUncappedSource) execErr() {} +func (InvalidAllotmentSum) execErr() {} +func (MetadataNotFoundError) execErr() {} +func (BadMetaValueError) execErr() {} +func (InvalidAccountName) execErr() {} +func (NegativeBalanceError) execErr() {} +func (DivideByZeroError) execErr() {} +func (InternalError) execErr() {} + +var ( + _ ExecutionError = (*MissingFundsError)(nil) + _ ExecutionError = (*AssetMismatchError)(nil) + _ ExecutionError = (*InvalidUncappedSource)(nil) + _ ExecutionError = (*InvalidAllotmentSum)(nil) + _ ExecutionError = (*MetadataNotFoundError)(nil) + _ ExecutionError = (*BadMetaValueError)(nil) + _ ExecutionError = (*InvalidAccountName)(nil) + _ ExecutionError = (*NegativeBalanceError)(nil) + _ ExecutionError = (*DivideByZeroError)(nil) + _ ExecutionError = (*InternalError)(nil) +) diff --git a/internal/vm/instruction.go b/internal/vm/instruction.go new file mode 100644 index 00000000..cd10fd05 --- /dev/null +++ b/internal/vm/instruction.go @@ -0,0 +1,129 @@ +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_AssertSameAsset + + // errors if the account name in str reg A is not well-formed + Op_AssertValidAccount + + // errors (NegativeBalanceError) if the monetary in reg A has a negative + // amount; B = account str reg (for the error) + Op_AssertNonNegativeBalance + + // --- metadata --- + // A = key (str reg), B = value (str reg) + Op_SetTxMeta + + // A = account (str reg), B = key (str reg), C = value (str reg) + Op_SetAccountMeta + + // meta(account, key) read, dispatched on the target type. + // A = dest, B = account (str reg), C = key (str reg) + Op_MetaStr + Op_MetaInt + Op_MetaPortion + Op_MetaMonetary + + // --- variables --- + Op_LoadVarInt // b_c = int-var index + Op_LoadVarStr // b_c = string-var index + + // --- constants --- + // 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 + + // checks the allotment leftover portion in reg A: errors if it is negative + // (portions summing to > 1), and — when B == 1 (no `remaining` clause) — if it + // is non-zero (portions not summing to exactly 1) + Op_AssertLeftover + + // save: reduce balance of account A for asset B by amount C (C == nilReg => + // save all), floored at 0 + Op_Save + + // --- 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 + Op_NegInt + Op_IntToString + Op_PortionToString + Op_MonetaryToString + + // --- BinaryOp --- + Op_MinInt + Op_AddInt + Op_SubInt + Op_AddString + Op_SubPortion + Op_MkPortion + Op_MkMonetary + Op_Balance // reads the account balance from the run-state +) diff --git a/internal/vm/meta_test.go b/internal/vm/meta_test.go new file mode 100644 index 00000000..eb29222a --- /dev/null +++ b/internal/vm/meta_test.go @@ -0,0 +1,55 @@ +package vm + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/stretchr/testify/require" +) + +func TestSetAccountMeta(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), // r_s0 = "acc" + bc(Op_LoadStr, 1, 1), // r_s1 = "k" + bc(Op_LoadStr, 2, 2), // r_s2 = "v" + abc(Op_SetAccountMeta, 0, 1, 2), // set_account_meta(acc, k, v) + }, + StringsPool: []string{"acc", "k", "v"}, + } + + res, execErr := Exec(NewVm(prog), nil, mockStore{}) + require.Nil(t, execErr) + require.Equal(t, runtime.AccountsMetadata{"acc": {"k": "v"}}, res.AccountsMetadata) +} + +func TestMetaStr(t *testing.T) { + // meta("config", "beneficiary") == "alice"; then send [USD/2 100] from world to it. + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), // s0 = "USD/2" + abc(Op_SetCurrentAsset, 0, 0, 0), + bc(Op_LoadStr, 1, 1), // s1 = "config" + bc(Op_LoadStr, 2, 2), // s2 = "beneficiary" + abc(Op_MetaStr, 3, 1, 2), // s3 = meta(config, beneficiary) = "alice" + bc(Op_LoadStr, 4, 3), // s4 = "world" + bc(Op_LoadInt, 0, 0), // i0 = 100 (cap) + abc(Op_PullAccount, 1, 4, 0), // i1 = pull(world, cap i0) + abc(0, nilReg, nilReg, 0), // ext: no overdraft, no color + abc(Op_SendToAccount, 3, nilReg, nilReg), // send to s3 (alice) + }, + StringsPool: []string{"USD/2", "config", "beneficiary", "world"}, + IntsPool: []big.Int{*big.NewInt(100)}, + } + + store := mockStore{meta: map[string]map[string]string{ + "config": {"beneficiary": "alice"}, + }} + + res, execErr := Exec(NewVm(prog), nil, store) + require.Nil(t, execErr) + require.Equal(t, []runtime.Posting{ + {Source: "world", Destination: "alice", Asset: "USD/2", Amount: big.NewInt(100)}, + }, res.Postings) +} diff --git a/internal/vm/program.go b/internal/vm/program.go new file mode 100644 index 00000000..c2252b43 --- /dev/null +++ b/internal/vm/program.go @@ -0,0 +1,223 @@ +package vm + +import ( + "encoding/binary" + "fmt" + "math/big" +) + +type Program struct { + Instructions []Instruction + + StringsPool []string + IntsPool []big.Int +} + +var le = binary.LittleEndian + +// TODO review AI blob +func (p Program) Encode() []byte { + instrs := make([]byte, 4*len(p.Instructions)) + for i, ins := range p.Instructions { + instrs[i*4], instrs[i*4+1], instrs[i*4+2], instrs[i*4+3] = ins.Opcode, ins.A, ins.B, ins.C + } + + data, strTable, intTable := encodePools(p.StringsPool, p.IntsPool) + + const headerLen = 4 + 4*8 // magic + 4 section pointers + instrStart := uint32(headerLen) + dataStart := instrStart + uint32(len(instrs)) + strTableStart := dataStart + uint32(len(data)) + intTableStart := strTableStart + uint32(len(strTable)) + + buf := make([]byte, 0, int(intTableStart)+len(intTable)) + buf = append(buf, "NUMB"...) + buf = appendSection(buf, instrStart, uint32(len(instrs))) + buf = appendSection(buf, dataStart, uint32(len(data))) + buf = appendSection(buf, strTableStart, uint32(len(strTable))) + buf = appendSection(buf, intTableStart, uint32(len(intTable))) + buf = append(buf, instrs...) + buf = append(buf, data...) + buf = append(buf, strTable...) + buf = append(buf, intTable...) + return buf +} + +// TODO review AI blob +func encodePools(strings []string, ints []big.Int) (data, strTable, intTable []byte) { + strOffsets := make([]uint32, len(strings)) + for i, s := range strings { + strOffsets[i] = uint32(len(data)) + var lenBuf [4]byte + le.PutUint32(lenBuf[:], uint32(len(s))) + data = append(data, lenBuf[:]...) + data = append(data, s...) + } + intOffsets := make([]uint32, len(ints)) + for i := range ints { + intOffsets[i] = uint32(len(data)) + n := &ints[i] + sign := byte(0) + if n.Sign() < 0 { + sign = 1 + } + mag := new(big.Int).Abs(n).Bytes() + var hdr [5]byte + hdr[0] = sign + le.PutUint32(hdr[1:], uint32(len(mag))) + data = append(data, hdr[:]...) + data = append(data, mag...) + } + return data, offsetTable(strOffsets), offsetTable(intOffsets) +} + +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/program_encode_test.go b/internal/vm/program_encode_test.go new file mode 100644 index 00000000..f5332e64 --- /dev/null +++ b/internal/vm/program_encode_test.go @@ -0,0 +1,40 @@ +package vm + +import ( + "math/big" + "reflect" + "testing" +) + +func TestProgramEncodeDecodeRoundTrip(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + abc(Op_LoadStr, 0, 1, 2), + bc(Op_LoadInt, 3, 1), + abc(Op_AddInt, 4, 3, 3), + }, + StringsPool: []string{"world", "dest", "USD/2"}, + IntsPool: []big.Int{*big.NewInt(0), *big.NewInt(-42)}, + } + got, err := DecodeProgram(prog.Encode()) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !reflect.DeepEqual(got.Instructions, prog.Instructions) { + t.Fatalf("instructions mismatch:\n got %+v\nwant %+v", got.Instructions, prog.Instructions) + } + if !reflect.DeepEqual(got.StringsPool, prog.StringsPool) { + t.Fatalf("strings mismatch: %+v vs %+v", got.StringsPool, prog.StringsPool) + } + for i := range prog.IntsPool { + if got.IntsPool[i].Cmp(&prog.IntsPool[i]) != 0 { + t.Fatalf("int %d: %s vs %s", i, got.IntsPool[i].String(), prog.IntsPool[i].String()) + } + } +} + +func TestEmptyProgramRoundTrip(t *testing.T) { + if _, err := DecodeProgram(Program{}.Encode()); err != nil { + t.Fatalf("empty round-trip: %v", err) + } +} diff --git a/internal/vm/vars.go b/internal/vm/vars.go new file mode 100644 index 00000000..bf94cee7 --- /dev/null +++ b/internal/vm/vars.go @@ -0,0 +1,77 @@ +package vm + +import ( + "fmt" + "math/big" +) + +type Vars struct { + StringsPool []string + IntsPool []big.Int +} + +func DecodeVars(buf []byte) (Vars, error) { + if len(buf) < 4 || string(buf[0:4]) != "NVAR" { + return Vars{}, fmt.Errorf("bad vars magic") + } + + idx := 4 + + dataSegment, err := readArr("data segment", buf, &idx, id) + if err != nil { + return Vars{}, err + } + + stringsPool, err := readArr("strings pool", buf, &idx, func(buf []byte) ([]string, error) { + return parseStringsPool(buf, dataSegment) + }) + if err != nil { + return Vars{}, err + } + + intsPool, err := readArr("ints", buf, &idx, func(buf []byte) ([]big.Int, error) { + return parseIntsPool(buf, dataSegment) + }) + if err != nil { + return Vars{}, err + } + + return Vars{ + StringsPool: stringsPool, + IntsPool: intsPool, + }, nil +} + +func (v Vars) Encode() []byte { + data, strTable, intTable := encodePools(v.StringsPool, v.IntsPool) + + const headerLen = 4 + 3*8 // magic + 3 section pointers + dataStart := uint32(headerLen) + strTableStart := dataStart + uint32(len(data)) + intTableStart := strTableStart + uint32(len(strTable)) + + buf := make([]byte, 0, int(intTableStart)+len(intTable)) + buf = append(buf, "NVAR"...) + buf = appendSection(buf, dataStart, uint32(len(data))) + buf = appendSection(buf, strTableStart, uint32(len(strTable))) + buf = appendSection(buf, intTableStart, uint32(len(intTable))) + buf = append(buf, data...) + buf = append(buf, strTable...) + buf = append(buf, intTable...) + return buf +} + +func offsetTable(offsets []uint32) []byte { + table := make([]byte, 4*len(offsets)) + for i, off := range offsets { + le.PutUint32(table[i*4:], off) + } + return table +} + +func appendSection(buf []byte, start, length uint32) []byte { + var b [8]byte + le.PutUint32(b[0:], start) + le.PutUint32(b[4:], length) + return append(buf, b[:]...) +} diff --git a/internal/vm/vars_test.go b/internal/vm/vars_test.go new file mode 100644 index 00000000..2d9efbe2 --- /dev/null +++ b/internal/vm/vars_test.go @@ -0,0 +1,50 @@ +package vm + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/runtime" + "github.com/stretchr/testify/require" +) + +func TestVarsRoundTrip(t *testing.T) { + in := Vars{ + StringsPool: []string{"alice", "USD/2"}, + IntsPool: []big.Int{*big.NewInt(1), *big.NewInt(4), *big.NewInt(-100)}, + } + + out, err := DecodeVars(in.Encode()) + require.NoError(t, err) + require.Equal(t, in, out) +} + +func TestLoadVarOpcodes(t *testing.T) { + vars, err := DecodeVars(Vars{ + StringsPool: []string{"world", "dest"}, + IntsPool: []big.Int{*big.NewInt(42)}, + }.Encode()) + require.NoError(t, err) + + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, sUSD, 0), // r_s0 = "USD/2" (current asset) + abc(Op_SetCurrentAsset, sUSD, 0, 0), + bc(Op_LoadVarStr, 1, 0), // r_s1 = var strings[0] = "world" + bc(Op_LoadVarStr, 2, 1), // r_s2 = var strings[1] = "dest" + bc(Op_LoadVarInt, 0, 0), // r_i0 = var ints[0] = 42 + abc(Op_PullAccount, 1, 1, 0), // r_i1 = pull(world, cap r_i0) + abc(0, nilReg, nilReg, 0), // ext: no overdraft, no color + abc(Op_SendToAccount, 2, nilReg, nilReg), // send to dest + }, + StringsPool: []string{"USD/2"}, + } + + res, execErr := Exec(NewVm(prog), &vars, mockStore{}) + require.Nil(t, execErr) + + want := []runtime.Posting{ + {Source: "world", Destination: "dest", Asset: "USD/2", Amount: big.NewInt(42)}, + } + require.Equal(t, want, res.Postings) +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go new file mode 100644 index 00000000..127e3f99 --- /dev/null +++ b/internal/vm/vm.go @@ -0,0 +1,397 @@ +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 + + GetMetadata(account, key string) (string, bool) +} + +func lookupMeta(store Store, account, key string) (string, ExecutionError) { + v, ok := store.GetMetadata(account, key) + if !ok { + return "", MetadataNotFoundError{Account: account, Key: key} + } + return v, nil +} + +func Exec[S Store]( + vm *Vm, + vars *Vars, + store S, // a generic S should allow monomorphisation of the Store +) (runtime.ExecutionResult, ExecutionError) { + if vm.runstate == nil { + vm.runstate = runtime.New(store) + } else { + vm.runstate.Reset(store) + } + runstate := vm.runstate + + var txMeta map[string]string + var accountsMeta runtime.AccountsMetadata + + 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: + // TODO crashes if this is the last instruction (the ext word is + // missing): instrs[pc] reads past the end. e.g. a program ending in a + // lone Op_PullAccount word. + 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] + } + + var color string + if instrExt.B != nilReg { + color = vm.stringsRegs[instrExt.B] + } + + out := &vm.intsRegs[instr.A] + switch { + case cap != nil: + runstate.Pull(out, account, "", cap, overdraft, color) + case overdraft != nil: + runstate.PullUncapped(out, account, "", overdraft, color) + default: + return runtime.ExecutionResult{}, InvalidUncappedSource{Account: account} + } + + 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: + // TODO crashes if this is the last instruction (missing ext word), + // same as Op_PullAccount. + instrExt := instrs[pc] + pc++ + + // TODO crashes when instr.A+instr.C > 256: the slice runs past the + // register bank. Both are bytes, so A+C can be up to 510. + 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 runtime.ExecutionResult{}, MissingFundsError{ + Asset: currentAsset, + Got: got, + Needed: needed, + } + } + + case Op_Save: + account := vm.stringsRegs[instr.A] + asset := vm.stringsRegs[instr.B] + var amount *big.Int + if instr.C != nilReg { + amount = &vm.intsRegs[instr.C] + } + runstate.Save(account, "", asset, "", amount) + + case Op_AssertLeftover: + leftover := &vm.portionsRegs[instr.A] + sign := leftover.Sign() + if sign < 0 || (instr.B == 1 && sign != 0) { + sum := new(big.Rat).Sub(big.NewRat(1, 1), leftover) + return runtime.ExecutionResult{}, InvalidAllotmentSum{ActualSum: *sum} + } + + case Op_SetCurrentAsset: + currentAsset = vm.stringsRegs[instr.A] + runstate.SetCurrentAsset(currentAsset) + + case Op_AssertSameAsset: + left := vm.stringsRegs[instr.A] + right := vm.stringsRegs[instr.B] + if left != right { + return runtime.ExecutionResult{}, AssetMismatchError{ + Expected: left, + Got: right, + } + } + + case Op_AssertValidAccount: + account := vm.stringsRegs[instr.A] + if !runtime.ValidateAccount(account) { + return runtime.ExecutionResult{}, InvalidAccountName{Name: account} + } + + case Op_AssertNonNegativeBalance: + m := &vm.monetariesRegs[instr.A] + if m.amount.Sign() < 0 { + return runtime.ExecutionResult{}, NegativeBalanceError{ + Account: vm.stringsRegs[instr.B], + Amount: m.amount, + } + } + + case Op_SetTxMeta: + if txMeta == nil { + txMeta = map[string]string{} + } + txMeta[vm.stringsRegs[instr.A]] = vm.stringsRegs[instr.B] + + case Op_SetAccountMeta: + if accountsMeta == nil { + accountsMeta = runtime.AccountsMetadata{} + } + account := vm.stringsRegs[instr.A] + accMeta := accountsMeta[account] + if accMeta == nil { + accMeta = runtime.AccountMetadata{} + accountsMeta[account] = accMeta + } + accMeta[vm.stringsRegs[instr.B]] = vm.stringsRegs[instr.C] + + case Op_MetaStr: + v, err := lookupMeta(store, vm.stringsRegs[instr.B], vm.stringsRegs[instr.C]) + if err != nil { + return runtime.ExecutionResult{}, err + } + vm.stringsRegs[instr.A] = v + + case Op_MetaInt: + account, key := vm.stringsRegs[instr.B], vm.stringsRegs[instr.C] + v, err := lookupMeta(store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + n, ok := runtime.ParseNumber(v) + if !ok { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + vm.intsRegs[instr.A].Set(n) + + case Op_MetaPortion: + account, key := vm.stringsRegs[instr.B], vm.stringsRegs[instr.C] + v, err := lookupMeta(store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + r, perr := runtime.ParsePortion(v) + if perr != nil { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + vm.portionsRegs[instr.A].Set(r) + + case Op_MetaMonetary: + account, key := vm.stringsRegs[instr.B], vm.stringsRegs[instr.C] + v, err := lookupMeta(store, account, key) + if err != nil { + return runtime.ExecutionResult{}, err + } + asset, amount, merr := runtime.ParseMonetary(v) + if merr != nil { + return runtime.ExecutionResult{}, BadMetaValueError{Account: account, Key: key, Raw: v} + } + dest := &vm.monetariesRegs[instr.A] + dest.asset = asset + dest.amount.Set(amount) + + // --- Vars + // TODO both crash if vars is nil (Exec called with no vars for a + // program that reads them), or if GetBC() >= len(vars pool) (caller + // passed fewer vars than the program declares). + case Op_LoadVarInt: + vm.intsRegs[instr.A].Set(&vars.IntsPool[instr.GetBC()]) + + case Op_LoadVarStr: + vm.stringsRegs[instr.A] = vars.StringsPool[instr.GetBC()] + + // --- Jumps + case Op_JmpIfZero: + arg := &vm.intsRegs[instr.A] + if arg.Sign() == 0 { + pc = int(instr.GetBC()) + } + + // --- consts + // TODO both crash if GetBC() >= len(pool), e.g. an Op_LoadInt referring to + // pool index 5 in a program whose ints pool has 3 entries. + 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_AddString: + vm.stringsRegs[instr.A] = vm.stringsRegs[instr.B] + vm.stringsRegs[instr.C] + + 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] + if den.Sign() == 0 { + return runtime.ExecutionResult{}, DivideByZeroError{Numerator: *num} + } + 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) + + case Op_Balance: + account := vm.stringsRegs[instr.B] + asset := vm.stringsRegs[instr.C] + + dest := &vm.monetariesRegs[instr.A] + dest.asset = asset + dest.amount.Set(runstate.GetAccountBalance(account, "", asset, "")) + + // --- 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) + + case Op_NegInt: + arg := &vm.intsRegs[instr.B] + vm.intsRegs[instr.A].Neg(arg) + + case Op_IntToString: + vm.stringsRegs[instr.A] = vm.intsRegs[instr.B].String() + + case Op_PortionToString: + vm.stringsRegs[instr.A] = vm.portionsRegs[instr.B].String() + + case Op_MonetaryToString: + mon := &vm.monetariesRegs[instr.B] + vm.stringsRegs[instr.A] = mon.asset + " " + mon.amount.String() + + default: + return runtime.ExecutionResult{}, InternalError{Opcode: instr.Opcode} + } + } + + return runtime.ExecutionResult{ + Postings: runstate.GetPostings(), + Metadata: txMeta, + AccountsMetadata: accountsMeta, + }, nil +} diff --git a/internal/vm/vm_test.go b/internal/vm/vm_test.go new file mode 100644 index 00000000..e3962f78 --- /dev/null +++ b/internal/vm/vm_test.go @@ -0,0 +1,211 @@ +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 + meta map[string]map[string]string +} + +func (m mockStore) GetBalance(account, asset string, color string) *big.Int { + return big.NewInt(m.bal[runtime.PairKey{Account: account, Asset: asset}]) +} + +func (m mockStore) GetMetadata(account, key string) (string, bool) { + v, ok := m.meta[account][key] + return v, ok +} + +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) + + res, 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(res.Postings, want) { + t.Errorf("postings mismatch\n got: %+v\nwant: %+v", res.Postings, want) + } +} + +func assertValidAccountProgram(name string) Program { + return Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), + abc(Op_AssertValidAccount, 0, nilReg, nilReg), + }, + StringsPool: []string{name}, + } +} + +func balanceNonNegativeProgram() Program { + return Program{ + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), + bc(Op_LoadStr, 1, 1), + abc(Op_Balance, 0, 0, 1), + abc(Op_AssertNonNegativeBalance, 0, 0, nilReg), + }, + StringsPool: []string{"acc", "USD/2"}, + } +} + +func TestAssertNonNegativeBalance(t *testing.T) { + store := mockStore{bal: map[runtime.PairKey]int64{{Account: "acc", Asset: "USD/2"}: 50}} + if _, err := Exec(NewVm(balanceNonNegativeProgram()), nil, store); err != nil { + t.Fatalf("non-negative balance rejected: %v", err) + } + + store = mockStore{bal: map[runtime.PairKey]int64{{Account: "acc", Asset: "USD/2"}: -50}} + _, err := Exec(NewVm(balanceNonNegativeProgram()), nil, store) + if _, ok := err.(NegativeBalanceError); !ok { + t.Fatalf("expected NegativeBalanceError, got %v", err) + } +} + +func TestUnknownOpcode(t *testing.T) { + prog := Program{Instructions: []Instruction{abc(0xFE, 0, 0, 0)}} + _, err := Exec(NewVm(prog), nil, mockStore{}) + if _, ok := err.(InternalError); !ok { + t.Fatalf("expected InternalError, got %v", err) + } +} + +func TestMkPortionDivideByZero(t *testing.T) { + prog := Program{ + Instructions: []Instruction{ + bc(Op_LoadInt, 0, 0), + bc(Op_LoadInt, 1, 1), + abc(Op_MkPortion, 0, 0, 1), + }, + IntsPool: []big.Int{*big.NewInt(1), *big.NewInt(0)}, + } + _, err := Exec(NewVm(prog), nil, mockStore{}) + if _, ok := err.(DivideByZeroError); !ok { + t.Fatalf("expected DivideByZeroError, got %v", err) + } +} + +func TestAssertValidAccount(t *testing.T) { + _, err := Exec(NewVm(assertValidAccountProgram("users:001:wallet")), nil, mockStore{}) + if err != nil { + t.Fatalf("valid account rejected: %v", err) + } + + _, err = Exec(NewVm(assertValidAccountProgram("bad name!")), nil, mockStore{}) + if _, ok := err.(InvalidAccountName); !ok { + t.Fatalf("expected InvalidAccountName, got %v", err) + } +} diff --git a/numscript.go b/numscript.go index eb4dc8d7..3ee7c674 100644 --- a/numscript.go +++ b/numscript.go @@ -3,8 +3,10 @@ package numscript import ( "context" + "github.com/formancehq/numscript/internal/compiler" "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/vm" ) // This struct represents a parsed numscript source code @@ -125,3 +127,40 @@ func (p ParseResult) ResolveDependencies(ctx context.Context, vars VariablesMap, func (p ParseResult) GetSource() string { return p.parseResult.Source } + +type ( + VarsEncoder = compiler.VarsEncoder + CompiledProgram = vm.Program + VMStore = vm.Store + Vm = vm.Vm + Vars = vm.Vars +) + +var NewVm = vm.NewVm + +var DecodeVars = vm.DecodeVars + +func (p ParseResult) Compile() (VarsEncoder, CompiledProgram, error) { + if len(p.parseResult.Errors) != 0 { + return VarsEncoder{}, CompiledProgram{}, p.parseResult.Errors[0] + } + return compiler.Compile(p.parseResult.Value) +} + +func Compile(source string) (VarsEncoder, CompiledProgram, error) { + return Parse(source).Compile() +} + +var DecodeCompiledProgram = vm.DecodeProgram + +func ExecVm[S VMStore](machine *Vm, vars *Vars, store S) (ExecutionResult, error) { + res, execErr := vm.Exec(machine, vars, store) + if execErr != nil { + return ExecutionResult{}, execErr + } + + // Postings share one type now (runtime.Posting); the VM leaves scope fields + // empty. TODO map VM tx/account metadata (stringified) onto the typed + // contract; deferred together with scopes/colors in the VM. + return ExecutionResult{Postings: res.Postings}, nil +} diff --git a/numscript_test.go b/numscript_test.go index fa24e76a..21e85114 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -455,7 +455,7 @@ set_tx_meta( require.Nil(t, err) require.Equal(t, interpreter.Metadata{ - "k": interpreter.NewMonetary("USD/2", 100), + "k": interpreter.Monetary{Asset: "USD/2", Amount: interpreter.NewMonetaryInt(100)}, }, res.Metadata) require.Equal(t,