Essential dapps (#3026)
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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']}` |
|
||||
|
||||
|
||||
|
||||
### Solidity to UML diagrams
|
||||
|
||||
@@ -21,6 +21,7 @@ declare global {
|
||||
abkw: string;
|
||||
__envs: Record<string, string>;
|
||||
__multichainConfig: MultichainConfig;
|
||||
__essentialDappsChains: MultichainConfig;
|
||||
}
|
||||
|
||||
namespace NodeJS {
|
||||
|
||||
@@ -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,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 |
@@ -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 |
@@ -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 :
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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 }`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -14,6 +14,9 @@ export const chainDataA = {
|
||||
symbol: 'ETH',
|
||||
decimals: 18,
|
||||
},
|
||||
rpcUrls: [
|
||||
'https://rpc.op-mainnet.com',
|
||||
],
|
||||
},
|
||||
UI: {
|
||||
navigation: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
src: url('/static/fonts/Inter-fallback.woff2') format('woff2');
|
||||
unicode-range: U+2192; /* Only the right arrow */
|
||||
font-display: swap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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"/>
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
| "refresh"
|
||||
| "repeat"
|
||||
| "restAPI"
|
||||
| "return"
|
||||
| "rocket_xl"
|
||||
| "rocket"
|
||||
| "RPC"
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface AddressTokenBalance {
|
||||
value: string;
|
||||
token_instance: TokenInstance | null;
|
||||
}
|
||||
export type AddressTokenBalancesResponse = Array<AddressTokenBalance>;
|
||||
|
||||
export type AddressNFT = TokenInstance & {
|
||||
token: TokenInfo;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -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 ]);
|
||||
}
|
||||