diff --git a/actions/admin/access_keys.go b/actions/admin/access_keys.go index c4ba9644a..ef72b4912 100644 --- a/actions/admin/access_keys.go +++ b/actions/admin/access_keys.go @@ -16,13 +16,14 @@ import ( // accessKeysSearch will fetch a list of access keys filtered by metadata // Access Keys Search godoc // @Summary Access Keys Search -// @Description Access Keys Search +// @Description Fetches a list of access keys filtered by metadata, creation range, and other parameters. // @Tags Admin // @Produce json -// @Param SearchAccessKeys body filter.AdminSearchAccessKeys false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" -// @Success 200 {object} []response.AccessKey "List of access keys" -// @Failure 400 "Bad request - Error while parsing SearchAccessKeys from request body" -// @Failure 500 "Internal server error - Error while searching for access keys" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param AdminAccessKeyFilter query filter.AdminAccessKeyFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.AccessKey] "List of access keys with pagination details" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for access keys" // @Router /api/v1/admin/users/keys [get] // @Security x-auth-xpub func accessKeysSearch(c *gin.Context, _ *reqctx.AdminContext) { diff --git a/actions/admin/contact.go b/actions/admin/contact.go index f7811b30d..25c64a6a0 100644 --- a/actions/admin/contact.go +++ b/actions/admin/contact.go @@ -18,13 +18,14 @@ import ( // contactsSearch will fetch a list of contacts filtered by Metadata and AdminContactFilters // Search for contacts filtering by metadata and AdminContactFilters godoc // @Summary Search for contacts -// @Description Search for contacts +// @Description Fetches a list of contacts filtered by metadata and other criteria // @Tags Admin // @Produce json -// @Param AdminSearchContacts body filter.AdminContactFilter false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" -// @Success 200 {object} response.PageModel[response.Contact] "List of contacts" -// @Failure 400 "Bad request - Error while parsing AdminSearchContacts from request body" -// @Failure 500 "Internal server error - Error while searching for contacts" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param AdminContactFilter query filter.AdminContactFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.Contact] "List of contacts with pagination details" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for contacts" // @Router /api/v1/admin/contacts [get] // @Security x-auth-xpub func contactsSearch(c *gin.Context, _ *reqctx.AdminContext) { diff --git a/actions/admin/helpers.go b/actions/admin/helpers.go index 7ce31a2c8..a3316ef50 100644 --- a/actions/admin/helpers.go +++ b/actions/admin/helpers.go @@ -2,14 +2,10 @@ package admin import ( "fmt" - "net/http" - "github.com/bitcoin-sv/spv-wallet/actions/common" "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/mappings" "github.com/bitcoin-sv/spv-wallet/models/filter" - "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/bitcoin-sv/spv-wallet/server/reqctx" "github.com/gin-gonic/gin" ) @@ -57,16 +53,3 @@ func countTransactions(c *gin.Context, params *transactionQueryParams) (int64, e } return count, nil } - -// Helper function to map transactions and send the response -// sendPaginatedResponse sends a paginated response with any content type. -func sendPaginatedResponse[T any, U any](c *gin.Context, content []*T, pageOptions *datastore.QueryParams, count int64, mapToContractFunc func(*T) *U) { - contracts := common.MapToTypeContracts(content, mapToContractFunc) - - result := response.PageModel[U]{ - Content: contracts, - Page: common.GetPageDescriptionFromSearchParams(pageOptions, count), - } - - c.JSON(http.StatusOK, result) -} diff --git a/actions/admin/paymail_addresses.go b/actions/admin/paymail_addresses.go index ab2921cd3..a5d934dec 100644 --- a/actions/admin/paymail_addresses.go +++ b/actions/admin/paymail_addresses.go @@ -19,13 +19,13 @@ import ( // paymailGetAddress will return a paymail address // Get Paymail godoc // @Summary Get paymail -// @Description Get paymail +// @Description Fetches a paymail address by its ID // @Tags Admin // @Produce json -// @Param PaymailAddress body PaymailAddress false "PaymailAddress model containing paymail address to get" -// @Success 200 {object} response.PaymailAddress "PaymailAddress with given address" -// @Failure 400 "Bad request - Error while parsing PaymailAddress from request body" -// @Failure 500 "Internal Server Error - Error while getting paymail address" +// @Param id path string true "Paymail ID" +// @Success 200 {object} response.PaymailAddress "PaymailAddress with the given ID" +// @Failure 400 "Bad request - Invalid ID" +// @Failure 500 "Internal Server Error - Error while retrieving the paymail address" // @Router /api/v1/admin/paymails/{id} [get] // @Security x-auth-xpub func paymailGetAddress(c *gin.Context, _ *reqctx.AdminContext) { @@ -49,13 +49,14 @@ func paymailGetAddress(c *gin.Context, _ *reqctx.AdminContext) { // paymailAddressesSearch will fetch a list of paymail addresses filtered by metadata // Paymail addresses search by metadata godoc // @Summary Paymail addresses search -// @Description Paymail addresses search +// @Description Fetches a list of paymail addresses filtered by metadata and other query parameters // @Tags Admin // @Produce json -// @Param SearchPaymails body filter.AdminPaymailFilter false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" -// @Success 200 {object} []response.PaymailAddress "List of paymail addresses -// @Failure 400 "Bad request - Error while parsing SearchPaymails from request body" -// @Failure 500 "Internal server error - Error while searching for paymail addresses" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param AdminPaymailFilter query filter.AdminPaymailFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.PaymailAddress] "List of paymail addresses with pagination" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for paymail addresses" // @Router /api/v1/admin/paymails [get] // @Security x-auth-xpub func paymailAddressesSearch(c *gin.Context, _ *reqctx.AdminContext) { @@ -160,7 +161,7 @@ func paymailCreateAddress(c *gin.Context, _ *reqctx.AdminContext) { // @Description Delete paymail // @Tags Admin // @Produce json -// @Param PaymailAddress body PaymailAddress false "PaymailAddress model containing paymail address to delete" +// @Param id path string true "id of the paymail" // @Success 200 // @Failure 400 "Bad request - Error while parsing PaymailAddress from request body or if address is missing" // @Failure 500 "Internal Server Error - Error while deleting paymail address" @@ -169,21 +170,12 @@ func paymailCreateAddress(c *gin.Context, _ *reqctx.AdminContext) { func paymailDeleteAddress(c *gin.Context, _ *reqctx.AdminContext) { logger := reqctx.Logger(c) engine := reqctx.Engine(c) - var requestBody PaymailAddress - if err := c.ShouldBindJSON(&requestBody); err != nil { - spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest.WithTrace(err), logger) - return - } - - if requestBody.Address == "" { - spverrors.ErrorResponse(c, spverrors.ErrMissingAddress, logger) - return - } + id := c.Param("id") opts := engine.DefaultModelOptions() // Delete a new paymail address - err := engine.DeletePaymailAddress(c.Request.Context(), requestBody.Address, opts...) + err := engine.DeletePaymailAddressByID(c.Request.Context(), id, opts...) if err != nil { spverrors.ErrorResponse(c, spverrors.ErrDeletePaymailAddress.WithTrace(err), logger) return diff --git a/actions/admin/paymail_addresses_test.go b/actions/admin/paymail_addresses_test.go index 633539f9f..f71218282 100644 --- a/actions/admin/paymail_addresses_test.go +++ b/actions/admin/paymail_addresses_test.go @@ -197,9 +197,6 @@ func TestPaymailLivecycle(t *testing.T) { // when: res, _ := client.R(). - SetBody(map[string]any{ - "address": newPaymail, - }). Delete("/api/v1/admin/paymails/" + testState.newPaymailID) // then: @@ -217,9 +214,6 @@ func TestPaymailLivecycle(t *testing.T) { // when: res, _ := client.R(). - SetBody(map[string]any{ - "address": newPaymail, - }). Delete("/api/v1/admin/paymails/" + testState.newPaymailID) // then: diff --git a/actions/admin/transactions.go b/actions/admin/transactions.go index 7f85e5641..9388cd3af 100644 --- a/actions/admin/transactions.go +++ b/actions/admin/transactions.go @@ -3,10 +3,12 @@ package admin import ( "net/http" + "github.com/bitcoin-sv/spv-wallet/actions/common" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/internal/query" "github.com/bitcoin-sv/spv-wallet/mappings" "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/bitcoin-sv/spv-wallet/server/reqctx" "github.com/gin-gonic/gin" ) @@ -43,15 +45,14 @@ func adminGetTxByID(c *gin.Context, _ *reqctx.AdminContext) { // adminSearchTxs will fetch a list of transactions filtered by metadata // Search for transactions filtering by metadata godoc // @Summary Search for transactions -// @Description Search for transactions +// @Description Fetches a list of transactions filtered by metadata and other criteria // @Tags Admin // @Produce json -// @Param metadata query string false "Filter by metadata in the form of key-value pairs" -// @Param conditions query string false "Additional conditions for filtering, in URL-encoded JSON" -// @Param queryParams query string false "Pagination and sorting options" -// @Success 200 {object} []response.Transaction "List of transactions" -// @Failure 400 "Bad request - Error while parsing query parameters" -// @Failure 500 "Internal server error - Error while searching for transactions" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param AdminTransactionFilter query filter.AdminTransactionFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.Transaction] "List of transactions with pagination details" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for transactions" // @Router /api/v1/admin/transactions [get] // @Security x-auth-xpub func adminSearchTxs(c *gin.Context, _ *reqctx.AdminContext) { @@ -77,5 +78,12 @@ func adminSearchTxs(c *gin.Context, _ *reqctx.AdminContext) { return } - sendPaginatedResponse(c, transactions, queryParams.PageOptions, count, mappings.MapToTransactionContractForAdmin) + transactionContracts := common.MapToTypeContracts(transactions, mappings.MapToTransactionContractForAdmin) + + result := response.PageModel[response.Transaction]{ + Content: transactionContracts, + Page: common.GetPageDescriptionFromSearchParams(queryParams.PageOptions, count), + } + + c.JSON(http.StatusOK, result) } diff --git a/actions/admin/utxos.go b/actions/admin/utxos.go index 8a12b208f..849bddaa8 100644 --- a/actions/admin/utxos.go +++ b/actions/admin/utxos.go @@ -16,13 +16,14 @@ import ( // utxosSearch will fetch a list of utxos filtered by metadata // Search for utxos filtering by metadata godoc // @Summary Search for utxos -// @Description Search for utxos +// @Description Fetches a list of UTXOs filtered by metadata and other criteria // @Tags Admin // @Produce json -// @Param SearchUtxos body filter.AdminUtxoFilter false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" -// @Success 200 {object} []response.Utxo "List of utxos" -// @Failure 400 "Bad request - Error while parsing SearchUtxos from request body" -// @Failure 500 "Internal server error - Error while searching for utxos" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param AdminUtxoFilter query filter.AdminUtxoFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.Utxo] "List of UTXOs with pagination details" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for UTXOs" // @Router /api/v1/admin/utxos [get] // @Security x-auth-xpub func utxosSearch(c *gin.Context, _ *reqctx.AdminContext) { diff --git a/actions/admin/xpubs.go b/actions/admin/xpubs.go index 10436fb13..882350940 100644 --- a/actions/admin/xpubs.go +++ b/actions/admin/xpubs.go @@ -50,13 +50,14 @@ func xpubsCreate(c *gin.Context, _ *reqctx.AdminContext) { // xpubsSearch will fetch a list of xpubs filtered by metadata // Search for xpubs filtering by metadata godoc // @Summary Search for xpubs -// @Description Search for xpubs +// @Description Fetches a list of xpubs filtered by metadata and other criteria // @Tags Admin // @Produce json -// @Param SearchXpubs body filter.XpubFilter false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" -// @Success 200 {object} []response.Xpub "List of xpubs" -// @Failure 400 "Bad request - Error while parsing SearchXpubs from request body" -// @Failure 500 "Internal server error - Error while searching for xpubs" +// @Param SwaggerCommonParams query swagger.CommonFilteringQueryParams false "Supports options for pagination and sorting to streamline data exploration and analysis" +// @Param XpubFilter query filter.XpubFilter false "Supports targeted resource searches with filters" +// @Success 200 {object} response.PageModel[response.Xpub] "List of xPubs with pagination details" +// @Failure 400 "Bad request - Invalid query parameters" +// @Failure 500 "Internal server error - Error while searching for xPubs" // @Router /api/v1/admin/users [get] // @Security x-auth-xpub func xpubsSearch(c *gin.Context, _ *reqctx.AdminContext) { diff --git a/actions/base/routes.go b/actions/base/routes.go index ef6e66d78..0fd02bda9 100644 --- a/actions/base/routes.go +++ b/actions/base/routes.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/bitcoin-sv/spv-wallet/config" + "github.com/bitcoin-sv/spv-wallet/docs" "github.com/bitcoin-sv/spv-wallet/server/handlers" "github.com/gin-gonic/gin" swaggerfiles "github.com/swaggo/files" @@ -22,6 +23,7 @@ func RegisterRoutes(handlersManager *handlers.Manager) { healthGroup.OPTIONS("", statusOK) healthGroup.HEAD("", statusOK) + docs.SwaggerInfo.Version = handlersManager.APIVersion() root.GET("/swagger", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/swagger/index.html") }) diff --git a/actions/paymailserver/incoming_paymail_tx_test.go b/actions/paymailserver/incoming_paymail_tx_test.go index 3c5295786..dd48dfa43 100644 --- a/actions/paymailserver/incoming_paymail_tx_test.go +++ b/actions/paymailserver/incoming_paymail_tx_test.go @@ -3,6 +3,7 @@ package paymailserver_test import ( "fmt" "testing" + "time" "github.com/bitcoin-sv/go-sdk/script" "github.com/bitcoin-sv/spv-wallet/actions/testabilities" @@ -13,6 +14,8 @@ import ( ) func TestIncomingPaymailRawTX(t *testing.T) { + t.Skip("Raw TX is not supported yet") + givenForAllTests := testabilities.Given(t) cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( testengine.WithDomainValidationDisabled(), @@ -23,6 +26,7 @@ func TestIncomingPaymailRawTX(t *testing.T) { var testState struct { reference string lockingScript *script.Script + txID string } // given: @@ -77,8 +81,6 @@ func TestIncomingPaymailRawTX(t *testing.T) { }) t.Run("step 2 - call receive-transaction capability", func(t *testing.T) { - t.Skip("Not implemented yet") - // given: txSpec := fixtures.GivenTX(t). WithInput(satoshis+1). @@ -119,6 +121,53 @@ func TestIncomingPaymailRawTX(t *testing.T) { "txid": txSpec.ID(), "note": note, }) + + // update: + testState.txID = txSpec.ID() + }) + + t.Run("step 3 - check balance", func(t *testing.T) { + // given: + recipientClient := given.HttpClient().ForGivenUser(fixtures.RecipientInternal) + + // when: + res, _ := recipientClient.R().Get("/api/v2/users/current") + + // then: + then.Response(res).IsOK().WithJSONf(`{ + "currentBalance": %d + }`, satoshis) + }) + + t.Run("step 4 - get operations", func(t *testing.T) { + // given: + recipientClient := given.HttpClient().ForGivenUser(fixtures.RecipientInternal) + + // when: + res, _ := recipientClient.R().Get("/api/v2/operations/search") + + // then: + then.Response(res).IsOK().WithJSONMatching(`{ + "content": [ + { + "txID": "{{ .txID }}", + "createdAt": "{{ matchTimestamp }}", + "value": {{ .value }}, + "type": "incoming", + "counterparty": "{{ .sender }}" + } + ], + "page": { + "number": 1, + "size": 1, + "totalElements": 1, + "totalPages": 1 + } + }`, map[string]any{ + "value": satoshis, + "txID": testState.txID, + "sender": senderPaymail, + }) }) } @@ -188,8 +237,6 @@ func TestIncomingPaymailBeef(t *testing.T) { }) t.Run("step 2 - call beef capability", func(t *testing.T) { - t.Skip("Not implemented yet") - // given: txSpec := fixtures.GivenTX(t). WithInput(satoshis+1). @@ -235,5 +282,98 @@ func TestIncomingPaymailBeef(t *testing.T) { "txid": txSpec.ID(), "note": note, }) + + // update: + testState.txID = txSpec.ID() + }) + + t.Run("step 3 - check balance", func(t *testing.T) { + // given: + recipientClient := given.HttpClient().ForGivenUser(fixtures.RecipientInternal) + + // when: + res, _ := recipientClient.R().Get("/api/v2/users/current") + + // then: + then.Response(res).IsOK().WithJSONf(`{ + "currentBalance": %d + }`, satoshis) }) + + t.Run("step 4 - get operations", func(t *testing.T) { + // given: + recipientClient := given.HttpClient().ForGivenUser(fixtures.RecipientInternal) + + // when: + res, _ := recipientClient.R().Get("/api/v2/operations/search") + + // then: + then.Response(res).IsOK().WithJSONMatching(`{ + "content": [ + { + "txID": "{{ .txID }}", + "createdAt": "{{ matchTimestamp }}", + "value": {{ .value }}, + "type": "incoming", + "counterparty": "{{ .sender }}" + } + ], + "page": { + "number": 1, + "size": 1, + "totalElements": 1, + "totalPages": 1 + } + }`, map[string]any{ + "value": satoshis, + "txID": testState.txID, + "sender": senderPaymail, + }) + }) +} + +func TestAddressResolution(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithDomainValidationDisabled(), + testengine.WithNewTransactionFlowEnabled(), + ) + defer cleanup() + + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // and: + senderPaymail := fixtures.SenderExternal.DefaultPaymail() + recipientPaymail := fixtures.RecipientInternal.DefaultPaymail() + satoshis := uint64(1000) + + // and: + requestBody := map[string]any{ + "dt": time.Now().UTC().Format(time.RFC3339), + "senderHandle": senderPaymail, + "senderName": "External Sender", + "purpose": "P2P", + "amount": satoshis, + } + + // when: + res, _ := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(requestBody). + Post( + fmt.Sprintf( + "https://example.com/v1/bsvalias/address/%s", + recipientPaymail, + ), + ) + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "address": "{{ matchAddress }}", + "output": "{{ matchHex }}" + }`, nil) } diff --git a/actions/paymailserver/old_incoming_paymail_tx_test.go b/actions/paymailserver/old_incoming_paymail_tx_test.go index 031d4cad9..db0c29345 100644 --- a/actions/paymailserver/old_incoming_paymail_tx_test.go +++ b/actions/paymailserver/old_incoming_paymail_tx_test.go @@ -285,3 +285,48 @@ func TestOldIncomingPaymailBeef(t *testing.T) { }) }) } + +func TestOldAddressResolution(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithDomainValidationDisabled(), + ) + defer cleanup() + + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // and: + senderPaymail := fixtures.SenderExternal.DefaultPaymail() + recipientPaymail := fixtures.RecipientInternal.DefaultPaymail() + satoshis := uint64(1000) + + // and: + requestBody := map[string]any{ + "dt": time.Now().UTC().Format(time.RFC3339), + "senderHandle": senderPaymail, + "senderName": "External Sender", + "purpose": "P2P", + "amount": satoshis, + } + + // when: + res, _ := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(requestBody). + Post( + fmt.Sprintf( + "https://example.com/v1/bsvalias/address/%s", + recipientPaymail, + ), + ) + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "address": "{{ matchAddress }}", + "output": "{{ matchHex }}" + }`, nil) +} diff --git a/actions/transactions/outlines_record.go b/actions/transactions/outlines_record.go index 442e095dc..3acab8935 100644 --- a/actions/transactions/outlines_record.go +++ b/actions/transactions/outlines_record.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin/binding" ) -func transactionRecordOutline(c *gin.Context, _ *reqctx.UserContext) { +func transactionRecordOutline(c *gin.Context, userContext *reqctx.UserContext) { logger := reqctx.Logger(c) var requestBody annotatedtx.Request @@ -18,8 +18,14 @@ func transactionRecordOutline(c *gin.Context, _ *reqctx.UserContext) { return } + userID, err := userContext.ShouldGetUserID() + if err != nil { + spverrors.ErrorResponse(c, err, logger) + return + } + recordService := reqctx.Engine(c).TransactionRecordService() - if err = recordService.RecordTransactionOutline(c, requestBody.ToEngine()); err != nil { + if err = recordService.RecordTransactionOutline(c, userID, requestBody.ToEngine()); err != nil { spverrors.ErrorResponse(c, err, logger) return } diff --git a/actions/v2/operations/routes.go b/actions/v2/operations/routes.go new file mode 100644 index 000000000..a8bb84cc7 --- /dev/null +++ b/actions/v2/operations/routes.go @@ -0,0 +1,12 @@ +package operations + +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, "/operations") + group.GET("search", handlers.AsUser(search)) +} diff --git a/actions/v2/operations/search.go b/actions/v2/operations/search.go new file mode 100644 index 000000000..f716f767d --- /dev/null +++ b/actions/v2/operations/search.go @@ -0,0 +1,51 @@ +package operations + +import ( + "net/http" + "slices" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/internal/query" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/bitcoin-sv/spv-wallet/server/reqctx" + "github.com/gin-gonic/gin" +) + +func search(c *gin.Context, userContext *reqctx.UserContext) { + userID, err := userContext.ShouldGetUserID() + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest, reqctx.Logger(c)) + return + } + + logger := reqctx.Logger(c) + + searchParams, err := query.ParseSearchParams[struct{}](c) + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCannotParseQueryParams.WithTrace(err), logger) + return + } + + pagedResult, err := reqctx.Engine(c).Repositories().Operations.PaginatedForUser(c.Request.Context(), userID, searchParams.Page) + if err != nil { + spverrors.ErrorResponse(c, err, reqctx.Logger(c)) + return + } + + c.JSON(http.StatusOK, response.PageModel[response.Operation]{ + Page: pagedResult.PageDescription, + Content: slices.AppendSeq( + make([]*response.Operation, 0, len(pagedResult.Content)), + func(yield func(operation *response.Operation) bool) { + for _, operation := range pagedResult.Content { + yield(&response.Operation{ + CreatedAt: operation.CreatedAt, + Value: operation.Value, + TxID: operation.TxID, + Type: operation.Type, + Counterparty: operation.Counterparty, + }) + } + }), + }) +} diff --git a/actions/v2/operations/search_test.go b/actions/v2/operations/search_test.go new file mode 100644 index 000000000..3ba0d67cb --- /dev/null +++ b/actions/v2/operations/search_test.go @@ -0,0 +1,60 @@ +package operations_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" +) + +func TestUserOperations(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithNewTransactionFlowEnabled(), + ) + defer cleanup() + + t.Run("return empty operations list for user", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R().Get("/api/v2/operations/search") + + // then: + then.Response(res).IsOK().WithJSONMatching(`{ + "content": [], + "page": { + "number": 1, + "size": 0, + "totalElements": 0, + "totalPages": 0 + } + }`, nil) + }) + + t.Run("try return user operations for admin", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R().Get("/api/v2/operations/search") + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("try return user operations for anonymous", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R().Get("/api/v2/operations/search") + + // then: + then.Response(res).IsUnauthorized() + }) +} diff --git a/actions/v2/register.go b/actions/v2/register.go new file mode 100644 index 000000000..a0d2614d9 --- /dev/null +++ b/actions/v2/register.go @@ -0,0 +1,13 @@ +package v2 + +import ( + "github.com/bitcoin-sv/spv-wallet/actions/v2/operations" + "github.com/bitcoin-sv/spv-wallet/actions/v2/users" + "github.com/bitcoin-sv/spv-wallet/server/handlers" +) + +// Register collects all the action's routes and registers them using the handlersManager +func Register(handlersManager *handlers.Manager) { + users.RegisterRoutes(handlersManager) + operations.RegisterRoutes(handlersManager) +} diff --git a/actions/v2/users/current.go b/actions/v2/users/current.go new file mode 100644 index 000000000..bbd318006 --- /dev/null +++ b/actions/v2/users/current.go @@ -0,0 +1,28 @@ +package users + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/bitcoin-sv/spv-wallet/server/reqctx" + "github.com/gin-gonic/gin" +) + +func current(c *gin.Context, userContext *reqctx.UserContext) { + userID, err := userContext.ShouldGetUserID() + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest, reqctx.Logger(c)) + return + } + + satoshis, err := reqctx.Engine(c).Repositories().Users.GetBalance(c.Request.Context(), userID, "bsv") + if err != nil { + spverrors.ErrorResponse(c, err, reqctx.Logger(c)) + return + } + + c.JSON(http.StatusOK, &response.UserInfo{ + CurrentBalance: satoshis, + }) +} diff --git a/actions/v2/users/current_test.go b/actions/v2/users/current_test.go new file mode 100644 index 000000000..043b6f720 --- /dev/null +++ b/actions/v2/users/current_test.go @@ -0,0 +1,56 @@ +package users_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" +) + +func TestUserCurrent(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithNewTransactionFlowEnabled(), + ) + defer cleanup() + + t.Run("return user info for user", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R().Get("/api/v2/users/current") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "currentBalance": 0 + }`, nil) + }) + + t.Run("try return user info for admin", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R().Get("/api/v2/users/current") + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("try return user info for anonymous", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R().Get("/api/v2/users/current") + + // then: + then.Response(res).IsUnauthorized() + }) +} diff --git a/actions/v2/users/routes.go b/actions/v2/users/routes.go new file mode 100644 index 000000000..e82e5a9e3 --- /dev/null +++ b/actions/v2/users/routes.go @@ -0,0 +1,12 @@ +package users + +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, "/users") + group.GET("current", handlers.AsUser(current)) +} diff --git a/config/load_config_test.go b/config/load_config_test.go index 519961504..e2f6a8b45 100644 --- a/config/load_config_test.go +++ b/config/load_config_test.go @@ -16,11 +16,12 @@ func TestLoadConfig(t *testing.T) { logger := tester.Logger(t) // when - _, err := config.Load("test", logger) + cfg, err := config.Load("test", logger) // then assert.NoError(t, err) assert.Equal(t, viper.GetString(config.ConfigFilePathKey), config.DefaultConfigFilePath) + assert.Equal(t, "test", cfg.Version) }) t.Run("custom configFilePath overridden by ENV", func(t *testing.T) { diff --git a/conv/convert_primitives.go b/conv/convert_primitives.go index bc8a2f214..5a1c33136 100644 --- a/conv/convert_primitives.go +++ b/conv/convert_primitives.go @@ -44,6 +44,14 @@ func Int64ToUint64(value int64) (uint64, error) { return uint64(value), nil } +// Int64ToInt will convert an int64 to an int, with range checks +func Int64ToInt(value int64) (int, error) { + if value < math.MinInt || value > math.MaxInt { + return 0, spverrors.ErrInvalidInt + } + return int(value), nil +} + // Uint64ToInt will convert a uint64 to an int, with range checks func Uint64ToInt(value uint64) (int, error) { if value > math.MaxInt { diff --git a/docs/docs.go b/docs/docs.go index 825b7ad18..18c662eda 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -67,7 +67,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Search for contacts", + "description": "Fetches a list of contacts filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -77,23 +77,122 @@ const docTemplate = `{ "summary": "Search for contacts", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "AdminSearchContacts", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminContactFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "Alice", + "name": "fullName", + "in": "query" + }, + { + "type": "string", + "example": "ffdbe74e-0700-4710-aac5-611a1f877c7f", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "string", + "example": "alice@example.com", + "name": "paymail", + "in": "query" + }, + { + "type": "string", + "example": "0334f01ecb971e93db179e6fb320cd1466beb0c1ec6c1c6a37aa6cb02e53d5dd1a", + "name": "pubKey", + "in": "query" + }, + { + "enum": [ + "unconfirmed", + "awaiting", + "confirmed", + "rejected" + ], + "type": "string", + "name": "status", + "in": "query" + }, + { + "type": "string", + "example": "623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of contacts", + "description": "List of contacts with pagination details", "schema": { "$ref": "#/definitions/response.PageModel-response_Contact" } }, "400": { - "description": "Bad request - Error while parsing AdminSearchContacts from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for contacts" @@ -330,7 +429,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Paymail addresses search", + "description": "Fetches a list of paymail addresses filtered by metadata and other query parameters", "produces": [ "application/json" ], @@ -340,26 +439,111 @@ const docTemplate = `{ "summary": "Paymail addresses search", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchPaymails", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminPaymailFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "alice", + "name": "alias", + "in": "query" + }, + { + "type": "string", + "example": "example.com", + "name": "domain", + "in": "query" + }, + { + "type": "string", + "example": "ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "string", + "example": "Alice", + "name": "publicName", + "in": "query" + }, + { + "type": "string", + "example": "79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of paymail addresses", + "description": "List of paymail addresses with pagination", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.PaymailAddress" - } + "$ref": "#/definitions/response.PageModel-response_PaymailAddress" } }, "400": { - "description": "Bad request - Error while parsing SearchPaymails from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for paymail addresses" @@ -413,7 +597,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Get paymail", + "description": "Fetches a paymail address by its ID", "produces": [ "application/json" ], @@ -423,26 +607,25 @@ const docTemplate = `{ "summary": "Get paymail", "parameters": [ { - "description": "PaymailAddress model containing paymail address to get", - "name": "PaymailAddress", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.PaymailAddress" - } + "type": "string", + "description": "Paymail ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "PaymailAddress with given address", + "description": "PaymailAddress with the given ID", "schema": { "$ref": "#/definitions/response.PaymailAddress" } }, "400": { - "description": "Bad request - Error while parsing PaymailAddress from request body" + "description": "Bad request - Invalid ID" }, "500": { - "description": "Internal Server Error - Error while getting paymail address" + "description": "Internal Server Error - Error while retrieving the paymail address" } } }, @@ -462,12 +645,11 @@ const docTemplate = `{ "summary": "Delete paymail", "parameters": [ { - "description": "PaymailAddress model containing paymail address to delete", - "name": "PaymailAddress", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.PaymailAddress" - } + "type": "string", + "description": "id of the paymail", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -543,7 +725,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Search for transactions", + "description": "Fetches a list of transactions filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -554,35 +736,159 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Filter by metadata in the form of key-value pairs", - "name": "metadata", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", "in": "query" }, { "type": "string", - "description": "Additional conditions for filtering, in URL-encoded JSON", - "name": "conditions", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", "in": "query" }, { - "type": "string", - "description": "Pagination and sorting options", - "name": "queryParams", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List of transactions", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Transaction" - } + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8", + "name": "blockHash", + "in": "query" + }, + { + "type": "integer", + "example": 839376, + "name": "blockHeight", + "in": "query" + }, + { + "type": "string", + "example": "d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14", + "name": "draftId", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "fee", + "in": "query" + }, + { + "type": "string", + "name": "hex", + "in": "query" + }, + { + "type": "string", + "example": "d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "numberOfInputs", + "in": "query" + }, + { + "type": "integer", + "example": 2, + "name": "numberOfOutputs", + "in": "query" + }, + { + "enum": [ + "UNKNOWN", + "QUEUED", + "RECEIVED", + "STORED", + "ANNOUNCED_TO_NETWORK", + "REQUESTED_BY_NETWORK", + "SENT_TO_NETWORK", + "ACCEPTED_BY_NETWORK", + "SEEN_ON_NETWORK", + "MINED", + "SEEN_IN_ORPHAN_MEMPOOL", + "CONFIRMED", + "REJECTED" + ], + "type": "string", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "example": 100000000, + "name": "totalValue", + "in": "query" + }, + { + "type": "string", + "example": "623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486", + "name": "xpubId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of transactions with pagination details", + "schema": { + "$ref": "#/definitions/response.PageModel-response_Transaction" } }, "400": { - "description": "Bad request - Error while parsing query parameters" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for transactions" @@ -637,7 +943,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Search for xpubs", + "description": "Fetches a list of xpubs filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -647,29 +953,96 @@ const docTemplate = `{ "summary": "Search for xpubs", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchXpubs", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.XpubFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "integer", + "example": 1000, + "name": "currentBalance", + "in": "query" + }, + { + "type": "string", + "example": "00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" } ], "responses": { "200": { - "description": "List of xpubs", + "description": "List of xPubs with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Xpub" - } + "$ref": "#/definitions/response.PageModel-response_Xpub" } }, "400": { - "description": "Bad request - Error while parsing SearchXpubs from request body" + "description": "Bad request - Invalid query parameters" }, "500": { - "description": "Internal server error - Error while searching for xpubs" + "description": "Internal server error - Error while searching for xPubs" } } }, @@ -705,52 +1078,112 @@ const docTemplate = `{ "$ref": "#/definitions/response.Xpub" } }, - "400": { - "description": "Bad request - Error while parsing CreateXpub from request body" + "400": { + "description": "Bad request - Error while parsing CreateXpub from request body" + }, + "500": { + "description": "Internal server error - Error while creating xpub" + } + } + } + }, + "/api/v1/admin/users/keys": { + "get": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Fetches a list of access keys filtered by metadata, creation range, and other parameters.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Access Keys Search", + "parameters": [ + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" }, - "500": { - "description": "Internal server error - Error while creating xpub" - } - } - } - }, - "/api/v1/admin/users/keys": { - "get": { - "security": [ { - "x-auth-xpub": [] - } - ], - "description": "Access Keys Search", - "produces": [ - "application/json" - ], - "tags": [ - "Admin" - ], - "summary": "Access Keys Search", - "parameters": [ + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchAccessKeys", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminSearchAccessKeys" - } + "type": "string", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of access keys", + "description": "List of access keys with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.AccessKey" - } + "$ref": "#/definitions/response.PageModel-response_AccessKey" } }, "400": { - "description": "Bad request - Error while parsing SearchAccessKeys from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for access keys" @@ -765,7 +1198,7 @@ const docTemplate = `{ "x-auth-xpub": [] } ], - "description": "Search for utxos", + "description": "Fetches a list of UTXOs filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -775,29 +1208,147 @@ const docTemplate = `{ "summary": "Search for utxos", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchUtxos", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminUtxoFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "89419d4c7c50810bfe5ff9df9ad5074b749959423782dc91a30f1058b9ad7ef7", + "name": "draftId", + "in": "query" + }, + { + "type": "string", + "example": "fe4cbfee0258aa589cbc79963f7c204061fd67d987e32ee5049aa90ce14658ee", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "integer", + "example": 0, + "name": "outputIndex", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "satoshis", + "in": "query" + }, + { + "type": "string", + "example": "76a914a5f271385e75f57bcd9092592dede812f8c466d088ac", + "name": "scriptPubKey", + "in": "query" + }, + { + "type": "string", + "example": "11a7746489a70e9c0170601c2be65558455317a984194eb2791b637f59f8cd6e", + "name": "spendingTxId", + "in": "query" + }, + { + "type": "string", + "example": "5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6", + "name": "transactionId", + "in": "query" + }, + { + "enum": [ + "pubkey", + "pubkeyhash", + "nulldata", + "multisig", + "nonstandard", + "scripthash", + "metanet", + "token_stas", + "token_sensible" + ], + "type": "string", + "name": "type", + "in": "query" + }, + { + "type": "string", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of utxos", + "description": "List of UTXOs with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Utxo" - } + "$ref": "#/definitions/response.PageModel-response_Utxo" } }, "400": { - "description": "Bad request - Error while parsing SearchUtxos from request body" + "description": "Bad request - Invalid query parameters" }, "500": { - "description": "Internal server error - Error while searching for utxos" + "description": "Internal server error - Error while searching for UTXOs" } } } @@ -2456,6 +3007,7 @@ const docTemplate = `{ "Admin" ], "summary": "Access Keys Count", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -2497,6 +3049,7 @@ const docTemplate = `{ "Admin" ], "summary": "Access Keys Search", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2541,6 +3094,7 @@ const docTemplate = `{ "Admin" ], "summary": "Accept contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2586,6 +3140,7 @@ const docTemplate = `{ "Admin" ], "summary": "Reject contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2631,6 +3186,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for contacts", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2672,6 +3228,7 @@ const docTemplate = `{ "Admin" ], "summary": "Delete contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2712,6 +3269,7 @@ const docTemplate = `{ "Admin" ], "summary": "Update contact FullName or Metadata", + "deprecated": true, "parameters": [ { "type": "string", @@ -2852,6 +3410,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for destinations", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2896,6 +3455,7 @@ const docTemplate = `{ "Admin" ], "summary": "Create paymail", + "deprecated": true, "parameters": [ { "description": " ", @@ -2937,6 +3497,7 @@ const docTemplate = `{ "Admin" ], "summary": "Delete paymail", + "deprecated": true, "parameters": [ { "description": "PaymailAddress model containing paymail address to delete", @@ -2975,6 +3536,7 @@ const docTemplate = `{ "Admin" ], "summary": "Get paymail", + "deprecated": true, "parameters": [ { "description": "PaymailAddress model containing paymail address to get", @@ -3016,6 +3578,7 @@ const docTemplate = `{ "Admin" ], "summary": "Paymail addresses count", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3057,6 +3620,7 @@ const docTemplate = `{ "Admin" ], "summary": "Paymail addresses search", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3156,6 +3720,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for transactions", + "deprecated": true, "parameters": [ { "type": "string", @@ -3253,6 +3818,7 @@ const docTemplate = `{ "Admin" ], "summary": "Count transactions", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3294,6 +3860,7 @@ const docTemplate = `{ "Admin" ], "summary": "Record transactions", + "deprecated": true, "parameters": [ { "description": "RecordTransaction model containing hex of the transaction to record", @@ -3336,6 +3903,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for transactions", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3380,6 +3948,7 @@ const docTemplate = `{ "Admin" ], "summary": "Count utxos", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3421,6 +3990,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for utxos", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3465,6 +4035,7 @@ const docTemplate = `{ "Admin" ], "summary": "Get All Webhooks", + "deprecated": true, "responses": { "200": { "description": "List of webhooks", @@ -3494,6 +4065,7 @@ const docTemplate = `{ "Admin" ], "summary": "Subscribe to a webhook", + "deprecated": true, "parameters": [ { "description": "URL to subscribe to and optional token header and value", @@ -3530,6 +4102,7 @@ const docTemplate = `{ "Admin" ], "summary": "Unsubscribe to a webhook", + "deprecated": true, "parameters": [ { "description": "URL to unsubscribe from", @@ -3568,6 +4141,7 @@ const docTemplate = `{ "Admin" ], "summary": "Create xPub", + "deprecated": true, "parameters": [ { "description": " ", @@ -3610,6 +4184,7 @@ const docTemplate = `{ "Admin" ], "summary": "Count xpubs", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3651,6 +4226,7 @@ const docTemplate = `{ "Admin" ], "summary": "Search for xpubs", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -4676,7 +5252,7 @@ const docTemplate = `{ "key2": "value2" } }, - "public_name": { + "publicName": { "description": "The public name of the paymail", "type": "string", "example": "Test" @@ -7538,6 +8114,26 @@ const docTemplate = `{ } } }, + "response.PageModel-response_PaymailAddress": { + "type": "object", + "properties": { + "content": { + "description": "Content is the collection of elements that serves as the content", + "type": "array", + "items": { + "$ref": "#/definitions/response.PaymailAddress" + } + }, + "page": { + "description": "Page is the page descriptor", + "allOf": [ + { + "$ref": "#/definitions/response.PageDescription" + } + ] + } + } + }, "response.PageModel-response_Transaction": { "type": "object", "properties": { @@ -7578,6 +8174,26 @@ const docTemplate = `{ } } }, + "response.PageModel-response_Xpub": { + "type": "object", + "properties": { + "content": { + "description": "Content is the collection of elements that serves as the content", + "type": "array", + "items": { + "$ref": "#/definitions/response.Xpub" + } + }, + "page": { + "description": "Page is the page descriptor", + "allOf": [ + { + "$ref": "#/definitions/response.PageDescription" + } + ] + } + } + }, "response.PaymailAddress": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 28a4e4e04..3ac43514b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -58,7 +58,7 @@ "x-auth-xpub": [] } ], - "description": "Search for contacts", + "description": "Fetches a list of contacts filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -68,23 +68,122 @@ "summary": "Search for contacts", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "AdminSearchContacts", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminContactFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "Alice", + "name": "fullName", + "in": "query" + }, + { + "type": "string", + "example": "ffdbe74e-0700-4710-aac5-611a1f877c7f", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "string", + "example": "alice@example.com", + "name": "paymail", + "in": "query" + }, + { + "type": "string", + "example": "0334f01ecb971e93db179e6fb320cd1466beb0c1ec6c1c6a37aa6cb02e53d5dd1a", + "name": "pubKey", + "in": "query" + }, + { + "enum": [ + "unconfirmed", + "awaiting", + "confirmed", + "rejected" + ], + "type": "string", + "name": "status", + "in": "query" + }, + { + "type": "string", + "example": "623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of contacts", + "description": "List of contacts with pagination details", "schema": { "$ref": "#/definitions/response.PageModel-response_Contact" } }, "400": { - "description": "Bad request - Error while parsing AdminSearchContacts from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for contacts" @@ -321,7 +420,7 @@ "x-auth-xpub": [] } ], - "description": "Paymail addresses search", + "description": "Fetches a list of paymail addresses filtered by metadata and other query parameters", "produces": [ "application/json" ], @@ -331,26 +430,111 @@ "summary": "Paymail addresses search", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchPaymails", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminPaymailFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "alice", + "name": "alias", + "in": "query" + }, + { + "type": "string", + "example": "example.com", + "name": "domain", + "in": "query" + }, + { + "type": "string", + "example": "ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "string", + "example": "Alice", + "name": "publicName", + "in": "query" + }, + { + "type": "string", + "example": "79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of paymail addresses", + "description": "List of paymail addresses with pagination", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.PaymailAddress" - } + "$ref": "#/definitions/response.PageModel-response_PaymailAddress" } }, "400": { - "description": "Bad request - Error while parsing SearchPaymails from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for paymail addresses" @@ -404,7 +588,7 @@ "x-auth-xpub": [] } ], - "description": "Get paymail", + "description": "Fetches a paymail address by its ID", "produces": [ "application/json" ], @@ -414,26 +598,25 @@ "summary": "Get paymail", "parameters": [ { - "description": "PaymailAddress model containing paymail address to get", - "name": "PaymailAddress", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.PaymailAddress" - } + "type": "string", + "description": "Paymail ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "PaymailAddress with given address", + "description": "PaymailAddress with the given ID", "schema": { "$ref": "#/definitions/response.PaymailAddress" } }, "400": { - "description": "Bad request - Error while parsing PaymailAddress from request body" + "description": "Bad request - Invalid ID" }, "500": { - "description": "Internal Server Error - Error while getting paymail address" + "description": "Internal Server Error - Error while retrieving the paymail address" } } }, @@ -453,12 +636,11 @@ "summary": "Delete paymail", "parameters": [ { - "description": "PaymailAddress model containing paymail address to delete", - "name": "PaymailAddress", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.PaymailAddress" - } + "type": "string", + "description": "id of the paymail", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -534,7 +716,7 @@ "x-auth-xpub": [] } ], - "description": "Search for transactions", + "description": "Fetches a list of transactions filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -545,35 +727,159 @@ "parameters": [ { "type": "string", - "description": "Filter by metadata in the form of key-value pairs", - "name": "metadata", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", "in": "query" }, { "type": "string", - "description": "Additional conditions for filtering, in URL-encoded JSON", - "name": "conditions", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", "in": "query" }, { - "type": "string", - "description": "Pagination and sorting options", - "name": "queryParams", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List of transactions", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Transaction" - } + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8", + "name": "blockHash", + "in": "query" + }, + { + "type": "integer", + "example": 839376, + "name": "blockHeight", + "in": "query" + }, + { + "type": "string", + "example": "d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14", + "name": "draftId", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "fee", + "in": "query" + }, + { + "type": "string", + "name": "hex", + "in": "query" + }, + { + "type": "string", + "example": "d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "numberOfInputs", + "in": "query" + }, + { + "type": "integer", + "example": 2, + "name": "numberOfOutputs", + "in": "query" + }, + { + "enum": [ + "UNKNOWN", + "QUEUED", + "RECEIVED", + "STORED", + "ANNOUNCED_TO_NETWORK", + "REQUESTED_BY_NETWORK", + "SENT_TO_NETWORK", + "ACCEPTED_BY_NETWORK", + "SEEN_ON_NETWORK", + "MINED", + "SEEN_IN_ORPHAN_MEMPOOL", + "CONFIRMED", + "REJECTED" + ], + "type": "string", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "example": 100000000, + "name": "totalValue", + "in": "query" + }, + { + "type": "string", + "example": "623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486", + "name": "xpubId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of transactions with pagination details", + "schema": { + "$ref": "#/definitions/response.PageModel-response_Transaction" } }, "400": { - "description": "Bad request - Error while parsing query parameters" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for transactions" @@ -628,7 +934,7 @@ "x-auth-xpub": [] } ], - "description": "Search for xpubs", + "description": "Fetches a list of xpubs filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -638,29 +944,96 @@ "summary": "Search for xpubs", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchXpubs", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.XpubFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "integer", + "example": 1000, + "name": "currentBalance", + "in": "query" + }, + { + "type": "string", + "example": "00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" } ], "responses": { "200": { - "description": "List of xpubs", + "description": "List of xPubs with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Xpub" - } + "$ref": "#/definitions/response.PageModel-response_Xpub" } }, "400": { - "description": "Bad request - Error while parsing SearchXpubs from request body" + "description": "Bad request - Invalid query parameters" }, "500": { - "description": "Internal server error - Error while searching for xpubs" + "description": "Internal server error - Error while searching for xPubs" } } }, @@ -696,52 +1069,112 @@ "$ref": "#/definitions/response.Xpub" } }, - "400": { - "description": "Bad request - Error while parsing CreateXpub from request body" + "400": { + "description": "Bad request - Error while parsing CreateXpub from request body" + }, + "500": { + "description": "Internal server error - Error while creating xpub" + } + } + } + }, + "/api/v1/admin/users/keys": { + "get": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Fetches a list of access keys filtered by metadata, creation range, and other parameters.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Access Keys Search", + "parameters": [ + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" }, - "500": { - "description": "Internal server error - Error while creating xpub" - } - } - } - }, - "/api/v1/admin/users/keys": { - "get": { - "security": [ { - "x-auth-xpub": [] - } - ], - "description": "Access Keys Search", - "produces": [ - "application/json" - ], - "tags": [ - "Admin" - ], - "summary": "Access Keys Search", - "parameters": [ + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchAccessKeys", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminSearchAccessKeys" - } + "type": "string", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of access keys", + "description": "List of access keys with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.AccessKey" - } + "$ref": "#/definitions/response.PageModel-response_AccessKey" } }, "400": { - "description": "Bad request - Error while parsing SearchAccessKeys from request body" + "description": "Bad request - Invalid query parameters" }, "500": { "description": "Internal server error - Error while searching for access keys" @@ -756,7 +1189,7 @@ "x-auth-xpub": [] } ], - "description": "Search for utxos", + "description": "Fetches a list of UTXOs filtered by metadata and other criteria", "produces": [ "application/json" ], @@ -766,29 +1199,147 @@ "summary": "Search for utxos", "parameters": [ { - "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", - "name": "SearchUtxos", - "in": "body", - "schema": { - "$ref": "#/definitions/filter.AdminUtxoFilter" - } + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "createdRange[to]", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "example": [ + "metadata[key]=value", + "metadata[key2]=value2" + ], + "description": "Metadata is a list of key-value pairs that can be used to filter the results. !ATTENTION! Unfortunately this parameter won't work from swagger UI.", + "name": "metadata", + "in": "query" + }, + { + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "name": "size", + "in": "query" + }, + { + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "name": "sortBy", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[from]", + "in": "query" + }, + { + "type": "string", + "example": "2024-02-26T11:01:28Z", + "name": "updatedRange[to]", + "in": "query" + }, + { + "type": "string", + "example": "89419d4c7c50810bfe5ff9df9ad5074b749959423782dc91a30f1058b9ad7ef7", + "name": "draftId", + "in": "query" + }, + { + "type": "string", + "example": "fe4cbfee0258aa589cbc79963f7c204061fd67d987e32ee5049aa90ce14658ee", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "example": true, + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "name": "includeDeleted", + "in": "query" + }, + { + "type": "integer", + "example": 0, + "name": "outputIndex", + "in": "query" + }, + { + "type": "integer", + "example": 1, + "name": "satoshis", + "in": "query" + }, + { + "type": "string", + "example": "76a914a5f271385e75f57bcd9092592dede812f8c466d088ac", + "name": "scriptPubKey", + "in": "query" + }, + { + "type": "string", + "example": "11a7746489a70e9c0170601c2be65558455317a984194eb2791b637f59f8cd6e", + "name": "spendingTxId", + "in": "query" + }, + { + "type": "string", + "example": "5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6", + "name": "transactionId", + "in": "query" + }, + { + "enum": [ + "pubkey", + "pubkeyhash", + "nulldata", + "multisig", + "nonstandard", + "scripthash", + "metanet", + "token_stas", + "token_sensible" + ], + "type": "string", + "name": "type", + "in": "query" + }, + { + "type": "string", + "name": "xpubId", + "in": "query" } ], "responses": { "200": { - "description": "List of utxos", + "description": "List of UTXOs with pagination details", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/response.Utxo" - } + "$ref": "#/definitions/response.PageModel-response_Utxo" } }, "400": { - "description": "Bad request - Error while parsing SearchUtxos from request body" + "description": "Bad request - Invalid query parameters" }, "500": { - "description": "Internal server error - Error while searching for utxos" + "description": "Internal server error - Error while searching for UTXOs" } } } @@ -2447,6 +2998,7 @@ "Admin" ], "summary": "Access Keys Count", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -2488,6 +3040,7 @@ "Admin" ], "summary": "Access Keys Search", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2532,6 +3085,7 @@ "Admin" ], "summary": "Accept contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2577,6 +3131,7 @@ "Admin" ], "summary": "Reject contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2622,6 +3177,7 @@ "Admin" ], "summary": "Search for contacts", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2663,6 +3219,7 @@ "Admin" ], "summary": "Delete contact", + "deprecated": true, "parameters": [ { "type": "string", @@ -2703,6 +3260,7 @@ "Admin" ], "summary": "Update contact FullName or Metadata", + "deprecated": true, "parameters": [ { "type": "string", @@ -2843,6 +3401,7 @@ "Admin" ], "summary": "Search for destinations", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -2887,6 +3446,7 @@ "Admin" ], "summary": "Create paymail", + "deprecated": true, "parameters": [ { "description": " ", @@ -2928,6 +3488,7 @@ "Admin" ], "summary": "Delete paymail", + "deprecated": true, "parameters": [ { "description": "PaymailAddress model containing paymail address to delete", @@ -2966,6 +3527,7 @@ "Admin" ], "summary": "Get paymail", + "deprecated": true, "parameters": [ { "description": "PaymailAddress model containing paymail address to get", @@ -3007,6 +3569,7 @@ "Admin" ], "summary": "Paymail addresses count", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3048,6 +3611,7 @@ "Admin" ], "summary": "Paymail addresses search", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3147,6 +3711,7 @@ "Admin" ], "summary": "Search for transactions", + "deprecated": true, "parameters": [ { "type": "string", @@ -3244,6 +3809,7 @@ "Admin" ], "summary": "Count transactions", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3285,6 +3851,7 @@ "Admin" ], "summary": "Record transactions", + "deprecated": true, "parameters": [ { "description": "RecordTransaction model containing hex of the transaction to record", @@ -3327,6 +3894,7 @@ "Admin" ], "summary": "Search for transactions", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3371,6 +3939,7 @@ "Admin" ], "summary": "Count utxos", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3412,6 +3981,7 @@ "Admin" ], "summary": "Search for utxos", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -3456,6 +4026,7 @@ "Admin" ], "summary": "Get All Webhooks", + "deprecated": true, "responses": { "200": { "description": "List of webhooks", @@ -3485,6 +4056,7 @@ "Admin" ], "summary": "Subscribe to a webhook", + "deprecated": true, "parameters": [ { "description": "URL to subscribe to and optional token header and value", @@ -3521,6 +4093,7 @@ "Admin" ], "summary": "Unsubscribe to a webhook", + "deprecated": true, "parameters": [ { "description": "URL to unsubscribe from", @@ -3559,6 +4132,7 @@ "Admin" ], "summary": "Create xPub", + "deprecated": true, "parameters": [ { "description": " ", @@ -3601,6 +4175,7 @@ "Admin" ], "summary": "Count xpubs", + "deprecated": true, "parameters": [ { "description": "Enables filtering of elements to be counted", @@ -3642,6 +4217,7 @@ "Admin" ], "summary": "Search for xpubs", + "deprecated": true, "parameters": [ { "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", @@ -4667,7 +5243,7 @@ "key2": "value2" } }, - "public_name": { + "publicName": { "description": "The public name of the paymail", "type": "string", "example": "Test" @@ -7529,6 +8105,26 @@ } } }, + "response.PageModel-response_PaymailAddress": { + "type": "object", + "properties": { + "content": { + "description": "Content is the collection of elements that serves as the content", + "type": "array", + "items": { + "$ref": "#/definitions/response.PaymailAddress" + } + }, + "page": { + "description": "Page is the page descriptor", + "allOf": [ + { + "$ref": "#/definitions/response.PageDescription" + } + ] + } + } + }, "response.PageModel-response_Transaction": { "type": "object", "properties": { @@ -7569,6 +8165,26 @@ } } }, + "response.PageModel-response_Xpub": { + "type": "object", + "properties": { + "content": { + "description": "Content is the collection of elements that serves as the content", + "type": "array", + "items": { + "$ref": "#/definitions/response.Xpub" + } + }, + "page": { + "description": "Page is the page descriptor", + "allOf": [ + { + "$ref": "#/definitions/response.PageDescription" + } + ] + } + } + }, "response.PaymailAddress": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index cfd6262fe..816e4124c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -34,7 +34,7 @@ definitions: key: value key2: value2 type: object - public_name: + publicName: description: The public name of the paymail example: Test type: string @@ -2124,6 +2124,18 @@ definitions: - $ref: '#/definitions/response.PageDescription' description: Page is the page descriptor type: object + response.PageModel-response_PaymailAddress: + properties: + content: + description: Content is the collection of elements that serves as the content + items: + $ref: '#/definitions/response.PaymailAddress' + type: array + page: + allOf: + - $ref: '#/definitions/response.PageDescription' + description: Page is the page descriptor + type: object response.PageModel-response_Transaction: properties: content: @@ -2148,6 +2160,18 @@ definitions: - $ref: '#/definitions/response.PageDescription' description: Page is the page descriptor type: object + response.PageModel-response_Xpub: + properties: + content: + description: Content is the collection of elements that serves as the content + items: + $ref: '#/definitions/response.Xpub' + type: array + page: + allOf: + - $ref: '#/definitions/response.PageDescription' + description: Page is the page descriptor + type: object response.PaymailAddress: properties: address: @@ -2779,25 +2803,92 @@ paths: - Admin /api/v1/admin/contacts: get: - description: Search for contacts + description: Fetches a list of contacts filtered by metadata and other criteria parameters: - - description: Supports targeted resource searches with filters and metadata, - plus options for pagination and sorting to streamline data exploration and - analysis - in: body - name: AdminSearchContacts - schema: - $ref: '#/definitions/filter.AdminContactFilter' + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[to] + type: string + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 + in: query + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort + type: string + - in: query + name: sortBy + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - example: Alice + in: query + name: fullName + type: string + - example: ffdbe74e-0700-4710-aac5-611a1f877c7f + in: query + name: id + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean + - example: alice@example.com + in: query + name: paymail + type: string + - example: 0334f01ecb971e93db179e6fb320cd1466beb0c1ec6c1c6a37aa6cb02e53d5dd1a + in: query + name: pubKey + type: string + - enum: + - unconfirmed + - awaiting + - confirmed + - rejected + in: query + name: status + type: string + - example: 623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486 + in: query + name: xpubId + type: string produces: - application/json responses: "200": - description: List of contacts + description: List of contacts with pagination details schema: $ref: '#/definitions/response.PageModel-response_Contact' "400": - description: Bad request - Error while parsing AdminSearchContacts from - request body + description: Bad request - Invalid query parameters "500": description: Internal server error - Error while searching for contacts security: @@ -2948,27 +3039,85 @@ paths: - Admin /api/v1/admin/paymails: get: - description: Paymail addresses search + description: Fetches a list of paymail addresses filtered by metadata and other + query parameters parameters: - - description: Supports targeted resource searches with filters and metadata, - plus options for pagination and sorting to streamline data exploration and - analysis - in: body - name: SearchPaymails - schema: - $ref: '#/definitions/filter.AdminPaymailFilter' + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[to] + type: string + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 + in: query + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort + type: string + - in: query + name: sortBy + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - example: alice + in: query + name: alias + type: string + - example: example.com + in: query + name: domain + type: string + - example: ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542 + in: query + name: id + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean + - example: Alice + in: query + name: publicName + type: string + - example: 79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00 + in: query + name: xpubId + type: string produces: - application/json responses: "200": - description: List of paymail addresses + description: List of paymail addresses with pagination schema: - items: - $ref: '#/definitions/response.PaymailAddress' - type: array + $ref: '#/definitions/response.PageModel-response_PaymailAddress' "400": - description: Bad request - Error while parsing SearchPaymails from request - body + description: Bad request - Invalid query parameters "500": description: Internal server error - Error while searching for paymail addresses security: @@ -3005,11 +3154,11 @@ paths: delete: description: Delete paymail parameters: - - description: PaymailAddress model containing paymail address to delete - in: body - name: PaymailAddress - schema: - $ref: '#/definitions/admin.PaymailAddress' + - description: id of the paymail + in: path + name: id + required: true + type: string produces: - application/json responses: @@ -3026,25 +3175,25 @@ paths: tags: - Admin get: - description: Get paymail + description: Fetches a paymail address by its ID parameters: - - description: PaymailAddress model containing paymail address to get - in: body - name: PaymailAddress - schema: - $ref: '#/definitions/admin.PaymailAddress' + - description: Paymail ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": - description: PaymailAddress with given address + description: PaymailAddress with the given ID schema: $ref: '#/definitions/response.PaymailAddress' "400": - description: Bad request - Error while parsing PaymailAddress from request - body + description: Bad request - Invalid ID "500": - description: Internal Server Error - Error while getting paymail address + description: Internal Server Error - Error while retrieving the paymail + address security: - x-auth-xpub: [] summary: Get paymail @@ -3084,48 +3233,137 @@ paths: - Admin /api/v1/admin/transactions: get: - description: Search for transactions + description: Fetches a list of transactions filtered by metadata and other criteria parameters: - - description: Filter by metadata in the form of key-value pairs + - example: "2024-02-26T11:01:28Z" in: query - name: metadata + name: createdRange[from] type: string - - description: Additional conditions for filtering, in URL-encoded JSON + - example: "2024-02-26T11:01:28Z" in: query - name: conditions + name: createdRange[to] type: string - - description: Pagination and sorting options + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 in: query - name: queryParams + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort type: string - produces: - - application/json - responses: - "200": - description: List of transactions - schema: - items: - $ref: '#/definitions/response.Transaction' - type: array - "400": - description: Bad request - Error while parsing query parameters - "500": - description: Internal server error - Error while searching for transactions - security: - - x-auth-xpub: [] - summary: Search for transactions - tags: - - Admin - /api/v1/admin/transactions/{id}: - get: - description: Get transaction by id for admins - parameters: - - description: Transaction ID - in: path - name: id - required: true + - in: query + name: sortBy type: string - produces: + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - example: 0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8 + in: query + name: blockHash + type: string + - example: 839376 + in: query + name: blockHeight + type: integer + - example: d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14 + in: query + name: draftId + type: string + - example: 1 + in: query + name: fee + type: integer + - in: query + name: hex + type: string + - example: d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14 + in: query + name: id + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean + - example: 1 + in: query + name: numberOfInputs + type: integer + - example: 2 + in: query + name: numberOfOutputs + type: integer + - enum: + - UNKNOWN + - QUEUED + - RECEIVED + - STORED + - ANNOUNCED_TO_NETWORK + - REQUESTED_BY_NETWORK + - SENT_TO_NETWORK + - ACCEPTED_BY_NETWORK + - SEEN_ON_NETWORK + - MINED + - SEEN_IN_ORPHAN_MEMPOOL + - CONFIRMED + - REJECTED + in: query + name: status + type: string + - example: 100000000 + in: query + name: totalValue + type: integer + - example: 623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486 + in: query + name: xpubId + type: string + produces: + - application/json + responses: + "200": + description: List of transactions with pagination details + schema: + $ref: '#/definitions/response.PageModel-response_Transaction' + "400": + description: Bad request - Invalid query parameters + "500": + description: Internal server error - Error while searching for transactions + security: + - x-auth-xpub: [] + summary: Search for transactions + tags: + - Admin + /api/v1/admin/transactions/{id}: + get: + description: Get transaction by id for admins + parameters: + - description: Transaction ID + in: path + name: id + required: true + type: string + produces: - application/json responses: "200": @@ -3143,29 +3381,74 @@ paths: - Admin /api/v1/admin/users: get: - description: Search for xpubs + description: Fetches a list of xpubs filtered by metadata and other criteria parameters: - - description: Supports targeted resource searches with filters and metadata, - plus options for pagination and sorting to streamline data exploration and - analysis - in: body - name: SearchXpubs - schema: - $ref: '#/definitions/filter.XpubFilter' + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[to] + type: string + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 + in: query + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort + type: string + - in: query + name: sortBy + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - example: 1000 + in: query + name: currentBalance + type: integer + - example: 00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d + in: query + name: id + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean produces: - application/json responses: "200": - description: List of xpubs + description: List of xPubs with pagination details schema: - items: - $ref: '#/definitions/response.Xpub' - type: array + $ref: '#/definitions/response.PageModel-response_Xpub' "400": - description: Bad request - Error while parsing SearchXpubs from request - body + description: Bad request - Invalid query parameters "500": - description: Internal server error - Error while searching for xpubs + description: Internal server error - Error while searching for xPubs security: - x-auth-xpub: [] summary: Search for xpubs @@ -3198,27 +3481,68 @@ paths: - Admin /api/v1/admin/users/keys: get: - description: Access Keys Search + description: Fetches a list of access keys filtered by metadata, creation range, + and other parameters. parameters: - - description: Supports targeted resource searches with filters and metadata, - plus options for pagination and sorting to streamline data exploration and - analysis - in: body - name: SearchAccessKeys - schema: - $ref: '#/definitions/filter.AdminSearchAccessKeys' + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[to] + type: string + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 + in: query + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort + type: string + - in: query + name: sortBy + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean + - in: query + name: xpubId + type: string produces: - application/json responses: "200": - description: List of access keys + description: List of access keys with pagination details schema: - items: - $ref: '#/definitions/response.AccessKey' - type: array + $ref: '#/definitions/response.PageModel-response_AccessKey' "400": - description: Bad request - Error while parsing SearchAccessKeys from request - body + description: Bad request - Invalid query parameters "500": description: Internal server error - Error while searching for access keys security: @@ -3228,29 +3552,110 @@ paths: - Admin /api/v1/admin/utxos: get: - description: Search for utxos + description: Fetches a list of UTXOs filtered by metadata and other criteria parameters: - - description: Supports targeted resource searches with filters and metadata, - plus options for pagination and sorting to streamline data exploration and - analysis - in: body - name: SearchUtxos - schema: - $ref: '#/definitions/filter.AdminUtxoFilter' + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: createdRange[to] + type: string + - collectionFormat: csv + description: Metadata is a list of key-value pairs that can be used to filter + the results. !ATTENTION! Unfortunately this parameter won't work from swagger + UI. + example: + - metadata[key]=value + - metadata[key2]=value2 + in: query + items: + type: string + name: metadata + type: array + - in: query + name: page + type: integer + - in: query + name: size + type: integer + - in: query + name: sort + type: string + - in: query + name: sortBy + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[from] + type: string + - example: "2024-02-26T11:01:28Z" + in: query + name: updatedRange[to] + type: string + - example: 89419d4c7c50810bfe5ff9df9ad5074b749959423782dc91a30f1058b9ad7ef7 + in: query + name: draftId + type: string + - example: fe4cbfee0258aa589cbc79963f7c204061fd67d987e32ee5049aa90ce14658ee + in: query + name: id + type: string + - default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + in: query + name: includeDeleted + type: boolean + - example: 0 + in: query + name: outputIndex + type: integer + - example: 1 + in: query + name: satoshis + type: integer + - example: 76a914a5f271385e75f57bcd9092592dede812f8c466d088ac + in: query + name: scriptPubKey + type: string + - example: 11a7746489a70e9c0170601c2be65558455317a984194eb2791b637f59f8cd6e + in: query + name: spendingTxId + type: string + - example: 5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6 + in: query + name: transactionId + type: string + - enum: + - pubkey + - pubkeyhash + - nulldata + - multisig + - nonstandard + - scripthash + - metanet + - token_stas + - token_sensible + in: query + name: type + type: string + - in: query + name: xpubId + type: string produces: - application/json responses: "200": - description: List of utxos + description: List of UTXOs with pagination details schema: - items: - $ref: '#/definitions/response.Utxo' - type: array + $ref: '#/definitions/response.PageModel-response_Utxo' "400": - description: Bad request - Error while parsing SearchUtxos from request - body + description: Bad request - Invalid query parameters "500": - description: Internal server error - Error while searching for utxos + description: Internal server error - Error while searching for UTXOs security: - x-auth-xpub: [] summary: Search for utxos @@ -4350,6 +4755,7 @@ paths: - Access-key /v1/admin/access-keys/count: post: + deprecated: true description: Access Keys Count parameters: - description: Enables filtering of elements to be counted @@ -4377,6 +4783,7 @@ paths: - Admin /v1/admin/access-keys/search: post: + deprecated: true description: Access Keys Search parameters: - description: Supports targeted resource searches with filters and metadata, @@ -4407,6 +4814,7 @@ paths: - Admin /v1/admin/contact/{id}: delete: + deprecated: true description: Delete contact parameters: - description: Contact id @@ -4433,6 +4841,7 @@ paths: tags: - Admin patch: + deprecated: true description: Update contact FullName or Metadata parameters: - description: Contact id @@ -4467,6 +4876,7 @@ paths: - Admin /v1/admin/contact/accepted/{id}: patch: + deprecated: true description: Accept contact parameters: - description: Contact id @@ -4495,6 +4905,7 @@ paths: - Admin /v1/admin/contact/rejected/{id}: patch: + deprecated: true description: Reject contact parameters: - description: Contact id @@ -4523,6 +4934,7 @@ paths: - Admin /v1/admin/contact/search: post: + deprecated: true description: Search for contacts parameters: - description: Supports targeted resource searches with filters and metadata, @@ -4609,6 +5021,7 @@ paths: - Admin /v1/admin/destinations/search: post: + deprecated: true description: Search for destinations parameters: - description: Supports targeted resource searches with filters and metadata, @@ -4639,6 +5052,7 @@ paths: - Admin /v1/admin/paymail/create: post: + deprecated: true description: Create paymail parameters: - description: ' ' @@ -4665,6 +5079,7 @@ paths: - Admin /v1/admin/paymail/delete: delete: + deprecated: true description: Delete paymail parameters: - description: PaymailAddress model containing paymail address to delete @@ -4689,6 +5104,7 @@ paths: - Admin /v1/admin/paymail/get: post: + deprecated: true description: Get paymail parameters: - description: PaymailAddress model containing paymail address to get @@ -4715,6 +5131,7 @@ paths: - Admin /v1/admin/paymails/count: post: + deprecated: true description: Paymail addresses count parameters: - description: Enables filtering of elements to be counted @@ -4742,6 +5159,7 @@ paths: - Admin /v1/admin/paymails/search: post: + deprecated: true description: Paymail addresses search parameters: - description: Supports targeted resource searches with filters and metadata, @@ -4808,6 +5226,7 @@ paths: - Admin /v1/admin/transactions: get: + deprecated: true description: Search for transactions parameters: - description: Filter by metadata in the form of key-value pairs @@ -4872,6 +5291,7 @@ paths: - Admin /v1/admin/transactions/count: post: + deprecated: true description: Count transactions parameters: - description: Enables filtering of elements to be counted @@ -4898,6 +5318,7 @@ paths: - Admin /v1/admin/transactions/record: post: + deprecated: true description: Record transactions parameters: - description: RecordTransaction model containing hex of the transaction to @@ -4927,6 +5348,7 @@ paths: - Admin /v1/admin/transactions/search: post: + deprecated: true description: Search for transactions parameters: - description: Supports targeted resource searches with filters and metadata, @@ -4957,6 +5379,7 @@ paths: - Admin /v1/admin/utxos/count: post: + deprecated: true description: Count utxos parameters: - description: Enables filtering of elements to be counted @@ -4982,6 +5405,7 @@ paths: - Admin /v1/admin/utxos/search: post: + deprecated: true description: Search for utxos parameters: - description: Supports targeted resource searches with filters and metadata, @@ -5012,6 +5436,7 @@ paths: - Admin /v1/admin/webhooks/subscriptions: delete: + deprecated: true description: Unsubscribe to a webhook to stop receiving notifications parameters: - description: URL to unsubscribe from @@ -5034,6 +5459,7 @@ paths: tags: - Admin get: + deprecated: true description: Get All Webhooks currently subscribed to produces: - application/json @@ -5052,6 +5478,7 @@ paths: tags: - Admin post: + deprecated: true description: Subscribe to a webhook to receive notifications parameters: - description: URL to subscribe to and optional token header and value @@ -5075,6 +5502,7 @@ paths: - Admin /v1/admin/xpub: post: + deprecated: true description: Create xPub parameters: - description: ' ' @@ -5101,6 +5529,7 @@ paths: - Admin /v1/admin/xpubs/count: post: + deprecated: true description: Count xpubs parameters: - description: Enables filtering of elements to be counted @@ -5126,6 +5555,7 @@ paths: - Admin /v1/admin/xpubs/search: post: + deprecated: true description: Search for xpubs parameters: - description: Supports targeted resource searches with filters and metadata, diff --git a/engine/action_paymails.go b/engine/action_paymails.go index b6a7e7276..7e69fbd51 100644 --- a/engine/action_paymails.go +++ b/engine/action_paymails.go @@ -138,6 +138,28 @@ func (c *Client) NewPaymailAddress(ctx context.Context, xPubKey, address, public return paymailAddress, nil } +// DeletePaymailAddressByID will delete a paymail address by its id +func (c *Client) DeletePaymailAddressByID(ctx context.Context, id string, opts ...ModelOps) error { + + // Get the paymail address + paymailAddress, err := getPaymailAddressByID(ctx, id, append(opts, c.DefaultModelOptions()...)...) + if err != nil { + return err + } else if paymailAddress == nil { + return spverrors.ErrCouldNotFindPaymail + } + + paymailAddress.DeletedAt.Valid = true + paymailAddress.DeletedAt.Time = time.Now() + + tx := c.Datastore().DB().Save(&paymailAddress) + if tx.Error != nil { + return spverrors.ErrDeletePaymailAddress.Wrap(tx.Error) + } + + return nil +} + // DeletePaymailAddress will delete a paymail address func (c *Client) DeletePaymailAddress(ctx context.Context, address string, opts ...ModelOps) error { diff --git a/engine/client.go b/engine/client.go index 682b70f62..a351543c4 100644 --- a/engine/client.go +++ b/engine/client.go @@ -9,7 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/chain" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" - "github.com/bitcoin-sv/spv-wallet/engine/database/dao" + "github.com/bitcoin-sv/spv-wallet/engine/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/logging" "github.com/bitcoin-sv/spv-wallet/engine/metrics" @@ -55,8 +55,7 @@ type ( arcConfig chainmodels.ARCConfig // Configuration for ARC bhsConfig chainmodels.BHSConfig // Configuration for BHS feeUnit *bsv.FeeUnit // Fee unit for transactions - transactionsDAO *dao.Transactions - usersDAO *dao.Users + repositories *repository.All // Repositories for all db models } // cacheStoreOptions holds the cache configuration and client @@ -145,7 +144,7 @@ func NewClient(ctx context.Context, opts ...ClientOps) (ClientInterface, error) return nil, err } - client.loadDAOs() + client.loadRepositories() // Load the Paymail client and service (if does not exist) if err = client.loadPaymailComponents(); err != nil { @@ -336,12 +335,7 @@ func (c *Client) FeeUnit() bsv.FeeUnit { return *c.options.feeUnit } -// TransactionsDAO will return the Transactions DAO -func (c *Client) TransactionsDAO() *dao.Transactions { - return c.options.transactionsDAO -} - -// UsersDAO will return the Users DAO -func (c *Client) UsersDAO() *dao.Users { - return c.options.usersDAO +// Repositories will return all the repositories +func (c *Client) Repositories() *repository.All { + return c.options.repositories } diff --git a/engine/client_internal.go b/engine/client_internal.go index 2ac0392ca..ab6b835be 100644 --- a/engine/client_internal.go +++ b/engine/client_internal.go @@ -7,7 +7,7 @@ import ( paymailserver "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/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/notifications" "github.com/bitcoin-sv/spv-wallet/engine/paymail" @@ -210,17 +210,20 @@ 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, dao.NewTransactionsAccessObject(c.Datastore().DB()), c.Chain()) + c.options.transactionRecordService = record.NewService( + logger, + c.Repositories().Addresses, + c.Repositories().Outputs, + c.Repositories().Operations, + c.Chain(), + ) } return nil } -func (c *Client) loadDAOs() { - if c.options.transactionsDAO == nil { - c.options.transactionsDAO = dao.NewTransactionsAccessObject(c.Datastore().DB()) - } - if c.options.usersDAO == nil { - c.options.usersDAO = dao.NewUsersAccessObject(c.Datastore().DB()) +func (c *Client) loadRepositories() { + if c.options.repositories == nil { + c.options.repositories = repository.NewRepositories(c.Datastore().DB()) } } @@ -286,7 +289,10 @@ func (c *Client) loadPaymailServer() (err error) { paymailServiceLogger := c.Logger().With().Str("subservice", "paymail-service-provider").Logger() serviceProvider = paymail.NewServiceProvider( &paymailServiceLogger, - c.UsersDAO(), + c.Repositories().Paymails, + c.Repositories().Users, + c.Chain(), + c.TransactionRecordService(), ) } else { serviceProvider = &PaymailDefaultServiceProvider{client: c} diff --git a/engine/database/dao/transactions.go b/engine/database/dao/transactions.go deleted file mode 100644 index 27bc9e4ca..000000000 --- a/engine/database/dao/transactions.go +++ /dev/null @@ -1,59 +0,0 @@ -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/dao/users.go b/engine/database/dao/users.go deleted file mode 100644 index 913fd7698..000000000 --- a/engine/database/dao/users.go +++ /dev/null @@ -1,63 +0,0 @@ -package dao - -import ( - "context" - "errors" - - "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" - "gorm.io/gorm" -) - -// Users is a data access object for users. -type Users struct { - db *gorm.DB -} - -// NewUsersAccessObject creates a new access object for users. -func NewUsersAccessObject(db *gorm.DB) *Users { - return &Users{db: db} -} - -// SaveUser saves a user to the database. -func (u *Users) SaveUser(ctx context.Context, userRow *database.User) error { - query := u.db.WithContext(ctx) - - if err := query.Create(userRow).Error; err != nil { - return spverrors.Wrapf(err, "failed to save user") - } - - return nil -} - -// GetPaymail returns a paymail by alias and domain. -func (u *Users) GetPaymail(ctx context.Context, alias, domain string) (*database.Paymail, error) { - var paymail database.Paymail - if err := u.db. - WithContext(ctx). - Preload("User"). - Where("alias = ? AND domain = ?", alias, domain). - First(&paymail).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - - return &paymail, nil -} - -// SaveAddress saves an address to the database. -func (u *Users) SaveAddress(ctx context.Context, userRow *database.User, addressRow *database.Address) error { - err := u.db. - WithContext(ctx). - Model(userRow). - Association("Addresses"). - Append(addressRow) - - if err != nil { - return spverrors.Wrapf(err, "failed to save address") - } - - return nil -} diff --git a/engine/database/dbquery/paginate.go b/engine/database/dbquery/paginate.go new file mode 100644 index 000000000..60c23e2af --- /dev/null +++ b/engine/database/dbquery/paginate.go @@ -0,0 +1,90 @@ +package dbquery + +import ( + "context" + "strings" + + "github.com/bitcoin-sv/spv-wallet/conv" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" + "gorm.io/gorm" +) + +// PagedResult is a generic struct for paginated results. +type PagedResult[T any] struct { + Content []*T + PageDescription response.PageDescription +} + +// PaginatedQuery is a generic function for getting paginated results from a database. +func PaginatedQuery[T any](ctx context.Context, page filter.Page, db *gorm.DB, queryFunc func(tx *gorm.DB) *gorm.DB) (*PagedResult[T], error) { + PageWithDefaults(&page) + model := PagedResult[T]{} + var modelType T + var totalElements int64 + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Model(&modelType).Scopes(queryFunc) + + if err := query. + Scopes(Paginate(page)). + Find(&model.Content).Error; err != nil { + return err + } + + if err := query. + Count(&totalElements).Error; err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, spverrors.Wrapf(err, "failed to get paginated result") + } + + model.PageDescription.Number = page.Number + model.PageDescription.Size = len(model.Content) + model.PageDescription.TotalElements, err = conv.Int64ToInt(totalElements) + if err != nil { + return nil, spverrors.Wrapf(err, "failed to convert total elements") + } + model.PageDescription.TotalPages = model.PageDescription.TotalElements / page.Size + if model.PageDescription.TotalElements%page.Size > 0 { + model.PageDescription.TotalPages++ + } + return &model, nil +} + +// Paginate is a Scope function that returns a function that paginates a database query. +func Paginate(page filter.Page) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + offset := (page.Number - 1) * page.Size + return db.Order(page.SortBy + " " + page.Sort).Offset(offset).Limit(page.Size) + } +} + +// PageWithDefaults sets default values for a Page object (in place). +func PageWithDefaults(page *filter.Page) { + if page.Number <= 0 { + page.Number = 1 + } + + switch { + case page.Size > 100: + page.Size = 100 + case page.Size <= 0: + page.Size = 20 + } + + page.SortBy = strings.ToLower(page.SortBy) + if page.SortBy == "" { + page.SortBy = "created_at" + } + + if strings.ToLower(page.Sort) == "arc" { + page.Sort = "ASC" + } else { + page.Sort = "DESC" + } +} diff --git a/engine/database/dbquery/scopes.go b/engine/database/dbquery/scopes.go new file mode 100644 index 000000000..31789ec59 --- /dev/null +++ b/engine/database/dbquery/scopes.go @@ -0,0 +1,10 @@ +package dbquery + +import "gorm.io/gorm" + +// UserID is a scope function that filters by user ID. +func UserID(id string) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Where("user_id = ?", id) + } +} diff --git a/engine/database/errors/errors.go b/engine/database/errors/errors.go new file mode 100644 index 000000000..07b99b61f --- /dev/null +++ b/engine/database/errors/errors.go @@ -0,0 +1,6 @@ +package dberrors + +import "github.com/bitcoin-sv/spv-wallet/models" + +// ErrDBFailed is when the database operation failed. +var ErrDBFailed = models.SPVError{Message: "database operation failed", StatusCode: 500, Code: "error-db-failed"} diff --git a/engine/database/models.go b/engine/database/models.go index b60874d11..4eb06854a 100644 --- a/engine/database/models.go +++ b/engine/database/models.go @@ -4,10 +4,12 @@ package database func Models() []any { return []any{ TrackedTransaction{}, - Output{}, + TrackedOutput{}, Data{}, User{}, Paymail{}, Address{}, + UserUTXO{}, + Operation{}, } } diff --git a/engine/database/operation.go b/engine/database/operation.go new file mode 100644 index 000000000..73e573c4e --- /dev/null +++ b/engine/database/operation.go @@ -0,0 +1,18 @@ +package database + +import "time" + +// Operation represents a user's operation on a transaction. +type Operation struct { + TxID string `gorm:"primaryKey"` + UserID string `gorm:"primaryKey"` + + CreatedAt time.Time + + Counterparty string + Type string + Value int64 + + User *User `gorm:"foreignKey:UserID"` + Transaction *TrackedTransaction `gorm:"foreignKey:TxID"` +} diff --git a/engine/database/repository/addresses.go b/engine/database/repository/addresses.go new file mode 100644 index 000000000..ee042241e --- /dev/null +++ b/engine/database/repository/addresses.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + "iter" + "slices" + + "github.com/bitcoin-sv/spv-wallet/engine/database" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "gorm.io/gorm" +) + +// Addresses is a repository for addresses. +type Addresses struct { + db *gorm.DB +} + +// NewAddressesRepo creates a new repository for addresses. +func NewAddressesRepo(db *gorm.DB) *Addresses { + return &Addresses{db: db} +} + +// FindByStringAddresses returns address rows from the database based on the provided iterator of string addresses. +func (r *Addresses) FindByStringAddresses(ctx context.Context, addresses iter.Seq[string]) ([]*database.Address, error) { + var rows []*database.Address + if err := r.db. + WithContext(ctx). + Model(&database.Address{}). + Where("address IN ?", slices.Collect(addresses)). + Find(&rows).Error; err != nil { + return nil, spverrors.Wrapf(err, "failed to get addresses") + } + + return rows, nil +} diff --git a/engine/database/repository/all.go b/engine/database/repository/all.go new file mode 100644 index 000000000..70a09f4e1 --- /dev/null +++ b/engine/database/repository/all.go @@ -0,0 +1,23 @@ +package repository + +import "gorm.io/gorm" + +// All holds all repositories. +type All struct { + Addresses *Addresses + Paymails *Paymails + Operations *Operations + Users *Users + Outputs *Outputs +} + +// NewRepositories creates a new holder for all repositories. +func NewRepositories(db *gorm.DB) *All { + return &All{ + Addresses: NewAddressesRepo(db), + Paymails: NewPaymailsRepo(db), + Operations: NewOperationsRepo(db), + Users: NewUsersRepo(db), + Outputs: NewOutputsRepo(db), + } +} diff --git a/engine/database/repository/operations.go b/engine/database/repository/operations.go new file mode 100644 index 000000000..6cef8d4d3 --- /dev/null +++ b/engine/database/repository/operations.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "iter" + "slices" + + "github.com/bitcoin-sv/spv-wallet/engine/database" + "github.com/bitcoin-sv/spv-wallet/engine/database/dbquery" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Operations is a repository for operations. +type Operations struct { + db *gorm.DB +} + +// NewOperationsRepo creates a new repository for operations. +func NewOperationsRepo(db *gorm.DB) *Operations { + return &Operations{db: db} +} + +// PaginatedForUser returns operations for a user based on userID and the provided paging options. +func (o *Operations) PaginatedForUser(ctx context.Context, userID string, page filter.Page) (*dbquery.PagedResult[database.Operation], error) { + return dbquery.PaginatedQuery[database.Operation](ctx, page, o.db, dbquery.UserID(userID)) +} + +// SaveAll saves operations to the database. +func (o *Operations) SaveAll(ctx context.Context, opRows iter.Seq[*database.Operation]) error { + query := o.db. + WithContext(ctx). + Clauses(clause.OnConflict{ + UpdateAll: true, + }) + + if err := query.Create(slices.Collect(opRows)).Error; err != nil { + return err + } + + return nil +} diff --git a/engine/database/repository/outputs.go b/engine/database/repository/outputs.go new file mode 100644 index 000000000..07290c765 --- /dev/null +++ b/engine/database/repository/outputs.go @@ -0,0 +1,42 @@ +package repository + +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" +) + +// Outputs is a repository for outputs. +type Outputs struct { + db *gorm.DB +} + +// NewOutputsRepo creates a new repository for outputs. +func NewOutputsRepo(db *gorm.DB) *Outputs { + return &Outputs{db: db} +} + +// FindByOutpoints returns outputs from the database based on the provided outpoints. +func (o *Outputs) FindByOutpoints(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.TrackedOutput, error) { + outpointsClause := slices.Collect(func(yield func(sqlPair []any) bool) { + for outpoint := range outpoints { + yield([]any{outpoint.TxID, outpoint.Vout}) + } + }) + + var outputs []*database.TrackedOutput + + if err := o.db.WithContext(ctx). + Model(&database.TrackedOutput{}). + Where("(tx_id, vout) IN ?", outpointsClause). + Find(&outputs).Error; err != nil { + return nil, spverrors.Wrapf(err, "failed to get outputs") + } + + return outputs, nil +} diff --git a/engine/database/repository/paymails.go b/engine/database/repository/paymails.go new file mode 100644 index 000000000..2d228f12a --- /dev/null +++ b/engine/database/repository/paymails.go @@ -0,0 +1,36 @@ +package repository + +import ( + "context" + "errors" + + "github.com/bitcoin-sv/spv-wallet/engine/database" + "gorm.io/gorm" +) + +// Paymails is a repository for paymails. +type Paymails struct { + db *gorm.DB +} + +// NewPaymailsRepo creates a new repository for paymails. +func NewPaymailsRepo(db *gorm.DB) *Paymails { + return &Paymails{db: db} +} + +// Get returns a paymail by alias and domain. +func (p *Paymails) Get(ctx context.Context, alias, domain string) (*database.Paymail, error) { + var paymail database.Paymail + if err := p.db. + WithContext(ctx). + Preload("User"). + Where("alias = ? AND domain = ?", alias, domain). + First(&paymail).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &paymail, nil +} diff --git a/engine/database/repository/users.go b/engine/database/repository/users.go new file mode 100644 index 000000000..e8bc97a64 --- /dev/null +++ b/engine/database/repository/users.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + + "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" +) + +// Users is a repository for users. +type Users struct { + db *gorm.DB +} + +// NewUsersRepo creates a new repository for users. +func NewUsersRepo(db *gorm.DB) *Users { + return &Users{db: db} +} + +// Save saves a user to the database. +func (u *Users) Save(ctx context.Context, userRow *database.User) error { + query := u.db.WithContext(ctx) + + if err := query.Create(userRow).Error; err != nil { + return spverrors.Wrapf(err, "failed to save user") + } + + return nil +} + +// AppendAddress appends an address to the database. +func (u *Users) AppendAddress(ctx context.Context, userRow *database.User, addressRow *database.Address) error { + err := u.db. + WithContext(ctx). + Model(userRow). + Association("Addresses"). + Append(addressRow) + + if err != nil { + return spverrors.Wrapf(err, "failed to save address") + } + + return nil +} + +// GetBalance returns the balance of a user in a given bucket. +func (u *Users) GetBalance(ctx context.Context, userID string, bucket string) (bsv.Satoshis, error) { + var balance bsv.Satoshis + err := u.db. + WithContext(ctx). + Model(&database.UserUTXO{}). + Where("user_id = ? AND bucket = ?", userID, bucket). + Select("COALESCE(SUM(satoshis), 0)"). + Row(). + Scan(&balance) + + if err != nil { + return 0, spverrors.Wrapf(err, "failed to get balance") + } + + return balance, nil +} diff --git a/engine/database/testabilities/fixture_database.go b/engine/database/testabilities/fixture_database.go index 07fe4717c..117719b4d 100644 --- a/engine/database/testabilities/fixture_database.go +++ b/engine/database/testabilities/fixture_database.go @@ -6,7 +6,6 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/database" testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" "github.com/bitcoin-sv/spv-wallet/models/bsv" - "github.com/stretchr/testify/require" "gorm.io/gorm" ) @@ -27,7 +26,7 @@ type UserUtxoFixture interface { // WithSatoshis sets the satoshis value of the UTXO. WithSatoshis(satoshis bsv.Satoshis) UserUtxoFixture - Storable[database.UserUtxos] + Storable[database.UserUTXO] } type Storable[Data any] interface { @@ -41,19 +40,15 @@ type databaseFixture struct { utxoEntriesIndex uint32 } -func Given(t testing.TB) (given DatabaseFixture, cleanup func()) { - engineWithConfign, cleanup := testengine.Given(t).Engine() +func Given(t testing.TB, opts ...testengine.ConfigOpts) (given DatabaseFixture, cleanup func()) { + engineWithConfig, cleanup := testengine.Given(t).EngineWithConfiguration(opts...) - db := engineWithConfign.Engine.Datastore().DB() + db := engineWithConfig.Engine.Datastore().DB() fixture := &databaseFixture{ t: t, db: db, } - // TODO: remove this when we will include UserUtxos in the production code - err := fixture.db.AutoMigrate(&database.UserUtxos{}) - require.NoError(t, err) - return fixture, cleanup } diff --git a/engine/database/testabilities/user_utxo_fixture.go b/engine/database/testabilities/user_utxo_fixture.go index 9c2a04b6c..cd746c5cb 100644 --- a/engine/database/testabilities/user_utxo_fixture.go +++ b/engine/database/testabilities/user_utxo_fixture.go @@ -8,32 +8,33 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/database" "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" "github.com/bitcoin-sv/spv-wallet/models/bsv" + "github.com/bitcoin-sv/spv-wallet/models/transaction/bucket" "gorm.io/gorm" ) var FirstCreatedAt = time.Date(2006, 02, 01, 15, 4, 5, 7, time.UTC) type userUtxoFixture struct { - db *gorm.DB - t testing.TB - index uint - xpubID string - txID string - vout uint32 - satoshis bsv.Satoshis - unlockingScriptEstimatedSize uint64 + db *gorm.DB + t testing.TB + index uint + userID string + txID string + vout uint32 + satoshis bsv.Satoshis + estimatedInputSize uint64 } func newUtxoFixture(t testing.TB, db *gorm.DB, index uint32) *userUtxoFixture { return &userUtxoFixture{ - t: t, - db: db, - index: uint(index), - xpubID: fixtures.Sender.XPubID(), - txID: txIDTemplated(uint(index)), - vout: index, - satoshis: 1, - unlockingScriptEstimatedSize: 106, + t: t, + db: db, + index: uint(index), + userID: fixtures.Sender.ID(), + txID: txIDTemplated(uint(index)), + vout: index, + satoshis: 1, + estimatedInputSize: database.EstimatedInputSizeForP2PKH, } } @@ -42,12 +43,12 @@ func txIDTemplated(index uint) string { } func (f *userUtxoFixture) OwnedBySender() UserUtxoFixture { - f.xpubID = fixtures.Sender.XPubID() + f.userID = fixtures.Sender.ID() return f } func (f *userUtxoFixture) P2PKH() UserUtxoFixture { - f.unlockingScriptEstimatedSize = fixtures.EstimatedUnlockingScriptSizeForP2PKH + f.estimatedInputSize = database.EstimatedInputSizeForP2PKH return f } @@ -56,16 +57,16 @@ func (f *userUtxoFixture) WithSatoshis(satoshis bsv.Satoshis) UserUtxoFixture { return f } -func (f *userUtxoFixture) Stored() *database.UserUtxos { - utxo := &database.UserUtxos{ - XPubID: f.xpubID, - TxID: f.txID, - Vout: f.vout, - Satoshis: uint64(f.satoshis), - UnlockingScriptEstimatedSize: f.unlockingScriptEstimatedSize, - Bucket: "bsv", - CreatedAt: FirstCreatedAt.Add(time.Duration(f.index) * time.Second), //nolint:gosec // this is used for testing and it should be fine even in case of integer overflow. - TouchedAt: FirstCreatedAt.Add(time.Duration(24) * time.Hour), +func (f *userUtxoFixture) Stored() *database.UserUTXO { + utxo := &database.UserUTXO{ + UserID: f.userID, + TxID: f.txID, + Vout: f.vout, + Satoshis: uint64(f.satoshis), + EstimatedInputSize: f.estimatedInputSize, + Bucket: string(bucket.BSV), + CreatedAt: FirstCreatedAt.Add(time.Duration(f.index) * time.Second), //nolint:gosec // this is used for testing and it should be fine even in case of integer overflow. + TouchedAt: FirstCreatedAt.Add(time.Duration(24) * time.Hour), } f.db.Create(utxo) diff --git a/engine/database/output.go b/engine/database/tracked_output.go similarity index 51% rename from engine/database/output.go rename to engine/database/tracked_output.go index 8bb62daf8..f8258a647 100644 --- a/engine/database/output.go +++ b/engine/database/tracked_output.go @@ -1,21 +1,32 @@ package database -import "github.com/bitcoin-sv/spv-wallet/models/bsv" +import ( + "time" -// Output represents an output of a transaction. -type Output struct { + "github.com/bitcoin-sv/spv-wallet/models/bsv" +) + +// TrackedOutput represents an output of a transaction. +type TrackedOutput struct { TxID string `gorm:"primaryKey"` Vout uint32 `gorm:"primaryKey"` SpendingTX string `gorm:"type:char(64)"` + + UserID string + + Satoshis bsv.Satoshis + + CreatedAt time.Time + UpdatedAt time.Time } // IsSpent returns true if the output is spent. -func (o *Output) IsSpent() bool { +func (o *TrackedOutput) IsSpent() bool { return o.SpendingTX != "" } // Outpoint returns bsv.Outpoint object which identifies the output. -func (o *Output) Outpoint() *bsv.Outpoint { +func (o *TrackedOutput) Outpoint() *bsv.Outpoint { return &bsv.Outpoint{ TxID: o.TxID, Vout: o.Vout, diff --git a/engine/database/tracked_transaction.go b/engine/database/tracked_transaction.go index e9b0bca8b..540927fcd 100644 --- a/engine/database/tracked_transaction.go +++ b/engine/database/tracked_transaction.go @@ -1,26 +1,77 @@ package database +import ( + "slices" + "time" + + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "gorm.io/datatypes" + "gorm.io/gorm" +) + // 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"` + CreatedAt time.Time + UpdatedAt time.Time + + Data []*Data `gorm:"foreignKey:TxID"` + + Inputs []*TrackedOutput `gorm:"foreignKey:SpendingTX"` + Outputs []*TrackedOutput `gorm:"foreignKey:TxID"` + + newUTXOs []*UserUTXO `gorm:"-"` } -// AddOutputs adds outputs to the transaction. -func (t *TrackedTransaction) AddOutputs(outputs ...*Output) { - t.Outputs = append(t.Outputs, outputs...) +// CreateP2PKHOutput prepares a new P2PKH output and adds it to the transaction. +func (t *TrackedTransaction) CreateP2PKHOutput(output *TrackedOutput, customInstructions datatypes.JSONSlice[CustomInstruction]) { + t.Outputs = append(t.Outputs, output) + t.newUTXOs = append(t.newUTXOs, NewP2PKHUserUTXO(output, customInstructions)) +} + +// CreateDataOutput prepares a new Data output and adds it to the transaction. +func (t *TrackedTransaction) CreateDataOutput(data *Data, userID string) { + t.Data = append(t.Data, data) + t.Outputs = append(t.Outputs, &TrackedOutput{ + TxID: data.TxID, + Vout: data.Vout, + UserID: userID, + Satoshis: 0, + }) } // AddInputs adds inputs to the transaction. -func (t *TrackedTransaction) AddInputs(inputs ...*Output) { +func (t *TrackedTransaction) AddInputs(inputs ...*TrackedOutput) { t.Inputs = append(t.Inputs, inputs...) } -// AddData adds data to the transaction. -func (t *TrackedTransaction) AddData(data ...*Data) { - t.Data = append(t.Data, data...) +// AfterCreate is a hook that is called after creating the transaction. +// It is responsible for adding new (User's) UTXOs and removing spent UTXOs. +func (t *TrackedTransaction) AfterCreate(tx *gorm.DB) error { + // Add new UTXOs + if len(t.newUTXOs) > 0 { + err := tx.Model(&UserUTXO{}).Create(t.newUTXOs).Error + if err != nil { + return spverrors.Wrapf(err, "failed to save user utxos") + } + } + + // Remove spent UTXOs + spentOutpoints := slices.AppendSeq( + make([][]any, 0, len(t.Inputs)), + func(yield func(sqlPair []any) bool) { + for _, outpoint := range t.Inputs { + yield([]any{outpoint.TxID, outpoint.Vout}) + } + }) + if len(spentOutpoints) > 0 { + err := tx.Where("(tx_id, vout) IN ?", spentOutpoints).Delete(&UserUTXO{}).Error + if err != nil { + return spverrors.Wrapf(err, "failed to delete spent utxos") + } + } + + return nil } diff --git a/engine/database/user_utxos.go b/engine/database/user_utxos.go index 211f34f9e..3ff670412 100644 --- a/engine/database/user_utxos.go +++ b/engine/database/user_utxos.go @@ -1,15 +1,44 @@ package database -import "time" - -// UserUtxos is a table holding user's Unspent Transaction Outputs (UTXOs). -type UserUtxos struct { - XPubID string `gorm:"primaryKey;column:xpub_id;uniqueIndex:idx_window,sort:asc,priority:1"` - TxID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:4"` - Vout uint32 `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:5"` - Satoshis uint64 - UnlockingScriptEstimatedSize uint64 - Bucket string `gorm:"check:chk_not_data_bucket,bucket <> 'data'"` - CreatedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:3"` - TouchedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:2"` +import ( + "time" + + "gorm.io/datatypes" +) + +// EstimatedInputSizeForP2PKH is the estimated size increase when adding and unlocking P2PKH input to transaction. +// 32 bytes txID +// + 4 bytes vout index +// + 1 byte script length +// + 107 bytes script pub key +// + 4 bytes nSequence +const EstimatedInputSizeForP2PKH = 148 + +// UserUTXO is a table holding user's Unspent Transaction Outputs (UTXOs). +type UserUTXO struct { + UserID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:1"` + TxID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:4"` + Vout uint32 `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:5"` + Satoshis uint64 + // EstimatedInputSize is the estimated size increase when adding and unlocking this UTXO to a transaction. + EstimatedInputSize uint64 + Bucket string `gorm:"check:chk_not_data_bucket,bucket <> 'data'"` + CreatedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:3"` + // TouchedAt is the time when the UTXO was last touched (selected for preparing transaction outline) - used for prioritizing UTXO selection. + TouchedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:2"` + // CustomInstructions is the list of instructions for unlocking given UTXO (it should be understood by client). + CustomInstructions datatypes.JSONSlice[CustomInstruction] +} + +// NewP2PKHUserUTXO creates a new UserUTXO instance for a P2PKH output based on the given output and custom instructions. +func NewP2PKHUserUTXO(output *TrackedOutput, customInstructions datatypes.JSONSlice[CustomInstruction]) *UserUTXO { + return &UserUTXO{ + UserID: output.UserID, + TxID: output.TxID, + Vout: output.Vout, + Satoshis: uint64(output.Satoshis), + EstimatedInputSize: EstimatedInputSizeForP2PKH, + Bucket: "bsv", + CustomInstructions: customInstructions, + } } diff --git a/engine/interface.go b/engine/interface.go index 60ec5d87a..38e13b765 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -8,7 +8,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/chain" "github.com/bitcoin-sv/spv-wallet/engine/chain/models" "github.com/bitcoin-sv/spv-wallet/engine/cluster" - "github.com/bitcoin-sv/spv-wallet/engine/database/dao" + "github.com/bitcoin-sv/spv-wallet/engine/database/repository" "github.com/bitcoin-sv/spv-wallet/engine/datastore" "github.com/bitcoin-sv/spv-wallet/engine/metrics" "github.com/bitcoin-sv/spv-wallet/engine/notifications" @@ -107,6 +107,7 @@ type ModelService interface { // PaymailService is the paymail actions & services type PaymailService interface { DeletePaymailAddress(ctx context.Context, address string, opts ...ModelOps) error + DeletePaymailAddressByID(ctx context.Context, id string, opts ...ModelOps) error GetPaymailConfig() *PaymailServerOptions GetPaymailAddress(ctx context.Context, address string, opts ...ModelOps) (*PaymailAddress, error) GetPaymailAddressByID(ctx context.Context, id string, opts ...ModelOps) (*PaymailAddress, error) @@ -194,6 +195,5 @@ type ClientInterface interface { Chain() chain.Service LogBHSReadiness(ctx context.Context) FeeUnit() bsv.FeeUnit - TransactionsDAO() *dao.Transactions - UsersDAO() *dao.Users + Repositories() *repository.All } diff --git a/engine/paymail/errors/errors.go b/engine/paymail/errors/errors.go index d9e97986e..5371c0088 100644 --- a/engine/paymail/errors/errors.go +++ b/engine/paymail/errors/errors.go @@ -25,3 +25,15 @@ var ErrPaymentDestination = models.SPVError{Message: "payment destination operat // ErrAddressSave is when the address save operation failed. var ErrAddressSave = models.SPVError{Message: "address save operation failed", StatusCode: 500, Code: "error-address-save"} + +// ErrPaymailMerkleRootVerificationFailed is when merkle root verification could not be processed +var ErrPaymailMerkleRootVerificationFailed = models.SPVError{Message: "merkle root verification could not be processed", StatusCode: 500, Code: "error-paymail-merkle-root-verification-failed"} + +// ErrPaymailInvalidMerkleRoots is when merkle roots verification by BHS returns status: INVALID +var ErrPaymailInvalidMerkleRoots = models.SPVError{Message: "invalid merkle roots", StatusCode: 400, Code: "error-paymail-invalid-merkle-roots"} + +// ErrParseIncomingTransaction is when the incoming hex transaction could not be parsed +var ErrParseIncomingTransaction = models.SPVError{Message: "incoming hex transaction could not be parsed", StatusCode: 400, Code: "error-parse-incoming-hex-transaction"} + +// ErrRecordTransaction is when the transaction could not be recorded +var ErrRecordTransaction = models.SPVError{Message: "transaction could not be recorded", StatusCode: 500, Code: "error-record-transaction"} diff --git a/engine/paymail/interfaces.go b/engine/paymail/interfaces.go index 8debeee79..438cc14b2 100644 --- a/engine/paymail/interfaces.go +++ b/engine/paymail/interfaces.go @@ -3,11 +3,27 @@ package paymail import ( "context" + "github.com/bitcoin-sv/go-paymail/spv" + trx "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/spv-wallet/engine/database" ) -// Repository is an interface for the paymail repository -type Repository interface { - GetPaymail(ctx context.Context, alias, domain string) (*database.Paymail, error) - SaveAddress(ctx context.Context, userRow *database.User, addressRow *database.Address) error +// PaymailsRepo is an interface for paymails repository. +type PaymailsRepo interface { + Get(ctx context.Context, alias, domain string) (*database.Paymail, error) +} + +// UsersRepo is an interface for users repository. +type UsersRepo interface { + AppendAddress(ctx context.Context, userRow *database.User, addressRow *database.Address) error +} + +// MerkleRootsVerifier is an interface for verifying merkle roots +type MerkleRootsVerifier interface { + VerifyMerkleRoots(ctx context.Context, merkleRoots []*spv.MerkleRootConfirmationRequestItem) (bool, error) +} + +// TxRecorder is an interface for recording transactions +type TxRecorder interface { + RecordPaymailTransaction(ctx context.Context, tx *trx.Transaction, senderPaymail, receiverPaymail string) error } diff --git a/engine/paymail/internal/utils.go b/engine/paymail/internal/utils.go new file mode 100644 index 000000000..9ab6f9f82 --- /dev/null +++ b/engine/paymail/internal/utils.go @@ -0,0 +1,8 @@ +package internal + +import "fmt" + +// PaymailAddress returns a paymail address from an alias and domain. +func PaymailAddress(alias, domain string) string { + return fmt.Sprintf("%s@%s", alias, domain) +} diff --git a/engine/paymail/paymail_service_provider.go b/engine/paymail/paymail_service_provider.go index 70e209b62..9e147c67c 100644 --- a/engine/paymail/paymail_service_provider.go +++ b/engine/paymail/paymail_service_provider.go @@ -9,6 +9,7 @@ import ( "github.com/bitcoin-sv/go-paymail/spv" primitives "github.com/bitcoin-sv/go-sdk/primitives/ec" "github.com/bitcoin-sv/go-sdk/script" + trx "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" "github.com/bitcoin-sv/spv-wallet/engine/database" "github.com/bitcoin-sv/spv-wallet/engine/keys/type42" @@ -20,83 +21,61 @@ import ( ) // NewServiceProvider create a new paymail service server which handlers incoming paymail requests -func NewServiceProvider(logger *zerolog.Logger, repo Repository) server.PaymailServiceProvider { +func NewServiceProvider( + logger *zerolog.Logger, + paymailsRepo PaymailsRepo, + usersRepo UsersRepo, + spv MerkleRootsVerifier, + recorder TxRecorder, +) server.PaymailServiceProvider { return &serviceProvider{ - logger: logger, - repo: repo, + logger: logger, + paymails: paymailsRepo, + users: usersRepo, + spv: spv, + recorder: recorder, } } type serviceProvider struct { - logger *zerolog.Logger - repo Repository + logger *zerolog.Logger + paymails PaymailsRepo + users UsersRepo + spv MerkleRootsVerifier + recorder TxRecorder } -func (s *serviceProvider) CreateAddressResolutionResponse(ctx context.Context, alias, domain string, senderValidation bool, metaData *server.RequestMetadata) (*paymail.ResolutionPayload, error) { - //TODO implement me - panic("implement me") -} - -func (s *serviceProvider) CreateP2PDestinationResponse(ctx context.Context, alias, domain string, satoshis uint64, metaData *server.RequestMetadata) (*paymail.PaymentDestinationPayload, error) { - paymailModel, err := s.repo.GetPaymail(ctx, alias, domain) - if err != nil { - return nil, pmerrors.ErrPaymailDBFailed.Wrap(err) - } - - pki, pkiDerivationKey, err := s.pki(paymailModel) +func (s *serviceProvider) CreateAddressResolutionResponse(ctx context.Context, alias, domain string, _ bool, _ *server.RequestMetadata) (*paymail.ResolutionPayload, error) { + destination, err := s.createDestinationForUser(ctx, alias, domain) if err != nil { return nil, err } - referenceID, err := utils.RandomHex(16) - if err != nil { - return nil, spverrors.Wrapf(err, "cannot generate reference id") - } - - dest, err := type42.Destination(pki, referenceID) - if err != nil { - return nil, pmerrors.ErrPaymentDestination.Wrap(err) - } - - address, err := script.NewAddressFromPublicKey(dest, true) - if err != nil { - return nil, pmerrors.ErrPaymentDestination.Wrap(err) - } + return &paymail.ResolutionPayload{ + Address: destination.address, + Output: destination.lockingScript, + Signature: "", // signature is not supported due to "noncustodial" nature of the wallet; private keys are not stored + }, nil +} - lockingScript, err := p2pkh.Lock(address) +func (s *serviceProvider) CreateP2PDestinationResponse(ctx context.Context, alias, domain string, satoshis uint64, _ *server.RequestMetadata) (*paymail.PaymentDestinationPayload, error) { + destination, err := s.createDestinationForUser(ctx, alias, domain) if err != nil { - return nil, pmerrors.ErrPaymentDestination.Wrap(err) - } - - err = s.repo.SaveAddress(ctx, paymailModel.User, &database.Address{ - Address: address.AddressString, - CustomInstructions: datatypes.NewJSONSlice([]database.CustomInstruction{ - { - Type: "type42", - Instruction: pkiDerivationKey, - }, - { - Type: "type42", - Instruction: referenceID, - }, - }), - }) - if err != nil { - return nil, pmerrors.ErrAddressSave.Wrap(err) + return nil, err } return &paymail.PaymentDestinationPayload{ Outputs: []*paymail.PaymentOutput{{ - Address: address.AddressString, + Address: destination.address, Satoshis: satoshis, - Script: lockingScript.String(), + Script: destination.lockingScript, }}, - Reference: referenceID, + Reference: destination.referenceID, }, nil } func (s *serviceProvider) GetPaymailByAlias(ctx context.Context, alias, domain string, _ *server.RequestMetadata) (*paymail.AddressInformation, error) { - model, err := s.repo.GetPaymail(ctx, alias, domain) + model, err := s.paymails.Get(ctx, alias, domain) if err != nil { return nil, pmerrors.ErrPaymailDBFailed.Wrap(err) } @@ -119,14 +98,59 @@ func (s *serviceProvider) GetPaymailByAlias(ctx context.Context, alias, domain s }, nil } -func (s *serviceProvider) RecordTransaction(ctx context.Context, p2pTx *paymail.P2PTransaction, metaData *server.RequestMetadata) (*paymail.P2PTransactionPayload, error) { - //TODO implement me - panic("implement me") +func (s *serviceProvider) RecordTransaction(ctx context.Context, p2pTx *paymail.P2PTransaction, requestMetadata *server.RequestMetadata) (*paymail.P2PTransactionPayload, error) { + isBEEF := p2pTx.DecodedBeef != nil && p2pTx.Beef != "" + isRawTX := p2pTx.Hex != "" + + if !isBEEF && !isRawTX { + return nil, pmerrors.ErrParseIncomingTransaction + } + + var tx *trx.Transaction + var err error + if isBEEF { + tx, err = trx.NewTransactionFromBEEFHex(p2pTx.Beef) + } else { + panic("not implemented yet") + } + + if err != nil { + return nil, pmerrors.ErrParseIncomingTransaction.Wrap(err) + } + + receiverPaymail := requestMetadata.Alias + "@" + requestMetadata.Domain + + err = s.recorder.RecordPaymailTransaction(ctx, tx, p2pTx.MetaData.Sender, receiverPaymail) + if err != nil { + return nil, pmerrors.ErrRecordTransaction.Wrap(err) + } + + // TODO: TrackMissingTxs for BEEF purposes (or handle it in other way) + + return &paymail.P2PTransactionPayload{ + Note: p2pTx.MetaData.Note, + TxID: tx.TxID().String(), + }, nil } func (s *serviceProvider) VerifyMerkleRoots(ctx context.Context, merkleProofs []*spv.MerkleRootConfirmationRequestItem) error { - //TODO implement me - panic("implement me") + // TODO include metrics for VerifyMerkleRoots (perhaps on another level - maybe ChainService) + + valid, err := s.spv.VerifyMerkleRoots(ctx, merkleProofs) + + // NOTE: these errors goes to go-paymail and are not logged there, so we need to log them here + + if err != nil { + s.logger.Error().Err(err).Msg("Error verifying merkle roots") + return pmerrors.ErrPaymailMerkleRootVerificationFailed.Wrap(err) + } + + if !valid { + s.logger.Warn().Msg("Not all provided merkle roots were confirmed by BHS") + return pmerrors.ErrPaymailInvalidMerkleRoots + } + + return nil } func (s *serviceProvider) pki(paymailModel *database.Paymail) (*primitives.PublicKey, string, error) { @@ -141,3 +165,64 @@ func (s *serviceProvider) pki(paymailModel *database.Paymail) (*primitives.Publi } return pki, derivationKey, nil } + +type destinationData struct { + address string + lockingScript string + referenceID string +} + +func (s *serviceProvider) createDestinationForUser(ctx context.Context, alias, domain string) (*destinationData, error) { + paymailModel, err := s.paymails.Get(ctx, alias, domain) + if err != nil { + return nil, pmerrors.ErrPaymailDBFailed.Wrap(err) + } + + pki, pkiDerivationKey, err := s.pki(paymailModel) + if err != nil { + return nil, err + } + + referenceID, err := utils.RandomHex(16) + if err != nil { + return nil, spverrors.Wrapf(err, "cannot generate reference id") + } + + dest, err := type42.Destination(pki, referenceID) + if err != nil { + return nil, pmerrors.ErrPaymentDestination.Wrap(err) + } + + address, err := script.NewAddressFromPublicKey(dest, true) + if err != nil { + return nil, pmerrors.ErrPaymentDestination.Wrap(err) + } + + lockingScript, err := p2pkh.Lock(address) + if err != nil { + return nil, pmerrors.ErrPaymentDestination.Wrap(err) + } + + err = s.users.AppendAddress(ctx, paymailModel.User, &database.Address{ + Address: address.AddressString, + CustomInstructions: datatypes.NewJSONSlice([]database.CustomInstruction{ + { + Type: "type42", + Instruction: pkiDerivationKey, + }, + { + Type: "type42", + Instruction: referenceID, + }, + }), + }) + if err != nil { + return nil, pmerrors.ErrAddressSave.Wrap(err) + } + + return &destinationData{ + address: address.AddressString, + lockingScript: lockingScript.String(), + referenceID: referenceID, + }, nil +} diff --git a/engine/testabilities/fixture_engine.go b/engine/testabilities/fixture_engine.go index fc67aff79..9b94818bc 100644 --- a/engine/testabilities/fixture_engine.go +++ b/engine/testabilities/fixture_engine.go @@ -9,7 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/config" "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/engine/database/dao" + "github.com/bitcoin-sv/spv-wallet/engine/database/repository" "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" @@ -165,7 +165,7 @@ func (f *engineFixture) initialiseFixtures() { } if f.config.ExperimentalFeatures.NewTransactionFlowEnabled { - usersDAO := dao.NewUsersAccessObject(f.engine.Datastore().DB()) + users := repository.NewUsersRepo(f.engine.Datastore().DB()) userEntity := &database.User{ PubKey: user.PublicKey().ToDERHex(), } @@ -180,7 +180,7 @@ func (f *engineFixture) initialiseFixtures() { }) } - err = usersDAO.SaveUser(context.Background(), userEntity) + err = users.Save(context.Background(), userEntity) require.NoError(f.t, err) } } diff --git a/engine/tester/fixtures/tx_const_fixtures.go b/engine/tester/fixtures/tx_const_fixtures.go index 4962a383c..32555d350 100644 --- a/engine/tester/fixtures/tx_const_fixtures.go +++ b/engine/tester/fixtures/tx_const_fixtures.go @@ -7,6 +7,3 @@ var DefaultFeeUnit = bsv.FeeUnit{ Satoshis: 1, Bytes: 1000, } - -// EstimatedUnlockingScriptSizeForP2PKH is the estimated unlocking script size for a P2PKH transaction. -const EstimatedUnlockingScriptSizeForP2PKH = 106 diff --git a/engine/tester/fixtures/tx_fixtures.go b/engine/tester/fixtures/tx_fixtures.go index a89c97a80..077fc80bf 100644 --- a/engine/tester/fixtures/tx_fixtures.go +++ b/engine/tester/fixtures/tx_fixtures.go @@ -32,6 +32,7 @@ var grandparentTXIDs = []string{ type GivenTXSpec interface { WithoutSigning() GivenTXSpec WithInput(satoshis uint64) GivenTXSpec + WithInputFromUTXO(tx *trx.Transaction, vout uint32) GivenTXSpec WithSingleSourceInputs(satoshis ...uint64) GivenTXSpec WithOPReturn(dataStr string) GivenTXSpec WithOutputScriptParts(parts ...ScriptPart) GivenTXSpec @@ -80,6 +81,16 @@ func (spec *txSpec) WithInput(satoshis uint64) GivenTXSpec { return spec.WithSingleSourceInputs(satoshis) } +func (spec *txSpec) WithInputFromUTXO(tx *trx.Transaction, vout uint32) GivenTXSpec { + output := tx.Outputs[vout] + utxo, err := trx.NewUTXO(tx.TxID().String(), vout, output.LockingScript.String(), output.Satoshis) + require.NoError(spec.t, err, "creating utxo for input") + + spec.utxos = append(spec.utxos, utxo) + spec.sourceTransactions[tx.TxID().String()] = tx + return spec +} + // WithSingleSourceInputs adds inputs to the transaction with the specified satoshis // All the inputs will be sourced from a single parent transaction func (spec *txSpec) WithSingleSourceInputs(satoshis ...uint64) GivenTXSpec { diff --git a/engine/tester/fixtures/tx_fixtures_test.go b/engine/tester/fixtures/tx_fixtures_test.go index 3c9908111..172420ac1 100644 --- a/engine/tester/fixtures/tx_fixtures_test.go +++ b/engine/tester/fixtures/tx_fixtures_test.go @@ -49,7 +49,8 @@ func TestMockTXGeneration(t *testing.T) { spec := test.spec // when - ok, err := spv.VerifyScripts(spec.TX()) + tx := spec.TX() + ok, err := spv.VerifyScripts(tx) // then: require.NoError(t, err) diff --git a/engine/tester/fixtures/users_fixtures.go b/engine/tester/fixtures/users_fixtures.go index 9dfcf04e7..f5bcf70d0 100644 --- a/engine/tester/fixtures/users_fixtures.go +++ b/engine/tester/fixtures/users_fixtures.go @@ -144,6 +144,12 @@ func (f *User) Address() *script.Address { return addr } +// ID returns the id of the user. +// Warning: this refers to the new-tx-flow approach where v2 user's id is effectively P2PKH address from the public key. +func (f *User) ID() string { + return f.Address().AddressString +} + // P2PKHLockingScript returns the locking script of this user. func (f *User) P2PKHLockingScript() *script.Script { lockingScript, err := p2pkh.Lock(f.Address()) diff --git a/engine/transaction/errors/errors.go b/engine/transaction/errors/errors.go index 89485f2b6..da8666033 100644 --- a/engine/transaction/errors/errors.go +++ b/engine/transaction/errors/errors.go @@ -68,4 +68,10 @@ var ( // ErrUnexpectedErrorDuringInputsSelection is when an unexpected error occurs during inputs selection for transaction outline. ErrUnexpectedErrorDuringInputsSelection = models.SPVError{Code: "error-input-selection", Message: "unexpected error during inputs selection", StatusCode: 500} + + // ErrNoOperations is when there are no operations to save. + ErrNoOperations = models.SPVError{Code: "error-no-operations", Message: "no operations to save", StatusCode: 400} + + // ErrGettingAddresses is when getting addresses fails. + ErrGettingAddresses = models.SPVError{Code: "error-getting-addresses", Message: "failed to get addresses", StatusCode: 500} ) diff --git a/engine/transaction/outlines/internal/inputs/inputs_query_composer.go b/engine/transaction/outlines/internal/inputs/inputs_query_composer.go index 2118a3d25..b51d34ab9 100644 --- a/engine/transaction/outlines/internal/inputs/inputs_query_composer.go +++ b/engine/transaction/outlines/internal/inputs/inputs_query_composer.go @@ -10,7 +10,7 @@ import ( ) type inputsQueryComposer struct { - xPubID string + userID string outputsTotalValue bsv.Satoshis txWithoutInputsSize uint64 feeUnit bsv.FeeUnit @@ -22,12 +22,12 @@ func (c *inputsQueryComposer) build(db *gorm.DB) *gorm.DB { utxoWithMinChange := c.searchForMinimalChangeValue(db, utxoWithChange) selectedOutpoints := c.chooseInputsToCoverOutputsAndFeesAndHaveMinimalChange(db, utxoWithMinChange) - res := db.Model(&database.UserUtxos{}).Where("(tx_id, vout) in (?)", selectedOutpoints) + res := db.Model(&database.UserUTXO{}).Where("(tx_id, vout) in (?)", selectedOutpoints) return res } func (c *inputsQueryComposer) utxos(db *gorm.DB) *gorm.DB { - return db.Model(&database.UserUtxos{}). + return db.Model(&database.UserUTXO{}). Select( txIdColumn, voutColumn, @@ -35,7 +35,7 @@ func (c *inputsQueryComposer) utxos(db *gorm.DB) *gorm.DB { c.feeCalculatedWithoutChangeOutput(), c.feeCalculatedWithChangeOutput(), ). - Where("xpub_id = @xPubId", sql.Named("xPubId", c.xPubID)) + Where("user_id = @userId", sql.Named("userId", c.userID)) } func (c *inputsQueryComposer) addChangeValueCalculation(db *gorm.DB, utxoTab *gorm.DB) *gorm.DB { @@ -60,11 +60,11 @@ func (c *inputsQueryComposer) searchForMinimalChangeValue(db *gorm.DB, utxoWithC } func (c *inputsQueryComposer) feeCalculatedWithChangeOutput() string { - return fmt.Sprintf("ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d + %d) / cast(%d as float)) * %d as fee_with_change_output", c.txWithoutInputsSize, estimatedChangeOutputSize, c.feeUnit.Bytes, c.feeUnit.Satoshis) + return fmt.Sprintf("ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d + %d) / cast(%d as float)) * %d as fee_with_change_output", c.txWithoutInputsSize, estimatedChangeOutputSize, c.feeUnit.Bytes, c.feeUnit.Satoshis) } func (c *inputsQueryComposer) feeCalculatedWithoutChangeOutput() string { - return fmt.Sprintf("ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d) / cast(%d as float)) * %d as fee_no_change_output", c.txWithoutInputsSize, c.feeUnit.Bytes, c.feeUnit.Satoshis) + return fmt.Sprintf("ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d) / cast(%d as float)) * %d as fee_no_change_output", c.txWithoutInputsSize, c.feeUnit.Bytes, c.feeUnit.Satoshis) } func (c *inputsQueryComposer) remainingValue() string { diff --git a/engine/transaction/outlines/internal/inputs/selector.go b/engine/transaction/outlines/internal/inputs/selector.go index a2ac5fb05..734c19649 100644 --- a/engine/transaction/outlines/internal/inputs/selector.go +++ b/engine/transaction/outlines/internal/inputs/selector.go @@ -16,7 +16,7 @@ const voutColumn = "vout" // Selector is a service that selects inputs for transaction. type Selector interface { - SelectInputsForTransaction(ctx context.Context, xPubID string, satoshis bsv.Satoshis, byteSizeOfTxBeforeAddingSelectedInputs uint64) ([]*database.UserUtxos, error) + SelectInputsForTransaction(ctx context.Context, userID string, satoshis bsv.Satoshis, byteSizeOfTxBeforeAddingSelectedInputs uint64) ([]*database.UserUTXO, error) } const ( @@ -39,9 +39,9 @@ func NewSelector(db *gorm.DB, feeUnit bsv.FeeUnit) Selector { } } -func (r *sqlInputsSelector) SelectInputsForTransaction(ctx context.Context, xPubID string, outputsTotalValue bsv.Satoshis, byteSizeOfTxWithoutInputs uint64) (utxos []*database.UserUtxos, err error) { +func (r *sqlInputsSelector) SelectInputsForTransaction(ctx context.Context, userID string, outputsTotalValue bsv.Satoshis, byteSizeOfTxWithoutInputs uint64) (utxos []*database.UserUTXO, err error) { err = r.db.WithContext(ctx).Transaction(func(db *gorm.DB) error { - inputsQuery := r.buildQueryForInputs(db, xPubID, outputsTotalValue, byteSizeOfTxWithoutInputs) + inputsQuery := r.buildQueryForInputs(db, userID, outputsTotalValue, byteSizeOfTxWithoutInputs) if err := inputsQuery.Find(&utxos).Error; err != nil { utxos = nil @@ -68,9 +68,9 @@ func (r *sqlInputsSelector) SelectInputsForTransaction(ctx context.Context, xPub return utxos, nil } -func (r *sqlInputsSelector) buildQueryForInputs(db *gorm.DB, xPubID string, outputsTotalValue bsv.Satoshis, txWithoutInputsSize uint64) *gorm.DB { +func (r *sqlInputsSelector) buildQueryForInputs(db *gorm.DB, userID string, outputsTotalValue bsv.Satoshis, txWithoutInputsSize uint64) *gorm.DB { composer := &inputsQueryComposer{ - xPubID: xPubID, + userID: userID, outputsTotalValue: outputsTotalValue, txWithoutInputsSize: txWithoutInputsSize, feeUnit: r.feeUnit, @@ -78,10 +78,10 @@ func (r *sqlInputsSelector) buildQueryForInputs(db *gorm.DB, xPubID string, outp return composer.build(db) } -func (r *sqlInputsSelector) buildUpdateTouchedAtQuery(db *gorm.DB, utxos []*database.UserUtxos) *gorm.DB { +func (r *sqlInputsSelector) buildUpdateTouchedAtQuery(db *gorm.DB, utxos []*database.UserUTXO) *gorm.DB { outpoints := make([][]any, 0, len(utxos)) for _, utxo := range utxos { outpoints = append(outpoints, []any{utxo.TxID, utxo.Vout}) } - return db.Model(&database.UserUtxos{}).Where("(tx_id, vout) in (?)", outpoints) + return db.Model(&database.UserUTXO{}).Where("(tx_id, vout) in (?)", outpoints) } diff --git a/engine/transaction/outlines/internal/inputs/selector_example_test.go b/engine/transaction/outlines/internal/inputs/selector_example_test.go index ff4c82090..49a55ff2f 100644 --- a/engine/transaction/outlines/internal/inputs/selector_example_test.go +++ b/engine/transaction/outlines/internal/inputs/selector_example_test.go @@ -18,14 +18,14 @@ func ExampleSelector_buildQueryForInputs_sqlite() { selector := givenInputsSelector(db) query := db.ToSQL(func(db *gorm.DB) *gorm.DB { - query := selector.buildQueryForInputs(db, "somexpubid", 1, 10) - query.Find(&database.UserUtxos{}) + query := selector.buildQueryForInputs(db, "someuserid", 1, 10) + query.Find(&database.UserUTXO{}) return query }) fmt.Println(query) - // Output: SELECT * FROM `xapi_user_utxos` WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT `tx_id`,`vout`,sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM `xapi_user_utxos` WHERE xpub_id = "somexpubid") as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change) + // Output: SELECT * FROM `xapi_user_utxos` WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT `tx_id`,`vout`,sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM `xapi_user_utxos` WHERE user_id = "someuserid") as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change) } // ExampleSelector_buildQueryForInputs_postgresql demonstrates what would be the query used to select inputs for a transaction. @@ -36,14 +36,14 @@ func ExampleSelector_buildQueryForInputs_postgresql() { selector := givenInputsSelector(db) query := db.ToSQL(func(db *gorm.DB) *gorm.DB { - query := selector.buildQueryForInputs(db, "somexpubid", 1, 10) - query.Find(&database.UserUtxos{}) + query := selector.buildQueryForInputs(db, "someuserid", 1, 10) + query.Find(&database.UserUTXO{}) return query }) fmt.Println(query) - // Output: SELECT * FROM "xapi_user_utxos" WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT "tx_id","vout",sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM "xapi_user_utxos" WHERE xpub_id = 'somexpubid') as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change) + // Output: SELECT * FROM "xapi_user_utxos" WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT "tx_id","vout",sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM "xapi_user_utxos" WHERE user_id = 'someuserid') as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change) } // ExampleSelector_buildUpdateTouchedAtQuery_sqlite demonstrates what would be the SQL statement used to update inputs after selecting them. @@ -52,10 +52,10 @@ func ExampleSelector_buildUpdateTouchedAtQuery_sqlite() { selector := givenInputsSelector(db) - utxos := []*database.UserUtxos{ - {XPubID: "id_of_xpub_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, - {XPubID: "id_of_xpub_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, - {XPubID: "id_of_xpub_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + utxos := []*database.UserUTXO{ + {UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + {UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + {UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, } query := db.ToSQL(func(db *gorm.DB) *gorm.DB { @@ -75,10 +75,10 @@ func ExampleSelector_buildUpdateTouchedAtQuery_postgres() { selector := givenInputsSelector(db) - utxos := []*database.UserUtxos{ - {XPubID: "id_of_xpub_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, - {XPubID: "id_of_xpub_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, - {XPubID: "id_of_xpub_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + utxos := []*database.UserUTXO{ + {UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + {UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, + {UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()}, } query := db.ToSQL(func(db *gorm.DB) *gorm.DB { diff --git a/engine/transaction/outlines/internal/inputs/selector_test.go b/engine/transaction/outlines/internal/inputs/selector_test.go index d41025e8b..7f29cd0c9 100644 --- a/engine/transaction/outlines/internal/inputs/selector_test.go +++ b/engine/transaction/outlines/internal/inputs/selector_test.go @@ -27,7 +27,7 @@ func TestInputsSelector(t *testing.T) { selector := given.NewInputSelector() // when: - utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.XPubID(), 0, 0) + utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.ID(), 0, 0) // then: then.WithoutError(err).SelectedInputs(utxos).AreEmpty() @@ -67,14 +67,14 @@ func TestInputsSelector(t *testing.T) { "select inputs that covers outputs and fee for more data": { selectBy: selectBy{ satoshis: 9, - txSizeWithoutInputs: uint64(fixtures.DefaultFeeUnit.Bytes + 1 - fixtures.EstimatedUnlockingScriptSizeForP2PKH), + txSizeWithoutInputs: uint64(fixtures.DefaultFeeUnit.Bytes + 1 - database.EstimatedInputSizeForP2PKH), }, expectToSelectInputs: []int{0, 1}, }, "select inputs when size is equal to fee unit bytes": { selectBy: selectBy{ satoshis: 9, - txSizeWithoutInputs: uint64(fixtures.DefaultFeeUnit.Bytes - fixtures.EstimatedUnlockingScriptSizeForP2PKH), + txSizeWithoutInputs: uint64(fixtures.DefaultFeeUnit.Bytes - database.EstimatedInputSizeForP2PKH), }, expectToSelectInputs: []int{0}, }, @@ -86,7 +86,7 @@ func TestInputsSelector(t *testing.T) { defer cleanup() // and: having some utxo in database - ownedInputs := []*database.UserUtxos{ + ownedInputs := []*database.UserUTXO{ given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), @@ -97,7 +97,7 @@ func TestInputsSelector(t *testing.T) { selector := given.NewInputSelector() // when: - utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.XPubID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) + utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.ID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) // then: then.WithoutError(err).SelectedInputs(utxos). @@ -132,7 +132,7 @@ func TestInputsSelector(t *testing.T) { defer cleanup() // and: having some utxo in database - ownedInputs := []*database.UserUtxos{ + ownedInputs := []*database.UserUTXO{ given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), given.DB().HasUTXO().OwnedBySender().P2PKH().WithSatoshis(10).Stored(), @@ -143,13 +143,13 @@ func TestInputsSelector(t *testing.T) { selector := given.NewInputSelector() // when: - _, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.XPubID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) + _, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.ID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) // then: require.NoError(t, err) // when: - utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.XPubID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) + utxos, err := selector.SelectInputsForTransaction(context.Background(), fixtures.Sender.ID(), test.selectBy.satoshis, test.selectBy.txSizeWithoutInputs) // then: then.WithoutError(err).SelectedInputs(utxos). diff --git a/engine/transaction/outlines/internal/inputs/testabilities/assertions_inputs_selector.go b/engine/transaction/outlines/internal/inputs/testabilities/assertions_inputs_selector.go index 7c3198e9c..6c390c900 100644 --- a/engine/transaction/outlines/internal/inputs/testabilities/assertions_inputs_selector.go +++ b/engine/transaction/outlines/internal/inputs/testabilities/assertions_inputs_selector.go @@ -13,12 +13,12 @@ type InputsSelectorAssertions interface { } type SuccessfullySelectedInputsAssertions interface { - SelectedInputs(inputs []*database.UserUtxos) SelectedInputsAssertions + SelectedInputs(inputs []*database.UserUTXO) SelectedInputsAssertions } type SelectedInputsAssertions interface { AreEmpty() - ComparingTo(inputs []*database.UserUtxos) ComparingSelectedInputsAssertions + ComparingTo(inputs []*database.UserUTXO) ComparingSelectedInputsAssertions } type ComparingSelectedInputsAssertions interface { @@ -29,8 +29,8 @@ type assertion struct { t testing.TB require *require.Assertions assert *assert.Assertions - actual []*database.UserUtxos - comparingSource []*database.UserUtxos + actual []*database.UserUTXO + comparingSource []*database.UserUTXO } func newAssertions(t testing.TB) InputsSelectorAssertions { @@ -47,7 +47,7 @@ func (a assertion) WithoutError(err error) SuccessfullySelectedInputsAssertions return a } -func (a assertion) SelectedInputs(inputs []*database.UserUtxos) SelectedInputsAssertions { +func (a assertion) SelectedInputs(inputs []*database.UserUTXO) SelectedInputsAssertions { a.t.Helper() a.actual = inputs return a @@ -58,7 +58,7 @@ func (a assertion) AreEmpty() { a.require.Empty(a.actual) } -func (a assertion) ComparingTo(inputs []*database.UserUtxos) ComparingSelectedInputsAssertions { +func (a assertion) ComparingTo(inputs []*database.UserUTXO) ComparingSelectedInputsAssertions { a.t.Helper() a.comparingSource = inputs return a @@ -71,7 +71,7 @@ func (a assertion) AreEntries(expectedIndexes []int) { for i, ownedIdx := range expectedIndexes { selectedUTXO := a.actual[i] expectedUTXO := a.comparingSource[ownedIdx] - a.assert.Equal(expectedUTXO.XPubID, selectedUTXO.XPubID) + a.assert.Equal(expectedUTXO.UserID, selectedUTXO.UserID) a.assert.Equal(expectedUTXO.TxID, selectedUTXO.TxID) a.assert.EqualValues(expectedUTXO.Vout, selectedUTXO.Vout) } diff --git a/engine/transaction/outlines/internal/inputs/testabilities/fixture_inputs_selector.go b/engine/transaction/outlines/internal/inputs/testabilities/fixture_inputs_selector.go index d83aaf975..fbee0c952 100644 --- a/engine/transaction/outlines/internal/inputs/testabilities/fixture_inputs_selector.go +++ b/engine/transaction/outlines/internal/inputs/testabilities/fixture_inputs_selector.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet/engine/database/testabilities" + testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines/internal/inputs" "gorm.io/gorm" @@ -20,7 +21,7 @@ type inputsSelectorFixture struct { } func newFixture(t testing.TB) (InputsSelectorFixture, func()) { - givenDB, cleanup := testabilities.Given(t) + givenDB, cleanup := testabilities.Given(t, testengine.WithNewTransactionFlowEnabled()) return &inputsSelectorFixture{ DatabaseFixture: givenDB, db: givenDB.GormDB(), diff --git a/engine/transaction/record/interfaces.go b/engine/transaction/record/interfaces.go index 95073723d..a3b02a02c 100644 --- a/engine/transaction/record/interfaces.go +++ b/engine/transaction/record/interfaces.go @@ -10,10 +10,19 @@ import ( "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, txRow *database.TrackedTransaction) error - GetOutputs(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) +// AddressesRepo is an interface for addresses repository. +type AddressesRepo interface { + FindByStringAddresses(ctx context.Context, addresses iter.Seq[string]) ([]*database.Address, error) +} + +// OutputsRepo is an interface for outputs repository. +type OutputsRepo interface { + FindByOutpoints(ctx context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.TrackedOutput, error) +} + +// OperationsRepo is an interface for operations repository. +type OperationsRepo interface { + SaveAll(ctx context.Context, opRows iter.Seq[*database.Operation]) error } // Broadcaster is an interface for broadcasting transactions. diff --git a/engine/transaction/record/operation_wrapper.go b/engine/transaction/record/operation_wrapper.go new file mode 100644 index 000000000..53006da9a --- /dev/null +++ b/engine/transaction/record/operation_wrapper.go @@ -0,0 +1,37 @@ +package record + +import ( + "iter" + + "github.com/bitcoin-sv/spv-wallet/conv" + "github.com/bitcoin-sv/spv-wallet/engine/database" + "github.com/bitcoin-sv/spv-wallet/models/bsv" +) + +type operationWrapper struct { + entity *database.Operation +} + +func (w *operationWrapper) add(satoshi bsv.Satoshis) { + signedSatoshi, err := conv.Uint64ToInt64(uint64(satoshi)) + if err != nil { + panic(err) + } + w.entity.Value = w.entity.Value + signedSatoshi +} + +func (w *operationWrapper) subtract(satoshi bsv.Satoshis) { + signedSatoshi, err := conv.Uint64ToInt64(uint64(satoshi)) + if err != nil { + panic(err) + } + w.entity.Value = w.entity.Value - signedSatoshi +} + +func toOperationEntities(wrappers iter.Seq[*operationWrapper]) iter.Seq[*database.Operation] { + return func(yield func(*database.Operation) bool) { + for wrapper := range wrappers { + yield(wrapper.entity) + } + } +} diff --git a/engine/transaction/record/record_outline.go b/engine/transaction/record/record_outline.go index 7110f3deb..ba8081ca4 100644 --- a/engine/transaction/record/record_outline.go +++ b/engine/transaction/record/record_outline.go @@ -3,130 +3,92 @@ package record import ( "context" - "github.com/bitcoin-sv/go-sdk/spv" trx "github.com/bitcoin-sv/go-sdk/transaction" "github.com/bitcoin-sv/spv-wallet/conv" "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/bitcoin-sv/spv-wallet/engine/transaction" txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines" - "github.com/bitcoin-sv/spv-wallet/models/bsv" "github.com/bitcoin-sv/spv-wallet/models/transaction/bucket" ) // RecordTransactionOutline will validate, broadcast and save a transaction outline -func (s *Service) RecordTransactionOutline(ctx context.Context, outline *outlines.Transaction) error { +func (s *Service) RecordTransactionOutline(ctx context.Context, userID string, outline *outlines.Transaction) error { tx, err := trx.NewTransactionFromBEEFHex(outline.BEEF) if err != nil { return txerrors.ErrTxValidation.Wrap(err) } - if ok, err := spv.VerifyScripts(tx); err != nil { - return txerrors.ErrTxValidation.Wrap(err) - } else if !ok { - return txerrors.ErrTxValidation - } - - utxos, err := s.getTrackedUTXOsFromInputs(ctx, tx) - if err != nil { + flow := newTxFlow(ctx, s, tx) + if err = flow.verifyScripts(); err != nil { return err } - newOutputs, newDataRecords, err := s.processAnnotatedOutputs(tx, &outline.Annotations) + trackedOutputs, err := flow.getFromInputs() if err != nil { return err } - if _, err = s.broadcaster.Broadcast(ctx, tx); err != nil { - return txerrors.ErrTxBroadcast.Wrap(err) + for _, utxo := range trackedOutputs { + operation := flow.operationOfUser(utxo.UserID, "outgoing", "") + operation.subtract(utxo.Satoshis) } - // TODO: handle TXInfo returned from Broadcast (SPV-1157) - txID := tx.TxID().String() + flow.spendInputs(trackedOutputs) - txRow := database.TrackedTransaction{ - ID: txID, - TxStatus: database.TxStatusBroadcasted, - } - txRow.AddInputs(utxos...) - txRow.AddOutputs(newOutputs...) - txRow.AddData(newDataRecords...) - - err = s.repo.SaveTX(ctx, &txRow) + newDataRecords, err := s.processDataOutputs(tx, &outline.Annotations) if err != nil { - return txerrors.ErrSavingData.Wrap(err) + return err } - return nil -} + // TODO: getOutputsForTrackedAddresses + // TODO: process Paymail Annotations -// getTrackedUTXOsFromInputs gets stored-in-our-database outputs used in provided tx -// NOTE: The flow accepts transactions with "other/not-tracked" UTXOs, -// if the untracked output is correctly unlocked by the input script we have no reason to block the transaction; -// but only the tracked UTXOs will be marked as spent (and considered for future double-spending checks) -func (s *Service) getTrackedUTXOsFromInputs(ctx context.Context, tx *trx.Transaction) ([]*database.Output, error) { - outpoints := func(yield func(outpoint bsv.Outpoint) bool) { - for _, input := range tx.Inputs { - yield(bsv.Outpoint{ - TxID: input.SourceTXID.String(), - Vout: input.SourceTxOutIndex, - }) - } + if len(newDataRecords) > 0 { + _ = flow.operationOfUser(userID, "data", "") + flow.createDataOutputs(userID, newDataRecords...) } - storedUTXOs, err := s.repo.GetOutputs(ctx, outpoints) - if err != nil { - return nil, txerrors.ErrGettingOutputs.Wrap(err) + + if err = flow.verify(); err != nil { + return err } - for _, utxo := range storedUTXOs { - if utxo.IsSpent() { - return nil, txerrors.ErrUTXOSpent.Wrap(spverrors.Newf("UTXO %s is already spent", utxo.Outpoint())) - } + if err = flow.broadcast(); err != nil { + return err } - return storedUTXOs, nil + return flow.save() } -func (s *Service) processAnnotatedOutputs(tx *trx.Transaction, annotations *transaction.Annotations) ([]*database.Output, []*database.Data, error) { +func (s *Service) processDataOutputs(tx *trx.Transaction, annotations *transaction.Annotations) ([]*database.Data, error) { txID := tx.TxID().String() - var outputRecords []*database.Output - var dataRecords []*database.Data + var dataRecords []*database.Data //nolint: prealloc for vout, annotation := range annotations.Outputs { if vout >= len(tx.Outputs) { - return nil, nil, txerrors.ErrAnnotationIndexOutOfRange + return nil, txerrors.ErrAnnotationIndexOutOfRange } voutU32, err := conv.IntToUint32(vout) if err != nil { - return nil, nil, txerrors.ErrAnnotationIndexConversion.Wrap(err) + return nil, txerrors.ErrAnnotationIndexConversion.Wrap(err) } lockingScript := tx.Outputs[vout].LockingScript - switch annotation.Bucket { - case bucket.Data: - data, err := getDataFromOpReturn(lockingScript) - if err != nil { - return nil, nil, err - } - dataRecords = append(dataRecords, &database.Data{ - TxID: txID, - Vout: voutU32, - Blob: data, - }) - outputRecords = append(outputRecords, &database.Output{ - TxID: txID, - Vout: voutU32, - }) - case bucket.BSV: - //TODO - s.logger.Warn().Msgf("support for BSV bucket is not implemented yet") - default: - s.logger.Warn().Msgf("Unknown annotation bucket %s", annotation.Bucket) + if annotation.Bucket != bucket.Data { continue } + + data, err := getDataFromOpReturn(lockingScript) + if err != nil { + return nil, err + } + dataRecords = append(dataRecords, &database.Data{ + TxID: txID, + Vout: voutU32, + Blob: data, + }) } - return outputRecords, dataRecords, nil + return dataRecords, nil } diff --git a/engine/transaction/record/record_outline_test.go b/engine/transaction/record/record_outline_test.go deleted file mode 100644 index 5640f4132..000000000 --- a/engine/transaction/record/record_outline_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package record_test - -import ( - "context" - "testing" - - "github.com/bitcoin-sv/go-sdk/script" - "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" - "github.com/bitcoin-sv/spv-wallet/engine/transaction" - txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" - "github.com/bitcoin-sv/spv-wallet/engine/transaction/outlines" - "github.com/bitcoin-sv/spv-wallet/engine/transaction/record/testabilities" - "github.com/bitcoin-sv/spv-wallet/models/bsv" - "github.com/bitcoin-sv/spv-wallet/models/transaction/bucket" - "github.com/pkg/errors" -) - -const ( - dataOfOpReturnTx = "hello world" - notABeefHex = "0100000001b23c7c47320b3818c665bf28a46c290f3fb379ea8d357625bbff3117ae14b09b0000000000ffffffff0100000000000000000e006a0b68656c6c6f20776f726c6400000000" -) - -func givenTXWithOpReturn(t *testing.T) fixtures.GivenTXSpec { - return fixtures.GivenTX(t). - WithInput(1). - WithOPReturn(dataOfOpReturnTx) -} - -func givenTxWithOpReturnWithoutOPFalse(t *testing.T) fixtures.GivenTXSpec { - return fixtures.GivenTX(t). - WithInput(1). - WithOutputScriptParts( - fixtures.OpCode(script.OpRETURN), - fixtures.PushData(dataOfOpReturnTx), - ) -} - -func givenStandardOpReturnOutline(t *testing.T) *outlines.Transaction { - return &outlines.Transaction{ - BEEF: givenTXWithOpReturn(t).BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - 0: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - } -} - -func TestRecordOutlineOpReturn(t *testing.T) { - tests := map[string]struct { - storedUTXOs []bsv.Outpoint - outline *outlines.Transaction - expectTxID string - expectOutputs []database.Output - expectData []database.Data - }{ - "RecordTransactionOutline for op_return": { - storedUTXOs: []bsv.Outpoint{givenTXWithOpReturn(t).InputUTXO(0)}, - outline: givenStandardOpReturnOutline(t), - expectTxID: givenTXWithOpReturn(t).ID(), - expectOutputs: []database.Output{ - { - TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, - Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: givenTXWithOpReturn(t).ID(), - }, - { - TxID: givenTXWithOpReturn(t).ID(), - Vout: 0, - SpendingTX: "", - }, - }, - expectData: []database.Data{ - { - TxID: givenTXWithOpReturn(t).ID(), - Vout: 0, - Blob: []byte(dataOfOpReturnTx), - }, - }, - }, - "RecordTransactionOutline for op_return without leading OP_FALSE": { - storedUTXOs: []bsv.Outpoint{givenTxWithOpReturnWithoutOPFalse(t).InputUTXO(0)}, - outline: &outlines.Transaction{ - BEEF: givenTxWithOpReturnWithoutOPFalse(t).BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - 0: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - }, - expectTxID: givenTxWithOpReturnWithoutOPFalse(t).ID(), - expectOutputs: []database.Output{ - { - TxID: givenTxWithOpReturnWithoutOPFalse(t).InputUTXO(0).TxID, - Vout: givenTxWithOpReturnWithoutOPFalse(t).InputUTXO(0).Vout, - SpendingTX: givenTxWithOpReturnWithoutOPFalse(t).ID(), - }, - { - TxID: givenTxWithOpReturnWithoutOPFalse(t).ID(), - Vout: 0, - SpendingTX: "", - }, - }, - expectData: []database.Data{ - { - TxID: givenTxWithOpReturnWithoutOPFalse(t).ID(), - Vout: 0, - Blob: []byte(dataOfOpReturnTx), - }, - }, - }, - "RecordTransactionOutline for op_return with untracked utxo as inputs": { - storedUTXOs: []bsv.Outpoint{}, - outline: givenStandardOpReturnOutline(t), - expectTxID: givenTXWithOpReturn(t).ID(), - expectOutputs: []database.Output{{ - TxID: givenTXWithOpReturn(t).ID(), - Vout: 0, - }}, - expectData: []database.Data{ - { - TxID: givenTXWithOpReturn(t).ID(), - Vout: 0, - Blob: []byte(dataOfOpReturnTx), - }, - }, - }, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - // given: - given, then := testabilities.New(t) - given.Repository().WithUTXOs(test.storedUTXOs...) - - service := given.NewRecordService() - - // when: - err := service.RecordTransactionOutline(context.Background(), test.outline) - - // then: - then.NoError(err). - Broadcasted(test.expectTxID). - StoredAsBroadcasted(test.expectTxID) - - then. - StoredOutputs(test.expectOutputs). - StoredData(test.expectData) - }) - } -} - -func TestRecordOutlineOpReturnErrorCases(t *testing.T) { - givenUnsignedTX := fixtures.GivenTX(t). - WithoutSigning(). - WithInput(1). - WithOPReturn(dataOfOpReturnTx) - - givenTxWithOpZeroAfterOpReturn := fixtures.GivenTX(t). - WithInput(1). - WithOutputScriptParts( - fixtures.OpCode(script.OpFALSE), - fixtures.OpCode(script.OpRETURN), - fixtures.PushData(dataOfOpReturnTx), - fixtures.OpCode(script.OpZERO), - fixtures.OpCode(script.OpZERO), - fixtures.PushData(dataOfOpReturnTx), - ) - - givenTxWithP2PKHOutput := fixtures.GivenTX(t). - WithInput(2). - WithP2PKHOutput(1) - - tests := map[string]struct { - storedOutputs []database.Output - outline *outlines.Transaction - expectErr error - }{ - "RecordTransactionOutline for not signed transaction": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: givenUnsignedTX.BEEF(), - }, - expectErr: txerrors.ErrTxValidation, - }, - "RecordTransactionOutline for not a BEEF hex": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: notABeefHex, - }, - expectErr: txerrors.ErrTxValidation, - }, - "RecordTransactionOutline for invalid OP_ZERO after OP_RETURN": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: givenTxWithOpZeroAfterOpReturn.BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - 0: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - }, - expectErr: txerrors.ErrOnlyPushDataAllowed, - }, - "Tx with already spent utxo": { - storedOutputs: []database.Output{{ - TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, - Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: "05aa91319c773db18071310ecd5ddc15d3aa4242b55705a13a66f7fefe2b80a1", - }}, - outline: &outlines.Transaction{ - BEEF: givenTXWithOpReturn(t).BEEF(), - }, - expectErr: txerrors.ErrUTXOSpent, - }, - "Vout out of range in annotation": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: givenTXWithOpReturn(t).BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - 1: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - }, - expectErr: txerrors.ErrAnnotationIndexOutOfRange, - }, - "Vout as negative value in annotation": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: givenTXWithOpReturn(t).BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - -1: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - }, - expectErr: txerrors.ErrAnnotationIndexConversion, - }, - "no-op_return output annotated as data": { - storedOutputs: []database.Output{}, - outline: &outlines.Transaction{ - BEEF: givenTxWithP2PKHOutput.BEEF(), - Annotations: transaction.Annotations{ - Outputs: transaction.OutputsAnnotations{ - 0: &transaction.OutputAnnotation{ - Bucket: bucket.Data, - }, - }, - }, - }, - expectErr: txerrors.ErrAnnotationMismatch, - }, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - // given: - given, then := testabilities.New(t) - given.Repository().WithOutputs(test.storedOutputs...) - - service := given.NewRecordService() - - // when: - err := service.RecordTransactionOutline(context.Background(), test.outline) - - // then: - then.ErrorIs(err, test.expectErr).NothingChanged() - }) - } -} - -func TestOnBroadcastErr(t *testing.T) { - // given: - given, then := testabilities.New(t) - given.Repository(). - WithOutputs(database.Output{ - TxID: givenTXWithOpReturn(t).InputUTXO(0).TxID, - Vout: givenTXWithOpReturn(t).InputUTXO(0).Vout, - SpendingTX: "", - }) - given.Broadcaster(). - WillFailOnBroadcast(errors.New("broadcast error")) - - service := given.NewRecordService() - - // and: - outline := givenStandardOpReturnOutline(t) - - // when: - err := service.RecordTransactionOutline(context.Background(), outline) - - // then: - then.ErrorIs(err, txerrors.ErrTxBroadcast).NothingChanged() -} - -func TestOnSaveTXErr(t *testing.T) { - // given: - given, then := testabilities.New(t) - given.Repository(). - WillFailOnSaveTX(errors.New("saveTX error")) - - service := given.NewRecordService() - - // and: - outline := givenStandardOpReturnOutline(t) - - // when: - err := service.RecordTransactionOutline(context.Background(), outline) - - // then: - then.ErrorIs(err, txerrors.ErrSavingData).NothingChanged() -} - -func TestOnGetOutputsErr(t *testing.T) { - // given: - given, then := testabilities.New(t) - given.Repository(). - WillFailOnGetOutputs(errors.New("getOutputs error")) - service := given.NewRecordService() - - // and: - outline := givenStandardOpReturnOutline(t) - - // when: - err := service.RecordTransactionOutline(context.Background(), outline) - - // then: - then.ErrorIs(err, txerrors.ErrGettingOutputs).NothingChanged() -} diff --git a/engine/transaction/record/record_paymail_transaction.go b/engine/transaction/record/record_paymail_transaction.go new file mode 100644 index 000000000..d81538502 --- /dev/null +++ b/engine/transaction/record/record_paymail_transaction.go @@ -0,0 +1,52 @@ +package record + +import ( + "context" + + trx "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" +) + +// RecordPaymailTransaction will validate, broadcast and save paymail transaction +func (s *Service) RecordPaymailTransaction(ctx context.Context, tx *trx.Transaction, senderPaymail, receiverPaymail string) error { + flow := newTxFlow(ctx, s, tx) + + trackedOutputs, err := flow.getFromInputs() + if err != nil { + return err + } + + for _, utxo := range trackedOutputs { + operation := flow.operationOfUser(utxo.UserID, "outgoing", receiverPaymail) + if len(flow.operations) > 1 { + return spverrors.Newf("paymail transaction with multiple senders is not supported") + } + operation.subtract(utxo.Satoshis) + } + + flow.spendInputs(trackedOutputs) + + p2pkhOutputs, err := flow.findRelevantP2PKHOutputs() + if err != nil { + return err + } + + for outputData := range p2pkhOutputs { + operation := flow.operationOfUser(outputData.userID, "incoming", senderPaymail) + if len(flow.operations) > 2 { + return spverrors.Newf("paymail transaction with multiple receivers is not supported") + } + operation.add(outputData.satoshis) + flow.createP2PKHOutput(outputData) + } + + if err = flow.verify(); err != nil { + return err + } + + if err = flow.broadcast(); err != nil { + return err + } + + return flow.save() +} diff --git a/engine/transaction/record/service.go b/engine/transaction/record/service.go index b160921fd..2c5b5c2fb 100644 --- a/engine/transaction/record/service.go +++ b/engine/transaction/record/service.go @@ -4,15 +4,26 @@ import "github.com/rs/zerolog" // Service for recording transactions type Service struct { - repo Repository + addresses AddressesRepo + outputs OutputsRepo + operations OperationsRepo + broadcaster Broadcaster logger zerolog.Logger } // NewService creates a new service for transactions -func NewService(logger zerolog.Logger, repo Repository, broadcaster Broadcaster) *Service { +func NewService( + logger zerolog.Logger, + addressesRepo AddressesRepo, + outputsRepo OutputsRepo, + operationsRepo OperationsRepo, + broadcaster Broadcaster, +) *Service { return &Service{ - repo: repo, + addresses: addressesRepo, + outputs: outputsRepo, + operations: operationsRepo, broadcaster: broadcaster, logger: logger, } diff --git a/engine/transaction/record/testabilities/ability_record_outline.go b/engine/transaction/record/testabilities/ability_record_outline.go deleted file mode 100644 index 9e7180b10..000000000 --- a/engine/transaction/record/testabilities/ability_record_outline.go +++ /dev/null @@ -1,8 +0,0 @@ -package testabilities - -import "testing" - -func New(t testing.TB) (RecordServiceFixture, RecordOutlineAssert) { - g := given(t) - return g, then(t, g) -} diff --git a/engine/transaction/record/testabilities/assert_record_outline.go b/engine/transaction/record/testabilities/assert_record_outline.go deleted file mode 100644 index 1d2658b8f..000000000 --- a/engine/transaction/record/testabilities/assert_record_outline.go +++ /dev/null @@ -1,79 +0,0 @@ -package testabilities - -import ( - "testing" - - "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/stretchr/testify/require" -) - -type ErrorAssert interface { - NothingChanged() -} - -type RecordOutlineAssert interface { - NoError(err error) SuccessfullyCreatedRecordOutlineAssertion - ErrorIs(err, expectedError error) ErrorAssert - - StoredOutputs([]database.Output) RecordOutlineAssert - StoredData([]database.Data) RecordOutlineAssert -} - -type SuccessfullyCreatedRecordOutlineAssertion interface { - Broadcasted(txID string) SuccessfullyCreatedRecordOutlineAssertion - StoredAsBroadcasted(txID string) SuccessfullyCreatedRecordOutlineAssertion -} - -type assert struct { - t testing.TB - require *require.Assertions - given *recordServiceFixture -} - -func then(t testing.TB, given *recordServiceFixture) RecordOutlineAssert { - return &assert{ - t: t, - require: require.New(t), - given: given, - } -} - -func (a *assert) NoError(err error) SuccessfullyCreatedRecordOutlineAssertion { - a.require.NoError(err, "Record transaction outline has error") - return a -} - -func (a *assert) ErrorIs(err, expectedError error) ErrorAssert { - require.Error(a.t, err, "Record transaction outline has no error") - require.ErrorIs(a.t, err, expectedError, "Record transaction outline has wrong error") - return a -} - -func (a *assert) Broadcasted(txID string) SuccessfullyCreatedRecordOutlineAssertion { - tx := a.given.broadcaster.checkBroadcasted(txID) - require.NotNil(a.t, tx, "Transaction %s is not broadcasted", txID) - return a -} - -func (a *assert) StoredAsBroadcasted(txID string) SuccessfullyCreatedRecordOutlineAssertion { - tx := a.given.repository.getTransaction(txID) - require.NotNil(a.t, tx, "Transaction %s is not stored", txID) - require.Equal(a.t, txID, tx.ID, "Transaction %s has wrong ID", txID) - require.Equal(a.t, database.TxStatusBroadcasted, tx.TxStatus, "Transaction %s is not stored as broadcasted", txID) - return a -} - -func (a *assert) StoredOutputs(outputs []database.Output) RecordOutlineAssert { - require.ElementsMatch(a.t, a.given.repository.GetAllOutputs(), outputs) - return a -} - -func (a *assert) StoredData(data []database.Data) RecordOutlineAssert { - require.ElementsMatch(a.t, a.given.repository.GetAllData(), data) - return a -} - -func (a *assert) NothingChanged() { - require.ElementsMatch(a.t, a.given.initialOutputs, a.given.repository.GetAllOutputs(), "Outputs are changed") - require.ElementsMatch(a.t, a.given.initialData, a.given.repository.GetAllData(), "Data are changed") -} diff --git a/engine/transaction/record/testabilities/fixture_record_outline.go b/engine/transaction/record/testabilities/fixture_record_outline.go deleted file mode 100644 index 55e8e91dc..000000000 --- a/engine/transaction/record/testabilities/fixture_record_outline.go +++ /dev/null @@ -1,60 +0,0 @@ -package testabilities - -import ( - "testing" - - "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/engine/tester" - "github.com/bitcoin-sv/spv-wallet/engine/transaction/record" - "github.com/bitcoin-sv/spv-wallet/models/bsv" -) - -type RecordServiceFixture interface { - NewRecordService() *record.Service - - Repository() RepositoryFixture - Broadcaster() BroadcasterFixture -} - -type RepositoryFixture interface { - WithOutputs(outputs ...database.Output) RepositoryFixture - WithUTXOs(outpoints ...bsv.Outpoint) RepositoryFixture - WillFailOnSaveTX(err error) RepositoryFixture - WillFailOnGetOutputs(err error) RepositoryFixture -} - -type BroadcasterFixture interface { - WillFailOnBroadcast(err error) BroadcasterFixture -} - -type recordServiceFixture struct { - repository *mockRepository - broadcaster *mockBroadcaster - t testing.TB - - initialOutputs []database.Output - initialData []database.Data -} - -func given(t testing.TB) *recordServiceFixture { - return &recordServiceFixture{ - t: t, - repository: newMockRepository(), - broadcaster: newMockBroadcaster(), - } -} - -func (f *recordServiceFixture) Repository() RepositoryFixture { - return f.repository -} - -func (f *recordServiceFixture) Broadcaster() BroadcasterFixture { - return f.broadcaster -} - -func (f *recordServiceFixture) NewRecordService() *record.Service { - f.initialOutputs = f.repository.GetAllOutputs() - f.initialData = f.repository.GetAllData() - - return record.NewService(tester.Logger(f.t), f.repository, f.broadcaster) -} diff --git a/engine/transaction/record/testabilities/mock_broadcaster.go b/engine/transaction/record/testabilities/mock_broadcaster.go deleted file mode 100644 index 8a3244600..000000000 --- a/engine/transaction/record/testabilities/mock_broadcaster.go +++ /dev/null @@ -1,37 +0,0 @@ -package testabilities - -import ( - "context" - - trx "github.com/bitcoin-sv/go-sdk/transaction" - chainmodels "github.com/bitcoin-sv/spv-wallet/engine/chain/models" -) - -type mockBroadcaster struct { - broadcastedTxs map[string]*trx.Transaction - returnErr error -} - -func newMockBroadcaster() *mockBroadcaster { - return &mockBroadcaster{ - broadcastedTxs: make(map[string]*trx.Transaction), - } -} - -func (m *mockBroadcaster) Broadcast(_ context.Context, tx *trx.Transaction) (*chainmodels.TXInfo, error) { - m.broadcastedTxs[tx.TxID().String()] = tx - return &chainmodels.TXInfo{ - TXStatus: chainmodels.SeenOnNetwork, - TxID: tx.TxID().String(), - }, m.returnErr -} - -func (m *mockBroadcaster) WillFailOnBroadcast(err error) BroadcasterFixture { - m.returnErr = err - return m -} - -func (m *mockBroadcaster) checkBroadcasted(txID string) *trx.Transaction { - tx := m.broadcastedTxs[txID] - return tx -} diff --git a/engine/transaction/record/testabilities/mock_repository.go b/engine/transaction/record/testabilities/mock_repository.go deleted file mode 100644 index 6615a6aa2..000000000 --- a/engine/transaction/record/testabilities/mock_repository.go +++ /dev/null @@ -1,103 +0,0 @@ -package testabilities - -import ( - "context" - "iter" - "maps" - "slices" - - "github.com/bitcoin-sv/spv-wallet/engine/database" - "github.com/bitcoin-sv/spv-wallet/models/bsv" -) - -type mockRepository struct { - transactions map[string]database.TrackedTransaction - outputs map[string]database.Output - data map[string]database.Data - - errOnSave error - errOnGetOutputs error -} - -func newMockRepository() *mockRepository { - return &mockRepository{ - 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.TrackedTransaction) error { - if m.errOnSave != nil { - return m.errOnSave - } - m.transactions[txTable.ID] = *txTable - 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 - } - return nil -} - -func (m *mockRepository) GetOutputs(_ context.Context, outpoints iter.Seq[bsv.Outpoint]) ([]*database.Output, error) { - if m.errOnGetOutputs != nil { - return nil, m.errOnGetOutputs - } - var outputs []*database.Output - for outpoint := range outpoints { - if output, ok := m.outputs[outpoint.String()]; ok { - outputs = append(outputs, &output) - } - } - return outputs, nil -} - -func (m *mockRepository) WithOutputs(outputs ...database.Output) RepositoryFixture { - for _, output := range outputs { - m.outputs[output.Outpoint().String()] = output - } - return m -} - -func (m *mockRepository) WithUTXOs(outpoints ...bsv.Outpoint) RepositoryFixture { - for _, o := range outpoints { - m.outputs[o.String()] = database.Output{ - TxID: o.TxID, - Vout: o.Vout, - } - } - return m -} - -func (m *mockRepository) WillFailOnSaveTX(err error) RepositoryFixture { - m.errOnSave = err - return m -} - -func (m *mockRepository) WillFailOnGetOutputs(err error) RepositoryFixture { - m.errOnGetOutputs = err - return m -} - -func (m *mockRepository) GetAllOutputs() []database.Output { - return slices.Collect(maps.Values(m.outputs)) -} - -func (m *mockRepository) GetAllData() []database.Data { - return slices.Collect(maps.Values(m.data)) -} - -func (m *mockRepository) getTransaction(txID string) *database.TrackedTransaction { - tx, ok := m.transactions[txID] - if !ok { - return nil - } - return &tx -} diff --git a/engine/transaction/record/tx_flow.go b/engine/transaction/record/tx_flow.go new file mode 100644 index 000000000..fbf1377e6 --- /dev/null +++ b/engine/transaction/record/tx_flow.go @@ -0,0 +1,185 @@ +package record + +import ( + "context" + "iter" + "maps" + + "github.com/bitcoin-sv/go-sdk/spv" + trx "github.com/bitcoin-sv/go-sdk/transaction" + "github.com/bitcoin-sv/spv-wallet/conv" + "github.com/bitcoin-sv/spv-wallet/engine/database" + txerrors "github.com/bitcoin-sv/spv-wallet/engine/transaction/errors" + "github.com/bitcoin-sv/spv-wallet/models/bsv" + "gorm.io/datatypes" +) + +type p2pkhOutput struct { + vout uint32 + customInstructions datatypes.JSONSlice[database.CustomInstruction] + address string + satoshis bsv.Satoshis + userID string +} + +type txFlow struct { + ctx context.Context + service *Service + + tx *trx.Transaction + txRow *database.TrackedTransaction + txID string + + operations map[string]*operationWrapper +} + +func newTxFlow(ctx context.Context, service *Service, tx *trx.Transaction) *txFlow { + txID := tx.TxID().String() + return &txFlow{ + ctx: ctx, + service: service, + + tx: tx, + txID: txID, + txRow: &database.TrackedTransaction{ + ID: txID, + TxStatus: database.TxStatusCreated, + }, + + operations: map[string]*operationWrapper{}, + } +} + +func (f *txFlow) verifyScripts() error { + if ok, err := spv.VerifyScripts(f.tx); err != nil { + return txerrors.ErrTxValidation.Wrap(err) + } else if !ok { + return txerrors.ErrTxValidation + } + return nil +} + +func (f *txFlow) getFromInputs() ([]*database.TrackedOutput, error) { + outpoints := func(yield func(outpoint bsv.Outpoint) bool) { + for _, input := range f.tx.Inputs { + yield(bsv.Outpoint{ + TxID: input.SourceTXID.String(), + Vout: input.SourceTxOutIndex, + }) + } + } + trackedOutputs, err := f.service.outputs.FindByOutpoints(f.ctx, outpoints) + if err != nil { + return nil, txerrors.ErrGettingOutputs.Wrap(err) + } + + for _, output := range trackedOutputs { + if output.IsSpent() { + return nil, txerrors.ErrUTXOSpent + } + } + + return trackedOutputs, nil +} + +func (f *txFlow) operationOfUser(userID string, operationType string, counterparty string) *operationWrapper { + if _, ok := f.operations[userID]; !ok { + f.operations[userID] = &operationWrapper{ + entity: &database.Operation{ + UserID: userID, + Type: operationType, + Counterparty: counterparty, + + Transaction: f.txRow, + Value: 0, + }, + } + } + return f.operations[userID] +} + +func (f *txFlow) spendInputs(trackedOutputs []*database.TrackedOutput) { + f.txRow.AddInputs(trackedOutputs...) +} + +func (f *txFlow) createP2PKHOutput(outputData *p2pkhOutput) { + f.txRow.CreateP2PKHOutput(&database.TrackedOutput{ + TxID: f.txID, + Vout: outputData.vout, + UserID: outputData.userID, + Satoshis: outputData.satoshis, + }, outputData.customInstructions) +} + +func (f *txFlow) createDataOutputs(userID string, dataRecords ...*database.Data) { + for _, data := range dataRecords { + f.txRow.CreateDataOutput(data, userID) + } +} + +func (f *txFlow) findRelevantP2PKHOutputs() (iter.Seq[*p2pkhOutput], error) { + relevantOutputs := map[string]uint32{} // address -> vout + for vout, output := range f.tx.Outputs { + lockingScript := output.LockingScript + if !lockingScript.IsP2PKH() { + continue + } + address, err := lockingScript.Address() + if err != nil { + f.service.logger.Warn().Err(err).Msg("failed to get address from locking script") + continue + } + + voutU32, err := conv.IntToUint32(vout) + if err != nil { + f.service.logger.Warn().Err(err).Msg("failed to convert vout to uint32") + continue + } + + relevantOutputs[address.AddressString] = voutU32 + } + + rows, err := f.service.addresses.FindByStringAddresses(f.ctx, maps.Keys(relevantOutputs)) + if err != nil { + return nil, txerrors.ErrGettingAddresses.Wrap(err) + } + + return func(yield func(*p2pkhOutput) bool) { + for _, row := range rows { + vout, ok := relevantOutputs[row.Address] + if !ok { + f.service.logger.Warn().Str("address", row.Address).Msg("Got not relevant address from database") + continue + } + yield(&p2pkhOutput{ + vout: vout, + customInstructions: row.CustomInstructions, + address: row.Address, + satoshis: bsv.Satoshis(f.tx.Outputs[vout].Satoshis), + userID: row.UserID, + }) + } + }, nil +} + +func (f *txFlow) verify() error { + if len(f.operations) == 0 { + return txerrors.ErrNoOperations + } + return nil +} + +func (f *txFlow) broadcast() error { + if _, err := f.service.broadcaster.Broadcast(f.ctx, f.tx); err != nil { + return txerrors.ErrTxBroadcast.Wrap(err) + } + return nil +} + +func (f *txFlow) save() error { + err := f.service.operations.SaveAll(f.ctx, toOperationEntities(maps.Values(f.operations))) + if err != nil { + return txerrors.ErrSavingData.Wrap(err) + } + return nil +} diff --git a/internal/query/parse.go b/internal/query/parse.go index c78a07bbf..e27fdeebe 100644 --- a/internal/query/parse.go +++ b/internal/query/parse.go @@ -21,6 +21,7 @@ func ParseSearchParams[T any](c *gin.Context) (*filter.SearchParams[T], error) { config := mapstructure.DecoderConfig{ DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339), WeaklyTypedInput: true, + Squash: true, Result: ¶ms, TagName: "json", // Small hax to reuse json tags which we have already defined } diff --git a/models/filter/access_key_filter.go b/models/filter/access_key_filter.go index 32c319be1..f76800b74 100644 --- a/models/filter/access_key_filter.go +++ b/models/filter/access_key_filter.go @@ -3,8 +3,7 @@ package filter // AccessKeyFilter is a struct for handling request parameters for destination search requests type AccessKeyFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` // RevokedRange specifies the time range when a record was revoked. RevokedRange *TimeRange `json:"revokedRange,omitempty"` diff --git a/models/filter/contact_admin_filter.go b/models/filter/contact_admin_filter.go index 7dfe1d6d7..02308f7d5 100644 --- a/models/filter/contact_admin_filter.go +++ b/models/filter/contact_admin_filter.go @@ -2,8 +2,7 @@ package filter // AdminContactFilter extends ContactFilter for admin-specific use, including xpubId filtering type AdminContactFilter struct { - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ContactFilter `json:",inline,squash"` + ContactFilter `json:",inline"` XPubID *string `json:"xpubId,omitempty" example:"623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486"` } diff --git a/models/filter/contact_filter.go b/models/filter/contact_filter.go index 083698471..4d4ec7b34 100644 --- a/models/filter/contact_filter.go +++ b/models/filter/contact_filter.go @@ -3,8 +3,7 @@ package filter // ContactFilter is a struct for handling request parameters for contact search requests type ContactFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` ID *string `json:"id" example:"ffdbe74e-0700-4710-aac5-611a1f877c7f"` FullName *string `json:"fullName" example:"Alice"` Paymail *string `json:"paymail" example:"alice@example.com"` diff --git a/models/filter/destination_filter.go b/models/filter/destination_filter.go index 02a000fbd..af7a2b121 100644 --- a/models/filter/destination_filter.go +++ b/models/filter/destination_filter.go @@ -3,8 +3,7 @@ package filter // DestinationFilter is a struct for handling request parameters for destination search requests type DestinationFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` LockingScript *string `json:"lockingScript,omitempty" example:"76a9147b05764a97f3b4b981471492aa703b188e45979b88ac"` Address *string `json:"address,omitempty" example:"1CDUf7CKu8ocTTkhcYUbq75t14Ft168K65"` DraftID *string `json:"draftId,omitempty" example:"b356f7fa00cd3f20cce6c21d704cd13e871d28d714a5ebd0532f5a0e0cde63f7"` diff --git a/models/filter/paymail_filter.go b/models/filter/paymail_filter.go index fcf1a668c..7eec3389d 100644 --- a/models/filter/paymail_filter.go +++ b/models/filter/paymail_filter.go @@ -3,8 +3,7 @@ package filter // PaymailFilter is a struct for handling request parameters for paymail_addresses search requests type PaymailFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` ID *string `json:"id,omitempty" example:"ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542"` Alias *string `json:"alias,omitempty" example:"alice"` @@ -30,8 +29,7 @@ func (d *PaymailFilter) ToDbConditions() map[string]interface{} { // AdminPaymailFilter wraps the PaymailFilter providing additional fields for admin paymail search requests type AdminPaymailFilter struct { - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - PaymailFilter `json:",inline,squash"` + PaymailFilter `json:",inline"` XpubID *string `json:"xpubId,omitempty" example:"79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00"` } diff --git a/models/filter/transaction_admin_filter.go b/models/filter/transaction_admin_filter.go index d2cc696ba..c5a42555c 100644 --- a/models/filter/transaction_admin_filter.go +++ b/models/filter/transaction_admin_filter.go @@ -2,8 +2,7 @@ package filter // AdminTransactionFilter extends TransactionFilter for admin-specific use, including xpubId filtering type AdminTransactionFilter struct { - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - TransactionFilter `json:",inline,squash"` + TransactionFilter `json:",inline"` XPubID *string `json:"xpubId,omitempty" example:"623bc25ce1c0fc510dea72b5ee27b2e70384c099f1f3dce9e73dd987198c3486"` } diff --git a/models/filter/transaction_filter.go b/models/filter/transaction_filter.go index bfc9a15c2..61025572c 100644 --- a/models/filter/transaction_filter.go +++ b/models/filter/transaction_filter.go @@ -3,8 +3,7 @@ package filter // TransactionFilter is a struct for handling request parameters for transactions search requests type TransactionFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` Id *string `json:"id,omitempty" example:"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"` Hex *string `json:"hex,omitempty"` BlockHash *string `json:"blockHash,omitempty" example:"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"` diff --git a/models/filter/utxo_filter.go b/models/filter/utxo_filter.go index d16441900..86f1ffc54 100644 --- a/models/filter/utxo_filter.go +++ b/models/filter/utxo_filter.go @@ -4,8 +4,7 @@ package filter type UtxoFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` TransactionID *string `json:"transactionId,omitempty" example:"5e17858ea0ca4155827754ba82bdcfcce108d5bb5b47fbb3aa54bd14540683c6"` OutputIndex *uint32 `json:"outputIndex,omitempty" example:"0"` diff --git a/models/filter/xpub_filter.go b/models/filter/xpub_filter.go index 8462ab6d2..3299853bc 100644 --- a/models/filter/xpub_filter.go +++ b/models/filter/xpub_filter.go @@ -3,8 +3,7 @@ package filter // XpubFilter is a struct for handling request parameters for utxo search requests type XpubFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests - //nolint:staticcheck // SA5008 We want to reuse json tags also to mapstructure. - ModelFilter `json:",inline,squash"` + ModelFilter `json:",inline"` ID *string `json:"id,omitempty" example:"00b953624f78004a4c727cd28557475d5233c15f17aef545106639f4d71b712d"` CurrentBalance *uint64 `json:"currentBalance,omitempty" example:"1000"` diff --git a/models/response/operation.go b/models/response/operation.go new file mode 100644 index 000000000..10b2db4ce --- /dev/null +++ b/models/response/operation.go @@ -0,0 +1,12 @@ +package response + +import "time" + +// Operation represents a user's operation on a transaction. +type Operation struct { + CreatedAt time.Time `json:"createdAt" example:"2024-02-26T11:00:28.069911Z"` + Value int64 `json:"value" example:"1234"` + TxID string `json:"txID" example:"bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50"` + Type string `json:"type" example:"incoming" enums:"incoming,outgoing"` + Counterparty string `json:"counterparty" example:"alice@example.com"` +} diff --git a/models/response/user_info.go b/models/response/user_info.go new file mode 100644 index 000000000..1ff3e6eb9 --- /dev/null +++ b/models/response/user_info.go @@ -0,0 +1,8 @@ +package response + +import "github.com/bitcoin-sv/spv-wallet/models/bsv" + +// UserInfo represents the response model for current user information +type UserInfo struct { + CurrentBalance bsv.Satoshis `json:"currentBalance"` +} diff --git a/server/handlers/manager.go b/server/handlers/manager.go index 586626de8..f48e5ee9f 100644 --- a/server/handlers/manager.go +++ b/server/handlers/manager.go @@ -60,3 +60,8 @@ func (mg *Manager) Get(endpointType GroupType) *gin.RouterGroup { func (mg *Manager) GetFeatureFlags() *config.ExperimentalConfig { return mg.appConfig.ExperimentalFeatures } + +// APIVersion returns the API version from app configuration +func (mg *Manager) APIVersion() string { + return mg.appConfig.Version +} diff --git a/server/reqctx/usercontext.go b/server/reqctx/usercontext.go index c9d67e68d..3c700ace6 100644 --- a/server/reqctx/usercontext.go +++ b/server/reqctx/usercontext.go @@ -1,6 +1,8 @@ package reqctx import ( + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "github.com/bitcoin-sv/go-sdk/script" "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" "github.com/gin-gonic/gin" @@ -86,6 +88,32 @@ func (ctx *UserContext) GetXPubObj() *engine.Xpub { return ctx.xPubObj } +// ShouldGetUserID returns userID for NEW DB SCHEMA +// Warning: Don't use it for old DB schema +func (ctx *UserContext) ShouldGetUserID() (string, error) { + xpub, err := ctx.ShouldGetXPub() + if err != nil { + return "", err + } + + xpubObj, err := bip32.NewKeyFromString(xpub) + if err != nil { + return "", spverrors.ErrInternal.Wrap(err) + } + + pubKey, err := xpubObj.ECPubKey() + if err != nil { + return "", spverrors.ErrInternal.Wrap(err) + } + + addr, err := script.NewAddressFromPublicKey(pubKey, true) + if err != nil { + return "", spverrors.ErrInternal.Wrap(err) + } + + return addr.AddressString, nil +} + // GetUserContext returns the user context from the request context func GetUserContext(c *gin.Context) *UserContext { value := c.MustGet(userContextKey) diff --git a/server/server.go b/server/server.go index 4690c52d7..6a33934a0 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/actions" "github.com/bitcoin-sv/spv-wallet/actions/paymailserver" + v2 "github.com/bitcoin-sv/spv-wallet/actions/v2" "github.com/bitcoin-sv/spv-wallet/config" "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/engine/spverrors" @@ -110,6 +111,10 @@ func setupServerRoutes(appConfig *config.AppConfig, spvWalletEngine engine.Clien actions.Register(handlersManager) paymailserver.Register(spvWalletEngine.GetPaymailConfig().Configuration, ginEngine) + if appConfig.ExperimentalFeatures.NewTransactionFlowEnabled { + v2.Register(handlersManager) + } + if appConfig.DebugProfiling { pprof.Register(ginEngine, "debug/pprof") } diff --git a/tests/tests.go b/tests/tests.go index 1b75c1296..f905b9830 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -7,8 +7,8 @@ import ( "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/tester" - "github.com/bitcoin-sv/spv-wallet/engine/utils" "github.com/bitcoin-sv/spv-wallet/logging" "github.com/bitcoin-sv/spv-wallet/server/middleware" "github.com/gin-gonic/gin" @@ -38,9 +38,11 @@ func (ts *TestSuite) BaseSetupSuite() { } cfg.Notifications.Enabled = false - // Defaults for safe thread testing + cfg.Db.Datastore.Engine = datastore.SQLite + cfg.Db.SQLite.Shared = false cfg.Db.SQLite.MaxIdleConnections = 1 cfg.Db.SQLite.MaxOpenConnections = 1 + cfg.Db.SQLite.DatabasePath = "file:spv-wallet-suite-test.db?mode=memory" ts.AppConfig = cfg } @@ -59,9 +61,6 @@ func (ts *TestSuite) BaseSetupTest() { var err error ts.Logger = tester.Logger(ts.T()) - ts.AppConfig.Db.SQLite.TablePrefix, err = utils.RandomHex(8) - require.NoError(ts.T(), err) - opts, err := ts.AppConfig.ToEngineOptions(ts.Logger) require.NoError(ts.T(), err)