From a710d68512cf9983bdf5230e2e99f267521c7210 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sat, 30 Dec 2023 22:25:34 -0600 Subject: [PATCH] feat(cosmos): impose defaults when sending VM actions --- golang/cosmos/vm/action.go | 130 ++++++++++++++++++++++ golang/cosmos/vm/action_test.go | 129 +++++++++++++++++++++ golang/cosmos/vm/controller.go | 6 - golang/cosmos/x/swingset/keeper/keeper.go | 26 ++++- 4 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 golang/cosmos/vm/action.go create mode 100644 golang/cosmos/vm/action_test.go diff --git a/golang/cosmos/vm/action.go b/golang/cosmos/vm/action.go new file mode 100644 index 00000000000..7aaf22c214f --- /dev/null +++ b/golang/cosmos/vm/action.go @@ -0,0 +1,130 @@ +package vm + +import ( + "reflect" + "strconv" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + zeroTime = time.Time{} + actionHeaderType = reflect.TypeOf(ActionHeader{}) + _ Action = &ActionHeader{} +) + +// Jsonable is a value, j, that can be passed through json.Marshal(j). +type Jsonable interface{} + +type Action interface { + GetActionHeader() ActionHeader +} + +// ActionPusher enqueues data for later consumption by the controller. +type ActionPusher func(ctx sdk.Context, action Action) error + +// ActionHeader should be embedded in all actions. It is populated by PopulateAction. +type ActionHeader struct { + // Type defaults to the `actionType:"..."` tag of the embedder's ActionHeader field. + Type string `json:"type"` + // BlockHeight defaults to sdk.Context.BlockHeight(). + BlockHeight int64 `json:"blockHeight,omitempty"` + // BlockTime defaults to sdk.Context.BlockTime().Unix(). + BlockTime int64 `json:"blockTime,omitempty"` +} + +func (ah ActionHeader) GetActionHeader() ActionHeader { + return ah +} + +// SetActionHeaderFromContext provides defaults to an ActionHeader. +func SetActionHeaderFromContext(ctx sdk.Context, actionType string, ah *ActionHeader) { + // Default the action type. + if len(ah.Type) == 0 { + ah.Type = actionType + } + + // Default the block height. + if ah.BlockHeight == 0 { + ah.BlockHeight = ctx.BlockHeight() + } + + if ah.BlockTime == 0 { + // Only default to a non-zero block time. + if blockTime := ctx.BlockTime(); blockTime != zeroTime { + ah.BlockTime = blockTime.Unix() + } + } +} + +// PopulateAction interprets `default:"..."` tags and specially handles an +// embedded ActionHeader struct. +func PopulateAction(ctx sdk.Context, action Action) Action { + oldRv := reflect.Indirect(reflect.ValueOf(action)) + if oldRv.Kind() != reflect.Struct { + return action + } + + // Shallow copy to a new value. + rp := reflect.New(oldRv.Type()) + rv := reflect.Indirect(rp) + for i := 0; i < rv.NumField(); i++ { + oldField := oldRv.Field(i) + field := rv.Field(i) + fieldType := rv.Type().Field(i) + if !field.CanSet() { + continue + } + + // Copy from original. + field.Set(oldField) + + // Populate any ActionHeader struct. + var ahp *ActionHeader + if fieldType.Type == actionHeaderType { + ahp = field.Addr().Interface().(*ActionHeader) + } else if fieldType.Type == reflect.PtrTo(actionHeaderType) { + if field.IsNil() { + ahp = &ActionHeader{} + } else { + ahp = field.Interface().(*ActionHeader) + } + } + if ahp != nil { + actionTypeTag, _ := fieldType.Tag.Lookup("actionType") + ah := *ahp + SetActionHeaderFromContext(ctx, actionTypeTag, &ah) + if field.Kind() == reflect.Ptr { + field.Set(reflect.ValueOf(&ah)) + } else { + field.Set(reflect.ValueOf(ah)) + } + continue + } + + // Still zero value, try default struct field tag. + defaultTag, _ := fieldType.Tag.Lookup("default") + if !field.IsZero() || len(defaultTag) == 0 { + continue + } + + switch field.Kind() { + case reflect.String: + field.SetString(defaultTag) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val, err := strconv.ParseInt(defaultTag, 0, 64); err == nil { + field.SetInt(val) + } + case reflect.Bool: + if val, err := strconv.ParseBool(defaultTag); err == nil { + field.SetBool(val) + } + case reflect.Float32, reflect.Float64: + if val, err := strconv.ParseFloat(defaultTag, 64); err == nil { + field.SetFloat(val) + } + } + } + return rv.Interface().(Action) +} diff --git a/golang/cosmos/vm/action_test.go b/golang/cosmos/vm/action_test.go new file mode 100644 index 00000000000..bde99144adf --- /dev/null +++ b/golang/cosmos/vm/action_test.go @@ -0,0 +1,129 @@ +package vm_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Agoric/agoric-sdk/golang/cosmos/vm" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +var ( + _ vm.Action = &Trivial{} + _ vm.Action = &Defaults{} + _ vm.Action = &dataAction{} +) + +type Trivial struct { + vm.ActionHeader + Abc int + def string +} + +type Defaults struct { + String string `default:"abc"` + Int int `default:"123"` + Float float64 `default:"4.56"` + Bool bool `default:"true"` + Any interface{} +} + +func (d Defaults) GetActionHeader() vm.ActionHeader { + return vm.ActionHeader{} +} + +type dataAction struct { + *vm.ActionHeader `actionType:"DATA_ACTION"` + Data []byte +} + +func TestActionContext(t *testing.T) { + emptyCtx := sdk.Context{} + + testCases := []struct { + name string + ctx sdk.Context + in vm.Action + expectedOut interface{} + }{ + {"nil", emptyCtx, nil, nil}, + {"no context", emptyCtx, + &Trivial{Abc: 123, def: "zot"}, + &Trivial{Abc: 123, def: "zot"}, + }, + {"block height", + emptyCtx.WithBlockHeight(998), + &Trivial{Abc: 123, def: "zot"}, + &Trivial{ActionHeader: vm.ActionHeader{BlockHeight: 998}, Abc: 123, def: "zot"}, + }, + {"block time", + emptyCtx.WithBlockTime(time.UnixMicro(1_000_000)), + &Trivial{Abc: 123, def: "zot"}, + &Trivial{Abc: 123, def: "zot", ActionHeader: vm.ActionHeader{BlockTime: 1}}, + }, + {"default tags", + emptyCtx, + &Defaults{}, + &Defaults{"abc", 123, 4.56, true, nil}, + }, + {"data action defaults", + emptyCtx.WithBlockHeight(998).WithBlockTime(time.UnixMicro(1_000_000)), + &dataAction{Data: []byte("hello")}, + &dataAction{Data: []byte("hello"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION", BlockHeight: 998, BlockTime: 1}}, + }, + {"data action override Type", + emptyCtx.WithBlockHeight(998).WithBlockTime(time.UnixMicro(1_000_000)), + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION2"}}, + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION2", BlockHeight: 998, BlockTime: 1}}, + }, + {"data action override BlockHeight", + emptyCtx.WithBlockHeight(998).WithBlockTime(time.UnixMicro(1_000_000)), + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{BlockHeight: 999}}, + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION", BlockHeight: 999, BlockTime: 1}}, + }, + {"data action override BlockTime", + emptyCtx.WithBlockHeight(998).WithBlockTime(time.UnixMicro(1_000_000)), + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{BlockTime: 2}}, + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION", BlockHeight: 998, BlockTime: 2}}, + }, + {"data action override all defaults", + emptyCtx.WithBlockHeight(998).WithBlockTime(time.UnixMicro(1_000_000)), + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION2", BlockHeight: 999, BlockTime: 2}}, + &dataAction{Data: []byte("hello2"), + ActionHeader: &vm.ActionHeader{Type: "DATA_ACTION2", BlockHeight: 999, BlockTime: 2}}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + jstr := func(in interface{}) string { + bz, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + return string(bz) + } + jsonIn := jstr(tc.in) + out := vm.PopulateAction(tc.ctx, tc.in) + jsonIn2 := jstr(tc.in) + if jsonIn != jsonIn2 { + t.Errorf("unexpected mutated input: %s to %s", jsonIn, jsonIn2) + } + jsonOut := jstr(out) + jsonExpectedOut := jstr(tc.expectedOut) + if jsonOut != jsonExpectedOut { + t.Errorf("expected %s, got %s", jsonExpectedOut, jsonOut) + } + }) + } +} diff --git a/golang/cosmos/vm/controller.go b/golang/cosmos/vm/controller.go index a414f8fb9ff..36930a4c9b8 100644 --- a/golang/cosmos/vm/controller.go +++ b/golang/cosmos/vm/controller.go @@ -20,12 +20,6 @@ type ControllerAdmissionMsg interface { IsHighPriority(sdk.Context, interface{}) (bool, error) } -// Jsonable is a value, j, that can be passed through json.Marshal(j). -type Jsonable interface{} - -// ActionPusher enqueues data for later consumption by the controller. -type ActionPusher func(ctx sdk.Context, action Jsonable) error - var wrappedEmptySDKContext = sdk.WrapSDKContext( sdk.Context{}.WithContext(context.Background()), ) diff --git a/golang/cosmos/x/swingset/keeper/keeper.go b/golang/cosmos/x/swingset/keeper/keeper.go index 27086661119..339f2643e50 100644 --- a/golang/cosmos/x/swingset/keeper/keeper.go +++ b/golang/cosmos/x/swingset/keeper/keeper.go @@ -110,6 +110,16 @@ func NewKeeper( } } +func populateAction(ctx sdk.Context, action vm.Action) (vm.Action, error) { + action = vm.PopulateAction(ctx, action) + ah := action.GetActionHeader() + if len(ah.Type) == 0 { + return nil, fmt.Errorf("action %q cannot have an empty ActionHeader.Type", action) + } + + return action, nil +} + // pushAction appends an action to the controller's specified inbound queue. // The queue is kept in the kvstore so that changes are properly reverted if the // kvstore is rolled back. By the time the block manager runs, it can commit @@ -118,7 +128,11 @@ func NewKeeper( // // The inbound queue's format is documented by `makeChainQueue` in // `packages/cosmic-swingset/src/helpers/make-queue.js`. -func (k Keeper) pushAction(ctx sdk.Context, inboundQueuePath string, action vm.Jsonable) error { +func (k Keeper) pushAction(ctx sdk.Context, inboundQueuePath string, action vm.Action) error { + action, err := populateAction(ctx, action) + if err != nil { + return err + } txHash, txHashOk := ctx.Context().Value(baseapp.TxHashContextKey).(string) if !txHashOk { txHash = "unknown" @@ -143,12 +157,12 @@ func (k Keeper) pushAction(ctx sdk.Context, inboundQueuePath string, action vm.J } // PushAction appends an action to the controller's actionQueue. -func (k Keeper) PushAction(ctx sdk.Context, action vm.Jsonable) error { +func (k Keeper) PushAction(ctx sdk.Context, action vm.Action) error { return k.pushAction(ctx, StoragePathActionQueue, action) } // PushAction appends an action to the controller's highPriorityQueue. -func (k Keeper) PushHighPriorityAction(ctx sdk.Context, action vm.Jsonable) error { +func (k Keeper) PushHighPriorityAction(ctx sdk.Context, action vm.Action) error { return k.pushAction(ctx, StoragePathHighPriorityQueue, action) } @@ -234,7 +248,11 @@ func (k Keeper) UpdateQueueAllowed(ctx sdk.Context) error { // until the response. It is orthogonal to PushAction, and should only be used // by SwingSet to perform block lifecycle events (BEGIN_BLOCK, END_BLOCK, // COMMIT_BLOCK). -func (k Keeper) BlockingSend(ctx sdk.Context, action vm.Jsonable) (string, error) { +func (k Keeper) BlockingSend(ctx sdk.Context, action vm.Action) (string, error) { + action, err := populateAction(ctx, action) + if err != nil { + return "", err + } bz, err := json.Marshal(action) if err != nil { return "", err