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-1160): new record outline endpoint with actual repository for op_return txs #777

Merged
merged 12 commits into from
Dec 2, 2024
Merged
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
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)
chris-4chain marked this conversation as resolved.
Show resolved Hide resolved
}
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{
dorzepowski marked this conversation as resolved.
Show resolved Hide resolved
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,
dorzepowski marked this conversation as resolved.
Show resolved Hide resolved
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
Loading