diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6373b74..2e8bb1a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,6 +25,9 @@ jobs: - name: Start Docker Compose run: docker compose up -d + - name: Wait for the server to start + run: npx wait-on tcp:8080 + - name: Install dependencies run: go get . @@ -55,7 +58,7 @@ jobs: - name: Test with the Go CLI run: go test ./... -coverprofile=./cover.out -covermode=atomic -coverpkg=./... - - name: check test coverage + - name: Check test coverage uses: vladopajic/go-test-coverage@v2 with: config: ./.testcoverage.yaml diff --git a/api/lf.go b/api/lf.go index 73c0c00..def9282 100644 --- a/api/lf.go +++ b/api/lf.go @@ -659,6 +659,9 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // GetGenesis request + GetGenesis(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // Metrics request Metrics(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -696,6 +699,18 @@ type ClientInterface interface { GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } +func (c *Algod) GetGenesis(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetGenesisRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Algod) Metrics(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewMetricsRequest(c.Server) if err != nil { @@ -840,6 +855,33 @@ func (c *Algod) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) ( return c.Client.Do(req) } +// NewGetGenesisRequest generates requests for GetGenesis +func NewGetGenesisRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/genesis") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewMetricsRequest generates requests for Metrics func NewMetricsRequest(server string) (*http.Request, error) { var err error @@ -1366,6 +1408,9 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // GetGenesisWithResponse request + GetGenesisWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetGenesisResponse, error) + // MetricsWithResponse request MetricsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*MetricsResponse, error) @@ -1403,6 +1448,28 @@ type ClientWithResponsesInterface interface { GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) } +type GetGenesisResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *string +} + +// Status returns HTTPResponse.Status +func (r GetGenesisResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetGenesisResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type MetricsResponse struct { Body []byte HTTPResponse *http.Response @@ -1868,6 +1935,15 @@ func (r GetVersionResponse) StatusCode() int { return 0 } +// GetGenesisWithResponse request returning *GetGenesisResponse +func (c *ClientWithResponses) GetGenesisWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetGenesisResponse, error) { + rsp, err := c.GetGenesis(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetGenesisResponse(rsp) +} + // MetricsWithResponse request returning *MetricsResponse func (c *ClientWithResponses) MetricsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*MetricsResponse, error) { rsp, err := c.Metrics(ctx, reqEditors...) @@ -1976,6 +2052,32 @@ func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEdi return ParseGetVersionResponse(rsp) } +// ParseGetGenesisResponse parses an HTTP response from a GetGenesisWithResponse call +func ParseGetGenesisResponse(rsp *http.Response) (*GetGenesisResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetGenesisResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest string + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseMetricsResponse parses an HTTP response from a MetricsWithResponse call func ParseMetricsResponse(rsp *http.Response) (*MetricsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/generate.yaml b/generate.yaml index 8ede487..024c589 100644 --- a/generate.yaml +++ b/generate.yaml @@ -19,3 +19,4 @@ output-options: - AppendKeys - GetBlock - AccountInformation + - GetGenesis diff --git a/go.mod b/go.mod index 04ae576..ebabe66 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,14 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 ) -require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) require ( github.com/algorand/go-algorand-sdk/v2 v2.6.0 diff --git a/internal/accounts.go b/internal/accounts.go index 30128b1..4a0e4fd 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -2,8 +2,10 @@ package internal import ( "context" + "encoding/json" "errors" "fmt" + "io" "time" "github.com/algorandfoundation/hack-tui/api" @@ -27,6 +29,78 @@ type Account struct { LastModified int } +// Gets the list of addresses created at genesis from the genesis file +func getAddressesFromGenesis(client *api.ClientWithResponses) ([]string, string, string, error) { + resp, err := client.GetGenesis(context.Background()) + if err != nil { + return []string{}, "", "", err + } + + if resp.StatusCode != 200 { + return []string{}, "", "", errors.New(fmt.Sprintf("Failed to get genesis file. Received error code: %d", resp.StatusCode)) + } + + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return []string{}, "", "", err + } + + // Unmarshal the JSON response into a map + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return []string{}, "", "", err + } + + // Two special addresses + rewardsPool := "7777777777777777777777777777777777777777777777777774MSJUVU" + feeSink := "A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE" + rewardsPoolIncluded := false + feeSinkIncluded := false + + // Loop over each entry in the "alloc" list and collect the "addr" values + var addresses []string + if allocList, ok := jsonResponse["alloc"].([]interface{}); ok { + for _, entry := range allocList { + if entryMap, ok := entry.(map[string]interface{}); ok { + if addr, ok := entryMap["addr"].(string); ok { + if addr == rewardsPool { + rewardsPoolIncluded = true + } else if addr == feeSink { + feeSinkIncluded = true + } else { + addresses = append(addresses, addr) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json no addr string found in list element entry: %+v", entry) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json list element of alloc-field is not a map: %+v", entry) + } + } + } else { + return []string{}, "", "", errors.New("alloc is not a list") + } + + if !rewardsPoolIncluded || !feeSinkIncluded { + return []string{}, "", "", errors.New("Expected RewardsPool and/or FeeSink addresses NOT found in genesis file") + } + + return addresses, rewardsPool, feeSink, nil +} + +func isValidStatus(status string) bool { + validStatuses := map[string]bool{ + "Online": true, + "Offline": true, + "Not Participating": true, + } + return validStatuses[status] +} + // Get Online Status of Account func getAccountOnlineStatus(client *api.ClientWithResponses, address string) (string, error) { var format api.AccountInformationParamsFormat = "json" @@ -45,6 +119,10 @@ func getAccountOnlineStatus(client *api.ClientWithResponses, address string) (st return "N/A", errors.New(fmt.Sprintf("Failed to get account information. Received error code: %d", r.StatusCode())) } + if r.JSON200 == nil { + return "N/A", errors.New("Failed to get account information. JSON200 is nil") + } + return r.JSON200.Status, nil } diff --git a/internal/accounts_test.go b/internal/accounts_test.go new file mode 100644 index 0000000..a4fecc7 --- /dev/null +++ b/internal/accounts_test.go @@ -0,0 +1,116 @@ +package internal + +import ( + "testing" + "time" + + "github.com/algorandfoundation/hack-tui/api" + "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" + "github.com/stretchr/testify/assert" +) + +func Test_AccountsFromState(t *testing.T) { + + // Setup elevated client + apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) + + addresses, rewardsPool, feeSink, err := getAddressesFromGenesis(client) + + if err != nil { + t.Fatal(err) + } + + // Test getAccountOnlineStatus + + var mapAddressOnlineStatus = make(map[string]string) + + for _, address := range addresses { + status, err := getAccountOnlineStatus(client, address) + if err != nil { + t.Fatal(err) + } + + assert.True(t, status == "Online" || status == "Offline") + mapAddressOnlineStatus[address] = status + } + + status, err := getAccountOnlineStatus(client, rewardsPool) + if err != nil { + t.Fatal(err) + } + if status != "Not Participating" { + t.Fatalf("Expected RewardsPool to be 'Not Participating', got %s", status) + } + + status, err = getAccountOnlineStatus(client, feeSink) + if err != nil { + t.Fatal(err) + } + if status != "Not Participating" { + t.Fatalf("Expected FeeSink to be 'Not Participating', got %s", status) + } + + _, err = getAccountOnlineStatus(client, "invalid_address") + if err == nil { + t.Fatal("Expected error for invalid address") + } + + // Test AccountFromState + + // Prepare expected results + // Only include addresses with "Online" status + onlineAddresses := make(map[string]string) + for address, status := range mapAddressOnlineStatus { + if status == "Online" { + onlineAddresses[address] = status + } + } + + // Create expectedAccounts dynamically from Online accounts, and mocked participation keys + mockedPartKeys := make([]api.ParticipationKey, 0) + expectedAccounts := make(map[string]Account) + for address, status := range onlineAddresses { + expectedAccounts[address] = Account{ + Address: address, + Status: status, + Balance: 0, + Expires: time.Unix(0, 0), + Keys: 1, + LastModified: 0, + } + + mockedPartKeys = append(mockedPartKeys, api.ParticipationKey{ + Address: address, + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteParticipationKey: nil, + VoteFirstValid: 0, + VoteLastValid: 9999999, + VoteKeyDilution: 0, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }) + } + + // Mock StateModel + state := &StateModel{ + ParticipationKeys: &mockedPartKeys, + } + + // Call AccountsFromState + accounts := AccountsFromState(state, client) + + // Assert results + assert.Equal(t, expectedAccounts, accounts) + +} diff --git a/ui/pages/transaction/controller.go b/ui/pages/transaction/controller.go index 0072019..5b5eaa9 100644 --- a/ui/pages/transaction/controller.go +++ b/ui/pages/transaction/controller.go @@ -31,7 +31,7 @@ func (m *ViewModel) UpdateTxnURLAndQRCode() error { isOnline = true case "Offline": isOnline = false - case "NotParticipating": // This status means the account can never participate in consensus + case "Not Participating": // This status means the account can never participate in consensus m.urlTxn = "" m.asciiQR = "" m.hint = fmt.Sprintf("%s is NotParticipating. Cannot register key.", m.Data.Address)