diff --git a/package-lock.json b/package-lock.json index 6ae3799..5ebe772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@rsksmart/rsk-contract-metadata": "^1.0.15", "@rsksmart/rsk-utils": "^1.1.0", "axios": "^1.6.2", + "axios-cache-interceptor": "^1.6.2", "cors": "^2.8.5", "dotenv": "^10.0.0", "ethers": "^6.13.1", @@ -2074,6 +2075,25 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-cache-interceptor": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.6.2.tgz", + "integrity": "sha512-YLbAODIHZZIcD4b3WYFVQOa5W2TY/WnJ6sBHqAg6Z+hx+RVj8/OcjQyRopO6awn7/kOkGL5X9TP16AucnlJ/lw==", + "dependencies": { + "cache-parser": "1.2.5", + "fast-defer": "1.1.8", + "object-code": "1.3.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1" + }, + "peerDependencies": { + "axios": "^1" + } + }, "node_modules/axios/node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -2369,6 +2389,11 @@ "node": ">= 0.8" } }, + "node_modules/cache-parser": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz", + "integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA==" + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -3812,6 +3837,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-defer": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz", + "integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q==" + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -6259,6 +6289,11 @@ "node": ">=0.10.0" } }, + "node_modules/object-code": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz", + "integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA==" + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", diff --git a/package.json b/package.json index 2604896..b433f47 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@rsksmart/rsk-contract-metadata": "^1.0.15", "@rsksmart/rsk-utils": "^1.1.0", "axios": "^1.6.2", + "axios-cache-interceptor": "^1.6.2", "cors": "^2.8.5", "dotenv": "^10.0.0", "ethers": "^6.13.1", diff --git a/src/api/openapi.js b/src/api/openapi.js index d69c418..72ca706 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -10,6 +10,10 @@ module.exports = { url: 'http://localhost:3000/', description: 'Local server' }, + { + url: 'https://dev.rws.app.rootstockcollective.xyz', + description: 'DAO TestNet' + }, { url: 'https://rws.app.rootstockcollective.xyz', description: 'DAO TestNet' @@ -674,6 +678,77 @@ module.exports = { } } }, + '/address/{address}/holders': { + get: { + summary: 'Get Token holders that belong to an address by chainId', + tags: [ + 'Tokens' + ], + parameters: [ + { + name: 'address', + in: 'path', + required: true, + description: 'NFT address', + schema: { + type: 'string' + }, + example: '0xa3076bcaCc7112B7fa7c5A87CF32275296d85D64' + }, + { + name: 'chainId', + in: 'query', + description: 'Chain Id identifies the network', + required: false, + schema: { + type: 'string', + default: '31' + }, + examples: { + 'RSK Testnet': { + value: '31' + }, + 'RSK Mainnet': { + value: '30' + } + } + } + ], + responses: { + 200: { + description: 'successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/TokenHolder' + } + }, + next_page_params: { + $ref: '#/components/schemas/NextPage' + } + } + } + } + } + }, + 400: { + description: 'Validation Error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ValidationError' + } + } + } + } + } + } + }, '/price': { get: { summary: 'Get token prices', @@ -1751,6 +1826,127 @@ module.exports = { } } }, + TokenHolder: { + type: 'object', + properties: { + address: { + $ref: '#/components/schemas/AddressParam' + }, + value: { + type: 'string', + example: '10000' + }, + token_id: { + type: 'string', + example: '10000' + }, + token: { + $ref: '#/components/schemas/TokenInfo' + } + } + }, + AddressParam: { + type: 'object', + properties: { + hash: { + type: 'string', + example: '0xEb533ee5687044E622C69c58B1B12329F56eD9ad' + }, + implementation_name: { + type: 'string', + example: 'implementationName' + }, + name: { + type: 'string', + example: 'contractName' + }, + is_contract: { + type: 'boolean' + }, + private_tags: { + type: 'array', + items: { + $ref: '#/components/schemas/AddressTag' + } + }, + watchlist_names: { + type: 'array', + items: { + $ref: '#/components/schemas/WatchlistName' + } + }, + public_tags: { + type: 'array', + items: { + $ref: '#/components/schemas/AddressTag' + } + }, + is_verified: { + type: 'boolean' + } + } + }, + TokenInfo: { + type: 'object', + properties: { + circulating_market_cap: { + type: 'string', + example: '83606435600.3635' + }, + icon_url: { + type: 'string', + example: 'https://raw.githubusercontent.com/trustwallet/assets/master' + + '/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png' + }, + name: { + type: 'string', + example: 'Tether USD' + }, + decimals: { + type: 'string', + example: '6' + }, + symbol: { + type: 'string', + example: 'USDT' + }, + address: { + type: 'string', + example: '0x394c399dbA25B99Ab7708EdB505d755B3aa29997' + }, + type: { + type: 'string', + example: 'ERC-20' + }, + holders: { + type: 'string', + example: '837494234523' + }, + exchange_rate: { + type: 'string', + example: '0.99' + }, + total_supply: { + type: 'string', + example: '10000000' + } + } + }, + NextPage: { + type: 'object', + properties: { + address_hash: { + type: 'string' + }, + items_count: { + type: 'integer' + }, + value: { + type: 'integer' + } + } + }, + ValidationError: { type: 'object', properties: { diff --git a/src/blockscoutApi/index.ts b/src/blockscoutApi/index.ts index f4b1474..ee75c74 100644 --- a/src/blockscoutApi/index.ts +++ b/src/blockscoutApi/index.ts @@ -12,10 +12,15 @@ import { fromApiToInternalTransaction, fromApiToNft, fromApiToNftOwner, fromApiToRtbcBalance, fromApiToTEvents, fromApiToTokenWithBalance, fromApiToTokens, fromApiToTransaction, transformResponseToNftHolder } from './utils' -import { GetEventLogsByAddressAndTopic0, GetNftHoldersData, GetTokenHoldersByAddress } from '../service/address/AddressService' +import { + GetEventLogsByAddressAndTopic0, GetNftHoldersData, + GetTokenHoldersByAddress +} from '../service/address/AddressService' +import { AxiosCacheInstance, setupCache } from 'axios-cache-interceptor' export class BlockscoutAPI extends DataSource { private chainId: number + private axiosCache: AxiosCacheInstance private errorHandling = (e) => { console.error(e) return [] @@ -24,6 +29,10 @@ export class BlockscoutAPI extends DataSource { constructor (apiURL: string, chainId: number, axios: typeof _axios, id: string) { super(apiURL, id, axios) this.chainId = chainId + this.axiosCache = setupCache(_axios.create(), { + ttl: 1000 * 60, + interpretHeader: false + }) } getTokens () { @@ -176,7 +185,8 @@ export class BlockscoutAPI extends DataSource { async getTokenHoldersByAddress ({ address, nextPageParams }: GetTokenHoldersByAddress) { try { const url = `${this.url}/v2/tokens/${address}/holders` - const response = await this.axios?.get>(url, { params: nextPageParams }) + const response = await this.axiosCache.get>(url, + { params: nextPageParams, validateStatus: (status) => status <= 500 }) if (response?.status === 200) { return response.data } @@ -186,8 +196,7 @@ export class BlockscoutAPI extends DataSource { error: `Blockscout error with status ${response?.status}` } } catch (error) { - console.log(typeof error, error) - // @TODO handle error + console.error(typeof error, error) return { items: [], next_page_params: null, @@ -196,11 +205,12 @@ export class BlockscoutAPI extends DataSource { } } - async getNftHoldersData ({ address, nextPageParams }: GetNftHoldersData) { + async getNftInstancesByAddress ({ address, nextPageParams }: GetNftHoldersData) { const url = `${this.url}/v2/tokens/${address.toLowerCase()}/instances` try { const response = await this.axios?.get>(url, { - params: nextPageParams + params: nextPageParams, + validateStatus: (status) => status <= 500 }) if (response?.status === 200) { @@ -217,7 +227,7 @@ export class BlockscoutAPI extends DataSource { error: `Blockscout error with status ${response?.status}` } } catch (error) { - console.log(typeof error, error) + console.error(typeof error, error) throw new Error(`Failed to get NFT holders data: ${error instanceof Error ? error.message : String(error)}`) }; } diff --git a/src/blockscoutApi/types.ts b/src/blockscoutApi/types.ts index bc6e1ae..882a1bc 100644 --- a/src/blockscoutApi/types.ts +++ b/src/blockscoutApi/types.ts @@ -324,6 +324,7 @@ export interface NftTokenHoldersTransformedResponse { name: string; } } + export interface TokenHolderAddress { ens_domain_name: string; hash: string; @@ -338,23 +339,9 @@ export interface TokenHolderAddress { watchlist_names: any[]; } -export interface TokenHolderToken { - address: string; - circulating_market_cap: null; - decimals: string; - exchange_rate: null; - holders: string; - icon_url: null; - name: string; - symbol: string; - total_supply: string; - type: string; - volume_24h: null; -} - export interface TokenHoldersResponse { address: TokenHolderAddress; token: Token; - token_id: null; + token_id: string; value: string; } diff --git a/src/controller/httpsAPI.ts b/src/controller/httpsAPI.ts index 1eafb26..368fc66 100644 --- a/src/controller/httpsAPI.ts +++ b/src/controller/httpsAPI.ts @@ -61,6 +61,7 @@ export class HttpsAPI { }) const whilelist = [ + 'https://dev.rws.app.rootstockcollective.xyz', 'https://rws.app.rootstockcollective.xyz', 'https://app.rootstockcollective.xyz', 'https://testnet.app.rootstockcollective.xyz', diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..7e8c35c --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,11 @@ +export {} +declare global { + interface Storage { + setItem(key: string, value: string): void; + getItem(key: string): string | null; + removeItem(key: string): void; + clear(): void; + length: number; + key(index: number): string | null; + } +} diff --git a/src/repository/DataSource.ts b/src/repository/DataSource.ts index 3d0f9f0..3bc629a 100644 --- a/src/repository/DataSource.ts +++ b/src/repository/DataSource.ts @@ -1,8 +1,10 @@ import _axios from 'axios' import { ethers } from 'ethers' import BitcoinCore from '../service/bitcoin/BitcoinCore' -import { GetEventLogsByAddressAndTopic0, GetNftHoldersData, - GetTokenHoldersByAddress } from '../service/address/AddressService' +import { + GetEventLogsByAddressAndTopic0, GetNftHoldersData, + GetTokenHoldersByAddress +} from '../service/address/AddressService' export abstract class DataSource { readonly url: string @@ -32,7 +34,7 @@ export abstract class DataSource { abstract getEventLogsByAddressAndTopic0({ address, topic0, toBlock, fromBlock } : Omit); - abstract getNftHoldersData({ address }: Omit); + abstract getNftInstancesByAddress({ address }: Omit); abstract getTokenHoldersByAddress({ address }: Omit) } diff --git a/src/rskExplorerApi/index.ts b/src/rskExplorerApi/index.ts index 716342c..1d6c556 100644 --- a/src/rskExplorerApi/index.ts +++ b/src/rskExplorerApi/index.ts @@ -144,10 +144,10 @@ export class RSKExplorerAPI extends DataSource { throw new Error('Feature not supported') } - getNftHoldersData () { + getNftInstancesByAddress () { throw new Error('Feature not supported') } - + getTokenHoldersByAddress () { throw new Error('Feature not supported') } diff --git a/src/service/address/AddressService.ts b/src/service/address/AddressService.ts index a6eb8fe..8b82536 100644 --- a/src/service/address/AddressService.ts +++ b/src/service/address/AddressService.ts @@ -159,9 +159,9 @@ export class AddressService { async getNftHoldersData ({ chainId, ...rest }: GetNftHoldersData) { const dataSource = this.dataSourceMapping[chainId] - return dataSource.getNftHoldersData(rest) + return dataSource.getNftInstancesByAddress(rest) } - + async getTokenHoldersByAddress ({ chainId, ...rest }: GetTokenHoldersByAddress) { const dataSource = this.dataSourceMapping[chainId] return dataSource.getTokenHoldersByAddress(rest)