From cfd828ae9bf2fa006ec14aeb405aacf2de6f5a15 Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Mon, 2 Sep 2024 11:15:54 +0200 Subject: [PATCH 1/2] feat(SPV-1020): create draft transaction handling OP RETURN output; --- engine/spverrors/definitions.go | 5 + engine/transaction/annotation.go | 30 +++ engine/transaction/draft/create_draft.go | 35 +++ engine/transaction/draft/create_draft_test.go | 39 ++++ .../draft/create_op_return_draft_test.go | 142 +++++++++++++ engine/transaction/draft/outputs/op_return.go | 65 ++++++ engine/transaction/draft/outputs/spec.go | 86 ++++++++ engine/transaction/draft/transaction.go | 9 + engine/transaction/draft/transaction_spec.go | 27 +++ engine/transaction/errors/errors.go | 17 ++ go.mod | 1 + go.sum | 2 + mappings/draft/to_engine.go | 65 ++++++ 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 | 201 ++++++++++++++++++ models/request/opreturn/data_type.go | 48 +++++ models/request/opreturn/output.go | 10 + 19 files changed, 964 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..06b6ad8de 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -66,6 +66,11 @@ 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 + +// ErrCannotMapFromModel is when request body model cannot be mapped into domain model. +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..73e6b1b9b --- /dev/null +++ b/engine/transaction/annotation.go @@ -0,0 +1,30 @@ +package transaction + +// Bucket represents the UTXO bucket where the output belongs to. +type Bucket string + +const ( + // BucketData represents the bucket for the data only outputs. + BucketData Bucket = "data" +) + +// Annotations represents a transaction metadata that will be used by server to properly handle given transaction. +type Annotations struct { + Outputs OutputsAnnotations +} + +// OutputAnnotation represents the metadata for the output. +type OutputAnnotation struct { + // What type of bucket should this output be stored in. + Bucket Bucket +} + +// OutputsAnnotations represents the metadata for chosen outputs. The key is the index of the output. +type OutputsAnnotations map[int]*OutputAnnotation + +// NewDataOutputAnnotation constructs a new OutputAnnotation for the data output. +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..08b6ecede --- /dev/null +++ b/engine/transaction/draft/create_draft.go @@ -0,0 +1,35 @@ +package draft + +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +// Create creates a new draft transaction based on specification. +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, spverrors.Wrapf(err, "failed to create draft transaction") + } + + 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..a1c62181b --- /dev/null +++ b/engine/transaction/draft/create_op_return_draft_test.go @@ -0,0 +1,142 @@ +package draft_test + +import ( + "context" + "encoding/hex" + "strings" + "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) { + const maxDataSize = 0xFFFFFFFF + + 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 invalid hex": { + spec: &outputs.OpReturn{ + DataType: opreturn.DataTypeHexes, + Data: []string{"invalid hex"}, + }, + expectedError: "failed to decode hex", + }, + "for unknown data type": { + spec: &outputs.OpReturn{ + DataType: 123, + Data: []string{"Example", " ", "data"}, + }, + expectedError: "unsupported data type", + }, + "for to big string": { + spec: &outputs.OpReturn{ + DataType: opreturn.DataTypeStrings, + Data: []string{strings.Repeat("1", maxDataSize+1)}, + }, + expectedError: "data is too large", + }, + } + 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..092d43cd8 --- /dev/null +++ b/engine/transaction/draft/outputs/op_return.go @@ -0,0 +1,65 @@ +package outputs + +import ( + "context" + "encoding/hex" + "errors" + + "github.com/bitcoin-sv/go-sdk/script" + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" + "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" +) + +// OpReturn represents an OP_RETURN output specification. +type OpReturn opreturn.Output + +func (o *OpReturn) evaluate(context.Context) (annotatedOutputs, error) { + if len(o.Data) == 0 { + return nil, txerrors.ErrDraftOpReturnDataRequired + } + + data, err := o.getData() + if err != nil { + return nil, err + } + output, err := sdk.CreateOpReturnOutput(data) + if err != nil { + if errors.Is(err, script.ErrPartTooBig) { + return nil, txerrors.ErrDraftOpReturnDataTooLarge + } + return nil, spverrors.Wrapf(err, "failed to create OP_RETURN output") + } + + 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..01101573e --- /dev/null +++ b/engine/transaction/draft/outputs/spec.go @@ -0,0 +1,86 @@ +package outputs + +import ( + "context" + + sdk "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/transaction" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +// Specifications are representing a client specification for outputs part of the transaction. +type Specifications struct { + Outputs []Spec +} + +// Spec is a specification for a single output of the transaction. +type Spec interface { + evaluate(ctx context.Context) (annotatedOutputs, error) +} + +// NewSpecifications constructs a new Specifications instance with provided outputs specifications. +func NewSpecifications(outputs ...Spec) *Specifications { + return &Specifications{ + Outputs: outputs, + } +} + +// Add a new output specification to the list of outputs. +func (s *Specifications) Add(output Spec) { + s.Outputs = append(s.Outputs, output) +} + +// Evaluate the outputs specifications and return the transaction outputs and their annotations. +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, spverrors.Wrapf(err, "failed to evaluate output specification %T", spec) + } + 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..0a13a9142 --- /dev/null +++ b/engine/transaction/draft/transaction.go @@ -0,0 +1,9 @@ +package draft + +import "github.com/bitcoin-sv/spv-wallet/engine/transaction" + +// Transaction represents a transaction draft. +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..eeb5ee123 --- /dev/null +++ b/engine/transaction/draft/transaction_spec.go @@ -0,0 +1,27 @@ +package draft + +import ( + "context" + + 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/engine/transaction/draft/outputs" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" +) + +// TransactionSpec represents client provided specification for a transaction draft. +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, spverrors.Wrapf(err, "failed to evaluate outputs") + } + return outs, annotations, nil +} diff --git a/engine/transaction/errors/errors.go b/engine/transaction/errors/errors.go new file mode 100644 index 000000000..e7a805736 --- /dev/null +++ b/engine/transaction/errors/errors.go @@ -0,0 +1,17 @@ +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} + + // ErrDraftOpReturnDataRequired is returned when an OP_RETURN output is created with no data. + ErrDraftOpReturnDataRequired = models.SPVError{Code: "draft-op-return-data-required", Message: "data is required for OP_RETURN output", StatusCode: 400} + + // ErrDraftOpReturnDataTooLarge is returned when OP_RETURN data part is too big to add to transaction. + ErrDraftOpReturnDataTooLarge = models.SPVError{Code: "draft-op-return-data-too-large", Message: "OP_RETURN data is too large", 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..4887520d7 --- /dev/null +++ b/mappings/draft/to_engine.go @@ -0,0 +1,65 @@ +package mappingsdraft + +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" +) + +// ToEngine converts a draft transaction request model to the engine model. +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, spverrors.Wrapf(err, spverrors.ErrCannotMapFromModel.Error()) + } + + return spec, nil +} + +func outputsHookFunc() mapstructure.DecodeHookFunc { + return func(_ 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..02a7aeac8 --- /dev/null +++ b/mappings/draft/to_engine_test.go @@ -0,0 +1,48 @@ +package mappingsdraft_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/draft/outputs" + mappingsdraft "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 := mappingsdraft.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..0ac7c69be --- /dev/null +++ b/models/request/draft_transaction_test.go @@ -0,0 +1,201 @@ +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 + draft *DraftTransaction + }{ + "OP_RETURN output with single string": { + json: `{ + "outputs": [ + { + "type": "op_return", + "dataType": "strings", + "data": [ "hello world" ] + } + ] + }`, + draft: &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" ] + } + ] + }`, + draft: &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" ] + } + ] + }`, + draft: &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" ] + } + ] + }`, + draft: &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" ] + } + ] + }`, + draft: &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.draft, draft) + }) + t.Run("draft to JSON: "+name, func(t *testing.T) { + data, err := json.Marshal(test.draft) + 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 + }{ + "Unsupported output type": { + json: `{ + "outputs": [ + { + "type": "unsupported" + } + ] + }`, + expectedErr: "unsupported output type", + }, + "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) + }) + } +} + +func TestDraftTransactionJSONEncodingErrors(t *testing.T) { + tests := map[string]struct { + draft *DraftTransaction + expectedErr string + }{ + "Unsupported output type": { + draft: &DraftTransaction{ + Outputs: []Output{&unsupportedOutput{}}, + }, + expectedErr: "unsupported output type", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := json.Marshal(test.draft) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} + +type unsupportedOutput struct{} + +func (o *unsupportedOutput) GetType() string { + return "unsupported" +} diff --git a/models/request/opreturn/data_type.go b/models/request/opreturn/data_type.go new file mode 100644 index 000000000..38dd802fd --- /dev/null +++ b/models/request/opreturn/data_type.go @@ -0,0 +1,48 @@ +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 "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" +} From 8ec3b614148199baf257da9e65d14a03dc28e46c Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:52:01 +0200 Subject: [PATCH 2/2] feat(SPV-000): suggestion to json marshalling --- models/request/draft_transaction_json.go | 98 ++++++++++++++++++------ 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/models/request/draft_transaction_json.go b/models/request/draft_transaction_json.go index e045d94d9..5d05806af 100644 --- a/models/request/draft_transaction_json.go +++ b/models/request/draft_transaction_json.go @@ -3,40 +3,92 @@ package request import ( "encoding/json" "errors" + "reflect" + "strings" "github.com/bitcoin-sv/spv-wallet/models/request/opreturn" ) +func MakeOutputParser[T Output]() OutputParser { + return OutputParser{ + Unmarshal: func(raw json.RawMessage) (Output, error) { return unmarshall[T](raw) }, + } +} + +var supportedOutputTypes = map[string]OutputParser{ + "op_return": MakeOutputParser[opreturn.Output](), +} + +// IsOutputSupported checks by type name if output is supported +func IsOutputSupported(typeName string) bool { + _, ok := supportedOutputTypes[typeName] + return ok +} + +func unmarshall[T Output](raw json.RawMessage) (Output, error) { + var desiredType T + if err := json.Unmarshal(raw, &desiredType); err != nil { + return nil, err //nolint:wrapcheck // TODO it later + } + return desiredType, nil +} + +// OutputParser defines supported outputs for json unmarshall +type OutputParser struct { + Unmarshal func(json.RawMessage) (Output, error) +} + // 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: + parser, ok := supportedOutputTypes[outputType] + if !ok { return nil, errors.New("unsupported output type") } + return parser.Unmarshal(rawOutput) } -// 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: +func expandOutputForMarshaling(output Output) (map[string]any, error) { + if !IsOutputSupported(output.GetType()) { return nil, errors.New("unsupported output type") } + result := map[string]any{ + "type": output.GetType(), + } + + v := reflect.ValueOf(output) + t := reflect.TypeOf(output) + + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + value := v.Field(i) + + fieldName, omit := keyNameFromJSONTag(field, value) + if omit { + continue + } + + result[fieldName] = value.Interface() + } + + return result, nil +} + +func isZeroOfUnderlyingType(rValue reflect.Value) bool { + return rValue.Interface() == reflect.Zero(rValue.Type()).Interface() +} + +func keyNameFromJSONTag(field reflect.StructField, value reflect.Value) (name string, omit bool) { + tag := field.Tag.Get("json") + tagParts := strings.Split(tag, ",") + + name = tagParts[0] + omitEmpty := len(tagParts) > 1 && tagParts[1] == "omitempty" + + if name == "-" || name == "" { + name = field.Name + } + + omit = omitEmpty && isZeroOfUnderlyingType(value) + return }