diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 3b25e5570c..08dbd2fe57 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -25,6 +25,7 @@ export { default as publicTagsSubmission } from './publicTagsSubmission'; export { default as restApiDocs } from './restApiDocs'; export { default as rollup } from './rollup'; export { default as safe } from './safe'; +export { default as saveOnGas } from './saveOnGas'; export { default as sentry } from './sentry'; export { default as sol2uml } from './sol2uml'; export { default as stats } from './stats'; diff --git a/configs/app/features/saveOnGas.ts b/configs/app/features/saveOnGas.ts new file mode 100644 index 0000000000..8cabb51f05 --- /dev/null +++ b/configs/app/features/saveOnGas.ts @@ -0,0 +1,27 @@ +import type { Feature } from './types'; + +import { getEnvValue } from '../utils'; +import marketplace from './marketplace'; + +const title = 'Save on gas with GasHawk'; + +const config: Feature<{ + apiUrlTemplate: string; + dappId: string; +}> = (() => { + if (getEnvValue('NEXT_PUBLIC_SAVE_ON_GAS_ENABLED') === 'true' && marketplace.isEnabled) { + return Object.freeze({ + title, + isEnabled: true, + dappId: 'gas-hawk', + apiUrlTemplate: 'https://core.gashawk.io/apiv2/stats/address/
/savingsPotential/0x1', + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 42745d3f92..0ea799c9a3 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -62,3 +62,4 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 7f31fb219d..95c07d0237 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -801,6 +801,7 @@ const schema = yup value => value === undefined, ), }), + NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(), // 6. External services envs NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), diff --git a/deploy/tools/envs-validator/test/.env.base b/deploy/tools/envs-validator/test/.env.base index fc628aeefd..9d0e91dacb 100644 --- a/deploy/tools/envs-validator/test/.env.base +++ b/deploy/tools/envs-validator/test/.env.base @@ -84,3 +84,4 @@ NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'uniswap'},{'text':'Payment link','icon':'payment_link','url':'https://example.com'}] NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'} NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'dapp_id': 'smol-refuel', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&utm_medium=address&disableBridges=true', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'} +NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true diff --git a/docs/ENVS.md b/docs/ENVS.md index fa32d893e6..cdc4bc8708 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -59,9 +59,10 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will - [Validators list](ENVS.md#validators-list) - [Sentry error monitoring](ENVS.md#sentry-error-monitoring) - [OpenTelemetry](ENVS.md#opentelemetry) - - [Swap button](ENVS.md#defi-dropdown) + - [DeFi dropdown](ENVS.md#defi-dropdown) - [Multichain balance button](ENVS.md#multichain-balance-button) - [Get gas button](ENVS.md#get-gas-button) + - [Save on gas with GasHawk](ENVS.md#save-on-gas-with-gashawk) - [3rd party services configuration](ENVS.md#external-services-configuration)   @@ -755,6 +756,16 @@ If the feature is enabled, a Get gas button will be displayed in the top bar, wh   +### Save on gas with GasHawk + +The feature enables a "Save with GasHawk" button next to the "Gas used" value on the address page. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_SAVE_ON_GAS_ENABLED | `boolean` | Set to "true" to enable the feature | - | - | `true` | v1.35.0+ | + +  + ## External services configuration ### Google ReCaptcha diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index 758612f3f6..044f267083 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -6,6 +6,7 @@ function generateCspPolicy() { descriptors.app(), descriptors.ad(), descriptors.cloudFlare(), + descriptors.gasHawk(), descriptors.googleAnalytics(), descriptors.googleFonts(), descriptors.googleReCaptcha(), diff --git a/nextjs/csp/policies/gasHawk.ts b/nextjs/csp/policies/gasHawk.ts new file mode 100644 index 0000000000..6a1a8e624a --- /dev/null +++ b/nextjs/csp/policies/gasHawk.ts @@ -0,0 +1,30 @@ +import type CspDev from 'csp-dev'; + +import config from 'configs/app'; + +const feature = config.features.saveOnGas; + +export function gasHawk(): CspDev.DirectiveDescriptor { + if (!feature.isEnabled) { + return {}; + } + + const apiOrigin = (() => { + try { + const url = new URL(feature.apiUrlTemplate); + return url.origin; + } catch (error) { + return ''; + } + })(); + + if (!apiOrigin) { + return {}; + } + + return { + 'connect-src': [ + apiOrigin, + ], + }; +} diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index 1cbe44f1bc..7fc5a5a68e 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -1,6 +1,7 @@ export { ad } from './ad'; export { app } from './app'; export { cloudFlare } from './cloudFlare'; +export { gasHawk } from './gasHawk'; export { googleAnalytics } from './googleAnalytics'; export { googleFonts } from './googleFonts'; export { googleReCaptcha } from './googleReCaptcha'; diff --git a/public/static/gas_hawk_logo.svg b/public/static/gas_hawk_logo.svg new file mode 100644 index 0000000000..ffa6acdde5 --- /dev/null +++ b/public/static/gas_hawk_logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/address/AddressDetails.tsx b/ui/address/AddressDetails.tsx index b370ec7be1..e781f5b519 100644 --- a/ui/address/AddressDetails.tsx +++ b/ui/address/AddressDetails.tsx @@ -21,6 +21,7 @@ import AddressBalance from './details/AddressBalance'; import AddressImplementations from './details/AddressImplementations'; import AddressNameInfo from './details/AddressNameInfo'; import AddressNetWorth from './details/AddressNetWorth'; +import AddressSaveOnGas from './details/AddressSaveOnGas'; import TokenSelect from './tokenSelect/TokenSelect'; import useAddressCountersQuery from './utils/useAddressCountersQuery'; import type { AddressQuery } from './utils/useAddressQuery'; @@ -211,6 +212,12 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => { /> ) : 0 } + { !countersQuery.isPlaceholderData && countersQuery.data?.gas_usage_count && ( + + ) } ) } diff --git a/ui/address/details/AddressSaveOnGas.tsx b/ui/address/details/AddressSaveOnGas.tsx new file mode 100644 index 0000000000..0b40c579e3 --- /dev/null +++ b/ui/address/details/AddressSaveOnGas.tsx @@ -0,0 +1,90 @@ +import { Image, Skeleton } from '@chakra-ui/react'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; +import * as v from 'valibot'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import LinkInternal from 'ui/shared/links/LinkInternal'; +import TextSeparator from 'ui/shared/TextSeparator'; + +const feature = config.features.saveOnGas; + +const responseSchema = v.object({ + percent: v.number(), +}); + +const ERROR_NAME = 'Invalid response schema'; + +interface Props { + gasUsed: string; + address: string; +} + +const AddressSaveOnGas = ({ gasUsed, address }: Props) => { + + const gasUsedNumber = Number(gasUsed); + + const query = useQuery({ + queryKey: [ 'gas_hawk_saving_potential', { address } ], + queryFn: async() => { + if (!feature.isEnabled) { + return; + } + + const response = await fetch(feature.apiUrlTemplate.replace('
', address)); + const data = await response.json(); + return data; + }, + select: (response) => { + const parsedResponse = v.safeParse(responseSchema, response); + + if (!parsedResponse.success) { + throw Error('Invalid response schema'); + } + + return parsedResponse.output; + }, + placeholderData: { percent: 42 }, + enabled: feature.isEnabled && gasUsedNumber > 0, + }); + + const errorMessage = query.error && 'message' in query.error ? query.error.message : undefined; + + React.useEffect(() => { + if (errorMessage === ERROR_NAME) { + fetch('/node-api/monitoring/invalid-api-schema', { + method: 'POST', + body: JSON.stringify({ + resource: 'gas_hawk_saving_potential', + url: feature.isEnabled ? feature.apiUrlTemplate.replace('
', address) : undefined, + }), + }); + } + }, [ address, errorMessage ]); + + if (gasUsedNumber <= 0 || !feature.isEnabled || query.isError || !query.data?.percent) { + return null; + } + + const percent = Math.round(query.data.percent); + + if (percent < 1) { + return null; + } + + return ( + <> + + + GasHawk logo + + Save { percent.toLocaleString(undefined, { maximumFractionDigits: 0 }) }% with GasHawk + + + + ); +}; + +export default React.memo(AddressSaveOnGas); diff --git a/ui/address/utils/useAddressCountersQuery.ts b/ui/address/utils/useAddressCountersQuery.ts index c0dc4ee995..44397fc59e 100644 --- a/ui/address/utils/useAddressCountersQuery.ts +++ b/ui/address/utils/useAddressCountersQuery.ts @@ -24,7 +24,7 @@ interface Params { addressQuery: AddressQuery; } -export default function useAddressQuery({ hash, addressQuery }: Params): AddressCountersQuery { +export default function useAddressCountersQuery({ hash, addressQuery }: Params): AddressCountersQuery { const enabled = Boolean(hash) && !addressQuery.isPlaceholderData; const apiQuery = useApiQuery<'address_counters', { status: number }>('address_counters', {