Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OCT-1284 Create an IPFS implementation with client failover #26

Merged
merged 6 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/tpl-deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ env:
TESTNET_RPC_URL: "${{ secrets.TESTNET_RPC_URL }}"
ETHERSCAN_API_KEY: "${{ secrets.ETHERSCAN_API_KEY }}"
VITE_ALCHEMY_ID: "${{ secrets.VITE_ALCHEMY_ID }}"
IPFS_GATEWAY: "${{ vars.IPFS_GATEWAY }}"
# ----------------------------------------------------------------------------
# CI/CD
GCP_DOCKER_IMAGES_REGISTRY_SERVICE_ACCOUNT: "${{ secrets.GCP_DOCKER_IMAGES_REGISTRY_SERVICE_ACCOUNT }}"
Expand Down
4 changes: 1 addition & 3 deletions ci/argocd/templates/octant-application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ spec:
namespace: $DEPLOYMENT_ID
sources:
- repoURL: 'https://gitlab.com/api/v4/projects/48137258/packages/helm/devel'
targetRevision: 0.2.37
targetRevision: 0.2.38
chart: octant
helm:
parameters:
Expand All @@ -33,8 +33,6 @@ spec:
value: '$NETWORK_NAME'
- name: 'webClient.hideCurrentProjectsOutsideAW'
value: 'false'
- name: 'webClient.ipfsGateway'
value: '$IPFS_GATEWAY'
## Graph Node
- name: graphNode.graph.env.NETWORK
value: '$NETWORK_NAME'
Expand Down
3 changes: 2 additions & 1 deletion client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Ensure that the `.env` file is present. See `.env.template`.

1. `VITE_NETWORK` sets network used by the application. Supported values are 'Local', 'Mainnet', 'Sepolia'. Whenever different value is set, app uses 'Sepolia' network config.
2. `VITE_JSON_RPC_ENDPOINT`: when provided, app uses first JSON RPC provided with this endpint. When it's not provided, app uses alchemy provider first.
3. `areCurrentEpochsProjectsHiddenOutsideAllocationWindow` when set to 'true' makes current epoch's projects hidden when allocation window is closed.
3. `VITE_ARE_CURRENT_EPOCHS_PROJECTS_HIDDEN_OUTSIDE_ALLOCATION_WINDOW` when set to 'true' makes current epoch's projects hidden when allocation window is closed.
4. `VITE_IPFS_GATEWAYS` is an array of URLs separated by strings sorted by priority with providers the app should try to fetch the data about projects from. When fetching from the last fails client shows error toast message. Each URL should end with a forward slash (`/`).

`yanr generate-abi-typings` is used to generate typings for proposals ABIs that we have in codebase. In these typings custom adjustments are added, e.g. in some places `string` is wrongly instead of `BigInt`. Linter is also disabled there. Since ABIs do not change, this command doesn't need to rerun.

Expand Down
64 changes: 43 additions & 21 deletions client/cypress/e2e/proposal.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ const getButtonAddToAllocate = (): Chainable<any> => {
return proposalView.find('[data-test=ProposalListItemHeader__ButtonAddToAllocate]');
};

const checkProposalItemElements = (): Chainable<any> => {
cy.get('[data-test^=ProposalsView__ProposalsListItem').first().click();
const proposalView = cy.get('[data-test=ProposalListItem').first();
proposalView.get('[data-test=ProposalListItemHeader__Img]').should('be.visible');
proposalView.get('[data-test=ProposalListItemHeader__name]').should('be.visible');
getButtonAddToAllocate().should('be.visible');
proposalView.get('[data-test=ProposalListItemHeader__Button]').should('be.visible');
proposalView.get('[data-test=ProposalListItem__Description]').should('be.visible');

cy.get('[data-test=ProposalListItem__Donors]')
.first()
.scrollIntoView({ offset: { left: 0, top: 100 } });

cy.get('[data-test=ProposalListItem__Donors]').first().should('be.visible');
cy.get('[data-test=ProposalListItem__Donors__DonorsHeader__count]')
.first()
.should('be.visible')
.should('have.text', '0');
return cy
.get('[data-test=ProposalListItem__Donors__noDonationsYet]')
.first()
.should('be.visible');
};

Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight }) => {
describe(`proposal: ${device}`, { viewportHeight, viewportWidth }, () => {
let proposalNames: string[] = [];
Expand Down Expand Up @@ -40,27 +64,25 @@ Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight }) =>
});

