Commit 4fde5d21 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #1709 from blockscout/dapps-security-score

Dapps security score
parents 586cfbce 946f77de
......@@ -10,13 +10,19 @@ const submitFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM');
const suggestIdeasFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM');
const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL');
const adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL');
const title = 'Marketplace';
const config: Feature<(
{ configUrl: string } |
{ api: { endpoint: string; basePath: string } }
) & { submitFormUrl: string; categoriesUrl: string | undefined; suggestIdeasFormUrl: string | undefined }
) & {
submitFormUrl: string;
categoriesUrl: string | undefined;
suggestIdeasFormUrl: string | undefined;
securityReportsUrl: string | undefined;
}
> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
if (configUrl) {
......@@ -27,6 +33,7 @@ const config: Feature<(
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
});
} else if (adminServiceApiHost) {
return Object.freeze({
......@@ -35,6 +42,7 @@ const config: Feature<(
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
api: {
endpoint: adminServiceApiHost,
basePath: '',
......
......@@ -48,6 +48,7 @@ NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://gist.githubusercontent.com/maxaleks/ce5c7e3de53e8f5b240b88265daf5839/raw/328383c958a8f7ecccf6d50c953bcdf8ab3faa0a/security_reports_goerli_test.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com
......
......@@ -16,6 +16,7 @@ ASSETS_DIR="$1"
ASSETS_ENVS=(
"NEXT_PUBLIC_MARKETPLACE_CONFIG_URL"
"NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL"
"NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL"
"NEXT_PUBLIC_FEATURED_NETWORKS"
"NEXT_PUBLIC_FOOTER_LINKS"
"NEXT_PUBLIC_NETWORK_LOGO"
......
......@@ -37,6 +37,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
'NEXT_PUBLIC_FEATURED_NETWORKS',
'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL',
'NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL',
'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL',
'NEXT_PUBLIC_FOOTER_LINKS',
];
......
......@@ -14,7 +14,7 @@ import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders }
import type { ContractCodeIde } from '../../../types/client/contract';
import { GAS_UNITS } from '../../../types/client/gasTracker';
import type { GasUnit } from '../../../types/client/gasTracker';
import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReportRaw, MarketplaceAppSecurityReport } from '../../../types/client/marketplace';
import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
import { ROLLUP_TYPES } from '../../../types/client/rollup';
......@@ -85,6 +85,65 @@ const marketplaceAppSchema: yup.ObjectSchema<MarketplaceAppOverview> = yup
priority: yup.number(),
});
const issueSeverityDistributionSchema: yup.ObjectSchema<MarketplaceAppSecurityReport['overallInfo']['issueSeverityDistribution']> = yup
.object({
critical: yup.number().required(),
gas: yup.number().required(),
high: yup.number().required(),
informational: yup.number().required(),
low: yup.number().required(),
medium: yup.number().required(),
});
const solidityscanReportSchema: yup.ObjectSchema<MarketplaceAppSecurityReport['contractsData'][number]['solidityScanReport']> = yup
.object({
contractname: yup.string().required(),
scan_status: yup.string().required(),
scan_summary: yup
.object({
issue_severity_distribution: issueSeverityDistributionSchema.required(),
lines_analyzed_count: yup.number().required(),
scan_time_taken: yup.number().required(),
score: yup.string().required(),
score_v2: yup.string().required(),
threat_score: yup.string().required(),
})
.required(),
scanner_reference_url: yup.string().test(urlTest).required(),
});
const contractDataSchema: yup.ObjectSchema<MarketplaceAppSecurityReport['contractsData'][number]> = yup
.object({
address: yup.string().required(),
isVerified: yup.boolean().required(),
solidityScanReport: solidityscanReportSchema.nullable().notRequired(),
});
const chainsDataSchema = yup.lazy((objValue) => {
let schema = yup.object();
Object.keys(objValue).forEach((key) => {
schema = schema.shape({
[key]: yup.object({
overallInfo: yup.object({
verifiedNumber: yup.number().required(),
totalContractsNumber: yup.number().required(),
solidityScanContractsNumber: yup.number().required(),
securityScore: yup.number().required(),
issueSeverityDistribution: issueSeverityDistributionSchema.required(),
}).required(),
contractsData: yup.array().of(contractDataSchema).required(),
}),
});
});
return schema;
});
const securityReportSchema: yup.ObjectSchema<MarketplaceAppSecurityReportRaw> = yup
.object({
appName: yup.string().required(),
chainsData: chainsDataSchema,
});
const marketplaceSchema = yup
.object()
.shape({
......@@ -125,6 +184,16 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL: yup
.array()
.json()
.of(securityReportSchema)
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
});
const beaconChainSchema = yup
......
......@@ -3,4 +3,5 @@ NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://example.com
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://example.com
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://example.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
......@@ -61,8 +61,10 @@ frontend:
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_AUTH_URL: https://blockscout-main.k8s-dev.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED: true
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM: https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL: https://gist.githubusercontent.com/maxaleks/ce5c7e3de53e8f5b240b88265daf5839/raw/328383c958a8f7ecccf6d50c953bcdf8ab3faa0a/security_reports_goerli_test.json
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli
......
......@@ -450,6 +450,7 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM | `string` | Link to form where users can suggest ideas for the marketplace | - | - | `https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | See in [Blockchain parameters](ENVS.md#blockchain-parameters) section | Required | - | `https://core.poa.network` |
| NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL | `string` | URL of configuration file (`.json` format only) which contains the list of categories to be displayed on the marketplace page in the specified order. If no URL is provided, then the list of categories will be compiled based on the `categories` fields from the marketplace (apps) configuration file | - | - | `https://example.com/marketplace_categories.json` |
| NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL | `string` | URL of configuration file (`.json` format only) which contains app security reports for displaying security scores on the Marketplace page | - | - | `https://example.com/marketplace_security_reports.json` |
#### Marketplace app configuration properties
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.167 4.167c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667V7.5c0 .92-.747 1.667-1.667 1.667H3.834c-.92 0-1.667-.747-1.667-1.667V4.167Zm1.667.5a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5V7a.5.5 0 0 1-.5.5H4.334a.5.5 0 0 1-.5-.5V4.667ZM10.5 4.27c0 .519.42.938.938.938h4.792a.938.938 0 0 0 0-1.875h-4.792a.937.937 0 0 0-.938.938Zm0 3.334c0 .518.42.938.938.938h4.792a.938.938 0 0 0 0-1.875h-4.792a.937.937 0 0 0-.938.937ZM2.167 12.5c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.747 1.667-1.667 1.667H3.834c-.92 0-1.667-.746-1.667-1.667V12.5Zm1.667.5a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H4.334a.5.5 0 0 1-.5-.5V13Zm6.666-.396c0 .518.42.938.938.938h4.792a.938.938 0 0 0 0-1.875h-4.792a.937.937 0 0 0-.938.937Zm0 3.334c0 .517.42.937.938.937h4.792a.938.938 0 0 0 0-1.875h-4.792a.937.937 0 0 0-.938.938Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 4.167c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667V7.5c0 .92-.747 1.667-1.667 1.667H4.667C3.747 9.167 3 8.42 3 7.5V4.167Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5V7a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V4.667ZM3 12.5c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.747 1.667-1.667 1.667H4.667C3.747 17.5 3 16.754 3 15.833V12.5Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V13ZM13 2.5c-.92 0-1.667.746-1.667 1.667V7.5c0 .92.746 1.667 1.667 1.667h3.333C17.253 9.167 18 8.42 18 7.5V4.167c0-.92-.746-1.667-1.667-1.667H13Zm3.333 2.167a.5.5 0 0 0-.5-.5H13.5a.5.5 0 0 0-.5.5V7a.5.5 0 0 0 .5.5h2.333a.5.5 0 0 0 .5-.5V4.667ZM11.333 12.5c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.746 1.667-1.667 1.667H13c-.92 0-1.667-.746-1.667-1.667V12.5ZM13 13a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H13.5a.5.5 0 0 1-.5-.5V13Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 26 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26" height="12" rx="2" fill="#F56565"/>
<path d="M3.028 9V1.727h1.061V4.43h.064c.062-.114.15-.245.267-.394.116-.15.277-.28.483-.391.205-.114.478-.17.816-.17.44 0 .834.11 1.18.333.345.223.616.544.812.963.2.419.299.923.299 1.512 0 .59-.098 1.095-.295 1.517a2.3 2.3 0 0 1-.81.97 2.097 2.097 0 0 1-1.175.337c-.332 0-.603-.056-.813-.167a1.54 1.54 0 0 1-.49-.391 2.957 2.957 0 0 1-.274-.398h-.089V9H3.028Zm1.04-2.727c0 .383.056.72.167 1.008.111.29.272.515.483.679.21.16.469.241.774.241.317 0 .582-.084.795-.252.214-.17.375-.401.483-.693.112-.29.167-.619.167-.983 0-.36-.054-.683-.163-.97a1.484 1.484 0 0 0-.483-.678c-.213-.166-.48-.249-.799-.249-.308 0-.568.08-.781.238-.21.159-.37.38-.48.664a2.77 2.77 0 0 0-.163.995Zm7.485 2.837c-.538 0-1-.115-1.389-.344a2.336 2.336 0 0 1-.894-.977c-.209-.421-.313-.915-.313-1.48 0-.56.104-1.052.313-1.478.21-.426.504-.759.88-.998.379-.239.822-.359 1.328-.359.308 0 .606.051.895.153.29.102.548.262.778.48.23.217.41.5.543.848.133.346.2.766.2 1.26v.377H9.556v-.795h3.296c0-.28-.057-.527-.17-.742a1.29 1.29 0 0 0-1.198-.703c-.298 0-.558.073-.78.22-.221.144-.391.334-.512.568a1.641 1.641 0 0 0-.178.756v.622c0 .364.064.674.192.93.13.256.311.451.543.586.232.133.503.199.814.199.2 0 .384-.028.55-.085a1.143 1.143 0 0 0 .707-.692l1.005.18a1.82 1.82 0 0 1-.434.778 2.1 2.1 0 0 1-.777.515 2.91 2.91 0 0 1-1.062.181Zm6.064-5.565v.853h-2.979v-.853h2.98Zm-2.18-1.306h1.062v5.16c0 .205.03.36.092.465.062.101.14.171.238.21.1.035.207.052.323.052.085 0 .16-.005.224-.017l.149-.029.192.877a2.08 2.08 0 0 1-.689.114 1.87 1.87 0 0 1-.781-.15 1.34 1.34 0 0 1-.586-.482c-.15-.218-.224-.491-.224-.82v-5.38Zm4.942 6.882c-.345 0-.658-.064-.937-.192a1.58 1.58 0 0 1-.664-.565c-.161-.246-.242-.548-.242-.905 0-.308.06-.561.178-.76a1.31 1.31 0 0 1 .48-.472c.2-.116.425-.204.674-.263.248-.06.502-.104.76-.135l.795-.092c.204-.027.352-.068.444-.125.092-.057.139-.149.139-.277V5.31c0-.31-.088-.55-.263-.72-.173-.171-.431-.256-.774-.256-.358 0-.64.08-.845.238-.204.156-.345.33-.423.522l-.998-.228a1.92 1.92 0 0 1 .519-.802c.23-.206.493-.355.791-.448.299-.094.612-.142.941-.142.218 0 .45.026.693.079.246.05.476.142.689.277.215.134.392.327.53.578.136.249.205.572.205.97V9h-1.037v-.746h-.043a1.512 1.512 0 0 1-.308.405 1.642 1.642 0 0 1-.53.33 2.053 2.053 0 0 1-.774.132Zm.231-.853c.294 0 .545-.058.753-.174.21-.116.37-.267.48-.454.11-.19.166-.392.166-.607V6.33a.552.552 0 0 1-.22.106 3.43 3.43 0 0 1-.366.082l-.401.06c-.13.017-.24.03-.327.043a2.63 2.63 0 0 0-.564.131.97.97 0 0 0-.405.266.665.665 0 0 0-.15.455c0 .263.098.462.292.597.194.132.441.198.742.198Z" fill="#fff"/>
</svg>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="12" height="12" rx="2" fill="#F56565"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.42 2.494c-.373-.313-.896-.49-1.5-.49a2.028 2.028 0 0 0-1.52.567 1.948 1.948 0 0 0-.453.685c-.1.257-.143.531-.127.805V10h.79V8.508a2.46 2.46 0 0 0 1.59.549c.3.01.6-.04.88-.147a2.21 2.21 0 0 0 .751-.48c.215-.208.383-.457.495-.733.112-.274.165-.568.157-.863a1.925 1.925 0 0 0-.309-1.09 1.978 1.978 0 0 0-.717-.664c.157-.147.287-.32.381-.514.12-.245.18-.515.176-.787 0-.523-.22-.97-.594-1.285Zm-2.023.36c.163-.06.338-.085.512-.074h.008c.413 0 .741.105.964.28.22.171.343.417.343.72v.004a.944.944 0 0 1-.288.713.99.99 0 0 1-.732.283l-.115-.004v.781h.112c.467 0 .84.13 1.094.351.253.219.398.534.398.927v.004a1.357 1.357 0 0 1-.418 1.04 1.422 1.422 0 0 1-1.069.4h-.012a1.532 1.532 0 0 1-1.109-.393 1.437 1.437 0 0 1-.475-1.052V4.05a1.165 1.165 0 0 1 .352-.923c.123-.12.272-.214.435-.274Z" fill="#fff"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.4 2a1.8 1.8 0 0 0-1.8 1.8v14.8A1.4 1.4 0 0 0 3 20h1.6v-1.326H3V3.516h1.6V2H3.4Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087-.252.289-.593.451-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm.948 1.088h6.87v4.497c0 .388.315.702.702.702h4.485v11.725H4.94V1.538Zm8.274.59 2.791 3.205h-2.79V2.128Z" fill="currentColor"/>
<rect x="7.2" y="14.3" width="7.8" height="1.2" rx=".6" fill="currentColor"/>
<rect x="7.2" y="12" width="7.8" height="1.2" rx=".6" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.4 2a1.8 1.8 0 0 0-1.8 1.8v14.8A1.4 1.4 0 0 0 3 20h1.6v-1.326H3V3.516h1.6V2H3.4Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087-.252.289-.593.451-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm8.709 1.088H4.94v16.924h12.057V6.472l-4.296-4.934Z" fill="currentColor"/>
<path d="m7.9 13.357 2.2 2.357L14.5 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.512.42c.388 0 .702.315.702.703v4.21h4.21a.702.702 0 1 1 0 1.404h-4.912a.702.702 0 0 1-.702-.702V1.123c0-.388.315-.702.702-.702Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.76 17.333a.603.603 0 0 1-.294-.075L9 14.798l-4.467 2.46a.606.606 0 0 1-.663-.051.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.854-5.21-3.616-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.301c.09-.08.2-.131.316-.149l4.994-.76 2.234-4.74a.65.65 0 0 1 .232-.269.61.61 0 0 1 .666 0c.1.065.18.158.233.269l2.233 4.74 4.994.76c.117.018.226.07.316.149a.66.66 0 0 1 .193.3.69.69 0 0 1-.16.678l-3.614 3.69.853 5.21a.692.692 0 0 1-.14.537.634.634 0 0 1-.216.173.605.605 0 0 1-.265.061Z" fill="currentColor"/>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3c.09-.08.199-.131.315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 18 18" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.76 17.333a.604.604 0 0 1-.294-.075l.293.075Zm.004 0a.625.625 0 0 0 .477-.234.671.671 0 0 0 .14-.538l-.853-5.21 3.615-3.689a.69.69 0 0 0 .16-.677.663.663 0 0 0-.194-.301.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.157-.117l-2.186-4.64a.65.65 0 0 0-.233-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.232.269l-2.186 4.64a.208.208 0 0 1-.158.117l-4.884.743a.618.618 0 0 0-.316.149.663.663 0 0 0-.193.3.69.69 0 0 0 .16.678l3.54 3.614a.208.208 0 0 1 .058.18l-.837 5.105a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.286.613.613 0 0 0 .663.05L8.9 14.854a.208.208 0 0 1 .2 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.307.216L8.9 12.95a.208.208 0 0 1 .2 0l2.923 1.61a.208.208 0 0 0 .306-.216l-.566-3.452a.208.208 0 0 1 .057-.18l2.486-2.536a.208.208 0 0 0-.118-.351l-3.408-.519a.208.208 0 0 1-.157-.117L9.189 4.145a.208.208 0 0 0-.377 0L7.378 7.19a.208.208 0 0 1-.158.117l-3.408.519a.208.208 0 0 0-.117.351l2.485 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.093 2.99h-.003.003Z"/>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0c.09 0 .18-.021.262-.061.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052l4.366-2.405a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/>
</svg>
......@@ -7,6 +7,7 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
security_score_exp: boolean;
}
export const growthBook = (() => {
......
......@@ -98,8 +98,12 @@ Type extends EventTypes.PAGE_WIDGET ? (
{
'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)';
} | {
'Type': 'Favorite app' | 'More button';
'Type': 'Favorite app' | 'More button' | 'Security score' | 'Total contracts' | 'Verified contracts' | 'Analyzed contracts';
'Info': string;
'Source': 'Discovery view' | 'Security view' | 'App modal' | 'App page' | 'Security score popup';
} | {
'Type': 'Security score';
'Source': 'Analyzed contracts popup';
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
export const securityReports = [
{
appName: 'token-approval-tracker',
doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet',
chainsData: {
'1': {
overallInfo: {
verifiedNumber: 1,
totalContractsNumber: 1,
solidityScanContractsNumber: 1,
securityScore: 87.5,
issueSeverityDistribution: {
critical: 4,
gas: 1,
high: 0,
informational: 4,
low: 2,
medium: 0,
},
},
contractsData: [
{
address: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE',
isVerified: true,
solidityScanReport: {
connection_id: '',
contract_address: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE',
contract_chain: 'optimism',
contract_platform: 'blockscout',
contract_url: 'http://optimism.blockscout.com/address/0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE',
contractname: 'LiFiDiamond',
is_quick_scan: true,
node_reference_id: null,
request_type: 'threat_scan',
scanner_reference_url: 'http://solidityscan.com/quickscan/0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE/blockscout/eth?ref=blockscout',
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
critical: 0,
gas: 1,
high: 0,
informational: 4,
low: 2,
medium: 0,
},
lines_analyzed_count: 72,
scan_time_taken: 1,
score: '4.38',
score_v2: '87.50',
threat_score: '100.00',
},
},
},
],
},
},
},
];
......@@ -4,6 +4,8 @@
| "ABI_slim"
| "ABI"
| "API"
| "apps_list"
| "apps_xs"
| "apps"
| "arrows/down-right"
| "arrows/east-mini"
......@@ -11,6 +13,8 @@
| "arrows/north-east"
| "arrows/south-east"
| "arrows/up-down"
| "beta_xs"
| "beta"
| "blob"
| "blobs/image"
| "blobs/raw"
......@@ -27,6 +31,8 @@
| "collection"
| "contract_verified"
| "contract"
| "contracts_verified"
| "contracts"
| "copy"
| "cross"
| "delete"
......
......@@ -65,6 +65,7 @@ export const VERIFIED_CONTRACTS_COUNTERS: VerifiedContractsCounters = {
export const SOLIDITYSCAN_REPORT: SolidityscanReport = {
scan_report: {
contractname: 'BullRunners',
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
......
......@@ -178,6 +178,7 @@ export interface SmartContractVerificationError {
export type SolidityscanReport = {
scan_report: {
contractname: string;
scan_status: string;
scan_summary: {
issue_severity_distribution: {
......
import type { SolidityscanReport } from 'types/api/contract';
export type MarketplaceAppPreview = {
id: string;
external?: boolean;
......@@ -24,7 +26,45 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport;
}
export enum MarketplaceCategory {
ALL = 'All',
FAVORITES = 'Favorites',
}
export enum ContractListTypes {
ANALYZED = 'Analyzed',
ALL = 'All',
VERIFIED = 'Verified',
}
export enum MarketplaceDisplayType {
DEFAULT = 'default',
SCORES = 'scores',
}
export type MarketplaceAppSecurityReport = {
overallInfo: {
verifiedNumber: number;
totalContractsNumber: number;
solidityScanContractsNumber: number;
securityScore: number;
totalIssues?: number;
issueSeverityDistribution: SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
};
contractsData: Array<{
address: string;
isVerified: boolean;
solidityScanReport?: SolidityscanReport['scan_report'] | null;
}>;
}
export type MarketplaceAppSecurityReportRaw = {
appName: string;
chainsData: {
[chainId: string]: MarketplaceAppSecurityReport;
};
}
import {
Box,
Flex,
Text,
Grid,
Button,
chakra,
Popover,
PopoverTrigger,
PopoverBody,
PopoverContent,
useDisclosure,
Skeleton,
Center,
useColorModeValue,
Icon,
} from '@chakra-ui/react';
import { Box, Text, chakra, Icon, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { SolidityscanReport as TSolidityscanReport } from 'types/api/contract';
// This icon doesn't work properly when it is in the sprite
// Probably because of the gradient
// eslint-disable-next-line no-restricted-imports
import solidityScanIcon from 'icons/brands/solidity_scan.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { SOLIDITYSCAN_REPORT } from 'stubs/contract';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
type DistributionItem = {
id: keyof TSolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
name: string;
color: string;
}
const DISTRIBUTION_ITEMS: Array<DistributionItem> = [
{ id: 'critical', name: 'Critical', color: '#891F11' },
{ id: 'high', name: 'High', color: '#EC672C' },
{ id: 'medium', name: 'Medium', color: '#FBE74D' },
{ id: 'low', name: 'Low', color: '#68C88E' },
{ id: 'informational', name: 'Informational', color: '#A3AEBE' },
{ id: 'gas', name: 'Gas', color: '#A47585' },
];
import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton';
import SolidityscanReportDetails from 'ui/shared/solidityscanReport/SolidityscanReportDetails';
import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanReportScore';
interface Props {
className?: string;
hash: string;
}
type ItemProps = {
item: DistributionItem;
vulnerabilities: TSolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
vulnerabilitiesCount: number;
}
const SolidityScanReportItem = ({ item, vulnerabilities, vulnerabilitiesCount }: ItemProps) => {
const bgBar = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
return (
<>
<Box w={ 3 } h={ 3 } bg={ item.color } borderRadius="6px" mr={ 2 }></Box>
<Flex justifyContent="space-between" mr={ 3 }>
<Text>{ item.name }</Text>
<Text color={ vulnerabilities[item.id] > 0 ? 'text' : yetAnotherGrayColor }>{ vulnerabilities[item.id] }</Text>
</Flex>
<Box bg={ bgBar } h="10px" borderRadius="8px">
<Box bg={ item.color } w={ vulnerabilities[item.id] / vulnerabilitiesCount } h="10px" borderRadius="8px"/>
</Box>
</>
);
};
const SolidityscanReport = ({ className, hash }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
......@@ -85,31 +30,10 @@ const SolidityscanReport = ({ className, hash }: Props) => {
const score = Number(data?.scan_report.scan_summary.score_v2);
const chartGrayColor = useColorModeValue('gray.100', 'gray.700');
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
const popoverBgColor = useColorModeValue('white', 'gray.900');
const greatScoreColor = useColorModeValue('green.600', 'green.400');
const averageScoreColor = useColorModeValue('purple.600', 'purple.400');
const lowScoreColor = useColorModeValue('red.600', 'red.400');
if (isError || !score) {
return null;
}
let scoreColor;
let scoreLevel;
if (score >= 80) {
scoreColor = greatScoreColor;
scoreLevel = 'GREAT';
} else if (score >= 30) {
scoreColor = averageScoreColor;
scoreLevel = 'AVERAGE';
} else {
scoreColor = lowScoreColor;
scoreLevel = 'LOW';
}
const vulnerabilities = data?.scan_report.scan_summary.issue_severity_distribution;
const vulnerabilitiesCounts = vulnerabilities ? Object.values(vulnerabilities) : [];
const vulnerabilitiesCount = vulnerabilitiesCounts.reduce((acc, val) => acc + val, 0);
......@@ -117,24 +41,12 @@ const SolidityscanReport = ({ className, hash }: Props) => {
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Skeleton isLoaded={ !isPlaceholderData } borderRadius="base">
<Button
className={ className }
color={ scoreColor }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="SolidityScan score"
fontWeight={ 500 }
px="6px"
h="32px"
flexShrink={ 0 }
>
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 } mr={ 1 }/>
{ score }
</Button>
</Skeleton>
<SolidityscanReportButton
className={ className }
score={ score }
isLoading={ isPlaceholderData }
onClick={ onToggle }
/>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm">
......@@ -143,35 +55,11 @@ const SolidityscanReport = ({ className, hash }: Props) => {
<Icon as={ solidityScanIcon } mr={ 1 } ml="6px" w="23px" h="20px" display="inline-block" verticalAlign="middle"/>
<Text fontWeight={ 600 } display="inline-block">SolidityScan</Text>
</Box>
<Flex alignItems="center" mb={ 5 }>
<Box
w={ 12 }
h={ 12 }
bgGradient={ `conic-gradient(${ scoreColor } 0, ${ scoreColor } ${ score }%, ${ chartGrayColor } 0, ${ chartGrayColor } 100%)` }
borderRadius="24px"
position="relative"
mr={ 3 }
>
<Center position="absolute" w="38px" h="38px" top="5px" right="5px" bg={ popoverBgColor } borderRadius="20px">
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 } color={ scoreColor }/>
</Center>
</Box>
<Box>
<Flex>
<Text color={ scoreColor } fontSize="lg" fontWeight={ 500 }>{ score }</Text>
<Text color={ yetAnotherGrayColor } fontSize="lg" fontWeight={ 500 } whiteSpace="pre"> / 100</Text>
</Flex>
<Text color={ scoreColor } fontWeight={ 500 }>Security score is { scoreLevel }</Text>
</Box>
</Flex>
<SolidityscanReportScore score={ score } mb={ 5 }/>
{ vulnerabilities && vulnerabilitiesCount > 0 && (
<Box mb={ 5 }>
<Text py="7px" variant="secondary" fontSize="xs" fontWeight={ 500 }>Vulnerabilities distribution</Text>
<Grid templateColumns="20px 1fr 100px" alignItems="center" rowGap={ 2 }>
{ DISTRIBUTION_ITEMS.map(item => (
<SolidityScanReportItem item={ item } key={ item.id } vulnerabilities={ vulnerabilities } vulnerabilitiesCount={ vulnerabilitiesCount }/>
)) }
</Grid>
<SolidityscanReportDetails vulnerabilities={ vulnerabilities } vulnerabilitiesCount={ vulnerabilitiesCount }/>
</Box>
) }
<LinkExternal href={ data?.scan_report.scanner_reference_url }>View full report</LinkExternal>
......
import { Box, Text, Link, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppSecurityReport } from 'types/client/marketplace';
import config from 'configs/app';
import { apos } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton';
import SolidityscanReportDetails from 'ui/shared/solidityscanReport/SolidityscanReportDetails';
import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanReportScore';
type Props = {
id: string;
securityReport?: MarketplaceAppSecurityReport;
height?: string | undefined;
showContractList: () => void;
isLoading?: boolean;
onlyIcon?: boolean;
source: 'Security view' | 'App modal' | 'App page';
}
const AppSecurityReport = ({ id, securityReport, height, showContractList, isLoading, onlyIcon, source }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const handleButtonClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Security score', Info: id, Source: source });
onToggle();
}, [ id, source, onToggle ]);
const handleLinkClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Analyzed contracts', Info: id, Source: 'Security score popup' });
showContractList();
}, [ id, showContractList ]);
if (!securityReport && !isLoading) {
return null;
}
const {
securityScore = 0,
solidityScanContractsNumber = 0,
issueSeverityDistribution = {} as MarketplaceAppSecurityReport['overallInfo']['issueSeverityDistribution'],
totalIssues = 0,
} = securityReport?.overallInfo || {};
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<SolidityscanReportButton
score={ securityScore }
isLoading={ isLoading }
onClick={ handleButtonClick }
height={ height }
onlyIcon={ onlyIcon }
/>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm">
<Box mb={ 5 }>
{ solidityScanContractsNumber } smart contract{ solidityScanContractsNumber === 1 ? ' was' : 's were' } evaluated to determine
this protocol{ apos }s overall security score on the { config.chain.name } network.
</Box>
<SolidityscanReportScore score={ securityScore } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && (
<Box mb={ 5 }>
<Text py="7px" variant="secondary" fontSize="xs" fontWeight={ 500 }>Threat score & vulnerabilities</Text>
<SolidityscanReportDetails vulnerabilities={ issueSeverityDistribution } vulnerabilitiesCount={ totalIssues }/>
</Box>
) }
<Link onClick={ handleLinkClick } display="inline-flex" alignItems="center">
Analyzed contracts
<IconSvg name="arrows/north-east" boxSize={ 5 } color="gray.400"/>
</Link>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default AppSecurityReport;
import { Link, Tooltip, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import config from 'configs/app';
import IconSvg from 'ui/shared/IconSvg';
export enum ContractListButtonVariants {
ALL_CONTRACTS = 'all contracts',
VERIFIED_CONTRACTS = 'verified contracts',
}
const values = {
[ContractListButtonVariants.ALL_CONTRACTS]: {
icon: 'contracts' as const,
iconColor: 'gray.500',
tooltip: `Total number of contracts deployed by the protocol on ${ config.chain.name }`,
},
[ContractListButtonVariants.VERIFIED_CONTRACTS]: {
icon: 'contracts_verified' as const,
iconColor: 'green.500',
tooltip: `Number of verified contracts on ${ config.chain.name }`,
},
};
interface Props {
children: string | number;
onClick: (event: MouseEvent) => void;
variant: ContractListButtonVariants;
isLoading?: boolean;
}
const ContractListButton = ({ children, onClick, variant, isLoading }: Props) => {
const { icon, iconColor, tooltip } = values[variant];
return (
<Tooltip
label={ tooltip }
textAlign="center"
padding={ 2 }
isDisabled={ !tooltip }
openDelay={ 500 }
width="250px"
>
<Skeleton
isLoaded={ !isLoading }
display="inline-flex"
alignItems="center"
width={ isLoading ? '40px' : 'auto' }
height="30px"
borderRadius="base"
>
<Link
fontSize="sm"
onClick={ onClick }
fontWeight="500"
display="inline-flex"
>
{ icon && <IconSvg name={ icon } boxSize={ 5 } color={ iconColor } mr={ 1 }/> }
{ children }
</Link>
</Skeleton>
</Tooltip>
);
};
export default ContractListButton;
import {
Box, Modal, Text, ModalBody,
ModalCloseButton, ModalContent, ModalHeader, ModalOverlay,
} from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import IconSvg from 'ui/shared/IconSvg';
import ContractSecurityReport from './ContractSecurityReport';
type Props = {
onClose: () => void;
onBack?: () => void;
type: ContractListTypes;
contracts?: MarketplaceAppSecurityReport['contractsData'];
}
const titles = {
[ContractListTypes.ALL]: `All app${ apos }s smart contracts`,
[ContractListTypes.ANALYZED]: 'Analyzed contracts',
[ContractListTypes.VERIFIED]: 'Verified contracts',
};
const ContractListModal = ({ onClose, onBack, type, contracts }: Props) => {
const isMobile = useIsMobile();
const displayedContracts = React.useMemo(() => {
if (!contracts) {
return [];
}
switch (type) {
default:
case ContractListTypes.ALL:
return contracts;
case ContractListTypes.ANALYZED:
return contracts
.filter((contract) => Boolean(contract.solidityScanReport))
.sort((a, b) =>
(parseFloat(b.solidityScanReport?.scan_summary.score_v2 ?? '0')) - (parseFloat(a.solidityScanReport?.scan_summary.score_v2 ?? '0')),
);
case ContractListTypes.VERIFIED:
return contracts.filter((contract) => contract.isVerified);
}
}, [ contracts, type ]);
if (!contracts) {
return null;
}
return (
<Modal
isOpen={ Boolean(type) }
onClose={ onClose }
size={ isMobile ? 'full' : 'md' }
isCentered
>
<ModalOverlay/>
<ModalContent>
<ModalHeader display="flex" alignItems="center" mb={ 4 }>
{ onBack && (
<IconSvg
name="arrows/east"
w={ 6 }
h={ 10 }
transform="rotate(180deg)"
verticalAlign="middle"
color="gray.400"
mr={ 3 }
cursor="pointer"
onClick={ onBack }
/>
) }
<Text fontWeight="500" textStyle="h3">
{ titles[type] }
</Text>
</ModalHeader>
<ModalCloseButton/>
<ModalBody
maxH={ isMobile ? 'auto' : '352px' }
overflow="scroll"
mb={ 0 }
display="grid"
gridTemplateColumns="max-content 1fr"
rowGap={ 2 }
columnGap={ type === ContractListTypes.ANALYZED ? 4 : 0 }
>
{ displayedContracts.map((contract) => (
<React.Fragment key={ contract.address }>
{ type === ContractListTypes.ANALYZED && (
<Box gridColumn={ 1 }>
<ContractSecurityReport securityReport={ contract.solidityScanReport }/>
</Box>
) }
<AddressEntity
address={{
hash: contract.address,
name: contract.solidityScanReport?.contractname,
is_contract: true,
is_verified: contract.isVerified,
}}
noCopy
gridColumn={ 2 }
height="32px"
/>
</React.Fragment>
)) }
</ModalBody>
</ModalContent>
</Modal>
);
};
export default ContractListModal;
import { Box, Text, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { SolidityscanReport } from 'types/api/contract';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton';
import SolidityscanReportDetails from 'ui/shared/solidityscanReport/SolidityscanReportDetails';
import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanReportScore';
type Props = {
securityReport?: SolidityscanReport['scan_report'] | null;
}
const ContractSecurityReport = ({ securityReport }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const handleClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Security score', Source: 'Analyzed contracts popup' });
onToggle();
}, [ onToggle ]);
if (!securityReport) {
return null;
}
const url = securityReport?.scanner_reference_url;
const {
score_v2: securityScore,
issue_severity_distribution: issueSeverityDistribution,
} = securityReport.scan_summary;
const totalIssues = Object.values(issueSeverityDistribution as Record<string, number>).reduce((acc, val) => acc + val, 0);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<SolidityscanReportButton
score={ parseFloat(securityScore) }
onClick={ handleClick }
/>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm">
<Box mb={ 5 }>
The security score was derived from evaluating the smart contracts of a protocol on the { config.chain.name } network.
</Box>
<SolidityscanReportScore score={ parseFloat(securityScore) } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && (
<Box mb={ 5 }>
<Text py="7px" variant="secondary" fontSize="xs" fontWeight={ 500 }>Threat score & vulnerabilities</Text>
<SolidityscanReportDetails vulnerabilities={ issueSeverityDistribution } vulnerabilitiesCount={ totalIssues }/>
</Box>
) }
<LinkExternal href={ url }>View full report</LinkExternal>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ContractSecurityReport;
import React from 'react';
import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
import { apos } from 'lib/html-entities';
import EmptySearchResultDefault from 'ui/shared/EmptySearchResult';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
const feature = config.features.marketplace;
type Props = {
favoriteApps: Array<string>;
selectedCategoryId?: string;
}
const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
<EmptySearchResultDefault
text={
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<>
You don{ apos }t have any favorite apps.
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites.
</>
) : (
<>
No matching apps found.
{ 'suggestIdeasFormUrl' in feature && (
<>
{ ' ' }Have a groundbreaking idea or app suggestion?{ ' ' }
<LinkExternal href={ feature.suggestIdeasFormUrl }>Share it with us</LinkExternal>
</>
) }
</>
)
}
/>
);
export default React.memo(EmptySearchResult);
import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, Tooltip } from '@chakra-ui/react';
import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview {
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void;
isLoading: boolean;
showDisclaimer: (id: string) => void;
onAppClick: (event: MouseEvent, id: string) => void;
}
const MarketplaceAppCard = ({
......@@ -31,48 +31,23 @@ const MarketplaceAppCard = ({
isFavorite,
onFavoriteClick,
isLoading,
showDisclaimer,
internalWallet,
onAppClick,
}: Props) => {
const categoriesLabel = categories.join(', ');
const handleClick = useCallback((event: MouseEvent) => {
const isShown = window.localStorage.getItem('marketplace-disclaimer-shown');
if (!isShown) {
event.preventDefault();
showDisclaimer(id);
}
}, [ showDisclaimer, id ]);
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id });
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite);
onFavoriteClick(id, isFavorite, 'Discovery view');
}, [ onFavoriteClick, id, isFavorite ]);
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
const [ integrationIcon, integrationIconColor, integrationText ] = React.useMemo(() => {
let icon: IconName = 'integration/partial';
let color = 'gray.400';
let text = 'This app opens in Blockscout without Blockscout wallet functionality. Use your external web3 wallet to connect directly to this application';
if (external) {
icon = 'arrows/north-east';
text = 'This app opens in a separate tab';
} else if (internalWallet) {
icon = 'integration/full';
color = 'green.500';
text = 'This app opens in Blockscout and your Blockscout wallet connects automatically';
}
return [ icon, color, text ];
}, [ external, internalWallet ]);
return (
<LinkBox
_hover={{
......@@ -130,25 +105,9 @@ const MarketplaceAppCard = ({
url={ url }
external={ external }
title={ title }
onClick={ handleClick }
onClick={ onAppClick }
/>
<Tooltip
label={ integrationText }
textAlign="center"
padding={ 2 }
openDelay={ 300 }
maxW={ 400 }
>
<IconSvg
name={ integrationIcon }
boxSize={ 5 }
color={ integrationIconColor }
position="relative"
cursor="pointer"
verticalAlign="middle"
marginBottom={ 1 }
/>
</Tooltip>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton
......@@ -191,8 +150,7 @@ const MarketplaceAppCard = ({
{ !isLoading && (
<IconButton
display={{ base: 'block', sm: isFavorite ? 'block' : 'none' }}
_groupHover={{ display: 'block' }}
display="block"
position="absolute"
right={{ base: 3, sm: '10px' }}
top={{ base: 3, sm: '14px' }}
......@@ -204,8 +162,8 @@ const MarketplaceAppCard = ({
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 4 } h={ 4 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 4 } h={ 4 } color="gray.300"/>
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/>
) }
......
......@@ -8,17 +8,21 @@ type Props = {
url: string;
external?: boolean;
title: string;
onClick?: (event: MouseEvent) => void;
onClick?: (event: MouseEvent, id: string) => void;
}
const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
const handleClick = React.useCallback((event: MouseEvent) => {
onClick?.(event, id);
}, [ onClick, id ]);
return external ? (
<LinkOverlay href={ url } isExternal={ true } marginRight={ 2 }>
{ title }
</LinkOverlay>
) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay onClick={ onClick } marginRight={ 2 }>
<LinkOverlay onClick={ handleClick } marginRight={ 2 }>
{ title }
</LinkOverlay>
</NextLink>
......
......@@ -28,7 +28,7 @@ test.describe('mobile', () => {
</TestApp>,
);
await page.getByText('Info').click();
await page.getByLabel('Show project info').click();
await expect(page).toHaveScreenshot();
});
......
......@@ -22,7 +22,7 @@ const MarketplaceAppInfo = ({ data }: Props) => {
if (isMobile) {
return (
<>
<TriggerButton onClick={ onToggle }/>
<TriggerButton onClick={ onToggle } onlyIcon/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
......
......@@ -5,9 +5,10 @@ import IconSvg from 'ui/shared/IconSvg';
interface Props {
onClick: () => void;
onlyIcon?: boolean;
}
const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const TriggerButton = ({ onClick, onlyIcon }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
......@@ -17,11 +18,11 @@ const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonEle
onClick={ onClick }
aria-label="Show project info"
fontWeight={ 500 }
px={ 2 }
px={ onlyIcon ? 1 : 2 }
h="32px"
>
<IconSvg name="info" boxSize={ 6 } mr={ 1 }/>
<span>Info</span>
<IconSvg name="info" boxSize={ 6 } mr={ onlyIcon ? 0 : 1 }/>
{ !onlyIcon && <span>Info</span> }
</Button>
);
};
......
import { Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
internalWallet: boolean | undefined;
external: boolean | undefined;
}
const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
const [ icon, iconColor, text ] = React.useMemo(() => {
let icon: IconName = 'integration/partial';
let color = 'gray.400';
let text = 'This app opens in Blockscout without Blockscout wallet functionality. Use your external web3 wallet to connect directly to this application';
if (external) {
icon = 'arrows/north-east';
text = 'This app opens in a separate tab';
} else if (internalWallet) {
icon = 'integration/full';
color = 'green.500';
text = 'This app opens in Blockscout and your Blockscout wallet connects automatically';
}
return [ icon, color, text ];
}, [ external, internalWallet ]);
return (
<Tooltip
label={ text }
textAlign="center"
padding={ 2 }
openDelay={ 300 }
maxW={ 400 }
>
<IconSvg
name={ icon }
boxSize={ 5 }
color={ iconColor }
position="relative"
cursor="pointer"
verticalAlign="middle"
marginBottom={ 1 }
/>
</Tooltip>
);
};
export default MarketplaceAppIntegrationIcon;
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { apps as appsMock } from 'mocks/apps/apps';
import TestApp from 'playwright/TestApp';
......@@ -9,7 +11,8 @@ import MarketplaceAppModal from './MarketplaceAppModal';
const props = {
onClose: () => {},
onFavoriteClick: () => {},
data: appsMock[0],
showContractList: () => {},
data: appsMock[0] as MarketplaceAppWithSecurityReport,
isFavorite: false,
};
......
import {
Box, Flex, Heading, IconButton, Image, Link, List, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Tag, Text, useColorModeValue,
ModalCloseButton, ModalContent, ModalFooter, ModalOverlay, Tag, Text, useColorModeValue,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from './ContractListButton';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
type Props = {
onClose: () => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
data: MarketplaceAppOverview;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
}
const MarketplaceAppModal = ({
......@@ -25,8 +31,13 @@ const MarketplaceAppModal = ({
isFavorite,
onFavoriteClick,
data,
showContractList: showContractListProp,
}: Props) => {
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const {
id,
title,
url,
external,
......@@ -39,6 +50,7 @@ const MarketplaceAppModal = ({
logo,
logoDarkMode,
categories,
securityReport,
} = data;
const socialLinks = [
......@@ -61,8 +73,27 @@ const MarketplaceAppModal = ({
}
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(data.id, isFavorite);
}, [ onFavoriteClick, data.id, isFavorite ]);
onFavoriteClick(id, isFavorite, 'App modal');
}, [ onFavoriteClick, id, isFavorite ]);
const showContractList = useCallback((type: ContractListTypes) => {
onClose();
showContractListProp(id, type, true);
}, [ onClose, showContractListProp, id ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'App modal' });
showContractList(ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'App modal' });
showContractList(ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(ContractListTypes.ANALYZED);
}, [ showContractList ]);
const isMobile = useIsMobile();
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
......@@ -77,10 +108,11 @@ const MarketplaceAppModal = ({
<ModalOverlay/>
<ModalContent>
<ModalHeader
<Box
display="grid"
gridTemplateColumns={{ base: 'auto 1fr' }}
paddingRight={{ sm: 12 }}
marginBottom={{ base: 6, sm: 8 }}
>
<Flex
alignItems="center"
......@@ -121,29 +153,54 @@ const MarketplaceAppModal = ({
gridColumn={{ base: '1 / 3', sm: 2 }}
marginTop={{ base: 6, sm: 0 }}
>
<Box display="flex">
<MarketplaceAppModalLink
id={ data.id }
url={ url }
external={ external }
title={ title }
/>
<Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', sm: 'auto' }}>
<MarketplaceAppModalLink
id={ data.id }
url={ url }
external={ external }
title={ title }
/>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
variant="outline"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 4 } h={ 4 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 4 } h={ 4 } color="gray.300"/> }
/>
</Box>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
variant="outline"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color={ starOutlineIconColor }/> }
/>
</Flex>
{ (isExperiment && securityReport) && (
<Flex alignItems="center" gap={ 3 }>
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
source="App modal"
/>
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
>
{ securityReport.overallInfo.totalContractsNumber }
</ContractListButton>
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
>
{ securityReport.overallInfo.verifiedNumber }
</ContractListButton>
</Flex>
) }
</Flex>
</Box>
</ModalHeader>
</Box>
<ModalCloseButton/>
......
import { chakra, Flex, Tooltip, Skeleton } from '@chakra-ui/react';
import { chakra, Flex, Tooltip, Skeleton, useBoolean, Box } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import { route } from 'nextjs-routes';
import { useAppContext } from 'lib/contexts/app';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal';
import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal';
import MarketplaceAppAlert from './MarketplaceAppAlert';
import MarketplaceAppInfo from './MarketplaceAppInfo';
......@@ -17,10 +22,14 @@ type Props = {
data: MarketplaceAppOverview | undefined;
isLoading: boolean;
isWalletConnected: boolean;
securityReport?: MarketplaceAppSecurityReport;
}
const MarketplaceAppTopBar = ({ data, isLoading, isWalletConnected }: Props) => {
const MarketplaceAppTopBar = ({ data, isLoading, isWalletConnected, securityReport }: Props) => {
const [ showContractList, setShowContractList ] = useBoolean(false);
const appProps = useAppContext();
const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
......@@ -36,34 +45,55 @@ const MarketplaceAppTopBar = ({ data, isLoading, isWalletConnected }: Props) =>
}
return (
<Flex alignItems="center" flexWrap="wrap" mb={{ base: 6, md: 2 }} rowGap={ 3 } columnGap={ 2 }>
<Tooltip label="Back to dApps list" order={ 1 }>
<LinkInternal display="inline-flex" href={ goBackUrl } h="32px" isLoading={ isLoading }>
<IconSvg name="arrows/east" boxSize={ 6 } transform="rotate(180deg)" margin="auto" color="gray.400"/>
</LinkInternal>
</Tooltip>
<Skeleton width={{ base: '100%', md: 'auto' }} order={{ base: 4, md: 2 }} isLoaded={ !isLoading }>
<MarketplaceAppAlert internalWallet={ data?.internalWallet } isWalletConnected={ isWalletConnected }/>
</Skeleton>
<Skeleton order={{ base: 2, md: 3 }} isLoaded={ !isLoading }>
<MarketplaceAppInfo data={ data }/>
</Skeleton>
<LinkExternal
order={{ base: 3, md: 4 }}
href={ data?.url }
variant="subtle"
fontSize="sm"
lineHeight={ 5 }
minW={ 0 }
maxW={{ base: 'calc(100% - 114px)', md: 'auto' }}
display="flex"
isLoading={ isLoading }
>
<chakra.span isTruncated>
{ getHostname(data?.url) }
</chakra.span>
</LinkExternal>
</Flex>
<>
<Flex alignItems="center" flexWrap="wrap" mb={{ base: 6, md: 2 }} rowGap={ 3 } columnGap={ 2 }>
<Tooltip label="Back to dApps list" order={ 1 }>
<LinkInternal display="inline-flex" href={ goBackUrl } h="32px" isLoading={ isLoading }>
<IconSvg name="arrows/east" boxSize={ 6 } transform="rotate(180deg)" margin="auto" color="gray.400"/>
</LinkInternal>
</Tooltip>
<Skeleton width={{ base: '100%', md: 'auto' }} order={{ base: 5, md: 2 }} isLoaded={ !isLoading }>
<MarketplaceAppAlert internalWallet={ data?.internalWallet } isWalletConnected={ isWalletConnected }/>
</Skeleton>
<Skeleton order={{ base: 2, md: 3 }} isLoaded={ !isLoading }>
<MarketplaceAppInfo data={ data }/>
</Skeleton>
{ (isExperiment && (securityReport || isLoading)) && (
<Box order={{ base: 3, md: 4 }}>
<AppSecurityReport
id={ data?.id || '' }
securityReport={ securityReport }
showContractList={ setShowContractList.on }
isLoading={ isLoading }
onlyIcon={ isMobile }
source="App page"
/>
</Box>
) }
<LinkExternal
order={{ base: 4, md: 5 }}
href={ data?.url }
variant="subtle"
fontSize="sm"
lineHeight={ 5 }
minW={ 0 }
maxW={{ base: 'calc(100% - 114px)', md: 'auto' }}
display="flex"
isLoading={ isLoading }
>
<chakra.span isTruncated>
{ getHostname(data?.url) }
</chakra.span>
</LinkExternal>
</Flex>
{ showContractList && (
<ContractListModal
type={ ContractListTypes.ANALYZED }
contracts={ securityReport?.contractsData }
onClose={ setShowContractList.off }
/>
) }
</>
);
};
......
import { Grid } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import IconSvg from 'ui/shared/IconSvg';
import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
type Props = {
apps: Array<MarketplaceAppPreview>;
onAppClick: (id: string) => void;
showAppInfo: (id: string) => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void;
isLoading: boolean;
showDisclaimer: (id: string) => void;
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void;
}
const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLoading, showDisclaimer, selectedCategoryId }: Props) => {
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick }: Props) => {
return apps.length > 0 ? (
<Grid
templateColumns={{
......@@ -33,7 +30,7 @@ const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLo
{ apps.map((app, index) => (
<MarketplaceAppCard
key={ app.id + (isLoading ? index : '') }
onInfoClick={ onAppClick }
onInfoClick={ showAppInfo }
id={ app.id }
external={ app.external }
url={ app.url }
......@@ -45,24 +42,13 @@ const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLo
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
isLoading={ isLoading }
showDisclaimer={ showDisclaimer }
internalWallet={ app.internalWallet }
onAppClick={ onAppClick }
/>
)) }
</Grid>
) : (
<EmptySearchResult
text={
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<>
You don{ apos }t have any favorite apps.
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites.
</>
) : (
`Couldn${ apos }t find an app that matches your filter query.`
)
}
/>
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
);
};
......
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import DataListDisplay from 'ui/shared/DataListDisplay';
import EmptySearchResult from './EmptySearchResult';
import ListItem from './MarketplaceListWithScores/ListItem';
import Table from './MarketplaceListWithScores/Table';
interface Props {
apps: Array<MarketplaceAppWithSecurityReport>;
showAppInfo: (id: string) => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
isLoading: boolean;
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const MarketplaceListWithScores = ({
apps,
showAppInfo,
favoriteApps,
onFavoriteClick,
isLoading,
selectedCategoryId,
onAppClick,
showContractList,
}: Props) => {
const displayedApps = React.useMemo(() => apps.sort((a, b) => {
if (!a.securityReport) {
return 1;
} else if (!b.securityReport) {
return -1;
}
return b.securityReport.overallInfo.securityScore - a.securityReport.overallInfo.securityScore;
}), [ apps ]);
const content = apps.length > 0 ? (
<>
<Show below="lg" ssr={ false }>
{ displayedApps.map((app, index) => (
<ListItem
key={ app.id + (isLoading ? index : '') }
app={ app }
onInfoClick={ showAppInfo }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
isLoading={ isLoading }
onAppClick={ onAppClick }
showContractList={ showContractList }
/>
)) }
</Show>
<Hide below="lg" ssr={ false }>
<Table
apps={ displayedApps }
isLoading={ isLoading }
onAppClick={ onAppClick }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
onInfoClick={ showAppInfo }
showContractList={ showContractList }
/>
</Hide>
</>
) : null;
return apps.length > 0 ? (
<DataListDisplay
isError={ false }
items={ apps }
emptyText="No apps found."
content={ content }
/>
) : (
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
);
};
export default MarketplaceListWithScores;
import { Flex, Skeleton, LinkBox, Image, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
interface Props {
app: MarketplaceAppPreview;
isLoading: boolean | undefined;
onAppClick: (event: MouseEvent, id: string) => void;
isLarge?: boolean;
}
const AppLink = ({ app, isLoading, onAppClick, isLarge = false }: Props) => {
const { id, url, external, title, logo, logoDarkMode, internalWallet, categories } = app;
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
const categoriesLabel = categories.join(', ');
return (
<LinkBox display="flex" height="100%" width="100%" role="group" alignItems="center" mb={ isLarge ? 0 : 4 }>
<Skeleton
isLoaded={ !isLoading }
w={ isLarge ? '56px' : '48px' }
h={ isLarge ? '56px' : '48px' }
display="flex"
alignItems="center"
justifyContent="center"
mr={ isLarge ? 3 : 4 }
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
<Flex direction="column">
<Skeleton
isLoaded={ !isLoading }
marginBottom={ 0 }
fontSize="sm"
fontWeight="semibold"
fontFamily="heading"
display="inline-block"
mb={ 1 }
>
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
title={ title }
onClick={ onAppClick }
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize={ isLarge ? 'sm' : 'xs' }
>
<span>{ categoriesLabel }</span>
</Skeleton>
</Flex>
</LinkBox>
);
};
export default AppLink;
import { Flex, IconButton, Text } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import AppSecurityReport from '../AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from '../ContractListButton';
import AppLink from './AppLink';
import MoreInfoButton from './MoreInfoButton';
type Props = {
app: MarketplaceAppWithSecurityReport;
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const ListItem = ({ app, onInfoClick, isFavorite, onFavoriteClick, isLoading, onAppClick, showContractList }: Props) => {
const { id, securityReport } = app;
const handleInfoClick = React.useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Security view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = React.useCallback(() => {
onFavoriteClick(id, isFavorite, 'Security view');
}, [ onFavoriteClick, id, isFavorite ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(id, ContractListTypes.ANALYZED);
}, [ showContractList, id ]);
return (
<ListItemMobile
rowGap={ 3 }
py={ 3 }
_first={{ borderTop: 'none', paddingTop: 0 }}
_last={{ borderBottom: 'none', paddingBottom: 0 }}
>
<Flex
direction="column"
justifyContent="stretch"
padding={ 3 }
width="100%"
>
<Flex position="relative">
<AppLink app={ app } isLoading={ isLoading } onAppClick={ onAppClick }/>
{ !isLoading && (
<IconButton
position="absolute"
right={ -1 }
top={ -1 }
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/>
) }
</Flex>
<Flex alignItems="center">
<Flex flex={ 1 } gap={ 3 } alignItems="center">
{ (securityReport || isLoading) ? (
<>
<AppSecurityReport
id={ id }
isLoading={ isLoading }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
height="30px"
source="Security view"
/>
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.totalContractsNumber ?? 0 }
</ContractListButton>
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.verifiedNumber ?? 0 }
</ContractListButton>
</>
) : (
<Text variant="secondary" fontSize="sm" fontWeight={ 500 }>Data will be available soon</Text>
) }
</Flex>
<MoreInfoButton onClick={ handleInfoClick } isLoading={ isLoading }/>
</Flex>
</Flex>
</ListItemMobile>
);
};
export default ListItem;
import { Link, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
interface Props {
onClick: (event: MouseEvent) => void;
isLoading?: boolean;
}
const MoreInfoButton = ({ onClick, isLoading }: Props) => (
<Skeleton
isLoaded={ !isLoading }
display="inline-flex"
alignItems="center"
height="30px"
borderRadius="base"
>
<Link
fontSize="sm"
onClick={ onClick }
fontWeight="500"
display="inline-flex"
>
More info
</Link>
</Skeleton>
);
export default MoreInfoButton;
import { Table as ChakraTable, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import { default as Thead } from 'ui/shared/TheadSticky';
import TableItem from './TableItem';
type Props = {
apps: Array<MarketplaceAppWithSecurityReport>;
isLoading?: boolean;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
onAppClick: (event: MouseEvent, id: string) => void;
onInfoClick: (id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const Table = ({ apps, isLoading, favoriteApps, onFavoriteClick, onAppClick, onInfoClick, showContractList }: Props) => {
return (
<ChakraTable>
<Thead top={ 0 }>
<Tr>
<Th w="5%"></Th>
<Th w="40%">App</Th>
<Th w="15%">Contracts score</Th>
<Th w="10%">Total</Th>
<Th w="10%">Verified</Th>
<Th w="20%"></Th>
</Tr>
</Thead>
<Tbody>
{ apps.map((app, index) => (
<TableItem
key={ app.id + (isLoading ? index : '') }
app={ app }
isLoading={ isLoading }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
onAppClick={ onAppClick }
onInfoClick={ onInfoClick }
showContractList={ showContractList }
/>
)) }
</Tbody>
</ChakraTable>
);
};
export default Table;
import { Td, Tr, IconButton, Skeleton, Text } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from '../AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from '../ContractListButton';
import AppLink from './AppLink';
import MoreInfoButton from './MoreInfoButton';
type Props = {
app: MarketplaceAppWithSecurityReport;
isLoading?: boolean;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
onAppClick: (event: MouseEvent, id: string) => void;
onInfoClick: (id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const TableItem = ({
app,
isLoading,
isFavorite,
onFavoriteClick,
onAppClick,
onInfoClick,
showContractList,
}: Props) => {
const { id, securityReport } = app;
const handleInfoClick = React.useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Security view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = React.useCallback(() => {
onFavoriteClick(id, isFavorite, 'Security view');
}, [ onFavoriteClick, id, isFavorite ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(id, ContractListTypes.ANALYZED);
}, [ showContractList, id ]);
return (
<Tr>
<Td verticalAlign="middle" px={ 2 }>
<Skeleton isLoaded={ !isLoading }>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<AppLink app={ app } isLoading={ isLoading } onAppClick={ onAppClick } isLarge/>
</Td>
{ (securityReport || isLoading) ? (
<>
<Td verticalAlign="middle">
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
isLoading={ isLoading }
source="Security view"
/>
</Td>
<Td verticalAlign="middle">
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.totalContractsNumber ?? 0 }
</ContractListButton>
</Td>
<Td verticalAlign="middle">
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.verifiedNumber ?? 0 }
</ContractListButton>
</Td>
</>
) : (
<Td verticalAlign="middle" colSpan={ 3 }>
<Text variant="secondary" fontSize="sm" fontWeight={ 500 }>Data will be available soon</Text>
</Td>
) }
<Td verticalAlign="middle" isNumeric>
<MoreInfoButton onClick={ handleInfoClick } isLoading={ isLoading }/>
</Td>
</Tr>
);
};
export default TableItem;
......@@ -2,7 +2,8 @@ import _pickBy from 'lodash/pickBy';
import { useRouter } from 'next/router';
import React from 'react';
import { MarketplaceCategory } from 'types/client/marketplace';
import type { ContractListTypes } from 'types/client/marketplace';
import { MarketplaceCategory, MarketplaceDisplayType } from 'types/client/marketplace';
import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -25,17 +26,25 @@ export default function useMarketplace() {
const router = useRouter();
const defaultCategoryId = getQueryParamString(router.query.category);
const defaultFilterQuery = getQueryParamString(router.query.filter);
const defaultDisplayType = getQueryParamString(router.query.tab);
const [ selectedAppId, setSelectedAppId ] = React.useState<string | null>(null);
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ selectedDisplayType, setSelectedDisplayType ] = React.useState<string>(
Object.values(MarketplaceDisplayType).includes(defaultDisplayType as MarketplaceDisplayType) ?
defaultDisplayType :
MarketplaceDisplayType.DEFAULT,
);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState<boolean>(false);
const [ isAppInfoModalOpen, setIsAppInfoModalOpen ] = React.useState<boolean>(false);
const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false);
const [ contractListModalType, setContractListModalType ] = React.useState<ContractListTypes | null>(null);
const [ hasPreviousStep, setHasPreviousStep ] = React.useState<boolean>(false);
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id });
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean, source: 'Discovery view' | 'Security view' | 'App modal') => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id, Source: source });
const favoriteApps = getFavoriteApps();
......@@ -60,11 +69,21 @@ export default function useMarketplace() {
setIsDisclaimerModalOpen(true);
}, []);
const showContractList = React.useCallback((id: string, type: ContractListTypes, hasPreviousStep?: boolean) => {
setSelectedAppId(id);
setContractListModalType(type);
if (hasPreviousStep) {
setHasPreviousStep(true);
}
}, []);
const debouncedFilterQuery = useDebounce(filterQuery, 500);
const clearSelectedAppId = React.useCallback(() => {
setSelectedAppId(null);
setIsAppInfoModalOpen(false);
setIsDisclaimerModalOpen(false);
setContractListModalType(null);
setHasPreviousStep(false);
}, []);
const handleCategoryChange = React.useCallback((newCategory: string) => {
......@@ -72,6 +91,10 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory);
}, []);
const handleDisplayTypeChange = React.useCallback((newDisplayType: MarketplaceDisplayType) => {
setSelectedDisplayType(newDisplayType);
}, []);
const {
isPlaceholderData, isError, error, data, displayedApps,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
......@@ -97,6 +120,7 @@ export default function useMarketplace() {
const query = _pickBy({
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery,
tab: selectedDisplayType === MarketplaceDisplayType.DEFAULT ? undefined : selectedDisplayType,
}, Boolean);
if (debouncedFilterQuery.length > 0) {
......@@ -111,7 +135,7 @@ export default function useMarketplace() {
// omit router in the deps because router.push() somehow modifies it
// and we get infinite re-renders then
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ debouncedFilterQuery, selectedCategoryId ]);
}, [ debouncedFilterQuery, selectedCategoryId, selectedDisplayType ]);
return React.useMemo(() => ({
selectedCategoryId,
......@@ -133,6 +157,11 @@ export default function useMarketplace() {
showDisclaimer,
appsTotal: data?.length || 0,
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
onDisplayTypeChange: handleDisplayTypeChange,
hasPreviousStep,
}), [
selectedCategoryId,
categories,
......@@ -152,5 +181,10 @@ export default function useMarketplace() {
showDisclaimer,
data?.length,
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
handleDisplayTypeChange,
hasPreviousStep,
]);
}
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
......@@ -10,19 +10,21 @@ import useApiFetch from 'lib/api/useApiFetch';
import useFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace';
import useSecurityReports from './useSecurityReports';
const feature = config.features.marketplace;
function isAppNameMatches(q: string, app: MarketplaceAppOverview) {
function isAppNameMatches(q: string, app: MarketplaceAppWithSecurityReport) {
return app.title.toLowerCase().includes(q.toLowerCase());
}
function isAppCategoryMatches(category: string, app: MarketplaceAppOverview, favoriteApps: Array<string>) {
function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array<string>) {
return category === MarketplaceCategory.ALL ||
(category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) ||
app.categories.includes(category);
}
function sortApps(apps: Array<MarketplaceAppOverview>, favoriteApps: Array<string>) {
function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: Array<string>) {
return apps.sort((a, b) => {
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
......@@ -56,6 +58,8 @@ export default function useMarketplaceApps(
const fetch = useFetch();
const apiFetch = useApiFetch();
const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports();
// Update favorite apps only when selectedCategoryId changes to avoid sortApps to be called on each favorite app click
const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>();
......@@ -65,38 +69,43 @@ export default function useMarketplaceApps(
}
}, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({
queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ],
queryFn: async() => {
if (!feature.isEnabled) {
return [];
} else if ('configUrl' in feature) {
return fetch<Array<MarketplaceAppOverview>, unknown>(feature.configUrl, undefined, { resource: 'marketplace-dapps' });
return fetch<Array<MarketplaceAppWithSecurityReport>, unknown>(feature.configUrl, undefined, { resource: 'marketplace-dapps' });
} else {
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } });
}
},
select: (data) => sortApps(data as Array<MarketplaceAppOverview>, snapshotFavoriteApps || []),
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps || []),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity,
enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)),
});
const appsWithSecurityReports = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })),
[ data, securityReports ]);
const displayedApps = React.useMemo(() => {
return data?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps || [])) || [];
}, [ selectedCategoryId, data, filter, favoriteApps ]);
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps || [])) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]);
return React.useMemo(() => ({
data,
displayedApps,
error,
isError,
isPlaceholderData,
isPlaceholderData: isPlaceholderData || isSecurityReportsPlaceholderData,
}), [
data,
displayedApps,
error,
isError,
isPlaceholderData,
isSecurityReportsPlaceholderData,
]);
}
import { useQuery } from '@tanstack/react-query';
import type { MarketplaceAppSecurityReport, MarketplaceAppSecurityReportRaw } from 'types/client/marketplace';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch';
const feature = config.features.marketplace;
const securityReportsUrl = (feature.isEnabled && feature.securityReportsUrl) || '';
export default function useSecurityReports() {
const apiFetch = useApiFetch();
return useQuery<unknown, ResourceError<unknown>, Record<string, MarketplaceAppSecurityReport>>({
queryKey: [ 'marketplace-security-reports' ],
queryFn: async() => apiFetch(securityReportsUrl, undefined, { resource: 'marketplace-security-reports' }),
select: (data) => {
const securityReports: Record<string, MarketplaceAppSecurityReport> = {};
(data as Array<MarketplaceAppSecurityReportRaw>).forEach((item) => {
const report = item.chainsData[config.chain.id || ''];
if (report) {
const issues: Record<string, number> = report.overallInfo.issueSeverityDistribution;
report.overallInfo.totalIssues = Object.values(issues).reduce((acc, val) => acc + val, 0);
report.overallInfo.securityScore = Number(report.overallInfo.securityScore.toFixed(2));
}
securityReports[item.appName] = report;
});
return securityReports;
},
placeholderData: securityReportsUrl ? {} : undefined,
staleTime: Infinity,
enabled: Boolean(securityReportsUrl),
});
}
......@@ -3,6 +3,7 @@ import React from 'react';
import { buildExternalAssetFilePath } from 'configs/app/utils';
import { apps as appsMock } from 'mocks/apps/apps';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
......@@ -10,6 +11,8 @@ import * as app from 'playwright/utils/app';
import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const MARKETPLACE_SECURITY_REPORTS_URL =
app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', 'https://marketplace-security-reports.json') || '';
const test = base.extend({
context: contextWithEnvs([
......@@ -41,3 +44,43 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot();
});
const testWithScoreFeature = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL },
{ name: 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', value: MARKETPLACE_SECURITY_REPORTS_URL },
{ name: 'pw_feature:security_score_exp', value: 'true' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
testWithScoreFeature('with scores +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(appsMock),
}));
await page.route(MARKETPLACE_SECURITY_REPORTS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(securityReportsMock),
}));
await Promise.all(appsMock.map(app =>
page.route(app.logo, (route) =>
route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
}),
),
));
const component = await mount(
<TestApp>
<Marketplace/>
</TestApp>,
);
await component.getByText('Apps scores').click();
await expect(component).toHaveScreenshot();
});
import { Box, Menu, MenuButton, MenuItem, MenuList, Flex, IconButton } from '@chakra-ui/react';
import { Box, Menu, MenuButton, MenuItem, MenuList, Flex, IconButton, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import { MarketplaceCategory } from 'types/client/marketplace';
import { MarketplaceCategory, MarketplaceDisplayType } from 'types/client/marketplace';
import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import ContractListModal from 'ui/marketplace/ContractListModal';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
import MarketplaceList from 'ui/marketplace/MarketplaceList';
import MarketplaceListWithScores from 'ui/marketplace/MarketplaceListWithScores';
import FilterInput from 'ui/shared/filters/FilterInput';
import IconSvg from 'ui/shared/IconSvg';
import type { IconName } from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TabsWithScroll from 'ui/shared/Tabs/TabsWithScroll';
......@@ -60,8 +65,15 @@ const Marketplace = () => {
showDisclaimer,
appsTotal,
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
onDisplayTypeChange,
hasPreviousStep,
} = useMarketplace();
const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const categoryTabs = React.useMemo(() => {
const tabs: Array<TabItem> = categories.map(category => ({
......@@ -80,7 +92,7 @@ const Marketplace = () => {
tabs.unshift({
id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 4 } h={ 4 }/>,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 }/>,
count: null,
component: null,
});
......@@ -93,18 +105,33 @@ const Marketplace = () => {
return index === -1 ? 0 : index;
}, [ categoryTabs, selectedCategoryId ]);
const selectedApp = displayedApps.find(app => app.id === selectedAppId);
const handleCategoryChange = React.useCallback((index: number) => {
onCategoryChange(categoryTabs[index].id);
}, [ categoryTabs, onCategoryChange ]);
const handleAppClick = React.useCallback((event: MouseEvent, id: string) => {
const isShown = window.localStorage.getItem('marketplace-disclaimer-shown');
if (!isShown) {
event.preventDefault();
showDisclaimer(id);
}
}, [ showDisclaimer ]);
const handleGoBackInContractListModal = React.useCallback(() => {
clearSelectedAppId();
if (selectedApp) {
showAppInfo(selectedApp.id);
}
}, [ clearSelectedAppId, showAppInfo, selectedApp ]);
throwOnResourceLoadError(isError && error ? { isError, error } : { isError: false, error: null });
if (!feature.isEnabled) {
return null;
}
const selectedApp = displayedApps.find(app => app.id === selectedAppId);
return (
<>
<PageTitle
......@@ -148,29 +175,76 @@ const Marketplace = () => {
tabs={ categoryTabs }
onTabChange={ handleCategoryChange }
defaultTabIndex={ selectedCategoryIndex }
marginBottom={{ base: 0, lg: -2 }}
marginBottom={ -2 }
/>
) }
</Box>
<FilterInput
initialValue={ filterQuery }
onChange={ onSearchInputChange }
marginBottom={{ base: '4', lg: '6' }}
w="100%"
placeholder="Find app"
isLoading={ isPlaceholderData }
/>
<Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}>
{ (feature.securityReportsUrl && isExperiment) && (
<Skeleton isLoaded={ !isPlaceholderData }>
<RadioButtonGroup<MarketplaceDisplayType>
onChange={ onDisplayTypeChange }
defaultValue={ selectedDisplayType }
name="type"
options={ [
{
title: 'Discovery',
value: MarketplaceDisplayType.DEFAULT,
icon: 'apps_xs',
onlyIcon: false,
},
{
title: 'Apps scores',
value: MarketplaceDisplayType.SCORES,
icon: 'apps_list',
onlyIcon: false,
contentAfter: (
<IconSvg
name={ isMobile ? 'beta_xs' : 'beta' }
h={ 3 }
w={ isMobile ? 3 : 7 }
ml={ 1 }
/>
),
},
] }
autoWidth
/>
</Skeleton>
) }
<FilterInput
initialValue={ filterQuery }
onChange={ onSearchInputChange }
placeholder="Find app by name or keyword..."
isLoading={ isPlaceholderData }
size={ (feature.securityReportsUrl && isExperiment) ? 'xs' : 'sm' }
flex="1"
/>
</Flex>
<MarketplaceList
apps={ displayedApps }
onAppClick={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
showDisclaimer={ showDisclaimer }
selectedCategoryId={ selectedCategoryId }
/>
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl && isExperiment) ? (
<MarketplaceListWithScores
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
showContractList={ showContractList }
/>
) : (
<MarketplaceList
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
/>
) }
{ (selectedApp && isAppInfoModalOpen) && (
<MarketplaceAppModal
......@@ -178,6 +252,7 @@ const Marketplace = () => {
isFavorite={ favoriteApps.includes(selectedApp.id) }
onFavoriteClick={ onFavoriteClick }
data={ selectedApp }
showContractList={ showContractList }
/>
) }
......@@ -188,6 +263,15 @@ const Marketplace = () => {
appId={ selectedApp.id }
/>
) }
{ (selectedApp && contractListModalType) && (
<ContractListModal
type={ contractListModalType }
contracts={ selectedApp?.securityReport?.contractsData }
onClose={ clearSelectedAppId }
onBack={ hasPreviousStep ? handleGoBackInContractListModal : undefined }
/>
) }
</>
);
};
......
......@@ -20,6 +20,7 @@ import ContentLoader from 'ui/shared/ContentLoader';
import MarketplaceAppTopBar from '../marketplace/MarketplaceAppTopBar';
import useAutoConnectWallet from '../marketplace/useAutoConnectWallet';
import useMarketplaceWallet from '../marketplace/useMarketplaceWallet';
import useSecurityReports from '../marketplace/useSecurityReports';
const feature = config.features.marketplace;
......@@ -104,6 +105,8 @@ const MarketplaceApp = () => {
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet(id);
useAutoConnectWallet();
const { data: securityReports, isLoading: isSecurityReportsLoading } = useSecurityReports();
const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-dapps', id ],
queryFn: async() => {
......@@ -140,7 +143,12 @@ const MarketplaceApp = () => {
return (
<>
<MarketplaceAppTopBar data={ data } isLoading={ isPending } isWalletConnected={ Boolean(address) }/>
<MarketplaceAppTopBar
data={ data }
isLoading={ isPending || isSecurityReportsLoading }
isWalletConnected={ Boolean(address) }
securityReport={ securityReports?.[id] }
/>
<DappscoutIframeProvider
address={ address }
appUrl={ data?.url }
......
......@@ -9,6 +9,7 @@ type RadioItemProps = {
title: string;
icon?: IconName;
onlyIcon: false | undefined;
contentAfter?: React.ReactNode;
} | {
title: string;
icon: IconName;
......@@ -67,9 +68,11 @@ const RadioButton = (props: RadioButtonProps) => {
>
<input { ...input }/>
<Flex
alignItems="center"
{ ...checkbox }
>
{ props.title }
{ props.contentAfter }
</Flex>
</Button>
);
......@@ -80,15 +83,22 @@ type RadioButtonGroupProps<T extends string> = {
name: string;
defaultValue: string;
options: Array<{ value: T } & RadioItemProps>;
autoWidth?: boolean;
}
const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, options }: RadioButtonGroupProps<T>) => {
const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, options, autoWidth = false }: RadioButtonGroupProps<T>) => {
const { getRootProps, getRadioProps } = useRadioGroup({ name, defaultValue, onChange });
const group = getRootProps();
return (
<ButtonGroup { ...group } isAttached size="sm" display="grid" gridTemplateColumns={ `repeat(${ options.length }, 1fr)` }>
<ButtonGroup
{ ...group }
isAttached
size="sm"
display="grid"
gridTemplateColumns={ `repeat(${ options.length }, ${ autoWidth ? 'auto' : '1fr' })` }
>
{ options.map((option) => {
const props = getRadioProps({ value: option.value });
return <RadioButton { ...props } key={ option.value } { ...option }/>;
......
import { Button, Skeleton } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
import useScoreLevelAndColor from './useScoreLevelAndColor';
interface Props {
className?: string;
score: number;
isLoading?: boolean;
height?: string;
onlyIcon?: boolean;
onClick?: () => void;
}
const SolidityscanReportButton = (
{ className, score, isLoading, height = '32px', onlyIcon, onClick }: Props,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const { scoreColor } = useScoreLevelAndColor(score);
return (
<Skeleton isLoaded={ !isLoading } borderRadius="base">
<Button
ref={ ref }
className={ className }
color={ scoreColor }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onClick }
aria-label="SolidityScan score"
fontWeight={ 500 }
px="6px"
h={ height }
flexShrink={ 0 }
>
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 } mr={ onlyIcon ? 0 : 1 }/>
{ onlyIcon ? null : score }
</Button>
</Skeleton>
);
};
export default React.forwardRef(SolidityscanReportButton);
import { Box, Flex, Text, Grid, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SolidityscanReport } from 'types/api/contract';
type DistributionItem = {
id: keyof SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
name: string;
color: string;
}
const DISTRIBUTION_ITEMS: Array<DistributionItem> = [
{ id: 'critical', name: 'Critical', color: '#891F11' },
{ id: 'high', name: 'High', color: '#EC672C' },
{ id: 'medium', name: 'Medium', color: '#FBE74D' },
{ id: 'low', name: 'Low', color: '#68C88E' },
{ id: 'informational', name: 'Informational', color: '#A3AEBE' },
{ id: 'gas', name: 'Gas', color: '#A47585' },
];
interface Props {
vulnerabilities: SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
vulnerabilitiesCount: number;
}
type ItemProps = {
item: DistributionItem;
vulnerabilities: SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
vulnerabilitiesCount: number;
}
const SolidityScanReportItem = ({ item, vulnerabilities, vulnerabilitiesCount }: ItemProps) => {
const bgBar = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
return (
<>
<Box w={ 3 } h={ 3 } bg={ item.color } borderRadius="6px" mr={ 2 }></Box>
<Flex justifyContent="space-between" mr={ 3 }>
<Text>{ item.name }</Text>
<Text color={ vulnerabilities[item.id] > 0 ? 'text' : yetAnotherGrayColor }>{ vulnerabilities[item.id] }</Text>
</Flex>
<Box bg={ bgBar } h="10px" borderRadius="8px">
<Box bg={ item.color } w={ vulnerabilities[item.id] / vulnerabilitiesCount } h="10px" borderRadius="8px"/>
</Box>
</>
);
};
const SolidityscanReportDetails = ({ vulnerabilities, vulnerabilitiesCount }: Props) => {
return (
<Grid templateColumns="20px 1fr 100px" alignItems="center" rowGap={ 2 }>
{ DISTRIBUTION_ITEMS.map(item => (
<SolidityScanReportItem item={ item } key={ item.id } vulnerabilities={ vulnerabilities } vulnerabilitiesCount={ vulnerabilitiesCount }/>
)) }
</Grid>
);
};
export default chakra(SolidityscanReportDetails);
import { Box, Flex, Text, chakra, Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
import useScoreLevelAndColor from './useScoreLevelAndColor';
interface Props {
className?: string;
score: number;
}
const SolidityscanReportScore = ({ className, score }: Props) => {
const { scoreLevel, scoreColor } = useScoreLevelAndColor(score);
const chartGrayColor = useColorModeValue('gray.100', 'gray.700');
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
const popoverBgColor = useColorModeValue('white', 'gray.900');
return (
<Flex className={ className } alignItems="center">
<Box
w={ 12 }
h={ 12 }
bgGradient={ `conic-gradient(${ scoreColor } 0, ${ scoreColor } ${ score }%, ${ chartGrayColor } 0, ${ chartGrayColor } 100%)` }
borderRadius="24px"
position="relative"
mr={ 3 }
>
<Center position="absolute" w="38px" h="38px" top="5px" right="5px" bg={ popoverBgColor } borderRadius="20px">
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 } color={ scoreColor }/>
</Center>
</Box>
<Box>
<Flex>
<Text color={ scoreColor } fontSize="lg" fontWeight={ 500 }>{ score }</Text>
<Text color={ yetAnotherGrayColor } fontSize="lg" fontWeight={ 500 } whiteSpace="pre"> / 100</Text>
</Flex>
<Text color={ scoreColor } fontWeight={ 500 }>Security score is { scoreLevel }</Text>
</Box>
</Flex>
);
};
export default chakra(SolidityscanReportScore);
import { useColorModeValue } from '@chakra-ui/react';
export default function useScoreLevelAndColor(score: number) {
const greatScoreColor = useColorModeValue('green.600', 'green.400');
const averageScoreColor = useColorModeValue('purple.600', 'purple.400');
const lowScoreColor = useColorModeValue('red.600', 'red.400');
let scoreColor;
let scoreLevel;
if (score >= 80) {
scoreColor = greatScoreColor;
scoreLevel = 'GREAT';
} else if (score >= 30) {
scoreColor = averageScoreColor;
scoreLevel = 'AVERAGE';
} else {
scoreColor = lowScoreColor;
scoreLevel = 'LOW';
}
return { scoreColor, scoreLevel };
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment