From 0d94f7c3a17fc36aa7bf9371905f121c3c6e12bf Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Wed, 23 Oct 2024 12:28:24 +0200 Subject: [PATCH 01/18] refactor(SPV-1087): Remove old project structure. (#2) --- access_keys_test.go | 60 - admin_contacts_test.go | 78 -- authentication.go | 194 --- client_options.go | 142 -- client_options_test.go | 33 - config.go | 89 -- contacts_test.go | 78 -- destinations_test.go | 96 -- errors.go | 95 -- examples/README.md | 54 - examples/Taskfile.yml | 78 -- examples/admin_add_user/admin_add_user.go | 43 - .../admin_remove_user/admin_remove_user.go | 33 - .../create_transaction/create_transaction.go | 46 - examples/errors.go | 21 - examples/example_keys.go | 19 - examples/generate_keys/generate_keys.go | 25 - examples/generate_totp/generate_totp.go | 48 - examples/get_balance/get_balance.go | 35 - examples/go.mod | 18 - examples/go.sum | 24 - .../handle_exceptions/handle_exceptions.go | 42 - .../list_transactions/list_transactions.go | 52 - examples/send_op_return/send_op_return.go | 53 - examples/sync_merkleroots/sync_merkleroots.go | 88 -- examples/utils.go | 46 - examples/webhooks/webhooks.go | 97 -- .../xpriv_from_mnemonic.go | 25 - examples/xpub_from_xpriv/xpub_from_xpriv.go | 25 - fixtures/fixtures.go | 217 --- fixtures/spv_wallet.go | 126 -- fixtures/sync_merkleroots.go | 119 -- http.go | 1170 ----------------- notifications/eventsMap.go | 25 - notifications/interface.go | 9 - notifications/options.go | 58 - notifications/registerer.go | 27 - notifications/webhook.go | 100 -- regression_tests/Taskfile.yml | 18 - regression_tests/regression_test.go | 133 -- regression_tests/utils.go | 207 --- search.go | 72 - sync_merkleroots.go | 72 - sync_merkleroots_test.go | 102 -- totp.go | 126 -- totp_test.go | 133 -- transactions_test.go | 117 -- utils/utils.go | 85 -- walletclient.go | 93 -- walletclient_test.go | 164 --- xpriv/example_test.go | 27 - xpriv/xpriv.go | 146 -- xpubs_test.go | 64 - 53 files changed, 5147 deletions(-) delete mode 100644 access_keys_test.go delete mode 100644 admin_contacts_test.go delete mode 100644 authentication.go delete mode 100644 client_options.go delete mode 100644 client_options_test.go delete mode 100644 config.go delete mode 100644 contacts_test.go delete mode 100644 destinations_test.go delete mode 100644 errors.go delete mode 100644 examples/README.md delete mode 100644 examples/Taskfile.yml delete mode 100644 examples/admin_add_user/admin_add_user.go delete mode 100644 examples/admin_remove_user/admin_remove_user.go delete mode 100644 examples/create_transaction/create_transaction.go delete mode 100644 examples/errors.go delete mode 100644 examples/example_keys.go delete mode 100644 examples/generate_keys/generate_keys.go delete mode 100644 examples/generate_totp/generate_totp.go delete mode 100644 examples/get_balance/get_balance.go delete mode 100644 examples/go.mod delete mode 100644 examples/go.sum delete mode 100644 examples/handle_exceptions/handle_exceptions.go delete mode 100644 examples/list_transactions/list_transactions.go delete mode 100644 examples/send_op_return/send_op_return.go delete mode 100644 examples/sync_merkleroots/sync_merkleroots.go delete mode 100644 examples/utils.go delete mode 100644 examples/webhooks/webhooks.go delete mode 100644 examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go delete mode 100644 examples/xpub_from_xpriv/xpub_from_xpriv.go delete mode 100644 fixtures/fixtures.go delete mode 100644 fixtures/spv_wallet.go delete mode 100644 fixtures/sync_merkleroots.go delete mode 100644 http.go delete mode 100644 notifications/eventsMap.go delete mode 100644 notifications/interface.go delete mode 100644 notifications/options.go delete mode 100644 notifications/registerer.go delete mode 100644 notifications/webhook.go delete mode 100644 regression_tests/Taskfile.yml delete mode 100644 regression_tests/regression_test.go delete mode 100644 regression_tests/utils.go delete mode 100644 search.go delete mode 100644 sync_merkleroots.go delete mode 100644 sync_merkleroots_test.go delete mode 100644 totp.go delete mode 100644 totp_test.go delete mode 100644 transactions_test.go delete mode 100644 utils/utils.go delete mode 100644 walletclient.go delete mode 100644 walletclient_test.go delete mode 100644 xpriv/example_test.go delete mode 100644 xpriv/xpriv.go delete mode 100644 xpubs_test.go diff --git a/access_keys_test.go b/access_keys_test.go deleted file mode 100644 index 42732a8e..00000000 --- a/access_keys_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package walletclient here we are testing walletclient public methods -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/stretchr/testify/require" -) - -// TestAccessKeys will test the AccessKey methods -func TestAccessKeys(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/access-key": - switch r.Method { - case http.MethodGet, http.MethodPost, http.MethodDelete: - json.NewEncoder(w).Encode(fixtures.AccessKey) - } - case "/v1/access-key/search": - json.NewEncoder(w).Encode([]*models.AccessKey{fixtures.AccessKey}) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString) - require.NoError(t, err) - require.NotNil(t, client.accessKey) - - t.Run("GetAccessKey", func(t *testing.T) { - accessKey, err := client.GetAccessKey(context.Background(), fixtures.AccessKey.ID) - require.NoError(t, err) - require.Equal(t, fixtures.AccessKey, accessKey) - }) - - t.Run("GetAccessKeys", func(t *testing.T) { - accessKeys, err := client.GetAccessKeys(context.Background(), nil, nil, nil) - require.NoError(t, err) - require.Equal(t, []*models.AccessKey{fixtures.AccessKey}, accessKeys) - }) - - t.Run("CreateAccessKey", func(t *testing.T) { - accessKey, err := client.CreateAccessKey(context.Background(), nil) - require.NoError(t, err) - require.Equal(t, fixtures.AccessKey, accessKey) - }) - - t.Run("RevokeAccessKey", func(t *testing.T) { - accessKey, err := client.RevokeAccessKey(context.Background(), fixtures.AccessKey.ID) - require.NoError(t, err) - require.Equal(t, fixtures.AccessKey, accessKey) - }) -} diff --git a/admin_contacts_test.go b/admin_contacts_test.go deleted file mode 100644 index ea0381a7..00000000 --- a/admin_contacts_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - responsemodels "github.com/bitcoin-sv/spv-wallet/models/response" - "github.com/stretchr/testify/require" -) - -// TestAdminContactActions testing Admin contacts methods -func TestAdminContactActions(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/v1/admin/contact/search" && r.Method == http.MethodPost: - c := fixtures.Contact - c.ID = "1" - content := models.PagedResponse[*models.Contact]{ - Content: []*models.Contact{c}, - } - json.NewEncoder(w).Encode(content) - case r.URL.Path == "/v1/admin/contact/1" && r.Method == http.MethodPatch: - contact := fixtures.Contact - json.NewEncoder(w).Encode(contact) - case r.URL.Path == "/v1/admin/contact/1" && r.Method == http.MethodDelete: - w.WriteHeader(http.StatusOK) - case r.URL.Path == "/v1/admin/contact/accepted/1" && r.Method == http.MethodPatch: - contact := fixtures.Contact - contact.Status = responsemodels.ContactNotConfirmed - json.NewEncoder(w).Encode(contact) - case r.URL.Path == "/v1/admin/contact/rejected/1" && r.Method == http.MethodPatch: - contact := fixtures.Contact - contact.Status = responsemodels.ContactRejected - json.NewEncoder(w).Encode(contact) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - client, err := NewWithAdminKey(server.URL, fixtures.XPrivString) - require.NoError(t, err) - require.NotNil(t, client.adminXPriv) - - t.Run("AdminGetContacts", func(t *testing.T) { - contacts, err := client.AdminGetContacts(context.Background(), nil, nil, nil) - require.NoError(t, err) - require.Equal(t, "1", contacts.Content[0].ID) - }) - - t.Run("AdminUpdateContact", func(t *testing.T) { - contact, err := client.AdminUpdateContact(context.Background(), "1", "Jane Doe", nil) - require.NoError(t, err) - require.Equal(t, "Test User", contact.FullName) - }) - - t.Run("AdminDeleteContact", func(t *testing.T) { - err := client.AdminDeleteContact(context.Background(), "1") - require.NoError(t, err) - }) - - t.Run("AdminAcceptContact", func(t *testing.T) { - contact, err := client.AdminAcceptContact(context.Background(), "1") - require.NoError(t, err) - require.Equal(t, responsemodels.ContactNotConfirmed, contact.Status) - }) - - t.Run("AdminRejectContact", func(t *testing.T) { - contact, err := client.AdminRejectContact(context.Background(), "1") - require.NoError(t, err) - require.Equal(t, responsemodels.ContactRejected, contact.Status) - }) -} diff --git a/authentication.go b/authentication.go deleted file mode 100644 index 1a777196..00000000 --- a/authentication.go +++ /dev/null @@ -1,194 +0,0 @@ -package walletclient - -import ( - "encoding/base64" - "fmt" - "net/http" - "time" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - bsm "github.com/bitcoin-sv/go-sdk/compat/bsm" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - script "github.com/bitcoin-sv/go-sdk/script" - trx "github.com/bitcoin-sv/go-sdk/transaction" - sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash" - "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" - - "github.com/bitcoin-sv/spv-wallet-go-client/utils" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// SetSignature will set the signature on the header for the request -func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error { - // Create the signature - authData, err := createSignature(xPriv, bodyString) - if err != nil { - return WrapError(err) - } - - // Set the auth header - header.Set(models.AuthHeader, authData.XPub) - - setSignatureHeaders(header, authData) - - return nil -} - -// GetSignedHex will sign all the inputs using the given xPriv key -func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { - // Create transaction from hex - tx, err := trx.NewTransactionFromHex(dt.Hex) - - // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign - tx.Inputs = make([]*trx.TransactionInput, 0) - if err != nil { - return "", err - } - - // Enrich inputs - for _, draftInput := range dt.Configuration.Inputs { - lockingScript, err := prepareLockingScript(&draftInput.Destination) - if err != nil { - return "", err - } - - unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination) - if err != nil { - return "", err - } - - tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) - } - - tx.Sign() - - return tx.String(), nil -} - -func prepareLockingScript(dst *models.Destination) (*script.Script, error) { - lockingScript, err := script.NewFromHex(dst.LockingScript) - if err != nil { - return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err) - } - - return lockingScript, nil -} - -func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { - key, err := getDerivedKeyForDestination(xPriv, dst) - if err != nil { - return nil, err - } - - return getUnlockingScript(key) -} - -func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) { - // Derive the child key (m/chain/num) - derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num) - if err != nil { - return nil, err - } - - // Handle paymail destination derivation if applicable - if dst.PaymailExternalDerivationNum != nil { - derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum) - if err != nil { - return nil, err - } - } - - // Get the private key from the derived key - return bip32.GetPrivateKeyFromHDKey(derivedKey) -} - -// Generate unlocking script using private key -func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { - sigHashFlags := sighash.AllForkID - return p2pkh.Unlock(privateKey, &sigHashFlags) -} - -// createSignature will create a signature for the given key & body contents -func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *models.AuthPayload, err error) { - // No key? - if xPriv == nil { - err = ErrMissingXpriv - return - } - - // Get the xPub - payload = new(models.AuthPayload) - if payload.XPub, err = bip32.GetExtendedPublicKey( - xPriv, - ); err != nil { // Should never error if key is correct - return - } - - // auth_nonce is a random unique string to seed the signing message - // this can be checked server side to make sure the request is not being replayed - if payload.AuthNonce, err = utils.RandomHex(32); err != nil { // Should never error if key is correct - return - } - - // Derive the address for signing - var key *bip32.ExtendedKey - if key, err = utils.DeriveChildKeyFromHex( - xPriv, payload.AuthNonce, - ); err != nil { - return - } - - var privateKey *ec.PrivateKey - if privateKey, err = bip32.GetPrivateKeyFromHDKey(key); err != nil { - return // Should never error if key is correct - } - - return createSignatureCommon(payload, bodyString, privateKey) -} - -// createSignatureCommon will create a signature -func createSignatureCommon(payload *models.AuthPayload, bodyString string, privateKey *ec.PrivateKey) (*models.AuthPayload, error) { - // Create the auth header hash - payload.AuthHash = utils.Hash(bodyString) - - // auth_time is the current time and makes sure a request can not be sent after 30 secs - payload.AuthTime = time.Now().UnixMilli() - - key := payload.XPub - if key == "" && payload.AccessKey != "" { - key = payload.AccessKey - } - - // Signature, using bitcoin signMessage - sigBytes, err := bsm.SignMessage( - privateKey, - getSigningMessage(key, payload), - ) - if err != nil { - return nil, err - } - - payload.Signature = base64.StdEncoding.EncodeToString(sigBytes) - - return payload, nil -} - -// getSigningMessage will build the signing message byte array -func getSigningMessage(xPub string, auth *models.AuthPayload) []byte { - message := fmt.Sprintf("%s%s%s%d", xPub, auth.AuthHash, auth.AuthNonce, auth.AuthTime) - return []byte(message) -} - -func setSignatureHeaders(header *http.Header, authData *models.AuthPayload) { - // Create the auth header hash - header.Set(models.AuthHeaderHash, authData.AuthHash) - - // Set the nonce - header.Set(models.AuthHeaderNonce, authData.AuthNonce) - - // Set the time - header.Set(models.AuthHeaderTime, fmt.Sprintf("%d", authData.AuthTime)) - - // Set the signature - header.Set(models.AuthSignature, authData.Signature) -} diff --git a/client_options.go b/client_options.go deleted file mode 100644 index 9e2df8bc..00000000 --- a/client_options.go +++ /dev/null @@ -1,142 +0,0 @@ -package walletclient - -import ( - "fmt" - "net/http" - "net/url" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" -) - -// configurator is the interface for configuring WalletClient -type configurator interface { - Configure(c *WalletClient) error -} - -// xPrivConf sets the xPrivString field of a WalletClient -type xPrivConf struct { - XPrivString string -} - -func (w *xPrivConf) Configure(c *WalletClient) error { - var err error - if c.xPriv, err = bip32.GenerateHDKeyFromString(w.XPrivString); err != nil { - c.xPriv = nil - return ErrInvalidXpriv.Wrap(err) - } - return nil -} - -// xPubConf sets the xPubString on the client -type xPubConf struct { - XPubString string -} - -func (w *xPubConf) Configure(c *WalletClient) error { - var err error - if c.xPub, err = bip32.GetHDKeyFromExtendedPublicKey(w.XPubString); err != nil { - c.xPub = nil - return ErrInvalidXpub.Wrap(err) - } - return nil -} - -// accessKeyConf sets the accessKeyString on the client -type accessKeyConf struct { - AccessKeyString string -} - -func (w *accessKeyConf) Configure(c *WalletClient) error { - var err error - if c.accessKey, err = w.initializeAccessKey(); err != nil { - c.accessKey = nil - return err - } - return nil -} - -func (w *accessKeyConf) initializeAccessKey() (*ec.PrivateKey, error) { - var errPriv, errPub error - privateKey, errPriv := ec.PrivateKeyFromWif(w.AccessKeyString) - if errPriv != nil { - privateKey, errPub = ec.PrivateKeyFromHex(w.AccessKeyString) - if privateKey == nil { - return nil, ErrInvalidAccessKey.Wrap(errPriv).Wrap(errPub) - } - } - - return privateKey, nil -} - -// adminKeyConf sets the admin key for creating new xpubs -type adminKeyConf struct { - AdminKeyString string -} - -func (w *adminKeyConf) Configure(c *WalletClient) error { - var err error - c.adminXPriv, err = bip32.GenerateHDKeyFromString(w.AdminKeyString) - if err != nil { - c.adminXPriv = nil - return ErrInvalidAdminKey.Wrap(err) - } - return nil -} - -// httpConf sets the URL and httpConf client of a WalletClient -type httpConf struct { - ServerURL string - HTTPClient *http.Client -} - -func (w *httpConf) Configure(c *WalletClient) error { - // Ensure the ServerURL ends with a clean base URL - baseURL, err := validateAndCleanURL(w.ServerURL) - if err != nil { - return ErrInvalidServerURL.Wrap(err) - } - - const basePath = "/v1" - c.server = fmt.Sprintf("%s%s", baseURL, basePath) - - c.httpClient = w.HTTPClient - if w.HTTPClient != nil { - c.httpClient = w.HTTPClient - } else { - c.httpClient = http.DefaultClient - } - return nil -} - -// signRequest configures whether to sign HTTP requests -type signRequest struct { - Sign bool -} - -func (w *signRequest) Configure(c *WalletClient) error { - c.signRequest = w.Sign - return nil -} - -// validateAndCleanURL ensures that the provided URL is valid, and strips it down to just the base URL. -func validateAndCleanURL(rawURL string) (string, error) { - if rawURL == "" { - return "", fmt.Errorf("empty URL") - } - - // Parse the URL to validate it - parsedURL, err := url.Parse(rawURL) - if err != nil { - return "", fmt.Errorf("parsing URL failed: %w", err) - } - - // Rebuild the URL with only the scheme and host (and port if included) - cleanedURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) - - if parsedURL.Path == "" || parsedURL.Path == "/" { - return cleanedURL, nil - } - - return cleanedURL, nil -} diff --git a/client_options_test.go b/client_options_test.go deleted file mode 100644 index 5627afa3..00000000 --- a/client_options_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package walletclient - -import "testing" - -func TestValidateAndCleanURL(t *testing.T) { - tests := []struct { - name string - rawURL string - expected string - wantErr bool - }{ - {"Empty URL", "", "", true}, - {"Valid URL with path", "http://example.com/path", "http://example.com", false}, - {"Valid URL without path", "http://example.com", "http://example.com", false}, - {"Valid URL with port", "http://example.com:8080", "http://example.com:8080", false}, - {"Invalid URL", "http://%41:8080/", "", true}, - {"HTTPS URL", "https://example.com", "https://example.com", false}, - {"HTTPS URL with path", "https://example.com/path", "https://example.com", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := validateAndCleanURL(tt.rawURL) - if (err != nil) != tt.wantErr { - t.Errorf("validateAndCleanURL() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.expected { - t.Errorf("validateAndCleanURL() = %v, expected %v", got, tt.expected) - } - }) - } -} diff --git a/config.go b/config.go deleted file mode 100644 index cde6aeb2..00000000 --- a/config.go +++ /dev/null @@ -1,89 +0,0 @@ -package walletclient - -import "github.com/bitcoin-sv/spv-wallet/models" - -// TransportType the type of transport being used ('http' for usage or 'mock' for testing) -type TransportType string - -// SPVWalletUserAgent the spv wallet user agent sent to the spv wallet. -const SPVWalletUserAgent = "SPVWallet: go-client" - -const ( - // SPVWalletTransportHTTP uses the http transport for all spv-wallet actions - SPVWalletTransportHTTP TransportType = "http" - - // SPVWalletTransportMock uses the mock transport for all spv-wallet actions - SPVWalletTransportMock TransportType = "mock" -) - -// Recipients is a struct for recipients -type Recipients struct { - OpReturn *models.OpReturn `json:"op_return"` - Satoshis uint64 `json:"satoshis"` - To string `json:"to"` -} - -const ( - // FieldMetadata is the field name for metadata - FieldMetadata = "metadata" - - // FieldQueryParams is the field name for the query params - FieldQueryParams = "params" - - // FieldXpubKey is the field name for xpub key - FieldXpubKey = "key" - - // FieldXpubID is the field name for xpub id - FieldXpubID = "xpub_id" - - // FieldAddress is the field name for paymail address - FieldAddress = "address" - - // FieldPublicName is the field name for (paymail) public name - FieldPublicName = "public_name" - - // FieldAvatar is the field name for (paymail) avatar - FieldAvatar = "avatar" - - // FieldConditions is the field name for conditions - FieldConditions = "conditions" - - // FieldTo is the field name for "to" - FieldTo = "to" - - // FieldSatoshis is the field name for "satoshis" - FieldSatoshis = "satoshis" - - // FieldOpReturn is the field name for "op_return" - FieldOpReturn = "op_return" - - // FieldConfig is the field name for "config" - FieldConfig = "config" - - // FieldOutputs is the field name for "outputs" - FieldOutputs = "outputs" - - // FieldHex is the field name for "hex" - FieldHex = "hex" - - // FieldReferenceID is the field name for "reference_id" - FieldReferenceID = "reference_id" - - // FieldID is the id field for most models - FieldID = "id" - - // FieldLockingScript is the field for locking script - FieldLockingScript = "locking_script" - - // FieldUserAgent is the field for storing the user agent - FieldUserAgent = "user_agent" - - // FieldTransactionConfig is the field for the config of a new transaction - FieldTransactionConfig = "transaction_config" - - // FieldTransactionID is the field for transaction ID - FieldTransactionID = "tx_id" - - // FieldOutputIndex is the field for "output_index" - FieldOutputIndex = "output_index" -) diff --git a/contacts_test.go b/contacts_test.go deleted file mode 100644 index 5781edad..00000000 --- a/contacts_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - responsemodels "github.com/bitcoin-sv/spv-wallet/models/response" - "github.com/stretchr/testify/require" -) - -// TestContactActionsRouting will test routing -func TestContactActionsRouting(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case strings.HasPrefix(r.URL.Path, "/v1/contact/rejected/"): - if r.Method == http.MethodPatch { - json.NewEncoder(w).Encode(map[string]string{"result": "rejected"}) - } - case r.URL.Path == "/v1/contact/accepted/": - if r.Method == http.MethodPost { - json.NewEncoder(w).Encode(map[string]string{"result": string(responsemodels.ContactNotConfirmed)}) - } - case r.URL.Path == "/v1/contact/search": - if r.Method == http.MethodPost { - content := models.PagedResponse[*models.Contact]{ - Content: []*models.Contact{fixtures.Contact}, - } - json.NewEncoder(w).Encode(content) - } - case strings.HasPrefix(r.URL.Path, "/v1/contact/"): - if r.Method == http.MethodPost || r.Method == http.MethodPut { - json.NewEncoder(w).Encode(map[string]string{"result": "upserted"}) - } - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString) - require.NoError(t, err) - require.NotNil(t, client.accessKey) - - t.Run("RejectContact", func(t *testing.T) { - err := client.RejectContact(context.Background(), fixtures.PaymailAddress) - require.NoError(t, err) - }) - - t.Run("AcceptContact", func(t *testing.T) { - err := client.AcceptContact(context.Background(), fixtures.PaymailAddress) - require.NoError(t, err) - }) - - t.Run("GetContacts", func(t *testing.T) { - contacts, err := client.GetContacts(context.Background(), nil, nil, nil) - require.NoError(t, err) - require.NotNil(t, contacts) - }) - - t.Run("UpsertContact", func(t *testing.T) { - contact, err := client.UpsertContact(context.Background(), "test-id", "test@paymail.com", "", nil) - require.NoError(t, err) - require.NotNil(t, contact) - }) - - t.Run("UpsertContactForPaymail", func(t *testing.T) { - contact, err := client.UpsertContactForPaymail(context.Background(), "test-id", "test@paymail.com", nil, "test@paymail.com") - require.NoError(t, err) - require.NotNil(t, contact) - }) -} diff --git a/destinations_test.go b/destinations_test.go deleted file mode 100644 index f117bf22..00000000 --- a/destinations_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/filter" - "github.com/stretchr/testify/require" -) - -func TestDestinations(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sendJSONResponse := func(data interface{}) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(data); err != nil { - w.WriteHeader(http.StatusInternalServerError) - } - } - - const dest = "/v1/destination" - - switch { - case r.URL.Path == "/v1/v1/destination/address/"+fixtures.Destination.Address && r.Method == http.MethodGet: - sendJSONResponse(fixtures.Destination) - case r.URL.Path == "/v1/destination/lockingScript/"+fixtures.Destination.LockingScript && r.Method == http.MethodGet: - sendJSONResponse(fixtures.Destination) - case r.URL.Path == "/v1/destination/search" && r.Method == http.MethodPost: - sendJSONResponse([]*models.Destination{fixtures.Destination}) - case r.URL.Path == dest && r.Method == http.MethodGet: - sendJSONResponse(fixtures.Destination) - case r.URL.Path == dest && r.Method == http.MethodPatch: - sendJSONResponse(fixtures.Destination) - case r.URL.Path == dest && r.Method == http.MethodPost: - sendJSONResponse(fixtures.Destination) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString) - require.NoError(t, err) - require.NotNil(t, client.accessKey) - - t.Run("GetDestinationByID", func(t *testing.T) { - destination, err := client.GetDestinationByID(context.Background(), fixtures.Destination.ID) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("GetDestinationByAddress", func(t *testing.T) { - destination, err := client.GetDestinationByAddress(context.Background(), fixtures.Destination.Address) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("GetDestinationByLockingScript", func(t *testing.T) { - destination, err := client.GetDestinationByLockingScript(context.Background(), fixtures.Destination.LockingScript) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("GetDestinations", func(t *testing.T) { - destinations, err := client.GetDestinations(context.Background(), &filter.DestinationFilter{}, nil, nil) - require.NoError(t, err) - require.Equal(t, []*models.Destination{fixtures.Destination}, destinations) - }) - - t.Run("NewDestination", func(t *testing.T) { - destination, err := client.NewDestination(context.Background(), fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("UpdateDestinationMetadataByID", func(t *testing.T) { - destination, err := client.UpdateDestinationMetadataByID(context.Background(), fixtures.Destination.ID, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("UpdateDestinationMetadataByAddress", func(t *testing.T) { - destination, err := client.UpdateDestinationMetadataByAddress(context.Background(), fixtures.Destination.Address, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) - - t.Run("UpdateDestinationMetadataByLockingScript", func(t *testing.T) { - destination, err := client.UpdateDestinationMetadataByLockingScript(context.Background(), fixtures.Destination.LockingScript, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Destination, destination) - }) -} diff --git a/errors.go b/errors.go deleted file mode 100644 index 277ff343..00000000 --- a/errors.go +++ /dev/null @@ -1,95 +0,0 @@ -package walletclient - -import ( - "encoding/json" - "github.com/bitcoin-sv/spv-wallet/models" - "net/http" -) - -// ErrAdminKey admin key not set -var ErrAdminKey = models.SPVError{Message: "an admin key must be set to be able to create an xpub", StatusCode: 401, Code: "error-unauthorized-admin-key-not-set"} - -// ErrMissingXpriv is when xpriv is missing -var ErrMissingXpriv = models.SPVError{Message: "xpriv is missing", StatusCode: 401, Code: "error-unauthorized-xpriv-missing"} - -// ErrInvalidXpriv is when xpriv is invalid -var ErrInvalidXpriv = models.SPVError{Message: "xpriv is invalid", StatusCode: 401, Code: "error-unauthorized-xpriv-invalid"} - -// ErrInvalidXpub is when xpub is invalid -var ErrInvalidXpub = models.SPVError{Message: "xpub is invalid", StatusCode: 401, Code: "error-unauthorized-xpub-invalid"} - -// ErrInvalidAccessKey is when access key is invalid -var ErrInvalidAccessKey = models.SPVError{Message: "access key is invalid", StatusCode: 401, Code: "error-unauthorized-access-key-invalid"} - -// ErrInvalidAdminKey is when admin key is invalid -var ErrInvalidAdminKey = models.SPVError{Message: "admin key is invalid", StatusCode: 401, Code: "error-unauthorized-admin-key-invalid"} - -// ErrInvalidServerURL is when server url is invalid -var ErrInvalidServerURL = models.SPVError{Message: "server url is invalid", StatusCode: 401, Code: "error-unauthorized-server-url-invalid"} - -// ErrCreateClient is when client creation fails -var ErrCreateClient = models.SPVError{Message: "failed to create client", StatusCode: 500, Code: "error-create-client-failed"} - -// ErrMissingKey is when neither xPriv nor adminXPriv is provided -var ErrMissingKey = models.SPVError{Message: "neither xPriv nor adminXPriv is provided", StatusCode: 404, Code: "error-shared-config-key-missing"} - -// ErrMissingAccessKey is when access key is missing -var ErrMissingAccessKey = models.SPVError{Message: "access key is missing", StatusCode: 401, Code: "error-unauthorized-access-key-missing"} - -// ErrCouldNotFindDraftTransaction is when draft transaction is not found -var ErrCouldNotFindDraftTransaction = models.SPVError{Message: "could not find draft transaction", StatusCode: 404, Code: "error-draft-transaction-not-found"} - -// ErrTotpInvalid is when totp is invalid -var ErrTotpInvalid = models.SPVError{Message: "totp is invalid", StatusCode: 400, Code: "error-totp-invalid"} - -// ErrContactPubKeyInvalid is when contact's PubKey is invalid -var ErrContactPubKeyInvalid = models.SPVError{Message: "contact's PubKey is invalid", StatusCode: 400, Code: "error-contact-pubkey-invalid"} - -// ErrStaleLastEvaluatedKey is when the last evaluated key returned from sync merkleroots is the same as it was in a previous iteration -// indicating sync issue or a potential loop -var ErrStaleLastEvaluatedKey = models.SPVError{Message: "The last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.", StatusCode: 500, Code: "error-stale-last-evaluated-key"} - -// ErrStaleLastEvaluatedKey is when the last evaluated key returned from sync merkleroots is the same as it was in a previous iteration -// indicating sync issue or a potential loop -var ErrSyncMerkleRootsTimeout = models.SPVError{Message: "SyncMerkleRoots operation timed out", StatusCode: 500, Code: "error-sync-merkleroots-timeout"} - -// WrapError wraps an error into SPVError -func WrapError(err error) error { - if err == nil { - return nil - } - - return models.SPVError{ - StatusCode: http.StatusInternalServerError, - Message: err.Error(), - Code: models.UnknownErrorCode, - } -} - -// WrapResponseError wraps a http response into SPVError -func WrapResponseError(res *http.Response) error { - if res == nil { - return nil - } - - var resError models.ResponseError - - err := json.NewDecoder(res.Body).Decode(&resError) - if err != nil { - return WrapError(err) - } - - return models.SPVError{ - StatusCode: res.StatusCode, - Code: resError.Code, - Message: resError.Message, - } -} - -func CreateErrorResponse(code string, message string) error { - return models.SPVError{ - StatusCode: http.StatusInternalServerError, - Code: code, - Message: message, - } -} diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 73b27adb..00000000 --- a/examples/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Quick Guide how to run examples - -In this directory you can find examples of how to use the `spv-wallet-go-client` package. - -## Before you run - -### Pre-requisites - -- You have access to the `spv-wallet` non-custodial wallet (running locally or remotely). -- You have installed this package on your machine (`go install` on this project's root directory). - -### Concerning the keys - -- The `ExampleAdminKey` defined in `example_keys.go` is the default one from [spv-wallet-web-backend repository](https://github.com/bitcoin-sv/spv-wallet-web-backend/blob/main/config/viper.go#L56) - - If in your current `spv-wallet` instance you have a different `adminKey`, you should replace the one in `example_keys` with the one you have. -- The `ExampleXPub` and `ExampleXPriv` are just placeholders, which won't work. - - You should replace them by newly generated ones using `task generate_keys`, - - ... or use your actual keys if you have them (don't use the keys which are already added to another wallet). - -> Additionally, to make it work properly, you should adjust the `ExamplePaymail` to align with your `domains` configuration in the `spv-wallet` instance. - -## Proposed order of executing examples - -1. `generate_keys` - generates new keys (you can copy them to `example_keys` if you want to use them in next examples) -2. `admin_add_user` - adds a new user (more precisely adds `ExampleXPub` and then `ExamplePaymail` to the wallet) - -> To fully experience the next steps, it would be beneficial to transfer some funds to your `ExamplePaymail`. This ensures the examples run smoothly by demonstrating the creation of a transaction with an actual balance. You can transfer funds to your `ExamplePaymail` using a Bitcoin SV wallet application such as HandCash or any other that supports Paymail. - -3. `get_balance` - checks the balance - if you've transferred funds to your `ExamplePaymail`, you should see them here -4. `create_transaction` - creates a transaction (you can adjust the `outputs` to your needs) -5. `list_transactions` - lists all transactions and with example filtering -6. `send_op_return` - sends an OP_RETURN transaction -7. `admin_remove_user` - removes the user - -In addition to the above, there are additional examples showing how to use the client from a developer perspective: - -- `handle_exceptions` - presents how to "catch" exceptions which the client can throw - -## Util examples - -1. `xpriv_from_mnemonic` - allows you to generate/extract an xPriv key from a mnemonic phrase. To you use it you just need to replace the `mnemonic` variable with your own mnemonic phrase. -2. `xpub_from_xpriv` - allows you to generate an xPub key from an xPriv key. To you use it you just need to replace the `xPriv` variable with your own xPriv key. -3. `generate_totp` - allows you to generate and check validity of a TOTP code for client xPriv and a contact's PKI - -## How to run an example - -The examples are written in Go and can be run by: - -```bash -cd examples -task name_of_the_example -``` - -> See the `examples/Taskfile.yml` for the list of available examples and scripts diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml deleted file mode 100644 index 88689de6..00000000 --- a/examples/Taskfile.yml +++ /dev/null @@ -1,78 +0,0 @@ ---- -version: "3" - -tasks: - admin_add_user: - desc: "running admin_add_user..." - cmds: - - echo "running admin_add_user..." - - go run ./admin_add_user/admin_add_user.go - - admin_remove_user: - desc: "running admin_remove_user..." - cmds: - - echo "running admin_remove_user..." - - go run ./admin_remove_user/admin_remove_user.go - - create_transaction: - desc: "running create_transaction..." - cmds: - - echo "running create_transaction..." - - go run ./create_transaction/create_transaction.go - - generate_keys: - desc: "running generate_keys..." - cmds: - - echo "running generate_keys..." - - go run ./generate_keys/generate_keys.go - - get_balance: - desc: "running get_balance..." - cmds: - - echo "running get_balance..." - - go run ./get_balance/get_balance.go - - handle_exceptions: - desc: "running handle_exceptions..." - cmds: - - echo "running handle_exceptions..." - - go run ./handle_exceptions/handle_exceptions.go - - list_transactions: - desc: "running list_transactions..." - cmds: - - echo "running list_transactions..." - - go run ./list_transactions/list_transactions.go - - send_op_return: - desc: "running send_op_return..." - cmds: - - echo "running send_op_return..." - - go run ./send_op_return/send_op_return.go - - xpriv_from_mnemonic: - desc: "running xpriv_from_mnemonic..." - cmds: - - echo "running xpriv_from_mnemonic..." - - go run ./xpriv_from_mnemonic/xpriv_from_mnemonic.go - - xpub_from_xpriv: - desc: "running xpub_from_xpriv..." - cmds: - - echo "running xpub_from_xpriv..." - - go run ./xpub_from_xpriv/xpub_from_xpriv.go - generate_totp: - desc: "running generate_totp..." - cmds: - - echo "running generate_totp..." - - go run ./generate_totp/generate_totp.go - webhooks: - desc: "running webhooks..." - cmds: - - echo "running webhooks..." - - go run ./webhooks/webhooks.go || true - sync_merkleroots: - desc: "running sync_merkleroots.." - cmds: - - echo "running sync_merkleroots..." - - go run ./sync_merkleroots/sync_merkleroots.go diff --git a/examples/admin_add_user/admin_add_user.go b/examples/admin_add_user/admin_add_user.go deleted file mode 100644 index d0645136..00000000 --- a/examples/admin_add_user/admin_add_user.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Package main - admin_add_user example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfAdminKeyExists() - - server := "http://localhost:3003/v1" - - adminClient, err := walletclient.NewWithAdminKey(server, examples.ExampleAdminKey) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - metadata := map[string]any{"some_metadata": "example"} - - err = adminClient.AdminNewXpub(ctx, examples.ExampleXPub, metadata) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - createPaymailRes, err := adminClient.AdminCreatePaymail(ctx, examples.ExampleXPub, examples.ExamplePaymail, "Some public name", "") - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("AdminCreatePaymail response: ", createPaymailRes) -} diff --git a/examples/admin_remove_user/admin_remove_user.go b/examples/admin_remove_user/admin_remove_user.go deleted file mode 100644 index 95b7949c..00000000 --- a/examples/admin_remove_user/admin_remove_user.go +++ /dev/null @@ -1,33 +0,0 @@ -/* -Package main - admin_remove_user example -*/ -package main - -import ( - "context" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfAdminKeyExists() - - const server = "http://localhost:3003/v1" - - adminClient, err := walletclient.NewWithAdminKey(server, examples.ExampleAdminKey) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - err = adminClient.AdminDeletePaymail(ctx, examples.ExamplePaymail) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } -} diff --git a/examples/create_transaction/create_transaction.go b/examples/create_transaction/create_transaction.go deleted file mode 100644 index d3d6a64d..00000000 --- a/examples/create_transaction/create_transaction.go +++ /dev/null @@ -1,46 +0,0 @@ -/* -Package main - create_transaction example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - recipient := walletclient.Recipients{To: "alice@example.com", Satoshis: 1} - recipients := []*walletclient.Recipients{&recipient} - metadata := map[string]any{"some_metadata": "example"} - - newTransaction, err := client.SendToRecipients(ctx, recipients, metadata) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("SendToRecipients response: ", newTransaction) - - tx, err := client.GetTransaction(ctx, newTransaction.ID) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("GetTransaction response: ", tx) -} diff --git a/examples/errors.go b/examples/errors.go deleted file mode 100644 index 932a7527..00000000 --- a/examples/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -package examples - -import ( - "errors" - "fmt" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// GetFullErrorMessage prints detailed info about the error -func GetFullErrorMessage(err error) { - var errMsg string - - var spvError models.SPVError - if errors.As(err, &spvError) { - errMsg = fmt.Sprintf("Error, Message: %s, Code: %s, HTTP status code: %d", spvError.GetMessage(), spvError.GetCode(), spvError.GetStatusCode()) - } else { - errMsg = fmt.Sprintf("Error, Message: %s, Code: %s, HTTP status code: %d", err.Error(), models.UnknownErrorCode, 500) - } - fmt.Println(errMsg) -} diff --git a/examples/example_keys.go b/examples/example_keys.go deleted file mode 100644 index 8c527470..00000000 --- a/examples/example_keys.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Package examples - key constants to be used in the examples and utility function for generating keys -*/ -package examples - -const ( - // ExampleAdminKey - example admin key - ExampleAdminKey string = "xprv9s21ZrQH143K3CbJXirfrtpLvhT3Vgusdo8coBritQ3rcS7Jy7sxWhatuxG5h2y1Cqj8FKmPp69536gmjYRpfga2MJdsGyBsnB12E19CESK" - - // you can generate new keys using `task generate_keys` - - // ExampleXPriv - example private key - ExampleXPriv string = "" - // ExampleXPub - example public key - ExampleXPub string = "" - - // ExamplePaymail - example Paymail address - ExamplePaymail string = "" -) diff --git a/examples/generate_keys/generate_keys.go b/examples/generate_keys/generate_keys.go deleted file mode 100644 index 93af46c4..00000000 --- a/examples/generate_keys/generate_keys.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - generate_keys example -*/ -package main - -import ( - "fmt" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func main() { - keys, err := xpriv.Generate() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - exampleXPriv := keys.XPriv() - exampleXPub := keys.XPub().String() - - fmt.Println("exampleXPriv: ", exampleXPriv) - fmt.Println("exampleXPub: ", exampleXPub) -} diff --git a/examples/generate_totp/generate_totp.go b/examples/generate_totp/generate_totp.go deleted file mode 100644 index 7a06fb82..00000000 --- a/examples/generate_totp/generate_totp.go +++ /dev/null @@ -1,48 +0,0 @@ -/* -Package main - generate_totp example -*/ -package main - -import ( - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet/models" -) - -func main() { - defer examples.HandlePanic() - - const server = "http://localhost:3003/v1" - const aliceXPriv = "xprv9s21ZrQH143K4JFXqGhBzdrthyNFNuHPaMUwvuo8xvpHwWXprNK7T4JPj1w53S1gojQncyj8JhSh8qouYPZpbocsq934cH5G1t1DRBfgbod" - const bobPKI = "03a48e13dc598dce5fda9b14ea13f32d5dbc4e8d8a34447dda84f9f4c457d57fe7" - const digits = 4 - const period = 1200 // 20 minutes - - client, err := walletclient.NewWithXPriv(server, aliceXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - mockContact := &models.Contact{ - PubKey: bobPKI, - Paymail: "test@paymail.com", - } - - totpCode, err := client.GenerateTotpForContact(mockContact, period, digits) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("TOTP code from Alice to Bob: ", totpCode) - - valid, err := client.ValidateTotpForContact(mockContact, totpCode, mockContact.Paymail, period, digits) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Is TOTP code valid: ", valid) -} diff --git a/examples/get_balance/get_balance.go b/examples/get_balance/get_balance.go deleted file mode 100644 index 2d0ee6ad..00000000 --- a/examples/get_balance/get_balance.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -Package main - get_balance example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - xpubInfo, err := client.GetXPub(ctx) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Current balance: ", xpubInfo.CurrentBalance) -} diff --git a/examples/go.mod b/examples/go.mod deleted file mode 100644 index 3f670b64..00000000 --- a/examples/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module github.com/bitcoin-sv/spv-wallet-go-client/examples - -go 1.22.5 - -replace github.com/bitcoin-sv/spv-wallet-go-client => ../ - -require ( - github.com/bitcoin-sv/spv-wallet-go-client v0.0.0-00010101000000-000000000000 - github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 -) - -require ( - github.com/bitcoin-sv/go-sdk v1.1.9 // indirect - github.com/boombuler/barcode v1.0.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pquerna/otp v1.4.0 // indirect - golang.org/x/crypto v0.26.0 // indirect -) diff --git a/examples/go.sum b/examples/go.sum deleted file mode 100644 index 53a449e9..00000000 --- a/examples/go.sum +++ /dev/null @@ -1,24 +0,0 @@ -github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qtjc= -github.com/bitcoin-sv/go-sdk v1.1.9/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= -github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/handle_exceptions/handle_exceptions.go b/examples/handle_exceptions/handle_exceptions.go deleted file mode 100644 index ed55de42..00000000 --- a/examples/handle_exceptions/handle_exceptions.go +++ /dev/null @@ -1,42 +0,0 @@ -/* -Package main - handle_exceptions example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" -) - -func main() { - defer examples.HandlePanic() - - fmt.Println("Handle exceptions example") - - examples.CheckIfXPubExists() - - fmt.Println("XPub exists") - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPub(server, examples.ExampleAdminKey) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - fmt.Println("Client created") - - status, err := client.AdminGetStatus(ctx) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Println("Status: ", status) -} diff --git a/examples/list_transactions/list_transactions.go b/examples/list_transactions/list_transactions.go deleted file mode 100644 index 670c2f2d..00000000 --- a/examples/list_transactions/list_transactions.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Package main - list_transactions example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet/models/filter" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - metadata := map[string]any{} - - conditions := filter.TransactionFilter{} - queryParams := filter.QueryParams{} - - txs, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("GetTransactions response: ", txs) - - targetBlockHeight := uint64(839228) - conditions = filter.TransactionFilter{BlockHeight: &targetBlockHeight} - queryParams = filter.QueryParams{PageSize: 100, Page: 1} - - txsFiltered, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Filtered GetTransactions response: ", txsFiltered) -} diff --git a/examples/send_op_return/send_op_return.go b/examples/send_op_return/send_op_return.go deleted file mode 100644 index 04aae504..00000000 --- a/examples/send_op_return/send_op_return.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -Package main - send_op_return example -*/ -package main - -import ( - "context" - "fmt" - "os" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet/models" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfXPrivExists() - - const server = "http://localhost:3003/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx := context.Background() - - metadata := map[string]any{} - - opReturn := models.OpReturn{StringParts: []string{"hello", "world"}} - transactionConfig := models.TransactionConfig{Outputs: []*models.TransactionOutput{{OpReturn: &opReturn}}} - - draftTransaction, err := client.DraftTransaction(ctx, &transactionConfig, metadata) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("DraftTransaction response: ", draftTransaction) - - finalized, err := client.FinalizeTransaction(draftTransaction) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - transaction, err := client.RecordTransaction(ctx, finalized, draftTransaction.ID, metadata) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Transaction with OP_RETURN: ", transaction) -} diff --git a/examples/sync_merkleroots/sync_merkleroots.go b/examples/sync_merkleroots/sync_merkleroots.go deleted file mode 100644 index da9da731..00000000 --- a/examples/sync_merkleroots/sync_merkleroots.go +++ /dev/null @@ -1,88 +0,0 @@ -/* -Package main - sync_merkleroots example -*/ -package main - -import ( - "context" - "fmt" - "os" - "time" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method -type db struct { - MerkleRoots []models.MerkleRoot -} - -func (db *db) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error { - fmt.Print("\nSaveMerkleRoots called\n") - db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...) - return nil -} - -func (db *db) GetLastMerkleRoot() string { - if len(db.MerkleRoots) == 0 { - return "" - } - return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot -} - -// initalize the storage that exists on a client side -var repository = &db{ - MerkleRoots: []models.MerkleRoot{ - { - MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - BlockHeight: 0, - }, - { - MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", - BlockHeight: 1, - }, - { - MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", - BlockHeight: 2, - }, - }, -} - -func getLastFiveOrFewer(merkleroots []models.MerkleRoot) []models.MerkleRoot { - startIndex := len(merkleroots) - 5 - if startIndex < 0 { - startIndex = 0 - } - - return merkleroots[startIndex:] -} - -func main() { - defer examples.HandlePanic() - - server := "http://localhost:3003/api/v1" - - client, err := walletclient.NewWithXPriv(server, examples.ExampleXPriv) - if err != nil { - fmt.Println("Error: ", err) - examples.GetFullErrorMessage(err) - os.Exit(1) - } - ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond) - defer cancel() - - fmt.Printf("\n\n Initial State Length: \n %d\n\n", len(repository.MerkleRoots)) - fmt.Printf("\n\nInitial State Last 5 MerkleRoots (or fewer):\n%+v\n", getLastFiveOrFewer(repository.MerkleRoots)) - - err = client.SyncMerkleRoots(ctx, repository) - if err != nil { - fmt.Println("Error: ", err) - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Printf("\n\n After Sync State Length: \n %d\n\n", len(repository.MerkleRoots)) - fmt.Printf("\n\n After Sync State Last 5 MerkleRoots (or fewer):\n%+v\n", getLastFiveOrFewer(repository.MerkleRoots)) -} diff --git a/examples/utils.go b/examples/utils.go deleted file mode 100644 index 0fb323e8..00000000 --- a/examples/utils.go +++ /dev/null @@ -1,46 +0,0 @@ -/* -Package examples - Utility functions for this package -*/ -package examples - -import ( - "fmt" - "os" -) - -func printMissingKeyError(key string) { - fmt.Printf("Please provide a valid %s. ", key) -} - -// HandlePanic - function used to handle a recovery after a panic - use with defer -func HandlePanic() { - r := recover() - - if r != nil { - fmt.Println("Recovering: ", r) - } -} - -// CheckIfXPrivExists - checks if ExampleXPriv is not empty -func CheckIfXPrivExists() { - if ExampleXPriv == "" { - printMissingKeyError("xPriv") - os.Exit(1) - } -} - -// CheckIfXPubExists - checks if ExampleXPub is not empty -func CheckIfXPubExists() { - if ExampleXPub == "" { - printMissingKeyError("xPub") - os.Exit(1) - } -} - -// CheckIfAdminKeyExists - checks if ExampleAdminKey is not empty -func CheckIfAdminKeyExists() { - if ExampleAdminKey == "" { - printMissingKeyError("adminKey") - os.Exit(1) - } -} diff --git a/examples/webhooks/webhooks.go b/examples/webhooks/webhooks.go deleted file mode 100644 index 3dc1b640..00000000 --- a/examples/webhooks/webhooks.go +++ /dev/null @@ -1,97 +0,0 @@ -/* -Package main - send_op_return example -*/ -package main - -import ( - "context" - "fmt" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet-go-client/notifications" - "github.com/bitcoin-sv/spv-wallet/models" -) - -func main() { - defer examples.HandlePanic() - - examples.CheckIfAdminKeyExists() - - client, err := walletclient.NewWithAdminKey("http://localhost:3003/v1", examples.ExampleAdminKey) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - wh := notifications.NewWebhook( - client, - "http://localhost:5005/notification", - notifications.WithToken("Authorization", "this-is-the-token"), - notifications.WithProcessors(3), - ) - err = wh.Subscribe(context.Background()) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - http.Handle("/notification", wh.HTTPHandler()) - - // show all subscribed webhooks (including the current one) - allWebhooks, err := client.AdminGetWebhooks(context.Background()) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - fmt.Println("Subscribed webhooks list") - for _, item := range allWebhooks { - fmt.Printf("URL: %s, banned: %v\n", item.URL, item.Banned) - } - - if err = notifications.RegisterHandler(wh, func(gpe *models.StringEvent) { - time.Sleep(50 * time.Millisecond) // simulate processing time - fmt.Printf("Processing event-string: %s\n", gpe.Value) - }); err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - if err = notifications.RegisterHandler(wh, func(gpe *models.TransactionEvent) { - time.Sleep(50 * time.Millisecond) // simulate processing time - fmt.Printf("Processing event-transaction: XPubID: %s, TxID: %s, Status: %s\n", gpe.XPubID, gpe.TransactionID, gpe.Status) - }); err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - server := http.Server{ - Addr: ":5005", - Handler: nil, - ReadHeaderTimeout: time.Second * 10, - } - go func() { - _ = server.ListenAndServe() - }() - - // wait for signal to shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - - fmt.Printf("Unsubscribing...\n") - if err = wh.Unsubscribe(context.Background()); err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Printf("Shutting down...\n") - if err = server.Shutdown(context.Background()); err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } -} diff --git a/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go b/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go deleted file mode 100644 index 2332ea30..00000000 --- a/examples/xpriv_from_mnemonic/xpriv_from_mnemonic.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - xpriv_from_mnemonic example -*/ -package main - -import ( - "fmt" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func main() { - // This is an example mnemonic phrase - replace it with your own - const mnemonicPhrase = "nut same spike popular already mercy kit board rent light illegal local eight filter tube" - - keys, err := xpriv.FromMnemonic(mnemonicPhrase) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Println("extracted xPriv: ", keys.XPriv()) -} diff --git a/examples/xpub_from_xpriv/xpub_from_xpriv.go b/examples/xpub_from_xpriv/xpub_from_xpriv.go deleted file mode 100644 index 83e077d1..00000000 --- a/examples/xpub_from_xpriv/xpub_from_xpriv.go +++ /dev/null @@ -1,25 +0,0 @@ -/* -Package main - xpub_from_xpriv example -*/ -package main - -import ( - "fmt" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "os" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func main() { - // This is an example xPriv key - replace it with your own - const xPriv = "xprv9s21ZrQH143K4VneY3UWCF1o5Kk2tmgGrGtMtsrThCTsHsszEZ6H1iP37ZTwuUBvMwudG68SRkcfTjeu8h3rkayfyqkjKAStFBkuNsBnAkS" - - keys, err := xpriv.FromString(xPriv) - if err != nil { - examples.GetFullErrorMessage(err) - os.Exit(1) - } - - fmt.Println("extracted xPub: ", keys.XPub().String()) -} diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go deleted file mode 100644 index 12990b53..00000000 --- a/fixtures/fixtures.go +++ /dev/null @@ -1,217 +0,0 @@ -// Package fixtures contains fixtures for testing -package fixtures - -import ( - "encoding/json" - - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/common" - responsemodels "github.com/bitcoin-sv/spv-wallet/models/response" -) - -var ( - // RequestType http or https - RequestType = "http" - // ServerURL ex. https://localhost - ServerURL = "https://example.com/" - // XPubString public key - XPubString = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J" - // XPrivString private key - XPrivString = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ" - // AccessKeyString access key - AccessKeyString = "7779d24ca6f8821f225042bf55e8f80aa41b08b879b72827f51e41e6523b9cd0" - // PaymailAddress ex. "address@paymail.com" - PaymailAddress = "address@paymail.com" - // PubKey ex. "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde" - PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde" -) - -// MarshallForTestHandler its marshaling test handler -func MarshallForTestHandler(object any) string { - json, err := json.Marshal(object) - if err != nil { - // as this is just for tests, empty string will make the tests fail, - // so it's acceptable as an "error" here, in case there's a problem with marshall - return "" - } - return string(json) -} - -// TestMetadata model for metadata -var TestMetadata = map[string]any{"test-key": "test-value"} - -// Xpub model for testing -var Xpub = &models.Xpub{ - Model: common.Model{Metadata: TestMetadata}, - ID: "cba0be1e753a7609e1a2f792d2e80ea6fce241be86f0690ec437377477809ccc", - CurrentBalance: 16680, - NextInternalNum: 2, - NextExternalNum: 1, -} - -// AccessKey model for testing -var AccessKey = &models.AccessKey{ - Model: common.Model{Metadata: TestMetadata}, - ID: "access-key-id", - XpubID: Xpub.ID, - Key: AccessKeyString, -} - -// Destination model for testing -var Destination = &models.Destination{ - Model: common.Model{Metadata: TestMetadata}, - ID: "90d10acb85f37dd009238fe7ec61a1411725825c82099bd8432fcb47ad8326ce", - XpubID: Xpub.ID, - LockingScript: "76a9140e0eb4911d79e9b7683f268964f595b66fa3604588ac", - Type: "pubkeyhash", - Chain: 1, - Num: 19, - Address: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa", - DraftID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df", -} - -// Transaction model for testing -var Transaction = &models.Transaction{ - Model: common.Model{Metadata: TestMetadata}, - ID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda", - Hex: "0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000", - XpubInIDs: []string{Xpub.ID}, - XpubOutIDs: []string{Xpub.ID}, - BlockHash: "00000000000000000896d2b93efa4476c4bd47ed7a554aeac6b38044745a6257", - BlockHeight: 825599, - Fee: 97, - NumberOfInputs: 4, - NumberOfOutputs: 2, - DraftID: "fe6fe12c25b81106b7332d58fe87dab7bc6e56c8c21ca45b4de05f673f3f653c", - TotalValue: 6955, - OutputValue: 1725, - Outputs: map[string]int64{"680d975a403fd9ec90f613e87d17802c029d2d930df1c8373cdcdda2f536a1c0": 62}, - Status: "confirmed", - TransactionDirection: "incoming", -} - -// DraftTx model for testing -var DraftTx = &models.DraftTransaction{ - Model: common.Model{Metadata: TestMetadata}, - ID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df", - Hex: "010000000123462f14e60556718916a8cff9dbf2258195a928777c0373200dba1cee105bdb0100000000ffffffff020c000000000000001976a914c4b15e7f65e3e6a062c1d21b7f1d7d2cd3b18e8188ac0b000000000000001976a91455873fd2baa7b51a624f6416b1d824939d99151a88ac00000000", - XpubID: Xpub.ID, - Configuration: models.TransactionConfig{ - ChangeDestinations: []*models.Destination{Destination}, - ChangeStrategy: "", - ChangeMinimumSatoshis: 0, - ChangeNumberOfDestinations: 0, - ChangeSatoshis: 11, - Fee: 1, - FeeUnit: &models.FeeUnit{ - Satoshis: 1, - Bytes: 1000, - }, - FromUtxos: []*models.UtxoPointer{{ - TransactionID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda", - OutputIndex: 1, - }}, - IncludeUtxos: []*models.UtxoPointer{{ - TransactionID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda", - OutputIndex: 1, - }}, - Inputs: []*models.TransactionInput{{ - Utxo: models.Utxo{ - UtxoPointer: models.UtxoPointer{ - TransactionID: "db5b10ee1cba0d2073037c7728a9958125f2dbf9cfa81689715605e6142f4623", - OutputIndex: 1, - }, - ID: "041479f86c475603fd510431cf702bc8c9849a9c350390eb86b467d82a13cc24", - XpubID: "9fe44728bf16a2dde3748f72cc65ea661f3bf18653b320d31eafcab37cf7fb36", - Satoshis: 24, - ScriptPubKey: "76a914673d3a53dade2723c48b446578681e253b5c548b88ac", - Type: "pubkeyhash", - DraftID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df", - SpendingTxID: "", - }, - Destination: *Destination, - }}, - Outputs: []*models.TransactionOutput{ - { - PaymailP4: &models.PaymailP4{ - Alias: "dorzepowski", - Domain: "damiano.4chain.space", - FromPaymail: "test3@kuba.4chain.space", - Note: "paymail_note", - PubKey: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG", - ReceiveEndpoint: "https://damiano.serveo.net/v1/bsvalias/receive-transaction/{alias}@{domain.tld}", - ReferenceID: "9b48dde1821fa82cf797372a297363c8", - ResolutionType: "p2p", - }, - Satoshis: 12, - Scripts: []*models.ScriptOutput{{ - Address: "1Jw1vRUq6pYqiMBAT6x3wBfebXCrXv6Qbr", - Satoshis: 12, - Script: "76a914c4b15e7f65e3e6a062c1d21b7f1d7d2cd3b18e8188ac", - ScriptType: "pubkeyhash", - }}, - To: "pubkeyhash", - UseForChange: false, - }, - { - Satoshis: 11, - Scripts: []*models.ScriptOutput{{ - Address: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa", - Satoshis: 11, - Script: "76a91455873fd2baa7b51a624f6416b1d824939d99151a88ac", - ScriptType: "pubkeyhash", - }}, - To: "18oETbMcqRB9S7NEGZgwsHKpoTpB3nKBMa", - }, - }, - SendAllTo: &models.TransactionOutput{ - OpReturn: &models.OpReturn{ - Hex: "0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000", - HexParts: []string{"0100000001cf4faa628ce1abdd2cfc641c948898bb7a3dbe043999236c3ea4436a0c79f5dc000000006a47304402206aeca14175e4477031970c1cda0af4d9d1206289212019b54f8e1c9272b5bac2022067c4d32086146ca77640f02a989f51b3c6738ebfa24683c4a923f647cf7f1c624121036295a81525ba33e22c6497c0b758e6a84b60d97c2d8905aa603dd364915c3a0effffffff023e030000000000001976a914f7fc6e0b05e91c3610efd0ce3f04f6502e2ed93d88ac99030000000000001976a914550e06a3aa71ba7414b53922c13f96a882bf027988ac00000000"}, - Map: &models.MapProtocol{ - App: "app_protocol", - Keys: map[string]interface{}{"test-key": "test-value"}, - Type: "app_protocol_type", - }, - StringParts: []string{"string", "parts"}, - }, - PaymailP4: &models.PaymailP4{ - Alias: "alias", - Domain: "domain.tld", - FromPaymail: "alias@paymail.com", - Note: "paymail_note", - PubKey: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG", - ReceiveEndpoint: "https://bsvalias.example.org/alias@domain.tld/payment-destination-response", - ReferenceID: "3d7c2ca83a46", - ResolutionType: "resolution_type", - }, - Satoshis: 1220, - Script: "script", - Scripts: []*models.ScriptOutput{{ - Address: "12HL5RyEy3Rt6SCwxgpiFSTigem1Pzbq22", - Satoshis: 1220, - Script: "script", - ScriptType: "pubkeyhash", - }}, - To: "1DSsgJdB2AnWaFNgSbv4MZC2m71116JafG", - UseForChange: false, - }, - Sync: &models.SyncConfig{ - Broadcast: true, - BroadcastInstant: true, - PaymailP2P: true, - SyncOnChain: true, - }, - }, - Status: "draft", - FinalTxID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda", -} - -// Contact model for testing -var Contact = &models.Contact{ - ID: "68af358bde7d8641621c7dd3de1a276c9a62cfa9e2d0740494519f1ba61e2f4a", - FullName: "Test User", - Paymail: "test@spv-wallet.com", - PubKey: "xpub661MyMwAqRbcGpZVrSHU...", - Status: responsemodels.ContactNotConfirmed, -} diff --git a/fixtures/spv_wallet.go b/fixtures/spv_wallet.go deleted file mode 100644 index fa6c009f..00000000 --- a/fixtures/spv_wallet.go +++ /dev/null @@ -1,126 +0,0 @@ -package fixtures - -import ( - "slices" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -const ( - SPVWalletURL = "http://localhost:3003/api/v1" -) - -// MockedSPVWalletData is mocked merkle roots data on spv-wallet side -var MockedSPVWalletData = []models.MerkleRoot{ - { - BlockHeight: 0, - MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - }, - { - BlockHeight: 1, - MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", - }, - { - BlockHeight: 2, - MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", - }, - { - BlockHeight: 3, - MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644", - }, - { - BlockHeight: 4, - MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a", - }, - { - BlockHeight: 5, - MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1", - }, - { - BlockHeight: 6, - MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37", - }, - { - BlockHeight: 7, - MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f", - }, - { - BlockHeight: 8, - MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3", - }, - { - BlockHeight: 9, - MerkleRoot: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c9", - }, - { - BlockHeight: 10, - MerkleRoot: "d3ad39fa52a89997ac7381c95eeffeaf40b66af7a57e9eba144be0a175a12b11", - }, - { - BlockHeight: 11, - MerkleRoot: "f8325d8f7fa5d658ea143629288d0530d2710dc9193ddc067439de803c37066e", - }, - { - BlockHeight: 12, - MerkleRoot: "3b96bb7e197ef276b85131afd4a09c059cc368133a26ca04ebffb0ab4f75c8b8", - }, - { - BlockHeight: 13, - MerkleRoot: "9962d5c704ec27243364cbe9d384808feeac1c15c35ac790dffd1e929829b271", - }, - { - BlockHeight: 14, - MerkleRoot: "e1afd89295b68bc5247fe0ca2885dd4b8818d7ce430faa615067d7bab8640156", - }, -} - -// LastMockedMerkleRoot returns last merkleroot value from MockedSPVWalletData -func LastMockedMerkleRoot() models.MerkleRoot { - return MockedSPVWalletData[len(MockedSPVWalletData)-1] -} - -// MockedMerkleRootsAPIResponseFn is a mock of SPV-Wallet it will return a paged response of merkle roots since last evaluated merkle root -func MockedMerkleRootsAPIResponseFn(lastMerkleRoot string) models.ExclusiveStartKeyPage[[]models.MerkleRoot] { - if lastMerkleRoot == "" { - return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ - Content: MockedSPVWalletData, - Page: models.ExclusiveStartKeyPageInfo{ - LastEvaluatedKey: "", - TotalElements: len(MockedSPVWalletData), - Size: len(MockedSPVWalletData), - }, - } - } - - lastMerkleRootIdx := slices.IndexFunc(MockedSPVWalletData, func(mr models.MerkleRoot) bool { - return mr.MerkleRoot == lastMerkleRoot - }) - - // handle case when lastMerkleRoot is already highest in the servers database - if lastMerkleRootIdx == len(MockedSPVWalletData)-1 { - return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ - Content: []models.MerkleRoot{}, - Page: models.ExclusiveStartKeyPageInfo{ - LastEvaluatedKey: "", - TotalElements: len(MockedSPVWalletData), - Size: 0, - }, - } - } - - content := MockedSPVWalletData[lastMerkleRootIdx+1:] - lastEvaluatedKey := content[len(content)-1].MerkleRoot - - if lastEvaluatedKey == MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot { - lastEvaluatedKey = "" - } - - return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ - Content: content, - Page: models.ExclusiveStartKeyPageInfo{ - LastEvaluatedKey: lastEvaluatedKey, - TotalElements: len(MockedSPVWalletData), - Size: len(content), - }, - } -} diff --git a/fixtures/sync_merkleroots.go b/fixtures/sync_merkleroots.go deleted file mode 100644 index 430bdc64..00000000 --- a/fixtures/sync_merkleroots.go +++ /dev/null @@ -1,119 +0,0 @@ -package fixtures - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "time" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method -type DB struct { - MerkleRoots []models.MerkleRoot -} - -func (db *DB) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error { - db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...) - return nil -} - -func (db *DB) GetLastMerkleRoot() string { - if len(db.MerkleRoots) == 0 { - return "" - } - return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot -} - -// CreateRepository creates a simulated repository a client passes to SyncMerkleRoots() -func CreateRepository(merkleRoots []models.MerkleRoot) *DB { - return &DB{ - MerkleRoots: merkleRoots, - } -} - -func sendJSONResponse(data interface{}, w *http.ResponseWriter) { - (*w).Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(*w).Encode(data); err != nil { - (*w).WriteHeader(http.StatusInternalServerError) - } -} - -func MockMerkleRootsAPIResponseNormal() *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet: - lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") - sendJSONResponse(MockedMerkleRootsAPIResponseFn(lastEvaluatedKey), &w) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - - return server -} - -func MockMerkleRootsAPIResponseDelayed() *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet: - lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") - // it is to limit the result up to 3 merkle roots per request to ensure - // that the sync merkleroots will loop more than once and hit the timeout - all := MockedMerkleRootsAPIResponseFn(lastEvaluatedKey) - if len(all.Content) > 3 { - all.Content = all.Content[:3] - } - - all.Page.Size = len(all.Content) - - if len(all.Content) > 0 { - all.Page.LastEvaluatedKey = all.Content[len(all.Content)-1].MerkleRoot - } else { - all.Page.LastEvaluatedKey = "" - } - - time.Sleep(50 * time.Millisecond) - sendJSONResponse(all, &w) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - - return server -} - -func MockMerkleRootsAPIResponseStale() *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/v1/merkleroots" && r.Method == http.MethodGet: - staleLastEvaluatedKeyResponse := models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ - Content: []models.MerkleRoot{ - { - MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - BlockHeight: 0, - }, - { - MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", - BlockHeight: 1, - }, - { - MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", - BlockHeight: 2, - }, - }, - Page: models.ExclusiveStartKeyPageInfo{ - LastEvaluatedKey: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", - Size: 3, - TotalElements: len(MockedSPVWalletData), - }, - } - sendJSONResponse(staleLastEvaluatedKeyResponse, &w) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - - return server -} diff --git a/http.go b/http.go deleted file mode 100644 index a8960264..00000000 --- a/http.go +++ /dev/null @@ -1,1170 +0,0 @@ -package walletclient - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "strconv" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - "github.com/bitcoin-sv/spv-wallet-go-client/utils" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/filter" -) - -// SetSignRequest turn the signing of the http request on or off -func (wc *WalletClient) SetSignRequest(signRequest bool) { - wc.signRequest = signRequest -} - -// IsSignRequest return whether to sign all requests -func (wc *WalletClient) IsSignRequest() bool { - return wc.signRequest -} - -// SetAdminKey set the admin key -func (wc *WalletClient) SetAdminKey(adminKey *bip32.ExtendedKey) { - wc.adminXPriv = adminKey -} - -// GetXPub will get the xpub of the current xpub -func (wc *WalletClient) GetXPub(ctx context.Context) (*models.Xpub, error) { - var xPub models.Xpub - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/xpub", nil, wc.xPriv, true, &xPub, - ); err != nil { - return nil, err - } - - return &xPub, nil -} - -// UpdateXPubMetadata update the metadata of the logged in xpub -func (wc *WalletClient) UpdateXPubMetadata(ctx context.Context, metadata map[string]any) (*models.Xpub, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var xPub models.Xpub - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/xpub", jsonStr, wc.xPriv, true, &xPub, - ); err != nil { - return nil, err - } - - return &xPub, nil -} - -// GetAccessKey will get an access key by id -func (wc *WalletClient) GetAccessKey(ctx context.Context, id string) (*models.AccessKey, error) { - var accessKey models.AccessKey - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/access-key?"+FieldID+"="+id, nil, wc.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// GetAccessKeys will get all access keys matching the metadata filter -func (wc *WalletClient) GetAccessKeys( - ctx context.Context, - conditions *filter.AccessKeyFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.AccessKey, error) { - return Search[filter.AccessKeyFilter, []*models.AccessKey]( - ctx, http.MethodPost, - "/access-key/search", - wc.xPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// GetAccessKeysCount will get the count of access keys -func (wc *WalletClient) GetAccessKeysCount( - ctx context.Context, - conditions *filter.AccessKeyFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.AccessKeyFilter]( - ctx, http.MethodPost, - "/access-key/count", - wc.xPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// RevokeAccessKey will revoke an access key by id -func (wc *WalletClient) RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, error) { - var accessKey models.AccessKey - if err := wc.doHTTPRequest( - ctx, http.MethodDelete, "/access-key?"+FieldID+"="+id, nil, wc.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// CreateAccessKey will create new access key -func (wc *WalletClient) CreateAccessKey(ctx context.Context, metadata map[string]any) (*models.AccessKey, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - var accessKey models.AccessKey - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/access-key", jsonStr, wc.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// GetDestinationByID will get a destination by id -func (wc *WalletClient) GetDestinationByID(ctx context.Context, id string) (*models.Destination, error) { - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodGet, fmt.Sprintf("/destination?%s=%s", FieldID, id), nil, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinationByAddress will get a destination by address -func (wc *WalletClient) GetDestinationByAddress(ctx context.Context, address string) (*models.Destination, error) { - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/destination?"+FieldAddress+"="+address, nil, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinationByLockingScript will get a destination by locking script -func (wc *WalletClient) GetDestinationByLockingScript(ctx context.Context, lockingScript string) (*models.Destination, error) { - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/destination?"+FieldLockingScript+"="+lockingScript, nil, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinations will get all destinations matching the metadata filter -func (wc *WalletClient) GetDestinations(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any, queryParams *filter.QueryParams) ([]*models.Destination, error) { - return Search[filter.DestinationFilter, []*models.Destination]( - ctx, http.MethodPost, - "/destination/search", - wc.xPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// GetDestinationsCount will get the count of destinations matching the metadata filter -func (wc *WalletClient) GetDestinationsCount(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any) (int64, error) { - return Count( - ctx, - http.MethodPost, - "/destination/count", - wc.xPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// NewDestination will create a new destination and return it -func (wc *WalletClient) NewDestination(ctx context.Context, metadata map[string]any) (*models.Destination, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/destination", jsonStr, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByID updates the destination metadata by id -func (wc *WalletClient) UpdateDestinationMetadataByID(ctx context.Context, id string, metadata map[string]any) (*models.Destination, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldID: id, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByAddress updates the destination metadata by address -func (wc *WalletClient) UpdateDestinationMetadataByAddress(ctx context.Context, address string, metadata map[string]any) (*models.Destination, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByLockingScript updates the destination metadata by locking script -func (wc *WalletClient) UpdateDestinationMetadataByLockingScript(ctx context.Context, lockingScript string, metadata map[string]any) (*models.Destination, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldLockingScript: lockingScript, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, wc.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetTransaction will get a transaction by ID -func (wc *WalletClient) GetTransaction(ctx context.Context, txID string) (*models.Transaction, error) { - var transaction models.Transaction - if err := wc.doHTTPRequest(ctx, http.MethodGet, "/transaction?"+FieldID+"="+txID, nil, wc.xPriv, wc.signRequest, &transaction); err != nil { - return nil, err - } - - return &transaction, nil -} - -// GetTransactions will get transactions by conditions -func (wc *WalletClient) GetTransactions( - ctx context.Context, - conditions *filter.TransactionFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.Transaction, error) { - return Search[filter.TransactionFilter, []*models.Transaction]( - ctx, http.MethodPost, - "/transaction/search", - wc.xPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// GetTransactionsCount get number of user transactions -func (wc *WalletClient) GetTransactionsCount( - ctx context.Context, - conditions *filter.TransactionFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.TransactionFilter]( - ctx, http.MethodPost, - "/transaction/count", - wc.xPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// DraftToRecipients is a draft transaction to a slice of recipients -func (wc *WalletClient) DraftToRecipients(ctx context.Context, recipients []*Recipients, metadata map[string]any) (*models.DraftTransaction, error) { - outputs := make([]map[string]interface{}, 0) - for _, recipient := range recipients { - outputs = append(outputs, map[string]interface{}{ - FieldTo: recipient.To, - FieldSatoshis: recipient.Satoshis, - FieldOpReturn: recipient.OpReturn, - }) - } - - return wc.createDraftTransaction(ctx, map[string]interface{}{ - FieldConfig: map[string]interface{}{ - FieldOutputs: outputs, - }, - FieldMetadata: metadata, - }) -} - -// DraftTransaction is a draft transaction -func (wc *WalletClient) DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, metadata map[string]any) (*models.DraftTransaction, error) { - return wc.createDraftTransaction(ctx, map[string]interface{}{ - FieldConfig: transactionConfig, - FieldMetadata: metadata, - }) -} - -// createDraftTransaction will create a draft transaction -func (wc *WalletClient) createDraftTransaction(ctx context.Context, - jsonData map[string]interface{}, -) (*models.DraftTransaction, error) { - jsonStr, err := json.Marshal(jsonData) - if err != nil { - return nil, WrapError(err) - } - - var draftTransaction *models.DraftTransaction - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/transaction", jsonStr, wc.xPriv, true, &draftTransaction, - ); err != nil { - return nil, err - } - if draftTransaction == nil { - return nil, ErrCouldNotFindDraftTransaction - } - - return draftTransaction, nil -} - -// RecordTransaction will record a transaction -func (wc *WalletClient) RecordTransaction(ctx context.Context, hex, referenceID string, metadata map[string]any) (*models.Transaction, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldHex: hex, - FieldReferenceID: referenceID, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/transaction/record", jsonStr, wc.xPriv, wc.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// UpdateTransactionMetadata update the metadata of a transaction -func (wc *WalletClient) UpdateTransactionMetadata(ctx context.Context, txID string, metadata map[string]any) (*models.Transaction, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldID: txID, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/transaction", jsonStr, wc.xPriv, wc.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// SetSignatureFromAccessKey will set the signature on the header for the request from an access key -func SetSignatureFromAccessKey(header *http.Header, privateKeyHex, bodyString string) error { - // Create the signature - authData, err := createSignatureAccessKey(privateKeyHex, bodyString) - if err != nil { - return WrapError(err) - } - - // Set the auth header - header.Set(models.AuthAccessKey, authData.AccessKey) - - setSignatureHeaders(header, authData) - - return nil -} - -// GetUtxo will get a utxo by transaction ID -func (wc *WalletClient) GetUtxo(ctx context.Context, txID string, outputIndex uint32) (*models.Utxo, error) { - outputIndexStr := strconv.FormatUint(uint64(outputIndex), 10) - - url := fmt.Sprintf("/utxo?%s=%s&%s=%s", FieldTransactionID, txID, FieldOutputIndex, outputIndexStr) - - var utxo models.Utxo - if err := wc.doHTTPRequest( - ctx, http.MethodGet, url, nil, wc.xPriv, true, &utxo, - ); err != nil { - return nil, err - } - - return &utxo, nil -} - -// GetUtxos will get a list of utxos filtered by conditions and metadata -func (wc *WalletClient) GetUtxos(ctx context.Context, conditions *filter.UtxoFilter, metadata map[string]any, queryParams *filter.QueryParams) ([]*models.Utxo, error) { - return Search[filter.UtxoFilter, []*models.Utxo]( - ctx, http.MethodPost, - "/utxo/search", - wc.xPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// GetUtxosCount will get the count of utxos filtered by conditions and metadata -func (wc *WalletClient) GetUtxosCount(ctx context.Context, conditions *filter.UtxoFilter, metadata map[string]any) (int64, error) { - return Count[filter.UtxoFilter]( - ctx, http.MethodPost, - "/utxo/count", - wc.xPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// createSignatureAccessKey will create a signature for the given access key & body contents -func createSignatureAccessKey(privateKeyHex, bodyString string) (payload *models.AuthPayload, err error) { - // No key? - if privateKeyHex == "" { - err = CreateErrorResponse("error-unauthorized-missing-access-key", "missing access key") - return - } - - var privateKey *ec.PrivateKey - if privateKey, err = ec.PrivateKeyFromHex( - privateKeyHex, - ); err != nil { - return - } - publicKey := privateKey.PubKey() - - // Get the AccessKey - payload = new(models.AuthPayload) - payload.AccessKey = hex.EncodeToString(publicKey.SerializeCompressed()) - - // auth_nonce is a random unique string to seed the signing message - // this can be checked server side to make sure the request is not being replayed - payload.AuthNonce, err = utils.RandomHex(32) - if err != nil { - return nil, err - } - - return createSignatureCommon(payload, bodyString, privateKey) -} - -// doHTTPRequest will create and submit the HTTP request -func (wc *WalletClient) doHTTPRequest(ctx context.Context, method string, path string, - rawJSON []byte, xPriv *bip32.ExtendedKey, sign bool, responseJSON interface{}, -) error { - req, err := http.NewRequestWithContext(ctx, method, wc.server+path, bytes.NewBuffer(rawJSON)) - if err != nil { - return WrapError(err) - } - req.Header.Set("Content-Type", "application/json") - - if xPriv != nil { - err := wc.authenticateWithXpriv(sign, req, xPriv, rawJSON) - if err != nil { - return err - } - } else { - err := wc.authenticateWithAccessKey(req, rawJSON) - if err != nil { - return err - } - } - - var resp *http.Response - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - if resp, err = wc.httpClient.Do(req); err != nil { - return WrapError(err) - } - if resp.StatusCode >= http.StatusBadRequest { - return WrapResponseError(resp) - } - - if responseJSON == nil { - return nil - } - - err = json.NewDecoder(resp.Body).Decode(&responseJSON) - if err != nil { - return WrapError(err) - } - return nil -} - -func (wc *WalletClient) authenticateWithXpriv(sign bool, req *http.Request, xPriv *bip32.ExtendedKey, rawJSON []byte) error { - if sign { - if err := addSignature(&req.Header, xPriv, string(rawJSON)); err != nil { - return err - } - } else { - var xPub string - xPub, err := bip32.GetExtendedPublicKey(xPriv) - if err != nil { - return WrapError(err) - } - req.Header.Set(models.AuthHeader, xPub) - req.Header.Set("", xPub) - } - return nil -} - -func (wc *WalletClient) authenticateWithAccessKey(req *http.Request, rawJSON []byte) error { - if wc.accessKey == nil { - return ErrMissingAccessKey - } - return SetSignatureFromAccessKey(&req.Header, hex.EncodeToString(wc.accessKey.Serialize()), string(rawJSON)) -} - -// AcceptContact will accept the contact associated with the paymail -func (wc *WalletClient) AcceptContact(ctx context.Context, paymail string) error { - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/contact/accepted/"+paymail, nil, wc.xPriv, wc.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// RejectContact will reject the contact associated with the paymail -func (wc *WalletClient) RejectContact(ctx context.Context, paymail string) error { - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/contact/rejected/"+paymail, nil, wc.xPriv, wc.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// ConfirmContact will confirm the contact associated with the paymail -func (wc *WalletClient) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { - isTotpValid, err := wc.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits) - if err != nil { - return WrapError(ErrTotpInvalid) - } - - if !isTotpValid { - return WrapError(ErrTotpInvalid) - } - - if err := wc.doHTTPRequest( - ctx, http.MethodPatch, "/contact/confirmed/"+contact.Paymail, nil, wc.xPriv, wc.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// GetContacts will get contacts by conditions -func (wc *WalletClient) GetContacts(ctx context.Context, conditions *filter.ContactFilter, metadata map[string]any, queryParams *filter.QueryParams) (*models.SearchContactsResponse, error) { - return Search[filter.ContactFilter, *models.SearchContactsResponse]( - ctx, http.MethodPost, - "/contact/search", - wc.xPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// UpsertContact add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts. -func (wc *WalletClient) UpsertContact(ctx context.Context, paymail, fullName, requesterPaymail string, metadata map[string]any) (*models.Contact, error) { - return wc.UpsertContactForPaymail(ctx, paymail, fullName, metadata, requesterPaymail) -} - -// UpsertContactForPaymail add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts. -func (wc *WalletClient) UpsertContactForPaymail(ctx context.Context, paymail, fullName string, metadata map[string]any, requesterPaymail string) (*models.Contact, error) { - payload := map[string]interface{}{ - "fullName": fullName, - FieldMetadata: metadata, - } - - if requesterPaymail != "" { - payload["requesterPaymail"] = requesterPaymail - } - - jsonStr, err := json.Marshal(payload) - if err != nil { - return nil, WrapError(err) - } - - var result models.Contact - if err := wc.doHTTPRequest( - ctx, http.MethodPut, "/contact/"+paymail, jsonStr, wc.xPriv, wc.signRequest, &result, - ); err != nil { - return nil, err - } - - return &result, nil -} - -// GetSharedConfig gets the shared config -func (wc *WalletClient) GetSharedConfig(ctx context.Context) (*models.SharedConfig, error) { - var model *models.SharedConfig - - key := wc.xPriv - if wc.adminXPriv != nil { - key = wc.adminXPriv - } - if key == nil { - return nil, WrapError(ErrMissingKey) - } - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/shared-config", nil, key, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminNewXpub will register an xPub -func (wc *WalletClient) AdminNewXpub(ctx context.Context, rawXPub string, metadata map[string]any) error { - // Adding a xpub needs to be signed by an admin key - if wc.adminXPriv == nil { - return WrapError(ErrAdminKey) - } - - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: metadata, - FieldXpubKey: rawXPub, - }) - if err != nil { - return WrapError(err) - } - - var xPubData models.Xpub - - return wc.doHTTPRequest( - ctx, http.MethodPost, "/admin/xpub", jsonStr, wc.adminXPriv, true, &xPubData, - ) -} - -// AdminGetStatus get whether admin key is valid -func (wc *WalletClient) AdminGetStatus(ctx context.Context) (bool, error) { - var status bool - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/admin/status", nil, wc.adminXPriv, true, &status, - ); err != nil { - return false, err - } - - return status, nil -} - -// AdminGetStats get admin stats -func (wc *WalletClient) AdminGetStats(ctx context.Context) (*models.AdminStats, error) { - var stats *models.AdminStats - if err := wc.doHTTPRequest( - ctx, http.MethodGet, "/admin/stats", nil, wc.adminXPriv, true, &stats, - ); err != nil { - return nil, err - } - - return stats, nil -} - -// AdminGetAccessKeys get all access keys filtered by conditions -func (wc *WalletClient) AdminGetAccessKeys( - ctx context.Context, - conditions *filter.AdminAccessKeyFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.AccessKey, error) { - return Search[filter.AdminAccessKeyFilter, []*models.AccessKey]( - ctx, http.MethodPost, - "/admin/access-keys/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetAccessKeysCount get a count of all the access keys filtered by conditions -func (wc *WalletClient) AdminGetAccessKeysCount( - ctx context.Context, - conditions *filter.AdminAccessKeyFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.AdminAccessKeyFilter]( - ctx, http.MethodPost, - "/admin/access-keys/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// AdminGetBlockHeaders get all block headers filtered by conditions -func (wc *WalletClient) AdminGetBlockHeaders( - ctx context.Context, - conditions map[string]interface{}, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.BlockHeader, error) { - var models []*models.BlockHeader - if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/block-headers/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetBlockHeadersCount get a count of all the block headers filtered by conditions -func (wc *WalletClient) AdminGetBlockHeadersCount( - ctx context.Context, - conditions map[string]interface{}, - metadata map[string]any, -) (int64, error) { - return wc.adminCount(ctx, conditions, metadata, "/admin/block-headers/count") -} - -// AdminGetDestinations get all block destinations filtered by conditions -func (wc *WalletClient) AdminGetDestinations(ctx context.Context, conditions *filter.DestinationFilter, - metadata map[string]any, queryParams *filter.QueryParams, -) ([]*models.Destination, error) { - return Search[filter.DestinationFilter, []*models.Destination]( - ctx, http.MethodPost, - "/admin/destinations/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetDestinationsCount get a count of all the destinations filtered by conditions -func (wc *WalletClient) AdminGetDestinationsCount(ctx context.Context, conditions *filter.DestinationFilter, metadata map[string]any) (int64, error) { - return Count( - ctx, - http.MethodPost, - "/admin/destinations/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// AdminGetPaymail get a paymail by address -func (wc *WalletClient) AdminGetPaymail(ctx context.Context, address string) (*models.PaymailAddress, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - }) - if err != nil { - return nil, WrapError(err) - } - - var model *models.PaymailAddress - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/admin/paymail/get", jsonStr, wc.adminXPriv, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminGetPaymails get all block paymails filtered by conditions -func (wc *WalletClient) AdminGetPaymails( - ctx context.Context, - conditions *filter.AdminPaymailFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.PaymailAddress, error) { - return Search[filter.AdminPaymailFilter, []*models.PaymailAddress]( - ctx, http.MethodPost, - "/admin/paymails/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetPaymailsCount get a count of all the paymails filtered by conditions -func (wc *WalletClient) AdminGetPaymailsCount(ctx context.Context, conditions *filter.AdminPaymailFilter, metadata map[string]any) (int64, error) { - return Count( - ctx, http.MethodPost, - "/admin/paymails/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// AdminCreatePaymail create a new paymail for a xpub -func (wc *WalletClient) AdminCreatePaymail(ctx context.Context, rawXPub string, address string, publicName string, avatar string) (*models.PaymailAddress, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldXpubKey: rawXPub, - FieldAddress: address, - FieldPublicName: publicName, - FieldAvatar: avatar, - }) - if err != nil { - return nil, WrapError(err) - } - - var model *models.PaymailAddress - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/admin/paymail/create", jsonStr, wc.adminXPriv, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminDeletePaymail delete a paymail address from the database -func (wc *WalletClient) AdminDeletePaymail(ctx context.Context, address string) error { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - }) - if err != nil { - return WrapError(err) - } - - if err := wc.doHTTPRequest( - ctx, http.MethodDelete, "/admin/paymail/delete", jsonStr, wc.adminXPriv, true, nil, - ); err != nil { - return err - } - - return nil -} - -// AdminGetTransactions get all block transactions filtered by conditions -func (wc *WalletClient) AdminGetTransactions( - ctx context.Context, - conditions *filter.TransactionFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.Transaction, error) { - return Search[filter.TransactionFilter, []*models.Transaction]( - ctx, http.MethodPost, - "/admin/transactions/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetTransactionsCount get a count of all the transactions filtered by conditions -func (wc *WalletClient) AdminGetTransactionsCount( - ctx context.Context, - conditions *filter.TransactionFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.TransactionFilter]( - ctx, http.MethodPost, - "/admin/transactions/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// AdminGetUtxos get all block utxos filtered by conditions -func (wc *WalletClient) AdminGetUtxos( - ctx context.Context, - conditions *filter.AdminUtxoFilter, - metadata map[string]any, - queryParams *filter.QueryParams, -) ([]*models.Utxo, error) { - return Search[filter.AdminUtxoFilter, []*models.Utxo]( - ctx, http.MethodPost, - "/admin/utxos/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetUtxosCount get a count of all the utxos filtered by conditions -func (wc *WalletClient) AdminGetUtxosCount( - ctx context.Context, - conditions *filter.AdminUtxoFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.AdminUtxoFilter]( - ctx, http.MethodPost, - "/admin/utxos/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -// AdminGetXPubs get all block xpubs filtered by conditions -func (wc *WalletClient) AdminGetXPubs(ctx context.Context, conditions *filter.XpubFilter, - metadata map[string]any, queryParams *filter.QueryParams, -) ([]*models.Xpub, error) { - return Search[filter.XpubFilter, []*models.Xpub]( - ctx, http.MethodPost, - "/admin/xpubs/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminGetXPubsCount get a count of all the xpubs filtered by conditions -func (wc *WalletClient) AdminGetXPubsCount( - ctx context.Context, - conditions *filter.XpubFilter, - metadata map[string]any, -) (int64, error) { - return Count[filter.XpubFilter]( - ctx, http.MethodPost, - "/admin/xpubs/count", - wc.adminXPriv, - conditions, - metadata, - wc.doHTTPRequest, - ) -} - -func (wc *WalletClient) adminGetModels( - ctx context.Context, - conditions map[string]interface{}, - metadata map[string]any, - queryParams *filter.QueryParams, - path string, - models interface{}, -) error { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: metadata, - FieldQueryParams: queryParams, - }) - if err != nil { - return WrapError(err) - } - - if err := wc.doHTTPRequest( - ctx, http.MethodPost, path, jsonStr, wc.adminXPriv, true, &models, - ); err != nil { - return err - } - - return nil -} - -func (wc *WalletClient) adminCount(ctx context.Context, conditions map[string]interface{}, metadata map[string]any, path string) (int64, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: metadata, - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := wc.doHTTPRequest( - ctx, http.MethodPost, path, jsonStr, wc.adminXPriv, true, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// AdminRecordTransaction will record a transaction as an admin -func (wc *WalletClient) AdminRecordTransaction(ctx context.Context, hex string) (*models.Transaction, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldHex: hex, - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := wc.doHTTPRequest( - ctx, http.MethodPost, "/admin/transactions/record", jsonStr, wc.adminXPriv, wc.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// AdminGetContacts executes an HTTP POST request to search for contacts based on specified conditions, metadata, and query parameters. -func (wc *WalletClient) AdminGetContacts(ctx context.Context, conditions *filter.ContactFilter, metadata map[string]any, queryParams *filter.QueryParams) (*models.SearchContactsResponse, error) { - return Search[filter.ContactFilter, *models.SearchContactsResponse]( - ctx, http.MethodPost, - "/admin/contact/search", - wc.adminXPriv, - conditions, - metadata, - queryParams, - wc.doHTTPRequest, - ) -} - -// AdminUpdateContact executes an HTTP PATCH request to update a specific contact's full name using their ID. -func (wc *WalletClient) AdminUpdateContact(ctx context.Context, id, fullName string, metadata map[string]any) (*models.Contact, error) { - jsonStr, err := json.Marshal(map[string]interface{}{ - "fullName": fullName, - FieldMetadata: metadata, - }) - if err != nil { - return nil, WrapError(err) - } - var contact models.Contact - err = wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/%s", id), jsonStr, wc.adminXPriv, true, &contact) - return &contact, WrapError(err) -} - -// AdminDeleteContact executes an HTTP DELETE request to remove a contact using their ID. -func (wc *WalletClient) AdminDeleteContact(ctx context.Context, id string) error { - err := wc.doHTTPRequest(ctx, http.MethodDelete, fmt.Sprintf("/admin/contact/%s", id), nil, wc.adminXPriv, true, nil) - return WrapError(err) -} - -// AdminAcceptContact executes an HTTP PATCH request to mark a contact as accepted using their ID. -func (wc *WalletClient) AdminAcceptContact(ctx context.Context, id string) (*models.Contact, error) { - var contact models.Contact - err := wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/accepted/%s", id), nil, wc.adminXPriv, true, &contact) - return &contact, WrapError(err) -} - -// AdminRejectContact executes an HTTP PATCH request to mark a contact as rejected using their ID. -func (wc *WalletClient) AdminRejectContact(ctx context.Context, id string) (*models.Contact, error) { - var contact models.Contact - err := wc.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/rejected/%s", id), nil, wc.adminXPriv, true, &contact) - return &contact, WrapError(err) -} - -// FinalizeTransaction will finalize the transaction -func (wc *WalletClient) FinalizeTransaction(draft *models.DraftTransaction) (string, error) { - res, err := GetSignedHex(draft, wc.xPriv) - if err != nil { - return "", WrapError(err) - } - - return res, nil -} - -// SendToRecipients send to recipients -func (wc *WalletClient) SendToRecipients(ctx context.Context, recipients []*Recipients, metadata map[string]any) (*models.Transaction, error) { - draft, err := wc.DraftToRecipients(ctx, recipients, metadata) - if err != nil { - return nil, err - } - - var hex string - if hex, err = wc.FinalizeTransaction(draft); err != nil { - return nil, err - } - - return wc.RecordTransaction(ctx, hex, draft.ID, metadata) -} - -// AdminSubscribeWebhook subscribes to a webhook to receive notifications from spv-wallet -func (wc *WalletClient) AdminSubscribeWebhook(ctx context.Context, webhookURL, tokenHeader, tokenValue string) error { - requestModel := models.SubscribeRequestBody{ - URL: webhookURL, - TokenHeader: tokenHeader, - TokenValue: tokenValue, - } - rawJSON, err := json.Marshal(requestModel) - if err != nil { - return WrapError(err) - } - err = wc.doHTTPRequest(ctx, http.MethodPost, "/admin/webhooks/subscriptions", rawJSON, wc.adminXPriv, true, nil) - return WrapError(err) -} - -// AdminUnsubscribeWebhook unsubscribes from a webhook -func (wc *WalletClient) AdminUnsubscribeWebhook(ctx context.Context, webhookURL string) error { - requestModel := models.UnsubscribeRequestBody{ - URL: webhookURL, - } - rawJSON, err := json.Marshal(requestModel) - if err != nil { - return WrapError(err) - } - err = wc.doHTTPRequest(ctx, http.MethodDelete, "/admin/webhooks/subscriptions", rawJSON, wc.adminXPriv, true, nil) - return err -} - -// AdminGetWebhooks gets all webhooks -func (wc *WalletClient) AdminGetWebhooks(ctx context.Context) ([]*models.Webhook, error) { - var webhooks []*models.Webhook - err := wc.doHTTPRequest(ctx, http.MethodGet, "/admin/webhooks/subscriptions", nil, wc.adminXPriv, true, &webhooks) - if err != nil { - return nil, WrapError(err) - } - return webhooks, nil -} diff --git a/notifications/eventsMap.go b/notifications/eventsMap.go deleted file mode 100644 index 00e0b523..00000000 --- a/notifications/eventsMap.go +++ /dev/null @@ -1,25 +0,0 @@ -package notifications - -import "sync" - -type eventsMap struct { - registered *sync.Map -} - -func newEventsMap() *eventsMap { - return &eventsMap{ - registered: &sync.Map{}, - } -} - -func (em *eventsMap) store(name string, handler *eventHandler) { - em.registered.Store(name, handler) -} - -func (em *eventsMap) load(name string) (*eventHandler, bool) { - h, ok := em.registered.Load(name) - if !ok { - return nil, false - } - return h.(*eventHandler), true -} diff --git a/notifications/interface.go b/notifications/interface.go deleted file mode 100644 index 610ea2b7..00000000 --- a/notifications/interface.go +++ /dev/null @@ -1,9 +0,0 @@ -package notifications - -import "context" - -// WebhookSubscriber - interface for subscribing and unsubscribing to webhooks -type WebhookSubscriber interface { - AdminSubscribeWebhook(ctx context.Context, webhookURL, tokenHeader, tokenValue string) error - AdminUnsubscribeWebhook(ctx context.Context, webhookURL string) error -} diff --git a/notifications/options.go b/notifications/options.go deleted file mode 100644 index 436a3ed7..00000000 --- a/notifications/options.go +++ /dev/null @@ -1,58 +0,0 @@ -package notifications - -import ( - "context" - "runtime" -) - -// WebhookOptions - options for the webhook -type WebhookOptions struct { - TokenHeader string - TokenValue string - BufferSize int - RootContext context.Context - Processors int -} - -// NewWebhookOptions - creates a new webhook options -func NewWebhookOptions() *WebhookOptions { - return &WebhookOptions{ - TokenHeader: "", - TokenValue: "", - BufferSize: 100, - Processors: runtime.NumCPU(), - RootContext: context.Background(), - } -} - -// WebhookOpts - functional options for the webhook -type WebhookOpts = func(*WebhookOptions) - -// WithToken - sets the token header and value -func WithToken(tokenHeader, tokenValue string) WebhookOpts { - return func(w *WebhookOptions) { - w.TokenHeader = tokenHeader - w.TokenValue = tokenValue - } -} - -// WithBufferSize - sets the buffer size -func WithBufferSize(size int) WebhookOpts { - return func(w *WebhookOptions) { - w.BufferSize = size - } -} - -// WithRootContext - sets the root context -func WithRootContext(ctx context.Context) WebhookOpts { - return func(w *WebhookOptions) { - w.RootContext = ctx - } -} - -// WithProcessors - sets the number of concurrent loops which will process the events -func WithProcessors(count int) WebhookOpts { - return func(w *WebhookOptions) { - w.Processors = count - } -} diff --git a/notifications/registerer.go b/notifications/registerer.go deleted file mode 100644 index edaf2e96..00000000 --- a/notifications/registerer.go +++ /dev/null @@ -1,27 +0,0 @@ -package notifications - -import ( - "reflect" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -type eventHandler struct { - Caller reflect.Value - ModelType reflect.Type -} - -// RegisterHandler - registers a handler for a specific event type -func RegisterHandler[EventType models.Events](nd *Webhook, handlerFunction func(event *EventType)) error { - handlerValue := reflect.ValueOf(handlerFunction) - - modelType := handlerValue.Type().In(0).Elem() - name := modelType.Name() - - nd.handlers.store(name, &eventHandler{ - Caller: handlerValue, - ModelType: modelType, - }) - - return nil -} diff --git a/notifications/webhook.go b/notifications/webhook.go deleted file mode 100644 index 0dd488da..00000000 --- a/notifications/webhook.go +++ /dev/null @@ -1,100 +0,0 @@ -package notifications - -import ( - "context" - "encoding/json" - "net/http" - "reflect" - "time" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// Webhook - the webhook event receiver -type Webhook struct { - URL string - options *WebhookOptions - buffer chan *models.RawEvent - subscriber WebhookSubscriber - handlers *eventsMap -} - -// NewWebhook - creates a new webhook -func NewWebhook(subscriber WebhookSubscriber, url string, opts ...WebhookOpts) *Webhook { - options := NewWebhookOptions() - for _, opt := range opts { - opt(options) - } - - wh := &Webhook{ - URL: url, - options: options, - buffer: make(chan *models.RawEvent, options.BufferSize), - subscriber: subscriber, - handlers: newEventsMap(), - } - for i := 0; i < options.Processors; i++ { - go wh.process() - } - return wh -} - -// Subscribe - sends a subscription request to the spv-wallet -func (w *Webhook) Subscribe(ctx context.Context) error { - return w.subscriber.AdminSubscribeWebhook(ctx, w.URL, w.options.TokenHeader, w.options.TokenValue) -} - -// Unsubscribe - sends an unsubscription request to the spv-wallet -func (w *Webhook) Unsubscribe(ctx context.Context) error { - return w.subscriber.AdminUnsubscribeWebhook(ctx, w.URL) -} - -// HTTPHandler - returns an http handler for the webhook; it should be registered with the http server -func (w *Webhook) HTTPHandler() http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if w.options.TokenHeader != "" && r.Header.Get(w.options.TokenHeader) != w.options.TokenValue { - http.Error(rw, "Unauthorized", http.StatusUnauthorized) - return - } - var events []*models.RawEvent - if err := json.NewDecoder(r.Body).Decode(&events); err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for _, event := range events { - select { - case w.buffer <- event: - // event sent - case <-r.Context().Done(): - // request context canceled - return - case <-w.options.RootContext.Done(): - // root context canceled - the whole event processing has been stopped - return - case <-time.After(1 * time.Second): - // timeout, most probably the channel is full - } - } - rw.WriteHeader(http.StatusOK) - }) -} - -func (w *Webhook) process() { - for { - select { - case event := <-w.buffer: - handler, ok := w.handlers.load(event.Type) - if !ok { - continue - } - model := reflect.New(handler.ModelType).Interface() - if err := json.Unmarshal(event.Content, model); err != nil { - continue - } - handler.Caller.Call([]reflect.Value{reflect.ValueOf(model)}) - case <-w.options.RootContext.Done(): - return - } - } -} diff --git a/regression_tests/Taskfile.yml b/regression_tests/Taskfile.yml deleted file mode 100644 index 8c3e5be6..00000000 --- a/regression_tests/Taskfile.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3" - -tasks: - default: - cmds: - - task -l - - run_regression_tests: - desc: "running go regression tests" - cmds: - - echo "running go regression tests..." - - go test -tags=regression ./... - dir: . - env: - CLIENT_ONE_URL: "{{.CLIENT_ONE_URL}}" - CLIENT_TWO_URL: "{{.CLIENT_TWO_URL}}" - CLIENT_ONE_LEADER_XPRIV: "{{.CLIENT_ONE_LEADER_XPRIV}}" - CLIENT_TWO_LEADER_XPRIV: "{{.CLIENT_TWO_LEADER_XPRIV}}" diff --git a/regression_tests/regression_test.go b/regression_tests/regression_test.go deleted file mode 100644 index ce2a573e..00000000 --- a/regression_tests/regression_test.go +++ /dev/null @@ -1,133 +0,0 @@ -//go:build regression -// +build regression - -package regressiontests - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -const ( - minimalFundsPerTransaction = 2 - - adminXPriv = "xprv9s21ZrQH143K3CbJXirfrtpLvhT3Vgusdo8coBritQ3rcS7Jy7sxWhatuxG5h2y1Cqj8FKmPp69536gmjYRpfga2MJdsGyBsnB12E19CESK" - adminXPub = "xpub661MyMwAqRbcFgfmdkPgE2m5UjHXu9dj124DbaGLSjaqVESTWfCD4VuNmEbVPkbYLCkykwVZvmA8Pbf8884TQr1FgdG2nPoHR8aB36YdDQh" - - errGettingEnvVariables = "failed to get environment variables: %s" - errGettingSharedConfig = "failed to get shared config: %s" - errCreatingUser = "failed to create user: %s" - errDeletingUserPaymail = "failed to delete user's paymail: %s" - errSendingFunds = "failed to send funds: %s" - errGettingBalance = "failed to get balance: %s" - errGettingTransactions = "failed to get transactions: %s" -) - -func TestRegression(t *testing.T) { - ctx := context.Background() - rtConfig, err := getEnvVariables() - require.NoError(t, err, fmt.Sprintf(errGettingEnvVariables, err)) - - var paymailDomainInstanceOne, paymailDomainInstanceTwo string - var userOne, userTwo *regressionTestUser - - t.Run("Initialize Shared Configurations", func(t *testing.T) { - t.Run("Should get sharedConfig for instance one", func(t *testing.T) { - paymailDomainInstanceOne, err = getPaymailDomain(ctx, adminXPriv, rtConfig.ClientOneURL) - require.NoError(t, err, fmt.Sprintf(errGettingSharedConfig, err)) - }) - - t.Run("Should get shared config for instance two", func(t *testing.T) { - paymailDomainInstanceTwo, err = getPaymailDomain(ctx, adminXPriv, rtConfig.ClientTwoURL) - require.NoError(t, err, fmt.Sprintf(errGettingSharedConfig, err)) - }) - }) - - t.Run("Create Users", func(t *testing.T) { - t.Run("Should create user for instance one", func(t *testing.T) { - userName := "instanceOneUser1" - userOne, err = createUser(ctx, userName, paymailDomainInstanceOne, rtConfig.ClientOneURL, adminXPriv) - require.NoError(t, err, fmt.Sprintf(errCreatingUser, err)) - }) - - t.Run("Should create user for instance two", func(t *testing.T) { - userName := "instanceTwoUser1" - userTwo, err = createUser(ctx, userName, paymailDomainInstanceTwo, rtConfig.ClientTwoURL, adminXPriv) - require.NoError(t, err, fmt.Sprintf(errCreatingUser, err)) - }) - }) - - defer func() { - t.Run("Cleanup: Remove Paymails", func(t *testing.T) { - t.Run("Should remove user's paymail on first instance", func(t *testing.T) { - if userOne != nil { - err := removeRegisteredPaymail(ctx, userOne.Paymail, rtConfig.ClientOneURL, adminXPriv) - require.NoError(t, err, fmt.Sprintf(errDeletingUserPaymail, err)) - } - }) - - t.Run("Should remove user's paymail on second instance", func(t *testing.T) { - if userTwo != nil { - err := removeRegisteredPaymail(ctx, userTwo.Paymail, rtConfig.ClientTwoURL, adminXPriv) - require.NoError(t, err, fmt.Sprintf(errDeletingUserPaymail, err)) - } - }) - }) - }() - - t.Run("Perform Transactions", func(t *testing.T) { - t.Run("Send money to instance 1", func(t *testing.T) { - const amountToSend = 3 - transaction, err := sendFunds(ctx, rtConfig.ClientTwoURL, rtConfig.ClientTwoLeaderXPriv, userOne.Paymail, amountToSend) - require.NoError(t, err, fmt.Sprintf(errSendingFunds, err)) - require.GreaterOrEqual(t, int64(-1), transaction.OutputValue) - - balance, err := getBalance(ctx, rtConfig.ClientOneURL, userOne.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingBalance, err)) - require.GreaterOrEqual(t, balance, 1) - - transactions, err := getTransactions(ctx, rtConfig.ClientOneURL, userOne.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err)) - require.GreaterOrEqual(t, len(transactions), 1) - }) - - t.Run("Send money to instance 2", func(t *testing.T) { - transaction, err := sendFunds(ctx, rtConfig.ClientOneURL, rtConfig.ClientOneLeaderXPriv, userTwo.Paymail, minimalFundsPerTransaction) - require.NoError(t, err, fmt.Sprintf(errSendingFunds, err)) - require.GreaterOrEqual(t, int64(-1), transaction.OutputValue) - - balance, err := getBalance(ctx, rtConfig.ClientTwoURL, userTwo.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingBalance, err)) - require.GreaterOrEqual(t, balance, 1) - - transactions, err := getTransactions(ctx, rtConfig.ClientTwoURL, userTwo.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err)) - require.GreaterOrEqual(t, len(transactions), 1) - }) - - t.Run("Send money from instance 1 to instance 2", func(t *testing.T) { - transaction, err := sendFunds(ctx, rtConfig.ClientOneURL, userOne.XPriv, userTwo.Paymail, minimalFundsPerTransaction) - require.NoError(t, err, fmt.Sprintf(errSendingFunds, err)) - require.GreaterOrEqual(t, int64(-1), transaction.OutputValue) - - balance, err := getBalance(ctx, rtConfig.ClientTwoURL, userTwo.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingBalance, err)) - require.GreaterOrEqual(t, balance, 2) - - transactions, err := getTransactions(ctx, rtConfig.ClientTwoURL, userTwo.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err)) - require.GreaterOrEqual(t, len(transactions), 2) - - balance, err = getBalance(ctx, rtConfig.ClientOneURL, userOne.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingBalance, err)) - require.GreaterOrEqual(t, balance, 0) - - transactions, err = getTransactions(ctx, rtConfig.ClientOneURL, userOne.XPriv) - require.NoError(t, err, fmt.Sprintf(errGettingTransactions, err)) - require.GreaterOrEqual(t, len(transactions), 2) - }) - }) -} diff --git a/regression_tests/utils.go b/regression_tests/utils.go deleted file mode 100644 index 3ce11588..00000000 --- a/regression_tests/utils.go +++ /dev/null @@ -1,207 +0,0 @@ -package regressiontests - -import ( - "context" - "errors" - "fmt" - "os" - "regexp" - "strings" - - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/filter" -) - -const ( - atSign = "@" - domainPrefix = "https://" - - ClientOneURLEnvVar = "CLIENT_ONE_URL" - ClientTwoURLEnvVar = "CLIENT_TWO_URL" - ClientOneLeaderXPrivEnvVar = "CLIENT_ONE_LEADER_XPRIV" - ClientTwoLeaderXPrivEnvVar = "CLIENT_TWO_LEADER_XPRIV" -) - -var ( - explicitHTTPURLRegex = regexp.MustCompile(`^https?://`) - errEmptyXPrivEnvVariables = errors.New("missing xpriv variables") -) - -type regressionTestUser struct { - XPriv string `json:"xpriv"` - XPub string `json:"xpub"` - Paymail string `json:"paymail"` -} - -type regressionTestConfig struct { - ClientOneURL string - ClientTwoURL string - ClientOneLeaderXPriv string - ClientTwoLeaderXPriv string -} - -// getEnvVariables retrieves the environment variables needed for the regression tests. -func getEnvVariables() (*regressionTestConfig, error) { - rtConfig := regressionTestConfig{ - ClientOneURL: os.Getenv(ClientOneURLEnvVar), - ClientTwoURL: os.Getenv(ClientTwoURLEnvVar), - ClientOneLeaderXPriv: os.Getenv(ClientOneLeaderXPrivEnvVar), - ClientTwoLeaderXPriv: os.Getenv(ClientTwoLeaderXPrivEnvVar), - } - - if rtConfig.ClientOneLeaderXPriv == "" || rtConfig.ClientTwoLeaderXPriv == "" { - return nil, errEmptyXPrivEnvVariables - } - if rtConfig.ClientOneURL == "" || rtConfig.ClientTwoURL == "" { - rtConfig.ClientOneURL = "http://localhost:3003" - rtConfig.ClientTwoURL = "http://localhost:3003" - } - - rtConfig.ClientOneURL = addPrefixIfNeeded(rtConfig.ClientOneURL) - rtConfig.ClientTwoURL = addPrefixIfNeeded(rtConfig.ClientTwoURL) - - return &rtConfig, nil -} - -// getPaymailDomain retrieves the shared configuration from the SPV Wallet. -func getPaymailDomain(ctx context.Context, xpriv string, clientUrl string) (string, error) { - wc, err := walletclient.NewWithXPriv(clientUrl, xpriv) - if err != nil { - return "", err - } - sharedConfig, err := wc.GetSharedConfig(ctx) - if err != nil { - return "", err - } - if len(sharedConfig.PaymailDomains) != 1 { - return "", fmt.Errorf("expected 1 paymail domain, got %d", len(sharedConfig.PaymailDomains)) - } - return sharedConfig.PaymailDomains[0], nil -} - -// createUser creates a set of keys and new paymail in the SPV Wallet. -func createUser(ctx context.Context, paymail string, paymailDomain string, instanceUrl string, adminXPriv string) (*regressionTestUser, error) { - keys, err := xpriv.Generate() - if err != nil { - return nil, err - } - - user := ®ressionTestUser{ - XPriv: keys.XPriv(), - XPub: keys.XPub().String(), - Paymail: preparePaymail(paymail, paymailDomain), - } - - adminClient, err := walletclient.NewWithAdminKey(instanceUrl, adminXPriv) - if err != nil { - return nil, err - } - - if err := adminClient.AdminNewXpub(ctx, user.XPub, map[string]any{"some_metadata": "remove"}); err != nil { - return nil, err - } - - _, err = adminClient.AdminCreatePaymail(ctx, user.XPub, user.Paymail, "Regression tests", "") - if err != nil { - return nil, err - } - - return user, nil -} - -// removeRegisteredPaymail soft deletes paymail from the SPV Wallet. -func removeRegisteredPaymail(ctx context.Context, paymail string, instanceURL string, adminXPriv string) error { - adminClient, err := walletclient.NewWithAdminKey(instanceURL, adminXPriv) - if err != nil { - return err - } - err = adminClient.AdminDeletePaymail(ctx, paymail) - if err != nil { - return err - } - return nil -} - -// getBalance retrieves the balance from the SPV Wallet. -func getBalance(ctx context.Context, fromInstance string, fromXPriv string) (int, error) { - client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv) - if err != nil { - return -1, err - } - xpubInfo, err := client.GetXPub(ctx) - if err != nil { - return -1, err - } - return int(xpubInfo.CurrentBalance), nil -} - -// getTransactions retrieves the transactions from the SPV Wallet. -func getTransactions(ctx context.Context, fromInstance string, fromXPriv string) ([]*models.Transaction, error) { - client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv) - if err != nil { - return nil, err - } - - metadata := map[string]any{} - conditions := filter.TransactionFilter{} - queryParams := filter.QueryParams{} - - txs, err := client.GetTransactions(ctx, &conditions, metadata, &queryParams) - if err != nil { - return nil, err - } - return txs, nil -} - -// sendFunds sends funds from one paymail to another. -func sendFunds(ctx context.Context, fromInstance string, fromXPriv string, toPaymail string, howMuch int) (*models.Transaction, error) { - client, err := walletclient.NewWithXPriv(fromInstance, fromXPriv) - if err != nil { - return nil, err - } - - balance, err := getBalance(ctx, fromInstance, fromXPriv) - if err != nil { - return nil, err - } - if balance < howMuch { - return nil, fmt.Errorf("insufficient funds: %d", balance) - } - - recipient := walletclient.Recipients{To: toPaymail, Satoshis: uint64(howMuch)} - recipients := []*walletclient.Recipients{&recipient} - metadata := map[string]any{ - "description": "regression-test", - } - - transaction, err := client.SendToRecipients(ctx, recipients, metadata) - if err != nil { - return nil, err - } - return transaction, nil -} - -// preparePaymail prepares the paymail address by combining the alias and domain. -func preparePaymail(paymailAlias string, domain string) string { - if isValidURL(domain) { - splitedDomain := strings.SplitAfter(domain, "//") - domain = splitedDomain[1] - } - url := paymailAlias + atSign + domain - return url -} - -// addPrefixIfNeeded adds the HTTPS prefix to the URL if it is missing. -func addPrefixIfNeeded(url string) string { - if !isValidURL(url) { - return domainPrefix + url - } - return url -} - -// isValidURL validates the URL if it has http or https prefix. -func isValidURL(rawURL string) bool { - return explicitHTTPURLRegex.MatchString(rawURL) -} diff --git a/search.go b/search.go deleted file mode 100644 index aa23dcb0..00000000 --- a/search.go +++ /dev/null @@ -1,72 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - "github.com/bitcoin-sv/spv-wallet/models/filter" -) - -// SearchRequester is a function that sends a request to the server and returns the response. -type SearchRequester func(ctx context.Context, method string, path string, rawJSON []byte, xPriv *bip32.ExtendedKey, sign bool, responseJSON interface{}) error - -// Search prepares and sends a search request to the server. -func Search[TFilter any, TResp any]( - ctx context.Context, - method string, - path string, - xPriv *bip32.ExtendedKey, - f *TFilter, - metadata map[string]any, - queryParams *filter.QueryParams, - requester SearchRequester, -) (TResp, error) { - jsonStr, err := json.Marshal(filter.SearchModel[TFilter]{ - ConditionsModel: filter.ConditionsModel[TFilter]{ - Conditions: f, - Metadata: metadata, - }, - QueryParams: queryParams, - }) - var resp TResp // before initialization, this var is empty slice or nil so it can be returned in case of error - if err != nil { - return resp, WrapError(err) - } - - if err := requester(ctx, method, path, jsonStr, xPriv, true, &resp); err != nil { - return resp, err - } - - return resp, nil -} - -// Count prepares and sends a count request to the server. -func Count[TFilter any]( - ctx context.Context, - method string, - path string, - xPriv *bip32.ExtendedKey, - f *TFilter, - metadata map[string]any, - requester SearchRequester, -) (int64, error) { - jsonStr, err := json.Marshal(filter.ConditionsModel[TFilter]{ - Conditions: f, - Metadata: metadata, - }) - if err != nil { - return 0, WrapError(err) - } - var count int64 - if err := requester(ctx, method, path, jsonStr, xPriv, true, &count); err != nil { - return 0, err - } - - return count, nil -} - -// Optional returns a pointer to provided value, it's necessary to define "optional" fields in filters -func Optional[T any](val T) *T { - return &val -} diff --git a/sync_merkleroots.go b/sync_merkleroots.go deleted file mode 100644 index a69ee2ab..00000000 --- a/sync_merkleroots.go +++ /dev/null @@ -1,72 +0,0 @@ -package walletclient - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// MerkleRootsRepository is an interface responsible for storing synchronized MerkleRoots and retrieving the last evaluation key from the database. -type MerkleRootsRepository interface { - // GetLastMerkleRoot should return the Merkle root with the highest height from your memory, or undefined if empty. - GetLastMerkleRoot() string - // SaveMerkleRoots should store newly synced merkle roots into your storage; - // NOTE: items are sorted in ascending order by block height. - SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error -} - -// SyncMerkleRoots syncs merkleroots known to spv-wallet with the client database -// If timeout is needed pass context.WithTimeout() as ctx param -func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error { - lastEvaluatedKey := repo.GetLastMerkleRoot() - requestPath := "merkleroots" - lastEvaluatedKeyQuery := "" - previousLastEvaluatedKey := lastEvaluatedKey - - if lastEvaluatedKey != "" { - lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", lastEvaluatedKey) - } - - for { - select { - case <-ctx.Done(): - return ErrSyncMerkleRootsTimeout - default: - url := fmt.Sprintf("/%s%s", requestPath, lastEvaluatedKeyQuery) - - var merkleRootsResponse models.ExclusiveStartKeyPage[[]models.MerkleRoot] - - err := wc.doHTTPRequest(ctx, http.MethodGet, url, nil, wc.xPriv, true, &merkleRootsResponse) - - if err != nil { - // In case if the context deadline exceeds its limit during http request, httpClient - // cancels the request wrapping it as spverror, so we need to check if the message - // is the same as context deadline exceeded error - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return ErrSyncMerkleRootsTimeout - } - return WrapError(err) - } - - lastEvaluatedKey = merkleRootsResponse.Page.LastEvaluatedKey - if lastEvaluatedKey != "" && previousLastEvaluatedKey == lastEvaluatedKey { - return ErrStaleLastEvaluatedKey - } - - err = repo.SaveMerkleRoots(merkleRootsResponse.Content) - if err != nil { - return err - } - - previousLastEvaluatedKey = lastEvaluatedKey - if previousLastEvaluatedKey == "" { - return nil - } - - lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", previousLastEvaluatedKey) - } - } -} diff --git a/sync_merkleroots_test.go b/sync_merkleroots_test.go deleted file mode 100644 index 5b4c7829..00000000 --- a/sync_merkleroots_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package walletclient - -import ( - "context" - "testing" - "time" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/stretchr/testify/require" -) - -func TestSyncMerkleRoots(t *testing.T) { - - t.Run("Should properly sync database when empty", func(t *testing.T) { - // setup - server := fixtures.MockMerkleRootsAPIResponseNormal() - defer server.Close() - - // given - repo := fixtures.CreateRepository([]models.MerkleRoot{}) - client, err := NewWithXPriv(server.URL, fixtures.XPrivString) - require.NoError(t, err) - - // when - err = client.SyncMerkleRoots(context.Background(), repo) - - // then - require.NoError(t, err) - require.Len(t, repo.MerkleRoots, len(fixtures.MockedSPVWalletData)) - require.Equal(t, fixtures.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1]) - }) - - t.Run("Should properly sync database when partially filled", func(t *testing.T) { - // setup - server := fixtures.MockMerkleRootsAPIResponseNormal() - defer server.Close() - - // given - repo := fixtures.CreateRepository([]models.MerkleRoot{ - { - MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - BlockHeight: 0, - }, - { - MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", - BlockHeight: 1, - }, - { - MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", - BlockHeight: 2, - }, - }) - client, err := NewWithXPriv(server.URL, fixtures.XPrivString) - require.NoError(t, err) - - // when - err = client.SyncMerkleRoots(context.Background(), repo) - - // then - require.NoError(t, err) - require.Len(t, repo.MerkleRoots, len(fixtures.MockedSPVWalletData)) - require.Equal(t, fixtures.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1]) - }) - - t.Run("Should fail sync merkleroots due to the time out", func(t *testing.T) { - // setup - server := fixtures.MockMerkleRootsAPIResponseDelayed() - defer server.Close() - - // given - repo := fixtures.CreateRepository([]models.MerkleRoot{}) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) - defer cancel() - - client, err := NewWithXPriv(server.URL, fixtures.XPrivString) - require.NoError(t, err) - - // when - err = client.SyncMerkleRoots(ctx, repo) - - // then - require.ErrorIs(t, err, ErrSyncMerkleRootsTimeout) - }) - - t.Run("Should fail sync merkleroots due to last evaluated key being the same in the response", func(t *testing.T) { - // setup - server := fixtures.MockMerkleRootsAPIResponseStale() - defer server.Close() - - // given - repo := fixtures.CreateRepository([]models.MerkleRoot{}) - client, err := NewWithXPriv(server.URL, fixtures.XPrivString) - require.NoError(t, err) - - // when - err = client.SyncMerkleRoots(context.Background(), repo) - - // then - require.ErrorIs(t, err, ErrStaleLastEvaluatedKey) - }) -} diff --git a/totp.go b/totp.go deleted file mode 100644 index babeb08a..00000000 --- a/totp.go +++ /dev/null @@ -1,126 +0,0 @@ -package walletclient - -import ( - "encoding/base32" - "encoding/hex" - "time" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - "github.com/bitcoin-sv/spv-wallet-go-client/utils" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" -) - -const ( - // TotpDefaultPeriod - Default number of seconds a TOTP is valid for. - TotpDefaultPeriod uint = 30 - // TotpDefaultDigits - Default TOTP length - TotpDefaultDigits uint = 2 -) - -/* -Basic flow: -Alice generates passcodeForBob with (sharedSecret+(contact.Paymail as bobPaymail)) -Alice sends passcodeForBob to Bob (e.g. via email) -Bob validates passcodeForBob with (sharedSecret+(requesterPaymail as bobPaymail)) -The (sharedSecret+paymail) is a "directedSecret". This ensures that passcodeForBob-from-Alice != passcodeForAlice-from-Bob. -The flow looks the same for Bob generating passcodeForAlice. -*/ - -// GenerateTotpForContact creates one time-based one-time password based on secret shared between the user and the contact -func (b *WalletClient) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { - sharedSecret, err := makeSharedSecret(b, contact) - if err != nil { - return "", err - } - - opts := getTotpOpts(period, digits) - return totp.GenerateCodeCustom(directedSecret(sharedSecret, contact.Paymail), time.Now(), *opts) -} - -// ValidateTotpForContact validates one time-based one-time password based on secret shared between the user and the contact -func (b *WalletClient) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) (bool, error) { - sharedSecret, err := makeSharedSecret(b, contact) - if err != nil { - return false, err - } - - opts := getTotpOpts(period, digits) - return totp.ValidateCustom(passcode, directedSecret(sharedSecret, requesterPaymail), time.Now(), *opts) -} - -func makeSharedSecret(b *WalletClient, c *models.Contact) ([]byte, error) { - privKey, pubKey, err := getSharedSecretFactors(b, c) - if err != nil { - return nil, err - } - - x, _ := ec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes()) - return x.Bytes(), nil -} - -func getTotpOpts(period, digits uint) *totp.ValidateOpts { - if period == 0 { - period = TotpDefaultPeriod - } - - if digits == 0 { - digits = TotpDefaultDigits - } - - return &totp.ValidateOpts{ - Period: period, - Digits: otp.Digits(digits), - } -} - -func getSharedSecretFactors(b *WalletClient, c *models.Contact) (*ec.PrivateKey, *ec.PublicKey, error) { - if b.xPriv == nil { - return nil, nil, ErrMissingXpriv - } - - xpriv, err := deriveXprivForPki(b.xPriv) - if err != nil { - return nil, nil, err - } - - privKey, err := xpriv.ECPrivKey() - if err != nil { - return nil, nil, err - } - - pubKey, err := convertPubKey(c.PubKey) - if err != nil { - return nil, nil, ErrContactPubKeyInvalid - } - - return privKey, pubKey, nil -} - -func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) { - // PKI derivation path: m/0/0/0 - // NOTICE: we currently do not support PKI rotation; however, adjustments will be made if and when we decide to implement it - - pkiXpriv, err := bip32.GetHDKeyByPath(xpriv, utils.ChainExternal, 0) - if err != nil { - return nil, err - } - - return pkiXpriv.Child(0) -} - -func convertPubKey(pubKey string) (*ec.PublicKey, error) { - hex, err := hex.DecodeString(pubKey) - if err != nil { - return nil, err - } - - return ec.ParsePubKey(hex) -} - -// directedSecret appends a paymail to the secret and encodes it into base32 string -func directedSecret(sharedSecret []byte, paymail string) string { - return base32.StdEncoding.EncodeToString(append(sharedSecret, []byte(paymail)...)) -} diff --git a/totp_test.go b/totp_test.go deleted file mode 100644 index d2c3e24a..00000000 --- a/totp_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package walletclient - -import ( - "encoding/hex" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/stretchr/testify/require" -) - -func TestGenerateTotpForContact(t *testing.T) { - t.Run("success", func(t *testing.T) { - // given - sut, err := NewWithXPriv("localhost:3001", fixtures.XPrivString) - require.NoError(t, err) - require.NotNil(t, sut.xPriv) - - contact := models.Contact{PubKey: fixtures.PubKey} - // when - pass, err := sut.GenerateTotpForContact(&contact, 30, 2) - - // then - require.NoError(t, err) - require.Len(t, pass, 2) - }) - - t.Run("WalletClient without xPriv - returns error", func(t *testing.T) { - // given - sut, err := NewWithXPub("localhost:3001", fixtures.XPubString) - require.NoError(t, err) - require.NotNil(t, sut.xPub) - // when - _, err = sut.GenerateTotpForContact(nil, 30, 2) - - // then - require.ErrorIs(t, err, ErrMissingXpriv) - }) - - t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { - // given - sut, err := NewWithXPriv("localhost:3001", fixtures.XPrivString) - require.NoError(t, err) - require.NotNil(t, sut.xPriv) - - contact := models.Contact{PubKey: "invalid-pk-format"} - // when - _, err = sut.GenerateTotpForContact(&contact, 30, 2) - - // then - require.ErrorIs(t, err, ErrContactPubKeyInvalid) - - }) -} - -func TestValidateTotpForContact(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // This handler could be adjusted depending on the expected API endpoints - w.WriteHeader(http.StatusOK) - w.Write([]byte("123456")) // Simulate a TOTP response for any requests - })) - defer server.Close() - - serverURL := fmt.Sprintf("%s/v1", server.URL) - t.Run("success", func(t *testing.T) { - aliceKeys, err := xpriv.Generate() - require.NoError(t, err) - bobKeys, err := xpriv.Generate() - require.NoError(t, err) - - // Set up the WalletClient for Alice and Bob - clientAlice, err := NewWithXPriv(serverURL, aliceKeys.XPriv()) - require.NoError(t, err) - require.NotNil(t, clientAlice.xPriv) - clientBob, err := NewWithXPriv(serverURL, bobKeys.XPriv()) - require.NoError(t, err) - require.NotNil(t, clientBob.xPriv) - - aliceContact := &models.Contact{ - PubKey: makeMockPKI(aliceKeys.XPub().String()), - Paymail: "bob@example.com", - } - - bobContact := &models.Contact{ - PubKey: makeMockPKI(bobKeys.XPub().String()), - Paymail: "bob@example.com", - } - - // Generate and validate TOTP - passcode, err := clientAlice.GenerateTotpForContact(bobContact, 3600, 6) - require.NoError(t, err) - result, err := clientBob.ValidateTotpForContact(aliceContact, passcode, bobContact.Paymail, 3600, 6) - require.NoError(t, err) - require.True(t, result) - }) - - t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { - sut, err := NewWithXPriv(serverURL, fixtures.XPrivString) - require.NoError(t, err) - - invalidContact := &models.Contact{ - PubKey: "invalid_pub_key_format", - Paymail: "invalid@example.com", - } - - _, err = sut.ValidateTotpForContact(invalidContact, "123456", "someone@example.com", 3600, 6) - require.Error(t, err) - require.Contains(t, err.Error(), "contact's PubKey is invalid") - }) -} - -func makeMockPKI(xpub string) string { - xPub, _ := bip32.NewKeyFromString(xpub) - var err error - for i := 0; i < 3; i++ { //magicNumberOfInheritance is 3 -> 2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI - xPub, err = xPub.Child(0) - if err != nil { - panic(err) - } - } - - pubKey, err := xPub.ECPubKey() - if err != nil { - panic(err) - } - - return hex.EncodeToString(pubKey.SerializeCompressed()) -} diff --git a/transactions_test.go b/transactions_test.go deleted file mode 100644 index 7bc61755..00000000 --- a/transactions_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/filter" - "github.com/stretchr/testify/require" -) - -func TestTransactions(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/transaction": - handleTransaction(w, r) - case "/v1/transaction/search": - json.NewEncoder(w).Encode([]*models.Transaction{fixtures.Transaction}) - case "/v1/transaction/count": - json.NewEncoder(w).Encode(1) - case "/v1/transaction/record": - if r.Method == http.MethodPost { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(fixtures.Transaction) - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - client, err := NewWithXPriv(server.URL, fixtures.XPrivString) - require.NoError(t, err) - require.NotNil(t, client.xPriv) - - t.Run("GetTransaction", func(t *testing.T) { - tx, err := client.GetTransaction(context.Background(), fixtures.Transaction.ID) - require.NoError(t, err) - require.Equal(t, fixtures.Transaction, tx) - }) - - t.Run("GetTransactions", func(t *testing.T) { - conditions := &filter.TransactionFilter{ - Fee: Optional(uint64(97)), - TotalValue: Optional(uint64(6955)), - } - txs, err := client.GetTransactions(context.Background(), conditions, fixtures.TestMetadata, nil) - require.NoError(t, err) - require.Equal(t, []*models.Transaction{fixtures.Transaction}, txs) - }) - - t.Run("GetTransactionsCount", func(t *testing.T) { - count, err := client.GetTransactionsCount(context.Background(), nil, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, int64(1), count) - }) - - t.Run("RecordTransaction", func(t *testing.T) { - tx, err := client.RecordTransaction(context.Background(), fixtures.Transaction.Hex, "", fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Transaction, tx) - }) - - t.Run("UpdateTransactionMetadata", func(t *testing.T) { - tx, err := client.UpdateTransactionMetadata(context.Background(), fixtures.Transaction.ID, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Transaction, tx) - }) - - t.Run("SendToRecipients", func(t *testing.T) { - recipients := []*Recipients{{ - OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn, - Satoshis: 1000, - To: fixtures.Destination.Address, - }} - tx, err := client.SendToRecipients(context.Background(), recipients, fixtures.TestMetadata) - require.NoError(t, err) - require.Equal(t, fixtures.Transaction, tx) - }) -} - -func handleTransaction(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet, http.MethodPost: - if err := json.NewEncoder(w).Encode(fixtures.Transaction); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - case http.MethodPatch: - var input map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - w.WriteHeader(http.StatusBadRequest) - if err := json.NewEncoder(w).Encode(map[string]string{"error": "bad request"}); err != nil { - http.Error(w, "Failed to encode error response", http.StatusInternalServerError) - } - return - } - response := fixtures.Transaction - if metadata, ok := input["metadata"].(map[string]interface{}); ok { - response.Metadata = metadata - } - if id, ok := input["id"].(string); ok { - response.ID = id - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index 84649d50..00000000 --- a/utils/utils.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package utils contains utility functions for the wallet like hashes and crypto functions -package utils - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "math" - "strconv" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" -) - -const ( - // XpubKeyLength is the length of an xPub string key - XpubKeyLength = 111 - - // ChainInternal internal chain num - ChainInternal = uint32(1) - - // ChainExternal external chain num - ChainExternal = uint32(0) - - // MaxInt32 max integer for int32 - MaxInt32 = int64(1<<(32-1) - 1) -) - -// Hash returns the sha256 hash of the data string -func Hash(data string) string { - hash := sha256.Sum256([]byte(data)) - return hex.EncodeToString(hash[:]) -} - -// RandomHex returns a random hex string and error -func RandomHex(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// DeriveChildKeyFromHex derive the child extended key from the hex string -func DeriveChildKeyFromHex(hdKey *bip32.ExtendedKey, hexHash string) (*bip32.ExtendedKey, error) { - var childKey *bip32.ExtendedKey - childKey = hdKey - - childNums, err := GetChildNumsFromHex(hexHash) - if err != nil { - return nil, err - } - - for _, num := range childNums { - if childKey, err = childKey.Child(num); err != nil { - return nil, err - } - } - - return childKey, nil -} - -// GetChildNumsFromHex get an array of uint32 numbers from the hex string -func GetChildNumsFromHex(hexHash string) ([]uint32, error) { - strLen := len(hexHash) - size := 8 - splitLength := int(math.Ceil(float64(strLen) / float64(size))) - childNums := make([]uint32, 0) - for i := 0; i < splitLength; i++ { - start := i * size - stop := start + size - if stop > strLen { - stop = strLen - } - num, err := strconv.ParseInt(hexHash[start:stop], 16, 64) - if err != nil { - return nil, err - } - if num > MaxInt32 { - num = num - MaxInt32 - } - childNums = append(childNums, uint32(num)) // todo: re-work to remove casting (possible cutoff) - } - - return childNums, nil -} diff --git a/walletclient.go b/walletclient.go deleted file mode 100644 index 412f7d59..00000000 --- a/walletclient.go +++ /dev/null @@ -1,93 +0,0 @@ -package walletclient - -import ( - "net/http" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" -) - -// WalletClient is the spv wallet Go client representation. -type WalletClient struct { - signRequest bool - server string - httpClient *http.Client - accessKey *ec.PrivateKey - adminXPriv *bip32.ExtendedKey - xPriv *bip32.ExtendedKey - xPub *bip32.ExtendedKey -} - -// NewWithXPriv creates a new WalletClient instance using a private key (xPriv). -// It configures the client with a specific server URL and a flag indicating whether requests should be signed. -// - `xPriv`: The extended private key used for cryptographic operations. -// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003 -func NewWithXPriv(serverURL, xPriv string) (*WalletClient, error) { - return makeClient( - &xPrivConf{XPrivString: xPriv}, - &httpConf{ServerURL: serverURL}, - &signRequest{Sign: true}, - ) -} - -// NewWithXPub creates a new WalletClient instance using a public key (xPub). -// This client is configured for operations that require a public key, such as verifying signatures or receiving transactions. -// - `xPub`: The extended public key used for cryptographic verification and other public operations. -// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003 -func NewWithXPub(serverURL, xPub string) (*WalletClient, error) { - return makeClient( - &xPubConf{XPubString: xPub}, - &httpConf{ServerURL: serverURL}, - &signRequest{Sign: false}, - ) -} - -// NewWithAdminKey creates a new WalletClient using an administrative key for advanced operations. -// This configuration is typically used for administrative tasks such as managing sub-wallets or configuring system-wide settings. -// - `adminKey`: The extended private key used for administrative operations. -// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003 -func NewWithAdminKey(serverURL, adminKey string) (*WalletClient, error) { - return makeClient( - &adminKeyConf{AdminKeyString: adminKey}, - &httpConf{ServerURL: serverURL}, - &signRequest{Sign: true}, - ) -} - -// NewWithAccessKey creates a new WalletClient configured with an access key for API authentication. -// This method is useful for scenarios where the client needs to authenticate using a less sensitive key than an xPriv. -// - `accessKey`: The access key used for API authentication. -// - `serverURL`: The URL of the server the client will interact with. ex. https://hostname:3003 -func NewWithAccessKey(serverURL, accessKey string) (*WalletClient, error) { - return makeClient( - &accessKeyConf{AccessKeyString: accessKey}, - &httpConf{ServerURL: serverURL}, - &signRequest{Sign: true}, - ) -} - -// makeClient creates a new WalletClient using the provided configuration options. -func makeClient(configurators ...configurator) (*WalletClient, error) { - client := &WalletClient{} - - var err error - for _, configurator := range configurators { - err = configurator.Configure(client) - if err != nil { - return nil, ErrCreateClient.Wrap(err) - } - } - - return client, nil -} - -// addSignature will add the signature to the request -func addSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error { - return setSignature(header, xPriv, bodyString) -} - -// SetAdminKeyByString will set aminXPriv key -func (wc *WalletClient) SetAdminKeyByString(adminKey string) error { - keyConf := accessKeyConf{AccessKeyString: adminKey} - return keyConf.Configure(wc) -} diff --git a/walletclient_test.go b/walletclient_test.go deleted file mode 100644 index 399a9ff0..00000000 --- a/walletclient_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package walletclient - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" - "github.com/stretchr/testify/require" -) - -func TestNewWalletClient(t *testing.T) { - // Create a mock HTTP server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"result": "success"}`)) - })) - defer server.Close() - - serverURL := fmt.Sprintf("%s/v1", server.URL) - // Test creating a client with a valid xPriv - t.Run("NewWalletClientWithXPrivate success", func(t *testing.T) { - keys, err := xpriv.Generate() - require.NoError(t, err) - client, err := NewWithXPriv(serverURL, keys.XPriv()) - require.NoError(t, err) - require.NotNil(t, client.xPriv) - require.Equal(t, keys.XPriv(), client.xPriv.String()) - require.NotNil(t, client.httpClient) - require.True(t, client.signRequest) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) - if err != nil { - t.Fatalf("Failed to create HTTP request: %v", err) - } - - // Ensure HTTP calls can be made - resp, err := client.httpClient.Do(req) - if err != nil { - t.Fatalf("Failed to make HTTP request: %v", err) - } - defer resp.Body.Close() - - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("NewWalletClientWithXPrivate fail", func(t *testing.T) { - xPriv := "invalid_key" - client, err := NewWithXPriv(xPriv, "http://example.com") - require.ErrorIs(t, err, ErrInvalidXpriv) - require.Nil(t, client) - }) - - t.Run("NewWalletClientWithXPublic success", func(t *testing.T) { - keys, err := xpriv.Generate() - require.NoError(t, err) - client, err := NewWithXPub(serverURL, keys.XPub().String()) - require.NoError(t, err) - require.NotNil(t, client.xPub) - require.Equal(t, keys.XPub().String(), client.xPub.String()) - require.NotNil(t, client.httpClient) - require.False(t, client.signRequest) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) - if err != nil { - t.Fatalf("Failed to create HTTP request: %v", err) - } - - // Ensure HTTP calls can be made - resp, err := client.httpClient.Do(req) - if err != nil { - t.Fatalf("Failed to make HTTP request: %v", err) - } - defer resp.Body.Close() - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("NewWalletClientWithXPublic fail", func(t *testing.T) { - client, err := NewWithXPub(serverURL, "invalid_key") - require.ErrorIs(t, err, ErrInvalidXpub) - require.Nil(t, client) - }) - - t.Run("NewWalletClientWithAdminKey success", func(t *testing.T) { - client, err := NewWithAdminKey(server.URL, fixtures.XPrivString) - require.NoError(t, err) - require.NotNil(t, client.adminXPriv) - require.Nil(t, client.xPriv) - require.Equal(t, fixtures.XPrivString, client.adminXPriv.String()) - require.Equal(t, serverURL, client.server) - require.NotNil(t, client.httpClient) - require.True(t, client.signRequest) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) - if err != nil { - t.Fatalf("Failed to create HTTP request: %v", err) - } - - // Ensure HTTP calls can be made - resp, err := client.httpClient.Do(req) - if err != nil { - t.Fatalf("Failed to make HTTP request: %v", err) - } - defer resp.Body.Close() - - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("NewWalletClientWithAdminKey fail", func(t *testing.T) { - client, err := NewWithAdminKey(serverURL, "invalid_key") - require.ErrorIs(t, err, ErrInvalidAdminKey) - require.Nil(t, client) - }) - - t.Run("NewWalletClientWithAccessKey success", func(t *testing.T) { - // Attempt to create a new WalletClient with an access key - client, err := NewWithAccessKey(server.URL, fixtures.AccessKeyString) - require.NoError(t, err) - require.NotNil(t, client.accessKey) - - require.Equal(t, serverURL, client.server) - require.True(t, client.signRequest) - require.NotNil(t, client.httpClient) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) - if err != nil { - t.Fatalf("Failed to create HTTP request: %v", err) - } - - // Ensure HTTP calls can be made - resp, err := client.httpClient.Do(req) - if err != nil { - t.Fatalf("Failed to make HTTP request: %v", err) - } - defer resp.Body.Close() - - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("NewWalletClientWithAccessKey fail", func(t *testing.T) { - client, err := NewWithAccessKey(serverURL, "invalid_key") - require.ErrorIs(t, err, ErrInvalidAccessKey) - require.Nil(t, client) - }) -} diff --git a/xpriv/example_test.go b/xpriv/example_test.go deleted file mode 100644 index ad4143d0..00000000 --- a/xpriv/example_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package xpriv_test - -import ( - "fmt" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" -) - -func ExampleGenerate() { - keys, _ := xpriv.Generate() - - fmt.Println("xpriv:", keys.XPriv()) - fmt.Println("xpub:", keys.XPub().String()) -} - -func ExampleFromMnemonic() { - keys, _ := xpriv.FromMnemonic("absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult") - - fmt.Println("mnemonic:", keys.Mnemonic()) - fmt.Println("xpriv:", keys.XPriv()) - fmt.Println("xpub:", keys.XPub().String()) - - // Output: - // mnemonic: absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult - // xpriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si - // xpub: xpub661MyMwAqRbcFpmY3fFdD4V6ueUBTcaCi49XDCPbRTs5XtDomZpzxAS3LUb2hMfUVphDsSPxfjietmsBRFkLDY9Xa3P4jbgNDMnDK3UqJe2 -} diff --git a/xpriv/xpriv.go b/xpriv/xpriv.go deleted file mode 100644 index f87ca3d3..00000000 --- a/xpriv/xpriv.go +++ /dev/null @@ -1,146 +0,0 @@ -// Package xpriv manges keys -package xpriv - -// "github.com/libsv/go-bk/bip39" - no replacements - -import ( - "fmt" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - bip39 "github.com/bitcoin-sv/go-sdk/compat/bip39" - chaincfg "github.com/bitcoin-sv/go-sdk/transaction/chaincfg" -) - -// Keys is a struct containing the xpriv, xpub and mnemonic -type Keys struct { - xpriv string - xpub PublicKey - mnemonic string -} - -// PublicKey is a struct containing public key information -type PublicKey string - -// Key represents basic key methods -type Key interface { - XPriv() string - XPub() PubKey -} - -// PubKey represents public key methods -type PubKey interface { - String() string -} - -// KeyWithMnemonic represents methods for generated keys -type KeyWithMnemonic interface { - Key - Mnemonic() string -} - -// XPub return hierarchical struct which contain xpub info -func (k *Keys) XPub() PubKey { - return k.xpub -} - -// XPriv return hierarchical deterministic private key -func (k *Keys) XPriv() string { - return k.xpriv -} - -// Mnemonic return mnemonic from which keys where generated -func (k *Keys) Mnemonic() string { - return k.mnemonic -} - -// String return hierarchical deterministic publick ey -func (k PublicKey) String() string { - return string(k) -} - -// Generate generates a random set of keys - xpriv, xpb and mnemonic -func Generate() (KeyWithMnemonic, error) { - entropy, err := bip39.NewEntropy(160) - if err != nil { - return nil, fmt.Errorf("generate method: key generation error when creating entropy: %w", err) - } - - mnemonic, err := bip39.NewMnemonic(entropy) - - if err != nil { - return nil, fmt.Errorf("generate method: key generation error when creating mnemonic: %w", err) - } - - hdKey, err := bip32.GenerateHDKeyFromMnemonic(mnemonic, "", &chaincfg.MainNet) - if err != nil { - return nil, err - } - - hdXpriv := hdKey.String() - hdXpub, err := bip32.GetExtendedPublicKey(hdKey) - if err != nil { - return nil, err - } - - keys := &Keys{ - xpriv: hdXpriv, - xpub: PublicKey(hdXpub), - mnemonic: mnemonic, - } - - return keys, nil -} - -// FromMnemonic generates Keys based on given mnemonic -func FromMnemonic(mnemonic string) (KeyWithMnemonic, error) { - seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "") - if err != nil { - return nil, fmt.Errorf("FromMnemonic method: error when creating seed: %w", err) - } - - hdXpriv, hdXpub, err := createXPrivAndXPub(seed) - if err != nil { - return nil, fmt.Errorf("FromMnemonic method: %w", err) - } - - keys := &Keys{ - xpriv: hdXpriv.String(), - xpub: PublicKey(hdXpub.String()), - mnemonic: mnemonic, - } - - return keys, nil -} - -// FromString generates keys from given xpriv -func FromString(xpriv string) (Key, error) { - hdXpriv, err := bip32.NewKeyFromString(xpriv) - if err != nil { - return nil, fmt.Errorf("FromString method: key generation error when creating hd private key: %w", err) - } - - hdXpub, err := hdXpriv.Neuter() - if err != nil { - return nil, fmt.Errorf("FromString method: key generation error when creating hd public hey: %w", err) - } - - keys := &Keys{ - xpriv: hdXpriv.String(), - xpub: PublicKey(hdXpub.String()), - } - - return keys, nil -} - -func createXPrivAndXPub(seed []byte) (hdXpriv *bip32.ExtendedKey, hdXpub *bip32.ExtendedKey, err error) { - hdXpriv, err = bip32.NewMaster(seed, &chaincfg.MainNet) - if err != nil { - return nil, nil, fmt.Errorf("key generation error when creating hd private key: %w", err) - } - - hdXpub, err = hdXpriv.Neuter() - if err != nil { - return nil, nil, fmt.Errorf("key generation error when creating hd public hey: %w", err) - } - return hdXpriv, hdXpub, nil -} diff --git a/xpubs_test.go b/xpubs_test.go deleted file mode 100644 index dfddea92..00000000 --- a/xpubs_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package walletclient - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/stretchr/testify/require" -) - -type xpub struct { - CurrentBalance uint64 `json:"current_balance"` - Metadata *models.Metadata `json:"metadata"` -} - -func TestXpub(t *testing.T) { - var update bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - var response xpub - // Check path and method to customize the response - switch { - case r.URL.Path == "/v1/xpub": - metadata := &models.Metadata{"key": "value"} - if update { - metadata = &models.Metadata{"updated": "info"} - } - response = xpub{ - CurrentBalance: 1234, - Metadata: metadata, - } - } - respBytes, _ := json.Marshal(response) - w.Write(respBytes) - })) - defer server.Close() - keys, err := xpriv.Generate() - require.NoError(t, err) - - client, err := NewWithXPriv(server.URL, keys.XPriv()) - require.NoError(t, err) - require.NotNil(t, client.xPriv) - - t.Run("GetXPub", func(t *testing.T) { - xpub, err := client.GetXPub(context.Background()) - require.NoError(t, err) - require.NotNil(t, xpub) - require.Equal(t, uint64(1234), xpub.CurrentBalance) - require.Equal(t, "value", xpub.Metadata["key"]) - }) - - t.Run("UpdateXPubMetadata", func(t *testing.T) { - update = true - metadata := map[string]any{"updated": "info"} - xpub, err := client.UpdateXPubMetadata(context.Background(), metadata) - require.NoError(t, err) - require.NotNil(t, xpub) - require.Equal(t, "info", xpub.Metadata["updated"]) - }) -} From fa1d4d699f44be0b271515a86004666b8ed8b52d Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Thu, 24 Oct 2024 09:53:23 +0200 Subject: [PATCH 02/18] refactor(SPV-1087): Paste authentication, utils files content from the old go-cli. (#3) --- go.mod | 9 +- go.sum | 33 +---- internal/auth/auth.go | 174 ++++++++++++++++++++++ internal/cryptoutil/cryptoutil.go | 126 ++++++++++++++++ internal/cryptoutil/cryptoutil_test.go | 195 +++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 35 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 internal/cryptoutil/cryptoutil.go create mode 100644 internal/cryptoutil/cryptoutil_test.go diff --git a/go.mod b/go.mod index d565b595..4acf797e 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,13 @@ go 1.22.5 require ( github.com/bitcoin-sv/go-sdk v1.1.9 github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 - github.com/pquerna/otp v1.4.0 github.com/stretchr/testify v1.9.0 ) require ( - github.com/boombuler/barcode v1.0.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/crypto v0.26.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 19d6f615..eddde960 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,18 @@ github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qtjc= github.com/bitcoin-sv/go-sdk v1.1.9/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.30 h1:ISfi6nkJ+hHGexkI89bAUjT44SPWNW82qE6QKa90bIs= -github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.30/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= -github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..992f4107 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,174 @@ +package auth + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "time" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + bsm "github.com/bitcoin-sv/go-sdk/compat/bsm" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + "github.com/bitcoin-sv/go-sdk/script" + trx "github.com/bitcoin-sv/go-sdk/transaction" + sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash" + "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +// GetSignedHex will sign all the inputs using the given xPriv key +func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { + // Create transaction from hex + tx, err := trx.NewTransactionFromHex(dt.Hex) + // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign + tx.Inputs = make([]*trx.TransactionInput, 0) + if err != nil { + return "", err + } + + // Enrich inputs + for _, draftInput := range dt.Configuration.Inputs { + lockingScript, err := prepareLockingScript(&draftInput.Destination) + if err != nil { + return "", err + } + unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination) + if err != nil { + return "", err + } + tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) + } + + tx.Sign() + return tx.String(), nil +} + +// SetSignature will set the signature on the header for the request +func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error { + // Create the signature + authData, err := createSignature(xPriv, bodyString) + if err != nil { + return fmt.Errorf("create signature op failure: %w", err) + } + + // Set the auth header + header.Set(models.AuthHeader, authData.XPub) + setSignatureHeaders(header, authData) + return nil +} + +func prepareLockingScript(dst *models.Destination) (*script.Script, error) { + lockingScript, err := script.NewFromHex(dst.LockingScript) + if err != nil { + return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err) + } + return lockingScript, nil +} + +func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { + key, err := getDerivedKeyForDestination(xPriv, dst) + if err != nil { + return nil, err + } + return getUnlockingScript(key) +} + +func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) { + // Derive the child key (m/chain/num) + derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num) + if err != nil { + return nil, err + } + // Handle paymail destination derivation if applicable + if dst.PaymailExternalDerivationNum != nil { + derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum) + if err != nil { + return nil, err + } + } + // Get the private key from the derived key + return bip32.GetPrivateKeyFromHDKey(derivedKey) +} + +// Generate unlocking script using private key +func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { + sigHashFlags := sighash.AllForkID + return p2pkh.Unlock(privateKey, &sigHashFlags) +} + +// createSignature will create a signature for the given key & body contents +func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *models.AuthPayload, err error) { + // No key? + if xPriv == nil { + err = ErrMissingXpriv + return + } + + // Get the xPub + payload = new(models.AuthPayload) + if payload.XPub, err = bip32.GetExtendedPublicKey(xPriv); err != nil { // Should never error if key is correct + return + } + + // auth_nonce is a random unique string to seed the signing message + // this can be checked server side to make sure the request is not being replayed + if payload.AuthNonce, err = cryptoutil.RandomHex(32); err != nil { // Should never error if key is correct + return + } + + // Derive the address for signing + var key *bip32.ExtendedKey + if key, err = cryptoutil.DeriveChildKeyFromHex(xPriv, payload.AuthNonce); err != nil { + return + } + + var privateKey *ec.PrivateKey + if privateKey, err = bip32.GetPrivateKeyFromHDKey(key); err != nil { + return // Should never error if key is correct + } + return createSignatureCommon(payload, bodyString, privateKey) +} + +// createSignatureCommon will create a signature +func createSignatureCommon(payload *models.AuthPayload, bodyString string, privateKey *ec.PrivateKey) (*models.AuthPayload, error) { + // Create the auth header hash + payload.AuthHash = cryptoutil.Hash(bodyString) + // auth_time is the current time and makes sure a request can not be sent after 30 secs + payload.AuthTime = time.Now().UnixMilli() + + key := payload.XPub + if key == "" && payload.AccessKey != "" { + key = payload.AccessKey + } + // Signature, using bitcoin signMessage + sigBytes, err := bsm.SignMessage(privateKey, getSigningMessage(key, payload)) + if err != nil { + return nil, err + } + + payload.Signature = base64.StdEncoding.EncodeToString(sigBytes) + return payload, nil +} + +// getSigningMessage will build the signing message byte array +func getSigningMessage(xPub string, auth *models.AuthPayload) []byte { + message := fmt.Sprintf("%s%s%s%d", xPub, auth.AuthHash, auth.AuthNonce, auth.AuthTime) + return []byte(message) +} + +func setSignatureHeaders(header *http.Header, authData *models.AuthPayload) { + // Create the auth header hash + header.Set(models.AuthHeaderHash, authData.AuthHash) + // Set the nonce + header.Set(models.AuthHeaderNonce, authData.AuthNonce) + // Set the time + header.Set(models.AuthHeaderTime, fmt.Sprintf("%d", authData.AuthTime)) + // Set the signature + header.Set(models.AuthSignature, authData.Signature) +} + +// ErrMissingXpriv is when xpriv is missing +var ErrMissingXpriv = errors.New("xpriv is missing") diff --git a/internal/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go new file mode 100644 index 00000000..75972e29 --- /dev/null +++ b/internal/cryptoutil/cryptoutil.go @@ -0,0 +1,126 @@ +package cryptoutil + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "strconv" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" +) + +const ( + // XpubKeyLength is the length of an xPub string key. + XpubKeyLength = 111 + + // ChainInternal internal chain num. + ChainInternal = uint32(1) + + // ChainExternal external chain num. + ChainExternal = uint32(0) +) + +// Hash returns encoded string from SHA256 checksum of the s value. +func Hash(s string) string { + bb := sha256.Sum256([]byte(s)) + return hex.EncodeToString(bb[:]) +} + +// RandomHex returns a random hexadecimal string of length `n`. +// An non-nil error is returned when random byte generation process failure occurs (rand.Read). +func RandomHex(n int) (string, error) { + bb := make([]byte, n) + _, err := rand.Read(bb) + if err != nil { + return "", fmt.Errorf("failed to read bytes after rand: %w", err) + } + return hex.EncodeToString(bb), nil +} + +// DeriveChildKeyFromHex derives a child extended key from a BIP32 master key using a hexadecimal hash. +// The hexadecimal string is parsed into a 32-bit unsigned integers slice, each of which is used +// to derive a successive child key from the provided master or parent key. +func DeriveChildKeyFromHex(key *bip32.ExtendedKey, hexHash string) (*bip32.ExtendedKey, error) { + nums, err := ParseChildNumsFromHex(hexHash) + if err != nil { + return nil, fmt.Errorf("failed to return parsed child nums from hex hash: %w", err) + } + + child := key + for _, n := range nums { + child, err = child.Child(n) + if err != nil { + return nil, fmt.Errorf("failed to return derived child extended key: %w", err) + } + } + return child, nil +} + +// ParseChildNumsFromHex parses a hexadecimal string into multiple 32-bit unsigned integers. +// The input hex string is divided into 8-character chunks, each of which is interpreted as a 32-bit +// unsigned integer in hexadecimal format. The function returns a slice of these integers or an error +// if any part of the hex string is not valid. +func ParseChildNumsFromHex(hexHash string) ([]uint32, error) { + if hexHash == "" { + return nil, nil + } + + const size = 8 + parts := (len(hexHash) + size - 1) / size // Avoids the need for floating-point division and ensures correct rounding up. + var nums []uint32 + for i := 0; i < parts; i++ { + start := i * size + end := start + size + if end > len(hexHash) { + end = len(hexHash) // Adjust end to fit remaining substring. + } + num, err := parseHexPart(hexHash[start:end]) + if err != nil { + return nil, fmt.Errorf("failed to parse hex part %q: %w", hexHash[start:end], err) + } + nums = append(nums, num) + } + return nums, nil +} + +// parseHexPart converts a hexadecimal string to a uint32 value. +// The input string is expected to represent a valid hexadecimal number, and its value +// must fit within the range of a 32-bit unsigned integer. +func parseHexPart(part string) (uint32, error) { + i, err := strconv.ParseInt(part, 16, 64) + if err != nil { + return 0, errors.Join(err, ErrHexHashPartIntParse) + } + u, err := Int64ToUint32(i % math.MaxInt32) + if err != nil { + return 0, fmt.Errorf("failed to convert int64 to uint32: %w", err) + } + return u, nil +} + +// Int64ToUint32 converts an int64 value to a uint32, ensuring that the input is within the valid range for a uint32. +// The function performs a range check to ensure the int64 value is non-negative and does not exceed the maximum value +// for a uint32 (which is 2^32 - 1). +func Int64ToUint32(value int64) (uint32, error) { + if value < 0 { + return 0, ErrNegativeValueNotAllowed + } + if value > math.MaxUint32 { + return 0, ErrMaxUint32LimitExceeded + } + return uint32(value), nil +} + +var ( + // ErrMaxUint32LimitExceeded occurs when attempting to convert an int64 value that exceeds the maximum uint32 limit. + ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded") + + // ErrNegativeValueNotAllowed occurs when attempting to convert a negative int64 value to uint32. + ErrNegativeValueNotAllowed = errors.New("negative value is not allowed") + + // ErrHexHashPartIntParse occurs when attempting to parse part of the hex hash to int64. + ErrHexHashPartIntParse = errors.New("parse hex hash part to int64 failed") +) diff --git a/internal/cryptoutil/cryptoutil_test.go b/internal/cryptoutil/cryptoutil_test.go new file mode 100644 index 00000000..2475bf7c --- /dev/null +++ b/internal/cryptoutil/cryptoutil_test.go @@ -0,0 +1,195 @@ +package cryptoutil_test + +import ( + "math" + "testing" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + compat "github.com/bitcoin-sv/go-sdk/compat/bip32" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" + "github.com/stretchr/testify/require" +) + +func TestHash(t *testing.T) { + tests := map[string]struct { + expectedHash string + expectedErr error + input string + }{ + "input: empty": { + expectedHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + input: "", + }, + "input: 1234567": { + input: "1234567", + expectedHash: "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414", + }, + "input: xpub": { + input: "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J", + expectedHash: "1a0b10d4eda0636aae1709e7e7080485a4d99af3ca2962c6e677cf5b53d8ab8c", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Run(name, func(t *testing.T) { + got := cryptoutil.Hash(tc.input) + require.Equal(t, tc.expectedHash, got) + }) + }) + } +} + +func TestDeriveChildKeyFromHex(t *testing.T) { + const ( + input = "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414" + XPriv = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ" + XPub = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J" + expectedXPriv = "xprvA8mj2ZL1w6Nqpi6D2amJLo4Gxy24tW9uv82nQKmamT2rkg5DgjzJZRFnW33e7QJwn65uUWSuN6YQyWrujNjZdVShPRnpNUSRVTru4cxaqfd" + expectedXPub = "xpub6Mm5S4rumTw93CAg8cJJhw11WzrZHxsmHLxPCiBCKnZqdUQNEHJZ7DaGMKucRzXPHtoS2ZqsVSRjxVbibEvwmR2wXkZDd8RrTftmm42cRsf" + ) + + generateHDKey := func(s string) *bip32.ExtendedKey { + k, err := compat.GenerateHDKeyFromString(s) + if err != nil { + t.Fatal(err) + } + return k + } + + t.Run("child extended key from xpriv", func(t *testing.T) { + key := generateHDKey(XPriv) + got, err := cryptoutil.DeriveChildKeyFromHex(key, input) + + require.NoError(t, err) + require.Equal(t, expectedXPriv, got.String()) + }) + + t.Run("child extended key from xpub", func(t *testing.T) { + key := generateHDKey(XPub) + got, err := cryptoutil.DeriveChildKeyFromHex(key, input) + + require.NoError(t, err) + require.Equal(t, expectedXPub, got.String()) + }) + + t.Run("extended public key from extended private key", func(t *testing.T) { + key := generateHDKey(XPriv) + child, err := cryptoutil.DeriveChildKeyFromHex(key, input) + require.NoError(t, err) + + got, err := child.Neuter() + require.NoError(t, err) + require.Equal(t, expectedXPub, got.String()) + }) +} + +func TestRandomHex(t *testing.T) { + tests := map[string]struct { + input int + expectedLen int + }{ + "input: zero": { + input: 0, + expectedLen: 0, + }, + "input: 100_000": { + input: 100_000, + expectedLen: 200_000, + }, + "input: 16": { + input: 16, + expectedLen: 32, + }, + "input: 32": { + input: 32, + expectedLen: 64, + }, + + "input: 8": { + input: 8, + expectedLen: 16, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := cryptoutil.RandomHex(tc.input) + require.NoError(t, err) + require.Len(t, got, tc.expectedLen) + }) + } +} + +func TestInt64ToUint32(t *testing.T) { + tests := map[string]struct { + input int64 + expectedErr error + expectedUint32 uint32 + }{ + "input: negative value": { + input: -1, + expectedErr: cryptoutil.ErrNegativeValueNotAllowed, + expectedUint32: 0, + }, + "input: max value exceeded": { + input: math.MaxUint32 + 1, + expectedErr: cryptoutil.ErrMaxUint32LimitExceeded, + expectedUint32: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := cryptoutil.Int64ToUint32(tc.input) + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedUint32, got) + }) + } +} + +func TestParseChildNumsFromHex(t *testing.T) { + tests := map[string]struct { + hex string + expectedErr error + expectedResult []uint32 + }{ + "input: empty hex": { + hex: "", + expectedErr: nil, + expectedResult: nil, + }, + "input: invalid hex": { + hex: "test", + expectedErr: cryptoutil.ErrHexHashPartIntParse, + expectedResult: nil, + }, + "input: short hex ababab": { + hex: "ababab", + expectedErr: nil, + expectedResult: []uint32{11250603}, + }, + "input: medium hex 8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414": { + hex: "8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414", + expectedErr: nil, + expectedResult: []uint32{ + 196136815, // 8bb0cf6e = 2343620462 - 2147483647 + 967933200, // b9b17d0f = 3115416847 - 2147483647 + 2099426390, // 7d22b456 + 1897997694, // f121257d = 4045481341 - 2147483647 + 1092963872, // c1254e1f = 3240447519 - 2147483647 + 23483248, // 01665370 + 1197704170, // 476383ea + 2003694612, // 776df414 + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := cryptoutil.ParseChildNumsFromHex(tc.hex) + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResult, got) + }) + } +} From 5c649a117b4a78359865c8b41a254f99ff1921be Mon Sep 17 00:00:00 2001 From: Damian Orzepowski Date: Mon, 28 Oct 2024 13:08:37 +0100 Subject: [PATCH 03/18] ci(SPV-1138): add separated linter files and fix warnings. (#4) --- .github/mergify.yml | 218 ---------------- .github/workflows/run-tests.yml | 70 +----- .golangci-lint.yml | 190 ++++++++++++++ .golangci-style.yml | 137 ++++++++++ .golangci.yml | 431 -------------------------------- codecov.yml | 2 +- internal/auth/auth.go | 37 ++- this_file_should_be_removed.go | 6 + 8 files changed, 372 insertions(+), 719 deletions(-) delete mode 100644 .github/mergify.yml create mode 100644 .golangci-lint.yml create mode 100644 .golangci-style.yml delete mode 100644 .golangci.yml create mode 100644 this_file_should_be_removed.go diff --git a/.github/mergify.yml b/.github/mergify.yml deleted file mode 100644 index c7b457b8..00000000 --- a/.github/mergify.yml +++ /dev/null @@ -1,218 +0,0 @@ -pull_request_rules: - - # =============================================================================== - # DEPENDABOT - # =============================================================================== - - - name: Automatic Merge for Dependabot Minor Version Pull Requests - conditions: - - -draft - - author~=^dependabot(|-preview)\[bot\]$ - - check-success='test (1.19.x, ubuntu-latest)' - - check-success='Analyze (go)' - actions: - review: - type: APPROVE - message: Automatically approving dependabot pull request - merge: - method: merge - - # =============================================================================== - # AUTOMATIC MERGE (APPROVALS) - # =============================================================================== - - - name: Automatic Merge ⬇️ on Approval ✔ - conditions: - - "#approved-reviews-by>=1" - - "#review-requested=0" - - "#changes-requested-reviews-by=0" - - check-success='test (1.19.x, ubuntu-latest)' - - check-success='Analyze (go)' - - -title~=(?i)wip - - label!=work-in-progress - - -draft - actions: - merge: - method: merge - - # =============================================================================== - # AUTHOR - # =============================================================================== - - - name: Auto-Assign Author - conditions: - - "#assignee=0" - actions: - assign: - users: ["mrz1836"] - - # =============================================================================== - # ALERTS - # =============================================================================== - - - name: Notify on merge - conditions: - - merged - - label=automerge - actions: - comment: - message: "✅ @{{author}}: **{{title}}** has been merged successfully." - - name: Alert on merge conflict - conditions: - - conflict - - label=automerge - actions: - comment: - message: "🆘 @{{author}}: `{{head}}` has conflicts with `{{base}}` that must be resolved." - - name: Alert on tests failure for automerge - conditions: - - label=automerge - - status-failure=commit - actions: - comment: - message: "🆘 @{{author}}: unable to merge due to CI failure." - - # =============================================================================== - # LABELS - # =============================================================================== - # Automatically add labels when PRs match certain patterns - # - # NOTE: - # - single quotes for regex to avoid accidental escapes - # - Mergify leverages Python regular expressions to match rules. - # - # Semantic commit messages - # - chore: updating grunt tasks etc.; no production code change - # - docs: changes to the documentation - # - feat: feature or story - # - feature: new feature or story - # - fix: bug fix for the user, not a fix to a build script - # - idea: general idea or suggestion - # - question: question regarding code - # - test: test related changes - # - wip: work in progress PR - # =============================================================================== - - - name: Work in Progress - conditions: - - "head~=(?i)^wip" # if the PR branch starts with wip/ - actions: - label: - add: ["work-in-progress"] - - name: Hotfix label - conditions: - - "head~=(?i)^hotfix" # if the PR branch starts with hotfix/ - actions: - label: - add: ["hot-fix"] - - name: Bug / Fix label - conditions: - - "head~=(?i)^(bug)?fix" # if the PR branch starts with (bug)?fix/ - actions: - label: - add: ["bug-P3"] - - name: Documentation label - conditions: - - "head~=(?i)^docs" # if the PR branch starts with docs/ - actions: - label: - add: ["documentation"] - - name: Feature label - conditions: - - "head~=(?i)^feat(ure)?" # if the PR branch starts with feat(ure)?/ - actions: - label: - add: ["feature"] - - name: Chore label - conditions: - - "head~=(?i)^chore" # if the PR branch starts with chore/ - actions: - label: - add: ["update"] - - name: Question label - conditions: - - "head~=(?i)^question" # if the PR branch starts with question/ - actions: - label: - add: ["question"] - - name: Test label - conditions: - - "head~=(?i)^test" # if the PR branch starts with test/ - actions: - label: - add: ["test"] - - name: Idea label - conditions: - - "head~=(?i)^idea" # if the PR branch starts with idea/ - actions: - label: - add: ["idea"] - - # =============================================================================== - # CONTRIBUTORS - # =============================================================================== - - - name: Welcome New Contributors - conditions: - - and: - - author!=dependabot[bot] - - author!=mergify[bot] - - author!=allcontributors[bot] - - author!=mrz1836 - - author!=icellan - - author!=dorzepowski - - author!=pawellewandowski98 - - author!=arkadiuszos4chain - - author!=wregulski - - author!=mwilkosinski - actions: - comment: - message: "Welcome to our open-source project @{{author}}! 💘" - - # =============================================================================== - # STALE BRANCHES - # =============================================================================== - - - name: Close stale pull request - conditions: - - base=main - - -closed - - updated-at<21 days ago - actions: - close: - message: | - This pull request looks stale. Feel free to reopen it if you think it's a mistake. - label: - add: ["stale"] - - # =============================================================================== - # BRANCHES - # =============================================================================== - - - name: Delete head branch after merge - conditions: - - merged - actions: - delete_head_branch: - - # =============================================================================== - # CONVENTION - # =============================================================================== - # https://www.conventionalcommits.org/en/v1.0.0/ - # Premium feature only - - #- name: Conventional Commit - # conditions: - # - "title~=^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.+\\))?:" - # actions: - # post_check: - # title: | - # {% if check_succeed %} - # Title follows Conventional Commit - # {% else %} - # Title does not follow Conventional Commit - # {% endif %} - # summary: | - # {% if not check_succeed %} - # Your pull request title must follow [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/). - # {% endif %} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0f344d74..ef942111 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,62 +1,16 @@ -# See more at: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions -name: run-go-tests - -env: - GO111MODULE: on - on: - pull_request: - branches: - - "*" push: - branches: - - "*" - # schedule: - # - cron: '1 4 * * *' + branches-ignore: + - main + - master + +permissions: + contents: write + pull-requests: read jobs: - yamllint: - name: Run yaml linter - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Run yaml linter - uses: ibiqlik/action-yamllint@v3.1 - asknancy: - name: Ask Nancy (check dependencies) - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Write go list - run: go list -json -m all > go.list - - name: Ask Nancy - uses: sonatype-nexus-community/nancy-github-action@v1.0.3 - continue-on-error: true - test: - needs: [yamllint, asknancy] - strategy: - matrix: - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Install Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Cache code - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod # Module download cache - ~/.cache/go-build # Build cache (Linux) - ~/Library/Caches/go-build # Build cache (Mac) - '%LocalAppData%\go-build' # Build cache (Windows) - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Run linter and tests - run: make test-coverage-custom + on-push: + uses: bactions/workflows/.github/workflows/on-push-go.yml@main + secrets: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.ON_PUSH_SLACK_WEBHOOK_URL }} diff --git a/.golangci-lint.yml b/.golangci-lint.yml new file mode 100644 index 00000000..f70b6949 --- /dev/null +++ b/.golangci-lint.yml @@ -0,0 +1,190 @@ +# This file contains all available configuration options +# with their default values. + +# options for analysis running +run: + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 5m + + # exit code when at least one issue was found, default is 1 + issues-exit-code: 1 + + # include test files or not, default is true + tests: true + + # list of build tags, all linters use it. Default is empty list. + build-tags: + - mytag + + + # default is true. Enables skipping of directories: + # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + skip-dirs-use-default: true + + + # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + # modules-download-mode: readonly|release|vendor + + # Allow multiple parallel golangci-lint instances running. + # If false (default) - golangci-lint acquires file lock on start. + allow-parallel-runners: false + + +# output configuration options +output: + # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" + formats: colored-line-number + + # print lines of code with issue, default is true + print-issued-lines: true + + # print linter name in the end of issue text, default is true + print-linter-name: true + + # make issues output unique by line, default is true + uniq-by-line: true + + # add a prefix to the output file references; default is no prefix + path-prefix: "" + +linters: + # Disable all linters. + # Default: false + disable-all: true + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - bodyclose + - exhaustive + - gosec + - prealloc + - govet + - revive + - unconvert + - ineffassign + - dogsled + - exportloopref + - sqlclosecheck + - nolintlint + - errcheck + - gosimple + - staticcheck + - unused + - wrapcheck + - errorlint + - wastedassign + +linters-settings: + wrapcheck: + ignoreSigRegexps: + - spverrors\.(Newf|Wrapf) + ignorePackageGlobs: + - "github.com/go-ozzo/ozzo-validation" + revive: + rules: + - name: exported + exclude: "**/testabilities/**" + +issues: + # List of regexps of issue texts to exclude, empty list by default. + # But independently of this option we use default exclude patterns, + # it can be disabled by `exclude-use-default: false`. To list all + # excluded by default patterns execute `golangci-lint run --help` + exclude: + - Using the variable on range scope .* in function literal + - should have a package comment + + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - wrapcheck + - bodyclose + + # Exclude known linters from partially "hard-vendored" code, + # which is impossible to exclude via "nolint" comments. + - path: internal/hmac/ + text: "weak cryptographic primitive" + linters: + - gosec + + # Exclude some "staticcheck" messages + - linters: + - staticcheck + text: "SA1019:" + + # Exclude lll issues for long lines with go:generate + - linters: + - lll + source: "^//go:generate " + + # Independently of option `exclude` we use default exclude patterns, + # it can be disabled by this option. To list all + # excluded by default patterns execute `golangci-lint run --help`. + # Default value for this option is true. + exclude-use-default: false + + # which files to skip: they will be analyzed, but issues from them + # won't be reported. Default value is empty list, but there is + # no need to include all autogenerated files, we confidently recognize + # autogenerated files. If it's not please let us know. + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + exclude-files: + - ".*\\.my\\.go$" + - lib/bad.go + # which dirs to skip: issues from them won't be reported; + # can use regexp here: generated.*, regexp is applied on full path; + # default value is empty list, but default dirs are skipped independently + # of this option's value (see skip-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + exclude-dirs: + - .github + - .make + - dist + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + + # Show only new issues created after git revision `REV` + new-from-rev: "" + +severity: + # Default value is empty string. + # Set the default severity for issues. If severity rules are defined and the issues + # do not match or no severity is provided to the rule this will be the default + # severity applied. Severities should match the supported severity names of the + # selected out format. + # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity + # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity + # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + default-severity: error + + # The default value is false. + # If set to true severity-rules regular expressions become case-sensitive. + case-sensitive: false + + # Default value is empty list. + # When a list of severity rules are provided, severity information will be added to lint + # issues. Severity rules have the same filtering capability as exclude rules except you + # are allowed to specify one matcher per severity rule. + # Only affects out formats that support setting severity information. + rules: + - linters: + - dupl + severity: info diff --git a/.golangci-style.yml b/.golangci-style.yml new file mode 100644 index 00000000..3fa083c1 --- /dev/null +++ b/.golangci-style.yml @@ -0,0 +1,137 @@ +# This file contains all available configuration options +# with their default values. + +# options for analysis running +run: + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 5m + + # exit code when at least one issue was found, default is 1 + issues-exit-code: 1 + + # include test files or not, default is true + tests: true + + # list of build tags, all linters use it. Default is empty list. + build-tags: + - mytag + + # default is true. Enables skipping of directories: + # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + skip-dirs-use-default: true + + # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + # modules-download-mode: readonly|release|vendor + + # Allow multiple parallel golangci-lint instances running. + # If false (default) - golangci-lint acquires file lock on start. + allow-parallel-runners: false + + +# output configuration options +output: + # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" + formats: colored-line-number + + # print lines of code with issue, default is true + print-issued-lines: true + + # print linter name in the end of issue text, default is true + print-linter-name: true + + # make issues output unique by line, default is true + uniq-by-line: true + + # add a prefix to the output file references; default is no prefix + path-prefix: "" + +linters: + # Disable all linters. + # Default: false + disable-all: true + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - gci + - misspell + + +linters-settings: + gci: + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type. + - prefix(bitcoin-sv/spv-wallet) # Custom section: groups all imports with the specified Prefix. + misspell: + # Correct spellings using locale preferences for US or UK. + # Default is to use a neutral variety of English. + # Setting locale to US will correct the British spelling of 'colour' to 'color'. + locale: US + ignore-words: + - bsv + - bitcoin + - serialise + +issues: + # Independently of option `exclude` we use default exclude patterns, + # it can be disabled by this option. + # To list all excluded by default patterns execute `golangci-lint run --help`. + # Default: true + exclude-use-default: false + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + # Show only new issues created after git revision `REV` + new-from-rev: "" + exclude-files: + # which files to skip: they will be analyzed, but issues from them + # won't be reported. Default value is empty list, but there is + # no need to include all autogenerated files, we confidently recognize + # autogenerated files. If it's not please let us know. + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + - ".*\\.my\\.go$" + - lib/bad.go + # which dirs to skip: issues from them won't be reported; + # can use regexp here: generated.*, regexp is applied on full path; + # default value is empty list, but default dirs are skipped independently + # of this option's value (see skip-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + exclude-dirs: + - .github + - .make + - dist + +severity: + # Default value is empty string. + # Set the default severity for issues. If severity rules are defined and the issues + # do not match or no severity is provided to the rule this will be the default + # severity applied. Severities should match the supported severity names of the + # selected out format. + # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity + # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity + # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + default-severity: error + + # The default value is false. + # If set to true severity-rules regular expressions become case-sensitive. + case-sensitive: false + + # Default value is empty list. + # When a list of severity rules are provided, severity information will be added to lint + # issues. Severity rules have the same filtering capability as exclude rules except you + # are allowed to specify one matcher per severity rule. + # Only affects out formats that support setting severity information. + rules: + - linters: + - dupl + severity: info diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 006dcd88..00000000 --- a/.golangci.yml +++ /dev/null @@ -1,431 +0,0 @@ -# This file contains all available configuration options -# with their default values. - -# options for analysis running -run: - # default concurrency is an available CPU number - concurrency: 4 - - # timeout for analysis, e.g. 30s, 5m, default is 1m - timeout: 5m - - # exit code when at least one issue was found, default is 1 - issues-exit-code: 1 - - # include test files or not, default is true - tests: true - - # list of build tags, all linters use it. Default is empty list. - build-tags: - - mytag - - # which dirs to skip: issues from them won't be reported; - # can use regexp here: generated.*, regexp is applied on full path; - # default value is empty list, but default dirs are skipped independently - # of this option's value (see skip-dirs-use-default). - # "/" will be replaced by current OS file path separator to properly work - # on Windows. - skip-dirs: - - .github - - .make - - dist - - # default is true. Enables skipping of directories: - # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - skip-dirs-use-default: true - - # which files to skip: they will be analyzed, but issues from them - # won't be reported. Default value is empty list, but there is - # no need to include all autogenerated files, we confidently recognize - # autogenerated files. If it's not please let us know. - # "/" will be replaced by current OS file path separator to properly work - # on Windows. - skip-files: - - ".*\\.my\\.go$" - - lib/bad.go - - # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": - # If invoked with -mod=readonly, the go command is disallowed from the implicit - # automatic updating of go.mod described above. Instead, it fails when any changes - # to go.mod are needed. This setting is most useful to check that go.mod does - # not need updates, such as in a continuous integration and testing system. - # If invoked with -mod=vendor, the go command assumes that the vendor - # directory holds the correct copies of dependencies and ignores - # the dependency descriptions in go.mod. - #modules-download-mode: readonly|release|vendor - - # Allow multiple parallel golangci-lint instances running. - # If false (default) - golangci-lint acquires file lock on start. - allow-parallel-runners: false - - -# output configuration options -output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" - format: colored-line-number - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true - - # make issues output unique by line, default is true - uniq-by-line: true - - # add a prefix to the output file references; default is no prefix - path-prefix: "" - - -# all available settings of specific linters -linters-settings: - dogsled: - # checks assignments with too many blank identifiers; default is 2 - max-blank-identifiers: 2 - dupl: - # tokens count to trigger issue, 150 by default - threshold: 150 - errcheck: - # report about not checking of errors in type assertions: `a := b.(MyStruct)`; - # default is false: such cases aren't reported by default. - check-type-assertions: false - - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; - # default is false: such cases aren't reported by default. - check-blank: false - - # [deprecated] comma-separated list of pairs of the form pkg:regex - # the regex is used to ignore names within pkg. (default "fmt:.*"). - # see https://github.com/kisielk/errcheck#the-deprecated-method for details - ignore: fmt:.*,io/ioutil:^Read.* - - # path to a file containing a list of functions to exclude from checking - # see https://github.com/kisielk/errcheck#excluding-functions for details - #exclude: /path/to/file.txt - exhaustive: - # indicates that switch statements are to be considered exhaustive if a - # 'default' case is present, even if all enum members aren't listed in the - # switch - default-signifies-exhaustive: false - funlen: - lines: 60 - statements: 40 - gci: - # put imports beginning with prefix after 3rd-party packages; - # only support one prefix - # if not set, use goimports.local-prefixes - local-prefixes: github.com/org/project - gocognit: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 10 - nestif: - # minimal complexity of if statements to report, 5 by default - min-complexity: 4 - goconst: - # minimal length of string constant, 3 by default - min-len: 3 - # minimal occurrences count to trigger, 3 by default - min-occurrences: 3 - gocritic: - # Which checks should be enabled; can't be combined with 'disabled-checks'; - # See https://go-critic.github.io/overview#checks-overview - # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` - # By default list of stable checks is used. - #enabled-checks: - # - rangeValCopy - - # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty - disabled-checks: - - regexpMust - - # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. - # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". - enabled-tags: - - performance - disabled-tags: - - experimental - - settings: # settings passed to gocritic - captLocal: # must be valid enabled check name - paramsOnly: true - rangeValCopy: - sizeThreshold: 32 - gocyclo: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 10 - godot: - # check all top-level comments, not only declarations - check-all: false - godox: - # report any comments starting with keywords, this is useful for TODO or FIXME comments that - # might be left in the code accidentally and should be resolved before merging - keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting - - NOTE - - OPTIMIZE # marks code that should be optimized before merging - - HACK # marks hack-arounds that should be removed before merging - gofmt: - # simplify code: gofmt with `-s` option, true by default - simplify: true - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - local-prefixes: github.com/org/project - gomnd: - settings: - mnd: - # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. - checks: - - argument - - case - - condition - - operation - - return - - assign - govet: - # report about shadowed variables - check-shadowing: true - - # settings per analyzer - settings: - printf: # analyzer name, run `go tool vet help` to see all analyzers - funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf - - # enable or disable analyzers by name - enable: - - atomicalign - enable-all: false - disable-all: false - depguard: - list-type: blacklist - include-go-root: false - packages: - - github.com/sirupsen/logrus - packages-with-error-message: - # specify an error message to output when a blacklisted package is used - - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" - lll: - # max line length, lines longer will be reported. Default is 120. - # '\t' is counted as 1 character by default, and can be changed with the tab-width option - line-length: 120 - # tab width in spaces. Default to 1. - tab-width: 1 - maligned: - # print struct with more effective memory layout or not, false by default - suggest-new: true - misspell: - # Correct spellings using locale preferences for US or UK. - # Default is to use a neutral variety of English. - # Setting locale to US will correct the British spelling of 'colour' to 'color'. - locale: US - ignore-words: - - bsv - - bitcoin - nakedret: - # make an issue if func has more lines of code than this setting, and it has naked returns; default is 30 - max-func-lines: 30 - prealloc: - # XXX: we don't recommend using this linter before doing performance profiling. - # For most programs usage of prealloc will be a premature optimization. - - # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. - # True by default. - simple: true - range-loops: true # Report preallocation suggestions on range loops, true by default - for-loops: false # Report preallocation suggestions on for loops, false by default - nolintlint: - # Enable to ensure that nolint directives are all used. Default is true. - allow-unused: false - # Disable to ensure that nolint directives don't have a leading space. Default is true. - allow-leading-space: true - # Exclude following linters from requiring an explanation. Default is []. - allow-no-explanation: [] - # Enable to require an explanation of nonzero length after each nolint directive. Default is false. - require-explanation: true - # Enable to require nolint directives to mention the specific linter being suppressed. Default is false. - require-specific: true - rowserrcheck: - packages: - - github.com/jmoiron/sqlx - testpackage: - # regexp pattern to skip files - skip-regexp: (export|internal)_test\.go - unparam: - # Inspect exported functions, default is false. Set to true if no external program/library imports your code. - # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: - # if it's called for sub-dir of a project it can't find external interfaces. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - unused: - # treat code as a program (not a library) and report unused exported identifiers; default is false. - # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: - # if it's called for sub-dir of a project it can't find function usages. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - whitespace: - multi-if: false # Enforces newlines (or comments) after every multi-line if statement - multi-func: false # Enforces newlines (or comments) after every multi-line function signature - wsl: - # If true append is only allowed to be cuddled if appending value is - # matching variables, fields or types online above. Default is true. - strict-append: true - # Allow calls and assignments to be cuddled as long as the lines have any - # matching variables, fields or types. Default is true. - allow-assign-and-call: true - # Allow multiline assignments to be cuddled. Default is true. - allow-multiline-assign: true - # Allow declarations (var) to be cuddled. - allow-cuddle-declarations: true - # Allow trailing comments in ending of blocks - allow-trailing-comment: false - # Force newlines in end of case at this limit (0 = never). - force-case-trailing-whitespace: 0 - # Force cuddling of err checks with err var assignment - force-err-cuddling: false - # Allow leading comments to be separated with empty liens - allow-separated-leading-comment: false - gofumpt: - # Choose whether to use the extra rules that are disabled - # by default - extra-rules: false - - # The custom section can be used to define linter plugins to be loaded at runtime. See README doc - # for more info. - custom: - # Each custom linter should have a unique name. - #example: - # The path to the plugin *.so. Can be absolute or local. Required for each custom linter - #path: /path/to/example.so - # The description of the linter. Optional, just for documentation purposes. - #description: This is an example usage of a plugin linter. - # Intended to point to the repo location of the linter. Optional, just for documentation purposes. - #original-url: github.com/golangci/example-linter - -linters: - enable: - - megacheck - - govet - - gosec - - bodyclose - - revive - - unconvert - - dupl - - misspell - - ineffassign - - dogsled - - prealloc - - exportloopref - - exhaustive - - sqlclosecheck - - nolintlint - - gci - - goconst - disable: - - gocritic # use this for very opinionated linting - - gochecknoglobals - - whitespace - - wsl - - goerr113 - - godot - - testpackage - - nestif - - nlreturn - disable-all: false - presets: - - bugs - - unused - fast: false - - -issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently of this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` - exclude: - - Using the variable on range scope .* in function literal - - # Excluding configuration per-path, per-linter, per-text and per-source - exclude-rules: - # Exclude some linters from running on tests files. - - path: _test\.go - linters: - - gocyclo - - errcheck - - dupl - - gosec - - # Exclude known linters from partially "hard-vendored" code, - # which is impossible to exclude via "nolint" comments. - - path: internal/hmac/ - text: "weak cryptographic primitive" - linters: - - gosec - - # Exclude some "staticcheck" messages - - linters: - - staticcheck - text: "SA1019:" - - # Exclude lll issues for long lines with go:generate - - linters: - - lll - source: "^//go:generate " - - # Independently of option `exclude` we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute `golangci-lint run --help`. - # Default value for this option is true. - exclude-use-default: false - - # The default value is false. If set to true exclude and exclude-rules - # regular expressions become case-sensitive. - exclude-case-sensitive: false - - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. - max-issues-per-linter: 0 - - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. - max-same-issues: 0 - - # Show only new issues: if there are "un-staged" changes or untracked files, - # only those changes are analyzed, else only changes in HEAD~ are analyzed. - # It's a super-useful option for integration of golangci-lint into existing - # large codebase. It's not practical to fix all existing issues at the moment - # of integration: much better don't allow issues in new code. - # Default is false. - new: false - - # Show only new issues created after git revision `REV` - new-from-rev: "" - - # Show only new issues created in git patch with set file path. - #new-from-patch: path/to/patch/file - -severity: - # Default value is empty string. - # Set the default severity for issues. If severity rules are defined and the issues - # do not match or no severity is provided to the rule this will be the default - # severity applied. Severities should match the supported severity names of the - # selected out format. - # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity - # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity - # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message - default-severity: error - - # The default value is false. - # If set to true severity-rules regular expressions become case-sensitive. - case-sensitive: false - - # Default value is empty list. - # When a list of severity rules are provided, severity information will be added to lint - # issues. Severity rules have the same filtering capability as exclude rules except you - # are allowed to specify one matcher per severity rule. - # Only affects out formats that support setting severity information. - rules: - - linters: - - dupl - severity: info diff --git a/codecov.yml b/codecov.yml index 21745834..56c50c43 100644 --- a/codecov.yml +++ b/codecov.yml @@ -39,4 +39,4 @@ parsers: comment: layout: "reach,diff,flags,files,footer" behavior: default - require_changes: false \ No newline at end of file + require_changes: false diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 992f4107..96dd0d0b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,3 +1,5 @@ +// Package auth is responsible for handling the authentication of the requests to the server. +// nolint: unused,nolintlint // FIXME: this file will be used soon package auth import ( @@ -15,7 +17,6 @@ import ( sighash "github.com/bitcoin-sv/go-sdk/transaction/sighash" "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" - "github.com/bitcoin-sv/spv-wallet/models" ) @@ -26,23 +27,29 @@ func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign tx.Inputs = make([]*trx.TransactionInput, 0) if err != nil { - return "", err + return "", fmt.Errorf("failed to parse hex, %w", err) } // Enrich inputs for _, draftInput := range dt.Configuration.Inputs { lockingScript, err := prepareLockingScript(&draftInput.Destination) if err != nil { - return "", err + return "", fmt.Errorf("failed to prepare locking script, %w", err) } unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination) if err != nil { - return "", err + return "", fmt.Errorf("failed to prepare unlocking script, %w", err) + } + err = tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) + if err != nil { + return "", fmt.Errorf("failed to add inputs to transaction, %w", err) } - tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) } - tx.Sign() + err = tx.Sign() + if err != nil { + return "", fmt.Errorf("failed to sign transaction, %w", err) + } return tx.String(), nil } @@ -80,23 +87,31 @@ func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destinati // Derive the child key (m/chain/num) derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to derive key for unlocking input, %w", err) } // Handle paymail destination derivation if applicable if dst.PaymailExternalDerivationNum != nil { derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to derive key for unlocking paymail input, %w", err) } } // Get the private key from the derived key - return bip32.GetPrivateKeyFromHDKey(derivedKey) + priv, err := bip32.GetPrivateKeyFromHDKey(derivedKey) + if err != nil { + return nil, fmt.Errorf("failed to get private key for unlocking paymail input, %w", err) + } + return priv, nil } // Generate unlocking script using private key func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { sigHashFlags := sighash.AllForkID - return p2pkh.Unlock(privateKey, &sigHashFlags) + unlocked, err := p2pkh.Unlock(privateKey, &sigHashFlags) + if err != nil { + return nil, fmt.Errorf("failed to create unlocking script, %w", err) + } + return unlocked, nil } // createSignature will create a signature for the given key & body contents @@ -146,7 +161,7 @@ func createSignatureCommon(payload *models.AuthPayload, bodyString string, priva // Signature, using bitcoin signMessage sigBytes, err := bsm.SignMessage(privateKey, getSigningMessage(key, payload)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to sign message, %w", err) } payload.Signature = base64.StdEncoding.EncodeToString(sigBytes) diff --git a/this_file_should_be_removed.go b/this_file_should_be_removed.go new file mode 100644 index 00000000..334f3440 --- /dev/null +++ b/this_file_should_be_removed.go @@ -0,0 +1,6 @@ +package spv_wallet_go_client + +// ThisFileShouldBeRemoved is returning a reason why this should be removed. +func ThisFileShouldBeRemoved() string { + return "This file should be removed, it is there to make linter working, because currently there is no public files that the linter can check" +} From fd131afd1f81a95985c86b5456e44901f708b534 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Mon, 4 Nov 2024 13:34:04 +0100 Subject: [PATCH 04/18] refactor(SPV-1087): add configurations API implementation, spv client. (#5) --- .golangci-lint.yml | 2 +- client.go | 161 ++++++++++++++++++ go.mod | 4 + go.sum | 10 ++ internal/api/v1/user/configs/configs.go | 37 ++++ internal/api/v1/user/configs/configs_test.go | 67 ++++++++ .../configstest/response_200_status_code.json | 9 + internal/auth/auth.go | 56 +++--- internal/auth/authenitcators.go | 120 +++++++++++++ internal/auth/authenticators_test.go | 124 ++++++++++++++ internal/cryptoutil/cryptoutil.go | 34 +--- internal/testfixtures/testfixtures.go | 56 ++++++ this_file_should_be_removed.go | 6 - 13 files changed, 628 insertions(+), 58 deletions(-) create mode 100644 client.go create mode 100644 internal/api/v1/user/configs/configs.go create mode 100644 internal/api/v1/user/configs/configs_test.go create mode 100644 internal/api/v1/user/configs/configstest/response_200_status_code.json create mode 100644 internal/auth/authenitcators.go create mode 100644 internal/auth/authenticators_test.go create mode 100644 internal/testfixtures/testfixtures.go delete mode 100644 this_file_should_be_removed.go diff --git a/.golangci-lint.yml b/.golangci-lint.yml index f70b6949..4cd87044 100644 --- a/.golangci-lint.yml +++ b/.golangci-lint.yml @@ -90,7 +90,7 @@ linters-settings: revive: rules: - name: exported - exclude: "**/testabilities/**" + exclude: ["**/testabilities/**", "**/internal/**"] issues: # List of regexps of issue texts to exclude, empty list by default. diff --git a/client.go b/client.go new file mode 100644 index 00000000..2b0a06b2 --- /dev/null +++ b/client.go @@ -0,0 +1,161 @@ +package client + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +// Config holds configuration settings for establishing a connection and handling +// request details in the application. +type Config struct { + Addr string // The base address of the SPV Wallet API. + Timeout time.Duration // The HTTP requests timeout duration. + Transport http.RoundTripper // Custom HTTP transport, allowing optional customization of the HTTP client behavior. +} + +// NewDefaultConfig returns a default configuration for connecting to the SPV Wallet API, +// setting a one-minute timeout, using the default HTTP transport, and applying the +// base API address as the addr value. +func NewDefaultConfig(addr string) Config { + return Config{ + Addr: addr, + Timeout: 1 * time.Minute, + Transport: http.DefaultTransport, + } +} + +// Client provides methods for user-related and admin-related APIs. +// This struct is designed to abstract and simplify the process of making HTTP calls +// to the relevant endpoints. By utilizing this Client struct, developers can easily +// interact with both user and admin APIs without needing to manage the details +// of the HTTP requests and responses directly. +type Client struct { + configsAPI *configs.API +} + +// NewWithXPub creates a new client instance using an extended public key (xPub). +// Requests made with this instance will not be signed, that's why we strongly recommend to use `WithXPriv` or `WithAccessKey` option instead. +func NewWithXPub(cfg Config, xPub string) (*Client, error) { + key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) + } + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xpub authenticator: %w", err) + } + + return newClient(cfg, authenticator), nil +} + +// NewWithXPriv creates a new client instance using an extended private key (xPriv). +// Generates an HD key from the provided xPriv and sets up the client instance to sign requests +// by setting the SignRequest flag to true. The generated HD key can be used for secure communications. +func NewWithXPriv(cfg Config, xPriv string) (*Client, error) { + key, err := bip32.GenerateHDKeyFromString(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xpriv: %w", err) + } + + authenticator, err := auth.NewXprivAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xpriv authenticator: %w", err) + } + + return newClient(cfg, authenticator), nil +} + +// NewWithAccessKey creates a new client instance using an access key. +// Function attempts to convert the provided access key from either hex or WIF format +// to a PrivateKey. The resulting PrivateKey is used to sign requests made by the client instance +// by setting the SignRequest flag to true. +func NewWithAccessKey(cfg Config, accessKey string) (*Client, error) { + key, err := privateKeyFromHexOrWIF(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to return private key from hex or WIF: %w", err) + } + + authenticator, err := auth.NewAccessKeyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) + } + + return newClient(cfg, authenticator), nil +} + +// SharedConfig retrieves the shared configuration from the user configurations API. +// This method constructs an HTTP GET request to the "/shared" endpoint and expects +// a response that can be unmarshaled into the response.SharedConfig struct. +// If the request fails or the response cannot be decoded, an error will be returned. +func (c *Client) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { + res, err := c.configsAPI.SharedConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve shared configuration from user configs API: %w", err) + } + + return res, nil +} + +func privateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { + pk, err1 := ec.PrivateKeyFromWif(s) + if err1 == nil { + return pk, nil + } + + pk, err2 := ec.PrivateKeyFromHex(s) + if err2 != nil { + return nil, errors.Join(err1, err2) + } + + return pk, nil +} + +type authenticator interface { + Authenticate(r *resty.Request) error +} + +func newClient(cfg Config, auth authenticator) *Client { + restyCli := newRestyClient(cfg, auth) + cli := Client{ + configsAPI: configs.NewAPI(cfg.Addr, restyCli), + } + return &cli +} + +func newRestyClient(cfg Config, auth authenticator) *resty.Client { + return resty.New(). + SetTransport(cfg.Transport). + SetBaseURL(cfg.Addr). + SetTimeout(cfg.Timeout). + OnBeforeRequest(func(_ *resty.Client, r *resty.Request) error { + return auth.Authenticate(r) + }). + SetError(&models.SPVError{}). + OnAfterResponse(func(_ *resty.Client, r *resty.Response) error { + if r.IsSuccess() { + return nil + } + + if spvError, ok := r.Error().(*models.SPVError); ok && len(spvError.Code) > 0 { + return spvError + } + + return fmt.Errorf("%w: %s", ErrUnrecognizedAPIResponse, r.Body()) + }) +} + +// ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API +// does not match the expected expected format or structure. +var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") diff --git a/go.mod b/go.mod index 4acf797e..f0a74bc3 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,12 @@ require ( github.com/stretchr/testify v1.9.0 ) +require golang.org/x/net v0.27.0 // indirect + require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-resty/resty/v2 v2.15.3 + github.com/jarcoal/httpmock v1.3.1 github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/crypto v0.26.0 // indirect diff --git a/go.sum b/go.sum index eddde960..a8948275 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,12 @@ github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQ github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= +github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -12,6 +18,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/api/v1/user/configs/configs.go b/internal/api/v1/user/configs/configs.go new file mode 100644 index 00000000..1e47b1c3 --- /dev/null +++ b/internal/api/v1/user/configs/configs.go @@ -0,0 +1,37 @@ +package configs + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/configs" + +type API struct { + addr string + cli *resty.Client +} + +func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { + var result response.SharedConfig + _, err := a.cli. + R(). + SetContext(ctx). + SetResult(&result). + Get(a.addr + "/shared") + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewAPI(addr string, cli *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + cli: cli, + } +} diff --git a/internal/api/v1/user/configs/configs_test.go b/internal/api/v1/user/configs/configs_test.go new file mode 100644 index 00000000..bd26e83e --- /dev/null +++ b/internal/api/v1/user/configs/configs_test.go @@ -0,0 +1,67 @@ +package configs_test + +import ( + "context" + "net/http" + "testing" + + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/testfixtures" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { + tests := map[string]struct { + statusCode int + expectedResponse *response.SharedConfig + expectedErr error + responder httpmock.Responder + }{ + "HTTP GET /api/v1/configs/shared response: 200": { + expectedResponse: &response.SharedConfig{ + PaymailDomains: []string{"john.test.4chain.space"}, + ExperimentalFeatures: map[string]bool{ + "pikeContactsEnabled": true, + "pikePaymentEnabled": true, + }, + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("configstest/response_200_status_code.json")), + }, + "HTTP GET /api/v1/configs/shared response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }), + }, + "HTTP GET /api/v1/configs/shared str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := testfixtures.TestAPIAddr + "/api/v1/configs/shared" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := testfixtures.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.SharedConfig(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/user/configs/configstest/response_200_status_code.json b/internal/api/v1/user/configs/configstest/response_200_status_code.json new file mode 100644 index 00000000..56b93eb5 --- /dev/null +++ b/internal/api/v1/user/configs/configstest/response_200_status_code.json @@ -0,0 +1,9 @@ +{ + "paymailDomains": [ + "john.test.4chain.space" + ], + "experimentalFeatures": { + "pikeContactsEnabled": true, + "pikePaymentEnabled": true + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 96dd0d0b..1d35318b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,10 +1,8 @@ -// Package auth is responsible for handling the authentication of the requests to the server. -// nolint: unused,nolintlint // FIXME: this file will be used soon package auth import ( "encoding/base64" - "errors" + "encoding/hex" "fmt" "net/http" "time" @@ -20,7 +18,6 @@ import ( "github.com/bitcoin-sv/spv-wallet/models" ) -// GetSignedHex will sign all the inputs using the given xPriv key func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { // Create transaction from hex tx, err := trx.NewTransactionFromHex(dt.Hex) @@ -36,10 +33,12 @@ func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string if err != nil { return "", fmt.Errorf("failed to prepare locking script, %w", err) } + unlockScript, err := prepareUnlockingScript(xPriv, &draftInput.Destination) if err != nil { return "", fmt.Errorf("failed to prepare unlocking script, %w", err) } + err = tx.AddInputFrom(draftInput.TransactionID, draftInput.OutputIndex, lockingScript.String(), draftInput.Satoshis, unlockScript) if err != nil { return "", fmt.Errorf("failed to add inputs to transaction, %w", err) @@ -50,15 +49,15 @@ func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string if err != nil { return "", fmt.Errorf("failed to sign transaction, %w", err) } + return tx.String(), nil } -// SetSignature will set the signature on the header for the request func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) error { // Create the signature authData, err := createSignature(xPriv, bodyString) if err != nil { - return fmt.Errorf("create signature op failure: %w", err) + return fmt.Errorf("failed to create signature: %w", err) } // Set the auth header @@ -72,14 +71,16 @@ func prepareLockingScript(dst *models.Destination) (*script.Script, error) { if err != nil { return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err) } + return lockingScript, nil } func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { key, err := getDerivedKeyForDestination(xPriv, dst) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get derived key for destination: %w", err) } + return getUnlockingScript(key) } @@ -89,6 +90,7 @@ func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destinati if err != nil { return nil, fmt.Errorf("failed to derive key for unlocking input, %w", err) } + // Handle paymail destination derivation if applicable if dst.PaymailExternalDerivationNum != nil { derivedKey, err = derivedKey.Child(*dst.PaymailExternalDerivationNum) @@ -96,32 +98,27 @@ func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destinati return nil, fmt.Errorf("failed to derive key for unlocking paymail input, %w", err) } } + // Get the private key from the derived key priv, err := bip32.GetPrivateKeyFromHDKey(derivedKey) if err != nil { return nil, fmt.Errorf("failed to get private key for unlocking paymail input, %w", err) } + return priv, nil } -// Generate unlocking script using private key func getUnlockingScript(privateKey *ec.PrivateKey) (*p2pkh.P2PKH, error) { sigHashFlags := sighash.AllForkID unlocked, err := p2pkh.Unlock(privateKey, &sigHashFlags) if err != nil { return nil, fmt.Errorf("failed to create unlocking script, %w", err) } + return unlocked, nil } -// createSignature will create a signature for the given key & body contents func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *models.AuthPayload, err error) { - // No key? - if xPriv == nil { - err = ErrMissingXpriv - return - } - // Get the xPub payload = new(models.AuthPayload) if payload.XPub, err = bip32.GetExtendedPublicKey(xPriv); err != nil { // Should never error if key is correct @@ -147,7 +144,6 @@ func createSignature(xPriv *bip32.ExtendedKey, bodyString string) (payload *mode return createSignatureCommon(payload, bodyString, privateKey) } -// createSignatureCommon will create a signature func createSignatureCommon(payload *models.AuthPayload, bodyString string, privateKey *ec.PrivateKey) (*models.AuthPayload, error) { // Create the auth header hash payload.AuthHash = cryptoutil.Hash(bodyString) @@ -168,22 +164,36 @@ func createSignatureCommon(payload *models.AuthPayload, bodyString string, priva return payload, nil } -// getSigningMessage will build the signing message byte array func getSigningMessage(xPub string, auth *models.AuthPayload) []byte { message := fmt.Sprintf("%s%s%s%d", xPub, auth.AuthHash, auth.AuthNonce, auth.AuthTime) return []byte(message) } func setSignatureHeaders(header *http.Header, authData *models.AuthPayload) { - // Create the auth header hash header.Set(models.AuthHeaderHash, authData.AuthHash) - // Set the nonce header.Set(models.AuthHeaderNonce, authData.AuthNonce) - // Set the time header.Set(models.AuthHeaderTime, fmt.Sprintf("%d", authData.AuthTime)) - // Set the signature header.Set(models.AuthSignature, authData.Signature) } -// ErrMissingXpriv is when xpriv is missing -var ErrMissingXpriv = errors.New("xpriv is missing") +func createSignatureAccessKey(privateKeyHex, bodyString string) (payload *models.AuthPayload, err error) { + privateKey, err := ec.PrivateKeyFromHex(privateKeyHex) + if err != nil { + return + } + + publicKey := privateKey.PubKey() + + // Get the AccessKey + payload = new(models.AuthPayload) + payload.AccessKey = hex.EncodeToString(publicKey.SerializeCompressed()) + + // auth_nonce is a random unique string to seed the signing message + // this can be checked server side to make sure the request is not being replayed + payload.AuthNonce, err = cryptoutil.RandomHex(32) + if err != nil { + return nil, fmt.Errorf("failed to generate random hexadecimal string: %w", err) + } + + return createSignatureCommon(payload, bodyString, privateKey) +} diff --git a/internal/auth/authenitcators.go b/internal/auth/authenitcators.go new file mode 100644 index 00000000..476a0ba7 --- /dev/null +++ b/internal/auth/authenitcators.go @@ -0,0 +1,120 @@ +package auth + +import ( + "encoding/hex" + "errors" + "fmt" + "net/http" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/go-resty/resty/v2" +) + +type XpubAuthenticator struct { + hdKey *bip32.ExtendedKey +} + +func (x *XpubAuthenticator) Authenticate(r *resty.Request) error { + xPub, err := bip32.GetExtendedPublicKey(x.hdKey) + if err != nil { + return fmt.Errorf("failed to get extended public key: %w", err) + } + + r.SetHeader(models.AuthHeader, xPub) + return nil +} + +type XprivAuthenticator struct { + xpubAuth *XpubAuthenticator + xpriv *bip32.ExtendedKey +} + +func (x *XprivAuthenticator) Authenticate(r *resty.Request) error { + err := x.xpubAuth.Authenticate(r) + if err != nil { + return fmt.Errorf("failed to set xpub header: %w", err) + } + + body := bodyString(r) + header := make(http.Header) + err = setSignature(&header, x.xpriv, body) + if err != nil { + return fmt.Errorf("failed to sign request with xpriv: %w", err) + } + + r.SetHeaderMultiValues(header) + return nil +} + +type AccessKeyAuthenticator struct { + priv *ec.PrivateKey + pub *ec.PublicKey +} + +func (a *AccessKeyAuthenticator) Authenticate(r *resty.Request) error { + r.Header.Set(models.AuthAccessKey, a.pubKeyHex()) + body := bodyString(r) + sign, err := createSignatureAccessKey(a.privKeyHex(), body) + if err != nil { + return fmt.Errorf("failed to sign request with access key: %w", err) + } + + setSignatureHeaders(&r.Header, sign) + return nil +} + +func (a *AccessKeyAuthenticator) privKeyHex() string { + return hex.EncodeToString(a.priv.Serialize()) +} + +func (a *AccessKeyAuthenticator) pubKeyHex() string { + return hex.EncodeToString(a.pub.SerializeCompressed()) +} + +func bodyString(r *resty.Request) string { + switch r.Method { + case http.MethodGet: + return "" + } + return "" +} + +func NewXprivAuthenticator(xpriv *bip32.ExtendedKey) (*XprivAuthenticator, error) { + if xpriv == nil { + return nil, ErrBip32ExtendedKey + } + + x := XprivAuthenticator{ + xpriv: xpriv, + xpubAuth: &XpubAuthenticator{hdKey: xpriv}, + } + return &x, nil +} + +func NewAccessKeyAuthenticator(accessKey *ec.PrivateKey) (*AccessKeyAuthenticator, error) { + if accessKey == nil { + return nil, ErrEcPrivateKey + } + + a := AccessKeyAuthenticator{ + priv: accessKey, + pub: accessKey.PubKey(), + } + return &a, nil +} + +func NewXpubOnlyAuthenticator(xpub *bip32.ExtendedKey) (*XpubAuthenticator, error) { + if xpub == nil { + return nil, ErrBip32ExtendedKey + } + + x := XpubAuthenticator{hdKey: xpub} + return &x, nil +} + +var ( + ErrBip32ExtendedKey = errors.New("authenticator failed: expected a BIP32 extended key but none was provided") + ErrEcPrivateKey = errors.New("authenticator failed: expected an EC private key but none was provided") +) diff --git a/internal/auth/authenticators_test.go b/internal/auth/authenticators_test.go new file mode 100644 index 00000000..d4714dbd --- /dev/null +++ b/internal/auth/authenticators_test.go @@ -0,0 +1,124 @@ +package auth_test + +import ( + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/testfixtures" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" +) + +const ( + xAuthKey = "X-Auth-Key" + xAuthXPubKey = "X-Auth-Xpub" + xAuthHashKey = "X-Auth-Hash" + xAuthNonceKey = "X-Auth-Nonce" + xAuthTimeKey = "X-Auth-Time" + xAuthSignatureKey = "X-Auth-Signature" +) + +func TestAccessKeyAuthenitcator_NewWithNilAccessKey(t *testing.T) { + // when: + authenticator, err := auth.NewAccessKeyAuthenticator(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrEcPrivateKey) +} + +func TestAccessKeyAuthenticator_Authenticate(t *testing.T) { + // given: + key := testfixtures.PrivateKey(t) + authenticator, err := auth.NewAccessKeyAuthenticator(key) + require.NotNil(t, authenticator) + require.NoError(t, err) + + req := resty.New().R() + + // when: + err = authenticator.Authenticate(req) + + // then: + require.NoError(t, err) + requireXAuthHeaderToBeSet(t, req.Header) + requireSignatureHeadersToBeSet(t, req.Header) +} + +func TestXprivAuthenitcator_NewWithNilXpriv(t *testing.T) { + // when: + authenticator, err := auth.NewXprivAuthenticator(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrBip32ExtendedKey) +} + +func TestXprivAuthenitcator_Authenticate(t *testing.T) { + // given: + key := testfixtures.ExtendedKey(t) + authenticator, err := auth.NewXprivAuthenticator(key) + require.NotNil(t, authenticator) + require.NoError(t, err) + + req := resty.New().R() + + // when: + err = authenticator.Authenticate(req) + + // then: + require.NoError(t, err) + requireXpubHeaderToBeSet(t, req.Header) + requireSignatureHeadersToBeSet(t, req.Header) +} + +func TestXpubOnlyAuthenticator_NewWithNilXpub(t *testing.T) { + // when: + authenticator, err := auth.NewXpubOnlyAuthenticator(nil) + + // then: + require.Nil(t, authenticator) + require.ErrorIs(t, err, auth.ErrBip32ExtendedKey) +} + +func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { + // given: + key := testfixtures.ExtendedKey(t) + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + require.NotNil(t, authenticator) + require.NoError(t, err) + + req := resty.New().R() + + // when: + err = authenticator.Authenticate(req) + + // then: + require.NoError(t, err) + requireXpubHeaderToBeSet(t, req.Header) +} + +func requireXAuthHeaderToBeSet(t *testing.T, h http.Header) { + require.Equal(t, []string{testfixtures.UserPubAccessKey}, h[xAuthKey]) +} + +func requireXpubHeaderToBeSet(t *testing.T, h http.Header) { + require.Equal(t, []string{testfixtures.UserXPub}, h[xAuthXPubKey]) +} + +func requireSignatureHeadersToBeSet(t *testing.T, h http.Header) { + expected := []string{ + xAuthHashKey, + xAuthNonceKey, + xAuthTimeKey, + xAuthSignatureKey, + } + + actual := make([]string, 0, len(expected)) + for k := range h { + actual = append(actual, k) + } + require.Subset(t, actual, expected) +} diff --git a/internal/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go index 75972e29..b0027750 100644 --- a/internal/cryptoutil/cryptoutil.go +++ b/internal/cryptoutil/cryptoutil.go @@ -13,36 +13,26 @@ import ( ) const ( - // XpubKeyLength is the length of an xPub string key. XpubKeyLength = 111 - - // ChainInternal internal chain num. ChainInternal = uint32(1) - - // ChainExternal external chain num. ChainExternal = uint32(0) ) -// Hash returns encoded string from SHA256 checksum of the s value. func Hash(s string) string { bb := sha256.Sum256([]byte(s)) return hex.EncodeToString(bb[:]) } -// RandomHex returns a random hexadecimal string of length `n`. -// An non-nil error is returned when random byte generation process failure occurs (rand.Read). func RandomHex(n int) (string, error) { bb := make([]byte, n) _, err := rand.Read(bb) if err != nil { return "", fmt.Errorf("failed to read bytes after rand: %w", err) } + return hex.EncodeToString(bb), nil } -// DeriveChildKeyFromHex derives a child extended key from a BIP32 master key using a hexadecimal hash. -// The hexadecimal string is parsed into a 32-bit unsigned integers slice, each of which is used -// to derive a successive child key from the provided master or parent key. func DeriveChildKeyFromHex(key *bip32.ExtendedKey, hexHash string) (*bip32.ExtendedKey, error) { nums, err := ParseChildNumsFromHex(hexHash) if err != nil { @@ -59,10 +49,6 @@ func DeriveChildKeyFromHex(key *bip32.ExtendedKey, hexHash string) (*bip32.Exten return child, nil } -// ParseChildNumsFromHex parses a hexadecimal string into multiple 32-bit unsigned integers. -// The input hex string is divided into 8-character chunks, each of which is interpreted as a 32-bit -// unsigned integer in hexadecimal format. The function returns a slice of these integers or an error -// if any part of the hex string is not valid. func ParseChildNumsFromHex(hexHash string) ([]uint32, error) { if hexHash == "" { return nil, nil @@ -81,29 +67,26 @@ func ParseChildNumsFromHex(hexHash string) ([]uint32, error) { if err != nil { return nil, fmt.Errorf("failed to parse hex part %q: %w", hexHash[start:end], err) } + nums = append(nums, num) } return nums, nil } -// parseHexPart converts a hexadecimal string to a uint32 value. -// The input string is expected to represent a valid hexadecimal number, and its value -// must fit within the range of a 32-bit unsigned integer. func parseHexPart(part string) (uint32, error) { i, err := strconv.ParseInt(part, 16, 64) if err != nil { return 0, errors.Join(err, ErrHexHashPartIntParse) } + u, err := Int64ToUint32(i % math.MaxInt32) if err != nil { return 0, fmt.Errorf("failed to convert int64 to uint32: %w", err) } + return u, nil } -// Int64ToUint32 converts an int64 value to a uint32, ensuring that the input is within the valid range for a uint32. -// The function performs a range check to ensure the int64 value is non-negative and does not exceed the maximum value -// for a uint32 (which is 2^32 - 1). func Int64ToUint32(value int64) (uint32, error) { if value < 0 { return 0, ErrNegativeValueNotAllowed @@ -115,12 +98,7 @@ func Int64ToUint32(value int64) (uint32, error) { } var ( - // ErrMaxUint32LimitExceeded occurs when attempting to convert an int64 value that exceeds the maximum uint32 limit. - ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded") - - // ErrNegativeValueNotAllowed occurs when attempting to convert a negative int64 value to uint32. + ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded") ErrNegativeValueNotAllowed = errors.New("negative value is not allowed") - - // ErrHexHashPartIntParse occurs when attempting to parse part of the hex hash to int64. - ErrHexHashPartIntParse = errors.New("parse hex hash part to int64 failed") + ErrHexHashPartIntParse = errors.New("parse hex hash part to int64 failed") ) diff --git a/internal/testfixtures/testfixtures.go b/internal/testfixtures/testfixtures.go new file mode 100644 index 00000000..7e196d6b --- /dev/null +++ b/internal/testfixtures/testfixtures.go @@ -0,0 +1,56 @@ +package testfixtures + +import ( + "testing" + "time" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/jarcoal/httpmock" +) + +const TestAPIAddr = "http://localhost:3003" + +const ( + UserXPriv = "xprv9s21ZrQH143K3fqNnUmXmgfT9ToMtiq5cuKsVBG4E5UqVh4psHDY2XKsEfZKuV4FSZcPS9CYgEQiLUpW2xmHqHFyp23SvTkTCE153cCdwaj" + UserXPub = "xpub661MyMwAqRbcG9uqtWJY8pcBhVdrJBYvz8FUHZffnR1pNVPyQpXnaKeM5w2FyH5Wwhf5Cf15mFDVRZnuK9sEHDqqd39qWz36UDoobrzLyFM" + UserPrivAccessKey = "03a446ede05f04fd92d2707599a80b67ad76f63b3958706819c76308bfc7c1143d" + UserPubAccessKey = "0239a60e37d62b0217ac86881caba194ab943e18099c080de70c173daf75d917b2" +) + +func ExtendedKey(t *testing.T) *bip32.ExtendedKey { + t.Helper() + key, err := bip32.GenerateHDKeyFromString(UserXPriv) + if err != nil { + t.Fatalf("test helper - bip32 generate hd key from string: %s", err) + } + + return key +} + +func PrivateKey(t *testing.T) *ec.PrivateKey { + key, err := ec.PrivateKeyFromHex(UserPrivAccessKey) + if err != nil { + t.Fatalf("test helper - ec private key from hex: %s", err) + } + + return key +} + +func GivenSPVWalletClient(t *testing.T) (*client.Client, *httpmock.MockTransport) { + t.Helper() + transport := httpmock.NewMockTransport() + cfg := client.Config{ + Addr: TestAPIAddr, + Timeout: 5 * time.Second, + Transport: transport, + } + + spv, err := client.NewWithXPriv(cfg, UserXPriv) + if err != nil { + t.Fatalf("test helper - spv wallet client with xpriv: %s", err) + } + + return spv, transport +} diff --git a/this_file_should_be_removed.go b/this_file_should_be_removed.go deleted file mode 100644 index 334f3440..00000000 --- a/this_file_should_be_removed.go +++ /dev/null @@ -1,6 +0,0 @@ -package spv_wallet_go_client - -// ThisFileShouldBeRemoved is returning a reason why this should be removed. -func ThisFileShouldBeRemoved() string { - return "This file should be removed, it is there to make linter working, because currently there is no public files that the linter can check" -} From 6e7159dcd826a57dfb4f59c742f8e7c8cbb0f170 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Wed, 13 Nov 2024 16:45:52 +0100 Subject: [PATCH 05/18] refactor(SPV-1087): add user transactions api implementation. (#7) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: chris-4chain <152964795+chris-4chain@users.noreply.github.com> --- .golangci-lint.yml | 5 + client.go | 81 ++++- commands/transactions.go | 27 ++ internal/api/v1/user/configs/configs.go | 10 +- internal/api/v1/user/configs/configs_test.go | 6 +- .../user/querybuilders/extended_url_values.go | 90 +++++ .../querybuilders/extended_url_values_test.go | 78 +++++ .../querybuilders/metadata_filter_builder.go | 107 ++++++ .../metadata_filter_builder_test.go | 217 ++++++++++++ .../querybuilders/model_filter_builder.go | 20 ++ .../model_filter_builder_test.go | 124 +++++++ .../v1/user/querybuilders/query_builder.go | 79 +++++ .../user/querybuilders/query_builder_test.go | 137 ++++++++ .../query_params_filter_builder.go | 20 ++ .../query_params_filter_builder_test.go | 79 +++++ .../querybuilderstest/querybuilderstest.go | 29 ++ .../transaction_filter_builder.go | 38 +++ .../transaction_filter_builder_test.go | 192 +++++++++++ .../api/v1/user/transactions/transactions.go | 122 +++++++ .../v1/user/transactions/transactions_test.go | 245 ++++++++++++++ .../transactionstest/transaction_200.json | 33 ++ .../transaction_draft_200.json | 127 +++++++ .../transaction_record_201.json | 30 ++ .../transaction_update_metadata_200.json | 34 ++ .../transactionstest/transactions_200.json | 76 +++++ .../transactionstest/transactionstest.go | 312 ++++++++++++++++++ internal/auth/authenticators_test.go | 12 +- .../clienttest.go} | 2 +- queries/transactions.go | 41 +++ 29 files changed, 2352 insertions(+), 21 deletions(-) create mode 100644 commands/transactions.go create mode 100644 internal/api/v1/user/querybuilders/extended_url_values.go create mode 100644 internal/api/v1/user/querybuilders/extended_url_values_test.go create mode 100644 internal/api/v1/user/querybuilders/metadata_filter_builder.go create mode 100644 internal/api/v1/user/querybuilders/metadata_filter_builder_test.go create mode 100644 internal/api/v1/user/querybuilders/model_filter_builder.go create mode 100644 internal/api/v1/user/querybuilders/model_filter_builder_test.go create mode 100644 internal/api/v1/user/querybuilders/query_builder.go create mode 100644 internal/api/v1/user/querybuilders/query_builder_test.go create mode 100644 internal/api/v1/user/querybuilders/query_params_filter_builder.go create mode 100644 internal/api/v1/user/querybuilders/query_params_filter_builder_test.go create mode 100644 internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go create mode 100644 internal/api/v1/user/transactions/transaction_filter_builder.go create mode 100644 internal/api/v1/user/transactions/transaction_filter_builder_test.go create mode 100644 internal/api/v1/user/transactions/transactions.go create mode 100644 internal/api/v1/user/transactions/transactions_test.go create mode 100644 internal/api/v1/user/transactions/transactionstest/transaction_200.json create mode 100644 internal/api/v1/user/transactions/transactionstest/transaction_draft_200.json create mode 100644 internal/api/v1/user/transactions/transactionstest/transaction_record_201.json create mode 100644 internal/api/v1/user/transactions/transactionstest/transaction_update_metadata_200.json create mode 100644 internal/api/v1/user/transactions/transactionstest/transactions_200.json create mode 100644 internal/api/v1/user/transactions/transactionstest/transactionstest.go rename internal/{testfixtures/testfixtures.go => clienttest/clienttest.go} (98%) create mode 100644 queries/transactions.go diff --git a/.golangci-lint.yml b/.golangci-lint.yml index 4cd87044..c74038d0 100644 --- a/.golangci-lint.yml +++ b/.golangci-lint.yml @@ -91,6 +91,11 @@ linters-settings: rules: - name: exported exclude: ["**/testabilities/**", "**/internal/**"] + exhaustive: + # Presence of "default" case in switch statements satisfies exhaustiveness, + # even if all enum members are not listed. + # Default: false + default-signifies-exhaustive: true issues: # List of regexps of issue texts to exclude, empty list by default. diff --git a/client.go b/client.go index 2b0a06b2..7145c887 100644 --- a/client.go +++ b/client.go @@ -9,8 +9,11 @@ import ( bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -41,7 +44,8 @@ func NewDefaultConfig(addr string) Config { // interact with both user and admin APIs without needing to manage the details // of the HTTP requests and responses directly. type Client struct { - configsAPI *configs.API + configsAPI *configs.API + transactionsAPI *transactions.API } // NewWithXPub creates a new client instance using an extended public key (xPub). @@ -108,6 +112,74 @@ func (c *Client) SharedConfig(ctx context.Context) (*response.SharedConfig, erro return res, nil } +// DraftTransaction creates a new draft transaction using the user transactions API. +// This method sends an HTTP POST request to the "/draft" endpoint and expects +// a response that can be unmarshaled into a response.DraftTransaction struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) DraftTransaction(ctx context.Context, cmd *commands.DraftTransaction) (*response.DraftTransaction, error) { + res, err := c.transactionsAPI.DraftTransaction(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to create a draft transaction by calling the user transactions API: %w", err) + } + + return res, nil +} + +// RecordTransaction submits a transaction for recording using the user transactions API. +// This method sends an HTTP POST request to the "/transactions" endpoint, expecting +// a response that can be unmarshaled into a response.Transaction struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) RecordTransaction(ctx context.Context, cmd *commands.RecordTransaction) (*response.Transaction, error) { + res, err := c.transactionsAPI.RecordTransaction(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to record a transaction with reference ID: %s by calling the user transactions API: %w", cmd.ReferenceID, err) + } + + return res, nil +} + +// UpdateTransactionMetadata updates the metadata of a transaction using the user transactions API. +// This method sends an HTTP PATCH request with updated metadata and expects a response +// that can be unmarshaled into a response.Transaction struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) UpdateTransactionMetadata(ctx context.Context, cmd *commands.UpdateTransactionMetadata) (*response.Transaction, error) { + res, err := c.transactionsAPI.UpdateTransactionMetadata(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to update a transaction metadata by calling the user user transactions API: %w", err) + } + + return res, nil +} + +// Transactions retrieves a list of transactions using the user transactions API. +// This method applies optional query parameters and expects a response that can be +// unmarshaled into a slice of response.Transaction pointers. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) Transactions(ctx context.Context, opts ...queries.TransctionsQueryOption) ([]*response.Transaction, error) { + res, err := c.transactionsAPI.Transactions(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to retrieve transactions from the user transactions API: %w", err) + } + + return res, nil +} + +// Transaction retrieves a specific transaction by its ID using the user transactions API. +// This method expects a response that can be unmarshaled into a response.Transaction struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) Transaction(ctx context.Context, ID string) (*response.Transaction, error) { + res, err := c.transactionsAPI.Transaction(ctx, ID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve transaction with ID: %s from the user transactions API: %w", ID, err) + } + + return res, nil +} + +// ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API +// does not match the expected expected format or structure. +var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") + func privateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { pk, err1 := ec.PrivateKeyFromWif(s) if err1 == nil { @@ -129,7 +201,8 @@ type authenticator interface { func newClient(cfg Config, auth authenticator) *Client { restyCli := newRestyClient(cfg, auth) cli := Client{ - configsAPI: configs.NewAPI(cfg.Addr, restyCli), + configsAPI: configs.NewAPI(cfg.Addr, restyCli), + transactionsAPI: transactions.NewAPI(cfg.Addr, restyCli), } return &cli } @@ -155,7 +228,3 @@ func newRestyClient(cfg Config, auth authenticator) *resty.Client { return fmt.Errorf("%w: %s", ErrUnrecognizedAPIResponse, r.Body()) }) } - -// ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API -// does not match the expected expected format or structure. -var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") diff --git a/commands/transactions.go b/commands/transactions.go new file mode 100644 index 00000000..0d9889c1 --- /dev/null +++ b/commands/transactions.go @@ -0,0 +1,27 @@ +package commands + +import ( + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// RecordTransaction holds the arguments required to record a user transaction. +type RecordTransaction struct { + Metadata querybuilders.Metadata `json:"metadata"` // Metadata associated with the transaction. + Hex string `json:"hex"` // Hexadecimal string representation of the transaction. + ReferenceID string `json:"referenceId"` // Reference ID for the transaction. +} + +// DraftTransaction holds the arguments required to create user draft transaction. +type DraftTransaction struct { + Config response.TransactionConfig `json:"config"` // Configuration for the transaction. + Metadata querybuilders.Metadata `json:"metadata"` // Metadata related to the transaction. +} + +// UpdateTransactionMetadata holds the arguments required to update the metadata of a user transaction. +// The ID field is ignored in the request body sent to the SPV Wallet API; instead, it is used as part +// of the transaction metadata update endpoint (e.g., /api/v1/transactions/{ID}). +type UpdateTransactionMetadata struct { + ID string `json:"-"` // Unique identifier of the transaction to be updated. + Metadata querybuilders.Metadata `json:"metadata"` // New metadata to associate with the transaction. +} diff --git a/internal/api/v1/user/configs/configs.go b/internal/api/v1/user/configs/configs.go index 1e47b1c3..da06f6b6 100644 --- a/internal/api/v1/user/configs/configs.go +++ b/internal/api/v1/user/configs/configs.go @@ -11,13 +11,13 @@ import ( const route = "api/v1/configs" type API struct { - addr string - cli *resty.Client + addr string + httpClient *resty.Client } func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { var result response.SharedConfig - _, err := a.cli. + _, err := a.httpClient. R(). SetContext(ctx). SetResult(&result). @@ -31,7 +31,7 @@ func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) func NewAPI(addr string, cli *resty.Client) *API { return &API{ - addr: addr + "/" + route, - cli: cli, + addr: addr + "/" + route, + httpClient: cli, } } diff --git a/internal/api/v1/user/configs/configs_test.go b/internal/api/v1/user/configs/configs_test.go index bd26e83e..adb15ced 100644 --- a/internal/api/v1/user/configs/configs_test.go +++ b/internal/api/v1/user/configs/configs_test.go @@ -6,7 +6,7 @@ import ( "testing" client "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/testfixtures" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" @@ -51,11 +51,11 @@ func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { }, } - URL := testfixtures.TestAPIAddr + "/api/v1/configs/shared" + URL := clienttest.TestAPIAddr + "/api/v1/configs/shared" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := testfixtures.GivenSPVWalletClient(t) + wallet, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodGet, URL, tc.responder) // then: diff --git a/internal/api/v1/user/querybuilders/extended_url_values.go b/internal/api/v1/user/querybuilders/extended_url_values.go new file mode 100644 index 00000000..c9f30b1e --- /dev/null +++ b/internal/api/v1/user/querybuilders/extended_url_values.go @@ -0,0 +1,90 @@ +package querybuilders + +import ( + "fmt" + "net/url" + "time" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type ExtendedURLValues struct { + url.Values +} + +func (e *ExtendedURLValues) AddPair(key string, val any) { + if val == nil || len(key) == 0 { + return + } + + write := func(v any) { e.Add(key, fmt.Sprintf("%v", v)) } + writeRange := func(v filter.TimeRange) { + if v.From != nil && !v.From.IsZero() { + e.Add(fmt.Sprintf("%s[from]", key), v.From.Format(time.RFC3339)) + } + + if v.To != nil && !v.To.IsZero() { + e.Add(fmt.Sprintf("%s[to]", key), v.To.Format(time.RFC3339)) + } + } + + switch v := val.(type) { + case int: + if v > 0 { + write(v) + } + + case string: + if len(v) > 0 { + write(v) + } + + case *string: + if v != nil && len(*v) > 0 { + write(*v) + } + + case *uint64: + if v != nil && *v > 0 { + write(*v) + } + + case *uint32: + if v != nil && *v > 0 { + write(*v) + } + + case *bool: + if v != nil { + write(*v) + } + + case *filter.TimeRange: + if v != nil { + writeRange(*v) + } + } +} + +func (e *ExtendedURLValues) ParseToMap() map[string]string { + m := make(map[string]string) + for k, v := range e.Values { + m[k] = v[0] + } + + return m +} + +func (e *ExtendedURLValues) Append(vv ...url.Values) { + for _, v := range vv { + for k, iv := range v { + e.Values[k] = append(e.Values[k], iv...) + } + } +} + +func NewExtendedURLValues() *ExtendedURLValues { + return &ExtendedURLValues{ + make(url.Values), + } +} diff --git a/internal/api/v1/user/querybuilders/extended_url_values_test.go b/internal/api/v1/user/querybuilders/extended_url_values_test.go new file mode 100644 index 00000000..d0ca3cf5 --- /dev/null +++ b/internal/api/v1/user/querybuilders/extended_url_values_test.go @@ -0,0 +1,78 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestExtendedURLValues_AddPair(t *testing.T) { + // given: + to := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + from := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + expectedValues := url.Values{ + "key1": []string{"str"}, + "key2": []string{"1"}, + "key3": []string{"str_ptr"}, + "key4": []string{"64"}, + "key5": []string{"32"}, + "key6": []string{"false"}, + "key7[from]": []string{from.Format(time.RFC3339)}, + "key7[to]": []string{to.Format(time.RFC3339)}, + } + + // when: + params := querybuilders.NewExtendedURLValues() + params.AddPair("key1", "str") + params.AddPair("key2", 1) + params.AddPair("key3", querybuilderstest.Ptr("str_ptr")) + params.AddPair("key4", querybuilderstest.Ptr(uint64(64))) + params.AddPair("key5", querybuilderstest.Ptr(uint32(32))) + params.AddPair("key6", querybuilderstest.Ptr(bool(false))) + params.AddPair("key7", &filter.TimeRange{ + From: &from, + To: &to, + }) + + // then: + require.EqualValues(t, expectedValues, params.Values) +} + +func TestExtendedURLValues_ParseToMap(t *testing.T) { + // given: + to := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + from := querybuilderstest.ParseTime(t, "2024-10-07T14:03:26.736816Z") + expectedValues := map[string]string{ + "key1": "str", + "key2": "1", + "key3": "str_ptr", + "key4": "64", + "key5": "32", + "key6": "false", + "key7[from]": from.Format(time.RFC3339), + "key7[to]": to.Format(time.RFC3339), + } + + params := querybuilders.NewExtendedURLValues() + params.AddPair("key1", "str") + params.AddPair("key2", 1) + params.AddPair("key3", querybuilderstest.Ptr("str_ptr")) + params.AddPair("key4", querybuilderstest.Ptr(uint64(64))) + params.AddPair("key5", querybuilderstest.Ptr(uint32(32))) + params.AddPair("key6", querybuilderstest.Ptr(bool(false))) + params.AddPair("key7", &filter.TimeRange{ + From: &from, + To: &to, + }) + + // when: + got := params.ParseToMap() + + // then: + require.EqualValues(t, expectedValues, got) +} diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder.go b/internal/api/v1/user/querybuilders/metadata_filter_builder.go new file mode 100644 index 00000000..50437fe6 --- /dev/null +++ b/internal/api/v1/user/querybuilders/metadata_filter_builder.go @@ -0,0 +1,107 @@ +package querybuilders + +import ( + "errors" + "fmt" + "net/url" + "reflect" +) + +type Metadata map[string]any + +const DefaultMaxDepth = 100 + +type metadataPath string + +func (m metadataPath) NestPath(key any) metadataPath { + return metadataPath(fmt.Sprintf("%s[%v]", m, key)) +} + +func (m metadataPath) AddToURL(urlValues url.Values, value any) { + urlValues.Add(string(m), fmt.Sprintf("%v", value)) +} + +func (m metadataPath) AddArrayToURL(urlValues url.Values, values []any) { + key := string(m) + "[]" + for _, value := range values { + urlValues.Add(key, fmt.Sprintf("%v", value)) + } +} + +func newMetadataPath(key string) metadataPath { + return metadataPath(fmt.Sprintf("metadata[%s]", key)) +} + +type MetadataFilterBuilder struct { + MaxDepth int + Metadata Metadata +} + +func (m *MetadataFilterBuilder) Build() (url.Values, error) { + params := make(url.Values) + for k, v := range m.Metadata { + path := newMetadataPath(k) + if err := m.generateQueryParams(0, path, v, params); err != nil { + return nil, err + } + } + + return params, nil +} + +func (m *MetadataFilterBuilder) generateQueryParams(depth int, path metadataPath, val any, params url.Values) error { + if depth > m.MaxDepth { + return fmt.Errorf("%w - max depth: %d", ErrMetadataFilterMaxDepthExceeded, m.MaxDepth) + } + + if val == nil { + return nil + } + + switch reflect.TypeOf(val).Kind() { + case reflect.Map: + return m.processMapQueryParams(depth+1, val, path, params) + case reflect.Slice: + return m.processSliceQueryParams(val, path, params) + default: + path.AddToURL(params, val) + return nil + } +} + +func (m *MetadataFilterBuilder) processMapQueryParams(depth int, val any, param metadataPath, params url.Values) error { + rval := reflect.ValueOf(val) + for _, k := range rval.MapKeys() { + nested := param.NestPath(k.Interface()) + if err := m.generateQueryParams(depth+1, nested, rval.MapIndex(k).Interface(), params); err != nil { + return err + } + } + + return nil +} + +func (m *MetadataFilterBuilder) processSliceQueryParams(val any, path metadataPath, params url.Values) error { + slice := reflect.ValueOf(val) + arr := make([]any, slice.Len()) + for i := 0; i < slice.Len(); i++ { + item := slice.Index(i) + + // safe check - only primitive types are allowed in arrays + // note: kind := item.Kind() is not enough, because it returns interface instead of actual underlying type + kind := reflect.TypeOf(item.Interface()).Kind() + if kind == reflect.Map || kind == reflect.Slice { + return ErrMetadataWrongTypeInArray + } + + arr[i] = item.Interface() + } + path.AddArrayToURL(params, arr) + + return nil +} + +var ( + ErrMetadataFilterMaxDepthExceeded = errors.New("maximum depth of nesting in metadata map exceeded") + ErrMetadataWrongTypeInArray = errors.New("wrong type in array") +) diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go b/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go new file mode 100644 index 00000000..6e2be661 --- /dev/null +++ b/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go @@ -0,0 +1,217 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/stretchr/testify/require" +) + +func TestMetadataFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + metadata querybuilders.Metadata + expectedParams url.Values + expectedErr error + depth int + }{ + "metadata: empty map": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: make(url.Values), + }, + "metadata: map entry [key]=nil": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: make(url.Values), + metadata: querybuilders.Metadata{ + "key": nil, + }, + }, + "metadata: map entries [key1]=value1, [key2]=nil": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1][]": []string{"value1"}, + }, + metadata: querybuilders.Metadata{ + "key1": []string{"value1"}, + "key2": nil, + }, + }, + "metadata: map entry [key]=value1": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key]": []string{"value1"}, + }, + metadata: querybuilders.Metadata{ + "key": "value1", + }, + }, + "metadata: map entries [key1]=value1, [key2]=1024": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2]": []string{"1024"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + "metadata: two keys nested in one": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1][key2]": []string{"value1"}, + "metadata[key1][key3]": []string{"1024"}, + }, + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": "value1", + "key3": 1024, + }, + }, + }, + "metadata: map entries [hey=123&522]=value1, [key2]=value=123": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[hey=123&522]": []string{"value1"}, + "metadata[key2]": []string{"value=123"}, + }, + metadata: querybuilders.Metadata{ + "hey=123&522": "value1", + "key2": "value=123", + }, + }, + "metadata: map entries [key1]=value1, [key2]=[]{value2,value3,value4}": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2][]": []string{"value2", "value3", "value4"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": []string{"value2", "value3", "value4"}, + }, + }, + "metadata: map entries [key1]=value1, [key2]=[]{value2, value3, value4}, [key3]=value5, [key4]=[]{value6,value7,value8}": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2][]": []string{"value2", "value3", "value4"}, + "metadata[key3]": []string{"value5"}, + "metadata[key4][]": []string{"value6", "value7", "value8"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": []string{"value2", "value3", "value4"}, + "key3": "value5", + "key4": []string{"value6", "value7", "value8"}, + }, + }, + "metadata: map entries [key1]=value1, [key2]=[value1,value2,value3,value4], [key3][key3_nested]=value5, [key4][key4_nested]=[6, 7]": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2][]": []string{"value2", "value3", "value4"}, + "metadata[key3][key3_nested]": []string{"value5"}, + "metadata[key4][key4_nested][]": []string{"6", "7"}, + }, + metadata: querybuilders.Metadata{ + "key1": "value1", + "key2": []string{"value2", "value3", "value4"}, + "key3": querybuilders.Metadata{ + "key3_nested": "value5", + }, + "key4": querybuilders.Metadata{ + "key4_nested": []int{6, 7}, + }, + }, + }, + "metadata: 11 map entries, complex nesting, max depth set to 100": { + depth: querybuilders.DefaultMaxDepth, + expectedParams: url.Values{ + "metadata[key1][key2][key3][key1]": []string{"abc"}, + "metadata[key1][key2][key3][key2][key1]": []string{"9"}, + "metadata[key1][key2][key3][key3][key1][key2][key1][]": []string{"1", "2", "3", "4"}, + "metadata[key1][key2][key3][key3][key1][key2][key2]": []string{"10"}, + "metadata[key1][key2][key3][key3][key1][key2][key3]": []string{"abc"}, + "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key1]": []string{"2"}, + "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key2]": []string{"cde"}, + "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key3][key1][]": []string{"5", "6", "7", "8"}, + "metadata[key1][key2][key3][key3][key1][key2][key4][key1][key1][key3][key2][]": []string{"a", "b", "c"}, + }, + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key3": querybuilders.Metadata{ + "key1": "abc", + "key2": querybuilders.Metadata{ + "key1": 9, + }, + "key3": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key1": []int{1, 2, 3, 4}, + "key2": 10, + "key3": "abc", + "key4": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key1": 2, + "key2": "cde", + "key3": querybuilders.Metadata{ + "key1": []int{5, 6, 7, 8}, + "key2": []string{"a", "b", "c"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "metadata: map entries depth exceeded - map entries: 4, max depth: 3": { + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": querybuilders.Metadata{ + "key3": querybuilders.Metadata{ + "key4": "value1", + }, + }, + }, + }, + depth: 3, + expectedErr: querybuilders.ErrMetadataFilterMaxDepthExceeded, + }, + "metadata: unsupported map in array": { + metadata: querybuilders.Metadata{ + "key1": querybuilders.Metadata{ + "key2": []any{ + querybuilders.Metadata{ + "key3": "value1", + }, + }, + }, + }, + depth: querybuilders.DefaultMaxDepth, + expectedErr: querybuilders.ErrMetadataWrongTypeInArray, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.MetadataFilterBuilder{ + MaxDepth: tc.depth, + Metadata: tc.metadata, + } + + // then: + got, err := builder.Build() + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/querybuilders/model_filter_builder.go b/internal/api/v1/user/querybuilders/model_filter_builder.go new file mode 100644 index 00000000..8ee09bbc --- /dev/null +++ b/internal/api/v1/user/querybuilders/model_filter_builder.go @@ -0,0 +1,20 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type ModelFilterBuilder struct { + ModelFilter filter.ModelFilter +} + +func (m *ModelFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("includeDeleted", m.ModelFilter.IncludeDeleted) + params.AddPair("createdRange", m.ModelFilter.CreatedRange) + params.AddPair("updatedRange", m.ModelFilter.UpdatedRange) + + return params.Values, nil +} diff --git a/internal/api/v1/user/querybuilders/model_filter_builder_test.go b/internal/api/v1/user/querybuilders/model_filter_builder_test.go new file mode 100644 index 00000000..a113b566 --- /dev/null +++ b/internal/api/v1/user/querybuilders/model_filter_builder_test.go @@ -0,0 +1,124 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestModelFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.ModelFilter + expectedParams url.Values + expectedErr error + }{ + "model filter: filter with only 'include deleted field set": { + expectedParams: url.Values{ + "includeDeleted": []string{"true"}, + }, + filter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + }, + }, + "model filter: filter with only created range 'from' field set": { + expectedParams: url.Values{ + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter wtth only created range 'to' field set": { + expectedParams: url.Values{ + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only created range both fields set": { + expectedParams: url.Values{ + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range 'from' field set": { + expectedParams: url.Values{ + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range 'to' field set": { + expectedParams: url.Values{ + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: filter with only updated range both fields set": { + expectedParams: url.Values{ + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "model filter: all fields set": { + expectedParams: url.Values{ + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.ModelFilterBuilder{ModelFilter: tc.filter} + + // then: + got, err := builder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/querybuilders/query_builder.go b/internal/api/v1/user/querybuilders/query_builder.go new file mode 100644 index 00000000..1c803d13 --- /dev/null +++ b/internal/api/v1/user/querybuilders/query_builder.go @@ -0,0 +1,79 @@ +package querybuilders + +import ( + "errors" + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type QueryBuilderOption func(*QueryBuilder) + +func WithQueryParamsFilter(q filter.QueryParams) QueryBuilderOption { + var zero filter.QueryParams + return func(qb *QueryBuilder) { + if q != zero { + qb.builders = append(qb.builders, &QueryParamsFilterBuilder{q}) + } + } +} + +func WithMetadataFilter(m Metadata) QueryBuilderOption { + return func(qb *QueryBuilder) { + if m != nil { + qb.builders = append(qb.builders, &MetadataFilterBuilder{MaxDepth: DefaultMaxDepth, Metadata: m}) + } + } +} + +func WithModelFilter(m filter.ModelFilter) QueryBuilderOption { + var zero filter.ModelFilter + return func(qb *QueryBuilder) { + if m != zero { + qb.builders = append(qb.builders, &ModelFilterBuilder{ModelFilter: m}) + } + } +} + +func WithFilterQueryBuilder(b FilterQueryBuilder) QueryBuilderOption { + return func(qb *QueryBuilder) { + if b != nil { + qb.builders = append(qb.builders, b) + } + } +} + +type FilterQueryBuilder interface { + Build() (url.Values, error) +} + +type QueryBuilder struct { + builders []FilterQueryBuilder +} + +func (q *QueryBuilder) Build() (*ExtendedURLValues, error) { + params := NewExtendedURLValues() + for _, builder := range q.builders { + values, err := builder.Build() + if err != nil { + return nil, errors.Join(err, ErrFilterQueryBuilder) + } + + if len(values) > 0 { + params.Append(values) + } + } + + return params, nil +} + +func NewQueryBuilder(opts ...QueryBuilderOption) *QueryBuilder { + var qb QueryBuilder + for _, o := range opts { + o(&qb) + } + + return &qb +} + +var ErrFilterQueryBuilder = errors.New("filter query builder - build operation failure") diff --git a/internal/api/v1/user/querybuilders/query_builder_test.go b/internal/api/v1/user/querybuilders/query_builder_test.go new file mode 100644 index 00000000..c3020633 --- /dev/null +++ b/internal/api/v1/user/querybuilders/query_builder_test.go @@ -0,0 +1,137 @@ +package querybuilders_test + +import ( + "errors" + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestQueryBuilder_Build(t *testing.T) { + type filters struct { + QueryParamsFilter filter.QueryParams + MetadataFilter querybuilders.Metadata + ModelFilter filter.ModelFilter + } + tests := map[string]struct { + filters filters + expectedParams url.Values + expectedErr error + builder querybuilders.FilterQueryBuilder + }{ + "query bilder: empty filters": { + filters: filters{}, + expectedParams: make(url.Values), + }, + "query builder: URL values with query params filter-only": { + filters: filters{ + QueryParamsFilter: filter.QueryParams{ + Page: 10, + PageSize: 20, + OrderByField: "id", + SortDirection: "asc", + }, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"id"}, + "sort": []string{"asc"}, + }, + }, + "query builder: URL values with metadata filter-only": { + expectedParams: url.Values{ + "metadata[key1]": []string{"value1"}, + "metadata[key2]": []string{"1024"}, + }, + filters: filters{ + MetadataFilter: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + }, + "query builder: URL values with all filters set": { + filters: filters{ + QueryParamsFilter: filter.QueryParams{ + Page: 10, + PageSize: 20, + OrderByField: "id", + SortDirection: "asc", + }, + ModelFilter: filter.ModelFilter{ + IncludeDeleted: querybuilderstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: querybuilderstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: querybuilderstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + MetadataFilter: querybuilders.Metadata{ + "key1": "value1", + "key2": 1024, + }, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"id"}, + "sort": []string{"asc"}, + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "metadata[key1]": []string{"value1"}, + "metadata[key2]": []string{"1024"}, + }, + }, + "query builder: injected dependency filter query builder failure": { + filters: filters{ + QueryParamsFilter: filter.QueryParams{ + Page: 10, + PageSize: 20, + OrderByField: "id", + SortDirection: "asc", + }, + }, + builder: &filterQueryBuilderFailureStub{}, + expectedErr: querybuilders.ErrFilterQueryBuilder, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + opts := []querybuilders.QueryBuilderOption{ + querybuilders.WithMetadataFilter(tc.filters.MetadataFilter), + querybuilders.WithQueryParamsFilter(tc.filters.QueryParamsFilter), + querybuilders.WithModelFilter(tc.filters.ModelFilter), + querybuilders.WithFilterQueryBuilder(tc.builder), + } + builder := querybuilders.NewQueryBuilder(opts...) + + // then: + got, err := builder.Build() + require.ErrorIs(t, err, tc.expectedErr) + + if got != nil { + require.Equal(t, tc.expectedParams, got.Values) + } + }) + } +} + +type filterQueryBuilderFailureStub struct{} + +func (f *filterQueryBuilderFailureStub) Build() (url.Values, error) { + return nil, errors.New("filter query builder failure stub - query build op failure") +} diff --git a/internal/api/v1/user/querybuilders/query_params_filter_builder.go b/internal/api/v1/user/querybuilders/query_params_filter_builder.go new file mode 100644 index 00000000..b4526e7e --- /dev/null +++ b/internal/api/v1/user/querybuilders/query_params_filter_builder.go @@ -0,0 +1,20 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type QueryParamsFilterBuilder struct { + QueryParamsFilter filter.QueryParams +} + +func (q *QueryParamsFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("page", q.QueryParamsFilter.Page) + params.AddPair("size", q.QueryParamsFilter.PageSize) + params.AddPair("sortBy", q.QueryParamsFilter.OrderByField) + params.AddPair("sort", q.QueryParamsFilter.SortDirection) + return params.Values, nil +} diff --git a/internal/api/v1/user/querybuilders/query_params_filter_builder_test.go b/internal/api/v1/user/querybuilders/query_params_filter_builder_test.go new file mode 100644 index 00000000..7279753a --- /dev/null +++ b/internal/api/v1/user/querybuilders/query_params_filter_builder_test.go @@ -0,0 +1,79 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestQueryParamsFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.QueryParams + expectedParams url.Values + expectedErr error + }{ + "query params: filter with only 'page' field set": { + filter: filter.QueryParams{ + Page: 10, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + }, + }, + "query params: filter with only 'page size' field set": { + filter: filter.QueryParams{ + PageSize: 20, + }, + expectedParams: url.Values{ + "size": []string{"20"}, + }, + }, + "query params: filter with only 'order by' field set": { + filter: filter.QueryParams{ + OrderByField: "value1", + }, + expectedParams: url.Values{ + "sortBy": []string{"value1"}, + }, + }, + "query params: filter with only 'sort by' field set": { + filter: filter.QueryParams{ + SortDirection: "asc", + }, + expectedParams: url.Values{ + "sort": []string{"asc"}, + }, + }, + "query params: all fields set": { + filter: filter.QueryParams{ + Page: 10, + PageSize: 20, + OrderByField: "value1", + SortDirection: "asc", + }, + expectedParams: url.Values{ + "page": []string{"10"}, + "size": []string{"20"}, + "sortBy": []string{"value1"}, + "sort": []string{"asc"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := querybuilders.QueryParamsFilterBuilder{ + QueryParamsFilter: tc.filter, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go b/internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go new file mode 100644 index 00000000..d7599b03 --- /dev/null +++ b/internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go @@ -0,0 +1,29 @@ +package querybuilderstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +func ParseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} + +func Ptr[T any](value T) *T { + return &value +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} diff --git a/internal/api/v1/user/transactions/transaction_filter_builder.go b/internal/api/v1/user/transactions/transaction_filter_builder.go new file mode 100644 index 00000000..13502800 --- /dev/null +++ b/internal/api/v1/user/transactions/transaction_filter_builder.go @@ -0,0 +1,38 @@ +package transactions + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type transactionFilterBuilder struct { + TransactionFilter filter.TransactionFilter + ModelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (t *transactionFilterBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := t.ModelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", t.TransactionFilter.Id) + params.AddPair("hex", t.TransactionFilter.Hex) + params.AddPair("blockHash", t.TransactionFilter.BlockHash) + params.AddPair("blockHeight", t.TransactionFilter.BlockHeight) + params.AddPair("fee", t.TransactionFilter.Fee) + params.AddPair("numberOfInputs", t.TransactionFilter.NumberOfInputs) + params.AddPair("numberOfOutputs", t.TransactionFilter.NumberOfOutputs) + params.AddPair("draftId", t.TransactionFilter.DraftID) + params.AddPair("totalValue", t.TransactionFilter.TotalValue) + params.AddPair("status", t.TransactionFilter.Status) + return params.Values, nil +} diff --git a/internal/api/v1/user/transactions/transaction_filter_builder_test.go b/internal/api/v1/user/transactions/transaction_filter_builder_test.go new file mode 100644 index 00000000..7ca129ca --- /dev/null +++ b/internal/api/v1/user/transactions/transaction_filter_builder_test.go @@ -0,0 +1,192 @@ +package transactions + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestTransactionFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.TransactionFilter + expectedParams url.Values + expectedErr error + }{ + "transaction filter: zero values": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr(""), + Hex: transactionstest.Ptr(""), + BlockHash: transactionstest.Ptr(""), + BlockHeight: transactionstest.Ptr(uint64(0)), + Fee: transactionstest.Ptr(uint64(0)), + NumberOfInputs: transactionstest.Ptr(uint32(0)), + NumberOfOutputs: transactionstest.Ptr(uint32(0)), + DraftID: transactionstest.Ptr(""), + TotalValue: transactionstest.Ptr(uint64(0)), + Status: transactionstest.Ptr(""), + }, + expectedParams: make(url.Values), + }, + "transaction filter: filter with only 'id' field set": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + }, + expectedParams: url.Values{ + "id": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + }, + }, + "transaction filter: filter with only 'hex' field set": { + filter: filter.TransactionFilter{ + Hex: transactionstest.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"), + }, + expectedParams: url.Values{ + "hex": []string{"001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"}, + }, + }, + "transaction filter: filter with only 'block hash' field set": { + filter: filter.TransactionFilter{ + BlockHash: transactionstest.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"), + }, + expectedParams: url.Values{ + "blockHash": []string{"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"}, + }, + }, + "transaction filter: filter with only 'block height' field set": { + filter: filter.TransactionFilter{ + BlockHeight: transactionstest.Ptr(uint64(839376)), + }, + expectedParams: url.Values{ + "blockHeight": []string{"839376"}, + }, + }, + "transaction filter: filter with only 'fee' field set": { + filter: filter.TransactionFilter{ + Fee: transactionstest.Ptr(uint64(1)), + }, + expectedParams: url.Values{ + "fee": []string{"1"}, + }, + }, + "transaction filter: filter with only 'number of inputs' field set": { + filter: filter.TransactionFilter{ + NumberOfInputs: transactionstest.Ptr(uint32(10)), + }, + expectedParams: url.Values{ + "numberOfInputs": []string{"10"}, + }, + }, + "transaction filter: filter with only 'number of outputs' field set": { + filter: filter.TransactionFilter{ + NumberOfOutputs: transactionstest.Ptr(uint32(20)), + }, + expectedParams: url.Values{ + "numberOfOutputs": []string{"20"}, + }, + }, + "transaction filter: filter with only 'draft id' field set": { + filter: filter.TransactionFilter{ + DraftID: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + }, + expectedParams: url.Values{ + "draftId": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + }, + }, + "transaction filter: filter with only 'total value' field set": { + filter: filter.TransactionFilter{ + TotalValue: transactionstest.Ptr(uint64(100000000)), + }, + expectedParams: url.Values{ + "totalValue": []string{"100000000"}, + }, + }, + "transaction filter: filter with only 'status' field set": { + filter: filter.TransactionFilter{ + Status: transactionstest.Ptr("RECEIVED"), + }, + expectedParams: url.Values{ + "status": []string{"RECEIVED"}, + }, + }, + "transaction filter: filter with only 'model filter' fields set": { + filter: filter.TransactionFilter{ + ModelFilter: filter.ModelFilter{ + IncludeDeleted: transactionstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: url.Values{ + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + }, + "transaction filter: all fields set": { + filter: filter.TransactionFilter{ + Id: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + Hex: transactionstest.Ptr("001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"), + BlockHash: transactionstest.Ptr("0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"), + BlockHeight: transactionstest.Ptr(uint64(839376)), + Fee: transactionstest.Ptr(uint64(1)), + NumberOfInputs: transactionstest.Ptr(uint32(10)), + NumberOfOutputs: transactionstest.Ptr(uint32(20)), + DraftID: transactionstest.Ptr("d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"), + TotalValue: transactionstest.Ptr(uint64(100000000)), + Status: transactionstest.Ptr("RECEIVED"), + ModelFilter: filter.ModelFilter{ + IncludeDeleted: transactionstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: transactionstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: transactionstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: url.Values{ + "id": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + "hex": []string{"001290b87619e679aaf6b8aadd30c778726c89fc4442110feb6d8265a190386beb8311a31e7e97a1c9ff2c84f3993283078965eb81f6fa64f3d7ba7fdd09678d"}, + "blockHash": []string{"0000000000000000031928c28075a82d7a00c2c90b489d1d66dc0afa3f8d26f8"}, + "blockHeight": []string{"839376"}, + "fee": []string{"1"}, + "numberOfInputs": []string{"10"}, + "numberOfOutputs": []string{"20"}, + "draftId": []string{"d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14"}, + "totalValue": []string{"100000000"}, + "status": []string{"RECEIVED"}, + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tfb := transactionFilterBuilder{ + TransactionFilter: tc.filter, + ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + got, err := tfb.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/transactions/transactions.go b/internal/api/v1/user/transactions/transactions.go new file mode 100644 index 00000000..50f00c60 --- /dev/null +++ b/internal/api/v1/user/transactions/transactions.go @@ -0,0 +1,122 @@ +package transactions + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/transactions" + +type API struct { + addr string + httpClient *resty.Client +} + +func (a *API) DraftTransaction(ctx context.Context, r *commands.DraftTransaction) (*response.DraftTransaction, error) { + var result response.DraftTransaction + + URL := a.addr + "/drafts" + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(r). + Post(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) RecordTransaction(ctx context.Context, r *commands.RecordTransaction) (*response.Transaction, error) { + var result response.Transaction + + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(r). + Post(a.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) UpdateTransactionMetadata(ctx context.Context, r *commands.UpdateTransactionMetadata) (*response.Transaction, error) { + var result response.Transaction + + URL := a.addr + "/" + r.ID + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(r). + Patch(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) Transaction(ctx context.Context, ID string) (*response.Transaction, error) { + var result response.Transaction + + URL := a.addr + "/" + ID + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + Get(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.TransctionsQueryOption) ([]*response.Transaction, error) { + var query queries.TransactionsQuery + for _, o := range transactionsOpts { + o(&query) + } + + builderOpts := []querybuilders.QueryBuilderOption{ + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithQueryParamsFilter(query.QueryParams), + querybuilders.WithFilterQueryBuilder(&transactionFilterBuilder{ + TransactionFilter: query.Filter, + ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.Filter.ModelFilter}, + }), + } + builder := querybuilders.NewQueryBuilder(builderOpts...) + params, err := builder.Build() + if err != nil { + return nil, fmt.Errorf("failed to create transactions query params: %w", err) + } + + var result response.PageModel[response.Transaction] + _, err = a.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(a.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return result.Content, nil +} + +func NewAPI(addr string, cli *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + httpClient: cli, + } +} diff --git a/internal/api/v1/user/transactions/transactions_test.go b/internal/api/v1/user/transactions/transactions_test.go new file mode 100644 index 00000000..5974f60a --- /dev/null +++ b/internal/api/v1/user/transactions/transactions_test.go @@ -0,0 +1,245 @@ +package transactions_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { + ID := "1024" + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *response.Transaction + expectedErr error + }{ + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 200", ID): { + expectedResponse: transactionstest.ExpectedTransactionWithUpdatedMetadata(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_update_metadata_200.json")), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPatch, URL, tc.responder) + + // then: + got, err := wallet.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ + ID: ID, + Metadata: querybuilders.Metadata{ + "example_key1": "example_key10_val", + "example_key2": "example_key20_val", + }, + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestTransactionsAPI_RecordTransaction(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *response.Transaction + expectedErr error + }{ + "HTTP POST /api/v1/transactions response: 201": { + statusCode: http.StatusCreated, + expectedResponse: transactionstest.ExpectedRecordTransaction(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_record_201.json")), + }, + "HTTP GET /api/v1/transactions response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/transactions str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/transactions" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + got, err := wallet.RecordTransaction(context.Background(), &commands.RecordTransaction{}) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestTransactionsAPI_DraftTransaction(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *response.DraftTransaction + expectedErr error + }{ + "HTTP POST /api/v1/transactions/drafts response: 200": { + statusCode: http.StatusOK, + expectedResponse: transactionstest.ExpectedDraftTransaction(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_draft_200.json")), + }, + "HTTP POST /api/v1/transactions/drafts response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/transactions/drafts str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/transactions/drafts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + got, err := wallet.DraftTransaction(context.Background(), &commands.DraftTransaction{ + Config: response.TransactionConfig{}, + Metadata: map[string]any{}, + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestTransactionsAPI_Transaction(t *testing.T) { + ID := "1024" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *response.Transaction + expectedErr error + }{ + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 200", ID): { + statusCode: http.StatusOK, + expectedResponse: transactionstest.ExpectedTransaction(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_200.json")), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.Transaction(context.Background(), ID) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestTransactionsAPI_Transactions(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse []*response.Transaction + expectedErr error + }{ + "HTTP GET /api/v1/transactions response: 200": { + statusCode: http.StatusOK, + expectedResponse: transactionstest.ExpectedTransactions(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transactions_200.json")), + }, + "HTTP GET /api/v1/transactions response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/transactions str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/transactions" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.Transactions(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_200.json new file mode 100644 index 00000000..0e5f10a7 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transaction_200.json @@ -0,0 +1,33 @@ +{ + "createdAt": "2024-10-07T14:03:26.736816Z", + "updatedAt": "2024-10-07T14:03:26.736816Z", + "deletedAt": null, + "metadata": { + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "ip_address": "127.0.0.01", + "p2p_tx_metadata": { + "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd", + "sender": "john.doe@handcash.io" + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "user_agent": "node-fetch" + }, + "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e", + "hex": "283b1c6deb6d6263b3cec7a4701d46d3", + "xpubInIds": null, + "xpubOutIds": [ + "4c9a0a0d-ea4f-4f03-b740-84438b3d210d" + ], + "blockHash": "47758f612c6bf5b454bcd642fe8031f6", + "blockHeight": 512, + "fee": 1, + "numberOfInputs": 2, + "numberOfOutputs": 3, + "draftId": "", + "totalValue": 311, + "outputValue": 100, + "status": "MINED", + "direction": "incoming" +} diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_draft_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_draft_200.json new file mode 100644 index 00000000..4ceae768 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transaction_draft_200.json @@ -0,0 +1,127 @@ +{ + "createdAt": "2024-11-05T07:30:14.219077Z", + "updatedAt": "2024-11-05T07:30:14.219077Z", + "deletedAt": null, + "metadata": { + "receiver": "john.doe.test4@john.doe.test.4chain.space", + "sender": "john.doe.test4@john.doe.test.4chain.space" + }, + "id": "36be741b-31c7-4aed-8840-5e5b2eafeb41", + "hex": "c959fdb6-f438-4ef9-aef9-92a1852885ef", + "xpubId": "3f0a90d3-4f8b-45f6-81e4-9858fa47ecc0", + "expiresAt": "2024-11-05T07:30:27.372912Z", + "configuration": { + "changeDestinations": [ + { + "createdAt": "2024-11-05T07:30:14.219077Z", + "updatedAt": "2024-11-05T07:30:14.219077Z", + "deletedAt": null, + "metadata": null, + "id": "c86dd8f4-316f-4d71-be00-7bd1a38079e4", + "xpubId": "d6884260-1624-415b-8625-652a59345ead", + "lockingScript": "189593db-0048-4fb7-80da-b69bce8fbf78", + "type": "pubkeyhash", + "chain": 1, + "num": 5, + "paymailExternalDerivationNum": null, + "address": "3f96ea59-ac83-476e-a0ea-f0d668086081", + "draftId": "fc60742e-92b5-4a98-90a7-422d89879494" + } + ], + "changeDestinationsStrategy": "", + "changeMinimumSatoshis": 0, + "changeNumberOfDestinations": 0, + "changeSatoshis": 98, + "expiresIn": 0, + "fee": 0, + "feeUnit": { + "satoshis": 1, + "bytes": 1000 + }, + "fromUtxos": null, + "includeUtxos": null, + "inputs": [ + { + "createdAt": "2024-11-05T07:30:14.219077Z", + "updatedAt": "2024-11-05T07:30:14.219077Z", + "deletedAt": null, + "metadata": null, + "transactionId": "3e0c5f6d-0dfc-462d-8a63-31b7a20d0c6b", + "outputIndex": 0, + "id": "203277ff-006a-4e48-bbe9-2f1b6fb9ddfd", + "xpubId": "4676a7d6-45f8-46b3-850b-68a9bb7642bc", + "satoshis": 100, + "scriptPubKey": "9d7eede4-00cd-47fd-ab3d-b0ae6d2ca6a6", + "type": "pubkeyhash", + "draftId": "f1ebe294-d921-4fb7-8b22-ed33e090e7ea", + "reservedAt": "2024-11-05T07:30:14.207287Z", + "spendingTxId": "", + "transaction": null, + "destination": { + "createdAt": "2024-11-05T07:30:14.219077Z", + "updatedAt": "2024-11-05T07:30:14.219077Z", + "deletedAt": null, + "metadata": { + "domain": "john.doe.test.4chain.space", + "ip_address": "127.0.0.1", + "paymail_request": "CreateP2PDestinationResponse", + "reference_id": "1a461311db24115cd5e0525f8c9b5613", + "satoshis": 100, + "user_agent": "node-fetch" + }, + "id": "bc22a0b9-d91c-4d0b-a7e4-8ea2d37e42db", + "xpubId": "325b1440-3af4-4a65-bf90-d88ed978948b", + "lockingScript": "e459d941-d820-4663-a5d8-6a12457825e9", + "type": "pubkeyhash", + "chain": 0, + "num": 0, + "paymailExternalDerivationNum": 3, + "address": "6e4f50b1-356b-4453-a83e-2f412f328c25", + "draftId": "" + } + } + ], + "outputs": [ + { + "paymailP4": { + "alias": "john.doe.test4", + "domain": "john.doe.test.4chain.space", + "fromPaymail": "from@domain.com", + "receiveEndpoint": "https://john.doe.test.4chain.space:443/v1/bsvalias/beef/{alias}@{domain.tld}", + "referenceId": "bdac6a12ec7f31feb5ae426e28c9ddfa", + "resolutionType": "p2p" + }, + "satoshis": 1, + "script": "", + "scripts": [ + { + "address": "18p1xtQQeaVVpsxrSiRUhUKMyR5jPEvAhY", + "satoshis": 1, + "script": "45a858f8-c645-48c3-bff0-f776d8d8452d", + "scriptType": "pubkeyhash" + } + ], + "to": "john.doe.test4@john.doe.test.4chain.space", + "useForChange": false + }, + { + "satoshis": 98, + "script": "", + "scripts": [ + { + "address": "19a5857d-3eb9-43f8-b240-c29c05909fdc", + "satoshis": 98, + "script": "cca457ab-2277-457b-bf53-17face515f5c", + "scriptType": "pubkeyhash" + } + ], + "to": "b1e97d9c-e1e5-4120-b0f1-0363693b1959", + "useForChange": false + } + ], + "sendAllTo": null, + "sync": null + }, + "status": "", + "finalTxId": "" +} diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_record_201.json b/internal/api/v1/user/transactions/transactionstest/transaction_record_201.json new file mode 100644 index 00000000..f4c903d4 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transaction_record_201.json @@ -0,0 +1,30 @@ +{ + "blockHash": "47758f612c6bf5b454bcd642fe8031f6", + "blockHeight": 1024, + "createdAt": "2024-10-07T14:03:26.736816Z", + "direction": "outgoing", + "draftId": "d3fb66d6-6e3b-4a1f-aa80-dda848079663", + "fee": 1, + "hex": "fda8f356-615e-4b4c-a3c8-53a47531a446", + "id": "fdad0324-1185-4a54-8eae-f0c8858fa3ce", + "metadata": { + "key": "value", + "key2": "value2" + }, + "numberOfInputs": 3, + "numberOfOutputs": 2, + "outputValue": 50, + "outputs": { + "92640954841510a9d95f7737a43075f22ebf7255976549de4c52e8f3faf57470": -51, + "9d07977d2fc14402426288a6010b4cdf7d91b61461acfb75af050b209d2d07ba": 50 + }, + "status": "MINED", + "totalValue": 51, + "updatedAt": "2024-10-07T14:03:26.736816Z", + "xpubInIds": [ + "e2be970c-a867-4e65-b141-7f2aafd44a42" + ], + "xpubOutIds": [ + "475e5e90-a117-46b6-b9e5-6983f2721b19" + ] +} diff --git a/internal/api/v1/user/transactions/transactionstest/transaction_update_metadata_200.json b/internal/api/v1/user/transactions/transactionstest/transaction_update_metadata_200.json new file mode 100644 index 00000000..c5265c05 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transaction_update_metadata_200.json @@ -0,0 +1,34 @@ +{ + "createdAt": "2024-10-07T14:03:26.736816Z", + "updatedAt": "2024-10-07T14:03:26.736816Z", + "deletedAt": null, + "metadata": { + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "example_key2": "example_key20_val", + "ip_address": "127.0.0.01", + "p2p_tx_metadata": { + "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd", + "sender": "john.doe@handcash.io" + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "user_agent": "node-fetch" + }, + "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e", + "hex": "283b1c6deb6d6263b3cec7a4701d46d3", + "xpubInIds": null, + "xpubOutIds": [ + "4c9a0a0d-ea4f-4f03-b740-84438b3d210d" + ], + "blockHash": "47758f612c6bf5b454bcd642fe8031f6", + "blockHeight": 512, + "fee": 1, + "numberOfInputs": 2, + "numberOfOutputs": 3, + "draftId": "", + "totalValue": 311, + "outputValue": 100, + "status": "MINED", + "direction": "incoming" +} diff --git a/internal/api/v1/user/transactions/transactionstest/transactions_200.json b/internal/api/v1/user/transactions/transactionstest/transactions_200.json new file mode 100644 index 00000000..ebf92671 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transactions_200.json @@ -0,0 +1,76 @@ +{ + "content": [ + { + "createdAt": "2024-10-07T14:03:26.736816Z", + "updatedAt": "2024-10-07T14:03:26.736816Z", + "deletedAt": null, + "metadata": { + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "ip_address": "127.0.0.01", + "p2p_tx_metadata": { + "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065", + "sender": "john.doe@handcash.io" + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "user_agent": "node-fetch" + }, + "id": "2c250e21-c33a-41e3-a4e3-77c68b03244e", + "hex": "283b1c6deb6d6263b3cec7a4701d46d3", + "xpubInIds": null, + "xpubOutIds": [ + "4c9a0a0d-ea4f-4f03-b740-84438b3d210d" + ], + "blockHash": "47758f612c6bf5b454bcd642fe8031f6", + "blockHeight": 512, + "fee": 1, + "numberOfInputs": 2, + "numberOfOutputs": 3, + "draftId": "", + "totalValue": 311, + "outputValue": 100, + "status": "MINED", + "direction": "incoming" + }, + { + "createdAt": "2024-10-07T14:03:26.736816Z", + "updatedAt": "2024-10-07T14:03:26.736816Z", + "deletedAt": null, + "metadata": { + "domain": "jane.doe.test.4chain.space", + "example_key101": "example_key101_val", + "ip_address": "127.0.0.01", + "p2p_tx_metadata": { + "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa", + "sender": "jane.doe@handcash.io" + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50", + "user_agent": "node-fetch" + }, + "id": "1c110e11-c23a-51e5-a7e7-99c12b01233e", + "hex": "283b1c7deb7d7773b3cec7a8801d47d2", + "xpubInIds": null, + "xpubOutIds": [ + "2c8a1a1d-ea5f-5f04-b890-92418b2d411d" + ], + "blockHash": "56659f622c6bf5b554bcd742fe8132f9", + "blockHeight": 1024, + "fee": 1, + "numberOfInputs": 2, + "numberOfOutputs": 3, + "draftId": "", + "totalValue": 500, + "outputValue": 200, + "status": "MINED", + "direction": "incoming" + } + ], + "page": { + "number": 2, + "size": 2, + "totalElements": 2, + "totalPages": 1 + } +} diff --git a/internal/api/v1/user/transactions/transactionstest/transactionstest.go b/internal/api/v1/user/transactions/transactionstest/transactionstest.go new file mode 100644 index 00000000..4be9dbc7 --- /dev/null +++ b/internal/api/v1/user/transactions/transactionstest/transactionstest.go @@ -0,0 +1,312 @@ +package transactionstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedDraftTransaction(t *testing.T) *response.DraftTransaction { + return &response.DraftTransaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + Metadata: map[string]interface{}{ + "receiver": "john.doe.test4@john.doe.test.4chain.space", + "sender": "john.doe.test4@john.doe.test.4chain.space", + }, + }, + ID: "36be741b-31c7-4aed-8840-5e5b2eafeb41", + Hex: "c959fdb6-f438-4ef9-aef9-92a1852885ef", + XpubID: "3f0a90d3-4f8b-45f6-81e4-9858fa47ecc0", + ExpiresAt: ParseTime(t, "2024-11-05T07:30:27.372912Z"), + Configuration: response.TransactionConfig{ + ChangeSatoshis: 98, + ChangeDestinations: []*response.Destination{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + }, + ID: "c86dd8f4-316f-4d71-be00-7bd1a38079e4", + XpubID: "d6884260-1624-415b-8625-652a59345ead", + LockingScript: "189593db-0048-4fb7-80da-b69bce8fbf78", + Type: "pubkeyhash", + Chain: 1, + Num: 5, + Address: "3f96ea59-ac83-476e-a0ea-f0d668086081", + DraftID: "fc60742e-92b5-4a98-90a7-422d89879494", + }, + }, + FeeUnit: &response.FeeUnit{ + Satoshis: 1, + Bytes: 1000, + }, + Inputs: []*response.TransactionInput{ + { + Utxo: response.Utxo{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + }, + UtxoPointer: response.UtxoPointer{ + TransactionID: "3e0c5f6d-0dfc-462d-8a63-31b7a20d0c6b", + }, + ID: "203277ff-006a-4e48-bbe9-2f1b6fb9ddfd", + XpubID: "4676a7d6-45f8-46b3-850b-68a9bb7642bc", + Satoshis: 100, + ScriptPubKey: "9d7eede4-00cd-47fd-ab3d-b0ae6d2ca6a6", + Type: "pubkeyhash", + DraftID: "f1ebe294-d921-4fb7-8b22-ed33e090e7ea", + ReservedAt: ParseTime(t, "2024-11-05T07:30:14.207287Z"), + }, + Destination: response.Destination{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + UpdatedAt: ParseTime(t, "2024-11-05T07:30:14.219077Z"), + Metadata: map[string]interface{}{ + "domain": "john.doe.test.4chain.space", + "ip_address": "127.0.0.1", + "paymail_request": "CreateP2PDestinationResponse", + "reference_id": "1a461311db24115cd5e0525f8c9b5613", + "satoshis": float64(100), + "user_agent": "node-fetch", + }, + }, + ID: "bc22a0b9-d91c-4d0b-a7e4-8ea2d37e42db", + XpubID: "325b1440-3af4-4a65-bf90-d88ed978948b", + LockingScript: "e459d941-d820-4663-a5d8-6a12457825e9", + Type: "pubkeyhash", + Chain: 0, + Num: 0, + PaymailExternalDerivationNum: Ptr(uint32(3)), + Address: "6e4f50b1-356b-4453-a83e-2f412f328c25", + DraftID: "", + }, + }, + }, + Outputs: []*response.TransactionOutput{ + { + PaymailP4: &response.PaymailP4{ + Alias: "john.doe.test4", + Domain: "john.doe.test.4chain.space", + FromPaymail: "from@domain.com", + ReceiveEndpoint: "https://john.doe.test.4chain.space:443/v1/bsvalias/beef/{alias}@{domain.tld}", + ReferenceID: "bdac6a12ec7f31feb5ae426e28c9ddfa", + ResolutionType: "p2p", + }, + Satoshis: 1, + Scripts: []*response.ScriptOutput{ + { + Address: "18p1xtQQeaVVpsxrSiRUhUKMyR5jPEvAhY", + Satoshis: 1, + Script: "45a858f8-c645-48c3-bff0-f776d8d8452d", + ScriptType: "pubkeyhash", + }, + }, + To: "john.doe.test4@john.doe.test.4chain.space", + UseForChange: false, + }, + { + Satoshis: 98, + Scripts: []*response.ScriptOutput{ + { + Address: "19a5857d-3eb9-43f8-b240-c29c05909fdc", + Satoshis: 98, + Script: "cca457ab-2277-457b-bf53-17face515f5c", + ScriptType: "pubkeyhash", + }, + }, + To: "b1e97d9c-e1e5-4120-b0f1-0363693b1959", + UseForChange: false, + }, + }, + }, + } +} + +func ExpectedRecordTransaction(t *testing.T) *response.Transaction { + return &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + }, + ID: "fdad0324-1185-4a54-8eae-f0c8858fa3ce", + Hex: "fda8f356-615e-4b4c-a3c8-53a47531a446", + XpubInIDs: []string{"e2be970c-a867-4e65-b141-7f2aafd44a42"}, + XpubOutIDs: []string{"475e5e90-a117-46b6-b9e5-6983f2721b19"}, + BlockHash: "47758f612c6bf5b454bcd642fe8031f6", + BlockHeight: 1024, + Fee: 1, + NumberOfInputs: 3, + NumberOfOutputs: 2, + DraftID: "d3fb66d6-6e3b-4a1f-aa80-dda848079663", + TotalValue: 51, + OutputValue: 50, + Outputs: map[string]int64{ + "92640954841510a9d95f7737a43075f22ebf7255976549de4c52e8f3faf57470": -51, + "9d07977d2fc14402426288a6010b4cdf7d91b61461acfb75af050b209d2d07ba": 50, + }, + Status: "MINED", + TransactionDirection: "outgoing", + } +} + +func ExpectedTransactionWithUpdatedMetadata(t *testing.T) *response.Transaction { + return &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "example_key2": "example_key20_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "p2p_tx_metadata": map[string]any{ + "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd", + "sender": "john.doe@handcash.io", + }, + }, + }, + ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e", + Hex: "283b1c6deb6d6263b3cec7a4701d46d3", + XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"}, + BlockHash: "47758f612c6bf5b454bcd642fe8031f6", + BlockHeight: 512, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 311, + OutputValue: 100, + Status: "MINED", + TransactionDirection: "incoming", + } +} + +func ExpectedTransaction(t *testing.T) *response.Transaction { + return &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "p2p_tx_metadata": map[string]any{ + "pubkey": "3fa7af5b-4568-4873-86da-0aa442ca91dd", + "sender": "john.doe@handcash.io", + }, + }, + }, + ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e", + Hex: "283b1c6deb6d6263b3cec7a4701d46d3", + XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"}, + BlockHash: "47758f612c6bf5b454bcd642fe8031f6", + BlockHeight: 512, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 311, + OutputValue: 100, + Status: "MINED", + TransactionDirection: "incoming", + } +} + +func ExpectedTransactions(t *testing.T) []*response.Transaction { + return []*response.Transaction{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "p2p_tx_metadata": map[string]any{ + "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065", + "sender": "john.doe@handcash.io", + }, + }, + }, + ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e", + Hex: "283b1c6deb6d6263b3cec7a4701d46d3", + XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"}, + BlockHash: "47758f612c6bf5b454bcd642fe8031f6", + BlockHeight: 512, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 311, + OutputValue: 100, + Status: "MINED", + TransactionDirection: "incoming", + }, + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "jane.doe.test.4chain.space", + "example_key101": "example_key101_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50", + "p2p_tx_metadata": map[string]any{ + "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa", + "sender": "jane.doe@handcash.io", + }, + }, + }, + ID: "1c110e11-c23a-51e5-a7e7-99c12b01233e", + Hex: "283b1c7deb7d7773b3cec7a8801d47d2", + XpubOutIDs: []string{"2c8a1a1d-ea5f-5f04-b890-92418b2d411d"}, + BlockHash: "56659f622c6bf5b554bcd742fe8132f9", + BlockHeight: 1024, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 500, + OutputValue: 200, + Status: "MINED", + TransactionDirection: "incoming", + }, + } +} + +func ParseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} + +func Ptr[T any](value T) *T { + return &value +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} diff --git a/internal/auth/authenticators_test.go b/internal/auth/authenticators_test.go index d4714dbd..ccfae3d7 100644 --- a/internal/auth/authenticators_test.go +++ b/internal/auth/authenticators_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/testfixtures" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/go-resty/resty/v2" "github.com/stretchr/testify/require" ) @@ -30,7 +30,7 @@ func TestAccessKeyAuthenitcator_NewWithNilAccessKey(t *testing.T) { func TestAccessKeyAuthenticator_Authenticate(t *testing.T) { // given: - key := testfixtures.PrivateKey(t) + key := clienttest.PrivateKey(t) authenticator, err := auth.NewAccessKeyAuthenticator(key) require.NotNil(t, authenticator) require.NoError(t, err) @@ -57,7 +57,7 @@ func TestXprivAuthenitcator_NewWithNilXpriv(t *testing.T) { func TestXprivAuthenitcator_Authenticate(t *testing.T) { // given: - key := testfixtures.ExtendedKey(t) + key := clienttest.ExtendedKey(t) authenticator, err := auth.NewXprivAuthenticator(key) require.NotNil(t, authenticator) require.NoError(t, err) @@ -84,7 +84,7 @@ func TestXpubOnlyAuthenticator_NewWithNilXpub(t *testing.T) { func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { // given: - key := testfixtures.ExtendedKey(t) + key := clienttest.ExtendedKey(t) authenticator, err := auth.NewXpubOnlyAuthenticator(key) require.NotNil(t, authenticator) @@ -101,11 +101,11 @@ func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { } func requireXAuthHeaderToBeSet(t *testing.T, h http.Header) { - require.Equal(t, []string{testfixtures.UserPubAccessKey}, h[xAuthKey]) + require.Equal(t, []string{clienttest.UserPubAccessKey}, h[xAuthKey]) } func requireXpubHeaderToBeSet(t *testing.T, h http.Header) { - require.Equal(t, []string{testfixtures.UserXPub}, h[xAuthXPubKey]) + require.Equal(t, []string{clienttest.UserXPub}, h[xAuthXPubKey]) } func requireSignatureHeadersToBeSet(t *testing.T, h http.Header) { diff --git a/internal/testfixtures/testfixtures.go b/internal/clienttest/clienttest.go similarity index 98% rename from internal/testfixtures/testfixtures.go rename to internal/clienttest/clienttest.go index 7e196d6b..cfe378bb 100644 --- a/internal/testfixtures/testfixtures.go +++ b/internal/clienttest/clienttest.go @@ -1,4 +1,4 @@ -package testfixtures +package clienttest import ( "testing" diff --git a/queries/transactions.go b/queries/transactions.go new file mode 100644 index 00000000..cb95c769 --- /dev/null +++ b/queries/transactions.go @@ -0,0 +1,41 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +// TransactionsQuery aggregates query parameter filters describing the key-value pairs +// that should be appended to the transaction URL being constructed +type TransactionsQuery struct { + Metadata map[string]any + Filter filter.TransactionFilter + QueryParams filter.QueryParams +} + +// TransctionsQueryOption represents a functional option for creating a customized list +// of transaction query parameters. +type TransctionsQueryOption func(*TransactionsQuery) + +// TransactionsQueryWithMetadataFilter applies specific metadata attributes as filters +// to the transactions endpoint URL being constructed. +func TransactionsQueryWithMetadataFilter(m map[string]any) TransctionsQueryOption { + return func(tq *TransactionsQuery) { + tq.Metadata = m + } +} + +// TransactionsQueryWithFilter applies general query parameters like BlockHeight, BlockHash, +// transaction status, etc. to the transactions endpoint URL being constructed. +func TransactionsQueryWithFilter(tf filter.TransactionFilter) TransctionsQueryOption { + return func(tq *TransactionsQuery) { + tq.Filter = tf + } +} + +// TransactionsQueryWithQueryParamsFilter applies general query parameters like pagination and sort order etc. +// to the transactions endpoint URL being constructed. +func TransactionsQueryWithQueryParamsFilter(q filter.QueryParams) TransctionsQueryOption { + return func(tq *TransactionsQuery) { + tq.QueryParams = q + } +} From 6e5fd7bbac6d6f3e41e98e4642958927e3f4d424 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Thu, 14 Nov 2024 10:17:30 +0100 Subject: [PATCH 06/18] refactor(SPV-1192): add page filter query builder implementation. (#14) --- .../user/querybuilders/page_filter_builder.go | 21 +++++ .../querybuilders/page_filter_builder_test.go | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 internal/api/v1/user/querybuilders/page_filter_builder.go create mode 100644 internal/api/v1/user/querybuilders/page_filter_builder_test.go diff --git a/internal/api/v1/user/querybuilders/page_filter_builder.go b/internal/api/v1/user/querybuilders/page_filter_builder.go new file mode 100644 index 00000000..72d8aced --- /dev/null +++ b/internal/api/v1/user/querybuilders/page_filter_builder.go @@ -0,0 +1,21 @@ +package querybuilders + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type PageFilterBuilder struct { + Page filter.Page +} + +func (p *PageFilterBuilder) Build() (url.Values, error) { + params := NewExtendedURLValues() + params.AddPair("page", p.Page.Number) + params.AddPair("size", p.Page.Size) + params.AddPair("sort", p.Page.Sort) + params.AddPair("sortBy", p.Page.SortBy) + + return params.Values, nil +} diff --git a/internal/api/v1/user/querybuilders/page_filter_builder_test.go b/internal/api/v1/user/querybuilders/page_filter_builder_test.go new file mode 100644 index 00000000..1e5a73b4 --- /dev/null +++ b/internal/api/v1/user/querybuilders/page_filter_builder_test.go @@ -0,0 +1,79 @@ +package querybuilders_test + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestPageFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.Page + expectedParams url.Values + expectedErr error + }{ + "page filter: filter with only 'number' set": { + filter: filter.Page{ + Number: 10, + }, + expectedParams: url.Values{ + "page": []string{"10"}, + }, + }, + "page filter: filter with only 'size' set": { + filter: filter.Page{ + Size: 20, + }, + expectedParams: url.Values{ + "size": []string{"20"}, + }, + }, + "page filter: filter with only 'sort' set": { + filter: filter.Page{ + Sort: "asc", + }, + expectedParams: url.Values{ + "sort": []string{"asc"}, + }, + }, + "page filter: filter with only 'sortBy' set": { + filter: filter.Page{ + SortBy: "key", + }, + expectedParams: url.Values{ + "sortBy": []string{"key"}, + }, + }, + "page filter: all fields set": { + filter: filter.Page{ + Number: 10, + Size: 20, + Sort: "asc", + SortBy: "key", + }, + expectedParams: url.Values{ + "sortBy": []string{"key"}, + "sort": []string{"asc"}, + "size": []string{"20"}, + "page": []string{"10"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + builder := querybuilders.PageFilterBuilder{ + Page: tc.filter, + } + + // then: + got, err := builder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} From 6efd9e65cf864158472ee3b70da2551e1db5e682 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Thu, 14 Nov 2024 13:20:17 +0100 Subject: [PATCH 07/18] refactor(SPV-1192): replace query params by page filter. (#15) --- client.go | 15 ++- .../v1/user/querybuilders/query_builder.go | 18 +-- .../user/querybuilders/query_builder_test.go | 40 +++--- .../api/v1/user/transactions/transactions.go | 6 +- .../v1/user/transactions/transactions_test.go | 4 +- .../transactionstest/transactionstest.go | 118 ++++++++++-------- queries/transactions.go | 41 +++--- 7 files changed, 129 insertions(+), 113 deletions(-) diff --git a/client.go b/client.go index 7145c887..47f5f7c8 100644 --- a/client.go +++ b/client.go @@ -151,14 +151,17 @@ func (c *Client) UpdateTransactionMetadata(ctx context.Context, cmd *commands.Up return res, nil } -// Transactions retrieves a list of transactions using the user transactions API. -// This method applies optional query parameters and expects a response that can be -// unmarshaled into a slice of response.Transaction pointers. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) Transactions(ctx context.Context, opts ...queries.TransctionsQueryOption) ([]*response.Transaction, error) { +// Transactions retrieves a paginated list of transactions from the user transactions API. +// The returned response includes transactions and pagination details, such as the page number, +// sort order, and sorting field (sortBy). +// +// This method allows optional query parameters to be applied via the provided query options. +// The response is expected to unmarshal into a *response.PageModel[response.Transaction] struct. +// If the API request fails or the response cannot be decoded successfully, an error is returned. +func (c *Client) Transactions(ctx context.Context, opts ...queries.TransactionsQueryOption) (*queries.TransactionPage, error) { res, err := c.transactionsAPI.Transactions(ctx, opts...) if err != nil { - return nil, fmt.Errorf("failed to retrieve transactions from the user transactions API: %w", err) + return nil, fmt.Errorf("failed to retrieve transactions page from the user transactions API: %w", err) } return res, nil diff --git a/internal/api/v1/user/querybuilders/query_builder.go b/internal/api/v1/user/querybuilders/query_builder.go index 1c803d13..338cf237 100644 --- a/internal/api/v1/user/querybuilders/query_builder.go +++ b/internal/api/v1/user/querybuilders/query_builder.go @@ -9,15 +9,6 @@ import ( type QueryBuilderOption func(*QueryBuilder) -func WithQueryParamsFilter(q filter.QueryParams) QueryBuilderOption { - var zero filter.QueryParams - return func(qb *QueryBuilder) { - if q != zero { - qb.builders = append(qb.builders, &QueryParamsFilterBuilder{q}) - } - } -} - func WithMetadataFilter(m Metadata) QueryBuilderOption { return func(qb *QueryBuilder) { if m != nil { @@ -35,6 +26,15 @@ func WithModelFilter(m filter.ModelFilter) QueryBuilderOption { } } +func WithPageFilterQueryBuilder(p filter.Page) QueryBuilderOption { + var zero filter.Page + return func(qb *QueryBuilder) { + if p != zero { + qb.builders = append(qb.builders, &PageFilterBuilder{Page: p}) + } + } +} + func WithFilterQueryBuilder(b FilterQueryBuilder) QueryBuilderOption { return func(qb *QueryBuilder) { if b != nil { diff --git a/internal/api/v1/user/querybuilders/query_builder_test.go b/internal/api/v1/user/querybuilders/query_builder_test.go index c3020633..6d39aedf 100644 --- a/internal/api/v1/user/querybuilders/query_builder_test.go +++ b/internal/api/v1/user/querybuilders/query_builder_test.go @@ -14,9 +14,9 @@ import ( func TestQueryBuilder_Build(t *testing.T) { type filters struct { - QueryParamsFilter filter.QueryParams - MetadataFilter querybuilders.Metadata - ModelFilter filter.ModelFilter + MetadataFilter querybuilders.Metadata + ModelFilter filter.ModelFilter + PageFilter filter.Page } tests := map[string]struct { filters filters @@ -28,13 +28,13 @@ func TestQueryBuilder_Build(t *testing.T) { filters: filters{}, expectedParams: make(url.Values), }, - "query builder: URL values with query params filter-only": { + "query builder: URL values with page filter-only": { filters: filters{ - QueryParamsFilter: filter.QueryParams{ - Page: 10, - PageSize: 20, - OrderByField: "id", - SortDirection: "asc", + PageFilter: filter.Page{ + Number: 10, + Size: 20, + SortBy: "id", + Sort: "asc", }, }, expectedParams: url.Values{ @@ -58,11 +58,11 @@ func TestQueryBuilder_Build(t *testing.T) { }, "query builder: URL values with all filters set": { filters: filters{ - QueryParamsFilter: filter.QueryParams{ - Page: 10, - PageSize: 20, - OrderByField: "id", - SortDirection: "asc", + PageFilter: filter.Page{ + Number: 10, + Size: 20, + Sort: "asc", + SortBy: "id", }, ModelFilter: filter.ModelFilter{ IncludeDeleted: querybuilderstest.Ptr(true), @@ -96,11 +96,11 @@ func TestQueryBuilder_Build(t *testing.T) { }, "query builder: injected dependency filter query builder failure": { filters: filters{ - QueryParamsFilter: filter.QueryParams{ - Page: 10, - PageSize: 20, - OrderByField: "id", - SortDirection: "asc", + PageFilter: filter.Page{ + Number: 10, + Size: 20, + Sort: "id", + SortBy: "asc", }, }, builder: &filterQueryBuilderFailureStub{}, @@ -113,7 +113,7 @@ func TestQueryBuilder_Build(t *testing.T) { // when: opts := []querybuilders.QueryBuilderOption{ querybuilders.WithMetadataFilter(tc.filters.MetadataFilter), - querybuilders.WithQueryParamsFilter(tc.filters.QueryParamsFilter), + querybuilders.WithPageFilterQueryBuilder(tc.filters.PageFilter), querybuilders.WithModelFilter(tc.filters.ModelFilter), querybuilders.WithFilterQueryBuilder(tc.builder), } diff --git a/internal/api/v1/user/transactions/transactions.go b/internal/api/v1/user/transactions/transactions.go index 50f00c60..5d2712ab 100644 --- a/internal/api/v1/user/transactions/transactions.go +++ b/internal/api/v1/user/transactions/transactions.go @@ -80,7 +80,7 @@ func (a *API) Transaction(ctx context.Context, ID string) (*response.Transaction return &result, nil } -func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.TransctionsQueryOption) ([]*response.Transaction, error) { +func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.TransactionsQueryOption) (*queries.TransactionPage, error) { var query queries.TransactionsQuery for _, o := range transactionsOpts { o(&query) @@ -88,7 +88,7 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran builderOpts := []querybuilders.QueryBuilderOption{ querybuilders.WithMetadataFilter(query.Metadata), - querybuilders.WithQueryParamsFilter(query.QueryParams), + querybuilders.WithPageFilterQueryBuilder(query.Page), querybuilders.WithFilterQueryBuilder(&transactionFilterBuilder{ TransactionFilter: query.Filter, ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.Filter.ModelFilter}, @@ -111,7 +111,7 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran return nil, fmt.Errorf("HTTP response failure: %w", err) } - return result.Content, nil + return &result, nil } func NewAPI(addr string, cli *resty.Client) *API { diff --git a/internal/api/v1/user/transactions/transactions_test.go b/internal/api/v1/user/transactions/transactions_test.go index 5974f60a..0d713f4f 100644 --- a/internal/api/v1/user/transactions/transactions_test.go +++ b/internal/api/v1/user/transactions/transactions_test.go @@ -205,12 +205,12 @@ func TestTransactionsAPI_Transactions(t *testing.T) { tests := map[string]struct { responder httpmock.Responder statusCode int - expectedResponse []*response.Transaction + expectedResponse *response.PageModel[response.Transaction] expectedErr error }{ "HTTP GET /api/v1/transactions response: 200": { statusCode: http.StatusOK, - expectedResponse: transactionstest.ExpectedTransactions(t), + expectedResponse: transactionstest.ExpectedTransactionsPage(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transactions_200.json")), }, "HTTP GET /api/v1/transactions response: 400": { diff --git a/internal/api/v1/user/transactions/transactionstest/transactionstest.go b/internal/api/v1/user/transactions/transactionstest/transactionstest.go index 4be9dbc7..c8c27517 100644 --- a/internal/api/v1/user/transactions/transactionstest/transactionstest.go +++ b/internal/api/v1/user/transactions/transactionstest/transactionstest.go @@ -226,67 +226,75 @@ func ExpectedTransaction(t *testing.T) *response.Transaction { } } -func ExpectedTransactions(t *testing.T) []*response.Transaction { - return []*response.Transaction{ - { - Model: response.Model{ - CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), - UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), - Metadata: map[string]any{ - "domain": "john.doe.test.4chain.space", - "example_key1": "example_key10_val", - "ip_address": "127.0.0.01", - "user_agent": "node-fetch", - "paymail_request": "HandleReceivedP2pTransaction", - "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", - "p2p_tx_metadata": map[string]any{ - "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065", - "sender": "john.doe@handcash.io", +func ExpectedTransactionsPage(t *testing.T) *response.PageModel[response.Transaction] { + return &response.PageModel[response.Transaction]{ + Content: []*response.Transaction{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "john.doe.test.4chain.space", + "example_key1": "example_key10_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "1c2dcc61-f48f-44f2-aba2-9a759a514d49", + "p2p_tx_metadata": map[string]any{ + "pubkey": "3efe9fcb-859c-47f1-b85f-0fa8b1eee065", + "sender": "john.doe@handcash.io", + }, }, }, + ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e", + Hex: "283b1c6deb6d6263b3cec7a4701d46d3", + XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"}, + BlockHash: "47758f612c6bf5b454bcd642fe8031f6", + BlockHeight: 512, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 311, + OutputValue: 100, + Status: "MINED", + TransactionDirection: "incoming", }, - ID: "2c250e21-c33a-41e3-a4e3-77c68b03244e", - Hex: "283b1c6deb6d6263b3cec7a4701d46d3", - XpubOutIDs: []string{"4c9a0a0d-ea4f-4f03-b740-84438b3d210d"}, - BlockHash: "47758f612c6bf5b454bcd642fe8031f6", - BlockHeight: 512, - Fee: 1, - NumberOfInputs: 2, - NumberOfOutputs: 3, - TotalValue: 311, - OutputValue: 100, - Status: "MINED", - TransactionDirection: "incoming", - }, - { - Model: response.Model{ - CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), - UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), - Metadata: map[string]any{ - "domain": "jane.doe.test.4chain.space", - "example_key101": "example_key101_val", - "ip_address": "127.0.0.01", - "user_agent": "node-fetch", - "paymail_request": "HandleReceivedP2pTransaction", - "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50", - "p2p_tx_metadata": map[string]any{ - "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa", - "sender": "jane.doe@handcash.io", + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + UpdatedAt: ParseTime(t, "2024-10-07T14:03:26.736816Z"), + Metadata: map[string]any{ + "domain": "jane.doe.test.4chain.space", + "example_key101": "example_key101_val", + "ip_address": "127.0.0.01", + "user_agent": "node-fetch", + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "2c6dcc71-f42f-54f2-ada1-1c658a515d50", + "p2p_tx_metadata": map[string]any{ + "pubkey": "4fa8af6b-3217-2373-76da-0aa552ca88aa", + "sender": "jane.doe@handcash.io", + }, }, }, + ID: "1c110e11-c23a-51e5-a7e7-99c12b01233e", + Hex: "283b1c7deb7d7773b3cec7a8801d47d2", + XpubOutIDs: []string{"2c8a1a1d-ea5f-5f04-b890-92418b2d411d"}, + BlockHash: "56659f622c6bf5b554bcd742fe8132f9", + BlockHeight: 1024, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 500, + OutputValue: 200, + Status: "MINED", + TransactionDirection: "incoming", }, - ID: "1c110e11-c23a-51e5-a7e7-99c12b01233e", - Hex: "283b1c7deb7d7773b3cec7a8801d47d2", - XpubOutIDs: []string{"2c8a1a1d-ea5f-5f04-b890-92418b2d411d"}, - BlockHash: "56659f622c6bf5b554bcd742fe8132f9", - BlockHeight: 1024, - Fee: 1, - NumberOfInputs: 2, - NumberOfOutputs: 3, - TotalValue: 500, - OutputValue: 200, - Status: "MINED", - TransactionDirection: "incoming", + }, + Page: response.PageDescription{ + Size: 2, + Number: 2, + TotalElements: 2, + TotalPages: 1, }, } } diff --git a/queries/transactions.go b/queries/transactions.go index cb95c769..b81a34d9 100644 --- a/queries/transactions.go +++ b/queries/transactions.go @@ -2,40 +2,45 @@ package queries import ( "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" ) -// TransactionsQuery aggregates query parameter filters describing the key-value pairs -// that should be appended to the transaction URL being constructed +// TransactionPage is an alias for the transactions response page model +// returned by the SPV Wallet API, which contains a paginated list of +// transactions along with pagination metadata. +type TransactionPage = response.PageModel[response.Transaction] + +// TransactionsQuery aggregates query parameters for constructing a transactions endpoint URL. +// It holds filters for metadata, transaction-specific attributes, and pagination. type TransactionsQuery struct { - Metadata map[string]any - Filter filter.TransactionFilter - QueryParams filter.QueryParams + Metadata map[string]any // Metadata filters for the transactions. + Filter filter.TransactionFilter // Transaction-specific filters (e.g., block height, status). + Page filter.Page // Pagination details (page number, size, sorting). } -// TransctionsQueryOption represents a functional option for creating a customized list -// of transaction query parameters. -type TransctionsQueryOption func(*TransactionsQuery) +// TransactionsQueryOption defines a functional option for configuring a TransactionsQuery instance. +type TransactionsQueryOption func(*TransactionsQuery) -// TransactionsQueryWithMetadataFilter applies specific metadata attributes as filters -// to the transactions endpoint URL being constructed. -func TransactionsQueryWithMetadataFilter(m map[string]any) TransctionsQueryOption { +// TransactionsQueryWithMetadataFilter adds metadata filters to the transactions endpoint URL. +// The specified metadata attributes will be appended as query parameters. +func TransactionsQueryWithMetadataFilter(m map[string]any) TransactionsQueryOption { return func(tq *TransactionsQuery) { tq.Metadata = m } } -// TransactionsQueryWithFilter applies general query parameters like BlockHeight, BlockHash, -// transaction status, etc. to the transactions endpoint URL being constructed. -func TransactionsQueryWithFilter(tf filter.TransactionFilter) TransctionsQueryOption { +// TransactionsQueryWithFilter adds transaction-specific filters, such as block height, block hash, +// transaction status, etc., to the transactions endpoint URL as query parameters. +func TransactionsQueryWithFilter(tf filter.TransactionFilter) TransactionsQueryOption { return func(tq *TransactionsQuery) { tq.Filter = tf } } -// TransactionsQueryWithQueryParamsFilter applies general query parameters like pagination and sort order etc. -// to the transactions endpoint URL being constructed. -func TransactionsQueryWithQueryParamsFilter(q filter.QueryParams) TransctionsQueryOption { +// TransactionsQueryWithPageFilter adds pagination details, like page number, page size, and sort order, +// to the transactions endpoint URL as query parameters. +func TransactionsQueryWithPageFilter(pf filter.Page) TransactionsQueryOption { return func(tq *TransactionsQuery) { - tq.QueryParams = q + tq.Page = pf } } From 4d3962d764ea9f82a16c05f40ce300fd8f222361 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Mon, 18 Nov 2024 09:29:59 +0100 Subject: [PATCH 08/18] refactor(SPV-1174): add user contacts, invitation api implementations (#10) --- client.go | 108 ++++++- commands/contacts.go | 8 + go.mod | 7 +- go.sum | 15 +- internal/api/v1/user/configs/configs.go | 4 +- .../contacts/contact_filter_query_builder.go | 33 +++ .../contact_filter_query_builder_test.go | 84 ++++++ internal/api/v1/user/contacts/contacts_api.go | 140 +++++++++ .../api/v1/user/contacts/contacts_api_test.go | 277 ++++++++++++++++++ .../contactstest/contacts_api_fixtures.go | 97 ++++++ .../contactstest/get_contact_paymail_200.json | 11 + .../contactstest/get_contacts_200.json | 32 ++ .../contactstest/put_contact_upsert_200.json | 16 + .../v1/user/invitations/invitations_api.go | 48 +++ .../user/invitations/invitations_api_test.go | 113 +++++++ .../v1/user/querybuilders/query_builder.go | 2 +- .../user/querybuilders/query_builder_test.go | 2 +- .../api/v1/user/transactions/transactions.go | 6 +- queries/contacts.go | 46 +++ 19 files changed, 1032 insertions(+), 17 deletions(-) create mode 100644 commands/contacts.go create mode 100644 internal/api/v1/user/contacts/contact_filter_query_builder.go create mode 100644 internal/api/v1/user/contacts/contact_filter_query_builder_test.go create mode 100644 internal/api/v1/user/contacts/contacts_api.go create mode 100644 internal/api/v1/user/contacts/contacts_api_test.go create mode 100644 internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go create mode 100644 internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json create mode 100644 internal/api/v1/user/contacts/contactstest/get_contacts_200.json create mode 100644 internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json create mode 100644 internal/api/v1/user/invitations/invitations_api.go create mode 100644 internal/api/v1/user/invitations/invitations_api_test.go create mode 100644 queries/contacts.go diff --git a/client.go b/client.go index 47f5f7c8..9e882751 100644 --- a/client.go +++ b/client.go @@ -11,6 +11,8 @@ import ( ec "github.com/bitcoin-sv/go-sdk/primitives/ec" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -45,6 +47,8 @@ func NewDefaultConfig(addr string) Config { // of the HTTP requests and responses directly. type Client struct { configsAPI *configs.API + contactsAPI *contacts.API + invitationsAPI *invitations.API transactionsAPI *transactions.API } @@ -99,10 +103,97 @@ func NewWithAccessKey(cfg Config, accessKey string) (*Client, error) { return newClient(cfg, authenticator), nil } +// Contacts retrieves a paginated list of user contacts from the user contacts API. +// The API response includes user contacts along with pagination details, such as +// the current page number, sort order, and the field used for sorting (sortBy). +// +// Optional query parameters can be provided via query options. The response is +// unmarshaled into a *queries.UserContactsPage struct. If the API request fails +// or the response cannot be decoded, an error is returned. +func (c *Client) Contacts(ctx context.Context, contactOpts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { + res, err := c.contactsAPI.Contacts(ctx, contactOpts...) + if err != nil { + return nil, fmt.Errorf("failed to retrieve contacts from the user contacts API: %w", err) + } + return res, nil +} + +// ContactWithPaymail retrieves a specific user contact by their paymail address. +// The response is unmarshaled into a *response.Contact struct. If the API request +// fails or the response cannot be decoded, an error is returned. +func (c *Client) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) { + res, err := c.contactsAPI.ContactWithPaymail(ctx, paymail) + if err != nil { + return nil, fmt.Errorf("failed to retrieve contact by paymail from the user contacts API: %w", err) + } + return res, nil +} + +// UpsertContact adds or updates a user contact through the user contacts API. +// The response is unmarshaled into a *response.Contact struct. If the API request +// fails or the response cannot be decoded, an error is returned. +func (c *Client) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) { + res, err := c.contactsAPI.UpsertContact(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to upsert contact using the user contacts API: %w", err) + } + return res, nil +} + +// RemoveContact deletes a user contact using the user contacts API. +// If the API request fails, an error is returned. +func (c *Client) RemoveContact(ctx context.Context, paymail string) error { + err := c.contactsAPI.RemoveContact(ctx, paymail) + if err != nil { + return fmt.Errorf("failed to remove contact using the user contacts API: %w", err) + } + return nil +} + +// ConfirmContact confirms a user contact using the user contacts API. +// If the API request fails, an error is returned. +func (c *Client) ConfirmContact(ctx context.Context, paymail string) error { + err := c.contactsAPI.ConfirmContact(ctx, paymail) + if err != nil { + return fmt.Errorf("failed to confirm contact using the user contacts API: %w", err) + } + return nil +} + +// UnconfirmContact unconfirms a user contact using the user contacts API. +// If the API request fails, an error is returned. +func (c *Client) UnconfirmContact(ctx context.Context, paymail string) error { + err := c.contactsAPI.UnconfirmContact(ctx, paymail) + if err != nil { + return fmt.Errorf("failed to unconfirm contact using the user contacts API: %w", err) + } + return nil +} + +// AcceptInvitation accepts a contact invitation using the user invitations API. +// If the API request fails, an error is returned. +func (c *Client) AcceptInvitation(ctx context.Context, paymail string) error { + err := c.invitationsAPI.AcceptInvitation(ctx, paymail) + if err != nil { + return fmt.Errorf("failed to accept invitation using the user invitations API: %w", err) + } + return nil +} + +// RejectInvitation rejects a contact invitation using the user invitations API. +// If the API request fails, an error is returned. +func (c *Client) RejectInvitation(ctx context.Context, paymail string) error { + err := c.invitationsAPI.RejectInvitation(ctx, paymail) + if err != nil { + return fmt.Errorf("failed to reject invitation using the user invitations API: %w", err) + } + return nil +} + // SharedConfig retrieves the shared configuration from the user configurations API. -// This method constructs an HTTP GET request to the "/shared" endpoint and expects -// a response that can be unmarshaled into the response.SharedConfig struct. -// If the request fails or the response cannot be decoded, an error will be returned. +// This method constructs an HTTP GET request to the "api/v1/configs/shared" endpoint and expects +// a response that can be unmarshaled into the response.SharedConfig struct. If the request fails +// or the response cannot be decoded, an error will be returned. func (c *Client) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { res, err := c.configsAPI.SharedConfig(ctx) if err != nil { @@ -202,12 +293,13 @@ type authenticator interface { } func newClient(cfg Config, auth authenticator) *Client { - restyCli := newRestyClient(cfg, auth) - cli := Client{ - configsAPI: configs.NewAPI(cfg.Addr, restyCli), - transactionsAPI: transactions.NewAPI(cfg.Addr, restyCli), + httpClient := newRestyClient(cfg, auth) + return &Client{ + configsAPI: configs.NewAPI(cfg.Addr, httpClient), + contactsAPI: contacts.NewAPI(cfg.Addr, httpClient), + invitationsAPI: invitations.NewAPI(cfg.Addr, httpClient), + transactionsAPI: transactions.NewAPI(cfg.Addr, httpClient), } - return &cli } func newRestyClient(cfg Config, auth authenticator) *resty.Client { diff --git a/commands/contacts.go b/commands/contacts.go new file mode 100644 index 00000000..76d7edfb --- /dev/null +++ b/commands/contacts.go @@ -0,0 +1,8 @@ +package commands + +// UpsertContact holds the necessary arguments for adding or updating a user's contact information. +type UpsertContact struct { + FullName string `json:"fullName"` // The full name of the user. + Metadata map[string]any `json:"metadata"` // Metadata associated with the transaction. + Paymail string `json:"requesterPaymail"` // Paymail address of the user, which is used for secure and simplified payment transfers. +} diff --git a/go.mod b/go.mod index f0a74bc3..d68efbae 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,12 @@ require ( github.com/stretchr/testify v1.9.0 ) -require golang.org/x/net v0.27.0 // indirect +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/net v0.27.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index a8948275..d82f9ef3 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,30 @@ github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qt github.com/bitcoin-sv/go-sdk v1.1.9/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= @@ -22,7 +34,8 @@ golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/v1/user/configs/configs.go b/internal/api/v1/user/configs/configs.go index da06f6b6..c7a4a838 100644 --- a/internal/api/v1/user/configs/configs.go +++ b/internal/api/v1/user/configs/configs.go @@ -29,9 +29,9 @@ func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) return &result, nil } -func NewAPI(addr string, cli *resty.Client) *API { +func NewAPI(addr string, httpClient *resty.Client) *API { return &API{ addr: addr + "/" + route, - httpClient: cli, + httpClient: httpClient, } } diff --git a/internal/api/v1/user/contacts/contact_filter_query_builder.go b/internal/api/v1/user/contacts/contact_filter_query_builder.go new file mode 100644 index 00000000..327e915b --- /dev/null +++ b/internal/api/v1/user/contacts/contact_filter_query_builder.go @@ -0,0 +1,33 @@ +package contacts + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type contactFilterQueryBuilder struct { + modelFilterBuilder querybuilders.ModelFilterBuilder + contactFilter filter.ContactFilter +} + +func (c *contactFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := c.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", c.contactFilter.ID) + params.AddPair("fullName", c.contactFilter.FullName) + params.AddPair("paymail", c.contactFilter.Paymail) + params.AddPair("pubKey", c.contactFilter.PubKey) + params.AddPair("status", c.contactFilter.Status) + return params.Values, nil +} diff --git a/internal/api/v1/user/contacts/contact_filter_query_builder_test.go b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go new file mode 100644 index 00000000..b00c0e30 --- /dev/null +++ b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go @@ -0,0 +1,84 @@ +package contacts + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestContactFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.ContactFilter + expectedParams url.Values + expectedErr error + }{ + "contact filter: zero values": { + expectedParams: make(url.Values), + }, + "contact filter: filter with only 'id' field set": { + filter: filter.ContactFilter{ + ID: contactstest.Ptr("e3a1e174-cdf8-4683-b112-e198144eb9d2"), + }, + expectedParams: url.Values{ + "id": []string{"e3a1e174-cdf8-4683-b112-e198144eb9d2"}, + }, + }, + "contact filter: filter with only 'full name' field set": { + filter: filter.ContactFilter{ + FullName: contactstest.Ptr("John Doe"), + }, + expectedParams: url.Values{ + "fullName": []string{"John Doe"}, + }, + }, + "contact filter: filter with only 'paymail' field set": { + filter: filter.ContactFilter{ + Paymail: contactstest.Ptr("john.doe@test.com"), + }, + expectedParams: url.Values{ + "paymail": []string{"john.doe@test.com"}, + }, + }, + "contact filter: filter with only 'status' field set": { + filter: filter.ContactFilter{ + Status: contactstest.Ptr("confirmed"), + }, + expectedParams: url.Values{ + "status": []string{"confirmed"}, + }, + }, + "contact filter: filter with all fields set": { + filter: filter.ContactFilter{ + ID: contactstest.Ptr("e3a1e174-cdf8-4683-b112-e198144eb9d2"), + FullName: contactstest.Ptr("John Doe"), + Paymail: contactstest.Ptr("john.doe@test.com"), + Status: contactstest.Ptr("confirmed"), + }, + expectedParams: url.Values{ + "paymail": []string{"john.doe@test.com"}, + "status": []string{"confirmed"}, + "id": []string{"e3a1e174-cdf8-4683-b112-e198144eb9d2"}, + "fullName": []string{"John Doe"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := contactFilterQueryBuilder{ + contactFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/contacts/contacts_api.go b/internal/api/v1/user/contacts/contacts_api.go new file mode 100644 index 00000000..3c8a6b83 --- /dev/null +++ b/internal/api/v1/user/contacts/contacts_api.go @@ -0,0 +1,140 @@ +package contacts + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/contacts" + +type API struct { + addr string + httpClient *resty.Client +} + +func (a *API) Contacts(ctx context.Context, opts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { + var query queries.ContactQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&contactFilterQueryBuilder{ + contactFilter: query.ContactFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ + ModelFilter: query.ContactFilter.ModelFilter, + }, + }), + ) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build user contacts query params: %w", err) + } + + var result queries.UserContactsPage + _, err = a.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(a.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) { + var result response.Contact + + URL := a.addr + "/" + paymail + _, err := a.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + Get(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) UpsertContact(ctx context.Context, r commands.UpsertContact) (*response.Contact, error) { + var result response.CreateContactResponse + + URL := a.addr + "/" + r.Paymail + _, err := a.httpClient. + R(). + SetBody(r). + SetContext(ctx). + SetResult(&result). + Put(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &response.Contact{ + Model: result.Contact.Model, + ID: result.Contact.ID, + FullName: result.Contact.FullName, + Paymail: result.Contact.Paymail, + PubKey: result.Contact.PubKey, + Status: result.Contact.Status, + }, nil +} + +func (a *API) RemoveContact(ctx context.Context, paymail string) error { + URL := a.addr + "/" + paymail + _, err := a.httpClient. + R(). + SetContext(ctx). + Delete(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func (a *API) ConfirmContact(ctx context.Context, paymail string) error { + URL := a.addr + "/" + paymail + "/confirmation" + _, err := a.httpClient. + R(). + SetContext(ctx). + Post(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func (a *API) UnconfirmContact(ctx context.Context, paymail string) error { + URL := a.addr + "/" + paymail + "/confirmation" + _, err := a.httpClient. + R(). + SetContext(ctx). + Delete(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func NewAPI(addr string, httpClient *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/contacts/contacts_api_test.go b/internal/api/v1/user/contacts/contacts_api_test.go new file mode 100644 index 00000000..9c4ab2b4 --- /dev/null +++ b/internal/api/v1/user/contacts/contacts_api_test.go @@ -0,0 +1,277 @@ +package contacts_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestContactsAPI_Contacts(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *queries.UserContactsPage + expectedErr error + }{ + "HTTP GET /api/v1/contacts response: 200": { + statusCode: http.StatusOK, + expectedResponse: contactstest.ExpectedUserContactsPage(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contacts_200.json")), + }, + "HTTP GET /api/v1/contacts response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/contacts str response: 500": { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.Contacts(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestContactsAPI_ContactWithPaymail(t *testing.T) { + paymail := "john.doe.test5@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *response.Contact + expectedErr error + }{ + fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 200", paymail): { + statusCode: http.StatusOK, + expectedResponse: contactstest.ExpectedContactWithWithPaymail(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contact_paymail_200.json")), + }, + fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP GET /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.ContactWithPaymail(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestContactsAPI_UpsertContact(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *response.Contact + expectedErr error + }{ + fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 200", paymail): { + statusCode: http.StatusOK, + expectedResponse: contactstest.ExpectedUpsertContact(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/put_contact_upsert_200.json")), + }, + fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP PUT /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPut, URL, tc.responder) + + // then: + got, err := wallet.UpsertContact(context.Background(), commands.UpsertContact{ + FullName: "John Doe", + Metadata: map[string]any{"example_key": "example_val"}, + Paymail: paymail, + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestContactsAPI_RemoveContact(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 200", paymail): { + statusCode: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + + // then: + err := wallet.RemoveContact(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +func TestContactsAPI_ConfirmContact(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", paymail): { + statusCode: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + err := wallet.ConfirmContact(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +func TestContactsAPI_UnconfirmContact(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 200", paymail): { + statusCode: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + + // then: + err := wallet.UnconfirmContact(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} diff --git a/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go new file mode 100644 index 00000000..0bec7e5d --- /dev/null +++ b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go @@ -0,0 +1,97 @@ +package contactstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedUserContactsPage(t *testing.T) *queries.UserContactsPage { + return &queries.UserContactsPage{ + Content: []*response.Contact{ + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: ParseTime(t, "2024-10-18T15:08:44.739918Z"), + }, + ID: "4f730efa-2a33-4275-bfdb-1f21fc110963", + FullName: "John Doe", + Paymail: "john.doe.test5@john.doe.4chain.space", + PubKey: "19751ea9-6c1f-4ba7-a7e2-551ef7930136", + Status: "unconfirmed", + }, + { + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: ParseTime(t, "2024-10-18T15:08:44.739918Z"), + }, + ID: "e55a4d4e-4a4b-4720-8556-1c00dd6a5cf3", + FullName: "Jane Doe", + Paymail: "jane.doe.test5@jane.doe.4chain.space", + PubKey: "f8898969-3f96-48d3-b122-bbb3e738dbf5", + Status: "unconfirmed", + }, + }, + Page: response.PageDescription{ + Size: 2, + Number: 2, + TotalElements: 2, + TotalPages: 1, + }, + } +} + +func ExpectedContactWithWithPaymail(t *testing.T) *response.Contact { + return &response.Contact{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: ParseTime(t, "2024-10-18T15:08:44.739918Z"), + }, + ID: "4f730efa-2a33-4275-bfdb-1f21fc110963", + FullName: "John Doe", + Paymail: "john.doe.test5@john.doe.4chain.space", + PubKey: "19751ea9-6c1f-4ba7-a7e2-551ef7930136", + Status: "unconfirmed", + } +} + +func ExpectedUpsertContact(t *testing.T) *response.Contact { + return &response.Contact{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-18T12:07:44.739839Z"), + UpdatedAt: ParseTime(t, "2024-11-06T11:30:35.090124Z"), + Metadata: map[string]interface{}{ + "example_key": "example_val", + }, + }, + ID: "68acf78f-5ece-4917-821d-8028ecf06c9a", + FullName: "John Doe", + Paymail: "john.doe.test@john.doe.test.4chain.space", + PubKey: "0df36839-67bb-49e7-a9c7-e839aa564871", + Status: "unconfirmed", + } +} + +func ParseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} + +func Ptr[T any](value T) *T { + return &value +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} diff --git a/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json b/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json new file mode 100644 index 00000000..330de88c --- /dev/null +++ b/internal/api/v1/user/contacts/contactstest/get_contact_paymail_200.json @@ -0,0 +1,11 @@ +{ + "createdAt": "2024-10-18T12:07:44.739839Z", + "updatedAt": "2024-10-18T15:08:44.739918Z", + "deletedAt": null, + "metadata": null, + "id": "4f730efa-2a33-4275-bfdb-1f21fc110963", + "fullName": "John Doe", + "paymail": "john.doe.test5@john.doe.4chain.space", + "pubKey": "19751ea9-6c1f-4ba7-a7e2-551ef7930136", + "status": "unconfirmed" +} diff --git a/internal/api/v1/user/contacts/contactstest/get_contacts_200.json b/internal/api/v1/user/contacts/contactstest/get_contacts_200.json new file mode 100644 index 00000000..661b3e6b --- /dev/null +++ b/internal/api/v1/user/contacts/contactstest/get_contacts_200.json @@ -0,0 +1,32 @@ +{ + "content": [ + { + "createdAt": "2024-10-18T12:07:44.739839Z", + "updatedAt": "2024-10-18T15:08:44.739918Z", + "deletedAt": null, + "metadata": null, + "id": "4f730efa-2a33-4275-bfdb-1f21fc110963", + "fullName": "John Doe", + "paymail": "john.doe.test5@john.doe.4chain.space", + "pubKey": "19751ea9-6c1f-4ba7-a7e2-551ef7930136", + "status": "unconfirmed" + }, + { + "createdAt": "2024-10-18T12:07:44.739839Z", + "updatedAt": "2024-10-18T15:08:44.739918Z", + "deletedAt": null, + "metadata": null, + "id": "e55a4d4e-4a4b-4720-8556-1c00dd6a5cf3", + "fullName": "Jane Doe", + "paymail": "jane.doe.test5@jane.doe.4chain.space", + "pubKey": "f8898969-3f96-48d3-b122-bbb3e738dbf5", + "status": "unconfirmed" + } + ], + "page": { + "number": 2, + "size": 2, + "totalElements": 2, + "totalPages": 1 + } +} diff --git a/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json b/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json new file mode 100644 index 00000000..38f5b15e --- /dev/null +++ b/internal/api/v1/user/contacts/contactstest/put_contact_upsert_200.json @@ -0,0 +1,16 @@ +{ + "contact": { + "createdAt": "2024-10-18T12:07:44.739839Z", + "updatedAt": "2024-11-06T11:30:35.090124Z", + "deletedAt": null, + "metadata": { + "example_key": "example_val" + }, + "id": "68acf78f-5ece-4917-821d-8028ecf06c9a", + "fullName": "John Doe", + "paymail": "john.doe.test@john.doe.test.4chain.space", + "pubKey": "0df36839-67bb-49e7-a9c7-e839aa564871", + "status": "unconfirmed" + }, + "additionalInfo": {} +} diff --git a/internal/api/v1/user/invitations/invitations_api.go b/internal/api/v1/user/invitations/invitations_api.go new file mode 100644 index 00000000..fa1795da --- /dev/null +++ b/internal/api/v1/user/invitations/invitations_api.go @@ -0,0 +1,48 @@ +package invitations + +import ( + "context" + "fmt" + + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/invitations" + +type API struct { + addr string + httpClient *resty.Client +} + +func (a *API) AcceptInvitation(ctx context.Context, paymail string) error { + URL := a.addr + "/" + paymail + "/contacts" + _, err := a.httpClient. + R(). + SetContext(ctx). + Post(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func (a *API) RejectInvitation(ctx context.Context, paymail string) error { + URL := a.addr + "/" + paymail + _, err := a.httpClient. + R(). + SetContext(ctx). + Delete(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func NewAPI(addr string, httpClient *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/invitations/invitations_api_test.go b/internal/api/v1/user/invitations/invitations_api_test.go new file mode 100644 index 00000000..0eb1c29a --- /dev/null +++ b/internal/api/v1/user/invitations/invitations_api_test.go @@ -0,0 +1,113 @@ +package invitations_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestInvitationsAPI_AcceptInvitation(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 200", paymail): { + statusCode: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + err := wallet.AcceptInvitation(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +func TestInvitationsAPI_RejectInvitation(t *testing.T) { + paymail := "john.doe.test@john.doe.test.4chain.space" + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 200", paymail): { + statusCode: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 400", paymail): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP POST /api/v1/invitations/%s str response: 500", paymail): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + + // then: + err := wallet.RejectInvitation(context.Background(), paymail) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +func ParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} diff --git a/internal/api/v1/user/querybuilders/query_builder.go b/internal/api/v1/user/querybuilders/query_builder.go index 338cf237..ccb33b16 100644 --- a/internal/api/v1/user/querybuilders/query_builder.go +++ b/internal/api/v1/user/querybuilders/query_builder.go @@ -26,7 +26,7 @@ func WithModelFilter(m filter.ModelFilter) QueryBuilderOption { } } -func WithPageFilterQueryBuilder(p filter.Page) QueryBuilderOption { +func WithPageFilter(p filter.Page) QueryBuilderOption { var zero filter.Page return func(qb *QueryBuilder) { if p != zero { diff --git a/internal/api/v1/user/querybuilders/query_builder_test.go b/internal/api/v1/user/querybuilders/query_builder_test.go index 6d39aedf..ff45dafb 100644 --- a/internal/api/v1/user/querybuilders/query_builder_test.go +++ b/internal/api/v1/user/querybuilders/query_builder_test.go @@ -113,7 +113,7 @@ func TestQueryBuilder_Build(t *testing.T) { // when: opts := []querybuilders.QueryBuilderOption{ querybuilders.WithMetadataFilter(tc.filters.MetadataFilter), - querybuilders.WithPageFilterQueryBuilder(tc.filters.PageFilter), + querybuilders.WithPageFilter(tc.filters.PageFilter), querybuilders.WithModelFilter(tc.filters.ModelFilter), querybuilders.WithFilterQueryBuilder(tc.builder), } diff --git a/internal/api/v1/user/transactions/transactions.go b/internal/api/v1/user/transactions/transactions.go index 5d2712ab..babd8888 100644 --- a/internal/api/v1/user/transactions/transactions.go +++ b/internal/api/v1/user/transactions/transactions.go @@ -88,7 +88,7 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran builderOpts := []querybuilders.QueryBuilderOption{ querybuilders.WithMetadataFilter(query.Metadata), - querybuilders.WithPageFilterQueryBuilder(query.Page), + querybuilders.WithPageFilter(query.Page), querybuilders.WithFilterQueryBuilder(&transactionFilterBuilder{ TransactionFilter: query.Filter, ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.Filter.ModelFilter}, @@ -114,9 +114,9 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran return &result, nil } -func NewAPI(addr string, cli *resty.Client) *API { +func NewAPI(addr string, httpClient *resty.Client) *API { return &API{ addr: addr + "/" + route, - httpClient: cli, + httpClient: httpClient, } } diff --git a/queries/contacts.go b/queries/contacts.go new file mode 100644 index 00000000..68a58f89 --- /dev/null +++ b/queries/contacts.go @@ -0,0 +1,46 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// UserContactsPage is an alias for the user contacts response page model returned by the SPV Wallet API. +// It provides a paginated list of user contacts along with pagination metadata. +type UserContactsPage = response.PageModel[response.Contact] + +// ContactQuery aggregates query parameters for constructing the user contacts endpoint URL. +// It contains filters for metadata, pagination, and user contact-specific attributes. +type ContactQuery struct { + Metadata map[string]any // Metadata filters for refining the search. + PageFilter filter.Page // Pagination details, including page number, size, and sorting. + ContactFilter filter.ContactFilter // Filters for contact attributes (paymail, public key, ID, status). +} + +// ContactQueryOption defines a functional option for configuring a ContactQuery instance. +// These options allow flexible setup of filters and pagination for the query. +type ContactQueryOption func(*ContactQuery) + +// ContactQueryWithMetadataFilter adds metadata filters to the user contacts search URL. +// The provided metadata attributes are appended as query parameters. +func ContactQueryWithMetadataFilter(m map[string]any) ContactQueryOption { + return func(cq *ContactQuery) { + cq.Metadata = m + } +} + +// ContactQueryWithPageFilter adds pagination settings, like page number, size, and sorting options, +// to the user contacts search URL as query parameters. +func ContactQueryWithPageFilter(f filter.Page) ContactQueryOption { + return func(cq *ContactQuery) { + cq.PageFilter = f + } +} + +// ContactQueryWithContactFilter adds filters for user contact attributes, such as paymail, public key, +// contact ID, and status. These filters are appended as query parameters to the user contacts search URL. +func ContactQueryWithContactFilter(cf filter.ContactFilter) ContactQueryOption { + return func(cq *ContactQuery) { + cq.ContactFilter = cf + } +} From 3acdd4c47d02d65782934e856179b345d371af9a Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Mon, 18 Nov 2024 09:41:40 +0100 Subject: [PATCH 09/18] refactor(SPV-1184): add users information + user access key api implementation. (#12) --- client.go | 80 +++++++ commands/users.go | 13 ++ internal/api/v1/user/users/access_key_api.go | 101 +++++++++ .../api/v1/user/users/access_key_api_test.go | 197 ++++++++++++++++++ .../users/access_key_filter_query_builder.go | 29 +++ .../access_key_filter_query_builder_test.go | 79 +++++++ .../userstest/access_key_api_fixtures.go | 87 ++++++++ .../users/userstest/get_access_key_200.json | 10 + .../users/userstest/get_access_keys_200.json | 42 ++++ .../v1/user/users/userstest/get_xpub_200.json | 14 ++ .../userstest/patch_xpub_metadata_200.json | 14 ++ .../users/userstest/post_access_key_200.json | 11 + .../user/users/userstest/xpub_api_fixtures.go | 66 ++++++ internal/api/v1/user/users/xpub_api.go | 52 +++++ internal/api/v1/user/users/xpub_api_test.go | 108 ++++++++++ queries/access_key.go | 47 +++++ 16 files changed, 950 insertions(+) create mode 100644 commands/users.go create mode 100644 internal/api/v1/user/users/access_key_api.go create mode 100644 internal/api/v1/user/users/access_key_api_test.go create mode 100644 internal/api/v1/user/users/access_key_filter_query_builder.go create mode 100644 internal/api/v1/user/users/access_key_filter_query_builder_test.go create mode 100644 internal/api/v1/user/users/userstest/access_key_api_fixtures.go create mode 100644 internal/api/v1/user/users/userstest/get_access_key_200.json create mode 100644 internal/api/v1/user/users/userstest/get_access_keys_200.json create mode 100644 internal/api/v1/user/users/userstest/get_xpub_200.json create mode 100644 internal/api/v1/user/users/userstest/patch_xpub_metadata_200.json create mode 100644 internal/api/v1/user/users/userstest/post_access_key_200.json create mode 100644 internal/api/v1/user/users/userstest/xpub_api_fixtures.go create mode 100644 internal/api/v1/user/users/xpub_api.go create mode 100644 internal/api/v1/user/users/xpub_api_test.go create mode 100644 queries/access_key.go diff --git a/client.go b/client.go index 9e882751..11c55f42 100644 --- a/client.go +++ b/client.go @@ -14,6 +14,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" @@ -46,6 +47,8 @@ func NewDefaultConfig(addr string) Config { // interact with both user and admin APIs without needing to manage the details // of the HTTP requests and responses directly. type Client struct { + xpubAPI *users.XPubAPI + accessKeyAPI *users.AccessKeyAPI configsAPI *configs.API contactsAPI *contacts.API invitationsAPI *invitations.API @@ -270,6 +273,81 @@ func (c *Client) Transaction(ctx context.Context, ID string) (*response.Transact return res, nil } +// XPub retrieves the complete xpub information for the current user. +// The server's response is expected to be unmarshaled into a *response.Xpub struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) XPub(ctx context.Context) (*response.Xpub, error) { + res, err := c.xpubAPI.XPub(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve xpub information from the users API: %w", err) + } + + return res, nil +} + +// UpdateXPubMetadata updates the metadata associated with the current user's xpub. +// The server's response is expected to be unmarshaled into a *response.Xpub struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) { + res, err := c.xpubAPI.UpdateXPubMetadata(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to update xpub metadata using the users API: %w", err) + } + + return res, nil +} + +// GenerateAccessKey creates a new access key associated with the current user's xpub. +// The server's response is expected to be unmarshaled into a *response.AccessKey struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) { + res, err := c.accessKeyAPI.GenerateAccessKey(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to generate access key using the user access key API: %w", err) + } + + return res, nil +} + +// AccessKeys retrieves a paginated list of access keys from the user access keys API. +// The response includes access keys and pagination details, such as the page number, +// sort order, and sorting field (sortBy). +// +// This method allows optional query parameters to be applied via the provided query options. +// The response is expected to unmarshal into a *queries.AccessKeyPage struct. +// If the API request fails or the response cannot be decoded successfully, an error is returned. +func (c *Client) AccessKeys(ctx context.Context, accessKeyOpts ...queries.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { + res, err := c.accessKeyAPI.AccessKeys(ctx, accessKeyOpts...) + if err != nil { + return nil, fmt.Errorf("failed to retrieve access keys page from the user access key API: %w", err) + } + + return res, nil +} + +// AccessKey retrieves the access key associated with the specified ID. +// The server's response is expected to be unmarshaled into a *response.AccessKey struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (c *Client) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) { + res, err := c.accessKeyAPI.AccessKey(ctx, ID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve access key using the user access key API: %w", err) + } + + return res, nil +} + +// RevokeAccessKey revokes the access key associated with the given ID. +// If the request fails or the response cannot be processed, an error is returned. +func (c *Client) RevokeAccessKey(ctx context.Context, ID string) error { + err := c.accessKeyAPI.RevokeAccessKey(ctx, ID) + if err != nil { + return fmt.Errorf("failed to revoke access key using the users API: %w", err) + } + + return nil +} + // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API // does not match the expected expected format or structure. var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") @@ -296,6 +374,8 @@ func newClient(cfg Config, auth authenticator) *Client { httpClient := newRestyClient(cfg, auth) return &Client{ configsAPI: configs.NewAPI(cfg.Addr, httpClient), + accessKeyAPI: users.NewAccessKeyAPI(cfg.Addr, httpClient), + xpubAPI: users.NewXPubAPI(cfg.Addr, httpClient), contactsAPI: contacts.NewAPI(cfg.Addr, httpClient), invitationsAPI: invitations.NewAPI(cfg.Addr, httpClient), transactionsAPI: transactions.NewAPI(cfg.Addr, httpClient), diff --git a/commands/users.go b/commands/users.go new file mode 100644 index 00000000..d689003d --- /dev/null +++ b/commands/users.go @@ -0,0 +1,13 @@ +package commands + +// UpdateXPubMetadata contains the parameters needed to update the metadata +// associated with the current user's xpub. +type UpdateXPubMetadata struct { + Metadata map[string]any `json:"metadata"` // Key-value pairs representing the xpub metadata +} + +// GenerateAccessKey contains the parameters needed to generate a new access key +// for the current user, including any associated metadata. +type GenerateAccessKey struct { + Metadata map[string]any `json:"metadata"` // Key-value pairs representing the access key metadata +} diff --git a/internal/api/v1/user/users/access_key_api.go b/internal/api/v1/user/users/access_key_api.go new file mode 100644 index 00000000..d1cfc1ee --- /dev/null +++ b/internal/api/v1/user/users/access_key_api.go @@ -0,0 +1,101 @@ +package users + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +type AccessKeyAPI struct { + addr string + httpClient *resty.Client +} + +func (a *AccessKeyAPI) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) { + var result response.AccessKey + + URL := a.addr + "/keys" + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(cmd). + Post(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) { + var result response.AccessKey + + URL := a.addr + "/keys/" + ID + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + Get(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) AccessKeys(ctx context.Context, opts ...queries.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { + var query queries.AccessKeyQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&accessKeyFilterQueryBuilder{ + accessKeyFilter: query.AccessKeyFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.AccessKeyFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build access keys query params: %w", err) + } + + var result response.PageModel[response.AccessKey] + URL := a.addr + "/keys" + _, err = a.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(URL) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *AccessKeyAPI) RevokeAccessKey(ctx context.Context, ID string) error { + URL := a.addr + "/keys/" + ID + _, err := a.httpClient.R(). + SetContext(ctx). + Delete(URL) + if err != nil { + return fmt.Errorf("HTTP response failure: %w", err) + } + + return nil +} + +func NewAccessKeyAPI(addr string, httpClient *resty.Client) *AccessKeyAPI { + return &AccessKeyAPI{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/users/access_key_api_test.go b/internal/api/v1/user/users/access_key_api_test.go new file mode 100644 index 00000000..f95c8a4f --- /dev/null +++ b/internal/api/v1/user/users/access_key_api_test.go @@ -0,0 +1,197 @@ +package users_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) { + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *response.AccessKey + expectedErr error + }{ + "HTTP POST /api/v1/users/current/keys response: 200": { + expectedResponse: userstest.ExpectedCreatedAccessKey(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/post_access_key_200.json")), + }, + "HTTP POST /api/v1/users/current/keys response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/users/current/keys str response: 500": { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + got, err := wallet.GenerateAccessKey(context.Background(), &commands.GenerateAccessKey{ + Metadata: map[string]any{ + "example_key": "example_value", + }, + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestAccessKeyAPI_AccessKey(t *testing.T) { + ID := "1fb70cc2-e9d9-41a3-842e-f71cc58d9787" + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *response.AccessKey + expectedErr error + }{ + fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 200", ID): { + expectedResponse: userstest.ExpectedRertrivedAccessKey(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_key_200.json")), + }, + fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 400", ID): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s str response: 500", ID): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.AccessKey(context.Background(), ID) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestAccessKeyAPI_AccessKeys(t *testing.T) { + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *queries.AccessKeyPage + expectedErr error + }{ + "HTTP GET /api/v1/users/current/keys response: 200": { + expectedResponse: userstest.ExpectedAccessKeyPage(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_keys_200.json")), + }, + "HTTP GET /api/v1/users/current/keys response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/users/current/keys str response: 500": { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.AccessKeys(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) { + ID := "081743f7-040e-45a3-8c36-dde39001e20d" + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedErr error + }{ + fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 200", ID): { + code: http.StatusOK, + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + }, + fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 400", ID): { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s str response: 500", ID): { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + + // then: + err := wallet.RevokeAccessKey(context.Background(), ID) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} diff --git a/internal/api/v1/user/users/access_key_filter_query_builder.go b/internal/api/v1/user/users/access_key_filter_query_builder.go new file mode 100644 index 00000000..38357ba6 --- /dev/null +++ b/internal/api/v1/user/users/access_key_filter_query_builder.go @@ -0,0 +1,29 @@ +package users + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type accessKeyFilterQueryBuilder struct { + accessKeyFilter filter.AccessKeyFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (a *accessKeyFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := a.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("revokedRange", a.accessKeyFilter.RevokedRange) + return params.Values, nil +} diff --git a/internal/api/v1/user/users/access_key_filter_query_builder_test.go b/internal/api/v1/user/users/access_key_filter_query_builder_test.go new file mode 100644 index 00000000..1b012798 --- /dev/null +++ b/internal/api/v1/user/users/access_key_filter_query_builder_test.go @@ -0,0 +1,79 @@ +package users + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestAccessKeyFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.AccessKeyFilter + expectedParams url.Values + expectedErr error + }{ + "access key filter: zero values": { + expectedParams: make(url.Values), + }, + "access key filter: filter with only 'revoked range' field set": { + filter: filter.AccessKeyFilter{ + RevokedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + expectedParams: url.Values{ + "revokedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "revokedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + }, + "access key filter: all fields set": { + filter: filter.AccessKeyFilter{ + RevokedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + ModelFilter: filter.ModelFilter{ + IncludeDeleted: userstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: url.Values{ + "revokedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "revokedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := accessKeyFilterQueryBuilder{ + accessKeyFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/users/userstest/access_key_api_fixtures.go b/internal/api/v1/user/users/userstest/access_key_api_fixtures.go new file mode 100644 index 00000000..ebaf9222 --- /dev/null +++ b/internal/api/v1/user/users/userstest/access_key_api_fixtures.go @@ -0,0 +1,87 @@ +package userstest + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedCreatedAccessKey(t *testing.T) *response.AccessKey { + return &response.AccessKey{ + Model: response.Model{ + Metadata: map[string]interface{}{ + "key": "value", + }, + CreatedAt: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: ParseTime(t, "2024-11-13T12:44:04.954844+01:00"), + }, + ID: "d8558b86-9382-4c42-8ebe-7cca5d8de60b", + XpubID: "345cef2e-36a7-4c28-b0a7-948bfdb03e5e", + Key: "dbb23e77-0467-4262-a0ef-3d30653866ae", + } +} + +func ExpectedRertrivedAccessKey(t *testing.T) *response.AccessKey { + return &response.AccessKey{ + Model: response.Model{ + Metadata: map[string]interface{}{ + "key": "value", + }, + CreatedAt: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: ParseTime(t, "2024-11-13T11:44:04.954844Z"), + }, + ID: "1fb70cc2-e9d9-41a3-842e-f71cc58d9787", + XpubID: "e8d7d52f-01a1-4466-87fe-25a2225ef5e4", + } +} + +func ExpectedAccessKeyPage(t *testing.T) *queries.AccessKeyPage { + ts1 := ParseTime(t, "2024-11-13T11:54:36.987563Z") + ts2 := ParseTime(t, "2024-11-08T13:43:18.599995Z") + return &queries.AccessKeyPage{ + Content: []*response.AccessKey{ + { + Model: response.Model{ + Metadata: map[string]interface{}{ + "key_1": "value_1", + }, + CreatedAt: ParseTime(t, "2024-11-13T11:44:04.95481Z"), + UpdatedAt: ParseTime(t, "2024-11-13T11:54:36.988715Z"), + }, + ID: "1f0504cd-d42d-4334-a441-a88a53aa47f8", + XpubID: "b271ae7e-ab17-4504-94c1-3a888f8b042a", + RevokedAt: &ts1, + }, + { + Model: response.Model{ + Metadata: map[string]interface{}{ + "key_2": "value_2", + }, + CreatedAt: ParseTime(t, "2024-11-13T11:07:43.595835Z"), + UpdatedAt: ParseTime(t, "2024-11-13T11:07:43.595876Z"), + }, + ID: "41943e46-6999-409e-8dfd-d36ee75f1702", + XpubID: "3e32dd04-72bd-4cc5-92da-123c29708472", + }, + { + Model: response.Model{ + Metadata: map[string]interface{}{ + "key_3": "value_3", + }, + CreatedAt: ParseTime(t, "2024-11-08T13:43:18.554228Z"), + UpdatedAt: ParseTime(t, "2024-11-08T13:43:18.60036Z"), + }, + ID: "41a87305-88f9-4d86-91f8-b2401078aaf9", + XpubID: "a035a7f0-2381-4d45-8a2d-197dd961f031", + RevokedAt: &ts2, + }, + }, + Page: response.PageDescription{ + Size: 50, + Number: 1, + TotalElements: 7, + TotalPages: 1, + }, + } +} diff --git a/internal/api/v1/user/users/userstest/get_access_key_200.json b/internal/api/v1/user/users/userstest/get_access_key_200.json new file mode 100644 index 00000000..bf12135c --- /dev/null +++ b/internal/api/v1/user/users/userstest/get_access_key_200.json @@ -0,0 +1,10 @@ +{ + "createdAt": "2024-11-13T11:44:04.95481Z", + "updatedAt": "2024-11-13T11:44:04.954844Z", + "deletedAt": null, + "metadata": { + "key": "value" + }, + "id": "1fb70cc2-e9d9-41a3-842e-f71cc58d9787", + "xpubId": "e8d7d52f-01a1-4466-87fe-25a2225ef5e4" + } diff --git a/internal/api/v1/user/users/userstest/get_access_keys_200.json b/internal/api/v1/user/users/userstest/get_access_keys_200.json new file mode 100644 index 00000000..403124a1 --- /dev/null +++ b/internal/api/v1/user/users/userstest/get_access_keys_200.json @@ -0,0 +1,42 @@ +{ + "content": [ + { + "createdAt": "2024-11-13T11:44:04.95481Z", + "updatedAt": "2024-11-13T11:54:36.988715Z", + "deletedAt": null, + "metadata": { + "key_1": "value_1" + }, + "id": "1f0504cd-d42d-4334-a441-a88a53aa47f8", + "xpubId": "b271ae7e-ab17-4504-94c1-3a888f8b042a", + "revokedAt": "2024-11-13T11:54:36.987563Z" + }, + { + "createdAt": "2024-11-13T11:07:43.595835Z", + "updatedAt": "2024-11-13T11:07:43.595876Z", + "deletedAt": null, + "metadata": { + "key_2": "value_2" + }, + "id": "41943e46-6999-409e-8dfd-d36ee75f1702", + "xpubId": "3e32dd04-72bd-4cc5-92da-123c29708472" + }, + { + "createdAt": "2024-11-08T13:43:18.554228Z", + "updatedAt": "2024-11-08T13:43:18.60036Z", + "deletedAt": null, + "metadata": { + "key_3": "value_3" + }, + "id": "41a87305-88f9-4d86-91f8-b2401078aaf9", + "xpubId": "a035a7f0-2381-4d45-8a2d-197dd961f031", + "revokedAt": "2024-11-08T13:43:18.599995Z" + } + ], + "page": { + "size": 50, + "number": 1, + "totalElements": 7, + "totalPages": 1 + } +} diff --git a/internal/api/v1/user/users/userstest/get_xpub_200.json b/internal/api/v1/user/users/userstest/get_xpub_200.json new file mode 100644 index 00000000..a9f13bfc --- /dev/null +++ b/internal/api/v1/user/users/userstest/get_xpub_200.json @@ -0,0 +1,14 @@ +{ + "createdAt": "2024-10-07T13:39:07.886862Z", + "updatedAt": "2024-11-12T11:31:07.741621Z", + "deletedAt": null, + "metadata": { + "metadata": { + "key": "value" + } + }, + "id": "af64633f-b2ce-441e-9d61-acda0884eb53", + "currentBalance": 315, + "nextInternalNum": 13, + "nextExternalNum": 2 + } diff --git a/internal/api/v1/user/users/userstest/patch_xpub_metadata_200.json b/internal/api/v1/user/users/userstest/patch_xpub_metadata_200.json new file mode 100644 index 00000000..a83c12fe --- /dev/null +++ b/internal/api/v1/user/users/userstest/patch_xpub_metadata_200.json @@ -0,0 +1,14 @@ +{ + "createdAt": "2024-10-07T13:39:07.886862Z", + "updatedAt": "2024-11-13T11:41:56.115402Z", + "deletedAt": null, + "metadata": { + "metadata": { + "key": "value" + } + }, + "id": "1356cc11-8164-4364-afa4-59f096a79eb5", + "currentBalance": 315, + "nextInternalNum": 13, + "nextExternalNum": 2 + } diff --git a/internal/api/v1/user/users/userstest/post_access_key_200.json b/internal/api/v1/user/users/userstest/post_access_key_200.json new file mode 100644 index 00000000..b88bc813 --- /dev/null +++ b/internal/api/v1/user/users/userstest/post_access_key_200.json @@ -0,0 +1,11 @@ +{ + "createdAt": "2024-11-13T11:44:04.95481Z", + "updatedAt": "2024-11-13T12:44:04.954844+01:00", + "deletedAt": null, + "metadata": { + "key": "value" + }, + "id": "d8558b86-9382-4c42-8ebe-7cca5d8de60b", + "xpubId": "345cef2e-36a7-4c28-b0a7-948bfdb03e5e", + "key": "dbb23e77-0467-4262-a0ef-3d30653866ae" +} diff --git a/internal/api/v1/user/users/userstest/xpub_api_fixtures.go b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go new file mode 100644 index 00000000..231eab7d --- /dev/null +++ b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go @@ -0,0 +1,66 @@ +package userstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func ExpectedUpdatedXPubMetadata(t *testing.T) *response.Xpub { + return &response.Xpub{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T13:39:07.886862Z"), + UpdatedAt: ParseTime(t, "2024-11-13T11:41:56.115402Z"), + Metadata: map[string]any{ + "metadata": map[string]any{ + "key": "value", + }, + }, + }, + ID: "1356cc11-8164-4364-afa4-59f096a79eb5", + CurrentBalance: 315, + NextInternalNum: 13, + NextExternalNum: 2, + } +} + +func ExpectedUserXPub(t *testing.T) *response.Xpub { + return &response.Xpub{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-10-07T13:39:07.886862Z"), + UpdatedAt: ParseTime(t, "2024-11-12T11:31:07.741621Z"), + Metadata: map[string]any{ + "metadata": map[string]any{ + "key": "value", + }, + }, + }, + ID: "af64633f-b2ce-441e-9d61-acda0884eb53", + CurrentBalance: 315, + NextInternalNum: 13, + NextExternalNum: 2, + } +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func ParseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} + +func Ptr[T any](value T) *T { + return &value +} diff --git a/internal/api/v1/user/users/xpub_api.go b/internal/api/v1/user/users/xpub_api.go new file mode 100644 index 00000000..e8a78006 --- /dev/null +++ b/internal/api/v1/user/users/xpub_api.go @@ -0,0 +1,52 @@ +package users + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/users/current" + +type XPubAPI struct { + addr string + httpClient *resty.Client +} + +func (x *XPubAPI) XPub(ctx context.Context) (*response.Xpub, error) { + var result response.Xpub + _, err := x.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + Get(x.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (x *XPubAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) { + var result response.Xpub + _, err := x.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(cmd). + Patch(x.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewXPubAPI(addr string, httpClient *resty.Client) *XPubAPI { + return &XPubAPI{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/users/xpub_api_test.go b/internal/api/v1/user/users/xpub_api_test.go new file mode 100644 index 00000000..81f2775a --- /dev/null +++ b/internal/api/v1/user/users/xpub_api_test.go @@ -0,0 +1,108 @@ +package users_test + +import ( + "context" + "net/http" + "testing" + + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestXPubAPI_UpdateXPubMetadata(t *testing.T) { + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *response.Xpub + expectedErr error + }{ + "HTTP PATCH /api/v1/users/current response: 200": { + expectedResponse: userstest.ExpectedUpdatedXPubMetadata(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/patch_xpub_metadata_200.json")), + }, + "HTTP PATCH /api/v1/users/current response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP PATCH /api/v1/users/current str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodPatch, URL, tc.responder) + + // then: + got, err := wallet.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{ + Metadata: map[string]any{ + "example_key": "example_value", + }, + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestXPubAPI_XPub(t *testing.T) { + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *response.Xpub + expectedErr error + }{ + "HTTP GET /api/v1/users/current/ response: 200": { + expectedResponse: userstest.ExpectedUserXPub(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_xpub_200.json")), + }, + "HTTP GET /api/v1/users/current response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/users/current str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/users/current" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.XPub(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} diff --git a/queries/access_key.go b/queries/access_key.go new file mode 100644 index 00000000..8f92b366 --- /dev/null +++ b/queries/access_key.go @@ -0,0 +1,47 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// AccessKeyPage is an alias for the access key response page model +// returned by the SPV Wallet API, which contains a paginated list of +// access keys along with pagination metadata. +type AccessKeyPage = response.PageModel[response.AccessKey] + +// AccessKeyQuery aggregates query parameters for constructing the search access key endpoint URL. +// It holds filters for metadata, pagination, and specific access key attributes. +type AccessKeyQuery struct { + Metadata map[string]any // Metadata filters for the search. + PageFilter filter.Page // Pagination details (page number, size, sorting). + AccessKeyFilter filter.AccessKeyFilter // Filters for access key properties (date ranges, deletion status). +} + +// AccessKeyQueryOption defines a functional option for configuring an AccessKeyQuery instance. +type AccessKeyQueryOption func(*AccessKeyQuery) + +// AccessKeyQueryWithMetadataFilter adds metadata filters to the search access key endpoint URL. +// The specified metadata attributes will be appended as query parameters. +func AccessKeyQueryWithMetadataFilter(m map[string]any) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.Metadata = m + } +} + +// AccessKeyQueryWithAccessKeyFilter adds filters for access key properties, such as date ranges +// (created, updated, revoked) and a flag indicating deletion status. These will be appended +// as query parameters to the search access key endpoint URL. +func AccessKeyQueryWithAccessKeyFilter(f filter.AccessKeyFilter) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.AccessKeyFilter = f + } +} + +// AccessKeyQueryWithPageFilter adds pagination details, such as page number, page size, and sorting +// options, to the search access key endpoint URL as query parameters. +func AccessKeyQueryWithPageFilter(f filter.Page) AccessKeyQueryOption { + return func(akq *AccessKeyQuery) { + akq.PageFilter = f + } +} From 2da8faefa8511aaa76c6e74a1216d316698118a6 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Mon, 18 Nov 2024 11:17:45 +0100 Subject: [PATCH 10/18] refactor(SPV-1206): add user utxos API implementation. (#16) --- client.go | 20 +++ .../user/utxos/utxo_filter_query_builder.go | 37 ++++ .../utxos/utxo_filter_query_builder_test.go | 165 ++++++++++++++++++ internal/api/v1/user/utxos/utxos_api.go | 57 ++++++ internal/api/v1/user/utxos/utxos_api_test.go | 59 +++++++ .../user/utxos/utxostest/get_utxos_200.json | 95 ++++++++++ .../user/utxos/utxostest/utxo_api_fixtures.go | 123 +++++++++++++ queries/utxos.go | 46 +++++ 8 files changed, 602 insertions(+) create mode 100644 internal/api/v1/user/utxos/utxo_filter_query_builder.go create mode 100644 internal/api/v1/user/utxos/utxo_filter_query_builder_test.go create mode 100644 internal/api/v1/user/utxos/utxos_api.go create mode 100644 internal/api/v1/user/utxos/utxos_api_test.go create mode 100644 internal/api/v1/user/utxos/utxostest/get_utxos_200.json create mode 100644 internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go create mode 100644 queries/utxos.go diff --git a/client.go b/client.go index 11c55f42..f832f7cf 100644 --- a/client.go +++ b/client.go @@ -15,6 +15,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" @@ -53,6 +54,7 @@ type Client struct { contactsAPI *contacts.API invitationsAPI *invitations.API transactionsAPI *transactions.API + utxosAPI *utxos.API } // NewWithXPub creates a new client instance using an extended public key (xPub). @@ -348,6 +350,22 @@ func (c *Client) RevokeAccessKey(ctx context.Context, ID string) error { return nil } +// UTXOs fetches a paginated list of UTXOs from the user UTXOs API. +// The response includes UTXOs along with pagination details, such as page number, +// sort order, and sorting field. +// +// Optional query parameters can be applied using the provided query options. +// The response is unmarshaled into a *queries.UtxosPage struct. +// Returns an error if the API request fails or the response cannot be decoded. +func (c *Client) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*queries.UtxosPage, error) { + res, err := c.utxosAPI.UTXOs(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to retrieve UTXOs page from the user UTXOs API: %w", err) + } + + return res, nil +} + // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API // does not match the expected expected format or structure. var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") @@ -372,8 +390,10 @@ type authenticator interface { func newClient(cfg Config, auth authenticator) *Client { httpClient := newRestyClient(cfg, auth) + return &Client{ configsAPI: configs.NewAPI(cfg.Addr, httpClient), + utxosAPI: utxos.NewAPI(cfg.Addr, httpClient), accessKeyAPI: users.NewAccessKeyAPI(cfg.Addr, httpClient), xpubAPI: users.NewXPubAPI(cfg.Addr, httpClient), contactsAPI: contacts.NewAPI(cfg.Addr, httpClient), diff --git a/internal/api/v1/user/utxos/utxo_filter_query_builder.go b/internal/api/v1/user/utxos/utxo_filter_query_builder.go new file mode 100644 index 00000000..60481963 --- /dev/null +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder.go @@ -0,0 +1,37 @@ +package utxos + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type utxoFilterQueryBuilder struct { + utxoFilter filter.UtxoFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (u *utxoFilterQueryBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := u.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("transactionId", u.utxoFilter.TransactionID) + params.AddPair("outputIndex", u.utxoFilter.OutputIndex) + params.AddPair("id", u.utxoFilter.ID) + params.AddPair("satoshis", u.utxoFilter.Satoshis) + params.AddPair("scriptPubKey", u.utxoFilter.ScriptPubKey) + params.AddPair("type", u.utxoFilter.Type) + params.AddPair("draftId", u.utxoFilter.DraftID) + params.AddPair("reservedRange", u.utxoFilter.ReservedRange) + params.AddPair("spendingTxId", u.utxoFilter.SpendingTxID) + return params.Values, nil +} diff --git a/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go new file mode 100644 index 00000000..f63ae845 --- /dev/null +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go @@ -0,0 +1,165 @@ +package utxos + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestUtoxFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.UtxoFilter + expectedParams url.Values + expectedErr error + }{ + "utxo filter: zero values": { + expectedParams: make(url.Values), + }, + "utxo filter: filter with only 'transaction id' field set": { + expectedParams: url.Values{ + "transactionId": []string{"124c2237-9b54-46c4-bf53-3cec86f7e316"}, + }, + filter: filter.UtxoFilter{ + TransactionID: utxostest.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"), + }, + }, + "utxo filter: filter with only 'output index' field set": { + expectedParams: url.Values{ + "outputIndex": []string{"32"}, + }, + filter: filter.UtxoFilter{ + OutputIndex: utxostest.Ptr(uint32(32)), + }, + }, + "utxo filter: filter with only 'id' field set": { + expectedParams: url.Values{ + "id": []string{"abb6a871-dd95-4f7a-8090-ca34cff63801"}, + }, + filter: filter.UtxoFilter{ + ID: utxostest.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"), + }, + }, + "utxo filter: filter with only 'satoshis' field set": { + expectedParams: url.Values{ + "satoshis": []string{"64"}, + }, + filter: filter.UtxoFilter{ + Satoshis: utxostest.Ptr(uint64(64)), + }, + }, + "utxo filter: filter with only 'script pub key' field set": { + expectedParams: url.Values{ + "scriptPubKey": []string{"3adec124-32eb-46f1-94f2-4949a86dbe8d"}, + }, + filter: filter.UtxoFilter{ + ScriptPubKey: utxostest.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"), + }, + }, + "utxo filter: filter with only 'type' field set": { + expectedParams: url.Values{ + "type": []string{"0f65e842-decf-4725-8ad9-877634280e4f"}, + }, + filter: filter.UtxoFilter{ + Type: utxostest.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"), + }, + }, + "utxo filter: filter with only 'draft id' field set": { + expectedParams: url.Values{ + "draftId": []string{"2453797c-4089-4078-8723-5ecb68e70bd7"}, + }, + filter: filter.UtxoFilter{ + DraftID: utxostest.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"), + }, + }, + "utxo filter: filter with only reserved range 'from' field set": { + expectedParams: url.Values{ + "reservedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only reserved range 'to' field set": { + expectedParams: url.Values{ + "reservedRange[to]": []string{"2021-02-02T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only reserved range field set": { + expectedParams: url.Values{ + "reservedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "reservedRange[from]": []string{"2021-02-01T00:00:00Z"}, + }, + filter: filter.UtxoFilter{ + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + "utxo filter: filter with only 'spending tx id' field set": { + expectedParams: url.Values{ + "spendingTxId": []string{"7539366c-beb2-4405-8597-025bf2dc7cbd"}, + }, + filter: filter.UtxoFilter{ + SpendingTxID: utxostest.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"), + }, + }, + "utxo filter: all fields set": { + expectedParams: url.Values{ + "scriptPubKey": []string{"3adec124-32eb-46f1-94f2-4949a86dbe8d"}, + "draftId": []string{"2453797c-4089-4078-8723-5ecb68e70bd7"}, + "reservedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "reservedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "transactionId": []string{"124c2237-9b54-46c4-bf53-3cec86f7e316"}, + "spendingTxId": []string{"7539366c-beb2-4405-8597-025bf2dc7cbd"}, + "type": []string{"0f65e842-decf-4725-8ad9-877634280e4f"}, + "satoshis": []string{"64"}, + "id": []string{"abb6a871-dd95-4f7a-8090-ca34cff63801"}, + "outputIndex": []string{"32"}, + }, + filter: filter.UtxoFilter{ + SpendingTxID: utxostest.Ptr("7539366c-beb2-4405-8597-025bf2dc7cbd"), + DraftID: utxostest.Ptr("2453797c-4089-4078-8723-5ecb68e70bd7"), + Type: utxostest.Ptr("0f65e842-decf-4725-8ad9-877634280e4f"), + ScriptPubKey: utxostest.Ptr("3adec124-32eb-46f1-94f2-4949a86dbe8d"), + ID: utxostest.Ptr("abb6a871-dd95-4f7a-8090-ca34cff63801"), + OutputIndex: utxostest.Ptr(uint32(32)), + Satoshis: utxostest.Ptr(uint64(64)), + TransactionID: utxostest.Ptr("124c2237-9b54-46c4-bf53-3cec86f7e316"), + ReservedRange: &filter.TimeRange{ + To: utxostest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + From: utxostest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := utxoFilterQueryBuilder{ + utxoFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ + ModelFilter: tc.filter.ModelFilter, + }, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/user/utxos/utxos_api.go b/internal/api/v1/user/utxos/utxos_api.go new file mode 100644 index 00000000..e5237136 --- /dev/null +++ b/internal/api/v1/user/utxos/utxos_api.go @@ -0,0 +1,57 @@ +package utxos + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/utxos" + +type API struct { + addr string + httpClient *resty.Client +} + +func (a *API) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*queries.UtxosPage, error) { + var query queries.UtxoQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&utxoFilterQueryBuilder{ + utxoFilter: query.UtxoFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.UtxoFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build utxo query params: %w", err) + } + + var result queries.UtxosPage + _, err = a.httpClient. + R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(a.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewAPI(addr string, httpClient *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/utxos/utxos_api_test.go b/internal/api/v1/user/utxos/utxos_api_test.go new file mode 100644 index 00000000..23a8e6dd --- /dev/null +++ b/internal/api/v1/user/utxos/utxos_api_test.go @@ -0,0 +1,59 @@ +package utxos_test + +import ( + "context" + "net/http" + "testing" + + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestUTXOAPI_UTXOs(t *testing.T) { + tests := map[string]struct { + code int + responder httpmock.Responder + statusCode int + expectedResponse *queries.UtxosPage + expectedErr error + }{ + "HTTP GET /api/v1/utxos response: 200": { + expectedResponse: utxostest.ExpectedUtxosPage(t), + code: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("utxostest/get_utxos_200.json")), + }, + "HTTP GET /api/v1/utxos response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, utxostest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/utxos str response: 500": { + expectedErr: client.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/utxos" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.UTXOs(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/user/utxos/utxostest/get_utxos_200.json b/internal/api/v1/user/utxos/utxostest/get_utxos_200.json new file mode 100644 index 00000000..d13b4723 --- /dev/null +++ b/internal/api/v1/user/utxos/utxostest/get_utxos_200.json @@ -0,0 +1,95 @@ +{ + "content": [ + { + "createdAt": "2024-11-12T11:31:07.728974Z", + "updatedAt": "2024-11-12T11:31:07.732139Z", + "deletedAt": null, + "metadata": null, + "transactionId": "f365f697-3db9-44fd-bd0d-ba8e94ca63f2", + "outputIndex": 0, + "id": "db9bdd87-432d-44e6-b08f-9c0abd0d90ef", + "xpubId": "0f8ff805-a282-48d6-be70-8b607deba5f1", + "satoshis": 100, + "scriptPubKey": "88ca49f2-816e-4a0b-b5c5-e5c574e2d292", + "type": "pubkeyhash", + "reservedAt": "0001-01-01T00:00:00Z", + "spendingTxId": "", + "transaction": { + "createdAt": "2024-11-12T11:31:07.72894Z", + "updatedAt": "2024-11-12T12:33:35.266758Z", + "deletedAt": null, + "metadata": { + "domain": "john.doe.test.space", + "ip_address": "127.0.0.1", + "p2p_tx_metadata": { + "pubkey": "d90c6998-010a-466f-83d7-25c39188a1c5", + "sender": "john.doe@test.com" + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "81fbfb26-e648-463e-99ce-ade498774c8f", + "user_agent": "node-fetch" + }, + "id": "ec943c46-bfa8-4764-820a-a604c8b6c890", + "hex": "d825088b-1f04-406a-b046-059bc0736b11", + "xpubInIds": null, + "xpubOutIds": [ + "6e980e21-a8f8-4699-9d11-98aef96bdf98" + ], + "blockHash": "a7755931-eceb-473e-ab5b-6a6459948166", + "blockHeight": 1024, + "fee": 0, + "numberOfInputs": 2, + "numberOfOutputs": 3, + "draftId": "", + "totalValue": 1305, + "status": "MINED", + "direction": "outgoing" + } + }, + { + "createdAt": "2024-11-08T13:40:55.592Z", + "updatedAt": "2024-11-08T13:40:55.593441Z", + "deletedAt": null, + "metadata": null, + "transactionId": "54ed5bcb-a964-47af-892b-1054065c28a8", + "outputIndex": 1, + "id": "7ed4a935-6b62-4e83-9d97-7e9a7f9eab30", + "xpubId": "68019cf3-616c-4d14-b9bf-cd9486b63f4f", + "satoshis": 18, + "scriptPubKey": "5e63148d-f506-43fb-88c3-2d98491625da", + "type": "pubkeyhash", + "draftId": "", + "reservedAt": "0001-01-01T00:00:00Z", + "spendingTxId": "", + "transaction": { + "createdAt": "2024-11-08T13:40:55.591986Z", + "updatedAt": "2024-11-08T14:43:56.256571Z", + "deletedAt": null, + "metadata": null, + "id": "29b89717-f139-45ae-9848-f2d7415ea596", + "hex": "6a1c1ddb-f3c1-4491-98b4-9ce3eb016e60", + "xpubInIds": [ + "32dfa8c9-82e3-4f49-8d33-ff7130e1cfae" + ], + "xpubOutIds": [ + "b0559e5f-b4b5-416f-b1f8-116f19a89f30" + ], + "blockHash": "f90fb747-4cec-4e00-912a-582d46090d61", + "blockHeight": 2048, + "fee": 1, + "numberOfInputs": 2, + "numberOfOutputs": 2, + "draftId": "057a743c-4c97-444b-b6ac-8b4a757aee8c", + "totalValue": 0, + "status": "MINED", + "direction": "outgoing" + } + } + ], + "page": { + "size": 2, + "number": 1, + "totalElements": 9, + "totalPages": 5 + } +} diff --git a/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go new file mode 100644 index 00000000..aabd4ed2 --- /dev/null +++ b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go @@ -0,0 +1,123 @@ +package utxostest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func ParseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} + +func Ptr[T any](value T) *T { + return &value +} + +func ExpectedUtxosPage(t *testing.T) *queries.UtxosPage { + return &queries.UtxosPage{ + Content: []*response.Utxo{ + { + ID: "db9bdd87-432d-44e6-b08f-9c0abd0d90ef", + XpubID: "0f8ff805-a282-48d6-be70-8b607deba5f1", + Satoshis: 100, + ScriptPubKey: "88ca49f2-816e-4a0b-b5c5-e5c574e2d292", + Type: "pubkeyhash", + ReservedAt: ParseTime(t, "0001-01-01T00:00:00Z"), + UtxoPointer: response.UtxoPointer{ + TransactionID: "f365f697-3db9-44fd-bd0d-ba8e94ca63f2", + OutputIndex: 0, + }, + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-12T11:31:07.728974Z"), + UpdatedAt: ParseTime(t, "2024-11-12T11:31:07.732139Z"), + }, + Transaction: &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-12T11:31:07.72894Z"), + UpdatedAt: ParseTime(t, "2024-11-12T12:33:35.266758Z"), + + Metadata: map[string]any{ + "domain": "john.doe.test.space", + "ip_address": "127.0.0.1", + "p2p_tx_metadata": map[string]any{ + "pubkey": "d90c6998-010a-466f-83d7-25c39188a1c5", + "sender": "john.doe@test.com", + }, + "paymail_request": "HandleReceivedP2pTransaction", + "reference_id": "81fbfb26-e648-463e-99ce-ade498774c8f", + "user_agent": "node-fetch", + }, + }, + ID: "ec943c46-bfa8-4764-820a-a604c8b6c890", + Hex: "d825088b-1f04-406a-b046-059bc0736b11", + XpubOutIDs: []string{"6e980e21-a8f8-4699-9d11-98aef96bdf98"}, + BlockHash: "a7755931-eceb-473e-ab5b-6a6459948166", + BlockHeight: 1024, + NumberOfInputs: 2, + NumberOfOutputs: 3, + TotalValue: 1305, + Status: "MINED", + TransactionDirection: "outgoing", + }, + }, + { + ID: "7ed4a935-6b62-4e83-9d97-7e9a7f9eab30", + XpubID: "68019cf3-616c-4d14-b9bf-cd9486b63f4f", + Satoshis: 18, + ScriptPubKey: "5e63148d-f506-43fb-88c3-2d98491625da", + Type: "pubkeyhash", + ReservedAt: ParseTime(t, "0001-01-01T00:00:00Z"), + UtxoPointer: response.UtxoPointer{ + TransactionID: "54ed5bcb-a964-47af-892b-1054065c28a8", + OutputIndex: 1, + }, + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-08T13:40:55.592Z"), + UpdatedAt: ParseTime(t, "2024-11-08T13:40:55.593441Z"), + }, + Transaction: &response.Transaction{ + Model: response.Model{ + CreatedAt: ParseTime(t, "2024-11-08T13:40:55.591986Z"), + UpdatedAt: ParseTime(t, "2024-11-08T14:43:56.256571Z"), + }, + ID: "29b89717-f139-45ae-9848-f2d7415ea596", + Hex: "6a1c1ddb-f3c1-4491-98b4-9ce3eb016e60", + XpubInIDs: []string{"32dfa8c9-82e3-4f49-8d33-ff7130e1cfae"}, + XpubOutIDs: []string{"b0559e5f-b4b5-416f-b1f8-116f19a89f30"}, + BlockHash: "f90fb747-4cec-4e00-912a-582d46090d61", + BlockHeight: 2048, + Fee: 1, + NumberOfInputs: 2, + NumberOfOutputs: 2, + DraftID: "057a743c-4c97-444b-b6ac-8b4a757aee8c", + TotalValue: 0, + Status: "MINED", + TransactionDirection: "outgoing", + }, + }, + }, + Page: response.PageDescription{ + Size: 2, + Number: 1, + TotalElements: 9, + TotalPages: 5, + }, + } +} diff --git a/queries/utxos.go b/queries/utxos.go new file mode 100644 index 00000000..6856789a --- /dev/null +++ b/queries/utxos.go @@ -0,0 +1,46 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// UtxosPage is an alias for the UTXOs response page model returned by the SPV Wallet API. +// It contains a paginated list of UTXOs along with pagination metadata. +type UtxosPage = response.PageModel[response.Utxo] + +// UtxoQuery aggregates query parameters for constructing the UTXOs search endpoint URL. +// It includes filters for metadata, pagination, and specific UTXO attributes. +type UtxoQuery struct { + Metadata map[string]any // Filters based on metadata attributes. + PageFilter filter.Page // Pagination details, such as page number, size, and sort order. + UtxoFilter filter.UtxoFilter // Filters for UTXO properties (e.g., date ranges, transaction ID, satoshis, status). +} + +// UtxoQueryOption defines a functional option for configuring a UtxoQuery instance. +type UtxoQueryOption func(*UtxoQuery) + +// UtxoQueryWithMetadataFilter applies metadata filters to the UTXOs search endpoint URL. +// The provided metadata attributes are added as query parameters. +func UtxoQueryWithMetadataFilter(m map[string]any) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.Metadata = m + } +} + +// UtxoQueryWithPageFilter sets pagination details for the UTXOs search endpoint URL. +// This includes page number, page size, and sort order, added as query parameters. +func UtxoQueryWithPageFilter(pf filter.Page) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.PageFilter = pf + } +} + +// UtxoQueryWithUtxoFilter applies filters for UTXO properties to the search endpoint URL. +// These include reserved date ranges (e.g., created, updated), transaction ID, output index, +// satoshis, etc., added as query parameters. +func UtxoQueryWithUtxoFilter(uf filter.UtxoFilter) UtxoQueryOption { + return func(uq *UtxoQuery) { + uq.UtxoFilter = uf + } +} From 4d0325afee0eb20ba0aad1086cc98218393c3c0f Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Mon, 18 Nov 2024 12:49:35 +0100 Subject: [PATCH 11/18] refactor(SPV-1183): add user merkleroots api implementation (#11) --- client.go | 24 +++++++- .../v1/user/merkleroots/merkleroots_api.go | 49 +++++++++++++++ .../user/merkleroots/merkleroots_api_test.go | 58 ++++++++++++++++++ .../merkleroots/merkleroots_filter_builder.go | 19 ++++++ .../merkleroots_filter_builder_test.go | 61 +++++++++++++++++++ .../merklerootstest/get_merkleroots_200.json | 23 +++++++ .../merkleroots_api_fixtures.go | 46 ++++++++++++++ queries/merkle_roots.go | 38 ++++++++++++ 8 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/api/v1/user/merkleroots/merkleroots_api.go create mode 100644 internal/api/v1/user/merkleroots/merkleroots_api_test.go create mode 100644 internal/api/v1/user/merkleroots/merkleroots_filter_builder.go create mode 100644 internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go create mode 100644 queries/merkle_roots.go diff --git a/client.go b/client.go index f832f7cf..a2c884af 100644 --- a/client.go +++ b/client.go @@ -13,6 +13,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" @@ -51,6 +52,7 @@ type Client struct { xpubAPI *users.XPubAPI accessKeyAPI *users.AccessKeyAPI configsAPI *configs.API + merkleRootsAPI *merkleroots.API contactsAPI *contacts.API invitationsAPI *invitations.API transactionsAPI *transactions.API @@ -366,6 +368,25 @@ func (c *Client) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*q return res, nil } +// MerkleRoots retrieves a paginated list of Merkle roots from the user Merkle roots API. +// The API response includes Merkle roots along with pagination details, such as the current +// page number, sort order, and sorting field (sortBy). +// +// This method supports optional query parameters, which can be specified using the provided +// query options. These options customize the behavior of the API request, such as setting +// batch size or applying filters for pagination. +// +// The response is unmarshaled into a *queries.MerkleRootPage struct. If the API request fails +// or the response cannot be successfully decoded, an error is returned. +func (c *Client) MerkleRoots(ctx context.Context, opts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) { + res, err := c.merkleRootsAPI.MerkleRoots(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to retrieve Merkle roots from the API: %w", err) + } + + return res, nil +} + // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API // does not match the expected expected format or structure. var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") @@ -392,13 +413,14 @@ func newClient(cfg Config, auth authenticator) *Client { httpClient := newRestyClient(cfg, auth) return &Client{ + merkleRootsAPI: merkleroots.NewAPI(cfg.Addr, httpClient), configsAPI: configs.NewAPI(cfg.Addr, httpClient), + transactionsAPI: transactions.NewAPI(cfg.Addr, httpClient), utxosAPI: utxos.NewAPI(cfg.Addr, httpClient), accessKeyAPI: users.NewAccessKeyAPI(cfg.Addr, httpClient), xpubAPI: users.NewXPubAPI(cfg.Addr, httpClient), contactsAPI: contacts.NewAPI(cfg.Addr, httpClient), invitationsAPI: invitations.NewAPI(cfg.Addr, httpClient), - transactionsAPI: transactions.NewAPI(cfg.Addr, httpClient), } } diff --git a/internal/api/v1/user/merkleroots/merkleroots_api.go b/internal/api/v1/user/merkleroots/merkleroots_api.go new file mode 100644 index 00000000..477a5275 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_api.go @@ -0,0 +1,49 @@ +package merkleroots + +import ( + "context" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/merkleroots" + +type API struct { + addr string + httpClient *resty.Client +} + +func (a *API) MerkleRoots(ctx context.Context, merkleRootOpts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) { + var query queries.MerkleRootsQuery + for _, o := range merkleRootOpts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder(querybuilders.WithFilterQueryBuilder(&merkleRootsFilterQueryBuilder{query: query})) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build merkle roots query params: %w", err) + } + + var result queries.MerkleRootPage + _, err = a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(a.addr) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewAPI(addr string, httpClient *resty.Client) *API { + return &API{ + addr: addr + "/" + route, + httpClient: httpClient, + } +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go new file mode 100644 index 00000000..7d75f440 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -0,0 +1,58 @@ +package merkleroots_test + +import ( + "context" + "net/http" + "testing" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestMerkleRootsAPI_MerkleRoots(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + statusCode int + expectedResponse *queries.MerkleRootPage + expectedErr error + }{ + "HTTP GET /api/v1/merkleroots response: 200": { + statusCode: http.StatusOK, + expectedResponse: merklerootstest.ExpectedMerkleRootsPage(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_200.json")), + }, + "HTTP GET /api/v1/merkleroots response: 400": { + expectedErr: models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + }, + statusCode: http.StatusOK, + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, merklerootstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/merkleroots str response: 500": { + expectedErr: wallet.ErrUnrecognizedAPIResponse, + statusCode: http.StatusInternalServerError, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := clienttest.TestAPIAddr + "/api/v1/merkleroots" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := spvWalletClient.MerkleRoots(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go new file mode 100644 index 00000000..408d99fc --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go @@ -0,0 +1,19 @@ +package merkleroots + +import ( + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" +) + +type merkleRootsFilterQueryBuilder struct { + query queries.MerkleRootsQuery +} + +func (m *merkleRootsFilterQueryBuilder) Build() (url.Values, error) { + params := querybuilders.NewExtendedURLValues() + params.AddPair("batchSize", m.query.BatchSize) + params.AddPair("lastEvaluatedKey", m.query.LastEvaluatedKey) + return params.Values, nil +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go b/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go new file mode 100644 index 00000000..a9a41759 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_filter_builder_test.go @@ -0,0 +1,61 @@ +package merkleroots + +import ( + "net/url" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/stretchr/testify/require" +) + +func TestMerklerootsFilterQueryBuilder_Build(t *testing.T) { + tests := map[string]struct { + query queries.MerkleRootsQuery + expectedParams url.Values + expectedErr error + }{ + "merkle roots query: zero value": { + expectedParams: make(url.Values), + }, + "merkle roots query: query with 'batch size' set only": { + query: queries.MerkleRootsQuery{ + BatchSize: 10, + }, + expectedParams: url.Values{ + "batchSize": []string{"10"}, + }, + }, + "merkle roots query: query with 'last evaluated key' set only": { + query: queries.MerkleRootsQuery{ + LastEvaluatedKey: "key", + }, + expectedParams: url.Values{ + "lastEvaluatedKey": []string{"key"}, + }, + }, + "merkle roots query: all fields set": { + query: queries.MerkleRootsQuery{ + BatchSize: 10, + LastEvaluatedKey: "key", + }, + expectedParams: url.Values{ + "batchSize": []string{"10"}, + "lastEvaluatedKey": []string{"key"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := merkleRootsFilterQueryBuilder{ + query: tc.query, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, got, tc.expectedParams) + }) + } +} diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json new file mode 100644 index 00000000..3ef31de8 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_200.json @@ -0,0 +1,23 @@ +{ + "content": [ + { + "blockHeight": 1, + "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689" + }, + { + "blockHeight": 2, + "merkleRoot": "132a2a38-b23f-404b-940f-f811de886114" + }, + { + "blockHeight": 3, + "merkleRoot": "d229c224-6c21-4c68-ba25-261119e9b8dc" + } + ], + "page": { + "lastEvaluatedKey": "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6", + "orderByField": "blockHeight", + "size": 20, + "sortDirection": "asc", + "totalElements": 10 + } + } diff --git a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go new file mode 100644 index 00000000..588f5d6e --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go @@ -0,0 +1,46 @@ +package merklerootstest + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" +) + +func ExpectedMerkleRootsPage() *queries.MerkleRootPage { + return &queries.MerkleRootPage{ + Content: []models.MerkleRoot{ + { + MerkleRoot: "d02ab7b5-ac3e-4612-9377-9bffe05ac689", + BlockHeight: 1, + }, + { + MerkleRoot: "132a2a38-b23f-404b-940f-f811de886114", + BlockHeight: 2, + }, + { + MerkleRoot: "d229c224-6c21-4c68-ba25-261119e9b8dc", + BlockHeight: 3, + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + OrderByField: Ptr("blockHeight"), + SortDirection: Ptr("asc"), + TotalElements: 10, + Size: 20, + LastEvaluatedKey: "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6", + }, + } +} + +func Ptr[T any](value T) *T { + return &value +} + +func NewBadRequestSPVError() *models.SPVError { + return &models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} diff --git a/queries/merkle_roots.go b/queries/merkle_roots.go new file mode 100644 index 00000000..fae2350d --- /dev/null +++ b/queries/merkle_roots.go @@ -0,0 +1,38 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models" +) + +// MerkleRootPage is an alias for the Merkle roots response page model +// returned by the SPV Wallet API. It provides a paginated list of Merkle roots +// along with pagination metadata. +type MerkleRootPage = models.MerkleRootsBHSResponse + +// MerkleRootsQuery aggregates query parameters for constructing a URL to retrieve Merkle roots. +// These parameters, such as BatchSize and LastEvaluatedKey, control how the API request is processed. +type MerkleRootsQuery struct { + BatchSize int // The number of Merkle roots to fetch in a single API request. + LastEvaluatedKey string // A key used for pagination, indicating where to continue the query. +} + +// MerkleRootsQueryOption defines a functional option for customizing a MerkleRootsQuery. +// These options allow for flexible configuration by applying filters like batch size or +// the last evaluated key for pagination. +type MerkleRootsQueryOption func(*MerkleRootsQuery) + +// MerkleRootsQueryWithBatchSize returns a MerkleRootsQueryOption to set the batch size for the query. +// This option specifies how many Merkle roots should be retrieved in a single API request. +func MerkleRootsQueryWithBatchSize(n int) MerkleRootsQueryOption { + return func(q *MerkleRootsQuery) { + q.BatchSize = n + } +} + +// MerkleRootsQueryWithLastEvaluatedKey returns a MerkleRootsQueryOption to set the last evaluated key for pagination. +// This option uses the last processed Merkle root in the client's database to continue the query. +func MerkleRootsQueryWithLastEvaluatedKey(key string) MerkleRootsQueryOption { + return func(q *MerkleRootsQuery) { + q.LastEvaluatedKey = key + } +} From dbceb5cbc50b82b2ee15bfbc537110160b5b4587 Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Tue, 19 Nov 2024 11:56:08 +0100 Subject: [PATCH 12/18] refactor(SPV-1209): fix url path building in user APIs. (#17) --- client.go | 41 ++++++++++++------- .../configs/{configs.go => configs_api.go} | 9 ++-- .../{configs_test.go => configs_api_test.go} | 0 internal/api/v1/user/contacts/contacts_api.go | 30 ++++++-------- .../v1/user/invitations/invitations_api.go | 13 +++--- .../v1/user/merkleroots/merkleroots_api.go | 9 ++-- .../{transactions.go => transactions_api.go} | 28 ++++++------- ...tions_test.go => transactions_api_test.go} | 32 +++++++-------- internal/api/v1/user/users/access_key_api.go | 19 ++++----- internal/api/v1/user/users/xpub_api.go | 11 ++--- internal/api/v1/user/utxos/utxos_api.go | 9 ++-- 11 files changed, 102 insertions(+), 99 deletions(-) rename internal/api/v1/user/configs/{configs.go => configs_api.go} (76%) rename internal/api/v1/user/configs/{configs_test.go => configs_api_test.go} (100%) rename internal/api/v1/user/transactions/{transactions.go => transactions_api.go} (85%) rename internal/api/v1/user/transactions/{transactions_test.go => transactions_api_test.go} (88%) diff --git a/client.go b/client.go index a2c884af..f23580f3 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "time" bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" @@ -72,7 +73,7 @@ func NewWithXPub(cfg Config, xPub string) (*Client, error) { return nil, fmt.Errorf("failed to intialized xpub authenticator: %w", err) } - return newClient(cfg, authenticator), nil + return newClient(cfg, authenticator) } // NewWithXPriv creates a new client instance using an extended private key (xPriv). @@ -89,7 +90,7 @@ func NewWithXPriv(cfg Config, xPriv string) (*Client, error) { return nil, fmt.Errorf("failed to intialized xpriv authenticator: %w", err) } - return newClient(cfg, authenticator), nil + return newClient(cfg, authenticator) } // NewWithAccessKey creates a new client instance using an access key. @@ -107,7 +108,7 @@ func NewWithAccessKey(cfg Config, accessKey string) (*Client, error) { return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) } - return newClient(cfg, authenticator), nil + return newClient(cfg, authenticator) } // Contacts retrieves a paginated list of user contacts from the user contacts API. @@ -122,6 +123,7 @@ func (c *Client) Contacts(ctx context.Context, contactOpts ...queries.ContactQue if err != nil { return nil, fmt.Errorf("failed to retrieve contacts from the user contacts API: %w", err) } + return res, nil } @@ -133,6 +135,7 @@ func (c *Client) ContactWithPaymail(ctx context.Context, paymail string) (*respo if err != nil { return nil, fmt.Errorf("failed to retrieve contact by paymail from the user contacts API: %w", err) } + return res, nil } @@ -144,6 +147,7 @@ func (c *Client) UpsertContact(ctx context.Context, cmd commands.UpsertContact) if err != nil { return nil, fmt.Errorf("failed to upsert contact using the user contacts API: %w", err) } + return res, nil } @@ -154,6 +158,7 @@ func (c *Client) RemoveContact(ctx context.Context, paymail string) error { if err != nil { return fmt.Errorf("failed to remove contact using the user contacts API: %w", err) } + return nil } @@ -164,6 +169,7 @@ func (c *Client) ConfirmContact(ctx context.Context, paymail string) error { if err != nil { return fmt.Errorf("failed to confirm contact using the user contacts API: %w", err) } + return nil } @@ -174,6 +180,7 @@ func (c *Client) UnconfirmContact(ctx context.Context, paymail string) error { if err != nil { return fmt.Errorf("failed to unconfirm contact using the user contacts API: %w", err) } + return nil } @@ -184,6 +191,7 @@ func (c *Client) AcceptInvitation(ctx context.Context, paymail string) error { if err != nil { return fmt.Errorf("failed to accept invitation using the user invitations API: %w", err) } + return nil } @@ -194,6 +202,7 @@ func (c *Client) RejectInvitation(ctx context.Context, paymail string) error { if err != nil { return fmt.Errorf("failed to reject invitation using the user invitations API: %w", err) } + return nil } @@ -409,19 +418,23 @@ type authenticator interface { Authenticate(r *resty.Request) error } -func newClient(cfg Config, auth authenticator) *Client { - httpClient := newRestyClient(cfg, auth) +func newClient(cfg Config, auth authenticator) (*Client, error) { + url, err := url.Parse(cfg.Addr) + if err != nil { + return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) + } + httpClient := newRestyClient(cfg, auth) return &Client{ - merkleRootsAPI: merkleroots.NewAPI(cfg.Addr, httpClient), - configsAPI: configs.NewAPI(cfg.Addr, httpClient), - transactionsAPI: transactions.NewAPI(cfg.Addr, httpClient), - utxosAPI: utxos.NewAPI(cfg.Addr, httpClient), - accessKeyAPI: users.NewAccessKeyAPI(cfg.Addr, httpClient), - xpubAPI: users.NewXPubAPI(cfg.Addr, httpClient), - contactsAPI: contacts.NewAPI(cfg.Addr, httpClient), - invitationsAPI: invitations.NewAPI(cfg.Addr, httpClient), - } + merkleRootsAPI: merkleroots.NewAPI(url, httpClient), + configsAPI: configs.NewAPI(url, httpClient), + transactionsAPI: transactions.NewAPI(url, httpClient), + utxosAPI: utxos.NewAPI(url, httpClient), + accessKeyAPI: users.NewAccessKeyAPI(url, httpClient), + xpubAPI: users.NewXPubAPI(url, httpClient), + contactsAPI: contacts.NewAPI(url, httpClient), + invitationsAPI: invitations.NewAPI(url, httpClient), + }, nil } func newRestyClient(cfg Config, auth authenticator) *resty.Client { diff --git a/internal/api/v1/user/configs/configs.go b/internal/api/v1/user/configs/configs_api.go similarity index 76% rename from internal/api/v1/user/configs/configs.go rename to internal/api/v1/user/configs/configs_api.go index c7a4a838..4313de78 100644 --- a/internal/api/v1/user/configs/configs.go +++ b/internal/api/v1/user/configs/configs_api.go @@ -3,6 +3,7 @@ package configs import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -11,7 +12,7 @@ import ( const route = "api/v1/configs" type API struct { - addr string + url *url.URL httpClient *resty.Client } @@ -21,7 +22,7 @@ func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) R(). SetContext(ctx). SetResult(&result). - Get(a.addr + "/shared") + Get(a.url.JoinPath("shared").String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -29,9 +30,9 @@ func (a *API) SharedConfig(ctx context.Context) (*response.SharedConfig, error) return &result, nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(url *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/configs/configs_test.go b/internal/api/v1/user/configs/configs_api_test.go similarity index 100% rename from internal/api/v1/user/configs/configs_test.go rename to internal/api/v1/user/configs/configs_api_test.go diff --git a/internal/api/v1/user/contacts/contacts_api.go b/internal/api/v1/user/contacts/contacts_api.go index 3c8a6b83..2e5b1248 100644 --- a/internal/api/v1/user/contacts/contacts_api.go +++ b/internal/api/v1/user/contacts/contacts_api.go @@ -3,6 +3,7 @@ package contacts import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" @@ -14,7 +15,7 @@ import ( const route = "api/v1/contacts" type API struct { - addr string + url *url.URL httpClient *resty.Client } @@ -45,7 +46,7 @@ func (a *API) Contacts(ctx context.Context, opts ...queries.ContactQueryOption) SetContext(ctx). SetResult(&result). SetQueryParams(params.ParseToMap()). - Get(a.addr) + Get(a.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -55,13 +56,11 @@ func (a *API) Contacts(ctx context.Context, opts ...queries.ContactQueryOption) func (a *API) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) { var result response.Contact - - URL := a.addr + "/" + paymail _, err := a.httpClient. R(). SetContext(ctx). SetResult(&result). - Get(URL) + Get(a.url.JoinPath(paymail).String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -69,16 +68,14 @@ func (a *API) ContactWithPaymail(ctx context.Context, paymail string) (*response return &result, nil } -func (a *API) UpsertContact(ctx context.Context, r commands.UpsertContact) (*response.Contact, error) { +func (a *API) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) { var result response.CreateContactResponse - - URL := a.addr + "/" + r.Paymail _, err := a.httpClient. R(). - SetBody(r). + SetBody(cmd). SetContext(ctx). SetResult(&result). - Put(URL) + Put(a.url.JoinPath(cmd.Paymail).String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -94,11 +91,10 @@ func (a *API) UpsertContact(ctx context.Context, r commands.UpsertContact) (*res } func (a *API) RemoveContact(ctx context.Context, paymail string) error { - URL := a.addr + "/" + paymail _, err := a.httpClient. R(). SetContext(ctx). - Delete(URL) + Delete(a.url.JoinPath(paymail).String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -107,11 +103,10 @@ func (a *API) RemoveContact(ctx context.Context, paymail string) error { } func (a *API) ConfirmContact(ctx context.Context, paymail string) error { - URL := a.addr + "/" + paymail + "/confirmation" _, err := a.httpClient. R(). SetContext(ctx). - Post(URL) + Post(a.url.JoinPath(paymail, "confirmation").String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -120,11 +115,10 @@ func (a *API) ConfirmContact(ctx context.Context, paymail string) error { } func (a *API) UnconfirmContact(ctx context.Context, paymail string) error { - URL := a.addr + "/" + paymail + "/confirmation" _, err := a.httpClient. R(). SetContext(ctx). - Delete(URL) + Delete(a.url.JoinPath(paymail, "confirmation").String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -132,9 +126,9 @@ func (a *API) UnconfirmContact(ctx context.Context, paymail string) error { return nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(url *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/invitations/invitations_api.go b/internal/api/v1/user/invitations/invitations_api.go index fa1795da..8734cf84 100644 --- a/internal/api/v1/user/invitations/invitations_api.go +++ b/internal/api/v1/user/invitations/invitations_api.go @@ -3,6 +3,7 @@ package invitations import ( "context" "fmt" + "net/url" "github.com/go-resty/resty/v2" ) @@ -10,16 +11,15 @@ import ( const route = "api/v1/invitations" type API struct { - addr string + url *url.URL httpClient *resty.Client } func (a *API) AcceptInvitation(ctx context.Context, paymail string) error { - URL := a.addr + "/" + paymail + "/contacts" _, err := a.httpClient. R(). SetContext(ctx). - Post(URL) + Post(a.url.JoinPath(paymail, "contacts").String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -28,11 +28,10 @@ func (a *API) AcceptInvitation(ctx context.Context, paymail string) error { } func (a *API) RejectInvitation(ctx context.Context, paymail string) error { - URL := a.addr + "/" + paymail _, err := a.httpClient. R(). SetContext(ctx). - Delete(URL) + Delete(a.url.JoinPath(paymail).String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -40,9 +39,9 @@ func (a *API) RejectInvitation(ctx context.Context, paymail string) error { return nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(url *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/merkleroots/merkleroots_api.go b/internal/api/v1/user/merkleroots/merkleroots_api.go index 477a5275..e2d929ab 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api.go @@ -3,6 +3,7 @@ package merkleroots import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -12,7 +13,7 @@ import ( const route = "api/v1/merkleroots" type API struct { - addr string + url *url.URL httpClient *resty.Client } @@ -33,7 +34,7 @@ func (a *API) MerkleRoots(ctx context.Context, merkleRootOpts ...queries.MerkleR SetContext(ctx). SetResult(&result). SetQueryParams(params.ParseToMap()). - Get(a.addr) + Get(a.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -41,9 +42,9 @@ func (a *API) MerkleRoots(ctx context.Context, merkleRootOpts ...queries.MerkleR return &result, nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(url *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/transactions/transactions.go b/internal/api/v1/user/transactions/transactions_api.go similarity index 85% rename from internal/api/v1/user/transactions/transactions.go rename to internal/api/v1/user/transactions/transactions_api.go index babd8888..f18cfa0f 100644 --- a/internal/api/v1/user/transactions/transactions.go +++ b/internal/api/v1/user/transactions/transactions_api.go @@ -3,6 +3,7 @@ package transactions import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" @@ -14,19 +15,18 @@ import ( const route = "api/v1/transactions" type API struct { - addr string + url *url.URL httpClient *resty.Client } func (a *API) DraftTransaction(ctx context.Context, r *commands.DraftTransaction) (*response.DraftTransaction, error) { var result response.DraftTransaction - URL := a.addr + "/drafts" _, err := a.httpClient.R(). SetContext(ctx). SetResult(&result). SetBody(r). - Post(URL) + Post(a.url.JoinPath("drafts").String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -41,7 +41,7 @@ func (a *API) RecordTransaction(ctx context.Context, r *commands.RecordTransacti SetContext(ctx). SetResult(&result). SetBody(r). - Post(a.addr) + Post(a.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -52,12 +52,11 @@ func (a *API) RecordTransaction(ctx context.Context, r *commands.RecordTransacti func (a *API) UpdateTransactionMetadata(ctx context.Context, r *commands.UpdateTransactionMetadata) (*response.Transaction, error) { var result response.Transaction - URL := a.addr + "/" + r.ID _, err := a.httpClient.R(). SetContext(ctx). SetResult(&result). SetBody(r). - Patch(URL) + Patch(a.url.JoinPath(r.ID).String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -68,11 +67,10 @@ func (a *API) UpdateTransactionMetadata(ctx context.Context, r *commands.UpdateT func (a *API) Transaction(ctx context.Context, ID string) (*response.Transaction, error) { var result response.Transaction - URL := a.addr + "/" + ID _, err := a.httpClient.R(). SetContext(ctx). SetResult(&result). - Get(URL) + Get(a.url.JoinPath(ID).String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -86,16 +84,14 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran o(&query) } - builderOpts := []querybuilders.QueryBuilderOption{ - querybuilders.WithMetadataFilter(query.Metadata), + queryBuilder := querybuilders.NewQueryBuilder(querybuilders.WithMetadataFilter(query.Metadata), querybuilders.WithPageFilter(query.Page), querybuilders.WithFilterQueryBuilder(&transactionFilterBuilder{ TransactionFilter: query.Filter, ModelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.Filter.ModelFilter}, }), - } - builder := querybuilders.NewQueryBuilder(builderOpts...) - params, err := builder.Build() + ) + params, err := queryBuilder.Build() if err != nil { return nil, fmt.Errorf("failed to create transactions query params: %w", err) } @@ -106,7 +102,7 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran SetContext(ctx). SetResult(&result). SetQueryParams(params.ParseToMap()). - Get(a.addr) + Get(a.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -114,9 +110,9 @@ func (a *API) Transactions(ctx context.Context, transactionsOpts ...queries.Tran return &result, nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(URL *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: URL.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/transactions/transactions_test.go b/internal/api/v1/user/transactions/transactions_api_test.go similarity index 88% rename from internal/api/v1/user/transactions/transactions_test.go rename to internal/api/v1/user/transactions/transactions_api_test.go index 0d713f4f..eb33a88e 100644 --- a/internal/api/v1/user/transactions/transactions_test.go +++ b/internal/api/v1/user/transactions/transactions_api_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - client "github.com/bitcoin-sv/spv-wallet-go-client" + wallet "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" @@ -41,7 +41,7 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: wallet.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, @@ -51,11 +51,11 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodPatch, URL, tc.responder) // then: - got, err := wallet.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ + got, err := spvWalletClient.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ ID: ID, Metadata: querybuilders.Metadata{ "example_key1": "example_key10_val", @@ -90,7 +90,7 @@ func TestTransactionsAPI_RecordTransaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: wallet.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, @@ -100,11 +100,11 @@ func TestTransactionsAPI_RecordTransaction(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodPost, URL, tc.responder) // then: - got, err := wallet.RecordTransaction(context.Background(), &commands.RecordTransaction{}) + got, err := spvWalletClient.RecordTransaction(context.Background(), &commands.RecordTransaction{}) require.ErrorIs(t, err, tc.expectedErr) require.EqualValues(t, tc.expectedResponse, got) }) @@ -133,7 +133,7 @@ func TestTransactionsAPI_DraftTransaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP POST /api/v1/transactions/drafts str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: wallet.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, @@ -143,11 +143,11 @@ func TestTransactionsAPI_DraftTransaction(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodPost, URL, tc.responder) // then: - got, err := wallet.DraftTransaction(context.Background(), &commands.DraftTransaction{ + got, err := spvWalletClient.DraftTransaction(context.Background(), &commands.DraftTransaction{ Config: response.TransactionConfig{}, Metadata: map[string]any{}, }) @@ -180,7 +180,7 @@ func TestTransactionsAPI_Transaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: wallet.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, @@ -190,11 +190,11 @@ func TestTransactionsAPI_Transaction(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodGet, URL, tc.responder) // then: - got, err := wallet.Transaction(context.Background(), ID) + got, err := spvWalletClient.Transaction(context.Background(), ID) require.ErrorIs(t, err, tc.expectedErr) require.EqualValues(t, tc.expectedResponse, got) }) @@ -223,7 +223,7 @@ func TestTransactionsAPI_Transactions(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: wallet.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, @@ -233,11 +233,11 @@ func TestTransactionsAPI_Transactions(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) transport.RegisterResponder(http.MethodGet, URL, tc.responder) // then: - got, err := wallet.Transactions(context.Background()) + got, err := spvWalletClient.Transactions(context.Background()) require.ErrorIs(t, err, tc.expectedErr) require.EqualValues(t, tc.expectedResponse, got) }) diff --git a/internal/api/v1/user/users/access_key_api.go b/internal/api/v1/user/users/access_key_api.go index d1cfc1ee..9aaed9fe 100644 --- a/internal/api/v1/user/users/access_key_api.go +++ b/internal/api/v1/user/users/access_key_api.go @@ -3,6 +3,7 @@ package users import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" @@ -12,19 +13,18 @@ import ( ) type AccessKeyAPI struct { - addr string + url *url.URL httpClient *resty.Client } func (a *AccessKeyAPI) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) { var result response.AccessKey - URL := a.addr + "/keys" _, err := a.httpClient.R(). SetContext(ctx). SetResult(&result). SetBody(cmd). - Post(URL) + Post(a.url.JoinPath("keys").String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -35,11 +35,10 @@ func (a *AccessKeyAPI) GenerateAccessKey(ctx context.Context, cmd *commands.Gene func (a *AccessKeyAPI) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) { var result response.AccessKey - URL := a.addr + "/keys/" + ID _, err := a.httpClient.R(). SetContext(ctx). SetResult(&result). - Get(URL) + Get(a.url.JoinPath("keys", ID).String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -67,13 +66,12 @@ func (a *AccessKeyAPI) AccessKeys(ctx context.Context, opts ...queries.AccessKey } var result response.PageModel[response.AccessKey] - URL := a.addr + "/keys" _, err = a.httpClient. R(). SetContext(ctx). SetResult(&result). SetQueryParams(params.ParseToMap()). - Get(URL) + Get(a.url.JoinPath("keys").String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -82,10 +80,9 @@ func (a *AccessKeyAPI) AccessKeys(ctx context.Context, opts ...queries.AccessKey } func (a *AccessKeyAPI) RevokeAccessKey(ctx context.Context, ID string) error { - URL := a.addr + "/keys/" + ID _, err := a.httpClient.R(). SetContext(ctx). - Delete(URL) + Delete(a.url.JoinPath("keys", ID).String()) if err != nil { return fmt.Errorf("HTTP response failure: %w", err) } @@ -93,9 +90,9 @@ func (a *AccessKeyAPI) RevokeAccessKey(ctx context.Context, ID string) error { return nil } -func NewAccessKeyAPI(addr string, httpClient *resty.Client) *AccessKeyAPI { +func NewAccessKeyAPI(url *url.URL, httpClient *resty.Client) *AccessKeyAPI { return &AccessKeyAPI{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/users/xpub_api.go b/internal/api/v1/user/users/xpub_api.go index e8a78006..428893a0 100644 --- a/internal/api/v1/user/users/xpub_api.go +++ b/internal/api/v1/user/users/xpub_api.go @@ -3,6 +3,7 @@ package users import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet/models/response" @@ -12,7 +13,7 @@ import ( const route = "api/v1/users/current" type XPubAPI struct { - addr string + url *url.URL httpClient *resty.Client } @@ -22,7 +23,7 @@ func (x *XPubAPI) XPub(ctx context.Context) (*response.Xpub, error) { R(). SetContext(ctx). SetResult(&result). - Get(x.addr) + Get(x.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -36,7 +37,7 @@ func (x *XPubAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXP SetContext(ctx). SetResult(&result). SetBody(cmd). - Patch(x.addr) + Patch(x.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -44,9 +45,9 @@ func (x *XPubAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXP return &result, nil } -func NewXPubAPI(addr string, httpClient *resty.Client) *XPubAPI { +func NewXPubAPI(url *url.URL, httpClient *resty.Client) *XPubAPI { return &XPubAPI{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } diff --git a/internal/api/v1/user/utxos/utxos_api.go b/internal/api/v1/user/utxos/utxos_api.go index e5237136..7919f305 100644 --- a/internal/api/v1/user/utxos/utxos_api.go +++ b/internal/api/v1/user/utxos/utxos_api.go @@ -3,6 +3,7 @@ package utxos import ( "context" "fmt" + "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -12,7 +13,7 @@ import ( const route = "api/v1/utxos" type API struct { - addr string + url *url.URL httpClient *resty.Client } @@ -41,7 +42,7 @@ func (a *API) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*quer SetContext(ctx). SetResult(&result). SetQueryParams(params.ParseToMap()). - Get(a.addr) + Get(a.url.String()) if err != nil { return nil, fmt.Errorf("HTTP response failure: %w", err) } @@ -49,9 +50,9 @@ func (a *API) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*quer return &result, nil } -func NewAPI(addr string, httpClient *resty.Client) *API { +func NewAPI(url *url.URL, httpClient *resty.Client) *API { return &API{ - addr: addr + "/" + route, + url: url.JoinPath(route), httpClient: httpClient, } } From b823c1f13055f940387559794b5c66c4112d8842 Mon Sep 17 00:00:00 2001 From: augustyn chmiel <149666032+ac4ch@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:13:04 +0100 Subject: [PATCH 13/18] Feat/spv-1207/migrate totp operations logic from old spv wallet go client (#18) Co-authored-by: Augustyn Chmiel Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: chris-4chain <152964795+chris-4chain@users.noreply.github.com> --- client.go | 58 +++++-- errors/errors.go | 19 +++ go.mod | 2 + go.sum | 8 + .../api/v1/user/configs/configs_api_test.go | 8 +- .../api/v1/user/contacts/contacts_api_test.go | 67 ++++---- .../user/invitations/invitations_api_test.go | 14 +- .../user/merkleroots/merkleroots_api_test.go | 8 +- .../querybuilders/metadata_filter_builder.go | 12 +- .../metadata_filter_builder_test.go | 5 +- .../v1/user/querybuilders/query_builder.go | 5 +- .../user/querybuilders/query_builder_test.go | 3 +- internal/api/v1/user/totp/totp.go | 146 ++++++++++++++++++ internal/api/v1/user/totp/totp_test.go | 96 ++++++++++++ .../transactions/transactions_api_test.go | 32 ++-- .../api/v1/user/users/access_key_api_test.go | 26 ++-- internal/api/v1/user/users/xpub_api_test.go | 14 +- internal/api/v1/user/utxos/utxos_api_test.go | 8 +- internal/clienttest/clienttest.go | 55 +++++++ internal/clienttest/transportmock_wrapper.go | 58 +++++++ 20 files changed, 538 insertions(+), 106 deletions(-) create mode 100644 errors/errors.go create mode 100644 internal/api/v1/user/totp/totp.go create mode 100644 internal/api/v1/user/totp/totp_test.go create mode 100644 internal/clienttest/transportmock_wrapper.go diff --git a/client.go b/client.go index f23580f3..cea0b400 100644 --- a/client.go +++ b/client.go @@ -11,10 +11,12 @@ import ( bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" ec "github.com/bitcoin-sv/go-sdk/primitives/ec" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" @@ -58,6 +60,8 @@ type Client struct { invitationsAPI *invitations.API transactionsAPI *transactions.API utxosAPI *utxos.API + + totp *totp.Client //only available when using xPriv } // NewWithXPub creates a new client instance using an extended public key (xPub). @@ -72,8 +76,11 @@ func NewWithXPub(cfg Config, xPub string) (*Client, error) { if err != nil { return nil, fmt.Errorf("failed to intialized xpub authenticator: %w", err) } - - return newClient(cfg, authenticator) + client, err := newClient(cfg, authenticator) + if err != nil { + return nil, fmt.Errorf("failed to create new client: %w", err) + } + return client, nil } // NewWithXPriv creates a new client instance using an extended private key (xPriv). @@ -90,7 +97,14 @@ func NewWithXPriv(cfg Config, xPriv string) (*Client, error) { return nil, fmt.Errorf("failed to intialized xpriv authenticator: %w", err) } - return newClient(cfg, authenticator) + client, err := newClient(cfg, authenticator) + if err != nil { + return nil, fmt.Errorf("failed to create new client: %w", err) + } + + client.totp = totp.New(key) + + return client, nil } // NewWithAccessKey creates a new client instance using an access key. @@ -162,10 +176,13 @@ func (c *Client) RemoveContact(ctx context.Context, paymail string) error { return nil } -// ConfirmContact confirms a user contact using the user contacts API. -// If the API request fails, an error is returned. -func (c *Client) ConfirmContact(ctx context.Context, paymail string) error { - err := c.contactsAPI.ConfirmContact(ctx, paymail) +// ConfirmContact checks the TOTP code and if it's ok, confirms user's contact using the user contacts API. +func (c *Client) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { + if err := c.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + return fmt.Errorf("failed to validate TOTP for contact: %w", err) + } + + err := c.contactsAPI.ConfirmContact(ctx, contact.Paymail) if err != nil { return fmt.Errorf("failed to confirm contact using the user contacts API: %w", err) } @@ -396,9 +413,28 @@ func (c *Client) MerkleRoots(ctx context.Context, opts ...queries.MerkleRootsQue return res, nil } -// ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API -// does not match the expected expected format or structure. -var ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") +// GenerateTotpForContact generates a TOTP code for the specified contact. +func (c *Client) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { + if c.totp == nil { + return "", errors.New("totp client not initialized - xPriv authentication required") + } + totp, err := c.totp.GenerateTotpForContact(contact, period, digits) + if err != nil { + return "", fmt.Errorf("failed to generate TOTP for contact: %w", err) + } + return totp, nil +} + +// ValidateTotpForContact validates a TOTP code for the specified contact. +func (c *Client) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { + if c.totp == nil { + return errors.New("totp client not initialized - xPriv authentication required") + } + if err := c.totp.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + return fmt.Errorf("failed to validate TOTP for contact: %w", err) + } + return nil +} func privateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { pk, err1 := ec.PrivateKeyFromWif(s) @@ -455,6 +491,6 @@ func newRestyClient(cfg Config, auth authenticator) *resty.Client { return spvError } - return fmt.Errorf("%w: %s", ErrUnrecognizedAPIResponse, r.Body()) + return fmt.Errorf("%w: %s", goclienterr.ErrUnrecognizedAPIResponse, r.Body()) }) } diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 00000000..37e2cfc5 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,19 @@ +package errors + +import "errors" + +var ( + // ErrMissingXpriv is returned when the xpriv is missing. + ErrMissingXpriv = errors.New("xpriv is missing") + // ErrContactPubKeyInvalid is returned when the contact's PubKey is invalid. + ErrContactPubKeyInvalid = errors.New("contact's PubKey is invalid") + // ErrMetadataFilterMaxDepthExceeded is returned when the maximum depth of nesting in metadata map is exceeded. + ErrMetadataFilterMaxDepthExceeded = errors.New("maximum depth of nesting in metadata map exceeded") + // ErrMetadataWrongTypeInArray is returned when the wrong type is in the array. + ErrMetadataWrongTypeInArray = errors.New("wrong type in array") + // ErrFilterQueryBuilder is returned when the filter query builder fails to build the operation. + ErrFilterQueryBuilder = errors.New("filter query builder - build operation failure") + // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API + // does not match the expected format or structure. + ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") +) diff --git a/go.mod b/go.mod index d68efbae..f4b1253e 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.22.5 require ( github.com/bitcoin-sv/go-sdk v1.1.9 github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 + github.com/pquerna/otp v1.4.0 github.com/stretchr/testify v1.9.0 ) require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect golang.org/x/net v0.27.0 // indirect diff --git a/go.sum b/go.sum index d82f9ef3..ab2e3196 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,10 @@ github.com/bitcoin-sv/go-sdk v1.1.9 h1:N/LlZUMHNYKjEBuY72c3XSlzUI/q7IN34R0p6J0Qt github.com/bitcoin-sv/go-sdk v1.1.9/go.mod h1:NOAkJLbjqKOLuxJmb9ABG86ExTZp4HS8+iygiDIUps4= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31 h1:Y7JZ1oxjQnINGuDxK7VMOQiTCCuEm3BXC/SLhpaZoPs= github.com/bitcoin-sv/spv-wallet/models v1.0.0-beta.31/go.mod h1:PEJdH9ZWKOiKHyOZkzYsRbKuZjzlRaEJy3GsM75Icdo= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= @@ -21,11 +24,16 @@ github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwU github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= diff --git a/internal/api/v1/user/configs/configs_api_test.go b/internal/api/v1/user/configs/configs_api_test.go index adb15ced..73036604 100644 --- a/internal/api/v1/user/configs/configs_api_test.go +++ b/internal/api/v1/user/configs/configs_api_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" @@ -45,18 +45,18 @@ func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { }), }, "HTTP GET /api/v1/configs/shared str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/configs/shared" + url := clienttest.TestAPIAddr + "/api/v1/configs/shared" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.SharedConfig(context.Background()) diff --git a/internal/api/v1/user/contacts/contacts_api_test.go b/internal/api/v1/user/contacts/contacts_api_test.go index 9c4ab2b4..1a7990e7 100644 --- a/internal/api/v1/user/contacts/contacts_api_test.go +++ b/internal/api/v1/user/contacts/contacts_api_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - wallet "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -39,18 +39,18 @@ func TestContactsAPI_Contacts(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/contacts str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts" + url := clienttest.TestAPIAddr + "/api/v1/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.Contacts(context.Background()) @@ -83,18 +83,18 @@ func TestContactsAPI_ContactWithPaymail(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP GET /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.ContactWithPaymail(context.Background(), paymail) @@ -127,18 +127,18 @@ func TestContactsAPI_UpsertContact(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PUT /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPut, URL, tc.responder) + transport.RegisterResponder(http.MethodPut, url, tc.responder) // then: got, err := wallet.UpsertContact(context.Background(), commands.UpsertContact{ @@ -173,18 +173,18 @@ func TestContactsAPI_RemoveContact(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: err := wallet.RemoveContact(context.Background(), paymail) @@ -194,43 +194,58 @@ func TestContactsAPI_RemoveContact(t *testing.T) { } func TestContactsAPI_ConfirmContact(t *testing.T) { - paymail := "john.doe.test@john.doe.test.4chain.space" + contact := &models.Contact{ + Paymail: "alice@example.com", + PubKey: clienttest.MockPKI(t, clienttest.UserXPub), + } + tests := map[string]struct { responder httpmock.Responder statusCode int expectedErr error }{ - fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", paymail): { + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", contact.Paymail): { statusCode: http.StatusOK, responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, - fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", paymail): { + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", contact.Paymail): { expectedErr: models.SPVError{ Message: http.StatusText(http.StatusBadRequest), StatusCode: http.StatusBadRequest, Code: "invalid-data-format", }, - statusCode: http.StatusOK, + statusCode: http.StatusBadRequest, responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, - fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", contact.Paymail): { + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + url := clienttest.TestAPIAddr + "/api/v1/contacts/" + contact.Paymail + "/confirmation" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPost, URL, tc.responder) + wrappedTransport := clienttest.NewTransportWrapper() + aliceClient, _ := clienttest.GivenSPVWalletClientWithTransport(t, wrappedTransport) + wrappedTransport.RegisterResponder(http.MethodPost, url, tc.responder) + + passcode, err := aliceClient.GenerateTotpForContact(contact, 3600, 6) + require.NoError(t, err) // then: - err := wallet.ConfirmContact(context.Background(), paymail) + err = aliceClient.ConfirmContact(context.Background(), contact, passcode, contact.Paymail, 3600, 6) require.ErrorIs(t, err, tc.expectedErr) + + // Assert status code: + resp, respErr := wrappedTransport.GetResponse() + require.NoError(t, respErr) + require.NotNil(t, resp, "response should not be nil") + require.Equal(t, tc.statusCode, resp.StatusCode, "unexpected HTTP status code") + }) } } @@ -256,18 +271,18 @@ func TestContactsAPI_UnconfirmContact(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: err := wallet.UnconfirmContact(context.Background(), paymail) diff --git a/internal/api/v1/user/invitations/invitations_api_test.go b/internal/api/v1/user/invitations/invitations_api_test.go index 0eb1c29a..6ef4c740 100644 --- a/internal/api/v1/user/invitations/invitations_api_test.go +++ b/internal/api/v1/user/invitations/invitations_api_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" @@ -35,18 +35,18 @@ func TestInvitationsAPI_AcceptInvitation(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" + url := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPost, URL, tc.responder) + transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: err := wallet.AcceptInvitation(context.Background(), paymail) @@ -76,18 +76,18 @@ func TestInvitationsAPI_RejectInvitation(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s str response: 500", paymail): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + url := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: err := wallet.RejectInvitation(context.Background(), paymail) diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go index 7d75f440..039bc640 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api_test.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -36,18 +36,18 @@ func TestMerkleRootsAPI_MerkleRoots(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, merklerootstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/merkleroots str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/merkleroots" + url := clienttest.TestAPIAddr + "/api/v1/merkleroots" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := spvWalletClient.MerkleRoots(context.Background()) diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder.go b/internal/api/v1/user/querybuilders/metadata_filter_builder.go index 50437fe6..ee26f729 100644 --- a/internal/api/v1/user/querybuilders/metadata_filter_builder.go +++ b/internal/api/v1/user/querybuilders/metadata_filter_builder.go @@ -1,10 +1,11 @@ package querybuilders import ( - "errors" "fmt" "net/url" "reflect" + + "github.com/bitcoin-sv/spv-wallet-go-client/errors" ) type Metadata map[string]any @@ -51,7 +52,7 @@ func (m *MetadataFilterBuilder) Build() (url.Values, error) { func (m *MetadataFilterBuilder) generateQueryParams(depth int, path metadataPath, val any, params url.Values) error { if depth > m.MaxDepth { - return fmt.Errorf("%w - max depth: %d", ErrMetadataFilterMaxDepthExceeded, m.MaxDepth) + return fmt.Errorf("%w - max depth: %d", errors.ErrMetadataFilterMaxDepthExceeded, m.MaxDepth) } if val == nil { @@ -91,7 +92,7 @@ func (m *MetadataFilterBuilder) processSliceQueryParams(val any, path metadataPa // note: kind := item.Kind() is not enough, because it returns interface instead of actual underlying type kind := reflect.TypeOf(item.Interface()).Kind() if kind == reflect.Map || kind == reflect.Slice { - return ErrMetadataWrongTypeInArray + return errors.ErrMetadataWrongTypeInArray } arr[i] = item.Interface() @@ -100,8 +101,3 @@ func (m *MetadataFilterBuilder) processSliceQueryParams(val any, path metadataPa return nil } - -var ( - ErrMetadataFilterMaxDepthExceeded = errors.New("maximum depth of nesting in metadata map exceeded") - ErrMetadataWrongTypeInArray = errors.New("wrong type in array") -) diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go b/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go index 6e2be661..d6a61959 100644 --- a/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go +++ b/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go @@ -4,6 +4,7 @@ import ( "net/url" "testing" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/stretchr/testify/require" ) @@ -183,7 +184,7 @@ func TestMetadataFilterBuilder_Build(t *testing.T) { }, }, depth: 3, - expectedErr: querybuilders.ErrMetadataFilterMaxDepthExceeded, + expectedErr: errors.ErrMetadataFilterMaxDepthExceeded, }, "metadata: unsupported map in array": { metadata: querybuilders.Metadata{ @@ -196,7 +197,7 @@ func TestMetadataFilterBuilder_Build(t *testing.T) { }, }, depth: querybuilders.DefaultMaxDepth, - expectedErr: querybuilders.ErrMetadataWrongTypeInArray, + expectedErr: errors.ErrMetadataWrongTypeInArray, }, } diff --git a/internal/api/v1/user/querybuilders/query_builder.go b/internal/api/v1/user/querybuilders/query_builder.go index ccb33b16..4f39ad4e 100644 --- a/internal/api/v1/user/querybuilders/query_builder.go +++ b/internal/api/v1/user/querybuilders/query_builder.go @@ -4,6 +4,7 @@ import ( "errors" "net/url" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet/models/filter" ) @@ -56,7 +57,7 @@ func (q *QueryBuilder) Build() (*ExtendedURLValues, error) { for _, builder := range q.builders { values, err := builder.Build() if err != nil { - return nil, errors.Join(err, ErrFilterQueryBuilder) + return nil, errors.Join(err, goclienterr.ErrFilterQueryBuilder) } if len(values) > 0 { @@ -75,5 +76,3 @@ func NewQueryBuilder(opts ...QueryBuilderOption) *QueryBuilder { return &qb } - -var ErrFilterQueryBuilder = errors.New("filter query builder - build operation failure") diff --git a/internal/api/v1/user/querybuilders/query_builder_test.go b/internal/api/v1/user/querybuilders/query_builder_test.go index ff45dafb..918a8dbf 100644 --- a/internal/api/v1/user/querybuilders/query_builder_test.go +++ b/internal/api/v1/user/querybuilders/query_builder_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" "github.com/bitcoin-sv/spv-wallet/models/filter" @@ -104,7 +105,7 @@ func TestQueryBuilder_Build(t *testing.T) { }, }, builder: &filterQueryBuilderFailureStub{}, - expectedErr: querybuilders.ErrFilterQueryBuilder, + expectedErr: goclienterr.ErrFilterQueryBuilder, }, } diff --git a/internal/api/v1/user/totp/totp.go b/internal/api/v1/user/totp/totp.go new file mode 100644 index 00000000..3083545a --- /dev/null +++ b/internal/api/v1/user/totp/totp.go @@ -0,0 +1,146 @@ +package totp + +import ( + "encoding/base32" + "encoding/hex" + "fmt" + "time" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" + utils "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + // DefaultPeriod - Default number of seconds a TOTP is valid for. + DefaultPeriod uint = 30 + // DefaultDigits - Default TOTP length + DefaultDigits uint = 2 +) + +// Client handles TOTP generation and validation. +type Client struct { + xPriv *bip32.ExtendedKey +} + +// New creates a new TOTP WalletClient. +func New(xPriv *bip32.ExtendedKey) *Client { + return &Client{xPriv: xPriv} +} + +// GenerateTotpForContact generates a time-based one-time password (TOTP) for a contact. +func (b *Client) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { + sharedSecret, err := b.makeSharedSecret(contact) + if err != nil { + return "", fmt.Errorf("generateTotpForContact: error when making shared: %w", err) + } + + opts := getTotpOpts(period, digits) + passcode, err := totp.GenerateCodeCustom(directedSecret(sharedSecret, contact.Paymail), time.Now(), *opts) + if err != nil { + return "", fmt.Errorf("generateTotpForContact: error when generating TOTP: %w", err) + } + return passcode, nil +} + +// ValidateTotpForContact validates a TOTP for a contact. +func (b *Client) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { + sharedSecret, err := b.makeSharedSecret(contact) + if err != nil { + return fmt.Errorf("ValidateTotpForContact: error when making shared secret: %w", err) + } + + opts := getTotpOpts(period, digits) + valid, err := totp.ValidateCustom(passcode, directedSecret(sharedSecret, requesterPaymail), time.Now(), *opts) + if err != nil { + return fmt.Errorf("ValidateTotpForContact: error when validating TOTP: %w", err) + } + if !valid { + return fmt.Errorf("ValidateTotpForContact: TOTP is invalid") + } + return nil +} + +func (b *Client) makeSharedSecret(contact *models.Contact) ([]byte, error) { + privKey, pubKey, err := b.getSharedSecretFactors(contact) + if err != nil { + return nil, fmt.Errorf("makeSharedSecret: error when getting shared secret factors: %w", err) + } + + x, _ := ec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes()) + return x.Bytes(), nil +} + +func (b *Client) getSharedSecretFactors(contact *models.Contact) (*ec.PrivateKey, *ec.PublicKey, error) { + if b.xPriv == nil { + return nil, nil, errors.ErrMissingXpriv + } + + // Derive private key from xPriv for PKI operations. + xpriv, err := deriveXprivForPki(b.xPriv) + if err != nil { + return nil, nil, fmt.Errorf("getSharedSecretFactors: error when deriving xpriv for PKI: %w", err) + } + + privKey, err := xpriv.ECPrivKey() + if err != nil { + return nil, nil, fmt.Errorf("getSharedSecretFactors: error when deriving private key: %w", err) + } + + // Convert contact's public key. + pubKey, err := convertPubKey(contact.PubKey) + if err != nil { + return nil, nil, errors.ErrContactPubKeyInvalid + } + + return privKey, pubKey, nil +} + +func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) { + pkiXpriv, err := bip32.GetHDKeyByPath(xpriv, utils.ChainExternal, 0) + if err != nil { + return nil, fmt.Errorf("deriveXprivForPki: error when deriving xpriv for PKI: %w", err) + } + pki, err := pkiXpriv.Child(0) + if err != nil { + return nil, fmt.Errorf("deriveXprivForPki: error when deriving xpriv for PKI: %w", err) + } + return pki, nil +} + +func convertPubKey(pubKey string) (*ec.PublicKey, error) { + decoded, err := hex.DecodeString(pubKey) + if err != nil { + return nil, fmt.Errorf("convertPubKey: error when decoding public key: %w", err) + } + + parsedPubKey, err := ec.ParsePubKey(decoded) + if err != nil { + return nil, fmt.Errorf("convertPubKey: error when parsing public key: %w", err) + } + return parsedPubKey, nil +} + +func getTotpOpts(period, digits uint) *totp.ValidateOpts { + if period == 0 { + period = DefaultPeriod + } + + if digits == 0 { + digits = DefaultDigits + } + + return &totp.ValidateOpts{ + Period: period, + Digits: otp.Digits(digits), //nolint: gosec + } +} + +// directedSecret appends a paymail to the shared secret and encodes it as base32. +func directedSecret(sharedSecret []byte, paymail string) string { + return base32.StdEncoding.EncodeToString(append(sharedSecret, []byte(paymail)...)) +} diff --git a/internal/api/v1/user/totp/totp_test.go b/internal/api/v1/user/totp/totp_test.go new file mode 100644 index 00000000..1d0414a7 --- /dev/null +++ b/internal/api/v1/user/totp/totp_test.go @@ -0,0 +1,96 @@ +package totp_test + +import ( + "testing" + "time" + + client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/stretchr/testify/require" +) + +func TestClient_GenerateTotpForContact(t *testing.T) { + t.Run("success", func(t *testing.T) { + // given + contact := models.Contact{PubKey: clienttest.PubKey} + wc := totp.New(clienttest.ExtendedKey(t)) + + // when + pass, err := wc.GenerateTotpForContact(&contact, 30, 2) + + // then + require.NoError(t, err) + require.Len(t, pass, 2) + }) + + t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { + // given + contact := models.Contact{PubKey: "invalid-pk-format"} + wc := totp.New(clienttest.ExtendedKey(t)) + + // when + _, err := wc.GenerateTotpForContact(&contact, 30, 2) + + // then + require.ErrorIs(t, err, errors.ErrContactPubKeyInvalid) + }) +} + +func TestClient_ValidateTotpForContact(t *testing.T) { + cfg := client.Config{ + Addr: clienttest.TestAPIAddr, + Timeout: 5 * time.Second, + } + t.Run("success", func(t *testing.T) { + // given + clientAlice, err := client.NewWithXPriv(cfg, clienttest.AliceXPriv) + require.NoError(t, err) + + clientBob, err := client.NewWithXPriv(cfg, clienttest.BobXPriv) + require.NoError(t, err) + + // and + aliceContact := &models.Contact{ + PubKey: clienttest.MockPKI(t, clienttest.AliceXPub), + Paymail: "alice@example.com", + } + + bobContact := &models.Contact{ + PubKey: clienttest.MockPKI(t, clienttest.BobXPub), + Paymail: "bob@example.com", + } + + // when + passcode, err := clientAlice.GenerateTotpForContact(bobContact, 3600, 6) + + // then + require.NoError(t, err) + + // when + err = clientBob.ValidateTotpForContact(aliceContact, passcode, bobContact.Paymail, 3600, 6) + + // then + require.NoError(t, err) + }) + + t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { + // given + sut, err := client.NewWithXPriv(cfg, clienttest.UserXPriv) + require.NoError(t, err) + + // and + invalidContact := &models.Contact{ + PubKey: "invalid_pub_key_format", + Paymail: "invalid@example.com", + } + + // when + err = sut.ValidateTotpForContact(invalidContact, "123456", "someone@example.com", 3600, 6) + + // when + require.Contains(t, err.Error(), "contact's PubKey is invalid") + }) +} diff --git a/internal/api/v1/user/transactions/transactions_api_test.go b/internal/api/v1/user/transactions/transactions_api_test.go index eb33a88e..7a159df0 100644 --- a/internal/api/v1/user/transactions/transactions_api_test.go +++ b/internal/api/v1/user/transactions/transactions_api_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - wallet "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" @@ -41,18 +41,18 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + url := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPatch, URL, tc.responder) + transport.RegisterResponder(http.MethodPatch, url, tc.responder) // then: got, err := spvWalletClient.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ @@ -90,18 +90,18 @@ func TestTransactionsAPI_RecordTransaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/transactions" + url := clienttest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPost, URL, tc.responder) + transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: got, err := spvWalletClient.RecordTransaction(context.Background(), &commands.RecordTransaction{}) @@ -133,18 +133,18 @@ func TestTransactionsAPI_DraftTransaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP POST /api/v1/transactions/drafts str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/transactions/drafts" + url := clienttest.TestAPIAddr + "/api/v1/transactions/drafts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPost, URL, tc.responder) + transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: got, err := spvWalletClient.DraftTransaction(context.Background(), &commands.DraftTransaction{ @@ -180,18 +180,18 @@ func TestTransactionsAPI_Transaction(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + url := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := spvWalletClient.Transaction(context.Background(), ID) @@ -223,18 +223,18 @@ func TestTransactionsAPI_Transactions(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/transactions" + url := clienttest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := spvWalletClient.Transactions(context.Background()) diff --git a/internal/api/v1/user/users/access_key_api_test.go b/internal/api/v1/user/users/access_key_api_test.go index f95c8a4f..707b9705 100644 --- a/internal/api/v1/user/users/access_key_api_test.go +++ b/internal/api/v1/user/users/access_key_api_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - wallet "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -40,18 +40,18 @@ func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP POST /api/v1/users/current/keys str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + url := clienttest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPost, URL, tc.responder) + transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: got, err := wallet.GenerateAccessKey(context.Background(), &commands.GenerateAccessKey{ @@ -89,18 +89,18 @@ func TestAccessKeyAPI_AccessKey(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s str response: 500", ID): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + url := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.AccessKey(context.Background(), ID) @@ -133,18 +133,18 @@ func TestAccessKeyAPI_AccessKeys(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/users/current/keys str response: 500": { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + url := clienttest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.AccessKeys(context.Background()) @@ -176,18 +176,18 @@ func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s str response: 500", ID): { - expectedErr: wallet.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + url := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodDelete, URL, tc.responder) + transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: err := wallet.RevokeAccessKey(context.Background(), ID) diff --git a/internal/api/v1/user/users/xpub_api_test.go b/internal/api/v1/user/users/xpub_api_test.go index 81f2775a..b8d4ed38 100644 --- a/internal/api/v1/user/users/xpub_api_test.go +++ b/internal/api/v1/user/users/xpub_api_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - client "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet/models" @@ -38,18 +38,18 @@ func TestXPubAPI_UpdateXPubMetadata(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP PATCH /api/v1/users/current str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current" + url := clienttest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodPatch, URL, tc.responder) + transport.RegisterResponder(http.MethodPatch, url, tc.responder) // then: got, err := wallet.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{ @@ -86,18 +86,18 @@ func TestXPubAPI_XPub(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/users/current str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/users/current" + url := clienttest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.XPub(context.Background()) diff --git a/internal/api/v1/user/utxos/utxos_api_test.go b/internal/api/v1/user/utxos/utxos_api_test.go index 23a8e6dd..8cf0c2b7 100644 --- a/internal/api/v1/user/utxos/utxos_api_test.go +++ b/internal/api/v1/user/utxos/utxos_api_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -37,18 +37,18 @@ func TestUTXOAPI_UTXOs(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, utxostest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/utxos str response: 500": { - expectedErr: client.ErrUnrecognizedAPIResponse, + expectedErr: errors.ErrUnrecognizedAPIResponse, statusCode: http.StatusInternalServerError, responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), }, } - URL := clienttest.TestAPIAddr + "/api/v1/utxos" + url := clienttest.TestAPIAddr + "/api/v1/utxos" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: wallet, transport := clienttest.GivenSPVWalletClient(t) - transport.RegisterResponder(http.MethodGet, URL, tc.responder) + transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: got, err := wallet.UTXOs(context.Background()) diff --git a/internal/clienttest/clienttest.go b/internal/clienttest/clienttest.go index cfe378bb..9bbff217 100644 --- a/internal/clienttest/clienttest.go +++ b/internal/clienttest/clienttest.go @@ -1,6 +1,8 @@ package clienttest import ( + "encoding/hex" + "net/http" "testing" "time" @@ -17,6 +19,12 @@ const ( UserXPub = "xpub661MyMwAqRbcG9uqtWJY8pcBhVdrJBYvz8FUHZffnR1pNVPyQpXnaKeM5w2FyH5Wwhf5Cf15mFDVRZnuK9sEHDqqd39qWz36UDoobrzLyFM" UserPrivAccessKey = "03a446ede05f04fd92d2707599a80b67ad76f63b3958706819c76308bfc7c1143d" UserPubAccessKey = "0239a60e37d62b0217ac86881caba194ab943e18099c080de70c173daf75d917b2" + PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde" + + AliceXPriv = "xprv9s21ZrQH143K4JFXqGhBzdrthyNFNuHPaMUwvuo8xvpHwWXprNK7T4JPj1w53S1gojQncyj8JhSh8qouYPZpbocsq934cH5G1t1DRBfgbod" + AliceXPub = "xpub661MyMwAqRbcGnKzwJECMmodG1CjnN1EwaQYjJCkXGMGpJryPudMzrcsaK6frwUxXqFxRJwPkKvJh6myJEpQPJS9N67jhZWr24biGe277DH" + BobXPriv = "xprv9s21ZrQH143K4VneY3UWCF1o5Kk2tmgGrGtMtsrThCTsHsszEZ6H1iP37ZTwuUBvMwudG68SRkcfTjeu8h3rkayfyqkjKAStFBkuNsBnAkS" + BobXPub = "xpub661MyMwAqRbcGys7e51WZNxXdMaXJEQ8DVoxhGG5FXzrAgD8n6QXZWhWxrm2yMzH8e9fxV8TYxmkL9sivVEEoPfDpg4u5mrp2VTqvfGT1Us" ) func ExtendedKey(t *testing.T) *bip32.ExtendedKey { @@ -30,6 +38,7 @@ func ExtendedKey(t *testing.T) *bip32.ExtendedKey { } func PrivateKey(t *testing.T) *ec.PrivateKey { + t.Helper() key, err := ec.PrivateKeyFromHex(UserPrivAccessKey) if err != nil { t.Fatalf("test helper - ec private key from hex: %s", err) @@ -54,3 +63,49 @@ func GivenSPVWalletClient(t *testing.T) (*client.Client, *httpmock.MockTransport return spv, transport } + +func GivenSPVWalletClientWithTransport(t *testing.T, transport http.RoundTripper) (*client.Client, *httpmock.MockTransport) { + t.Helper() + + // Extract the wrapped MockTransport if it's a TransportWrapper + var mockTransport *httpmock.MockTransport + if wrapper, ok := transport.(*TransportWrapper); ok { + mockTransport = wrapper.MockTransport + } else if mt, ok := transport.(*httpmock.MockTransport); ok { + mockTransport = mt + } else { + t.Fatalf("expected transport to be of type *httpmock.MockTransport or *httpmockwrapper.TransportWrapper, got %T", transport) + } + + cfg := client.Config{ + Addr: TestAPIAddr, + Timeout: 5 * time.Second, + Transport: transport, + } + + spv, err := client.NewWithXPriv(cfg, UserXPriv) + if err != nil { + t.Fatalf("test helper - spv wallet client with xpriv: %s", err) + } + + return spv, mockTransport +} + +func MockPKI(t *testing.T, xpub string) string { + t.Helper() + xPub, _ := bip32.NewKeyFromString(xpub) + var err error + for i := 0; i < 3; i++ { //magicNumberOfInheritance is 3 -> 2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI + xPub, err = xPub.Child(0) + if err != nil { + panic(err) + } + } + + pubKey, err := xPub.ECPubKey() + if err != nil { + t.Fatalf("test helper - ec public key from xpub: %s", err) + } + + return hex.EncodeToString(pubKey.SerializeCompressed()) +} diff --git a/internal/clienttest/transportmock_wrapper.go b/internal/clienttest/transportmock_wrapper.go new file mode 100644 index 00000000..3aa47c5b --- /dev/null +++ b/internal/clienttest/transportmock_wrapper.go @@ -0,0 +1,58 @@ +package clienttest + +import ( + "fmt" + "net/http" + "sync" + + "github.com/jarcoal/httpmock" +) + +type TransportWrapper struct { + *httpmock.MockTransport + + mu sync.RWMutex + lastRequest *http.Request + lastResponse *http.Response + lastError error +} + +// NewTransportWrapper creates a new wrapper around the default httpmock.MockTransport. +func NewTransportWrapper() *TransportWrapper { + return &TransportWrapper{ + MockTransport: httpmock.NewMockTransport(), + } +} + +// RoundTrip intercepts the request and stores the response and error. +func (tw *TransportWrapper) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := tw.MockTransport.RoundTrip(req) + + tw.mu.Lock() + defer tw.mu.Unlock() + + tw.lastRequest = req + tw.lastResponse = resp + tw.lastError = err + + if err != nil { + return resp, fmt.Errorf("Round trip error - %w", err) + } + return resp, nil +} + +// GetResponse retrieves the last response and error. +func (tw *TransportWrapper) GetResponse() (*http.Response, error) { + tw.mu.RLock() + defer tw.mu.RUnlock() + + return tw.lastResponse, tw.lastError +} + +// GetRequest retrieves the last request. +func (tw *TransportWrapper) GetRequest() *http.Request { + tw.mu.RLock() + defer tw.mu.RUnlock() + + return tw.lastRequest +} From cdf95b0980a973a38d220fca95be7996f7367634 Mon Sep 17 00:00:00 2001 From: augustyn chmiel <149666032+ac4ch@users.noreply.github.com> Date: Wed, 27 Nov 2024 09:45:18 +0100 Subject: [PATCH 14/18] refactoring(SPV-1208) Migrate_Merkel_Roots_Sync_operations_logic_from_old_SPVWalletGoClient (#21) Co-authored-by: Augustyn Chmiel --- client.go | 13 +- errors/errors.go | 7 + go.mod | 1 + go.sum | 2 + .../v1/user/merkleroots/merkleroots_api.go | 60 +++ .../user/merkleroots/merkleroots_api_test.go | 103 +++++ .../user/merkleroots/merkleroots_sync_test.go | 105 ++++++ .../get_merkleroots_page1.json | 14 + .../get_merkleroots_page2.json | 12 + .../get_merkleroots_stale.json | 13 + .../merkleroots_sync_fixtures.go | 353 ++++++++++++++++++ 11 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 internal/api/v1/user/merkleroots/merkleroots_sync_test.go create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json create mode 100644 internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go diff --git a/client.go b/client.go index cea0b400..15ca37f7 100644 --- a/client.go +++ b/client.go @@ -60,8 +60,7 @@ type Client struct { invitationsAPI *invitations.API transactionsAPI *transactions.API utxosAPI *utxos.API - - totp *totp.Client //only available when using xPriv + totp *totp.Client } // NewWithXPub creates a new client instance using an extended public key (xPub). @@ -436,6 +435,16 @@ func (c *Client) ValidateTotpForContact(contact *models.Contact, passcode, reque return nil } +// SyncMerkleRoots synchronizes Merkle roots known to the SPV Wallet with the client database. +// This method sends a series of HTTP GET requests to the "/merkleroots" endpoint, fetching +// Merkle roots and storing them in the client database. The process continues until all +func (c *Client) SyncMerkleRoots(ctx context.Context, repo merkleroots.MerkleRootsRepository) error { + if err := c.merkleRootsAPI.SyncMerkleRoots(ctx, repo); err != nil { + return fmt.Errorf("failed to sync Merkle roots: %w", err) + } + return nil +} + func privateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { pk, err1 := ec.PrivateKeyFromWif(s) if err1 == nil { diff --git a/errors/errors.go b/errors/errors.go index 37e2cfc5..381533d8 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -16,4 +16,11 @@ var ( // ErrUnrecognizedAPIResponse indicates that the response received from the SPV Wallet API // does not match the expected format or structure. ErrUnrecognizedAPIResponse = errors.New("unrecognized response from API") + // ErrSyncMerkleRootsTimeout is returned when the SyncMerkleRoots operation times out. + ErrSyncMerkleRootsTimeout = errors.New("SyncMerkleRoots operation timed out") + // ErrStaleLastEvaluatedKey is returned when the last evaluated key has not changed between requests, + ErrStaleLastEvaluatedKey = errors.New("the last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.") + + // ErrFailedToFetchMerkleRootsFromAPI is returned when the API fails to fetch merkle roots. + ErrFailedToFetchMerkleRootsFromAPI = errors.New("failed to fetch merkle roots from API") ) diff --git a/go.mod b/go.mod index f4b1253e..05827635 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/net v0.27.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index ab2e3196..263f065e 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/internal/api/v1/user/merkleroots/merkleroots_api.go b/internal/api/v1/user/merkleroots/merkleroots_api.go index e2d929ab..7fb1edd2 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api.go @@ -2,16 +2,28 @@ package merkleroots import ( "context" + "errors" "fmt" "net/url" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" "github.com/go-resty/resty/v2" ) const route = "api/v1/merkleroots" +// MerkleRootsRepository is an interface responsible for storing synchronized MerkleRoots and retrieving the last evaluation key from the database. +type MerkleRootsRepository interface { + // GetLastMerkleRoot should return the Merkle root with the highest height from your memory, or undefined if empty. + GetLastMerkleRoot() string + // SaveMerkleRoots should store newly synced merkle roots into your storage; + // NOTE: items are sorted in ascending order by block height. + SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error +} + type API struct { url *url.URL httpClient *resty.Client @@ -48,3 +60,51 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +// SyncMerkleRoots syncs merkleroots known to spv-wallet with the client database +// If timeout is needed pass context.WithTimeout() as ctx param +// SyncMerkleRoots synchronizes Merkle roots known to SPV Wallet with the client database. +func (a *API) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error { + + lastEvaluatedKey := repo.GetLastMerkleRoot() + previousLastEvaluatedKey := lastEvaluatedKey + + for { + select { + case <-ctx.Done(): + return goclienterr.ErrSyncMerkleRootsTimeout + default: + // Query the MerkleRoots API + result, err := a.MerkleRoots(ctx, queries.MerkleRootsQueryWithLastEvaluatedKey(lastEvaluatedKey)) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return goclienterr.ErrSyncMerkleRootsTimeout + } + return fmt.Errorf("failed to fetch merkle roots from API: %w", err) + } + + // Handle empty results + if len(result.Content) == 0 { + return nil + } + + // Update the last evaluated key + lastEvaluatedKey = result.Page.LastEvaluatedKey + if lastEvaluatedKey != "" && previousLastEvaluatedKey == lastEvaluatedKey { + return goclienterr.ErrStaleLastEvaluatedKey + } + + // Save fetched Merkle roots + err = repo.SaveMerkleRoots(result.Content) + if err != nil { + return fmt.Errorf("failed to save merkle roots: %w", err) + } + + if lastEvaluatedKey == "" { + return nil + } + + previousLastEvaluatedKey = lastEvaluatedKey + } + } +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go index 039bc640..328c5429 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api_test.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -11,6 +11,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -56,3 +57,105 @@ func TestMerkleRootsAPI_MerkleRoots(t *testing.T) { }) } } + +// Mock repository for testing +type MockMerkleRootsRepository struct { + mock.Mock +} + +// GetLastMerkleRoot retrieves the last Merkle root from storage. +func (m *MockMerkleRootsRepository) GetLastMerkleRoot() string { + args := m.Called() + return args.String(0) +} + +// SaveMerkleRoots appends synced Merkle roots to the simulated storage. +func (m *MockMerkleRootsRepository) SaveMerkleRoots(roots []models.MerkleRoot) error { + args := m.Called(roots) + return args.Error(0) +} + +// TestSyncMerkleRoots tests the SyncMerkleRoots functionality +func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + setupMock func(mockRepo *MockMerkleRootsRepository) + expectedErr error + }{ + "Successful Sync with Pagination": { + responder: httpmock.ResponderFromMultipleResponses( + []*http.Response{ + httpmock.NewStringResponse(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_page1.json").String()), + httpmock.NewStringResponse(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_page2.json").String()), + }, + ), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("") // Start with no data + mockRepo.On("SaveMerkleRoots", mock.MatchedBy(func(roots []models.MerkleRoot) bool { + return len(roots) > 0 + })).Return(nil).Twice() // Called twice for two pages + }, + }, + "Stale LastEvaluatedKey Error": { + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_stale.json")), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("stale-key") // Simulate a stale key + mockRepo.On("SaveMerkleRoots", mock.Anything).Return(nil) + }, + expectedErr: errors.ErrStaleLastEvaluatedKey, + }, + "API Returns Error Response": { + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "Internal Server Error"), + setupMock: func(mockRepo *MockMerkleRootsRepository) { + mockRepo.On("GetLastMerkleRoot").Return("") // No data initially + }, + expectedErr: errors.ErrUnrecognizedAPIResponse, + }, + } + + url := clienttest.TestAPIAddr + "/api/v1/merkleroots" + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Arrange + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + transport.RegisterResponder(http.MethodGet, url, tc.responder) + + mockRepo := new(MockMerkleRootsRepository) + tc.setupMock(mockRepo) + + // Act + err := spvWalletClient.SyncMerkleRoots(context.Background(), mockRepo) + + // Assert + if tc.expectedErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully tests the SyncMerkleRoots functionality +func TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully(t *testing.T) { + // given: + db := merklerootstest.CreateRepository([]models.MerkleRoot{}) + url := clienttest.TestAPIAddr + "/api/v1/merkleroots" + spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + + var expected []models.MerkleRoot + expected = append(expected, merklerootstest.FirstMerkleRootsPage().Content...) + expected = append(expected, merklerootstest.SecondMerkleRootsPage().Content...) + expected = append(expected, merklerootstest.ThirdMerkleRootsPage().Content...) + + transport.RegisterResponder(http.MethodGet, url, merklerootstest.ResponderWithThreeMerkleRootPagesSuccess(t)) + + // when: + err := spvWalletClient.SyncMerkleRoots(context.Background(), db) + + // then: + require.NoError(t, err) + require.Equal(t, expected, db.MerkleRoots) +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_sync_test.go b/internal/api/v1/user/merkleroots/merkleroots_sync_test.go new file mode 100644 index 00000000..b2671b4d --- /dev/null +++ b/internal/api/v1/user/merkleroots/merkleroots_sync_test.go @@ -0,0 +1,105 @@ +package merkleroots_test + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/errors" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" +) + +func TestSyncMerkleRoots(t *testing.T) { + t.Run("Should properly sync database when empty", func(t *testing.T) { + // setup + server := merklerootstest.MockMerkleRootsAPIResponseNormal() + defer server.Close() + + apiURL, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + repo := merklerootstest.CreateRepository([]models.MerkleRoot{}) + client := merkleroots.NewAPI(apiURL, resty.New()) + + // when + err = client.SyncMerkleRoots(context.Background(), repo) + + // then + require.NoError(t, err) + require.Len(t, repo.MerkleRoots, len(merklerootstest.MockedSPVWalletData)) + require.Equal(t, merklerootstest.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1]) + }) + + t.Run("Should properly sync database when partially filled", func(t *testing.T) { + // setup + server := merklerootstest.MockMerkleRootsAPIResponseNormal() + defer server.Close() + + apiURL, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + client := merkleroots.NewAPI(apiURL, resty.New()) + require.NoError(t, err) + + repo := merklerootstest.CreateRepository([]models.MerkleRoot{}) + + // when + err = client.SyncMerkleRoots(context.Background(), repo) + + // then + require.NoError(t, err) + require.Len(t, repo.MerkleRoots, len(merklerootstest.MockedSPVWalletData)) + require.Equal(t, merklerootstest.LastMockedMerkleRoot(), repo.MerkleRoots[len(repo.MerkleRoots)-1]) + }) + + t.Run("Should fail sync merkleroots due to the timeout", func(t *testing.T) { + // setup + server := merklerootstest.MockMerkleRootsAPIResponseDelayed() + defer server.Close() + + apiURL, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + repo := merklerootstest.CreateRepository([]models.MerkleRoot{}) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + + client := merkleroots.NewAPI(apiURL, resty.New()) + require.NoError(t, err) + + // when + err = client.SyncMerkleRoots(ctx, repo) + + // then + require.ErrorIs(t, err, goclienterr.ErrSyncMerkleRootsTimeout) + }) + + t.Run("Should fail sync merkleroots due to last evaluated key being the same in the response", func(t *testing.T) { + // setup + server := merklerootstest.MockMerkleRootsAPIResponseStale() + defer server.Close() + + apiURL, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + repo := merklerootstest.CreateRepository([]models.MerkleRoot{}) + client := merkleroots.NewAPI(apiURL, resty.New()) + require.NoError(t, err) + + // when + err = client.SyncMerkleRoots(context.Background(), repo) + + // then + require.ErrorIs(t, err, errors.ErrStaleLastEvaluatedKey) + }) +} diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json new file mode 100644 index 00000000..594c2be2 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page1.json @@ -0,0 +1,14 @@ +{ + "content": [ + { "blockHeight": 1, "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689" }, + { "blockHeight": 2, "merkleRoot": "132a2a38-b23f-404b-940f-f811de886114" } + ], + "page": { + "lastEvaluatedKey": "key-1", + "orderByField": "blockHeight", + "size": 20, + "sortDirection": "asc", + "totalElements": 10 + } + } + \ No newline at end of file diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json new file mode 100644 index 00000000..a999a917 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_page2.json @@ -0,0 +1,12 @@ +{ + "content": [ + { "blockHeight": 3, "merkleRoot": "d229c224-6c21-4c68-ba25-261119e9b8dc" } + ], + "page": { + "lastEvaluatedKey": "", + "orderByField": "blockHeight", + "size": 20, + "sortDirection": "asc", + "totalElements": 10 + } +} diff --git a/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json new file mode 100644 index 00000000..3456c12d --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/get_merkleroots_stale.json @@ -0,0 +1,13 @@ +{ + "content": [ + { "blockHeight": 1, "merkleRoot": "d02ab7b5-ac3e-4612-9377-9bffe05ac689" } + ], + "page": { + "lastEvaluatedKey": "stale-key", + "orderByField": "blockHeight", + "size": 20, + "sortDirection": "asc", + "totalElements": 10 + } + } + \ No newline at end of file diff --git a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go new file mode 100644 index 00000000..c49ee978 --- /dev/null +++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_sync_fixtures.go @@ -0,0 +1,353 @@ +package merklerootstest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/jarcoal/httpmock" +) + +// DB simulates a storage of Merkle roots for testing. +type DB struct { + MerkleRoots []models.MerkleRoot +} + +// SaveMerkleRoots appends synced Merkle roots to the simulated storage. +func (db *DB) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error { + db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...) + return nil +} + +// GetLastMerkleRoot retrieves the last Merkle root from storage. +func (db *DB) GetLastMerkleRoot() string { + if len(db.MerkleRoots) == 0 { + return "" + } + return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot +} + +// CreateRepository initializes a simulated repository with the provided Merkle roots. +func CreateRepository(merkleRoots []models.MerkleRoot) *DB { + return &DB{ + MerkleRoots: merkleRoots, + } +} + +// sendJSONResponse sends a JSON response from the mock server. +func sendJSONResponse(data any, w *http.ResponseWriter) { + (*w).Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(*w).Encode(data); err != nil { + (*w).WriteHeader(http.StatusInternalServerError) + } +} + +// MockMerkleRootsAPIResponseNormal creates a mock server with normal API responses. +func MockMerkleRootsAPIResponseNormal() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet { + lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") + sendJSONResponse(MockedMerkleRootsAPIResponseFn(lastEvaluatedKey), &w) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + return server +} + +// MockMerkleRootsAPIResponseDelayed creates a mock server with delayed API responses. +func MockMerkleRootsAPIResponseDelayed() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet { + lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") + all := MockedMerkleRootsAPIResponseFn(lastEvaluatedKey) + if len(all.Content) > 3 { + all.Content = all.Content[:3] + } + all.Page.Size = len(all.Content) + if len(all.Content) > 0 { + all.Page.LastEvaluatedKey = all.Content[len(all.Content)-1].MerkleRoot + } else { + all.Page.LastEvaluatedKey = "" + } + time.Sleep(50 * time.Millisecond) + sendJSONResponse(all, &w) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + return server +} + +// MockMerkleRootsAPIResponseStale creates a mock server with a stale last evaluated key. +func MockMerkleRootsAPIResponseStale() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet { + staleLastEvaluatedKeyResponse := models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: []models.MerkleRoot{ + { + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + BlockHeight: 0, + }, + { + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + BlockHeight: 1, + }, + { + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + BlockHeight: 2, + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + Size: 3, + TotalElements: len(MockedSPVWalletData), + }, + } + sendJSONResponse(staleLastEvaluatedKeyResponse, &w) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + return server +} + +// MockedSPVWalletData is mocked merkle roots data on spv-wallet side +var MockedSPVWalletData = []models.MerkleRoot{ + { + BlockHeight: 0, + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + }, + { + BlockHeight: 1, + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + }, + { + BlockHeight: 2, + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + }, + { + BlockHeight: 3, + MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644", + }, + { + BlockHeight: 4, + MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a", + }, + { + BlockHeight: 5, + MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1", + }, + { + BlockHeight: 6, + MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37", + }, + { + BlockHeight: 7, + MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f", + }, + { + BlockHeight: 8, + MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3", + }, + { + BlockHeight: 9, + MerkleRoot: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c9", + }, + { + BlockHeight: 10, + MerkleRoot: "d3ad39fa52a89997ac7381c95eeffeaf40b66af7a57e9eba144be0a175a12b11", + }, + { + BlockHeight: 11, + MerkleRoot: "f8325d8f7fa5d658ea143629288d0530d2710dc9193ddc067439de803c37066e", + }, + { + BlockHeight: 12, + MerkleRoot: "3b96bb7e197ef276b85131afd4a09c059cc368133a26ca04ebffb0ab4f75c8b8", + }, + { + BlockHeight: 13, + MerkleRoot: "9962d5c704ec27243364cbe9d384808feeac1c15c35ac790dffd1e929829b271", + }, + { + BlockHeight: 14, + MerkleRoot: "e1afd89295b68bc5247fe0ca2885dd4b8818d7ce430faa615067d7bab8640156", + }, +} + +// LastMockedMerkleRoot returns last merkleroot value from MockedSPVWalletData +func LastMockedMerkleRoot() models.MerkleRoot { + return MockedSPVWalletData[len(MockedSPVWalletData)-1] +} + +func MockedMerkleRootsAPIResponseFn(lastMerkleRoot string) models.ExclusiveStartKeyPage[[]models.MerkleRoot] { + // If no lastMerkleRoot is provided, return the full dataset + if lastMerkleRoot == "" { + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: MockedSPVWalletData, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot, // Last Merkle root as key + TotalElements: len(MockedSPVWalletData), + Size: len(MockedSPVWalletData), + }, + } + } + + // Find the index of the lastMerkleRoot + lastMerkleRootIdx := slices.IndexFunc(MockedSPVWalletData, func(mr models.MerkleRoot) bool { + return mr.MerkleRoot == lastMerkleRoot + }) + + // If lastMerkleRoot is not found, return an empty response (or handle as error if desired) + if lastMerkleRootIdx == -1 { + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: []models.MerkleRoot{}, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "", + TotalElements: len(MockedSPVWalletData), + Size: 0, + }, + } + } + + // If lastMerkleRoot is the highest in the server database, return no new content + if lastMerkleRootIdx >= len(MockedSPVWalletData)-1 { + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: []models.MerkleRoot{}, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "", + TotalElements: len(MockedSPVWalletData), + Size: 0, + }, + } + } + + // Return all Merkle roots after the given lastMerkleRoot + content := MockedSPVWalletData[lastMerkleRootIdx+1:] + + // Set the LastEvaluatedKey to the last Merkle root in the current page, or "" if it's the final one + lastEvaluatedKey := "" + if len(content) > 0 && content[len(content)-1].MerkleRoot != MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot { + lastEvaluatedKey = content[len(content)-1].MerkleRoot + } + + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: content, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: lastEvaluatedKey, + TotalElements: len(MockedSPVWalletData), + Size: len(content), + }, + } +} + +func FirstMerkleRootsPage() *queries.MerkleRootPage { + return &queries.MerkleRootPage{ + Content: []models.MerkleRoot{ + { + BlockHeight: 0, + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + }, + { + BlockHeight: 1, + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + }, + { + BlockHeight: 2, + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + OrderByField: Ptr("blockHeight"), + SortDirection: Ptr("asc"), + TotalElements: 9, + Size: 3, + LastEvaluatedKey: "e4774f7a-eb99-4cac-956e-634d2aeccc93", + }, + } +} + +func SecondMerkleRootsPage() *queries.MerkleRootPage { + return &queries.MerkleRootPage{ + Content: []models.MerkleRoot{ + { + BlockHeight: 3, + MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644", + }, + { + BlockHeight: 4, + MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a", + }, + { + BlockHeight: 5, + MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1", + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + OrderByField: Ptr("blockHeight"), + SortDirection: Ptr("asc"), + TotalElements: 9, + Size: 3, + LastEvaluatedKey: "6bad63f5-8f2e-4756-aca9-cc9cb4a001c6", + }, + } +} + +func ThirdMerkleRootsPage() *queries.MerkleRootPage { + return &queries.MerkleRootPage{ + Content: []models.MerkleRoot{ + { + BlockHeight: 6, + MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37", + }, + { + BlockHeight: 7, + MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f", + }, + { + BlockHeight: 8, + MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3", + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + OrderByField: Ptr("blockHeight"), + SortDirection: Ptr("asc"), + TotalElements: 9, + Size: 3, + LastEvaluatedKey: "09232c7e-ecf7-4e33-8feb-a32170c6e7b6", + }, + } +} + +func ResponderWithThreeMerkleRootPagesSuccess(t *testing.T) httpmock.Responder { + pages := map[int]*queries.MerkleRootPage{ + 0: FirstMerkleRootsPage(), + 1: SecondMerkleRootsPage(), + 2: ThirdMerkleRootsPage(), + } + + var num int + return func(r *http.Request) (*http.Response, error) { + defer func() { num++ }() + + if num < len(pages) { + res, err := httpmock.NewJsonResponse(http.StatusPartialContent, pages[num]) + if err != nil { + t.Fatalf("test helper - failed to generate new json response: %s", err) + } + return res, nil + } + + res, err := httpmock.NewJsonResponse(http.StatusOK, queries.MerkleRootPage{}) + if err != nil { + t.Fatalf("test helper - failed to generate new json response: %s", err) + } + return res, nil + } +} From 1eb35b59201aea82b7092208a2eca698cd7f844e Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Wed, 27 Nov 2024 11:15:20 +0100 Subject: [PATCH 15/18] refactor(SPV-1213): Admin API access with user xpubs implementation. (#20) --- .golangci-lint.yml | 1 + admin_api.go | 110 ++++ client.go | 505 ------------------ commands/transactions.go | 2 +- commands/xpub.go | 9 + config/config.go | 25 + errors/errors.go | 5 +- .../admin/users/userstest/get_xpubs_200.json | 34 ++ .../admin/users/userstest/post_xpub_201.json | 12 + .../users/userstest/xpub_api_fixtures.go | 80 +++ .../api/v1/admin/users/xpub_filter_builder.go | 30 ++ .../admin/users/xpub_filter_builder_test.go | 81 +++ internal/api/v1/admin/users/xpubs_api.go | 82 +++ internal/api/v1/admin/users/xpubs_api_test.go | 89 +++ internal/api/v1/errutil/errutil.go | 22 + internal/api/v1/errutil/errutil_test.go | 33 ++ .../querybuilders/extended_url_values.go | 0 .../querybuilders/extended_url_values_test.go | 4 +- .../querybuilders/metadata_filter_builder.go | 0 .../metadata_filter_builder_test.go | 2 +- .../querybuilders/model_filter_builder.go | 0 .../model_filter_builder_test.go | 4 +- .../querybuilders/page_filter_builder.go | 0 .../querybuilders/page_filter_builder_test.go | 2 +- .../{user => }/querybuilders/query_builder.go | 0 .../querybuilders/query_builder_test.go | 4 +- .../query_params_filter_builder.go | 0 .../query_params_filter_builder_test.go | 2 +- .../querybuilderstest/querybuilderstest.go | 0 internal/api/v1/user/configs/configs_api.go | 9 + .../api/v1/user/configs/configs_api_test.go | 6 +- .../contacts/contact_filter_query_builder.go | 2 +- .../contact_filter_query_builder_test.go | 2 +- internal/api/v1/user/contacts/contacts_api.go | 11 +- .../api/v1/user/contacts/contacts_api_test.go | 30 +- .../v1/user/invitations/invitations_api.go | 9 + .../user/invitations/invitations_api_test.go | 10 +- .../v1/user/merkleroots/merkleroots_api.go | 15 +- .../user/merkleroots/merkleroots_api_test.go | 14 +- .../merkleroots/merkleroots_filter_builder.go | 2 +- internal/api/v1/user/totp/totp_test.go | 23 +- .../transaction_filter_builder.go | 2 +- .../transaction_filter_builder_test.go | 2 +- .../v1/user/transactions/transactions_api.go | 11 +- .../transactions/transactions_api_test.go | 24 +- internal/api/v1/user/users/access_key_api.go | 11 +- .../api/v1/user/users/access_key_api_test.go | 18 +- .../users/access_key_filter_query_builder.go | 2 +- .../access_key_filter_query_builder_test.go | 2 +- internal/api/v1/user/users/xpub_api.go | 9 + internal/api/v1/user/users/xpub_api_test.go | 10 +- .../user/utxos/utxo_filter_query_builder.go | 2 +- .../utxos/utxo_filter_query_builder_test.go | 2 +- internal/api/v1/user/utxos/utxos_api.go | 11 +- internal/api/v1/user/utxos/utxos_api_test.go | 6 +- internal/auth/authenticators_test.go | 12 +- internal/cryptoutil/cryptoutil.go | 15 + internal/restyutil/restyutil.go | 36 ++ .../spvwallettest.go} | 34 +- .../transportmock_wrapper.go | 2 +- queries/xpubs.go | 47 ++ user_api.go | 466 ++++++++++++++++ 62 files changed, 1375 insertions(+), 620 deletions(-) create mode 100644 admin_api.go delete mode 100644 client.go create mode 100644 commands/xpub.go create mode 100644 config/config.go create mode 100644 internal/api/v1/admin/users/userstest/get_xpubs_200.json create mode 100644 internal/api/v1/admin/users/userstest/post_xpub_201.json create mode 100644 internal/api/v1/admin/users/userstest/xpub_api_fixtures.go create mode 100644 internal/api/v1/admin/users/xpub_filter_builder.go create mode 100644 internal/api/v1/admin/users/xpub_filter_builder_test.go create mode 100644 internal/api/v1/admin/users/xpubs_api.go create mode 100644 internal/api/v1/admin/users/xpubs_api_test.go create mode 100644 internal/api/v1/errutil/errutil.go create mode 100644 internal/api/v1/errutil/errutil_test.go rename internal/api/v1/{user => }/querybuilders/extended_url_values.go (100%) rename internal/api/v1/{user => }/querybuilders/extended_url_values_test.go (92%) rename internal/api/v1/{user => }/querybuilders/metadata_filter_builder.go (100%) rename internal/api/v1/{user => }/querybuilders/metadata_filter_builder_test.go (98%) rename internal/api/v1/{user => }/querybuilders/model_filter_builder.go (100%) rename internal/api/v1/{user => }/querybuilders/model_filter_builder_test.go (95%) rename internal/api/v1/{user => }/querybuilders/page_filter_builder.go (100%) rename internal/api/v1/{user => }/querybuilders/page_filter_builder_test.go (95%) rename internal/api/v1/{user => }/querybuilders/query_builder.go (100%) rename internal/api/v1/{user => }/querybuilders/query_builder_test.go (95%) rename internal/api/v1/{user => }/querybuilders/query_params_filter_builder.go (100%) rename internal/api/v1/{user => }/querybuilders/query_params_filter_builder_test.go (95%) rename internal/api/v1/{user => }/querybuilders/querybuilderstest/querybuilderstest.go (100%) create mode 100644 internal/restyutil/restyutil.go rename internal/{clienttest/clienttest.go => spvwallettest/spvwallettest.go} (78%) rename internal/{clienttest => spvwallettest}/transportmock_wrapper.go (98%) create mode 100644 queries/xpubs.go create mode 100644 user_api.go diff --git a/.golangci-lint.yml b/.golangci-lint.yml index c74038d0..1bf88e94 100644 --- a/.golangci-lint.yml +++ b/.golangci-lint.yml @@ -85,6 +85,7 @@ linters-settings: wrapcheck: ignoreSigRegexps: - spverrors\.(Newf|Wrapf) + - errutil\.HTTPErrorFormatter ignorePackageGlobs: - "github.com/go-ozzo/ozzo-validation" revive: diff --git a/admin_api.go b/admin_api.go new file mode 100644 index 00000000..d998a812 --- /dev/null +++ b/admin_api.go @@ -0,0 +1,110 @@ +package spvwallet + +import ( + "context" + "fmt" + "net/url" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/config" + xpubs "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/users" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/restyutil" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// AdminAPI provides a simplified interface for interacting with admin-related APIs. +// It abstracts the complexities of making HTTP requests and handling responses, +// allowing developers to easily interact with admin API endpoints. +// +// A zero-value AdminAPI is not usable. Use the NewAdminAPI function to create +// a properly initialized instance. +// +// Methods may return wrapped errors, including models.SPVError or +// ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API. +type AdminAPI struct { + xpubsAPI *xpubs.API // Internal API for managing operations related to XPubs. +} + +// CreateXPub creates a new XPub record via the Admin XPubs API. +// The provided command contains the necessary parameters to define the XPub record. +// +// The API response is unmarshaled into a *response.Xpub struct. +// Returns an error if the API request fails or the response cannot be decoded. +func (a *AdminAPI) CreateXPub(ctx context.Context, cmd *commands.CreateUserXpub) (*response.Xpub, error) { + res, err := a.xpubsAPI.CreateXPub(ctx, cmd) + if err != nil { + return nil, xpubs.HTTPErrorFormatter("failed to create XPub", err).FormatPostErr() + } + + return res, nil +} + +// XPubs retrieves a paginated list of user XPubs via the Admin XPubs API. +// The response includes user XPubs along with pagination metadata, such as +// the current page number, sort order, and the field used for sorting (sortBy). +// +// Query parameters can be configured using optional query options. These options allow +// filtering based on metadata, pagination settings, or specific XPub attributes. +// +// The API response is unmarshaled into a *queries.XPubPage struct. +// Returns an error if the API request fails or the response cannot be decoded. +func (a *AdminAPI) XPubs(ctx context.Context, opts ...queries.XPubQueryOption) (*queries.XPubPage, error) { + res, err := a.xpubsAPI.XPubs(ctx, opts...) + if err != nil { + return nil, xpubs.HTTPErrorFormatter("failed to retrieve XPubs page", err).FormatGetErr() + } + + return res, nil +} + +// NewAdminAPIWithXPriv initializes a new AdminAPI instance using an extended private key (xPriv). +// This function configures the API client with the provided configuration and uses the xPriv key for authentication. +// If any step fails, an appropriate error is returned. +// +// Note: Requests made with this instance will be securely signed. +func NewAdminAPIWithXPriv(cfg config.Config, xPriv string) (*AdminAPI, error) { + key, err := bip32.GenerateHDKeyFromString(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPriv: %w", err) + } + + authenticator, err := auth.NewXprivAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to initialize xPriv authenticator: %w", err) + } + + return initAdminAPI(cfg, authenticator) +} + +// NewAdminWithXPub initializes a new AdminAPI instance using an extended public key (xPub). +// This function configures the API client with the provided configuration and uses the xPub key for authentication. +// If any configuration or initialization step fails, an appropriate error is returned. +// +// Note: Requests made with this instance will not be signed. +// For enhanced security, it is strongly recommended to use `NewAdminAPIWithXPriv` instead. +func NewAdminWithXPub(cfg config.Config, xPub string) (*AdminAPI, error) { + key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) + } + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to initialize xPub authenticator: %w", err) + } + + return initAdminAPI(cfg, authenticator) +} + +func initAdminAPI(cfg config.Config, auth authenticator) (*AdminAPI, error) { + url, err := url.Parse(cfg.Addr) + if err != nil { + return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) + } + + httpClient := restyutil.NewHTTPClient(cfg, auth) + return &AdminAPI{xpubsAPI: xpubs.NewAPI(url, httpClient)}, nil +} diff --git a/client.go b/client.go deleted file mode 100644 index 15ca37f7..00000000 --- a/client.go +++ /dev/null @@ -1,505 +0,0 @@ -package client - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "time" - - bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" - ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - "github.com/bitcoin-sv/spv-wallet-go-client/commands" - goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" - "github.com/bitcoin-sv/spv-wallet-go-client/queries" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/response" - "github.com/go-resty/resty/v2" -) - -// Config holds configuration settings for establishing a connection and handling -// request details in the application. -type Config struct { - Addr string // The base address of the SPV Wallet API. - Timeout time.Duration // The HTTP requests timeout duration. - Transport http.RoundTripper // Custom HTTP transport, allowing optional customization of the HTTP client behavior. -} - -// NewDefaultConfig returns a default configuration for connecting to the SPV Wallet API, -// setting a one-minute timeout, using the default HTTP transport, and applying the -// base API address as the addr value. -func NewDefaultConfig(addr string) Config { - return Config{ - Addr: addr, - Timeout: 1 * time.Minute, - Transport: http.DefaultTransport, - } -} - -// Client provides methods for user-related and admin-related APIs. -// This struct is designed to abstract and simplify the process of making HTTP calls -// to the relevant endpoints. By utilizing this Client struct, developers can easily -// interact with both user and admin APIs without needing to manage the details -// of the HTTP requests and responses directly. -type Client struct { - xpubAPI *users.XPubAPI - accessKeyAPI *users.AccessKeyAPI - configsAPI *configs.API - merkleRootsAPI *merkleroots.API - contactsAPI *contacts.API - invitationsAPI *invitations.API - transactionsAPI *transactions.API - utxosAPI *utxos.API - totp *totp.Client -} - -// NewWithXPub creates a new client instance using an extended public key (xPub). -// Requests made with this instance will not be signed, that's why we strongly recommend to use `WithXPriv` or `WithAccessKey` option instead. -func NewWithXPub(cfg Config, xPub string) (*Client, error) { - key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) - if err != nil { - return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) - } - - authenticator, err := auth.NewXpubOnlyAuthenticator(key) - if err != nil { - return nil, fmt.Errorf("failed to intialized xpub authenticator: %w", err) - } - client, err := newClient(cfg, authenticator) - if err != nil { - return nil, fmt.Errorf("failed to create new client: %w", err) - } - return client, nil -} - -// NewWithXPriv creates a new client instance using an extended private key (xPriv). -// Generates an HD key from the provided xPriv and sets up the client instance to sign requests -// by setting the SignRequest flag to true. The generated HD key can be used for secure communications. -func NewWithXPriv(cfg Config, xPriv string) (*Client, error) { - key, err := bip32.GenerateHDKeyFromString(xPriv) - if err != nil { - return nil, fmt.Errorf("failed to generate HD key from xpriv: %w", err) - } - - authenticator, err := auth.NewXprivAuthenticator(key) - if err != nil { - return nil, fmt.Errorf("failed to intialized xpriv authenticator: %w", err) - } - - client, err := newClient(cfg, authenticator) - if err != nil { - return nil, fmt.Errorf("failed to create new client: %w", err) - } - - client.totp = totp.New(key) - - return client, nil -} - -// NewWithAccessKey creates a new client instance using an access key. -// Function attempts to convert the provided access key from either hex or WIF format -// to a PrivateKey. The resulting PrivateKey is used to sign requests made by the client instance -// by setting the SignRequest flag to true. -func NewWithAccessKey(cfg Config, accessKey string) (*Client, error) { - key, err := privateKeyFromHexOrWIF(accessKey) - if err != nil { - return nil, fmt.Errorf("failed to return private key from hex or WIF: %w", err) - } - - authenticator, err := auth.NewAccessKeyAuthenticator(key) - if err != nil { - return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) - } - - return newClient(cfg, authenticator) -} - -// Contacts retrieves a paginated list of user contacts from the user contacts API. -// The API response includes user contacts along with pagination details, such as -// the current page number, sort order, and the field used for sorting (sortBy). -// -// Optional query parameters can be provided via query options. The response is -// unmarshaled into a *queries.UserContactsPage struct. If the API request fails -// or the response cannot be decoded, an error is returned. -func (c *Client) Contacts(ctx context.Context, contactOpts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { - res, err := c.contactsAPI.Contacts(ctx, contactOpts...) - if err != nil { - return nil, fmt.Errorf("failed to retrieve contacts from the user contacts API: %w", err) - } - - return res, nil -} - -// ContactWithPaymail retrieves a specific user contact by their paymail address. -// The response is unmarshaled into a *response.Contact struct. If the API request -// fails or the response cannot be decoded, an error is returned. -func (c *Client) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) { - res, err := c.contactsAPI.ContactWithPaymail(ctx, paymail) - if err != nil { - return nil, fmt.Errorf("failed to retrieve contact by paymail from the user contacts API: %w", err) - } - - return res, nil -} - -// UpsertContact adds or updates a user contact through the user contacts API. -// The response is unmarshaled into a *response.Contact struct. If the API request -// fails or the response cannot be decoded, an error is returned. -func (c *Client) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) { - res, err := c.contactsAPI.UpsertContact(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to upsert contact using the user contacts API: %w", err) - } - - return res, nil -} - -// RemoveContact deletes a user contact using the user contacts API. -// If the API request fails, an error is returned. -func (c *Client) RemoveContact(ctx context.Context, paymail string) error { - err := c.contactsAPI.RemoveContact(ctx, paymail) - if err != nil { - return fmt.Errorf("failed to remove contact using the user contacts API: %w", err) - } - - return nil -} - -// ConfirmContact checks the TOTP code and if it's ok, confirms user's contact using the user contacts API. -func (c *Client) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { - if err := c.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { - return fmt.Errorf("failed to validate TOTP for contact: %w", err) - } - - err := c.contactsAPI.ConfirmContact(ctx, contact.Paymail) - if err != nil { - return fmt.Errorf("failed to confirm contact using the user contacts API: %w", err) - } - - return nil -} - -// UnconfirmContact unconfirms a user contact using the user contacts API. -// If the API request fails, an error is returned. -func (c *Client) UnconfirmContact(ctx context.Context, paymail string) error { - err := c.contactsAPI.UnconfirmContact(ctx, paymail) - if err != nil { - return fmt.Errorf("failed to unconfirm contact using the user contacts API: %w", err) - } - - return nil -} - -// AcceptInvitation accepts a contact invitation using the user invitations API. -// If the API request fails, an error is returned. -func (c *Client) AcceptInvitation(ctx context.Context, paymail string) error { - err := c.invitationsAPI.AcceptInvitation(ctx, paymail) - if err != nil { - return fmt.Errorf("failed to accept invitation using the user invitations API: %w", err) - } - - return nil -} - -// RejectInvitation rejects a contact invitation using the user invitations API. -// If the API request fails, an error is returned. -func (c *Client) RejectInvitation(ctx context.Context, paymail string) error { - err := c.invitationsAPI.RejectInvitation(ctx, paymail) - if err != nil { - return fmt.Errorf("failed to reject invitation using the user invitations API: %w", err) - } - - return nil -} - -// SharedConfig retrieves the shared configuration from the user configurations API. -// This method constructs an HTTP GET request to the "api/v1/configs/shared" endpoint and expects -// a response that can be unmarshaled into the response.SharedConfig struct. If the request fails -// or the response cannot be decoded, an error will be returned. -func (c *Client) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { - res, err := c.configsAPI.SharedConfig(ctx) - if err != nil { - return nil, fmt.Errorf("failed to retrieve shared configuration from user configs API: %w", err) - } - - return res, nil -} - -// DraftTransaction creates a new draft transaction using the user transactions API. -// This method sends an HTTP POST request to the "/draft" endpoint and expects -// a response that can be unmarshaled into a response.DraftTransaction struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) DraftTransaction(ctx context.Context, cmd *commands.DraftTransaction) (*response.DraftTransaction, error) { - res, err := c.transactionsAPI.DraftTransaction(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to create a draft transaction by calling the user transactions API: %w", err) - } - - return res, nil -} - -// RecordTransaction submits a transaction for recording using the user transactions API. -// This method sends an HTTP POST request to the "/transactions" endpoint, expecting -// a response that can be unmarshaled into a response.Transaction struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) RecordTransaction(ctx context.Context, cmd *commands.RecordTransaction) (*response.Transaction, error) { - res, err := c.transactionsAPI.RecordTransaction(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to record a transaction with reference ID: %s by calling the user transactions API: %w", cmd.ReferenceID, err) - } - - return res, nil -} - -// UpdateTransactionMetadata updates the metadata of a transaction using the user transactions API. -// This method sends an HTTP PATCH request with updated metadata and expects a response -// that can be unmarshaled into a response.Transaction struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) UpdateTransactionMetadata(ctx context.Context, cmd *commands.UpdateTransactionMetadata) (*response.Transaction, error) { - res, err := c.transactionsAPI.UpdateTransactionMetadata(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to update a transaction metadata by calling the user user transactions API: %w", err) - } - - return res, nil -} - -// Transactions retrieves a paginated list of transactions from the user transactions API. -// The returned response includes transactions and pagination details, such as the page number, -// sort order, and sorting field (sortBy). -// -// This method allows optional query parameters to be applied via the provided query options. -// The response is expected to unmarshal into a *response.PageModel[response.Transaction] struct. -// If the API request fails or the response cannot be decoded successfully, an error is returned. -func (c *Client) Transactions(ctx context.Context, opts ...queries.TransactionsQueryOption) (*queries.TransactionPage, error) { - res, err := c.transactionsAPI.Transactions(ctx, opts...) - if err != nil { - return nil, fmt.Errorf("failed to retrieve transactions page from the user transactions API: %w", err) - } - - return res, nil -} - -// Transaction retrieves a specific transaction by its ID using the user transactions API. -// This method expects a response that can be unmarshaled into a response.Transaction struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) Transaction(ctx context.Context, ID string) (*response.Transaction, error) { - res, err := c.transactionsAPI.Transaction(ctx, ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve transaction with ID: %s from the user transactions API: %w", ID, err) - } - - return res, nil -} - -// XPub retrieves the complete xpub information for the current user. -// The server's response is expected to be unmarshaled into a *response.Xpub struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) XPub(ctx context.Context) (*response.Xpub, error) { - res, err := c.xpubAPI.XPub(ctx) - if err != nil { - return nil, fmt.Errorf("failed to retrieve xpub information from the users API: %w", err) - } - - return res, nil -} - -// UpdateXPubMetadata updates the metadata associated with the current user's xpub. -// The server's response is expected to be unmarshaled into a *response.Xpub struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) { - res, err := c.xpubAPI.UpdateXPubMetadata(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to update xpub metadata using the users API: %w", err) - } - - return res, nil -} - -// GenerateAccessKey creates a new access key associated with the current user's xpub. -// The server's response is expected to be unmarshaled into a *response.AccessKey struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) { - res, err := c.accessKeyAPI.GenerateAccessKey(ctx, cmd) - if err != nil { - return nil, fmt.Errorf("failed to generate access key using the user access key API: %w", err) - } - - return res, nil -} - -// AccessKeys retrieves a paginated list of access keys from the user access keys API. -// The response includes access keys and pagination details, such as the page number, -// sort order, and sorting field (sortBy). -// -// This method allows optional query parameters to be applied via the provided query options. -// The response is expected to unmarshal into a *queries.AccessKeyPage struct. -// If the API request fails or the response cannot be decoded successfully, an error is returned. -func (c *Client) AccessKeys(ctx context.Context, accessKeyOpts ...queries.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { - res, err := c.accessKeyAPI.AccessKeys(ctx, accessKeyOpts...) - if err != nil { - return nil, fmt.Errorf("failed to retrieve access keys page from the user access key API: %w", err) - } - - return res, nil -} - -// AccessKey retrieves the access key associated with the specified ID. -// The server's response is expected to be unmarshaled into a *response.AccessKey struct. -// If the request fails or the response cannot be decoded, an error is returned. -func (c *Client) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) { - res, err := c.accessKeyAPI.AccessKey(ctx, ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve access key using the user access key API: %w", err) - } - - return res, nil -} - -// RevokeAccessKey revokes the access key associated with the given ID. -// If the request fails or the response cannot be processed, an error is returned. -func (c *Client) RevokeAccessKey(ctx context.Context, ID string) error { - err := c.accessKeyAPI.RevokeAccessKey(ctx, ID) - if err != nil { - return fmt.Errorf("failed to revoke access key using the users API: %w", err) - } - - return nil -} - -// UTXOs fetches a paginated list of UTXOs from the user UTXOs API. -// The response includes UTXOs along with pagination details, such as page number, -// sort order, and sorting field. -// -// Optional query parameters can be applied using the provided query options. -// The response is unmarshaled into a *queries.UtxosPage struct. -// Returns an error if the API request fails or the response cannot be decoded. -func (c *Client) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*queries.UtxosPage, error) { - res, err := c.utxosAPI.UTXOs(ctx, opts...) - if err != nil { - return nil, fmt.Errorf("failed to retrieve UTXOs page from the user UTXOs API: %w", err) - } - - return res, nil -} - -// MerkleRoots retrieves a paginated list of Merkle roots from the user Merkle roots API. -// The API response includes Merkle roots along with pagination details, such as the current -// page number, sort order, and sorting field (sortBy). -// -// This method supports optional query parameters, which can be specified using the provided -// query options. These options customize the behavior of the API request, such as setting -// batch size or applying filters for pagination. -// -// The response is unmarshaled into a *queries.MerkleRootPage struct. If the API request fails -// or the response cannot be successfully decoded, an error is returned. -func (c *Client) MerkleRoots(ctx context.Context, opts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) { - res, err := c.merkleRootsAPI.MerkleRoots(ctx, opts...) - if err != nil { - return nil, fmt.Errorf("failed to retrieve Merkle roots from the API: %w", err) - } - - return res, nil -} - -// GenerateTotpForContact generates a TOTP code for the specified contact. -func (c *Client) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { - if c.totp == nil { - return "", errors.New("totp client not initialized - xPriv authentication required") - } - totp, err := c.totp.GenerateTotpForContact(contact, period, digits) - if err != nil { - return "", fmt.Errorf("failed to generate TOTP for contact: %w", err) - } - return totp, nil -} - -// ValidateTotpForContact validates a TOTP code for the specified contact. -func (c *Client) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { - if c.totp == nil { - return errors.New("totp client not initialized - xPriv authentication required") - } - if err := c.totp.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { - return fmt.Errorf("failed to validate TOTP for contact: %w", err) - } - return nil -} - -// SyncMerkleRoots synchronizes Merkle roots known to the SPV Wallet with the client database. -// This method sends a series of HTTP GET requests to the "/merkleroots" endpoint, fetching -// Merkle roots and storing them in the client database. The process continues until all -func (c *Client) SyncMerkleRoots(ctx context.Context, repo merkleroots.MerkleRootsRepository) error { - if err := c.merkleRootsAPI.SyncMerkleRoots(ctx, repo); err != nil { - return fmt.Errorf("failed to sync Merkle roots: %w", err) - } - return nil -} - -func privateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { - pk, err1 := ec.PrivateKeyFromWif(s) - if err1 == nil { - return pk, nil - } - - pk, err2 := ec.PrivateKeyFromHex(s) - if err2 != nil { - return nil, errors.Join(err1, err2) - } - - return pk, nil -} - -type authenticator interface { - Authenticate(r *resty.Request) error -} - -func newClient(cfg Config, auth authenticator) (*Client, error) { - url, err := url.Parse(cfg.Addr) - if err != nil { - return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) - } - - httpClient := newRestyClient(cfg, auth) - return &Client{ - merkleRootsAPI: merkleroots.NewAPI(url, httpClient), - configsAPI: configs.NewAPI(url, httpClient), - transactionsAPI: transactions.NewAPI(url, httpClient), - utxosAPI: utxos.NewAPI(url, httpClient), - accessKeyAPI: users.NewAccessKeyAPI(url, httpClient), - xpubAPI: users.NewXPubAPI(url, httpClient), - contactsAPI: contacts.NewAPI(url, httpClient), - invitationsAPI: invitations.NewAPI(url, httpClient), - }, nil -} - -func newRestyClient(cfg Config, auth authenticator) *resty.Client { - return resty.New(). - SetTransport(cfg.Transport). - SetBaseURL(cfg.Addr). - SetTimeout(cfg.Timeout). - OnBeforeRequest(func(_ *resty.Client, r *resty.Request) error { - return auth.Authenticate(r) - }). - SetError(&models.SPVError{}). - OnAfterResponse(func(_ *resty.Client, r *resty.Response) error { - if r.IsSuccess() { - return nil - } - - if spvError, ok := r.Error().(*models.SPVError); ok && len(spvError.Code) > 0 { - return spvError - } - - return fmt.Errorf("%w: %s", goclienterr.ErrUnrecognizedAPIResponse, r.Body()) - }) -} diff --git a/commands/transactions.go b/commands/transactions.go index 0d9889c1..f40fa3c3 100644 --- a/commands/transactions.go +++ b/commands/transactions.go @@ -1,7 +1,7 @@ package commands import ( - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/response" ) diff --git a/commands/xpub.go b/commands/xpub.go new file mode 100644 index 00000000..1a42a225 --- /dev/null +++ b/commands/xpub.go @@ -0,0 +1,9 @@ +package commands + +import "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + +// CreateUserXpub contains the parameters required to register a user's XPub. +type CreateUserXpub struct { + Metadata querybuilders.Metadata `json:"metadata"` // Metadata associated with the XPub. + XPub string `json:"key"` // The user's XPub key to be recorded. +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..c3a7f7b5 --- /dev/null +++ b/config/config.go @@ -0,0 +1,25 @@ +package config + +import ( + "net/http" + "time" +) + +// Config holds configuration settings for establishing a connection and handling +// request details in the application. +type Config struct { + Addr string // The base address of the SPV Wallet API. + Timeout time.Duration // The HTTP requests timeout duration. + Transport http.RoundTripper // Custom HTTP transport, allowing optional customization of the HTTP client behavior. +} + +// NewDefaultConfig returns a default configuration for connecting to the SPV Wallet API, +// setting a one-minute timeout, using the default HTTP transport, and applying the +// base API address as the addr value. +func NewDefaultConfig(addr string) Config { + return Config{ + Addr: addr, + Timeout: 1 * time.Minute, + Transport: http.DefaultTransport, + } +} diff --git a/errors/errors.go b/errors/errors.go index 381533d8..f9d6cc4f 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -1,6 +1,8 @@ package errors -import "errors" +import ( + "errors" +) var ( // ErrMissingXpriv is returned when the xpriv is missing. @@ -20,7 +22,6 @@ var ( ErrSyncMerkleRootsTimeout = errors.New("SyncMerkleRoots operation timed out") // ErrStaleLastEvaluatedKey is returned when the last evaluated key has not changed between requests, ErrStaleLastEvaluatedKey = errors.New("the last evaluated key has not changed between requests, indicating a possible loop or synchronization issue.") - // ErrFailedToFetchMerkleRootsFromAPI is returned when the API fails to fetch merkle roots. ErrFailedToFetchMerkleRootsFromAPI = errors.New("failed to fetch merkle roots from API") ) diff --git a/internal/api/v1/admin/users/userstest/get_xpubs_200.json b/internal/api/v1/admin/users/userstest/get_xpubs_200.json new file mode 100644 index 00000000..ff1602e7 --- /dev/null +++ b/internal/api/v1/admin/users/userstest/get_xpubs_200.json @@ -0,0 +1,34 @@ +{ + "content": [ + { + "createdAt": "2024-11-21T11:41:49.830635Z", + "updatedAt": "2024-11-21T11:41:49.830649Z", + "deletedAt": null, + "metadata": { + "key": "val" + }, + "id": "3c7a9d02-32e3-4d83-a391-af64f1933acb", + "currentBalance": 10, + "nextInternalNum": 20, + "nextExternalNum": 30 + }, + { + "createdAt": "2024-11-21T11:26:43.091808Z", + "updatedAt": "2024-11-21T11:26:43.091857Z", + "deletedAt": null, + "metadata": { + "key": "val" + }, + "id": "301f38e2-f1dc-43cb-9db2-f2835a648b8b", + "currentBalance": 40, + "nextInternalNum": 50, + "nextExternalNum": 60 + } + ], + "page": { + "size": 50, + "number": 1, + "totalElements": 40, + "totalPages": 1 + } +} diff --git a/internal/api/v1/admin/users/userstest/post_xpub_201.json b/internal/api/v1/admin/users/userstest/post_xpub_201.json new file mode 100644 index 00000000..cbbcb84a --- /dev/null +++ b/internal/api/v1/admin/users/userstest/post_xpub_201.json @@ -0,0 +1,12 @@ +{ + "createdAt": "2024-11-22T07:51:37.708754Z", + "updatedAt": "2024-11-22T08:51:37.708865+01:00", + "deletedAt": null, + "metadata": { + "key": "value" + }, + "id": "d7ff33b6-8c25-4955-bcea-a5557c18bb95", + "currentBalance": 0, + "nextInternalNum": 0, + "nextExternalNum": 0 + } diff --git a/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go new file mode 100644 index 00000000..37f7cdca --- /dev/null +++ b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go @@ -0,0 +1,80 @@ +package userstest + +import ( + "net/http" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func ExpectedXPub(t *testing.T) *response.Xpub { + return &response.Xpub{ + Model: response.Model{ + CreatedAt: parseTime(t, "2024-11-22T07:51:37.708754Z"), + UpdatedAt: parseTime(t, "2024-11-22T08:51:37.708865+01:00"), + Metadata: map[string]any{"key": "value"}, + }, + ID: "d7ff33b6-8c25-4955-bcea-a5557c18bb95", + CurrentBalance: 0, + NextInternalNum: 0, + NextExternalNum: 0, + } +} + +func ExpectedXPubsPage(t *testing.T) *queries.XPubPage { + return &queries.XPubPage{ + Content: []*response.Xpub{ + { + Model: response.Model{ + CreatedAt: parseTime(t, "2024-11-21T11:41:49.830635Z"), + UpdatedAt: parseTime(t, "2024-11-21T11:41:49.830649Z"), + Metadata: map[string]any{"key": "val"}, + }, + ID: "3c7a9d02-32e3-4d83-a391-af64f1933acb", + CurrentBalance: 10, + NextInternalNum: 20, + NextExternalNum: 30, + }, + { + Model: response.Model{ + CreatedAt: parseTime(t, "2024-11-21T11:26:43.091808Z"), + UpdatedAt: parseTime(t, "2024-11-21T11:26:43.091857Z"), + Metadata: map[string]any{"key": "val"}, + }, + ID: "301f38e2-f1dc-43cb-9db2-f2835a648b8b", + CurrentBalance: 40, + NextInternalNum: 50, + NextExternalNum: 60, + }, + }, + Page: response.PageDescription{ + Size: 50, + Number: 1, + TotalElements: 40, + TotalPages: 1, + }, + } +} + +func Ptr[T any](value T) *T { + return &value +} + +func parseTime(t *testing.T, s string) time.Time { + ts, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("test helper - time parse: %s", err) + } + return ts +} diff --git a/internal/api/v1/admin/users/xpub_filter_builder.go b/internal/api/v1/admin/users/xpub_filter_builder.go new file mode 100644 index 00000000..d07c4094 --- /dev/null +++ b/internal/api/v1/admin/users/xpub_filter_builder.go @@ -0,0 +1,30 @@ +package xpubs + +import ( + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" +) + +type xpubFilterBuilder struct { + xpubFilter filter.XpubFilter + modelFilterBuilder querybuilders.ModelFilterBuilder +} + +func (x *xpubFilterBuilder) Build() (url.Values, error) { + modelFilterBuilder, err := x.modelFilterBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build model filter query params: %w", err) + } + + params := querybuilders.NewExtendedURLValues() + if len(modelFilterBuilder) > 0 { + params.Append(modelFilterBuilder) + } + + params.AddPair("id", x.xpubFilter.ID) + params.AddPair("currentBalance", x.xpubFilter.CurrentBalance) + return params.Values, nil +} diff --git a/internal/api/v1/admin/users/xpub_filter_builder_test.go b/internal/api/v1/admin/users/xpub_filter_builder_test.go new file mode 100644 index 00000000..4c6477c2 --- /dev/null +++ b/internal/api/v1/admin/users/xpub_filter_builder_test.go @@ -0,0 +1,81 @@ +package xpubs + +import ( + "net/url" + "testing" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/stretchr/testify/require" +) + +func TestXPubFilterBuilder_Build(t *testing.T) { + tests := map[string]struct { + filter filter.XpubFilter + expectedParams url.Values + expectedErr error + }{ + "xpub filter: zero values": { + expectedParams: make(url.Values), + }, + "xpub filter: filter with only 'id' field set": { + filter: filter.XpubFilter{ + ID: userstest.Ptr("5505cbc3-b38f-40d4-885f-c53efd84828f"), + }, + expectedParams: url.Values{ + "id": []string{"5505cbc3-b38f-40d4-885f-c53efd84828f"}, + }, + }, + "xpub filter: filter with only 'current balance' field set": { + filter: filter.XpubFilter{ + CurrentBalance: userstest.Ptr(uint64(24)), + }, + expectedParams: url.Values{ + "currentBalance": []string{"24"}, + }, + }, + "xpub filter: all fields set": { + filter: filter.XpubFilter{ + ID: userstest.Ptr("5505cbc3-b38f-40d4-885f-c53efd84828f"), + CurrentBalance: userstest.Ptr(uint64(24)), + ModelFilter: filter.ModelFilter{ + IncludeDeleted: userstest.Ptr(true), + CreatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)), + }, + UpdatedRange: &filter.TimeRange{ + From: userstest.Ptr(time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC)), + To: userstest.Ptr(time.Date(2021, 2, 2, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + expectedParams: url.Values{ + "includeDeleted": []string{"true"}, + "createdRange[from]": []string{"2021-01-01T00:00:00Z"}, + "createdRange[to]": []string{"2021-01-02T00:00:00Z"}, + "updatedRange[from]": []string{"2021-02-01T00:00:00Z"}, + "updatedRange[to]": []string{"2021-02-02T00:00:00Z"}, + "id": []string{"5505cbc3-b38f-40d4-885f-c53efd84828f"}, + "currentBalance": []string{"24"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + queryBuilder := xpubFilterBuilder{ + xpubFilter: tc.filter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: tc.filter.ModelFilter}, + } + + // then: + got, err := queryBuilder.Build() + require.ErrorIs(t, tc.expectedErr, err) + require.Equal(t, tc.expectedParams, got) + }) + } +} diff --git a/internal/api/v1/admin/users/xpubs_api.go b/internal/api/v1/admin/users/xpubs_api.go new file mode 100644 index 00000000..262ad8ac --- /dev/null +++ b/internal/api/v1/admin/users/xpubs_api.go @@ -0,0 +1,82 @@ +package xpubs + +import ( + "context" + "fmt" + "net/url" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +const route = "api/v1/admin/users" + +type API struct { + url *url.URL + httpClient *resty.Client +} + +func (a *API) CreateXPub(ctx context.Context, cmd *commands.CreateUserXpub) (*response.Xpub, error) { + var result response.Xpub + _, err := a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetBody(cmd). + Post(a.url.String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func (a *API) XPubs(ctx context.Context, opts ...queries.XPubQueryOption) (*queries.XPubPage, error) { + var query queries.XPubQuery + for _, o := range opts { + o(&query) + } + + queryBuilder := querybuilders.NewQueryBuilder( + querybuilders.WithMetadataFilter(query.Metadata), + querybuilders.WithPageFilter(query.PageFilter), + querybuilders.WithFilterQueryBuilder(&xpubFilterBuilder{ + xpubFilter: query.XpubFilter, + modelFilterBuilder: querybuilders.ModelFilterBuilder{ModelFilter: query.XpubFilter.ModelFilter}, + }), + ) + params, err := queryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build user xpubs query params: %w", err) + } + + var result queries.XPubPage + _, err = a.httpClient.R(). + SetContext(ctx). + SetResult(&result). + SetQueryParams(params.ParseToMap()). + Get(a.url.String()) + if err != nil { + return nil, fmt.Errorf("HTTP response failure: %w", err) + } + + return &result, nil +} + +func NewAPI(url *url.URL, httpClient *resty.Client) *API { + return &API{ + url: url.JoinPath(route), + httpClient: httpClient, + } +} + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "Admin Users XPub API", + Err: err, + } +} diff --git a/internal/api/v1/admin/users/xpubs_api_test.go b/internal/api/v1/admin/users/xpubs_api_test.go new file mode 100644 index 00000000..a74c1dab --- /dev/null +++ b/internal/api/v1/admin/users/xpubs_api_test.go @@ -0,0 +1,89 @@ +package xpubs_test + +import ( + "context" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/admin/users/userstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestXPubsAPI_CreateXPub(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + expectedResponse *response.Xpub + expectedErr error + }{ + "HTTP POST /api/v1/admin/users response: 201": { + expectedResponse: userstest.ExpectedXPub(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusCreated, httpmock.File("userstest/post_xpub_201.json")), + }, + "HTTP POST /api/v1/admin/users response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP POST /api/v1/admin/users str response: 500": { + expectedErr: errors.ErrUnrecognizedAPIResponse, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := spvwallettest.GivenSPVAdminAPI(t) + transport.RegisterResponder(http.MethodPost, URL, tc.responder) + + // then: + got, err := wallet.CreateXPub(context.Background(), &commands.CreateUserXpub{ + Metadata: map[string]any{}, + XPub: "", + }) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} + +func TestXPubsAPI_XPubs(t *testing.T) { + tests := map[string]struct { + responder httpmock.Responder + expectedResponse *queries.XPubPage + expectedErr error + }{ + "HTTP GET /api/v1/admin/users response: 200": { + expectedResponse: userstest.ExpectedXPubsPage(t), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_xpubs_200.json")), + }, + "HTTP GET /api/v1/admin/users response: 400": { + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + }, + "HTTP GET /api/v1/admin/users str response: 500": { + expectedErr: errors.ErrUnrecognizedAPIResponse, + responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + }, + } + + URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // when: + wallet, transport := spvwallettest.GivenSPVAdminAPI(t) + transport.RegisterResponder(http.MethodGet, URL, tc.responder) + + // then: + got, err := wallet.XPubs(context.Background()) + require.ErrorIs(t, err, tc.expectedErr) + require.EqualValues(t, tc.expectedResponse, got) + }) + } +} diff --git a/internal/api/v1/errutil/errutil.go b/internal/api/v1/errutil/errutil.go new file mode 100644 index 00000000..cd302a02 --- /dev/null +++ b/internal/api/v1/errutil/errutil.go @@ -0,0 +1,22 @@ +package errutil + +import ( + "fmt" + "net/http" +) + +type HTTPErrorFormatter struct { + Action string + API string + Err error +} + +func (h HTTPErrorFormatter) Format(method string) error { + return fmt.Errorf("failed to send HTTP %s request to %s via %s: %w", method, h.Action, h.API, h.Err) +} + +func (h HTTPErrorFormatter) FormatPutErr() error { return h.Format(http.MethodPut) } +func (h HTTPErrorFormatter) FormatPatchErr() error { return h.Format(http.MethodPatch) } +func (h HTTPErrorFormatter) FormatPostErr() error { return h.Format(http.MethodPost) } +func (h HTTPErrorFormatter) FormatGetErr() error { return h.Format(http.MethodGet) } +func (h HTTPErrorFormatter) FormatDeleteErr() error { return h.Format(http.MethodDelete) } diff --git a/internal/api/v1/errutil/errutil_test.go b/internal/api/v1/errutil/errutil_test.go new file mode 100644 index 00000000..1c80854b --- /dev/null +++ b/internal/api/v1/errutil/errutil_test.go @@ -0,0 +1,33 @@ +package errutil_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/stretchr/testify/require" +) + +func TestHTTPErrorFormatter_Format(t *testing.T) { + // given: + const ( + API = "Users API" + action = "retrieve users page" + ) + wrappedErr := errors.New(http.StatusText(http.StatusInternalServerError)) + expectedErr := fmt.Errorf("failed to send HTTP %s request to %s via %s: %w", http.MethodPost, action, API, wrappedErr) + + formatter := errutil.HTTPErrorFormatter{ + Action: action, + API: API, + Err: wrappedErr, + } + + // when: + got := formatter.Format(http.MethodPost) + + // then: + require.Equal(t, got, expectedErr) +} diff --git a/internal/api/v1/user/querybuilders/extended_url_values.go b/internal/api/v1/querybuilders/extended_url_values.go similarity index 100% rename from internal/api/v1/user/querybuilders/extended_url_values.go rename to internal/api/v1/querybuilders/extended_url_values.go diff --git a/internal/api/v1/user/querybuilders/extended_url_values_test.go b/internal/api/v1/querybuilders/extended_url_values_test.go similarity index 92% rename from internal/api/v1/user/querybuilders/extended_url_values_test.go rename to internal/api/v1/querybuilders/extended_url_values_test.go index d0ca3cf5..96c3feba 100644 --- a/internal/api/v1/user/querybuilders/extended_url_values_test.go +++ b/internal/api/v1/querybuilders/extended_url_values_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder.go b/internal/api/v1/querybuilders/metadata_filter_builder.go similarity index 100% rename from internal/api/v1/user/querybuilders/metadata_filter_builder.go rename to internal/api/v1/querybuilders/metadata_filter_builder.go diff --git a/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go b/internal/api/v1/querybuilders/metadata_filter_builder_test.go similarity index 98% rename from internal/api/v1/user/querybuilders/metadata_filter_builder_test.go rename to internal/api/v1/querybuilders/metadata_filter_builder_test.go index d6a61959..52d52203 100644 --- a/internal/api/v1/user/querybuilders/metadata_filter_builder_test.go +++ b/internal/api/v1/querybuilders/metadata_filter_builder_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/model_filter_builder.go b/internal/api/v1/querybuilders/model_filter_builder.go similarity index 100% rename from internal/api/v1/user/querybuilders/model_filter_builder.go rename to internal/api/v1/querybuilders/model_filter_builder.go diff --git a/internal/api/v1/user/querybuilders/model_filter_builder_test.go b/internal/api/v1/querybuilders/model_filter_builder_test.go similarity index 95% rename from internal/api/v1/user/querybuilders/model_filter_builder_test.go rename to internal/api/v1/querybuilders/model_filter_builder_test.go index a113b566..45d6c36e 100644 --- a/internal/api/v1/user/querybuilders/model_filter_builder_test.go +++ b/internal/api/v1/querybuilders/model_filter_builder_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/page_filter_builder.go b/internal/api/v1/querybuilders/page_filter_builder.go similarity index 100% rename from internal/api/v1/user/querybuilders/page_filter_builder.go rename to internal/api/v1/querybuilders/page_filter_builder.go diff --git a/internal/api/v1/user/querybuilders/page_filter_builder_test.go b/internal/api/v1/querybuilders/page_filter_builder_test.go similarity index 95% rename from internal/api/v1/user/querybuilders/page_filter_builder_test.go rename to internal/api/v1/querybuilders/page_filter_builder_test.go index 1e5a73b4..7f04dd84 100644 --- a/internal/api/v1/user/querybuilders/page_filter_builder_test.go +++ b/internal/api/v1/querybuilders/page_filter_builder_test.go @@ -4,7 +4,7 @@ import ( "net/url" "testing" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/query_builder.go b/internal/api/v1/querybuilders/query_builder.go similarity index 100% rename from internal/api/v1/user/querybuilders/query_builder.go rename to internal/api/v1/querybuilders/query_builder.go diff --git a/internal/api/v1/user/querybuilders/query_builder_test.go b/internal/api/v1/querybuilders/query_builder_test.go similarity index 95% rename from internal/api/v1/user/querybuilders/query_builder_test.go rename to internal/api/v1/querybuilders/query_builder_test.go index 918a8dbf..9d13dad4 100644 --- a/internal/api/v1/user/querybuilders/query_builder_test.go +++ b/internal/api/v1/querybuilders/query_builder_test.go @@ -7,8 +7,8 @@ import ( "time" goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders/querybuilderstest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders/querybuilderstest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/query_params_filter_builder.go b/internal/api/v1/querybuilders/query_params_filter_builder.go similarity index 100% rename from internal/api/v1/user/querybuilders/query_params_filter_builder.go rename to internal/api/v1/querybuilders/query_params_filter_builder.go diff --git a/internal/api/v1/user/querybuilders/query_params_filter_builder_test.go b/internal/api/v1/querybuilders/query_params_filter_builder_test.go similarity index 95% rename from internal/api/v1/user/querybuilders/query_params_filter_builder_test.go rename to internal/api/v1/querybuilders/query_params_filter_builder_test.go index 7279753a..b22e0a6d 100644 --- a/internal/api/v1/user/querybuilders/query_params_filter_builder_test.go +++ b/internal/api/v1/querybuilders/query_params_filter_builder_test.go @@ -4,7 +4,7 @@ import ( "net/url" "testing" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go b/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go similarity index 100% rename from internal/api/v1/user/querybuilders/querybuilderstest/querybuilderstest.go rename to internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go diff --git a/internal/api/v1/user/configs/configs_api.go b/internal/api/v1/user/configs/configs_api.go index 4313de78..aa45f2fd 100644 --- a/internal/api/v1/user/configs/configs_api.go +++ b/internal/api/v1/user/configs/configs_api.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" ) @@ -36,3 +37,11 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Shared Config API", + Err: err, + } +} diff --git a/internal/api/v1/user/configs/configs_api_test.go b/internal/api/v1/user/configs/configs_api_test.go index 73036604..8ba1aa31 100644 --- a/internal/api/v1/user/configs/configs_api_test.go +++ b/internal/api/v1/user/configs/configs_api_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" @@ -51,11 +51,11 @@ func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/configs/shared" + url := spvwallettest.TestAPIAddr + "/api/v1/configs/shared" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: diff --git a/internal/api/v1/user/contacts/contact_filter_query_builder.go b/internal/api/v1/user/contacts/contact_filter_query_builder.go index 327e915b..c4d50d77 100644 --- a/internal/api/v1/user/contacts/contact_filter_query_builder.go +++ b/internal/api/v1/user/contacts/contact_filter_query_builder.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" ) diff --git a/internal/api/v1/user/contacts/contact_filter_query_builder_test.go b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go index b00c0e30..a39e0239 100644 --- a/internal/api/v1/user/contacts/contact_filter_query_builder_test.go +++ b/internal/api/v1/user/contacts/contact_filter_query_builder_test.go @@ -4,8 +4,8 @@ import ( "net/url" "testing" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" ) diff --git a/internal/api/v1/user/contacts/contacts_api.go b/internal/api/v1/user/contacts/contacts_api.go index 2e5b1248..a38c86e8 100644 --- a/internal/api/v1/user/contacts/contacts_api.go +++ b/internal/api/v1/user/contacts/contacts_api.go @@ -6,7 +6,8 @@ import ( "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -132,3 +133,11 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Contacts API", + Err: err, + } +} diff --git a/internal/api/v1/user/contacts/contacts_api_test.go b/internal/api/v1/user/contacts/contacts_api_test.go index 1a7990e7..bd1d53f3 100644 --- a/internal/api/v1/user/contacts/contacts_api_test.go +++ b/internal/api/v1/user/contacts/contacts_api_test.go @@ -9,7 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" @@ -45,11 +45,11 @@ func TestContactsAPI_Contacts(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts" + url := spvwallettest.TestAPIAddr + "/api/v1/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -89,11 +89,11 @@ func TestContactsAPI_ContactWithPaymail(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -133,11 +133,11 @@ func TestContactsAPI_UpsertContact(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPut, url, tc.responder) // then: @@ -179,11 +179,11 @@ func TestContactsAPI_RemoveContact(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: @@ -196,7 +196,7 @@ func TestContactsAPI_RemoveContact(t *testing.T) { func TestContactsAPI_ConfirmContact(t *testing.T) { contact := &models.Contact{ Paymail: "alice@example.com", - PubKey: clienttest.MockPKI(t, clienttest.UserXPub), + PubKey: spvwallettest.MockPKI(t, spvwallettest.UserXPub), } tests := map[string]struct { @@ -224,13 +224,13 @@ func TestContactsAPI_ConfirmContact(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts/" + contact.Paymail + "/confirmation" + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + contact.Paymail + "/confirmation" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wrappedTransport := clienttest.NewTransportWrapper() - aliceClient, _ := clienttest.GivenSPVWalletClientWithTransport(t, wrappedTransport) + wrappedTransport := spvwallettest.NewTransportWrapper() + aliceClient, _ := spvwallettest.GivenSPVWalletClientWithTransport(t, wrappedTransport) wrappedTransport.RegisterResponder(http.MethodPost, url, tc.responder) passcode, err := aliceClient.GenerateTotpForContact(contact, 3600, 6) @@ -277,11 +277,11 @@ func TestContactsAPI_UnconfirmContact(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" + url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: diff --git a/internal/api/v1/user/invitations/invitations_api.go b/internal/api/v1/user/invitations/invitations_api.go index 8734cf84..237f6754 100644 --- a/internal/api/v1/user/invitations/invitations_api.go +++ b/internal/api/v1/user/invitations/invitations_api.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" "github.com/go-resty/resty/v2" ) @@ -45,3 +46,11 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Invitations API", + Err: err, + } +} diff --git a/internal/api/v1/user/invitations/invitations_api_test.go b/internal/api/v1/user/invitations/invitations_api_test.go index 6ef4c740..4f27319a 100644 --- a/internal/api/v1/user/invitations/invitations_api_test.go +++ b/internal/api/v1/user/invitations/invitations_api_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" @@ -41,11 +41,11 @@ func TestInvitationsAPI_AcceptInvitation(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" + url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: @@ -82,11 +82,11 @@ func TestInvitationsAPI_RejectInvitation(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/invitations/" + paymail + url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: diff --git a/internal/api/v1/user/merkleroots/merkleroots_api.go b/internal/api/v1/user/merkleroots/merkleroots_api.go index 7fb1edd2..6beef0f8 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api.go @@ -7,7 +7,8 @@ import ( "net/url" goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/go-resty/resty/v2" @@ -61,11 +62,15 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { } } -// SyncMerkleRoots syncs merkleroots known to spv-wallet with the client database -// If timeout is needed pass context.WithTimeout() as ctx param -// SyncMerkleRoots synchronizes Merkle roots known to SPV Wallet with the client database. -func (a *API) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error { +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Merkle roots API", + Err: err, + } +} +func (a *API) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository) error { lastEvaluatedKey := repo.GetLastMerkleRoot() previousLastEvaluatedKey := lastEvaluatedKey diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go index 328c5429..0e3a154d 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api_test.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -7,7 +7,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots/merklerootstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" @@ -43,11 +43,11 @@ func TestMerkleRootsAPI_MerkleRoots(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/merkleroots" + url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -113,12 +113,12 @@ func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/merkleroots" + url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" for name, tc := range tests { t.Run(name, func(t *testing.T) { // Arrange - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) mockRepo := new(MockMerkleRootsRepository) @@ -142,8 +142,8 @@ func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) { func TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully(t *testing.T) { // given: db := merklerootstest.CreateRepository([]models.MerkleRoot{}) - url := clienttest.TestAPIAddr + "/api/v1/merkleroots" - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) var expected []models.MerkleRoot expected = append(expected, merklerootstest.FirstMerkleRootsPage().Content...) diff --git a/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go index 408d99fc..04b66f91 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go +++ b/internal/api/v1/user/merkleroots/merkleroots_filter_builder.go @@ -3,7 +3,7 @@ package merkleroots import ( "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" ) diff --git a/internal/api/v1/user/totp/totp_test.go b/internal/api/v1/user/totp/totp_test.go index 1d0414a7..4ed1a755 100644 --- a/internal/api/v1/user/totp/totp_test.go +++ b/internal/api/v1/user/totp/totp_test.go @@ -5,9 +5,10 @@ import ( "time" client "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/config" "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/stretchr/testify/require" ) @@ -15,8 +16,8 @@ import ( func TestClient_GenerateTotpForContact(t *testing.T) { t.Run("success", func(t *testing.T) { // given - contact := models.Contact{PubKey: clienttest.PubKey} - wc := totp.New(clienttest.ExtendedKey(t)) + contact := models.Contact{PubKey: spvwallettest.PubKey} + wc := totp.New(spvwallettest.ExtendedKey(t)) // when pass, err := wc.GenerateTotpForContact(&contact, 30, 2) @@ -29,7 +30,7 @@ func TestClient_GenerateTotpForContact(t *testing.T) { t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { // given contact := models.Contact{PubKey: "invalid-pk-format"} - wc := totp.New(clienttest.ExtendedKey(t)) + wc := totp.New(spvwallettest.ExtendedKey(t)) // when _, err := wc.GenerateTotpForContact(&contact, 30, 2) @@ -40,26 +41,26 @@ func TestClient_GenerateTotpForContact(t *testing.T) { } func TestClient_ValidateTotpForContact(t *testing.T) { - cfg := client.Config{ - Addr: clienttest.TestAPIAddr, + cfg := config.Config{ + Addr: spvwallettest.TestAPIAddr, Timeout: 5 * time.Second, } t.Run("success", func(t *testing.T) { // given - clientAlice, err := client.NewWithXPriv(cfg, clienttest.AliceXPriv) + clientAlice, err := client.NewUserAPIWithXPriv(cfg, spvwallettest.AliceXPriv) require.NoError(t, err) - clientBob, err := client.NewWithXPriv(cfg, clienttest.BobXPriv) + clientBob, err := client.NewUserAPIWithXPriv(cfg, spvwallettest.BobXPriv) require.NoError(t, err) // and aliceContact := &models.Contact{ - PubKey: clienttest.MockPKI(t, clienttest.AliceXPub), + PubKey: spvwallettest.MockPKI(t, spvwallettest.AliceXPub), Paymail: "alice@example.com", } bobContact := &models.Contact{ - PubKey: clienttest.MockPKI(t, clienttest.BobXPub), + PubKey: spvwallettest.MockPKI(t, spvwallettest.BobXPub), Paymail: "bob@example.com", } @@ -78,7 +79,7 @@ func TestClient_ValidateTotpForContact(t *testing.T) { t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { // given - sut, err := client.NewWithXPriv(cfg, clienttest.UserXPriv) + sut, err := client.NewUserAPIWithXPriv(cfg, spvwallettest.UserXPriv) require.NoError(t, err) // and diff --git a/internal/api/v1/user/transactions/transaction_filter_builder.go b/internal/api/v1/user/transactions/transaction_filter_builder.go index 13502800..e361c426 100644 --- a/internal/api/v1/user/transactions/transaction_filter_builder.go +++ b/internal/api/v1/user/transactions/transaction_filter_builder.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" ) diff --git a/internal/api/v1/user/transactions/transaction_filter_builder_test.go b/internal/api/v1/user/transactions/transaction_filter_builder_test.go index 7ca129ca..af5f57bc 100644 --- a/internal/api/v1/user/transactions/transaction_filter_builder_test.go +++ b/internal/api/v1/user/transactions/transaction_filter_builder_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" diff --git a/internal/api/v1/user/transactions/transactions_api.go b/internal/api/v1/user/transactions/transactions_api.go index f18cfa0f..c669b7af 100644 --- a/internal/api/v1/user/transactions/transactions_api.go +++ b/internal/api/v1/user/transactions/transactions_api.go @@ -6,7 +6,8 @@ import ( "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -116,3 +117,11 @@ func NewAPI(URL *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Transactions API", + Err: err, + } +} diff --git a/internal/api/v1/user/transactions/transactions_api_test.go b/internal/api/v1/user/transactions/transactions_api_test.go index 7a159df0..45a264e1 100644 --- a/internal/api/v1/user/transactions/transactions_api_test.go +++ b/internal/api/v1/user/transactions/transactions_api_test.go @@ -8,9 +8,9 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/errors" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" @@ -47,11 +47,11 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPatch, url, tc.responder) // then: @@ -96,11 +96,11 @@ func TestTransactionsAPI_RecordTransaction(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/transactions" + url := spvwallettest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: @@ -139,11 +139,11 @@ func TestTransactionsAPI_DraftTransaction(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/transactions/drafts" + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/drafts" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: @@ -186,11 +186,11 @@ func TestTransactionsAPI_Transaction(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/transactions/" + ID + url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -229,11 +229,11 @@ func TestTransactionsAPI_Transactions(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/transactions" + url := spvwallettest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - spvWalletClient, transport := clienttest.GivenSPVWalletClient(t) + spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: diff --git a/internal/api/v1/user/users/access_key_api.go b/internal/api/v1/user/users/access_key_api.go index 9aaed9fe..81b30bae 100644 --- a/internal/api/v1/user/users/access_key_api.go +++ b/internal/api/v1/user/users/access_key_api.go @@ -6,7 +6,8 @@ import ( "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -96,3 +97,11 @@ func NewAccessKeyAPI(url *url.URL, httpClient *resty.Client) *AccessKeyAPI { httpClient: httpClient, } } + +func AccessKeysHTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User Access Keys API", + Err: err, + } +} diff --git a/internal/api/v1/user/users/access_key_api_test.go b/internal/api/v1/user/users/access_key_api_test.go index 707b9705..f089dbff 100644 --- a/internal/api/v1/user/users/access_key_api_test.go +++ b/internal/api/v1/user/users/access_key_api_test.go @@ -9,7 +9,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" @@ -46,11 +46,11 @@ func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) // then: @@ -95,11 +95,11 @@ func TestAccessKeyAPI_AccessKey(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -139,11 +139,11 @@ func TestAccessKeyAPI_AccessKeys(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current/keys" + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: @@ -182,11 +182,11 @@ func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current/keys/" + ID + url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) // then: diff --git a/internal/api/v1/user/users/access_key_filter_query_builder.go b/internal/api/v1/user/users/access_key_filter_query_builder.go index 38357ba6..6df26851 100644 --- a/internal/api/v1/user/users/access_key_filter_query_builder.go +++ b/internal/api/v1/user/users/access_key_filter_query_builder.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" ) diff --git a/internal/api/v1/user/users/access_key_filter_query_builder_test.go b/internal/api/v1/user/users/access_key_filter_query_builder_test.go index 1b012798..0952d08c 100644 --- a/internal/api/v1/user/users/access_key_filter_query_builder_test.go +++ b/internal/api/v1/user/users/access_key_filter_query_builder_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" diff --git a/internal/api/v1/user/users/xpub_api.go b/internal/api/v1/user/users/xpub_api.go index 428893a0..9517fd24 100644 --- a/internal/api/v1/user/users/xpub_api.go +++ b/internal/api/v1/user/users/xpub_api.go @@ -6,6 +6,7 @@ import ( "net/url" "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" ) @@ -50,4 +51,12 @@ func NewXPubAPI(url *url.URL, httpClient *resty.Client) *XPubAPI { url: url.JoinPath(route), httpClient: httpClient, } + +} +func XPubsHTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User XPubs API", + Err: err, + } } diff --git a/internal/api/v1/user/users/xpub_api_test.go b/internal/api/v1/user/users/xpub_api_test.go index b8d4ed38..3bd79349 100644 --- a/internal/api/v1/user/users/xpub_api_test.go +++ b/internal/api/v1/user/users/xpub_api_test.go @@ -8,7 +8,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" @@ -44,11 +44,11 @@ func TestXPubAPI_UpdateXPubMetadata(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current" + url := spvwallettest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPatch, url, tc.responder) // then: @@ -92,11 +92,11 @@ func TestXPubAPI_XPub(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/users/current" + url := spvwallettest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: diff --git a/internal/api/v1/user/utxos/utxo_filter_query_builder.go b/internal/api/v1/user/utxos/utxo_filter_query_builder.go index 60481963..2eee46c3 100644 --- a/internal/api/v1/user/utxos/utxo_filter_query_builder.go +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet/models/filter" ) diff --git a/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go index f63ae845..d0fdeedd 100644 --- a/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go +++ b/internal/api/v1/user/utxos/utxo_filter_query_builder_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" "github.com/bitcoin-sv/spv-wallet/models/filter" "github.com/stretchr/testify/require" diff --git a/internal/api/v1/user/utxos/utxos_api.go b/internal/api/v1/user/utxos/utxos_api.go index 7919f305..42e46679 100644 --- a/internal/api/v1/user/utxos/utxos_api.go +++ b/internal/api/v1/user/utxos/utxos_api.go @@ -5,7 +5,8 @@ import ( "fmt" "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/go-resty/resty/v2" ) @@ -56,3 +57,11 @@ func NewAPI(url *url.URL, httpClient *resty.Client) *API { httpClient: httpClient, } } + +func HTTPErrorFormatter(action string, err error) *errutil.HTTPErrorFormatter { + return &errutil.HTTPErrorFormatter{ + Action: action, + API: "User UTXOs API", + Err: err, + } +} diff --git a/internal/api/v1/user/utxos/utxos_api_test.go b/internal/api/v1/user/utxos/utxos_api_test.go index 8cf0c2b7..cba4730e 100644 --- a/internal/api/v1/user/utxos/utxos_api_test.go +++ b/internal/api/v1/user/utxos/utxos_api_test.go @@ -7,7 +7,7 @@ import ( "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" @@ -43,11 +43,11 @@ func TestUTXOAPI_UTXOs(t *testing.T) { }, } - url := clienttest.TestAPIAddr + "/api/v1/utxos" + url := spvwallettest.TestAPIAddr + "/api/v1/utxos" for name, tc := range tests { t.Run(name, func(t *testing.T) { // when: - wallet, transport := clienttest.GivenSPVWalletClient(t) + wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) // then: diff --git a/internal/auth/authenticators_test.go b/internal/auth/authenticators_test.go index ccfae3d7..000e9451 100644 --- a/internal/auth/authenticators_test.go +++ b/internal/auth/authenticators_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" - "github.com/bitcoin-sv/spv-wallet-go-client/internal/clienttest" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/go-resty/resty/v2" "github.com/stretchr/testify/require" ) @@ -30,7 +30,7 @@ func TestAccessKeyAuthenitcator_NewWithNilAccessKey(t *testing.T) { func TestAccessKeyAuthenticator_Authenticate(t *testing.T) { // given: - key := clienttest.PrivateKey(t) + key := spvwallettest.PrivateKey(t) authenticator, err := auth.NewAccessKeyAuthenticator(key) require.NotNil(t, authenticator) require.NoError(t, err) @@ -57,7 +57,7 @@ func TestXprivAuthenitcator_NewWithNilXpriv(t *testing.T) { func TestXprivAuthenitcator_Authenticate(t *testing.T) { // given: - key := clienttest.ExtendedKey(t) + key := spvwallettest.ExtendedKey(t) authenticator, err := auth.NewXprivAuthenticator(key) require.NotNil(t, authenticator) require.NoError(t, err) @@ -84,7 +84,7 @@ func TestXpubOnlyAuthenticator_NewWithNilXpub(t *testing.T) { func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { // given: - key := clienttest.ExtendedKey(t) + key := spvwallettest.ExtendedKey(t) authenticator, err := auth.NewXpubOnlyAuthenticator(key) require.NotNil(t, authenticator) @@ -101,11 +101,11 @@ func TestXpubOnlyAuthenticator_Authenticate(t *testing.T) { } func requireXAuthHeaderToBeSet(t *testing.T, h http.Header) { - require.Equal(t, []string{clienttest.UserPubAccessKey}, h[xAuthKey]) + require.Equal(t, []string{spvwallettest.UserPubAccessKey}, h[xAuthKey]) } func requireXpubHeaderToBeSet(t *testing.T, h http.Header) { - require.Equal(t, []string{clienttest.UserXPub}, h[xAuthXPubKey]) + require.Equal(t, []string{spvwallettest.UserXPub}, h[xAuthXPubKey]) } func requireSignatureHeadersToBeSet(t *testing.T, h http.Header) { diff --git a/internal/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go index b0027750..58bebb91 100644 --- a/internal/cryptoutil/cryptoutil.go +++ b/internal/cryptoutil/cryptoutil.go @@ -10,6 +10,7 @@ import ( "strconv" bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + ec "github.com/bitcoin-sv/go-sdk/primitives/ec" ) const ( @@ -97,6 +98,20 @@ func Int64ToUint32(value int64) (uint32, error) { return uint32(value), nil } +func PrivateKeyFromHexOrWIF(s string) (*ec.PrivateKey, error) { + pk, err1 := ec.PrivateKeyFromWif(s) + if err1 == nil { + return pk, nil + } + + pk, err2 := ec.PrivateKeyFromHex(s) + if err2 != nil { + return nil, errors.Join(err1, err2) + } + + return pk, nil +} + var ( ErrMaxUint32LimitExceeded = errors.New("max uint32 value exceeded") ErrNegativeValueNotAllowed = errors.New("negative value is not allowed") diff --git a/internal/restyutil/restyutil.go b/internal/restyutil/restyutil.go new file mode 100644 index 00000000..c6d0550b --- /dev/null +++ b/internal/restyutil/restyutil.go @@ -0,0 +1,36 @@ +package restyutil + +import ( + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/config" + goclienterr "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/go-resty/resty/v2" +) + +type Authenticator interface { + Authenticate(r *resty.Request) error +} + +func NewHTTPClient(cfg config.Config, auth Authenticator) *resty.Client { + return resty.New(). + SetTransport(cfg.Transport). + SetBaseURL(cfg.Addr). + SetTimeout(cfg.Timeout). + OnBeforeRequest(func(_ *resty.Client, r *resty.Request) error { + return auth.Authenticate(r) + }). + SetError(&models.SPVError{}). + OnAfterResponse(func(_ *resty.Client, r *resty.Response) error { + if r.IsSuccess() { + return nil + } + + if spvError, ok := r.Error().(*models.SPVError); ok && len(spvError.Code) > 0 { + return spvError + } + + return fmt.Errorf("%w: %s", goclienterr.ErrUnrecognizedAPIResponse, r.Body()) + }) +} diff --git a/internal/clienttest/clienttest.go b/internal/spvwallettest/spvwallettest.go similarity index 78% rename from internal/clienttest/clienttest.go rename to internal/spvwallettest/spvwallettest.go index 9bbff217..369f7b12 100644 --- a/internal/clienttest/clienttest.go +++ b/internal/spvwallettest/spvwallettest.go @@ -1,4 +1,4 @@ -package clienttest +package spvwallettest import ( "encoding/hex" @@ -8,7 +8,8 @@ import ( bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" ec "github.com/bitcoin-sv/go-sdk/primitives/ec" - client "github.com/bitcoin-sv/spv-wallet-go-client" + spvwallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/config" "github.com/jarcoal/httpmock" ) @@ -47,16 +48,16 @@ func PrivateKey(t *testing.T) *ec.PrivateKey { return key } -func GivenSPVWalletClient(t *testing.T) (*client.Client, *httpmock.MockTransport) { +func GivenSPVUserAPI(t *testing.T) (*spvwallet.UserAPI, *httpmock.MockTransport) { t.Helper() transport := httpmock.NewMockTransport() - cfg := client.Config{ + cfg := config.Config{ Addr: TestAPIAddr, Timeout: 5 * time.Second, Transport: transport, } - spv, err := client.NewWithXPriv(cfg, UserXPriv) + spv, err := spvwallet.NewUserAPIWithXPriv(cfg, UserXPriv) if err != nil { t.Fatalf("test helper - spv wallet client with xpriv: %s", err) } @@ -64,7 +65,24 @@ func GivenSPVWalletClient(t *testing.T) (*client.Client, *httpmock.MockTransport return spv, transport } -func GivenSPVWalletClientWithTransport(t *testing.T, transport http.RoundTripper) (*client.Client, *httpmock.MockTransport) { +func GivenSPVAdminAPI(t *testing.T) (*spvwallet.AdminAPI, *httpmock.MockTransport) { + t.Helper() + transport := httpmock.NewMockTransport() + cfg := config.Config{ + Addr: TestAPIAddr, + Timeout: 5 * time.Second, + Transport: transport, + } + + api, err := spvwallet.NewAdminAPIWithXPriv(cfg, UserXPriv) + if err != nil { + t.Fatalf("test helper - admin api with xPub: %s", err) + } + + return api, transport +} + +func GivenSPVWalletClientWithTransport(t *testing.T, transport http.RoundTripper) (*spvwallet.UserAPI, *httpmock.MockTransport) { t.Helper() // Extract the wrapped MockTransport if it's a TransportWrapper @@ -77,13 +95,13 @@ func GivenSPVWalletClientWithTransport(t *testing.T, transport http.RoundTripper t.Fatalf("expected transport to be of type *httpmock.MockTransport or *httpmockwrapper.TransportWrapper, got %T", transport) } - cfg := client.Config{ + cfg := config.Config{ Addr: TestAPIAddr, Timeout: 5 * time.Second, Transport: transport, } - spv, err := client.NewWithXPriv(cfg, UserXPriv) + spv, err := spvwallet.NewUserAPIWithXPriv(cfg, UserXPriv) if err != nil { t.Fatalf("test helper - spv wallet client with xpriv: %s", err) } diff --git a/internal/clienttest/transportmock_wrapper.go b/internal/spvwallettest/transportmock_wrapper.go similarity index 98% rename from internal/clienttest/transportmock_wrapper.go rename to internal/spvwallettest/transportmock_wrapper.go index 3aa47c5b..1f7091d9 100644 --- a/internal/clienttest/transportmock_wrapper.go +++ b/internal/spvwallettest/transportmock_wrapper.go @@ -1,4 +1,4 @@ -package clienttest +package spvwallettest import ( "fmt" diff --git a/queries/xpubs.go b/queries/xpubs.go new file mode 100644 index 00000000..e63429fd --- /dev/null +++ b/queries/xpubs.go @@ -0,0 +1,47 @@ +package queries + +import ( + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +// XPubPage represents a paginated response model containing XPubs, +// as provided by the SPV Wallet API. +type XPubPage = response.PageModel[response.Xpub] + +// XPubQuery defines the query parameters used to construct the XPubs search endpoint URL. +// It includes filters for metadata, pagination, and attributes specific to XPubs and UTXOs. +type XPubQuery struct { + Metadata map[string]any // Filters based on key-value pairs of metadata. + PageFilter filter.Page // Pagination settings, including page number, size, and sort order. + XpubFilter filter.XpubFilter // Filters for XPub properties, such as ID, balance, and date ranges. +} + +// XPubQueryOption specifies a functional option for configuring an XPubQuery instance. +type XPubQueryOption func(*XPubQuery) + +// XPubQueryWithMetadataFilter applies metadata filters to the XPubQuery instance. +// The specified key-value pairs will be added as query parameters to the search URL. +func XPubQueryWithMetadataFilter(m map[string]any) XPubQueryOption { + return func(xq *XPubQuery) { + xq.Metadata = m + } +} + +// XPubQueryWithPageFilter applies pagination settings to the XPubQuery instance. +// These include details like page number, page size, and sort order, which will be +// appended as query parameters to the search URL. +func XPubQueryWithPageFilter(f filter.Page) XPubQueryOption { + return func(xq *XPubQuery) { + xq.PageFilter = f + } +} + +// XPubQueryWithXPubFilter applies XPub-specific filters to the XPubQuery instance. +// This includes filters for attributes like ID, balance, or date ranges, which will +// be added as query parameters to the search URL. +func XPubQueryWithXPubFilter(f filter.XpubFilter) XPubQueryOption { + return func(xq *XPubQuery) { + xq.XpubFilter = f + } +} diff --git a/user_api.go b/user_api.go new file mode 100644 index 00000000..a2a7a6ad --- /dev/null +++ b/user_api.go @@ -0,0 +1,466 @@ +package spvwallet + +import ( + "context" + "errors" + "fmt" + "net/url" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/config" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/merkleroots" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/totp" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/restyutil" + "github.com/bitcoin-sv/spv-wallet-go-client/queries" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/go-resty/resty/v2" +) + +// UserAPI provides methods for interacting with user-related APIs. +// It abstracts the details of HTTP request and response handling, +// simplifying interaction with the endpoints. +// +// A zero-value UserAPI is not usable. Use one of the constructors +// (e.g., NewUserAPIWithAccessKey, NewUserAPIWithXPriv, or NewUserAPIWithXPub) +// to create a properly initialized instance. +// +// UserAPI methods may return wrapped errors, including models.SPVError or +// ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API. +type UserAPI struct { + xpubAPI *users.XPubAPI + accessKeyAPI *users.AccessKeyAPI + configsAPI *configs.API + merkleRootsAPI *merkleroots.API + contactsAPI *contacts.API + invitationsAPI *invitations.API + transactionsAPI *transactions.API + utxosAPI *utxos.API + totp *totp.Client //only available when using xPriv +} + +// Contacts retrieves a paginated list of user contacts from the user contacts API. +// +// The response includes contact data along with pagination details, such as the +// current page, sort order, and sortBy field. Optional query parameters can be +// provided using query options. The result is unmarshaled into a *queries.UserContactsPage. +// Returns an error if the API request fails or the response cannot be decoded. +func (u *UserAPI) Contacts(ctx context.Context, contactOpts ...queries.ContactQueryOption) (*queries.UserContactsPage, error) { + res, err := u.contactsAPI.Contacts(ctx, contactOpts...) + if err != nil { + return nil, contacts.HTTPErrorFormatter("retrieve contact", err).FormatGetErr() + } + + return res, nil +} + +// ContactWithPaymail retrieves a user contact by their paymail address. +// The response is unmarshaled into a *response.Contact. +// Returns an error if the API request fails or the response cannot be decoded. +func (u *UserAPI) ContactWithPaymail(ctx context.Context, paymail string) (*response.Contact, error) { + res, err := u.contactsAPI.ContactWithPaymail(ctx, paymail) + if err != nil { + return nil, contacts.HTTPErrorFormatter("retrieve contact with paymail", err).FormatGetErr() + } + + return res, nil +} + +// UpsertContact adds or updates a user contact via the user contacts API. +// The response is unmarshaled into a *response.Contact. +// Returns an error if the API request fails or the response cannot be decoded. +func (u *UserAPI) UpsertContact(ctx context.Context, cmd commands.UpsertContact) (*response.Contact, error) { + res, err := u.contactsAPI.UpsertContact(ctx, cmd) + if err != nil { + return nil, contacts.HTTPErrorFormatter("upsert contact", err).FormatPutErr() + } + + return res, nil +} + +// RemoveContact deletes a user contact with the given paymail via the user contacts API. +// Returns an error if the API request fails or the response cannot be decoded. +// A nil error indicates the deleting contact was successful. +func (u *UserAPI) RemoveContact(ctx context.Context, paymail string) error { + err := u.contactsAPI.RemoveContact(ctx, paymail) + if err != nil { + return contacts.HTTPErrorFormatter("remove contact", err).FormatDeleteErr() + } + + return nil +} + +// ConfirmContact checks the TOTP code and if it's ok, confirms user's contact using the user contacts API. +func (u *UserAPI) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { + if err := u.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + return fmt.Errorf("failed to validate TOTP for contact: %w", err) + } + + err := u.contactsAPI.ConfirmContact(ctx, contact.Paymail) + if err != nil { + return contacts.HTTPErrorFormatter("confirm contact", err).FormatPostErr() + } + + return nil +} + +// UnconfirmContact unconfirms a user contact with the given paymail via the user contacts API. +// Returns an error if the API request fails or the response cannot be decoded. A nil error indicates the deleting confirmation was successful. +func (u *UserAPI) UnconfirmContact(ctx context.Context, paymail string) error { + err := u.contactsAPI.UnconfirmContact(ctx, paymail) + if err != nil { + return contacts.HTTPErrorFormatter("unconfirm contact", err).FormatDeleteErr() + } + + return nil +} + +// AcceptInvitation accepts a user contact with the given paymail via the user contacts API. +// Returns an error if the API request fails or the response cannot be decoded. A nil error indicates the acceptation was successful. +func (u *UserAPI) AcceptInvitation(ctx context.Context, paymail string) error { + err := u.invitationsAPI.AcceptInvitation(ctx, paymail) + if err != nil { + return invitations.HTTPErrorFormatter("accept invitation", err).FormatPostErr() + } + + return nil +} + +// RejectInvitation rejects a user contact with the given paymail via the user contacts API. +// Returns an error if the API request fails or the response cannot be decoded. +// A nil error indicates the rejection was successful. +func (u *UserAPI) RejectInvitation(ctx context.Context, paymail string) error { + err := u.invitationsAPI.RejectInvitation(ctx, paymail) + if err != nil { + return invitations.HTTPErrorFormatter("reject invitation", err).FormatDeleteErr() + } + + return nil +} + +// SharedConfig retrieves the shared configuration via the user configurations API. +// The response is unmarshaled into a response.SharedConfig. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) SharedConfig(ctx context.Context) (*response.SharedConfig, error) { + res, err := u.configsAPI.SharedConfig(ctx) + if err != nil { + return nil, configs.HTTPErrorFormatter("retrieve shared configuration", err).FormatGetErr() + } + + return res, nil +} + +// DraftTransaction creates a new draft transaction using the user transactions API. +// The response is expected to be unmarshaled into a *response.DraftTransaction struct. +// If the request fails or the response cannot be decoded, an error is returned. +func (u *UserAPI) DraftTransaction(ctx context.Context, cmd *commands.DraftTransaction) (*response.DraftTransaction, error) { + res, err := u.transactionsAPI.DraftTransaction(ctx, cmd) + if err != nil { + return nil, transactions.HTTPErrorFormatter("create a draft transaction", err).FormatPostErr() + } + + return res, nil +} + +// RecordTransaction submits a transaction for recording via the user transactions API. +// The response is unmarshaled into a *response.Transaction. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) RecordTransaction(ctx context.Context, cmd *commands.RecordTransaction) (*response.Transaction, error) { + res, err := u.transactionsAPI.RecordTransaction(ctx, cmd) + if err != nil { + msg := fmt.Sprintf("record a transaction with reference ID: %s", cmd.ReferenceID) + return nil, transactions.HTTPErrorFormatter(msg, err).FormatPostErr() + } + + return res, nil +} + +// UpdateTransactionMetadata updates the metadata of a transaction via the user transactions API. +// The response is expected to be unmarshaled into a *response.Transaction struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) UpdateTransactionMetadata(ctx context.Context, cmd *commands.UpdateTransactionMetadata) (*response.Transaction, error) { + res, err := u.transactionsAPI.UpdateTransactionMetadata(ctx, cmd) + if err != nil { + msg := fmt.Sprintf("record a transaction with ID: %s", cmd.ID) + return nil, transactions.HTTPErrorFormatter(msg, err).FormatPutErr() + } + + return res, nil +} + +// Transactions retrieves a paginated list of transactions via the user transactions API. +// The returned response includes transactions and pagination details, such as the page number, +// sort order, and sorting field (sortBy). +// +// This method allows optional query parameters to be applied via the provided query options. +// The response is expected to be to unmarshal into a *response.PageModel[response.Transaction] struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) Transactions(ctx context.Context, opts ...queries.TransactionsQueryOption) (*queries.TransactionPage, error) { + res, err := u.transactionsAPI.Transactions(ctx, opts...) + if err != nil { + return nil, transactions.HTTPErrorFormatter("retrieve transactions page", err).FormatGetErr() + } + + return res, nil +} + +// Transaction retrieves a specific transaction by its ID via the user transactions API. +// The response is expected to be unmarshaled into a *response.Transaction struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) Transaction(ctx context.Context, ID string) (*response.Transaction, error) { + res, err := u.transactionsAPI.Transaction(ctx, ID) + if err != nil { + msg := fmt.Sprintf("record a transaction with ID: %s", ID) + return nil, transactions.HTTPErrorFormatter(msg, err).FormatGetErr() + } + + return res, nil +} + +// XPub retrieves the full xpub information for the current user via the users API. +// The response is unmarshaled into a *response.Xpub. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) XPub(ctx context.Context) (*response.Xpub, error) { + res, err := u.xpubAPI.XPub(ctx) + if err != nil { + return nil, users.XPubsHTTPErrorFormatter("retrieve xpub information", err).FormatGetErr() + } + + return res, nil +} + +// UpdateXPubMetadata updates the metadata associated with the current user's xpub via the users API. +// The response is unmarshaled into a *response.Xpub. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) UpdateXPubMetadata(ctx context.Context, cmd *commands.UpdateXPubMetadata) (*response.Xpub, error) { + res, err := u.xpubAPI.UpdateXPubMetadata(ctx, cmd) + if err != nil { + return nil, users.XPubsHTTPErrorFormatter("update xpub metadata ", err).FormatGetErr() + } + + return res, nil +} + +// GenerateAccessKey creates a new access key associated with the current user's xpub via the users access key API. +// The response is unmarshaled into a *response.AccessKey. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) GenerateAccessKey(ctx context.Context, cmd *commands.GenerateAccessKey) (*response.AccessKey, error) { + res, err := u.accessKeyAPI.GenerateAccessKey(ctx, cmd) + if err != nil { + return nil, users.AccessKeysHTTPErrorFormatter("generate access key ", err).FormatPostErr() + } + + return res, nil +} + +// AccessKeys retrieves a paginated list of access keys via the user access keys API. +// The response includes access keys and pagination details, such as the page number, +// sort order, and sorting field (sortBy). +// +// This method allows optional query parameters to be applied via the provided query options. +// The response is expected to unmarshal into a *queries.AccessKeyPage struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) AccessKeys(ctx context.Context, accessKeyOpts ...queries.AccessKeyQueryOption) (*queries.AccessKeyPage, error) { + res, err := u.accessKeyAPI.AccessKeys(ctx, accessKeyOpts...) + if err != nil { + return nil, users.AccessKeysHTTPErrorFormatter("retrieve access keys page ", err).FormatGetErr() + } + + return res, nil +} + +// AccessKey retrieves the access key associated with the specified ID via the user access keys API. +// The response is expected to be unmarshaled into a *response.AccessKey struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) AccessKey(ctx context.Context, ID string) (*response.AccessKey, error) { + res, err := u.accessKeyAPI.AccessKey(ctx, ID) + if err != nil { + msg := fmt.Sprintf("retrieve access key with ID: %s", ID) + return nil, users.AccessKeysHTTPErrorFormatter(msg, err).FormatGetErr() + } + + return res, nil +} + +// RevokeAccessKey revokes the access key associated with the given ID via the user access keys API. +// If the request fails or the response cannot be processed, an error is returned. +// A nil error indicates the revoking access key was successful. +func (u *UserAPI) RevokeAccessKey(ctx context.Context, ID string) error { + err := u.accessKeyAPI.RevokeAccessKey(ctx, ID) + if err != nil { + msg := fmt.Sprintf("revoke access key with ID: %s", ID) + return users.AccessKeysHTTPErrorFormatter(msg, err).FormatDeleteErr() + } + + return nil +} + +// UTXOs fetches a paginated list of UTXOs via the user UTXOs API. +// The response includes UTXOs along with pagination details, such as page number, +// sort order, and sorting field. +// +// Optional query parameters can be applied using the provided query options. +// The response is unmarshaled into a *queries.UtxosPage struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) UTXOs(ctx context.Context, opts ...queries.UtxoQueryOption) (*queries.UtxosPage, error) { + res, err := u.utxosAPI.UTXOs(ctx, opts...) + if err != nil { + return nil, utxos.HTTPErrorFormatter("retrieve UTXOs page", err).FormatGetErr() + } + + return res, nil +} + +// MerkleRoots retrieves a paginated list of Merkle roots via the user Merkle roots API. +// The API response includes Merkle roots along with pagination details, such as the current +// page number, sort order, and sorting field (sortBy). +// +// This method supports optional query parameters, which can be specified using the provided +// query options. These options customize the behavior of the API request, such as setting +// batch size or applying filters for pagination. +// +// The response is unmarshaled into a *queries.MerkleRootPage struct. +// Returns an error if the request fails or the response cannot be decoded. +func (u *UserAPI) MerkleRoots(ctx context.Context, opts ...queries.MerkleRootsQueryOption) (*queries.MerkleRootPage, error) { + res, err := u.merkleRootsAPI.MerkleRoots(ctx, opts...) + if err != nil { + return nil, merkleroots.HTTPErrorFormatter("retrieve Merkle root page", err).FormatGetErr() + } + + return res, nil +} + +// SyncMerkleRoots synchronizes Merkle roots known to the SPV Wallet with the client database. +// This method sends a series of HTTP GET requests to the "/merkleroots" endpoint, fetching +// Merkle roots and storing them in the client database. The process continues until all +func (u *UserAPI) SyncMerkleRoots(ctx context.Context, repo merkleroots.MerkleRootsRepository) error { + err := u.merkleRootsAPI.SyncMerkleRoots(ctx, repo) + if err != nil { + return fmt.Errorf("failed to sync Merkle roots: %w", err) + } + + return nil +} + +// GenerateTotpForContact generates a TOTP code for the specified contact. +func (u *UserAPI) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { + if u.totp == nil { + return "", errors.New("totp client not initialized - xPriv authentication required") + } + + totp, err := u.totp.GenerateTotpForContact(contact, period, digits) + if err != nil { + return "", fmt.Errorf("failed to generate TOTP for contact: %w", err) + } + + return totp, nil +} + +// ValidateTotpForContact validates a TOTP code for the specified contact. +func (u *UserAPI) ValidateTotpForContact(contact *models.Contact, passcode, requesterPaymail string, period, digits uint) error { + if u.totp == nil { + return errors.New("totp client not initialized - xPriv authentication required") + } + + if err := u.totp.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits); err != nil { + return fmt.Errorf("failed to validate TOTP for contact: %w", err) + } + + return nil +} + +// NewUserAPIWithXPub initializes a new UserAPI instance using an extended public key (xPub). +// This function configures the API client with the provided configuration and uses the xPub key for authentication. +// If any configuration or initialization step fails, an appropriate error is returned. +// +// Note: Requests made with this instance will not be signed. +// For enhanced security, it is strongly recommended to use `NewUserAPIWithXPriv` or `NewUserAPIWithAccessKey` instead. +func NewUserAPIWithXPub(cfg config.Config, xPub string) (*UserAPI, error) { + key, err := bip32.GetHDKeyFromExtendedPublicKey(xPub) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPub: %w", err) + } + + authenticator, err := auth.NewXpubOnlyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xPub authenticator: %w", err) + } + + return initUserAPI(cfg, authenticator) +} + +// NewUserAPIWithXPriv initializes a new UserAPI instance using an extended private key (xPriv). +// This function configures the API client with the provided configuration and uses the xPriv key for authentication. +// If any step fails, an appropriate error is returned. +// +// Note: Requests made with this instance will be securely signed. +func NewUserAPIWithXPriv(cfg config.Config, xPriv string) (*UserAPI, error) { + key, err := bip32.GenerateHDKeyFromString(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from xPriv: %w", err) + } + + authenticator, err := auth.NewXprivAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized xPriv authenticator: %w", err) + } + + userAPI, err := initUserAPI(cfg, authenticator) + if err != nil { + return nil, fmt.Errorf("failed to create new client: %w", err) + } + + userAPI.totp = totp.New(key) + return userAPI, nil +} + +// NewUserAPIWithAccessKey initializes a new UserAPI instance using an access key. +// This function configures the API client and converts the provided access key from either hex or WIF format into a private key. +// This private key is used for authentication. If any step in the process fails, an appropriate error is returned. +// +// Note: Requests made with this instance will be securely signed. +func NewUserAPIWithAccessKey(cfg config.Config, accessKey string) (*UserAPI, error) { + key, err := cryptoutil.PrivateKeyFromHexOrWIF(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to return private key from hex or WIF: %w", err) + } + + authenticator, err := auth.NewAccessKeyAuthenticator(key) + if err != nil { + return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) + } + + return initUserAPI(cfg, authenticator) +} + +type authenticator interface { + Authenticate(r *resty.Request) error +} + +func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) { + url, err := url.Parse(cfg.Addr) + if err != nil { + return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) + } + + httpClient := restyutil.NewHTTPClient(cfg, auth) + return &UserAPI{ + merkleRootsAPI: merkleroots.NewAPI(url, httpClient), + configsAPI: configs.NewAPI(url, httpClient), + transactionsAPI: transactions.NewAPI(url, httpClient), + utxosAPI: utxos.NewAPI(url, httpClient), + accessKeyAPI: users.NewAccessKeyAPI(url, httpClient), + xpubAPI: users.NewXPubAPI(url, httpClient), + contactsAPI: contacts.NewAPI(url, httpClient), + invitationsAPI: invitations.NewAPI(url, httpClient), + }, nil +} From f34a5edf4e453ffe616ec470a477ba772f20e4af Mon Sep 17 00:00:00 2001 From: Michal Gosek Date: Wed, 27 Nov 2024 15:46:56 +0100 Subject: [PATCH 16/18] =?UTF-8?q?refactor(SPV-1173):=20add=20example=20pkg?= =?UTF-8?q?,=20taskfile,=20keys=20generator,=20readme=E2=80=A6=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/README.md | 148 ++++++++++++++++++ examples/Taskfile.yml | 117 ++++++++++++++ .../accept_invitation/accept_invitation.go | 26 +++ .../contact_confirmation.go | 28 ++++ examples/contact_remove/contact_remove.go | 26 +++ examples/contact_upsert/contact_upsert.go | 33 ++++ .../draft_transaction/draft_transaction.go | 36 +++++ examples/example_keys.go | 7 + examples/exampleutil/exampleutil.go | 23 +++ examples/fetch_access_key/fetch_access_key.go | 26 +++ .../fetch_access_keys/fetch_access_keys.go | 24 +++ .../fetch_contact_by_paymail.go | 26 +++ examples/fetch_contacts/fetch_contacts.go | 24 +++ .../fetch_merkleroots/fetch_merkleroots.go | 24 +++ .../fetch_shared_config.go | 24 +++ .../fetch_transaction/fetch_transaction.go | 26 +++ .../fetch_transactions/fetch_transactions.go | 24 +++ examples/fetch_utxos/fetch_utxos.go | 24 +++ examples/fetch_xpub/fetch_xpub.go | 24 +++ .../generate_access_key.go | 28 ++++ .../record_transaction/record_transaction.go | 29 ++++ .../reject_invitation/reject_invitation.go | 26 +++ .../revoke_access_key/revoke_access_key.go | 26 +++ .../unconfirm_contact/unconfirm_contact.go | 23 +++ .../update_transaction_metadata.go | 30 ++++ .../update_xpub_metadata.go | 27 ++++ walletkeys/cmd/main.go | 19 +++ walletkeys/walletkeys.go | 135 ++++++++++++++++ walletkeys/walletkeys_example_test.go | 45 ++++++ 29 files changed, 1078 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/Taskfile.yml create mode 100644 examples/accept_invitation/accept_invitation.go create mode 100644 examples/contact_confirmation/contact_confirmation.go create mode 100644 examples/contact_remove/contact_remove.go create mode 100644 examples/contact_upsert/contact_upsert.go create mode 100644 examples/draft_transaction/draft_transaction.go create mode 100644 examples/example_keys.go create mode 100644 examples/exampleutil/exampleutil.go create mode 100644 examples/fetch_access_key/fetch_access_key.go create mode 100644 examples/fetch_access_keys/fetch_access_keys.go create mode 100644 examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go create mode 100644 examples/fetch_contacts/fetch_contacts.go create mode 100644 examples/fetch_merkleroots/fetch_merkleroots.go create mode 100644 examples/fetch_shared_config/fetch_shared_config.go create mode 100644 examples/fetch_transaction/fetch_transaction.go create mode 100644 examples/fetch_transactions/fetch_transactions.go create mode 100644 examples/fetch_utxos/fetch_utxos.go create mode 100644 examples/fetch_xpub/fetch_xpub.go create mode 100644 examples/generate_access_key/generate_access_key.go create mode 100644 examples/record_transaction/record_transaction.go create mode 100644 examples/reject_invitation/reject_invitation.go create mode 100644 examples/revoke_access_key/revoke_access_key.go create mode 100644 examples/unconfirm_contact/unconfirm_contact.go create mode 100644 examples/update_transaction_metadata/update_transaction_metadata.go create mode 100644 examples/update_xpub_metadata/update_xpub_metadata.go create mode 100644 walletkeys/cmd/main.go create mode 100644 walletkeys/walletkeys.go create mode 100644 walletkeys/walletkeys_example_test.go diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..d9282907 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,148 @@ +# Qucik Guide + +In this directory you can find bunch of examples describing how to use +the wallet client package during interaction wit the SPV Wallet API. + +1. [Before you run](#before-you-run) +1. [Authorization](#authroization) +1. [How to run example](#how-to-run-an-example) + +## Before you run + +### Pre-requisites + +- You have access to the `spv-wallet` non-custodial wallet (running locally or remotely). +- [Taskfile](https://taskfile.dev/installation/) is installed on your environment. +- SPV Wallet go client instance is properly created and configured. + +> [!TIP] +> To verify Taskfile installation run: `task` command in the terminal. + +``` +task: [default] task --list +task: Available tasks for this project: +* default: Display all available tasks. +* fetch-user-contact-by-paymail: Fetch user contact by given paymail. +* fetch-user-contacts: Fetch user contacts page. +* fetch-user-merkleroots: Fetch user Merkle roots page. +* fetch-user-shared-config: Fetch user shared configuration. +* fetch-user-transaction: Fetch user transaction with a given ID. +* fetch-user-transactions: Fetch user transactions page. +* fetch-user-utxos: Fetch user UTXOs page. +* fetch-user-xpub: Fetch current authorized user's xpub info. +* generate-keys: Generate keys for SPV Wallet API access. +* user-contact-confirmation: Confirm user contact with a given paymail address. +* user-contact-remove: Remove user contact with a given paymail address. +* user-contact-unconfirm: Unconfirm user contact with a given paymail address. +* user-contact-upsert: Upsert user contact with a given paymail address. +* user-draft-transaction: Create a user draft transaction. +* user-invitation-accept: Accept user contact invitation with a given paymail address. +* user-invitation-reject: Reject user contact invitation with a given paymail address. +* user-transaction-metadata-update: Update user transaction metadata with a given ID. +* user-xpub-metadata: Update current authorized user's xpub metadata. +``` + +## Authroization + +> [!CAUTION] +> Don't use the keys which are already added to another wallet. + + +> [!IMPORTANT] +> Additionally, to make it work properly, you should adjust the `ExamplePaymail` to align with your `domains` configuration in the `spv-wallet` instance. + +Before interacting with the SPV Wallet API, you must complete the authorization process. + +To begin, generate a pair of keys using the `task generate-keys command`, which is included in the dedicated Taskfile. + +**Example output:** +``` +================================================================== +XPriv: xprv1d77e47e-452c-453f-bc4c-a42748f8145f +XPub: xpubd82c277b-0a7e-482f-8ad8-e92958d15acb +Mnemonic: mnemonic +================================================================== +``` + +## + +> [!TIP] +> Previously generated keys can be used as function parameters. + +To verify the connection and authorization, you can either run one of the available code snippets from the examples directory or use the following example. Please note that this is a testable code snippet and should be customized to fit your specific setup. + +**Code snippet:** + +``` +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" +) + +func main() { + xPriv := "121d2f43-4261-42ab-813e-3d3fa4d87313" + cfg := wallet.NewDefaultConfig("http://localhost:3003") + spv, err := wallet.NewWithXPriv(cfg, xPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := spv.XPub(context.Background()) + if err != nil { + log.Fatal(err) + } + Print("XPub", xPub) +} + +func Print(s string, a any) { + fmt.Println(strings.Repeat("~", 100)) + fmt.Println(s) + fmt.Println(strings.Repeat("~", 100)) + res, err := json.MarshalIndent(a, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(res)) +} + +``` +**Example output:** + +``` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +XPub +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +{ + "createdAt": "2024-10-07T13:39:07.886862Z", + "updatedAt": "2024-11-20T11:05:22.235832Z", + "deletedAt": null, + "metadata": { + "metadata": { + "key": "value" + } + }, + "id": "c50e4656-75e4-482e-a52d-2b4319919a26", + "currentBalance": 100, + "nextInternalNum": 20, + "nextExternalNum": 2 +} +``` + +## How to run an example + +The examples are written in Go and can be run by: + +```bash +cd examples +task name_of_the_example +``` + + > [!TIP] +> To verify Taskfile installation run: `task` command in the terminal. diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml new file mode 100644 index 00000000..c74fd13d --- /dev/null +++ b/examples/Taskfile.yml @@ -0,0 +1,117 @@ +version: '3' + +tasks: + default: + cmds: + - task --list + desc: "Display all available tasks." + + generate-keys: + desc: "Generate keys for SPV Wallet API access." + silent: true + cmds: + - echo "==================================================================" + - go run ../walletkeys/cmd/main.go + - echo "==================================================================" + + fetch-user-shared-config: + desc: "Fetch user shared configuration." + silent: true + cmds: + - go run ./fetch_shared_config/fetch_shared_config.go + + fetch-user-merkleroots: + desc: "Fetch user Merkle roots page." + silent: true + cmds: + - go run ./fetch_merkleroots/fetch_merkleroots.go + + fetch-user-contacts: + desc: "Fetch user contacts page." + silent: true + cmds: + - go run ./fetch_contacts/fetch_contacts.go + + fetch-user-contact-by-paymail: + desc: "Fetch user contact by given paymail." + silent: true + cmds: + - go run ./fetch_contact_by_paymail/fetch_contact_by_paymail.go + + user-contact-confirmation: + desc: "Confirm user contact with a given paymail address." + silent: true + cmds: + - go run ./contact_confirmation/contact_confirmation.go + + user-contact-unconfirm: + desc: "Unconfirm user contact with a given paymail address." + silent: true + cmds: + - go run ./unconfirm_contact/unconfirm_contact.go + + user-contact-remove: + desc: "Remove user contact with a given paymail address." + silent: true + cmds: + - go run ./contact_remove/contact_remove.go + + user-contact-upsert: + desc: "Upsert user contact with a given paymail address." + silent: true + cmds: + - go run ./contact_upsert/contact_upsert.go + + user-invitation-accept: + desc: "Accept user contact invitation with a given paymail address." + silent: true + cmds: + - go run ./accept_invitation/accept_invitation.go + + user-invitation-reject: + desc: "Reject user contact invitation with a given paymail address." + silent: true + cmds: + - go run ./reject_invitation/reject_invitation.go + + fetch-user-transactions: + desc: "Fetch user transactions page." + silent: true + cmds: + - go run ./fetch_transactions/fetch_transactions.go + + fetch-user-transaction: + desc: "Fetch user transaction with a given ID." + silent: true + cmds: + - go run ./fetch_transaction/fetch_transaction.go + + user-draft-transaction: + desc: "Create a user draft transaction." + silent: true + cmds: + - go run ./draft_transaction/draft_transaction.go + + user-transaction-metadata-update: + desc: "Update user transaction metadata with a given ID." + silent: true + cmds: + - go run ./update_transaction_metadata/update_transaction_metadata.go + + fetch-user-utxos: + desc: "Fetch user UTXOs page." + silent: true + cmds: + - go run ./fetch_utxos/fetch_utxos.go + + fetch-user-xpub: + desc: "Fetch current authorized user's xpub info." + silent: true + cmds: + - go run ./fetch_xpub/fetch_xpub.go + + user-xpub-metadata: + desc: "Update current authorized user's xpub metadata." + silent: true + cmds: + - go run ./update_xpub_metadata/update_xpub_metadata.go diff --git a/examples/accept_invitation/accept_invitation.go b/examples/accept_invitation/accept_invitation.go new file mode 100644 index 00000000..5016e734 --- /dev/null +++ b/examples/accept_invitation/accept_invitation.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.AcceptInvitation(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP POST] Accept contact invitation - api/v1/invitations/%s/contacts", paymail)) +} diff --git a/examples/contact_confirmation/contact_confirmation.go b/examples/contact_confirmation/contact_confirmation.go new file mode 100644 index 00000000..05120ce4 --- /dev/null +++ b/examples/contact_confirmation/contact_confirmation.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" + "github.com/bitcoin-sv/spv-wallet/models" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + code := "f22b4214-ab56-45c0-8399-60ed3a4ecf8e" + err = usersAPI.ConfirmContact(context.Background(), &models.Contact{ID: "b2215c13-5690-469e-868f-e7bc240a0a23"}, code, paymail, 1, 8) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP POST] Confirm contact - api/v1/contacts/%s/confirmation", paymail)) +} diff --git a/examples/contact_remove/contact_remove.go b/examples/contact_remove/contact_remove.go new file mode 100644 index 00000000..b76d791d --- /dev/null +++ b/examples/contact_remove/contact_remove.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.RemoveContact(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Remove contact - api/v1/contacts/%s", paymail)) +} diff --git a/examples/contact_upsert/contact_upsert.go b/examples/contact_upsert/contact_upsert.go new file mode 100644 index 00000000..fe0657bf --- /dev/null +++ b/examples/contact_upsert/contact_upsert.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + contact, err := usersAPI.UpsertContact(context.Background(), commands.UpsertContact{ + FullName: "John Doe", + Metadata: map[string]any{ + "key": "value", + }, + Paymail: paymail, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP PUT] Upsert contact - api/v1/contacts/%s", paymail), contact) +} diff --git a/examples/draft_transaction/draft_transaction.go b/examples/draft_transaction/draft_transaction.go new file mode 100644 index 00000000..799cc98d --- /dev/null +++ b/examples/draft_transaction/draft_transaction.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transaction, err := usersAPI.DraftTransaction(context.Background(), &commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: []*response.TransactionOutput{ + { + To: "receiver@example.com", + Satoshis: 1, + }, + }, + }, + Metadata: map[string]any{"key": "value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP POST] Draft transaction - api/v1/transactions", transaction) +} diff --git a/examples/example_keys.go b/examples/example_keys.go new file mode 100644 index 00000000..7ff7492b --- /dev/null +++ b/examples/example_keys.go @@ -0,0 +1,7 @@ +package examples + +const ( + XPriv string = "" + XPub string = "" + AccessKey string = "" +) diff --git a/examples/exampleutil/exampleutil.go b/examples/exampleutil/exampleutil.go new file mode 100644 index 00000000..a9b9a7eb --- /dev/null +++ b/examples/exampleutil/exampleutil.go @@ -0,0 +1,23 @@ +package exampleutil + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/bitcoin-sv/spv-wallet-go-client/config" +) + +var ExampleConfig = config.NewDefaultConfig("http://localhost:3003") + +func Print(s string, a any) { + fmt.Println(strings.Repeat("~", 100)) + fmt.Println(s) + fmt.Println(strings.Repeat("~", 100)) + res, err := json.MarshalIndent(a, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(res)) +} diff --git a/examples/fetch_access_key/fetch_access_key.go b/examples/fetch_access_key/fetch_access_key.go new file mode 100644 index 00000000..fe346e41 --- /dev/null +++ b/examples/fetch_access_key/fetch_access_key.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeyID := "35465782-e247-42dd-a2e7-a01ba5b56285" + accessKey, err := usersAPI.AccessKey(context.Background(), accessKeyID) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Access key - api/v1/users/current/keys/%s", accessKeyID), accessKey) +} diff --git a/examples/fetch_access_keys/fetch_access_keys.go b/examples/fetch_access_keys/fetch_access_keys.go new file mode 100644 index 00000000..aab1a501 --- /dev/null +++ b/examples/fetch_access_keys/fetch_access_keys.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeys, err := usersAPI.AccessKeys(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Access keys - api/v1/users/current/keys", accessKeys) +} diff --git a/examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go b/examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go new file mode 100644 index 00000000..d821f497 --- /dev/null +++ b/examples/fetch_contact_by_paymail/fetch_contact_by_paymail.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + contact, err := usersAPI.ContactWithPaymail(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Contact by paymail - api/v1/contacts/%s", paymail), contact) +} diff --git a/examples/fetch_contacts/fetch_contacts.go b/examples/fetch_contacts/fetch_contacts.go new file mode 100644 index 00000000..44e46b93 --- /dev/null +++ b/examples/fetch_contacts/fetch_contacts.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.Contacts(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Contacts page - api/v1/contacts", page) +} diff --git a/examples/fetch_merkleroots/fetch_merkleroots.go b/examples/fetch_merkleroots/fetch_merkleroots.go new file mode 100644 index 00000000..399bf64e --- /dev/null +++ b/examples/fetch_merkleroots/fetch_merkleroots.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.MerkleRoots(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Merkle roots page - api/v1/merkleroots", page) +} diff --git a/examples/fetch_shared_config/fetch_shared_config.go b/examples/fetch_shared_config/fetch_shared_config.go new file mode 100644 index 00000000..754b7dc1 --- /dev/null +++ b/examples/fetch_shared_config/fetch_shared_config.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + res, err := usersAPI.SharedConfig(context.Background()) + if err != nil { + log.Fatal() + } + + exampleutil.Print("[HTTP GET] Shared config - api/v1/configs/shared", res) +} diff --git a/examples/fetch_transaction/fetch_transaction.go b/examples/fetch_transaction/fetch_transaction.go new file mode 100644 index 00000000..2a5d7dc9 --- /dev/null +++ b/examples/fetch_transaction/fetch_transaction.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transactionID := "d291ac4c-04b3-48cc-a7e3-1338ac42810b" + transaction, err := usersAPI.Transaction(context.Background(), transactionID) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP GET] Transaction - api/v1/transactions/%s", transactionID), transaction) +} diff --git a/examples/fetch_transactions/fetch_transactions.go b/examples/fetch_transactions/fetch_transactions.go new file mode 100644 index 00000000..770782f1 --- /dev/null +++ b/examples/fetch_transactions/fetch_transactions.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.Transactions(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Transaction page - api/v1/transactions", page) +} diff --git a/examples/fetch_utxos/fetch_utxos.go b/examples/fetch_utxos/fetch_utxos.go new file mode 100644 index 00000000..9f2e4552 --- /dev/null +++ b/examples/fetch_utxos/fetch_utxos.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + page, err := usersAPI.UTXOs(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] UTXOs page - api/v1/utxos", page) +} diff --git a/examples/fetch_xpub/fetch_xpub.go b/examples/fetch_xpub/fetch_xpub.go new file mode 100644 index 00000000..02db3659 --- /dev/null +++ b/examples/fetch_xpub/fetch_xpub.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := usersAPI.XPub(context.Background()) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP GET] Current user xPub - api/v1/users/current", xPub) +} diff --git a/examples/generate_access_key/generate_access_key.go b/examples/generate_access_key/generate_access_key.go new file mode 100644 index 00000000..749089bc --- /dev/null +++ b/examples/generate_access_key/generate_access_key.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + accessKey, err := usersAPI.GenerateAccessKey(ctx, &commands.GenerateAccessKey{ + Metadata: map[string]any{"key": "value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP POST] Generate access key - api/v1/users/current/keys", accessKey) +} diff --git a/examples/record_transaction/record_transaction.go b/examples/record_transaction/record_transaction.go new file mode 100644 index 00000000..3c3fd377 --- /dev/null +++ b/examples/record_transaction/record_transaction.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transaction, err := usersAPI.RecordTransaction(context.Background(), &commands.RecordTransaction{ + Metadata: map[string]any{"key": "value"}, + ReferenceID: "8bc53e34-b6fd-4e8b-b1b7-6f30f8f149f2", + Hex: "0100000002...", + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP POST] Record transaction - api/v1/transactions", transaction) +} diff --git a/examples/reject_invitation/reject_invitation.go b/examples/reject_invitation/reject_invitation.go new file mode 100644 index 00000000..f529894f --- /dev/null +++ b/examples/reject_invitation/reject_invitation.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.RejectInvitation(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Reject contact invitation - api/v1/invitations/%s", paymail)) +} diff --git a/examples/revoke_access_key/revoke_access_key.go b/examples/revoke_access_key/revoke_access_key.go new file mode 100644 index 00000000..c5fc43eb --- /dev/null +++ b/examples/revoke_access_key/revoke_access_key.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + accessKeyID := "9f7efc4af8f2c9f745ca8dfa737394d810dd8828c072c7c05e07c7aae67ff790" + err = usersAPI.RevokeAccessKey(context.Background(), accessKeyID) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fmt.Sprintf("\n[HTTP DELETE] Revoke access key - api/v1/users/current/keys/%s", accessKeyID)) +} diff --git a/examples/unconfirm_contact/unconfirm_contact.go b/examples/unconfirm_contact/unconfirm_contact.go new file mode 100644 index 00000000..dac8920b --- /dev/null +++ b/examples/unconfirm_contact/unconfirm_contact.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + paymail := "john.doe@example.com" + err = usersAPI.UnconfirmContact(context.Background(), paymail) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/update_transaction_metadata/update_transaction_metadata.go b/examples/update_transaction_metadata/update_transaction_metadata.go new file mode 100644 index 00000000..d22288f5 --- /dev/null +++ b/examples/update_transaction_metadata/update_transaction_metadata.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + transactionID := "86cafa5b-fdaa-4629-ae46-78d68d6a180b" + transaction, err := usersAPI.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ + ID: transactionID, + Metadata: map[string]any{"new_key": "new_value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print(fmt.Sprintf("[HTTP PATCH] Update transaction metadata - api/v1/transactions/%s", transactionID), transaction) +} diff --git a/examples/update_xpub_metadata/update_xpub_metadata.go b/examples/update_xpub_metadata/update_xpub_metadata.go new file mode 100644 index 00000000..7787fdc4 --- /dev/null +++ b/examples/update_xpub_metadata/update_xpub_metadata.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + xPub, err := usersAPI.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{ + Metadata: map[string]any{"key": "value"}, + }) + if err != nil { + log.Fatal(err) + } + + exampleutil.Print("[HTTP PATCH] Current user xPub metadata update - api/v1/users/current", xPub) +} diff --git a/walletkeys/cmd/main.go b/walletkeys/cmd/main.go new file mode 100644 index 00000000..634f038c --- /dev/null +++ b/walletkeys/cmd/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "log" + + "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys" +) + +func main() { + keys, err := walletkeys.RandomKeysWithMnemonic() + if err != nil { + log.Fatal(err) + } + + fmt.Println("XPriv: ", keys.Keys.XPriv()) + fmt.Println("XPub: ", keys.Keys.XPub()) + fmt.Println("Mnemonic: ", keys.Mnemonic()) +} diff --git a/walletkeys/walletkeys.go b/walletkeys/walletkeys.go new file mode 100644 index 00000000..acabc8b2 --- /dev/null +++ b/walletkeys/walletkeys.go @@ -0,0 +1,135 @@ +package walletkeys + +import ( + "fmt" + + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" + bip39 "github.com/bitcoin-sv/go-sdk/compat/bip39" + chaincfg "github.com/bitcoin-sv/go-sdk/transaction/chaincfg" +) + +// DefaultEntropy defines the default entropy (bit size) used for cryptographic purposes. +// The value must be a multiple of 32 and within the inclusive range of {128, 256}. +// It represents the default level of entropy for key generation or similar operations. +const DefaultEntropy = 128 + +// Keys represents a set of hierarchical deterministic (HD) keys, +// including the extended private key (XPriv) and extended public key (XPub). +type Keys struct { + xPriv string + xPub string +} + +// XPriv returns the HD extended private key as a string. +func (k *Keys) XPriv() string { return k.xPriv } + +// XPub returns the HD extended public key as a string. +func (k *Keys) XPub() string { return k.xPub } + +// KeysWithMnemonic extends the Keys struct by including the mnemonic phrase +// used to generate the associated xPriv and XPub HD keys as strings. +type KeysWithMnemonic struct { + Keys + mnemonic string +} + +// Mnemonic returns the mnemonic phrase used to generate the keys. +func (k *KeysWithMnemonic) Mnemonic() string { return k.mnemonic } + +// XPrivFromString generates an extended private key (xPriv) from a string. +// It returns the extended private key and an error if the conversion fails. +func XPrivFromString(s string) (*bip32.ExtendedKey, error) { + xPriv, err := bip32.NewKeyFromString(s) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from string: %w", err) + } + + return xPriv, nil +} + +// XPrivFromMnemonic generates an extended private key (xPriv) from a mnemonic phrase. +// It returns the extended private key and an error if seed generation or HD key creation fails. +func XPrivFromMnemonic(mnemonic string) (*bip32.ExtendedKey, error) { + seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "") + if err != nil { + return nil, fmt.Errorf("failed to generate seed from mnemonic: %w", err) + } + + xPriv, err := bip32.NewMaster(seed, &chaincfg.MainNet) + if err != nil { + return nil, fmt.Errorf("failed to create master node HD key: %w", err) + } + + return xPriv, nil +} + +// RandomXPriv generates a random extended private key (xPriv). +// The seed size is specified as 32 bytes (256 bits), as defined by the bip32.RecommendedSeedLen constant. +// It returns a pointer to the extended private key and an error if seed generation or the creation of the master node HD key fails. +func RandomXPriv() (*bip32.ExtendedKey, error) { + seed, err := bip32.GenerateSeed(bip32.RecommendedSeedLen) + if err != nil { + return nil, fmt.Errorf("failed to generate seed: %w", err) + } + + xPriv, err := bip32.NewMaster(seed, &chaincfg.MainNet) + if err != nil { + return nil, fmt.Errorf("failed to generate master node HD key: %w", err) + } + + return xPriv, nil +} + +// RandomMnemonic generates a mnemonic phrase consisting of words derived from default entropy. +// It returns the mnemonic as a string and an error if entropy generation or mnemonic creation fails. +func RandomMnemonic() (string, error) { + entropy, err := bip39.NewEntropy(DefaultEntropy) + if err != nil { + return "", fmt.Errorf("failed to generate entropy: %w", err) + } + + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return "", fmt.Errorf("failed to generate mnemonic: %w", err) + } + + return mnemonic, nil +} + +// RandomKeys generates random HD keys (xPriv and xPub). +// It returns a Keys struct containing the extended private and public keys and an error if any generation fails. +func RandomKeys() (*Keys, error) { + xPriv, err := RandomXPriv() + if err != nil { + return nil, fmt.Errorf("failed to generate random xPriv: %w", err) + } + + xPub, err := bip32.GetExtendedPublicKey(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to get extended public key: %w", err) + } + + return &Keys{xPriv: xPriv.String(), xPub: xPub}, nil +} + +// RandomKeysWithMnemonic generates random HD keys (xPriv and xPub) along with a mnemonic phrase. +// It returns a KeysWithMnemonic struct containing the keys and the associated mnemonic, and an error if any generation fails. +func RandomKeysWithMnemonic() (*KeysWithMnemonic, error) { + mnemonic, err := RandomMnemonic() + if err != nil { + return nil, fmt.Errorf("failed to generate random mnemonic: %w", err) + } + + xPriv, err := bip32.GenerateHDKeyFromMnemonic(mnemonic, "", &chaincfg.MainNet) + if err != nil { + return nil, fmt.Errorf("failed to generate HD key from mnemonic: %w", err) + } + + xPub, err := bip32.GetExtendedPublicKey(xPriv) + if err != nil { + return nil, fmt.Errorf("failed to get extended public key: %w", err) + } + + keys := Keys{xPriv: xPriv.String(), xPub: xPub} + return &KeysWithMnemonic{mnemonic: mnemonic, Keys: keys}, nil +} diff --git a/walletkeys/walletkeys_example_test.go b/walletkeys/walletkeys_example_test.go new file mode 100644 index 00000000..fe48f933 --- /dev/null +++ b/walletkeys/walletkeys_example_test.go @@ -0,0 +1,45 @@ +package walletkeys_test + +import ( + "fmt" + "log" + + "github.com/bitcoin-sv/spv-wallet-go-client/walletkeys" +) + +func ExampleRandomKeysWithMnemonic() { + keys, err := walletkeys.RandomKeysWithMnemonic() + if err != nil { + log.Fatal(err) + } + + fmt.Println("Mnemonic: ", keys.Mnemonic()) + fmt.Println("xPriv: ", keys.Keys.XPriv()) + fmt.Println("XPub: ", keys.Keys.XPub()) +} + +func ExampleXPrivFromString() { + key := "xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si" + xPriv, err := walletkeys.XPrivFromString(key) + if err != nil { + log.Fatal(err) + } + + fmt.Println("xPriv:", xPriv) + + // Output: + // xPriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si +} + +func ExampleXPrivFromMnemonic() { + mnemonic := "absorb corn ostrich order sing boost just harvest enable make detail future desert bus adult" + xPriv, err := walletkeys.XPrivFromMnemonic(mnemonic) + if err != nil { + log.Fatal(err) + } + + fmt.Println("xPriv:", xPriv) + + // Output: + // xPriv: xprv9s21ZrQH143K3Lh4wdicqvYNMcdh49rMLqDvQoyys8L6f5tfE2WkQN7ZVE2awBrfVWNSJ8pPd4QLLr94Nur85Dvj8kD8RoZghBuNTpvL8si +} From f89e3d8ae6edb7c362fdc465fa916529085a5b63 Mon Sep 17 00:00:00 2001 From: augustyn chmiel <149666032+ac4ch@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:13:34 +0100 Subject: [PATCH 17/18] Ref(SPV-1216) Fix unit tests status codes (#24) Co-authored-by: Augustyn Chmiel --- .../users/userstest/xpub_api_fixtures.go | 8 + internal/api/v1/admin/users/xpubs_api_test.go | 20 ++- .../querybuilderstest/querybuilderstest.go | 11 -- .../api/v1/user/configs/configs_api_test.go | 31 ++-- .../configstest/configs_api_fixtures.go | 23 +++ .../api/v1/user/contacts/contacts_api_test.go | 161 +++++++----------- .../contactstest/contacts_api_fixtures.go | 12 +- .../user/invitations/invitations_api_test.go | 68 +++----- .../invitations_api_fixtures.go | 32 ++++ .../user/merkleroots/merkleroots_api_test.go | 46 ++--- .../merkleroots_api_fixtures.go | 12 +- .../transactions/transactions_api_test.go | 123 +++++-------- .../transactionstest/transactionstest.go | 12 +- .../api/v1/user/users/access_key_api_test.go | 104 ++++------- .../user/users/userstest/xpub_api_fixtures.go | 24 ++- internal/api/v1/user/users/xpub_api_test.go | 54 ++---- internal/api/v1/user/utxos/utxos_api_test.go | 27 +-- .../user/utxos/utxostest/utxo_api_fixtures.go | 24 ++- internal/spvwallettest/spvwallettest.go | 2 +- 19 files changed, 351 insertions(+), 443 deletions(-) create mode 100644 internal/api/v1/user/configs/configstest/configs_api_fixtures.go create mode 100644 internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go diff --git a/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go index 37f7cdca..af20319d 100644 --- a/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go +++ b/internal/api/v1/admin/users/userstest/xpub_api_fixtures.go @@ -18,6 +18,14 @@ func NewBadRequestSPVError() models.SPVError { } } +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} + func ExpectedXPub(t *testing.T) *response.Xpub { return &response.Xpub{ Model: response.Model{ diff --git a/internal/api/v1/admin/users/xpubs_api_test.go b/internal/api/v1/admin/users/xpubs_api_test.go index a74c1dab..a92e6077 100644 --- a/internal/api/v1/admin/users/xpubs_api_test.go +++ b/internal/api/v1/admin/users/xpubs_api_test.go @@ -38,17 +38,19 @@ func TestXPubsAPI_CreateXPub(t *testing.T) { URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVAdminAPI(t) transport.RegisterResponder(http.MethodPost, URL, tc.responder) - // then: + // when: got, err := wallet.CreateXPub(context.Background(), &commands.CreateUserXpub{ Metadata: map[string]any{}, XPub: "", }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -68,22 +70,24 @@ func TestXPubsAPI_XPubs(t *testing.T) { responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/admin/users str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } URL := spvwallettest.TestAPIAddr + "/api/v1/admin/users" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVAdminAPI(t) transport.RegisterResponder(http.MethodGet, URL, tc.responder) - // then: + // when: got, err := wallet.XPubs(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } diff --git a/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go b/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go index d7599b03..c24928e4 100644 --- a/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go +++ b/internal/api/v1/querybuilders/querybuilderstest/querybuilderstest.go @@ -1,11 +1,8 @@ package querybuilderstest import ( - "net/http" "testing" "time" - - "github.com/bitcoin-sv/spv-wallet/models" ) func ParseTime(t *testing.T, s string) time.Time { @@ -19,11 +16,3 @@ func ParseTime(t *testing.T, s string) time.Time { func Ptr[T any](value T) *T { return &value } - -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - } -} diff --git a/internal/api/v1/user/configs/configs_api_test.go b/internal/api/v1/user/configs/configs_api_test.go index 8ba1aa31..35b4a6e1 100644 --- a/internal/api/v1/user/configs/configs_api_test.go +++ b/internal/api/v1/user/configs/configs_api_test.go @@ -5,9 +5,8 @@ import ( "net/http" "testing" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/configs/configstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" @@ -15,7 +14,6 @@ import ( func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { tests := map[string]struct { - statusCode int expectedResponse *response.SharedConfig expectedErr error responder httpmock.Responder @@ -28,38 +26,29 @@ func TestConfigsAPI_SharedConfig_APIResponses(t *testing.T) { "pikePaymentEnabled": true, }, }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("configstest/response_200_status_code.json")), + responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("configstest/response_200_status_code.json")), }, "HTTP GET /api/v1/configs/shared response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, &models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }), + expectedErr: configstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, configstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/configs/shared str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: configstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, configstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/configs/shared" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.SharedConfig(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) require.Equal(t, tc.expectedResponse, got) }) diff --git a/internal/api/v1/user/configs/configstest/configs_api_fixtures.go b/internal/api/v1/user/configs/configstest/configs_api_fixtures.go new file mode 100644 index 00000000..09fb684e --- /dev/null +++ b/internal/api/v1/user/configs/configstest/configs_api_fixtures.go @@ -0,0 +1,23 @@ +package configstest + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/contacts/contacts_api_test.go b/internal/api/v1/user/contacts/contacts_api_test.go index bd1d53f3..e7137532 100644 --- a/internal/api/v1/user/contacts/contacts_api_test.go +++ b/internal/api/v1/user/contacts/contacts_api_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/contacts/contactstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" @@ -20,42 +19,36 @@ import ( func TestContactsAPI_Contacts(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *queries.UserContactsPage expectedErr error }{ "HTTP GET /api/v1/contacts response: 200": { - statusCode: http.StatusOK, expectedResponse: contactstest.ExpectedUserContactsPage(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contacts_200.json")), }, "HTTP GET /api/v1/contacts response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/contacts str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.Contacts(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -64,42 +57,36 @@ func TestContactsAPI_ContactWithPaymail(t *testing.T) { paymail := "john.doe.test5@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.Contact expectedErr error }{ fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 200", paymail): { - statusCode: http.StatusOK, expectedResponse: contactstest.ExpectedContactWithWithPaymail(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/get_contact_paymail_200.json")), }, fmt.Sprintf("HTTP GET /api/v1/contacts/%s response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP GET /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.ContactWithPaymail(context.Background(), paymail) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -108,46 +95,40 @@ func TestContactsAPI_UpsertContact(t *testing.T) { paymail := "john.doe.test@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.Contact expectedErr error }{ fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 200", paymail): { - statusCode: http.StatusOK, expectedResponse: contactstest.ExpectedUpsertContact(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("contactstest/put_contact_upsert_200.json")), }, fmt.Sprintf("HTTP PUT /api/v1/contacts/%s response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PUT /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPut, url, tc.responder) - // then: + // when: got, err := wallet.UpsertContact(context.Background(), commands.UpsertContact{ FullName: "John Doe", Metadata: map[string]any{"example_key": "example_val"}, Paymail: paymail, }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -156,38 +137,32 @@ func TestContactsAPI_RemoveContact(t *testing.T) { paymail := "john.doe.test@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 200", paymail): { - statusCode: http.StatusOK, - responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) - // then: + // when: err := wallet.RemoveContact(context.Background(), paymail) + + // then: require.ErrorIs(t, err, tc.expectedErr) }) } @@ -201,51 +176,41 @@ func TestContactsAPI_ConfirmContact(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 200", contact.Paymail): { - statusCode: http.StatusOK, - responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation response: 400", contact.Paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusBadRequest, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP POST /api/v1/contacts/%s/confirmation str response: 500", contact.Paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + contact.Paymail + "/confirmation" - for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: + const period = 3600 + const digits = 6 + wrappedTransport := spvwallettest.NewTransportWrapper() aliceClient, _ := spvwallettest.GivenSPVWalletClientWithTransport(t, wrappedTransport) wrappedTransport.RegisterResponder(http.MethodPost, url, tc.responder) - passcode, err := aliceClient.GenerateTotpForContact(contact, 3600, 6) - require.NoError(t, err) + // when: + passcode, err := aliceClient.GenerateTotpForContact(contact, period, digits) // then: - err = aliceClient.ConfirmContact(context.Background(), contact, passcode, contact.Paymail, 3600, 6) - require.ErrorIs(t, err, tc.expectedErr) - - // Assert status code: - resp, respErr := wrappedTransport.GetResponse() - require.NoError(t, respErr) - require.NotNil(t, resp, "response should not be nil") - require.Equal(t, tc.statusCode, resp.StatusCode, "unexpected HTTP status code") + require.NoError(t, err) + require.NotEmpty(t, passcode) + err = aliceClient.ConfirmContact(context.Background(), contact, passcode, contact.Paymail, period, digits) + require.ErrorIs(t, err, tc.expectedErr) }) } } @@ -254,38 +219,32 @@ func TestContactsAPI_UnconfirmContact(t *testing.T) { paymail := "john.doe.test@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 200", paymail): { - statusCode: http.StatusOK, - responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), + expectedErr: contactstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, contactstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/contacts/%s/confirmation str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: contactstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, contactstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/contacts/" + paymail + "/confirmation" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) - // then: + // when: err := wallet.UnconfirmContact(context.Background(), paymail) + + // then: require.ErrorIs(t, err, tc.expectedErr) }) } diff --git a/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go index 0bec7e5d..093530ce 100644 --- a/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go +++ b/internal/api/v1/user/contacts/contactstest/contacts_api_fixtures.go @@ -88,10 +88,18 @@ func Ptr[T any](value T) *T { return &value } -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ Message: http.StatusText(http.StatusBadRequest), StatusCode: http.StatusBadRequest, Code: "invalid-data-format", } } + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/invitations/invitations_api_test.go b/internal/api/v1/user/invitations/invitations_api_test.go index 4f27319a..393be71e 100644 --- a/internal/api/v1/user/invitations/invitations_api_test.go +++ b/internal/api/v1/user/invitations/invitations_api_test.go @@ -5,11 +5,9 @@ import ( "fmt" "net/http" "testing" - "time" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/invitations/invitationstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" ) @@ -18,38 +16,32 @@ func TestInvitationsAPI_AcceptInvitation(t *testing.T) { paymail := "john.doe.test@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 200", paymail): { - statusCode: http.StatusOK, - responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), + expectedErr: invitationstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, invitationstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s/contacts str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: invitationstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, invitationstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail + "/contacts" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) - // then: + // when: err := wallet.AcceptInvitation(context.Background(), paymail) + + // then: require.ErrorIs(t, err, tc.expectedErr) }) } @@ -59,55 +51,33 @@ func TestInvitationsAPI_RejectInvitation(t *testing.T) { paymail := "john.doe.test@john.doe.test.4chain.space" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 200", paymail): { - statusCode: http.StatusOK, - responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), + responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s response: 400", paymail): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, NewBadRequestSPVError()), + expectedErr: invitationstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, invitationstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP POST /api/v1/invitations/%s str response: 500", paymail): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: invitationstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, invitationstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/invitations/" + paymail for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) - // then: + // when: err := wallet.RejectInvitation(context.Background(), paymail) + + // then: require.ErrorIs(t, err, tc.expectedErr) }) } } - -func ParseTime(s string) time.Time { - t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - panic(err) - } - return t -} - -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - } -} diff --git a/internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go b/internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go new file mode 100644 index 00000000..888f34bd --- /dev/null +++ b/internal/api/v1/user/invitations/invitationstest/invitations_api_fixtures.go @@ -0,0 +1,32 @@ +package invitationstest + +import ( + "net/http" + "time" + + "github.com/bitcoin-sv/spv-wallet/models" +) + +func ParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/merkleroots/merkleroots_api_test.go b/internal/api/v1/user/merkleroots/merkleroots_api_test.go index 0e3a154d..cd50b5c1 100644 --- a/internal/api/v1/user/merkleroots/merkleroots_api_test.go +++ b/internal/api/v1/user/merkleroots/merkleroots_api_test.go @@ -18,42 +18,36 @@ import ( func TestMerkleRootsAPI_MerkleRoots(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *queries.MerkleRootPage expectedErr error }{ "HTTP GET /api/v1/merkleroots response: 200": { - statusCode: http.StatusOK, expectedResponse: merklerootstest.ExpectedMerkleRootsPage(), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("merklerootstest/get_merkleroots_200.json")), }, "HTTP GET /api/v1/merkleroots response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, merklerootstest.NewBadRequestSPVError()), + expectedErr: merklerootstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, merklerootstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/merkleroots str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: merklerootstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, merklerootstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := spvWalletClient.MerkleRoots(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -105,40 +99,32 @@ func TestMerkleRootsAPI_SyncMerkleRoots(t *testing.T) { expectedErr: errors.ErrStaleLastEvaluatedKey, }, "API Returns Error Response": { - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "Internal Server Error"), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, merklerootstest.NewInternalServerSPVError()), setupMock: func(mockRepo *MockMerkleRootsRepository) { mockRepo.On("GetLastMerkleRoot").Return("") // No data initially }, - expectedErr: errors.ErrUnrecognizedAPIResponse, + expectedErr: merklerootstest.NewInternalServerSPVError(), }, } url := spvwallettest.TestAPIAddr + "/api/v1/merkleroots" - for name, tc := range tests { t.Run(name, func(t *testing.T) { - // Arrange + // given: + mockRepo := new(MockMerkleRootsRepository) spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - - mockRepo := new(MockMerkleRootsRepository) tc.setupMock(mockRepo) - // Act + // when: err := spvWalletClient.SyncMerkleRoots(context.Background(), mockRepo) - // Assert - if tc.expectedErr != nil { - require.Error(t, err) - require.ErrorIs(t, err, tc.expectedErr) - } else { - require.NoError(t, err) - } + // then: + require.ErrorIs(t, err, tc.expectedErr) }) } } -// TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully tests the SyncMerkleRoots functionality func TestMerkleRootsAPI_SyncMerkleRoots_PartialResponsesStoredSuccessfully(t *testing.T) { // given: db := merklerootstest.CreateRepository([]models.MerkleRoot{}) diff --git a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go index 588f5d6e..bb50f0b0 100644 --- a/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go +++ b/internal/api/v1/user/merkleroots/merklerootstest/merkleroots_api_fixtures.go @@ -37,10 +37,18 @@ func Ptr[T any](value T) *T { return &value } -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ Message: http.StatusText(http.StatusBadRequest), StatusCode: http.StatusBadRequest, Code: "invalid-data-format", } } + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/transactions/transactions_api_test.go b/internal/api/v1/user/transactions/transactions_api_test.go index 45a264e1..372319eb 100644 --- a/internal/api/v1/user/transactions/transactions_api_test.go +++ b/internal/api/v1/user/transactions/transactions_api_test.go @@ -7,11 +7,9 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/transactions/transactionstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" @@ -20,41 +18,32 @@ import ( func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { ID := "1024" tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *response.Transaction expectedErr error }{ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 200", ID): { expectedResponse: transactionstest.ExpectedTransactionWithUpdatedMetadata(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_update_metadata_200.json")), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPatch, url, tc.responder) - // then: + // when: got, err := spvWalletClient.UpdateTransactionMetadata(context.Background(), &commands.UpdateTransactionMetadata{ ID: ID, Metadata: querybuilders.Metadata{ @@ -62,8 +51,10 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { "example_key2": "example_key20_val", }, }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -71,42 +62,36 @@ func TestTransactionsAPI_UpdateTransactionMetadata(t *testing.T) { func TestTransactionsAPI_RecordTransaction(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.Transaction expectedErr error }{ "HTTP POST /api/v1/transactions response: 201": { - statusCode: http.StatusCreated, expectedResponse: transactionstest.ExpectedRecordTransaction(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_record_201.json")), }, "HTTP GET /api/v1/transactions response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) - // then: + // when: got, err := spvWalletClient.RecordTransaction(context.Background(), &commands.RecordTransaction{}) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -114,45 +99,39 @@ func TestTransactionsAPI_RecordTransaction(t *testing.T) { func TestTransactionsAPI_DraftTransaction(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.DraftTransaction expectedErr error }{ "HTTP POST /api/v1/transactions/drafts response: 200": { - statusCode: http.StatusOK, expectedResponse: transactionstest.ExpectedDraftTransaction(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_draft_200.json")), }, "HTTP POST /api/v1/transactions/drafts response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP POST /api/v1/transactions/drafts str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/transactions/drafts" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) - // then: + // when: got, err := spvWalletClient.DraftTransaction(context.Background(), &commands.DraftTransaction{ Config: response.TransactionConfig{}, Metadata: map[string]any{}, }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -161,42 +140,36 @@ func TestTransactionsAPI_Transaction(t *testing.T) { ID := "1024" tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.Transaction expectedErr error }{ fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 200", ID): { - statusCode: http.StatusOK, expectedResponse: transactionstest.ExpectedTransaction(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transaction_200.json")), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s response: 400", ID): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP PATCH /api/v1/transactions/%s str response: 500", ID): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/transactions/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := spvWalletClient.Transaction(context.Background(), ID) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -204,42 +177,36 @@ func TestTransactionsAPI_Transaction(t *testing.T) { func TestTransactionsAPI_Transactions(t *testing.T) { tests := map[string]struct { responder httpmock.Responder - statusCode int expectedResponse *response.PageModel[response.Transaction] expectedErr error }{ "HTTP GET /api/v1/transactions response: 200": { - statusCode: http.StatusOK, expectedResponse: transactionstest.ExpectedTransactionsPage(t), responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("transactionstest/transactions_200.json")), }, "HTTP GET /api/v1/transactions response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), + expectedErr: transactionstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, transactionstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/transactions str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: transactionstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, transactionstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/transactions" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: spvWalletClient, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := spvWalletClient.Transactions(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } diff --git a/internal/api/v1/user/transactions/transactionstest/transactionstest.go b/internal/api/v1/user/transactions/transactionstest/transactionstest.go index c8c27517..c3d97d45 100644 --- a/internal/api/v1/user/transactions/transactionstest/transactionstest.go +++ b/internal/api/v1/user/transactions/transactionstest/transactionstest.go @@ -311,10 +311,18 @@ func Ptr[T any](value T) *T { return &value } -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ Message: http.StatusText(http.StatusBadRequest), StatusCode: http.StatusBadRequest, Code: "invalid-data-format", } } + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/users/access_key_api_test.go b/internal/api/v1/user/users/access_key_api_test.go index f089dbff..aa7e3608 100644 --- a/internal/api/v1/user/users/access_key_api_test.go +++ b/internal/api/v1/user/users/access_key_api_test.go @@ -7,11 +7,9 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" @@ -19,48 +17,39 @@ import ( func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) { tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *response.AccessKey expectedErr error }{ "HTTP POST /api/v1/users/current/keys response: 200": { expectedResponse: userstest.ExpectedCreatedAccessKey(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/post_access_key_200.json")), }, "HTTP POST /api/v1/users/current/keys response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP POST /api/v1/users/current/keys str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPost, url, tc.responder) - // then: + // when: got, err := wallet.GenerateAccessKey(context.Background(), &commands.GenerateAccessKey{ - Metadata: map[string]any{ - "example_key": "example_value", - }, + Metadata: map[string]any{"example_key": "example_value"}, }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -68,88 +57,74 @@ func TestAccessKeyAPI_GenerateAccessKey(t *testing.T) { func TestAccessKeyAPI_AccessKey(t *testing.T) { ID := "1fb70cc2-e9d9-41a3-842e-f71cc58d9787" tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *response.AccessKey expectedErr error }{ fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 200", ID): { expectedResponse: userstest.ExpectedRertrivedAccessKey(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_key_200.json")), }, fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s response: 400", ID): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP GET /api/v1/users/current/keys/%s str response: 500", ID): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.AccessKey(context.Background(), ID) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } func TestAccessKeyAPI_AccessKeys(t *testing.T) { tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *queries.AccessKeyPage expectedErr error }{ "HTTP GET /api/v1/users/current/keys response: 200": { expectedResponse: userstest.ExpectedAccessKeyPage(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_access_keys_200.json")), }, "HTTP GET /api/v1/users/current/keys response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/users/current/keys str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.AccessKeys(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } @@ -157,40 +132,33 @@ func TestAccessKeyAPI_AccessKeys(t *testing.T) { func TestAccessKeyAPI_RevokeAccessKey(t *testing.T) { ID := "081743f7-040e-45a3-8c36-dde39001e20d" tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedErr error }{ fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 200", ID): { - code: http.StatusOK, responder: httpmock.NewStringResponder(http.StatusOK, http.StatusText(http.StatusOK)), }, fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s response: 400", ID): { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, fmt.Sprintf("HTTP DELETE /api/v1/users/current/keys/%s str response: 500", ID): { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current/keys/" + ID for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodDelete, url, tc.responder) - // then: + // when: err := wallet.RevokeAccessKey(context.Background(), ID) + + // then: require.ErrorIs(t, err, tc.expectedErr) }) } diff --git a/internal/api/v1/user/users/userstest/xpub_api_fixtures.go b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go index 231eab7d..6f926442 100644 --- a/internal/api/v1/user/users/userstest/xpub_api_fixtures.go +++ b/internal/api/v1/user/users/userstest/xpub_api_fixtures.go @@ -45,14 +45,6 @@ func ExpectedUserXPub(t *testing.T) *response.Xpub { } } -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - } -} - func ParseTime(t *testing.T, s string) time.Time { ts, err := time.Parse(time.RFC3339Nano, s) if err != nil { @@ -64,3 +56,19 @@ func ParseTime(t *testing.T, s string) time.Time { func Ptr[T any](value T) *T { return &value } + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/api/v1/user/users/xpub_api_test.go b/internal/api/v1/user/users/xpub_api_test.go index 3bd79349..793ce0a1 100644 --- a/internal/api/v1/user/users/xpub_api_test.go +++ b/internal/api/v1/user/users/xpub_api_test.go @@ -6,10 +6,8 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/users/userstest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" @@ -17,90 +15,74 @@ import ( func TestXPubAPI_UpdateXPubMetadata(t *testing.T) { tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *response.Xpub expectedErr error }{ "HTTP PATCH /api/v1/users/current response: 200": { expectedResponse: userstest.ExpectedUpdatedXPubMetadata(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/patch_xpub_metadata_200.json")), }, "HTTP PATCH /api/v1/users/current response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP PATCH /api/v1/users/current str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodPatch, url, tc.responder) - // then: + // when: got, err := wallet.UpdateXPubMetadata(context.Background(), &commands.UpdateXPubMetadata{ - Metadata: map[string]any{ - "example_key": "example_value", - }, + Metadata: map[string]any{"example_key": "example_value"}, }) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } func TestXPubAPI_XPub(t *testing.T) { tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *response.Xpub expectedErr error }{ "HTTP GET /api/v1/users/current/ response: 200": { expectedResponse: userstest.ExpectedUserXPub(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("userstest/get_xpub_200.json")), }, "HTTP GET /api/v1/users/current response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), + expectedErr: userstest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, userstest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/users/current str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: userstest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, userstest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/users/current" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.XPub(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) require.EqualValues(t, tc.expectedResponse, got) }) diff --git a/internal/api/v1/user/utxos/utxos_api_test.go b/internal/api/v1/user/utxos/utxos_api_test.go index cba4730e..e9d64de8 100644 --- a/internal/api/v1/user/utxos/utxos_api_test.go +++ b/internal/api/v1/user/utxos/utxos_api_test.go @@ -5,55 +5,46 @@ import ( "net/http" "testing" - "github.com/bitcoin-sv/spv-wallet-go-client/errors" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/user/utxos/utxostest" "github.com/bitcoin-sv/spv-wallet-go-client/internal/spvwallettest" "github.com/bitcoin-sv/spv-wallet-go-client/queries" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" ) func TestUTXOAPI_UTXOs(t *testing.T) { tests := map[string]struct { - code int responder httpmock.Responder - statusCode int expectedResponse *queries.UtxosPage expectedErr error }{ "HTTP GET /api/v1/utxos response: 200": { expectedResponse: utxostest.ExpectedUtxosPage(t), - code: http.StatusOK, responder: httpmock.NewJsonResponderOrPanic(http.StatusOK, httpmock.File("utxostest/get_utxos_200.json")), }, "HTTP GET /api/v1/utxos response: 400": { - expectedErr: models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - }, - statusCode: http.StatusOK, - responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, utxostest.NewBadRequestSPVError()), + expectedErr: utxostest.NewBadRequestSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, utxostest.NewBadRequestSPVError()), }, "HTTP GET /api/v1/utxos str response: 500": { - expectedErr: errors.ErrUnrecognizedAPIResponse, - statusCode: http.StatusInternalServerError, - responder: httpmock.NewStringResponder(http.StatusInternalServerError, "unexpected internal server failure"), + expectedErr: utxostest.NewInternalServerSPVError(), + responder: httpmock.NewJsonResponderOrPanic(http.StatusInternalServerError, utxostest.NewInternalServerSPVError()), }, } url := spvwallettest.TestAPIAddr + "/api/v1/utxos" for name, tc := range tests { t.Run(name, func(t *testing.T) { - // when: + // given: wallet, transport := spvwallettest.GivenSPVUserAPI(t) transport.RegisterResponder(http.MethodGet, url, tc.responder) - // then: + // when: got, err := wallet.UTXOs(context.Background()) + + // then: require.ErrorIs(t, err, tc.expectedErr) - require.EqualValues(t, tc.expectedResponse, got) + require.Equal(t, tc.expectedResponse, got) }) } } diff --git a/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go index aabd4ed2..71671798 100644 --- a/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go +++ b/internal/api/v1/user/utxos/utxostest/utxo_api_fixtures.go @@ -10,14 +10,6 @@ import ( "github.com/bitcoin-sv/spv-wallet/models/response" ) -func NewBadRequestSPVError() *models.SPVError { - return &models.SPVError{ - Message: http.StatusText(http.StatusBadRequest), - StatusCode: http.StatusBadRequest, - Code: "invalid-data-format", - } -} - func ParseTime(t *testing.T, s string) time.Time { ts, err := time.Parse(time.RFC3339Nano, s) if err != nil { @@ -121,3 +113,19 @@ func ExpectedUtxosPage(t *testing.T) *queries.UtxosPage { }, } } + +func NewBadRequestSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + Code: "invalid-data-format", + } +} + +func NewInternalServerSPVError() models.SPVError { + return models.SPVError{ + Message: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + Code: models.UnknownErrorCode, + } +} diff --git a/internal/spvwallettest/spvwallettest.go b/internal/spvwallettest/spvwallettest.go index 369f7b12..c307c725 100644 --- a/internal/spvwallettest/spvwallettest.go +++ b/internal/spvwallettest/spvwallettest.go @@ -116,7 +116,7 @@ func MockPKI(t *testing.T, xpub string) string { for i := 0; i < 3; i++ { //magicNumberOfInheritance is 3 -> 2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI xPub, err = xPub.Child(0) if err != nil { - panic(err) + t.Fatalf("test helper - retrieve a derived child extended key at index 0 failed: %s", err) } } From 13253bafb8fc85cfd16f5fe97f57991b8bd73a96 Mon Sep 17 00:00:00 2001 From: Damian Date: Mon, 2 Dec 2024 13:06:31 +0100 Subject: [PATCH 18/18] feat(SPV-1241): migrate FinalizeTransaction and SendToRecipients --- commands/transactions.go | 16 +++++ examples/Taskfile.yml | 8 ++- .../draft_transaction/draft_transaction.go | 36 ---------- examples/send_op_return/send_op_return.go | 65 +++++++++++++++++++ .../v1/user/transactions/transactions_api.go | 51 +++++++++++++++ internal/auth/auth.go | 9 +-- user_api.go | 38 +++++++++-- 7 files changed, 178 insertions(+), 45 deletions(-) delete mode 100644 examples/draft_transaction/draft_transaction.go create mode 100644 examples/send_op_return/send_op_return.go diff --git a/commands/transactions.go b/commands/transactions.go index f40fa3c3..c6ca674d 100644 --- a/commands/transactions.go +++ b/commands/transactions.go @@ -25,3 +25,19 @@ type UpdateTransactionMetadata struct { ID string `json:"-"` // Unique identifier of the transaction to be updated. Metadata querybuilders.Metadata `json:"metadata"` // New metadata to associate with the transaction. } + +// Recipients represents a single recipient in a transaction. +// It includes details about the recipient address, the amount to send, +// and an optional OP_RETURN script for including additional data in the transaction. +type Recipients struct { + OpReturn *response.OpReturn `json:"op_return"` // Optional OP_RETURN script for attaching data to the transaction. + Satoshis uint64 `json:"satoshis"` // Amount to send to the recipient, in satoshis. + To string `json:"to"` // Paymails address of the recipient. +} + +// SendToRecipients holds the arguments required to send a transaction to multiple recipients. +// This includes the list of recipients with their details and optional metadata for the transaction. +type SendToRecipients struct { + Recipients []*Recipients `json:"recipients"` // List of recipients for the transaction. + Metadata querybuilders.Metadata `json:"metadata"` // Metadata associated with the transaction. +} diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml index c74fd13d..7ca3a844 100644 --- a/examples/Taskfile.yml +++ b/examples/Taskfile.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" tasks: default: @@ -98,6 +98,12 @@ tasks: cmds: - go run ./update_transaction_metadata/update_transaction_metadata.go + create-transaction: + desc: "Send OP return." + silent: true + cmds: + - go run ./send_op_return/send_op_return.go + fetch-user-utxos: desc: "Fetch user UTXOs page." silent: true diff --git a/examples/draft_transaction/draft_transaction.go b/examples/draft_transaction/draft_transaction.go deleted file mode 100644 index 799cc98d..00000000 --- a/examples/draft_transaction/draft_transaction.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "context" - "log" - - wallet "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" - "github.com/bitcoin-sv/spv-wallet/models/response" -) - -func main() { - usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) - if err != nil { - log.Fatal(err) - } - - transaction, err := usersAPI.DraftTransaction(context.Background(), &commands.DraftTransaction{ - Config: response.TransactionConfig{ - Outputs: []*response.TransactionOutput{ - { - To: "receiver@example.com", - Satoshis: 1, - }, - }, - }, - Metadata: map[string]any{"key": "value"}, - }) - if err != nil { - log.Fatal(err) - } - - exampleutil.Print("[HTTP POST] Draft transaction - api/v1/transactions", transaction) -} diff --git a/examples/send_op_return/send_op_return.go b/examples/send_op_return/send_op_return.go new file mode 100644 index 00000000..8521dc30 --- /dev/null +++ b/examples/send_op_return/send_op_return.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + metadata := map[string]any{} + + opReturn := response.OpReturn{StringParts: []string{"hello", "world"}} + draftTransactionCmd := commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: []*response.TransactionOutput{ + { + OpReturn: &opReturn, + }, + }, + }, + Metadata: metadata, + } + + draftTransaction, err := usersAPI.DraftTransaction(ctx, &draftTransactionCmd) + if err != nil { + log.Fatal(err) + } + exampleutil.Print("DraftTransaction response: ", draftTransaction) + + finalized, err := usersAPI.FinalizeTransaction(draftTransaction) + if err != nil { + log.Fatal(err) + } + fmt.Println("Finalized transaction hex : ", finalized) + + recordTransactionCmd := commands.RecordTransaction{ + Hex: finalized, + Metadata: metadata, + ReferenceID: draftTransaction.ID, + } + transaction, err := usersAPI.RecordTransaction(ctx, &recordTransactionCmd) + if err != nil { + log.Fatal(err) + } + fmt.Println("Transaction with OP_RETURN: ", transaction) + + transactionG, err := usersAPI.Transaction(context.Background(), transaction.ID) + if err != nil { + log.Fatal(err) + } + fmt.Println("Transaction: ", transactionG) +} diff --git a/internal/api/v1/user/transactions/transactions_api.go b/internal/api/v1/user/transactions/transactions_api.go index c669b7af..c35394f8 100644 --- a/internal/api/v1/user/transactions/transactions_api.go +++ b/internal/api/v1/user/transactions/transactions_api.go @@ -5,9 +5,11 @@ import ( "fmt" "net/url" + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -20,6 +22,55 @@ type API struct { httpClient *resty.Client } +func (a *API) FinalizeTransaction(draft *response.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { + res, err := auth.GetSignedHex(draft, xPriv) + if err != nil { + return "", err + } + + return res, nil +} + +func (a *API) DraftToRecipients(ctx context.Context, r *commands.SendToRecipients) (*response.DraftTransaction, error) { + outputs := make([]*response.TransactionOutput, 0) + + for _, recipient := range r.Recipients { + outputs = append(outputs, &response.TransactionOutput{ + To: recipient.To, + Satoshis: recipient.Satoshis, + OpReturn: recipient.OpReturn, + }) + } + + draftTransactionCmd := &commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: outputs, + }, + Metadata: r.Metadata, + } + + return a.DraftTransaction(ctx, draftTransactionCmd) +} + +func (a *API) SendToRecipients(ctx context.Context, r *commands.SendToRecipients, xPriv *bip32.ExtendedKey) (*response.Transaction, error) { + draft, err := a.DraftToRecipients(ctx, r) + if err != nil { + return nil, err + } + + var hex string + if hex, err = a.FinalizeTransaction(draft, xPriv); err != nil { + return nil, err + } + + recordTransactionCmd := &commands.RecordTransaction{ + Metadata: r.Metadata, + Hex: hex, + ReferenceID: draft.ID, + } + return a.RecordTransaction(ctx, recordTransactionCmd) +} + func (a *API) DraftTransaction(ctx context.Context, r *commands.DraftTransaction) (*response.DraftTransaction, error) { var result response.DraftTransaction diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1d35318b..0af3c4bb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -16,9 +16,10 @@ import ( "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" ) -func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { +func GetSignedHex(dt *response.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { // Create transaction from hex tx, err := trx.NewTransactionFromHex(dt.Hex) // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign @@ -66,7 +67,7 @@ func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString stri return nil } -func prepareLockingScript(dst *models.Destination) (*script.Script, error) { +func prepareLockingScript(dst *response.Destination) (*script.Script, error) { lockingScript, err := script.NewFromHex(dst.LockingScript) if err != nil { return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err) @@ -75,7 +76,7 @@ func prepareLockingScript(dst *models.Destination) (*script.Script, error) { return lockingScript, nil } -func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { +func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *response.Destination) (*p2pkh.P2PKH, error) { key, err := getDerivedKeyForDestination(xPriv, dst) if err != nil { return nil, fmt.Errorf("failed to get derived key for destination: %w", err) @@ -84,7 +85,7 @@ func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) ( return getUnlockingScript(key) } -func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) { +func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *response.Destination) (*ec.PrivateKey, error) { // Derive the child key (m/chain/num) derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num) if err != nil { diff --git a/user_api.go b/user_api.go index a2a7a6ad..6f1bda06 100644 --- a/user_api.go +++ b/user_api.go @@ -37,6 +37,7 @@ import ( // UserAPI methods may return wrapped errors, including models.SPVError or // ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API. type UserAPI struct { + xPriv *bip32.ExtendedKey xpubAPI *users.XPubAPI accessKeyAPI *users.AccessKeyAPI configsAPI *configs.API @@ -226,6 +227,34 @@ func (u *UserAPI) Transaction(ctx context.Context, ID string) (*response.Transac return res, nil } +// FinalizeTransaction finalizes a draft transaction and returns its signed hex representation. +// It uses the draft transaction details to construct, enrich, and sign the transaction +// through the `auth.GetSignedHex` utility function. +// The response is the signed transaction in hex format. +// Returns an error if the transaction cannot be finalized. +func (u *UserAPI) FinalizeTransaction(draft *response.DraftTransaction) (string, error) { + res, err := u.transactionsAPI.FinalizeTransaction(draft, u.xPriv) + if err != nil { + return "", fmt.Errorf("couldn't finalize transaction with ID: %s, %w", draft.ID, err) + } + + return res, nil +} + +// SendToRecipients creates, finalizes, and broadcasts a transaction to multiple recipients. +// This method handles the complete process of drafting, finalizing, and recording the transaction +// using the recipient details provided in the command. +// The response is unmarshalled into a *response.Transaction struct. +// Returns an error if the transaction fails at any step, such as drafting, finalization or recording. +func (u *UserAPI) SendToRecipients(ctx context.Context, cmd *commands.SendToRecipients) (*response.Transaction, error) { + res, err := u.transactionsAPI.SendToRecipients(ctx, cmd, u.xPriv) + if err != nil { + return nil, transactions.HTTPErrorFormatter("send to recipients", err).FormatPostErr() + } + + return res, nil +} + // XPub retrieves the full xpub information for the current user via the users API. // The response is unmarshaled into a *response.Xpub. // Returns an error if the request fails or the response cannot be decoded. @@ -395,7 +424,7 @@ func NewUserAPIWithXPub(cfg config.Config, xPub string) (*UserAPI, error) { return nil, fmt.Errorf("failed to intialized xPub authenticator: %w", err) } - return initUserAPI(cfg, authenticator) + return initUserAPI(cfg, nil, authenticator) } // NewUserAPIWithXPriv initializes a new UserAPI instance using an extended private key (xPriv). @@ -414,7 +443,7 @@ func NewUserAPIWithXPriv(cfg config.Config, xPriv string) (*UserAPI, error) { return nil, fmt.Errorf("failed to intialized xPriv authenticator: %w", err) } - userAPI, err := initUserAPI(cfg, authenticator) + userAPI, err := initUserAPI(cfg, key, authenticator) if err != nil { return nil, fmt.Errorf("failed to create new client: %w", err) } @@ -439,14 +468,14 @@ func NewUserAPIWithAccessKey(cfg config.Config, accessKey string) (*UserAPI, err return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) } - return initUserAPI(cfg, authenticator) + return initUserAPI(cfg, nil, authenticator) } type authenticator interface { Authenticate(r *resty.Request) error } -func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) { +func initUserAPI(cfg config.Config, xPriv *bip32.ExtendedKey, auth authenticator) (*UserAPI, error) { url, err := url.Parse(cfg.Addr) if err != nil { return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) @@ -454,6 +483,7 @@ func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) { httpClient := restyutil.NewHTTPClient(cfg, auth) return &UserAPI{ + xPriv: xPriv, merkleRootsAPI: merkleroots.NewAPI(url, httpClient), configsAPI: configs.NewAPI(url, httpClient), transactionsAPI: transactions.NewAPI(url, httpClient),