From 58001844cc721695ed6e28e6b5c66593e00b2548 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Mon, 2 Sep 2024 11:15:54 +0200 Subject: [PATCH] feat(SPV-1020): create draft transaction handling OP RETURN output; --- engine/spverrors/definitions.go | 3 + engine/transaction/annotation.go | 25 +++ engine/transaction/draft/create_draft.go | 33 ++++ engine/transaction/draft/create_draft_test.go | 39 +++++ .../draft/create_op_return_draft_test.go | 118 +++++++++++++ engine/transaction/draft/outputs/op_return.go | 60 +++++++ engine/transaction/draft/outputs/spec.go | 80 +++++++++ engine/transaction/draft/transaction.go | 8 + engine/transaction/draft/transaction_spec.go | 25 +++ engine/transaction/errors/errors.go | 11 ++ go.mod | 1 + go.sum | 2 + mappings/draft/to_engine.go | 64 +++++++ mappings/draft/to_engine_test.go | 48 +++++ models/request/draft_transaction.go | 92 ++++++++++ models/request/draft_transaction_json.go | 42 +++++ models/request/draft_transaction_test.go | 165 ++++++++++++++++++ models/request/opreturn/data_type.go | 50 ++++++ models/request/opreturn/output.go | 10 ++ 19 files changed, 876 insertions(+) create mode 100644 engine/transaction/annotation.go create mode 100644 engine/transaction/draft/create_draft.go create mode 100644 engine/transaction/draft/create_draft_test.go create mode 100644 engine/transaction/draft/create_op_return_draft_test.go create mode 100644 engine/transaction/draft/outputs/op_return.go create mode 100644 engine/transaction/draft/outputs/spec.go create mode 100644 engine/transaction/draft/transaction.go create mode 100644 engine/transaction/draft/transaction_spec.go create mode 100644 engine/transaction/errors/errors.go create mode 100644 mappings/draft/to_engine.go create mode 100644 mappings/draft/to_engine_test.go create mode 100644 models/request/draft_transaction.go create mode 100644 models/request/draft_transaction_json.go create mode 100644 models/request/draft_transaction_test.go create mode 100644 models/request/opreturn/data_type.go create mode 100644 models/request/opreturn/output.go diff --git a/engine/spverrors/definitions.go b/engine/spverrors/definitions.go index 1dc0a9a53..45916c9c7 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -66,6 +66,9 @@ var ErrCannotParseQueryParams = models.SPVError{Message: "cannot parse request q // ErrInvalidConditions is when request has invalid conditions var ErrInvalidConditions = models.SPVError{Message: "invalid conditions", StatusCode: 400, Code: "error-bind-conditions-invalid"} +// ////////////////////////////////// MAPPING ERRORS +var ErrCannotMapFromModel = models.SPVError{Message: "error during reading request body", StatusCode: 500, Code: "error-request-read"} + // ////////////////////////////////// ACCESS KEY ERRORS // ErrCouldNotFindAccessKey is when could not find xpub diff --git a/engine/transaction/annotation.go b/engine/transaction/annotation.go new file mode 100644 index 000000000..6876d65c1 --- /dev/null +++ b/engine/transaction/annotation.go @@ -0,0 +1,25 @@ +package transaction + +type Bucket string + +const ( + BucketData Bucket = "data" +) + +// Annotations represents a transaction metadata that will be used by server to properly handle given transaction. +type Annotations struct { + Outputs OutputsAnnotations +} + +type OutputAnnotation struct { + // What type of bucket should this output be stored in. + Bucket Bucket +} + +type OutputsAnnotations map[int]*OutputAnnotation + +func NewDataOutputAnnotation() *OutputAnnotation { + return &OutputAnnotation{ + Bucket: BucketData, + } +} diff --git a/engine/transaction/draft/create_draft.go b/engine/transaction/draft/create_draft.go new file mode 100644 index 000000000..bf7561139 --- /dev/null +++ b/engine/transaction/draft/create_draft.go @@ -0,0 +1,33 @@ +package draft + +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +func Create(ctx context.Context, spec *TransactionSpec) (*Transaction, error) { + tx := &sdk.Transaction{} + if spec == nil { + return nil, txerrors.ErrDraftSpecificationRequired + } + outputs, annotations, err := spec.outputs(ctx) + if err != nil { + return nil, err + } + tx.Outputs = outputs + + beef, err := tx.BEEFHex() + if err != nil { + return nil, err + } + + return &Transaction{ + BEEF: beef, + Annotations: &transaction.Annotations{ + Outputs: annotations, + }, + }, nil +} diff --git a/engine/transaction/draft/create_draft_test.go b/engine/transaction/draft/create_draft_test.go new file mode 100644 index 000000000..a1e98b41c --- /dev/null +++ b/engine/transaction/draft/create_draft_test.go @@ -0,0 +1,39 @@ +package draft_test + +import ( + "context" + "testing" + + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + "github.com/stretchr/testify/require" +) + +func TestCreateTransactionDraftError(t *testing.T) { + errorTests := map[string]struct { + spec *draft.TransactionSpec + expectedError string + }{ + "for nil as transaction spec": { + spec: nil, + expectedError: "draft requires a specification", + }, + "for no outputs in transaction spec": { + spec: &draft.TransactionSpec{}, + expectedError: "draft requires at least one output", + }, + "for empty output list in transaction spec": { + spec: &draft.TransactionSpec{Outputs: outputs.NewSpecifications()}, + }, + } + for name, test := range errorTests { + t.Run("return error "+name, func(t *testing.T) { + // when: + _, err := draft.Create(context.Background(), test.spec) + + // then: + require.Error(t, err) + require.ErrorContains(t, err, test.expectedError) + }) + } +} diff --git a/engine/transaction/draft/create_op_return_draft_test.go b/engine/transaction/draft/create_op_return_draft_test.go new file mode 100644 index 000000000..159f082b3 --- /dev/null +++ b/engine/transaction/draft/create_op_return_draft_test.go @@ -0,0 +1,118 @@ +package draft_test + +import ( + "context" + "encoding/hex" + "testing" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" + "github.com/stretchr/testify/require" +) + +func TestCreateOpReturnDraft(t *testing.T) { + successTests := map[string]struct { + opReturn *outputs.OpReturn + lockingScript string + }{ + "for single string": { + opReturn: &outputs.OpReturn{ + DataType: opreturn.DataTypeStrings, + Data: []string{"Example data"}, + }, + lockingScript: "006a0c4578616d706c652064617461", + }, + "for multiple strings": { + opReturn: &outputs.OpReturn{ + DataType: opreturn.DataTypeStrings, + Data: []string{"Example", " ", "data"}, + }, + lockingScript: "006a074578616d706c6501200464617461", + }, + "for single hex": { + opReturn: &outputs.OpReturn{ + DataType: opreturn.DataTypeHexes, + Data: []string{toHex("Example data")}, + }, + lockingScript: "006a0c4578616d706c652064617461", + }, + "for multiple hexes": { + opReturn: &outputs.OpReturn{ + DataType: opreturn.DataTypeHexes, + Data: []string{toHex("Example"), toHex(" "), toHex("data")}, + }, + lockingScript: "006a074578616d706c6501200464617461", + }, + } + for name, test := range successTests { + t.Run("return draft"+name, func(t *testing.T) { + // given: + spec := &draft.TransactionSpec{ + Outputs: outputs.NewSpecifications(test.opReturn), + } + + // when: + draftTx, err := draft.Create(context.Background(), spec) + + // then: + require.NoError(t, err) + require.NotNil(t, draftTx) + + // and: + annotations := draftTx.Annotations + require.Len(t, annotations.Outputs, 1) + require.Equal(t, transaction.BucketData, annotations.Outputs[0].Bucket) + + // debug: + t.Logf("BEEF: %s", draftTx.BEEF) + + // when: + tx, err := sdk.NewTransactionFromBEEFHex(draftTx.BEEF) + + // then: + require.NoError(t, err) + require.Len(t, tx.Outputs, 1) + require.EqualValues(t, tx.Outputs[0].Satoshis, 0) + require.Equal(t, tx.Outputs[0].LockingScript.IsData(), true) + require.Equal(t, test.lockingScript, tx.Outputs[0].LockingScriptHex()) + }) + } + + errorTests := map[string]struct { + spec *outputs.OpReturn + expectedError string + }{ + "for no data in default type": { + spec: &outputs.OpReturn{}, + expectedError: "data is required for OP_RETURN output", + }, + "for no data string type": { + spec: &outputs.OpReturn{ + DataType: opreturn.DataTypeStrings, + }, + expectedError: "data is required for OP_RETURN output", + }, + } + for name, test := range errorTests { + t.Run("return error "+name, func(t *testing.T) { + // given: + spec := &draft.TransactionSpec{ + Outputs: outputs.NewSpecifications(test.spec), + } + + // when: + _, err := draft.Create(context.Background(), spec) + + // then: + require.Error(t, err) + require.ErrorContains(t, err, test.expectedError) + }) + } +} + +func toHex(data string) string { + return hex.EncodeToString([]byte(data)) +} diff --git a/engine/transaction/draft/outputs/op_return.go b/engine/transaction/draft/outputs/op_return.go new file mode 100644 index 000000000..6a50ddeb9 --- /dev/null +++ b/engine/transaction/draft/outputs/op_return.go @@ -0,0 +1,60 @@ +package outputs + +import ( + "context" + "encoding/hex" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" +) + +const opReturnDataRequiredError = "data is required for OP_RETURN output" + +type OpReturn opreturn.Output + +func (o *OpReturn) evaluate(context.Context) (annotatedOutputs, error) { + if len(o.Data) == 0 { + return nil, spverrors.Newf(opReturnDataRequiredError) + } + + data, err := o.getData() + if err != nil { + return nil, err + } + output, err := sdk.CreateOpReturnOutput(data) + if err != nil { + return nil, err + } + + annotation := transaction.NewDataOutputAnnotation() + return singleAnnotatedOutput(output, annotation), nil +} + +func (o *OpReturn) getData() ([][]byte, error) { + data := make([][]byte, 0) + for _, dataToStore := range o.Data { + bytes, err := toBytes(dataToStore, o.DataType) + if err != nil { + return nil, err + } + data = append(data, bytes) + } + return data, nil +} + +func toBytes(data string, dataType opreturn.DataType) ([]byte, error) { + switch dataType { + case opreturn.DataTypeDefault, opreturn.DataTypeStrings: + return []byte(data), nil + case opreturn.DataTypeHexes: + dataHex, err := hex.DecodeString(data) + if err != nil { + return nil, spverrors.Wrapf(err, "failed to decode hex") + } + return dataHex, nil + default: + return nil, spverrors.Newf("unsupported data type") + } +} diff --git a/engine/transaction/draft/outputs/spec.go b/engine/transaction/draft/outputs/spec.go new file mode 100644 index 000000000..c1fce254b --- /dev/null +++ b/engine/transaction/draft/outputs/spec.go @@ -0,0 +1,80 @@ +package outputs + +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +type Specifications struct { + Outputs []Spec +} + +type Spec interface { + evaluate(ctx context.Context) (annotatedOutputs, error) +} + +func NewSpecifications(outputs ...Spec) *Specifications { + return &Specifications{ + Outputs: outputs, + } +} + +func (s *Specifications) Add(output Spec) { + s.Outputs = append(s.Outputs, output) +} + +func (s *Specifications) Evaluate(ctx context.Context) ([]*sdk.TransactionOutput, transaction.OutputsAnnotations, error) { + if s.Outputs == nil { + return nil, nil, txerrors.ErrDraftRequiresAtLeastOneOutput + } + outputs, err := s.evaluate(ctx) + if err != nil { + return nil, nil, err + } + + txOutputs, annotations := outputs.splitIntoTransactionOutputsAndAnnotations() + return txOutputs, annotations, nil +} + +func (s *Specifications) evaluate(ctx context.Context) (annotatedOutputs, error) { + outputs := make(annotatedOutputs, 0) + for _, spec := range s.Outputs { + outs, err := spec.evaluate(ctx) + if err != nil { + return nil, err + } + outputs = append(outputs, outs...) + } + return outputs, nil +} + +type annotatedOutput struct { + *transaction.OutputAnnotation + *sdk.TransactionOutput +} + +type annotatedOutputs []*annotatedOutput + +func singleAnnotatedOutput(txOut *sdk.TransactionOutput, out *transaction.OutputAnnotation) annotatedOutputs { + return annotatedOutputs{ + &annotatedOutput{ + OutputAnnotation: out, + TransactionOutput: txOut, + }, + } +} + +func (a annotatedOutputs) splitIntoTransactionOutputsAndAnnotations() ([]*sdk.TransactionOutput, transaction.OutputsAnnotations) { + outputs := make([]*sdk.TransactionOutput, len(a)) + annotations := make(transaction.OutputsAnnotations) + for i, out := range a { + outputs[i] = out.TransactionOutput + if out.OutputAnnotation != nil { + annotations[i] = out.OutputAnnotation + } + } + return outputs, annotations +} diff --git a/engine/transaction/draft/transaction.go b/engine/transaction/draft/transaction.go new file mode 100644 index 000000000..d18e22495 --- /dev/null +++ b/engine/transaction/draft/transaction.go @@ -0,0 +1,8 @@ +package draft + +import "github.com/bitcoin-sv/spv-wallet/engine/transaction" + +type Transaction struct { + BEEF string + Annotations *transaction.Annotations +} diff --git a/engine/transaction/draft/transaction_spec.go b/engine/transaction/draft/transaction_spec.go new file mode 100644 index 000000000..d09525c24 --- /dev/null +++ b/engine/transaction/draft/transaction_spec.go @@ -0,0 +1,25 @@ +package draft + +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +type TransactionSpec struct { + Outputs *outputs.Specifications +} + +func (t *TransactionSpec) outputs(ctx context.Context) ([]*sdk.TransactionOutput, transaction.OutputsAnnotations, error) { + if t.Outputs == nil { + return nil, nil, txerrors.ErrDraftRequiresAtLeastOneOutput + } + outs, annotations, err := t.Outputs.Evaluate(ctx) + if err != nil { + return nil, nil, err + } + return outs, annotations, nil +} diff --git a/engine/transaction/errors/errors.go b/engine/transaction/errors/errors.go new file mode 100644 index 000000000..5883628a4 --- /dev/null +++ b/engine/transaction/errors/errors.go @@ -0,0 +1,11 @@ +package txerrors + +import "github.com/bitcoin-sv/spv-wallet/models" + +var ( + // ErrDraftSpecificationRequired is returned when a draft is created with no specification. + ErrDraftSpecificationRequired = models.SPVError{Code: "draft-spec-required", Message: "draft requires a specification", StatusCode: 400} + + // ErrDraftRequiresAtLeastOneOutput is returned when a draft is created with no outputs. + ErrDraftRequiresAtLeastOneOutput = models.SPVError{Code: "draft-output-required", Message: "draft requires at least one output", StatusCode: 400} +) diff --git a/go.mod b/go.mod index 245b43bc5..5d323646f 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( ) require ( + github.com/bitcoin-sv/go-sdk v1.1.1 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect diff --git a/go.sum b/go.sum index 510edcbc7..f3c84065a 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/bitcoin-sv/go-broadcast-client v0.20.0 h1:O9W8nesYBz/7ta/nVrW9C2KqKc4 github.com/bitcoin-sv/go-broadcast-client v0.20.0/go.mod h1:qyyjqXvXyIKJPNB2UZH+RzC004F/gu9o/j6E++FqUyA= github.com/bitcoin-sv/go-paymail v0.20.0 h1:quJj9qEK7+oRB+IMCVbXPdxP9/CKk9Y+/Nn87jLenBs= github.com/bitcoin-sv/go-paymail v0.20.0/go.mod h1:dYhHGsCKpTYCZvxFGwB+ENo3/5aScI4edzzPYpnvBmg= +github.com/bitcoin-sv/go-sdk v1.1.1 h1:GtpElKJyMe13W9rmY5kbxjZT+FUZSe4U+wREbrRY3+g= +github.com/bitcoin-sv/go-sdk v1.1.1/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4= github.com/bitcoinschema/go-bitcoin/v2 v2.0.5 h1:Sgh5Eb746Zck/46rFDrZZEXZWyO53fMuWYhNoZa1tck= github.com/bitcoinschema/go-bitcoin/v2 v2.0.5/go.mod h1:JjO1ivfZv6vhK0uAXzyH08AAHlzNMAfnyK1Fiv9r4ZA= github.com/bitcoinschema/go-bob v0.4.3 h1:0iboiIQ3PY2+rrqPr8Gsh5RX+9Ha6Uzyo0bw720Ljlc= diff --git a/mappings/draft/to_engine.go b/mappings/draft/to_engine.go new file mode 100644 index 000000000..7601e1813 --- /dev/null +++ b/mappings/draft/to_engine.go @@ -0,0 +1,64 @@ +package mappings_draft + +import ( + "errors" + "reflect" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + "github.com/bitcoin-sv/spv-wallet/models/request" + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" + "github.com/mitchellh/mapstructure" +) + +func ToEngine(tx *request.DraftTransaction) (*draft.TransactionSpec, error) { + spec := &draft.TransactionSpec{} + config := mapstructure.DecoderConfig{ + DecodeHook: outputsHookFunc(), + Result: &spec, + } + decoder, err := mapstructure.NewDecoder(&config) + if err != nil { + return nil, spverrors.Wrapf(err, spverrors.ErrCannotMapFromModel.Error()) + } + + err = decoder.Decode(tx) + if err != nil { + return nil, err + } + + return spec, nil +} + +func outputsHookFunc() mapstructure.DecodeHookFunc { + return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { + specs := outputs.NewSpecifications() + reqOutputs, ok := data.([]request.Output) + if !ok { + return data, nil + } + if to != reflect.TypeOf(specs) { + return data, nil + } + + for _, out := range reqOutputs { + spec, err := outputSpecFromRequest(out) + if err != nil { + return nil, err + } + specs.Add(spec) + } + return specs, nil + } +} + +func outputSpecFromRequest(req request.Output) (outputs.Spec, error) { + switch o := req.(type) { + case *opreturn.Output: + opReturn := outputs.OpReturn(*o) + return &opReturn, nil + default: + return nil, errors.New("unsupported output type") + } +} diff --git a/mappings/draft/to_engine_test.go b/mappings/draft/to_engine_test.go new file mode 100644 index 000000000..037e9f8e3 --- /dev/null +++ b/mappings/draft/to_engine_test.go @@ -0,0 +1,48 @@ +package mappings_draft_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + mappings_draft "github.com/bitcoin-sv/spv-wallet/mappings/draft" + "github.com/bitcoin-sv/spv-wallet/models/request" + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" + "github.com/stretchr/testify/require" +) + +func TestMapToEngine(t *testing.T) { + tests := map[string]struct { + req *request.DraftTransaction + expected *draft.TransactionSpec + }{ + "map op_return string output": { + req: &request.DraftTransaction{ + Outputs: []request.Output{ + &opreturn.Output{ + DataType: opreturn.DataTypeStrings, + Data: []string{"Example data"}, + }, + }, + }, + expected: &draft.TransactionSpec{ + Outputs: outputs.NewSpecifications( + &outputs.OpReturn{ + DataType: opreturn.DataTypeStrings, + Data: []string{"Example data"}, + }, + ), + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // when: + result, err := mappings_draft.ToEngine(test.req) + + // then: + require.NoError(t, err) + require.Equal(t, test.expected, result) + }) + } +} diff --git a/models/request/draft_transaction.go b/models/request/draft_transaction.go new file mode 100644 index 000000000..0e850dbe0 --- /dev/null +++ b/models/request/draft_transaction.go @@ -0,0 +1,92 @@ +package request + +import ( + "encoding/json" +) + +// DraftTransaction represents a request with specification for making a draft transaction. +type DraftTransaction struct { + Outputs []Output `json:"-"` +} + +// Output represents an output in a draft transaction request. +type Output interface { + GetType() string +} + +// UnmarshalJSON custom unmarshall logic for DraftTransaction +func (dt *DraftTransaction) UnmarshalJSON(data []byte) error { + rawOutputs, err := dt.unmarshalPartials(data) + if err != nil { + return err + } + + // Unmarshal each output based on the type field. + outputs, err := unmarshalOutputs(rawOutputs) + if err != nil { + return err + } + dt.Outputs = outputs + + return nil +} + +// unmarshalPartials unmarshalls the data into the DraftTransaction +// and returns also raw parts that couldn't be unmarshalled out of the box. +func (dt *DraftTransaction) unmarshalPartials(data []byte) (rawOutputs []json.RawMessage, err error) { + // Define a temporary struct to unmarshal the struct without unmarshalling outputs. + // We're defining it here, to not publish Alias type. + type Alias DraftTransaction + temp := &struct { + Outputs []json.RawMessage `json:"outputs"` + *Alias + }{ + Alias: (*Alias)(dt), + } + + if err := json.Unmarshal(data, &temp); err != nil { + return nil, err + } + + return temp.Outputs, nil +} + +func unmarshalOutputs(outputs []json.RawMessage) ([]Output, error) { + result := make([]Output, len(outputs)) + for i, rawOutput := range outputs { + var typeField struct { + Type string `json:"type"` + } + if err := json.Unmarshal(rawOutput, &typeField); err != nil { + return nil, err + } + + output, err := unmarshalOutput(rawOutput, typeField.Type) + if err != nil { + return nil, err + } + result[i] = output + } + return result, nil +} + +// MarshalJSON custom marshaller for DraftTransaction +func (dt *DraftTransaction) MarshalJSON() ([]byte, error) { + type Alias DraftTransaction + temp := &struct { + Outputs []interface{} `json:"outputs"` + *Alias + }{ + Alias: (*Alias)(dt), + } + + for _, output := range dt.Outputs { + out, err := expandOutputForMarshaling(output) + if err != nil { + return nil, err + } + temp.Outputs = append(temp.Outputs, out) + } + + return json.Marshal(temp) +} diff --git a/models/request/draft_transaction_json.go b/models/request/draft_transaction_json.go new file mode 100644 index 000000000..e045d94d9 --- /dev/null +++ b/models/request/draft_transaction_json.go @@ -0,0 +1,42 @@ +package request + +import ( + "encoding/json" + "errors" + + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" +) + +// unmarshalOutput used by DraftTransaction unmarshalling to get Output object by type +// IMPORTANT: Every time a new output type is added, it must be handled here also. +func unmarshalOutput(rawOutput json.RawMessage, outputType string) (Output, error) { + switch outputType { + case "op_return": + var opReturnOutput opreturn.Output + if err := json.Unmarshal(rawOutput, &opReturnOutput); err != nil { + return nil, err + } + return opReturnOutput, nil + default: + return nil, errors.New("unsupported output type") + } +} + +// expandOutputForMarshaling used by DraftTransaction marshalling to expand Output object before marshalling. +// IMPORTANT: Every time a new output type is added, it must be handled here also. +func expandOutputForMarshaling(output Output) (any, error) { + switch o := output.(type) { + // unfortunately we must do the same for each and every type, + // because go json is not handling unwrapping embedded type when using just Output interface. + case opreturn.Output: + return struct { + Type string `json:"type"` + *opreturn.Output + }{ + Type: o.GetType(), + Output: &o, + }, nil + default: + return nil, errors.New("unsupported output type") + } +} diff --git a/models/request/draft_transaction_test.go b/models/request/draft_transaction_test.go new file mode 100644 index 000000000..35a4ffb41 --- /dev/null +++ b/models/request/draft_transaction_test.go @@ -0,0 +1,165 @@ +package request + +import ( + "encoding/hex" + "encoding/json" + "testing" + + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" + "github.com/stretchr/testify/require" +) + +func TestDraftTransactionJSON(t *testing.T) { + tests := map[string]struct { + json string + expected *DraftTransaction + }{ + "OP_RETURN output with single string": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "strings", + "data": [ "hello world" ] + } + ] + }`, + expected: &DraftTransaction{ + Outputs: []Output{ + opreturn.Output{ + DataType: opreturn.DataTypeStrings, + Data: []string{"hello world"}, + }, + }, + }, + }, + "OP_RETURN output with multiple strings": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "strings", + "data": [ "hello", "world" ] + } + ] + }`, + expected: &DraftTransaction{ + Outputs: []Output{ + opreturn.Output{ + DataType: opreturn.DataTypeStrings, + Data: []string{"hello", "world"}, + }, + }, + }, + }, + "OP_RETURN output with default data type": { + json: `{ + "outputs": [ + { + "type": "op_return", + "data": [ "hello world" ] + } + ] + }`, + expected: &DraftTransaction{ + Outputs: []Output{ + opreturn.Output{ + DataType: opreturn.DataTypeDefault, + Data: []string{"hello world"}, + }, + }, + }, + }, + "OP_RETURN output with hex": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "hexes", + "data": [ "68656c6c6f20776f726c64" ] + } + ] + }`, + expected: &DraftTransaction{ + Outputs: []Output{ + opreturn.Output{ + DataType: opreturn.DataTypeHexes, + Data: []string{hex.EncodeToString([]byte("hello world"))}, + }, + }, + }, + }, + "OP_RETURN output with multiple hex": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "hexes", + "data": [ "68656c6c6f", "20776f726c64" ] + } + ] + }`, + expected: &DraftTransaction{ + Outputs: []Output{ + opreturn.Output{ + DataType: opreturn.DataTypeHexes, + Data: []string{hex.EncodeToString([]byte("hello")), hex.EncodeToString([]byte(" world"))}, + }, + }, + }, + }, + } + for name, test := range tests { + t.Run("draft from JSON: "+name, func(t *testing.T) { + var draft *DraftTransaction + err := json.Unmarshal([]byte(test.json), &draft) + require.NoError(t, err) + require.Equal(t, test.expected, draft) + }) + t.Run("draft to JSON: "+name, func(t *testing.T) { + data, err := json.Marshal(test.expected) + require.NoError(t, err) + jsonValue := string(data) + require.JSONEq(t, test.json, jsonValue) + }) + } +} + +func TestDraftTransactionJSONParsingErrors(t *testing.T) { + tests := map[string]struct { + json string + expectedErr string + }{ + "OP_RETURN output with unknown data type": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "unknown", + "data": [ "hello world" ] + } + ] + }`, + expectedErr: "invalid data type", + }, + "OP_RETURN output with string instead of array as data": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "strings", + "data": "hello world" + } + ] + }`, + expectedErr: "json: cannot unmarshal", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var draft *DraftTransaction + err := json.Unmarshal([]byte(test.json), &draft) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} diff --git a/models/request/opreturn/data_type.go b/models/request/opreturn/data_type.go new file mode 100644 index 000000000..87c9600b2 --- /dev/null +++ b/models/request/opreturn/data_type.go @@ -0,0 +1,50 @@ +package opreturn + +import ( + "encoding/json" + "errors" +) + +type DataType int + +const ( + DataTypeDefault DataType = iota + DataTypeStrings + DataTypeHexes +) + +// UnmarshalJSON custom unmarshaler for DataTypeEnum +func (d *DataType) UnmarshalJSON(data []byte) error { + var dataType string + if err := json.Unmarshal(data, &dataType); err != nil { + return err + } + + switch dataType { + case "": + *d = DataTypeDefault + case "strings": + *d = DataTypeStrings + case "hexes": + *d = DataTypeHexes + default: + return errors.New("invalid data type") + } + return nil +} + +// MarshalJSON custom marshaler for DataType Enum +func (d DataType) MarshalJSON() ([]byte, error) { + var dataType string + switch d { + case DataTypeDefault: + dataType = "" + case DataTypeStrings: + dataType = "strings" + case DataTypeHexes: + dataType = "hexes" + default: + return nil, errors.New("invalid data type") + } + return json.Marshal(dataType) +} diff --git a/models/request/opreturn/output.go b/models/request/opreturn/output.go new file mode 100644 index 000000000..0402c48e8 --- /dev/null +++ b/models/request/opreturn/output.go @@ -0,0 +1,10 @@ +package opreturn + +type Output struct { + DataType DataType `json:"dataType,omitempty"` + Data []string `json:"data"` +} + +func (o Output) GetType() string { + return "op_return" +}