diff --git a/pkg/core/transaction/attribute.go b/pkg/core/transaction/attribute.go index 26f9d2737..38fcf843e 100644 --- a/pkg/core/transaction/attribute.go +++ b/pkg/core/transaction/attribute.go @@ -10,7 +10,15 @@ import ( // Attribute represents a Transaction attribute. type Attribute struct { - Type AttrType + Type AttrType + Value interface { + io.Serializable + // toJSONMap is used for embedded json struct marshalling. + // Anonymous interface fields are not considered anonymous by + // json lib and marshaling Value together with type makes code + // harder to follow. + toJSONMap(map[string]interface{}) + } } // attrJSON is used for JSON I/O of Attribute. @@ -24,6 +32,9 @@ func (attr *Attribute) DecodeBinary(br *io.BinReader) { switch attr.Type { case HighPriority: + case OracleResponseT: + attr.Value = new(OracleResponse) + attr.Value.DecodeBinary(br) default: br.Err = fmt.Errorf("failed decoding TX attribute usage: 0x%2x", int(attr.Type)) return @@ -35,6 +46,8 @@ func (attr *Attribute) EncodeBinary(bw *io.BinWriter) { bw.WriteB(byte(attr.Type)) switch attr.Type { case HighPriority: + case OracleResponseT: + attr.Value.EncodeBinary(bw) default: bw.Err = fmt.Errorf("failed encoding TX attribute usage: 0x%2x", attr.Type) } @@ -42,9 +55,11 @@ func (attr *Attribute) EncodeBinary(bw *io.BinWriter) { // MarshalJSON implements the json Marshaller interface. func (attr *Attribute) MarshalJSON() ([]byte, error) { - return json.Marshal(attrJSON{ - Type: attr.Type.String(), - }) + m := map[string]interface{}{"type": attr.Type.String()} + if attr.Value != nil { + attr.Value.toJSONMap(m) + } + return json.Marshal(m) } // UnmarshalJSON implements the json.Unmarshaller interface. @@ -57,6 +72,12 @@ func (attr *Attribute) UnmarshalJSON(data []byte) error { switch aj.Type { case "HighPriority": attr.Type = HighPriority + case "OracleResponse": + attr.Type = OracleResponseT + // Note: because `type` field will not be present in any attribute + // value, we can unmarshal the same data. The overhead is minimal. + attr.Value = new(OracleResponse) + return json.Unmarshal(data, attr.Value) default: return errors.New("wrong Type") diff --git a/pkg/core/transaction/attribute_test.go b/pkg/core/transaction/attribute_test.go new file mode 100644 index 000000000..9072373ce --- /dev/null +++ b/pkg/core/transaction/attribute_test.go @@ -0,0 +1,66 @@ +package transaction + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/internal/testserdes" + "github.com/stretchr/testify/require" +) + +func TestAttribute_EncodeBinary(t *testing.T) { + t.Run("HighPriority", func(t *testing.T) { + attr := &Attribute{ + Type: HighPriority, + } + testserdes.EncodeDecodeBinary(t, attr, new(Attribute)) + }) + t.Run("OracleResponse", func(t *testing.T) { + attr := &Attribute{ + Type: OracleResponseT, + Value: &OracleResponse{ + ID: 0x1122334455, + Code: Success, + Result: []byte{1, 2, 3}, + }, + } + testserdes.EncodeDecodeBinary(t, attr, new(Attribute)) + }) +} + +func TestAttribute_MarshalJSON(t *testing.T) { + t.Run("HighPriority", func(t *testing.T) { + attr := &Attribute{Type: HighPriority} + data, err := json.Marshal(attr) + require.NoError(t, err) + require.JSONEq(t, `{"type":"HighPriority"}`, string(data)) + + actual := new(Attribute) + require.NoError(t, json.Unmarshal(data, actual)) + require.Equal(t, attr, actual) + }) + t.Run("OracleResponse", func(t *testing.T) { + res := []byte{1, 2, 3} + attr := &Attribute{ + Type: OracleResponseT, + Value: &OracleResponse{ + ID: 123, + Code: Success, + Result: res, + }, + } + data, err := json.Marshal(attr) + require.NoError(t, err) + require.JSONEq(t, `{ + "type":"OracleResponse", + "id": 123, + "code": 0, + "result": "`+base64.StdEncoding.EncodeToString(res)+`"}`, string(data)) + + actual := new(Attribute) + require.NoError(t, json.Unmarshal(data, actual)) + require.Equal(t, attr, actual) + testserdes.EncodeDecodeBinary(t, attr, new(Attribute)) + }) +} diff --git a/pkg/core/transaction/attrtype.go b/pkg/core/transaction/attrtype.go index 6c20ae438..4f687e088 100644 --- a/pkg/core/transaction/attrtype.go +++ b/pkg/core/transaction/attrtype.go @@ -1,11 +1,16 @@ package transaction -//go:generate stringer -type=AttrType +//go:generate stringer -type=AttrType -linecomment // AttrType represents the purpose of the attribute. type AttrType uint8 // List of valid attribute types. const ( - HighPriority AttrType = 1 + HighPriority AttrType = 1 + OracleResponseT AttrType = 0x11 // OracleResponse ) + +func (a AttrType) allowMultiple() bool { + return false +} diff --git a/pkg/core/transaction/attrtype_string.go b/pkg/core/transaction/attrtype_string.go index 79cde46a4..f520f46a1 100644 --- a/pkg/core/transaction/attrtype_string.go +++ b/pkg/core/transaction/attrtype_string.go @@ -1,4 +1,4 @@ -// Code generated by "stringer -type AttrType"; DO NOT EDIT. +// Code generated by "stringer -type=AttrType -linecomment"; DO NOT EDIT. package transaction @@ -9,16 +9,21 @@ func _() { // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[HighPriority-1] + _ = x[OracleResponseT-17] } -const _AttrType_name = "HighPriority" - -var _AttrType_index = [...]uint8{0, 12} +const ( + _AttrType_name_0 = "HighPriority" + _AttrType_name_1 = "OracleResponse" +) func (i AttrType) String() string { - i -= 1 - if i >= AttrType(len(_AttrType_index)-1) { - return "AttrType(" + strconv.FormatInt(int64(i+1), 10) + ")" + switch { + case i == 1: + return _AttrType_name_0 + case i == 17: + return _AttrType_name_1 + default: + return "AttrType(" + strconv.FormatInt(int64(i), 10) + ")" } - return _AttrType_name[_AttrType_index[i]:_AttrType_index[i+1]] } diff --git a/pkg/core/transaction/oracle.go b/pkg/core/transaction/oracle.go new file mode 100644 index 000000000..67c16fb83 --- /dev/null +++ b/pkg/core/transaction/oracle.go @@ -0,0 +1,66 @@ +package transaction + +import ( + "errors" + + "github.com/nspcc-dev/neo-go/pkg/io" +) + +// OracleResponseCode represents result code of oracle response. +type OracleResponseCode byte + +// OracleResponse represents oracle response. +type OracleResponse struct { + ID uint64 `json:"id"` + Code OracleResponseCode `json:"code"` + Result []byte `json:"result"` +} + +const maxResultSize = 1024 + +// Enumeration of possible oracle response types. +const ( + Success OracleResponseCode = 0x00 + NotFound OracleResponseCode = 0x10 + Timeout OracleResponseCode = 0x12 + Forbidden OracleResponseCode = 0x14 + Error OracleResponseCode = 0xff +) + +// Various validation errors. +var ( + ErrInvalidResponseCode = errors.New("invalid oracle response code") + ErrInvalidResult = errors.New("oracle response != success, but result is not empty") +) + +// IsValid checks if c is valid response code. +func (c OracleResponseCode) IsValid() bool { + return c == Success || c == NotFound || c == Timeout || c == Forbidden || c == Error +} + +// DecodeBinary implements io.Serializable interface. +func (r *OracleResponse) DecodeBinary(br *io.BinReader) { + r.ID = br.ReadU64LE() + r.Code = OracleResponseCode(br.ReadB()) + if !r.Code.IsValid() { + br.Err = ErrInvalidResponseCode + return + } + r.Result = br.ReadVarBytes(maxResultSize) + if r.Code != Success && len(r.Result) > 0 { + br.Err = ErrInvalidResult + } +} + +// EncodeBinary implements io.Serializable interface. +func (r *OracleResponse) EncodeBinary(w *io.BinWriter) { + w.WriteU64LE(r.ID) + w.WriteB(byte(r.Code)) + w.WriteVarBytes(r.Result) +} + +func (r *OracleResponse) toJSONMap(m map[string]interface{}) { + m["id"] = r.ID + m["code"] = r.Code + m["result"] = r.Result +} diff --git a/pkg/core/transaction/oracle_test.go b/pkg/core/transaction/oracle_test.go new file mode 100644 index 000000000..715f717bc --- /dev/null +++ b/pkg/core/transaction/oracle_test.go @@ -0,0 +1,76 @@ +package transaction + +import ( + "encoding/json" + "errors" + "math/rand" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/internal/testserdes" + "github.com/stretchr/testify/require" +) + +func TestOracleResponse_EncodeBinary(t *testing.T) { + t.Run("Success", func(t *testing.T) { + r := &OracleResponse{ + ID: rand.Uint64(), + Code: Success, + Result: []byte{1, 2, 3, 4, 5}, + } + testserdes.EncodeDecodeBinary(t, r, new(OracleResponse)) + }) + t.Run("ErrorCodes", func(t *testing.T) { + codes := []OracleResponseCode{NotFound, Timeout, Forbidden, Error} + for _, c := range codes { + r := &OracleResponse{ + ID: rand.Uint64(), + Code: c, + Result: []byte{}, + } + testserdes.EncodeDecodeBinary(t, r, new(OracleResponse)) + } + }) + t.Run("Error", func(t *testing.T) { + t.Run("InvalidCode", func(t *testing.T) { + r := &OracleResponse{ + ID: rand.Uint64(), + Code: 0x42, + Result: []byte{}, + } + bs, err := testserdes.EncodeBinary(r) + require.NoError(t, err) + + err = testserdes.DecodeBinary(bs, new(OracleResponse)) + require.True(t, errors.Is(err, ErrInvalidResponseCode), "got: %v", err) + }) + t.Run("InvalidResult", func(t *testing.T) { + r := &OracleResponse{ + ID: rand.Uint64(), + Code: Error, + Result: []byte{1}, + } + bs, err := testserdes.EncodeBinary(r) + require.NoError(t, err) + + err = testserdes.DecodeBinary(bs, new(OracleResponse)) + require.True(t, errors.Is(err, ErrInvalidResult), "got: %v", err) + }) + }) +} + +func TestOracleResponse_toJSONMap(t *testing.T) { + r := &OracleResponse{ + ID: rand.Uint64(), + Code: Success, + Result: []byte{1}, + } + + b1, err := json.Marshal(r) + require.NoError(t, err) + + m := map[string]interface{}{} + r.toJSONMap(m) + b2, err := json.Marshal(m) + require.NoError(t, err) + require.JSONEq(t, string(b1), string(b2)) +} diff --git a/pkg/core/transaction/transaction.go b/pkg/core/transaction/transaction.go index bcf4d2af8..a4be1664b 100644 --- a/pkg/core/transaction/transaction.go +++ b/pkg/core/transaction/transaction.go @@ -381,14 +381,14 @@ func (t *Transaction) isValid() error { } } } - hasHighPrio := false + attrs := map[AttrType]bool{} for i := range t.Attributes { - switch t.Attributes[i].Type { - case HighPriority: - if hasHighPrio { - return fmt.Errorf("%w: multiple high priority attributes", ErrInvalidAttribute) + typ := t.Attributes[i].Type + if !typ.allowMultiple() { + if attrs[typ] { + return fmt.Errorf("%w: multiple '%s' attributes", ErrInvalidAttribute, typ.String()) } - hasHighPrio = true + attrs[typ] = true } } if len(t.Script) == 0 { diff --git a/pkg/core/transaction/transaction_test.go b/pkg/core/transaction/transaction_test.go index 45157ced4..3d3e2381a 100644 --- a/pkg/core/transaction/transaction_test.go +++ b/pkg/core/transaction/transaction_test.go @@ -206,6 +206,14 @@ func TestTransaction_isValid(t *testing.T) { } require.True(t, errors.Is(tx.isValid(), ErrInvalidAttribute)) }) + t.Run("MultipleOracle", func(t *testing.T) { + tx := newTx() + tx.Attributes = []Attribute{ + {Type: OracleResponseT}, + {Type: OracleResponseT}, + } + require.True(t, errors.Is(tx.isValid(), ErrInvalidAttribute)) + }) t.Run("NoScript", func(t *testing.T) { tx := newTx() tx.Script = []byte{}