diff --git a/CHANGELOG.md b/CHANGELOG.md index cba2478fa..404c83189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file. - Add influxdb, prometheus, and device-health-oracle containers - SDK - Commands for setting global config, activating devices, updating devices, and closing device accounts now manage resource accounts. + - Serviceability: return error when GetProgramAccounts returns empty result instead of silently returning empty data - Smartcontract - feat(smartcontract): RFC 11 activation for User entity - Device Health Oracle diff --git a/e2e/funder_test.go b/e2e/funder_test.go index d47733bab..32b8cd909 100644 --- a/e2e/funder_test.go +++ b/e2e/funder_test.go @@ -65,16 +65,28 @@ func TestE2E_Funder(t *testing.T) { require.NoError(t, err) funderPK := funderPrivateKey.PublicKey() - // Check that the errors metric only contains "funder_account_balance_below_minimum" errors, - // which occur on startup while waiting for the manager/funder account to be funded. + // Check that the errors metric only contains expected startup errors: + // - "get_recipients": occurs when the serviceability program isn't ready yet + // - "funder_account_balance_below_minimum": occurs while waiting for the manager/funder account to be funded metricsClient := dn.Funder.GetMetricsClient() require.NoError(t, metricsClient.WaitForReady(ctx, 3*time.Second)) require.NoError(t, metricsClient.Fetch(ctx)) errors := metricsClient.GetCounterValues("doublezero_funder_errors_total") require.NotNil(t, errors) - require.Len(t, errors, 1) - require.Equal(t, "funder_account_balance_below_minimum", errors[0].Labels["error_type"]) - prevFunderAccountBalanceBelowMinimumCount := int(errors[0].Value) + allowedStartupErrors := map[string]bool{ + "get_recipients": true, + "funder_account_balance_below_minimum": true, + } + for _, e := range errors { + require.True(t, allowedStartupErrors[e.Labels["error_type"]], "unexpected error type during startup: %s", e.Labels["error_type"]) + } + var prevFunderAccountBalanceBelowMinimumCount int + for _, e := range errors { + if e.Labels["error_type"] == "funder_account_balance_below_minimum" { + prevFunderAccountBalanceBelowMinimumCount = int(e.Value) + break + } + } // Check the funder account balance metric. require.NoError(t, metricsClient.Fetch(ctx)) @@ -146,12 +158,16 @@ func TestE2E_Funder(t *testing.T) { require.NoError(t, metricsClient.Fetch(ctx)) errors = metricsClient.GetCounterValues("doublezero_funder_errors_total") require.NotNil(t, errors) - require.Len(t, errors, 1) - require.Equal(t, "funder_account_balance_below_minimum", errors[0].Labels["error_type"]) - if int(errors[0].Value) > prevFunderAccountBalanceBelowMinimumCount { - return true + for _, e := range errors { + if e.Labels["error_type"] == "funder_account_balance_below_minimum" { + if int(e.Value) > prevFunderAccountBalanceBelowMinimumCount { + return true + } + log.Debug("--> Waiting for funder account balance below minimum error to increase", "account", funderPK, "prevCount", prevFunderAccountBalanceBelowMinimumCount, "currentCount", int(e.Value)) + return false + } } - log.Debug("--> Waiting for funder account balance below minimum error to increase", "account", funderPK, "prevCount", prevFunderAccountBalanceBelowMinimumCount, "currentCount", int(errors[0].Value)) + log.Debug("--> Waiting for funder account balance below minimum error to appear", "account", funderPK) return false }, 60*time.Second, 5*time.Second) diff --git a/e2e/internal/devnet/smartcontract_init.go b/e2e/internal/devnet/smartcontract_init.go index d8a8d78ee..5c418526e 100644 --- a/e2e/internal/devnet/smartcontract_init.go +++ b/e2e/internal/devnet/smartcontract_init.go @@ -85,7 +85,7 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { doublezero --version doublezero init echo - + doublezero global-config authority set --activator-authority me --sentinel-authority me echo @@ -97,7 +97,7 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { doublezero global-config get echo - doublezero global-config authority set --activator-authority me --sentinel-authority me + doublezero global-config authority set --activator-authority me --sentinel-authority me # Populate location information onchain. echo "==> Populating location information onchain" @@ -144,6 +144,12 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { err = poll.Until(ctx, func() (bool, error) { data, err := client.GetProgramData(ctx) if err != nil { + // GetProgramData returns an error when the program has no accounts yet, + // which is expected before initialization completes. Continue polling. + if strings.Contains(err.Error(), "GetProgramAccounts returned empty result") { + dn.log.Debug("--> Waiting for program accounts to be created") + return false, nil + } return false, fmt.Errorf("failed to load serviceability program client: %w", err) } config := data.Config diff --git a/smartcontract/sdk/go/serviceability/client.go b/smartcontract/sdk/go/serviceability/client.go index 4ca39d815..765c2826a 100644 --- a/smartcontract/sdk/go/serviceability/client.go +++ b/smartcontract/sdk/go/serviceability/client.go @@ -2,6 +2,7 @@ package serviceability import ( "context" + "fmt" "github.com/gagliardetto/solana-go" ) @@ -37,6 +38,10 @@ func (c *Client) GetProgramData(ctx context.Context) (*ProgramData, error) { return nil, err } + if len(out) == 0 { + return nil, fmt.Errorf("GetProgramAccounts returned empty result for program %s", c.programID) + } + // We need to re-init these fields to prevent appending if this client is reused // and Load() is called multiple times. config := Config{} diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index b859a1c1b..3928695ff 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -93,11 +93,15 @@ var programconfigPayload = ` ` type mockSolanaClient struct { - payload string - pubkey solana.PublicKey + payload string + pubkey solana.PublicKey + returnEmpty bool } func (m *mockSolanaClient) GetProgramAccounts(context.Context, solana.PublicKey) (rpc.GetProgramAccountsResult, error) { + if m.returnEmpty { + return []*rpc.KeyedAccount{}, nil + } data, err := hex.DecodeString(strings.ReplaceAll(m.payload, "\n", "")) if err != nil { return nil, err @@ -431,3 +435,21 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { } } + +func TestSDK_Serviceability_GetProgramData_EmptyResult(t *testing.T) { + programID := solana.MustPublicKeyFromBase58("11111111111111111111111111111111") + client := &Client{ + rpc: &mockSolanaClient{returnEmpty: true}, + programID: programID, + } + + _, err := client.GetProgramData(t.Context()) + if err == nil { + t.Fatal("expected error for empty GetProgramAccounts result, got nil") + } + + expectedErrSubstring := "GetProgramAccounts returned empty result" + if !strings.Contains(err.Error(), expectedErrSubstring) { + t.Fatalf("expected error to contain %q, got: %v", expectedErrSubstring, err) + } +}