diff --git a/.env.example b/.env.example index 29dbc4a5a..c572d703b 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,6 @@ # NEXT_PUBLIC_INFURA_PROJECT_ID= # NEXT_PUBLIC_ESCROW_ADDRESSES={"11155111":"0x5494711392a67DA50D3bC7b1fcC2d1877cFaA4d2", "11155420":"0x4D49eEedFac8Ea03328c0E4871b680C06d892092"} # NEXT_PUBLIC_NODE_URI_MAP={"1":"https://mainnet...", "10":"https://optimism...", "11155111":"https://eth-sepolia.g.alchemy.com/.., "11155420":"https://opt-sepolia.g.alchemy.com/..} -# NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE=1500000000000000000 +# NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP={"11155111":[{"token":"0x1B083D8584dd3e6Ff37d04a6e7e82b5F622f3985","amount":"1500000000000000000"}, {"token": "0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4", "amount": "2000000"}]} # NEXT_PUBLIC_CONSUME_MARKET_FEE=0.1 # NEXT_PUBLIC_MARKET_FEE_ADDRESS=0x0db00a90deee402256cb1df89f3e14d6b9130fdd diff --git a/app.config.cjs b/app.config.cjs index 37dc8f100..0c7e00191 100644 --- a/app.config.cjs +++ b/app.config.cjs @@ -55,9 +55,8 @@ module.exports = { // consume market fee that is taken upon ordering an asset, it is an absolute value with 18 decimals, it is specified on order consumeMarketOrderFee: - getEnv('NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE') || - process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE || - '0', + getEnv('NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP') || + process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP, consumeMarketFee: getEnv('NEXT_PUBLIC_CONSUME_MARKET_FEE') || process.env.NEXT_PUBLIC_CONSUME_MARKET_FEE || diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index b298b78c9..887729637 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -25,8 +25,8 @@ const config = { process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, NEXT_PUBLIC_INFURA_PROJECT_ID: process.env.NEXT_PUBLIC_INFURA_PROJECT_ID, NEXT_PUBLIC_CONSUME_MARKET_FEE: process.env.NEXT_PUBLIC_CONSUME_MARKET_FEE, - NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE: - process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE, + NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP: + process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP, NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS: process.env.NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS, NEXT_PUBLIC_DISPENSER_ADDRESS: process.env.NEXT_PUBLIC_DISPENSER_ADDRESS, @@ -39,7 +39,7 @@ const config = { process.env.NEXT_PUBLIC_CREDENTIAL_VALIDITY_DURATION, NEXT_PUBLIC_NODE_URI_MAP: process.env.NEXT_PUBLIC_NODE_URI_MAP, NEXT_PUBLIC_MARKET_FEE_ADDRESS: process.env.NEXT_PUBLIC_MARKET_FEE_ADDRESS, - NEXT_PUBLIC_ERC20_ADDRESSES: process.env.NEXT_PUBLIC_ERC20_ADDRESSES + NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES: process.env.NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES } fs.writeFileSync( diff --git a/scripts/write-runtime-config.cjs b/scripts/write-runtime-config.cjs index 9e35f9551..d90d6d317 100644 --- a/scripts/write-runtime-config.cjs +++ b/scripts/write-runtime-config.cjs @@ -44,8 +44,8 @@ const config = { process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, NEXT_PUBLIC_INFURA_PROJECT_ID: process.env.NEXT_PUBLIC_INFURA_PROJECT_ID, NEXT_PUBLIC_CONSUME_MARKET_FEE: process.env.NEXT_PUBLIC_CONSUME_MARKET_FEE, - NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE: - process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE, + NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP: + process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP, NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS: process.env.NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS, NEXT_PUBLIC_DISPENSER_ADDRESS: process.env.NEXT_PUBLIC_DISPENSER_ADDRESS, @@ -58,7 +58,8 @@ const config = { process.env.NEXT_PUBLIC_CREDENTIAL_VALIDITY_DURATION, NEXT_PUBLIC_NODE_URI_MAP: process.env.NEXT_PUBLIC_NODE_URI_MAP, NEXT_PUBLIC_MARKET_FEE_ADDRESS: process.env.NEXT_PUBLIC_MARKET_FEE_ADDRESS, - NEXT_PUBLIC_ERC20_ADDRESSES: process.env.NEXT_PUBLIC_ERC20_ADDRESSES + NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES: + process.env.NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES } const outputPath = path.join(process.cwd(), 'public', 'runtime-config.js') diff --git a/src/@context/MarketMetadata/_types.ts b/src/@context/MarketMetadata/_types.ts index 3594b8acb..aea24d00b 100644 --- a/src/@context/MarketMetadata/_types.ts +++ b/src/@context/MarketMetadata/_types.ts @@ -1,9 +1,15 @@ -export interface OpcFee { - chainId: number +export interface OpcTokenData { + tokenAddress: string feePercentage: string maxFee: string minFee: string - approvedTokens: string[] + approved: boolean +} + +export interface OpcFee { + chainId: number + // Instead of a single fee, we have an array of fee data per token + tokensData: OpcTokenData[] } export interface AppConfig { @@ -16,6 +22,7 @@ export interface AppConfig { publisherMarketFixedSwapFee: string consumeMarketOrderFee: string consumeMarketFixedSwapFee: string + customProviderUrl?: string allowFixedPricing: string allowDynamicPricing: string allowFreePricing: string diff --git a/src/@context/MarketMetadata/index.tsx b/src/@context/MarketMetadata/index.tsx index 39056b817..51f249cd3 100644 --- a/src/@context/MarketMetadata/index.tsx +++ b/src/@context/MarketMetadata/index.tsx @@ -10,12 +10,12 @@ import { import { MarketMetadataProviderValue, OpcFee } from './_types' import siteContent from '../../../content/site.json' import appConfig from '../../../app.config.cjs' -import { LoggerInstance } from '@oceanprotocol/lib' import { useConnect, useChainId } from 'wagmi' import { getOceanConfig } from '@utils/ocean' import { getTokenInfo } from '@utils/wallet' import useEnterpriseFeeColletor from '@hooks/useEnterpriseFeeCollector' import { useEthersSigner } from '@hooks/useEthersSigner' + const MarketMetadataContext = createContext({} as MarketMetadataProviderValue) function MarketMetadataProvider({ @@ -28,9 +28,10 @@ function MarketMetadataProvider({ const chainId = useChainId() const signer = useEthersSigner() - const { getOpcData } = useEnterpriseFeeColletor() + const { getOpcData, enterpriseFeeCollector } = useEnterpriseFeeColletor() const [opcFees, setOpcFees] = useState() const [approvedBaseTokens, setApprovedBaseTokens] = useState() + const config = getOceanConfig(chainId) // --------------------------- @@ -38,17 +39,17 @@ function MarketMetadataProvider({ // --------------------------- useEffect(() => { async function fetchData() { + // Safety check: Don't run if we don't have a signer yet + if (!signer) return + const opcData = await getOpcData(appConfig.chainIdsSupported) - LoggerInstance.log('[MarketMetadata] Got new data.', { - opcFees: opcData, - siteContent, - appConfig - }) setOpcFees(opcData) } - if (signer) fetchData() - }, [signer]) + if (!opcFees && signer && enterpriseFeeCollector) { + fetchData() + } + }, [signer, getOpcData, enterpriseFeeCollector]) // --------------------------- // Get OPC fee for given token @@ -57,38 +58,58 @@ function MarketMetadataProvider({ (tokenAddress: string, chainId: number): string => { if (!opcFees) return '0' const opc = opcFees.find((x) => x.chainId === chainId) - return opc?.feePercentage || '0' + return ( + opc?.tokensData.find( + (x) => x.tokenAddress.toLowerCase() === tokenAddress.toLowerCase() + )?.feePercentage || '0' + ) }, [opcFees] ) // --------------------------- - // Load OCEAN token metadata + // Load approved tokens metadata // --------------------------- + + const tokenAddressesString = JSON.stringify(config?.tokenAddresses || []) + useEffect(() => { async function fetchTokenInfoSafe() { + const addresses = JSON.parse(tokenAddressesString) + try { if (isLoading) return - if (!config?.oceanTokenAddress) { - console.warn('[fetchTokenInfo] No oceanTokenAddress configured.') - return - } - if (!chainId) { - console.error('[fetchTokenInfo] chainId missing.') + if (!addresses || addresses.length === 0) { return } - if (!signer) { - console.warn('[fetchTokenInfo] Waiting for signer...') - return - } - const tokenDetails = await getTokenInfo( - config.oceanTokenAddress, - signer.provider + if (!chainId) return + if (!signer) return + + // 1. Fetch metadata for all configured tokens + const tokenPromises = addresses.map((address: string) => + getTokenInfo(address, signer.provider) ) - setApprovedBaseTokens([tokenDetails]) + const tokensDetails = await Promise.all(tokenPromises) + // 2. Identify allowed tokens from OPC Data for the current chain + const currentChainOpc = opcFees?.find((x) => x.chainId === chainId) + + // Create a Set of allowed addresses (normalized to lowercase) for fast lookup + const allowedAddresses = new Set( + currentChainOpc?.tokensData.map((t) => + t.tokenAddress.toLowerCase() + ) || [] + ) + // 3. Filter: Keep only valid tokens that ARE ALSO in the allowed list + const filteredTokens = tokensDetails.filter((t) => { + if (!t) return false // Filter out undefined fetch results + + return allowedAddresses.has(t.address.toLowerCase()) + }) + + setApprovedBaseTokens(filteredTokens) } catch (error: any) { console.error( '[fetchTokenInfo] Error fetching token info:', @@ -97,8 +118,9 @@ function MarketMetadataProvider({ } } + // added opcFees to dependency so it refilters when fee data arrives fetchTokenInfoSafe() - }, [isLoading, chainId, signer, config?.oceanTokenAddress]) + }, [isLoading, chainId, signer, tokenAddressesString, opcFees]) return ( void refreshEscrowFunds?: () => void } @@ -57,9 +67,11 @@ function ProfileProvider({ const chainId = useChainId() // FIX: Replaced useNetwork const { chainIds } = useUserPreferences() const { appConfig } = useMarketMetadata() - const [revenue, setRevenue] = useState(0) - const [escrowAvailableFunds, setEscrowAvailableFunds] = useState('0') - const [escrowLockedFunds, setEscrowLockedFunds] = useState('0') + const [revenue, setRevenue] = useState<{ [symbol: string]: number }>({}) + const [escrowFundsByToken, setEscrowFundsByToken] = useState<{ + [symbol: string]: EscrowFunds + }>({}) + const newCancelToken = useCancelToken() const [isEthAddress, setIsEthAddress] = useState() // @@ -100,8 +112,10 @@ function ProfileProvider({ // more queries to Aquarius. // const assetsWithPrices = await getAssetsBestPrices(result.results) // setAssetsWithPrices(assetsWithPrices) - } catch (error: any) { - LoggerInstance.error(error.message) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + LoggerInstance.error(errorMessage) } } getAllPublished() @@ -182,8 +196,9 @@ function ProfileProvider({ try { setIsDownloadsLoading(true) await fetchDownloads(cancelTokenSource.token) - } catch (err: any) { - LoggerInstance.log(err.message) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + LoggerInstance.log(errorMessage) } finally { setIsDownloadsLoading(false) } @@ -210,64 +225,157 @@ function ProfileProvider({ useEffect(() => { if (!accountId || chainIds.length === 0) { setSales(0) + setRevenue({}) return } async function getUserSalesNumber() { try { - const { totalOrders, totalRevenue } = await getUserSalesAndRevenue( - accountId, - chainIds - ) - setRevenue(totalRevenue) + const { totalOrders, revenueByToken, results } = + await getUserSalesAndRevenue(accountId, chainIds) setSales(totalOrders) - } catch (error: any) { - LoggerInstance.error(error.message) + + const enrichedResults = await Promise.all( + results.map(async (item) => { + try { + const accessDetails = await getAccessDetails( + item.credentialSubject.chainId, + item.credentialSubject.services[0], + accountId, + newCancelToken() + ) + return { + ...item, + accessDetails: [accessDetails] + } + } catch (err) { + LoggerInstance.warn( + `[Profile] Failed to fetch access details for ${item.id}`, + err.message + ) + return { ...item, accessDetails: [] } + } + }) + ) + + const revenueByTokenFromAccess: { [symbol: string]: number } = {} + enrichedResults.forEach((asset) => { + const orders = asset?.indexedMetadata?.stats?.[0]?.orders || 0 + const priceEntry = ( + asset.indexedMetadata?.stats?.[0] as { + prices?: Array<{ price?: number | string }> + } + )?.prices?.[0] + const price = priceEntry?.price ? Number(priceEntry.price) : 0 + + const firstAccessDetail = asset.accessDetails?.[0] + const baseTokenSymbol = + firstAccessDetail?.baseToken?.symbol || undefined + + if (!baseTokenSymbol) return + + const revenueValue = orders * price + if (revenueValue > 0) { + if (!revenueByTokenFromAccess[baseTokenSymbol]) { + revenueByTokenFromAccess[baseTokenSymbol] = 0 + } + revenueByTokenFromAccess[baseTokenSymbol] += revenueValue + } + }) + + const finalRevenueMap = + Object.keys(revenueByTokenFromAccess).length > 0 + ? revenueByTokenFromAccess + : revenueByToken + + setRevenue(finalRevenueMap) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + LoggerInstance.error(errorMessage) } } getUserSalesNumber() - }, [accountId, chainIds]) + }, [accountId, chainIds, newCancelToken]) async function getEscrowFunds() { if (!accountId || !isEthAddress || !walletClient || !chainId) { - setEscrowAvailableFunds('0') - setEscrowLockedFunds('0') + setEscrowFundsByToken({}) return } try { - const { oceanTokenAddress, escrowAddress } = getOceanConfig(chainId) - + const { escrowAddress } = getOceanConfig(chainId) const escrow = new EscrowContract( escrowAddress, - walletClient as any, + walletClient as Signer, chainId ) - const funds = await escrow.getUserFunds(accountId, oceanTokenAddress) + const providerUrl = appConfig?.customProviderUrl + if (!providerUrl) { + LoggerInstance.warn( + '[Profile] No provider URL for compute environments' + ) + return + } - const tokenDetails = await getTokenInfo( - oceanTokenAddress, - walletClient as any - ) + const computeEnvs = await getComputeEnvironments(providerUrl, chainId) + if (!computeEnvs || computeEnvs.length === 0) { + LoggerInstance.warn('[Profile] No compute environments found') + return + } - const availableFunds = formatUnits(funds.available, tokenDetails.decimals) - const lockedFunds = formatUnits(funds.locked, tokenDetails.decimals) + const feeTokenAddresses = new Set() + computeEnvs.forEach((env) => { + const chainIdString = chainId.toString() + const envWithFees = env as unknown as { + fees?: Record> + } + const fee = envWithFees.fees?.[chainIdString]?.[0] + if (fee?.feeToken) { + feeTokenAddresses.add(fee.feeToken) + } + }) + + const escrowFundsMap: { [symbol: string]: EscrowFunds } = {} + + for (const tokenAddress of feeTokenAddresses) { + try { + const funds = await escrow.getUserFunds(accountId, tokenAddress) + const tokenDetails = await getTokenInfo( + tokenAddress, + walletClient.provider + ) + + const available = formatUnits(funds.available, tokenDetails.decimals) + const locked = formatUnits(funds.locked, tokenDetails.decimals) + + escrowFundsMap[tokenDetails.symbol] = { + available, + locked, + symbol: tokenDetails.symbol, + address: tokenAddress, + decimals: tokenDetails.decimals + } + } catch (err) { + LoggerInstance.warn( + `[Profile] Failed to get escrow funds for token ${tokenAddress}`, + err.message + ) + } + } - setEscrowLockedFunds(lockedFunds) - setEscrowAvailableFunds(availableFunds) - } catch (error: any) { - LoggerInstance.error(error.message) + setEscrowFundsByToken(escrowFundsMap) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + LoggerInstance.error('[Profile] Error getting escrow funds', errorMessage) } } useEffect(() => { getEscrowFunds() - }, [accountId, walletClient, isEthAddress, chainId]) - - useEffect(() => { - // FIX: Update dependencies to use new variables - getEscrowFunds() - }, [accountId, walletClient, isEthAddress, chainId]) + }, [accountId, walletClient, isEthAddress, chainId, appConfig]) return ( diff --git a/src/@hooks/useEnterpriseFeeCollector.ts b/src/@hooks/useEnterpriseFeeCollector.ts index acf1f0d11..43a5fe179 100644 --- a/src/@hooks/useEnterpriseFeeCollector.ts +++ b/src/@hooks/useEnterpriseFeeCollector.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' // Import useCallback import { EnterpriseFeeCollectorContract } from '@oceanprotocol/lib' import { getOceanConfig } from '@utils/ocean' import { useChainId } from 'wagmi' @@ -8,63 +8,86 @@ import { Fees } from 'src/@types/feeCollector/FeeCollector.type' import { OpcFee } from '@context/MarketMetadata/_types' import { useEthersSigner } from './useEthersSigner' -function useEnterpriseFeeColletor() { +function useEnterpriseFeeCollector() { const chainId = useChainId() const signer = useEthersSigner() const [enterpriseFeeCollector, setEnterpriseFeeCollector] = useState< EnterpriseFeeCollectorContract | undefined >(undefined) - const [fees, setFees] = useState(undefined) - const fetchFees = async ( - enterpriseFeeColletor: EnterpriseFeeCollectorContract - ): Promise => { - try { - const config = getOceanConfig(chainId) - const isTokenApproved = - await enterpriseFeeColletor.contract.isTokenAllowed( - config.oceanTokenAddress - ) - if (isTokenApproved) { - const fees = await enterpriseFeeColletor.contract.getToken( - config.oceanTokenAddress - ) - const { oceanTokenAddress } = getOceanConfig(chainId) - const tokenDetails = await getTokenInfo( - oceanTokenAddress, - signer!.provider - ) - return { - approved: fees[0], // boolean - feePercentage: formatUnits(fees[1], 18), - maxFee: formatUnits(fees[2], tokenDetails.decimals), - minFee: formatUnits(fees[3], tokenDetails.decimals), - tokenAddress: config.oceanTokenAddress - } - } else { - return { - approved: false, - feePercentage: '0', - maxFee: '0', - minFee: '0', - tokenAddress: config.oceanTokenAddress + const [fees, setFees] = useState(undefined) + + // 1. Wrap fetchFees in useCallback + const fetchFees = useCallback( + async ( + enterpriseFeeColletor: EnterpriseFeeCollectorContract + ): Promise => { + try { + const config = getOceanConfig(chainId) + const { tokenAddresses } = config + + if (!tokenAddresses || tokenAddresses.length === 0 || !signer) { + return [] } + + const feesPromises = tokenAddresses.map( + async (tokenAddress: string) => { + try { + const isTokenApproved = + await enterpriseFeeColletor.contract.isTokenAllowed( + tokenAddress + ) + + if (isTokenApproved) { + const feesData = await enterpriseFeeColletor.contract.getToken( + tokenAddress + ) + const tokenDetails = await getTokenInfo( + tokenAddress, + signer!.provider + ) + + return { + approved: feesData[0], + feePercentage: formatUnits(feesData[1], 18), + maxFee: formatUnits(feesData[2], tokenDetails.decimals), + minFee: formatUnits(feesData[3], tokenDetails.decimals), + tokenAddress + } as Fees + } else { + return { + approved: false, + feePercentage: '0', + maxFee: '0', + minFee: '0', + tokenAddress + } as Fees + } + } catch (innerError) { + console.error( + `Error fetching fees for token ${tokenAddress}:`, + innerError + ) + return { + approved: false, + feePercentage: '0', + maxFee: '0', + minFee: '0', + tokenAddress + } as Fees + } + } + ) + + const results = await Promise.all(feesPromises) + return results + } catch (error: any) { + console.error('Error fetching fees:', error) + return [] } - } catch (error: any) { - console.error('Error fetching fees:', error) - if (error.code === 'NETWORK_ERROR') { - console.warn('Network change detected, reloading page...') - window.location.reload() - } - return { - approved: false, - feePercentage: '0', - maxFee: '0', - minFee: '0', - tokenAddress: '' - } - } - } + }, + [chainId, signer] // Dependencies for fetchFees + ) useEffect(() => { if (!signer || !chainId) return @@ -81,9 +104,6 @@ function useEnterpriseFeeColletor() { ) } catch (error: any) { console.error('Error initializing EnterpriseFeeCollectorContract:', error) - if (error.code === 'NETWORK_ERROR') { - window.location.reload() - } } }, [signer, chainId]) @@ -94,32 +114,39 @@ function useEnterpriseFeeColletor() { setFees(result) } fetchData() - }, [enterpriseFeeCollector]) + }, [enterpriseFeeCollector, fetchFees]) // Added fetchFees to deps - const getOpcData = async (chainIds: number[]) => { - if (!enterpriseFeeCollector) return [] - - const validChainIds = chainIds.filter((chainId) => { - const config = getOceanConfig(chainId) - return !!config?.routerFactoryAddress - }) - - const opcData: OpcFee[] = await Promise.all( - validChainIds.map(async (chainId) => { - const currentFees = await fetchFees(enterpriseFeeCollector) - return { - chainId, - approvedTokens: [currentFees.tokenAddress], - feePercentage: currentFees.feePercentage, - maxFee: currentFees.maxFee, - minFee: currentFees.minFee - } + // 2. Wrap getOpcData in useCallback + const getOpcData = useCallback( + async (chainIds: number[]): Promise => { + if (!enterpriseFeeCollector) return [] + const validChainIds = chainIds.filter((chainId) => { + const config = getOceanConfig(chainId) + return !!config?.routerFactoryAddress }) - ) - return opcData - } + const opcData: OpcFee[] = await Promise.all( + validChainIds.map(async (cId) => { + // Note: This uses current signer context + const currentFeesArray = await fetchFees(enterpriseFeeCollector) + + return { + chainId: cId, + tokensData: currentFeesArray.map((fee) => ({ + tokenAddress: fee.tokenAddress, + feePercentage: fee.feePercentage, + maxFee: fee.maxFee, + minFee: fee.minFee, + approved: fee.approved + })) + } + }) + ) + return opcData + }, + [enterpriseFeeCollector, fetchFees] // Dependencies for getOpcData + ) - return { fees, signer, getOpcData } + return { fees, signer, getOpcData, enterpriseFeeCollector } } -export default useEnterpriseFeeColletor +export default useEnterpriseFeeCollector diff --git a/src/@utils/accessDetailsAndPricing.ts b/src/@utils/accessDetailsAndPricing.ts index 2b4393f5e..d2c3600f6 100644 --- a/src/@utils/accessDetailsAndPricing.ts +++ b/src/@utils/accessDetailsAndPricing.ts @@ -1,5 +1,4 @@ import { - ComputeAsset, Datatoken, FixedRateExchange, getErrorMessage, @@ -36,11 +35,23 @@ export async function getOrderPriceAndFees( signer?: Signer, providerFees?: ProviderFees ): Promise { + const envFeeConfig = consumeMarketOrderFee + ? JSON.parse(consumeMarketOrderFee) + : {} + const chainId = asset.credentialSubject.chainId.toString() + const tokenAddress = accessDetails.baseToken.address.toLowerCase() + + const chainFees = envFeeConfig[chainId] || [] + const matchingFeeEntry = chainFees.find( + (f: { token: string; amount: string }) => + f.token.toLowerCase() === tokenAddress + ) + const orderFee = matchingFeeEntry ? matchingFeeEntry.amount : '0' const orderPriceAndFee = { price: accessDetails.price || '0', publisherMarketOrderFee: publisherMarketOrderFee || '0', publisherMarketFixedSwapFee: '0', - consumeMarketOrderFee: consumeMarketOrderFee || '0', + consumeMarketOrderFee: orderFee, consumeMarketFixedSwapFee: '0', providerFee: { providerFeeAmount: '0' diff --git a/src/@utils/aquarius/index.ts b/src/@utils/aquarius/index.ts index 5b8bedd2b..67211ea40 100644 --- a/src/@utils/aquarius/index.ts +++ b/src/@utils/aquarius/index.ts @@ -461,18 +461,15 @@ function isAccountAllowed(ddo: any, accountId: string): boolean { service.credentials?.deny && checkDenyList(service.credentials.deny) ) { - console.log('Denied by service deny list') return false } continue } if (hasAddressAllow && !checkAllowList(serviceAllow)) { - console.log('Denied by service allow list') return false } if (service.credentials?.deny && checkDenyList(service.credentials.deny)) { - console.log('Denied by service deny list') return false } } @@ -724,12 +721,19 @@ export async function getTopAssetsPublishers( export async function getUserSalesAndRevenue( accountId: string, chainIds: number[], - filter?: Filters -): Promise<{ totalOrders: number; totalRevenue: number; results: Asset[] }> { + filter?: Filters, + cancelToken?: CancelToken +): Promise<{ + totalOrders: number + totalRevenue: number + revenueByToken: { [symbol: string]: number } + results: Asset[] +}> { try { let page = 1 let totalOrders = 0 let totalRevenue = 0 + const revenueByToken: { [symbol: string]: number } = {} let assets: PagedAssets const allResults: Asset[] = [] @@ -737,20 +741,74 @@ export async function getUserSalesAndRevenue( assets = await getPublishedAssets( accountId, chainIds, - null, + cancelToken || null, false, false, filter, page ) - // TODO stats is not in ddo if (assets && assets.results) { assets.results.forEach((asset) => { const orders = asset?.indexedMetadata?.stats[0]?.orders || 0 - const price = - Number(asset?.indexedMetadata?.stats?.[0]?.prices?.[0]?.price) || 0 + + const firstAccessDetail = (asset as any)?.accessDetails?.[0] + let price = 0 + if (firstAccessDetail?.price) { + const priceValue = + typeof firstAccessDetail.price === 'string' + ? Number(firstAccessDetail.price) + : firstAccessDetail.price + if (!isNaN(priceValue)) { + price = priceValue + } + } + + if (price === 0) { + const stats = asset?.indexedMetadata?.stats?.[0] as + | { prices?: Array<{ price?: number | string }> } + | undefined + const priceEntry = stats?.prices?.[0] + if (priceEntry?.price) { + const priceValue = + typeof priceEntry.price === 'string' + ? Number(priceEntry.price) + : priceEntry.price + if (!isNaN(priceValue)) { + price = priceValue + } + } + } + + let tokenSymbol: string | undefined + if (firstAccessDetail?.baseToken?.symbol) { + tokenSymbol = firstAccessDetail.baseToken.symbol + } else { + const credentialSubjectStats = (asset.credentialSubject as any) + ?.stats + const { price: credentialPrice } = credentialSubjectStats || {} + const { tokenSymbol: credentialTokenSymbol } = credentialPrice || {} + if (credentialTokenSymbol) { + tokenSymbol = credentialTokenSymbol + } else { + const stats = asset.indexedMetadata?.stats?.[0] as + | { price?: { tokenSymbol?: string } } + | undefined + const { price: indexedPrice } = stats || {} + const { tokenSymbol: indexedTokenSymbol } = indexedPrice || {} + if (indexedTokenSymbol) { + tokenSymbol = indexedTokenSymbol + } + } + } + totalOrders += orders - totalRevenue += orders * price + const revenue = orders * price + totalRevenue += revenue + if (!tokenSymbol) return + if (!revenueByToken[tokenSymbol]) { + revenueByToken[tokenSymbol] = 0 + } + revenueByToken[tokenSymbol] += revenue }) allResults.push(...assets.results) } @@ -762,10 +820,15 @@ export async function getUserSalesAndRevenue( page <= assets.totalPages ) - return { totalOrders, totalRevenue, results: allResults } + return { totalOrders, totalRevenue, revenueByToken, results: allResults } } catch (error) { LoggerInstance.error('Error in getUserSales', error.message) - return { totalOrders: 0, totalRevenue: 0, results: [] } + return { + totalOrders: 0, + totalRevenue: 0, + revenueByToken: {}, + results: [] + } } } diff --git a/src/@utils/ocean/index.ts b/src/@utils/ocean/index.ts index 8311797ad..9b9935126 100644 --- a/src/@utils/ocean/index.ts +++ b/src/@utils/ocean/index.ts @@ -4,6 +4,12 @@ import { getOceanArtifactsAddressesByChainId } from '@oceanprotocol/lib' import { getRuntimeConfig } from '../runtimeConfig' +import { getAddress } from 'ethers' + +export interface ConfigEnterprise extends Config { + tokenAddresses: string[] + escrowAddress?: string +} /** This function takes a Config object as an input and returns a new sanitized Config object @@ -29,21 +35,42 @@ export function sanitizeDevelopmentConfig(config: Config): Config { } as Config } -export function getOceanConfig(network: string | number): any { +/** + * Helper to validate and checksum a list of addresses. + * Removes invalid addresses and logs a warning. + */ +function validateAndChecksumAddresses(addresses: string[]): string[] { + return addresses.reduce((acc: string[], address) => { + try { + // ethers.utils.getAddress (v5) or ethers.getAddress (v6) throws if invalid + // and returns the checksummed address if valid. + const checksummed = getAddress(address) // ethers v6 + + acc.push(checksummed) + } catch (e) { + console.warn( + `[Config] Invalid address found in env: ${address}, skipping.` + ) + } + return acc + }, []) +} + +export function getOceanConfig(network: string | number): ConfigEnterprise { const runtimeConfig = getRuntimeConfig() // Load the RPC map from .env const rpcMap: Record = runtimeConfig.NEXT_PUBLIC_NODE_URI_MAP ? JSON.parse(runtimeConfig.NEXT_PUBLIC_NODE_URI_MAP) : {} - const erc20Map: Record = - runtimeConfig.NEXT_PUBLIC_ERC20_ADDRESSES - ? JSON.parse(runtimeConfig.NEXT_PUBLIC_ERC20_ADDRESSES) + const erc20Map: Record = + runtimeConfig.NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES + ? JSON.parse(runtimeConfig.NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES) : {} if (!network) { console.warn('[getOceanConfig] No network provided yet.') - return {} as Config + return {} as ConfigEnterprise } let config = new ConfigHelper().getConfig( @@ -63,8 +90,14 @@ export function getOceanConfig(network: string | number): any { // Override nodeUri with value from RPC map if it exists const networkKey = network.toString() if (rpcMap[networkKey]) config.nodeUri = rpcMap[networkKey] - if (erc20Map[networkKey]) config.oceanTokenAddress = erc20Map[networkKey] - // Get contracts for current network + if (erc20Map[networkKey]) { + const validAddresses = validateAndChecksumAddresses(erc20Map[networkKey]) + + config.tokenAddresses = validAddresses + } else { + // Fallback if no map entry exists: use the default config ocean token as a single-item array + config.tokenAddresses = [config.oceanTokenAddress] + } // Get contracts for current network const enterpriseContracts = getOceanArtifactsAddressesByChainId( Number(network) ) @@ -96,7 +129,7 @@ export function getOceanConfig(network: string | number): any { config.OPFCommunityFeeCollectorCompute = enterpriseContracts.OPFCommunityFeeCollectorCompute } - return config as Config + return config as ConfigEnterprise } export function getDevelopmentConfig(): Config { diff --git a/src/@utils/order.ts b/src/@utils/order.ts index fbd602827..60bb72017 100644 --- a/src/@utils/order.ts +++ b/src/@utils/order.ts @@ -14,14 +14,7 @@ import { getErrorMessage, allowance } from '@oceanprotocol/lib' -import { - Signer, - ethers, - TransactionResponse, - formatUnits, - parseUnits, - BigNumberish -} from 'ethers' +import { Signer, TransactionResponse, formatUnits, parseUnits } from 'ethers' import { getOceanConfig } from './ocean' import appConfig, { marketFeeAddress, @@ -32,6 +25,7 @@ import appConfig, { import { toast } from 'react-toastify' import { Service } from 'src/@types/ddo/Service' import { AssetExtended } from 'src/@types/AssetExtended' +import { getTokenInfo } from './wallet' export async function initializeProvider( asset: AssetExtended, @@ -117,16 +111,35 @@ export async function order( const serviceIndex = asset.credentialSubject?.services.findIndex( (s: Service) => s.id === service.id ) + if (serviceIndex === -1) { throw new Error(`Service with id ${service.id} not found in the DDO.`) } + + // 1. Resolve the specific Consume Market Fee from the ENV configuration + const envFeeConfig = consumeMarketOrderFee + ? JSON.parse(consumeMarketOrderFee) + : {} + const chainId = asset.credentialSubject.chainId.toString() + const baseTokenAddress = accessDetails.baseToken.address.toLowerCase() + const chainFees = envFeeConfig[chainId] || [] + + const matchingFeeEntry = chainFees.find( + (f: { token: string; amount: string }) => + f.token.toLowerCase() === baseTokenAddress + ) + const activeConsumeMarketOrderFeeWei = matchingFeeEntry + ? matchingFeeEntry.amount + : '0' + + // 2. Setup Order Parameters const orderParams = { consumer: computeConsumerAddress || accountId, serviceIndex, _providerFee: providerFees || orderPriceAndFees?.providerFee, _consumeMarketFee: { consumeMarketFeeAddress: marketFeeAddress, - consumeMarketFeeAmount: consumeMarketOrderFee, + consumeMarketFeeAmount: activeConsumeMarketOrderFeeWei, consumeMarketFeeToken: accessDetails.baseToken?.address || '0x0000000000000000000000000000000000000000' @@ -146,30 +159,25 @@ export async function order( swapMarketFee: consumeMarketFixedSwapFee, marketFeeAddress } as FreOrderParams + if (accessDetails.templateId === 1) { + // Template 1 logic: Buy DT from FRE first, then startOrder if (!hasDatatoken) { - const approveAmount = orderPriceAndFees?.price - - const tx: any = await approve( + const txApprove: any = await approve( signer as any, config, - await signer.getAddress(), + accountId, accessDetails.baseToken.address, config.fixedRateExchangeAddress, - approveAmount, + orderPriceAndFees?.price, false ) - - const txApprove = - typeof tx !== 'number' ? await (tx as any).wait() : tx - - if (!txApprove) return + await txApprove.wait() const fre = new FixedRateExchange( config.fixedRateExchangeAddress, signer as any ) - const freTx = await fre.buyDatatokens( accessDetails.addressOrId, '1', @@ -189,71 +197,97 @@ export async function order( ) txResponse = startOrderTx as unknown as TransactionResponse } + if (accessDetails.templateId === 2) { + // Template 2 Logic: Atomic buy and order + const providerFeeToken = + orderParams._providerFee?.providerFeeToken?.toLowerCase() const providerFeeWei = - providerFees?.providerFeeAmount || - orderPriceAndFees.providerFee?.providerFeeAmount || - '0' - const baseTokenDecimals = accessDetails.baseToken?.decimals || 18 - const providerFeeHuman = formatUnits( - providerFeeWei as BigNumberish, - baseTokenDecimals + orderParams._providerFee?.providerFeeAmount || '0' + const isProviderTokenSameAsBase = providerFeeToken === baseTokenAddress + + // Calculate how much Base Token (e.g. EURC) to approve + const consumeMarketFeeHuman = formatUnits( + activeConsumeMarketOrderFeeWei, + accessDetails.baseToken.decimals ) - const approveAmount = ( + + let totalBaseTokenApprove = Number(orderPriceAndFees?.price) + Number(orderPriceAndFees?.opcFee) + - Number(providerFeeHuman) + - Number(Number(consumeMarketOrderFee) / 10 ** baseTokenDecimals) - ) // just added more amount to test - .toString() - console.log('[order] TEMPLATE 2 total approve amount:', approveAmount) - freParams.maxBaseTokenAmount = ( - Number(freParams.maxBaseTokenAmount) + - Number(orderPriceAndFees?.opcFee) + - Number(providerFeeHuman) - ).toString() + Number(consumeMarketFeeHuman) - const tx: any = await approve( + if (isProviderTokenSameAsBase && providerFeeWei !== '0') { + const providerFeeHuman = formatUnits( + providerFeeWei, + accessDetails.baseToken.decimals + ) + totalBaseTokenApprove += Number(providerFeeHuman) + } + + if (!isProviderTokenSameAsBase && providerFeeWei !== '0') { + console.log( + `[order] Approving Provider Fee separately in: ${providerFeeToken}` + ) + const providerTokenInfo = await getTokenInfo( + providerFeeToken, + signer?.provider + ) + const providerFeeHuman = formatUnits( + providerFeeWei, + providerTokenInfo?.decimals || 18 + ) + + const txProv: any = await approve( + signer as any, + config, + accountId, + providerFeeToken, + accessDetails.datatoken.address, + providerFeeHuman, + false + ) + await txProv.wait() + } + + console.log( + '[order] Template 2 base token approve amount:', + totalBaseTokenApprove + ) + const txBase: any = await approve( signer as any, config, accountId, accessDetails.baseToken.address, accessDetails.datatoken.address, - approveAmount, + totalBaseTokenApprove.toString(), false ) - const txApprove = typeof tx !== 'number' ? await (tx as any).wait() : tx - console.log('[order] TEMPLATE 2 approve tx confirmed:', txApprove) - // --- wait until allowance is actually reflected --- - const decimals = accessDetails.baseToken?.decimals || 18 - - const parsedApproveAmount = BigInt(parseUnits(approveAmount, decimals)) - - let currentAllowance: bigint = BigInt(0) + await txBase.wait() + // Wait for allowance to propagate + const decimals = accessDetails.baseToken?.decimals || 18 + const parsedApproveAmount = BigInt( + parseUnits(totalBaseTokenApprove.toString(), decimals) + ) + let currentAllowance = BigInt(0) while (currentAllowance < parsedApproveAmount) { - const allowanceValue = await allowance( + const val = await allowance( signer as any, accessDetails.baseToken.address, accountId, accessDetails.datatoken.address ) - try { - currentAllowance = BigInt(parseUnits(allowanceValue, decimals)) - } catch (err) { + currentAllowance = BigInt(parseUnits(val, decimals)) + if (currentAllowance < parsedApproveAmount) await new Promise((resolve) => setTimeout(resolve, 1000)) - continue - } - - if (currentAllowance < parsedApproveAmount) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - } } - console.log('buyFromFreAndOrder params:', { - datatokenAddress: accessDetails.datatoken.address, - orderParams, - freParams - }) + + // Adjust freParams to only include cost items paid via the exchange + freParams.maxBaseTokenAmount = ( + Number(orderPriceAndFees?.price) + Number(orderPriceAndFees?.opcFee) + ).toString() + const buyTx = await datatoken.buyFromFreAndOrder( accessDetails.datatoken.address, orderParams, @@ -264,6 +298,7 @@ export async function order( break } case 'free': { + // Template 1 Free logic if (accessDetails.templateId === 1) { const dispenser = new Dispenser(config.dispenserAddress, signer as any) await dispenser.dispense( @@ -280,31 +315,32 @@ export async function order( ) txResponse = startOrderTx as unknown as TransactionResponse } + // Template 2 Free logic if (accessDetails.templateId === 2) { const providerFeeWei = - providerFees?.providerFeeAmount || - orderPriceAndFees.providerFee?.providerFeeAmount || - '0' - const baseTokenDecimals = accessDetails.baseToken?.decimals || 18 - const providerFeeHuman = formatUnits( - providerFeeWei as BigNumberish, - baseTokenDecimals + orderParams._providerFee?.providerFeeAmount || '0' + const providerToken = orderParams._providerFee?.providerFeeToken + const providerTokenInfo = await getTokenInfo( + providerToken, + signer?.provider ) - const { oceanTokenAddress } = getOceanConfig( - asset.credentialSubject?.chainId + const providerFeeHuman = formatUnits( + providerFeeWei, + providerTokenInfo?.decimals || 18 ) + + // For free assets, we only need to approve the Provider Fee const tx: any = await approve( signer as any, config, accountId, - oceanTokenAddress, + providerToken, accessDetails.datatoken.address, providerFeeHuman, false ) + await tx.wait() - const txApprove = typeof tx !== 'number' ? await (tx as any).wait() : tx - console.log('[order] TEMPLATE 2 free approve tx confirmed:', txApprove) const buyTx = await datatoken.buyFromDispenserAndOrder( service.datatokenAddress, orderParams, @@ -312,12 +348,11 @@ export async function order( ) txResponse = buyTx as unknown as TransactionResponse } + break } } - if (txResponse) { - return txResponse - } + if (txResponse) return txResponse throw new Error('Order function failed to return a transaction.') } diff --git a/src/@utils/runtimeConfig.ts b/src/@utils/runtimeConfig.ts index 3234da6b8..80ba0e90f 100644 --- a/src/@utils/runtimeConfig.ts +++ b/src/@utils/runtimeConfig.ts @@ -16,17 +16,17 @@ export type RuntimeConfig = { NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID?: string NEXT_PUBLIC_INFURA_PROJECT_ID?: string NEXT_PUBLIC_CONSUME_MARKET_FEE?: string - NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE?: string + NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP?: string NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS?: string NEXT_PUBLIC_DISPENSER_ADDRESS?: string NEXT_PUBLIC_NFT_FACTORY_ADDRESS?: string NEXT_PUBLIC_ROUTER_FACTORY_ADDRESS?: string NEXT_PUBLIC_ACCESS_LIST_FACTORY_ADDRESS?: string NEXT_PUBLIC_NODE_URI_MAP?: string - NEXT_PUBLIC_ERC20_ADDRESSES?: string NEXT_PUBLIC_CREDENTIAL_VALIDITY_DURATION?: string NEXT_PUBLIC_MARKET_FEE_ADDRESS?: string NEXT_PUBLIC_MARKET_DEVELOPMENT?: string + NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES?: string } declare global { @@ -57,8 +57,8 @@ const runtimeConfig: RuntimeConfig = (() => { process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, NEXT_PUBLIC_INFURA_PROJECT_ID: process.env.NEXT_PUBLIC_INFURA_PROJECT_ID, NEXT_PUBLIC_CONSUME_MARKET_FEE: process.env.NEXT_PUBLIC_CONSUME_MARKET_FEE, - NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE: - process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE, + NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP: + process.env.NEXT_PUBLIC_CONSUME_MARKET_ORDER_FEE_MAP, NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS: process.env.NEXT_PUBLIC_FIXED_RATE_EXCHANGE_ADDRESS, NEXT_PUBLIC_DISPENSER_ADDRESS: process.env.NEXT_PUBLIC_DISPENSER_ADDRESS, @@ -69,11 +69,12 @@ const runtimeConfig: RuntimeConfig = (() => { NEXT_PUBLIC_ACCESS_LIST_FACTORY_ADDRESS: process.env.NEXT_PUBLIC_ACCESS_LIST_FACTORY_ADDRESS, NEXT_PUBLIC_NODE_URI_MAP: process.env.NEXT_PUBLIC_NODE_URI_MAP, - NEXT_PUBLIC_ERC20_ADDRESSES: process.env.NEXT_PUBLIC_ERC20_ADDRESSES, NEXT_PUBLIC_CREDENTIAL_VALIDITY_DURATION: process.env.NEXT_PUBLIC_CREDENTIAL_VALIDITY_DURATION, NEXT_PUBLIC_MARKET_FEE_ADDRESS: process.env.NEXT_PUBLIC_MARKET_FEE_ADDRESS, - NEXT_PUBLIC_MARKET_DEVELOPMENT: process.env.NEXT_PUBLIC_MARKET_DEVELOPMENT + NEXT_PUBLIC_MARKET_DEVELOPMENT: process.env.NEXT_PUBLIC_MARKET_DEVELOPMENT, + NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES: + process.env.NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES } if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__) { diff --git a/src/components/@shared/FormInput/InputElement/AssetSelection/index.tsx b/src/components/@shared/FormInput/InputElement/AssetSelection/index.tsx index 0096d647a..edc206709 100644 --- a/src/components/@shared/FormInput/InputElement/AssetSelection/index.tsx +++ b/src/components/@shared/FormInput/InputElement/AssetSelection/index.tsx @@ -11,6 +11,7 @@ import classNames from 'classnames/bind' import Pagination from '@components/@shared/Pagination' import { useAccount } from 'wagmi' import SearchSection from '@shared/SearchSection' +import Link from 'next/link' const cx = classNames.bind(styles) @@ -178,14 +179,14 @@ export default function AssetSelection({

