diff --git a/.gitignore b/.gitignore index 205a960ba..1e4ecbba6 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ dist coverage.txt # Development data dir -data/ +/data/ # Configuration files .env.config diff --git a/actions/v2/data/get.go b/actions/v2/data/get.go new file mode 100644 index 000000000..29178d4de --- /dev/null +++ b/actions/v2/data/get.go @@ -0,0 +1,41 @@ +package data + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/actions/v2/data/internal/mapping" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/models/bsv" + "github.com/bitcoin-sv/spv-wallet/server/reqctx" + "github.com/gin-gonic/gin" +) + +func get(c *gin.Context, userContext *reqctx.UserContext) { + logger := reqctx.Logger(c) + + userID, err := userContext.ShouldGetUserID() + if err != nil { + spverrors.AbortWithErrorResponse(c, err, logger) + return + } + + dataID := c.Param("id") + _, err = bsv.OutpointFromString(dataID) + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrInvalidDataID.Wrap(err), logger) + return + } + + data, err := reqctx.Engine(c).DataService().FindForUser(c.Request.Context(), dataID, userID) + if err != nil { + spverrors.ErrorResponse(c, err, logger) + return + } + + if data == nil { + spverrors.ErrorResponse(c, spverrors.ErrDataNotFound, logger) + return + } + + c.JSON(http.StatusOK, mapping.DataResponse(data)) +} diff --git a/actions/v2/data/get_test.go b/actions/v2/data/get_test.go new file mode 100644 index 000000000..dcd8a2fa1 --- /dev/null +++ b/actions/v2/data/get_test.go @@ -0,0 +1,107 @@ +package data_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + "github.com/bitcoin-sv/spv-wallet/actions/testabilities/apierror" + testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" + "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" + "github.com/bitcoin-sv/spv-wallet/models/bsv" +) + +// NOTE: More complex and real-world test case can be found in outlines_record_test.go +func TestGetData(t *testing.T) { + // given: + given, then := testabilities.New(t) + cleanup := given.StartedSPVWalletWithConfiguration( + testengine.WithV2(), + ) + defer cleanup() + + // and: + dataToStore := "hello world" + + // and: + _, dataID := given.Faucet(fixtures.Sender).StoreData(dataToStore) + + // and: + client := given.HttpClient().ForGivenUser(fixtures.Sender) + + // when: + res, _ := client.R(). + Get("/api/v2/data/" + dataID) + + // then: + then.Response(res). + IsOK().WithJSONMatching(`{ + "id": "{{ .outpoint }}", + "blob": "{{ .blob }}" + }`, map[string]any{ + "outpoint": dataID, + "blob": dataToStore, + }) +} + +func TestErrorCases(t *testing.T) { + // given: + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithV2(), + ) + defer cleanup() + + // and + mockTx := fixtures.GivenTX(t).WithInput(1).WithP2PKHOutput(1) + mockOutpoint := bsv.Outpoint{ + TxID: mockTx.ID(), + Vout: 0, + } + + t.Run("try to get data as admin", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + + // and: + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R().Get("/api/v2/data/" + mockOutpoint.String()) + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("try to get data as anonymous", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + + // and: + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R().Get("/api/v2/data/" + mockOutpoint.String()) + + // then: + then.Response(res).IsUnauthorized() + }) + + t.Run("try to get data with wrong id", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + + // and: + client := given.HttpClient().ForUser() + + // and: + wrongID := "wrong_id" //doesn't match the outpoint format "-" + + // when: + res, _ := client.R().Get("/api/v2/data/" + wrongID) + + // then: + then.Response(res).HasStatus(400).WithJSONf( + apierror.ExpectedJSON("error-invalid-data-id", "invalid data id"), + ) + }) +} diff --git a/actions/v2/data/internal/mapping/data.go b/actions/v2/data/internal/mapping/data.go new file mode 100644 index 000000000..676561445 --- /dev/null +++ b/actions/v2/data/internal/mapping/data.go @@ -0,0 +1,14 @@ +package mapping + +import ( + "github.com/bitcoin-sv/spv-wallet/engine/v2/data/datamodels" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// DataResponse maps a domain data model to a response model +func DataResponse(data *datamodels.Data) response.Data { + return response.Data{ + ID: data.ID(), + Blob: string(data.Blob), + } +} diff --git a/actions/v2/data/routes.go b/actions/v2/data/routes.go new file mode 100644 index 000000000..3a68ad45a --- /dev/null +++ b/actions/v2/data/routes.go @@ -0,0 +1,12 @@ +package data + +import ( + "github.com/bitcoin-sv/spv-wallet/server/handlers" + routes "github.com/bitcoin-sv/spv-wallet/server/handlers" +) + +// RegisterRoutes creates the specific package routes +func RegisterRoutes(handlersManager *routes.Manager) { + group := handlersManager.Group(routes.GroupAPIV2, "/data") + group.GET("/:id", handlers.AsUser(get)) +} diff --git a/actions/v2/operations/search.go b/actions/v2/operations/search.go index f716f767d..a44d8c0b0 100644 --- a/actions/v2/operations/search.go +++ b/actions/v2/operations/search.go @@ -12,14 +12,14 @@ import ( ) func search(c *gin.Context, userContext *reqctx.UserContext) { + logger := reqctx.Logger(c) + userID, err := userContext.ShouldGetUserID() if err != nil { - spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest, reqctx.Logger(c)) + spverrors.AbortWithErrorResponse(c, err, logger) return } - logger := reqctx.Logger(c) - searchParams, err := query.ParseSearchParams[struct{}](c) if err != nil { spverrors.ErrorResponse(c, spverrors.ErrCannotParseQueryParams.WithTrace(err), logger) diff --git a/actions/v2/register.go b/actions/v2/register.go index f7e648415..e34493510 100644 --- a/actions/v2/register.go +++ b/actions/v2/register.go @@ -2,6 +2,7 @@ package v2 import ( "github.com/bitcoin-sv/spv-wallet/actions/v2/admin" + "github.com/bitcoin-sv/spv-wallet/actions/v2/data" "github.com/bitcoin-sv/spv-wallet/actions/v2/operations" "github.com/bitcoin-sv/spv-wallet/actions/v2/transactions" "github.com/bitcoin-sv/spv-wallet/actions/v2/users" @@ -13,6 +14,7 @@ func Register(handlersManager *handlers.Manager) { users.RegisterRoutes(handlersManager) operations.RegisterRoutes(handlersManager) transactions.RegisterRoutes(handlersManager) + data.RegisterRoutes(handlersManager) admin.Register(handlersManager) } diff --git a/actions/v2/transactions/outlines_record_test.go b/actions/v2/transactions/outlines_record_test.go index 492aa7675..27d5a1284 100644 --- a/actions/v2/transactions/outlines_record_test.go +++ b/actions/v2/transactions/outlines_record_test.go @@ -8,6 +8,7 @@ import ( 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/fixtures" + "github.com/bitcoin-sv/spv-wallet/models/bsv" ) const ( @@ -113,6 +114,31 @@ func TestOutlinesRecordOpReturn(t *testing.T) { "sender": "", }) }) + + t.Run("Get stored data", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + + // and: + outpoint := bsv.Outpoint{TxID: txSpec.ID(), Vout: 0} + + // and: + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R(). + Get("/api/v2/data/" + outpoint.String()) + + // then: + then.Response(res). + IsOK().WithJSONMatching(`{ + "id": "{{ .outpoint }}", + "blob": "{{ .blob }}" + }`, map[string]any{ + "outpoint": outpoint.String(), + "blob": dataOfOpReturnTx, + }) + }) } func TestOutlinesRecordOpReturnErrorCases(t *testing.T) { diff --git a/engine/client.go b/engine/client.go index afbdcfdc1..28cceac1a 100644 --- a/engine/client.go +++ b/engine/client.go @@ -17,6 +17,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/v2/addresses" + "github.com/bitcoin-sv/spv-wallet/engine/v2/data" "github.com/bitcoin-sv/spv-wallet/engine/v2/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/v2/paymails" "github.com/bitcoin-sv/spv-wallet/engine/v2/transaction/outlines" @@ -62,6 +63,7 @@ type ( users *users.Service // User domain service paymails *paymails.Service // Paymail domain service addresses *addresses.Service + data *data.Service } // cacheStoreOptions holds the cache configuration and client @@ -155,6 +157,7 @@ func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error) client.loadUsersService() client.loadPaymailsService() client.loadAddressesService() + client.loadDataService() // Load the Paymail client and service (if does not exist) if err = client.loadPaymailComponents(); err != nil { @@ -360,3 +363,8 @@ func (c *Client) PaymailsService() *paymails.Service { func (c *Client) AddressesService() *addresses.Service { return c.options.addresses } + +// DataService will return the data domain service +func (c *Client) DataService() *data.Service { + return c.options.data +} diff --git a/engine/client_internal.go b/engine/client_internal.go index 5ccec993c..8d4d624df 100644 --- a/engine/client_internal.go +++ b/engine/client_internal.go @@ -13,6 +13,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/v2/addresses" + "github.com/bitcoin-sv/spv-wallet/engine/v2/data" "github.com/bitcoin-sv/spv-wallet/engine/v2/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/v2/paymails" paymailprovider "github.com/bitcoin-sv/spv-wallet/engine/v2/paymailserver" @@ -211,6 +212,12 @@ func (c *Client) loadAddressesService() { } } +func (c *Client) loadDataService() { + if c.options.data == nil { + c.options.data = data.NewService(c.Repositories().Data) + } +} + func (c *Client) loadChainService() { if c.options.chainService == nil { logger := c.Logger().With().Str("subservice", "chain").Logger() diff --git a/engine/interface.go b/engine/interface.go index 73dcefe53..d54a1ef1f 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/v2/addresses" + "github.com/bitcoin-sv/spv-wallet/engine/v2/data" "github.com/bitcoin-sv/spv-wallet/engine/v2/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/v2/paymails" "github.com/bitcoin-sv/spv-wallet/engine/v2/transaction/outlines" @@ -162,6 +163,7 @@ type V2 interface { UsersService() *users.Service PaymailsService() *paymails.Service AddressesService() *addresses.Service + DataService() *data.Service } // ClientInterface is the client (spv wallet engine) interface comprised of all services/actions diff --git a/engine/spverrors/definitions.go b/engine/spverrors/definitions.go index 70f52b66d..3d682ab6e 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -441,9 +441,6 @@ var ErrBroadcast = models.SPVError{Message: "broadcast error", StatusCode: 500, // ////////////////////////////////// CONVERSION ERRORS -// ErrInvalidUint is when uint value is invalid -var ErrInvalidUint = models.SPVError{Message: "invalid uint value", StatusCode: 500, Code: "error-invalid-uint-value"} - // ErrInvalidInt is when uint value is invalid var ErrInvalidInt = models.SPVError{Message: "invalid int value", StatusCode: 500, Code: "error-invalid-int-value"} @@ -464,3 +461,9 @@ var ErrInvalidUint64 = models.SPVError{Message: "invalid uint64 value", StatusCo // ErrMissingXPubID is when xpub_id is missing var ErrMissingXPubID = models.SPVError{Message: "missing xpub_id", StatusCode: 400, Code: "error-missing-xpub-id"} + +// ErrDataNotFound is when data record cannot be found +var ErrDataNotFound = models.SPVError{Message: "data not found", StatusCode: 404, Code: "error-data-not-found"} + +// ErrInvalidDataID is when data id is invalid +var ErrInvalidDataID = models.SPVError{Message: "invalid data id", StatusCode: 400, Code: "error-invalid-data-id"} diff --git a/engine/testabilities/fixture_engine.go b/engine/testabilities/fixture_engine.go index 653d82f06..8adbeb0ac 100644 --- a/engine/testabilities/fixture_engine.go +++ b/engine/testabilities/fixture_engine.go @@ -52,6 +52,7 @@ type EngineFixture interface { // FaucetFixture is a test fixture for the faucet service type FaucetFixture interface { TopUp(satoshis bsv.Satoshis) fixtures.GivenTXSpec + StoreData(data string) (fixtures.GivenTXSpec, string) } type EngineWithConfig struct { diff --git a/engine/testabilities/faucet_fixture.go b/engine/testabilities/fixture_faucet.go similarity index 66% rename from engine/testabilities/faucet_fixture.go rename to engine/testabilities/fixture_faucet.go index e9220a8b2..8ec0976b4 100644 --- a/engine/testabilities/faucet_fixture.go +++ b/engine/testabilities/fixture_faucet.go @@ -61,3 +61,40 @@ func (f *faucetFixture) TopUp(satoshis bsv.Satoshis) fixtures.GivenTXSpec { return txSpec } + +func (f *faucetFixture) StoreData(data string) (fixtures.GivenTXSpec, string) { + f.t.Helper() + + txSpec := fixtures.GivenTX(f.t). + WithSender(fixtures.ExternalFaucet). + WithInput(uint64(1000)). + WithOPReturn(data) + + outpoint := bsv.Outpoint{TxID: txSpec.ID(), Vout: 0} + + operation := txmodels.NewOperation{ + UserID: f.user.ID(), + + Type: "data", + Value: 0, + + Transaction: &txmodels.NewTransaction{ + ID: txSpec.ID(), + TxStatus: txmodels.TxStatusMined, + Outputs: []txmodels.NewOutput{ + txmodels.NewOutputForData( + outpoint, + f.user.ID(), + []byte(data), + ), + }, + }, + } + + err := f.engine.Repositories().Operations.SaveAll(context.Background(), func(yield func(*txmodels.NewOperation) bool) { + yield(&operation) + }) + f.assert.NoError(err) + + return txSpec, outpoint.String() +} diff --git a/engine/v2/data/data_service.go b/engine/v2/data/data_service.go new file mode 100644 index 000000000..d8ae73d7c --- /dev/null +++ b/engine/v2/data/data_service.go @@ -0,0 +1,29 @@ +package data + +import ( + "context" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/v2/data/datamodels" +) + +// Service is the domain service for data. +type Service struct { + dataRepo Repo +} + +// NewService creates a new instance of the data service. +func NewService(dataRepo Repo) *Service { + return &Service{ + dataRepo: dataRepo, + } +} + +// FindForUser returns the data by outpoint for a specific user. +func (s *Service) FindForUser(ctx context.Context, id string, userID string) (*datamodels.Data, error) { + item, err := s.dataRepo.FindForUser(ctx, id, userID) + if err != nil { + return nil, spverrors.Wrapf(err, "failed to find data for user %s", userID) + } + return item, nil +} diff --git a/engine/v2/data/datamodels/data.go b/engine/v2/data/datamodels/data.go new file mode 100644 index 000000000..13c193c4b --- /dev/null +++ b/engine/v2/data/datamodels/data.go @@ -0,0 +1,18 @@ +package datamodels + +import "github.com/bitcoin-sv/spv-wallet/models/bsv" + +// Data is a domain model for data stored in outputs (e.g. OP_RETURN). +type Data struct { + TxID string + Vout uint32 + + UserID string + + Blob []byte +} + +// ID returns the unique identifier of the data (outpoint string) +func (d *Data) ID() string { + return bsv.Outpoint{TxID: d.TxID, Vout: d.Vout}.String() +} diff --git a/engine/v2/data/interface.go b/engine/v2/data/interface.go new file mode 100644 index 000000000..c206778ee --- /dev/null +++ b/engine/v2/data/interface.go @@ -0,0 +1,12 @@ +package data + +import ( + "context" + + "github.com/bitcoin-sv/spv-wallet/engine/v2/data/datamodels" +) + +// Repo is the interface that wraps the basic operations with data. +type Repo interface { + FindForUser(ctx context.Context, id string, userID string) (*datamodels.Data, error) +} diff --git a/engine/v2/database/repository/all.go b/engine/v2/database/repository/all.go index 70a09f4e1..2e8ca64e4 100644 --- a/engine/v2/database/repository/all.go +++ b/engine/v2/database/repository/all.go @@ -9,6 +9,7 @@ type All struct { Operations *Operations Users *Users Outputs *Outputs + Data *Data } // NewRepositories creates a new holder for all repositories. @@ -19,5 +20,6 @@ func NewRepositories(db *gorm.DB) *All { Operations: NewOperationsRepo(db), Users: NewUsersRepo(db), Outputs: NewOutputsRepo(db), + Data: NewDataRepo(db), } } diff --git a/engine/v2/database/repository/data.go b/engine/v2/database/repository/data.go new file mode 100644 index 000000000..6d3ff58a4 --- /dev/null +++ b/engine/v2/database/repository/data.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + "errors" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/v2/data/datamodels" + "github.com/bitcoin-sv/spv-wallet/engine/v2/database" + "github.com/bitcoin-sv/spv-wallet/models/bsv" + "gorm.io/gorm" +) + +// Data is a repository for data. +type Data struct { + db *gorm.DB +} + +// NewDataRepo creates a new instance of the data repository. +func NewDataRepo(db *gorm.DB) *Data { + return &Data{ + db: db, + } +} + +// FindForUser returns the data by outpoint for a specific user. +func (r *Data) FindForUser(ctx context.Context, id string, userID string) (*datamodels.Data, error) { + outpoint, err := bsv.OutpointFromString(id) + if err != nil { + return nil, spverrors.Wrapf(err, "failed to parse Data ID to outpoint") + } + + var row database.Data + if err := r.db.WithContext(ctx). + Where("tx_id = ? AND vout = ? AND user_id = ?", outpoint.TxID, outpoint.Vout, userID). + First(&row). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &datamodels.Data{ + TxID: row.TxID, + Vout: row.Vout, + UserID: row.UserID, + Blob: row.Blob, + }, nil + +} diff --git a/models/bsv/outpoint.go b/models/bsv/outpoint.go index 2d672e701..40d6bd994 100644 --- a/models/bsv/outpoint.go +++ b/models/bsv/outpoint.go @@ -1,6 +1,9 @@ package bsv -import "fmt" +import ( + "fmt" + "math" +) // Outpoint is a struct that represents a pair consisting of a transaction ID and an output index // This represents a specific unspent transaction output (UTXO) @@ -10,6 +13,36 @@ type Outpoint struct { } // String returns a string representation of outpoint -func (o *Outpoint) String() string { +func (o Outpoint) String() string { return fmt.Sprintf("%s-%d", o.TxID, o.Vout) } + +// OutpointFromString creates an Outpoint from a string +func OutpointFromString(s string) (Outpoint, error) { + if s == "" { + return Outpoint{}, fmt.Errorf("empty string") + } + + var txID string + var voutTmp int + n, err := fmt.Sscanf(s, "%64s-%d", &txID, &voutTmp) + if err != nil { + return Outpoint{}, fmt.Errorf("invalid outpoint format: %w", err) + } else if n != 2 { + return Outpoint{}, fmt.Errorf("invalid outpoint format") + } + + vout, err := toUint32(voutTmp) + if err != nil { + return Outpoint{}, err + } + + return Outpoint{TxID: txID, Vout: vout}, nil +} + +func toUint32(value int) (uint32, error) { + if value < 0 || value > math.MaxUint32 { + return 0, fmt.Errorf("invalid vout") + } + return uint32(value), nil +} diff --git a/models/response/data.go b/models/response/data.go new file mode 100644 index 000000000..f4f80dca4 --- /dev/null +++ b/models/response/data.go @@ -0,0 +1,8 @@ +package response + +// Data is a response model for data stored in outputs (e.g. OP_RETURN). +type Data struct { + ID string `json:"id"` + + Blob string `json:"blob"` +} diff --git a/models/response/record_outline.go b/models/response/recorded_outline.go similarity index 100% rename from models/response/record_outline.go rename to models/response/recorded_outline.go