Skip to content

Commit

Permalink
feat(SPV-1160): new record outline endpoint with actual repository fo…
Browse files Browse the repository at this point in the history
…r op_return txs (#777)
  • Loading branch information
chris-4chain authored Dec 2, 2024
1 parent 18e6389 commit a4547fb
Show file tree
Hide file tree
Showing 27 changed files with 540 additions and 72 deletions.
13 changes: 13 additions & 0 deletions actions/testabilities/fixture_spvwallet_application.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/bitcoin-sv/spv-wallet/config"
chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models"
testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities"
"github.com/bitcoin-sv/spv-wallet/engine/tester"
"github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures"
Expand All @@ -27,6 +28,9 @@ type SPVWalletApplicationFixture interface {

// BHS creates a new test fixture for Block Header Service (BHS)
BHS() BlockHeadersServiceFixture

// ARC creates a new test fixture for ARC
ARC() ARCFixture
}

type BlockHeadersServiceFixture interface {
Expand All @@ -35,6 +39,11 @@ type BlockHeadersServiceFixture interface {
WillRespondForMerkleRoots(httpCode int, response string)
}

type ARCFixture interface {
// WillRespondForBroadcast returns a http response for a broadcast request.
WillRespondForBroadcast(httpCode int, info *chainmodels.TXInfo)
}

type SPVWalletHttpClientFixture interface {
// ForAnonymous returns a new http client that is configured without any authentication.
ForAnonymous() *resty.Client
Expand Down Expand Up @@ -115,3 +124,7 @@ func (f *appFixture) ForGivenUser(user fixtures.User) *resty.Client {
func (f *appFixture) BHS() BlockHeadersServiceFixture {
return f.engineFixture.BHS()
}

func (f *appFixture) ARC() ARCFixture {
return f.engineFixture.ARC()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package annotatedtx

import (
"maps"

"github.com/bitcoin-sv/spv-wallet/engine/transaction"
"github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines"
model "github.com/bitcoin-sv/spv-wallet/models/transaction"
)

// Request represents a request model for recording a transaction outline.
type Request model.AnnotatedTransaction

// ToEngine converts a request model to the engine model.
func (req Request) ToEngine() *outlines.Transaction {
return &outlines.Transaction{
BEEF: req.BEEF,
Annotations: transaction.Annotations{
Outputs: maps.Collect(func(yield func(int, *transaction.OutputAnnotation) bool) {
if req.Annotations == nil || len(req.Annotations.Outputs) == 0 {
return
}
for index, output := range req.Annotations.Outputs {
var paymail *transaction.PaymailAnnotation
if output.Paymail != nil {
paymail = &transaction.PaymailAnnotation{
Receiver: output.Paymail.Receiver,
Reference: output.Paymail.Reference,
Sender: output.Paymail.Sender,
}
}
yield(index, &transaction.OutputAnnotation{
Bucket: output.Bucket,
Paymail: paymail,
})
}
}),
},
}
}
2 changes: 1 addition & 1 deletion actions/transactions/outlines_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures"
)

const transactionsOutlinesURL = "/api/v1/transactions/outlines"
const transactionsOutlinesURL = "/api/v2/transactions/outlines"

func TestPOSTTransactionOutlines(t *testing.T) {
successTestCases := map[string]struct {
Expand Down
28 changes: 28 additions & 0 deletions actions/transactions/outlines_record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package transactions

import (
"github.com/bitcoin-sv/spv-wallet/actions/transactions/internal/mapping/annotatedtx"
"github.com/bitcoin-sv/spv-wallet/engine/spverrors"
"github.com/bitcoin-sv/spv-wallet/server/reqctx"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)

func transactionRecordOutline(c *gin.Context, _ *reqctx.UserContext) {
logger := reqctx.Logger(c)

var requestBody annotatedtx.Request
err := c.ShouldBindWith(&requestBody, binding.JSON)
if err != nil {
spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest.Wrap(err), logger)
return
}

recordService := reqctx.Engine(c).TransactionRecordService()
if err = recordService.RecordTransactionOutline(c, requestBody.ToEngine()); err != nil {
spverrors.ErrorResponse(c, err, logger)
return
}

c.JSON(200, nil)
}
176 changes: 176 additions & 0 deletions actions/transactions/outlines_record_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package transactions_test

import (
"testing"

"github.com/bitcoin-sv/spv-wallet/actions/testabilities"
"github.com/bitcoin-sv/spv-wallet/actions/testabilities/apierror"
chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models"
"github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures"
)

const (
transactionsOutlinesRecordURL = "/api/v2/transactions"
dataOfOpReturnTx = "hello world"
)

func givenTXWithOpReturn(t *testing.T) fixtures.GivenTXSpec {
return fixtures.GivenTX(t).
WithInput(1).
WithOPReturn(dataOfOpReturnTx)
}

func TestOutlinesRecordOpReturn(t *testing.T) {
t.Run("Record op_return data", func(t *testing.T) {
// given:
given, then := testabilities.New(t)
cleanup := given.StartedSPVWallet()
defer cleanup()

// and:
client := given.HttpClient().ForUser()

// and:
txSpec := givenTXWithOpReturn(t)
request := `{
"beef": "` + txSpec.BEEF() + `",
"annotations": {
"outputs": {
"0": {
"bucket": "data"
}
}
}
}`

// and:
given.ARC().WillRespondForBroadcast(200, &chainmodels.TXInfo{
TxID: txSpec.ID(),
TXStatus: chainmodels.SeenOnNetwork,
})

// when:
res, _ := client.R().
SetHeader("Content-Type", "application/json").
SetBody(request).
Post(transactionsOutlinesRecordURL)

// then:
then.Response(res).IsOK()
})
}

func TestOutlinesRecordOpReturnErrorCases(t *testing.T) {
givenUnsignedTX := fixtures.GivenTX(t).
WithoutSigning().
WithInput(1).
WithOPReturn(dataOfOpReturnTx)

givenTxWithP2PKHOutput := fixtures.GivenTX(t).
WithInput(2).
WithP2PKHOutput(1)

tests := map[string]struct {
request string
expectHttpCode int
expectedErr string
}{
"RecordTransactionOutline for not signed transaction": {
request: `{
"beef": "` + givenUnsignedTX.BEEF() + `"
}`,
expectHttpCode: 400,
expectedErr: apierror.ExpectedJSON("error-transaction-validation", "transaction validation failed"),
},
"RecordTransactionOutline for not a BEEF hex": {
request: `{
"beef": "0b3818c665bf28a46""
}`,
expectHttpCode: 400,
expectedErr: apierror.ExpectedJSON("error-bind-body-invalid", "cannot bind request body"),
},
"Vout out index as invalid number": {
request: `{
"beef": "` + givenTXWithOpReturn(t).BEEF() + `"
"annotations": {
"outputs": {
"invalid-number": {
"bucket": "data"
}
}
}
}`,
expectHttpCode: 400,
expectedErr: apierror.ExpectedJSON("error-bind-body-invalid", "cannot bind request body"),
},
"no-op_return output annotated as data": {
request: `{
"beef": "` + givenTxWithP2PKHOutput.BEEF() + `",
"annotations": {
"outputs": {
"0": {
"bucket": "data"
}
}
}
}`,
expectHttpCode: 400,
expectedErr: apierror.ExpectedJSON("error-annotation-mismatch", "annotation mismatch"),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
// given:
given, then := testabilities.New(t)
cleanup := given.StartedSPVWallet()
defer cleanup()

// and:
client := given.HttpClient().ForUser()

// when:
res, _ := client.R().
SetHeader("Content-Type", "application/json").
SetBody(test.request).
Post(transactionsOutlinesRecordURL)

// then:
then.Response(res).HasStatus(test.expectHttpCode).WithJSONf(test.expectedErr)
})
}
}

func TestOutlinesRecordOpReturnOnBroadcastError(t *testing.T) {
// given:
given, then := testabilities.New(t)
cleanup := given.StartedSPVWallet()
defer cleanup()

// and:
client := given.HttpClient().ForUser()

// and:
txSpec := givenTXWithOpReturn(t)
request := `{
"beef": "` + txSpec.BEEF() + `",
"annotations": {
"outputs": {
"0": {
"bucket": "data"
}
}
}
}`

// and:
given.ARC().WillRespondForBroadcast(500, &chainmodels.TXInfo{})

// when:
res, _ := client.R().
SetHeader("Content-Type", "application/json").
SetBody(request).
Post(transactionsOutlinesRecordURL)

// then:
then.Response(res).HasStatus(500).WithJSONf(apierror.ExpectedJSON("error-tx-broadcast", "failed to broadcast transaction"))
}
4 changes: 3 additions & 1 deletion actions/transactions/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ func RegisterRoutes(handlersManager *handlers.Manager) {
group.POST("/drafts", handlers.AsUser(newTransactionDraft))
group.POST("", handlers.AsUser(recordTransaction))

group.POST("/outlines", handlers.AsUser(transactionOutlines))
v2 := handlersManager.Group(handlers.GroupAPIV2, "/transactions")
v2.POST("/outlines", handlers.AsUser(transactionOutlines))
v2.POST("", handlers.AsUser(transactionRecordOutline))

handlersManager.Get(handlers.GroupTransactionCallback).POST(config.BroadcastCallbackRoute, broadcastCallback)
}
8 changes: 7 additions & 1 deletion engine/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/bitcoin-sv/spv-wallet/engine/spverrors"
"github.com/bitcoin-sv/spv-wallet/engine/taskmanager"
"github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines"
"github.com/bitcoin-sv/spv-wallet/engine/transaction/record"
"github.com/bitcoin-sv/spv-wallet/models/bsv"
"github.com/go-resty/resty/v2"
"github.com/mrz1836/go-cachestore"
Expand All @@ -44,7 +45,8 @@ type (
metrics *metrics.Metrics // Metrics with a collector interface
notifications *notificationsOptions // Configuration options for Notifications
paymail *paymailOptions // Paymail options & client
transactionOutlinesService outlines.Service // Service for transaction drafts
transactionOutlinesService outlines.Service // Service for transaction outlines
transactionRecordService *record.Service // Service for recording transactions
paymailAddressService paymailaddress.Service // Service for paymail addresses
taskManager *taskManagerOptions // Configuration options for the TaskManager (TaskQ, etc.)
userAgent string // User agent for all outgoing requests
Expand Down Expand Up @@ -164,6 +166,10 @@ func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error)

client.loadChainService()

if err = client.loadTransactionRecordService(); err != nil {
return nil, err
}

// Register all cron jobs
if err = client.registerCronJobs(); err != nil {
return nil, err
Expand Down
18 changes: 16 additions & 2 deletions engine/client_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"github.com/bitcoin-sv/go-paymail/server"
"github.com/bitcoin-sv/spv-wallet/engine/chain"
"github.com/bitcoin-sv/spv-wallet/engine/cluster"
"github.com/bitcoin-sv/spv-wallet/engine/database/dao"
"github.com/bitcoin-sv/spv-wallet/engine/datastore"
"github.com/bitcoin-sv/spv-wallet/engine/notifications"
paymailclient "github.com/bitcoin-sv/spv-wallet/engine/paymail"
"github.com/bitcoin-sv/spv-wallet/engine/paymailaddress"
"github.com/bitcoin-sv/spv-wallet/engine/spverrors"
"github.com/bitcoin-sv/spv-wallet/engine/taskmanager"
"github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines"
"github.com/bitcoin-sv/spv-wallet/engine/transaction/record"
"github.com/mrz1836/go-cachestore"
)

Expand Down Expand Up @@ -64,14 +66,18 @@ func (c *Client) autoMigrate(ctx context.Context) error {
if c.Datastore() == nil {
return spverrors.Newf("datastore is not loaded")
}
if err := c.Datastore().DB().WithContext(ctx).AutoMigrate(AllDBModels...); err != nil {

db := c.Datastore().DB().WithContext(ctx)
models := AllDBModels()

if err := db.AutoMigrate(models...); err != nil {
return spverrors.Wrapf(err, "failed to auto-migrate models")
}

// Legacy code compatibility:
// Some models implement post-migration logic to e.g. manually add some indexes
// NOTE: In the future, we should remove this and stick to GORM features
for _, model := range AllDBModels {
for _, model := range models {
if migrator, ok := model.(interface {
PostMigrate(client datastore.ClientInterface) error
}); ok {
Expand Down Expand Up @@ -201,6 +207,14 @@ func (c *Client) loadTransactionOutlinesService() error {
return nil
}

func (c *Client) loadTransactionRecordService() error {
if c.options.transactionRecordService == nil {
logger := c.Logger().With().Str("subservice", "transactionRecord").Logger()
c.options.transactionRecordService = record.NewService(logger, dao.NewTransactionsAccessObject(c.Datastore().DB()), c.Chain())
}
return nil
}

func (c *Client) loadChainService() {
if c.options.chainService == nil {
logger := c.Logger().With().Str("subservice", "chain").Logger()
Expand Down
Loading

0 comments on commit a4547fb

Please sign in to comment.