diff --git a/internal/circle/client.go b/internal/circle/client.go index d2d34d37f..0cd667337 100644 --- a/internal/circle/client.go +++ b/internal/circle/client.go @@ -10,19 +10,24 @@ import ( "net/url" "github.com/google/uuid" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" ) const ( pingPath = "/ping" transferPath = "/v1/transfers" + walletPath = "/v1/wallets" ) // ClientInterface defines the interface for interacting with the Circle API. +// +//go:generate mockery --name=ClientInterface --case=underscore --structname=MockClient type ClientInterface interface { Ping(ctx context.Context) (bool, error) PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error) GetTransferByID(ctx context.Context, id string) (*Transfer, error) + GetWalletByID(ctx context.Context, id string) (*Wallet, error) } // Client provides methods to interact with the Circle API. @@ -32,8 +37,13 @@ type Client struct { httpClient httpclient.HttpClientInterface } +// ClientFactory is a function that creates a ClientInterface. +type ClientFactory func(env Environment, apiKey string) ClientInterface + +var _ ClientFactory = NewClient + // NewClient creates a new instance of Circle Client. -func NewClient(env Environment, apiKey string) *Client { +func NewClient(env Environment, apiKey string) ClientInterface { return &Client{ BasePath: string(env), APIKey: apiKey, @@ -42,7 +52,8 @@ func NewClient(env Environment, apiKey string) *Client { } // Ping checks that the service is running. -// https://developers.circle.com/circle-mint/reference/ping. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/ping. func (client *Client) Ping(ctx context.Context) (bool, error) { u, err := url.JoinPath(client.BasePath, pingPath) if err != nil { @@ -74,7 +85,8 @@ func (client *Client) Ping(ctx context.Context) (bool, error) { } // PostTransfer creates a new transfer. -// https://developers.circle.com/circle-mint/reference/createtransfer +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/createtransfer. func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequest) (*Transfer, error) { err := transferReq.validate() if err != nil { @@ -109,7 +121,8 @@ func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequ } // GetTransferByID retrieves a transfer by its ID. -// https://developers.circle.com/circle-mint/reference/gettransfer +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/gettransfer. func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer, error) { u, err := url.JoinPath(client.BasePath, transferPath, id) if err != nil { @@ -132,6 +145,32 @@ func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer return parseTransferResponse(resp) } +// GetWalletByID retrieves a wallet by its ID. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/getwallet. +func (client *Client) GetWalletByID(ctx context.Context, id string) (*Wallet, error) { + url, err := url.JoinPath(client.BasePath, walletPath, id) + if err != nil { + return nil, fmt.Errorf("building path: %w", err) + } + + resp, err := client.request(ctx, url, http.MethodGet, true, nil) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + apiError, parseErr := parseAPIError(resp) + if parseErr != nil { + return nil, fmt.Errorf("parsing API error: %w", parseErr) + } + return nil, fmt.Errorf("API error: %w", apiError) + } + + return parseWalletResponse(resp) +} + // request makes an HTTP request to the Circle API. func (client *Client) request(ctx context.Context, u string, method string, isAuthed bool, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, u, body) diff --git a/internal/circle/client_test.go b/internal/circle/client_test.go index aed366b48..e8fd561b6 100644 --- a/internal/circle/client_test.go +++ b/internal/circle/client_test.go @@ -9,20 +9,25 @@ import ( "net/http" "testing" - httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" ) func Test_NewClient(t *testing.T) { t.Run("production environment", func(t *testing.T) { - cc := NewClient(Production, "test-key") + clientInterface := NewClient(Production, "test-key") + cc, ok := clientInterface.(*Client) + assert.True(t, ok) assert.Equal(t, "https://api.circle.com", cc.BasePath) assert.Equal(t, "test-key", cc.APIKey) }) t.Run("sandbox environment", func(t *testing.T) { - cc := NewClient(Sandbox, "test-key") + clientInterface := NewClient(Sandbox, "test-key") + cc, ok := clientInterface.(*Client) + assert.True(t, ok) assert.Equal(t, "https://api-sandbox.circle.com", cc.BasePath) assert.Equal(t, "test-key", cc.APIKey) }) @@ -193,6 +198,95 @@ func Test_Client_GetTransferByID(t *testing.T) { }) } +func Test_Client_GetWalletByID(t *testing.T) { + ctx := context.Background() + t.Run("get wallet by id error", func(t *testing.T) { + cc, httpClientMock := newClientWithMock(t) + testError := errors.New("test error") + httpClientMock. + On("Do", mock.Anything). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + }). + Return(nil, testError). + Once() + + wallet, err := cc.GetWalletByID(ctx, "test-id") + assert.EqualError(t, err, fmt.Errorf("making request: %w", testError).Error()) + assert.Nil(t, wallet) + }) + + t.Run("get wallet by id fails auth", func(t *testing.T) { + const unauthorizedResponse = `{ + "code": 401, + "message": "Malformed key. Does it contain three parts?" + }` + cc, httpClientMock := newClientWithMock(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), + }, nil). + Once() + + transfer, err := cc.GetWalletByID(ctx, "test-id") + assert.EqualError(t, err, "API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[]") + assert.Nil(t, transfer) + }) + + t.Run("get wallet by id successful", func(t *testing.T) { + const getWalletResponseJSON = `{ + "data": { + "walletId": "test-id", + "entityId": "2f47c999-9022-4939-acea-dc3afa9ccbaf", + "type": "end_user_wallet", + "description": "Treasury Wallet", + "balances": [ + { + "amount": "4790.00", + "currency": "USD" + } + ] + } + }` + cc, httpClientMock := newClientWithMock(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(getWalletResponseJSON)), + }, nil). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + }). + Once() + + wallet, err := cc.GetWalletByID(ctx, "test-id") + assert.NoError(t, err) + wantWallet := &Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []Balance{ + {Amount: "4790.00", Currency: "USD"}, + }, + } + assert.Equal(t, wantWallet, wallet) + }) +} + func newClientWithMock(t *testing.T) (Client, *httpclientMocks.HttpClientMock) { httpClientMock := httpclientMocks.NewHttpClientMock(t) diff --git a/internal/circle/environments.go b/internal/circle/environments.go index 41f1cf5ac..ffb7e6ddb 100644 --- a/internal/circle/environments.go +++ b/internal/circle/environments.go @@ -1,5 +1,11 @@ package circle +import ( + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + // Environment holds the possible environments for the Circle API. type Environment string @@ -7,3 +13,10 @@ const ( Production Environment = "https://api.circle.com" Sandbox Environment = "https://api-sandbox.circle.com" ) + +var AllowedAssetsMap = map[string]map[utils.NetworkType]data.Asset{ + "USD": { + utils.PubnetNetworkType: assets.USDCAssetPubnet, + utils.TestnetNetworkType: assets.USDCAssetTestnet, + }, +} diff --git a/internal/circle/mocks/client_interface.go b/internal/circle/mocks/client_interface.go new file mode 100644 index 000000000..f69da9b1b --- /dev/null +++ b/internal/circle/mocks/client_interface.go @@ -0,0 +1,133 @@ +// Code generated by mockery v2.27.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + circle "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the ClientInterface type +type MockClient struct { + mock.Mock +} + +// GetTransferByID provides a mock function with given fields: ctx, id +func (_m *MockClient) GetTransferByID(ctx context.Context, id string) (*circle.Transfer, error) { + ret := _m.Called(ctx, id) + + var r0 *circle.Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*circle.Transfer, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *circle.Transfer); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*circle.Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetWalletByID provides a mock function with given fields: ctx, id +func (_m *MockClient) GetWalletByID(ctx context.Context, id string) (*circle.Wallet, error) { + ret := _m.Called(ctx, id) + + var r0 *circle.Wallet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*circle.Wallet, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *circle.Wallet); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*circle.Wallet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Ping provides a mock function with given fields: ctx +func (_m *MockClient) Ping(ctx context.Context) (bool, error) { + ret := _m.Called(ctx) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (bool, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PostTransfer provides a mock function with given fields: ctx, transferRequest +func (_m *MockClient) PostTransfer(ctx context.Context, transferRequest circle.TransferRequest) (*circle.Transfer, error) { + ret := _m.Called(ctx, transferRequest) + + var r0 *circle.Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, circle.TransferRequest) (*circle.Transfer, error)); ok { + return rf(ctx, transferRequest) + } + if rf, ok := ret.Get(0).(func(context.Context, circle.TransferRequest) *circle.Transfer); ok { + r0 = rf(ctx, transferRequest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*circle.Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, circle.TransferRequest) error); ok { + r1 = rf(ctx, transferRequest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewMockClient interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockClient(t mockConstructorTestingTNewMockClient) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/circle/wallet.go b/internal/circle/wallet.go new file mode 100644 index 000000000..51dc18706 --- /dev/null +++ b/internal/circle/wallet.go @@ -0,0 +1,34 @@ +package circle + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type WalletResponse struct { + Data Wallet `json:"data"` +} + +type Wallet struct { + WalletID string `json:"walletId"` + EntityID string `json:"entityId"` + Type string `json:"type"` + Description string `json:"description"` + Balances []Balance `json:"balances"` +} + +type Balance struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +// parseWalletResponse parses the response from the Circle API into a Wallet struct. +func parseWalletResponse(resp *http.Response) (*Wallet, error) { + var walletResponse WalletResponse + if err := json.NewDecoder(resp.Body).Decode(&walletResponse); err != nil { + return nil, fmt.Errorf("unmarshalling Circle HTTP response: %w", err) + } + + return &walletResponse.Data, nil +} diff --git a/internal/serve/httphandler/balances_handler.go b/internal/serve/httphandler/balances_handler.go new file mode 100644 index 000000000..374bd34c7 --- /dev/null +++ b/internal/serve/httphandler/balances_handler.go @@ -0,0 +1,114 @@ +package httphandler + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/stellar/go/support/log" + "github.com/stellar/go/support/render/httpjson" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +type Balance struct { + Amount string `json:"amount"` + AssetCode string `json:"asset_code"` + AssetIssuer string `json:"asset_issuer"` +} + +type GetBalanceResponse struct { + Account schema.TransactionAccount `json:"account"` + Balances []Balance `json:"balances"` +} + +type BalancesHandler struct { + DistributionAccountResolver signing.DistributionAccountResolver + NetworkType utils.NetworkType + CircleClientFactory circle.ClientFactory +} + +// Get returns the balances of the distribution account. +func (h BalancesHandler) Get(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + distAccount, err := h.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve distribution account", err, nil).Render(w) + return + } + + if !distAccount.IsCircle() { + errResponseMsg := fmt.Sprintf("This endpoint is only available for tenants using %v", schema.CirclePlatform) + httperror.BadRequest(errResponseMsg, nil, nil).Render(w) + return + } + + // TODO: replace this mocked call after SDP-1170 is completed. + circleAPIKey, err := mockFnGetCircleAPIKey(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve Circle API key", err, nil).Render(w) + return + } + + circleEnv := circle.Sandbox + if h.NetworkType == utils.PubnetNetworkType { + circleEnv = circle.Production + } + circleSDK := h.CircleClientFactory(circleEnv, circleAPIKey) + circleWallet, err := circleSDK.GetWalletByID(ctx, distAccount.Address) + if err != nil { + var circleApiErr *circle.APIError + var httpError *httperror.HTTPError + if errors.As(err, &circleApiErr) { + extras := map[string]interface{}{"circle_errors": circleApiErr.Errors} + msg := fmt.Sprintf("Cannot retrieve Circle wallet: %s", circleApiErr.Message) + httpError = httperror.NewHTTPError(circleApiErr.Code, msg, circleApiErr, extras) + } else { + httpError = httperror.InternalError(ctx, "Cannot retrieve Circle wallet", err, nil) + } + httpError.Render(w) + return + } + + balances := h.filterBalances(ctx, circleWallet) + + response := GetBalanceResponse{ + Account: distAccount, + Balances: balances, + } + httpjson.Render(w, response, httpjson.JSON) +} + +func (h BalancesHandler) filterBalances(ctx context.Context, circleWallet *circle.Wallet) []Balance { + balances := []Balance{} + for _, balance := range circleWallet.Balances { + networkAssetMap, ok := circle.AllowedAssetsMap[balance.Currency] + if !ok { + log.Ctx(ctx).Debugf("Ignoring balance for asset %s, as it's not supported by the SDP", balance.Currency) + continue + } + + asset, ok := networkAssetMap[h.NetworkType] + if !ok { + log.Ctx(ctx).Debugf("Ignoring balance for asset %s, as it's not supported by the SDP in the %v network", balance.Currency, h.NetworkType) + continue + } + + balances = append(balances, Balance{ + Amount: balance.Amount, + AssetCode: asset.Code, + AssetIssuer: asset.Issuer, + }) + } + return balances +} + +func mockFnGetCircleAPIKey(ctx context.Context) (string, error) { + return "SAND_API_KEY:c57a34ffb46de9240da8353bcc394fbf:5b1ec227682031ce176a3970d85a785e", nil +} diff --git a/internal/serve/httphandler/balances_handler_test.go b/internal/serve/httphandler/balances_handler_test.go new file mode 100644 index 000000000..58b3b2290 --- /dev/null +++ b/internal/serve/httphandler/balances_handler_test.go @@ -0,0 +1,303 @@ +package httphandler + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + circleMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/circle/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_BalancesHandler_Get(t *testing.T) { + circleAPIError := &circle.APIError{ + Code: 400, + Message: "some circle error", + Errors: []circle.APIErrorDetail{ + { + Error: "some error", + Message: "some message", + Location: "some location", + }, + }, + } + testCases := []struct { + name string + networkType utils.NetworkType + prepareMocks func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) + expectedStatus int + expectedResponse string + }{ + { + name: "returns a 500 error in DistributionAccountResolver", + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{}, errors.New("distribution account error")). + Once() + }, + expectedStatus: http.StatusInternalServerError, + expectedResponse: `{"error":"Cannot retrieve distribution account"}`, + }, + { + name: "returns a 400 error if the distribution account is not Circle", + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + }, + expectedStatus: http.StatusBadRequest, + expectedResponse: fmt.Sprintf(`{"error":"This endpoint is only available for tenants using %v"}`, schema.CirclePlatform), + }, + { + name: "propagate Circle API error if GetWalletByID fails", + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Address: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleClient. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(nil, fmt.Errorf("wrapped error: %w", circleAPIError)). + Once() + }, + expectedStatus: circleAPIError.Code, + expectedResponse: `{ + "error": "Cannot retrieve Circle wallet: some circle error", + "extras": { + "circle_errors": [ + { + "error": "some error", + "message": "some message", + "location": "some location" + } + ] + } + }`, + }, + { + name: "returns a 500 if circle.GetWalletByID fails with an unexpected error", + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Address: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleClient. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(nil, errors.New("unexpected error")). + Once() + }, + expectedStatus: http.StatusInternalServerError, + expectedResponse: `{"error": "Cannot retrieve Circle wallet"}`, + }, + { + name: "[Testnet] 🎉 successfully returns balances", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Address: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleClient. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(&circle.Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []circle.Balance{ + {Amount: "123.00", Currency: "USD"}, + }, + }, nil). + Once() + }, + expectedStatus: http.StatusOK, + expectedResponse: `{ + "account": { + "address": "circle-wallet-id", + "type": "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", + "status": "ACTIVE" + }, + "balances": [{ + "amount": "123.00", + "asset_code": "USDC", + "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }] + }`, + }, + { + name: "[Pubnet] 🎉 successfully returns balances", + networkType: utils.PubnetNetworkType, + prepareMocks: func(t *testing.T, mCircleClient *circleMocks.MockClient, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Address: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleClient. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(&circle.Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []circle.Balance{ + {Amount: "123.00", Currency: "USD"}, + }, + }, nil). + Once() + }, + expectedStatus: http.StatusOK, + expectedResponse: `{ + "account": { + "address": "circle-wallet-id", + "type": "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", + "status": "ACTIVE" + }, + "balances": [{ + "amount": "123.00", + "asset_code": "USDC", + "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + }] + }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mCircleClient := circleMocks.NewMockClient(t) + tc.prepareMocks(t, mCircleClient, mDistributionAccountResolver) + h := BalancesHandler{ + DistributionAccountResolver: mDistributionAccountResolver, + NetworkType: tc.networkType, + CircleClientFactory: func(env circle.Environment, apiKey string) circle.ClientInterface { + if tc.networkType == utils.PubnetNetworkType { + assert.Equal(t, circle.Production, env) + } else { + assert.Equal(t, circle.Sandbox, env) + } + return mCircleClient + }, + } + + rr := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/balances", nil) + require.NoError(t, err) + http.HandlerFunc(h.Get).ServeHTTP(rr, req) + resp := rr.Result() + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expectedStatus, resp.StatusCode) + assert.JSONEq(t, tc.expectedResponse, string(respBody)) + }) + } +} + +func Test_BalancesHandler_filterBalances(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + networkType utils.NetworkType + circleWallet *circle.Wallet + expectedBalances []Balance + }{ + { + name: "[Pubnet] only supported assets are included", + networkType: utils.PubnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + {Currency: "USD", Amount: "200"}, + {Currency: "BAR", Amount: "300"}, + }, + }, + expectedBalances: []Balance{ + { + Amount: "200", + AssetCode: assets.USDCAssetPubnet.Code, + AssetIssuer: assets.USDCAssetPubnet.Issuer, + }, + }, + }, + { + name: "[Testnet] only supported assets are included", + networkType: utils.TestnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + {Currency: "USD", Amount: "200"}, + {Currency: "BAR", Amount: "300"}, + }, + }, + expectedBalances: []Balance{ + { + Amount: "200", + AssetCode: assets.USDCAssetTestnet.Code, + AssetIssuer: assets.USDCAssetTestnet.Issuer, + }, + }, + }, + { + name: "[Pubnet] none of the provided assets is supported", + networkType: utils.PubnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + }, + }, + expectedBalances: []Balance{}, + }, + { + name: "[Testnet] none of the provided assets is supported", + networkType: utils.TestnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + }, + }, + expectedBalances: []Balance{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + h := BalancesHandler{NetworkType: tc.networkType} + + actualBalances := h.filterBalances(ctx, tc.circleWallet) + + assert.Equal(t, tc.expectedBalances, actualBalances) + }) + } +} diff --git a/internal/serve/httphandler/countries_handler_test.go b/internal/serve/httphandler/countries_handler_test.go index e390fc84b..96a1e3cf9 100644 --- a/internal/serve/httphandler/countries_handler_test.go +++ b/internal/serve/httphandler/countries_handler_test.go @@ -8,11 +8,12 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_CountriesHandlerGetCountries(t *testing.T) { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index f88dda55b..e08195aab 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -17,6 +17,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" @@ -69,6 +70,7 @@ type ServeOptions struct { BaseURL string ResetTokenExpirationHours int NetworkPassphrase string + NetworkType utils.NetworkType SubmitterEngine engine.SubmitterEngine Sep10SigningPublicKey string Sep10SigningPrivateKey string @@ -129,6 +131,12 @@ func (opts *ServeOptions) SetupDependencies() error { return fmt.Errorf("error initializing password validator: %w", err) } + // Setup NetworkType + opts.NetworkType, err = utils.GetNetworkTypeFromNetworkPassphrase(opts.NetworkPassphrase) + if err != nil { + return fmt.Errorf("parsing network type: %w", err) + } + return nil } @@ -382,6 +390,13 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)). Get("/logo", profileHandler.GetOrganizationLogo) }) + + balancesHandler := httphandler.BalancesHandler{ + DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, + NetworkType: o.NetworkType, + CircleClientFactory: circle.NewClient, + } + r.Get("/balances", balancesHandler.Get) }) reCAPTCHAValidator := validators.NewGoogleReCAPTCHAValidator(o.ReCAPTCHASiteSecretKey, httpclient.DefaultClient()) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index f741d726f..dc251c56a 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -9,8 +9,6 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events" - "github.com/go-chi/chi/v5" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/network" @@ -25,6 +23,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" monitorMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor/mocks" @@ -415,7 +414,7 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { handlerMux := handleHTTP(serveOptions) // Authenticated endpoints - authenticatedEndpoints := []struct { // TODO: body to requests + authenticatedEndpoints := []struct { method string path string }{ @@ -469,6 +468,8 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { {http.MethodGet, "/organization"}, {http.MethodPatch, "/organization"}, {http.MethodGet, "/organization/logo"}, + // Balances + {http.MethodGet, "/balances"}, } // Expect 401 as a response: diff --git a/internal/transactionsubmission/engine/signing/mocks/signature_client.go b/internal/transactionsubmission/engine/signing/mocks/signature_client.go index a9c2524ab..3e6b1adad 100644 --- a/internal/transactionsubmission/engine/signing/mocks/signature_client.go +++ b/internal/transactionsubmission/engine/signing/mocks/signature_client.go @@ -135,20 +135,6 @@ func (_m *MockSignatureClient) SignStellarTransaction(ctx context.Context, stell return r0, r1 } -// Type provides a mock function with given fields: -func (_m *MockSignatureClient) Type() string { - ret := _m.Called() - - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(string) - } - - return r0 -} - type mockConstructorTestingTNewMockSignatureClient interface { mock.TestingT Cleanup(func()) diff --git a/stellar-multitenant/pkg/serve/serve_test.go b/stellar-multitenant/pkg/serve/serve_test.go index 83b60c6fc..b16f63df7 100644 --- a/stellar-multitenant/pkg/serve/serve_test.go +++ b/stellar-multitenant/pkg/serve/serve_test.go @@ -182,7 +182,7 @@ func Test_handleHTTP_authenticatedAdminEndpoints(t *testing.T) { handlerMux := handleHTTP(&serveOptions) // Authenticated endpoints - authenticatedEndpoints := []struct { // TODO: body to requests + authenticatedEndpoints := []struct { method string path string }{