diff --git a/actions/testabilities/fixture_spvwallet_application.go b/actions/testabilities/fixture_spvwallet_application.go index 742073297..82ecce4e2 100644 --- a/actions/testabilities/fixture_spvwallet_application.go +++ b/actions/testabilities/fixture_spvwallet_application.go @@ -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" @@ -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 { @@ -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 @@ -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() +} diff --git a/actions/transactions/internal/mapping/annotatedtx/annotated_tx_request_to_engine.go b/actions/transactions/internal/mapping/annotatedtx/annotated_tx_request_to_engine.go new file mode 100644 index 000000000..91b641df8 --- /dev/null +++ b/actions/transactions/internal/mapping/annotatedtx/annotated_tx_request_to_engine.go @@ -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, + }) + } + }), + }, + } +} diff --git a/actions/transactions/outlines_endpoint_test.go b/actions/transactions/outlines_endpoint_test.go index 4ee558510..2b78de6c5 100644 --- a/actions/transactions/outlines_endpoint_test.go +++ b/actions/transactions/outlines_endpoint_test.go @@ -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 { diff --git a/actions/transactions/outlines_record.go b/actions/transactions/outlines_record.go new file mode 100644 index 000000000..442e095dc --- /dev/null +++ b/actions/transactions/outlines_record.go @@ -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) +} diff --git a/actions/transactions/outlines_record_test.go b/actions/transactions/outlines_record_test.go new file mode 100644 index 000000000..a1ddaf0de --- /dev/null +++ b/actions/transactions/outlines_record_test.go @@ -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")) +} diff --git a/actions/transactions/routes.go b/actions/transactions/routes.go index f90fc3ab2..667b24f95 100644 --- a/actions/transactions/routes.go +++ b/actions/transactions/routes.go @@ -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) } diff --git a/engine/client.go b/engine/client.go index 1038e49fd..e61be518d 100644 --- a/engine/client.go +++ b/engine/client.go @@ -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" @@ -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 @@ -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 diff --git a/engine/client_internal.go b/engine/client_internal.go index 455be59d2..8b5969c55 100644 --- a/engine/client_internal.go +++ b/engine/client_internal.go @@ -7,6 +7,7 @@ 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" @@ -14,6 +15,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/mrz1836/go-cachestore" ) @@ -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 { @@ -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() diff --git a/engine/client_transaction.go b/engine/client_transaction.go index 9ac523cc7..9934b995f 100644 --- a/engine/client_transaction.go +++ b/engine/client_transaction.go @@ -2,12 +2,15 @@ package engine import ( "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines" + "github.com/bitcoin-sv/spv-wallet/engine/transaction/record" ) // TransactionOutlinesService will return the outlines.Service if it exists func (c *Client) TransactionOutlinesService() outlines.Service { - if c.options.transactionOutlinesService != nil { - return c.options.transactionOutlinesService - } - return nil + return c.options.transactionOutlinesService +} + +// TransactionRecordService will return the record.Service if it exists +func (c *Client) TransactionRecordService() *record.Service { + return c.options.transactionRecordService } diff --git a/engine/database/dao/transactions.go b/engine/database/dao/transactions.go new file mode 100644 index 000000000..27bc9e4ca --- /dev/null +++ b/engine/database/dao/transactions.go @@ -0,0 +1,59 @@ +package dao + +import ( + "context" + "iter" + "slices" + + "github.com/bitcoin-sv/spv-wallet/engine/database" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/models/bsv" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Transactions is a data access object for transactions. +type Transactions struct { + db *gorm.DB +} + +// NewTransactionsAccessObject creates a new access object for transactions. +func NewTransactionsAccessObject(db *gorm.DB) *Transactions { + return &Transactions{db: db} +} + +// SaveTX saves a transaction to the database. +func (r *Transactions) SaveTX(ctx context.Context, txRow *database.TrackedTransaction) error { + query := r.db. + WithContext(ctx). + Clauses(clause.OnConflict{ + UpdateAll: true, + }) + + if err := query.Create(txRow).Error; err != nil { + return spverrors.Wrapf(err, "failed to save transaction") + } + + return nil +} + +// GetOutputs returns outputs from the database based on the provided outpoints. +func (r *Transactions) GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) { + outpointsClause := slices.Collect(func(yield func(sqlPair []any) bool) { + for outpoint := range outpoints { + yield([]any{outpoint.TxID, outpoint.Vout}) + } + }) + + query := r.db. + WithContext(ctx). + Model(&database.Output{}). + Where("(tx_id, vout) IN ?", outpointsClause) + + var outputs []*database.Output + if err := query.Find(&outputs).Error; err != nil { + return nil, spverrors.Wrapf(err, "failed to get outputs") + } + + return outputs, nil +} diff --git a/engine/database/data.go b/engine/database/data.go index adb1dd536..937ed8bab 100644 --- a/engine/database/data.go +++ b/engine/database/data.go @@ -3,7 +3,6 @@ package database import "github.com/bitcoin-sv/spv-wallet/models/bsv" // Data holds the data stored in outputs. -// Fixme: This is not integrated with out db engine yet. type Data struct { TxID string `gorm:"primaryKey"` Vout uint32 `gorm:"primaryKey"` diff --git a/engine/database/models.go b/engine/database/models.go new file mode 100644 index 000000000..392e20040 --- /dev/null +++ b/engine/database/models.go @@ -0,0 +1,10 @@ +package database + +// Models returns a list of all models, e.g. for migrations. +func Models() []any { + return []any{ + TrackedTransaction{}, + Output{}, + Data{}, + } +} diff --git a/engine/database/output.go b/engine/database/output.go index dc4ee209a..8bb62daf8 100644 --- a/engine/database/output.go +++ b/engine/database/output.go @@ -3,21 +3,15 @@ package database import "github.com/bitcoin-sv/spv-wallet/models/bsv" // Output represents an output of a transaction. -// Fixme: This is not integrated with out db engine yet. type Output struct { - TxID string `gorm:"primaryKey"` - Vout uint32 `gorm:"primaryKey"` - SpendingTX *string `gorm:"type:char(64)"` + TxID string `gorm:"primaryKey"` + Vout uint32 `gorm:"primaryKey"` + SpendingTX string `gorm:"type:char(64)"` } // IsSpent returns true if the output is spent. func (o *Output) IsSpent() bool { - return o.SpendingTX != nil -} - -// Spend marks the output as spent. -func (o *Output) Spend(spendingTXID string) { - o.SpendingTX = &spendingTXID + return o.SpendingTX != "" } // Outpoint returns bsv.Outpoint object which identifies the output. diff --git a/engine/database/tracked_transaction.go b/engine/database/tracked_transaction.go new file mode 100644 index 000000000..e9b0bca8b --- /dev/null +++ b/engine/database/tracked_transaction.go @@ -0,0 +1,26 @@ +package database + +// TrackedTransaction represents a transaction in the database. +type TrackedTransaction struct { + ID string `gorm:"type:char(64);primaryKey"` + TxStatus TxStatus + + Outputs []*Output `gorm:"foreignKey:TxID"` + Data []*Data `gorm:"foreignKey:TxID"` + Inputs []*Output `gorm:"foreignKey:SpendingTX"` +} + +// AddOutputs adds outputs to the transaction. +func (t *TrackedTransaction) AddOutputs(outputs ...*Output) { + t.Outputs = append(t.Outputs, outputs...) +} + +// AddInputs adds inputs to the transaction. +func (t *TrackedTransaction) AddInputs(inputs ...*Output) { + t.Inputs = append(t.Inputs, inputs...) +} + +// AddData adds data to the transaction. +func (t *TrackedTransaction) AddData(data ...*Data) { + t.Data = append(t.Data, data...) +} diff --git a/engine/database/transaction.go b/engine/database/transaction.go deleted file mode 100644 index ea0c9b2a0..000000000 --- a/engine/database/transaction.go +++ /dev/null @@ -1,8 +0,0 @@ -package database - -// Transaction represents a transaction in the database. -// Fixme: This is not integrated with out db engine yet. -type Transaction struct { - ID string `gorm:"type:char(64);primaryKey"` - TxStatus TxStatus -} diff --git a/engine/definitions.go b/engine/definitions.go index 1e80168c5..8c73b631b 100644 --- a/engine/definitions.go +++ b/engine/definitions.go @@ -2,6 +2,8 @@ package engine import ( "time" + + "github.com/bitcoin-sv/spv-wallet/engine/database" ) // Defaults for engine functionality @@ -111,15 +113,23 @@ const ( cacheKeyXpubModel = "xpub-id-%s" // model-id- ) -// AllDBModels is the list of models for loading the engine and AutoMigration (defaults) -var AllDBModels = []interface{}{ - &Xpub{}, - &AccessKey{}, - &DraftTransaction{}, - &Transaction{}, - &Destination{}, - &Utxo{}, - &Contact{}, - &Webhook{}, - &PaymailAddress{}, +// AllDBModels returns all the database models, e.g. for migrations. +func AllDBModels() []any { + legacyModels := []any{ + &Xpub{}, + &AccessKey{}, + &DraftTransaction{}, + &Transaction{}, + &Destination{}, + &Utxo{}, + &Contact{}, + &Webhook{}, + &PaymailAddress{}, + } + + // New models from database package + // NOTE: Our intention is to move all models to the database package in the future + dbModels := database.Models() + + return append(legacyModels, dbModels...) } diff --git a/engine/interface.go b/engine/interface.go index 7e3aa95e3..1eb57630c 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -14,6 +14,7 @@ import ( paymailclient "github.com/bitcoin-sv/spv-wallet/engine/paymail" "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/mrz1836/go-cachestore" "github.com/rs/zerolog" @@ -57,6 +58,7 @@ type ClientService interface { PaymailClient() paymail.ClientInterface PaymailService() paymailclient.ServiceClient TransactionOutlinesService() outlines.Service + TransactionRecordService() *record.Service Taskmanager() taskmanager.TaskEngine } diff --git a/engine/testabilities/fixture_arc.go b/engine/testabilities/fixture_arc.go new file mode 100644 index 000000000..7ea54df8d --- /dev/null +++ b/engine/testabilities/fixture_arc.go @@ -0,0 +1,30 @@ +package testabilities + +import ( + "net/http" + + chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +type ARCFixture interface { + // WillRespondForBroadcast returns a http response for a broadcast request. + WillRespondForBroadcast(httpCode int, info *chainmodels.TXInfo) +} + +func (f *engineFixture) ARC() ARCFixture { + return f +} + +func (f *engineFixture) WillRespondForBroadcast(httpCode int, info *chainmodels.TXInfo) { + responder := func(req *http.Request) (*http.Response, error) { + res, err := httpmock.NewJsonResponse(httpCode, info) + require.NoError(f.t, err) + res.Header.Set("Content-Type", "application/json") + + return res, nil + } + + f.externalTransport.RegisterResponder("POST", "https://arc.taal.com/v1/tx", responder) +} diff --git a/engine/testabilities/fixture_engine.go b/engine/testabilities/fixture_engine.go index 397c7b76b..46701fd1f 100644 --- a/engine/testabilities/fixture_engine.go +++ b/engine/testabilities/fixture_engine.go @@ -3,13 +3,13 @@ package testabilities import ( "context" "errors" - "os" "testing" "github.com/bitcoin-sv/spv-wallet/config" "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/testabilities/testmode" "github.com/bitcoin-sv/spv-wallet/engine/tester" "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" "github.com/bitcoin-sv/spv-wallet/engine/tester/paymailmock" @@ -37,6 +37,9 @@ type EngineFixture 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 EngineWithConfig struct { @@ -118,18 +121,17 @@ func (f *engineFixture) prepareDBConfigForTests() { require.Equal(f.t, datastore.SQLite, f.config.Db.Datastore.Engine, "Other datastore engines are not supported in tests (yet)") // It is a workaround for development purpose to check the code with postgres instance. - mode := os.Getenv("TEST_DB_MODE") - if mode == "postgres" { + if ok, dbName := testmode.CheckPostgresMode(); ok { f.config.Db.Datastore.Engine = datastore.PostgreSQL f.config.Db.SQL.User = "postgres" f.config.Db.SQL.Password = "postgres" - f.config.Db.SQL.Name = "postgres" + f.config.Db.SQL.Name = dbName f.config.Db.SQL.Host = "localhost" return } // It is a workaround for development purpose to check what is the db state after running a tests. - if mode == "file" { + if testmode.CheckFileSQLiteMode() { f.dbConnectionString = fileDbConnectionString } else { f.dbConnectionString = inMemoryDbConnectionString diff --git a/engine/testabilities/testmode/db_mode.go b/engine/testabilities/testmode/db_mode.go new file mode 100644 index 000000000..f20138cdf --- /dev/null +++ b/engine/testabilities/testmode/db_mode.go @@ -0,0 +1,52 @@ +/* +Package testmode provides functions to set special modes for tests, +allowing to use actual Postgres or SQLite file for testing, especially for development purposes. +Important: It should be used only in LOCAL tests. +Calls of SetPostgresMode and SetFileSQLiteMode should not be committed. +*/ +package testmode + +import ( + "os" + "testing" +) + +const ( + modeEnvVar = "TEST_DB_MODE" + nameEnvVar = "TEST_DB_NAME" + + defaultPostgresDBName = "postgres" +) + +// DevelopmentOnly_SetPostgresMode sets the test mode to use actual Postgres and sets the database name. +func DevelopmentOnly_SetPostgresMode(t testing.TB) { + t.Setenv(modeEnvVar, "postgres") +} + +// DevelopmentOnly_SetPostgresModeWithName sets the test mode to use actual Postgres and sets the database name. +func DevelopmentOnly_SetPostgresModeWithName(t testing.TB, dbName string) { + DevelopmentOnly_SetPostgresMode(t) + t.Setenv(nameEnvVar, dbName) +} + +// DevelopmentOnly_SetFileSQLiteMode sets the test mode to use SQLite file +func DevelopmentOnly_SetFileSQLiteMode(t testing.TB) { + t.Setenv(modeEnvVar, "file") +} + +// CheckPostgresMode checks if the test mode is set to use actual Postgres and returns the database name. +func CheckPostgresMode() (ok bool, dbName string) { + if os.Getenv(modeEnvVar) != "postgres" { + return false, "" + } + dbName = os.Getenv(nameEnvVar) + if dbName == "" { + dbName = defaultPostgresDBName + } + return true, dbName +} + +// CheckFileSQLiteMode checks if the test mode is set to use SQLite file +func CheckFileSQLiteMode() bool { + return os.Getenv(modeEnvVar) == "file" +} diff --git a/engine/transaction/errors/errors.go b/engine/transaction/errors/errors.go index 332b02a80..89485f2b6 100644 --- a/engine/transaction/errors/errors.go +++ b/engine/transaction/errors/errors.go @@ -49,7 +49,7 @@ var ( ErrSavingData = models.SPVError{Code: "error-saving-data", Message: "failed to save data", StatusCode: 400} // ErrTxBroadcast is when the transaction broadcast fails. - ErrTxBroadcast = models.SPVError{Code: "error-tx-broadcast", Message: "failed to broadcast transaction", StatusCode: 400} + ErrTxBroadcast = models.SPVError{Code: "error-tx-broadcast", Message: "failed to broadcast transaction", StatusCode: 500} // ErrAnnotationIndexOutOfRange is when the annotation index is out of range. ErrAnnotationIndexOutOfRange = models.SPVError{Code: "error-annotation-index-out-of-range", Message: "annotation index is out of range", StatusCode: 400} diff --git a/engine/transaction/record/interfaces.go b/engine/transaction/record/interfaces.go index 1f9df546b..95073723d 100644 --- a/engine/transaction/record/interfaces.go +++ b/engine/transaction/record/interfaces.go @@ -5,17 +5,18 @@ import ( "iter" trx "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/database" "github.com/bitcoin-sv/spv-wallet/models/bsv" ) // Repository is an interface for saving transactions and outputs to the database. type Repository interface { - SaveTX(ctx context.Context, txTable *database.Transaction, outputs []*database.Output, data []*database.Data) error + SaveTX(ctx context.Context, txRow *database.TrackedTransaction) error GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) } // Broadcaster is an interface for broadcasting transactions. type Broadcaster interface { - Broadcast(ctx context.Context, tx *trx.Transaction) error + Broadcast(ctx context.Context, tx *trx.Transaction) (*chainmodels.TXInfo, error) } diff --git a/engine/transaction/record/record_outline.go b/engine/transaction/record/record_outline.go index 2e7a6f97b..7110f3deb 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -38,22 +38,22 @@ func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outline return err } - if err = s.broadcaster.Broadcast(ctx, tx); err != nil { + if _, err = s.broadcaster.Broadcast(ctx, tx); err != nil { return txerrors.ErrTxBroadcast.Wrap(err) } + // TODO: handle TXInfo returned from Broadcast (SPV-1157) txID := tx.TxID().String() - for _, utxo := range utxos { - utxo.Spend(txID) - } - txRecord := database.Transaction{ + txRow := database.TrackedTransaction{ ID: txID, TxStatus: database.TxStatusBroadcasted, } + txRow.AddInputs(utxos...) + txRow.AddOutputs(newOutputs...) + txRow.AddData(newDataRecords...) - upsertOutputs := append(newOutputs, utxos...) //newly created outputs and spent utxos - err = s.repo.SaveTX(ctx, &txRecord, upsertOutputs, newDataRecords) + err = s.repo.SaveTX(ctx, &txRow) if err != nil { return txerrors.ErrSavingData.Wrap(err) } diff --git a/engine/transaction/record/record_outline_test.go b/engine/transaction/record/record_outline_test.go index d7d246c75..6866ce6af 100644 --- a/engine/transaction/record/record_outline_test.go +++ b/engine/transaction/record/record_outline_test.go @@ -65,12 +65,12 @@ func TestRecordOutlineOpReturn(t *testing.T) { { TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: ptr(givenTXWithOpReturn(t).ID()), + SpendingTX: givenTXWithOpReturn(t).ID(), }, { TxID: givenTXWithOpReturn(t).ID(), Vout: 0, - SpendingTX: nil, + SpendingTX: "", }, }, expectData: []database.Data{ @@ -98,12 +98,12 @@ func TestRecordOutlineOpReturn(t *testing.T) { { TxID: givenTxWithOpReturnWithoutOPFalse(t).InputUTXO(0).TxID, Vout: givenTxWithOpReturnWithoutOPFalse(t).InputUTXO(0).Vout, - SpendingTX: ptr(givenTxWithOpReturnWithoutOPFalse(t).ID()), + SpendingTX: givenTxWithOpReturnWithoutOPFalse(t).ID(), }, { TxID: givenTxWithOpReturnWithoutOPFalse(t).ID(), Vout: 0, - SpendingTX: nil, + SpendingTX: "", }, }, expectData: []database.Data{ @@ -212,7 +212,7 @@ func TestRecordOutlineOpReturnErrorCases(t *testing.T) { storedOutputs: []database.Output{{ TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: ptr("05aa91319c773db18071310ecd5ddc15d3aa4242b55705a13a66f7fefe2b80a1"), + SpendingTX: "05aa91319c773db18071310ecd5ddc15d3aa4242b55705a13a66f7fefe2b80a1", }}, outline: &outlines.Transaction{ BEEF: givenTXWithOpReturn(t).BEEF(), @@ -286,7 +286,7 @@ func TestOnBroadcastErr(t *testing.T) { WithOutputs(database.Output{ TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: nil, + SpendingTX: "", }) given.Broadcaster(). WillFailOnBroadcast(errors.New("broadcast error")) @@ -337,7 +337,3 @@ func TestOnGetOutputsErr(t *testing.T) { // then: then.ErrorIs(err, txerrors.ErrGettingOutputs).NothingChanged() } - -func ptr[T any](value T) *T { - return &value -} diff --git a/engine/transaction/record/testabilities/mock_broadcaster.go b/engine/transaction/record/testabilities/mock_broadcaster.go index 53498f25a..8a3244600 100644 --- a/engine/transaction/record/testabilities/mock_broadcaster.go +++ b/engine/transaction/record/testabilities/mock_broadcaster.go @@ -4,6 +4,7 @@ import ( "context" trx "github.com/bitcoin-sv/go-sdk/transaction" + chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" ) type mockBroadcaster struct { @@ -17,9 +18,12 @@ func newMockBroadcaster() *mockBroadcaster { } } -func (m *mockBroadcaster) Broadcast(_ context.Context, tx *trx.Transaction) error { +func (m *mockBroadcaster) Broadcast(_ context.Context, tx *trx.Transaction) (*chainmodels.TXInfo, error) { m.broadcastedTxs[tx.TxID().String()] = tx - return m.returnErr + return &chainmodels.TXInfo{ + TXStatus: chainmodels.SeenOnNetwork, + TxID: tx.TxID().String(), + }, m.returnErr } func (m *mockBroadcaster) WillFailOnBroadcast(err error) BroadcasterFixture { diff --git a/engine/transaction/record/testabilities/mock_repository.go b/engine/transaction/record/testabilities/mock_repository.go index 5208d5463..6615a6aa2 100644 --- a/engine/transaction/record/testabilities/mock_repository.go +++ b/engine/transaction/record/testabilities/mock_repository.go @@ -11,7 +11,7 @@ import ( ) type mockRepository struct { - transactions map[string]database.Transaction + transactions map[string]database.TrackedTransaction outputs map[string]database.Output data map[string]database.Data @@ -21,21 +21,26 @@ type mockRepository struct { func newMockRepository() *mockRepository { return &mockRepository{ - transactions: make(map[string]database.Transaction), + transactions: make(map[string]database.TrackedTransaction), outputs: make(map[string]database.Output), data: make(map[string]database.Data), } } -func (m *mockRepository) SaveTX(_ context.Context, txTable *database.Transaction, outputs []*database.Output, data []*database.Data) error { +func (m *mockRepository) SaveTX(_ context.Context, txTable *database.TrackedTransaction) error { if m.errOnSave != nil { return m.errOnSave } m.transactions[txTable.ID] = *txTable - for _, output := range outputs { + for _, output := range txTable.Outputs { m.outputs[output.Outpoint().String()] = *output } - for _, d := range data { + for _, output := range txTable.Inputs { + utxo := *output + utxo.SpendingTX = txTable.ID + m.outputs[utxo.Outpoint().String()] = utxo + } + for _, d := range txTable.Data { m.data[d.Outpoint().String()] = *d } return nil @@ -89,7 +94,7 @@ func (m *mockRepository) GetAllData() []database.Data { return slices.Collect(maps.Values(m.data)) } -func (m *mockRepository) getTransaction(txID string) *database.Transaction { +func (m *mockRepository) getTransaction(txID string) *database.TrackedTransaction { tx, ok := m.transactions[txID] if !ok { return nil diff --git a/server/handlers/manager.go b/server/handlers/manager.go index d3cb8e18b..45a906c4e 100644 --- a/server/handlers/manager.go +++ b/server/handlers/manager.go @@ -18,6 +18,9 @@ const ( // GroupAPI is the group with the API prefix and auth middleware GroupAPI + // GroupAPIV2 is the group with the API v2 prefix and auth middleware + GroupAPIV2 + // GroupTransactionCallback is the group with the transaction callback prefix and callback token middleware (no auth middleware) GroupTransactionCallback ) @@ -39,6 +42,7 @@ func NewManager(engine *gin.Engine, apiVersion string) *Manager { GroupRoot: engine.Group(""), GroupOldAPI: authRouter.Group(prefix), GroupAPI: authRouter.Group("/api" + prefix), + GroupAPIV2: authRouter.Group("/api/v2"), GroupTransactionCallback: engine.Group("", middleware.CallbackTokenMiddleware()), }, }