it('entering proposal view renders all its elements', () => {
cy.get('[data-test^=ProposalsView__ProposalsListItem').first().click();
const proposalView = cy.get('[data-test=ProposalListItem').first();
proposalView.get('[data-test=ProposalListItemHeader__Img]').should('be.visible');
proposalView.get('[data-test=ProposalListItemHeader__name]').should('be.visible');
getButtonAddToAllocate().should('be.visible');
proposalView.get('[data-test=ProposalListItemHeader__Button]').should('be.visible');
proposalView.get('[data-test=ProposalListItem__Description]').should('be.visible');

cy.get('[data-test=ProposalListItem__Donors]')
.first()
.scrollIntoView({ offset: { left: 0, top: 100 } });

cy.get('[data-test=ProposalListItem__Donors]').first().should('be.visible');
cy.get('[data-test=ProposalListItem__Donors__DonorsHeader__count]')
.first()
.should('be.visible')
.should('have.text', '0');
return cy
.get('[data-test=ProposalListItem__Donors__noDonationsYet]')
.first()
.should('be.visible');
checkProposalItemElements();
});

it('entering proposal view renders all its elements with fallback IPFS provider', () => {
cy.intercept('GET', '**/ipfs/**', req => {
if (req.url.includes('infura')) {
req.destroy();
}
});

checkProposalItemElements();
});

it('entering proposal view renders all its elements with fallback IPFS provider', () => {
cy.intercept('GET', '**/ipfs/**', req => {
req.destroy();
});

cy.get('[data-test=Toast--ipfsMessage').should('be.visible');
});

