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 b84598c..ee75c74 100644 --- a/src/blockscoutApi/index.ts +++ b/src/blockscoutApi/index.ts @@ -6,16 +6,21 @@ import { TokenInfoResponse, TokenServerResponse, TokenTransferApi, TransactionServerResponse, TransactionsServerResponse, BlockscoutTransactionResponseTxResult, - NftTokenHoldersResponse + NftTokenHoldersResponse, TokenHoldersResponse } from './types' import { fromApiToInternalTransaction, fromApiToNft, fromApiToNftOwner, fromApiToRtbcBalance, fromApiToTEvents, fromApiToTokenWithBalance, fromApiToTokens, fromApiToTransaction, transformResponseToNftHolder } from './utils' -import { GetEventLogsByAddressAndTopic0, GetNftHoldersData } 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 () { @@ -173,11 +182,35 @@ export class BlockscoutAPI extends DataSource { .catch(() => []) } - async getNftHoldersData ({ address, nextPageParams }: GetNftHoldersData) { + async getTokenHoldersByAddress ({ address, nextPageParams }: GetTokenHoldersByAddress) { + try { + const url = `${this.url}/v2/tokens/${address}/holders` + const response = await this.axiosCache.get>(url, + { params: nextPageParams, validateStatus: (status) => status <= 500 }) + if (response?.status === 200) { + return response.data + } + return { + items: [], + next_page_params: null, + error: `Blockscout error with status ${response?.status}` + } + } catch (error) { + console.error(typeof error, error) + return { + items: [], + next_page_params: null, + error: 'Blockscout error' + } + } + } + + 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) { @@ -194,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 f8d35a6..882a1bc 100644 --- a/src/blockscoutApi/types.ts +++ b/src/blockscoutApi/types.ts @@ -324,3 +324,24 @@ export interface NftTokenHoldersTransformedResponse { name: string; } } + +export interface TokenHolderAddress { + ens_domain_name: string; + hash: string; + implementations: any[]; + is_contract: boolean; + is_verified: boolean; + metadata: null; + name: null; + private_tags: any[]; + proxy_type: null; + public_tags: any[]; + watchlist_names: any[]; +} + +export interface TokenHoldersResponse { + address: TokenHolderAddress; + token: Token; + token_id: string; + value: string; +} diff --git a/src/controller/httpsAPI.ts b/src/controller/httpsAPI.ts index 60f3050..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', @@ -193,6 +194,25 @@ export class HttpsAPI { } }) + this.app.get('/address/:address/holders', + async ({ params: { address }, query: { chainId = '31', ...rest } } : Request, res: Response, + nextFunction: NextFunction) => { + try { + chainIdSchema.validateSync({ chainId }) + addressSchema.validateSync({ address }) + const result = await this.addressService + .getTokenHoldersByAddress({ + chainId: chainId as string, + address: address as string, + ...rest + }) + .catch(nextFunction) + return this.responseJsonOk(res)(result) + } catch (e) { + this.handleValidationError(e, res) + } + }) + this.app.get( '/price', async (req: Request<{}, {}, {}, PricesQueryParams>, res: Response) => { 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 be3049c..3bc629a 100644 --- a/src/repository/DataSource.ts +++ b/src/repository/DataSource.ts @@ -1,7 +1,10 @@ import _axios from 'axios' import { ethers } from 'ethers' import BitcoinCore from '../service/bitcoin/BitcoinCore' -import { GetEventLogsByAddressAndTopic0, GetNftHoldersData } from '../service/address/AddressService' +import { + GetEventLogsByAddressAndTopic0, GetNftHoldersData, + GetTokenHoldersByAddress +} from '../service/address/AddressService' export abstract class DataSource { readonly url: string @@ -31,7 +34,8 @@ export abstract class DataSource { abstract getEventLogsByAddressAndTopic0({ address, topic0, toBlock, fromBlock } : Omit); - abstract getNftHoldersData({ address }: Omit); + abstract getNftInstancesByAddress({ address }: Omit); + abstract getTokenHoldersByAddress({ address }: Omit) } export type RSKDatasource = { diff --git a/src/rskExplorerApi/index.ts b/src/rskExplorerApi/index.ts index 0728159..1d6c556 100644 --- a/src/rskExplorerApi/index.ts +++ b/src/rskExplorerApi/index.ts @@ -144,7 +144,11 @@ 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 ab167c9..8b82536 100644 --- a/src/service/address/AddressService.ts +++ b/src/service/address/AddressService.ts @@ -43,6 +43,7 @@ export interface GetEventLogsByAddressAndTopic0 { } export interface GetNftHoldersData { address: string, nextPageParams?: NextPageParams, chainId: string } +export interface GetTokenHoldersByAddress { address: string, nextPageParams?: NextPageParams, chainId: string } type GetBalancesTransactionsPricesByAddress = { chainId: string @@ -158,6 +159,11 @@ 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) } }