diff --git a/access_keys.go b/access_keys.go deleted file mode 100644 index 5bab2cb..0000000 --- a/access_keys.go +++ /dev/null @@ -1,28 +0,0 @@ -package walletclient - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// GetAccessKey gets the access key given by id -func (b *WalletClient) GetAccessKey(ctx context.Context, id string) (*models.AccessKey, transports.ResponseError) { - return b.transport.GetAccessKey(ctx, id) -} - -// GetAccessKeys gets all the access keys filtered by the metadata -func (b *WalletClient) GetAccessKeys(ctx context.Context, metadataConditions *models.Metadata) ([]*models.AccessKey, transports.ResponseError) { - return b.transport.GetAccessKeys(ctx, metadataConditions) -} - -// CreateAccessKey creates new access key -func (b *WalletClient) CreateAccessKey(ctx context.Context, metadata *models.Metadata) (*models.AccessKey, transports.ResponseError) { - return b.transport.CreateAccessKey(ctx, metadata) -} - -// RevokeAccessKey revoked the access key given by id -func (b *WalletClient) RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, transports.ResponseError) { - return b.transport.RevokeAccessKey(ctx, id) -} diff --git a/access_keys_test.go b/access_keys_test.go index fa9de93..9df5437 100644 --- a/access_keys_test.go +++ b/access_keys_test.go @@ -1,77 +1,59 @@ +// 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/models" - "github.com/stretchr/testify/assert" - "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) { - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/access-key", - Result: fixtures.MarshallForTestHandler(fixtures.AccessKey), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } + 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() - t.Run("GetAccessKey", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, true) + client := NewWithAccessKey(server.URL, fixtures.AccessKeyString) + require.NotNil(t, client.accessKey) - // when + t.Run("GetAccessKey", func(t *testing.T) { accessKey, err := client.GetAccessKey(context.Background(), fixtures.AccessKey.ID) - - // then - assert.NoError(t, err) - assert.Equal(t, accessKey, fixtures.AccessKey) + require.NoError(t, err) + require.Equal(t, fixtures.AccessKey, accessKey) }) t.Run("GetAccessKeys", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/access-key/search", - Result: fixtures.MarshallForTestHandler([]*models.AccessKey{fixtures.AccessKey}), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, true) - - // when - accessKeys, err := client.GetAccessKeys(context.Background(), fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, accessKeys, []*models.AccessKey{fixtures.AccessKey}) + accessKeys, err := client.GetAccessKeys(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, []*models.AccessKey{fixtures.AccessKey}, accessKeys) }) t.Run("CreateAccessKey", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, true) - - // when - accessKey, err := client.CreateAccessKey(context.Background(), fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, accessKey, fixtures.AccessKey) + accessKey, err := client.CreateAccessKey(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, fixtures.AccessKey, accessKey) }) t.Run("RevokeAccessKey", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, true) - - // when accessKey, err := client.RevokeAccessKey(context.Background(), fixtures.AccessKey.ID) - - // then - assert.NoError(t, err) - assert.Equal(t, accessKey, fixtures.AccessKey) + require.NoError(t, err) + require.Equal(t, fixtures.AccessKey, accessKey) }) } diff --git a/admin_contacts.go b/admin_contacts.go deleted file mode 100644 index e8c2d53..0000000 --- a/admin_contacts.go +++ /dev/null @@ -1,34 +0,0 @@ -package walletclient - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet/models" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" -) - -// AdminGetContacts retrieves a list of contacts based on the provided conditions, metadata, and query parameters. -func (wc *WalletClient) AdminGetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *transports.QueryParams) ([]*models.Contact, transports.ResponseError) { - return wc.transport.AdminGetContacts(ctx, conditions, metadata, queryParams) -} - -// AdminUpdateContact updates a contact's details such as their full name using the specified contact ID and metadata. -func (wc *WalletClient) AdminUpdateContact(ctx context.Context, id, fullName string, metadata *models.Metadata) (*models.Contact, transports.ResponseError) { - return wc.transport.AdminUpdateContact(ctx, id, fullName, metadata) -} - -// AdminDeleteContact removes a contact from the system using the specified contact ID. -func (wc *WalletClient) AdminDeleteContact(ctx context.Context, id string) transports.ResponseError { - return wc.transport.AdminDeleteContact(ctx, id) -} - -// AdminAcceptContact marks a contact as accepted using the specified contact ID. -func (wc *WalletClient) AdminAcceptContact(ctx context.Context, id string) (*models.Contact, transports.ResponseError) { - return wc.transport.AdminAcceptContact(ctx, id) -} - -// AdminRejectContact marks a contact as rejected using the specified contact ID. -func (wc *WalletClient) AdminRejectContact(ctx context.Context, id string) (*models.Contact, transports.ResponseError) { - return wc.transport.AdminRejectContact(ctx, id) -} diff --git a/admin_contacts_test.go b/admin_contacts_test.go new file mode 100644 index 0000000..610d0fa --- /dev/null +++ b/admin_contacts_test.go @@ -0,0 +1,74 @@ +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" +) + +// 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" + contacts := []*models.Contact{c} + json.NewEncoder(w).Encode(contacts) + 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 = "accepted" + json.NewEncoder(w).Encode(contact) + case r.URL.Path == "/v1/admin/contact/rejected/1" && r.Method == http.MethodPatch: + contact := fixtures.Contact + contact.Status = "rejected" + json.NewEncoder(w).Encode(contact) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := NewWithAdminKey(server.URL, fixtures.XPrivString) + 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[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, models.ContactStatus("accepted"), contact.Status) + }) + + t.Run("AdminRejectContact", func(t *testing.T) { + contact, err := client.AdminRejectContact(context.Background(), "1") + require.NoError(t, err) + require.Equal(t, models.ContactStatus("rejected"), contact.Status) + }) +} diff --git a/transports/authentication.go b/authentication.go similarity index 99% rename from transports/authentication.go rename to authentication.go index 3a51108..55d2386 100644 --- a/transports/authentication.go +++ b/authentication.go @@ -1,4 +1,4 @@ -package transports +package walletclient import ( "encoding/hex" @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/bitcoin-sv/spv-wallet-go-client/utils" "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/models/apierrors" "github.com/bitcoinschema/go-bitcoin/v2" @@ -14,8 +15,6 @@ import ( "github.com/libsv/go-bt/v2" "github.com/libsv/go-bt/v2/bscript" "github.com/libsv/go-bt/v2/sighash" - - "github.com/bitcoin-sv/spv-wallet-go-client/utils" ) // SetSignature will set the signature on the header for the request diff --git a/client_options.go b/client_options.go index 0d0494c..a5d06fa 100644 --- a/client_options.go +++ b/client_options.go @@ -1,70 +1,141 @@ package walletclient import ( + "fmt" "net/http" + "net/url" - "github.com/bitcoin-sv/spv-wallet-go-client/transports" + "github.com/bitcoinschema/go-bitcoin/v2" + "github.com/libsv/go-bk/bec" + "github.com/libsv/go-bk/wif" + "github.com/pkg/errors" ) -// WithXPriv will set xPrivString on the client -func WithXPriv(xPrivString string) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.xPrivString = xPrivString - } +// configurator is the interface for configuring WalletClient +type configurator interface { + Configure(c *WalletClient) +} + +// xPrivConf sets the xPrivString field of a WalletClient +type xPrivConf struct { + XPrivString string +} + +func (w *xPrivConf) Configure(c *WalletClient) { + var err error + if c.xPriv, err = bitcoin.GenerateHDKeyFromString(w.XPrivString); err != nil { + c.xPriv = nil } } -// WithXPub will set xPubString on the client -func WithXPub(xPubString string) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.xPubString = xPubString - } +// xPubConf sets the xPubString on the client +type xPubConf struct { + XPubString string +} + +func (w *xPubConf) Configure(c *WalletClient) { + var err error + if c.xPub, err = bitcoin.GetHDKeyFromExtendedPublicKey(w.XPubString); err != nil { + c.xPub = nil } + } -// WithAccessKey will set the access key on the client -func WithAccessKey(accessKeyString string) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.accessKeyString = accessKeyString - } +// accessKeyConf sets the accessKeyString on the client +type accessKeyConf struct { + AccessKeyString string +} + +func (w *accessKeyConf) Configure(c *WalletClient) { + var err error + if c.accessKey, err = w.initializeAccessKey(); err != nil { + c.accessKey = nil } } -// WithHTTP will overwrite the default client with a custom client -func WithHTTP(serverURL string) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.transportOptions = append(c.transportOptions, transports.WithHTTP(serverURL)) - } +// adminKeyConf sets the admin key for creating new xpubs +type adminKeyConf struct { + AdminKeyString string +} + +func (w *adminKeyConf) Configure(c *WalletClient) { + var err error + c.adminXPriv, err = bitcoin.GenerateHDKeyFromString(w.AdminKeyString) + if err != nil { + c.adminXPriv = nil } } -// WithHTTPClient will overwrite the default client with a custom client -func WithHTTPClient(serverURL string, httpClient *http.Client) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.transportOptions = append(c.transportOptions, transports.WithHTTPClient(serverURL, httpClient)) - } +// httpConf sets the URL and httpConf client of a WalletClient +type httpConf struct { + ServerURL string + HTTPClient *http.Client +} + +func (w *httpConf) Configure(c *WalletClient) { + // Ensure the ServerURL ends with a clean base URL + baseURL, err := validateAndCleanURL(w.ServerURL) + if err != nil { + // Handle the error appropriately + fmt.Println("Invalid URL provided:", err) + return + } + + 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 } } -// WithAdminKey will set the admin key for admin requests -func WithAdminKey(adminKey string) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.transportOptions = append(c.transportOptions, transports.WithAdminKey(adminKey)) +// signRequest configures whether to sign HTTP requests +type signRequest struct { + Sign bool +} + +func (w *signRequest) Configure(c *WalletClient) { + c.signRequest = w.Sign +} + +// initializeAccessKey handles the specific initialization of the access key. +func (w *accessKeyConf) initializeAccessKey() (*bec.PrivateKey, error) { + var err error + var privateKey *bec.PrivateKey + var decodedWIF *wif.WIF + + if decodedWIF, err = wif.DecodeWIF(w.AccessKeyString); err != nil { + if privateKey, err = bitcoin.PrivateKeyFromString(w.AccessKeyString); err != nil { + return nil, errors.Wrap(err, "failed to decode access key") } + } else { + privateKey = decodedWIF.PrivKey } + + return privateKey, nil } -// WithSignRequest will set whether to sign all requests -func WithSignRequest(signRequest bool) ClientOps { - return func(c *WalletClient) { - if c != nil { - c.transportOptions = append(c.transportOptions, transports.WithSignRequest(signRequest)) - } +// 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 new file mode 100644 index 0000000..5627afa --- /dev/null +++ b/client_options_test.go @@ -0,0 +1,33 @@ +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/transports/config.go b/config.go similarity index 99% rename from transports/config.go rename to config.go index c98fc12..99f0c4b 100644 --- a/transports/config.go +++ b/config.go @@ -1,4 +1,4 @@ -package transports +package walletclient import "github.com/bitcoin-sv/spv-wallet/models" diff --git a/contacts.go b/contacts.go deleted file mode 100644 index e1e47e5..0000000 --- a/contacts.go +++ /dev/null @@ -1,50 +0,0 @@ -package walletclient - -import ( - "context" - "errors" - "fmt" - - "github.com/bitcoin-sv/spv-wallet/models" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" -) - -// 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 (b *WalletClient) UpsertContact(ctx context.Context, paymail, fullName string, metadata *models.Metadata) (*models.Contact, transports.ResponseError) { - return b.transport.UpsertContact(ctx, paymail, fullName, metadata, "") -} - -// 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 specified paymail in their contacts. -func (b *WalletClient) UpsertContactForPaymail(ctx context.Context, paymail, fullName string, metadata *models.Metadata, requesterPaymail string) (*models.Contact, transports.ResponseError) { - return b.transport.UpsertContact(ctx, paymail, fullName, metadata, requesterPaymail) -} - -// AcceptContact will accept the contact associated with the paymail -func (b *WalletClient) AcceptContact(ctx context.Context, paymail string) transports.ResponseError { - return b.transport.AcceptContact(ctx, paymail) -} - -// RejectContact will reject the contact associated with the paymail -func (b *WalletClient) RejectContact(ctx context.Context, paymail string) transports.ResponseError { - return b.transport.RejectContact(ctx, paymail) -} - -// ConfirmContact will try to confirm the contact -func (b *WalletClient) ConfirmContact(ctx context.Context, contact *models.Contact, passcode, requesterPaymail string, period, digits uint) transports.ResponseError { - isTotpValid, err := b.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits) - if err != nil { - return transports.WrapError(fmt.Errorf("totp validation failed: %w", err)) - } - - if !isTotpValid { - return transports.WrapError(errors.New("totp is invalid")) - } - - return b.transport.ConfirmContact(ctx, contact.Paymail) -} - -// GetContacts will get contacts by conditions -func (b *WalletClient) GetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *transports.QueryParams) ([]*models.Contact, transports.ResponseError) { - return b.transport.GetContacts(ctx, conditions, metadata, queryParams) -} diff --git a/contacts_test.go b/contacts_test.go index 08d87a6..1d10411 100644 --- a/contacts_test.go +++ b/contacts_test.go @@ -2,159 +2,72 @@ package walletclient import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/stretchr/testify/require" ) // TestContactActionsRouting will test routing func TestContactActionsRouting(t *testing.T) { - tcs := []struct { - name string - route string - responsePayload string - f func(c *WalletClient) error - }{ - { - name: "RejectContact", - route: "/contact/rejected/", - responsePayload: "{}", - f: func(c *WalletClient) error { return c.RejectContact(context.Background(), fixtures.PaymailAddress) }, - }, - { - name: "AcceptContact", - route: "/contact/accepted/", - responsePayload: "{}", - f: func(c *WalletClient) error { return c.AcceptContact(context.Background(), fixtures.PaymailAddress) }, - }, - { - name: "GetContacts", - route: "/contact/search/", - responsePayload: "[]", - f: func(c *WalletClient) error { - _, err := c.GetContacts(context.Background(), nil, nil, nil) - return err - }, - }, - { - name: "UpsertContact", - route: "/contact/", - responsePayload: "{}", - f: func(c *WalletClient) error { - _, err := c.UpsertContact(context.Background(), "", "", nil) - return err - }, - }, - { - name: "UpsertContactForPaymail", - route: "/contact/", - responsePayload: "{}", - f: func(c *WalletClient) error { - _, err := c.UpsertContactForPaymail(context.Background(), "", "", nil, "") - return err - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - // given - tmq := testTransportHandler{ - Type: fixtures.RequestType, - Path: tc.route, - Result: tc.responsePayload, - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, + 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"}) } - - client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString)) - - // when - err := tc.f(client) - - // then - assert.NoError(t, err) - }) - } - -} - -func TestConfirmContact(t *testing.T) { - t.Run("TOTP is valid - call Confirm Action", func(t *testing.T) { - // given - tmq := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/contact/confirmed/", - Result: "{}", - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - - clientMaker := func(opts ...ClientOps) (*WalletClient, error) { - return getTestWalletClientWithOpts(tmq, opts...), nil + case r.URL.Path == "/v1/contact/accepted/": + if r.Method == http.MethodPost { + json.NewEncoder(w).Encode(map[string]string{"result": "accepted"}) + } + case r.URL.Path == "/v1/contact/search": + if r.Method == http.MethodPost { + json.NewEncoder(w).Encode([]*models.Contact{fixtures.Contact}) + } + 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() - alice := makeMockUser("alice", clientMaker) - bob := makeMockUser("bob", clientMaker) + client := NewWithAccessKey(server.URL, fixtures.AccessKeyString) + require.NotNil(t, client.accessKey) - totp, err := alice.client.GenerateTotpForContact(bob.contact, 30, 2) + t.Run("RejectContact", func(t *testing.T) { + err := client.RejectContact(context.Background(), fixtures.PaymailAddress) require.NoError(t, err) - - // when - result := bob.client.ConfirmContact(context.Background(), alice.contact, totp, bob.paymail, 30, 2) - - // then - require.Nil(t, result) }) - t.Run("TOTP is invalid - do not call Confirm Action", func(t *testing.T) { - // given - tmq := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/unknown/", - Result: "{}", - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - - clientMaker := func(opts ...ClientOps) (*WalletClient, error) { - return getTestWalletClientWithOpts(tmq, opts...), nil - } - - alice := makeMockUser("alice", clientMaker) - bob := makeMockUser("bob", clientMaker) - - totp, err := alice.client.GenerateTotpForContact(bob.contact, 30, 2) + t.Run("AcceptContact", func(t *testing.T) { + err := client.AcceptContact(context.Background(), fixtures.PaymailAddress) require.NoError(t, err) - - //make sure the wrongTotp is not the same as the generated one - wrongTotp := incrementDigits(totp) //the length should remain the same - - // when - result := bob.client.ConfirmContact(context.Background(), alice.contact, wrongTotp, bob.paymail, 30, 2) - - // then - require.NotNil(t, result) - require.Equal(t, result.Error(), "totp is invalid") }) -} -// incrementDigits takes a string of digits and increments each digit by 1. -// Digits wrap around such that '9' becomes '0'. -func incrementDigits(input string) string { - var result strings.Builder + t.Run("GetContacts", func(t *testing.T) { + contacts, err := client.GetContacts(context.Background(), nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, contacts) + }) - for _, c := range input { - if c == '9' { - result.WriteRune('0') - } else { - result.WriteRune(c + 1) - } - } + 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) + }) - return result.String() + 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.go b/destinations.go deleted file mode 100644 index a0fd6c0..0000000 --- a/destinations.go +++ /dev/null @@ -1,58 +0,0 @@ -package walletclient - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// GetDestinationByID gets the destination by id -func (b *WalletClient) GetDestinationByID(ctx context.Context, id string) (*models.Destination, transports.ResponseError) { - return b.transport.GetDestinationByID(ctx, id) -} - -// GetDestinationByAddress gets the destination by address -func (b *WalletClient) GetDestinationByAddress(ctx context.Context, address string) (*models.Destination, transports.ResponseError) { - return b.transport.GetDestinationByAddress(ctx, address) -} - -// GetDestinationByLockingScript gets the destination by locking script -func (b *WalletClient) GetDestinationByLockingScript(ctx context.Context, - lockingScript string, -) (*models.Destination, transports.ResponseError) { - return b.transport.GetDestinationByLockingScript(ctx, lockingScript) -} - -// GetDestinations gets all destinations that match the metadata filter -func (b *WalletClient) GetDestinations(ctx context.Context, - metadataConditions *models.Metadata, -) ([]*models.Destination, transports.ResponseError) { - return b.transport.GetDestinations(ctx, metadataConditions) -} - -// NewDestination create a new destination and return it -func (b *WalletClient) NewDestination(ctx context.Context, metadata *models.Metadata) (*models.Destination, transports.ResponseError) { - return b.transport.NewDestination(ctx, metadata) -} - -// UpdateDestinationMetadataByID updates the destination metadata by id -func (b *WalletClient) UpdateDestinationMetadataByID(ctx context.Context, id string, - metadata *models.Metadata, -) (*models.Destination, transports.ResponseError) { - return b.transport.UpdateDestinationMetadataByID(ctx, id, metadata) -} - -// UpdateDestinationMetadataByAddress updates the destination metadata by address -func (b *WalletClient) UpdateDestinationMetadataByAddress(ctx context.Context, address string, - metadata *models.Metadata, -) (*models.Destination, transports.ResponseError) { - return b.transport.UpdateDestinationMetadataByAddress(ctx, address, metadata) -} - -// UpdateDestinationMetadataByLockingScript updates the destination metadata by locking script -func (b *WalletClient) UpdateDestinationMetadataByLockingScript(ctx context.Context, lockingScript string, - metadata *models.Metadata, -) (*models.Destination, transports.ResponseError) { - return b.transport.UpdateDestinationMetadataByLockingScript(ctx, lockingScript, metadata) -} diff --git a/destinations_test.go b/destinations_test.go index 176ffcc..dbd9483 100644 --- a/destinations_test.go +++ b/destinations_test.go @@ -2,124 +2,93 @@ package walletclient import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/stretchr/testify/assert" - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/stretchr/testify/require" ) -// TestDestinations will test the Destinations methods func TestDestinations(t *testing.T) { - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/destination", - Result: fixtures.MarshallForTestHandler(fixtures.Destination), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } + 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) + } + } - t.Run("GetDestinationByID", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) + 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 := NewWithAccessKey(server.URL, fixtures.AccessKeyString) + require.NotNil(t, client.accessKey) - // when + t.Run("GetDestinationByID", func(t *testing.T) { destination, err := client.GetDestinationByID(context.Background(), fixtures.Destination.ID) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("GetDestinationByAddress", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.GetDestinationByAddress(context.Background(), fixtures.Destination.Address) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("GetDestinationByLockingScript", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.GetDestinationByLockingScript(context.Background(), fixtures.Destination.LockingScript) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("GetDestinations", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/destination/search", - Result: fixtures.MarshallForTestHandler([]*models.Destination{fixtures.Destination}), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) - - // when destinations, err := client.GetDestinations(context.Background(), fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, destinations, []*models.Destination{fixtures.Destination}) + require.NoError(t, err) + require.Equal(t, []*models.Destination{fixtures.Destination}, destinations) }) t.Run("NewDestination", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.NewDestination(context.Background(), fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("UpdateDestinationMetadataByID", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.UpdateDestinationMetadataByID(context.Background(), fixtures.Destination.ID, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("UpdateDestinationMetadataByAddress", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.UpdateDestinationMetadataByAddress(context.Background(), fixtures.Destination.Address, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) t.Run("UpdateDestinationMetadataByLockingScript", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when destination, err := client.UpdateDestinationMetadataByLockingScript(context.Background(), fixtures.Destination.LockingScript, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, destination, fixtures.Destination) + require.NoError(t, err) + require.Equal(t, fixtures.Destination, destination) }) } diff --git a/transports/errors.go b/errors.go similarity index 98% rename from transports/errors.go rename to errors.go index 2a60e77..e4e22c8 100644 --- a/transports/errors.go +++ b/errors.go @@ -1,4 +1,4 @@ -package transports +package walletclient import ( "encoding/json" diff --git a/examples/http/http.go b/examples/http/http.go index c1c201d..231b8bd 100644 --- a/examples/http/http.go +++ b/examples/http/http.go @@ -12,10 +12,6 @@ func main() { keys, _ := xpriv.Generate() // Create a client - client, _ := walletclient.New( - walletclient.WithXPriv(keys.XPriv()), - walletclient.WithHTTP("localhost:3001"), - walletclient.WithSignRequest(true), - ) - fmt.Println(client.IsSignRequest()) + wc := walletclient.NewWithXPriv("https://localhost:3001", keys.XPriv()) + fmt.Println(wc.IsSignRequest()) } diff --git a/examples/http_with_access_key/http_with_access_key.go b/examples/http_with_access_key/http_with_access_key.go index 85ac44f..39d9f1a 100644 --- a/examples/http_with_access_key/http_with_access_key.go +++ b/examples/http_with_access_key/http_with_access_key.go @@ -1,15 +1,14 @@ package main -import walletclient "github.com/bitcoin-sv/spv-wallet-go-client" +import ( + walletclient "github.com/bitcoin-sv/spv-wallet-go-client" +) func main() { // Replace with created access key exampleAccessKey := "some_generated_access_key" // Create a client - _, _ = walletclient.New( - walletclient.WithAccessKey(exampleAccessKey), - walletclient.WithHTTP("http://localhost:3003/v1"), - walletclient.WithSignRequest(true), - ) + _ = walletclient.NewWithAccessKey("http://localhost:3003", exampleAccessKey) + } diff --git a/examples/new_paymail/new_paymail.go b/examples/new_paymail/new_paymail.go index bfbc836..6930ea6 100644 --- a/examples/new_paymail/new_paymail.go +++ b/examples/new_paymail/new_paymail.go @@ -12,11 +12,6 @@ func main() { keys, _ := xpriv.Generate() // Create a client - walletclient, _ := walletclient.New( - walletclient.WithXPriv(keys.XPriv()), - walletclient.WithHTTP("localhost:3001"), - walletclient.WithSignRequest(true), - ) - - walletclient.AdminCreatePaymail(context.Background(), keys.XPub().String(), "foo@domain.com", "", "Foo") + wc := walletclient.NewWithXPriv("https://localhost:3001", keys.XPriv()) + wc.AdminCreatePaymail(context.Background(), keys.XPub().String(), "foo@domain.com", "", "Foo") } diff --git a/examples/register_xpub/register_xpub.go b/examples/register_xpub/register_xpub.go index 73471cd..f35bb17 100644 --- a/examples/register_xpub/register_xpub.go +++ b/examples/register_xpub/register_xpub.go @@ -3,10 +3,11 @@ package main import ( "context" "fmt" - "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" - walletclient "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet/models" + + walletclient "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" ) func main() { @@ -14,17 +15,11 @@ func main() { keys, _ := xpriv.Generate() // Create a client - walletClient, _ := walletclient.New( - walletclient.WithXPriv(keys.XPriv()), - walletclient.WithHTTP("localhost:3003/v1"), - walletclient.WithSignRequest(true), - ) - + wc := walletclient.NewWithXPriv("localhost:3003", keys.XPriv()) ctx := context.Background() + _ = wc.AdminNewXpub(ctx, keys.XPub().String(), &models.Metadata{"example_field": "example_data"}) - _ = walletClient.AdminNewXpub(ctx, keys.XPub().String(), &models.Metadata{"example_field": "example_data"}) - - xpubKey, err := walletClient.GetXPub(ctx) + xpubKey, err := wc.GetXPub(ctx) if err != nil { fmt.Println(err) } diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index 356517b..fb7f464 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -1,3 +1,4 @@ +// Package fixtures contains fixtures for testing package fixtures import ( @@ -7,16 +8,24 @@ import ( "github.com/bitcoin-sv/spv-wallet/models/common" ) -const ( - RequestType = "http" - ServerURL = "https://example.com/" - XPubString = "xpub661MyMwAqRbcFrBJbKwBGCB7d3fr2SaAuXGM95BA62X41m6eW2ehRQGW4xLi9wkEXUGnQZYxVVj4PxXnyrLk7jdqvBAs1Qq9gf6ykMvjR7J" - XPrivString = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ" +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 = "address@paymail.com" - PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde" + // 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 { @@ -27,8 +36,10 @@ func MarshallForTestHandler(object any) string { return string(json) } +// TestMetadata model for metadata var TestMetadata = &models.Metadata{"test-key": "test-value"} +// Xpub model for testing var Xpub = &models.Xpub{ Model: common.Model{Metadata: *TestMetadata}, ID: "cba0be1e753a7609e1a2f792d2e80ea6fce241be86f0690ec437377477809ccc", @@ -37,6 +48,7 @@ var Xpub = &models.Xpub{ NextExternalNum: 1, } +// AccessKey model for testing var AccessKey = &models.AccessKey{ Model: common.Model{Metadata: *TestMetadata}, ID: "access-key-id", @@ -44,6 +56,7 @@ var AccessKey = &models.AccessKey{ Key: AccessKeyString, } +// Destination model for testing var Destination = &models.Destination{ Model: common.Model{Metadata: *TestMetadata}, ID: "90d10acb85f37dd009238fe7ec61a1411725825c82099bd8432fcb47ad8326ce", @@ -56,6 +69,7 @@ var Destination = &models.Destination{ DraftID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df", } +// Transaction model for testing var Transaction = &models.Transaction{ Model: common.Model{Metadata: *TestMetadata}, ID: "caae6e799210dfea7591e3d55455437eb7e1091bb01463ae1e7ddf9e29c75eda", @@ -75,6 +89,7 @@ var Transaction = &models.Transaction{ TransactionDirection: "incoming", } +// DraftTx model for testing var DraftTx = &models.DraftTransaction{ Model: common.Model{Metadata: *TestMetadata}, ID: "3a0e1fdd9ac6046c0c82aa36b462e477a455880ceeb08d3aabb1bf031553d1df", @@ -190,3 +205,12 @@ var DraftTx = &models.DraftTransaction{ 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: models.ContactStatus("unconfirmed"), +} diff --git a/http.go b/http.go new file mode 100644 index 0000000..7f12cfb --- /dev/null +++ b/http.go @@ -0,0 +1,1100 @@ +package walletclient + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/bitcoin-sv/spv-wallet-go-client/utils" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/apierrors" + "github.com/bitcoinschema/go-bitcoin/v2" + "github.com/libsv/go-bk/bec" + "github.com/libsv/go-bk/bip32" +) + +// 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, ResponseError) { + 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 *models.Metadata) (*models.Xpub, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldMetadata: processMetadata(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, ResponseError) { + 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, metadataConditions *models.Metadata) ([]*models.AccessKey, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldMetadata: processMetadata(metadataConditions), + }) + if err != nil { + return nil, WrapError(err) + } + var accessKey []*models.AccessKey + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/access-key/search", jsonStr, wc.xPriv, true, &accessKey, + ); err != nil { + return nil, err + } + + return accessKey, nil +} + +// GetAccessKeysCount will get the count of access keys +func (wc *WalletClient) GetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + }) + if err != nil { + return 0, WrapError(err) + } + + var count int64 + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/access-key/count", jsonStr, wc.xPriv, true, &count, + ); err != nil { + return 0, err + } + + return count, nil +} + +// RevokeAccessKey will revoke an access key by id +func (wc *WalletClient) RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, ResponseError) { + 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 *models.Metadata) (*models.AccessKey, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldMetadata: processMetadata(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, ResponseError) { + 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, ResponseError) { + 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, ResponseError) { + 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, metadataConditions *models.Metadata) ([]*models.Destination, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldMetadata: processMetadata(metadataConditions), + }) + if err != nil { + return nil, WrapError(err) + } + var destinations []*models.Destination + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/destination/search", jsonStr, wc.xPriv, true, &destinations, + ); err != nil { + return nil, err + } + + return destinations, nil +} + +// GetDestinationsCount will get the count of destinations matching the metadata filter +func (wc *WalletClient) GetDestinationsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + }) + if err != nil { + return 0, WrapError(err) + } + + var count int64 + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/destination/count", jsonStr, wc.xPriv, true, &count, + ); err != nil { + return 0, err + } + + return count, nil +} + +// NewDestination will create a new destination and return it +func (wc *WalletClient) NewDestination(ctx context.Context, metadata *models.Metadata) (*models.Destination, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldMetadata: processMetadata(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 *models.Metadata, +) (*models.Destination, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldID: id, + FieldMetadata: processMetadata(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 *models.Metadata, +) (*models.Destination, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldAddress: address, + FieldMetadata: processMetadata(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 *models.Metadata, +) (*models.Destination, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldLockingScript: lockingScript, + FieldMetadata: processMetadata(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, ResponseError) { + 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 map[string]interface{}, metadataConditions *models.Metadata, queryParams *QueryParams) ([]*models.Transaction, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadataConditions), + FieldQueryParams: queryParams, + }) + if err != nil { + return nil, WrapError(err) + } + + var transactions []*models.Transaction + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/transaction/search", jsonStr, wc.xPriv, wc.signRequest, &transactions, + ); err != nil { + return nil, err + } + + return transactions, nil +} + +// GetTransactionsCount get number of user transactions +func (wc *WalletClient) GetTransactionsCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + }) + if err != nil { + return 0, WrapError(err) + } + + var count int64 + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/transaction/count", jsonStr, wc.xPriv, wc.signRequest, &count, + ); err != nil { + return 0, err + } + + return count, nil +} + +// DraftToRecipients is a draft transaction to a slice of recipients +func (wc *WalletClient) DraftToRecipients(ctx context.Context, recipients []*Recipients, + metadata *models.Metadata, +) (*models.DraftTransaction, ResponseError) { + 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: processMetadata(metadata), + }) +} + +// DraftTransaction is a draft transaction +func (wc *WalletClient) DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, + metadata *models.Metadata, +) (*models.DraftTransaction, ResponseError) { + return wc.createDraftTransaction(ctx, map[string]interface{}{ + FieldConfig: transactionConfig, + FieldMetadata: processMetadata(metadata), + }) +} + +// createDraftTransaction will create a draft transaction +func (wc *WalletClient) createDraftTransaction(ctx context.Context, + jsonData map[string]interface{}, +) (*models.DraftTransaction, ResponseError) { + 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, WrapError(apierrors.ErrDraftNotFound) + } + + return draftTransaction, nil +} + +// RecordTransaction will record a transaction +func (wc *WalletClient) RecordTransaction(ctx context.Context, hex, referenceID string, + metadata *models.Metadata, +) (*models.Transaction, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldHex: hex, + FieldReferenceID: referenceID, + FieldMetadata: processMetadata(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 *models.Metadata, +) (*models.Transaction, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldID: txID, + FieldMetadata: processMetadata(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) ResponseError { + // Create the signature + authData, err := createSignatureAccessKey(privateKeyHex, bodyString) + if err != nil { + return WrapError(err) + } + + // Set the auth header + header.Set(models.AuthAccessKey, authData.AccessKey) + + return setSignatureHeaders(header, authData) +} + +// GetUtxo will get a utxo by transaction ID +func (wc *WalletClient) GetUtxo(ctx context.Context, txID string, outputIndex uint32) (*models.Utxo, ResponseError) { + 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 map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Utxo, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + FieldQueryParams: queryParams, + }) + if err != nil { + return nil, WrapError(err) + } + + var utxos []*models.Utxo + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/utxo/search", jsonStr, wc.xPriv, wc.signRequest, &utxos, + ); err != nil { + return nil, err + } + + return utxos, nil +} + +// GetUtxosCount will get the count of utxos filtered by conditions and metadata +func (wc *WalletClient) GetUtxosCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + }) + if err != nil { + return 0, WrapError(err) + } + + var count int64 + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/utxo/count", jsonStr, wc.xPriv, wc.signRequest, &count, + ); err != nil { + return 0, err + } + + return count, nil +} + +// 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 = apierrors.ErrMissingAccessKey + return + } + + var privateKey *bec.PrivateKey + if privateKey, err = bitcoin.PrivateKeyFromString( + privateKeyHex, + ); err != nil { + return + } + publicKey := privateKey.PubKey() + + // Get the AccessKey + payload = new(models.AuthPayload) + payload.AccessKey = hex.EncodeToString(publicKey.SerialiseCompressed()) + + // 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{}, +) ResponseError { + 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) ResponseError { + if sign { + if err := addSignature(&req.Header, xPriv, string(rawJSON)); err != nil { + return err + } + } else { + var xPub string + xPub, err := bitcoin.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) ResponseError { + return SetSignatureFromAccessKey(&req.Header, hex.EncodeToString(wc.accessKey.Serialise()), string(rawJSON)) +} + +// AcceptContact will accept the contact associated with the paymail +func (wc *WalletClient) AcceptContact(ctx context.Context, paymail string) ResponseError { + 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) ResponseError { + 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) ResponseError { + isTotpValid, err := wc.ValidateTotpForContact(contact, passcode, requesterPaymail, period, digits) + if err != nil { + return WrapError(fmt.Errorf("totp validation failed: %w", err)) + } + + if !isTotpValid { + return WrapError(errors.New("totp is invalid")) + } + + 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 map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + FieldQueryParams: queryParams, + }) + if err != nil { + return nil, WrapError(err) + } + + var result []*models.Contact + if err := wc.doHTTPRequest( + ctx, http.MethodPost, "/contact/search", jsonStr, wc.xPriv, wc.signRequest, &result, + ); err != nil { + return nil, err + } + + return result, nil +} + +// 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 string, metadata *models.Metadata) (*models.Contact, ResponseError) { + return wc.UpsertContactForPaymail(ctx, paymail, fullName, metadata, "") +} + +// 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 *models.Metadata, requesterPaymail string) (*models.Contact, ResponseError) { + payload := map[string]interface{}{ + "fullName": fullName, + FieldMetadata: processMetadata(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 +} + +// AdminNewXpub will register an xPub +func (wc *WalletClient) AdminNewXpub(ctx context.Context, rawXPub string, metadata *models.Metadata) ResponseError { + // 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: processMetadata(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, ResponseError) { + 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, ResponseError) { + 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 map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.AccessKey, ResponseError) { + var models []*models.AccessKey + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/access-keys/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetAccessKeysCount get a count of all the access keys filtered by conditions +func (wc *WalletClient) AdminGetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/access-keys/count") +} + +// AdminGetBlockHeaders get all block headers filtered by conditions +func (wc *WalletClient) AdminGetBlockHeaders(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.BlockHeader, ResponseError) { + 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 *models.Metadata, +) (int64, ResponseError) { + 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 map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.Destination, ResponseError) { + var models []*models.Destination + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/destinations/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetDestinationsCount get a count of all the destinations filtered by conditions +func (wc *WalletClient) AdminGetDestinationsCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/destinations/count") +} + +// AdminGetPaymail get a paymail by address +func (wc *WalletClient) AdminGetPaymail(ctx context.Context, address string) (*models.PaymailAddress, ResponseError) { + 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 map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.PaymailAddress, ResponseError) { + var models []*models.PaymailAddress + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/paymails/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetPaymailsCount get a count of all the paymails filtered by conditions +func (wc *WalletClient) AdminGetPaymailsCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/paymails/count") +} + +// 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, ResponseError) { + 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) ResponseError { + 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 map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.Transaction, ResponseError) { + var models []*models.Transaction + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/transactions/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetTransactionsCount get a count of all the transactions filtered by conditions +func (wc *WalletClient) AdminGetTransactionsCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/transactions/count") +} + +// AdminGetUtxos get all block utxos filtered by conditions +func (wc *WalletClient) AdminGetUtxos(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.Utxo, ResponseError) { + var models []*models.Utxo + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/utxos/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetUtxosCount get a count of all the utxos filtered by conditions +func (wc *WalletClient) AdminGetUtxosCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/utxos/count") +} + +// AdminGetXPubs get all block xpubs filtered by conditions +func (wc *WalletClient) AdminGetXPubs(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, +) ([]*models.Xpub, ResponseError) { + var models []*models.Xpub + if err := wc.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/xpubs/search", &models); err != nil { + return nil, err + } + + return models, nil +} + +// AdminGetXPubsCount get a count of all the xpubs filtered by conditions +func (wc *WalletClient) AdminGetXPubsCount(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, +) (int64, ResponseError) { + return wc.adminCount(ctx, conditions, metadata, "/admin/xpubs/count") +} + +func (wc *WalletClient) adminGetModels(ctx context.Context, conditions map[string]interface{}, + metadata *models.Metadata, queryParams *QueryParams, path string, models interface{}, +) ResponseError { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(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 *models.Metadata, path string) (int64, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(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, ResponseError) { + 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 +} + +// AdminGetSharedConfig gets the shared config +func (wc *WalletClient) AdminGetSharedConfig(ctx context.Context) (*models.SharedConfig, ResponseError) { + var model *models.SharedConfig + if err := wc.doHTTPRequest( + ctx, http.MethodGet, "/admin/shared-config", nil, wc.adminXPriv, true, &model, + ); err != nil { + return nil, err + } + + return model, 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 map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + FieldConditions: conditions, + FieldMetadata: processMetadata(metadata), + FieldQueryParams: queryParams, + }) + if err != nil { + return nil, WrapError(err) + } + + var contacts []*models.Contact + err = wc.doHTTPRequest(ctx, http.MethodPost, "/admin/contact/search", jsonStr, wc.adminXPriv, true, &contacts) + return contacts, WrapError(err) +} + +// 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 *models.Metadata) (*models.Contact, ResponseError) { + jsonStr, err := json.Marshal(map[string]interface{}{ + "fullName": fullName, + FieldMetadata: processMetadata(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) ResponseError { + 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, ResponseError) { + 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, ResponseError) { + 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, ResponseError) { + 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 *models.Metadata) (*models.Transaction, ResponseError) { + 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) +} diff --git a/totp.go b/totp.go index eac2cee..336f6d6 100644 --- a/totp.go +++ b/totp.go @@ -16,6 +16,7 @@ import ( "github.com/pquerna/otp/totp" ) +// ErrClientInitNoXpriv error per init client with first xpriv key var ErrClientInitNoXpriv = errors.New("init client with xPriv first") const ( diff --git a/totp_test.go b/totp_test.go index 0eccd6e..e6be2e4 100644 --- a/totp_test.go +++ b/totp_test.go @@ -2,6 +2,9 @@ package walletclient import ( "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" "testing" "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" @@ -14,11 +17,10 @@ import ( func TestGenerateTotpForContact(t *testing.T) { t.Run("success", func(t *testing.T) { // given - sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001")) - require.NoError(t, err) + sut := NewWithXPriv("localhost:3001", fixtures.XPrivString) + require.NotNil(t, sut.xPriv) contact := models.Contact{PubKey: fixtures.PubKey} - // when pass, err := sut.GenerateTotpForContact(&contact, 30, 2) @@ -29,11 +31,10 @@ func TestGenerateTotpForContact(t *testing.T) { t.Run("WalletClient without xPriv - returns error", func(t *testing.T) { // given - sut, err := New(WithXPub(fixtures.XPubString), WithHTTP("localhost:3001")) - require.NoError(t, err) - + sut := NewWithXPub("localhost:3001", fixtures.XPubString) + require.NotNil(t, sut.xPub) // when - _, err = sut.GenerateTotpForContact(nil, 30, 2) + _, err := sut.GenerateTotpForContact(nil, 30, 2) // then require.ErrorIs(t, err, ErrClientInitNoXpriv) @@ -41,13 +42,12 @@ func TestGenerateTotpForContact(t *testing.T) { t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { // given - sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001")) - require.NoError(t, err) + sut := NewWithXPriv("localhost:3001", fixtures.XPrivString) + require.NotNil(t, sut.xPriv) contact := models.Contact{PubKey: "invalid-pk-format"} - // when - _, err = sut.GenerateTotpForContact(&contact, 30, 2) + _, err := sut.GenerateTotpForContact(&contact, 30, 2) // then require.ErrorContains(t, err, "contact's PubKey is invalid:") @@ -56,78 +56,67 @@ func TestGenerateTotpForContact(t *testing.T) { } 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) { - // given - clientMaker := func(opts ...ClientOps) (*WalletClient, error) { - allOptions := append(opts, WithHTTP("localhost:3001")) - return New(allOptions...) - } - alice := makeMockUser("alice", clientMaker) - bob := makeMockUser("bob", clientMaker) - - pass, err := alice.client.GenerateTotpForContact(bob.contact, 3600, 2) + aliceKeys, err := xpriv.Generate() + require.NoError(t, err) + bobKeys, err := xpriv.Generate() require.NoError(t, err) - // when - result, err := bob.client.ValidateTotpForContact(alice.contact, pass, bob.paymail, 3600, 2) + // Set up the WalletClient for Alice and Bob + clientAlice := NewWithXPriv(serverURL, aliceKeys.XPriv()) + require.NotNil(t, clientAlice.xPriv) + clientBob := NewWithXPriv(serverURL, bobKeys.XPriv()) + require.NotNil(t, clientBob.xPriv) - // then + 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("WalletClient without xPriv - returns error", func(t *testing.T) { - // given - sut, err := New(WithXPub(fixtures.XPubString), WithHTTP("localhost:3001")) - require.NoError(t, err) - - // when - _, err = sut.ValidateTotpForContact(nil, "", fixtures.PaymailAddress, 30, 2) - - // then - require.ErrorIs(t, err, ErrClientInitNoXpriv) + client := NewWithXPub(serverURL, "invalid_xpub") + require.Nil(t, client.xPub) }) t.Run("contact has invalid PubKey - returns error", func(t *testing.T) { - // given - sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001")) - require.NoError(t, err) + sut := NewWithXPriv(serverURL, fixtures.XPrivString) - contact := models.Contact{PubKey: "invalid-pk-format"} - - // when - _, err = sut.ValidateTotpForContact(&contact, "", fixtures.PaymailAddress, 30, 2) - - // then - require.ErrorContains(t, err, "contact's PubKey is invalid:") + 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") }) } -type mockUser struct { - contact *models.Contact - client *WalletClient - paymail string -} - -func makeMockUser(name string, clientMaker func(opts ...ClientOps) (*WalletClient, error)) mockUser { - keys, _ := xpriv.Generate() - paymail := name + "@example.com" - client, _ := clientMaker(WithXPriv(keys.XPriv())) - pki := makeMockPKI(keys.XPub().String()) - contact := models.Contact{PubKey: pki, Paymail: paymail} - return mockUser{ - contact: &contact, - client: client, - paymail: paymail, - } -} - func makeMockPKI(xpub string) string { xPub, _ := bip32.NewKeyFromString(xpub) - magicNumberOfInheritance := 3 //2+1; 2: because of the way spv-wallet stores xpubs in db; 1: to make a PKI var err error - for i := 0; i < magicNumberOfInheritance; i++ { + 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) diff --git a/transactions.go b/transactions.go deleted file mode 100644 index a4e256f..0000000 --- a/transactions.go +++ /dev/null @@ -1,82 +0,0 @@ -package walletclient - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// GetTransaction get a transaction by id -func (b *WalletClient) GetTransaction(ctx context.Context, txID string) (*models.Transaction, transports.ResponseError) { - return b.transport.GetTransaction(ctx, txID) -} - -// GetTransactions get all transactions matching search criteria -func (b *WalletClient) GetTransactions(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *transports.QueryParams, -) ([]*models.Transaction, transports.ResponseError) { - return b.transport.GetTransactions(ctx, conditions, metadata, queryParams) -} - -// GetTransactionsCount get number of user transactions -func (b *WalletClient) GetTransactionsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, transports.ResponseError) { - return b.transport.GetTransactionsCount(ctx, conditions, metadata) -} - -// DraftToRecipients initialize a new P2PKH draft transaction to a list of recipients -func (b *WalletClient) DraftToRecipients(ctx context.Context, recipients []*transports.Recipients, - metadata *models.Metadata, -) (*models.DraftTransaction, transports.ResponseError) { - return b.transport.DraftToRecipients(ctx, recipients, metadata) -} - -// DraftTransaction initialize a new draft transaction -func (b *WalletClient) DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, - metadata *models.Metadata, -) (*models.DraftTransaction, transports.ResponseError) { - return b.transport.DraftTransaction(ctx, transactionConfig, metadata) -} - -// RecordTransaction record a new transaction -func (b *WalletClient) RecordTransaction(ctx context.Context, hex, draftID string, - metadata *models.Metadata, -) (*models.Transaction, transports.ResponseError) { - return b.transport.RecordTransaction(ctx, hex, draftID, metadata) -} - -// UpdateTransactionMetadata update the metadata of a transaction -func (b *WalletClient) UpdateTransactionMetadata(ctx context.Context, txID string, - metadata *models.Metadata, -) (*models.Transaction, transports.ResponseError) { - return b.transport.UpdateTransactionMetadata(ctx, txID, metadata) -} - -// FinalizeTransaction will finalize the transaction -func (b *WalletClient) FinalizeTransaction(draft *models.DraftTransaction) (string, transports.ResponseError) { - res, err := transports.GetSignedHex(draft, b.xPriv) - if err != nil { - return "", transports.WrapError(err) - } - - return res, nil -} - -// SendToRecipients send to recipients -func (b *WalletClient) SendToRecipients(ctx context.Context, recipients []*transports.Recipients, - metadata *models.Metadata, -) (*models.Transaction, transports.ResponseError) { - draft, err := b.DraftToRecipients(ctx, recipients, metadata) - if err != nil { - return nil, err - } - - var hex string - if hex, err = b.FinalizeTransaction(draft); err != nil { - return nil, err - } - - return b.RecordTransaction(ctx, hex, draft.ID, metadata) -} diff --git a/transactions_test.go b/transactions_test.go index e919007..77174cb 100644 --- a/transactions_test.go +++ b/transactions_test.go @@ -2,324 +2,114 @@ 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/libsv/go-bt/v2" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet-go-client/transports" ) -// TestTransactions will test the Transaction methods func TestTransactions(t *testing.T) { - t.Run("GetTransaction", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction", - Result: fixtures.MarshallForTestHandler(fixtures.Transaction), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, + 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) } - client := getTestWalletClient(transportHandler, false) + })) + defer server.Close() - // when - tx, err := client.GetTransaction(context.Background(), fixtures.Transaction.ID) + client := NewWithXPriv(server.URL, fixtures.XPrivString) + require.NotNil(t, client.xPriv) - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Transaction, tx) + 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) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction/search", - Result: fixtures.MarshallForTestHandler([]*models.Transaction{fixtures.Transaction}), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) conditions := map[string]interface{}{ - "fee": map[string]interface{}{ - "$lt": 100, - }, - "total_value": map[string]interface{}{ - "$lt": 740, - }, + "fee": map[string]interface{}{"$lt": 100}, + "total_value": map[string]interface{}{"$lt": 740}, } - - // when txs, err := client.GetTransactions(context.Background(), conditions, fixtures.TestMetadata, nil) - - // then - assert.NoError(t, err) - assert.Equal(t, []*models.Transaction{fixtures.Transaction}, txs) + require.NoError(t, err) + require.Equal(t, []*models.Transaction{fixtures.Transaction}, txs) }) t.Run("GetTransactionsCount", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction/count", - Result: "1", - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) - conditions := map[string]interface{}{ - "fee": map[string]interface{}{ - "$lt": 100, - }, - "total_value": map[string]interface{}{ - "$lt": 740, - }, - } - - // when - count, err := client.GetTransactionsCount(context.Background(), conditions, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, int64(1), count) + 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) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction/record", - Result: fixtures.MarshallForTestHandler(fixtures.Transaction), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) - - // when tx, err := client.RecordTransaction(context.Background(), fixtures.Transaction.Hex, "", fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Transaction, tx) + require.NoError(t, err) + require.Equal(t, fixtures.Transaction, tx) }) t.Run("UpdateTransactionMetadata", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction", - Result: fixtures.MarshallForTestHandler(fixtures.Transaction), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) - - // when tx, err := client.UpdateTransactionMetadata(context.Background(), fixtures.Transaction.ID, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Transaction, tx) + require.NoError(t, err) + require.Equal(t, fixtures.Transaction, tx) }) t.Run("SendToRecipients", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Queries: []*testTransportHandlerRequest{ - { - Path: "/transaction/record", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, fixtures.MarshallForTestHandler(fixtures.Transaction)) - }, - }, - { - Path: "/transaction", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, fixtures.MarshallForTestHandler(fixtures.DraftTx)) - }, - }, - }, - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } - client := getTestWalletClient(transportHandler, false) - recipients := []*transports.Recipients{{ + recipients := []*Recipients{{ OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn, Satoshis: 1000, To: fixtures.Destination.Address, }} - - // when tx, err := client.SendToRecipients(context.Background(), recipients, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Transaction, tx) + require.NoError(t, err) + require.Equal(t, fixtures.Transaction, tx) }) +} - t.Run("SendToRecipients - nil draft", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Queries: []*testTransportHandlerRequest{ - { - Path: "/transaction/record", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, fixtures.MarshallForTestHandler(fixtures.Transaction)) - }, - }, - { - Path: "/transaction", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, "nil") - }, - }, - }, - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, +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) } - client := getTestWalletClient(transportHandler, false) - recipients := []*transports.Recipients{{ - OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn, - Satoshis: 1000, - To: fixtures.Destination.Address, - }} - - // when - tx, err := client.SendToRecipients(context.Background(), recipients, fixtures.TestMetadata) - - // then - assert.Error(t, err) - assert.Nil(t, tx) - }) - - t.Run("SendToRecipients - FinalizeTransaction error", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Queries: []*testTransportHandlerRequest{ - { - Path: "/transaction/record", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, fixtures.MarshallForTestHandler(fixtures.Transaction)) - }, - }, - { - Path: "/transaction", - Result: func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, fixtures.MarshallForTestHandler(models.DraftTransaction{})) - }, - }, - }, - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, + 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 } - client := getTestWalletClient(transportHandler, false) - recipients := []*transports.Recipients{{ - OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn, - Satoshis: 1000, - To: fixtures.Destination.Address, - }} - - // when - tx, err := client.SendToRecipients(context.Background(), recipients, fixtures.TestMetadata) - - // then - assert.Error(t, err) - assert.Nil(t, tx) - }) - - t.Run("FinalizeTransaction", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction/record", - Result: fixtures.MarshallForTestHandler(fixtures.Transaction), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, + response := fixtures.Transaction + if metadata, ok := input["metadata"].(map[string]interface{}); ok { + response.Metadata = metadata } - client := getTestWalletClient(transportHandler, false) - - // when - signedHex, err := client.FinalizeTransaction(fixtures.DraftTx) - - txDraft, btErr := bt.NewTxFromString(signedHex) - require.NoError(t, btErr) - - // then - assert.NoError(t, err) - assert.Len(t, txDraft.Inputs, len(fixtures.DraftTx.Configuration.Inputs)) - assert.Len(t, txDraft.Outputs, len(fixtures.DraftTx.Configuration.Outputs)) - }) - - t.Run("CountUtxos", func(t *testing.T) { - // given - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/utxo/count", - ClientURL: fixtures.ServerURL, - Result: "0", - Client: WithHTTPClient, + if id, ok := input["id"].(string); ok { + response.ID = id } - client := getTestWalletClient(transportHandler, false) - - // when - _, err := client.GetUtxosCount(context.Background(), map[string]interface{}{}, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - }) -} - -// TestDraftTransactions will test the DraftTransaction methods -func TestDraftTransactions(t *testing.T) { - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/transaction", - Result: fixtures.MarshallForTestHandler(fixtures.DraftTx), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, + 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) } - - t.Run("DraftToRecipients", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - recipients := []*transports.Recipients{{ - OpReturn: fixtures.DraftTx.Configuration.Outputs[0].OpReturn, - Satoshis: 1000, - To: fixtures.Destination.Address, - }} - - // when - draft, err := client.DraftToRecipients(context.Background(), recipients, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.DraftTx, draft) - }) - - t.Run("DraftTransaction", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, false) - - // when - draft, err := client.DraftTransaction(context.Background(), &fixtures.DraftTx.Configuration, fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.DraftTx, draft) - }) } diff --git a/transports/client_options.go b/transports/client_options.go deleted file mode 100644 index 73c6f27..0000000 --- a/transports/client_options.go +++ /dev/null @@ -1,90 +0,0 @@ -package transports - -import ( - "net/http" - - "github.com/libsv/go-bk/bec" - "github.com/libsv/go-bk/bip32" -) - -// WithXPriv will set the xPriv -func WithXPriv(xPriv *bip32.ExtendedKey) ClientOps { - return func(c *Client) { - if c != nil { - c.xPriv = xPriv - } - } -} - -// WithXPub will set the xPub -func WithXPub(xPub *bip32.ExtendedKey) ClientOps { - return func(c *Client) { - if c != nil { - c.xPub = xPub - } - } -} - -// WithAccessKey will set the access key -func WithAccessKey(accessKey *bec.PrivateKey) ClientOps { - return func(c *Client) { - if c != nil { - c.accessKey = accessKey - } - } -} - -// WithHTTP will overwrite the default client with a custom client -func WithHTTP(serverURL string) ClientOps { - return func(c *Client) { - if c != nil { - c.transport = NewTransportService(&TransportHTTP{ - server: serverURL, - signRequest: c.signRequest, - adminXPriv: c.adminXPriv, - httpClient: &http.Client{}, - xPriv: c.xPriv, - xPub: c.xPub, - accessKey: c.accessKey, - }) - } - } -} - -// WithHTTPClient will overwrite the default client with a custom client -func WithHTTPClient(serverURL string, httpClient *http.Client) ClientOps { - return func(c *Client) { - if c != nil { - c.transport = NewTransportService(&TransportHTTP{ - server: serverURL, - signRequest: c.signRequest, - adminXPriv: c.adminXPriv, - httpClient: httpClient, - xPriv: c.xPriv, - xPub: c.xPub, - accessKey: c.accessKey, - }) - } - } -} - -// WithAdminKey will set the admin key for admin requests -func WithAdminKey(adminKey string) ClientOps { - return func(c *Client) { - if c != nil { - c.adminKey = adminKey - } - } -} - -// WithSignRequest will set whether to sign all requests -func WithSignRequest(signRequest bool) ClientOps { - return func(c *Client) { - if c != nil { - c.signRequest = signRequest - if c.transport != nil { - c.transport.SetSignRequest(signRequest) - } - } - } -} diff --git a/transports/http.go b/transports/http.go deleted file mode 100644 index b124a74..0000000 --- a/transports/http.go +++ /dev/null @@ -1,721 +0,0 @@ -package transports - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "strconv" - - "github.com/bitcoin-sv/spv-wallet-go-client/utils" - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoin-sv/spv-wallet/models/apierrors" - "github.com/bitcoinschema/go-bitcoin/v2" - "github.com/libsv/go-bk/bec" - "github.com/libsv/go-bk/bip32" -) - -// TransportHTTP is the struct for HTTP -type TransportHTTP struct { - accessKey *bec.PrivateKey - adminXPriv *bip32.ExtendedKey - httpClient *http.Client - server string - signRequest bool - xPriv *bip32.ExtendedKey - xPub *bip32.ExtendedKey -} - -// Init will initialize -func (h *TransportHTTP) Init() error { - return nil -} - -// SetSignRequest turn the signing of the http request on or off -func (h *TransportHTTP) SetSignRequest(signRequest bool) { - h.signRequest = signRequest -} - -// IsSignRequest return whether to sign all requests -func (h *TransportHTTP) IsSignRequest() bool { - return h.signRequest -} - -// SetAdminKey set the admin key -func (h *TransportHTTP) SetAdminKey(adminKey *bip32.ExtendedKey) { - h.adminXPriv = adminKey -} - -// GetXPub will get the xpub of the current xpub -func (h *TransportHTTP) GetXPub(ctx context.Context) (*models.Xpub, ResponseError) { - var xPub models.Xpub - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/xpub", nil, h.xPriv, true, &xPub, - ); err != nil { - return nil, err - } - - return &xPub, nil -} - -// UpdateXPubMetadata update the metadata of the logged in xpub -func (h *TransportHTTP) UpdateXPubMetadata(ctx context.Context, metadata *models.Metadata) (*models.Xpub, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var xPub models.Xpub - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/xpub", jsonStr, h.xPriv, true, &xPub, - ); err != nil { - return nil, err - } - - return &xPub, nil -} - -// GetAccessKey will get an access key by id -func (h *TransportHTTP) GetAccessKey(ctx context.Context, id string) (*models.AccessKey, ResponseError) { - var accessKey models.AccessKey - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/access-key?"+FieldID+"="+id, nil, h.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// GetAccessKeys will get all access keys matching the metadata filter -func (h *TransportHTTP) GetAccessKeys(ctx context.Context, metadataConditions *models.Metadata) ([]*models.AccessKey, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadataConditions), - }) - if err != nil { - return nil, WrapError(err) - } - var accessKey []*models.AccessKey - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/access-key/search", jsonStr, h.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return accessKey, nil -} - -// GetAccessKeysCount will get the count of access keys -func (h *TransportHTTP) GetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/access-key/count", jsonStr, h.xPriv, true, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// RevokeAccessKey will revoke an access key by id -func (h *TransportHTTP) RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, ResponseError) { - var accessKey models.AccessKey - if err := h.doHTTPRequest( - ctx, http.MethodDelete, "/access-key?"+FieldID+"="+id, nil, h.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// CreateAccessKey will create new access key -func (h *TransportHTTP) CreateAccessKey(ctx context.Context, metadata *models.Metadata) (*models.AccessKey, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - var accessKey models.AccessKey - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/access-key", jsonStr, h.xPriv, true, &accessKey, - ); err != nil { - return nil, err - } - - return &accessKey, nil -} - -// GetDestinationByID will get a destination by id -func (h *TransportHTTP) GetDestinationByID(ctx context.Context, id string) (*models.Destination, ResponseError) { - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/destination?"+FieldID+"="+id, nil, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinationByAddress will get a destination by address -func (h *TransportHTTP) GetDestinationByAddress(ctx context.Context, address string) (*models.Destination, ResponseError) { - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/destination?"+FieldAddress+"="+address, nil, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinationByLockingScript will get a destination by locking script -func (h *TransportHTTP) GetDestinationByLockingScript(ctx context.Context, lockingScript string) (*models.Destination, ResponseError) { - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/destination?"+FieldLockingScript+"="+lockingScript, nil, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetDestinations will get all destinations matching the metadata filter -func (h *TransportHTTP) GetDestinations(ctx context.Context, metadataConditions *models.Metadata) ([]*models.Destination, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadataConditions), - }) - if err != nil { - return nil, WrapError(err) - } - var destinations []*models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/destination/search", jsonStr, h.xPriv, true, &destinations, - ); err != nil { - return nil, err - } - - return destinations, nil -} - -// GetDestinationsCount will get the count of destinations matching the metadata filter -func (h *TransportHTTP) GetDestinationsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/destination/count", jsonStr, h.xPriv, true, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// NewDestination will create a new destination and return it -func (h *TransportHTTP) NewDestination(ctx context.Context, metadata *models.Metadata) (*models.Destination, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/destination", jsonStr, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByID updates the destination metadata by id -func (h *TransportHTTP) UpdateDestinationMetadataByID(ctx context.Context, id string, - metadata *models.Metadata, -) (*models.Destination, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldID: id, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByAddress updates the destination metadata by address -func (h *TransportHTTP) UpdateDestinationMetadataByAddress(ctx context.Context, address string, - metadata *models.Metadata, -) (*models.Destination, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// UpdateDestinationMetadataByLockingScript updates the destination metadata by locking script -func (h *TransportHTTP) UpdateDestinationMetadataByLockingScript(ctx context.Context, lockingScript string, - metadata *models.Metadata, -) (*models.Destination, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldLockingScript: lockingScript, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var destination models.Destination - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/destination", jsonStr, h.xPriv, true, &destination, - ); err != nil { - return nil, err - } - - return &destination, nil -} - -// GetTransaction will get a transaction by ID -func (h *TransportHTTP) GetTransaction(ctx context.Context, txID string) (*models.Transaction, ResponseError) { - var transaction models.Transaction - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/transaction?"+FieldID+"="+txID, nil, h.xPriv, h.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// GetTransactions will get transactions by conditions -func (h *TransportHTTP) GetTransactions(ctx context.Context, conditions map[string]interface{}, - metadataConditions *models.Metadata, queryParams *QueryParams, -) ([]*models.Transaction, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadataConditions), - FieldQueryParams: queryParams, - }) - if err != nil { - return nil, WrapError(err) - } - - var transactions []*models.Transaction - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/transaction/search", jsonStr, h.xPriv, h.signRequest, &transactions, - ); err != nil { - return nil, err - } - - return transactions, nil -} - -// GetTransactionsCount get number of user transactions -func (h *TransportHTTP) GetTransactionsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/transaction/count", jsonStr, h.xPriv, h.signRequest, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// DraftToRecipients is a draft transaction to a slice of recipients -func (h *TransportHTTP) DraftToRecipients(ctx context.Context, recipients []*Recipients, - metadata *models.Metadata, -) (*models.DraftTransaction, ResponseError) { - 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 h.createDraftTransaction(ctx, map[string]interface{}{ - FieldConfig: map[string]interface{}{ - FieldOutputs: outputs, - }, - FieldMetadata: processMetadata(metadata), - }) -} - -// DraftTransaction is a draft transaction -func (h *TransportHTTP) DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, - metadata *models.Metadata, -) (*models.DraftTransaction, ResponseError) { - return h.createDraftTransaction(ctx, map[string]interface{}{ - FieldConfig: transactionConfig, - FieldMetadata: processMetadata(metadata), - }) -} - -// createDraftTransaction will create a draft transaction -func (h *TransportHTTP) createDraftTransaction(ctx context.Context, - jsonData map[string]interface{}, -) (*models.DraftTransaction, ResponseError) { - jsonStr, err := json.Marshal(jsonData) - if err != nil { - return nil, WrapError(err) - } - - var draftTransaction *models.DraftTransaction - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/transaction", jsonStr, h.xPriv, true, &draftTransaction, - ); err != nil { - return nil, err - } - if draftTransaction == nil { - return nil, WrapError(apierrors.ErrDraftNotFound) - } - - return draftTransaction, nil -} - -// RecordTransaction will record a transaction -func (h *TransportHTTP) RecordTransaction(ctx context.Context, hex, referenceID string, - metadata *models.Metadata, -) (*models.Transaction, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldHex: hex, - FieldReferenceID: referenceID, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/transaction/record", jsonStr, h.xPriv, h.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// UpdateTransactionMetadata update the metadata of a transaction -func (h *TransportHTTP) UpdateTransactionMetadata(ctx context.Context, txID string, - metadata *models.Metadata, -) (*models.Transaction, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldID: txID, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/transaction", jsonStr, h.xPriv, h.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) ResponseError { - // Create the signature - authData, err := createSignatureAccessKey(privateKeyHex, bodyString) - if err != nil { - return WrapError(err) - } - - // Set the auth header - header.Set(models.AuthAccessKey, authData.AccessKey) - - return setSignatureHeaders(header, authData) -} - -// GetUtxo will get a utxo by transaction ID -func (h *TransportHTTP) GetUtxo(ctx context.Context, txID string, outputIndex uint32) (*models.Utxo, ResponseError) { - outputIndexStr := strconv.FormatUint(uint64(outputIndex), 10) - - url := fmt.Sprintf("/utxo?%s=%s&%s=%s", FieldTransactionID, txID, FieldOutputIndex, outputIndexStr) - - var utxo models.Utxo - if err := h.doHTTPRequest( - ctx, http.MethodGet, url, nil, h.xPriv, true, &utxo, - ); err != nil { - return nil, err - } - - return &utxo, nil -} - -// GetUtxos will get a list of utxos filtered by conditions and metadata -func (h *TransportHTTP) GetUtxos(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Utxo, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - FieldQueryParams: queryParams, - }) - if err != nil { - return nil, WrapError(err) - } - - var utxos []*models.Utxo - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/utxo/search", jsonStr, h.xPriv, h.signRequest, &utxos, - ); err != nil { - return nil, err - } - - return utxos, nil -} - -// GetUtxosCount will get the count of utxos filtered by conditions and metadata -func (h *TransportHTTP) GetUtxosCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/utxo/count", jsonStr, h.xPriv, h.signRequest, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// 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 = apierrors.ErrMissingAccessKey - return - } - - var privateKey *bec.PrivateKey - if privateKey, err = bitcoin.PrivateKeyFromString( - privateKeyHex, - ); err != nil { - return - } - publicKey := privateKey.PubKey() - - // Get the xPub - payload = new(models.AuthPayload) - payload.AccessKey = hex.EncodeToString(publicKey.SerialiseCompressed()) - - // 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 (h *TransportHTTP) doHTTPRequest(ctx context.Context, method string, path string, - rawJSON []byte, xPriv *bip32.ExtendedKey, sign bool, responseJSON interface{}, -) ResponseError { - req, err := http.NewRequestWithContext(ctx, method, h.server+path, bytes.NewBuffer(rawJSON)) - if err != nil { - return WrapError(err) - } - req.Header.Set("Content-Type", "application/json") - - if xPriv != nil { - err := h.authenticateWithXpriv(sign, req, xPriv, rawJSON) - if err != nil { - return err - } - } else { - err := h.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 = h.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 (h *TransportHTTP) authenticateWithXpriv(sign bool, req *http.Request, xPriv *bip32.ExtendedKey, rawJSON []byte) ResponseError { - if sign { - if err := addSignature(&req.Header, xPriv, string(rawJSON)); err != nil { - return err - } - } else { - var xPub string - xPub, err := bitcoin.GetExtendedPublicKey(xPriv) - if err != nil { - return WrapError(err) - } - req.Header.Set(models.AuthHeader, xPub) - req.Header.Set("", xPub) - } - return nil -} - -func (h *TransportHTTP) authenticateWithAccessKey(req *http.Request, rawJSON []byte) ResponseError { - return SetSignatureFromAccessKey(&req.Header, hex.EncodeToString(h.accessKey.Serialise()), string(rawJSON)) -} - -// AcceptContact will accept the contact associated with the paymail -func (h *TransportHTTP) AcceptContact(ctx context.Context, paymail string) ResponseError { - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/contact/accepted/"+paymail, nil, h.xPriv, h.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// RejectContact will reject the contact associated with the paymail -func (h *TransportHTTP) RejectContact(ctx context.Context, paymail string) ResponseError { - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/contact/rejected/"+paymail, nil, h.xPriv, h.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// ConfirmContact will confirm the contact associated with the paymail -func (h *TransportHTTP) ConfirmContact(ctx context.Context, paymail string) ResponseError { - if err := h.doHTTPRequest( - ctx, http.MethodPatch, "/contact/confirmed/"+paymail, nil, h.xPriv, h.signRequest, nil, - ); err != nil { - return err - } - - return nil -} - -// GetContacts will get contacts by conditions -func (h *TransportHTTP) GetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - FieldQueryParams: queryParams, - }) - if err != nil { - return nil, WrapError(err) - } - - var result []*models.Contact - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/contact/search", jsonStr, h.xPriv, h.signRequest, &result, - ); err != nil { - return nil, err - } - - return result, nil -} - -// 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 (h *TransportHTTP) UpsertContact(ctx context.Context, paymail, fullName string, metadata *models.Metadata, requesterPaymail string) (*models.Contact, ResponseError) { - payload := map[string]interface{}{ - "fullName": fullName, - FieldMetadata: processMetadata(metadata), - } - - if requesterPaymail != "" { - payload["requesterPaymail"] = requesterPaymail - } - - jsonStr, err := json.Marshal(payload) - if err != nil { - return nil, WrapError(err) - } - - var result models.Contact - if err := h.doHTTPRequest( - ctx, http.MethodPut, "/contact/"+paymail, jsonStr, h.xPriv, h.signRequest, &result, - ); err != nil { - return nil, err - } - - return &result, nil -} diff --git a/transports/http_admin.go b/transports/http_admin.go deleted file mode 100644 index 02ab6b1..0000000 --- a/transports/http_admin.go +++ /dev/null @@ -1,369 +0,0 @@ -package transports - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/bitcoin-sv/spv-wallet/models" -) - -// AdminNewXpub will register an xPub -func (h *TransportHTTP) AdminNewXpub(ctx context.Context, rawXPub string, metadata *models.Metadata) ResponseError { - // Adding a xpub needs to be signed by an admin key - if h.adminXPriv == nil { - return WrapError(ErrAdminKey) - } - - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldMetadata: processMetadata(metadata), - FieldXpubKey: rawXPub, - }) - if err != nil { - return WrapError(err) - } - - var xPubData models.Xpub - - return h.doHTTPRequest( - ctx, http.MethodPost, "/admin/xpub", jsonStr, h.adminXPriv, true, &xPubData, - ) -} - -// AdminGetStatus get whether admin key is valid -func (h *TransportHTTP) AdminGetStatus(ctx context.Context) (bool, ResponseError) { - var status bool - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/admin/status", nil, h.xPriv, true, &status, - ); err != nil { - return false, err - } - - return status, nil -} - -// AdminGetStats get admin stats -func (h *TransportHTTP) AdminGetStats(ctx context.Context) (*models.AdminStats, ResponseError) { - var stats *models.AdminStats - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/admin/stats", nil, h.xPriv, true, &stats, - ); err != nil { - return nil, err - } - - return stats, nil -} - -// AdminGetAccessKeys get all access keys filtered by conditions -func (h *TransportHTTP) AdminGetAccessKeys(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.AccessKey, ResponseError) { - var models []*models.AccessKey - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/access-keys/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetAccessKeysCount get a count of all the access keys filtered by conditions -func (h *TransportHTTP) AdminGetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/access-keys/count") -} - -// AdminGetBlockHeaders get all block headers filtered by conditions -func (h *TransportHTTP) AdminGetBlockHeaders(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.BlockHeader, ResponseError) { - var models []*models.BlockHeader - if err := h.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 (h *TransportHTTP) AdminGetBlockHeadersCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/block-headers/count") -} - -// AdminGetDestinations get all block destinations filtered by conditions -func (h *TransportHTTP) AdminGetDestinations(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.Destination, ResponseError) { - var models []*models.Destination - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/destinations/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetDestinationsCount get a count of all the destinations filtered by conditions -func (h *TransportHTTP) AdminGetDestinationsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/destinations/count") -} - -// AdminGetPaymail get a paymail by address -func (h *TransportHTTP) AdminGetPaymail(ctx context.Context, address string) (*models.PaymailAddress, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - }) - if err != nil { - return nil, WrapError(err) - } - - var model *models.PaymailAddress - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/admin/paymail/get", jsonStr, h.xPriv, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminGetPaymails get all block paymails filtered by conditions -func (h *TransportHTTP) AdminGetPaymails(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.PaymailAddress, ResponseError) { - var models []*models.PaymailAddress - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/paymails/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetPaymailsCount get a count of all the paymails filtered by conditions -func (h *TransportHTTP) AdminGetPaymailsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/paymails/count") -} - -// AdminCreatePaymail create a new paymail for a xpub -func (h *TransportHTTP) AdminCreatePaymail(ctx context.Context, rawXPub string, address string, publicName string, avatar string) (*models.PaymailAddress, ResponseError) { - 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 := h.doHTTPRequest( - ctx, http.MethodPost, "/admin/paymail/create", jsonStr, h.xPriv, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminDeletePaymail delete a paymail address from the database -func (h *TransportHTTP) AdminDeletePaymail(ctx context.Context, address string) ResponseError { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldAddress: address, - }) - if err != nil { - return WrapError(err) - } - - if err := h.doHTTPRequest( - ctx, http.MethodDelete, "/admin/paymail/delete", jsonStr, h.xPriv, true, nil, - ); err != nil { - return err - } - - return nil -} - -// AdminGetTransactions get all block transactions filtered by conditions -func (h *TransportHTTP) AdminGetTransactions(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.Transaction, ResponseError) { - var models []*models.Transaction - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/transactions/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetTransactionsCount get a count of all the transactions filtered by conditions -func (h *TransportHTTP) AdminGetTransactionsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/transactions/count") -} - -// AdminGetUtxos get all block utxos filtered by conditions -func (h *TransportHTTP) AdminGetUtxos(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.Utxo, ResponseError) { - var models []*models.Utxo - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/utxos/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetUtxosCount get a count of all the utxos filtered by conditions -func (h *TransportHTTP) AdminGetUtxosCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/utxos/count") -} - -// AdminGetXPubs get all block xpubs filtered by conditions -func (h *TransportHTTP) AdminGetXPubs(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, -) ([]*models.Xpub, ResponseError) { - var models []*models.Xpub - if err := h.adminGetModels(ctx, conditions, metadata, queryParams, "/admin/xpubs/search", &models); err != nil { - return nil, err - } - - return models, nil -} - -// AdminGetXPubsCount get a count of all the xpubs filtered by conditions -func (h *TransportHTTP) AdminGetXPubsCount(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, -) (int64, ResponseError) { - return h.adminCount(ctx, conditions, metadata, "/admin/xpubs/count") -} - -func (h *TransportHTTP) adminGetModels(ctx context.Context, conditions map[string]interface{}, - metadata *models.Metadata, queryParams *QueryParams, path string, models interface{}, -) ResponseError { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - FieldQueryParams: queryParams, - }) - if err != nil { - return WrapError(err) - } - - if err := h.doHTTPRequest( - ctx, http.MethodPost, path, jsonStr, h.xPriv, true, &models, - ); err != nil { - return err - } - - return nil -} - -func (h *TransportHTTP) adminCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, path string) (int64, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return 0, WrapError(err) - } - - var count int64 - if err := h.doHTTPRequest( - ctx, http.MethodPost, path, jsonStr, h.xPriv, true, &count, - ); err != nil { - return 0, err - } - - return count, nil -} - -// AdminRecordTransaction will record a transaction as an admin -func (h *TransportHTTP) AdminRecordTransaction(ctx context.Context, hex string) (*models.Transaction, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldHex: hex, - }) - if err != nil { - return nil, WrapError(err) - } - - var transaction models.Transaction - if err := h.doHTTPRequest( - ctx, http.MethodPost, "/admin/transactions/record", jsonStr, h.xPriv, h.signRequest, &transaction, - ); err != nil { - return nil, err - } - - return &transaction, nil -} - -// AdminGetSharedConfig gets the shared config -func (h *TransportHTTP) AdminGetSharedConfig(ctx context.Context) (*models.SharedConfig, ResponseError) { - var model *models.SharedConfig - if err := h.doHTTPRequest( - ctx, http.MethodGet, "/admin/shared-config", nil, h.xPriv, true, &model, - ); err != nil { - return nil, err - } - - return model, nil -} - -// AdminGetContacts executes an HTTP POST request to search for contacts based on specified conditions, metadata, and query parameters. -func (h *TransportHTTP) AdminGetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - FieldConditions: conditions, - FieldMetadata: processMetadata(metadata), - FieldQueryParams: queryParams, - }) - if err != nil { - return nil, WrapError(err) - } - - var contacts []*models.Contact - err = h.doHTTPRequest(ctx, http.MethodPost, "/admin/contact/search", jsonStr, h.adminXPriv, true, &contacts) - return contacts, WrapError(err) -} - -// AdminUpdateContact executes an HTTP PATCH request to update a specific contact's full name using their ID. -func (h *TransportHTTP) AdminUpdateContact(ctx context.Context, id, fullName string, metadata *models.Metadata) (*models.Contact, ResponseError) { - jsonStr, err := json.Marshal(map[string]interface{}{ - "fullName": fullName, - FieldMetadata: processMetadata(metadata), - }) - if err != nil { - return nil, WrapError(err) - } - var contact models.Contact - err = h.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/%s", id), jsonStr, h.adminXPriv, true, &contact) - return &contact, WrapError(err) -} - -// AdminDeleteContact executes an HTTP DELETE request to remove a contact using their ID. -func (h *TransportHTTP) AdminDeleteContact(ctx context.Context, id string) ResponseError { - err := h.doHTTPRequest(ctx, http.MethodDelete, fmt.Sprintf("/admin/contact/%s", id), nil, h.adminXPriv, true, nil) - return WrapError(err) -} - -// AdminAcceptContact executes an HTTP PATCH request to mark a contact as accepted using their ID. -func (h *TransportHTTP) AdminAcceptContact(ctx context.Context, id string) (*models.Contact, ResponseError) { - var contact models.Contact - err := h.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/accepted/%s", id), nil, h.adminXPriv, true, &contact) - return &contact, WrapError(err) -} - -// AdminRejectContact executes an HTTP PATCH request to mark a contact as rejected using their ID. -func (h *TransportHTTP) AdminRejectContact(ctx context.Context, id string) (*models.Contact, ResponseError) { - var contact models.Contact - err := h.doHTTPRequest(ctx, http.MethodPatch, fmt.Sprintf("/admin/contact/rejected/%s", id), nil, h.adminXPriv, true, &contact) - return &contact, WrapError(err) -} diff --git a/transports/interface.go b/transports/interface.go deleted file mode 100644 index a7ceeea..0000000 --- a/transports/interface.go +++ /dev/null @@ -1,104 +0,0 @@ -package transports - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/libsv/go-bk/bip32" -) - -// XpubService is the xPub related requests -type XpubService interface { - GetXPub(ctx context.Context) (*models.Xpub, ResponseError) - UpdateXPubMetadata(ctx context.Context, metadata *models.Metadata) (*models.Xpub, ResponseError) -} - -// AccessKeyService is the access key related requests -type AccessKeyService interface { - CreateAccessKey(ctx context.Context, metadata *models.Metadata) (*models.AccessKey, ResponseError) - GetAccessKey(ctx context.Context, id string) (*models.AccessKey, ResponseError) - GetAccessKeys(ctx context.Context, metadataConditions *models.Metadata) ([]*models.AccessKey, ResponseError) - GetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - RevokeAccessKey(ctx context.Context, id string) (*models.AccessKey, ResponseError) -} - -// DestinationService is the destination related requests -type DestinationService interface { - GetDestinationByAddress(ctx context.Context, address string) (*models.Destination, ResponseError) - GetDestinationByID(ctx context.Context, id string) (*models.Destination, ResponseError) - GetDestinationByLockingScript(ctx context.Context, lockingScript string) (*models.Destination, ResponseError) - GetDestinations(ctx context.Context, metadataConditions *models.Metadata) ([]*models.Destination, ResponseError) - GetDestinationsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - NewDestination(ctx context.Context, metadata *models.Metadata) (*models.Destination, ResponseError) - UpdateDestinationMetadataByAddress(ctx context.Context, lockingScript string, metadata *models.Metadata) (*models.Destination, ResponseError) - UpdateDestinationMetadataByID(ctx context.Context, id string, metadata *models.Metadata) (*models.Destination, ResponseError) - UpdateDestinationMetadataByLockingScript(ctx context.Context, address string, metadata *models.Metadata) (*models.Destination, ResponseError) -} - -// TransactionService is the transaction related requests -type TransactionService interface { - DraftToRecipients(ctx context.Context, recipients []*Recipients, metadata *models.Metadata) (*models.DraftTransaction, ResponseError) - DraftTransaction(ctx context.Context, transactionConfig *models.TransactionConfig, metadata *models.Metadata) (*models.DraftTransaction, ResponseError) - GetTransaction(ctx context.Context, txID string) (*models.Transaction, ResponseError) - GetTransactions(ctx context.Context, conditions map[string]interface{}, metadataConditions *models.Metadata, queryParams *QueryParams) ([]*models.Transaction, ResponseError) - GetTransactionsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - RecordTransaction(ctx context.Context, hex, referenceID string, metadata *models.Metadata) (*models.Transaction, ResponseError) - UpdateTransactionMetadata(ctx context.Context, txID string, metadata *models.Metadata) (*models.Transaction, ResponseError) - GetUtxo(ctx context.Context, txID string, outputIndex uint32) (*models.Utxo, ResponseError) - GetUtxos(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Utxo, ResponseError) - GetUtxosCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) -} - -// ContactService is the contact related requests -type ContactService interface { - AcceptContact(ctx context.Context, paymail string) ResponseError - RejectContact(ctx context.Context, paymail string) ResponseError - ConfirmContact(ctx context.Context, paymail string) ResponseError - GetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) - UpsertContact(ctx context.Context, paymail, fullName string, metadata *models.Metadata, requesterPaymail string) (*models.Contact, ResponseError) -} - -// AdminService is the admin related requests -type AdminService interface { - AdminGetStatus(ctx context.Context) (bool, ResponseError) - AdminGetStats(ctx context.Context) (*models.AdminStats, ResponseError) - AdminGetAccessKeys(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.AccessKey, ResponseError) - AdminGetAccessKeysCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminGetBlockHeaders(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.BlockHeader, ResponseError) - AdminGetBlockHeadersCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminGetDestinations(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Destination, ResponseError) - AdminGetDestinationsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminGetPaymail(ctx context.Context, address string) (*models.PaymailAddress, ResponseError) - AdminGetPaymails(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.PaymailAddress, ResponseError) - AdminGetPaymailsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminCreatePaymail(ctx context.Context, rawXPub string, address string, publicName string, avatar string) (*models.PaymailAddress, ResponseError) - AdminDeletePaymail(ctx context.Context, address string) ResponseError - AdminGetTransactions(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Transaction, ResponseError) - AdminGetTransactionsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminGetUtxos(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Utxo, ResponseError) - AdminGetUtxosCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminNewXpub(ctx context.Context, rawXPub string, metadata *models.Metadata) ResponseError - AdminGetXPubs(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Xpub, ResponseError) - AdminGetXPubsCount(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata) (int64, ResponseError) - AdminRecordTransaction(ctx context.Context, hex string) (*models.Transaction, ResponseError) - AdminGetSharedConfig(ctx context.Context) (*models.SharedConfig, ResponseError) - AdminGetContacts(ctx context.Context, conditions map[string]interface{}, metadata *models.Metadata, queryParams *QueryParams) ([]*models.Contact, ResponseError) - AdminUpdateContact(ctx context.Context, id, fullName string, metadata *models.Metadata) (*models.Contact, ResponseError) - AdminDeleteContact(ctx context.Context, id string) ResponseError - AdminAcceptContact(ctx context.Context, id string) (*models.Contact, ResponseError) - AdminRejectContact(ctx context.Context, id string) (*models.Contact, ResponseError) -} - -// TransportService the transport service interface -type TransportService interface { - AccessKeyService - AdminService - ContactService - DestinationService - TransactionService - XpubService - Init() error - IsSignRequest() bool - SetAdminKey(adminKey *bip32.ExtendedKey) - SetSignRequest(signRequest bool) -} diff --git a/transports/transports.go b/transports/transports.go deleted file mode 100644 index cd94475..0000000 --- a/transports/transports.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package transports encapsulates the different ways to communicate with SPV Wallet -package transports - -import ( - "net/http" - - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/libsv/go-bk/bec" - "github.com/libsv/go-bk/bip32" -) - -// Client is the transport client -type Client struct { - accessKey *bec.PrivateKey - adminKey string - adminXPriv *bip32.ExtendedKey - signRequest bool - transport TransportService - xPriv *bip32.ExtendedKey - xPub *bip32.ExtendedKey -} - -// ClientOps are the client options functions -type ClientOps func(c *Client) - -// addSignature will add the signature to the request -func addSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) ResponseError { - return setSignature(header, xPriv, bodyString) -} - -// NewTransport create a new transport service object -func NewTransport(opts ...ClientOps) (TransportService, error) { - client := Client{} - - for _, opt := range opts { - opt(&client) - } - - if client.transport == nil { - return nil, ErrNoClientSet - } - - if err := client.transport.Init(); err != nil { - return nil, err - } - - if client.adminKey != "" { - adminXPriv, err := bip32.NewKeyFromString(client.adminKey) - if err != nil { - return nil, err - } - client.adminXPriv = adminXPriv - client.transport.SetAdminKey(adminXPriv) - } - - return client.transport, nil -} - -// NewTransportService create a new transport service interface -func NewTransportService(transportService TransportService) TransportService { - return transportService -} - -// processMetadata will process the metadata -func processMetadata(metadata *models.Metadata) *models.Metadata { - if metadata == nil { - m := make(models.Metadata) - metadata = &m - } - - return metadata -} diff --git a/transports/transports_test.go b/transports/transports_test.go deleted file mode 100644 index 396fc14..0000000 --- a/transports/transports_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package transports - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestWithSignRequest will test the method WithSignRequest() -func TestWithSignRequest(t *testing.T) { - - t.Run("get opts", func(t *testing.T) { - opt := WithSignRequest(false) - assert.IsType(t, *new(ClientOps), opt) - }) - - t.Run("sign request false", func(t *testing.T) { - opts := []ClientOps{ - WithSignRequest(false), - WithHTTP(""), - } - c, err := NewTransport(opts...) - require.NoError(t, err) - require.NotNil(t, c) - - assert.Equal(t, false, c.IsSignRequest()) - }) - - t.Run("sign request true", func(t *testing.T) { - opts := []ClientOps{ - WithSignRequest(true), - WithHTTP(""), - } - c, err := NewTransport(opts...) - require.NoError(t, err) - require.NotNil(t, c) - - assert.Equal(t, true, c.IsSignRequest()) - }) -} diff --git a/walletclient.go b/walletclient.go index d3924b8..b676f6e 100644 --- a/walletclient.go +++ b/walletclient.go @@ -1,117 +1,100 @@ -// Package walletclient is a Go client for interacting with Spv Wallet. package walletclient import ( - "github.com/bitcoin-sv/spv-wallet-go-client/transports" - "github.com/bitcoinschema/go-bitcoin/v2" + "net/http" + + "github.com/bitcoin-sv/spv-wallet/models" "github.com/libsv/go-bk/bec" "github.com/libsv/go-bk/bip32" - "github.com/libsv/go-bk/wif" - "github.com/pkg/errors" ) -// ClientOps are used for client options -type ClientOps func(c *WalletClient) - -// WalletClient is the spv wallet go client representation. +// WalletClient is the spv wallet Go client representation. type WalletClient struct { - transports.TransportService - accessKey *bec.PrivateKey - accessKeyString string - transport transports.TransportService - transportOptions []transports.ClientOps - xPriv *bip32.ExtendedKey - xPrivString string - xPub *bip32.ExtendedKey - xPubString string + signRequest bool + server string + httpClient *http.Client + accessKey *bec.PrivateKey + adminXPriv *bip32.ExtendedKey + xPriv *bip32.ExtendedKey + xPub *bip32.ExtendedKey } -// New create a new wallet client -func New(opts ...ClientOps) (*WalletClient, error) { - client := &WalletClient{} +// 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 { + return makeClient( + &xPrivConf{XPrivString: xPriv}, + &httpConf{ServerURL: serverURL}, + &signRequest{Sign: true}, + ) +} - for _, opt := range opts { - opt(client) - } +// 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 { + return makeClient( + &xPubConf{XPubString: xPub}, + &httpConf{ServerURL: serverURL}, + &signRequest{Sign: false}, + ) +} - var err error - if client.xPrivString != "" { - if client.xPriv, err = bitcoin.GenerateHDKeyFromString(client.xPrivString); err != nil { - return nil, err - } - if client.xPub, err = client.xPriv.Neuter(); err != nil { - return nil, err - } - } else if client.xPubString != "" { - client.xPriv = nil - if client.xPub, err = bitcoin.GetHDKeyFromExtendedPublicKey(client.xPubString); err != nil { - return nil, err - } - } else if client.accessKeyString != "" { - client.xPriv = nil - client.xPub = nil +// 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 { + return makeClient( + &adminKeyConf{AdminKeyString: adminKey}, + &httpConf{ServerURL: serverURL}, + &signRequest{Sign: true}, + ) +} - var privateKey *bec.PrivateKey - var decodedWIF *wif.WIF - if decodedWIF, err = wif.DecodeWIF(client.accessKeyString); err != nil { - // try as a hex string - var errHex error - if privateKey, errHex = bitcoin.PrivateKeyFromString(client.accessKeyString); errHex != nil { - return nil, errors.Wrap(err, errHex.Error()) - } - } else { - privateKey = decodedWIF.PrivKey - } - client.accessKey = privateKey - } else { - return nil, errors.New("no keys available") - } +// 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 { + return makeClient( + &accessKeyConf{AccessKeyString: accessKey}, + &httpConf{ServerURL: serverURL}, + &signRequest{Sign: true}, + ) +} - transportOptions := make([]transports.ClientOps, 0) - if client.xPriv != nil { - transportOptions = append(transportOptions, transports.WithXPriv(client.xPriv)) - transportOptions = append(transportOptions, transports.WithXPub(client.xPub)) - } else if client.xPub != nil { - transportOptions = append(transportOptions, transports.WithXPub(client.xPub)) - } else if client.accessKey != nil { - transportOptions = append(transportOptions, transports.WithAccessKey(client.accessKey)) - } - if len(client.transportOptions) > 0 { - transportOptions = append(transportOptions, client.transportOptions...) - } +// makeClient creates a new WalletClient using the provided configuration options. +func makeClient(configurators ...configurator) *WalletClient { + client := &WalletClient{} - if client.transport, err = transports.NewTransport(transportOptions...); err != nil { - return nil, err + for _, configurator := range configurators { + configurator.Configure(client) } - client.TransportService = client.transport - - return client, nil + return client } -// SetAdminKey set the admin key to use to create new xpubs -func (b *WalletClient) SetAdminKey(adminKeyString string) error { - adminKey, err := bip32.NewKeyFromString(adminKeyString) - if err != nil { - return err +// processMetadata will process the metadata +func processMetadata(metadata *models.Metadata) *models.Metadata { + if metadata == nil { + m := make(models.Metadata) + metadata = &m } - b.transport.SetAdminKey(adminKey) - - return nil -} - -// SetSignRequest turn the signing of the http request on or off -func (b *WalletClient) SetSignRequest(signRequest bool) { - b.transport.SetSignRequest(signRequest) + return metadata } -// IsSignRequest return whether to sign all requests -func (b *WalletClient) IsSignRequest() bool { - return b.transport.IsSignRequest() +// addSignature will add the signature to the request +func addSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString string) ResponseError { + return setSignature(header, xPriv, bodyString) } -// GetTransport returns the current transport service -func (b *WalletClient) GetTransport() *transports.TransportService { - return &b.transport +// SetAdminKeyByString will set aminXPriv key +func (wc *WalletClient) SetAdminKeyByString(adminKey string) { + keyConf := accessKeyConf{AccessKeyString: adminKey} + keyConf.Configure(wc) } diff --git a/walletclient_test.go b/walletclient_test.go index 1e3514b..0c7fc07 100644 --- a/walletclient_test.go +++ b/walletclient_test.go @@ -2,361 +2,155 @@ package walletclient import ( "context" - "io" + "fmt" "net/http" "net/http/httptest" "testing" - - "github.com/bitcoin-sv/spv-wallet/models" - "github.com/bitcoinschema/go-bitcoin/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "time" "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" - "github.com/bitcoin-sv/spv-wallet-go-client/transports" + "github.com/bitcoin-sv/spv-wallet-go-client/xpriv" + "github.com/stretchr/testify/require" ) -// localRoundTripper is an http.RoundTripper that executes HTTP transactions -// by using handler directly, instead of going over an HTTP connection. -type localRoundTripper struct { - handler http.Handler -} - -func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - w := httptest.NewRecorder() - l.handler.ServeHTTP(w, req) - return w.Result(), nil -} - -func mustWrite(w io.Writer, s string) { - _, err := io.WriteString(w, s) - if err != nil { - panic(err) - } -} - -type testTransportHandler struct { - ClientURL string - Client func(serverURL string, httpClient *http.Client) ClientOps - Path string - Queries []*testTransportHandlerRequest - Result string - Type string -} - -type testTransportHandlerRequest struct { - Path string - Result func(w http.ResponseWriter, req *http.Request) -} - -// TestNewWalletClient will test the TestNewWalletClient method func TestNewWalletClient(t *testing.T) { - t.Run("no keys", func(t *testing.T) { - client, err := New() - assert.Error(t, err) - assert.Nil(t, client) - }) - - t.Run("empty xpriv", func(t *testing.T) { - client, err := New( - WithXPriv(""), - ) - assert.Error(t, err) - assert.Nil(t, client) - }) - - t.Run("invalid xpriv", func(t *testing.T) { - client, err := New( - WithXPriv("invalid-xpriv"), - ) - assert.Error(t, err) - assert.Nil(t, client) - }) - - t.Run("valid client", func(t *testing.T) { - client, err := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - require.NoError(t, err) - assert.IsType(t, WalletClient{}, *client) - }) - - t.Run("valid xPub client", func(t *testing.T) { - client, err := New( - WithXPub(fixtures.XPubString), - WithHTTP(fixtures.ServerURL), - ) + // 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) - assert.IsType(t, WalletClient{}, *client) - }) + client := NewWithXPriv(serverURL, keys.XPriv()) + 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) + } - t.Run("invalid xPub client", func(t *testing.T) { - client, err := New( - WithXPub("invalid-xpub"), - WithHTTP(fixtures.ServerURL), - ) - assert.Error(t, err) - assert.Nil(t, client) - }) + // 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() - t.Run("valid access keys", func(t *testing.T) { - client, err := New( - WithAccessKey(fixtures.AccessKeyString), - WithHTTP(fixtures.ServerURL), - ) require.NoError(t, err) - assert.IsType(t, WalletClient{}, *client) + require.Equal(t, http.StatusOK, resp.StatusCode) }) - t.Run("invalid access keys", func(t *testing.T) { - client, err := New( - WithAccessKey("invalid-access-key"), - WithHTTP(fixtures.ServerURL), - ) - assert.Error(t, err) - assert.Nil(t, client) + t.Run("NewWalletClientWithXPrivate fail", func(t *testing.T) { + xPriv := "invalid_key" + client := NewWithXPriv(xPriv, "http://example.com") + require.Nil(t, client.xPriv) }) - t.Run("valid access key WIF", func(t *testing.T) { - wifKey, _ := bitcoin.PrivateKeyToWif(fixtures.AccessKeyString) - client, err := New( - WithAccessKey(wifKey.String()), - WithHTTP(fixtures.ServerURL), - ) + t.Run("NewWalletClientWithXPublic success", func(t *testing.T) { + keys, err := xpriv.Generate() require.NoError(t, err) - assert.IsType(t, WalletClient{}, *client) - }) -} + client := NewWithXPub(serverURL, keys.XPub().String()) + 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) + } -// TestSetAdminKey will test the admin key setter -func TestSetAdminKey(t *testing.T) { - t.Run("invalid", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - err := client.SetAdminKey("") - assert.Error(t, 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("valid", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - err := client.SetAdminKey(fixtures.XPrivString) - assert.NoError(t, err) + t.Run("NewWalletClientWithXPublic fail", func(t *testing.T) { + client := NewWithXPub(serverURL, "invalid_key") + require.Nil(t, client.xPub) }) - t.Run("invalid with", func(t *testing.T) { - _, err := New( - WithXPriv(fixtures.XPrivString), - WithAdminKey("rest"), - WithHTTP(fixtures.ServerURL), - ) - assert.Error(t, err) - }) + t.Run("NewWalletClientWithAdminKey success", func(t *testing.T) { + client := NewWithAdminKey(server.URL, fixtures.XPrivString) + 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) - t.Run("valid with", func(t *testing.T) { - _, err := New( - WithXPriv(fixtures.XPrivString), - WithAdminKey(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - assert.NoError(t, err) - }) -} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() -// TestSetSignRequest will test the sign request setter -func TestSetSignRequest(t *testing.T) { - t.Run("true", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - client.SetSignRequest(true) - assert.True(t, client.IsSignRequest()) - }) + req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } - t.Run("false", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - client.SetSignRequest(false) - assert.False(t, client.IsSignRequest()) - }) + // 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() - t.Run("false by default", func(t *testing.T) { - client, err := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) require.NoError(t, err) - assert.False(t, client.IsSignRequest()) + require.Equal(t, http.StatusOK, resp.StatusCode) }) -} -// TestGetTransport will test the GetTransport method -func TestGetTransport(t *testing.T) { - t.Run("GetTransport", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - ) - transport := client.GetTransport() - assert.IsType(t, &transports.TransportHTTP{}, *transport) + t.Run("NewWalletClientWithAdminKey fail", func(t *testing.T) { + client := NewWithAdminKey(serverURL, "invalid_key") + require.Nil(t, client.adminXPriv) }) - t.Run("client GetTransport", func(t *testing.T) { - client, _ := New( - WithXPriv(fixtures.XPrivString), - WithHTTP(fixtures.ServerURL), - WithAdminKey(fixtures.XPrivString), - WithSignRequest(false), - ) - transport := client.GetTransport() - assert.IsType(t, &transports.TransportHTTP{}, *transport) - }) -} - -func TestAuthenticationWithOnlyAccessKey(t *testing.T) { - anyConditions := make(map[string]interface{}, 0) - var anyMetadataConditions *models.Metadata - anyParam := "sth" + t.Run("NewWalletClientWithAccessKey success", func(t *testing.T) { + // Attempt to create a new WalletClient with an access key + client := NewWithAccessKey(server.URL, fixtures.AccessKeyString) + require.NotNil(t, client.accessKey) - testCases := []struct { - caseTitle string - path string - clientMethod func(*WalletClient) (any, error) - }{ - { - caseTitle: "GetXPub", - path: "/xpub", - clientMethod: func(c *WalletClient) (any, error) { return c.GetXPub(context.Background()) }, - }, - { - caseTitle: "GetAccessKey", - path: "/access-key", - clientMethod: func(c *WalletClient) (any, error) { return c.GetAccessKey(context.Background(), anyParam) }, - }, - { - caseTitle: "GetAccessKeys", - path: "/access-key", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetAccessKeys(context.Background(), anyMetadataConditions) - }, - }, - { - caseTitle: "GetDestinationByID", - path: "/destination", - clientMethod: func(c *WalletClient) (any, error) { return c.GetDestinationByID(context.Background(), anyParam) }, - }, - { - caseTitle: "GetDestinationByAddress", - path: "/destination", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetDestinationByAddress(context.Background(), anyParam) - }, - }, - { - caseTitle: "GetDestinationByLockingScript", - path: "/destination", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetDestinationByLockingScript(context.Background(), anyParam) - }, - }, - { - caseTitle: "GetDestinations", - path: "/destination/search", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetDestinations(context.Background(), nil) - }, - }, - { - caseTitle: "GetTransaction", - path: "/transaction", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetTransaction(context.Background(), fixtures.Transaction.ID) - }, - }, - { - caseTitle: "GetTransactions", - path: "/transaction/search", - clientMethod: func(c *WalletClient) (any, error) { - return c.GetTransactions(context.Background(), anyConditions, anyMetadataConditions, &transports.QueryParams{}) - }, - }, - } + require.Equal(t, serverURL, client.server) + require.True(t, client.signRequest) + require.NotNil(t, client.httpClient) - for _, test := range testCases { - t.Run(test.caseTitle, func(t *testing.T) { - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Queries: []*testTransportHandlerRequest{{ - Path: test.path, - Result: func(w http.ResponseWriter, req *http.Request) { - assertAuthHeaders(t, req) - w.Header().Set("Content-Type", "application/json") - mustWrite(w, "{}") - }, - }}, - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - client := getTestWalletClientWithOpts(transportHandler, WithAccessKey(fixtures.AccessKeyString)) - - _, err := test.clientMethod(client) - if err != nil { - t.Log(err) - } - }) - } -} - -func assertAuthHeaders(t *testing.T, req *http.Request) { - assert.Empty(t, req.Header.Get("x-auth-xpub"), "Header value x-auth-xpub should be empty") - assert.NotEmpty(t, req.Header.Get("x-auth-key"), "Header value x-auth-key should not be empty") - assert.NotEmpty(t, req.Header.Get("x-auth-time"), "Header value x-auth-time should not be empty") - assert.NotEmpty(t, req.Header.Get("x-auth-hash"), "Header value x-auth-hash should not be empty") - assert.NotEmpty(t, req.Header.Get("x-auth-nonce"), "Header value x-auth-nonce should not be empty") - assert.NotEmpty(t, req.Header.Get("x-auth-signature"), "Header value x-auth-signature should not be empty") -} - -func getTestWalletClient(transportHandler testTransportHandler, adminKey bool) *WalletClient { - opts := []ClientOps{ - WithXPriv(fixtures.XPrivString), - } - if adminKey { - opts = append(opts, WithAdminKey(fixtures.XPrivString)) - } - - return getTestWalletClientWithOpts(transportHandler, opts...) -} - -func getTestWalletClientWithOpts(transportHandler testTransportHandler, options ...ClientOps) *WalletClient { - mux := http.NewServeMux() - if transportHandler.Queries != nil { - for _, query := range transportHandler.Queries { - mux.HandleFunc(query.Path, query.Result) + req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) } - } else { - mux.HandleFunc(transportHandler.Path, func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - mustWrite(w, transportHandler.Result) - }) - } - httpclient := &http.Client{Transport: localRoundTripper{handler: mux}} - - opts := []ClientOps{ - transportHandler.Client(transportHandler.ClientURL, httpclient), - } - opts = append(opts, options...) + // 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() - client, _ := New(opts...) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) - return client + t.Run("NewWalletClientWithAccessKey fail", func(t *testing.T) { + client := NewWithAccessKey(serverURL, "invalid_key") + require.Nil(t, client.accessKey) + }) } diff --git a/xpriv/xpriv.go b/xpriv/xpriv.go index a73b001..549f591 100644 --- a/xpriv/xpriv.go +++ b/xpriv/xpriv.go @@ -1,3 +1,4 @@ +// Package xpriv manges keys package xpriv import ( diff --git a/xpubs.go b/xpubs.go deleted file mode 100644 index 65beaa3..0000000 --- a/xpubs.go +++ /dev/null @@ -1,18 +0,0 @@ -package walletclient - -import ( - "context" - - "github.com/bitcoin-sv/spv-wallet-go-client/transports" - "github.com/bitcoin-sv/spv-wallet/models" -) - -// GetXPub gets the current xpub -func (b *WalletClient) GetXPub(ctx context.Context) (*models.Xpub, transports.ResponseError) { - return b.transport.GetXPub(ctx) -} - -// UpdateXPubMetadata update the metadata of the logged in xpub -func (b *WalletClient) UpdateXPubMetadata(ctx context.Context, metadata *models.Metadata) (*models.Xpub, transports.ResponseError) { - return b.transport.UpdateXPubMetadata(ctx, metadata) -} diff --git a/xpubs_test.go b/xpubs_test.go index 233d1c8..2430662 100644 --- a/xpubs_test.go +++ b/xpubs_test.go @@ -2,44 +2,62 @@ package walletclient import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - - "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" ) -// TestXpub will test the Xpub methods +type xpub struct { + CurrentBalance uint64 `json:"current_balance"` + Metadata *models.Metadata `json:"metadata"` +} + func TestXpub(t *testing.T) { - transportHandler := testTransportHandler{ - Type: fixtures.RequestType, - Path: "/xpub", - Result: fixtures.MarshallForTestHandler(fixtures.Xpub), - ClientURL: fixtures.ServerURL, - Client: WithHTTPClient, - } + 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 := NewWithXPriv(server.URL, keys.XPriv()) + require.NotNil(t, client.xPriv) t.Run("GetXPub", func(t *testing.T) { - // given - client := getTestWalletClient(transportHandler, true) - - // when xpub, err := client.GetXPub(context.Background()) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Xpub, xpub) + 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) { - // given - client := getTestWalletClient(transportHandler, true) - - // when - xpub, err := client.UpdateXPubMetadata(context.Background(), fixtures.TestMetadata) - - // then - assert.NoError(t, err) - assert.Equal(t, fixtures.Xpub, xpub) + update = true + metadata := &models.Metadata{"updated": "info"} + xpub, err := client.UpdateXPubMetadata(context.Background(), metadata) + require.NoError(t, err) + require.NotNil(t, xpub) + require.Equal(t, "info", xpub.Metadata["updated"]) }) }