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 5b58a63..c880bfb 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.26.0 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.8.4 ) 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 c87e53f..0462d9c 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.26.0 h1:BUWBPguOp/qV1RTCnKqjuqduhygLhe2zrJQf4Sl77kg= +github.com/bitcoin-sv/spv-wallet/models v0.26.0/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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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