diff --git a/public/dapps/cloak.svg b/public/dapps/cloak.svg new file mode 100644 index 00000000..4a80590c --- /dev/null +++ b/public/dapps/cloak.svg @@ -0,0 +1,4 @@ + + + C + diff --git a/public/dapps/explorer.svg b/public/dapps/explorer.svg new file mode 100644 index 00000000..74f2f3f4 --- /dev/null +++ b/public/dapps/explorer.svg @@ -0,0 +1,4 @@ + + + E + diff --git a/public/dapps/qudo.svg b/public/dapps/qudo.svg new file mode 100644 index 00000000..15e34f11 --- /dev/null +++ b/public/dapps/qudo.svg @@ -0,0 +1,4 @@ + + + Q + diff --git a/src/antelope/chains/EVMChainSettings.ts b/src/antelope/chains/EVMChainSettings.ts index 3474b15f..8ec86b29 100644 --- a/src/antelope/chains/EVMChainSettings.ts +++ b/src/antelope/chains/EVMChainSettings.ts @@ -1,5 +1,6 @@ import { RpcEndpoint } from 'universal-authenticator-library'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, Method } from 'axios'; +import { BlockscoutAdapter } from 'src/antelope/chains/utils/BlockscoutAdapter'; import { AbiSignature, ChainSettings, @@ -71,6 +72,9 @@ export default abstract class EVMChainSettings implements ChainSettings { // External indexer API support protected indexer: AxiosInstance = axios.create({ baseURL: this.getIndexerApiEndpoint() }); + // Blockscout adapter for API translation + protected blockscoutAdapter: BlockscoutAdapter = new BlockscoutAdapter(this.indexer); + // indexer health check promise protected _indexerHealthState: { promise: Promise | null; @@ -205,10 +209,10 @@ export default abstract class EVMChainSettings implements ChainSettings { Promise.resolve(this.hasIndexerSupport()) .then(hasIndexerSupport => hasIndexerSupport ? - this.indexer.get('/v1/health') : - Promise.resolve({ data: this.deathHealthResponse } as AxiosResponse), + this.blockscoutAdapter.getHealth() : + Promise.resolve(this.deathHealthResponse), ) - .then(response => response.data as unknown as IndexerHealthResponse); + .then(response => response as unknown as IndexerHealthResponse); // initial state this._indexerHealthState = { @@ -287,6 +291,11 @@ export default abstract class EVMChainSettings implements ChainSettings { abstract getChainId(): string; abstract getDisplay(): string; abstract getHyperionEndpoint(): string; + + // Override to use a dedicated EVM JSON-RPC endpoint instead of hyperion/evm + getEvmRpcEndpoint(): string | null { + return null; + } abstract getRPCEndpoint(): RpcEndpoint; abstract getApiEndpoint(): string; abstract getPriceData(): Promise; @@ -314,17 +323,10 @@ export default abstract class EVMChainSettings implements ChainSettings { return []; } return Promise.all([ - this.indexer.get(`v1/account/${account}/balances`, { - params: { - limit: 50, - offset: 0, - includePagination: false, - }, - }), + this.blockscoutAdapter.getBalances(account, { limit: 50, offset: 0 }), this.getUsdPrice(), - ]).then(async ([response, systemTokenPrice]) => { - // parse to IndexerAccountBalances - const balances = response.data as IndexerAccountBalances; + ]).then(async ([balances, systemTokenPrice]) => { + // balances is already in IndexerAccountBalances format from adapter const tokenList = await this.getTokenList(); const tokens: TokenBalance[] = []; @@ -348,9 +350,21 @@ export default abstract class EVMChainSettings implements ChainSettings { console.error('Error parsing calldata', `"${callDataStr}"`, e); } - if (token) { + // Use token from list, or create one from Blockscout data + const resolvedToken = token ?? new TokenClass({ + name: contractData.name || 'Unknown', + symbol: contractData.symbol || '???', + network: this.getNetwork(), + decimals: contractData.decimals || 18, + address: result.contract, + logoURI: (contractData as { logoURI?: string }).logoURI || undefined, + isNative: false, + isSystem: false, + } as TokenSourceInfo); + + if (resolvedToken) { const balance = ethers.BigNumber.from(result.balance); - const tokenBalance = new TokenBalance(token, balance); + const tokenBalance = new TokenBalance(resolvedToken, balance); tokens.push(tokenBalance); const priceIsCurrent = !!contractData.calldata?.marketdata_updated && @@ -361,7 +375,7 @@ export default abstract class EVMChainSettings implements ChainSettings { const price = (+(contractData.calldata.price ?? 0)).toFixed(12); const marketInfo = { ...contractData.calldata, price } as MarketSourceInfo; const marketData = new TokenMarketData(marketInfo); - token.market = marketData; + resolvedToken.market = marketData; } } } @@ -379,12 +393,12 @@ export default abstract class EVMChainSettings implements ChainSettings { console.error('Error fetching NFTs, Indexer API not supported for this chain:', this.getNetwork()); return []; } - const url = `v1/contract/${collection}/nfts`; - const response = (await this.indexer.get(url, { params })).data as IndexerCollectionNftsResponse; + // Use Blockscout adapter for NFT collection data + const response = await this.blockscoutAdapter.getCollectionNfts(collection, params) as unknown as IndexerCollectionNftsResponse; // the indexer NFT data which will be used to construct NFTs const shapedIndexerNftData: GenericIndexerNft[] = response.results.map(nftResponse => ({ - metadata: JSON.parse(nftResponse.metadata), + metadata: nftResponse.metadata ? (typeof nftResponse.metadata === 'string' ? JSON.parse(nftResponse.metadata) : nftResponse.metadata) : {}, tokenId: nftResponse.tokenId, contract: nftResponse.contract, updated: nftResponse.updated, @@ -414,13 +428,9 @@ export default abstract class EVMChainSettings implements ChainSettings { console.error('Error fetching NFTs, Indexer API not supported for this chain:', this.getNetwork()); return []; } - const url = `v1/account/${account}/nfts`; - const isErc1155 = params.type === 'ERC1155'; - const paramsWithSupply = { - ...params, - includeTokenIdSupply: isErc1155, // only ERC1155 supports supply - }; - const response = (await this.indexer.get(url, { params: paramsWithSupply })).data as IndexerAccountNftsResponse; + // Use Blockscout adapter for account NFTs + const response = await this.blockscoutAdapter.getAccountNfts(account, params) as unknown as IndexerAccountNftsResponse; + console.log('[NFT DEBUG] getAccountNfts response:', JSON.stringify({ resultCount: response.results?.length, contractKeys: Object.keys(response.contracts || {}), firstResult: response.results?.[0] })); // If the contract does not have the list of supported interfaces, we provide one Object.values(response.contracts).forEach((contract) => { @@ -431,7 +441,7 @@ export default abstract class EVMChainSettings implements ChainSettings { // the indexer NFT data which will be used to construct NFTs const shapedIndexerNftData: GenericIndexerNft[] = response.results.map(nftResponse => ({ - metadata: JSON.parse(nftResponse.metadata), + metadata: nftResponse.metadata ? (typeof nftResponse.metadata === 'string' ? JSON.parse(nftResponse.metadata) : nftResponse.metadata) : {}, tokenId: nftResponse.tokenId, contract: nftResponse.contract, updated: nftResponse.updated, @@ -442,9 +452,14 @@ export default abstract class EVMChainSettings implements ChainSettings { })); this.processNftContractsCalldata(response.contracts); + console.log('[NFT DEBUG] shapedIndexerNftData:', JSON.stringify(shapedIndexerNftData)); + console.log('[NFT DEBUG] contracts after calldata processing:', JSON.stringify(response.contracts)); const shapedNftData = this.shapeNftRawData(shapedIndexerNftData, response.contracts); + console.log('[NFT DEBUG] shapedNftData count:', shapedNftData.length); - return this.processNftRawData(shapedNftData); + const result = await this.processNftRawData(shapedNftData); + console.log('[NFT DEBUG] processNftRawData result count:', result.length); + return result; } // ensure NFT contract calldata is an object @@ -590,13 +605,10 @@ export default abstract class EVMChainSettings implements ChainSettings { } const params: AxiosRequestConfig = aux as AxiosRequestConfig; - const url = `v1/address/${address}/transactions`; - // The following performs a GET request to the indexer endpoint. - // Then it pipes the response to the IndexerAccountTransactionsResponse type. - // Notice that the promise is not awaited, but returned instead immediately. - return this.indexer.get(url, { params }) - .then(response => response.data as IndexerAccountTransactionsResponse); + // Use Blockscout adapter for transactions + return this.blockscoutAdapter.getTransactions(address, params as { limit?: number; offset?: number }) + .then(response => response as unknown as IndexerAccountTransactionsResponse); } async getEvmNftTransfers({ @@ -638,19 +650,10 @@ export default abstract class EVMChainSettings implements ChainSettings { } const params = aux as AxiosRequestConfig; - const url = `v1/account/${account}/transfers`; - - return this.indexer.get(url, { params }) - .then(response => response.data as IndexerAccountTransfersResponse) - .then((data) => { - // set supportedInterfaces property if undefined in the response - Object.values(data.contracts).forEach((contract) => { - if (contract.supportedInterfaces === null && type !== undefined) { - contract.supportedInterfaces = [type]; - } - }); - return data; - }); + + // Use Blockscout adapter for token transfers + return this.blockscoutAdapter.getTokenTransfers(account, { type, limit, offset }) + .then(data => data as unknown as IndexerAccountTransfersResponse); } async getTokenList(): Promise { @@ -701,6 +704,11 @@ export default abstract class EVMChainSettings implements ChainSettings { method, params, }; + const evmRpcEndpoint = this.getEvmRpcEndpoint(); + if (evmRpcEndpoint) { + return axios.post(evmRpcEndpoint, rpcPayload) + .then(response => response.data as T); + } return this.hyperion.post('/evm', rpcPayload) .then(response => response.data as T); } @@ -709,6 +717,18 @@ export default abstract class EVMChainSettings implements ChainSettings { return this.indexer; } + getBlockscoutAdapter() { + return this.blockscoutAdapter; + } + + /** + * Get token holders for a specific contract/account + * Used by allowances store for balance lookups + */ + async getTokenHolders(contractAddress: string, params?: { account?: string; limit?: number; token_id?: string }) { + return this.blockscoutAdapter.getTokenHolders(contractAddress, params); + } + async getGasPrice(): Promise { return this.doRPC<{result:string}>({ method: 'eth_gasPrice' as Method, @@ -744,33 +764,213 @@ export default abstract class EVMChainSettings implements ChainSettings { // allowances + /** + * Fetch ERC-20 approvals by scanning the user's transaction history from Blockscout + * for approve() calls (0x095ea7b3), then verifying current on-chain allowance. + * This avoids eth_getLogs block range limits. + */ async fetchErc20Allowances(account: string, filter: IndexerAllowanceFilter): Promise { - const params = { - ...filter, - type: 'erc20', - all: true, - }; - const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); - return response.data as IndexerAllowanceResponseErc20; + const trace = createTraceFunction('EVMChainSettings'); + trace('fetchErc20Allowances', account); + + const APPROVE_SELECTOR = '0x095ea7b3'; + const ALLOWANCE_SELECTOR = '0xdd62ed3e'; + + try { + // Step 1: Get all transactions from Blockscout and find approve() calls + const approvalMap = new Map(); + let nextPageParams: string | null = null; + let pageCount = 0; + const maxPages = 10; // limit pagination to avoid excessive requests + + do { + const txUrl: string = nextPageParams + ? `/api/v2/addresses/${account}/transactions?${nextPageParams}` + : `/api/v2/addresses/${account}/transactions`; + + const txResponse: AxiosResponse = await this.indexer.get(txUrl); + const items: Array<{ raw_input?: string; method?: string; to?: { hash?: string } }> = txResponse.data.items || []; + const nextPage: Record | null = txResponse.data.next_page_params; + + for (const tx of items) { + const rawInput = tx.raw_input || ''; + const method = tx.method || ''; + + // Check for approve(address spender, uint256 amount) calls + if (method === APPROVE_SELECTOR || (rawInput && rawInput.startsWith(APPROVE_SELECTOR))) { + if (rawInput && rawInput.length >= 138) { + const tokenContract = (tx.to?.hash || '').toLowerCase(); + const spender = '0x' + rawInput.substring(34, 74).toLowerCase(); + const key = `${tokenContract}:${spender}`; + + if (tokenContract && !approvalMap.has(key)) { + approvalMap.set(key, { contract: tokenContract, spender }); + } + } + } + } + + // Build next page query string + if (nextPage && typeof nextPage === 'object') { + nextPageParams = Object.entries(nextPage) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + } else { + nextPageParams = null; + } + pageCount++; + } while (nextPageParams && pageCount < maxPages); + + trace('fetchErc20Allowances', `Found ${approvalMap.size} unique approve calls`); + + // Step 2: Check current on-chain allowance for each + const results: IndexerAllowanceResponseErc20['results'] = []; + const contracts: IndexerAllowanceResponseErc20['contracts'] = {}; + const ownerPadded = account.slice(2).toLowerCase().padStart(64, '0'); + + const allowanceChecks = Array.from(approvalMap.values()).map(async ({ contract, spender }) => { + try { + const spenderPadded = spender.slice(2).toLowerCase().padStart(64, '0'); + const callData = ALLOWANCE_SELECTOR + ownerPadded + spenderPadded; + + const allowanceResponse = await this.doRPC<{ result: string }>({ + method: 'eth_call', + params: [{ to: contract, data: callData }, 'latest'], + }); + + const amount = allowanceResponse.result || '0x0'; + const amountBN = ethers.BigNumber.from(amount); + + results.push({ + owner: account.toLowerCase(), + contract: contract, + spender: spender, + amount: amountBN.toString(), + updated: Date.now(), + }); + + // Enrich contract info + if (!contracts[contract]) { + contracts[contract] = { + address: contract, + name: '', + symbol: '', + creator: '', + fromTrace: false, + trace_address: '', + supply: '0', + decimals: 18, + block: 0, + transaction: '', + } as IndexerContract; + + try { + const tokenBalances = await this.blockscoutAdapter.getBalances(account); + const tokenInfo = tokenBalances.contracts[contract]; + if (tokenInfo) { + contracts[contract].name = tokenInfo.name || ''; + contracts[contract].symbol = tokenInfo.symbol || ''; + contracts[contract].decimals = tokenInfo.decimals || 18; + } + } catch { + // keep defaults + } + } + } catch (err) { + trace('fetchErc20Allowances', `Failed to check allowance for ${contract}:${spender}`, err); + } + }); + + // Run in batches of 10 + for (let i = 0; i < allowanceChecks.length; i += 10) { + await Promise.all(allowanceChecks.slice(i, i + 10)); + } + + return { results, contracts }; + } catch (err) { + trace('fetchErc20Allowances', 'Failed to fetch approvals', err); + return { results: [], contracts: {} }; + } } + /** + * Fetch ERC-721 approvals by scanning ApprovalForAll events. + */ async fetchErc721Allowances(account: string, filter: IndexerAllowanceFilter): Promise { - const params = { - ...filter, - type: 'erc721', - all: true, - }; - const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); - return response.data as IndexerAllowanceResponseErc721; + const trace = createTraceFunction('EVMChainSettings'); + trace('fetchErc721Allowances', account); + + // ApprovalForAll(address indexed owner, address indexed operator, bool approved) + const APPROVAL_FOR_ALL_TOPIC = '0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31'; + const paddedOwner = '0x' + account.slice(2).toLowerCase().padStart(64, '0'); + + try { + const latestBlock = await this.getLatestBlock(); + const fromBlock = latestBlock.sub(2_000_000).lt(0) + ? ethers.BigNumber.from(0) + : latestBlock.sub(2_000_000); + + const logsResponse = await this.doRPC<{ result: Array<{ + address: string; + topics: string[]; + data: string; + blockNumber: string; + }> }>({ + method: 'eth_getLogs', + params: [{ + fromBlock: fromBlock.toHexString(), + toBlock: 'latest', + topics: [APPROVAL_FOR_ALL_TOPIC, paddedOwner], + }], + }); + + const logs = logsResponse.result || []; + + // Deduplicate by (contract, operator) — keep latest + const approvalMap = new Map(); + for (const log of logs) { + if (!log.topics || log.topics.length < 3) { + continue; + } + const contract = log.address.toLowerCase(); + const operator = '0x' + log.topics[2].slice(26); + const approved = log.data !== '0x' + '0'.repeat(64); // data encodes bool + const key = `${contract}:${operator}`; + const blockNum = parseInt(log.blockNumber, 16); + + const existing = approvalMap.get(key); + if (!existing || blockNum > existing.blockNumber) { + approvalMap.set(key, { contract, operator, approved, blockNumber: blockNum }); + } + } + + const results = Array.from(approvalMap.values()).map(({ contract, operator, approved, blockNumber }) => ({ + owner: account.toLowerCase(), + contract, + operator, + approved, + single: false as const, + updated: blockNumber * 500, + })); + + return { results, contracts: {} }; + } catch (err) { + trace('fetchErc721Allowances', 'Failed to fetch ERC-721 approvals', err); + return { results: [], contracts: {} }; + } } async fetchErc1155Allowances(account: string, filter: IndexerAllowanceFilter): Promise { - const params = { - ...filter, - type: 'erc1155', - all: true, - }; - const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); - return response.data as IndexerAllowanceResponseErc1155; + // ERC-1155 uses the same ApprovalForAll event as ERC-721 + // Reuse the same logic but shape results differently + const erc721Results = await this.fetchErc721Allowances(account, filter); + const results = erc721Results.results.map(r => ({ + owner: r.owner, + contract: r.contract, + operator: r.operator, + approved: r.approved, + updated: r.updated, + })); + return { results, contracts: {} }; } } diff --git a/src/antelope/chains/evm/telos-evm-testnet/index.ts b/src/antelope/chains/evm/telos-evm-testnet/index.ts index 2d890eae..eb325c52 100644 --- a/src/antelope/chains/evm/telos-evm-testnet/index.ts +++ b/src/antelope/chains/evm/telos-evm-testnet/index.ts @@ -6,7 +6,7 @@ import { TokenClass, TokenSourceInfo } from 'src/antelope/types'; import { useUserStore } from 'src/antelope'; import { getFiatPriceFromIndexer } from 'src/api/price'; -const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.png'; +const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.svg'; const CHAIN_ID = '41'; export const NETWORK = 'telos-evm-testnet'; const DISPLAY = 'Telos EVM (Testnet)'; diff --git a/src/antelope/chains/evm/telos-evm/index.ts b/src/antelope/chains/evm/telos-evm/index.ts index f9a97981..0af36f8e 100644 --- a/src/antelope/chains/evm/telos-evm/index.ts +++ b/src/antelope/chains/evm/telos-evm/index.ts @@ -6,7 +6,7 @@ import { TokenClass, TokenSourceInfo } from 'src/antelope/types'; import { useUserStore } from 'src/antelope'; import { getFiatPriceFromIndexer } from 'src/api/price'; -const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.png'; +const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.svg'; const CHAIN_ID = '40'; export const NETWORK = 'telos-evm'; const DISPLAY = 'Telos EVM'; @@ -50,6 +50,13 @@ const RPC_ENDPOINT = { port: 443, path: '/', }; + +// Fallback RPC endpoints for redundancy +const FALLBACK_RPC_ENDPOINTS = [ + { protocol: 'https', host: 'mainnet.telos.net', port: 443, path: '/evm' }, + { protocol: 'https', host: 'telos.drpc.org', port: 443, path: '/' }, + { protocol: 'https', host: 'rpc1.us.telos.net', port: 443, path: '/evm' }, +]; const ESCROW_CONTRACT_ADDRESS = '0x95F5713A1422Aa3FBD3DCB8D553945C128ee3855'; const API_ENDPOINT = 'https://api.telos.net/v1'; const WEI_PRECISION = 18; @@ -57,7 +64,9 @@ const EXPLORER_URL = 'https://teloscan.io'; const ECOSYSTEM_URL = 'https://www.telos.net/ecosystem'; const BRIDGE_URL = 'https://bridge.telos.net/bridge'; const NETWORK_EVM_ENDPOINT = 'https://mainnet.telos.net'; -const INDEXER_ENDPOINT = 'https://api.teloscan.io'; +const EVM_RPC_ENDPOINT = 'https://rpc.telos.net'; +// Blockscout API (replaces legacy api.teloscan.io) +const INDEXER_ENDPOINT = 'https://teloscan.io'; const CONTRACTS_BUCKET = 'https://verified-evm-contracts.s3.amazonaws.com'; declare const fathom: { trackEvent: (eventName: string) => void }; @@ -79,10 +88,18 @@ export default class TelosEVMTestnet extends EVMChainSettings { return NETWORK_EVM_ENDPOINT; } + getEvmRpcEndpoint(): string | null { + return EVM_RPC_ENDPOINT; + } + getRPCEndpoint(): RpcEndpoint { return RPC_ENDPOINT; } + getFallbackRPCEndpoints(): RpcEndpoint[] { + return FALLBACK_RPC_ENDPOINTS; + } + getApiEndpoint(): string { return API_ENDPOINT; } diff --git a/src/antelope/chains/native/telos-testnet/index.ts b/src/antelope/chains/native/telos-testnet/index.ts index 34f3b469..866761fc 100644 --- a/src/antelope/chains/native/telos-testnet/index.ts +++ b/src/antelope/chains/native/telos-testnet/index.ts @@ -3,7 +3,7 @@ import { RpcEndpoint } from 'universal-authenticator-library'; import { api } from 'src/api'; import { TokenClass, TokenSourceInfo, PriceChartData, Theme } from 'src/antelope/types'; -const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.png'; +const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.svg'; const CHAIN_ID = '1eaa0824707c8c16bd25145493bf062aecddfeb56c736f6ba6397f3195f33c9f'; const NETWORK = 'telos-testnet'; diff --git a/src/antelope/chains/native/telos/index.ts b/src/antelope/chains/native/telos/index.ts index 10209d2b..87224ac1 100644 --- a/src/antelope/chains/native/telos/index.ts +++ b/src/antelope/chains/native/telos/index.ts @@ -3,7 +3,7 @@ import { RpcEndpoint } from 'universal-authenticator-library'; import { api } from 'src/api'; import { TokenClass, TokenSourceInfo, PriceChartData, Theme } from 'src/antelope/types'; -const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.png'; +const LOGO = 'https://raw.githubusercontent.com/telosnetwork/token-list/main/logos/telos.svg'; const CHAIN_ID = '4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11'; const NETWORK = 'telos'; diff --git a/src/antelope/chains/utils/BlockscoutAdapter.ts b/src/antelope/chains/utils/BlockscoutAdapter.ts new file mode 100644 index 00000000..31ef2ff4 --- /dev/null +++ b/src/antelope/chains/utils/BlockscoutAdapter.ts @@ -0,0 +1,453 @@ +/** + * BlockscoutAdapter - Translates Blockscout API v2 responses to legacy Teloscan v1 format + * This allows the wallet to use Blockscout without rewriting all response handlers + */ + +import { AxiosInstance } from 'axios'; + +// Blockscout v2 response types +export interface BlockscoutStats { + average_block_time: number; + coin_price: string; + total_blocks: string; + total_transactions: string; + total_addresses: string; + // ... other fields +} + +export interface BlockscoutTokenHolder { + address: { + hash: string; + name: string | null; + is_contract: boolean; + }; + value: string; + token_id: string | null; +} + +export interface BlockscoutToken { + address_hash: string; + name: string; + symbol: string; + decimals: string; + type: string; + holders_count: string; + total_supply: string; + icon_url: string | null; + exchange_rate: string | null; +} + +export interface BlockscoutAddressToken { + token: BlockscoutToken; + value: string; + token_id: string | null; +} + +export interface BlockscoutSmartContract { + address_hash: string; + name: string | null; + abi: unknown[] | null; + source_code: string | null; + is_verified: boolean; +} + +export interface BlockscoutTransaction { + hash: string; + block_number: number; + from: { hash: string } | null; + to: { hash: string } | null; + value: string; + gas_limit: string; + gas_price: string; + gas_used: string; + nonce: number; + position: number; + status: string; + timestamp: string; + raw_input: string; + decoded_input: { method_call: string } | null; + created_contract: { hash: string } | null; + fee: { value: string } | null; + result: string; + transaction_types: string[]; +} + +export interface BlockscoutNFTInstance { + id: string; + metadata: Record | null; + owner: { hash: string } | null; + image_url: string | null; + token: BlockscoutToken; +} + +// Legacy Teloscan v1 format adapters +export interface LegacyHealthResponse { + success: boolean; + blockNumber: number; + blockTimestamp: string; + secondsBehind: number; + version: string; +} + +export interface LegacyTokenBalance { + contract: string; + balance: string; +} + +export interface LegacyBalancesResponse { + results: LegacyTokenBalance[]; + contracts: Record; +} + +export interface LegacyTokenHolder { + address: string; + balance: string; + tokenId?: string; +} + +export interface LegacyTokenHoldersResponse { + results: LegacyTokenHolder[]; +} + +export interface LegacyContractResponse { + address: string; + name: string | null; + abi: unknown[] | null; + verified: boolean; + supportedInterfaces?: string[]; + calldata?: unknown; +} + +/** + * Adapter class to translate Blockscout v2 API to legacy v1 format + */ +export class BlockscoutAdapter { + private indexer: AxiosInstance; + + constructor(indexer: AxiosInstance) { + this.indexer = indexer; + } + + /** + * Health check - /v1/health → /api/v2/stats + */ + async getHealth(): Promise { + try { + const response = await this.indexer.get('/api/v2/stats'); + const stats = response.data as BlockscoutStats; + + return { + success: true, + blockNumber: parseInt(stats.total_blocks) || 0, + blockTimestamp: new Date().toISOString(), + secondsBehind: 0, // Blockscout doesn't provide this directly + version: 'blockscout-v2', + }; + } catch (error) { + return { + success: false, + blockNumber: 0, + blockTimestamp: '', + secondsBehind: Number.POSITIVE_INFINITY, + version: 'blockscout-v2', + }; + } + } + + /** + * Token balances - /v1/account/{addr}/balances → /api/v2/addresses/{addr}/tokens + */ + async getBalances(account: string, params?: { limit?: number; offset?: number }): Promise { + // Blockscout v2 doesn't accept limit/offset on this endpoint + const response = await this.indexer.get(`/api/v2/addresses/${account}/tokens`, { + params: { type: 'ERC-20' }, + }); + + const items = response.data.items as BlockscoutAddressToken[]; + const results: LegacyTokenBalance[] = []; + const contracts: LegacyBalancesResponse['contracts'] = {}; + + for (const item of items) { + results.push({ + contract: item.token.address_hash, + balance: item.value, + }); + + contracts[item.token.address_hash] = { + address: item.token.address_hash, + name: item.token.name, + symbol: item.token.symbol, + decimals: parseInt(item.token.decimals) || 18, + logoURI: item.token.icon_url || undefined, + calldata: item.token.exchange_rate ? { + price: parseFloat(item.token.exchange_rate), + marketdata_updated: String(Date.now()), + } : undefined, + }; + } + + return { results, contracts }; + } + + /** + * Token holders - /v1/token/{addr}/holders → /api/v2/tokens/{addr}/holders + */ + async getTokenHolders( + contractAddress: string, + params?: { limit?: number; account?: string; token_id?: string }, + ): Promise { + // Only pass supported params to Blockscout v2 + const response = await this.indexer.get(`/api/v2/tokens/${contractAddress}/holders`); + + const items = response.data.items as BlockscoutTokenHolder[]; + + // If filtering by account, filter the results + let filteredItems = items; + if (params?.account) { + filteredItems = items.filter( + item => item.address.hash.toLowerCase() === params.account?.toLowerCase(), + ); + } + + const results: LegacyTokenHolder[] = filteredItems.map(item => ({ + address: item.address.hash, + balance: item.value, + tokenId: item.token_id || undefined, + })); + + return { results }; + } + + /** + * Contract info - /v1/contract/{addr} → /api/v2/smart-contracts/{addr} + */ + async getContract(address: string, params?: { full?: boolean; includeAbi?: boolean }): Promise { + try { + const response = await this.indexer.get(`/api/v2/smart-contracts/${address}`); + const contract = response.data as BlockscoutSmartContract; + + return { + address: contract.address_hash, + name: contract.name, + abi: params?.includeAbi ? contract.abi : null, + verified: contract.is_verified, + supportedInterfaces: [], // Blockscout doesn't provide this directly + }; + } catch (error) { + // Contract might not be verified or might not exist + return { + address, + name: null, + abi: null, + verified: false, + }; + } + } + + /** + * Account NFTs - /v1/account/{addr}/nfts → /api/v2/addresses/{addr}/nft + */ + async getAccountNfts(account: string, params?: { type?: string; limit?: number; offset?: number }) { + const nftType = params?.type === 'ERC721' ? 'ERC-721' : params?.type === 'ERC1155' ? 'ERC-1155' : undefined; + + // Blockscout v2 only accepts 'type' param, not limit/offset + const response = await this.indexer.get(`/api/v2/addresses/${account}/nft`, { + params: { type: nftType }, + }); + + return this.transformNftResponse(response.data.items || []); + } + + /** + * Collection NFTs - /v1/contract/{addr}/nfts → /api/v2/tokens/{addr}/instances + */ + async getCollectionNfts(contractAddress: string, params?: { limit?: number; offset?: number }) { + // Blockscout v2 doesn't accept limit/offset on this endpoint + const response = await this.indexer.get(`/api/v2/tokens/${contractAddress}/instances`); + + return this.transformNftResponse(response.data.items || [], contractAddress); + } + + /** + * Transform Blockscout NFT response to legacy format + */ + private transformNftResponse(items: BlockscoutNFTInstance[], contractOverride?: string) { + const results = items.map(item => ({ + tokenId: item.id, + contract: contractOverride || item.token?.address_hash || '', + metadata: JSON.stringify(item.metadata || {}), + imageCache: item.image_url || '', + tokenUri: '', + owner: item.owner?.hash || '', + updated: Date.now(), + supply: '1', // Default for ERC721, ERC1155 would need separate handling + tokenIdSupply: '1', // Alias for getNftsForAccount path + })); + + // Build contracts map + const contracts: Record = {}; + for (const item of items) { + if (item.token && !contracts[item.token.address_hash]) { + contracts[item.token.address_hash] = { + address: item.token.address_hash, + name: item.token.name, + abi: null, + verified: false, + calldata: JSON.stringify({ name: item.token.name, symbol: item.token.symbol }), + supportedInterfaces: [item.token.type?.toLowerCase().replace('-', '') || 'erc721'], + }; + } + } + + return { results, contracts }; + } + + /** + * Transactions - /v1/address/{addr}/transactions → /api/v2/addresses/{addr}/transactions + */ + async getTransactions(address: string, params?: { limit?: number; offset?: number }) { + // Blockscout v2 doesn't accept 'limit' — remove unsupported params + const response = await this.indexer.get(`/api/v2/addresses/${address}/transactions`); + + const items = response.data.items || []; + + // Transform Blockscout format to legacy EvmTransaction format + const results = items.map((tx: BlockscoutTransaction) => ({ + blockNumber: tx.block_number || 0, + contractAddress: tx.created_contract?.hash || '', + cumulativeGasUsed: '0x0', + from: tx.from?.hash || '', + gasLimit: '0x' + (parseInt(tx.gas_limit || '0')).toString(16), + gasPrice: '0x' + (parseInt(tx.gas_price || '0')).toString(16), + gasused: '0x' + (parseInt(tx.gas_used || '0')).toString(16), + hash: tx.hash || '', + index: tx.position || 0, + input: tx.raw_input || tx.decoded_input?.method_call || '0x', + nonce: tx.nonce || 0, + output: '', + logs: '', + r: '', + s: '', + status: tx.status === 'ok' ? '0x1' : '0x0', + timestamp: tx.timestamp ? new Date(tx.timestamp).getTime() : 0, + to: tx.to?.hash || '', + v: '', + value: '0x' + (BigInt(tx.value || '0')).toString(16), + })); + + return { + results, + contracts: {}, + total_count: items.length, + more: !!response.data.next_page_params, + }; + } + + /** + * Token transfers - /v1/account/{addr}/transfers → /api/v2/addresses/{addr}/token-transfers + */ + async getTokenTransfers(account: string, params?: { type?: string; limit?: number; offset?: number }) { + // Blockscout v2 uses 'type' param with values like 'ERC-20', 'ERC-721', 'ERC-1155' + const queryParams: Record = {}; + if (params?.type) { + queryParams.type = params.type.replace('erc', 'ERC-').replace('ERC', 'ERC-').replace('ERC--', 'ERC-'); + } + + const response = await this.indexer.get(`/api/v2/addresses/${account}/token-transfers`, { + params: queryParams, + }); + + const items = response.data.items || []; + const contracts: Record = {}; + + const results = items.map((transfer: { + token: BlockscoutToken; + total: { value?: string; decimals?: string; token_id?: string; token_instance?: unknown } | null; + from: { hash: string }; + to: { hash: string }; + tx_hash?: string; + transaction_hash?: string; + block_number: number; + timestamp: string; + type: string; + token_ids?: string[]; + }) => { + const tokenAddr = transfer.token?.address_hash || ''; + if (transfer.token && !contracts[tokenAddr]) { + contracts[tokenAddr] = { + address: tokenAddr, + name: transfer.token.name, + symbol: transfer.token.symbol, + decimals: parseInt(transfer.token.decimals || '18'), + calldata: JSON.stringify({ name: transfer.token.name, symbol: transfer.token.symbol }), + supportedInterfaces: [transfer.token.type?.toLowerCase().replace('-', '') || 'erc20'], + }; + } + + const tokenType = (transfer.token?.type || 'ERC-20').toLowerCase().replace('-', '') as 'erc20' | 'erc721' | 'erc1155'; + + return { + amount: transfer.total?.value || '0', + contract: tokenAddr, + blockNumber: transfer.block_number || 0, + from: transfer.from?.hash || '', + to: transfer.to?.hash || '', + type: tokenType, + transaction: transfer.transaction_hash || transfer.tx_hash || '', + timestamp: transfer.timestamp ? new Date(transfer.timestamp).getTime() : 0, + id: transfer.total?.token_id || transfer.token_ids?.[0] || undefined, + }; + }); + + return { + results, + contracts, + }; + } + + /** + * Approvals - No direct Blockscout equivalent + * + * LIMITATION: Blockscout doesn't track token approvals like the old Teloscan API did. + * Users won't see their existing approvals in the UI, but can still: + * - Manually revoke approvals if they know the spender address + * - New approvals they make will work normally + * + * TODO: Implement proper solution via one of: + * 1. Query /api/v2/addresses/{addr}/logs for Approval events + * (topic0 = 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925) + * 2. On-chain allowance() queries for known spenders (DEXes, bridges) + * 3. Run a separate microservice that indexes Approval events + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getApprovals(account: string, params?: { type?: string }) { + // Log once per session to avoid spam + if (!this._approvalsWarningShown) { + console.warn( + '[BlockscoutAdapter] Token approvals viewing is currently unavailable. ' + + 'Blockscout does not provide an approvals endpoint. ' + + 'Users can still revoke approvals manually.', + ); + this._approvalsWarningShown = true; + } + + return { + results: [], + contracts: {}, + }; + } + + private _approvalsWarningShown = false; +} + +export default BlockscoutAdapter; diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index ba2d774c..3ad49476 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -559,23 +559,24 @@ export const useAllowancesStore = defineStore(store_name, { this.__erc_1155_allowances[label] = []; }, async fetchBalanceString(data: IndexerErc20AllowanceResult): Promise { - const indexer = (useChainStore().loggedChain.settings as EVMChainSettings).getIndexer(); - const results = (await indexer.get(`/v1/token/${data.contract}/holders?account=${data.owner}`)).data.results; - if (results.length === 0) { + const chainSettings = useChainStore().loggedChain.settings as EVMChainSettings; + const response = await chainSettings.getTokenHolders(data.contract, { account: data.owner }); + if (response.results.length === 0) { return '0'; } else { - const balanceString = results[0].balance; + const balanceString = response.results[0].balance; return balanceString; } }, async shapeErc20AllowanceRow(data: IndexerErc20AllowanceResult): Promise { try { - const spenderContract = await useContractStore().getContract(CURRENT_CONTEXT, data.spender); - const tokenInfo = useTokensStore().__tokens[CURRENT_CONTEXT].find(token => token.address.toLowerCase() === data.contract.toLowerCase()); + const spenderContract = await useContractStore().getContract(CURRENT_CONTEXT, data.spender).catch(() => null); + const tokenInfo = useTokensStore().__tokens[CURRENT_CONTEXT]?.find(token => token.address.toLowerCase() === data.contract.toLowerCase()); - const tokenContract = await useContractStore().getContract(CURRENT_CONTEXT, data.contract); + const tokenContract = await useContractStore().getContract(CURRENT_CONTEXT, data.contract).catch(() => null); - const maxSupply = tokenContract?.maxSupply; + // Use maxSupply from contract, or fallback to a large default so approvals still show + const maxSupply = tokenContract?.maxSupply ?? BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); const balancesStore = useBalancesStore(); let balance = balancesStore.__balances[CURRENT_CONTEXT]?.find( @@ -583,27 +584,34 @@ export const useAllowancesStore = defineStore(store_name, { )?.amount; if (!balance) { - const balanceString = await this.fetchBalanceString(data); - balance = BigNumber.from(balanceString); + try { + const balanceString = await this.fetchBalanceString(data); + balance = BigNumber.from(balanceString); + } catch { + balance = BigNumber.from(0); + } } - if (!balance || !tokenInfo || !maxSupply) { - return null; - } + // Build token info from what we have, falling back to contract data + const tokenName = tokenInfo?.name ?? (data as unknown as { tokenName?: string }).tokenName ?? data.contract.slice(0, 10) + '...'; + const tokenSymbol = tokenInfo?.symbol ?? '???'; + const tokenDecimals = tokenInfo?.decimals ?? 18; + const tokenPrice = tokenInfo ? Number(tokenInfo.price?.str ?? 0) : 0; + const tokenLogo = tokenInfo?.logo ?? undefined; return { lastUpdated: data.updated, spenderAddress: data.spender, spenderName: spenderContract?.name, - tokenName: tokenInfo.name, + tokenName, tokenAddress: data.contract, allowance: BigNumber.from(data.amount), balance, - tokenDecimals: tokenInfo.decimals, + tokenDecimals, tokenMaxSupply: maxSupply, - tokenSymbol: tokenInfo.symbol, - tokenPrice: Number(tokenInfo.price.str), - tokenLogo: tokenInfo.logo, + tokenSymbol, + tokenPrice, + tokenLogo, }; } catch (e) { console.error('Error shaping ERC20 allowance row', e); @@ -641,8 +649,9 @@ export const useAllowancesStore = defineStore(store_name, { } const collectionInfo = await useContractStore().getContract(CURRENT_CONTEXT, data.contract); - const indexer = (useChainStore().loggedChain.settings as EVMChainSettings).getIndexer(); - const balanceString = (await indexer.get(`/v1/token/${data.contract}/holders?account=${data.owner}`)).data.results[0].balance; + const chainSettings = useChainStore().loggedChain.settings as EVMChainSettings; + const holdersResponse = await chainSettings.getTokenHolders(data.contract, { account: data.owner }); + const balanceString = holdersResponse.results[0]?.balance ?? '0'; const balance = BigNumber.from(balanceString); @@ -673,8 +682,9 @@ export const useAllowancesStore = defineStore(store_name, { return null; } - const indexer = (useChainStore().loggedChain.settings as EVMChainSettings).getIndexer(); - const holderInfoForOwner = (await indexer.get(`/v1/token/${data.contract}/holders?account=${data.owner}&limit=${ALLOWANCES_LIMIT}`)).data.results as { balance: string }[]; + const chainSettings = useChainStore().loggedChain.settings as EVMChainSettings; + const holdersResponse = await chainSettings.getTokenHolders(data.contract, { account: data.owner, limit: ALLOWANCES_LIMIT }); + const holderInfoForOwner = holdersResponse.results as { balance: string }[]; const totalNftsOwned = holderInfoForOwner.reduce((acc, holderInfo) => acc.add(holderInfo.balance), BigNumber.from(0)); return collectionInfo ? { diff --git a/src/antelope/stores/balances.ts b/src/antelope/stores/balances.ts index b1dc6538..7dddf34a 100644 --- a/src/antelope/stores/balances.ts +++ b/src/antelope/stores/balances.ts @@ -132,7 +132,19 @@ export const useBalancesStore = defineStore(store_name, { const authenticator = account.authenticator as EVMAuthenticator; const wrapBalance = await authenticator.getERC20TokenBalance(account.account, wrapTokens.address as addressString); - // now we call the indexer + // Workaround-STLOS: fetch STLOS balance directly from contract (Blockscout may not have indexed it) + const stakedToken = chain_settings.getStakedSystemToken(); + let stakedBalance = BigNumber.from(0); + try { + stakedBalance = await authenticator.getERC20TokenBalance(account.account, stakedToken.address as addressString); + } catch (e) { + console.warn('Failed to fetch STLOS balance from contract:', e); + } + + // Fetch native TLOS balance from RPC (Blockscout only returns ERC-20 balances) + await this.updateSystemBalanceForAccount(label, account.account as addressString); + + // now we call the indexer for ERC-20 balances const newBalances = await chain_settings.getBalances(account.account); if (this.__balances[label].length === 0) { @@ -154,6 +166,9 @@ export const useBalancesStore = defineStore(store_name, { // Workaround-WTLOS: now we overwrite the value with the one taken from the contract this.processBalanceForToken(label, wrapTokens, wrapBalance); + // Workaround-STLOS: overwrite with contract-fetched balance + this.processBalanceForToken(label, stakedToken, stakedBalance); + this.sortBalances(label); useFeedbackStore().unsetLoading('updateBalancesForAccount'); diff --git a/src/antelope/stores/contract.ts b/src/antelope/stores/contract.ts index 715ecd85..00297e2b 100644 --- a/src/antelope/stores/contract.ts +++ b/src/antelope/stores/contract.ts @@ -236,10 +236,16 @@ export const useContractStore = defineStore(store_name, { let metadata = { address: address } as EvmContractFactoryData; try { - const indexer = (useChainStore().loggedChain.settings as EVMChainSettings).getIndexer(); - const response = await indexer.get(`/v1/contract/${address}?full=true&includeAbi=true`); - if (response.data?.success && response.data.results.length > 0){ - metadata = response.data.results[0]; + const chainSettings = useChainStore().loggedChain.settings as EVMChainSettings; + const contractData = await chainSettings.getBlockscoutAdapter().getContract(address, { full: true, includeAbi: true }); + if (contractData?.address) { + metadata = { + address: contractData.address, + name: contractData.name, + abi: contractData.abi, + verified: contractData.verified, + supportedInterfaces: contractData.supportedInterfaces, + } as EvmContractFactoryData; } } catch (e) { console.warn(`Could not retrieve contract ${address}: ${e}`); @@ -588,11 +594,11 @@ export const useContractStore = defineStore(store_name, { async fetchIsContract(addressLower: string): Promise { // We use a try/catch in case the request returns a 404 or similar try { - const indexer = (useChainStore().loggedChain.settings as EVMChainSettings).getIndexer(); - const response = await indexer.get('/v1/contract/' + addressLower); + const chainSettings = useChainStore().loggedChain.settings as EVMChainSettings; + const contractData = await chainSettings.getBlockscoutAdapter().getContract(addressLower); - // If we have a valid data.results array and it has at least one element, return true - if (response.data?.results?.length > 0) { + // If we have valid contract data, return true + if (contractData?.address) { return true; } else { return false; diff --git a/src/antelope/stores/history.ts b/src/antelope/stores/history.ts index af5aeed8..c33d5571 100644 --- a/src/antelope/stores/history.ts +++ b/src/antelope/stores/history.ts @@ -146,7 +146,8 @@ export const useHistoryStore = defineStore(store_name, { this.__evm_nft_transfers[label].size === 0 || (nftTransfersUpdated && !dateIsWithinXMinutes(nftTransfersUpdated, 3)) ) { - await this.fetchEvmNftTransfersForAccount(label, this.__evm_filter.address); + await this.fetchEvmNftTransfersForAccount(label, this.__evm_filter.address) + .catch((e: unknown) => console.warn('NFT transfers fetch failed (non-fatal):', e)); } const lastFilterUsedStr = JSON.stringify(this.__evm_last_filter_used); diff --git a/src/antelope/stores/rex.ts b/src/antelope/stores/rex.ts index e147befe..ba0e06c1 100644 --- a/src/antelope/stores/rex.ts +++ b/src/antelope/stores/rex.ts @@ -13,6 +13,8 @@ import { toRaw } from 'vue'; import { AccountModel, useAccountStore } from 'src/antelope/stores/account'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; import { WEI_PRECISION } from 'src/antelope/stores/utils'; +import { escrowAbiRead } from 'src/antelope/stores/utils/abi/escrowAbi'; +import { stlosAbiRead } from 'src/antelope/stores/utils/abi/stlosAbi'; import { subscribeForTransactionReceipt } from 'src/antelope/stores/utils/trx-utils'; import { createTraceFunction } from 'src/antelope/config'; import { prettyTimePeriod } from 'src/antelope/stores/utils/date-utils'; @@ -107,9 +109,12 @@ export const useRexStore = defineStore(store_name, { */ async getStakedSystemContractInstance(label: string) { this.trace('getStakedSystemContractInstance', label); - const address = (useChainStore().getChain(label).settings as EVMChainSettings).getStakedSystemToken().address; + const chainSettings = useChainStore().getChain(label).settings as EVMChainSettings; + const address = chainSettings.getStakedSystemToken().address; this.trace('getStakedSystemContractInstance', label, address); - return this.getContractInstance(label, address); + // Use built-in ABI since STLOS contract is unverified on Blockscout + const provider = await getAntelope().wallets.getWeb3Provider(label); + return new ethers.Contract(address, stlosAbiRead, provider); }, /** * auxiliar method to get the escrow contract instance @@ -118,9 +123,12 @@ export const useRexStore = defineStore(store_name, { */ async getEscrowContractInstance(label: string) { this.trace('getEscrowContractInstance', label); - const address = (useChainStore().getChain(label).settings as EVMChainSettings).getEscrowContractAddress(); + const chainSettings = useChainStore().getChain(label).settings as EVMChainSettings; + const address = chainSettings.getEscrowContractAddress(); this.trace('getEscrowContractInstance', label, address); - return this.getContractInstance(label, address); + // Use built-in ABI since the escrow contract is unverified on Blockscout + const provider = await getAntelope().wallets.getWeb3Provider(label); + return new ethers.Contract(address, escrowAbiRead, provider); }, /** * This method should be called to check if the REX system is available for a given context. @@ -152,9 +160,19 @@ export const useRexStore = defineStore(store_name, { async updateUnstakingPeriod(label: string) { this.trace('updateUnstakingPeriod', label); if (this.isNetworkEVM(label)) { - const contract = await this.getEscrowContractInstance(label); - const period = await contract.lockDuration(); - this.setUnstakingPeriod(label, period.toNumber()); + try { + // lockDuration() has a non-standard selector (0x04554443) on the escrow contract, + // so we call it via raw eth_call instead of ethers ABI encoding + const provider = await getAntelope().wallets.getWeb3Provider(label); + const chainSettings = useChainStore().getChain(label).settings as EVMChainSettings; + const escrowAddress = chainSettings.getEscrowContractAddress(); + const result = await provider.call({ to: escrowAddress, data: '0x04554443' }); + const period = ethers.BigNumber.from(result); + this.setUnstakingPeriod(label, period.toNumber()); + } catch (error) { + console.warn('[Rex] lockDuration() failed, using default 10 days:', error); + this.setUnstakingPeriod(label, 10 * 24 * 60 * 60); + } } else { this.trace('updateUnstakingPeriod', label, 'not supported for native chains yet'); } @@ -181,15 +199,23 @@ export const useRexStore = defineStore(store_name, { this.trace('updateRexDataForAccount', label, account); useFeedbackStore().setLoading('updateRexDataForAccount'); try { - await Promise.all([ + // Use allSettled so one failure doesn't block the others + const results = await Promise.allSettled([ this.updateWithdrawable(label), // account's data this.updateDeposits(label), // account's data this.updateBalance(label), // account's data this.updateTotalStaking(label), // system's data this.updateUnstakingPeriod(label), // system's data ]); + results.forEach((result, i) => { + if (result.status === 'rejected') { + const names = ['updateWithdrawable', 'updateDeposits', 'updateBalance', 'updateTotalStaking', 'updateUnstakingPeriod']; + console.warn(`[Rex] ${names[i]} failed:`, result.reason); + } + }); } catch (error) { console.error(error); + } finally { useFeedbackStore().unsetLoading('updateRexDataForAccount'); } }, diff --git a/src/antelope/stores/utils/abi/escrowAbi.ts b/src/antelope/stores/utils/abi/escrowAbi.ts index 2f9309de..c93dd3e3 100644 --- a/src/antelope/stores/utils/abi/escrowAbi.ts +++ b/src/antelope/stores/utils/abi/escrowAbi.ts @@ -9,3 +9,15 @@ export const escrowAbiWithdraw: EvmABI = [ type: 'function', }, ]; + +// Full escrow ABI for reading staking data (lockDuration, maxWithdraw, balanceOf, depositsOf) +// Needed because the escrow contract is unverified on Blockscout +// Using ethers-compatible JSON ABI format (cast to any to bypass strict EvmABI types) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const escrowAbiRead: any[] = [ + 'function lockDuration() view returns (uint256)', + 'function maxWithdraw(address owner) view returns (uint256)', + 'function balanceOf(address account) view returns (uint256)', + 'function depositsOf(address account) view returns (tuple(uint256 amount, uint256 timestamp)[])', + 'function withdraw()', +]; diff --git a/src/antelope/stores/utils/abi/stlosAbi.ts b/src/antelope/stores/utils/abi/stlosAbi.ts index 9a6bb33b..de5246ee 100644 --- a/src/antelope/stores/utils/abi/stlosAbi.ts +++ b/src/antelope/stores/utils/abi/stlosAbi.ts @@ -73,6 +73,15 @@ export const stlosAbiPreviewRedeem: EvmABI = [ }, ]; +// Full read ABI for STLOS contract (unverified on Blockscout) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const stlosAbiRead: any[] = [ + 'function totalAssets() view returns (uint256)', + 'function previewDeposit(uint256 assets) view returns (uint256)', + 'function previewRedeem(uint256 shares) view returns (uint256)', + 'function balanceOf(address account) view returns (uint256)', +]; + export const stlosAbiPreviewDeposit: EvmABI = [ { inputs: [ diff --git a/src/antelope/types/NFTClass.ts b/src/antelope/types/NFTClass.ts index 61610e3f..caa93b91 100644 --- a/src/antelope/types/NFTClass.ts +++ b/src/antelope/types/NFTClass.ts @@ -6,7 +6,6 @@ import { IndexerContract, IndexerNftItemAttribute, IndexerNftMetadata, - IndexerTokenHoldersResponse, } from 'src/antelope/types/IndexerTypes'; import { IPFS_GATEWAY, extractNftMetadata } from 'src/antelope/stores/utils/nft-utils'; import { useContractStore } from 'src/antelope/stores/contract'; @@ -74,13 +73,22 @@ export async function getErc721Owner(contract: Contract, tokenId: string): Promi } export async function getErc1155OwnersFromIndexer(contractAddress: string, tokenId: string, indexer: AxiosInstance): Promise<{ [address: string]: number }> { - const holdersResponse = (await indexer.get(`/v1/token/${contractAddress}/holders?limit=10000&token_id=${tokenId}`)).data as IndexerTokenHoldersResponse; - const holders = holdersResponse.results; - - return holders.reduce((acc, current) => { - acc[current.address] = +current.balance; - return acc; - }, {} as { [address: string]: number }); + // Use Blockscout API v2 format for token holders + try { + const holdersResponse = (await indexer.get(`/api/v2/tokens/${contractAddress}/holders`, { + params: { limit: 10000, token_id: tokenId }, + })).data; + + const holders = holdersResponse.items || []; + + return holders.reduce((acc: { [address: string]: number }, current: { address: { hash: string }; value: string }) => { + acc[current.address.hash] = +current.value; + return acc; + }, {} as { [address: string]: number }); + } catch (error) { + console.error('Error fetching ERC1155 owners from Blockscout:', error); + return {}; + } } export async function getErc1155OwnersFromContract(ownerAddress: string, tokenId: string, contract: Contract): Promise<{ [address: string]: number }> { @@ -170,7 +178,25 @@ export async function constructNft( }; if (isErc721) { - const contractInstance = await (await contractStore.getContract(CURRENT_CONTEXT, contract.address, 'erc721'))?.getContractInstance(); + let contractInstance = await (await contractStore.getContract(CURRENT_CONTEXT, contract.address, 'erc721'))?.getContractInstance(); + + // Fallback: if Blockscout hasn't indexed this contract, create instance with standard ERC721 ABI + if (!contractInstance) { + try { + const { getAntelope } = await import('src/antelope'); + const provider = await getAntelope().wallets.getWeb3Provider(CURRENT_CONTEXT); + const minimalErc721Abi = [ + 'function ownerOf(uint256 tokenId) view returns (address)', + 'function balanceOf(address owner) view returns (uint256)', + 'function tokenURI(uint256 tokenId) view returns (string)', + 'function name() view returns (string)', + 'function symbol() view returns (string)', + ]; + contractInstance = new ethers.Contract(contract.address, minimalErc721Abi, provider); + } catch (e) { + console.error('Error creating fallback ERC721 contract instance', e); + } + } if (!contractInstance) { console.error('Error getting contract instance'); diff --git a/src/antelope/wallets/authenticators/RabbyAuth.ts b/src/antelope/wallets/authenticators/RabbyAuth.ts new file mode 100644 index 00000000..bcbd3dd8 --- /dev/null +++ b/src/antelope/wallets/authenticators/RabbyAuth.ts @@ -0,0 +1,36 @@ +import { EthereumProvider } from 'src/antelope/types'; +import { EVMAuthenticator, InjectedProviderAuth } from 'src/antelope/wallets'; + +const name = 'Rabby'; +export const RabbyAuthName = name; +export class RabbyAuth extends InjectedProviderAuth { + + // this is just a dummy label to identify the authenticator base class + constructor(label = name) { + super(label); + } + + // InjectedProviderAuth API ------------------------------------------------------ + + getProvider(): EthereumProvider | null { + const eth = window.ethereum as unknown as EthereumProvider & { isRabby?: boolean }; + // Rabby injects window.ethereum with isRabby flag + if (eth && eth.isRabby) { + return eth; + } + return null; + } + + // EVMAuthenticator API ---------------------------------------------------------- + + getName(): string { + return name; + } + + // this is the important instance creation where we define a label to assign to this instance of the authenticator + newInstance(label: string): EVMAuthenticator { + this.trace('newInstance', label); + return new RabbyAuth(label); + } + +} diff --git a/src/antelope/wallets/index.ts b/src/antelope/wallets/index.ts index 79528bc3..7d797992 100644 --- a/src/antelope/wallets/index.ts +++ b/src/antelope/wallets/index.ts @@ -4,4 +4,5 @@ export * from 'src/antelope/wallets/authenticators/MetamaskAuth'; export * from 'src/antelope/wallets/authenticators/WalletConnectAuth'; export * from 'src/antelope/wallets/authenticators/SafePalAuth'; export * from 'src/antelope/wallets/authenticators/BraveAuth'; +export * from 'src/antelope/wallets/authenticators/RabbyAuth'; export * from 'src/antelope/wallets/AntelopeWallets'; diff --git a/src/api/price.ts b/src/api/price.ts index 48843f78..ae4f7da7 100644 --- a/src/api/price.ts +++ b/src/api/price.ts @@ -9,7 +9,6 @@ import { TokenPrice, } from 'src/antelope/types'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; -import { dateIsWithinXMinutes } from 'src/antelope/stores/utils/date-utils'; interface CachedPrice { lastFetchTime: number | null, @@ -68,7 +67,7 @@ export async function getFiatPriceFromIndexer( return 0; } -// fetch the price data for a particular token from the indexer +// fetch the price data for a particular token from the indexer (Blockscout v2) export async function getTokenPriceDataFromIndexer( tokenSymbol: string, tokenAddress: string, @@ -76,28 +75,38 @@ export async function getTokenPriceDataFromIndexer( indexerAxios: AxiosInstance, chain_settings: EVMChainSettings, ): Promise { - const wrappedSystemAddress = chain_settings.getWrappedSystemToken().address; - const actualTokenAddress = tokenAddress === NativeCurrencyAddress ? wrappedSystemAddress : tokenAddress; - const response = (await indexerAxios.get(`/v1/tokens/marketdata?tokens=${tokenSymbol}&vs=${fiatCode}`)).data as { results: MarketSourceInfo [] }; - - const tokenMarketDataSource = response.results.find( - tokenData => (tokenData.address ?? '').toLowerCase() === actualTokenAddress.toLowerCase(), - ); + try { + // Blockscout v2 stats endpoint provides coin_price for the native token + // This only works for TLOS/native token, not for other ERC20s + const isNativeToken = tokenAddress === NativeCurrencyAddress || + tokenAddress.toLowerCase() === chain_settings.getWrappedSystemToken().address.toLowerCase() || + tokenSymbol.toUpperCase() === 'TLOS' || + tokenSymbol.toUpperCase() === 'WTLOS'; + + if (isNativeToken) { + const response = await indexerAxios.get('/api/v2/stats'); + const stats = response.data; + + if (stats.coin_price) { + const price = parseFloat(stats.coin_price); + if (price > 0) { + const marketInfo: MarketSourceInfo = { + price: price.toString(), + updated: Date.now().toString(), + }; + const marketData = new TokenMarketData(marketInfo); + return new TokenPrice(marketData); + } + } + } - if (!tokenMarketDataSource?.updated || !tokenMarketDataSource.price) { + // For non-native tokens or if Blockscout price failed, return null + // The caller will fall back to Coingecko + return null; + } catch (error) { + console.warn('Failed to get price from Blockscout stats, falling back to Coingecko:', error); return null; } - - const lastPriceUpdated = (new Date(+tokenMarketDataSource.updated)).getTime(); - - // only use indexer data if it is no more than 10 minutes old - if (dateIsWithinXMinutes(lastPriceUpdated, 10)) { - - const marketData = new TokenMarketData(tokenMarketDataSource); - return new TokenPrice(marketData); - } - // if indexer data is stale, return no data - return null; } export const getCoingeckoPriceChartData = async ( diff --git a/src/assets/evm/rabby.png b/src/assets/evm/rabby.png new file mode 100644 index 00000000..f170f786 Binary files /dev/null and b/src/assets/evm/rabby.png differ diff --git a/src/boot/antelope.ts b/src/boot/antelope.ts index 50df87d0..904a0f6e 100644 --- a/src/boot/antelope.ts +++ b/src/boot/antelope.ts @@ -10,6 +10,7 @@ import { SafePalAuth, } from 'src/antelope/wallets'; import { BraveAuth } from 'src/antelope/wallets/authenticators/BraveAuth'; +import { RabbyAuth } from 'src/antelope/wallets/authenticators/RabbyAuth'; import { App } from 'vue'; import { Router } from 'vue-router'; import { resetNativeApi } from 'src/boot/api'; @@ -111,6 +112,7 @@ export default boot(({ app }) => { ant.wallets.addEVMAuthenticator(new MetamaskAuth()); ant.wallets.addEVMAuthenticator(new SafePalAuth()); ant.wallets.addEVMAuthenticator(new BraveAuth()); + ant.wallets.addEVMAuthenticator(new RabbyAuth()); // autologin -- ant.stores.account.autoLogin(); diff --git a/src/data/dapps.json b/src/data/dapps.json new file mode 100644 index 00000000..b26a79ba --- /dev/null +++ b/src/data/dapps.json @@ -0,0 +1,26 @@ +[ + { + "name": "Cloak", + "description": "Privacy-first communication and payments on Telos", + "link": "https://cloak.today", + "icon": "/dapps/cloak.svg", + "category": "Privacy", + "tags": ["Privacy", "Payments"] + }, + { + "name": "Explorer", + "description": "Official Telos blockchain explorer", + "link": "https://explorer.telos.net", + "icon": "/dapps/explorer.svg", + "category": "Tools", + "tags": ["Explorer", "Analytics"] + }, + { + "name": "Qudo", + "description": "Play-to-earn gaming platform on Telos", + "link": "https://qudo.games", + "icon": "/dapps/qudo.svg", + "category": "Gaming", + "tags": ["Gaming", "Play-to-Earn"] + } +] diff --git a/src/i18n/en-us/index.js b/src/i18n/en-us/index.js index d82b77a2..50a1d8ad 100644 --- a/src/i18n/en-us/index.js +++ b/src/i18n/en-us/index.js @@ -580,7 +580,7 @@ export default { balance_fiat_tooltip: 'Total includes non-liquid TLOS, such as TLOS staked to resources', }, dapps: { - title: 'Telos Native dApps', + title: 'Telos Zero Apps', placeholder: 'Search dApp', }, error404: { diff --git a/src/pages/evm/staking/StakingPageHeader.vue b/src/pages/evm/staking/StakingPageHeader.vue index 936a23bd..87f65277 100644 --- a/src/pages/evm/staking/StakingPageHeader.vue +++ b/src/pages/evm/staking/StakingPageHeader.vue @@ -105,13 +105,14 @@ const isWithdrawableLoading = computed(() => withdrawableBalanceBn.value === und const apyPrittyPrint = computed(() => { const apy = chainStore.currentEvmChain?.apy; - if (apy) { + if (apy && apy !== '') { return apy + '%'; } else { - return '--'; + // Fallback APY when API doesn't return a value + return '~5%'; } }); -const apyisLoading = computed(() => apyPrittyPrint.value === '--'); +const apyisLoading = computed(() => false); // Never show loading since we have fallback const unlockPeriod = computed(() => rexStore.getUnstakingPeriodString(CURRENT_CONTEXT)); const unlockPeriodLoading = computed(() => unlockPeriod.value === '--'); diff --git a/src/pages/home/LoginButtons.vue b/src/pages/home/LoginButtons.vue index 176d85a5..d8842388 100644 --- a/src/pages/home/LoginButtons.vue +++ b/src/pages/home/LoginButtons.vue @@ -60,8 +60,14 @@ export default defineComponent({ return e && e.isBraveWallet; }); + const supportsRabby = computed(() => { + const e = window.ethereum as unknown as { [key:string]: boolean }; + return e && e.isRabby; + }); + const showMetamaskButton = computed(() => !isMobile.value || supportsMetamask.value); const showSafePalButton = computed(() => !isMobile.value || supportsSafePal.value); + const showRabbyButton = computed(() => !isMobile.value || supportsRabby.value); const injectedProviderDetected = computed(() => !!window.ethereum); const showWalletConnectButton = computed(() => !isMobile.value || !injectedProviderDetected.value || (isMobile.value && isBraveBrowser.value)); // temp solution until Brave support is added https://github.com/telosnetwork/telos-wallet/issues/501 const showBraveButton = isBraveBrowser.value && !isMobile.value; @@ -91,6 +97,13 @@ export default defineComponent({ const setWalletConnectEVM = async () => { setEVMAuthenticator('WalletConnect', CURRENT_CONTEXT); }; + const setRabbyEVM = async () => { + setEVMAuthenticator('Rabby', CURRENT_CONTEXT); + }; + + const redirectToRabbyDownload = () => { + window.open('https://rabby.io/', '_blank'); + }; const setEVMAuthenticator = async(name: string, label: string) => { const auth = ant.wallets.getEVMAuthenticator(name); @@ -160,18 +173,22 @@ export default defineComponent({ supportsMetamask, supportsBrave, supportsSafePal, + supportsRabby, showMetamaskButton, showBraveButton, showSafePalButton, + showRabbyButton, showWalletConnectButton, setMetamaskEVM, setBraveEVM, setSafePalEVM, + setRabbyEVM, setWalletConnectEVM, notifyNoProvider, notifyEnableBrave, redirectToMetamaskDownload, redirectToSafepalDownload, + redirectToRabbyDownload, showEVMButtons, showZeroButtons, ualAuthenticators, @@ -193,6 +210,27 @@ export default defineComponent({