diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..efb778d --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,39 @@ +# Code of Conduct + +## 1. Purpose + +The primary goal of this project is to foster an inclusive, respectful, and open community for everyone, regardless of their background or identity. This Code of Conduct outlines our expectations for participant behavior and the consequences for unacceptable behavior. + +## 2. Open Discussions & Respectful Feedback + +- **Encourage Diverse Ideas:** Everyone brings a unique perspective. Encourage different viewpoints, and listen openly to each other’s ideas. + +- **Constructive Feedback:** Focus on providing constructive feedback rather than criticizing individuals. Discuss ideas, not the person presenting them. + +- **Avoid Harmful Language:** Refrain from using offensive or harmful language, including but not limited to sexist, racist, homophobic, transphobic, ableist, or discriminatory remarks. + +## 3. No Politics or Off-topic Discussions + +- **Stay On Topic:** Keep discussions focused on the project and avoid bringing in off-topic or political discussions. + +- **Respectful Discourse:** If discussions become heated, maintain a level of respect and understanding, and work towards a compromise. + +## 4. Reporting & Enforcement + +- **Report Violations:** If you observe a violation of this Code of Conduct, please report it by contacting the project team members. + +- **Consequences:** Violations of this Code of Conduct may result in temporary or permanent banning from the project community. + +## 5. Inclusion & Diversity + +- **Welcome Everyone:** Foster an environment where everyone feels welcome, regardless of their background, identity, or level of experience. + +- **Help Newcomers:** Offer help and guidance to newcomers to make them feel welcome in our community. + +## 6. Be Kind & Courteous + +- **Respect Time & Effort:** Recognize and respect the time and effort put in by contributors and maintainers. + +- **Courtesy:** Be courteous and polite. Treat others as you would like to be treated. + +By participating in this project, you agree to abide by this Code of Conduct. Let’s work together to make this community respectful and inclusive for everyone. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ab1a987..94c604d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ version: 2 updates: # Maintain dependencies for the core library - package-ecosystem: "gomod" - target-branch: "master" + target-branch: "main" directory: "/" schedule: interval: "daily" @@ -19,7 +19,7 @@ updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" - target-branch: "master" + target-branch: "main" directory: "/" schedule: interval: "weekly" diff --git a/.github/mergify.yml b/.github/mergify.yml index 8ce4c56..c7b457b 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -175,7 +175,7 @@ pull_request_rules: - name: Close stale pull request conditions: - - base=master + - base=main - -closed - updated-at<21 days ago actions: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8af4d0d..53457fd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -7,10 +7,14 @@ name: "CodeQL" on: push: - branches: [master] + branches: + - master + - main pull_request: # The branches below must be a subset of the branches above - branches: [master] + branches: + - master + - main # schedule: # - cron: '0 23 * * 0' diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index da0514a..2f39157 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -5,6 +5,7 @@ on: push: branches: - master + - main paths: - .github/labels.yml jobs: diff --git a/README.md b/README.md index ca1dfac..8897f02 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ # SPV Wallet: Go Client [![Release](https://img.shields.io/github/release-pre/bitcoin-sv/spv-wallet-go-client.svg?logo=github&style=flat&v=2)](https://github.com/bitcoin-sv/spv-wallet-go-client/releases) -[![Build Status](https://img.shields.io/github/actions/workflow/status/bitcoin-sv/spv-wallet-go-client/run-tests.yml?branch=master&v=2)](https://github.com/bitcoin-sv/spv-wallet-go-client/actions) +[![Build Status](https://img.shields.io/github/actions/workflow/status/bitcoin-sv/spv-wallet-go-client/run-tests.yml?branch=main&v=2)](https://github.com/bitcoin-sv/spv-wallet-go-client/actions) [![Report](https://goreportcard.com/badge/github.com/bitcoin-sv/spv-wallet-go-client?style=flat&v=2)](https://goreportcard.com/report/github.com/bitcoin-sv/spv-wallet-go-client) -[![codecov](https://codecov.io/gh/bitcoin-sv/spv-wallet-go-client/branch/master/graph/badge.svg?v=2)](https://codecov.io/gh/bitcoin-sv/spv-wallet-go-client) +[![codecov](https://codecov.io/gh/bitcoin-sv/spv-wallet-go-client/branch/main/graph/badge.svg?v=2)](https://codecov.io/gh/bitcoin-sv/spv-wallet-go-client) [![Mergify Status](https://img.shields.io/endpoint.svg?url=https://api.mergify.com/v1/badges/bitcoin-sv/spv-wallet-go-client&style=flat&v=2)](https://mergify.io)
@@ -14,6 +14,7 @@ [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat&v=2)](https://github.com/RichardLitt/standard-readme) [![Makefile Included](https://img.shields.io/badge/Makefile-Supported%20-brightgreen?=flat&logo=probot&v=2)](Makefile) +
@@ -47,6 +48,8 @@ go get -u github.com/bitcoin-sv/spv-wallet-go-client ## Documentation View the generated [documentation](https://pkg.go.dev/github.com/bitcoin-sv/spv-wallet-go-client) +For in-depth information and guidance, please refer to the [SPV Wallet Documentation](https://bsvblockchain.gitbook.io/docs). + [![GoDoc](https://godoc.org/github.com/bitcoin-sv/spv-wallet-go-client?status.svg&style=flat&v=2)](https://pkg.go.dev/github.com/bitcoin-sv/spv-wallet-go-client)
diff --git a/contacts.go b/contacts.go new file mode 100644 index 0000000..8ddc9b3 --- /dev/null +++ b/contacts.go @@ -0,0 +1,49 @@ +package walletclient + +import ( + "context" + "errors" + "fmt" + + "github.com/bitcoin-sv/spv-wallet-go-client/transports" + "github.com/bitcoin-sv/spv-wallet/models" +) + +// 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 string, period, digits uint) transports.ResponseError { + isTotpValid, err := b.ValidateTotpForContact(contact, passcode, 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 new file mode 100644 index 0000000..80226f9 --- /dev/null +++ b/contacts_test.go @@ -0,0 +1,145 @@ +package walletclient + +import ( + "context" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/stretchr/testify/assert" + "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 + }, + }, + { + name: "ConfirmContact", + route: "/contact/confirmed/", + responsePayload: "{}", + f: func(c *WalletClient) error { + contact := models.Contact{PubKey: fixtures.PubKey} + + passcode, _ := c.GenerateTotpForContact(&contact, 30, 2) + return c.ConfirmContact(context.Background(), &contact, passcode, 30, 2) + }, + }, + } + + 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, + } + + 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, + } + + client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString)) + + contact := &models.Contact{PubKey: fixtures.PubKey} + totp, err := client.GenerateTotpForContact(contact, 30, 2) + require.NoError(t, err) + + // when + result := client.ConfirmContact(context.Background(), contact, totp, 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, + } + + client := getTestWalletClientWithOpts(tmq, WithXPriv(fixtures.XPrivString)) + + alice := &models.Contact{PubKey: "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde"} + a_totp, err := client.GenerateTotpForContact(alice, 30, 2) + require.NoError(t, err) + + bob := &models.Contact{PubKey: "02dde493752f7bc89822ed8a13e0e4aa04550c6c4430800e4be1e5e5c2556cf65b"} + + // when + result := client.ConfirmContact(context.Background(), bob, a_totp, 30, 2) + + // then + require.NotNil(t, result) + require.Equal(t, result.Error(), "totp is invalid") + }) +} diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index c9e494c..356517b 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -14,6 +14,7 @@ const ( XPrivString = "xprv9s21ZrQH143K3N6qVJQAu4EP51qMcyrKYJLkLgmYXgz58xmVxVLSsbx2DfJUtjcnXK8NdvkHMKfmmg5AJT2nqqRWUrjSHX29qEJwBgBPkJQ" AccessKeyString = "7779d24ca6f8821f225042bf55e8f80aa41b08b879b72827f51e41e6523b9cd0" PaymailAddress = "address@paymail.com" + PubKey = "034252e5359a1de3b8ec08e6c29b80594e88fb47e6ae9ce65ee5a94f0d371d2cde" ) func MarshallForTestHandler(object any) string { diff --git a/go.mod b/go.mod index 92f824c..fbe649b 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,18 @@ module github.com/bitcoin-sv/spv-wallet-go-client go 1.21 require ( - github.com/bitcoin-sv/spv-wallet/models v0.24.0 + github.com/bitcoin-sv/spv-wallet/models v0.27.3 github.com/bitcoinschema/go-bitcoin/v2 v2.0.5 github.com/libsv/go-bk v0.1.6 github.com/libsv/go-bt/v2 v2.2.5 github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.4.0 github.com/stretchr/testify v1.9.0 ) require ( github.com/bitcoinsv/bsvd v0.0.0-20190609155523-4c29707f7173 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.17.0 // indirect diff --git a/go.sum b/go.sum index 741b39b..df11ce1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,12 @@ -github.com/bitcoin-sv/spv-wallet/models v0.24.0 h1:cIfi2noDfRWaFt0VMOECwF8H9OsJFdP0T03XK4BULe0= -github.com/bitcoin-sv/spv-wallet/models v0.24.0/go.mod h1:P8vXF1mPg1Zh3xSvB9yqwuPJfOR8Tt/SAG2FYztwENI= +github.com/bitcoin-sv/spv-wallet/models v0.27.3 h1:R7eaWSBMrcyqzdSXslM+/AULkdUaChwlQ3ISvTACtu0= +github.com/bitcoin-sv/spv-wallet/models v0.27.3/go.mod h1:P8vXF1mPg1Zh3xSvB9yqwuPJfOR8Tt/SAG2FYztwENI= github.com/bitcoinschema/go-bitcoin/v2 v2.0.5 h1:Sgh5Eb746Zck/46rFDrZZEXZWyO53fMuWYhNoZa1tck= github.com/bitcoinschema/go-bitcoin/v2 v2.0.5/go.mod h1:JjO1ivfZv6vhK0uAXzyH08AAHlzNMAfnyK1Fiv9r4ZA= github.com/bitcoinsv/bsvd v0.0.0-20190609155523-4c29707f7173 h1:2yTIV9u7H0BhRDGXH5xrAwAz7XibWJtX2dNezMeNsUo= github.com/bitcoinsv/bsvd v0.0.0-20190609155523-4c29707f7173/go.mod h1:BZ1UcC9+tmcDEcdVXgpt13hMczwJxWzpAn68wNs7zRA= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -18,8 +21,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= diff --git a/totp.go b/totp.go new file mode 100644 index 0000000..ef7c571 --- /dev/null +++ b/totp.go @@ -0,0 +1,117 @@ +package walletclient + +import ( + "encoding/base32" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/utils" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoinschema/go-bitcoin/v2" + "github.com/libsv/go-bk/bec" + "github.com/libsv/go-bk/bip32" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +var ErrClientInitNoXpriv = errors.New("init client with xPriv first") + +const ( + // Default number of seconds a TOTP is valid for. + TotpDefaultPeriod uint = 30 + // Default TOTP length + TotpDefaultDigits uint = 2 +) + +// GenerateTotpForContact creates one time-based one-time password based on secret shared between the user and the contact +func (b *WalletClient) GenerateTotpForContact(contact *models.Contact, period, digits uint) (string, error) { + secret, err := sharedSecret(b, contact) + if err != nil { + return "", err + } + + opts := getTotpOpts(period, digits) + return totp.GenerateCodeCustom(string(secret), time.Now(), *opts) +} + +// ValidateTotpForContact validates one time-based one-time password based on secret shared between the user and the contact +func (b *WalletClient) ValidateTotpForContact(contact *models.Contact, passcode string, period, digits uint) (bool, error) { + secret, err := sharedSecret(b, contact) + if err != nil { + return false, err + } + + opts := getTotpOpts(period, digits) + return totp.ValidateCustom(passcode, string(secret), time.Now(), *opts) +} + +func sharedSecret(b *WalletClient, c *models.Contact) (string, error) { + privKey, pubKey, err := getSharedSecretFactors(b, c) + if err != nil { + return "", err + } + + x, _ := bec.S256().ScalarMult(pubKey.X, pubKey.Y, privKey.D.Bytes()) + return base32.StdEncoding.EncodeToString(x.Bytes()), nil +} + +func getTotpOpts(period, digits uint) *totp.ValidateOpts { + if period == 0 { + period = TotpDefaultPeriod + } + + if digits == 0 { + digits = TotpDefaultDigits + } + + return &totp.ValidateOpts{ + Period: period, + Digits: otp.Digits(digits), + } +} + +func getSharedSecretFactors(b *WalletClient, c *models.Contact) (*bec.PrivateKey, *bec.PublicKey, error) { + if b.xPriv == nil { + return nil, nil, ErrClientInitNoXpriv + } + + xpriv, err := deriveXprivForPki(b.xPriv) + if err != nil { + return nil, nil, err + } + + privKey, err := xpriv.ECPrivKey() + if err != nil { + return nil, nil, err + } + + pubKey, err := convertPubKey(c.PubKey) + if err != nil { + return nil, nil, fmt.Errorf("contact's PubKey is invalid: %w", err) + } + + return privKey, pubKey, nil +} + +func deriveXprivForPki(xpriv *bip32.ExtendedKey) (*bip32.ExtendedKey, error) { + // PKI derivation path: m/0/0/0 + // NOTICE: we currently do not support PKI rotation; however, adjustments will be made if and when we decide to implement it + + pkiXpriv, err := bitcoin.GetHDKeyByPath(xpriv, utils.ChainExternal, 0) + if err != nil { + return nil, err + } + + return pkiXpriv.Child(0) +} + +func convertPubKey(pubKey string) (*bec.PublicKey, error) { + hex, err := hex.DecodeString(pubKey) + if err != nil { + return nil, err + } + + return bec.ParsePubKey(hex, bec.S256()) +} diff --git a/totp_test.go b/totp_test.go new file mode 100644 index 0000000..f528d5f --- /dev/null +++ b/totp_test.go @@ -0,0 +1,99 @@ +package walletclient + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" + "github.com/bitcoin-sv/spv-wallet/models" + "github.com/stretchr/testify/require" +) + +func TestGenerateTotpForContact(t *testing.T) { + t.Run("success", func(t *testing.T) { + // given + sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001")) + require.NoError(t, err) + + contact := models.Contact{PubKey: fixtures.PubKey} + + // when + pass, err := sut.GenerateTotpForContact(&contact, 30, 2) + + // then + require.NoError(t, err) + require.Len(t, pass, 2) + }) + + t.Run("WalletClient without xPriv - returns error", func(t *testing.T) { + // given + sut, err := New(WithXPub(fixtures.XPubString), WithHTTP("localhost:3001")) + require.NoError(t, err) + + // when + _, err = sut.GenerateTotpForContact(nil, 30, 2) + + // then + require.ErrorIs(t, err, ErrClientInitNoXpriv) + }) + + 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) + + contact := models.Contact{PubKey: "invalid-pk-format"} + + // when + _, err = sut.GenerateTotpForContact(&contact, 30, 2) + + // then + require.ErrorContains(t, err, "contact's PubKey is invalid:") + + }) +} + +func TestValidateTotpForContact(t *testing.T) { + t.Run("success", func(t *testing.T) { + // given + sut, err := New(WithXPriv(fixtures.XPrivString), WithHTTP("localhost:3001")) + require.NoError(t, err) + + contact := models.Contact{PubKey: fixtures.PubKey} + pass, err := sut.GenerateTotpForContact(&contact, 30, 2) + require.NoError(t, err) + + // when + result, err := sut.ValidateTotpForContact(&contact, pass, 30, 2) + + // then + 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, "", 30, 2) + + // then + require.ErrorIs(t, err, ErrClientInitNoXpriv) + }) + + 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) + + contact := models.Contact{PubKey: "invalid-pk-format"} + + // when + _, err = sut.ValidateTotpForContact(&contact, "", 30, 2) + + // then + require.ErrorContains(t, err, "contact's PubKey is invalid:") + + }) +} diff --git a/transports/http.go b/transports/http.go index c827953..b124a74 100644 --- a/transports/http.go +++ b/transports/http.go @@ -329,7 +329,7 @@ func (h *TransportHTTP) GetTransaction(ctx context.Context, txID string) (*model return &transaction, nil } -// GetTransactions will get a transactions by conditions +// 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) { @@ -639,3 +639,83 @@ func (h *TransportHTTP) authenticateWithXpriv(sign bool, req *http.Request, xPri 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/interface.go b/transports/interface.go index dcec8ee..3ae040b 100644 --- a/transports/interface.go +++ b/transports/interface.go @@ -49,6 +49,15 @@ type TransactionService interface { 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) @@ -79,6 +88,7 @@ type AdminService interface { type TransportService interface { AccessKeyService AdminService + ContactService DestinationService TransactionService XpubService