From a97e5e8e4832df84d9186b9293aa0d54a85ae229 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 4 Nov 2026 15:15:02 +0100 Subject: [PATCH 1/2] feat: add the delete_account_metadata statement Gated behind experimental-delete-account-metadata. Both engines call a new Store.DeleteAccountMetadata method during execution and drop any earlier set_account_meta of the same key from the result. Compiled to the new DELETE_ACCOUNT_META opcode (0x26), which the verifier rejects inside a mark. --- builder/builder_test.go | 7 +- builder/statement_extra.go | 11 +++ bytecode_corpus_test.go | 4 + instruction-encoding.md | 7 +- internal/analysis/check.go | 21 ++++- internal/analysis/check_test.go | 79 +++++++++++++++++++ internal/builtins/builtins.go | 17 ++-- internal/cmd/bytecode_run.go | 4 + internal/cmd/test_init.go | 4 + internal/compiler/bench_test.go | 4 + internal/compiler/compile_error_test.go | 5 ++ internal/compiler/compiler.go | 15 ++++ internal/compiler/compiler_example_test.go | 4 + internal/compiler/e2e_test.go | 9 +++ internal/compiler/scripts_test.go | 7 ++ internal/difftest/run_vm.go | 4 + internal/flags/flags.go | 2 + internal/interpreter/function_statements.go | 21 +++++ internal/interpreter/interpreter.go | 2 + internal/interpreter/interpreter_error.go | 13 +++ internal/interpreter/resolve_dependencies.go | 14 ++++ .../interpreter/resolve_dependencies_test.go | 4 + internal/interpreter/set_accounts_metadata.go | 4 + internal/interpreter/store.go | 10 +++ .../delete-then-set.num | 2 + .../delete-then-set.num.specs.json | 11 +++ .../delete-account-metadata/delete.num | 7 ++ .../delete.num.specs.json | 14 ++++ .../delete-account-metadata/scoped.num | 3 + .../scoped.num.specs.json | 15 ++++ .../set-then-delete.num | 3 + .../set-then-delete.num.specs.json | 11 +++ internal/ir/assemble.go | 18 +++++ internal/ir/dump.go | 7 ++ internal/ir/instr.go | 7 ++ internal/ir/parse.go | 2 + internal/ir/parse_test.go | 17 ++++ internal/ir/typecheck.go | 6 ++ internal/ir/typecheck_test.go | 3 + internal/specs_format/index.go | 36 ++++++++- internal/typecheck/typecheck.go | 17 ++-- internal/vm/instruction.go | 4 + internal/vm/ir_test.go | 4 + internal/vm/verify.go | 11 ++- internal/vm/verify_test.go | 14 ++++ internal/vm/vm.go | 36 +++++++-- internal/vm/vm_test.go | 4 + ir-textual-format.md | 1 + numscript.go | 5 +- numscript_test.go | 65 +++++++++++++++ v1.specs.schema.json | 29 +++++++ 51 files changed, 586 insertions(+), 38 deletions(-) create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num.specs.json create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num create mode 100644 internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num.specs.json diff --git a/builder/builder_test.go b/builder/builder_test.go index 2b1b0792..acaa0bcc 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -598,8 +598,9 @@ func TestSaveAndSetMeta(t *testing.T) { `with "quotes"`, builder.ExprString("value"), ) + stmt5 := builder.StmtDeleteAccountMetadata(builder.UnsafeAccount("acc2"), "k") - _, _, script := builder.BuildProgram(stmt1, stmt2, stmt3, stmt4) + _, _, script := builder.BuildProgram(stmt1, stmt2, stmt3, stmt4, stmt5) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { string $string_0 asset $asset_0 @@ -611,7 +612,9 @@ save [$asset_0 *] from @acc1 set_tx_meta("my key", 42) -set_account_meta(@acc2, "with \"quotes\"", $string_0)`)) +set_account_meta(@acc2, "with \"quotes\"", $string_0) + +delete_account_metadata(@acc2, "k")`)) } func TestOriginVars(t *testing.T) { diff --git a/builder/statement_extra.go b/builder/statement_extra.go index d8bc6ab9..f3e96351 100644 --- a/builder/statement_extra.go +++ b/builder/statement_extra.go @@ -37,6 +37,17 @@ func StmtSetTxMeta[T ExprType](key string, value Expression[T]) Statement { } } +// StmtDeleteAccountMetadata: `delete_account_metadata(, "")`. +func StmtDeleteAccountMetadata(account Expression[ExprTypeAccount], key string) Statement { + return func(env *env, w int) { + env.builder.WriteString("delete_account_metadata(") + account(env, w) + env.builder.WriteString(", ") + writeStringLiteral(env, key) + env.builder.WriteString(")") + } +} + // StmtSetAccountMeta: `set_account_meta(, "", )`. func StmtSetAccountMeta[T ExprType]( account Expression[ExprTypeAccount], diff --git a/bytecode_corpus_test.go b/bytecode_corpus_test.go index 4ea7cc4a..a1e455d3 100644 --- a/bytecode_corpus_test.go +++ b/bytecode_corpus_test.go @@ -372,3 +372,7 @@ func decodeJSONExact(t *testing.T, data []byte) any { require.NoError(t, dec.Decode(&v)) return v } + +func (s corpusStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/instruction-encoding.md b/instruction-encoding.md index 31f5b545..54bb106e 100644 --- a/instruction-encoding.md +++ b/instruction-encoding.md @@ -194,7 +194,12 @@ Instructions are **4 bytes** wide: `[Opcode: 8] [A: 8] [B: 8] [C: 8]`. int_regs[A] = amount; scope reg in B (0xFF = unscoped) - 0x26..0x2F reserved + 380x26DELETE_ACCOUNT_META + acckeyscope + Calls Store.DeleteAccountMetadata for account A, key B, scope C (0xFF = unscoped). Not allowed while a mark is open + + + 0x27..0x2F reserved diff --git a/internal/analysis/check.go b/internal/analysis/check.go index 428111e7..4395b618 100644 --- a/internal/analysis/check.go +++ b/internal/analysis/check.go @@ -42,8 +42,9 @@ type VarOriginFnCallResolution struct { VersionConstraints []VersionClause } type StatementFnCallResolution struct { - Params []string - Docs string + Params []string + Docs string + VersionConstraints []VersionClause } func (VarOriginFnCallResolution) ContextName() string { return "variable origin" } @@ -57,8 +58,9 @@ func (r StatementFnCallResolution) GetParams() []string { return r.Params } const ( // Statement fns - FnSetTxMeta = builtins.SetTxMeta - FnSetAccountMeta = builtins.SetAccountMeta + FnSetTxMeta = builtins.SetTxMeta + FnSetAccountMeta = builtins.SetAccountMeta + FnDeleteAccountMetadata = builtins.DeleteAccountMetadata // Expr fns FnVarOriginMeta = builtins.Meta @@ -78,6 +80,16 @@ var Builtins = map[string]FnCallResolution{ Params: []string{TypeAccount, TypeString, TypeAny}, Docs: "set account metadata", }, + FnDeleteAccountMetadata: StatementFnCallResolution{ + Params: []string{TypeAccount, TypeString}, + Docs: "delete account metadata", + VersionConstraints: []VersionClause{ + { + Version: parser.NewVersionInterpreter(0, 1, 4), + FeatureFlag: flags.ExperimentalDeleteAccountMetadata, + }, + }, + }, FnVarOriginMeta: VarOriginFnCallResolution{ Params: []string{TypeAccount, TypeString}, Return: TypeAny, @@ -363,6 +375,7 @@ func (res *CheckResult) checkStatement(statement parser.Statement) { if ok { if varOrigin, ok := resolution.(StatementFnCallResolution); ok { res.fnCallResolution[statement.Caller] = varOrigin + res.requireVersion(statement.Range, varOrigin.VersionConstraints...) } } diff --git a/internal/analysis/check_test.go b/internal/analysis/check_test.go index f51b8995..71ab2121 100644 --- a/internal/analysis/check_test.go +++ b/internal/analysis/check_test.go @@ -1216,3 +1216,82 @@ func TestIncompleteVarOriginDoesNotPanic(t *testing.T) { }) } } + +func TestRequireFlagForDeleteAccountMetadata(t *testing.T) { + t.Parallel() + + input := ` +// @version interpreter 0.1.4 +delete_account_metadata(@acc, "k") +` + + require.Equal(t, + []analysis.Diagnostic{ + { + Range: parser.RangeOfIndexed(input, `delete_account_metadata(@acc, "k")`, 0), + Kind: analysis.ExperimentalFeature{ + Name: flags.ExperimentalDeleteAccountMetadata, + }, + }, + }, + checkSource(input), + ) +} + +func TestRequireVersionForDeleteAccountMetadata(t *testing.T) { + t.Parallel() + + input := ` +// @version interpreter 0.1.3 +delete_account_metadata(@acc, "k") +` + + require.Equal(t, + []analysis.Diagnostic{ + { + Range: parser.RangeOfIndexed(input, `delete_account_metadata(@acc, "k")`, 0), + Kind: analysis.VersionMismatch{ + GotVersion: parser.NewVersionInterpreter(0, 1, 3), + RequiredVersion: parser.NewVersionInterpreter(0, 1, 4), + }, + }, + }, + checkSource(input), + ) +} + +func TestDeleteAccountMetadataWithFlag(t *testing.T) { + t.Parallel() + + input := ` +// @version interpreter 0.1.4 +// @feature_flag experimental-delete-account-metadata +delete_account_metadata(@acc, "k") +` + + require.Empty(t, checkSource(input)) +} + +func TestDeleteAccountMetadataSignature(t *testing.T) { + t.Parallel() + + input := `delete_account_metadata("acc", @k, @extra)` + + require.Equal(t, + []analysis.Diagnostic{ + { + Range: parser.RangeOfIndexed(input, "@extra", 0), + Kind: analysis.BadArity{Expected: 2, Actual: 3}, + }, + { + Range: parser.RangeOfIndexed(input, `"acc"`, 0), + Kind: analysis.TypeMismatch{Expected: analysis.TypeAccount, Got: analysis.TypeString}, + }, + { + Range: parser.RangeOfIndexed(input, "@k", 0), + Kind: analysis.TypeMismatch{Expected: analysis.TypeString, Got: analysis.TypeAccount}, + }, + }, + checkSource(input), + ) +} diff --git a/internal/builtins/builtins.go b/internal/builtins/builtins.go index d9bec8eb..a90b0ba9 100644 --- a/internal/builtins/builtins.go +++ b/internal/builtins/builtins.go @@ -1,12 +1,13 @@ package builtins const ( - SetTxMeta = "set_tx_meta" - SetAccountMeta = "set_account_meta" - Meta = "meta" - Balance = "balance" - Overdraft = "overdraft" - GetAsset = "get_asset" - GetAmount = "get_amount" - Scoped = "scoped" + SetTxMeta = "set_tx_meta" + SetAccountMeta = "set_account_meta" + DeleteAccountMetadata = "delete_account_metadata" + Meta = "meta" + Balance = "balance" + 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 56a6d1bc..2acc4f7c 100644 --- a/internal/cmd/bytecode_run.go +++ b/internal/cmd/bytecode_run.go @@ -275,3 +275,7 @@ The bytecode format tracks an unstable instruction set and is not a public inter return &cmd } + +func (s vmStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/internal/cmd/test_init.go b/internal/cmd/test_init.go index ee76c39c..4481d74f 100644 --- a/internal/cmd/test_init.go +++ b/internal/cmd/test_init.go @@ -253,3 +253,7 @@ func (s *TestInitStore) GetBalances(ctx context.Context, q interpreter.BalanceQu func (s *TestInitStore) GetAccountsMetadata(c context.Context, q interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) { return s.StaticStore.GetAccountsMetadata(c, q) } + +func (s *TestInitStore) DeleteAccountMetadata(c context.Context, account, scope, key string) error { + return s.StaticStore.DeleteAccountMetadata(c, account, scope, key) +} diff --git a/internal/compiler/bench_test.go b/internal/compiler/bench_test.go index 5c7db2b9..b1c9b5e3 100644 --- a/internal/compiler/bench_test.go +++ b/internal/compiler/bench_test.go @@ -333,3 +333,7 @@ func BenchmarkCompiledVMFanIn(b *testing.B) { {Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100), }}) } + +func (benchStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/internal/compiler/compile_error_test.go b/internal/compiler/compile_error_test.go index 26b26e20..918c3163 100644 --- a/internal/compiler/compile_error_test.go +++ b/internal/compiler/compile_error_test.go @@ -103,6 +103,11 @@ func TestFeatureFlagGating(t *testing.T) { } )`, }, + { + name: "delete_account_metadata", + flag: flags.ExperimentalDeleteAccountMetadata, + src: `delete_account_metadata(@a, "k")`, + }, { name: "account interpolation", flag: flags.ExperimentalAccountInterpolationFlag, diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 24b6ec02..0355bc77 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -1276,6 +1276,21 @@ func (st *state) compileStatements(stmt parser.Statement) CompilerError { st.Push(ir.SetAccountMeta{Account: acc.Name, Key: key, Value: value, Scope: acc.Scope}) return nil + case builtins.DeleteAccountMetadata: + if err := st.checkFeatureFlag(stmt.Range, flags.ExperimentalDeleteAccountMetadata); err != nil { + return err + } + acc, err := st.compileAccountExpr(stmt.Args[0]) + if err != nil { + return err + } + key, err := st.compileExpr(stmt.Args[1]) + if err != nil { + return err + } + st.Push(ir.DeleteAccountMeta{Account: acc.Name, Key: key, Scope: acc.Scope}) + return nil + default: return UnsupportedNode{Range: stmt.Range, Node: stmt.Caller.Name} } diff --git a/internal/compiler/compiler_example_test.go b/internal/compiler/compiler_example_test.go index eb5559ac..df82ff0e 100644 --- a/internal/compiler/compiler_example_test.go +++ b/internal/compiler/compiler_example_test.go @@ -83,3 +83,7 @@ func (s testStore) GetBalance(ctx context.Context, account, scope, asset, color func (testStore) GetMetadata(ctx context.Context, account, scope, key string) (string, bool, error) { return "", false, nil } + +func (testStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/internal/compiler/e2e_test.go b/internal/compiler/e2e_test.go index 7f9af02a..bc9422a8 100644 --- a/internal/compiler/e2e_test.go +++ b/internal/compiler/e2e_test.go @@ -8,6 +8,7 @@ import ( "github.com/formancehq/numscript/internal/compiler" "github.com/formancehq/numscript/internal/funds" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/specs_format" "github.com/formancehq/numscript/internal/vm" "github.com/stretchr/testify/require" ) @@ -16,6 +17,14 @@ import ( type e2eStore struct { balances map[funds.PairKey]*big.Int metadata map[e2eMetaKey]string + deleted *[]specs_format.DeletedMetadataRow +} + +func (s e2eStore) DeleteAccountMetadata(_ context.Context, account, scope, key string) error { + if s.deleted != nil { + *s.deleted = append(*s.deleted, specs_format.DeletedMetadataRow{Account: account, Key: key, Scope: scope}) + } + return nil } // e2eMetaKey identifies one metadata slot: account, scope and key. diff --git a/internal/compiler/scripts_test.go b/internal/compiler/scripts_test.go index 7a4f4d6e..268d45fa 100644 --- a/internal/compiler/scripts_test.go +++ b/internal/compiler/scripts_test.go @@ -93,6 +93,8 @@ func runScriptSpec(t *testing.T, specs specs_format.Specs, src string) { machine := vm.NewVm(program) store := scriptStore(balances, specs.Meta, tc.Meta) + var deleted []specs_format.DeletedMetadataRow + store.deleted = &deleted res, execErr := vm.Exec(context.Background(), machine, &vars, store) if tc.ExpectMissingFunds { @@ -135,6 +137,11 @@ func runScriptSpec(t *testing.T, specs specs_format.Specs, src string) { require.Equal(t, accountsMetaAsStrings(tc.ExpectAccountsMeta), res.AccountsMetadata, "case %q: expect.metadata", tc.It) } + + if tc.ExpectDeletedMeta != nil { + require.True(t, slices.Equal(tc.ExpectDeletedMeta, deleted), + "case %q: expect.deletedMetadata: want %v, got %v", tc.It, tc.ExpectDeletedMeta, deleted) + } } } diff --git a/internal/difftest/run_vm.go b/internal/difftest/run_vm.go index 07116619..68d146c5 100644 --- a/internal/difftest/run_vm.go +++ b/internal/difftest/run_vm.go @@ -127,3 +127,7 @@ func runVM(ctx context.Context, script string, vars map[string]string, balances return SideResult{Postings: postings, TxMeta: txMeta, AccountMeta: accountMeta} } + +func (s vmStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 0b7e5319..bf36b7da 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -12,6 +12,7 @@ const ( ExperimentalMidScriptFunctionCall FeatureFlag = "experimental-mid-script-function-call" ExperimentalAssetColors FeatureFlag = "experimental-asset-colors" AssetScaling FeatureFlag = "experimental-asset-scaling" + ExperimentalDeleteAccountMetadata FeatureFlag = "experimental-delete-account-metadata" ) var AllFlags []string = []string{ @@ -24,4 +25,5 @@ var AllFlags []string = []string{ ExperimentalMidScriptFunctionCall, ExperimentalAssetColors, AssetScaling, + ExperimentalDeleteAccountMetadata, } diff --git a/internal/interpreter/function_statements.go b/internal/interpreter/function_statements.go index a555080e..dfe320a0 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -1,6 +1,7 @@ package interpreter import ( + "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" ) @@ -46,3 +47,23 @@ func setAccountMeta(st *programState, r parser.Range, args []Value) InterpreterE return nil } + +func deleteAccountMetadata(st *programState, r parser.Range, args []Value) InterpreterError { + if err := st.checkFeatureFlag(flags.ExperimentalDeleteAccountMetadata); err != nil { + return err + } + + p := NewArgsParser(args) + account := parseArg(p, r, expectAccount) + key := parseArg(p, r, expectString) + err := p.parse() + if err != nil { + return err + } + + st.SetAccountsMeta.Delete(account.Name, account.Scope, string(key)) + if err := st.Store.DeleteAccountMetadata(st.ctx, account.Name, account.Scope, string(key)); err != nil { + return DeleteMetadataError{Range: r, WrappedError: err} + } + return nil +} diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 9d48a80c..c6799ac1 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -274,6 +274,8 @@ func (st *programState) runStatement(statement parser.Statement) InterpreterErro return setTxMeta(st, statement.Caller.Range, args) case analysis.FnSetAccountMeta: return setAccountMeta(st, statement.Caller.Range, args) + case analysis.FnDeleteAccountMetadata: + return deleteAccountMetadata(st, statement.Caller.Range, args) default: return UnboundFunctionErr{Name: statement.Caller.Name} } diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index e18218e5..22067577 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -253,6 +253,19 @@ func (e QueryMetadataError) Unwrap() error { return e.WrappedError } +type DeleteMetadataError struct { + parser.Range + WrappedError error +} + +func (e DeleteMetadataError) Error() string { + return e.WrappedError.Error() +} + +func (e DeleteMetadataError) Unwrap() error { + return e.WrappedError +} + type ExperimentalFeature struct { parser.Range FlagName string diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index 504e249f..e8a705da 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -62,6 +62,10 @@ func (s *recordingStore) GetAccountsMetadata(ctx context.Context, q MetadataQuer return s.inner.GetAccountsMetadata(ctx, q) } +func (s *recordingStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return errors.New("dependency resolution must not delete metadata") +} + type resolveDependenciesState struct { env *evalEnv deps ResolvedDependencies @@ -199,6 +203,16 @@ func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall addMetaDep(st.deps.MetaWrites, account, string(key)) return nil + case analysis.FnDeleteAccountMetadata: + p := NewArgsParser(args) + account := parseArg(p, fnCall.Range, expectAccount) + key := parseArg(p, fnCall.Range, expectString) + if err := p.parse(); err != nil { + return err + } + addMetaDep(st.deps.MetaWrites, account, string(key)) + return nil + case analysis.FnSetTxMeta: p := NewArgsParser(args) key := parseArg(p, fnCall.Range, expectString) diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index c6df32ec..6a6e465a 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -35,6 +35,10 @@ func (q *queryLog) GetAccountsMetadata(ctx context.Context, query interpreter.Me return q.inner.GetAccountsMetadata(ctx, query) } +func (q *queryLog) DeleteAccountMetadata(ctx context.Context, account, scope, key string) error { + return q.inner.DeleteAccountMetadata(ctx, account, scope, key) +} + // TestResolveDependenciesCoversRuntime checks the soundness property: for a // script that executes successfully, every balance/metadata the interpreter // queries is a resolved read, and every account a posting touches is a resolved diff --git a/internal/interpreter/set_accounts_metadata.go b/internal/interpreter/set_accounts_metadata.go index a30f7efb..d56348c2 100644 --- a/internal/interpreter/set_accounts_metadata.go +++ b/internal/interpreter/set_accounts_metadata.go @@ -50,6 +50,10 @@ func (m internalSetAccountsMeta) Set(account, scope, key string, value Value) { m[metadataKey{Account: account, Scope: scope, Key: key}] = value } +func (m internalSetAccountsMeta) Delete(account, scope, key string) { + delete(m, metadataKey{Account: account, Scope: scope, Key: key}) +} + // toRows flattens the set metadata into the external representation, sorted by // (account, scope, key) for deterministic output. Values are rendered here: the // runtime keeps them typed, the wire format does not. diff --git a/internal/interpreter/store.go b/internal/interpreter/store.go index aa409a06..58ac68ab 100644 --- a/internal/interpreter/store.go +++ b/internal/interpreter/store.go @@ -31,6 +31,11 @@ type Store interface { // Malformed Balances will result it undefined behaviour, and the implementation doesn't guarantee keys are deduped. GetBalances(context.Context, BalanceQuery) (Balances, error) GetAccountsMetadata(context.Context, MetadataQuery) (AccountsMetadata, error) + + // Called during execution, in script order, even by a run that later fails. + // The result's AccountsMetadata holds only writes made after the key's last + // delete, so apply the deletions first. + DeleteAccountMetadata(ctx context.Context, account, scope, key string) error } type StaticStore struct { @@ -99,3 +104,8 @@ func (s StaticStore) GetAccountsMetadata(_ context.Context, q MetadataQuery) (Ac } return output, nil } + +// DeleteAccountMetadata is a no-op: a StaticStore is an immutable snapshot. +func (s StaticStore) DeleteAccountMetadata(_ context.Context, _, _, _ string) error { + return nil +} diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num new file mode 100644 index 00000000..278a6f73 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num @@ -0,0 +1,2 @@ +delete_account_metadata(@acc, "k") +set_account_meta(@acc, "k", "v") diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num.specs.json new file mode 100644 index 00000000..26118b1a --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete-then-set.num.specs.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", + "featureFlags": ["experimental-delete-account-metadata"], + "testCases": [ + { + "it": "keeps a write made after the delete", + "expect.metadata": [{ "account": "acc", "key": "k", "value": "v" }], + "expect.deletedMetadata": [{ "account": "acc", "key": "k" }] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num new file mode 100644 index 00000000..88e1dd32 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num @@ -0,0 +1,7 @@ +vars { + account $acc + string $key +} + +delete_account_metadata($acc, $key) +delete_account_metadata(@other, "k") diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num.specs.json new file mode 100644 index 00000000..af325555 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/delete.num.specs.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", + "featureFlags": ["experimental-delete-account-metadata"], + "variables": { "acc": "alice", "key": "status" }, + "testCases": [ + { + "it": "deletes each key from the store, in script order", + "expect.deletedMetadata": [ + { "account": "alice", "key": "status" }, + { "account": "other", "key": "k" } + ] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num new file mode 100644 index 00000000..87266eb8 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num @@ -0,0 +1,3 @@ +set_account_meta(@acc, "k", "unscoped") +set_account_meta(scoped(@acc, "myscope"), "k", "scoped") +delete_account_metadata(scoped(@acc, "myscope"), "k") diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num.specs.json new file mode 100644 index 00000000..78dd9245 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/scoped.num.specs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", + "featureFlags": [ + "experimental-delete-account-metadata", + "experimental-mid-script-function-call", + "experimental-scoped-function" + ], + "testCases": [ + { + "it": "deletes only the entry of the account's scope", + "expect.metadata": [{ "account": "acc", "key": "k", "value": "unscoped" }], + "expect.deletedMetadata": [{ "account": "acc", "key": "k", "scope": "myscope" }] + } + ] +} diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num new file mode 100644 index 00000000..10cdddaa --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num @@ -0,0 +1,3 @@ +set_account_meta(@acc, "k1", "v1") +set_account_meta(@acc, "k2", "v2") +delete_account_metadata(@acc, "k1") diff --git a/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num.specs.json b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num.specs.json new file mode 100644 index 00000000..ab8b7916 --- /dev/null +++ b/internal/interpreter/testdata/script-tests/experimental/delete-account-metadata/set-then-delete.num.specs.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/formancehq/numscript/main/v1.specs.schema.json", + "featureFlags": ["experimental-delete-account-metadata"], + "testCases": [ + { + "it": "drops an earlier write to the deleted key", + "expect.metadata": [{ "account": "acc", "key": "k2", "value": "v2" }], + "expect.deletedMetadata": [{ "account": "acc", "key": "k1" }] + } + ] +} diff --git a/internal/ir/assemble.go b/internal/ir/assemble.go index 755a5f1d..08ccada6 100644 --- a/internal/ir/assemble.go +++ b/internal/ir/assemble.go @@ -1061,3 +1061,21 @@ func (i MarkEnd) assemble(a *assembler) error { a.emit(vm.Op_MarkEnd, rewind, maxReg, maxReg) return nil } + +func (i DeleteAccountMeta) 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 + } + scope, err := a.optionalReg((*assembler).strReg, i.Scope) + if err != nil { + return err + } + + a.emit(vm.Op_DeleteAccountMeta, account, key, scope) + return nil +} diff --git a/internal/ir/dump.go b/internal/ir/dump.go index 26316df7..234230bb 100644 --- a/internal/ir/dump.go +++ b/internal/ir/dump.go @@ -250,3 +250,10 @@ func joinOpts(parts ...string) string { } return strings.Join(kept, ", ") } + +func (i DeleteAccountMeta) String() string { + if i.Scope != nil { + return fmt.Sprintf("delete_account_meta(%s, %s, %s)", i.Account, i.Key, optLabel("scope", i.Scope)) + } + return fmt.Sprintf("delete_account_meta(%s, %s)", i.Account, i.Key) +} diff --git a/internal/ir/instr.go b/internal/ir/instr.go index fd87f464..44e57706 100644 --- a/internal/ir/instr.go +++ b/internal/ir/instr.go @@ -125,6 +125,10 @@ type ( Account, Key, Value Reg // str, str, str Scope *Reg // str } + DeleteAccountMeta struct { + Account, Key Reg // str, str + Scope *Reg // str + } MetaVar struct { Dest Reg Account, Key Reg // str, str @@ -270,6 +274,9 @@ func (i SetTxMeta) sources() []Reg { return []Reg{i.Key, i.Value} } func (i SetAccountMeta) dests() []Reg { return nil } func (i SetAccountMeta) sources() []Reg { return present(&i.Account, &i.Key, &i.Value, i.Scope) } +func (i DeleteAccountMeta) dests() []Reg { return nil } +func (i DeleteAccountMeta) sources() []Reg { return present(&i.Account, &i.Key, i.Scope) } + func (i MetaVar) dests() []Reg { return []Reg{i.Dest} } func (i MetaVar) sources() []Reg { return present(&i.Account, &i.Key, i.Scope) } diff --git a/internal/ir/parse.go b/internal/ir/parse.go index 57e12121..b448b503 100644 --- a/internal/ir/parse.go +++ b/internal/ir/parse.go @@ -558,6 +558,8 @@ func (t *transformer) transformCall(s *syntax.InstrStmt) (Instr, *Error) { instr = SetTxMeta{Key: ap.reg(), Value: ap.reg()} case "set_account_meta": instr = SetAccountMeta{Account: ap.reg(), Key: ap.reg(), Value: ap.reg(), Scope: ap.optLabeledReg("scope")} + case "delete_account_meta": + instr = DeleteAccountMeta{Account: ap.reg(), Key: ap.reg(), Scope: ap.optLabeledReg("scope")} case "jmp_if_false": cond, target := ap.reg(), ap.labelRef() diff --git a/internal/ir/parse_test.go b/internal/ir/parse_test.go index c79b0bea..eda6fd0b 100644 --- a/internal/ir/parse_test.go +++ b/internal/ir/parse_test.go @@ -649,6 +649,23 @@ func TestRoundtripAllInstructions(t *testing.T) { $r1 = "key" $r2 = "value" set_account_meta($r0, $r1, $r2) +`, + }, + { + name: "delete_account_meta", + ir: ` + $r0 = "acct" + $r1 = "key" + delete_account_meta($r0, $r1) +`, + }, + { + name: "delete_account_meta scoped", + ir: ` + $r0 = "acct" + $r1 = "key" + $r2 = "scope" + delete_account_meta($r0, $r1, scope: $r2) `, }, { diff --git a/internal/ir/typecheck.go b/internal/ir/typecheck.go index 83a247e4..fbba7b29 100644 --- a/internal/ir/typecheck.go +++ b/internal/ir/typecheck.go @@ -149,6 +149,12 @@ func (tc *bytecodeTypechecker) check(instr Instr) error { tc.use(i.Value, regStr), tc.useOpt(i.Scope, regStr), ) + case DeleteAccountMeta: + return firstErr( + tc.use(i.Account, regStr), + tc.use(i.Key, regStr), + tc.useOpt(i.Scope, regStr), + ) case MetaVar: t, err := metaRegType(i.Typ) if err != nil { diff --git a/internal/ir/typecheck_test.go b/internal/ir/typecheck_test.go index 880fd114..735c08eb 100644 --- a/internal/ir/typecheck_test.go +++ b/internal/ir/typecheck_test.go @@ -92,6 +92,9 @@ func TestBytecodeTypecheck_OperandTypes(t *testing.T) { {"set_account_meta account", SetAccountMeta{Account: intReg, Key: strReg, Value: strReg}}, {"set_account_meta key", SetAccountMeta{Account: strReg, Key: intReg, Value: strReg}}, {"set_account_meta value", SetAccountMeta{Account: strReg, Key: strReg, Value: intReg}}, + {"delete_account_meta account", DeleteAccountMeta{Account: intReg, Key: strReg}}, + {"delete_account_meta key", DeleteAccountMeta{Account: strReg, Key: intReg}}, + {"delete_account_meta scope", DeleteAccountMeta{Account: strReg, Key: strReg, Scope: &intReg}}, {"meta account", MetaVar{Dest: 9, Account: intReg, Key: strReg, Typ: MetaStr{}}}, {"meta key", MetaVar{Dest: 9, Account: strReg, Key: intReg, Typ: MetaStr{}}}, {"meta_monetary account", MetaMonetary{DestAsset: 9, DestAmount: 10, Account: intReg, Key: strReg}}, diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index d548050e..4454a106 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -64,6 +64,23 @@ const SchemaURL = "https://raw.githubusercontent.com/formancehq/numscript/main/v // InputsSchemaURL is the equivalent for the inputs format. const InputsSchemaURL = "https://raw.githubusercontent.com/formancehq/numscript/main/v1.inputs.schema.json" +// DeletedMetadataRow is one Store.DeleteAccountMetadata call. +type DeletedMetadataRow struct { + Account string `json:"account"` + Key string `json:"key"` + Scope string `json:"scope,omitempty"` +} + +type deletionRecorder struct { + interpreter.StaticStore + deleted []DeletedMetadataRow +} + +func (s *deletionRecorder) DeleteAccountMetadata(_ context.Context, account, scope, key string) error { + s.deleted = append(s.deleted, DeletedMetadataRow{Account: account, Key: key, Scope: scope}) + return nil +} + // --- Specs: type Specs struct { Schema string `json:"$schema,omitempty"` @@ -93,6 +110,7 @@ type TestCase struct { ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"` ExpectTxMeta ExpectedTxMeta `json:"expect.txMetadata,omitempty"` ExpectAccountsMeta interpreter.SetAccountsMetadata `json:"expect.metadata,omitempty"` + ExpectDeletedMeta []DeletedMetadataRow `json:"expect.deletedMetadata,omitempty"` ExpectEndBalances interpreter.Balances `json:"expect.endBalances,omitempty"` ExpectEndBalancesInclude interpreter.Balances `json:"expect.endBalances.include,omitempty"` ExpectMovements Movements `json:"expect.movements,omitempty"` @@ -163,14 +181,15 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp featureFlags[flag] = struct{}{} } + store := &deletionRecorder{StaticStore: interpreter.StaticStore{ + Meta: meta, + Balances: mergedBalances, + }} result, err := interpreter.RunProgram( context.Background(), program, vars, - interpreter.StaticStore{ - Meta: meta, - Balances: mergedBalances, - }, + store, featureFlags, ) @@ -244,6 +263,15 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp ) } + if testCase.ExpectDeletedMeta != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.deletedMetadata", + testCase.ExpectDeletedMeta, + store.deleted, + slices.Equal, + ) + } + if testCase.ExpectEndBalances != nil { failedAssertions = runAssertion(failedAssertions, "expect.endBalances", diff --git a/internal/typecheck/typecheck.go b/internal/typecheck/typecheck.go index 41e36653..6fd8e6bd 100644 --- a/internal/typecheck/typecheck.go +++ b/internal/typecheck/typecheck.go @@ -120,14 +120,15 @@ type fnSig struct { } 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}, - builtins.Scoped: {params: []Type{TypeAccount, TypeString}, ret: TypeAccount}, + builtins.SetTxMeta: {params: []Type{TypeString, TypeAny}}, + builtins.SetAccountMeta: {params: []Type{TypeAccount, TypeString, TypeAny}}, + builtins.DeleteAccountMetadata: {params: []Type{TypeAccount, TypeString}}, + 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}, + builtins.Scoped: {params: []Type{TypeAccount, TypeString}, ret: TypeAccount}, } // --- Result / entrypoint diff --git a/internal/vm/instruction.go b/internal/vm/instruction.go index a6e4fe7d..1df919e9 100644 --- a/internal/vm/instruction.go +++ b/internal/vm/instruction.go @@ -108,6 +108,10 @@ const ( // scope (str reg, 0xFF = unscoped) Op_MetaMonetary Opcode = 0x25 + // A = account (str reg), B = key (str reg), C = scope (str reg, 0xFF = + // unscoped). Calls Store.DeleteAccountMetadata. + Op_DeleteAccountMeta Opcode = 0x26 + // --- arithmetic & constructors (0x30) --- Op_AddInt Opcode = 0x30 Op_SubInt Opcode = 0x31 diff --git a/internal/vm/ir_test.go b/internal/vm/ir_test.go index 839abfa7..f7428e83 100644 --- a/internal/vm/ir_test.go +++ b/internal/vm/ir_test.go @@ -1432,3 +1432,7 @@ func TestIREndWithQueuedFundsIsAnInternalError(t *testing.T) { `, balances(map[string]int64{"src": 100}), nil) require.Equal(t, vm.InternalError{Err: vm.ErrEndWithQueuedFunds}, execErr) } + +func (s irStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/internal/vm/verify.go b/internal/vm/verify.go index 94930cbb..47a21189 100644 --- a/internal/vm/verify.go +++ b/internal/vm/verify.go @@ -353,8 +353,8 @@ func checkDefiniteAssignment(steps []step, g cfg) error { // checkMarkBalance rejects a program in which the number of open marks is not // the same on every path reaching an instruction, a mark end runs with no open -// mark, a send, save or set_current_asset runs with a mark open, or the run can -// end with a mark open. +// mark, a send, save, set_current_asset or delete_account_meta runs with a mark +// open, or the run can end with a mark open. func checkMarkBalance(steps []step, g cfg) error { depthOut := make([]int, len(steps)) for k, st := range steps { @@ -371,7 +371,7 @@ func checkMarkBalance(steps []step, g cfg) error { } } if st.d.notInMark && in > 0 { - return fmt.Errorf("at instruction %d: send, save or set_current_asset while a mark is open", st.at) + return fmt.Errorf("at instruction %d: send, save, set_current_asset or delete_account_meta while a mark is open", st.at) } out := in + st.d.markDelta if out < 0 { @@ -503,6 +503,11 @@ func decodeInstr(instr, ext Instruction) (decoded, error) { read(bankStr, instr.B) read(bankStr, instr.C) readOpt(bankStr, ext.A) // scope + case Op_DeleteAccountMeta: + read(bankStr, instr.A) + read(bankStr, instr.B) + readOpt(bankStr, instr.C) // scope + d.notInMark = true case Op_MetaStr: read(bankStr, instr.B) read(bankStr, instr.C) diff --git a/internal/vm/verify_test.go b/internal/vm/verify_test.go index dd2cd42d..1651ef71 100644 --- a/internal/vm/verify_test.go +++ b/internal/vm/verify_test.go @@ -213,6 +213,20 @@ func TestVerifyMarkBalance(t *testing.T) { }}) }) + t.Run("delete_account_meta inside a mark", func(t *testing.T) { + requireRejectedWith(t, Program{ + MaxRegString: 2, + StringsPool: []string{"acc"}, + Instructions: []Instruction{ + bc(Op_LoadStr, 0, 0), + bc(Op_LoadStr, 1, 0), + push, + abc(Op_DeleteAccountMeta, 0, 1, nilReg), + commit, + }, + }, "delete_account_meta while a mark is open") + }) + t.Run("unreachable mark end is ignored", func(t *testing.T) { mustAccept(t, Program{Instructions: []Instruction{ bc(Op_Jmp, 0, 1), // 0: -> 2 diff --git a/internal/vm/vm.go b/internal/vm/vm.go index c04317ad..0e7d132a 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -21,13 +21,15 @@ type accountMetaKey struct { 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. +// The ops a mark cannot survive, rejected by the arms below. save and +// delete_account_meta are on the list permanently: save floors the balance at +// zero, and the clamp destroys the information needed to invert it; a store +// delete cannot be undone. var ( - errSendWhileMarkOpen = errors.New("send while a mark is open") - errSetAssetWhileMarkOpen = errors.New("set_current_asset while a mark is open") - errSaveWhileMarkOpen = errors.New("save while a mark is open") + errSendWhileMarkOpen = errors.New("send while a mark is open") + errSetAssetWhileMarkOpen = errors.New("set_current_asset while a mark is open") + errSaveWhileMarkOpen = errors.New("save while a mark is open") + errDeleteMetaWhileMarkOpen = errors.New("delete_account_meta while a mark is open") ) // Not statically decidable: whether a send drains the queue depends on the @@ -83,6 +85,11 @@ type Store interface { scope, key string, ) (string, bool, error) + + // Called during execution, in script order, even by a run that later fails. + // The result's AccountsMetadata holds only writes made after the key's last + // delete, so apply the deletions first. + DeleteAccountMetadata(ctx context.Context, account, scope, key string) error } func lookupMeta(ctx context.Context, store Store, account, scope, key string) (string, ExecutionError) { @@ -381,6 +388,23 @@ func Exec[S Store]( } accountsMeta[key] = stringsRegs[instr.C] + case Op_DeleteAccountMeta: + if runstate.HasOpenMark() { + return funds.ExecutionResult{}, InternalError{Err: errDeleteMetaWhileMarkOpen} + } + + key := accountMetaKey{ + account: stringsRegs[instr.A], + key: stringsRegs[instr.B], + } + if instr.C != nilReg { + key.scope = stringsRegs[instr.C] + } + delete(accountsMeta, key) + if err := store.DeleteAccountMetadata(ctx, key.account, key.scope, key.key); err != nil { + return funds.ExecutionResult{}, StoreError{Wrapped: err} + } + case Op_MetaStr: instrExt := instrs[pc] pc++ diff --git a/internal/vm/vm_test.go b/internal/vm/vm_test.go index ac377b59..dd4676cc 100644 --- a/internal/vm/vm_test.go +++ b/internal/vm/vm_test.go @@ -615,3 +615,7 @@ func TestQueuedZeroAmountIsNotQueuedFunds(t *testing.T) { require.Nil(t, err) require.Empty(t, res.Postings) } + +func (m mockStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} diff --git a/ir-textual-format.md b/ir-textual-format.md index b7409167..25fd9d91 100644 --- a/ir-textual-format.md +++ b/ir-textual-format.md @@ -259,6 +259,7 @@ There is no allotment instruction. Splitting an amount across portions is built | --- | --- | | `set_tx_meta($key, $value)` | `str, str` | | `set_account_meta($account, $key, $value)` | `str, str, str` | +| `delete_account_meta($account, $key)` | `str, str` | ### Control flow diff --git a/numscript.go b/numscript.go index db3b5c64..8d2be3a7 100644 --- a/numscript.go +++ b/numscript.go @@ -130,8 +130,9 @@ type ( // Interpreter errors reporting that a Store call failed; Unwrap yields the // store's own error. type ( - QueryBalanceError = interpreter.QueryBalanceError - QueryMetadataError = interpreter.QueryMetadataError + QueryBalanceError = interpreter.QueryBalanceError + QueryMetadataError = interpreter.QueryMetadataError + DeleteMetadataError = interpreter.DeleteMetadataError ) // Interpreter errors reporting defects in numscript itself, never caused by the diff --git a/numscript_test.go b/numscript_test.go index 2ce9f149..ffb40753 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -572,6 +572,10 @@ func (s *AliasingStore) GetAccountsMetadata(_ context.Context, _ interpreter.Met return nil, nil } +func (s *AliasingStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} + func TestStoreBalancesNotMutatedInPlace(t *testing.T) { parseResult := numscript.Parse(`send [COIN 100] ( source = @alice @@ -611,6 +615,10 @@ func (os *ObservableStore) GetAccountsMetadata(ctx context.Context, q interprete return os.StaticStore.GetAccountsMetadata(ctx, q) } +func (os *ObservableStore) DeleteAccountMetadata(ctx context.Context, account, scope, key string) error { + return os.StaticStore.DeleteAccountMetadata(ctx, account, scope, key) +} + type ErrorStore struct{} func (*ErrorStore) GetBalances(ctx context.Context, q interpreter.BalanceQuery) (interpreter.Balances, error) { @@ -621,6 +629,10 @@ func (*ErrorStore) GetAccountsMetadata(ctx context.Context, q interpreter.Metada return nil, errors.New("Error while fetching metadata") } +func (*ErrorStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return errors.New("Error while deleting metadata") +} + func TestResolveDependenciesPublicAPI(t *testing.T) { parsed := numscript.Parse(` send [USD 10] ( @@ -793,3 +805,56 @@ func TestExecVmReleasesStore(t *testing.T) { }) } } + +func (s vmTestStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} + +func (s *releaseTestStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return nil +} + +var deleteMetaFlags = map[string]struct{}{flags.ExperimentalDeleteAccountMetadata: {}} + +func TestDeleteMetadataRequiresFlag(t *testing.T) { + parseResult := numscript.Parse(`delete_account_metadata(@acc, "k")`) + require.Empty(t, parseResult.GetParsingErrors()) + + _, err := parseResult.Run(context.Background(), interpreter.VariablesMap{}, numscript.StaticStore{}) + require.Equal(t, numscript.ExperimentalFeature{FlagName: flags.ExperimentalDeleteAccountMetadata}, err) +} + +func TestDeleteMetadataErr(t *testing.T) { + parseResult := numscript.Parse(`delete_account_metadata(@acc, "k")`) + require.Empty(t, parseResult.GetParsingErrors()) + + _, err := parseResult.RunWithFeatureFlags(context.Background(), interpreter.VariablesMap{}, &ErrorStore{}, deleteMetaFlags) + require.IsType(t, numscript.DeleteMetadataError{}, err) +} + +type vmDeleteErrStore struct{ vmTestStore } + +func (vmDeleteErrStore) DeleteAccountMetadata(context.Context, string, string, string) error { + return errors.New("Error while deleting metadata") +} + +func TestVmDeleteMetadataErr(t *testing.T) { + enc, program, err := numscript.CompileWithFeatureFlags(`delete_account_metadata(@acc, "k")`, deleteMetaFlags) + require.NoError(t, err) + vars, err := enc.Encode(nil) + require.NoError(t, err) + + _, execErr := numscript.ExecVm(context.Background(), numscript.NewVm(program), &vars, vmDeleteErrStore{}) + require.IsType(t, numscript.VmStoreError{}, execErr) +} + +func TestResolveDependenciesDeleteMetadata(t *testing.T) { + parseResult := numscript.Parse(`delete_account_metadata(@acc, "k")`) + require.Empty(t, parseResult.GetParsingErrors()) + + deps, err := parseResult.ResolveDependencies(context.Background(), nil, numscript.StaticStore{}) + require.NoError(t, err) + require.Equal(t, map[numscript.MetaDependency]struct{}{ + {Account: "acc", Key: "k"}: {}, + }, deps.MetaWrites) +} diff --git a/v1.specs.schema.json b/v1.specs.schema.json index d4da83db..3941c922 100644 --- a/v1.specs.schema.json +++ b/v1.specs.schema.json @@ -76,6 +76,10 @@ "$ref": "#/definitions/SetAccountsMetadata" }, + "expect.deletedMetadata": { + "$ref": "#/definitions/DeletedAccountsMetadata" + }, + "expect.error.missingFunds": { "type": "boolean" }, @@ -164,6 +168,31 @@ "items": { "$ref": "#/definitions/SetAccountMetadataRow" } }, + "DeletedAccountsMetadata": { + "type": "array", + "description": "The account metadata deletions requested during execution, in script order.", + "items": { "$ref": "#/definitions/DeletedAccountMetadataRow" } + }, + + "DeletedAccountMetadataRow": { + "type": "object", + "additionalProperties": false, + "required": ["account", "key"], + "properties": { + "account": { + "type": "string", + "pattern": "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$" + }, + "key": { + "type": "string" + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9_]*$" + } + } + }, + "SetAccountMetadataRow": { "type": "object", "description": "A single set metadata entry: the value of a given (account, key, scope)", From d096c681ea68e29f59bd4ed412e0bbdd055418cc Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 4 Nov 2026 16:40:26 +0100 Subject: [PATCH 2/2] feat(vm): bump bytecode version to 0.2 Older 0.x freezes keep running with their header re-stamped to the current version, so a freeze only adds the programs no earlier freeze holds. The 0.2 freeze holds the four delete_account_metadata programs. --- bytecode_corpus_test.go | 103 +++++++++++++++--- compiler-architecture.md | 6 +- internal/vm/section.go | 2 +- ...lete-account-metadata-delete-then-set.numb | Bin 0 -> 108 bytes .../balances.json | 1 + .../postings.json | 11 ++ .../vars.nvar | Bin 0 -> 30 bytes ...mental-delete-account-metadata-delete.numb | Bin 0 -> 97 bytes .../balances.json | 1 + .../postings.json | 5 + .../vars.nvar | Bin 0 -> 49 bytes ...mental-delete-account-metadata-scoped.numb | Bin 0 -> 172 bytes .../balances.json | 1 + .../postings.json | 11 ++ .../vars.nvar | Bin 0 -> 30 bytes ...lete-account-metadata-set-then-delete.numb | Bin 0 -> 142 bytes .../balances.json | 1 + .../postings.json | 11 ++ .../vars.nvar | Bin 0 -> 30 bytes 19 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete-then-set.numb create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete-then-set/keeps-a-write-made-after-the-delete/balances.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete-then-set/keeps-a-write-made-after-the-delete/postings.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete-then-set/keeps-a-write-made-after-the-delete/vars.nvar create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete.numb create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete/deletes-each-key-from-the-store-in-script-order/balances.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete/deletes-each-key-from-the-store-in-script-order/postings.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-delete/deletes-each-key-from-the-store-in-script-order/vars.nvar create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-scoped.numb create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-scoped/deletes-only-the-entry-of-the-account-s-scope/balances.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-scoped/deletes-only-the-entry-of-the-account-s-scope/postings.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-scoped/deletes-only-the-entry-of-the-account-s-scope/vars.nvar create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-set-then-delete.numb create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-set-then-delete/drops-an-earlier-write-to-the-deleted-key/balances.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-set-then-delete/drops-an-earlier-write-to-the-deleted-key/postings.json create mode 100644 testdata/bytecode/0.2/experimental-delete-account-metadata-set-then-delete/drops-an-earlier-write-to-the-deleted-key/vars.nvar diff --git a/bytecode_corpus_test.go b/bytecode_corpus_test.go index a1e455d3..bb3cab6c 100644 --- a/bytecode_corpus_test.go +++ b/bytecode_corpus_test.go @@ -3,6 +3,7 @@ package numscript_test import ( "bytes" "context" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -90,6 +91,13 @@ func TestFreezeBytecodeCorpus(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, rawSpecs) + var earlier []string + for _, freeze := range readCorpusFreezes(t) { + if runsOnCurrentVm(freezeVersion(t, freeze)) { + earlier = append(earlier, freeze) + } + } + frozen := 0 for _, rawSpec := range rawSpecs { rel, err := filepath.Rel(bytecodeFixturesDir, rawSpec.NumscriptPath) @@ -115,8 +123,7 @@ func TestFreezeBytecodeCorpus(t *testing.T) { } require.NoError(t, cErr, rel) - require.NoError(t, os.MkdirAll(filepath.Join(dir, programName), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, programName+corpusProgramExt), program.Encode(), 0o644)) + files := map[string][]byte{programName + corpusProgramExt: program.Encode()} used := map[string]bool{} for _, tc := range specs.TestCases { @@ -131,22 +138,29 @@ func TestFreezeBytecodeCorpus(t *testing.T) { res, execErr := numscript.ExecVm(context.Background(), numscript.NewVm(program), &vars, newCorpusStore(balances, metadata)) - caseDir := filepath.Join(dir, programName, caseFolderName(tc.It, used)) - require.NoError(t, os.MkdirAll(caseDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(caseDir, corpusVarsFile), vars.Encode(), 0o644)) - writeCorpusJSON(t, filepath.Join(caseDir, corpusBalancesFile), balances) + caseDir := filepath.Join(programName, caseFolderName(tc.It, used)) + files[filepath.Join(caseDir, corpusVarsFile)] = vars.Encode() + files[filepath.Join(caseDir, corpusBalancesFile)] = corpusJSON(t, balances) if len(metadata) != 0 { - writeCorpusJSON(t, filepath.Join(caseDir, corpusMetadataFile), metadata) + files[filepath.Join(caseDir, corpusMetadataFile)] = corpusJSON(t, metadata) } if execErr != nil { - writeCorpusJSON(t, filepath.Join(caseDir, corpusErrorFile), corpusErrorOf(t, execErr)) + files[filepath.Join(caseDir, corpusErrorFile)] = corpusJSON(t, corpusErrorOf(t, execErr)) } else { - writeCorpusJSON(t, filepath.Join(caseDir, corpusPostingsFile), corpusResult(res)) + files[filepath.Join(caseDir, corpusPostingsFile)] = corpusJSON(t, corpusResult(res)) } - frozen++ } + + if slices.ContainsFunc(earlier, func(freeze string) bool { return freezeHasFiles(t, freeze, programName, files) }) { + continue + } + for path, data := range files { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(path)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, path), data, 0o644)) + } + frozen++ } - t.Logf("froze %d cases into %s", frozen, dir) + t.Logf("froze %d programs into %s", frozen, dir) } func TestBytecodeCorpusHasCurrentVersion(t *testing.T) { @@ -205,6 +219,7 @@ func checkFrozenProgram(t *testing.T, dir, name string, version numscript.Byteco require.NotEmpty(t, cases, "no cases") readable := numscript.CurrentBytecodeVersion.CanRead(programVersion) + restamp := !readable && runsOnCurrentVm(programVersion) for _, c := range cases { require.True(t, c.IsDir(), "%s: not a case folder", c.Name()) t.Run(c.Name(), func(t *testing.T) { @@ -223,14 +238,21 @@ func checkFrozenProgram(t *testing.T, dir, name string, version numscript.Byteco _, err = numscript.DecodeVars(varsBytes) require.ErrorAs(t, err, &unsupported) require.Equal(t, varsVersion, unsupported.Encoded) - return + if !restamp { + return + } + varsBytes = withCurrentVersion(varsBytes) + } + programBytes := programBytes + if restamp { + programBytes = withCurrentVersion(programBytes) } program, err := numscript.DecodeCompiledProgram(programBytes) require.NoError(t, err) vars, err := numscript.DecodeVars(varsBytes) require.NoError(t, err) - if programVersion == numscript.CurrentBytecodeVersion { + if programVersion == numscript.CurrentBytecodeVersion || restamp { require.Equal(t, programBytes, program.Encode(), "re-encoding changed the program bytes") require.Equal(t, varsBytes, vars.Encode(), "re-encoding changed the vars bytes") } @@ -349,10 +371,61 @@ func sortedKeys(m map[string]bool) []string { return keys } -func writeCorpusJSON(t *testing.T, path string, v any) { +func corpusJSON(t *testing.T, v any) []byte { data, err := json.MarshalIndent(v, "", " ") require.NoError(t, err) - require.NoError(t, os.WriteFile(path, append(data, '\n'), 0o644)) + return append(data, '\n') +} + +// Under 0.x a reader rejects every other version, but an older 0.x freeze still +// runs, re-stamped, so unchanged programs are not frozen again on a bump. +func runsOnCurrentVm(v numscript.BytecodeVersion) bool { + current := numscript.CurrentBytecodeVersion + return current.CanRead(v) || (current.Major == 0 && v.Major == 0) +} + +// bytes 4..8 of a program or vars blob are the little-endian major and minor +func withCurrentVersion(blob []byte) []byte { + out := slices.Clone(blob) + binary.LittleEndian.PutUint16(out[4:], numscript.CurrentBytecodeVersion.Major) + binary.LittleEndian.PutUint16(out[6:], numscript.CurrentBytecodeVersion.Minor) + return out +} + +// freezeHasFiles reports whether freeze holds exactly files for programName, +// comparing .numb and .nvar blobs without their version. +func freezeHasFiles(t *testing.T, freeze, programName string, files map[string][]byte) bool { + root := filepath.Join(bytecodeCorpusDir, freeze) + onDisk := []string{programName + corpusProgramExt} + if _, err := os.Stat(filepath.Join(root, onDisk[0])); err != nil { + return false + } + require.NoError(t, filepath.WalkDir(filepath.Join(root, programName), func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(root, p) + onDisk = append(onDisk, rel) + return err + })) + if len(onDisk) != len(files) { + return false + } + for _, path := range onDisk { + want, ok := files[path] + if !ok { + return false + } + got, err := os.ReadFile(filepath.Join(root, path)) + require.NoError(t, err) + if strings.HasSuffix(path, corpusProgramExt) || strings.HasSuffix(path, corpusVarsFile) { + got, want = withCurrentVersion(got), withCurrentVersion(want) + } + if !bytes.Equal(got, want) { + return false + } + } + return true } func readCorpusJSON(t *testing.T, path string, v any) { diff --git a/compiler-architecture.md b/compiler-architecture.md index ecae0f63..dd4f4089 100644 --- a/compiler-architecture.md +++ b/compiler-architecture.md @@ -188,9 +188,9 @@ Major 0 is **unstable**, and the format is at `0.1` today. Any 0.x change may ch #### Frozen bytecode -`testdata/bytecode/` holds, per freeze, the bytecode the compiler produced from every script fixture at one point in time, with each test case's vars blob, inputs and the output it produced then. A freeze directory is named after the bytecode version (`0.1`, or `0.1-` for a later freeze at the same version) and is never edited once committed. Inside it, `