diff --git a/compiler-architecture.md b/compiler-architecture.md index e78a495c..938e356e 100644 --- a/compiler-architecture.md +++ b/compiler-architecture.md @@ -98,7 +98,11 @@ Instructions are fetched and evaluated one at the time until they are finished ( Instructions can move data by manipulating registers. Registers banks are separated by type (so that we don't have to have a single heap-allocated value, nor unsafe pointers or manually handled unsafe memory). With "type" here we mean the internal representation of data, which isn't the same as numscript types (there isn't necessarily a 1-1 relationship). For example, both strings, assets and accounts are represented via the golang `string` type. The `bool` bank is the other direction: it has no numscript counterpart at all, and exists so that a branch condition can't be a monetary quantity. -> Note: we'll probably change accounts' representation when adding scopes +> Note: scopes turned out not to need a representation change. `scoped(account, "scope")` +> compiles to a plain second `string` register carried alongside the account's own, using +> the same nilable-optional-operand idiom `Color`/`Overdraft` already use on `PullAccount` — +> exactly the same non-bank treatment `Monetary` gets as a `(str, int)` pair. See +> `compiler.compileAccountExpr`/`accountValue` and `ir.AssertValidScope`. A simple example of an instruction is: diff --git a/internal/builtins/builtins.go b/internal/builtins/builtins.go index c12ef614..d9bec8eb 100644 --- a/internal/builtins/builtins.go +++ b/internal/builtins/builtins.go @@ -8,4 +8,5 @@ const ( Overdraft = "overdraft" GetAsset = "get_asset" GetAmount = "get_amount" + Scoped = "scoped" ) diff --git a/internal/cmd/bytecode_run.go b/internal/cmd/bytecode_run.go index 4db68d93..1fd890d2 100644 --- a/internal/cmd/bytecode_run.go +++ b/internal/cmd/bytecode_run.go @@ -41,55 +41,48 @@ type BytecodeRunArgs struct { OutFormatOpt string } +// vmMetaKey identifies one metadata slot: account, scope and key. +type vmMetaKey struct { + account string + scope string + key string +} + // vmStore is a vm.Store over the rows of an inputs file. type vmStore struct { balances map[runtime.PairKey]*big.Int - meta map[string]map[string]string + meta map[vmMetaKey]string } -func (s vmStore) GetBalance(_ context.Context, account, asset, color string) (*big.Int, error) { +func (s vmStore) GetBalance(_ context.Context, account, scope, asset, color string) (*big.Int, error) { // the caller owns what it gets: the run state mutates balances in place - if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + if v, ok := s.balances[runtime.PairKey{Account: account, Scope: scope, Asset: asset, Color: color}]; ok { return new(big.Int).Set(v), nil } return new(big.Int), nil } -func (s vmStore) GetMetadata(_ context.Context, account, key string) (string, bool, error) { - v, ok := s.meta[account][key] +func (s vmStore) GetMetadata(_ context.Context, account, scope, key string) (string, bool, error) { + v, ok := s.meta[vmMetaKey{account: account, scope: scope, key: key}] return v, ok, nil } func newVmStore(inputsPath string, inputs BytecodeInputsFile) (vmStore, error) { store := vmStore{ balances: make(map[runtime.PairKey]*big.Int, len(inputs.Balances)), - meta: make(map[string]map[string]string), + meta: make(map[vmMetaKey]string, len(inputs.Meta)), } for _, row := range inputs.Balances { - // the vm never sets a scope, so a scoped row could never be read back - if row.Scope != "" { - return vmStore{}, fmt.Errorf("invalid inputs file '%s': scoped balances are not supported by the vm (account=%q scope=%q)", inputsPath, row.Account, row.Scope) - } - amount := row.Amount if amount == nil { amount = new(big.Int) } - store.balances[runtime.PairKey{Account: row.Account, Asset: row.Asset, Color: row.Color}] = amount + store.balances[runtime.PairKey{Account: row.Account, Scope: row.Scope, Asset: row.Asset, Color: row.Color}] = amount } for _, row := range inputs.Meta { - if row.Scope != "" { - return vmStore{}, fmt.Errorf("invalid inputs file '%s': scoped metadata is not supported by the vm (account=%q scope=%q)", inputsPath, row.Account, row.Scope) - } - - byKey, ok := store.meta[row.Account] - if !ok { - byKey = map[string]string{} - store.meta[row.Account] = byKey - } - byKey[row.Key] = row.Value + store.meta[vmMetaKey{account: row.Account, scope: row.Scope, key: row.Key}] = row.Value } return store, nil @@ -211,15 +204,7 @@ func showBytecodePretty(result runtime.ExecutionResult) error { if len(result.AccountsMetadata) != 0 { fmt.Println("Accounts meta:") - accounts := make([]string, 0, len(result.AccountsMetadata)) - for account := range result.AccountsMetadata { - accounts = append(accounts, account) - } - sort.Strings(accounts) - for _, account := range accounts { - fmt.Printf("@%s\n", account) - fmt.Print(prettyPrintStringMeta(result.AccountsMetadata[account])) - } + fmt.Print(result.AccountsMetadata.PrettyPrint()) } return nil diff --git a/internal/cmd/bytecode_run_test.go b/internal/cmd/bytecode_run_test.go index 636f73ab..af45b8e9 100644 --- a/internal/cmd/bytecode_run_test.go +++ b/internal/cmd/bytecode_run_test.go @@ -210,7 +210,7 @@ func TestVmStoreReturnsACopyOfTheBalance(t *testing.T) { }) require.NoError(t, err) - got, err := store.GetBalance(context.Background(), "src", "USD/2", "") + got, err := store.GetBalance(context.Background(), "src", "", "USD/2", "") require.NoError(t, err) require.Zero(t, got.Cmp(big.NewInt(100))) @@ -222,27 +222,43 @@ func TestVmStoreUnknownAccountIsZeroNotAnError(t *testing.T) { store, err := newVmStore("in.json", BytecodeInputsFile{}) require.NoError(t, err) - got, err := store.GetBalance(context.Background(), "nobody", "USD/2", "") + got, err := store.GetBalance(context.Background(), "nobody", "", "USD/2", "") require.NoError(t, err) require.Zero(t, got.Sign()) - _, ok, err := store.GetMetadata(context.Background(), "nobody", "k") + _, ok, err := store.GetMetadata(context.Background(), "nobody", "", "k") require.NoError(t, err) require.False(t, ok) } -func TestVmStoreRejectsScopedRows(t *testing.T) { - _, err := newVmStore("in.json", BytecodeInputsFile{ +func TestVmStoreSupportsScopedRows(t *testing.T) { + store, err := newVmStore("in.json", BytecodeInputsFile{ Balances: interpreter.Balances{ {Account: "src", Asset: "USD/2", Amount: big.NewInt(1), Scope: "reserve"}, + {Account: "src", Asset: "USD/2", Amount: big.NewInt(100)}, }, - }) - require.ErrorContains(t, err, "scoped balances are not supported by the vm") - - _, err = newVmStore("in.json", BytecodeInputsFile{ Meta: interpreter.AccountsMetadata{ - {Account: "src", Key: "k", Value: "v", Scope: "reserve"}, + {Account: "src", Key: "k", Value: "scoped", Scope: "reserve"}, + {Account: "src", Key: "k", Value: "unscoped"}, }, }) - require.ErrorContains(t, err, "scoped metadata is not supported by the vm") + require.NoError(t, err) + + scopedBal, err := store.GetBalance(context.Background(), "src", "reserve", "USD/2", "") + require.NoError(t, err) + require.Zero(t, scopedBal.Cmp(big.NewInt(1))) + + unscopedBal, err := store.GetBalance(context.Background(), "src", "", "USD/2", "") + require.NoError(t, err) + require.Zero(t, unscopedBal.Cmp(big.NewInt(100))) + + scopedMeta, ok, err := store.GetMetadata(context.Background(), "src", "reserve", "k") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "scoped", scopedMeta) + + unscopedMeta, ok, err := store.GetMetadata(context.Background(), "src", "", "k") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "unscoped", unscopedMeta) } diff --git a/internal/compiler/bench_test.go b/internal/compiler/bench_test.go index dddce1aa..24509f60 100644 --- a/internal/compiler/bench_test.go +++ b/internal/compiler/bench_test.go @@ -17,14 +17,14 @@ type benchStore struct { balances map[runtime.PairKey]*big.Int } -func (s benchStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { - if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { +func (s benchStore) GetBalance(ctx context.Context, account, scope, asset, color string) (*big.Int, error) { + if v, ok := s.balances[runtime.PairKey{Account: account, Scope: scope, Asset: asset, Color: color}]; ok { return v, nil } return new(big.Int), nil } -func (benchStore) GetMetadata(ctx context.Context, k, v string) (string, bool, error) { +func (benchStore) GetMetadata(ctx context.Context, account, scope, key string) (string, bool, error) { return "", false, nil } @@ -34,10 +34,11 @@ type runtimeStoreAdapter struct { func (s runtimeStoreAdapter) GetBalance( account string, + scope string, asset string, color string, ) (*big.Int, error) { - return s.store.GetBalance(context.Background(), account, asset, color) + return s.store.GetBalance(context.Background(), account, scope, asset, color) } // Both benchmarks run the SAME program with the same starting balance; only the diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 26e400b6..52307e4d 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -67,9 +67,6 @@ type state struct { // register, so the comparison is compiled, not built in. const worldAccount = "world" -// Every flag in flags.AllFlags has a check site below except -// ExperimentalScopedFunction: scoped() isn't in typecheck's builtin table, so a -// call to it is already rejected as an unknown function. func (st *state) checkFeatureFlag(rng parser.Range, flag flags.FeatureFlag) CompilerError { if _, ok := st.featureFlags[flag]; ok { return nil @@ -399,6 +396,13 @@ func (st *state) compileFnCall(expr *parser.FnCall, isVarOrigin bool) (ir.Reg, C case builtins.Meta: return 0, InvalidMetaPosition{Range: expr.Range} + // scoped() only ever reaches here if some caller compiled a TypeAccount + // expression through the generic (single-register) path instead of + // compileAccountExpr — every real call site is routed through the latter, so + // this is a defensive backstop, not an expected path. + case builtins.Scoped: + return 0, InvalidScopedAccountPosition{Range: expr.Range} + default: panic("TODO compileExpr fn call " + expr.Caller.Name) } @@ -488,7 +492,7 @@ func (st *state) compileMonetaryFnCall(expr *parser.FnCall, isVarOrigin bool) (m switch expr.Caller.Name { case builtins.Balance: - accountReg, err := st.compileExpr(expr.Args[0]) + acc, err := st.compileAccountExpr(expr.Args[0]) if err != nil { return monetaryValue{}, err } @@ -497,16 +501,16 @@ func (st *state) compileMonetaryFnCall(expr *parser.FnCall, isVarOrigin bool) (m return monetaryValue{}, err } balReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { - return ir.FetchBalance{Dest: dest, Account: accountReg, Asset: assetReg} + return ir.FetchBalance{Dest: dest, Account: acc.Name, Asset: assetReg, Scope: acc.Scope} }) - st.Push(ir.AssertNonNegativeBalance{Balance: balReg, Account: accountReg}) + st.Push(ir.AssertNonNegativeBalance{Balance: balReg, Account: acc.Name}) return monetaryValue{Asset: assetReg, Amount: balReg}, nil case builtins.Overdraft: if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalOverdraftFunctionFeatureFlag); err != nil { return monetaryValue{}, err } - accountReg, err := st.compileExpr(expr.Args[0]) + acc, err := st.compileAccountExpr(expr.Args[0]) if err != nil { return monetaryValue{}, err } @@ -515,7 +519,7 @@ func (st *state) compileMonetaryFnCall(expr *parser.FnCall, isVarOrigin bool) (m return monetaryValue{}, err } balReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { - return ir.FetchBalance{Dest: dest, Account: accountReg, Asset: assetReg} + return ir.FetchBalance{Dest: dest, Account: acc.Name, Asset: assetReg, Scope: acc.Scope} }) zeroReg := st.PushWithDest(func(dest ir.Reg) ir.Instr { return ir.LoadInt{Value: *big.NewInt(0), Dest: dest} @@ -535,6 +539,63 @@ func (st *state) compileMonetaryFnCall(expr *parser.FnCall, isVarOrigin bool) (m } } +// compileAccountExpr compiles a TypeAccount expression into its (name, scope) +// pair, mirroring compileMonetaryExpr's role for TypeMonetary. Scope is nil +// unless the expression is (or resolves, through a var, to) a scoped() call. +func (st *state) compileAccountExpr(expr parser.ValueExpr) (accountValue, CompilerError) { + switch expr := expr.(type) { + case *parser.Variable: + v, ok := st.vars[expr.Name] + if !ok { + return accountValue{}, UnboundVar{Range: expr.Range, Var: expr.Name} + } + if v.Acc != nil { + return *v.Acc, nil + } + return accountValue{Name: v.Reg}, nil + + case *parser.FnCall: + // scoped() is the only builtin whose return type is TypeAccount, so any + // FnCall reaching here (checked by typecheck already) must be it. + return st.compileScopedFnCall(expr, false) + + default: + r, err := st.compileExpr(expr) + if err != nil { + return accountValue{}, err + } + return accountValue{Name: r}, nil + } +} + +// compileScopedFnCall compiles a scoped(account, scope) call into the (name, +// scope) pair. isVarOrigin carries the same meaning as in compileFnCall / +// compileMonetaryFnCall: true only when this call is itself a variable's whole +// origin expression. +func (st *state) compileScopedFnCall(expr *parser.FnCall, isVarOrigin bool) (accountValue, CompilerError) { + if !isVarOrigin { + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalMidScriptFunctionCall); err != nil { + return accountValue{}, err + } + } + if err := st.checkFeatureFlag(expr.Range, flags.ExperimentalScopedFunction); err != nil { + return accountValue{}, err + } + + inner, err := st.compileAccountExpr(expr.Args[0]) + if err != nil { + return accountValue{}, err + } + scopeReg, err := st.compileExpr(expr.Args[1]) + if err != nil { + return accountValue{}, err + } + st.Push(ir.AssertValidScope{Scope: scopeReg}) + + // scoped(scoped(x, "a"), "b") overwrites: the result is scoped "b", not "a". + return accountValue{Name: inner.Name, Scope: &scopeReg}, nil +} + // compileColor returns nil when the source has no color clause: PullAccount with // no color pulls the uncolored balance, same as an empty color string. func (st *state) compileColor(colorExpr parser.ValueExpr) (*ir.Reg, CompilerError) { @@ -569,14 +630,15 @@ func (st *state) compileColor(colorExpr parser.ValueExpr) (*ir.Reg, CompilerErro // every account, so the two arms would be identical and the branch is skipped. // A literal @world still gets the branch; collapsing it is a peephole's job // (const-fold str_eq, then drop the dead arm). -func (st *state) pullFromAccount(accReg ir.Reg, capReg, overdraftReg, colorReg *ir.Reg) ir.Reg { +func (st *state) pullFromAccount(acc accountValue, capReg, overdraftReg, colorReg *ir.Reg) ir.Reg { pull := func(dest ir.Reg, overdraft *ir.Reg) ir.Instr { return ir.PullAccount{ Dest: dest, - Account: accReg, + Account: acc.Name, Cap: capReg, Overdraft: overdraft, Color: colorReg, + Scope: acc.Scope, } } @@ -585,7 +647,7 @@ func (st *state) pullFromAccount(accReg ir.Reg, capReg, overdraftReg, colorReg * } isWorld := st.PushWithDest(func(dest ir.Reg) ir.Instr { - return ir.BinaryOp{Op: ir.OpStrEq{}, Left: accReg, Right: st.worldReg, Dest: dest} + return ir.BinaryOp{Op: ir.OpStrEq{}, Left: acc.Name, Right: st.worldReg, Dest: dest} }) notWorldLabel := st.FreshLabel("not_world") endLabel := st.FreshLabel("pull_end") @@ -649,7 +711,7 @@ func (st *state) compileSource( ) (ir.Reg, CompilerError) { switch src := src.(type) { case *parser.SourceAccount: - accReg, err := st.compileExpr(src.ValueExpr) + acc, err := st.compileAccountExpr(src.ValueExpr) if err != nil { return 0, err } @@ -666,7 +728,7 @@ func (st *state) compileSource( } }) - return st.pullFromAccount(accReg, capReg, &overdraftReg, colorReg), nil + return st.pullFromAccount(acc, capReg, &overdraftReg, colorReg), nil case *parser.SourceOverdraft: if src.Bounded == nil && capReg == nil { @@ -675,7 +737,7 @@ func (st *state) compileSource( } } - accReg, err := st.compileExpr(src.Address) + acc, err := st.compileAccountExpr(src.Address) if err != nil { return 0, err } @@ -694,7 +756,7 @@ func (st *state) compileSource( overdraftReg = &amtReg } - return st.pullFromAccount(accReg, capReg, overdraftReg, colorReg), nil + return st.pullFromAccount(acc, capReg, overdraftReg, colorReg), nil case *parser.SourceCapped: clauseCapIntReg, err := st.compileCapAmount(src.Cap) @@ -956,7 +1018,7 @@ func (st *state) compileDestination( return nil case *parser.DestinationAccount: - accReg, err := st.compileExpr(dest.ValueExpr) + acc, err := st.compileAccountExpr(dest.ValueExpr) if err != nil { return err } @@ -966,8 +1028,9 @@ func (st *state) compileDestination( cap = ¤tCap } st.Push(ir.SendToAccount{ - Account: &accReg, + Account: &acc.Name, Cap: cap, + Scope: acc.Scope, }) case *parser.DestinationInorder: @@ -1092,11 +1155,11 @@ func (st *state) compileStatements(stmt parser.Statement) CompilerError { utils.NonExhaustiveMatchPanic[any](stmt.SentValue) } - accReg, err := st.compileExpr(stmt.Account) + acc, err := st.compileAccountExpr(stmt.Account) if err != nil { return err } - st.Push(ir.Save{Account: accReg, Asset: assetReg, Amount: amountReg}) + st.Push(ir.Save{Account: acc.Name, Asset: assetReg, Amount: amountReg, Scope: acc.Scope}) return nil case *parser.FnCall: switch stmt.Caller.Name { @@ -1113,7 +1176,7 @@ func (st *state) compileStatements(stmt parser.Statement) CompilerError { return nil case builtins.SetAccountMeta: - account, err := st.compileExpr(stmt.Args[0]) + acc, err := st.compileAccountExpr(stmt.Args[0]) if err != nil { return err } @@ -1125,7 +1188,7 @@ func (st *state) compileStatements(stmt parser.Statement) CompilerError { if err != nil { return err } - st.Push(ir.SetAccountMeta{Account: account, Key: key, Value: value}) + st.Push(ir.SetAccountMeta{Account: acc.Name, Key: key, Value: value, Scope: acc.Scope}) return nil default: @@ -1156,13 +1219,28 @@ func (st *state) compileMetaValue(expr parser.ValueExpr) (ir.Reg, CompilerError) }), nil } + // a scoped account can be the *subject* of a metadata write (compiled via + // compileAccountExpr elsewhere), but never the stored *value* — mirrors the + // interpreter's CannotStoreScopedAccountInMeta, checked here at compile time + // since scopedness is static. + if st.exprTypes[expr] == typecheck.TypeAccount { + acc, err := st.compileAccountExpr(expr) + if err != nil { + return 0, err + } + if acc.Scope != nil { + return 0, CannotStoreScopedAccountInMeta{Range: expr.GetRange()} + } + return acc.Name, nil + } + r, err := st.compileExpr(expr) if err != nil { return 0, err } switch st.exprTypes[expr] { - case typecheck.TypeString, typecheck.TypeAccount, typecheck.TypeAsset: + case typecheck.TypeString, typecheck.TypeAsset: return r, nil case typecheck.TypeNumber: return st.PushWithDest(func(dest ir.Reg) ir.Instr { @@ -1251,6 +1329,31 @@ func (st *state) compileVarDeclaration(decl parser.VarDeclaration) CompilerError return nil } + if decl.Type.Name == typecheck.TypeAccount { + if fnCall, ok := (*decl.Origin).(*parser.FnCall); ok { + // meta() can produce any declared type, account included (e.g. + // `account $seller = meta($sale, "seller")`) — a value read from + // metadata is always a plain, unscoped account name. + if fnCall.Caller.Name == builtins.Meta { + return st.compileMetaVar(decl, fnCall) + } + // scoped() is the only other builtin returning TypeAccount; a call + // that is the whole origin expression isn't a mid-script call. + acc, err := st.compileScopedFnCall(fnCall, true) + if err != nil { + return err + } + st.vars[decl.Name.Name] = accValue(acc) + return nil + } + acc, err := st.compileAccountExpr(*decl.Origin) + if err != nil { + return err + } + st.vars[decl.Name.Name] = accValue(acc) + return nil + } + var r ir.Reg var err CompilerError if fnCall, ok := (*decl.Origin).(*parser.FnCall); ok { @@ -1272,7 +1375,7 @@ func (st *state) compileVarDeclaration(decl parser.VarDeclaration) CompilerError } func (st *state) compileMetaVar(decl parser.VarDeclaration, fnCall *parser.FnCall) CompilerError { - account, err := st.compileExpr(fnCall.Args[0]) + acc, err := st.compileAccountExpr(fnCall.Args[0]) if err != nil { return err } @@ -1289,8 +1392,9 @@ func (st *state) compileMetaVar(decl parser.VarDeclaration, fnCall *parser.FnCal st.Push(ir.MetaMonetary{ DestAsset: destAsset, DestAmount: destAmount, - Account: account, + Account: acc.Name, Key: key, + Scope: acc.Scope, }) st.vars[decl.Name.Name] = monValue(monetaryValue{Asset: destAsset, Amount: destAmount}) return nil @@ -1309,7 +1413,7 @@ func (st *state) compileMetaVar(decl parser.VarDeclaration, fnCall *parser.FnCal } st.vars[decl.Name.Name] = scalarValue(st.PushWithDest(func(dest ir.Reg) ir.Instr { - return ir.MetaVar{Dest: dest, Account: account, Key: key, Typ: typ} + return ir.MetaVar{Dest: dest, Account: acc.Name, Key: key, Typ: typ, Scope: acc.Scope} })) return nil } diff --git a/internal/compiler/compiler_error.go b/internal/compiler/compiler_error.go index 5cca733e..a8cb77de 100644 --- a/internal/compiler/compiler_error.go +++ b/internal/compiler/compiler_error.go @@ -45,6 +45,24 @@ type ( Type typecheck.Type } + // CannotStoreScopedAccountInMeta is reported when a scoped account (the + // result of scoped()) is used as the *value* stored by set_tx_meta or + // set_account_meta. Mirrors the interpreter's runtime error of the same name, + // but caught at compile time since the compiler already knows an expression's + // scopedness statically. + CannotStoreScopedAccountInMeta struct { + parser.Range + } + + // InvalidScopedAccountPosition is reported when scoped() is reached from a + // position that only wants a plain string/generic value (e.g. account + // interpolation, or any other non-account context) — a defensive check that + // should be unreachable given the compiler's other call sites already route + // account-typed expressions through compileAccountExpr. + InvalidScopedAccountPosition struct { + parser.Range + } + // FeatureNotImplemented is returned (never panicked) when the compiler meets a // construct it does not support yet — e.g. colors or scoped accounts — so the // host gets an error instead of a crash. @@ -69,15 +87,17 @@ type ( } ) -func (UnboundVar) compileError() {} -func (TypeError) compileError() {} -func (InvalidUncappedSource) compileError() {} -func (DuplicateRemaining) compileError() {} -func (InvalidMetaPosition) compileError() {} -func (CannotCastToString) compileError() {} -func (FeatureNotImplemented) compileError() {} -func (ExperimentalFeature) compileError() {} -func (InvalidFeature) compileError() {} +func (UnboundVar) compileError() {} +func (TypeError) compileError() {} +func (InvalidUncappedSource) compileError() {} +func (DuplicateRemaining) compileError() {} +func (InvalidMetaPosition) compileError() {} +func (CannotCastToString) compileError() {} +func (CannotStoreScopedAccountInMeta) compileError() {} +func (InvalidScopedAccountPosition) compileError() {} +func (FeatureNotImplemented) compileError() {} +func (ExperimentalFeature) compileError() {} +func (InvalidFeature) compileError() {} func (e FeatureNotImplemented) Error() string { return "internal error: feature not implemented: " + e.Feature @@ -98,6 +118,12 @@ func (InvalidMetaPosition) Error() string { func (e CannotCastToString) Error() string { return "cannot cast a value of type " + string(e.Type) + " to string" } +func (CannotStoreScopedAccountInMeta) Error() string { + return "cannot store a scoped account as a metadata value" +} +func (InvalidScopedAccountPosition) Error() string { + return "a scoped account cannot be used here" +} func (e ExperimentalFeature) Error() string { return fmt.Sprintf("this feature is experimental. You need the '%s' feature flag to enable it", e.FlagName) } @@ -112,6 +138,8 @@ var ( _ CompilerError = (*DuplicateRemaining)(nil) _ CompilerError = (*InvalidMetaPosition)(nil) _ CompilerError = (*CannotCastToString)(nil) + _ CompilerError = (*CannotStoreScopedAccountInMeta)(nil) + _ CompilerError = (*InvalidScopedAccountPosition)(nil) _ CompilerError = (*FeatureNotImplemented)(nil) _ CompilerError = (*ExperimentalFeature)(nil) _ CompilerError = (*InvalidFeature)(nil) diff --git a/internal/compiler/compiler_example_test.go b/internal/compiler/compiler_example_test.go index b2910d85..eb5559ac 100644 --- a/internal/compiler/compiler_example_test.go +++ b/internal/compiler/compiler_example_test.go @@ -76,10 +76,10 @@ func TestCompilerExample(t *testing.T) { type testStore map[string]int64 -func (s testStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { +func (s testStore) GetBalance(ctx context.Context, account, scope, asset, color string) (*big.Int, error) { return big.NewInt(s[account]), nil } -func (testStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { +func (testStore) GetMetadata(ctx context.Context, account, scope, key string) (string, bool, error) { return "", false, nil } diff --git a/internal/compiler/e2e_test.go b/internal/compiler/e2e_test.go index 17c9e71f..ed469cce 100644 --- a/internal/compiler/e2e_test.go +++ b/internal/compiler/e2e_test.go @@ -15,18 +15,25 @@ import ( // 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 + metadata map[e2eMetaKey]string } -func (s e2eStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { - if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { +// e2eMetaKey identifies one metadata slot: account, scope and key. +type e2eMetaKey struct { + account string + scope string + key string +} + +func (s e2eStore) GetBalance(ctx context.Context, account, scope, asset, color string) (*big.Int, error) { + if v, ok := s.balances[runtime.PairKey{Account: account, Scope: scope, Asset: asset, Color: color}]; ok { return v, nil } return new(big.Int), nil } -func (s e2eStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { - v, ok := s.metadata[account][key] +func (s e2eStore) GetMetadata(ctx context.Context, account, scope, key string) (string, bool, error) { + v, ok := s.metadata[e2eMetaKey{account: account, scope: scope, key: key}] return v, ok, nil } @@ -989,9 +996,9 @@ type countingStore struct { balanceCalls int } -func (s *countingStore) GetBalance(ctx context.Context, account, asset, color string) (*big.Int, error) { +func (s *countingStore) GetBalance(ctx context.Context, account, scope, asset, color string) (*big.Int, error) { s.balanceCalls++ - return s.e2eStore.GetBalance(ctx, account, asset, color) + return s.e2eStore.GetBalance(ctx, account, scope, asset, color) } // The compiled world arm has no overdraft operand, which is what makes the pull diff --git a/internal/compiler/scripts_test.go b/internal/compiler/scripts_test.go index 48eed882..fe0404b9 100644 --- a/internal/compiler/scripts_test.go +++ b/internal/compiler/scripts_test.go @@ -21,19 +21,10 @@ import ( const scriptsFolder = "../interpreter/testdata/script-tests" -// scriptsBlacklist lists spec files the compiler+VM can't run yet. What's left is -// the three experimental features the compiler has no lowering for: scopes, -// colors and scaling. Delete entries as features land, until it's empty. +// scriptsBlacklist lists spec files the compiler+VM can't run yet. What's left +// is asset-scaling, which the compiler has no lowering for. Delete entries as +// features land, until it's empty. var scriptsBlacklist = []string{ - "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-scaling/no-solution.num", "experimental/asset-scaling/scaling-all-allotment.num", "experimental/asset-scaling/scaling-allotment.num", @@ -164,8 +155,8 @@ func vmMetaValue(v interpreter.Value) string { } } -func txMetaAsStrings(rows specs_format.ExpectedTxMeta) runtime.AccountMetadata { - out := runtime.AccountMetadata{} +func txMetaAsStrings(rows specs_format.ExpectedTxMeta) map[string]string { + out := map[string]string{} for _, row := range rows { out[row.Key] = vmMetaValue(row.Value) } @@ -173,12 +164,14 @@ func txMetaAsStrings(rows specs_format.ExpectedTxMeta) runtime.AccountMetadata { } func accountsMetaAsStrings(rows interpreter.SetAccountsMetadata) runtime.AccountsMetadata { - out := runtime.AccountsMetadata{} + out := make(runtime.AccountsMetadata, 0, len(rows)) for _, row := range rows { - if out[row.Account] == nil { - out[row.Account] = runtime.AccountMetadata{} - } - out[row.Account][row.Key] = vmMetaValue(row.Value) + out = append(out, runtime.AccountMetadataEntry{ + Account: row.Account, + Scope: row.Scope, + Key: row.Key, + Value: vmMetaValue(row.Value), + }) } return out } @@ -186,16 +179,13 @@ func accountsMetaAsStrings(rows interpreter.SetAccountsMetadata) runtime.Account func scriptStore(balances interpreter.Balances, metaOuter, metaInner interpreter.AccountsMetadata) e2eStore { m := map[runtime.PairKey]*big.Int{} for _, b := range balances { - m[runtime.PairKey{Account: b.Account, Asset: b.Asset, Color: b.Color}] = b.Amount + m[runtime.PairKey{Account: b.Account, Scope: b.Scope, Asset: b.Asset, Color: b.Color}] = b.Amount } - meta := map[string]map[string]string{} + meta := map[e2eMetaKey]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 + meta[e2eMetaKey{account: row.Account, scope: row.Scope, key: row.Key}] = row.Value } } diff --git a/internal/compiler/value.go b/internal/compiler/value.go index 172d52df..22a43fe2 100644 --- a/internal/compiler/value.go +++ b/internal/compiler/value.go @@ -13,13 +13,26 @@ type monetaryValue struct { Amount ir.Reg // int } +// accountValue is an account-typed expression after codegen. Scope is a second +// register alongside the name, nil when the expression is provably unscoped (an +// account literal, a plain var, or any account not produced by scoped()) — the +// same nilable-operand idiom PullAccount already uses for Color/Overdraft. +type accountValue struct { + Name ir.Reg // str + Scope *ir.Reg // str +} + // value is a compiled expression of any type. Mon is set exactly for -// monetary-typed expressions, Reg for every other type. +// monetary-typed expressions, Acc for account-typed ones, Reg for every other +// type. type value struct { Reg ir.Reg Mon *monetaryValue + Acc *accountValue } func scalarValue(r ir.Reg) value { return value{Reg: r} } func monValue(m monetaryValue) value { return value{Mon: &m} } + +func accValue(a accountValue) value { return value{Acc: &a} } diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 32a162a7..8ce03038 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -16,7 +16,7 @@ import ( // zero — exactly the semantics this store provides. type zeroStore struct{} -func (zeroStore) GetBalance(account, asset, color string) (*big.Int, error) { +func (zeroStore) GetBalance(account, scope, asset, color string) (*big.Int, error) { return new(big.Int), nil } diff --git a/internal/ir/assemble.go b/internal/ir/assemble.go index 8f59c206..7dade73b 100644 --- a/internal/ir/assemble.go +++ b/internal/ir/assemble.go @@ -427,6 +427,8 @@ func (i CheckEnoughFunds) assemble(a *assembler) error { return nil } +// Save needs a fourth operand (scope) but its base word's three slots are all +// taken, so it spills scope into an ext word, like PullAccount. func (i Save) assemble(a *assembler) error { account, err := a.strReg(i.Account) if err != nil { @@ -440,7 +442,18 @@ func (i Save) assemble(a *assembler) error { if err != nil { return err } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } + a.emit(vm.Op_Save, account, asset, amount) + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: scope, + B: maxReg, + C: maxReg, + }) return nil } @@ -493,13 +506,18 @@ func (i PullAccount) assemble(a *assembler) error { return err } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + 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 + C: scope, // scope (str) }) return nil @@ -516,7 +534,12 @@ func (i SendToAccount) assemble(a *assembler) error { return err } - a.emit(vm.Op_SendToAccount, account, cap, maxReg) + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } + + a.emit(vm.Op_SendToAccount, account, cap, scope) return nil } @@ -557,6 +580,17 @@ func (i AssertValidColor) assemble(a *assembler) error { return nil } +func (i AssertValidScope) assemble(a *assembler) error { + scope, err := a.strReg(i.Scope) + if err != nil { + return err + } + + a.emit(vm.Op_AssertValidScope, scope, maxReg, maxReg) + + return nil +} + func (i AssertNonNegativeBalance) assemble(a *assembler) error { balance, err := a.intReg(i.Balance) if err != nil { @@ -587,7 +621,11 @@ func (i SetTxMeta) assemble(a *assembler) error { return nil } -func (a *assembler) emitMeta(opcode vm.Opcode, dest byte, account, key Reg) error { +// emitMeta emits the shared base word for a meta(account, key) read (dest, +// account, key) plus an ext word carrying scope, shared by MetaStr/Int/Portion. +// MetaMonetary needs its ext word for a second destination too, so it builds its +// own instead of sharing this helper. +func (a *assembler) emitMeta(opcode vm.Opcode, dest byte, account, key Reg, scope *Reg) error { acc, err := a.strReg(account) if err != nil { return err @@ -596,40 +634,52 @@ func (a *assembler) emitMeta(opcode vm.Opcode, dest byte, account, key Reg) erro if err != nil { return err } + s, err := a.optionalReg((*assembler).strReg, scope) + if err != nil { + return err + } + a.emit(opcode, dest, acc, k) + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: s, + B: maxReg, + C: maxReg, + }) return nil } -func (MetaStr) assembleMeta(a *assembler, dest, account, key Reg) error { +func (MetaStr) assembleMeta(a *assembler, dest, account, key Reg, scope *Reg) error { d, err := a.strReg(dest) if err != nil { return err } - return a.emitMeta(vm.Op_MetaStr, d, account, key) + return a.emitMeta(vm.Op_MetaStr, d, account, key, scope) } -func (MetaInt) assembleMeta(a *assembler, dest, account, key Reg) error { +func (MetaInt) assembleMeta(a *assembler, dest, account, key Reg, scope *Reg) error { d, err := a.intReg(dest) if err != nil { return err } - return a.emitMeta(vm.Op_MetaInt, d, account, key) + return a.emitMeta(vm.Op_MetaInt, d, account, key, scope) } -func (MetaPortion) assembleMeta(a *assembler, dest, account, key Reg) error { +func (MetaPortion) assembleMeta(a *assembler, dest, account, key Reg, scope *Reg) error { d, err := a.portionReg(dest) if err != nil { return err } - return a.emitMeta(vm.Op_MetaPortion, d, account, key) + return a.emitMeta(vm.Op_MetaPortion, d, account, key, scope) } func (i MetaVar) assemble(a *assembler) error { - return i.Typ.assembleMeta(a, i.Dest, i.Account, i.Key) + return i.Typ.assembleMeta(a, i.Dest, i.Account, i.Key, i.Scope) } -// MetaMonetary needs four operands, so it spills the second destination into an -// ext word, like PullAccount. +// MetaMonetary needs four operands plus scope, so it spills the second +// destination and scope into its own ext word rather than sharing emitMeta +// (which would otherwise burn a second, conflicting ext word). func (i MetaMonetary) assemble(a *assembler) error { destAsset, err := a.strReg(i.DestAsset) if err != nil { @@ -639,18 +689,31 @@ func (i MetaMonetary) assemble(a *assembler) error { if err != nil { return err } - if err := a.emitMeta(vm.Op_MetaMonetary, destAsset, i.Account, i.Key); err != nil { + account, err := a.strReg(i.Account) + if err != nil { return err } + key, err := a.strReg(i.Key) + if err != nil { + return err + } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } + + a.emit(vm.Op_MetaMonetary, destAsset, account, key) a.instructions = append(a.instructions, vm.Instruction{ Opcode: maxReg, A: destAmount, - B: maxReg, + B: scope, C: maxReg, }) return nil } +// SetAccountMeta needs a fourth operand (scope) but its base word's three slots +// are all taken, so it spills scope into an ext word, like Save. func (i SetAccountMeta) assemble(a *assembler) error { account, err := a.strReg(i.Account) if err != nil { @@ -664,12 +727,24 @@ func (i SetAccountMeta) assemble(a *assembler) error { if err != nil { return err } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } a.emit(vm.Op_SetAccountMeta, account, key, value) + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: scope, + B: maxReg, + C: maxReg, + }) return nil } +// FetchBalance needs a fourth operand (scope) but its base word's three slots +// are all taken, so it spills scope into an ext word, like Save. func (i FetchBalance) assemble(a *assembler) error { dest, err := a.intReg(i.Dest) if err != nil { @@ -683,8 +758,18 @@ func (i FetchBalance) assemble(a *assembler) error { if err != nil { return err } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } a.emit(vm.Op_Balance, dest, account, asset) + a.instructions = append(a.instructions, vm.Instruction{ + Opcode: maxReg, + A: scope, + B: maxReg, + C: maxReg, + }) return nil } diff --git a/internal/ir/dump.go b/internal/ir/dump.go index 7daf35ea..1a3489bb 100644 --- a/internal/ir/dump.go +++ b/internal/ir/dump.go @@ -39,6 +39,7 @@ func (i PullAccount) String() string { optLabel("cap", i.Cap), optLabel("overdraft", i.Overdraft), optLabel("color", i.Color), + optLabel("scope", i.Scope), ) s := fmt.Sprintf("%s = pull_account(account: %s", i.Dest, i.Account) if opts != "" { @@ -48,7 +49,7 @@ func (i PullAccount) String() string { } func (i SendToAccount) String() string { - opts := joinOpts(optLabel("account", i.Account), optLabel("cap", i.Cap)) + opts := joinOpts(optLabel("account", i.Account), optLabel("cap", i.Cap), optLabel("scope", i.Scope)) return fmt.Sprintf("send_to_account(%s)", opts) } @@ -57,10 +58,12 @@ func (i CheckEnoughFunds) String() string { } func (i Save) String() string { - if i.Amount == nil { - return fmt.Sprintf("save(account: %s, asset: %s)", i.Account, i.Asset) + opts := joinOpts(optLabel("amount", i.Amount), optLabel("scope", i.Scope)) + s := fmt.Sprintf("save(account: %s, asset: %s", i.Account, i.Asset) + if opts != "" { + s += ", " + opts } - return fmt.Sprintf("save(account: %s, asset: %s, amount: %s)", i.Account, i.Asset, *i.Amount) + return s + ")" } func (i AssertLeftover) String() string { @@ -86,6 +89,10 @@ func (i AssertValidColor) String() string { return fmt.Sprintf("assert_valid_color(%s)", i.Color) } +func (i AssertValidScope) String() string { + return fmt.Sprintf("assert_valid_scope(%s)", i.Scope) +} + func (i AssertNonNegativeBalance) String() string { return fmt.Sprintf("assert_non_negative_balance(%s, %s)", i.Balance, i.Account) } @@ -95,15 +102,27 @@ func (i SetTxMeta) String() string { } func (i SetAccountMeta) String() string { - return fmt.Sprintf("set_account_meta(%s, %s, %s)", i.Account, i.Key, i.Value) + s := fmt.Sprintf("set_account_meta(%s, %s, %s)", i.Account, i.Key, i.Value) + if i.Scope != nil { + s = fmt.Sprintf("set_account_meta(%s, %s, %s, %s)", i.Account, i.Key, i.Value, optLabel("scope", i.Scope)) + } + return s } func (i MetaVar) String() string { - return fmt.Sprintf("%s = meta<%s>(%s, %s)", i.Dest, i.Typ, i.Account, i.Key) + s := fmt.Sprintf("%s = meta<%s>(%s, %s)", i.Dest, i.Typ, i.Account, i.Key) + if i.Scope != nil { + s = fmt.Sprintf("%s = meta<%s>(%s, %s, %s)", i.Dest, i.Typ, i.Account, i.Key, optLabel("scope", i.Scope)) + } + return s } func (i MetaMonetary) String() string { - return fmt.Sprintf("[%s, %s] = meta_monetary(%s, %s)", i.DestAsset, i.DestAmount, i.Account, i.Key) + s := fmt.Sprintf("[%s, %s] = meta_monetary(%s, %s)", i.DestAsset, i.DestAmount, i.Account, i.Key) + if i.Scope != nil { + s = fmt.Sprintf("[%s, %s] = meta_monetary(%s, %s, %s)", i.DestAsset, i.DestAmount, i.Account, i.Key, optLabel("scope", i.Scope)) + } + return s } func (MetaStr) String() string { return "str" } @@ -111,7 +130,11 @@ func (MetaInt) String() string { return "int" } func (MetaPortion) String() string { return "portion" } func (i FetchBalance) String() string { - return fmt.Sprintf("%s = balance(%s, %s)", i.Dest, i.Account, i.Asset) + s := fmt.Sprintf("%s = balance(%s, %s)", i.Dest, i.Account, i.Asset) + if i.Scope != nil { + s = fmt.Sprintf("%s = balance(%s, %s, %s)", i.Dest, i.Account, i.Asset, optLabel("scope", i.Scope)) + } + return s } func (i LoadVar) String() string { diff --git a/internal/ir/instr.go b/internal/ir/instr.go index 2777ad8b..316249b3 100644 --- a/internal/ir/instr.go +++ b/internal/ir/instr.go @@ -82,7 +82,7 @@ type ( type MetaType interface { fmt.Stringer - assembleMeta(a *assembler, Dest, Account, Key Reg) error + assembleMeta(a *assembler, Dest, Account, Key Reg, Scope *Reg) error } type ( @@ -93,33 +93,39 @@ type ( type ( PullAccount struct { - Dest Reg // int: amount pulled - Account Reg // str - Cap, Overdraft, Color *Reg // int, int, str + Dest Reg // int: amount pulled + Account Reg // str + Cap, Overdraft, Color, Scope *Reg // int, int, str, str } SendToAccount struct { - Account, Cap *Reg // str, int + Account, Cap, Scope *Reg // str, int, str } Save struct { Account Reg // str Asset Reg // str Amount *Reg // int; nil = save all + Scope *Reg // str } 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 - AssertValidColor struct{ Color Reg } // str - AssertNonNegativeBalance struct{ Balance, Account Reg } // int (the amount), str - SetTxMeta struct{ Key, Value Reg } // str, str - SetAccountMeta struct{ Account, Key, Value Reg } // str, str, str - MetaVar struct { + SetCurrentAsset struct{ Asset Reg } // str + AssertSameAsset struct{ Left, Right Reg } // str, str + AssertValidAccount struct{ Account Reg } // str + AssertValidColor struct{ Color Reg } // str + AssertValidScope struct{ Scope Reg } // str + AssertNonNegativeBalance struct{ Balance, Account Reg } // int (the amount), str + SetTxMeta struct{ Key, Value Reg } // str, str + SetAccountMeta struct { + Account, Key, Value Reg // str, str, str + Scope *Reg // str + } + MetaVar struct { Dest Reg - Account, Key Reg // str, str + Account, Key Reg // str, str + Scope *Reg // str Typ MetaType } // MetaMonetary is meta: one store read yields both halves, so it is @@ -129,10 +135,12 @@ type ( DestAmount Reg // int Account Reg // str Key Reg // str + Scope *Reg } FetchBalance struct { Dest Reg // int (the amount; the asset is the Asset operand) Account, Asset Reg // str, str + Scope *Reg } // reads the run-state (impure) LoadVar struct { Dest Reg @@ -200,11 +208,13 @@ type Instr interface { 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 PullAccount) dests() []Reg { return []Reg{i.Dest} } +func (i PullAccount) sources() []Reg { + return present(&i.Account, i.Cap, i.Overdraft, i.Color, i.Scope) +} func (i SendToAccount) dests() []Reg { return nil } -func (i SendToAccount) sources() []Reg { return present(i.Account, i.Cap) } +func (i SendToAccount) sources() []Reg { return present(i.Account, i.Cap, i.Scope) } func (i CheckEnoughFunds) dests() []Reg { return nil } func (i CheckEnoughFunds) sources() []Reg { return []Reg{i.Got, i.Needed} } @@ -215,6 +225,9 @@ func (i Save) sources() []Reg { if i.Amount != nil { regs = append(regs, *i.Amount) } + if i.Scope != nil { + regs = append(regs, *i.Scope) + } return regs } @@ -233,6 +246,9 @@ func (i AssertValidAccount) sources() []Reg { return []Reg{i.Account} } func (i AssertValidColor) dests() []Reg { return nil } func (i AssertValidColor) sources() []Reg { return []Reg{i.Color} } +func (i AssertValidScope) dests() []Reg { return nil } +func (i AssertValidScope) sources() []Reg { return []Reg{i.Scope} } + func (i AssertNonNegativeBalance) dests() []Reg { return nil } func (i AssertNonNegativeBalance) sources() []Reg { return []Reg{i.Balance, i.Account} } @@ -240,16 +256,16 @@ 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 SetAccountMeta) sources() []Reg { return present(&i.Account, &i.Key, &i.Value, i.Scope) } func (i MetaVar) dests() []Reg { return []Reg{i.Dest} } -func (i MetaVar) sources() []Reg { return []Reg{i.Account, i.Key} } +func (i MetaVar) sources() []Reg { return present(&i.Account, &i.Key, i.Scope) } func (i MetaMonetary) dests() []Reg { return []Reg{i.DestAsset, i.DestAmount} } -func (i MetaMonetary) sources() []Reg { return []Reg{i.Account, i.Key} } +func (i MetaMonetary) sources() []Reg { return present(&i.Account, &i.Key, i.Scope) } func (i FetchBalance) dests() []Reg { return []Reg{i.Dest} } -func (i FetchBalance) sources() []Reg { return []Reg{i.Account, i.Asset} } +func (i FetchBalance) sources() []Reg { return present(&i.Account, &i.Asset, i.Scope) } func (i LoadVar) dests() []Reg { return []Reg{i.Dest} } func (i LoadVar) sources() []Reg { return nil } diff --git a/internal/ir/parse.go b/internal/ir/parse.go index 32251240..f1c115c9 100644 --- a/internal/ir/parse.go +++ b/internal/ir/parse.go @@ -413,10 +413,10 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { default: return nil, &Error{Range: s.Call.Range, Msg: fmt.Sprintf("meta: expected type parameter str, int or portion, got %q", typeParam)} } - instr = MetaVar{Dest: dest, Typ: typ, Account: ap.reg(), Key: ap.reg()} + instr = MetaVar{Dest: dest, Typ: typ, Account: ap.reg(), Key: ap.reg(), Scope: ap.optLabeledReg("scope")} case "balance": - instr = FetchBalance{Dest: dest, Account: ap.reg(), Asset: ap.reg()} + instr = FetchBalance{Dest: dest, Account: ap.reg(), Asset: ap.reg(), Scope: ap.optLabeledReg("scope")} case "monetary_to_string": instr = ap.BinaryOp(dest, OpMonetaryToString{}) @@ -475,14 +475,20 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { Cap: ap.optLabeledReg("cap"), Overdraft: ap.optLabeledReg("overdraft"), Color: ap.optLabeledReg("color"), + Scope: ap.optLabeledReg("scope"), } case "send_to_account": - instr = SendToAccount{Account: ap.optLabeledReg("account"), Cap: ap.optLabeledReg("cap")} + instr = SendToAccount{ + Account: ap.optLabeledReg("account"), + Cap: ap.optLabeledReg("cap"), + Scope: ap.optLabeledReg("scope"), + } case "save": instr = Save{ Account: ap.reqLabeledReg("account"), Asset: ap.reqLabeledReg("asset"), Amount: ap.optLabeledReg("amount"), + Scope: ap.optLabeledReg("scope"), } case "meta_monetary": @@ -495,6 +501,7 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { DestAmount: dests[1], Account: ap.reg(), Key: ap.reg(), + Scope: ap.optLabeledReg("scope"), } case "check_enough_funds": @@ -511,6 +518,8 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { instr = AssertValidAccount{Account: ap.reg()} case "assert_valid_color": instr = AssertValidColor{Color: ap.reg()} + case "assert_valid_scope": + instr = AssertValidScope{Scope: ap.reg()} case "assert_non_negative_balance": instr = AssertNonNegativeBalance{Balance: ap.reg(), Account: ap.reg()} @@ -525,7 +534,7 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { case "set_tx_meta": instr = SetTxMeta{Key: ap.reg(), Value: ap.reg()} case "set_account_meta": - instr = SetAccountMeta{Account: ap.reg(), Key: ap.reg(), Value: ap.reg()} + instr = SetAccountMeta{Account: ap.reg(), Key: ap.reg(), Value: ap.reg(), Scope: ap.optLabeledReg("scope")} case "jmp_if_false": cond, target := ap.reg(), ap.labelRef() diff --git a/internal/ir/typecheck.go b/internal/ir/typecheck.go index 2a28823d..2554c34a 100644 --- a/internal/ir/typecheck.go +++ b/internal/ir/typecheck.go @@ -104,12 +104,18 @@ func (tc *bytecodeTypechecker) check(instr Instr) error { tc.useOpt(i.Cap, regInt), tc.useOpt(i.Overdraft, regInt), tc.useOpt(i.Color, regStr), + tc.useOpt(i.Scope, regStr), tc.def(i.Dest, regInt), ) case SendToAccount: - return firstErr(tc.useOpt(i.Account, regStr), tc.useOpt(i.Cap, regInt)) + return firstErr(tc.useOpt(i.Account, regStr), tc.useOpt(i.Cap, regInt), tc.useOpt(i.Scope, regStr)) case Save: - return firstErr(tc.use(i.Account, regStr), tc.use(i.Asset, regStr), tc.useOpt(i.Amount, regInt)) + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Asset, regStr), + tc.useOpt(i.Amount, regInt), + tc.useOpt(i.Scope, regStr), + ) case CheckEnoughFunds: return firstErr(tc.use(i.Got, regInt), tc.use(i.Needed, regInt)) @@ -123,28 +129,46 @@ func (tc *bytecodeTypechecker) check(instr Instr) error { return tc.use(i.Account, regStr) case AssertValidColor: return tc.use(i.Color, regStr) + case AssertValidScope: + return tc.use(i.Scope, regStr) case AssertNonNegativeBalance: return firstErr(tc.use(i.Balance, regInt), 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)) + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Key, regStr), + tc.use(i.Value, regStr), + tc.useOpt(i.Scope, 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)) + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Key, regStr), + tc.useOpt(i.Scope, regStr), + tc.def(i.Dest, t), + ) case MetaMonetary: return firstErr( tc.use(i.Account, regStr), tc.use(i.Key, regStr), + tc.useOpt(i.Scope, regStr), tc.def(i.DestAsset, regStr), tc.def(i.DestAmount, regInt), ) case FetchBalance: - return firstErr(tc.use(i.Account, regStr), tc.use(i.Asset, regStr), tc.def(i.Dest, regInt)) + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Asset, regStr), + tc.useOpt(i.Scope, regStr), + tc.def(i.Dest, regInt), + ) case JmpIfFalse: return tc.use(i.Cond, regBool) diff --git a/internal/ir/typecheck_test.go b/internal/ir/typecheck_test.go index f26236ac..880fd114 100644 --- a/internal/ir/typecheck_test.go +++ b/internal/ir/typecheck_test.go @@ -285,8 +285,8 @@ func (unknownVarType) assembleLoad(*assembler, Reg, uint16) error { return nil } type unknownMetaType struct{} -func (unknownMetaType) String() string { return "unknown_meta_type" } -func (unknownMetaType) assembleMeta(*assembler, Reg, Reg, Reg) error { return nil } +func (unknownMetaType) String() string { return "unknown_meta_type" } +func (unknownMetaType) assembleMeta(*assembler, Reg, Reg, Reg, *Reg) error { return nil } func TestBytecodeTypecheck_UnknownTags(t *testing.T) { str := Reg(0) diff --git a/internal/runtime/metadata.go b/internal/runtime/metadata.go index e7603936..bd1071dd 100644 --- a/internal/runtime/metadata.go +++ b/internal/runtime/metadata.go @@ -4,49 +4,27 @@ 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 +// AccountMetadataEntry is one piece of account metadata set during execution. +// Scope is a second dimension of the account, like the balance PairKey: a flat +// list rather than a nested map, since JSON object keys must be strings and +// folding scope into the account key would need an encoding hack. +type AccountMetadataEntry struct { + Account string `json:"account"` + Scope string `json:"scope,omitempty"` + Key string `json:"key"` + Value string `json:"value"` } -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 - } - } -} +// AccountsMetadata is the account metadata produced by the script (the +// execution result's accountsMeta). +type AccountsMetadata []AccountMetadataEntry func (m AccountsMetadata) PrettyPrint() string { - header := []string{"Account", "Name", "Value"} + header := []string{"Account", "Scope", "Key", "Value"} - var rows [][]string - for account, accMetadata := range m { - for name, value := range accMetadata { - row := []string{account, name, value} - rows = append(rows, row) - } + rows := make([][]string, 0, len(m)) + for _, e := range m { + rows = append(rows, []string{e.Account, e.Scope, e.Key, e.Value}) } return utils.CsvPretty(header, rows, true) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 88b51bc6..0a03051c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -31,11 +31,12 @@ import ( // pushes to ends, so it only surfaces for hand-written IR or a hand-crafted .numb. var ErrNoOpenMark = errors.New("runtime: no open mark to end") -// Store supplies the starting balance for an (account, asset, color) triple. -// 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 reuse it. +// Store supplies the starting balance for an (account, scope, asset, color) +// tuple. Implementations should return 0 (or nil, treated as 0) for unknown +// tuples, not an error. The returned *big.Int is cloned on ingest, so the Store +// may reuse it. type Store interface { - GetBalance(account, asset, color string) (*big.Int, error) + GetBalance(account, scope, asset, color string) (*big.Int, error) } // Posting is aliased as the interpreter's public Posting type, so the json tags @@ -523,14 +524,12 @@ func (s *RunState) entryFor(key PairKey) *balanceEntry { } // loadBase folds e's starting balance in from the Store on first need, turning a -// delta-only entry into an absolute one. Idempotent once baseLoaded is set. The -// Store is scope-agnostic: scoped balances are seeded via Prewarm, and the VM (the -// only path hitting the Store) never uses scopes. +// delta-only entry into an absolute one. Idempotent once baseLoaded is set. func (s *RunState) loadBase(key PairKey, e *balanceEntry) error { if e.baseLoaded { return nil } - fromStore, err := s.store.GetBalance(key.Account, key.Asset, key.Color) + fromStore, err := s.store.GetBalance(key.Account, key.Scope, key.Asset, key.Color) if err != nil { return err } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 9dd1d9f6..235061f4 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -27,8 +27,8 @@ func newMockStore(initial map[runtime.PairKey]int64) *mockStore { return &mockStore{balances: b, calls: make(map[runtime.PairKey]int)} } -func (m *mockStore) GetBalance(account, asset, color string) (*big.Int, error) { - k := runtime.PairKey{account, "", asset, color} +func (m *mockStore) GetBalance(account, scope, asset, color string) (*big.Int, error) { + k := runtime.PairKey{account, scope, asset, color} m.calls[k]++ if v, ok := m.balances[k]; ok { return v, nil diff --git a/internal/typecheck/typecheck.go b/internal/typecheck/typecheck.go index afbcba8e..d9967bc7 100644 --- a/internal/typecheck/typecheck.go +++ b/internal/typecheck/typecheck.go @@ -128,6 +128,7 @@ var builtinSigs = map[string]fnSig{ builtins.Overdraft: {params: []Type{TypeAccount, TypeAsset}, ret: TypeMonetary}, builtins.GetAsset: {params: []Type{TypeMonetary}, ret: TypeAsset}, builtins.GetAmount: {params: []Type{TypeMonetary}, ret: TypeNumber}, + builtins.Scoped: {params: []Type{TypeAccount, TypeString}, ret: TypeAccount}, } // --- Result / entrypoint diff --git a/internal/vm/execution_err.go b/internal/vm/execution_err.go index f98a27f4..1c5eba52 100644 --- a/internal/vm/execution_err.go +++ b/internal/vm/execution_err.go @@ -49,6 +49,10 @@ type ( Color string } + InvalidScope struct { + Scope string + } + NegativeBalanceError struct { Account string Amount big.Int @@ -107,6 +111,10 @@ func (e InvalidColor) Error() string { return fmt.Sprintf("invalid color name: %q", e.Color) } +func (e InvalidScope) Error() string { + return fmt.Sprintf("invalid scope name: %q", e.Scope) +} + func (e NegativeBalanceError) Error() string { return fmt.Sprintf("cannot fetch negative balance from account @%s", e.Account) } @@ -134,6 +142,7 @@ func (MetadataNotFoundError) execErr() {} func (BadMetaValueError) execErr() {} func (InvalidAccountName) execErr() {} func (InvalidColor) execErr() {} +func (InvalidScope) execErr() {} func (NegativeBalanceError) execErr() {} func (DivideByZeroError) execErr() {} func (InternalError) execErr() {} @@ -148,6 +157,7 @@ var ( _ ExecutionError = (*BadMetaValueError)(nil) _ ExecutionError = (*InvalidAccountName)(nil) _ ExecutionError = (*InvalidColor)(nil) + _ ExecutionError = (*InvalidScope)(nil) _ ExecutionError = (*NegativeBalanceError)(nil) _ ExecutionError = (*DivideByZeroError)(nil) _ ExecutionError = (*InternalError)(nil) diff --git a/internal/vm/instruction.go b/internal/vm/instruction.go index 44a76cc9..d62ca8d1 100644 --- a/internal/vm/instruction.go +++ b/internal/vm/instruction.go @@ -56,6 +56,9 @@ const ( // errors if the color in str reg A is not well-formed Op_AssertValidColor Opcode = 0x06 + // errors if the scope in str reg A is not well-formed + Op_AssertValidScope Opcode = 0x07 + // --- constants & variables (0x10) --- // may split into one opcode per expr_typ later Op_LoadInt Opcode = 0x10 // LoadConst (`Int) -> b_c = const-pool index @@ -74,17 +77,20 @@ const ( // A = key (str reg), B = value (str reg) Op_SetTxMeta Opcode = 0x20 - // A = account (str reg), B = key (str reg), C = value (str reg) + // A = account (str reg), B = key (str reg), C = value (str reg); ext.A = + // scope (str reg, 0xFF = unscoped) Op_SetAccountMeta Opcode = 0x21 // meta(account, key) read, dispatched on the target type. - // A = dest, B = account (str reg), C = key (str reg) + // A = dest, B = account (str reg), C = key (str reg); ext.A = scope (str reg, + // 0xFF = unscoped) Op_MetaStr Opcode = 0x22 Op_MetaInt Opcode = 0x23 Op_MetaPortion Opcode = 0x24 // as above, but a monetary needs two destinations, so the amount's goes in an - // ext word: A = dest asset (str reg), ext.A = dest amount (int reg) + // ext word: A = dest asset (str reg), ext.A = dest amount (int reg), ext.B = + // scope (str reg, 0xFF = unscoped) Op_MetaMonetary Opcode = 0x25 // --- arithmetic & constructors (0x30) --- @@ -146,22 +152,24 @@ const ( // --- funds & postings (0x50) --- - // The most general form: account,cap,overdraft,color - // The 0xFF special register means NULL for cap,overdraft and color + // The most general form: account,cap,overdraft,color,scope + // The 0xFF special register means NULL for cap,overdraft,color and scope + // ext.A = overdraft (int reg), ext.B = color (str reg), ext.C = scope (str reg) Op_PullAccount Opcode = 0x50 - // account?, cap?, color? + // account?, cap?, scope? Op_SendToAccount Opcode = 0x51 // save: reduce balance of account A for asset B by amount C (C == nilReg => - // save all), floored at 0 + // save all), floored at 0; ext.A = scope (str reg, 0xFF = unscoped) Op_Save Opcode = 0x52 // 0x53 was Op_MkAllotment: an allotment share is now built out of pure ops // (Op_IntToPortion, Op_MulPortion, Op_PortionToInt plus the leftover fixup), // so there is no variadic domain instruction. Reserved, do not reuse. - // reads the account balance from the run-state + // reads the account balance from the run-state; A = dest, B = account, C = + // asset; ext.A = scope (str reg, 0xFF = unscoped) Op_Balance Opcode = 0x54 // --- marks (oneof backtracking) --- diff --git a/internal/vm/ir_test.go b/internal/vm/ir_test.go index 01ddce87..8ada51c7 100644 --- a/internal/vm/ir_test.go +++ b/internal/vm/ir_test.go @@ -23,17 +23,17 @@ type irStore struct { err error } -func (s irStore) GetBalance(_ context.Context, account, asset, color string) (*big.Int, error) { +func (s irStore) GetBalance(_ context.Context, account, scope, asset, color string) (*big.Int, error) { if s.err != nil { return nil, s.err } - if v, ok := s.balances[runtime.PairKey{Account: account, Asset: asset, Color: color}]; ok { + if v, ok := s.balances[runtime.PairKey{Account: account, Scope: scope, Asset: asset, Color: color}]; ok { return new(big.Int).Set(v), nil } return new(big.Int), nil } -func (s irStore) GetMetadata(_ context.Context, account, key string) (string, bool, error) { +func (s irStore) GetMetadata(_ context.Context, account, scope, key string) (string, bool, error) { if s.err != nil { return "", false, s.err } @@ -714,7 +714,7 @@ func TestIRMetadata(t *testing.T) { `, balances(nil), nil) require.Equal(t, map[string]string{"tx": "yes"}, res.Metadata) - require.Equal(t, runtime.AccountsMetadata{"acc": {"k": "v"}}, res.AccountsMetadata) + require.Equal(t, runtime.AccountsMetadata{{Account: "acc", Key: "k", Value: "v"}}, res.AccountsMetadata) } func TestIRReadsMetadataFromStore(t *testing.T) { diff --git a/internal/vm/meta_test.go b/internal/vm/meta_test.go index 8c317ecc..8e127a5a 100644 --- a/internal/vm/meta_test.go +++ b/internal/vm/meta_test.go @@ -16,13 +16,14 @@ func TestSetAccountMeta(t *testing.T) { 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) + abc(0, nilReg, nilReg, nilReg), // ext: no scope }, StringsPool: []string{"acc", "k", "v"}, } res, execErr := Exec(context.Background(), NewVm(prog), nil, mockStore{}) require.Nil(t, execErr) - require.Equal(t, runtime.AccountsMetadata{"acc": {"k": "v"}}, res.AccountsMetadata) + require.Equal(t, runtime.AccountsMetadata{{Account: "acc", Key: "k", Value: "v"}}, res.AccountsMetadata) } func TestMetaStr(t *testing.T) { @@ -34,10 +35,11 @@ func TestMetaStr(t *testing.T) { 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" + abc(0, nilReg, nilReg, nilReg), // ext: no scope 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(0, nilReg, nilReg, nilReg), // ext: no overdraft, no color, no scope abc(Op_SendToAccount, 3, nilReg, nilReg), // send to s3 (alice) }, StringsPool: []string{"USD/2", "config", "beneficiary", "world"}, diff --git a/internal/vm/vars_test.go b/internal/vm/vars_test.go index b7dd7c31..f5bcc2f8 100644 --- a/internal/vm/vars_test.go +++ b/internal/vm/vars_test.go @@ -95,7 +95,7 @@ func TestLoadVarOpcodes(t *testing.T) { 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(0, nilReg, nilReg, nilReg), // ext: no overdraft, no color, no scope abc(Op_SendToAccount, 2, nilReg, nilReg), // send to dest }, StringsPool: []string{"USD/2"}, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index c3fb00f2..8d35d358 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -5,12 +5,22 @@ import ( "errors" "fmt" "math/big" + "sort" "github.com/formancehq/numscript/internal/runtime" ) const nilReg byte = 0xFF +// accountMetaKey identifies one set_account_meta slot during a run, so repeated +// writes to the same (account, scope, key) upsert rather than accumulating +// duplicate rows. +type accountMetaKey struct { + account string + scope string + key string +} + // The three ops a mark cannot survive, rejected by the arms below. save is on the // list permanently, unlike the other two: it floors the balance at zero, and the // clamp destroys the information needed to invert it. @@ -44,6 +54,7 @@ type Store interface { GetBalance( ctx context.Context, account string, + scope string, asset string, color string, ) (*big.Int, error) @@ -51,12 +62,13 @@ type Store interface { GetMetadata( ctx context.Context, account, + scope, key string, ) (string, bool, error) } -func lookupMeta(ctx context.Context, store Store, account, key string) (string, ExecutionError) { - v, ok, err := store.GetMetadata(ctx, account, key) +func lookupMeta(ctx context.Context, store Store, account, scope, key string) (string, ExecutionError) { + v, ok, err := store.GetMetadata(ctx, account, scope, key) if err != nil { return "", StoreError{Wrapped: err} } @@ -73,10 +85,11 @@ type runtimeStoreAdapter struct { func (s runtimeStoreAdapter) GetBalance( account string, + scope string, asset string, color string, ) (*big.Int, error) { - return s.store.GetBalance(s.ctx, account, asset, color) + return s.store.GetBalance(s.ctx, account, scope, asset, color) } func Exec[S Store]( @@ -99,7 +112,10 @@ func Exec[S Store]( runstate := vm.runstate var txMeta map[string]string - var accountsMeta runtime.AccountsMetadata + // accountsMeta accumulates with upsert semantics (last write to a given + // (account, scope, key) wins), so it is keyed during the run and only + // flattened into the row-based runtime.AccountsMetadata at the very end. + var accountsMeta map[accountMetaKey]string // Hoist register banks and constant pools into locals so the hot loop indexes // them directly instead of reloading the header off *vm / vm.program on every @@ -147,14 +163,19 @@ func Exec[S Store]( color = stringsRegs[instrExt.B] } + var scope string + if instrExt.C != nilReg { + scope = stringsRegs[instrExt.C] + } + out := &intsRegs[instr.A] switch { case cap != nil: - if err := runstate.Pull(out, account, "", cap, overdraft, color); err != nil { + if err := runstate.Pull(out, account, scope, cap, overdraft, color); err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } case overdraft != nil: - if err := runstate.PullUncapped(out, account, "", overdraft, color); err != nil { + if err := runstate.PullUncapped(out, account, scope, overdraft, color); err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } default: @@ -180,17 +201,17 @@ func Exec[S Store]( cap = &intsRegs[instr.B] } - var color *string + var scope string if instr.C != nilReg { - color = &stringsRegs[instr.C] + scope = stringsRegs[instr.C] } if cap == nil { - if err := runstate.SendUncapped(dest, "", color); err != nil { + if err := runstate.SendUncapped(dest, scope, nil); err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } } else { - if err := runstate.Send(dest, "", cap, color); err != nil { + if err := runstate.Send(dest, scope, cap, nil); err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } } @@ -213,13 +234,20 @@ func Exec[S Store]( return runtime.ExecutionResult{}, InternalError{Err: errSaveWhileMarkOpen} } + instrExt := instrs[pc] + pc++ + account := stringsRegs[instr.A] asset := stringsRegs[instr.B] var amount *big.Int if instr.C != nilReg { amount = &intsRegs[instr.C] } - if err := runstate.Save(account, "", asset, "", amount); err != nil { + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] + } + if err := runstate.Save(account, scope, asset, "", amount); err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } @@ -272,6 +300,12 @@ func Exec[S Store]( return runtime.ExecutionResult{}, InvalidColor{Color: color} } + case Op_AssertValidScope: + scope := stringsRegs[instr.A] + if !runtime.ValidateScope(scope) { + return runtime.ExecutionResult{}, InvalidScope{Scope: scope} + } + case Op_AssertNonNegativeBalance: amount := &intsRegs[instr.A] if amount.Sign() < 0 { @@ -288,27 +322,47 @@ func Exec[S Store]( txMeta[stringsRegs[instr.A]] = stringsRegs[instr.B] case Op_SetAccountMeta: + instrExt := instrs[pc] + pc++ + if accountsMeta == nil { - accountsMeta = runtime.AccountsMetadata{} + accountsMeta = map[accountMetaKey]string{} } - account := stringsRegs[instr.A] - accMeta := accountsMeta[account] - if accMeta == nil { - accMeta = runtime.AccountMetadata{} - accountsMeta[account] = accMeta + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] } - accMeta[stringsRegs[instr.B]] = stringsRegs[instr.C] + key := accountMetaKey{ + account: stringsRegs[instr.A], + scope: scope, + key: stringsRegs[instr.B], + } + accountsMeta[key] = stringsRegs[instr.C] case Op_MetaStr: - v, err := lookupMeta(ctx, store, stringsRegs[instr.B], stringsRegs[instr.C]) + instrExt := instrs[pc] + pc++ + + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] + } + v, err := lookupMeta(ctx, store, stringsRegs[instr.B], scope, stringsRegs[instr.C]) if err != nil { return runtime.ExecutionResult{}, err } stringsRegs[instr.A] = v case Op_MetaInt: + instrExt := instrs[pc] + pc++ + account, key := stringsRegs[instr.B], stringsRegs[instr.C] - v, err := lookupMeta(ctx, store, account, key) + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] + } + v, err := lookupMeta(ctx, store, account, scope, key) if err != nil { return runtime.ExecutionResult{}, err } @@ -319,8 +373,15 @@ func Exec[S Store]( intsRegs[instr.A].Set(n) case Op_MetaPortion: + instrExt := instrs[pc] + pc++ + account, key := stringsRegs[instr.B], stringsRegs[instr.C] - v, err := lookupMeta(ctx, store, account, key) + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] + } + v, err := lookupMeta(ctx, store, account, scope, key) if err != nil { return runtime.ExecutionResult{}, err } @@ -337,7 +398,11 @@ func Exec[S Store]( pc++ account, key := stringsRegs[instr.B], stringsRegs[instr.C] - v, err := lookupMeta(ctx, store, account, key) + var scope string + if instrExt.B != nilReg { + scope = stringsRegs[instrExt.B] + } + v, err := lookupMeta(ctx, store, account, scope, key) if err != nil { return runtime.ExecutionResult{}, err } @@ -454,10 +519,17 @@ func Exec[S Store]( portionsRegs[instr.A].SetFrac(num, den) case Op_Balance: + instrExt := instrs[pc] + pc++ + account := stringsRegs[instr.B] asset := stringsRegs[instr.C] + var scope string + if instrExt.A != nilReg { + scope = stringsRegs[instrExt.A] + } - bal, err := runstate.GetAccountBalance(account, "", asset, "") + bal, err := runstate.GetAccountBalance(account, scope, asset, "") if err != nil { return runtime.ExecutionResult{}, StoreError{Wrapped: err} } @@ -504,9 +576,34 @@ func Exec[S Store]( } } + var accountsMetaRows runtime.AccountsMetadata + if len(accountsMeta) != 0 { + accountsMetaRows = make(runtime.AccountsMetadata, 0, len(accountsMeta)) + for k, v := range accountsMeta { + accountsMetaRows = append(accountsMetaRows, runtime.AccountMetadataEntry{ + Account: k.account, + Scope: k.scope, + Key: k.key, + Value: v, + }) + } + // deterministic output: accountsMeta was built from a map, whose iteration + // order is random + sort.Slice(accountsMetaRows, func(i, j int) bool { + a, b := accountsMetaRows[i], accountsMetaRows[j] + if a.Account != b.Account { + return a.Account < b.Account + } + if a.Scope != b.Scope { + return a.Scope < b.Scope + } + return a.Key < b.Key + }) + } + return runtime.ExecutionResult{ Postings: runstate.GetPostings(), Metadata: txMeta, - AccountsMetadata: accountsMeta, + AccountsMetadata: accountsMetaRows, }, nil } diff --git a/internal/vm/vm_test.go b/internal/vm/vm_test.go index 36b6b2ac..5d0da661 100644 --- a/internal/vm/vm_test.go +++ b/internal/vm/vm_test.go @@ -44,11 +44,11 @@ type mockStore struct { meta map[string]map[string]string } -func (m mockStore) GetBalance(ctx context.Context, account, asset string, color string) (*big.Int, error) { - return big.NewInt(m.bal[runtime.PairKey{Account: account, Asset: asset}]), nil +func (m mockStore) GetBalance(ctx context.Context, account, scope, asset string, color string) (*big.Int, error) { + return big.NewInt(m.bal[runtime.PairKey{Account: account, Scope: scope, Asset: asset}]), nil } -func (m mockStore) GetMetadata(ctx context.Context, account, key string) (string, bool, error) { +func (m mockStore) GetMetadata(ctx context.Context, account, scope, key string) (string, bool, error) { v, ok := m.meta[account][key] return v, ok, nil } @@ -73,6 +73,7 @@ func balanceNonNegativeProgram() Program { bc(Op_LoadStr, 0, 0), bc(Op_LoadStr, 1, 1), abc(Op_Balance, 0, 0, 1), + abc(0, nilReg, nilReg, nilReg), // ext: no scope abc(Op_AssertNonNegativeBalance, 0, 0, nilReg), }, StringsPool: []string{"acc", "USD/2"}, diff --git a/ir-textual-format.md b/ir-textual-format.md index 97c48962..93199afa 100644 --- a/ir-textual-format.md +++ b/ir-textual-format.md @@ -206,30 +206,32 @@ a != b -> $t = eq_int($a, $b) ; not($t) | syntax | signature | | --- | --- | -| `$d = balance($account, $asset)` | `(str, str) -> int` — the amount only; the monetary's asset is the `$asset` operand you already hold | -| `$d = meta($account, $key)` | `(str, str) -> str` | -| `$d = meta($account, $key)` | `(str, str) -> int` | -| `$d = meta($account, $key)` | `(str, str) -> portion` | -| `[$asset, $amt] = meta_monetary($account, $key)` | `(str, str) -> (str, int)` | +| `$d = balance($account, $asset, scope: $s)` | `(str, str) -> int` — the amount only; the monetary's asset is the `$asset` operand you already hold | +| `$d = meta($account, $key, scope: $s)` | `(str, str) -> str` | +| `$d = meta($account, $key, scope: $s)` | `(str, str) -> int` | +| `$d = meta($account, $key, scope: $s)` | `(str, str) -> portion` | +| `[$asset, $amt] = meta_monetary($account, $key, scope: $s)` | `(str, str) -> (str, int)` | `meta_monetary` is not `meta`: one store read yields both halves, so it is the only instruction that writes a **dest list**, and its list must be exactly two registers (asset then amount). +`scope` is optional on all five (omitted means unscoped) and, like `pull_account`'s `color`, is a labeled argument — `balance($account, $asset)` and `balance($account, $asset, scope: $s)`. + ### Funds movement ``` - $pulled = pull_account(account: $a, cap: $c, overdraft: $o, color: $col) + $pulled = pull_account(account: $a, cap: $c, overdraft: $o, color: $col, scope: $s) ``` -`account: str` is required; `cap: int`, `overdraft: int`, `color: str` are optional. Writes the amount actually pulled (`int`) into the destination. No `cap` means uncapped. Canonical dump order: `account, cap, overdraft, color`. +`account: str` is required; `cap: int`, `overdraft: int`, `color: str`, `scope: str` are optional. Writes the amount actually pulled (`int`) into the destination. No `cap` means uncapped. Canonical dump order: `account, cap, overdraft, color, scope`. ``` - send_to_account(account: $a, cap: $c) + send_to_account(account: $a, cap: $c, scope: $s) ``` -No destination. Both arguments are optional: no `cap` sends everything currently queued; **no `account` refunds the funds to their sources without emitting postings**. +No destination. All arguments are optional: no `cap` sends everything currently queued; **no `account` refunds the funds to their sources without emitting postings**; no `scope` sends to the unscoped destination. ``` - save(account: $a, asset: $as, amount: $amt) + save(account: $a, asset: $as, amount: $amt, scope: $s) ``` -No destination. `account: str` and `asset: str` are required, `amount: int` is optional — omitting it saves the whole balance. +No destination. `account: str` and `asset: str` are required, `amount: int` and `scope: str` are optional — omitting `amount` saves the whole balance, omitting `scope` saves the unscoped balance. There is no allotment instruction. Splitting an amount across portions is built out of the pure ops above: each share is `portion_to_int(mul_portion($p_i, int_to_portion($amount)))`, and the leftover from flooring is then handed to the earliest shares a unit at a time, using `lt_int` and forward jumps to a shared exit. See `compileAllotmentSplit` in `internal/compiler/compiler.go`.