Skip to content

Commit

Permalink
feat(SPV-1020): create draft transaction handling OP RETURN output;
Browse files Browse the repository at this point in the history
  • Loading branch information
dorzepowski committed Sep 5, 2024
1 parent a134b1a commit 5800184
Show file tree
Hide file tree
Showing 19 changed files with 876 additions and 0 deletions.
3 changes: 3 additions & 0 deletions engine/spverrors/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check failure on line 69 in engine/spverrors/definitions.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: comment on exported var ErrCannotMapFromModel should be of the form "ErrCannotMapFromModel ..." (revive)
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
25 changes: 25 additions & 0 deletions engine/transaction/annotation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package transaction

type Bucket string

Check failure on line 3 in engine/transaction/annotation.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type Bucket should have comment or be unexported (revive)

const (
BucketData Bucket = "data"

Check failure on line 6 in engine/transaction/annotation.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported const BucketData should have comment (or a comment on this block) or be unexported (revive)
)

// Annotations represents a transaction metadata that will be used by server to properly handle given transaction.
type Annotations struct {
Outputs OutputsAnnotations
}

type OutputAnnotation struct {

Check failure on line 14 in engine/transaction/annotation.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type OutputAnnotation should have comment or be unexported (revive)
// What type of bucket should this output be stored in.
Bucket Bucket
}

type OutputsAnnotations map[int]*OutputAnnotation

Check failure on line 19 in engine/transaction/annotation.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type OutputsAnnotations should have comment or be unexported (revive)

func NewDataOutputAnnotation() *OutputAnnotation {

Check failure on line 21 in engine/transaction/annotation.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported function NewDataOutputAnnotation should have comment or be unexported (revive)
return &OutputAnnotation{
Bucket: BucketData,
}
}
33 changes: 33 additions & 0 deletions engine/transaction/draft/create_draft.go
Original file line number Diff line number Diff line change
@@ -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
}
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)
})
}
}
118 changes: 118 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,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))
}
60 changes: 60 additions & 0 deletions engine/transaction/draft/outputs/op_return.go
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 15 in engine/transaction/draft/outputs/op_return.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type OpReturn should have comment or be unexported (revive)

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")
}
}
80 changes: 80 additions & 0 deletions engine/transaction/draft/outputs/spec.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure on line 11 in engine/transaction/draft/outputs/spec.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type Specifications should have comment or be unexported (revive)
Outputs []Spec
}

type Spec interface {

Check failure on line 15 in engine/transaction/draft/outputs/spec.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported type Spec should have comment or be unexported (revive)
evaluate(ctx context.Context) (annotatedOutputs, error)
}

func NewSpecifications(outputs ...Spec) *Specifications {

Check failure on line 19 in engine/transaction/draft/outputs/spec.go

View workflow job for this annotation

GitHub Actions / error-lint

exported: exported function NewSpecifications should have comment or be unexported (revive)
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
}
8 changes: 8 additions & 0 deletions engine/transaction/draft/transaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package draft

import "github.com/bitcoin-sv/spv-wallet/engine/transaction"

type Transaction struct {
BEEF string
Annotations *transaction.Annotations
}
Loading

0 comments on commit 5800184

Please sign in to comment.