From efcdcd6a599ff768ad369f4320f511b90fcdb747 Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:11:05 +0100 Subject: [PATCH 1/5] feat(SPV-1160): actual repository for new record outline --- .../fixture_spvwallet_application.go | 13 ++ .../annotated_tx_request_to_engine.go | 40 ++++++ actions/transactions/outlines_record.go | 28 ++++ actions/transactions/outlines_record_test.go | 135 ++++++++++++++++++ actions/transactions/routes.go | 1 + engine/client.go | 8 +- engine/client_internal.go | 17 ++- engine/client_transaction.go | 11 +- engine/database/data.go | 1 - engine/database/models.go | 10 ++ engine/database/output.go | 1 - engine/database/transaction.go | 21 ++- engine/definitions.go | 32 +++-- engine/interface.go | 2 + engine/record_tx_repository.go | 53 +++++++ engine/testabilities/fixture_arc.go | 30 ++++ engine/testabilities/fixture_engine.go | 12 +- engine/testabilities/testmode/db_mode.go | 45 ++++++ engine/transaction/record/interfaces.go | 5 +- engine/transaction/record/record_outline.go | 8 +- .../record/testabilities/mock_broadcaster.go | 8 +- .../record/testabilities/mock_repository.go | 6 +- 22 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 actions/transactions/internal/mapping/annotatedtx/annotated_tx_request_to_engine.go create mode 100644 actions/transactions/outlines_record.go create mode 100644 actions/transactions/outlines_record_test.go create mode 100644 engine/database/models.go create mode 100644 engine/record_tx_repository.go create mode 100644 engine/testabilities/fixture_arc.go create mode 100644 engine/testabilities/testmode/db_mode.go diff --git a/actions/testabilities/fixture_spvwallet_application.go b/actions/testabilities/fixture_spvwallet_application.go index 74207329..82ecce4e 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 00000000..91b641df --- /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_record.go b/actions/transactions/outlines_record.go new file mode 100644 index 00000000..442e095d --- /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 00000000..cf92c8e4 --- /dev/null +++ b/actions/transactions/outlines_record_test.go @@ -0,0 +1,135 @@ +package transactions_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" + "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" +) + +const ( + transactionsOutlinesRecordURL = "/api/v1/transactions/outlines/record" + 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 + }{ + "RecordTransactionOutline for not signed transaction": { + request: `{ + "beef": "` + givenUnsignedTX.BEEF() + `" + }`, + expectHttpCode: 400, + }, + "RecordTransactionOutline for not a BEEF hex": { + request: `{ + "beef": "0b3818c665bf28a46"" + }`, + expectHttpCode: 400, + }, + "Vout out index as invalid number": { + request: `{ + "beef": "` + givenTXWithOpReturn(t).BEEF() + `" + "annotations": { + "outputs": { + "invalid-number": { + "bucket": "data" + } + } + } + }`, + expectHttpCode: 400, + }, + "no-op_return output annotated as data": { + request: `{ + "beef": "` + givenTxWithP2PKHOutput.BEEF() + `", + "annotations": { + "outputs": { + "0": { + "bucket": "data" + } + } + } + }`, + expectHttpCode: 400, + }, + } + 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) + }) + } +} diff --git a/actions/transactions/routes.go b/actions/transactions/routes.go index f90fc3ab..c26d09a4 100644 --- a/actions/transactions/routes.go +++ b/actions/transactions/routes.go @@ -26,6 +26,7 @@ func RegisterRoutes(handlersManager *handlers.Manager) { group.POST("", handlers.AsUser(recordTransaction)) group.POST("/outlines", handlers.AsUser(transactionOutlines)) + group.POST("/outlines/record", handlers.AsUser(transactionRecordOutline)) handlersManager.Get(handlers.GroupTransactionCallback).POST(config.BroadcastCallbackRoute, broadcastCallback) } diff --git a/engine/client.go b/engine/client.go index 1038e49f..e61be518 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 455be59d..15766339 100644 --- a/engine/client_internal.go +++ b/engine/client_internal.go @@ -14,6 +14,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 +65,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 +206,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, &recordTXRepository{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 9ac523cc..9934b995 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/data.go b/engine/database/data.go index adb1dd53..937ed8ba 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 00000000..9b27747c --- /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{ + Transaction{}, + Output{}, + Data{}, + } +} diff --git a/engine/database/output.go b/engine/database/output.go index dc4ee209..242101f2 100644 --- a/engine/database/output.go +++ b/engine/database/output.go @@ -3,7 +3,6 @@ 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"` diff --git a/engine/database/transaction.go b/engine/database/transaction.go index ea0c9b2a..d60f729f 100644 --- a/engine/database/transaction.go +++ b/engine/database/transaction.go @@ -1,8 +1,27 @@ 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 + + Outputs []*Output `gorm:"foreignKey:TxID"` + Data []*Data `gorm:"foreignKey:TxID"` +} + +// TableName implements gorm.Tabler to override automatic table naming. +// NOTE: This is because we have already a legacy table named "transactions". +// TODO: Remove this when we have migrated all data. +func (t *Transaction) TableName() string { + return "new_transactions" +} + +// AddOutputs adds outputs to the transaction. +func (t *Transaction) AddOutputs(outputs ...*Output) { + t.Outputs = append(t.Outputs, outputs...) +} + +// AddData adds data to the transaction. +func (t *Transaction) AddData(data ...*Data) { + t.Data = append(t.Data, data...) } diff --git a/engine/definitions.go b/engine/definitions.go index 1e80168c..8c73b631 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 7e3aa95e..1eb57630 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/record_tx_repository.go b/engine/record_tx_repository.go new file mode 100644 index 00000000..853c9c28 --- /dev/null +++ b/engine/record_tx_repository.go @@ -0,0 +1,53 @@ +package engine + +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" +) + +type recordTXRepository struct { + db *gorm.DB +} + +// SaveTX saves a transaction to the database. +func (r *recordTXRepository) SaveTX(ctx context.Context, txTable *database.Transaction) error { + query := r.db. + WithContext(ctx). + Clauses(clause.OnConflict{ + UpdateAll: true, + }) + + if err := query.Create(txTable).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 *recordTXRepository) 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/testabilities/fixture_arc.go b/engine/testabilities/fixture_arc.go new file mode 100644 index 00000000..7ea54df8 --- /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 5795f169..18e7463e 100644 --- a/engine/testabilities/fixture_engine.go +++ b/engine/testabilities/fixture_engine.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "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" @@ -38,6 +38,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 { @@ -119,12 +122,11 @@ func (f *engineFixture) ConfigForTests(opts ...ConfigOpts) *config.AppConfig { // initDbConnection creates a new connection that will be used as connection for engine func (f *engineFixture) initDbConnection() { - 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 } @@ -133,7 +135,7 @@ func (f *engineFixture) initDbConnection() { panic("Other datastore engines are not supported in tests (yet)") } - 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 00000000..2c74c1b7 --- /dev/null +++ b/engine/testabilities/testmode/db_mode.go @@ -0,0 +1,45 @@ +/* +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" +) + +// SetPostgresMode sets the test mode to use actual Postgres and sets the database name. +func SetPostgresMode(t testing.TB, dbName string) { + t.Setenv(modeEnvVar, "postgres") + t.Setenv(nameEnvVar, dbName) +} + +// SetFileSQLiteMode sets the test mode to use SQLite file +func 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 == "" { + panic("TEST_DB_NAME must be set when TEST_DB_MODE is 'postgres'") + } + 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/record/interfaces.go b/engine/transaction/record/interfaces.go index 1f9df546..98773eb0 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, txTable *database.Transaction) 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 2e7a6f97..bab37555 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -38,9 +38,10 @@ 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 { @@ -51,9 +52,10 @@ func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outline ID: txID, TxStatus: database.TxStatusBroadcasted, } + txRecord.AddOutputs(append(newOutputs, utxos...)...) //newly created outputs and spent utxos + txRecord.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, &txRecord) if err != nil { return txerrors.ErrSavingData.Wrap(err) } diff --git a/engine/transaction/record/testabilities/mock_broadcaster.go b/engine/transaction/record/testabilities/mock_broadcaster.go index 53498f25..8a324460 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 5208d546..390d3ae5 100644 --- a/engine/transaction/record/testabilities/mock_repository.go +++ b/engine/transaction/record/testabilities/mock_repository.go @@ -27,15 +27,15 @@ func newMockRepository() *mockRepository { } } -func (m *mockRepository) SaveTX(_ context.Context, txTable *database.Transaction, outputs []*database.Output, data []*database.Data) error { +func (m *mockRepository) SaveTX(_ context.Context, txTable *database.Transaction) 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 _, d := range txTable.Data { m.data[d.Outpoint().String()] = *d } return nil From f780e3704e3d70450fca871cc5f61f69e394ffef Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:56:30 +0100 Subject: [PATCH 2/5] feat(SPV-1160): addressing simple comments --- actions/transactions/outlines_record_test.go | 8 +++++++- engine/record_tx_repository.go | 4 ++-- engine/testabilities/testmode/db_mode.go | 11 +++++++++-- engine/transaction/record/interfaces.go | 2 +- engine/transaction/record/record_outline.go | 8 ++++---- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/actions/transactions/outlines_record_test.go b/actions/transactions/outlines_record_test.go index cf92c8e4..ab36c648 100644 --- a/actions/transactions/outlines_record_test.go +++ b/actions/transactions/outlines_record_test.go @@ -4,6 +4,7 @@ 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" ) @@ -72,18 +73,21 @@ func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { 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: `{ @@ -97,6 +101,7 @@ func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { } }`, expectHttpCode: 400, + expectedErr: apierror.ExpectedJSON("error-bind-body-invalid", "cannot bind request body"), }, "no-op_return output annotated as data": { request: `{ @@ -110,6 +115,7 @@ func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { } }`, expectHttpCode: 400, + expectedErr: apierror.ExpectedJSON("error-annotation-mismatch", "annotation mismatch"), }, } for name, test := range tests { @@ -129,7 +135,7 @@ func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { Post(transactionsOutlinesRecordURL) // then: - then.Response(res).HasStatus(test.expectHttpCode) + then.Response(res).HasStatus(test.expectHttpCode).WithJSONf(test.expectedErr) }) } } diff --git a/engine/record_tx_repository.go b/engine/record_tx_repository.go index 853c9c28..0ee9fd27 100644 --- a/engine/record_tx_repository.go +++ b/engine/record_tx_repository.go @@ -17,14 +17,14 @@ type recordTXRepository struct { } // SaveTX saves a transaction to the database. -func (r *recordTXRepository) SaveTX(ctx context.Context, txTable *database.Transaction) error { +func (r *recordTXRepository) SaveTX(ctx context.Context, txRow *database.Transaction) error { query := r.db. WithContext(ctx). Clauses(clause.OnConflict{ UpdateAll: true, }) - if err := query.Create(txTable).Error; err != nil { + if err := query.Create(txRow).Error; err != nil { return spverrors.Wrapf(err, "failed to save transaction") } diff --git a/engine/testabilities/testmode/db_mode.go b/engine/testabilities/testmode/db_mode.go index 2c74c1b7..e3e40229 100644 --- a/engine/testabilities/testmode/db_mode.go +++ b/engine/testabilities/testmode/db_mode.go @@ -14,11 +14,18 @@ import ( const ( modeEnvVar = "TEST_DB_MODE" nameEnvVar = "TEST_DB_NAME" + + defaultPostgresDBName = "postgres" ) // SetPostgresMode sets the test mode to use actual Postgres and sets the database name. -func SetPostgresMode(t testing.TB, dbName string) { +func SetPostgresMode(t testing.TB) { t.Setenv(modeEnvVar, "postgres") +} + +// SetPostgresModeWithName sets the test mode to use actual Postgres and sets the database name. +func SetPostgresModeWithName(t testing.TB, dbName string) { + SetPostgresMode(t) t.Setenv(nameEnvVar, dbName) } @@ -34,7 +41,7 @@ func CheckPostgresMode() (ok bool, dbName string) { } dbName = os.Getenv(nameEnvVar) if dbName == "" { - panic("TEST_DB_NAME must be set when TEST_DB_MODE is 'postgres'") + dbName = defaultPostgresDBName } return true, dbName } diff --git a/engine/transaction/record/interfaces.go b/engine/transaction/record/interfaces.go index 98773eb0..7a758566 100644 --- a/engine/transaction/record/interfaces.go +++ b/engine/transaction/record/interfaces.go @@ -12,7 +12,7 @@ import ( // Repository is an interface for saving transactions and outputs to the database. type Repository interface { - SaveTX(ctx context.Context, txTable *database.Transaction) error + SaveTX(ctx context.Context, txRow *database.Transaction) error GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) } diff --git a/engine/transaction/record/record_outline.go b/engine/transaction/record/record_outline.go index bab37555..cda57338 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -48,14 +48,14 @@ func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outline utxo.Spend(txID) } - txRecord := database.Transaction{ + txRow := database.Transaction{ ID: txID, TxStatus: database.TxStatusBroadcasted, } - txRecord.AddOutputs(append(newOutputs, utxos...)...) //newly created outputs and spent utxos - txRecord.AddData(newDataRecords...) + txRow.AddOutputs(append(newOutputs, utxos...)...) //newly created outputs and spent utxos + txRow.AddData(newDataRecords...) - err = s.repo.SaveTX(ctx, &txRecord) + err = s.repo.SaveTX(ctx, &txRow) if err != nil { return txerrors.ErrSavingData.Wrap(err) } From 097565e48ec72f52ea8bf7ae7b78b313cac0739b Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:22:11 +0100 Subject: [PATCH 3/5] feat(SPV-1160): SpendingTX as foreign key --- actions/transactions/outlines_endpoint_test.go | 2 +- actions/transactions/outlines_record_test.go | 2 +- actions/transactions/routes.go | 5 +++-- engine/client_internal.go | 3 ++- .../dao/transactions.go} | 14 ++++++++++---- engine/database/output.go | 13 ++++--------- engine/database/transaction.go | 6 ++++++ engine/testabilities/testmode/db_mode.go | 14 +++++++------- engine/transaction/record/record_outline.go | 6 ++---- engine/transaction/record/record_outline_test.go | 16 ++++++---------- .../record/testabilities/mock_repository.go | 5 +++++ server/handlers/manager.go | 4 ++++ 12 files changed, 51 insertions(+), 39 deletions(-) rename engine/{record_tx_repository.go => database/dao/transactions.go} (68%) diff --git a/actions/transactions/outlines_endpoint_test.go b/actions/transactions/outlines_endpoint_test.go index 4ee55851..2b78de6c 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_test.go b/actions/transactions/outlines_record_test.go index ab36c648..f7c0f264 100644 --- a/actions/transactions/outlines_record_test.go +++ b/actions/transactions/outlines_record_test.go @@ -10,7 +10,7 @@ import ( ) const ( - transactionsOutlinesRecordURL = "/api/v1/transactions/outlines/record" + transactionsOutlinesRecordURL = "/api/v2/transactions" dataOfOpReturnTx = "hello world" ) diff --git a/actions/transactions/routes.go b/actions/transactions/routes.go index c26d09a4..667b24f9 100644 --- a/actions/transactions/routes.go +++ b/actions/transactions/routes.go @@ -25,8 +25,9 @@ func RegisterRoutes(handlersManager *handlers.Manager) { group.POST("/drafts", handlers.AsUser(newTransactionDraft)) group.POST("", handlers.AsUser(recordTransaction)) - group.POST("/outlines", handlers.AsUser(transactionOutlines)) - group.POST("/outlines/record", handlers.AsUser(transactionRecordOutline)) + 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_internal.go b/engine/client_internal.go index 15766339..8b5969c5 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" @@ -209,7 +210,7 @@ func (c *Client) loadTransactionOutlinesService() error { func (c *Client) loadTransactionRecordService() error { if c.options.transactionRecordService == nil { logger := c.Logger().With().Str("subservice", "transactionRecord").Logger() - c.options.transactionRecordService = record.NewService(logger, &recordTXRepository{c.Datastore().DB()}, c.Chain()) + c.options.transactionRecordService = record.NewService(logger, dao.NewTransactionsAccessObject(c.Datastore().DB()), c.Chain()) } return nil } diff --git a/engine/record_tx_repository.go b/engine/database/dao/transactions.go similarity index 68% rename from engine/record_tx_repository.go rename to engine/database/dao/transactions.go index 0ee9fd27..95f69cc9 100644 --- a/engine/record_tx_repository.go +++ b/engine/database/dao/transactions.go @@ -1,4 +1,4 @@ -package engine +package dao import ( "context" @@ -12,12 +12,18 @@ import ( "gorm.io/gorm/clause" ) -type recordTXRepository struct { +// 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 *recordTXRepository) SaveTX(ctx context.Context, txRow *database.Transaction) error { +func (r *Transactions) SaveTX(ctx context.Context, txRow *database.Transaction) error { query := r.db. WithContext(ctx). Clauses(clause.OnConflict{ @@ -32,7 +38,7 @@ func (r *recordTXRepository) SaveTX(ctx context.Context, txRow *database.Transac } // GetOutputs returns outputs from the database based on the provided outpoints. -func (r *recordTXRepository) GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) { +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}) diff --git a/engine/database/output.go b/engine/database/output.go index 242101f2..8bb62daf 100644 --- a/engine/database/output.go +++ b/engine/database/output.go @@ -4,19 +4,14 @@ import "github.com/bitcoin-sv/spv-wallet/models/bsv" // Output represents an output of a transaction. 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/transaction.go b/engine/database/transaction.go index d60f729f..f6d2675e 100644 --- a/engine/database/transaction.go +++ b/engine/database/transaction.go @@ -7,6 +7,7 @@ type Transaction struct { Outputs []*Output `gorm:"foreignKey:TxID"` Data []*Data `gorm:"foreignKey:TxID"` + Inputs []*Output `gorm:"foreignKey:SpendingTX"` } // TableName implements gorm.Tabler to override automatic table naming. @@ -21,6 +22,11 @@ func (t *Transaction) AddOutputs(outputs ...*Output) { t.Outputs = append(t.Outputs, outputs...) } +// AddInputs adds inputs to the transaction. +func (t *Transaction) AddInputs(inputs ...*Output) { + t.Inputs = append(t.Inputs, inputs...) +} + // AddData adds data to the transaction. func (t *Transaction) AddData(data ...*Data) { t.Data = append(t.Data, data...) diff --git a/engine/testabilities/testmode/db_mode.go b/engine/testabilities/testmode/db_mode.go index e3e40229..f20138cd 100644 --- a/engine/testabilities/testmode/db_mode.go +++ b/engine/testabilities/testmode/db_mode.go @@ -18,19 +18,19 @@ const ( defaultPostgresDBName = "postgres" ) -// SetPostgresMode sets the test mode to use actual Postgres and sets the database name. -func SetPostgresMode(t testing.TB) { +// 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") } -// SetPostgresModeWithName sets the test mode to use actual Postgres and sets the database name. -func SetPostgresModeWithName(t testing.TB, dbName string) { - SetPostgresMode(t) +// 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) } -// SetFileSQLiteMode sets the test mode to use SQLite file -func SetFileSQLiteMode(t testing.TB) { +// DevelopmentOnly_SetFileSQLiteMode sets the test mode to use SQLite file +func DevelopmentOnly_SetFileSQLiteMode(t testing.TB) { t.Setenv(modeEnvVar, "file") } diff --git a/engine/transaction/record/record_outline.go b/engine/transaction/record/record_outline.go index cda57338..0196fef3 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -44,15 +44,13 @@ func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outline // TODO: handle TXInfo returned from Broadcast (SPV-1157) txID := tx.TxID().String() - for _, utxo := range utxos { - utxo.Spend(txID) - } txRow := database.Transaction{ ID: txID, TxStatus: database.TxStatusBroadcasted, } - txRow.AddOutputs(append(newOutputs, utxos...)...) //newly created outputs and spent utxos + txRow.AddInputs(utxos...) + txRow.AddOutputs(newOutputs...) txRow.AddData(newDataRecords...) err = s.repo.SaveTX(ctx, &txRow) diff --git a/engine/transaction/record/record_outline_test.go b/engine/transaction/record/record_outline_test.go index d7d246c7..6866ce6a 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_repository.go b/engine/transaction/record/testabilities/mock_repository.go index 390d3ae5..b632fad6 100644 --- a/engine/transaction/record/testabilities/mock_repository.go +++ b/engine/transaction/record/testabilities/mock_repository.go @@ -35,6 +35,11 @@ func (m *mockRepository) SaveTX(_ context.Context, txTable *database.Transaction for _, output := range txTable.Outputs { m.outputs[output.Outpoint().String()] = *output } + 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 } diff --git a/server/handlers/manager.go b/server/handlers/manager.go index d3cb8e18..45a906c4 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()), }, } From bf6883fa8198ead121372e83ffce1584a22df61c Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:46:27 +0100 Subject: [PATCH 4/5] feat(SPV-1160): additional test when cannot broadcast via ARC --- actions/transactions/outlines_record_test.go | 35 ++++++++++++++++++++ engine/transaction/errors/errors.go | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/actions/transactions/outlines_record_test.go b/actions/transactions/outlines_record_test.go index f7c0f264..a1ddaf0d 100644 --- a/actions/transactions/outlines_record_test.go +++ b/actions/transactions/outlines_record_test.go @@ -139,3 +139,38 @@ func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { }) } } + +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/engine/transaction/errors/errors.go b/engine/transaction/errors/errors.go index 0b7dd1c0..bb12a378 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} From cf90b64df5ba6b8443d66eac89f58059f109852e Mon Sep 17 00:00:00 2001 From: Krzysztof Tomecki <152964795+chris-4chain@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:39:23 +0100 Subject: [PATCH 5/5] feat(SPV-1160): rename new transaction table to tracked_transaction --- engine/database/dao/transactions.go | 2 +- engine/database/models.go | 2 +- engine/database/tracked_transaction.go | 26 +++++++++++++++ engine/database/transaction.go | 33 ------------------- engine/transaction/record/interfaces.go | 2 +- engine/transaction/record/record_outline.go | 2 +- .../record/testabilities/mock_repository.go | 8 ++--- 7 files changed, 34 insertions(+), 41 deletions(-) create mode 100644 engine/database/tracked_transaction.go delete mode 100644 engine/database/transaction.go diff --git a/engine/database/dao/transactions.go b/engine/database/dao/transactions.go index 95f69cc9..27bc9e4c 100644 --- a/engine/database/dao/transactions.go +++ b/engine/database/dao/transactions.go @@ -23,7 +23,7 @@ func NewTransactionsAccessObject(db *gorm.DB) *Transactions { } // SaveTX saves a transaction to the database. -func (r *Transactions) SaveTX(ctx context.Context, txRow *database.Transaction) error { +func (r *Transactions) SaveTX(ctx context.Context, txRow *database.TrackedTransaction) error { query := r.db. WithContext(ctx). Clauses(clause.OnConflict{ diff --git a/engine/database/models.go b/engine/database/models.go index 9b27747c..392e2004 100644 --- a/engine/database/models.go +++ b/engine/database/models.go @@ -3,7 +3,7 @@ package database // Models returns a list of all models, e.g. for migrations. func Models() []any { return []any{ - Transaction{}, + TrackedTransaction{}, Output{}, Data{}, } diff --git a/engine/database/tracked_transaction.go b/engine/database/tracked_transaction.go new file mode 100644 index 00000000..e9b0bca8 --- /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 f6d2675e..00000000 --- a/engine/database/transaction.go +++ /dev/null @@ -1,33 +0,0 @@ -package database - -// Transaction represents a transaction in the database. -type Transaction 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"` -} - -// TableName implements gorm.Tabler to override automatic table naming. -// NOTE: This is because we have already a legacy table named "transactions". -// TODO: Remove this when we have migrated all data. -func (t *Transaction) TableName() string { - return "new_transactions" -} - -// AddOutputs adds outputs to the transaction. -func (t *Transaction) AddOutputs(outputs ...*Output) { - t.Outputs = append(t.Outputs, outputs...) -} - -// AddInputs adds inputs to the transaction. -func (t *Transaction) AddInputs(inputs ...*Output) { - t.Inputs = append(t.Inputs, inputs...) -} - -// AddData adds data to the transaction. -func (t *Transaction) AddData(data ...*Data) { - t.Data = append(t.Data, data...) -} diff --git a/engine/transaction/record/interfaces.go b/engine/transaction/record/interfaces.go index 7a758566..95073723 100644 --- a/engine/transaction/record/interfaces.go +++ b/engine/transaction/record/interfaces.go @@ -12,7 +12,7 @@ import ( // Repository is an interface for saving transactions and outputs to the database. type Repository interface { - SaveTX(ctx context.Context, txRow *database.Transaction) error + SaveTX(ctx context.Context, txRow *database.TrackedTransaction) error GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) } diff --git a/engine/transaction/record/record_outline.go b/engine/transaction/record/record_outline.go index 0196fef3..7110f3de 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -45,7 +45,7 @@ func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outline txID := tx.TxID().String() - txRow := database.Transaction{ + txRow := database.TrackedTransaction{ ID: txID, TxStatus: database.TxStatusBroadcasted, } diff --git a/engine/transaction/record/testabilities/mock_repository.go b/engine/transaction/record/testabilities/mock_repository.go index b632fad6..6615a6aa 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,13 +21,13 @@ 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) error { +func (m *mockRepository) SaveTX(_ context.Context, txTable *database.TrackedTransaction) error { if m.errOnSave != nil { return m.errOnSave } @@ -94,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