Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SPV-000): Suggestions how Outputs are (json) marshalled and unmarshalled #690

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions engine/spverrors/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions engine/transaction/annotation.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
35 changes: 35 additions & 0 deletions engine/transaction/draft/create_draft.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions engine/transaction/draft/create_draft_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
142 changes: 142 additions & 0 deletions engine/transaction/draft/create_op_return_draft_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
65 changes: 65 additions & 0 deletions engine/transaction/draft/outputs/op_return.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading