Essential dapps (#3026)

This commit is contained in:
Max Alekseenko
2025-10-17 14:58:12 +02:00
committed by GitHub
parent dba988715e
commit 487fe0317a
157 changed files with 6754 additions and 143 deletions
+1 -1
View File
@@ -114,4 +114,4 @@ jobs:
build-args: |
GIT_COMMIT_SHA=${{ env.SHORT_SHA }}
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }}
${{ inputs.build_args }}
${{ inputs.build_args }}
+2 -1
View File
@@ -15,6 +15,7 @@
/public/assets/envs.js
/public/assets/configs
/public/assets/multichain
/public/assets/essential-dapps
/public/icons/sprite.svg
/public/icons/sprite.*.svg
/public/icons/registry.json
@@ -63,4 +64,4 @@ yarn-error.log*
# build outputs
/tools/preset-sync/index.js
/toolkit/package/dist
/toolkit/package/dist
+15 -3
View File
@@ -51,6 +51,12 @@ WORKDIR /multichain-config-generator
COPY ./deploy/tools/multichain-config-generator/package.json ./deploy/tools/multichain-config-generator/yarn.lock ./
RUN yarn --frozen-lockfile --network-timeout 100000
### ESSENTIAL DAPPS CHAINS CONFIG GENERATOR
# Install dependencies
WORKDIR /essential-dapps-chains-config-generator
COPY ./deploy/tools/essential-dapps-chains-config-generator/package.json ./
RUN yarn --frozen-lockfile --network-timeout 100000
### llms.txt GENERATOR
# Install dependencies
WORKDIR /llms-txt-generator
@@ -104,7 +110,7 @@ RUN cd ./deploy/tools/feature-reporter && yarn build
### ENV VARIABLES CHECKER
# Copy dependencies and source code, then build
# Copy dependencies and source code, then build
COPY --from=deps /envs-validator/node_modules ./deploy/tools/envs-validator/node_modules
RUN cd ./deploy/tools/envs-validator && yarn build
@@ -119,12 +125,17 @@ COPY --from=deps /favicon-generator/node_modules ./deploy/tools/favicon-generato
COPY --from=deps /sitemap-generator/node_modules ./deploy/tools/sitemap-generator/node_modules
### MULTICHAIN CONFIG GENERATOR
# Copy dependencies and source code, then build
# Copy dependencies and source code, then build
COPY --from=deps /multichain-config-generator/node_modules ./deploy/tools/multichain-config-generator/node_modules
RUN cd ./deploy/tools/multichain-config-generator && yarn build
### ESSENTIAL DAPPS CHAINS CONFIG GENERATOR
# Copy dependencies and source code, then build
COPY --from=deps /essential-dapps-chains-config-generator/node_modules ./deploy/tools/essential-dapps-chains-config-generator/node_modules
RUN cd ./deploy/tools/essential-dapps-chains-config-generator && yarn build
### llms.txt GENERATOR
# Copy dependencies and source code, then build
# Copy dependencies and source code, then build
COPY --from=deps /llms-txt-generator/node_modules ./deploy/tools/llms-txt-generator/node_modules
RUN cd ./deploy/tools/llms-txt-generator && yarn build
@@ -158,6 +169,7 @@ COPY --from=builder /app/deploy/tools/envs-validator/index.js ./envs-validator.j
COPY --from=builder /app/deploy/tools/feature-reporter/index.js ./feature-reporter.js
COPY --from=builder /app/deploy/tools/multichain-config-generator/dist ./deploy/tools/multichain-config-generator/dist
COPY --from=builder /app/deploy/tools/llms-txt-generator/dist ./deploy/tools/llms-txt-generator/dist
COPY --from=builder /app/deploy/tools/essential-dapps-chains-config-generator/dist ./deploy/tools/essential-dapps-chains-config-generator/dist
# Copy scripts
## Entrypoint
+6 -1
View File
@@ -1,8 +1,10 @@
import type { Feature } from './types';
import type { EssentialDappsConfig } from 'types/client/marketplace';
import apis from '../apis';
import chain from '../chain';
import { getEnvValue, getExternalAssetFilePath } from '../utils';
import { getEnvValue, getExternalAssetFilePath, parseEnvJson } from '../utils';
import blockchainInteraction from './blockchainInteraction';
// config file will be downloaded at run-time and saved in the public folder
const enabled = getEnvValue('NEXT_PUBLIC_MARKETPLACE_ENABLED');
@@ -14,6 +16,7 @@ const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL');
const graphLinksUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL');
const essentialDappsConfig = parseEnvJson<EssentialDappsConfig>(getEnvValue('NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG'));
const title = 'Marketplace';
@@ -27,6 +30,7 @@ const config: Feature<(
featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined;
graphLinksUrl: string | undefined;
essentialDapps: EssentialDappsConfig | undefined;
}> = (() => {
if (enabled === 'true' && chain.rpcUrls.length > 0 && submitFormUrl) {
const props = {
@@ -39,6 +43,7 @@ const config: Feature<(
linkUrl: bannerLinkUrl,
} : undefined,
graphLinksUrl,
essentialDapps: blockchainInteraction.isEnabled ? (essentialDappsConfig || undefined) : undefined,
};
if (configUrl) {
+2 -1
View File
@@ -43,6 +43,7 @@ NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blocksc
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG={'swap': {'chains': ['1', '10', '30', '100', '122', '130', '137', '324', '480', '1135', '1514', '1868', '8453', '13371', '42161', '42220', '57073', '534352', '11155111', '1313161554'], 'fee': '0.004', 'integrator': 'blockscout'}, 'revoke': {'chains': ['1', '10', '30', '100', '122', '130', '137', '324', '480', '1135', '1514', '1868', '8453', '13371', '42161', '42220', '57073', '534352', '11155111', '1313161554']}, 'multisend': {'chains': ['1', '10', '30', '100', '122', '130', '137', '324', '480', '1135', '1514', '1868', '8453', '13371', '42161', '42220', '57073', '534352', '11155111', '1313161554']}}
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true
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'},{'name': 'zapper', 'url_template': 'https://zapper.xyz/account/{address}', 'logo': 'https://blockscout-content.s3.amazonaws.com/zapper-icon.png'}]
@@ -71,4 +72,4 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'Rarible','collection_url':'https://rarible.com/collection/{hash}/items','instance_url':'https://rarible.com/token/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'},{'name':'Blur','collection_url':'https://blur.io/eth/collection/{hash}','instance_url':'https://blur.io/eth/asset/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/blur.png'},{'name':'MagicEden','collection_url':'https://magiceden.io/collections/ethereum/{hash}','instance_url':'https://magiceden.io/item-details/ethereum/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/magiceden.png'}]
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
@@ -0,0 +1,48 @@
/* eslint-disable no-restricted-properties */
import type { MultichainConfig } from 'types/multichain';
import config from 'configs/app';
function isRunningInDocker() {
return process.env.HOSTNAME !== undefined;
}
let value: MultichainConfig | undefined = undefined;
async function fetchConfig() {
if (process.env.NEXT_RUNTIME !== 'edge') {
throw new Error('NEXT_RUNTIME is not edge');
}
// In edge runtime, we need to use absolute URLs
// When in Docker, use the internal hostname
const baseUrl = isRunningInDocker() ?
`http://${ process.env.HOSTNAME }:${ config.app.port || 3000 }` :
config.app.baseUrl;
const url = baseUrl + '/assets/essential-dapps/chains.json';
const response = await fetch(url);
const json = await response.json();
value = json as MultichainConfig;
return value;
}
export async function load() {
if (!value) {
return new Promise<MultichainConfig | undefined>((resolve, reject) => {
fetchConfig()
.then((value) => {
resolve(value);
}).catch((error) => {
reject(error);
});
});
}
return Promise.resolve(value);
}
export function getValue() {
return value;
}
@@ -0,0 +1,43 @@
import type { MultichainConfig } from 'types/multichain';
let value: MultichainConfig | undefined = undefined;
function readFileConfig() {
// eslint-disable-next-line no-restricted-properties
if (process.env.NEXT_RUNTIME !== 'nodejs') {
throw new Error('NEXT_RUNTIME is not nodejs');
}
try {
const path = require('path');
const { readFileSync } = require('fs');
const publicFolder = path.resolve('public');
const configPath = path.resolve(publicFolder, 'assets/essential-dapps/chains.json');
const config = readFileSync(configPath, 'utf8');
value = JSON.parse(config) as MultichainConfig;
return value;
} catch (error) {
return;
}
}
export async function load() {
if (!value) {
return new Promise<MultichainConfig | undefined>((resolve) => {
const value = readFileConfig();
resolve(value);
});
}
return Promise.resolve(value);
}
export function getValue() {
if (!value) {
return readFileConfig();
}
return value;
}
+18
View File
@@ -0,0 +1,18 @@
import type { MultichainConfig } from 'types/multichain';
import config from 'configs/app';
import { isBrowser } from 'toolkit/utils/isBrowser';
const marketplaceFeature = config.features.marketplace;
const essentialDappsChains: () => MultichainConfig | undefined = () => {
if (!marketplaceFeature.isEnabled || !marketplaceFeature.essentialDapps) {
return;
}
if (isBrowser()) {
return window.__essentialDappsChains;
}
};
export default essentialDappsChains;
Vendored
+1
View File
@@ -1,3 +1,4 @@
declare module 'react-identicons';
declare module 'use-font-face-observer';
declare module 'brotli-compress/js';
declare module '@multisender.app/multisender-react-widget';
+4 -1
View File
@@ -17,7 +17,7 @@ export_envs_from_preset() {
fi
local blacklist=(
"NEXT_PUBLIC_APP_PROTOCOL"
"NEXT_PUBLIC_APP_PROTOCOL"
"NEXT_PUBLIC_APP_HOST"
"NEXT_PUBLIC_APP_PORT"
"NEXT_PUBLIC_APP_ENV"
@@ -71,6 +71,9 @@ node --no-warnings ./og_image_generator.js
# Generate multichain config
node ./deploy/tools/multichain-config-generator/dist/index.js
# Generate essential dapps chains config
node ./deploy/tools/essential-dapps-chains-config-generator/dist/index.js
# Generate sitemap.xml and robots.txt files
./sitemap_generator.sh
+41 -3
View File
@@ -18,7 +18,7 @@ import type { DeFiDropdownItem } from '../../../types/client/deFiDropdown';
import type { GasRefuelProviderConfig } from '../../../types/client/gasRefuelProviderConfig';
import { GAS_UNITS } from '../../../types/client/gasTracker';
import type { GasUnit } from '../../../types/client/gasTracker';
import type { MarketplaceAppBase, MarketplaceAppSocialInfo } from '../../../types/client/marketplace';
import type { MarketplaceAppBase, MarketplaceAppSocialInfo, EssentialDappsConfig } from '../../../types/client/marketplace';
import type { MultichainProviderConfig } from '../../../types/client/multichainProviderConfig';
import type { ApiDocsTabId } from '../../../types/views/apiDocs';
import { API_DOCS_TABS } from '../../../types/views/apiDocs';
@@ -178,6 +178,44 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG: yup
.mixed()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema.test('shape', 'Invalid schema were provided for NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG, it should contain optional swap/revoke/multisend sections with required fields', (data) => {
const isUndefined = data === undefined;
const chainsSchema = yup.array().of(yup.string().required()).min(1).required();
const valueSchema = yup.object<EssentialDappsConfig>().transform(replaceQuotes).json().shape({
swap: yup.lazy(value => value ?
yup.object<EssentialDappsConfig['swap']>().shape({
chains: chainsSchema,
fee: yup.string().required(),
integrator: yup.string().required(),
}) :
yup.object().nullable(),
),
revoke: yup.lazy(value => value ?
yup.object<EssentialDappsConfig['revoke']>().shape({ chains: chainsSchema }) :
yup.object().nullable(),
),
multisend: yup.lazy(value => value ?
yup.object<EssentialDappsConfig['multisend']>().shape({
chains: chainsSchema,
posthogKey: yup.string(),
posthogHost: yup.string().test(urlTest),
}) :
yup.object().nullable(),
),
});
return isUndefined || valueSchema.isValidSync(data);
}),
// eslint-disable-next-line max-len
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED',
value => value === undefined,
),
}),
});
const beaconChainSchema = yup
@@ -687,11 +725,11 @@ const zetaChainSchema = yup
.test('zetachain-api-host-dependency', 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST cannot be used without NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL', function(value) {
const hasApiHost = Boolean(value?.NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST);
const hasChainsConfig = Boolean(value?.NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL?.length);
if (hasApiHost && !hasChainsConfig) {
return this.createError({ message: 'NEXT_PUBLIC_ZETACHAIN_SERVICE_API_HOST cannot be used without NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL' });
}
return true;
});
@@ -7,3 +7,4 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG={'swap': {'chains': ['1', '10', '100', '11155111'], 'fee': '0.004', 'integrator': 'blockscout'}, 'revoke': {'chains': ['1', '10', '100', '11155111']}, 'multisend': {'chains': ['1', '10', '100', '11155111'], 'posthogKey': '123', 'posthogHost': 'https://example.com'}}
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store
@@ -0,0 +1,12 @@
#!/bin/bash
config_file="../../../configs/envs/.env.eth"
if [ ! -f "$config_file" ]; then
echo "Error: File '$config_file' not found."
exit 1
fi
dotenv \
-e $config_file \
-- bash -c 'yarn build && yarn generate'
@@ -0,0 +1,144 @@
/* eslint-disable no-console */
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
import * as viemChains from 'viem/chains';
import { pick } from 'es-toolkit';
import { EssentialDappsConfig } from 'types/client/marketplace';
import { ChainConfig } from 'types/multichain';
import { getEnvValue, parseEnvJson } from 'configs/app/utils';
import {uniq } from 'es-toolkit';
import currentChainConfig from 'configs/app';
const currentFilePath = fileURLToPath(import.meta.url);
const currentDir = dirname(currentFilePath);
async function getChainscoutInfo(externalChainIds: Array<string>, currentChainId: string | undefined) {
const response = await fetch('https://chains.blockscout.com/api/chains');
if (!response.ok) {
throw new Error(`Failed to fetch chains info from Chainscout API`);
}
const chainsInfo = await response.json() as Record<string, { explorers: [ { url: string } ], logo: string }>;
return {
externals: externalChainIds.map((chainId) => ({
id: chainId,
explorerUrl: chainsInfo[chainId]?.explorers[0]?.url,
logoUrl: chainsInfo[chainId]?.logo,
})),
current: currentChainId ? {
id: currentChainId,
explorerUrl: chainsInfo[currentChainId]?.explorers[0]?.url,
logoUrl: chainsInfo[currentChainId]?.logo,
} : undefined,
}
}
function getSlug(chainName: string) {
return chainName.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-]/g, '');
}
function trimChainConfig(config: ChainConfig['config'], logoUrl: string | undefined) {
return {
...pick(config, [ 'app', 'chain' ]),
apis: pick(config.apis || {}, [ 'general' ]),
UI: {
navigation: {
icon: {
'default': logoUrl,
}
},
}
};
}
async function computeChainConfig(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const workerPath = resolvePath(currentDir, 'worker.js');
const worker = new Worker(workerPath, {
workerData: { url },
env: {} // Start with empty environment
});
worker.on('message', (config) => {
resolve(config);
});
worker.on('error', (error) => {
console.error('Worker error:', error);
reject(error);
});
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${ code }`));
}
});
});
}
async function run() {
try {
if (!process.env.NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG) {
console.log('️ NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG is not set, skipping essential dapps chains config generation\n');
return;
}
console.log('🌀 Generating essential dapps chains config...');
const featureConfig = parseEnvJson<EssentialDappsConfig>(getEnvValue('NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG'));
const enabledChains = uniq([
...Object.values(featureConfig?.swap?.chains ?? []),
...Object.values(featureConfig?.revoke?.chains ?? []),
...Object.values(featureConfig?.multisend?.chains ?? []),
].filter((chain) => chain !== currentChainConfig.chain.id))
if (enabledChains.length === 0) {
console.log('⚠️ No chains are enabled. Skipping essential dapps chains config generation.\n');
return;
}
const chainscoutInfo = await getChainscoutInfo(enabledChains, currentChainConfig.chain.id);
const chainsWithoutUrl = Object.entries(chainscoutInfo.externals).filter(([_, explorerUrl]) => !explorerUrl);
if (chainsWithoutUrl.length > 0) {
console.log(`⚠️ For the following chains explorer url was not found: ${ chainsWithoutUrl.map(([chainId]) => chainId).join(', ') }. Therefore, they will not be enabled.`);
}
const explorerUrls = Object.values(chainscoutInfo.externals).map(({ explorerUrl }) => explorerUrl);
console.log(`️ For ${ explorerUrls.length } chains explorer url was found in static config. Fetching parameters for each chain...`);
const chainConfigs = await Promise.all(explorerUrls.map(computeChainConfig)) as Array<ChainConfig['config']>;
const result = {
chains: [ currentChainConfig, ...chainConfigs ].map((config, index) => {
const logoUrl = [...chainscoutInfo.externals, chainscoutInfo.current].find((chain) => chain?.id === config.chain.id)?.logoUrl;
const chainName = (config as { chain: { name: string } })?.chain?.name ?? `Chain ${ index + 1 }`;
return {
slug: getSlug(chainName),
config: trimChainConfig(config, logoUrl),
contracts: Object.values(viemChains).find(({ id }) => id === Number(config.chain.id))?.contracts
};
}),
};
const outputDir = resolvePath(currentDir, '../../../../public/assets/essential-dapps');
mkdirSync(outputDir, { recursive: true });
const outputPathJson = resolvePath(outputDir, 'chains.json');
writeFileSync(outputPathJson, JSON.stringify(result, null, 2));
const outputPathJs = resolvePath(outputDir, 'chains.js');
writeFileSync(outputPathJs, `window.__essentialDappsChains = ${ JSON.stringify(result) };`);
console.log('👍 Done!\n');
} catch (error) {
console.error('🚨 Error generating essential dapps chains config:', error);
console.log('\n');
process.exit(1);
}
}
run();
@@ -0,0 +1,22 @@
{
"name": "@blockscout/essential-dapps-chains-config-generator",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build --logLevel error",
"generate": "node dist/index.js",
"dev": "./dev.sh"
},
"dependencies": {
"es-toolkit": "1.39.10",
"viem": "2.23.14"
},
"devDependencies": {
"@types/node": "22.12.0",
"dotenv-cli": "10.0.0",
"typescript": "5.4.2",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.4"
}
}
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"configs/*": ["../../../configs/*"],
"lib/*": ["../../../lib/*"],
"toolkit/*": ["../../../toolkit/*"],
"types/*": ["../../../types/*"]
},
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"index.ts",
"worker.ts",
],
"exclude": [
"node_modules",
"dist"
]
}
@@ -0,0 +1,33 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'index.ts'),
worker: resolve(__dirname, 'worker.ts'),
},
formats: [ 'es' ],
fileName: (format, entryName) => `${ entryName }.js`,
},
rollupOptions: {
external: [ 'node:url', 'node:path', 'node:fs', 'node:worker_threads' ],
output: {
dir: 'dist',
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]',
},
},
},
resolve: {
alias: {
configs: resolve(__dirname, '../../../configs'),
lib: resolve(__dirname, '../../../lib'),
toolkit: resolve(__dirname, '../../../toolkit'),
types: resolve(__dirname, '../../../types'),
},
preserveSymlinks: true,
},
});
@@ -0,0 +1,48 @@
/* eslint-disable no-console */
import { parentPort, workerData } from 'node:worker_threads';
interface WorkerData {
url: string;
}
interface ChainConfig {
envs: Record<string, string>;
}
async function fetchChainConfig(url: string): Promise<ChainConfig> {
const response = await fetch(`${ url }/node-api/config`);
if (!response.ok) {
throw new Error(`Failed to fetch config from ${ url }: ${ response.statusText }`);
}
const config = await response.json();
return config as ChainConfig;
}
async function computeConfig() {
try {
const { url } = workerData as WorkerData;
console.log(' ⏳ Fetching chain config from:', url);
// 1. Fetch chain config
const chainConfig = await fetchChainConfig(url);
// 2. Set environment variables
Object.entries(chainConfig.envs).forEach(([ key, value ]) => {
// eslint-disable-next-line no-restricted-properties
process.env[key] = value;
});
// 3. Import and compute app config
const { 'default': appConfig } = await import('configs/app/index');
console.log(' ✅ Config computed for:', url);
// 4. Send config back to main thread
parentPort?.postMessage(appConfig);
} catch (error) {
console.error(' ❌ Worker error:', error);
process.exit(1);
}
}
computeConfig();
File diff suppressed because it is too large Load Diff
+13
View File
@@ -584,6 +584,7 @@ Ads are enabled by default on all self-hosted instances. If you would like to di
| NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL | `string` | URL of the file (`.json` format only) which contains the list of The Graph links to be displayed on the Marketplace page | - | - | `https://example.com/graph_links.json` | v1.36.0+ |
| NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG | `EssentialDappsConfig`, see details [below](#essential-dapps-configuration-properties) | Configuration of the essential dapps to be displayed on the Marketplace page | - | - | `{'swap': {'chains': ['1', '10', '100', '11155111'], 'fee': '0.004', 'integrator': 'blockscout'}}` | upcoming |
#### Marketplace app configuration properties
@@ -605,6 +606,18 @@ Ads are enabled by default on all self-hosted instances. If you would like to di
| internalWallet | `boolean` | `true` means that the application can automatically connect to the Blockscout wallet. | - | `true` |
| priority | `number` | The higher the priority, the higher the app will appear in the list on the Marketplace page. | - | `7` |
#### Essential dapps configuration properties
Essential dapps are built-in dapps that are displayed on the Marketplace page in a separate section.
*Note* All chains should have a Blockscout instance. The current chain id should also be present in the `chains` array, if needed.
| Property | Type | Description | Compulsoriness | Example value |
| --- | --- | --- | --- | --- |
| swap | `{ chains: Array<string>, fee: string, integrator: string }` | Swap config | - | `{'chains': ['1'], 'fee': '0.004', 'integrator': 'blockscout'}` |
| revoke | `{ chains: Array<string> }` | Revoke config | - | `{'chains': ['100']}` |
| multisend | `{ chains: Array<string> }` | Multisend config | - | `{'chains': ['1', '10']}` |
&nbsp;
### Solidity to UML diagrams
Vendored
+1
View File
@@ -21,6 +21,7 @@ declare global {
abkw: string;
__envs: Record<string, string>;
__multichainConfig: MultichainConfig;
__essentialDappsChains: MultichainConfig;
}
namespace NodeJS {
+1 -1
View File
@@ -1,4 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.575 15v4.599L8.841 20l-.714-.401V15h1.448Zm2.899-12.955-.738 1.278L8.928 1.71v1.453H8.7V1.73L2.238 5.53l6.138 3.617-.701 1.215L1.37 6.8v7.48l1.244-.713.113.197-1.26.72 5.324 3.118.033 1.671L0 15.297V5.029L8.83 0l3.644 2.045ZM5.068 12.43l-1.32.752-.111-.197 1.32-.752.111.197Zm2.245-1.44-.134.232-1.12.637-.112-.196 1.32-.752.045.078Zm1.615-4.17v1.37l-.081.142H8.7V6.821h.227Zm0-2.644v1.52H8.7v-1.52h.227Z" fill="currentColor"/>
<path d="M9.575 15v4.599L8.841 20l-.714-.401V15h1.448Zm2.899-12.955-.738 1.278L8.928 1.71v1.453H8.7V1.73l-6.462 3.8 6.138 3.617-.701 1.215L1.37 6.8v7.48l1.244-.713.113.197-1.26.72 5.324 3.118.033 1.671L0 15.297V5.029L8.83 0l3.644 2.045ZM5.068 12.43l-1.32.752-.111-.197 1.32-.752.111.197Zm2.245-1.44-.134.232-1.12.637-.112-.196 1.32-.752.045.078Zm1.615-4.17v1.37l-.081.142H8.7V6.821h.227Zm0-2.644v1.52H8.7v-1.52h.227Z" fill="currentColor"/>
<path d="M14.353 9.353H20L12.941 19.94v-6.353H8L14.353 3v6.353Zm-1.412 1.412V8.096l-2.448 4.08h3.86v3.102l3.01-4.513H12.94Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 671 B

After

Width:  |  Height:  |  Size: 671 B

+1 -1
View File
@@ -1,5 +1,5 @@
<svg viewBox="0 0 30 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.232 23.08h6.577v2.23H5.115A1.115 1.115 0 0 1 4 24.194V4.115A1.115 1.115 0 0 1 5.115 3h17.617a1.117 1.117 0 0 1 1.116 1.115V13h-2.23V5.231H6.231V23.08Z" fill="currentColor"/>
<path d="M14.188 13.584c1.085.04 2.092.36 2.966.877-.72.33-1.373.78-1.931 1.327a4.22 4.22 0 0 0-1.268-.195c-1.48 0-2.78.725-3.589 1.879a.418.418 0 0 1-.34.178H8.643a.417.417 0 0 1-.368-.609c1.088-2.07 3.208-3.461 5.68-3.462l.233.005ZM14.086 6.6a3.184 3.184 0 0 1 3.022 3.179 3.184 3.184 0 0 1-3.022 3.178l-.164.005a3.185 3.185 0 0 1-3.186-3.183 3.186 3.186 0 0 1 3.186-3.184l.164.005Zm-.164 2.009a1.17 1.17 0 0 0-1.17 1.17l.006.119a1.17 1.17 0 0 0 2.335-.12l-.006-.119a1.171 1.171 0 0 0-1.165-1.05Z" fill="currentColor"/>
<path d="M14.188 13.584a6.26 6.26 0 0 1 2.966.877c-.72.33-1.373.78-1.931 1.327a4.22 4.22 0 0 0-1.268-.195c-1.48 0-2.78.725-3.589 1.879a.418.418 0 0 1-.34.178H8.643a.417.417 0 0 1-.368-.609c1.088-2.07 3.208-3.461 5.68-3.462l.233.005ZM14.086 6.6a3.184 3.184 0 0 1 3.022 3.179 3.184 3.184 0 0 1-3.022 3.178l-.164.005a3.185 3.185 0 0 1-3.186-3.183 3.186 3.186 0 0 1 3.186-3.184l.164.005Zm-.164 2.009a1.17 1.17 0 0 0-1.17 1.17l.006.119a1.17 1.17 0 0 0 2.335-.12l-.006-.119a1.171 1.171 0 0 0-1.165-1.05Z" fill="currentColor"/>
<path clip-rule="evenodd" d="M22.998 24.393a4.805 4.805 0 1 1 .728-.728l2.457 2.456a.515.515 0 1 1-.728.728l-2.457-2.456Zm.761-3.743a3.776 3.776 0 1 1-7.55 0 3.776 3.776 0 0 1 7.55 0Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M8.707 4.707a1 1 0 0 0-1.414-1.414l-5 5a1 1 0 0 0 0 1.414l5 5a1 1 0 0 0 1.414-1.414L5.414 10H15.5a4.5 4.5 0 1 1 0 9H5a1 1 0 1 0 0 2h10.5a6.5 6.5 0 1 0 0-13H5.414l3.293-3.293Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

+7
View File
@@ -19,6 +19,7 @@ import type {
AddressTokenTransferFilters,
AddressTokensFilter,
AddressNFTTokensFilter,
AddressTokenBalancesResponse,
} from 'types/api/address';
import type { AddressesMetadataSearchFilters, AddressesMetadataSearchResult, AddressesResponse } from 'types/api/addresses';
import type { DepositsResponse } from 'types/api/deposits';
@@ -98,6 +99,11 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
filterFields: [ 'type' as const ],
paginated: true,
},
address_token_balances: {
path: '/api/v2/addresses/:hash/token-balances',
pathParams: [ 'hash' as const ],
filterFields: [ ],
},
address_nfts: {
path: '/api/v2/addresses/:hash/nft',
pathParams: [ 'hash' as const ],
@@ -178,6 +184,7 @@ R extends 'general:address_coin_balance' ? AddressCoinBalanceHistoryResponse :
R extends 'general:address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
R extends 'general:address_logs' ? LogsResponseAddress :
R extends 'general:address_tokens' ? AddressTokensResponse :
R extends 'general:address_token_balances' ? AddressTokenBalancesResponse :
R extends 'general:address_nfts' ? AddressNFTsResponse :
R extends 'general:address_collections' ? AddressCollectionsResponse :
R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse :
+5 -2
View File
@@ -1,6 +1,8 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { ChainConfig } from 'types/multichain';
import multichainConfig from 'configs/multichain';
import { useMultichainContext } from 'lib/contexts/multichain';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
@@ -15,6 +17,7 @@ export interface Params<R extends ResourceName, E = unknown, D = ResourcePayload
queryOptions?: Partial<Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryFn'>>;
logError?: boolean;
chainSlug?: string;
chain?: ChainConfig;
}
export interface GetResourceKeyParams<R extends ResourceName, E = unknown, D = ResourcePayload<R>>
@@ -32,10 +35,10 @@ export function getResourceKey<R extends ResourceName>(resource: R, { pathParams
export default function useApiQuery<R extends ResourceName, E = unknown, D = ResourcePayload<R>>(
resource: R,
{ queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug }: Params<R, E, D> = {},
{ queryOptions, pathParams, queryParams, fetchParams, logError, chainSlug, chain: chainProp }: Params<R, E, D> = {},
) {
const apiFetch = useApiFetch();
const { chain } = useMultichainContext() ||
const { chain } = useMultichainContext() || (chainProp && { chain: chainProp }) ||
{ chain: chainSlug ? multichainConfig()?.chains.find((chain) => chain.slug === chainSlug) : undefined };
return useQuery<ResourcePayload<R>, ResourceError<E>, D>({
+1 -1
View File
@@ -350,7 +350,7 @@ export default function useNavItems(): ReturnType {
text: 'DApps',
nextRoute: { pathname: '/apps' as const },
icon: 'apps',
isActive: pathname.startsWith('/app'),
isActive: pathname.startsWith('/app') || pathname.startsWith('/essential-dapps'),
} : null,
statsNavItem,
apiNavItem,
+2 -2
View File
@@ -59,12 +59,12 @@ export default function useRewardsActivity() {
} catch {}
}, [ apiFetch, checkActivityPassQuery.data, apiToken ]);
const trackTransaction = useCallback(async(from: string, to: string) => {
const trackTransaction = useCallback(async(from: string, to: string, chainId?: string) => {
return (
await makeRequest('rewards:user_activity_track_tx', {
from_address: from,
to_address: to,
chain_id: config.chain.id ?? '',
chain_id: chainId || config.chain.id || '',
})
) as PreSubmitTransactionResponse | undefined;
}, [ makeRequest ]);
+4
View File
@@ -13,12 +13,16 @@ import getPageOgType from './getPageOgType';
import * as templates from './templates';
export default function generate<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata {
const idCap = route.pathname === '/essential-dapps/[id]' && typeof route.query?.id === 'string' ?
route.query.id.charAt(0).toUpperCase() + route.query.id.slice(1) : undefined;
const params = {
...route.query,
...apiData,
network_name: config.chain.name,
network_title: getNetworkTitle(),
network_gwei: currencyUnits.gwei,
id_cap: idCap,
};
const title = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params);
+1
View File
@@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/token/[hash]/instance/[id]': 'Regular page',
'/apps': 'Root page',
'/apps/[id]': 'Regular page',
'/essential-dapps/[id]': 'Regular page',
'/clusters/[name]': 'Regular page',
'/stats': 'Root page',
'/stats/[id]': 'Regular page',
+1
View File
@@ -26,6 +26,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/token/[hash]/instance/[id]': '%hash%, balances and analytics on the %network_title%',
'/apps': DEFAULT_TEMPLATE,
'/apps/[id]': DEFAULT_TEMPLATE,
'/essential-dapps/[id]': DEFAULT_TEMPLATE,
'/clusters/[name]': '%cluster_name% cluster | %app_name%',
'/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
+1
View File
@@ -23,6 +23,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/token/[hash]/instance/[id]': '%network_name% NFT instance',
'/apps': '%network_name% DApps - Explore top apps',
'/apps/[id]': '%network_name% marketplace app',
'/essential-dapps/[id]': '%id_cap%',
'/stats': '%network_name% stats - %network_name% network insights',
'/stats/[id]': '%network_name% stats - %id% chart',
'/uptime': '%network_name% uptime',
+1
View File
@@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/token/[hash]/instance/[id]': 'Token Instance',
'/apps': 'DApps',
'/apps/[id]': 'DApp',
'/essential-dapps/[id]': 'Essential dapps',
'/clusters/[name]': 'Cluster details',
'/stats': 'Stats',
'/stats/[id]': 'Stats chart',
+2
View File
@@ -21,6 +21,7 @@ export default function useLogPageView(isInited: boolean) {
const tab = getQueryParamString(router.query.tab);
const page = getQueryParamString(router.query.page);
const id = getQueryParamString(router.query.id);
const { colorMode } = useColorMode();
React.useEffect(() => {
@@ -34,6 +35,7 @@ export default function useLogPageView(isInited: boolean) {
'Page type': getPageType(router.pathname),
Tab: getTabName(tab),
Page: page || undefined,
Source: router.pathname === '/essential-dapps/[id]' ? id : undefined,
'Color mode': colorMode,
'Color theme': cookieColorTheme || getDefaultColorTheme(colorMode),
});
+9 -2
View File
@@ -34,6 +34,7 @@ Type extends EventTypes.PAGE_VIEW ?
'Page type': string;
Tab: string;
Page?: string;
Source?: string;
'Color mode': 'light' | 'dark';
'Color theme': ColorThemeId | undefined;
} :
@@ -98,7 +99,7 @@ Type extends EventTypes.VERIFY_TOKEN ? {
Action: 'Form opened' | 'Submit';
} :
Type extends EventTypes.WALLET_CONNECT ? {
Source: 'Header' | 'Login' | 'Profile' | 'Profile dropdown' | 'Smart contracts' | 'Swap button' | 'Merits';
Source: 'Header' | 'Login' | 'Profile' | 'Profile dropdown' | 'Smart contracts' | 'Swap button' | 'Merits' | 'Essential dapps';
Status: 'Started' | 'Connected';
} :
Type extends EventTypes.WALLET_ACTION ? (
@@ -106,8 +107,10 @@ Type extends EventTypes.WALLET_ACTION ? (
Action: 'Open' | 'Address click';
} | {
Action: 'Send Transaction' | 'Sign Message' | 'Sign Typed Data';
Address: string | undefined;
Source: 'Dappscout' | 'Essential dapps';
AppId: string;
Address?: string;
ChainId?: string;
}
) :
Type extends EventTypes.CONTRACT_INTERACTION ? {
@@ -139,6 +142,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
} | {
Type: 'Share chart';
Info: string;
} | {
Type: 'Chain switch';
Info: string;
Source: 'Revoke essential dapp';
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
+9 -1
View File
@@ -1,8 +1,16 @@
import type { ChainConfig } from 'types/multichain';
export default function getIconUrl(config: ChainConfig) {
const appUrl = config.config.app.baseUrl;
if (!config.config?.UI?.navigation?.icon || !config.config.app) {
return;
}
const iconPath = config.config.UI.navigation.icon.default;
if (iconPath?.startsWith('http')) {
return iconPath;
}
const appUrl = config.config.app.baseUrl;
return `${ appUrl }${ iconPath }`;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { useCallback } from 'react';
export function useQueryParams() {
const router = useRouter();
const updateQuery = useCallback((updates: Record<string, string | undefined>) => {
const updateQuery = useCallback((updates: Record<string, string | undefined>, replace = false) => {
const newQuery = { ...router.query };
Object.entries(updates).forEach(([ key, value ]) => {
@@ -15,7 +15,8 @@ export function useQueryParams() {
}
});
router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true });
const routerFn = replace ? router.replace : router.push;
routerFn({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true });
}, [ router ]);
return { updateQuery };
+30 -3
View File
@@ -1,9 +1,10 @@
import { type Chain } from 'viem';
import type { Chain } from 'viem';
import appConfig from 'configs/app';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
import multichainConfig from 'configs/multichain';
const getChainInfo = (config: typeof appConfig = appConfig) => {
const getChainInfo = (config: typeof appConfig = appConfig, contracts?: Chain['contracts']): Chain => {
return {
id: Number(config.chain.id),
name: config.chain.name ?? '',
@@ -24,6 +25,7 @@ const getChainInfo = (config: typeof appConfig = appConfig) => {
},
},
testnet: config.chain.isTestnet,
contracts,
};
};
@@ -68,5 +70,30 @@ export const clusterChains: Array<Chain> | undefined = (() => {
return;
}
return config.chains.map(({ config }) => getChainInfo(config)).filter(Boolean);
return config.chains.map(({ config, contracts }) => getChainInfo(config, contracts)).filter(Boolean);
})();
export const essentialDappsChains: Array<Chain> | undefined = (() => {
const config = essentialDappsChainsConfig();
if (!config) {
return;
}
return config.chains.map(({ config, contracts }) => getChainInfo(config, contracts)).filter(Boolean);
})();
export const chains = (() => {
if (essentialDappsChains) {
const hasCurrentChain = essentialDappsChains.some((chain) => chain.id === currentChain?.id);
const hasParentChain = essentialDappsChains.some((chain) => chain.id === parentChain?.id);
return [
...essentialDappsChains,
hasCurrentChain ? undefined : currentChain,
hasParentChain ? undefined : parentChain,
].filter(Boolean);
}
return [ currentChain, parentChain, ...(clusterChains ?? []) ].filter(Boolean);
})();
+13 -12
View File
@@ -5,13 +5,12 @@ import { fallback, http } from 'viem';
import { createConfig } from 'wagmi';
import appConfig from 'configs/app';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
import multichainConfig from 'configs/multichain';
import { currentChain, parentChain, clusterChains } from 'lib/web3/chains';
import { chains, parentChain } from 'lib/web3/chains';
const feature = appConfig.features.blockchainInteraction;
const chains = [ currentChain, parentChain, ...(clusterChains ?? []) ].filter(Boolean);
const getChainTransportFromConfig = (config: typeof appConfig, readOnly?: boolean): Record<string, Transport> => {
if (!config.chain.id) {
return {};
@@ -22,19 +21,21 @@ const getChainTransportFromConfig = (config: typeof appConfig, readOnly?: boolea
config.chain.rpcUrls
.concat(readOnly ? `${ config.apis.general.endpoint }/api/eth-rpc` : '')
.filter(Boolean)
.map((url) => http(url, { batch: { wait: 100 } })),
.map((url) => http(url, { batch: { wait: 100, batchSize: 5 } })),
),
};
};
const reduceClusterChainsToTransportConfig = (readOnly: boolean): Record<string, Transport> => {
const config = multichainConfig();
const reduceExternalChainsToTransportConfig = (readOnly: boolean): Record<string, Transport> => {
const multichain = multichainConfig();
const essentialDapps = essentialDappsChainsConfig();
const chains = [ ...(multichain?.chains ?? []), ...(essentialDapps?.chains ?? []) ].filter(Boolean);
if (!config) {
if (!chains) {
return {};
}
return config.chains
return chains
.map(({ config }) => getChainTransportFromConfig(config, readOnly))
.reduce((result, item) => {
Object.entries(item).map(([ id, transport ]) => {
@@ -52,10 +53,10 @@ const wagmi = (() => {
transports: {
...getChainTransportFromConfig(appConfig, true),
...(parentChain ? { [parentChain.id]: http(parentChain.rpcUrls.default.http[0]) } : {}),
...reduceClusterChainsToTransportConfig(true),
...reduceExternalChainsToTransportConfig(true),
},
ssr: true,
batch: { multicall: { wait: 100 } },
batch: { multicall: { wait: 100, batchSize: 5 } },
});
return { config: wagmiConfig, adapter: null };
@@ -67,11 +68,11 @@ const wagmi = (() => {
transports: {
...getChainTransportFromConfig(appConfig, false),
...(parentChain ? { [parentChain.id]: http() } : {}),
...reduceClusterChainsToTransportConfig(false),
...reduceExternalChainsToTransportConfig(false),
},
projectId: feature.walletConnect.projectId,
ssr: true,
batch: { multicall: { wait: 100 } },
batch: { multicall: { wait: 100, batchSize: 5 } },
syncConnectedChain: false,
});
+54
View File
@@ -0,0 +1,54 @@
import type { AllowanceType } from 'types/client/revoke';
import * as contractsMock from 'mocks/contracts';
export const allowances: Array<AllowanceType> = [
{
type: 'ERC-20',
address: contractsMock.contract1.address.hash as `0x${ string }`,
transactionId: null,
allowance: 'Unlimited',
balance: '536',
decimals: 18,
spender: contractsMock.contract2.address.hash as `0x${ string }`,
symbol: 'USDT',
name: 'Tether USD',
totalSupply: BigInt(0),
timestamp: 1624800884000,
valueAtRiskUsd: 536,
tokenReputation: 'ok',
tokenIcon: 'http://localhost:3000/token-icon.png',
},
{
type: 'ERC-20',
address: contractsMock.contract2.address.hash as `0x${ string }`,
transactionId: null,
allowance: '30.5',
balance: '120.67',
decimals: 18,
spender: contractsMock.contract3.address.hash as `0x${ string }`,
symbol: 'UNI',
name: 'Uniswap',
totalSupply: BigInt(0),
timestamp: 1666596884000,
valueAtRiskUsd: 97,
tokenReputation: 'ok',
tokenIcon: 'http://localhost:3000/token-icon.png',
},
{
type: 'ERC-20',
address: contractsMock.contract3.address.hash as `0x${ string }`,
transactionId: null,
allowance: '1500.34',
balance: '3000',
decimals: 18,
spender: contractsMock.contract1.address.hash as `0x${ string }`,
symbol: 'UNKN',
name: 'Unknown',
totalSupply: BigInt(0),
timestamp: 1650785684000,
valueAtRiskUsd: 0,
tokenReputation: 'ok',
tokenIcon: 'http://localhost:3000/token-icon.png',
},
];
+3
View File
@@ -14,6 +14,9 @@ export const chainDataA = {
symbol: 'ETH',
decimals: 18,
},
rpcUrls: [
'https://rpc.op-mainnet.com',
],
},
UI: {
navigation: {
+4
View File
@@ -1,13 +1,17 @@
import appConfig from 'configs/app';
import * as essentialDappsChainsConfig from 'configs/essential-dapps-chains/config.edge';
import * as multichainConfig from 'configs/multichain/config.edge';
import generateCspPolicy from './generateCspPolicy';
const marketplaceFeature = appConfig.features.marketplace;
let cspPolicy: string | undefined = undefined;
export async function get() {
if (!cspPolicy) {
appConfig.features.opSuperchain.isEnabled && await multichainConfig.load();
marketplaceFeature.isEnabled && marketplaceFeature.essentialDapps && await essentialDappsChainsConfig.load();
cspPolicy = generateCspPolicy();
}
+20
View File
@@ -1,6 +1,7 @@
import type CspDev from 'csp-dev';
import config from 'configs/app';
import * as essentialDappsChains from 'configs/essential-dapps-chains/config.edge';
const feature = config.features.marketplace;
@@ -9,13 +10,32 @@ export function marketplace(): CspDev.DirectiveDescriptor {
return {};
}
const chainsConfig = feature.essentialDapps && essentialDappsChains.getValue();
const externalApiEndpoints = chainsConfig?.chains.map((chain) => chain.config.apis.general?.endpoint).filter(Boolean);
const defaultRpcUrls = chainsConfig?.chains.map((chain) => chain.config.chain.rpcUrls).flat();
const liFiHost = feature.essentialDapps?.swap ? 'li.quest' : '';
const multisenderHost = feature.essentialDapps?.multisend ? '*.multisender.app' : '';
const posthogHost = feature.essentialDapps?.multisend?.posthogHost ? '*.posthog.com' : '';
return {
'connect-src': [
'api' in feature ? feature.api.endpoint : '',
...(feature.essentialDapps ? [
liFiHost,
multisenderHost,
posthogHost,
...(externalApiEndpoints ?? []),
...(defaultRpcUrls ?? []),
] : []),
],
'frame-src': [
'*',
],
'script-src': [
posthogHost,
],
};
}
+9
View File
@@ -43,6 +43,15 @@ export const marketplace: Guard = (chainConfig: typeof config) => async() => {
}
};
export const marketplaceEssentialDapp: Guard = (chainConfig: typeof config) => async() => {
const feature = chainConfig.features.marketplace;
if (!feature.isEnabled || !feature.essentialDapps) {
return {
notFound: true,
};
}
};
export const apiDocs: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.apiDocs.isEnabled) {
return {
+1
View File
@@ -7,6 +7,7 @@ export const account = factory([ guards.account ]);
export const verifiedAddresses = factory([ guards.account, guards.verifiedAddresses ]);
export const userOps = factory([ guards.userOps ]);
export const marketplace = factory([ guards.marketplace ]);
export const marketplaceEssentialDapp = factory([ guards.marketplaceEssentialDapp ]);
export const apiDocs = factory([ guards.apiDocs ]);
export const csvExport = factory([ guards.csvExport ]);
export const stats = factory([ guards.stats ]);
+1 -1
View File
@@ -3,4 +3,4 @@
src: url('/static/fonts/Inter-fallback.woff2') format('woff2');
unicode-range: U+2192; /* Only the right arrow */
font-display: swap;
}
}
+1
View File
@@ -57,6 +57,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/dispute-games">
| DynamicRoute<"/epochs/[number]", { "number": string }>
| StaticRoute<"/epochs">
| DynamicRoute<"/essential-dapps/[id]", { "id": string }>
| StaticRoute<"/gas-tracker">
| StaticRoute<"/">
| StaticRoute<"/internal-txs">
+10 -1
View File
@@ -42,6 +42,7 @@
"postinstall": "chakra typegen ./toolkit/theme/theme.ts"
},
"dependencies": {
"@bigmi/react": "0.5.2",
"@blockscout/bens-types": "1.4.1",
"@blockscout/multichain-aggregator-types": "1.6.0-alpha.2",
"@blockscout/points-types": "1.4.0-alpha.1",
@@ -53,9 +54,13 @@
"@emotion/react": "11.14.0",
"@growthbook/growthbook-react": "0.21.0",
"@helia/verified-fetch": "2.6.12",
"@lifi/widget": "3.25.0",
"@lifi/wallet-management": "3.6.1",
"@metamask/post-message-stream": "^7.0.0",
"@metamask/providers": "^10.2.1",
"@monaco-editor/react": "^4.7.0",
"@multisender.app/multisender-react-widget": "0.2.2",
"@mysten/dapp-kit": "0.17.6",
"@next/bundle-analyzer": "15.5.2",
"@nouns/assets": "^0.10.0",
"@nouns/sdk": "^1.2.0",
@@ -68,11 +73,13 @@
"@opentelemetry/sdk-node": "0.49.1",
"@opentelemetry/sdk-trace-node": "1.22.0",
"@opentelemetry/semantic-conventions": "1.22.0",
"@openzeppelin/contracts": "5.4.0",
"@reown/appkit": "1.7.0",
"@reown/appkit-adapter-wagmi": "1.7.0",
"@rollbar/react": "0.12.1",
"@scure/base": "1.1.9",
"@slise/embed-react": "^2.2.0",
"@solana/wallet-adapter-react": "0.15.39",
"@specify-sh/sdk": "0.4.1",
"@tanstack/react-query": "5.55.4",
"@tanstack/react-query-devtools": "5.55.4",
@@ -196,7 +203,9 @@
},
"resolutions": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1"
"@types/react-dom": "18.3.1",
"@lifi/widget": "3.25.0",
"@lifi/wallet-management": "3.6.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+8
View File
@@ -8,6 +8,8 @@ import * as serverTiming from 'nextjs/utils/serverTiming';
import config from 'configs/app';
import * as svgSprite from 'ui/shared/IconSvg';
const marketplaceFeature = config.features.marketplace;
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const originalRenderPage = ctx.renderPage;
@@ -50,6 +52,12 @@ class MyDocument extends Document {
<script src="/assets/multichain/config.js"/>
</>
) }
{ marketplaceFeature.isEnabled && marketplaceFeature.essentialDapps && (
<>
{ /* eslint-disable-next-line @next/next/no-sync-scripts */ }
<script src="/assets/essential-dapps/chains.js"/>
</>
) }
{ /* FAVICON */ }
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png"/>
+18
View File
@@ -0,0 +1,18 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps/handlers';
import PageNextJs from 'nextjs/PageNextJs';
const MarketplaceEssentialDapp = dynamic(() => import('ui/pages/MarketplaceEssentialDapp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => (
<PageNextJs pathname="/essential-dapps/[id]" query={ props.query }>
<MarketplaceEssentialDapp/>
</PageNextJs>
);
export default Page;
export { marketplaceEssentialDapp as getServerSideProps } from 'nextjs/getServerSideProps/main';
@@ -0,0 +1,19 @@
import type { TestFixture, Page } from '@playwright/test';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
export type MockEssentialDappsChainsConfigFixture = () => Promise<void>;
const fixture: TestFixture<MockEssentialDappsChainsConfigFixture, { page: Page }> = async({ page }, use) => {
await use(async() => {
await page.evaluate(([ opSuperchainMock ]) => {
window.__essentialDappsChains = {
chains: [
opSuperchainMock.chainDataA,
],
};
}, [ opSuperchainMock ]);
});
};
export default fixture;
+3
View File
@@ -8,6 +8,7 @@ import * as mockAssetResponse from './fixtures/mockAssetResponse';
import * as mockConfigResponse from './fixtures/mockConfigResponse';
import * as mockContractReadResponse from './fixtures/mockContractReadResponse';
import * as mockEnvs from './fixtures/mockEnvs';
import * as mockEssentialDappsChainsConfig from './fixtures/mockEssentialDappsChainsConfig';
import * as mockFeatures from './fixtures/mockFeatures';
import * as mockMultichainConfig from './fixtures/mockMultichainConfig';
import * as mockRpcResponse from './fixtures/mockRpcResponse';
@@ -24,6 +25,7 @@ export interface Fixtures {
mockEnvs: mockEnvs.MockEnvsFixture;
mockFeatures: mockFeatures.MockFeaturesFixture;
mockMultichainConfig: mockMultichainConfig.MockMultichainConfigFixture;
mockEssentialDappsChainsConfig: mockEssentialDappsChainsConfig.MockEssentialDappsChainsConfigFixture;
mockRpcResponse: mockRpcResponse.MockRpcResponseFixture;
createSocket: socketServer.CreateSocketFixture;
injectMetaMaskProvider: injectMetaMaskProvider.InjectMetaMaskProvider;
@@ -41,6 +43,7 @@ const test = base.extend<Fixtures>({
mockEnvs: mockEnvs.default,
mockFeatures: mockFeatures.default,
mockMultichainConfig: mockMultichainConfig.default,
mockEssentialDappsChainsConfig: mockEssentialDappsChainsConfig.default,
mockRpcResponse: mockRpcResponse.default,
// FIXME: for some reason Playwright does not intercept requests to text ad provider when running multiple tests in parallel
// even if we have a global request interceptor (maybe it is related to service worker issue, maybe not)
+1
View File
@@ -141,6 +141,7 @@
| "refresh"
| "repeat"
| "restAPI"
| "return"
| "rocket_xl"
| "rocket"
| "RPC"
+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10" fill="#627EEA"/>
<path d="M10.018 8.102V2.725c-.008 0-.013.004-.02.009-.008.006-.018.014-.043.014-.166.331-.343.65-.523.974-.07.129-.142.258-.214.388-.337.6-.674 1.223-1.01 1.846-.181.338-.367.675-.553 1.013-.182.332-.365.663-.542.995a81.08 81.08 0 0 1-.516.934 79.7 79.7 0 0 0-.516.935c-.031.057-.063.108-.094.16a3.988 3.988 0 0 0-.096.163h.021c.013.014.018.01.025.006a7.611 7.611 0 0 0 .249-.122c.079-.04.158-.08.232-.115.2-.11.382-.198.562-.287l.027-.013.695-.346.569-.277.695-.346c.147-.09.307-.166.46-.238a15.98 15.98 0 0 0 .346-.169c.066-.033.133-.067.204-.1.021 0 .042-.024.042-.047ZM10 17.332l.018.001V13.71c0-.023 0-.046-.02-.046a3.323 3.323 0 0 0-.17-.104c-.057-.035-.115-.07-.168-.104-.323-.217-.652-.428-.98-.64-.335-.216-.67-.431-.999-.652-.284-.185-.574-.37-.863-.554-.29-.185-.58-.37-.864-.554-.01 0-.015-.005-.02-.011-.006-.006-.011-.012-.022-.012 0 .012.006.018.01.023.006.006.011.012.011.023l.316.485c.255.411.518.813.779 1.212.149.227.297.454.443.68l1.263 1.939c.204.312.402.62.601.927l.641.988c.012.014.018.02.024.022Z" fill="#fff"/>
<path d="M10.015 2.667v7.538h4.123l-4.123-7.538Zm-4.124 7.45L10.015 8.1v4.733L5.89 10.117Zm4.124 3.593v3.594l4.043-6.135-4.043 2.541Z" fill="#C0CBF6"/>
<path d="M10.015 12.834V8.101l4.123 2.103-4.123 2.63Z" fill="#8197EE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+4
View File
@@ -1,6 +1,10 @@
import config from 'configs/app';
import * as essentialDappsChainsConfig from 'configs/essential-dapps-chains/config.nodejs';
import * as multichainConfig from 'configs/multichain/config.nodejs';
const marketplaceFeature = config.features.marketplace;
(async() => {
config.features.opSuperchain.isEnabled && await multichainConfig.load();
marketplaceFeature.isEnabled && marketplaceFeature.essentialDapps && await essentialDappsChainsConfig.load();
})();
+19
View File
@@ -0,0 +1,19 @@
import type { AllowanceType } from 'types/client/revoke';
import { ZERO_ADDRESS } from 'toolkit/utils/consts';
export const ALLOWANCES: Array<AllowanceType> = Array.from({ length: 3 }, () => ({
type: 'ERC-20',
address: ZERO_ADDRESS,
transactionId: null,
allowance: 'Unlimited',
balance: '100000',
decimals: 18,
spender: ZERO_ADDRESS,
symbol: 'ETH',
name: 'Ethereum',
totalSupply: BigInt(0),
timestamp: 1718352000,
valueAtRiskUsd: 100000,
tokenReputation: 'ok',
}));
+7 -1
View File
@@ -17,6 +17,7 @@ fi
# remove previous assets
rm -rf ./public/assets/configs
rm -rf ./public/assets/multichain
rm -rf ./public/assets/essential-dapps
rm -rf ./public/assets/envs.js
# download assets for the running instance
@@ -31,6 +32,11 @@ if [[ "$preset_name" == "optimism_superchain" ]]; then
-- bash -c 'cd deploy/tools/multichain-config-generator && yarn install --silent && yarn build && yarn generate'
fi
# generate essential dapps chains config if marketplace essential dapps enabled
dotenv \
-e $config_file \
-- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && yarn install --silent && yarn build && yarn generate'
source ./deploy/scripts/build_sprite.sh
echo ""
@@ -42,4 +48,4 @@ dotenv \
-e $config_file \
-e $secrets_file \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' |
pino-pretty
pino-pretty
+1 -1
View File
@@ -18,5 +18,5 @@
"types": ["node", "jest"],
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", "**/*.pw.tsx", "decs.d.ts", "global.d.ts"],
"exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./deploy/tools/multichain-config-generator", "./toolkit/package", "./deploy/tools/llms-txt-generator"],
"exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./deploy/tools/multichain-config-generator", "./deploy/tools/essential-dapps-chains-config-generator", "./toolkit/package", "./deploy/tools/llms-txt-generator"],
}
+1
View File
@@ -67,6 +67,7 @@ export interface AddressTokenBalance {
value: string;
token_instance: TokenInstance | null;
}
export type AddressTokenBalancesResponse = Array<AddressTokenBalance>;
export type AddressNFT = TokenInstance & {
token: TokenInfo;
+16
View File
@@ -33,3 +33,19 @@ export enum MarketplaceCategory {
ALL = 'All',
FAVORITES = 'Favorites',
}
export type EssentialDappsConfig = {
swap?: {
chains: Array<string>;
fee: string;
integrator: string;
};
revoke?: {
chains: Array<string>;
};
multisend?: {
chains: Array<string>;
posthogKey?: string;
posthogHost?: string;
};
};
+29
View File
@@ -0,0 +1,29 @@
import type { TokenReputation } from 'types/api/token';
export interface ContractAllowanceType {
transactionId: `0x${ string }` | null;
spender: `0x${ string }`;
allowance?: bigint;
blockNumber: bigint;
tokenId?: string;
}
export interface AllowanceType {
type: 'ERC-20' | 'ERC-721' | 'ERC-1155';
address: `0x${ string }`;
transactionId: `0x${ string }` | null;
tokenId?: string;
tokenIcon?: string;
tokenReputation: TokenReputation | null;
allowance?: string;
price?: string;
balance?: string;
valueAtRiskUsd?: number;
decimals?: number;
spender: `0x${ string }`;
spenderName?: string;
symbol?: string;
name?: string;
totalSupply?: bigint;
timestamp: number;
}
+6
View File
@@ -1,8 +1,14 @@
import type { Chain } from 'viem';
import type config from 'configs/app';
export interface ChainConfig {
// TODO @tom2drum make optional
slug: string;
// TODO @tom2drum make partial
// TODO @tom2drum make chain id primary key
config: typeof config;
contracts?: Chain['contracts'];
}
export interface MultichainConfig {
+10 -21
View File
@@ -71,10 +71,10 @@ const MarketplaceAppCard = ({
_focusWithin={{
boxShadow: isLoading ? 'none' : 'md',
}}
borderRadius="md"
padding={{ base: 3, md: '20px' }}
borderRadius="base"
padding={ 3 }
borderWidth="1px"
borderColor={{ _light: 'gray.200', _dark: 'gray.600' }}
borderColor={{ _light: 'blackAlpha.300', _dark: 'whiteAlpha.300' }}
>
<Flex
flexDirection="column"
@@ -82,18 +82,15 @@ const MarketplaceAppCard = ({
alignContent="start"
gap={ 2 }
>
<Flex
display={{ base: 'flex', md: 'contents' }}
gap={ 4 }
>
<Flex gap={ 4 }>
<Skeleton
loading={ isLoading }
w={{ base: '64px', md: '96px' }}
h={{ base: '64px', md: '96px' }}
w="64px"
h="64px"
display="flex"
alignItems="center"
justifyContent="center"
mb={{ base: 0, md: 2 }}
flexShrink={ 0 }
>
<Image
src={ isLoading ? undefined : logoUrl }
@@ -102,15 +99,9 @@ const MarketplaceAppCard = ({
/>
</Skeleton>
<Flex
display={{ base: 'flex', md: 'contents' }}
flexDirection="column"
gap={ 2 }
pt={ 1 }
>
<Flex flexDirection="column" gap={ 2 } pt={ 1 }>
<Skeleton
loading={ isLoading }
paddingRight={{ base: '40px', md: 0 }}
display="inline-flex"
alignItems="center"
>
@@ -120,10 +111,8 @@ const MarketplaceAppCard = ({
external={ external }
title={ title }
onClick={ onAppClick }
textStyle="sm"
fontWeight="semibold"
fontFamily="heading"
fontSize={{ base: 'sm', md: 'lg' }}
lineHeight={{ base: '20px', md: '28px' }}
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
<MarketplaceAppGraphLinks
@@ -148,7 +137,7 @@ const MarketplaceAppCard = ({
loading={ isLoading }
asChild
>
<Text lineClamp={{ base: 2, md: 3 }} textStyle="sm">
<Text lineClamp={ 2 } textStyle="sm">
{ shortDescription }
</Text>
</Skeleton>
@@ -0,0 +1,55 @@
import { Text } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import { Button } from 'toolkit/chakra/button';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import { Image } from 'toolkit/chakra/image';
import { LinkOverlay, LinkBox } from 'toolkit/chakra/link';
type Props = {
id: string;
title: string;
description: string;
buttonText: string;
imageUrl: string;
darkImageUrl: string;
};
const EssentialDappCard = ({ id, title, description, buttonText, imageUrl, darkImageUrl }: Props) => {
const imageSrc = useColorModeValue(imageUrl, darkImageUrl);
return (
<LinkBox
display="flex"
flexDirection="column"
alignItems="flex-start"
flex={{ base: 1, md: 'unset' }}
minW="130px"
maxW={{ base: '160px', md: '200px' }}
w={{ base: 'auto', md: '200px' }}
p={{ base: 3, md: 5 }}
border="1px solid"
borderColor={{ _light: 'blackAlpha.300', _dark: 'whiteAlpha.300' }}
borderRadius="base"
_hover={{ boxShadow: 'md' }}
_focusWithin={{ boxShadow: 'md' }}
className="group"
>
<Image src={ imageSrc } alt={ title } h={{ base: '37px', md: '50px' }} mb={ 6 }/>
<Text textStyle={{ base: 'sm', md: 'xl' }} fontWeight="600" mb={ 2 }>{ title }</Text>
<Text textStyle={{ base: 'xs', md: 'sm' }} mb={ 3 }>{ description }</Text>
<LinkOverlay
href={ route({ pathname: '/essential-dapps/[id]', query: { id } }) }
w={{ base: 'full', md: 'auto' }}
mt="auto"
>
<Button size="sm" variant="outline" w="full" _groupHover={{ borderColor: 'hover', color: 'hover' }}>
{ buttonText }
</Button>
</LinkOverlay>
</LinkBox>
);
};
export default EssentialDappCard;
@@ -0,0 +1,72 @@
import { Flex } from '@chakra-ui/react';
import type { EssentialDappsConfig } from 'types/client/marketplace';
import config from 'configs/app';
import EssentialDappCard from './EssentialDappCard';
const feature = config.features.marketplace;
const essentialDappsConfig = feature.isEnabled ? feature.essentialDapps : undefined;
const essentialDapps = [
{
id: 'swap',
title: 'Swap',
description: 'Swap, trade and bridge tokens between chains',
buttonText: 'Swap tokens',
imageUrl: '/static/marketplace/swap.png',
darkImageUrl: '/static/marketplace/swap-dark.png',
},
{
id: 'revoke',
title: 'Revoke',
description: 'View and remove token approvals',
buttonText: 'Get started',
imageUrl: '/static/marketplace/revoke.png',
darkImageUrl: '/static/marketplace/revoke-dark.png',
},
{
id: 'multisend',
title: 'Multisend',
description: 'Send tokens to multiple addresses at once',
buttonText: 'Send tokens',
imageUrl: '/static/marketplace/multisend.png',
darkImageUrl: '/static/marketplace/multisend-dark.png',
},
].filter((dapp) =>
feature.isEnabled && Boolean(essentialDappsConfig?.[dapp.id as keyof EssentialDappsConfig]),
);
const EssentialDappsList = () => {
return (
<Flex
gap={{ base: 2, md: 3 }}
mb={ 8 }
w="full"
overflowX={{ base: 'auto', md: 'initial' }}
css={{
// hide scrollbar
'&::-webkit-scrollbar': { /* Chromiums */
display: 'none',
},
'-ms-overflow-style': 'none', /* IE and Edge */
scrollbarWidth: 'none', /* Firefox */
}}
>
{ essentialDapps.map((dapp) => (
<EssentialDappCard
key={ dapp.id }
id={ dapp.id }
title={ dapp.title }
description={ dapp.description }
buttonText={ dapp.buttonText }
imageUrl={ dapp.imageUrl }
darkImageUrl={ dapp.darkImageUrl }
/>
)) }
</Flex>
);
};
export default EssentialDappsList;
@@ -0,0 +1,21 @@
import React from 'react';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect } from 'playwright/lib';
import Multisend from './Multisend';
const ESSENTIAL_DAPPS_CONFIG = JSON.stringify({
multisend: { chains: [ '1' ] },
});
test('base view +@dark-mode +@mobile', async({ render, mockEnvs }: TestFnArgs) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG', ESSENTIAL_DAPPS_CONFIG ],
]);
const component = await render(<Multisend/>);
await expect(component).toHaveScreenshot();
});
@@ -0,0 +1,523 @@
import { Box } from '@chakra-ui/react';
import { MultisenderWidget } from '@multisender.app/multisender-react-widget';
import React from 'react';
import config from 'configs/app';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
const feature = config.features.marketplace;
const dappConfig = feature.isEnabled ? feature.essentialDapps?.multisend : undefined;
const Container = ({ children }: { children: React.ReactNode }) => (
<Box
maxW="670px"
mx="auto"
css={{
'& > .multisenderTheme': {
'--mw-color-brand': { _light: 'colors.blue.600', _dark: 'colors.blue.500' },
'--mw-color-brand-stroke': { _light: 'colors.gray.200', _dark: 'colors.whiteAlpha.100' },
'--mw-color-brand-text-secondary': 'colors.text.secondary',
'--mw-color-brand-fill': 'colors.dialog.bg', // modal background
fontFamily: 'body',
'& [class*="_stepBody_"] > span': {
fontSize: 'xs',
fontWeight: 'normal',
},
'& [class*="_step_"]:not([data-progress="true"]), & [class*="_step_"][data-completed="true"]': {
'& [class*="_stepBody_"] > span': {
color: 'text.secondary',
},
'& [class*="_stepIcon_"]': {
backgroundColor: { _light: 'gray.100', _dark: 'whiteAlpha.100' },
},
'& [class*="_stepCompletedIcon_"]': {
color: 'text.secondary',
},
},
'& [class*="_stepIcon_"]': {
fontSize: 'xs',
width: '24px',
height: '24px',
minWidth: '24px',
minHeight: '24px',
},
'& [class*="_stepSeparator_"]': {
height: '1px',
},
'& [class*="_stepperRoot_"] > :nth-child(1)': {
padding: '0 12px',
},
'& [class*="_fieldsetRoot_"]:not([class*="_itemRoot_"])': {
borderRadius: 'md',
padding: '24px',
'& button': {
fontSize: 'xs',
'& label': {
fontWeight: 'medium !important',
},
},
'& > *:first-child': {
gap: '12px',
'& > :nth-child(4) > button': {
marginBottom: '12px',
border: '2px solid',
borderColor: 'button.outline.fg',
borderRadius: 'base',
color: 'button.outline.fg',
height: '32px',
padding: '0 12px',
fontSize: 'sm',
transitionProperty: 'all',
transitionDuration: 'moderate',
'&:hover': {
background: 'none',
color: 'hover',
borderColor: 'hover',
},
'&:active': {
transform: 'none',
},
},
},
'& > *:last-child': {
'& p[data-size="sm"], & a[data-size="sm"]': {
fontSize: 'xs',
},
'& a[data-size="sm"]': {
color: 'link.primary',
fontWeight: 'medium !important',
'&:hover': {
color: 'link.primary.hover',
},
},
},
'& p[data-size="xl"], & h5': {
fontFamily: 'heading',
fontSize: '18px',
fontWeight: 'medium !important',
},
'& [class*="_tableTr_"]': {
fontWeight: 'medium',
'& td': {
padding: '12px 0',
},
'& a': {
color: 'link.primary',
fontSize: 'sm',
'&:hover': {
color: 'link.primary.hover',
textDecoration: 'none',
},
},
},
'& [class*="SummaryList-module__root__"]': {
'& + div': {
gap: '4px',
'& > div:last-child': {
'& button': {
background: 'none',
border: 'none',
color: 'button.outline.fg',
transitionProperty: 'all',
transitionDuration: 'moderate',
'&:hover': {
color: 'hover',
},
'&:active': {
transform: 'none',
},
},
'& p': {
fontSize: 'sm',
color: 'text.secondary',
},
},
},
'& + button': {
marginTop: '12px',
},
},
},
'& [class*="_itemRoot_"]': {
border: 'none',
borderRadius: 'base',
backgroundColor: { _light: 'gray.50', _dark: 'whiteAlpha.100' },
'& > div': {
gap: '8px',
},
},
'& [class*="_inputWrapper_"]': {
'& [class*="_input_"]': {
borderRadius: 'base',
border: '2px solid',
borderColor: 'input.border.filled',
height: '32px',
minHeight: '32px',
fontSize: 'sm',
fontWeight: 'medium',
color: 'input.fg',
'&::placeholder': {
color: 'input.placeholder',
},
},
'&[data-size="xl"]': {
'& [class*="_input_"]': {
height: '60px',
minHeight: '60px',
fontSize: 'md',
},
'& button': {
height: '40px',
fontSize: 'md',
backgroundColor: 'button.solid.bg',
borderRadius: 'base',
transitionProperty: 'all',
transitionDuration: 'moderate',
color: 'white',
'&:hover': {
backgroundColor: 'hover',
},
'&:active': {
transform: 'none',
},
'&:disabled': {
opacity: '0.2',
},
},
},
},
'& [class*="RecipientsEditor-module__head_"]': {
fontSize: 'xs',
marginTop: '12px',
'& p': {
fontWeight: 'medium',
},
'& button': {
background: 'none',
color: 'link.primary',
fontWeight: 'medium',
padding: 0,
height: '18px',
transitionProperty: 'all',
transitionDuration: 'moderate',
borderRadius: 0,
'&:hover': {
color: 'link.primary.hover',
},
'&:active': {
transform: 'none',
},
},
},
'& [class*="CodeMirror"]': {
fontSize: '15px',
'& .cm-scroller': {
borderRadius: 'base',
borderWidth: '2px',
borderColor: 'input.border.filled',
},
'& .cm-gutters': {
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
borderRightWidth: '2px',
borderColor: 'input.border.filled',
backgroundColor: { _light: 'gray.50', _dark: 'whiteAlpha.50' },
backdropFilter: 'none',
'&:before': {
background: 'none',
},
},
'& .cm-placeholder': {
color: 'input.placeholder',
},
},
'& [class*="_modalRoot_"]': {
'& [class*="_modalOverlay_"]': {
backgroundColor: 'blackAlpha.800',
},
'& [class*="_modalContent_"]': {
borderRadius: 'xl',
},
'& [class*="_modalHeader_"]': {
minHeight: '64px',
height: '64px !important',
padding: '24px !important',
paddingBottom: '0 !important',
marginBottom: '8px',
'& [class*="_modalTitle_"]': {
fontFamily: 'heading',
fontWeight: 'medium !important',
fontSize: '24px !important',
},
},
'& [class*="_modalHeader_"] + div': {
padding: '24px !important',
paddingTop: '0 !important',
'& > div': {
padding: '0 !important',
},
'& h6, & p': {
fontSize: 'md',
},
},
'& [class*="_closeButton_"]': {
background: 'none',
color: 'close-button-fg',
'&:hover': {
color: 'hover',
},
},
},
'& div[role="alert"]': {
borderRadius: 'base',
padding: '8px 12px',
margin: '0 !important',
'& p, & div': {
fontSize: 'md',
color: 'text.primary',
},
'& [class*="_alertIcon_"]': {
marginTop: '2px',
marginRight: '8px',
'& svg': {
fill: 'text.primary',
width: '20px',
height: '20px',
},
},
'&:not([data-variant="filled"]) [class*="_alertIcon_"]': {
display: 'none',
},
},
'& div[role="tooltip"]': {
backgroundColor: 'tooltip.bg',
color: 'tooltip.fg',
borderRadius: 'sm',
padding: '4px 8px !important',
'& p': {
fontSize: 'sm',
fontWeight: 'medium',
},
},
'& [class*="_button_"][data-size="lg"], [class*="_button_"][data-size="md"]': {
borderRadius: 'base',
color: 'white',
height: '40px',
backgroundColor: 'button.solid.bg',
transitionProperty: 'background-color',
transitionDuration: 'moderate',
'&:disabled': {
opacity: '0.2',
},
'&:hover': {
backgroundColor: 'hover',
},
'&:active': {
transform: 'none',
},
'& > span[aria-hidden="true"]': {
width: '20px',
height: '20px',
'& > span::after': {
width: '20px',
height: '20px',
},
},
},
'& [class*="Summary-module__upgradeBtn__"]': {
borderRadius: 'base',
border: 'none',
height: '32px',
padding: '0 12px',
fontSize: 'sm !important',
fontWeight: 'semibold',
transitionProperty: 'all',
transitionDuration: 'moderate',
'&[class*="_crown_"]': {
color: 'white',
backgroundColor: 'button.solid.bg',
'&:hover': {
backgroundColor: 'hover',
},
},
'&[class*="_gift_"]': {
color: 'button.outline.fg',
border: '2px solid',
borderColor: 'button.outline.fg',
marginRight: '-4px',
background: 'none',
'&:hover': {
color: 'hover',
borderColor: 'hover',
},
},
'& span[data-position="left"]': {
display: 'none',
},
},
'& [class*="Summary-module__editGasCost__"]': {
border: 'none',
background: 'none',
'& svg': {
width: '16px',
height: '16px',
color: 'link.primary',
transitionProperty: 'all',
transitionDuration: 'moderate',
},
'&:hover svg': {
color: 'link.primary.hover',
},
'&:active': {
transform: 'none',
},
},
'& div[role="radiogroup"]': {
'& > div': {
gap: '8px',
},
'& [class*="LargeRadio-module__root_"]': {
'& svg': {
transform: 'none',
},
'& label': {
'& p:not([data-size="sm"])': {
fontWeight: 'semibold !important',
},
'& p[data-size="sm"]': {
color: 'text.secondary',
},
},
'& > div[class*="LargeRadio-module__body_"]': {
border: 'none',
borderRadius: 'base',
backgroundColor: { _light: 'gray.50', _dark: 'whiteAlpha.100' },
},
'&:not([data-checked="true"]) input': {
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'input.border.filled',
},
'& > div > div:not([class*="_labelWrapper_"])': {
marginTop: '2px',
},
'& .tabler-icon-info-circle': {
stroke: 'text.primary',
width: '20px',
height: '20px',
},
},
},
'& [class*="TokenSelect-module__grid_"]': {
display: 'flex',
justifyContent: 'flex-start',
gap: '12px',
flexWrap: 'wrap',
},
'& [class*="TokenSelect-module__chainsList_"]': {
gap: 0,
marginTop: '24px',
'& > div': {
display: 'flex',
padding: '8px',
margin: '0 -8px',
borderRadius: 'base',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'selected.control.bg',
},
'& > [class*="_chainRow_"]': {
flex: 1,
'&:hover': {
color: 'inherit',
},
},
'& > svg': {
width: '18px',
},
},
},
'& [class*="TokenSelect-module__stickySection_"]': {
top: '16px',
'& > div:last-child': {
gap: '8px !important',
},
},
'& [class*="TokenSelect-module__searchInput_"]': {
marginTop: '8px',
},
'& [class*="TokenSelect-module__tokensList_"]': {
gap: 0,
'& svg > path:first-child': {
stroke: 'text.primary',
},
},
'& [class*="TokenSelect-module__tokenRow_"]': {
padding: '8px',
margin: '0 -8px',
borderRadius: 'base',
'&:hover': {
backgroundColor: 'selected.control.bg',
},
'& [class*="_tokenIcon_"]': {
width: '40px',
height: '40px',
},
},
'& > .multisenderMantineProvider': {
'--mantine-color-text': 'text.primary',
'--mantine-color-red-light': 'colors.alert.bg.error',
'--mantine-color-blue-light': 'colors.alert.bg.info',
'--mantine-color-blue-light-color': 'colors.text.secondary',
'--mantine-color-blue-text': 'colors.hover',
'--mantine-color-blue-outline': 'colors.hover',
'--mantine-color-green-text': 'colors.green.500',
'& > div:nth-child(1)': {
padding: '0 !important',
gap: 0,
'& > :nth-child(1)': {
margin: '0 !important',
},
},
},
},
}}
>{ children }</Box>
);
const widgetConfig = Object.fromEntries(dappConfig?.chains.map((chainId) => {
const chainConfig = essentialDappsChainsConfig()?.chains.find((chain) => chain.config.chain.id === chainId);
const explorerUrl = chainConfig?.config.app.baseUrl;
const apiUrl = chainConfig?.config.apis.general?.endpoint;
return [
chainId,
{
id: Number(chainId),
blockExplorerUrl: {
tx: `${ explorerUrl }/tx/`,
address: `${ explorerUrl }/address/`,
},
rpcUrls: [ `${ apiUrl }/api/eth-rpc` ],
blockScoutApiUrl: apiUrl,
},
];
}) || []);
const Multisend = () => {
return (
<Container>
<MultisenderWidget
config={ widgetConfig }
logoType="minified"
posthogKey={ dappConfig?.posthogKey }
posthogHost={ dappConfig?.posthogHost }
classNames={{
theme: 'multisenderTheme',
mantineProvider: 'multisenderMantineProvider',
}}
/>
</Container>
);
};
export default Multisend;
@@ -0,0 +1,23 @@
import React from 'react';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect } from 'playwright/lib';
import Revoke from './Revoke';
const ESSENTIAL_DAPPS_CONFIG = JSON.stringify({
revoke: { chains: [ opSuperchainMock.chainDataA.config.chain.id ] },
});
test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDappsChainsConfig }: TestFnArgs) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG', ESSENTIAL_DAPPS_CONFIG ],
]);
await mockEssentialDappsChainsConfig();
const component = await render(<Revoke/>);
await expect(component).toHaveScreenshot();
});
@@ -0,0 +1,221 @@
import { Flex, Text } from '@chakra-ui/react';
import { getEnsAddress } from '@wagmi/core';
import { useRouter } from 'next/router';
import React, { useMemo, useCallback, useEffect, useState } from 'react';
import { isAddress } from 'viem';
import { mainnet } from 'viem/chains';
import { normalize } from 'viem/ens';
import { useAccount } from 'wagmi';
import config from 'configs/app';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel';
import getQueryParamString from 'lib/router/getQueryParamString';
import { useQueryParams } from 'lib/router/useQueryParams';
import useWeb3Wallet from 'lib/web3/useWallet';
import wagmiConfig from 'lib/web3/wagmiConfig';
import { Button } from 'toolkit/chakra/button';
import { Tooltip } from 'toolkit/chakra/tooltip';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import AddressEntity from './components/AddressEntity';
import ChainSelect from './components/ChainSelect';
import Content from './components/Content';
import SearchInput from './components/SearchInput';
import StartScreen from './components/StartScreen';
import useApprovalsQuery from './hooks/useApprovalsQuery';
import useCoinBalanceQuery from './hooks/useCoinBalanceQuery';
const feature = config.features.marketplace;
const dappConfig = feature.isEnabled ? feature.essentialDapps?.revoke : undefined;
const defaultChainId = (
dappConfig?.chains.includes(config.chain.id as string) ?
config.chain.id :
dappConfig?.chains[0]
) as string;
const Revoke = () => {
const router = useRouter();
const { updateQuery } = useQueryParams();
const chainIdFromQuery: string | undefined = getQueryParamString(router.query.chainId);
const addressFromQuery = getQueryParamString(router.query.address);
const [ selectedChainId, setSelectedChainId ] = useState<Array<string>>([ chainIdFromQuery || defaultChainId ]);
const { address: connectedAddress } = useAccount();
const [ searchAddress, setSearchAddress ] = useState(addressFromQuery || '');
const [ searchInputValue, setSearchInputValue ] = useState('');
const selectedChain = essentialDappsChainsConfig()?.chains.find((chain) => chain.config.chain.id === selectedChainId[0]);
const approvalsQuery = useApprovalsQuery(selectedChain, searchAddress);
const coinBalanceQuery = useCoinBalanceQuery(selectedChain, searchAddress);
const web3Wallet = useWeb3Wallet({ source: 'Essential dapps' });
const isMobile = useIsMobile();
const isValidAddress = useMemo(
() => isAddress(searchAddress.toLowerCase()),
[ searchAddress ],
);
const isAddressMatch = useMemo(
() => searchAddress.toLowerCase() === connectedAddress?.toLowerCase(),
[ searchAddress, connectedAddress ],
);
const handleChainValueChange = useCallback(({ value }: { value: Array<string> }) => {
setSelectedChainId(value);
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Chain switch',
Info: value[0],
Source: 'Revoke essential dapp',
});
updateQuery({ chainId: value[0] }, true);
}, [ updateQuery ]);
const handleSearch = useCallback(async(address: string) => {
if (address.endsWith('.eth')) {
try {
const ensAddress = await getEnsAddress(wagmiConfig.config, {
chainId: mainnet.id,
name: normalize(address),
});
if (ensAddress) {
address = ensAddress.toLowerCase();
}
} catch {}
}
setSearchAddress(address);
setSearchInputValue('');
if (isAddress(address.toLowerCase())) {
updateQuery({ address }, true);
}
}, [ updateQuery ]);
const handleAddressClick = useCallback(
(address: string) => () => {
handleSearch(address);
},
[ handleSearch ],
);
useEffect(() => {
if (connectedAddress && !searchAddress) {
handleSearch(connectedAddress);
}
}, [ connectedAddress, handleSearch, searchAddress ]);
let content = <StartScreen/>;
if (searchAddress && selectedChainId) {
content = isValidAddress ? (
<Content
searchAddress={ searchAddress }
selectedChain={ selectedChain }
isAddressMatch={ isAddressMatch }
coinBalanceQuery={ coinBalanceQuery }
approvalsQuery={ approvalsQuery }
/>
) : (
<EmptySearchResult
text={ `The input "${ searchAddress }" is not correct. Enter a correct 0x address to search` }
/>
);
}
return (
<Flex flexDir="column" w="full" gap={{ base: 6, lg: 12 }}>
<Flex flexDir="column" w="full" gap={ 3 }>
<SearchInput
value={ searchInputValue }
onChange={ setSearchInputValue }
onSubmit={ handleSearch }
/>
<Flex flexDir={{ base: 'column', md: 'row' }} gap={ 3 } justifyContent="space-between">
<Flex
gap={ 3 }
alignItems="center"
h="32px"
overflowX="auto"
css={{
'-ms-overflow-style': 'none',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
display: 'none',
},
}}
>
<Text textStyle="sm" fontWeight="500" color="text.secondary">
Examples
</Text>
{ [
'0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
'0xFe89cc7aBB2C4183683ab71653C4cdc9B02D44b7',
'0xf6B6F07862A02C85628B3A9688beae07fEA9C863',
].slice(0, isMobile ? 2 : undefined).map((address) => (
<Button
key={ address }
variant="plain"
size="sm"
p={ 0 }
onClick={ handleAddressClick(address) }
>
<AddressEntity
address={{ hash: address }}
truncation="constant"
noLink
noCopy
textStyle="sm"
fontWeight="600"
/>
</Button>
)) }
</Flex>
<Flex gap={ 3 } w={{ base: 'full', md: 'auto' }}>
{ connectedAddress ? (
<Flex gap={ 2 } alignItems="center" flexShrink={ 0 }>
<Text textStyle="sm" fontWeight="500" color="text.secondary">My wallet</Text>
<Tooltip content="Click to see your approvals" disableOnMobile>
<Button
variant="plain"
size="sm"
p={ 0 }
onClick={ handleAddressClick(connectedAddress) }
>
<AddressEntity
address={{ hash: connectedAddress }}
truncation="constant"
noTooltip
noLink
noCopy
textStyle="sm"
fontWeight="600"
/>
</Button>
</Tooltip>
</Flex>
) : (
<Button
size="sm"
variant="outline"
onClick={ web3Wallet.connect }
loading={ web3Wallet.isOpen }
loadingText="Connect wallet"
flexShrink={ 0 }
>
Connect wallet
</Button>
) }
<ChainSelect
value={ selectedChainId }
onValueChange={ handleChainValueChange }
/>
</Flex>
</Flex>
</Flex>
{ content }
</Flex>
);
};
export default Revoke;
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,17 @@
import { chakra } from '@chakra-ui/react';
import { mainnet } from 'viem/chains';
import { useEnsName } from 'wagmi';
import type { EntityProps } from 'ui/shared/entities/address/AddressEntity';
import AddressEntityBase from 'ui/shared/entities/address/AddressEntity';
function AddressEntity({ address, ...props }: EntityProps) {
const ensQuery = useEnsName({
address: address.hash as `0x${ string }`,
chainId: mainnet.id,
});
return <AddressEntityBase address={{ ...address, name: ensQuery.data }} { ...props }/>;
}
export default chakra(AddressEntity);
@@ -0,0 +1,56 @@
import { Box, Flex } from '@chakra-ui/react';
import type { AllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import ApprovalsListItem from './ApprovalsListItem';
import ApprovalsTable from './ApprovalsTable';
type Props = {
selectedChain: ChainConfig | undefined;
approvals: Array<AllowanceType>;
isLoading?: boolean;
isAddressMatch?: boolean;
hideApproval: (approval: AllowanceType) => void;
};
export default function Approvals({
selectedChain,
approvals,
isLoading,
isAddressMatch,
hideApproval,
}: Props) {
return (
<>
<Box hideFrom="lg">
<Flex flexDirection="column">
{ approvals.map((approval, index) => (
<ApprovalsListItem
key={ index }
selectedChain={ selectedChain }
approval={ approval }
isLoading={ isLoading }
isAddressMatch={ isAddressMatch }
hideApproval={ hideApproval }
/>
)) }
{ !isLoading && !approvals.length && (
<EmptySearchResult text="No approvals found"/>
) }
</Flex>
</Box>
<Box hideBelow="lg">
<ApprovalsTable
selectedChain={ selectedChain }
approvals={ approvals }
isLoading={ isLoading }
isAddressMatch={ isAddressMatch }
hideApproval={ hideApproval }
/>
</Box>
</>
);
}
@@ -0,0 +1,139 @@
import { Text } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import type { AllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import { route } from 'nextjs/routes';
import dayjs from 'lib/date/dayjs';
import { Button } from 'toolkit/chakra/button';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import NumberEntity from 'ui/shared/NumberEntity';
import useRevoke from '../hooks/useRevoke';
import formatAllowance from '../lib/formatAllowance';
type Props = {
selectedChain: ChainConfig | undefined;
approval: AllowanceType;
isLoading?: boolean;
isAddressMatch?: boolean;
hideApproval: (approval: AllowanceType) => void;
};
export default function ApprovalsListItem({
selectedChain,
approval,
isLoading,
isAddressMatch,
hideApproval,
}: Props) {
const revoke = useRevoke();
const [ isPending, setIsPending ] = useState(false);
const allowance = formatAllowance(approval);
const handleRevoke = useCallback(async() => {
setIsPending(true);
const success = await revoke(approval, Number(selectedChain?.config.chain.id));
if (success) {
hideApproval(approval);
}
setIsPending(false);
}, [ revoke, hideApproval, approval, selectedChain?.config.chain.id ]);
return (
<ListItemMobileGrid.Container
gridTemplateColumns="minmax(0, 1fr) minmax(0, 1fr)"
fontWeight="500"
_first={{
borderTop: 0,
paddingTop: 0,
}}
_last={{
borderBottom: 0,
paddingBottom: 0,
}}
>
<ListItemMobileGrid.Label isLoading={ isLoading }>Token</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value display="flex" flexDir="column" gap={ 2 } color="inherit">
<TokenEntity
token={{
address_hash: approval.address,
type: approval.type,
symbol: approval.symbol || null,
name: approval.name || null,
icon_url: approval.tokenIcon || null,
reputation: approval.tokenReputation,
}}
isLoading={ isLoading }
noCopy
noLink
/>
<AddressEntity
address={{ hash: approval.address }}
truncation="constant"
noIcon
isLoading={ isLoading }
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/token/[hash]', query: { hash: approval.address } }) }
link={{ noIcon: true, external: true }}
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Approved spender</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity
address={{ hash: approval.spender }}
truncation="constant"
noIcon
isLoading={ isLoading }
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/address/[hash]', query: { hash: approval.spender } }) }
link={{ noIcon: true, external: true }}
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Approved amount</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value color="inherit">
<Skeleton loading={ isLoading }>
<NumberEntity
value={ allowance }
postfix={
[ 'Unlimited', 'N/A' ].includes(allowance) ? '' : approval.symbol
}
/>
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Value at risk</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value color="inherit">
<Skeleton loading={ isLoading }>
{ approval.valueAtRiskUsd ? (
<NumberEntity
value={ approval.valueAtRiskUsd.toString() }
suffix="$"
/>
) : '-' }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Last updated</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value color="inherit">
<Skeleton loading={ isLoading } display="flex" flexDir="column" rowGap={ 2 }>
<Text>{ dayjs(approval.timestamp).format('lll') }</Text>
<Text>{ dayjs(approval.timestamp).fromNow() }</Text>
</Skeleton>
</ListItemMobileGrid.Value>
{ isAddressMatch && (
<Button
size="sm"
variant="outline"
loading={ isLoading || isPending }
onClick={ handleRevoke }
gridColumn="span 2"
>
Revoke
</Button>
) }
</ListItemMobileGrid.Container>
);
}
@@ -0,0 +1,66 @@
import React from 'react';
import type { AllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import ApprovalsTableItem from './ApprovalsTableItem';
type Props = {
selectedChain: ChainConfig | undefined;
approvals: Array<AllowanceType>;
isLoading?: boolean;
isAddressMatch?: boolean;
hideApproval: (approval: AllowanceType) => void;
};
export default function ApprovalsTable({
selectedChain,
approvals,
isLoading,
isAddressMatch,
hideApproval,
}: Props) {
return (
<>
<TableRoot>
<TableHeaderSticky top={ 136 }>
<TableRow>
<TableColumnHeader w="30%">Token</TableColumnHeader>
<TableColumnHeader w="15%">Approved spender</TableColumnHeader>
<TableColumnHeader w="20%" isNumeric>
Approved amount
</TableColumnHeader>
<TableColumnHeader w="17%" isNumeric>
Value at risk
</TableColumnHeader>
<TableColumnHeader w={ isAddressMatch ? '30px' : '50px' }/>
<TableColumnHeader w="18%">
Last updated
<TimeFormatToggle/>
</TableColumnHeader>
{ isAddressMatch && <TableColumnHeader w="95px" isNumeric/> }
</TableRow>
</TableHeaderSticky>
<TableBody>
{ approvals.map((approval, index) => (
<ApprovalsTableItem
key={ index }
selectedChain={ selectedChain }
approval={ approval }
isLoading={ isLoading }
isAddressMatch={ isAddressMatch }
hideApproval={ hideApproval }
/>
)) }
</TableBody>
</TableRoot>
{ !isLoading && !approvals.length && (
<EmptySearchResult text="No approvals found"/>
) }
</>
);
}
@@ -0,0 +1,128 @@
import { Flex } from '@chakra-ui/react';
import { useCallback, useState } from 'react';
import type { AllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import { route } from 'nextjs/routes';
import { Button } from 'toolkit/chakra/button';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableRow, TableCell } from 'toolkit/chakra/table';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import NumberEntity from 'ui/shared/NumberEntity';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
import useRevoke from '../hooks/useRevoke';
import formatAllowance from '../lib/formatAllowance';
type Props = {
selectedChain: ChainConfig | undefined;
approval: AllowanceType;
isLoading?: boolean;
isAddressMatch?: boolean;
hideApproval: (approval: AllowanceType) => void;
};
export default function ApprovalsTableItem({
selectedChain,
approval,
isLoading,
isAddressMatch,
hideApproval,
}: Props) {
const revoke = useRevoke();
const [ isPending, setIsPending ] = useState(false);
const allowance = formatAllowance(approval);
const handleRevoke = useCallback(async() => {
setIsPending(true);
const success = await revoke(approval, Number(selectedChain?.config.chain.id));
if (success) {
hideApproval(approval);
}
setIsPending(false);
}, [ revoke, hideApproval, approval, selectedChain?.config.chain.id ]);
return (
<TableRow fontWeight="500">
<TableCell verticalAlign="middle">
<Flex flexDir="column" gap={ 2 } mr={ 2 }>
<TokenEntity
token={{
address_hash: approval.address,
type: approval.type,
symbol: approval.symbol || null,
name: approval.name || null,
icon_url: approval.tokenIcon || null,
reputation: approval.tokenReputation,
}}
isLoading={ isLoading }
noCopy
jointSymbol
textStyle="sm"
fontWeight="600"
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/token/[hash]', query: { hash: approval.address } }) }
link={{ noIcon: true, external: true }}
/>
<AddressEntity
address={{ hash: approval.address }}
truncation="constant"
noIcon
isLoading={ isLoading }
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/token/[hash]', query: { hash: approval.address } }) }
link={{ variant: 'secondary', noIcon: true, external: true }}
/>
</Flex>
</TableCell>
<TableCell verticalAlign="middle">
<AddressEntity
address={{ hash: approval.spender }}
truncation="constant"
noIcon
isLoading={ isLoading }
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/address/[hash]', query: { hash: approval.spender } }) }
link={{ noIcon: true, external: true }}
/>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline-block">
<NumberEntity
value={ allowance }
postfix={
[ 'Unlimited', 'N/A' ].includes(allowance) ? '' : approval.symbol
}
/>
</Skeleton>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline-block">
{ approval.valueAtRiskUsd && (
<NumberEntity
value={ approval.valueAtRiskUsd.toString() }
suffix="$"
/>
) }
</Skeleton>
</TableCell>
<TableCell></TableCell>
<TableCell verticalAlign="middle">
<TimeWithTooltip timestamp={ approval.timestamp } isLoading={ isLoading }/>
</TableCell>
{ isAddressMatch && (
<TableCell isNumeric verticalAlign="middle">
<Button
size="sm"
variant="outline"
loading={ isLoading || isPending }
onClick={ handleRevoke }
>
Revoke
</Button>
</TableCell>
) }
</TableRow>
);
}
@@ -0,0 +1,38 @@
import { createListCollection } from '@chakra-ui/react';
import React from 'react';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import { Select } from 'toolkit/chakra/select';
import type { SelectOption, SelectProps, ViewMode } from 'toolkit/chakra/select';
import ChainIcon from 'ui/optimismSuperchain/components/ChainIcon';
const collection = createListCollection<SelectOption>({
items: essentialDappsChainsConfig()?.chains.map((chain) => ({
value: chain.config.chain.id as string,
label: chain.config.chain.name || chain.slug,
icon: <ChainIcon data={ chain } borderRadius="none"/>,
})) || [],
});
interface Props extends Omit<SelectProps, 'collection' | 'placeholder'> {
loading?: boolean;
mode?: ViewMode;
}
const ChainSelect = ({ loading, mode, ...props }: Props) => {
const isInitialLoading = useIsInitialLoading(loading);
return (
<Select
collection={ collection }
defaultValue={ [ collection.items[0].value ] }
placeholder="Select chain"
loading={ isInitialLoading }
w="fit-content"
{ ...props }
/>
);
};
export default React.memo(ChainSelect);
@@ -0,0 +1,30 @@
import React from 'react';
import { hash as addressHash } from 'mocks/address/address';
import * as revokeMock from 'mocks/essentialDapps/revoke';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect } from 'playwright/lib';
import type useApprovalsQuery from '../hooks/useApprovalsQuery';
import Content from './Content';
test('base view +@dark-mode +@mobile', async({ render, mockAssetResponse }: TestFnArgs) => {
const props = {
selectedChain: opSuperchainMock.chainDataA,
searchAddress: addressHash,
isAddressMatch: false,
coinBalanceQuery: {
isPlaceholderData: false,
data: { balance: '12', balanceUsd: '47844', symbol: 'ETH', coinImage: revokeMock.allowances[0].tokenIcon },
},
approvalsQuery: { data: revokeMock.allowances, isPlaceholderData: false } as ReturnType<typeof useApprovalsQuery>,
hideApproval: () => {},
};
await mockAssetResponse(revokeMock.allowances[0].tokenIcon as string, './playwright/mocks/image_s.jpg');
const component = await render(<Content { ...props }/>);
await expect(component).toHaveScreenshot();
});
@@ -0,0 +1,220 @@
import { Flex, Text, Separator } from '@chakra-ui/react';
import React, { useState, useCallback, useMemo } from 'react';
import type { AllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import { route } from 'nextjs/routes';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Badge } from 'toolkit/chakra/badge';
import { Heading } from 'toolkit/chakra/heading';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
import type useApprovalsQuery from '../hooks/useApprovalsQuery';
import type useCoinBalanceQuery from '../hooks/useCoinBalanceQuery';
import AddressEntity from './AddressEntity';
import Approvals from './Approvals';
type Props = {
searchAddress: string;
selectedChain: ChainConfig | undefined;
isAddressMatch: boolean;
coinBalanceQuery: ReturnType<typeof useCoinBalanceQuery>;
approvalsQuery: ReturnType<typeof useApprovalsQuery>;
};
const Content = ({
searchAddress,
selectedChain,
isAddressMatch,
coinBalanceQuery,
approvalsQuery,
}: Props) => {
const isMobile = useIsMobile();
const [ hiddenApprovals, setHiddenApprovals ] = useState<Array<AllowanceType>>([]);
const approvals = useMemo(() => {
return approvalsQuery.data?.filter((approval) => !hiddenApprovals.includes(approval));
}, [ approvalsQuery.data, hiddenApprovals ]);
const totalValueAtRiskUsd = useMemo(() => {
if (approvalsQuery.isPlaceholderData || !approvals) return 0;
const maxValues: Record<`0x${ string }`, number> = {};
approvals.forEach((item) => {
const { address, valueAtRiskUsd } = item;
if (!valueAtRiskUsd) return;
if (
maxValues[address] === undefined ||
valueAtRiskUsd > maxValues[address]
) {
maxValues[address] = valueAtRiskUsd;
}
});
const sum = Object.values(maxValues).reduce((sum, val) => sum + val, 0);
return Number(sum.toFixed(2)).toLocaleString();
}, [ approvalsQuery.isPlaceholderData, approvals ]);
const hideApproval = useCallback((approval: AllowanceType) => {
setHiddenApprovals((prev) => [ ...prev, approval ]);
}, []);
return (
<Flex flexDir="column" w="full">
<Flex
flexDir={{ base: 'column', lg: 'row' }}
gap={ 2 }
mt={ -2 }
pt={ 2 }
pb={ 6 }
position={ !isMobile && approvals?.length ? 'sticky' : 'unset' }
top={ 0 }
zIndex="1"
bg={{ _light: 'white', _dark: 'black' }}
>
<Flex
flexDir="column"
alignItems="flex-start"
flex={ 1 }
bg={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
gap={ 3 }
p={ 6 }
borderRadius="base"
>
<Flex
flexDir={{ base: 'column', md: 'row' }}
gap={ 3 }
alignItems={{ base: 'flex-start', md: 'center' }}
justifyContent={{ base: 'flex-start', md: 'space-between' }}
w="full"
flexWrap="wrap"
>
<Flex gap={ 2 } alignItems="center">
<AddressEntity
address={{ hash: searchAddress }}
truncation="constant"
textStyle={{ base: 'heading.sm', lg: 'heading.md' }}
fontWeight="500"
icon={{ size: isMobile ? undefined : 30 }}
noLink
/>
<Tooltip content="Connect a wallet to revoke approvals" disabled={ isAddressMatch } disableOnMobile>
<Badge colorPalette={ isAddressMatch ? 'green' : 'gray' }>
{ isAddressMatch ? 'Connected' : 'Not connected' }
</Badge>
</Tooltip>
</Flex>
</Flex>
<Flex gap={ 3 } alignItems="center" flexWrap="wrap">
<Skeleton
loading={ coinBalanceQuery.isPlaceholderData }
as={ Flex }
gap={ 3 }
>
{ (coinBalanceQuery.isPlaceholderData ||
coinBalanceQuery.data) && (
<>
<Flex gap={ 2 } alignItems="center" ml={{ base: 0, lg: '5px' }}>
<Image
src={ coinBalanceQuery.data.coinImage }
alt={ coinBalanceQuery.data.symbol }
boxSize={ 5 }
fallback={ <TokenLogoPlaceholder/> }
/>
<Text textStyle="sm" fontWeight="500">
{ coinBalanceQuery.data.balance }{ ' ' }
{ coinBalanceQuery.data.symbol }
</Text>
</Flex>
<Text textStyle="sm" fontWeight="500" color="text.secondary">
${ coinBalanceQuery.data.balanceUsd }
</Text>
</>
) }
</Skeleton>
<Link
href={ selectedChain?.config.app.baseUrl + route({ pathname: '/address/[hash]', query: { hash: searchAddress } }) }
external
textStyle="sm"
fontWeight="500"
noIcon
>
View details
</Link>
</Flex>
</Flex>
<Flex
w={{ base: 'full', lg: '400px' }}
bg={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
p={ 6 }
borderRadius="base"
>
<Flex
flexDir="column"
flex={ 1 }
justifyContent="center"
alignItems="center"
gap={ 2 }
>
<Text textStyle="sm" fontWeight="500" color="text.secondary">
Total approvals
</Text>
<Skeleton
loading={ approvalsQuery.isPlaceholderData }
minW="40px"
textAlign="center"
>
<Heading level="3">
{ approvals?.length || 0 }
</Heading>
</Skeleton>
</Flex>
<Separator
orientation="vertical"
borderColor="border.divider"
mx={{ base: 4, md: 8 }}
/>
<Flex
flexDir="column"
flex={ 1 }
justifyContent="center"
alignItems="center"
gap={ 2 }
>
<Text textStyle="sm" fontWeight="500" color="text.secondary">
Total value at risk
</Text>
<Skeleton
loading={ approvalsQuery.isPlaceholderData }
minW="40px"
textAlign="center"
>
<Heading level="3">
${ totalValueAtRiskUsd }
</Heading>
</Skeleton>
</Flex>
</Flex>
</Flex>
<Approvals
selectedChain={ selectedChain }
approvals={ approvals || [] }
isLoading={ approvalsQuery.isPlaceholderData }
isAddressMatch={ isAddressMatch }
hideApproval={ hideApproval }
/>
</Flex>
);
};
export default Content;
@@ -0,0 +1,66 @@
import { chakra, Spinner } from '@chakra-ui/react';
import type { ChangeEvent, FormEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Input } from 'toolkit/chakra/input';
import { InputGroup } from 'toolkit/chakra/input-group';
import { ClearButton } from 'toolkit/components/buttons/ClearButton';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => Promise<void>;
};
export default function SearchInput({ value, onChange, onSubmit }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [ isLoading, setIsLoading ] = useState(false);
const handleSubmit = useCallback(async(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
await onSubmit(value);
setIsLoading(false);
inputRef.current?.blur();
}, [ onSubmit, value ]);
const handleValueChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
onChange(value);
}, [ onChange ]);
const handleFilterQueryClear = useCallback(() => {
onChange('');
inputRef?.current?.focus();
}, [ onChange ]);
const startElement = isLoading ? <Spinner size="sm"/> : <IconSvg boxSize={ 5 } name="search"/>;
const endElement = <ClearButton onClick={ handleFilterQueryClear } visible={ value.length > 0 }/>;
return (
<chakra.form
onSubmit={ handleSubmit }
noValidate
w="full"
>
<InputGroup
startElement={ startElement }
startElementProps={{ px: 2 }}
endElement={ endElement }
endElementProps={{ w: '32px' }}
>
<Input
ref={ inputRef }
size="sm"
value={ value }
onChange={ handleValueChange }
placeholder="Search accounts by address or domain..."
borderWidth="2px"
textOverflow="ellipsis"
whiteSpace="nowrap"
/>
</InputGroup>
</chakra.form>
);
}
@@ -0,0 +1,49 @@
import { Flex, Text } from '@chakra-ui/react';
import { Heading } from 'toolkit/chakra/heading';
import IconSvg from 'ui/shared/IconSvg';
import type { IconName } from 'ui/shared/IconSvg';
const STEPS = [
{
text: 'Click Connect Wallet on the top right or enter an address in the search bar.',
icon: 'wallet' as IconName,
},
{
text: 'Inspect your approvals by using the network selection, sorting and filtering options.',
icon: 'search' as IconName,
},
{
text: 'Revoke the approvals that you no longer use to prevent unwanted access to your funds.',
icon: 'return' as IconName,
},
];
export default function StartScreen() {
return (
<Flex flexDir="column" w="full" gap={{ base: 3, md: 6 }}>
<Heading level="3">
How to revoke your approvals
</Heading>
<Flex flexDir={{ base: 'column', md: 'row' }} gap={{ base: 2, md: 6 }}>
{ STEPS.map((step, index) => (
<Flex
key={ index }
flexDir={{ base: 'column', md: 'row' }}
alignItems={{ base: 'flex-start', md: 'center' }}
p={ 6 }
borderRadius="md"
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
flex={ 1 }
gap={ 6 }
>
<IconSvg name={ step.icon } boxSize={ 6 }/>
<Text textStyle="sm">
{ step.text }
</Text>
</Flex>
)) }
</Flex>
</Flex>
);
}
@@ -0,0 +1,65 @@
import ERC20Artifact from '@openzeppelin/contracts/build/contracts/ERC20.json';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import type { GetLogsParameters } from 'viem';
import { isAddress, getAbiItem } from 'viem';
import type { ChainConfig } from 'types/multichain';
import { ALLOWANCES } from 'stubs/essentialDapps/revoke';
import createPublicClient from '../lib/createPublicClient';
import getLogs from '../lib/getLogs';
import useSearchErc20Allowances from './useSearchErc20Allowances';
import useSearchNftAllowances from './useSearchNftAllowances';
export default function useApprovalsQuery(chain: ChainConfig | undefined, userAddress: string) {
const searchErc20Allowances = useSearchErc20Allowances();
const searchNftAllowances = useSearchNftAllowances();
const publicClient = useMemo(
() => createPublicClient(chain?.config.chain.id),
[ chain?.config.chain.id ],
);
const searchAllowances = useCallback(async(signal?: AbortSignal) => {
try {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
if (!publicClient) {
throw new Error('Public client not found');
}
const latestBlockNumber = await publicClient.getBlockNumber();
const filter = {
event: getAbiItem({ abi: ERC20Artifact.abi, name: 'Approval' }),
args: { owner: userAddress },
} as unknown as GetLogsParameters;
const approvalEvents = await getLogs(publicClient, filter, BigInt(0), latestBlockNumber, signal);
const [ erc20Allowances, nftAllowances ] = await Promise.all([
searchErc20Allowances(chain, userAddress, approvalEvents, publicClient, signal),
searchNftAllowances(chain, userAddress, approvalEvents, publicClient, latestBlockNumber, signal),
]);
const allowances = [ ...erc20Allowances, ...nftAllowances ].sort((a, b) => {
if (b.timestamp < a.timestamp) return -1;
if (b.timestamp > a.timestamp) return 1;
return 0;
});
return allowances;
} catch {
return [];
}
}, [ searchErc20Allowances, searchNftAllowances, publicClient, chain, userAddress ]);
return useQuery({
queryKey: [ 'revoke:approvals', chain?.config.chain.id, userAddress ],
queryFn: ({ signal }) => searchAllowances(signal),
enabled: Boolean(userAddress) && isAddress(userAddress),
placeholderData: ALLOWANCES,
});
}
@@ -0,0 +1,64 @@
import React from 'react';
import { formatUnits } from 'viem';
import type { ChainConfig } from 'types/multichain';
import useApiQuery from 'lib/api/useApiQuery';
const PLACEHOLDER_DATA = {
balance: '10000',
balanceUsd: '10000',
symbol: 'ETH',
coinImage: undefined,
};
export default function useCoinBalanceQuery(chain: ChainConfig | undefined, userAddress: string) {
const addressQuery = useApiQuery('general:address', {
pathParams: { hash: userAddress },
chain,
});
const statsQuery = useApiQuery('general:stats', { chain });
return React.useMemo(() => {
if (!addressQuery.data || !statsQuery.data) {
return {
isPlaceholderData: true,
data: PLACEHOLDER_DATA,
};
}
const coinImage = statsQuery.data.coin_image || undefined;
let balance = parseFloat(
formatUnits(
BigInt(addressQuery.data.coin_balance || '0'),
chain?.config.chain.currency.decimals || 18,
),
);
const balanceUsd = Number(
(balance * parseFloat(addressQuery.data.exchange_rate || '0')).toFixed(2),
).toLocaleString();
let balanceString;
if (balance > 0) {
balance = Number(
balance >= 1 ? balance.toFixed(2) : balance.toPrecision(5),
);
const [ integer, decimal ] = balance.toString().split('.');
balanceString = Number(integer).toLocaleString();
balanceString += decimal ? `.${ decimal }` : '';
}
return {
isPlaceholderData: false,
data: {
balance: balanceString || '0',
balanceUsd,
symbol: chain?.config.chain.currency.symbol,
coinImage,
},
};
}, [ addressQuery.data, statsQuery.data, chain?.config.chain.currency.decimals, chain?.config.chain.currency.symbol ]);
}
@@ -0,0 +1,52 @@
import { useCallback, useEffect } from 'react';
import type { Block } from 'types/api/block';
import type { ChainConfig } from 'types/multichain';
import useApiFetch from 'lib/api/useApiFetch';
// Cache for block timestamp requests across the session
const timestampCache = new Map<string, Promise<number>>();
let activeInstances = 0;
export default function useGetBlockTimestamp() {
const apiFetch = useApiFetch();
// Clear entire cache when the last consumer unmounts
useEffect(() => {
activeInstances += 1;
return () => {
activeInstances -= 1;
if (activeInstances === 0) {
timestampCache.clear();
}
};
}, []);
return useCallback(async(
chain: ChainConfig | undefined,
blockNumber: bigint,
signal?: AbortSignal,
): Promise<number> => {
const cacheKey = `${ chain?.config.chain.id }:${ blockNumber.toString() }`;
const cached = timestampCache.get(cacheKey);
if (cached) return cached;
const response = (apiFetch('general:block', {
pathParams: { height_or_hash: blockNumber.toString() },
chain,
fetchParams: {
signal,
},
}) as Promise<Block>)
.then((data) => data.timestamp ? Date.parse(data.timestamp) : 0)
.catch((err) => {
timestampCache.delete(cacheKey);
throw err;
});
timestampCache.set(cacheKey, response);
return response;
}, [ apiFetch ]);
}
@@ -0,0 +1,79 @@
import ERC20Artifact from '@openzeppelin/contracts/build/contracts/ERC20.json';
import NftArtifact from '@openzeppelin/contracts/build/contracts/ERC721.json';
import { waitForTransactionReceipt } from '@wagmi/core';
import { useCallback } from 'react';
import { useAccount, useWriteContract, useSwitchChain } from 'wagmi';
import type { AllowanceType } from 'types/client/revoke';
import useRewardsActivity from 'lib/hooks/useRewardsActivity';
import * as mixpanel from 'lib/mixpanel/index';
import wagmiConfig from 'lib/web3/wagmiConfig';
import { toaster } from 'toolkit/chakra/toaster';
export default function useRevoke() {
const { address: userAddress } = useAccount();
const { switchChainAsync } = useSwitchChain();
const { writeContractAsync } = useWriteContract();
const { trackTransaction, trackTransactionConfirm } = useRewardsActivity();
return useCallback(async(approval: AllowanceType, chainId: number) => {
try {
if (!userAddress) return;
await switchChainAsync({ chainId });
const activityResponse = await trackTransaction(userAddress, approval.address, String(chainId));
const isErc20 = approval.type === 'ERC-20';
const hash = await writeContractAsync({
account: userAddress,
address: approval.address,
abi: isErc20 ? ERC20Artifact.abi : NftArtifact.abi,
functionName: isErc20 ? 'approve' : 'setApprovalForAll',
args: [ approval.spender, isErc20 ? 0 : false ],
chainId,
});
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, {
Action: 'Send Transaction',
Address: userAddress,
AppId: 'revoke',
Source: 'Essential dapps',
ChainId: String(chainId),
});
if (activityResponse?.token) {
await trackTransactionConfirm(hash, activityResponse.token);
}
const receipt = await waitForTransactionReceipt(wagmiConfig.config, { hash, chainId });
if (receipt.status === 'reverted') {
throw new Error('Failed to revoke approval.');
}
toaster.success({
title: 'Success',
description: 'Approval revoked successfully.',
});
return true;
} catch (error) {
toaster.error({
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
});
return false;
}
}, [
userAddress,
writeContractAsync,
switchChainAsync,
trackTransaction,
trackTransactionConfirm,
]);
}
@@ -0,0 +1,239 @@
import ERC20Artifact from '@openzeppelin/contracts/build/contracts/ERC20.json';
import { uniq } from 'es-toolkit';
import { useCallback } from 'react';
import { getAddress, formatUnits, slice } from 'viem';
import type { PublicClient, Log } from 'viem';
import type { AddressTokenBalancesResponse } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import type { AllowanceType, ContractAllowanceType } from 'types/client/revoke';
import type { ChainConfig } from 'types/multichain';
import useApiFetch from 'lib/api/useApiFetch';
import useGetBlockTimestamp from './useGetBlockTimestamp';
function formatAllowance(
allowance: bigint,
decimals: number = 18,
totalSupply: bigint | undefined,
): string {
if (totalSupply && allowance > totalSupply) {
return 'Unlimited';
}
return formatUnits(allowance, decimals);
}
async function getERC20AllowanceFromApproval(
tokenAddress: `0x${ string }`,
ownerAddress: string,
approval: Log,
publicClient: PublicClient,
): Promise<ContractAllowanceType> {
const spender = getAddress(slice(approval.topics[2] as `0x${ string }`, 12));
const allowance = await publicClient
.readContract({
address: tokenAddress,
abi: ERC20Artifact.abi,
functionName: 'allowance',
args: [ ownerAddress, spender ],
})
.catch(() => undefined);
return {
transactionId: approval.transactionHash,
spender,
allowance: allowance as bigint | undefined,
blockNumber: approval.blockNumber as bigint,
};
}
async function getERC20AllowancesFromApprovals(
tokenAddress: `0x${ string }`,
ownerAddress: string,
approvals: Array<Log>,
publicClient: PublicClient,
) {
const deduplicatedApprovals = approvals.filter(
(approval, i) =>
i ===
approvals.findIndex((other) => approval.topics[2] === other.topics[2]),
);
const allowances: Array<ContractAllowanceType> = await Promise.all(
deduplicatedApprovals.map((approval) =>
getERC20AllowanceFromApproval(
tokenAddress,
ownerAddress,
approval,
publicClient,
),
),
);
return allowances;
}
const useGetERC20TokenData = () => {
const apiFetch = useApiFetch();
return useCallback(async(
tokenAddress: `0x${ string }`,
chain: ChainConfig | undefined,
signal?: AbortSignal,
) => {
try {
const data = await apiFetch('general:token', {
pathParams: { hash: tokenAddress },
chain,
fetchParams: {
signal,
},
}) as TokenInfo;
return {
symbol: data.symbol || undefined,
decimals: data.decimals ? Number(data.decimals) : undefined,
totalSupply: data.total_supply ? BigInt(data.total_supply) : undefined,
name: data.name || undefined,
tokenIcon: data.icon_url || undefined,
price: data.exchange_rate || undefined,
tokenReputation: data.reputation,
balance: undefined,
};
} catch {
return undefined;
}
}, [ apiFetch ]);
};
const useGetERC20Allowances = () => {
const getERC20TokenData = useGetERC20TokenData();
const getBlockTimestamp = useGetBlockTimestamp();
const apiFetch = useApiFetch();
return useCallback(async(
chain: ChainConfig | undefined,
tokenAddresses: Array<`0x${ string }`>,
searchQuery: string,
approvals: Array<Log>,
publicClient: PublicClient,
signal?: AbortSignal,
) => {
const allowances: Array<AllowanceType> = [];
let balances: Record<string, bigint> = {};
const response = await apiFetch('general:address_token_balances', {
pathParams: { hash: searchQuery },
chain,
fetchParams: {
signal,
},
}) as AddressTokenBalancesResponse;
balances = Object.fromEntries(
response.map((entry) => [ entry.token.address_hash, BigInt(entry.value) ]),
);
await Promise.all(
tokenAddresses.map(async(tokenAddress) => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const tokenData = await getERC20TokenData(tokenAddress, chain, signal);
if (tokenData) {
const tokenApprovals = approvals.filter(
(approval) => getAddress(approval.address) === tokenAddress,
);
if (tokenApprovals.length > 0) {
const tokenAllowances = await getERC20AllowancesFromApprovals(
tokenAddress,
searchQuery,
tokenApprovals,
publicClient,
);
await Promise.all(
tokenAllowances.map(async(allowance) => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const timestampMs = await getBlockTimestamp(chain, allowance.blockNumber, signal);
let valueAtRiskUsd;
if (
allowance.allowance &&
balances[tokenAddress] &&
tokenData.price &&
tokenData.decimals
) {
const valueAtRisk = allowance.allowance > balances[tokenAddress] ?
balances[tokenAddress] :
allowance.allowance;
valueAtRiskUsd = Number(
(
parseFloat(formatUnits(valueAtRisk, tokenData.decimals)) *
parseFloat(tokenData.price)
).toFixed(2),
);
}
if (allowance.allowance && allowance.allowance !== BigInt(0)) {
allowances.push({
...tokenData,
type: 'ERC-20',
address: tokenAddress,
transactionId: allowance.transactionId,
spender: allowance.spender,
timestamp: timestampMs,
allowance: allowance.allowance ?
formatAllowance(
allowance.allowance,
tokenData.decimals,
tokenData.totalSupply,
) : undefined,
valueAtRiskUsd,
});
}
}),
);
}
}
}),
);
return allowances;
}, [ getERC20TokenData, apiFetch, getBlockTimestamp ]);
};
export default function useSearchErc20Allowances() {
const getERC20Allowances = useGetERC20Allowances();
return useCallback(async(
chain: ChainConfig | undefined,
searchQuery: string,
approvalEvents: Array<Log>,
publicClient: PublicClient,
signal?: AbortSignal,
) => {
const erc20Events = approvalEvents.filter((ev) => ev.topics.length === 3);
const erc20Addresses = uniq(
erc20Events.map((event) => getAddress(event.address)),
);
return getERC20Allowances(
chain,
erc20Addresses,
searchQuery,
erc20Events,
publicClient,
signal,
);
}, [ getERC20Allowances ]);
}

Some files were not shown because too many files have changed in this diff Show More