{asset.name} - {asset.serviceName} - - +

diff --git a/src/components/@shared/FormInput/InputElement/DatasetSelection/index.tsx b/src/components/@shared/FormInput/InputElement/DatasetSelection/index.tsx index 72efe154f..d6c26eb89 100644 --- a/src/components/@shared/FormInput/InputElement/DatasetSelection/index.tsx +++ b/src/components/@shared/FormInput/InputElement/DatasetSelection/index.tsx @@ -8,6 +8,7 @@ import NetworkName from '@shared/NetworkName' import External from '@images/external.svg' import { AssetSelectionAsset } from '@shared/FormInput/InputElement/AssetSelection' import { AssetExtended } from 'src/@types/AssetExtended' +import Link from 'next/link' export interface DatasetSelectionDataset extends AssetSelectionAsset { checked: boolean @@ -94,7 +95,7 @@ export default function DatasetSelection({
{truncateDid(dataset.did)} {dataset.did && ( - e.stopPropagation()} > - + )}
{dataset.tokenSymbol && ( diff --git a/src/components/@shared/FormInput/InputElement/index.module.css b/src/components/@shared/FormInput/InputElement/index.module.css index 56e7544a0..afb35319b 100644 --- a/src/components/@shared/FormInput/InputElement/index.module.css +++ b/src/components/@shared/FormInput/InputElement/index.module.css @@ -436,6 +436,11 @@ body .publishSelect { padding: 4px 8px; } +.publishPrefix:has(select) { + border: none; + padding: 0; +} + .publishPrefixGroup input { border-left: 1px solid var(--input-border-color); border-top-left-radius: var(--input-border-radius); diff --git a/src/components/@shared/FormInput/index.tsx b/src/components/@shared/FormInput/index.tsx index e400063c3..3dbb9834e 100644 --- a/src/components/@shared/FormInput/index.tsx +++ b/src/components/@shared/FormInput/index.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, + CSSProperties, FormEvent, KeyboardEvent, ReactElement, @@ -81,6 +82,7 @@ export interface InputProps { size?: 'mini' | 'small' | 'large' | 'default' | 'medium' selectStyle?: 'default' | 'publish' | 'custom' | 'serviceLanguage' className?: string + style?: CSSProperties checked?: boolean disclaimer?: string disclaimerValues?: string[] diff --git a/src/components/@shared/NetworkName/index.module.css b/src/components/@shared/NetworkName/index.module.css index 8b7da36f3..9f467f2fb 100644 --- a/src/components/@shared/NetworkName/index.module.css +++ b/src/components/@shared/NetworkName/index.module.css @@ -6,6 +6,22 @@ font-weight: 700; font-size: 16px; color: var(--publish-text-secondary); + max-width: 100%; + min-width: 0; +} + +.network > svg, +.network > :first-child { + flex-shrink: 0; +} + +.network .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex-shrink: 1; + max-width: 100%; } .icon { @@ -14,6 +30,7 @@ height: 1.5em; fill: currentColor; margin-right: calc(var(--spacer) / 8); + flex-shrink: 0; } .minimal { diff --git a/src/components/@shared/atoms/Table/HistoryTable.tsx b/src/components/@shared/atoms/Table/HistoryTable.tsx index 91e3a5dfa..3ad93cd4e 100644 --- a/src/components/@shared/atoms/Table/HistoryTable.tsx +++ b/src/components/@shared/atoms/Table/HistoryTable.tsx @@ -12,6 +12,7 @@ import useNetworkMetadata, { import Button from '../Button' import styles from './index.module.css' import NumberUnit from '@components/Profile/Header/NumberUnit' +import { AssetExtended } from 'src/@types/AssetExtended' // Hack in support for returning components for each row, as this works, // but is not supported by the typings. @@ -31,7 +32,8 @@ export interface TableOceanProps extends TableProps { showPagination?: boolean page?: number totalPages?: number - revenue: number + revenueByToken?: Record + revenueTotal?: number sales: number items: number allResults?: any[] @@ -52,17 +54,83 @@ export default function HistoryTable({ showPagination, page, totalPages, - revenue, + revenueByToken, + revenueTotal, sales, items, allResults, ...props }: TableOceanProps): ReactElement { const { networksList } = useNetworkMetadata() + const revenueEntries = Object.entries(revenueByToken || {}) + .filter(([symbol]) => !!symbol && symbol !== 'UNKNOWN') + .sort(([symbolA], [symbolB]) => { + // Sort with OCEAN first, then alphabetically + if (symbolA === 'OCEAN') return -1 + if (symbolB === 'OCEAN') return 1 + return symbolA.localeCompare(symbolB) + }) + const totalRevenueValue = + revenueTotal ?? + revenueEntries.reduce((acc, [_, amount]) => acc + Number(amount || 0), 0) const handleExport = () => { - const exportData = allResults.map((asset) => { - const exportedAsset = {} + interface PriceEntry { + baseToken?: { symbol?: string } + price?: number | string + } + + interface StatsEntry { + prices?: PriceEntry[] + orders?: number + } + + const exportData = (allResults || []).map((asset) => { + const exportedAsset: Record = {} + const assetWithAccess = asset as AssetExtended + const access = assetWithAccess.accessDetails?.[0] + const statsEntry = assetWithAccess.indexedMetadata?.stats?.[0] as + | StatsEntry + | undefined + const priceEntry = statsEntry?.prices?.[0] + + let baseTokenSymbol: string | undefined + if (access?.baseToken?.symbol) { + baseTokenSymbol = access.baseToken.symbol + } else { + const credentialSubjectStats = ( + assetWithAccess.credentialSubject as any + )?.stats + if (credentialSubjectStats?.price?.tokenSymbol) { + baseTokenSymbol = credentialSubjectStats.price.tokenSymbol + } else { + const stats = assetWithAccess.indexedMetadata?.stats?.[0] as + | { price?: { tokenSymbol?: string } } + | undefined + if (stats?.price?.tokenSymbol) { + baseTokenSymbol = stats.price.tokenSymbol + } + } + } + + const accessPrice = + access?.price && typeof access.price === 'string' + ? Number(access.price) + : access?.price + ? Number(access.price) + : undefined + + const priceValue = + accessPrice ?? + (priceEntry?.price + ? typeof priceEntry.price === 'string' + ? Number(priceEntry.price) + : priceEntry.price + : undefined) ?? + 0 + + const orders = statsEntry?.orders || 0 + columns.forEach((col) => { const value = col.selector(asset) @@ -79,6 +147,15 @@ export default function HistoryTable({ exportedAsset[col.name as string] = new Date( asset.indexedMetadata?.event?.datetime ).toLocaleString() + } else if (col.name === 'Price') { + exportedAsset[col.name as string] = baseTokenSymbol + ? `${Number(priceValue)} ${baseTokenSymbol}` + : Number(priceValue) + } else if (col.name === 'Revenue') { + const revenueValue = orders * Number(priceValue) + exportedAsset[col.name as string] = baseTokenSymbol + ? `${revenueValue} ${baseTokenSymbol}` + : revenueValue } else { exportedAsset[col.name as string] = value } @@ -90,7 +167,7 @@ export default function HistoryTable({ dataset: exportData, totalSales: sales, totalPublished: items, - totalRevenue: revenue + revenueByToken } const jsonString = JSON.stringify(exportObject, null, 2) @@ -139,7 +216,20 @@ export default function HistoryTable({
- + {revenueEntries.length > 0 && + revenueEntries.map(([symbol, amount]) => ( + + ))} + {revenueEntries.length === 0 && ( + + )}
)} diff --git a/src/components/Asset/AssetActions/Compute/FormComputeAlgorithm.tsx b/src/components/Asset/AssetActions/Compute/FormComputeAlgorithm.tsx index a5984de5d..27434f942 100644 --- a/src/components/Asset/AssetActions/Compute/FormComputeAlgorithm.tsx +++ b/src/components/Asset/AssetActions/Compute/FormComputeAlgorithm.tsx @@ -106,6 +106,8 @@ export default function FormStartComputeAlgo({ }> > }): ReactElement { + // TODO remove this, get from env + const consumeMarketOrderFee = 0 const { address: accountId, isConnected } = useAccount() const { balance } = useBalance() const { lookupVerifierSessionId } = useSsiWallet() @@ -324,6 +326,7 @@ export default function FormStartComputeAlgo({ const rawPrice = details?.validOrderTx ? '0' : details?.price || '0' const price = new Decimal(rawPrice).toDecimalPlaces(MAX_DECIMALS) + // TODO here do like in normal order buy, so get from env the correct amount const fee = new Decimal( formatUnits(consumeMarketOrderFee, tokenInfo?.decimals) ) diff --git a/src/components/Asset/AssetActions/Compute/FormComputeDataset.tsx b/src/components/Asset/AssetActions/Compute/FormComputeDataset.tsx index f2d70f793..3868ac3f3 100644 --- a/src/components/Asset/AssetActions/Compute/FormComputeDataset.tsx +++ b/src/components/Asset/AssetActions/Compute/FormComputeDataset.tsx @@ -120,6 +120,8 @@ export default function FormStartCompute({ isLoadingJobs?: boolean refetchJobs?: () => void }): ReactElement { + // TODO remove and get from env + const consumeMarketOrderFee = 0 const { address: accountId, isConnected } = useAccount() const { balance } = useBalance() const { verifierSessionCache, lookupVerifierSessionId } = useSsiWallet() diff --git a/src/components/Asset/AssetActions/Download/index.module.css b/src/components/Asset/AssetActions/Download/index.module.css index d9859532e..f8f8c56b7 100644 --- a/src/components/Asset/AssetActions/Download/index.module.css +++ b/src/components/Asset/AssetActions/Download/index.module.css @@ -83,3 +83,31 @@ .marginBottom { margin-bottom: var(--sp-8); } + +.totalWrapper { + display: flex; + justify-content: flex-end; + align-items: baseline; + margin-top: 0.5rem; +} + +.amountMain { + font-size: 0.8rem; + font-weight: 600; + color: var(--brand-primary); +} + +.symbolMain { + font-size: 0.6rem; + font-weight: 600; + color: var(--brand-primary); + margin-left: 0.2rem; + margin-right: 0; +} + +.ampersand { + font-size: 0.7rem; + font-weight: 400; + color: var(--brand-primary); + margin: 0 0.1rem; +} diff --git a/src/components/Asset/AssetActions/Download/index.tsx b/src/components/Asset/AssetActions/Download/index.tsx index e90dc58e5..2e5e4f30f 100644 --- a/src/components/Asset/AssetActions/Download/index.tsx +++ b/src/components/Asset/AssetActions/Download/index.tsx @@ -51,7 +51,6 @@ import styles from './index.module.css' import { getDownloadValidationSchema } from './_validation' import { getDefaultValues } from '../ConsumerParameters/FormConsumerParameters' -import { getOceanConfig } from '@utils/ocean' import { getTokenInfo } from '@utils/wallet' import useBalance from '@hooks/useBalance' @@ -95,6 +94,10 @@ export default function Download({ const [isLoading, setIsLoading] = useState(false) const [isPriceLoading, setIsPriceLoading] = useState(false) const [tokenInfo, setTokenInfo] = useState(undefined) + const [tokenInfoProviderFee, setTokenInfoProviderFee] = useState< + TokenInfo | undefined + >(undefined) + const [isFullPriceLoading, setIsFullPriceLoading] = useState( accessDetails.type !== 'free' ) @@ -134,18 +137,35 @@ export default function Download({ useEffect(() => { const fetchTokenDetails = async () => { - if (!chainId || !signer?.provider) return + if (!chainId || !signer?.provider || !accessDetails?.baseToken?.address) + return - const { oceanTokenAddress } = getOceanConfig(chainId) const tokenDetails = await getTokenInfo( - oceanTokenAddress, + accessDetails?.baseToken?.address, signer.provider ) setTokenInfo(tokenDetails) } fetchTokenDetails() - }, [chainId, signer]) + }, [chainId, signer, accessDetails]) + + useEffect(() => { + const fetchTokenDetailsProviderFee = async () => { + if (!chainId || !signer?.provider) return + const tokenDetails = await getTokenInfo( + orderPriceAndFees?.providerFee?.providerFeeToken, + signer.provider + ) + setTokenInfoProviderFee(tokenDetails) + } + if ( + orderPriceAndFees?.providerFee?.providerFeeAmount && + orderPriceAndFees?.providerFee?.providerFeeToken + ) { + fetchTokenDetailsProviderFee() + } + }, [chainId, signer, orderPriceAndFees]) useEffect(() => { const licenseMirrors = @@ -180,7 +200,6 @@ export default function Download({ setIsOwned(accessDetails.isOwned || false) setValidOrderTx(accessDetails.validOrderTx || '') - // get full price and fees async function init() { if (accessDetails.addressOrId === ZERO_ADDRESS) return @@ -201,12 +220,6 @@ export default function Download({ } if (!orderPriceAndFees) init() - - /** - * we listen to the assets' changes to get the most updated price - * based on the asset and the poolData's information. - * Not adding isLoading and getOpcFeeForToken because we set these here. It is a compromise - */ }, [ accessDetails, accountId, @@ -229,14 +242,6 @@ export default function Download({ ) return - /** - * disabled in these cases: - * - if the asset is not purchasable - * - if the user is on the wrong network - * - if user balance is not sufficient - * - if user has no datatokens - * - if user is not whitelisted or blacklisted - */ const isDisabled = !accessDetails.isPurchasable || !isAssetNetwork || @@ -276,7 +281,6 @@ export default function Download({ service, accessDetails, accountId, - // Prefer validated session; if only skip-session exists, use it lookupVerifierSessionId(asset.id, service.id) || lookupVerifierSessionIdSkip(asset.id, service.id), validOrderTx, @@ -388,53 +392,75 @@ export default function Download({ ) } - const AssetAction = ({ asset }: { asset: AssetExtended }) => { - return ( -
- {isUnsupportedPricing ? ( - - ) : null} -
- ) - } - const AssetActionBuy = () => { const { isValid } = useFormikContext() - const finalAmount = new Decimal( + const getActiveConsumeFee = () => { + try { + const envFeeConfig = consumeMarketOrderFee + ? JSON.parse(consumeMarketOrderFee) + : {} + + const currentChainId = asset.credentialSubject.chainId.toString() + const currentTokenAddress = + accessDetails.baseToken.address.toLowerCase() + + const chainFees = envFeeConfig[currentChainId] || [] + const matchingFeeEntry = chainFees.find( + (f: { token: string; amount: string }) => + f.token.toLowerCase() === currentTokenAddress + ) + + return matchingFeeEntry ? matchingFeeEntry.amount : '0' + } catch (e) { + console.error('Error parsing consumeMarketOrderFee config', e) + return '0' + } + } + + const activeFeeWei = getActiveConsumeFee() + // 1. Calculate Base Token (EURC/OCEAN) requirements + const totalBaseTokenNeeded = new Decimal( new Decimal( Number(orderPriceAndFees?.price) || price.value || 0 ).toDecimalPlaces(MAX_DECIMALS) ) .add(new Decimal(orderPriceAndFees?.opcFee || 0)) - .add( - new Decimal( - formatUnits( - orderPriceAndFees?.providerFee?.providerFeeAmount || 0, - tokenInfo?.decimals - ) - ) + .add(new Decimal(formatUnits(activeFeeWei, tokenInfo?.decimals || 18))) + + // 2. Calculate Provider Token requirements + const totalProviderTokenNeeded = new Decimal( + formatUnits( + orderPriceAndFees?.providerFee?.providerFeeAmount || 0, + tokenInfoProviderFee?.decimals || 18 ) - .add(new Decimal(formatUnits(consumeMarketOrderFee, tokenInfo?.decimals))) + ) + + // 3. Determine if tokens are the same + const areTokensSame = + price.tokenSymbol === tokenInfoProviderFee?.symbol || + accessDetails?.baseToken?.address?.toLowerCase() === + orderPriceAndFees?.providerFee?.providerFeeToken?.toLowerCase() + + // 4. Balance check logic + const userBaseBalance = new Decimal( + balance?.approved?.[price.tokenSymbol?.toLowerCase()] || 0 + ) + const userProviderBalance = new Decimal( + balance?.approved?.[tokenInfoProviderFee?.symbol?.toLowerCase()] || 0 + ) + + const sufficient = areTokensSame + ? userBaseBalance.greaterThanOrEqualTo( + totalBaseTokenNeeded.add(totalProviderTokenNeeded) + ) + : userBaseBalance.greaterThanOrEqualTo(totalBaseTokenNeeded) && + userProviderBalance.greaterThanOrEqualTo(totalProviderTokenNeeded) - const firstKey = Object.keys(balance?.approved || {})[0] - const userBalance = new Decimal(balance?.approved?.[firstKey] || 0) - const sufficient = userBalance.greaterThanOrEqualTo(finalAmount) useEffect(() => { if (!orderPriceAndFees) return setIsBalanceSufficient(sufficient) - console.log( - `Balance check: user has ${userBalance.toString()}, total needed ${finalAmount.toString()} => ${sufficient}` - ) - }, [ - dtBalance, - finalAmount.toString(), - orderPriceAndFees, - setIsBalanceSufficient - ]) + }, [sufficient]) if (!orderPriceAndFees) return null @@ -467,20 +493,48 @@ export default function Download({ price={ formatUnits( orderPriceAndFees?.providerFee?.providerFeeAmount, - tokenInfo?.decimals + tokenInfoProviderFee?.decimals ) || '0' } - symbol={price.tokenSymbol} + symbol={tokenInfoProviderFee?.symbol} type="PROVIDER FEE" /> - + +
+ {areTokensSame ? ( + <> + + {totalBaseTokenNeeded + .add(totalProviderTokenNeeded) + .toString()} + + + {price.tokenSymbol} + + + ) : ( + <> + + {totalBaseTokenNeeded.toString()} + + + {price.tokenSymbol} + + & + + {totalProviderTokenNeeded.toString()} + + + {tokenInfoProviderFee?.symbol} + + + )} +
)} @@ -604,11 +658,6 @@ export default function Download({ appConfig.ssiEnabled && hasSession ? styles.tighterStack : '' }`} > - {isUnsupportedPricing && ( -
- -
- )} {!isOwner && (isFullPriceLoading ? ( <> @@ -677,20 +726,6 @@ export default function Download({ ))} - {/* {justBought && ( -
- -
- )} */} - {/* {asset.credentialSubject?.metadata?.type === 'algorithm' && ( - - )} */} ) })()} diff --git a/src/components/Asset/AssetContent/index.tsx b/src/components/Asset/AssetContent/index.tsx index df9f72792..51aa8fc9e 100644 --- a/src/components/Asset/AssetContent/index.tsx +++ b/src/components/Asset/AssetContent/index.tsx @@ -23,6 +23,7 @@ import { LanguageValueObject } from 'src/@types/ddo/LanguageValueObject' import MetaInfo from './MetaMain/MetaInfo' import EditIcon from '@images/edit.svg' import ComputeJobs from '@components/@shared/ComputeJobs' +import Link from 'next/link' export default function AssetContent({ asset @@ -303,10 +304,13 @@ export default function AssetContent({ )} {isOwner && isAssetNetwork && isConnected && (
- + Edit Asset - + {/*
() const [defaultPolicies, setDefaultPolicies] = useState([]) + const baseTokenOptions = useMemo(() => { + return approvedBaseTokens.map((token) => token.symbol) + }, [approvedBaseTokens]) const accessTypeOptionsTitles = getFieldContent('access', data).options @@ -38,6 +43,11 @@ export default function FormAddService({ setFieldValue('direction', 'ltr') } }, [setFieldValue, values.language]) + useEffect(() => { + if (!values.baseToken && approvedBaseTokens.length > 0) { + setFieldValue('baseToken', approvedBaseTokens[0].address) + } + }, [approvedBaseTokens, setFieldValue, values.baseToken]) const languageOptions = useMemo(() => { return supportedLanguages @@ -152,6 +162,27 @@ export default function FormAddService({ min={0} step={0.01} /> + token.address === values.baseToken + )?.symbol || '' + } + onChange={(e) => { + const selectedToken = approvedBaseTokens.find( + (token) => token.symbol === e.target.value + ) + + if (selectedToken) { + setFieldValue('baseToken', selectedToken.address) + } + }} + /> () const chainId = useChainId() - const { escrowAvailableFunds } = useProfile() + const { escrowFundsByToken } = useProfile() const walletClient = useEthersSigner() const [mode, setMode] = useState<'free' | 'paid'>(() => { @@ -200,6 +200,19 @@ export default function ConfigureEnvironment({ const [symbolMap, setSymbolMap] = useState<{ [address: string]: string }>({}) + const escrowAvailableFunds = useMemo(() => { + const env = values.computeEnv + if (!env) return 0 + const currentChainId = chainId?.toString() || '11155111' + const fee = env.fees?.[currentChainId]?.[0] + const tokenAddress = fee?.feeToken + if (!tokenAddress) return 0 + const tokenSymbol = symbolMap[tokenAddress] || '...' + if (tokenSymbol === '...') return 0 + const escrowFunds = escrowFundsByToken[tokenSymbol] + return escrowFunds ? parseFloat(escrowFunds.available) : 0 + }, [values.computeEnv, chainId, symbolMap, escrowFundsByToken]) + const getEnvResourceValues = useCallback( (isFree: boolean = true) => { const env = values.computeEnv @@ -436,7 +449,7 @@ export default function ConfigureEnvironment({ useEffect(() => { if (mode === 'paid') { const jobPrice = calculatePrice() - const availableEscrow = parseFloat(escrowAvailableFunds.toString() || '0') + const availableEscrow = escrowAvailableFunds const actualPaymentAmount = Math.max(0, jobPrice - availableEscrow) @@ -483,9 +496,7 @@ export default function ConfigureEnvironment({ } currentPrice = totalPrice * currentValues.jobDuration - const availableEscrow = parseFloat( - escrowAvailableFunds.toString() || '0' - ) + const availableEscrow = escrowAvailableFunds actualPaymentAmount = Math.max(0, currentPrice - availableEscrow) escrowCoveredAmount = Math.min(availableEscrow, currentPrice) } diff --git a/src/components/ComputeWizard/Review/PricingRow.tsx b/src/components/ComputeWizard/Review/PricingRow.tsx index 7b4aa8f11..d7a869a0c 100644 --- a/src/components/ComputeWizard/Review/PricingRow.tsx +++ b/src/components/ComputeWizard/Review/PricingRow.tsx @@ -10,6 +10,7 @@ import styles from './index.module.css' import Alert from '@shared/atoms/Alert' import Tooltip from '@shared/atoms/Tooltip' import { getFeeTooltip } from '@utils/feeTooltips' +import Link from 'next/link' interface PricingRowProps { itemName: string @@ -130,14 +131,14 @@ export default function PricingRow({ {label} {assetId && ( - - + )} )} diff --git a/src/components/ComputeWizard/Review/index.tsx b/src/components/ComputeWizard/Review/index.tsx index 1340fb569..351dccef0 100644 --- a/src/components/ComputeWizard/Review/index.tsx +++ b/src/components/ComputeWizard/Review/index.tsx @@ -148,6 +148,8 @@ export default function Review({ setSelectedDatasetAsset, tokenInfo }: ReviewProps): ReactElement { + // TODO remove and get from env + const consumeMarketOrderFee = 0 const isDatasetFlow = flow === 'dataset' const { address: accountId } = useAccount() const { balance } = useBalance() diff --git a/src/components/ComputeWizard/SelectServicesStep/index.tsx b/src/components/ComputeWizard/SelectServicesStep/index.tsx index 6a47988cf..dec8002ec 100644 --- a/src/components/ComputeWizard/SelectServicesStep/index.tsx +++ b/src/components/ComputeWizard/SelectServicesStep/index.tsx @@ -11,6 +11,7 @@ import { getDummySigner, getTokenInfo } from '@utils/wallet' import LoaderOverlay from '../LoaderOverlay' import External from '@images/external.svg' import { CopyToClipboard } from '@shared/CopyToClipboard' +import Link from 'next/link' type DatasetService = { id?: string @@ -271,7 +272,7 @@ function Row({ onClick={() => (isDatasetFlow ? undefined : onToggleExpand(asset.id))} > {asset.name} - e.stopPropagation()} > - +
diff --git a/src/components/Footer/Links.tsx b/src/components/Footer/Links.tsx index 026c05dda..87fb37608 100644 --- a/src/components/Footer/Links.tsx +++ b/src/components/Footer/Links.tsx @@ -3,13 +3,12 @@ import { useUserPreferences } from '@context/UserPreferences' import { useGdprMetadata } from '@hooks/useGdprMetadata' import styles from './Links.module.css' import { useMarketMetadata } from '@context/MarketMetadata' -import { useRouter } from 'next/router' +import Link from 'next/link' export default function Links(): ReactElement { const { appConfig, siteContent } = useMarketMetadata() const { setShowPPC, privacyPolicySlug } = useUserPreferences() const cookies = useGdprMetadata() - const router = useRouter() const { content, privacyTitle } = siteContent.footer @@ -24,48 +23,46 @@ export default function Links(): ReactElement { {section.links.map((e, i) => { if (e.name === 'Cookie Settings') { return ( - { - event.preventDefault() + href="/cookie-settings" + onClick={() => { setShowPPC(true) - router.push('/cookie-settings') }} > {cookies.optionalCookies ? 'Cookie Settings' : 'Cookies'} - + ) } if (e.name === 'Imprint' || e.name === 'Privacy') { return ( - { - event.preventDefault() - router.push(e.link) - }} - > + {e.name} - + ) } - return ( + const isInternalLink = e.link.startsWith('/') + return isInternalLink ? ( + + {e.name === 'Log' ? ( + <> + Log +  β†—{' '} + + ) : ( + e.name + )} + + ) : ( {e.name === 'Log' ? ( <> @@ -85,59 +82,38 @@ export default function Links(): ReactElement {

{privacyTitle}

- { - event.preventDefault() - router.push('/imprint') - }} - > + Imprint - - + { - event.preventDefault() - router.push(`${privacyPolicySlug}#terms-and-conditions`) - }} + href={`${privacyPolicySlug}#terms-and-conditions`} > Terms & Conditions - - + { - event.preventDefault() - router.push(`${privacyPolicySlug}#privacy-policy`) - }} + href={`${privacyPolicySlug}#privacy-policy`} > Privacy Policy - - + { - event.preventDefault() - router.push(`${privacyPolicySlug}#data-portal-usage-agreement`) - }} + href={`${privacyPolicySlug}#data-portal-usage-agreement`} > Data Portal Usage Agreement - + {appConfig.privacyPreferenceCenter === 'true' && ( - { - event.preventDefault() + href="/cookie-settings" + onClick={() => { setShowPPC(true) - router.push('/cookie-settings') }} > {cookies.optionalCookies ? 'Cookie Settings' : 'Cookies'} - + )}
diff --git a/src/components/Profile/Header/EscrowWithdrawModal.module.css b/src/components/Profile/Header/EscrowWithdrawModal.module.css index 4fc507ac7..ec0853001 100644 --- a/src/components/Profile/Header/EscrowWithdrawModal.module.css +++ b/src/components/Profile/Header/EscrowWithdrawModal.module.css @@ -97,3 +97,12 @@ font-weight: 600; margin-top: 6px; } + +.tokenSelect { + width: 100%; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid #d1d1d1; + font-size: 14px; + margin-bottom: 10px; +} diff --git a/src/components/Profile/Header/EscrowWithdrawModal.tsx b/src/components/Profile/Header/EscrowWithdrawModal.tsx index f5a6c9190..8d922ae9b 100644 --- a/src/components/Profile/Header/EscrowWithdrawModal.tsx +++ b/src/components/Profile/Header/EscrowWithdrawModal.tsx @@ -5,31 +5,46 @@ import { EscrowContract } from '@oceanprotocol/lib' import { useChainId } from 'wagmi' import { getOceanConfig } from '@utils/ocean' import { useProfile } from '@context/Profile' -import { Signer } from 'ethers' +import { Signer, parseUnits } from 'ethers' import { useEthersSigner } from '@hooks/useEthersSigner' +interface EscrowFunds { + available: string + locked: string + symbol: string + address: string + decimals: number +} + export default function EscrowWithdrawModal({ escrowFunds, onClose +}: { + escrowFunds: EscrowFunds + onClose: () => void }): ReactElement { - const { refreshEscrowFunds } = useProfile() + const { refreshEscrowFunds, escrowFundsByToken } = useProfile() const walletClient = useEthersSigner() const chainId = useChainId() const [amount, setAmount] = useState('') const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) + const [selectedToken, setSelectedToken] = useState(escrowFunds.symbol) + + const availableTokens = Object.keys(escrowFundsByToken || {}) + const selectedEscrowFunds = escrowFundsByToken?.[selectedToken] || escrowFunds function handleInputChange(e) { const val = e.target.value setAmount(val) setError('') - if (Number(val) > Number(escrowFunds)) { + if (Number(val) > Number(selectedEscrowFunds.available)) { setError('Amount can’t be greater than your escrow funds.') } } function handleMaxClick() { - setAmount(escrowFunds.toString()) + setAmount(selectedEscrowFunds.available) setError('') } @@ -38,7 +53,7 @@ export default function EscrowWithdrawModal({ setError('Please enter a valid withdrawal amount.') return } - if (Number(amount) > Number(escrowFunds)) { + if (Number(amount) > Number(selectedEscrowFunds.available)) { setError('Amount can’t be greater than your escrow funds.') return } @@ -50,10 +65,15 @@ export default function EscrowWithdrawModal({ setIsLoading(true) const signer = walletClient as unknown as Signer try { - const { oceanTokenAddress, escrowAddress } = getOceanConfig(chainId) + const { escrowAddress } = getOceanConfig(chainId) const escrow = new EscrowContract(escrowAddress, signer, chainId) - await escrow.withdraw([oceanTokenAddress], [amount]) + const amountInWei = parseUnits( + amount, + selectedEscrowFunds.decimals + ).toString() + + await escrow.withdraw([selectedEscrowFunds.address], [amountInWei]) if (refreshEscrowFunds) await refreshEscrowFunds() onClose() } catch (err) { @@ -67,8 +87,36 @@ export default function EscrowWithdrawModal({

Withdraw Escrow Funds

+ {availableTokens.length > 1 && ( +
+ + +
+ )}
- Available: {escrowFunds} + Available:{' '} + + {selectedEscrowFunds.available} {selectedEscrowFunds.symbol} +
Number(escrowFunds) + Number(amount) > Number(selectedEscrowFunds.available) } > {isLoading ? 'Withdrawing...' : 'Withdraw'} diff --git a/src/components/Profile/Header/Stats.module.css b/src/components/Profile/Header/Stats.module.css index b3ab3b03a..164060f7c 100644 --- a/src/components/Profile/Header/Stats.module.css +++ b/src/components/Profile/Header/Stats.module.css @@ -25,3 +25,8 @@ font-weight: var(--font-weight-base); font-size: var(--font-size-small); } + +.tokenSelect { + width: 100%; + margin-bottom: var(--spacer); +} diff --git a/src/components/Profile/Header/Stats.tsx b/src/components/Profile/Header/Stats.tsx index e5b6489f7..a70897466 100644 --- a/src/components/Profile/Header/Stats.tsx +++ b/src/components/Profile/Header/Stats.tsx @@ -1,43 +1,34 @@ -import { ReactElement, useEffect, useState } from 'react' +import { ReactElement, useState } from 'react' import NumberUnit from './NumberUnit' import styles from './Stats.module.css' import { useProfile } from '@context/Profile' -import EscrowWithdrawModal from './EscrowWithdrawModal' // Import the modal -import { useChainId, usePublicClient } from 'wagmi' -import { getOceanConfig } from '@utils/ocean' -import { getTokenInfo } from '@utils/wallet' -import { JsonRpcProvider } from 'ethers' +import EscrowWithdrawModal from './EscrowWithdrawModal' -export default function Stats(): ReactElement { +export default function Stats({ + selectedToken +}: { + selectedToken?: string +}): ReactElement { const { assetsTotal, sales, downloadsTotal, revenue, - escrowAvailableFunds, - escrowLockedFunds, + escrowFundsByToken, ownAccount } = useProfile() const [showModal, setShowModal] = useState(false) - const chainId = useChainId() - const [tokenSymbol, setTokenSymbol] = useState('OCEAN') - const publicClient = usePublicClient() - const rpcUrl = getOceanConfig(chainId)?.nodeUri - const ethersProvider = - publicClient && rpcUrl ? new JsonRpcProvider(rpcUrl) : undefined - - useEffect(() => { - async function fetchSymbol() { - if (!chainId || !ethersProvider) return - const { oceanTokenAddress } = getOceanConfig(chainId) - if (!oceanTokenAddress) return - // Pass Ethers v6 Provider to getTokenInfo - const tokenDetails = await getTokenInfo(oceanTokenAddress, ethersProvider) - setTokenSymbol(tokenDetails.symbol || 'OCEAN') - } - fetchSymbol() - }, [chainId, ethersProvider]) + const activeToken = + selectedToken || + Object.keys(revenue || {})[0] || + Object.keys(escrowFundsByToken || {})[0] || + '' + const selectedRevenue = revenue?.[activeToken] || 0 + const selectedEscrow = escrowFundsByToken?.[activeToken] || null + const selectedEscrowAvailable = selectedEscrow?.available || '0' + const selectedEscrowLocked = selectedEscrow?.locked || '0' + const hasAvailable = Number(selectedEscrowAvailable) > 0 return (
@@ -47,27 +38,39 @@ export default function Stats(): ReactElement { /> - - {ownAccount && ( + {activeToken && ( + + )} + {ownAccount && activeToken && ( <> -
setShowModal(true)} style={{ cursor: 'pointer' }}> +
setShowModal(true) : undefined} + style={{ cursor: hasAvailable ? 'pointer' : 'default' }} + >
)} - {showModal && ( + {showModal && selectedEscrow && ( setShowModal(false)} /> )} diff --git a/src/components/Profile/Header/TokenSelector.tsx b/src/components/Profile/Header/TokenSelector.tsx new file mode 100644 index 000000000..55ef39b0b --- /dev/null +++ b/src/components/Profile/Header/TokenSelector.tsx @@ -0,0 +1,72 @@ +import { ReactElement, useEffect, useMemo } from 'react' +import InputElement from '@components/@shared/FormInput/InputElement' +import styles from './index.module.css' +import { useProfile } from '@context/Profile' +import { useMarketMetadata } from '@context/MarketMetadata' + +export default function TokenSelector({ + selectedToken, + onTokenChange +}: { + selectedToken?: string + onTokenChange: (token: string) => void +}): ReactElement { + const { revenue, escrowFundsByToken } = useProfile() + const { approvedBaseTokens } = useMarketMetadata() + + const availableTokens = useMemo(() => { + const tokens = new Set() + approvedBaseTokens?.forEach((token) => tokens.add(token.symbol)) + Object.keys(revenue || {}).forEach((symbol) => tokens.add(symbol)) + Object.keys(escrowFundsByToken || {}).forEach((symbol) => + tokens.add(symbol) + ) + const tokenArray = Array.from(tokens) + // Sort with OCEAN first, then alphabetically + return tokenArray.sort((a, b) => { + if (a === 'OCEAN') return -1 + if (b === 'OCEAN') return 1 + return a.localeCompare(b) + }) + }, [approvedBaseTokens, revenue, escrowFundsByToken]) + + useEffect(() => { + const firstToken = availableTokens[0] + if ( + firstToken && + (!selectedToken || !availableTokens.includes(selectedToken)) + ) { + onTokenChange(firstToken) + } + }, [availableTokens, selectedToken, onTokenChange]) + + if (availableTokens.length === 0) { + return ( +
+
+ No tokens detected for this profile. +
+
+ ) + } + + const value = + (selectedToken && availableTokens.includes(selectedToken) + ? selectedToken + : availableTokens[0]) || '' + + return ( +
+
Select a token
+ onTokenChange((e.target as HTMLSelectElement).value)} + className={styles.tokenSelect} + /> +
+ ) +} diff --git a/src/components/Profile/Header/index.module.css b/src/components/Profile/Header/index.module.css index 1702470f4..7f3f858de 100644 --- a/src/components/Profile/Header/index.module.css +++ b/src/components/Profile/Header/index.module.css @@ -68,3 +68,38 @@ width: 1480px; } } + +.selectorColumn { + margin-top: var(--spacer); + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + gap: calc(var(--spacer) / 4); +} + +.tokenSelect { + width: auto; + min-width: 0; + align-self: flex-end; + display: inline-flex; +} + +.tokenSelect select { + width: auto !important; + min-width: 8rem; + max-width: 12rem; +} + +.tokenPlaceholder { + color: var(--color-secondary); + font-size: var(--font-size-small); +} + +.selectorLabel { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-label); + color: var(--font-color-label); + line-height: 1.2; + font-family: var(--publish-font-family); +} diff --git a/src/components/Profile/Header/index.tsx b/src/components/Profile/Header/index.tsx index dd3a612fc..14ba4f758 100644 --- a/src/components/Profile/Header/index.tsx +++ b/src/components/Profile/Header/index.tsx @@ -1,19 +1,26 @@ -import { ReactElement } from 'react' +import { ReactElement, useState } from 'react' import Stats from './Stats' import Account from './Account' import styles from './index.module.css' +import TokenSelector from './TokenSelector' export default function AccountHeader({ accountId }: { accountId: string }): ReactElement { + const [selectedToken, setSelectedToken] = useState('') + return (
- +
+
) } diff --git a/src/components/Profile/History/ComputeJobs/Details.tsx b/src/components/Profile/History/ComputeJobs/Details.tsx index 642a99294..0605b3431 100644 --- a/src/components/Profile/History/ComputeJobs/Details.tsx +++ b/src/components/Profile/History/ComputeJobs/Details.tsx @@ -13,6 +13,7 @@ import { Asset as AssetType } from 'src/@types/Asset' import External from '@images/external.svg' import CloseIcon from '@images/closeIcon.svg' import useIsMobile from '@hooks/useIsMobile' +import Link from 'next/link' const extractString = ( value: string | { '@value': string } | undefined @@ -42,14 +43,14 @@ function Asset({ {title} - - +
diff --git a/src/components/Profile/History/ComputeJobs/index.tsx b/src/components/Profile/History/ComputeJobs/index.tsx index 6004f639a..c8a06b167 100644 --- a/src/components/Profile/History/ComputeJobs/index.tsx +++ b/src/components/Profile/History/ComputeJobs/index.tsx @@ -9,6 +9,7 @@ import NetworkName from '@shared/NetworkName' import styles from './index.module.css' import AssetListTitle from '@shared/AssetListTitle' import { useAccount } from 'wagmi' +import { SkeletonTable } from '../HistoryData' // import { getAsset } from '@utils/aquarius' // import { useCancelToken } from '@hooks/useCancelToken' // import { getPdf } from '@utils/invoice/createInvoice' @@ -294,15 +295,19 @@ export default function ComputeJobs({ Refresh )} - await refetchJobs(true)} - /> + {isLoading ? ( + + ) : ( +
await refetchJobs(true)} + /> + )} ) : (
Please connect your wallet.
diff --git a/src/components/Profile/History/HistoryData.module.css b/src/components/Profile/History/HistoryData.module.css index 2ad317a1b..bb4bb4529 100644 --- a/src/components/Profile/History/HistoryData.module.css +++ b/src/components/Profile/History/HistoryData.module.css @@ -26,3 +26,72 @@ font-size: var(--font-size-small); font-style: italic; } + +.skeletonWrapper { + width: 100%; + height: 100%; +} + +.skeletonWrapper > div { + width: 100%; + height: 100%; +} + +.skeletonHeaderRow, +.skeletonRow { + display: grid; + grid-template-columns: 2.2fr 1.2fr 1fr 1.2fr 0.9fr 1.1fr; + gap: calc(var(--spacer) / 6); + margin-bottom: calc(var(--spacer) / 12); + padding: 5px; +} + +.skeletonCell, +.skeletonHeader { + height: 3rem; + border-radius: 4px; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.06) 25%, + rgba(0, 0, 0, 0.12) 37%, + rgba(0, 0, 0, 0.06) 63% + ), + var(--background-highlight); + background-size: 400% 100%; + animation: shimmer 1.2s ease-in-out infinite; +} + +.skeletonHeader { + height: 3.8rem; +} + +@keyframes shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +.networkWrapper { + display: inline-block; + vertical-align: middle; + max-width: 100%; +} + +.networkWrapper > span { + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; +} + +.networkWrapper > span > span:last-child { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex-shrink: 1; +} diff --git a/src/components/Profile/History/HistoryData.tsx b/src/components/Profile/History/HistoryData.tsx index 69c9492ff..2cd9876f5 100644 --- a/src/components/Profile/History/HistoryData.tsx +++ b/src/components/Profile/History/HistoryData.tsx @@ -1,73 +1,150 @@ import { LoggerInstance } from '@oceanprotocol/lib' -import { ReactElement, useEffect, useState } from 'react' +import axios, { CancelToken } from 'axios' +import { + ReactElement, + useCallback, + useEffect, + useMemo, + useState, + startTransition +} from 'react' import { getPublishedAssets, getUserSalesAndRevenue } from '@utils/aquarius' import { useUserPreferences } from '@context/UserPreferences' import styles from './HistoryData.module.css' import { useCancelToken } from '@hooks/useCancelToken' import Filter from '@components/Search/Filter' import { useMarketMetadata } from '@context/MarketMetadata' -import { CancelToken } from 'axios' import { useProfile } from '@context/Profile' import { useFilter, Filters } from '@context/Filter' -import { useDebouncedCallback } from 'use-debounce' import { TableOceanColumn } from '@shared/atoms/Table' import Time from '@shared/atoms/Time' import AssetTitle from '@shared/AssetListTitle' import NetworkName from '@shared/NetworkName' import HistoryTable from '@components/@shared/atoms/Table/HistoryTable' -import { getAccessDetails } from '@utils/accessDetailsAndPricing' +import useNetworkMetadata, { + getNetworkDataById, + getNetworkDisplayName +} from '@hooks/useNetworkMetadata' import { AssetExtended } from 'src/@types/AssetExtended' -import { Asset } from 'src/@types/Asset' - -const columns: TableOceanColumn[] = [ - { - name: 'Dataset', - selector: (asset) => - }, - { - name: 'Network', - selector: (asset) => ( - - ) - }, - { - name: 'Datatoken', - selector: (asset) => asset.indexedMetadata.stats[0]?.symbol - }, - { - name: 'Time', - selector: (asset) => { - const unixTime = Math.floor( - new Date(asset.credentialSubject.metadata.created).getTime() - ).toString() - return