Skip to content

Commit

Permalink
feat(SPV-1125) finalize the story for op_return recording (#877)
Browse files Browse the repository at this point in the history
  • Loading branch information
chris-4chain authored Jan 30, 2025
1 parent 0f4e6b8 commit 3893827
Show file tree
Hide file tree
Showing 22 changed files with 422 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dist
coverage.txt

# Development data dir
data/
/data/

# Configuration files
.env.config
Expand Down
41 changes: 41 additions & 0 deletions actions/v2/data/get.go
Original file line number Diff line number Diff line change
@@ -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))
}
107 changes: 107 additions & 0 deletions actions/v2/data/get_test.go
Original file line number Diff line number Diff line change
@@ -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 "<txID>-<vout>"

// 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"),
)
})
}
14 changes: 14 additions & 0 deletions actions/v2/data/internal/mapping/data.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
12 changes: 12 additions & 0 deletions actions/v2/data/routes.go
Original file line number Diff line number Diff line change
@@ -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))
}
6 changes: 3 additions & 3 deletions actions/v2/operations/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions actions/v2/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -13,6 +14,7 @@ func Register(handlersManager *handlers.Manager) {
users.RegisterRoutes(handlersManager)
operations.RegisterRoutes(handlersManager)
transactions.RegisterRoutes(handlersManager)
data.RegisterRoutes(handlersManager)

admin.Register(handlersManager)
}
26 changes: 26 additions & 0 deletions actions/v2/transactions/outlines_record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions engine/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions engine/client_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions engine/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions engine/spverrors/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand All @@ -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"}
1 change: 1 addition & 0 deletions engine/testabilities/fixture_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading

0 comments on commit 3893827

Please sign in to comment.