it('entering proposal view allows to add it to allocation and remove, triggering change of the icon, change of the number in navbar', () => {
Expand Down
20 changes: 18 additions & 2 deletions client/src/api/calls/proposals.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import env from 'env';
import apiService from 'services/apiService';

async function getFirstValid(arrayUrls: string[], baseUri: string, index: number): Promise<any> {
return apiService
.get(`${arrayUrls[index]}${baseUri}`)
.then(({ data }) => ({
data,
ipfsGatewayUsed: arrayUrls[index],
}))
.catch(e => {
if (index < arrayUrls.length - 1) {
return getFirstValid(arrayUrls, baseUri, index + 1);
}
throw e;
});
}

export function apiGetProposal(baseUri: string): Promise<any> {
const { ipfsGateway } = env;
return apiService.get(`${ipfsGateway}${baseUri}`).then(({ data }) => data);
const { ipfsGateways } = env;

return getFirstValid(ipfsGateways.split(','), baseUri, 0).then(({ data }) => data);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const AllocationItem: FC<AllocationItemProps> = ({
const isGweiRange = individualReward?.lt(GWEI_5) ?? false;

const [isInputFocused, setIsInputFocused] = useState(false);
const { ipfsGateway } = env;
const { ipfsGateways } = env;
const { isConnected } = useAccount();
const { data: currentEpoch } = useCurrentEpoch();
const { isFetching: isFetchingRewardsThreshold } = useProposalRewardsThreshold();
Expand Down Expand Up @@ -225,7 +225,7 @@ const AllocationItem: FC<AllocationItemProps> = ({
<Img
className={styles.image}
dataTest="ProposalItem__imageProfile"
src={`${ipfsGateway}${profileImageSmall}`}
sources={ipfsGateways.split(',').map(element => `${element}${profileImageSmall}`)}
/>
<div className={styles.nameAndRewards}>
<div className={styles.name}>{name}</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import React, { FC, memo } from 'react';

import Img from 'components/ui/Img/Img';
import env from 'env';
import useProposalsIpfs from 'hooks/queries/useProposalsIpfs';

import styles from './MetricsProjectsListItem.module.scss';
import MetricsProjectsListItemProps from './types';

const MetricsProjectsListItem: FC<MetricsProjectsListItemProps> = ({ address, epoch, value }) => {
const { ipfsGateway } = env;
const { ipfsGateways } = env;
const { data: proposalsIpfs } = useProposalsIpfs([address], epoch);

const image = proposalsIpfs.at(0)?.profileImageSmall;
const name = proposalsIpfs.at(0)?.name;

return (
<div className={styles.root}>
<img alt="project logo" className={styles.image} src={`${ipfsGateway}${image}`} />
<Img
alt="project logo"
className={styles.image}
sources={ipfsGateways.split(',').map(element => `${element}${image}`)}
/>
<div className={styles.name}>{name}</div>
<div className={styles.value}>{value}</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const ProposalListItemHeader: FC<ProposalListItemHeaderProps> = ({
website,
epoch,
}) => {
const { ipfsGateway } = env;
const { ipfsGateways } = env;
const { i18n } = useTranslation('translation', { keyPrefix: 'views.proposal' });
const { epoch: epochUrl } = useParams();
const { data: userAllocations } = useUserAllocations(epoch);
Expand Down Expand Up @@ -82,7 +82,7 @@ const ProposalListItemHeader: FC<ProposalListItemHeaderProps> = ({
<Img
className={styles.imageProfile}
dataTest="ProposalListItemHeader__Img"
src={`${ipfsGateway}${profileImageSmall}`}
sources={ipfsGateways.split(',').map(element => `${element}${profileImageSmall}`)}
/>
<div className={styles.actionsWrapper}>
<Tooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ProposalsListItem: FC<ProposalsListItemProps> = ({
epoch,
proposalIpfsWithRewards,
}) => {
const { ipfsGateway } = env;
const { ipfsGateways } = env;
const { address, isLoadingError, profileImageSmall, name, introDescription } =
proposalIpfsWithRewards;
const navigate = useNavigate();
Expand Down Expand Up @@ -94,7 +94,7 @@ const ProposalsListItem: FC<ProposalsListItemProps> = ({
? 'ProposalsListItem__imageProfile--archive'
: 'ProposalsListItem__imageProfile'
}
src={`${ipfsGateway}${profileImageSmall}`}
sources={ipfsGateways.split(',').map(element => `${element}${profileImageSmall}`)}
/>
{((isAllocatedTo && isArchivedProposal) || !isArchivedProposal) && (
<ButtonAddToAllocate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const ProjectAllocationDetailRow: FC<ProjectAllocationDetailRowProps> = ({
amount,
epoch,
}) => {
const { ipfsGateway } = env;
const { ipfsGateways } = env;
const {
data: { displayCurrency, isCryptoMainValueDisplay },
} = useSettingsStore(({ data }) => ({
Expand All @@ -39,7 +39,9 @@ const ProjectAllocationDetailRow: FC<ProjectAllocationDetailRowProps> = ({
<div className={styles.imageAndName}>
<Img
className={styles.image}
src={`${ipfsGateway}${proposalIpfs[0].profileImageSmall!}`}
sources={ipfsGateways
.split(',')
.map(element => `${element}${proposalIpfs[0].profileImageSmall!}`)}
/>
<div className={styles.name}>{proposalIpfs[0].name}</div>
</div>
Expand Down
38 changes: 34 additions & 4 deletions client/src/components/ui/Img/Img.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
import React, { FC } from 'react';
import React, { FC, useState } from 'react';

import ImgProps from './types';

const Img: FC<ImgProps> = ({ alt = '', dataTest, ...props }) => (
<img alt={alt} data-test={dataTest} {...props} />
);
const Img: FC<ImgProps> = ({ alt = '', dataTest, src, sources, ...props }) => {
const getSrcLocal = () => {
if (src) {
return src;
}
if (sources) {
return sources[0];
}
return '';
};

const [srcLocal, setSrcLocal] = useState<string>(getSrcLocal());

return (
<img
alt={alt}
data-test={dataTest}
onError={() => {
if (src !== undefined || !sources) {
return;
}

const indexOfCurrentSource = sources.indexOf(srcLocal);

if (sources.indexOf(srcLocal) < sources.length - 1) {
setSrcLocal(sources[indexOfCurrentSource + 1]);
}
}}
src={srcLocal}
{...props}
/>
);
};

export default Img;
3 changes: 2 additions & 1 deletion client/src/components/ui/Img/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export default interface ImgProps {
className?: string;
dataTest?: string;
onLoad?: () => void;
src: string;
sources?: string[];
src?: string;
}
4 changes: 2 additions & 2 deletions client/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const envViteKeys: EnvViteKeys = {
contractProposalsAddress: 'VITE_PROPOSALS_ADDRESS',
contractVaultAddress: 'VITE_VAULT_ADDRESS',
cryptoValuesEndpoint: 'VITE_CRYPTO_VALUES_ENDPOINT',
ipfsGateway: 'VITE_IPFS_GATEWAY',
ipfsGateways: 'VITE_IPFS_GATEWAYS',
jsonRpcEndpoint: 'VITE_JSON_RPC_ENDPOINT',
network: 'VITE_NETWORK',
serverEndpoint: 'VITE_SERVER_ENDPOINT',
Expand Down Expand Up @@ -41,7 +41,7 @@ const env: Env = {
// @ts-expect-error TS does not understand the way vite imports envs.
cryptoValuesEndpoint: import.meta.env[envViteKeys.cryptoValuesEndpoint],
// @ts-expect-error TS does not understand the way vite imports envs.
ipfsGateway: import.meta.env[envViteKeys.ipfsGateway],
ipfsGateways: import.meta.env[envViteKeys.ipfsGateways],
// @ts-expect-error TS does not understand the way vite imports envs.
jsonRpcEndpoint: import.meta.env[envViteKeys.jsonRpcEndpoint],
// @ts-expect-error TS does not understand the way vite imports envs.
Expand Down
18 changes: 10 additions & 8 deletions client/src/hooks/queries/useProposalsIpfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,23 @@ export default function useProposalsIpfs(
);
const { refetch } = useProposalsContract(epoch);

const proposalsIpfsResults: UseQueryResult<BackendProposal>[] = useQueries({
queries: (proposalsAddresses || []).map(address => ({
enabled: !!address && !!proposalsCid && (currentEpoch !== undefined || epoch !== undefined),
queryFn: () => apiGetProposal(`${proposalsCid}/${address}`),
queryKey: QUERY_KEYS.proposalsIpfsResults(address, epoch ?? currentEpoch!),
retry: false,
})),
});
const proposalsIpfsResults: UseQueryResult<BackendProposal & { ipfsGatewayUsed: string }>[] =
useQueries({
queries: (proposalsAddresses || []).map(address => ({
enabled: !!address && !!proposalsCid && (currentEpoch !== undefined || epoch !== undefined),
queryFn: () => apiGetProposal(`${proposalsCid}/${address}`),
queryKey: QUERY_KEYS.proposalsIpfsResults(address, epoch ?? currentEpoch!),
retry: false,
})),
});

const isAnyError = proposalsIpfsResults.some(element => element.isError);
useEffect(() => {
if (!isAnyError) {
return;
}
toastService.showToast({
dataTest: 'Toast--ipfsMessage',
message: t('ipfs.message'),
name: 'ipfsError',
type: 'error',
Expand Down
4 changes: 2 additions & 2 deletions client/src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type EnvViteKeys = {
contractProposalsAddress: 'VITE_PROPOSALS_ADDRESS';
contractVaultAddress: 'VITE_VAULT_ADDRESS';
cryptoValuesEndpoint: 'VITE_CRYPTO_VALUES_ENDPOINT';
ipfsGateway: 'VITE_IPFS_GATEWAY';
ipfsGateways: 'VITE_IPFS_GATEWAYS';
jsonRpcEndpoint: 'VITE_JSON_RPC_ENDPOINT';
network: 'VITE_NETWORK';
serverEndpoint: 'VITE_SERVER_ENDPOINT';
Expand All @@ -25,7 +25,7 @@ export type Env = {
contractProposalsAddress: string;
contractVaultAddress: string;
cryptoValuesEndpoint: string;
ipfsGateway: string;
ipfsGateways: string;
jsonRpcEndpoint?: string;
network: 'Local' | 'Mainnet' | 'Sepolia';
serverEndpoint: string;
Expand Down
Loading