diff --git a/.env.xmpl b/.env.xmpl index f52410f..a1039a3 100644 --- a/.env.xmpl +++ b/.env.xmpl @@ -1,5 +1,8 @@ # REQUIRED -#... +MULTICALL_ADDRESS = 0xcA11bde05977b3631167028862bE2a173976CA11 +RPC_URL = https://eth.llamarpc.com + # OPTIONAL -PORT=3000 -LOG_LEVEL='INFO' +PORT = 3000 +LOG_LEVEL = INFO + diff --git a/README.md b/README.md index e0aff4a..083ca07 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ This layers designed to maintain structure as it scales and organized by levels. "logLevel": "info" } ``` +## Tokencheck +`/token` +``` +{ + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "symbol": "USDT", + "decimals": "6", + "totalSupplyWei": "76922650752242969", + "totalSupplyTokens": "76922650752.242969" +} +``` ## Swagger `/swagger` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 737538a..0716c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^8.1.1", + "bignumber.js": "^4.0.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", + "ethers": "^6.13.5", "nest-winston": "^1.10.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -46,6 +48,12 @@ "typescript": "^5.1.3" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1853,6 +1861,30 @@ } } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2730,6 +2762,12 @@ "node": ">=0.4.0" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -3075,6 +3113,15 @@ ], "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-4.0.4.tgz", + "integrity": "sha512-LDXpJKVzEx2/OqNbG9mXBNvHuiRL4PzHCGfnANHMJ+fv68Ads3exDVJeGDJws+AoNEuca93bU3q+S0woeUaCdg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4520,6 +4567,49 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.5.tgz", + "integrity": "sha512-+knKNieu5EKRThQJWwqaJ10a6HE9sSehGeqWN65//wE7j47ZpFhKAnHB/JJFibwwg61I/koxaPsXbXpD/skNOQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -9174,7 +9264,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -9594,6 +9683,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 4b41872..02a307c 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^8.1.1", + "bignumber.js": "^4.0.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", + "ethers": "^6.13.5", "nest-winston": "^1.10.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/src/abis/erc-20.abi.json b/src/abis/erc-20.abi.json new file mode 100644 index 0000000..0efe3d7 --- /dev/null +++ b/src/abis/erc-20.abi.json @@ -0,0 +1,223 @@ +[ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_spender", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "name": "", + "type": "uint8" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "balance", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "payable": true, + "stateMutability": "payable", + "type": "fallback" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + } + ] + \ No newline at end of file diff --git a/src/abis/ethers.abi.multicall.ts b/src/abis/ethers.abi.multicall.ts new file mode 100644 index 0000000..0807d79 --- /dev/null +++ b/src/abis/ethers.abi.multicall.ts @@ -0,0 +1,3 @@ +export const MULTICALL_ABI = [ + 'function aggregate(tuple(address target, bytes callData)[] calls) view returns (uint256 blockNumber, bytes[] returnData)', +]; diff --git a/src/abis/index.ts b/src/abis/index.ts new file mode 100644 index 0000000..bd75035 --- /dev/null +++ b/src/abis/index.ts @@ -0,0 +1,5 @@ +import ERC20_ABI from './erc-20.abi.json'; +import { MULTICALL_ABI } from './ethers.abi.multicall'; + +export const erc20Abi = ERC20_ABI; +export const multicallAbi = MULTICALL_ABI; diff --git a/src/apis/token/index.ts b/src/apis/token/index.ts new file mode 100644 index 0000000..0244c07 --- /dev/null +++ b/src/apis/token/index.ts @@ -0,0 +1,2 @@ +export * from './token.api'; +export * from './token.controller'; diff --git a/src/apis/token/token-data.dto.ts b/src/apis/token/token-data.dto.ts new file mode 100644 index 0000000..e5468e3 --- /dev/null +++ b/src/apis/token/token-data.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; +import { TokenData } from '@/entities/token'; + +@Exclude() +export class TokenDataDto implements TokenData { + @Expose() + @IsString() + @ApiProperty({ + type: String, + example: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + }) + tokenAddress: string; + + @Expose() + @IsString() + @ApiProperty({ type: String, example: 'USDT' }) + symbol: string; + + @Expose() + @IsString() + @ApiProperty({ type: String, example: '6' }) + decimals: string; + + @Expose() + @IsString() + @ApiProperty({ type: String, example: '76922650752242969' }) + totalSupplyWei: string; + + @Expose() + @IsString() + @ApiProperty({ type: String, example: '76922650752.242969' }) + totalSupplyTokens: string; +} diff --git a/src/apis/token/token.api.ts b/src/apis/token/token.api.ts new file mode 100644 index 0000000..20f11fc --- /dev/null +++ b/src/apis/token/token.api.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TokenModule } from '@/services/token'; +import { TokenController } from './token.controller'; + +@Module({ + imports: [TokenModule], + controllers: [TokenController], +}) +export class TokenApi {} diff --git a/src/apis/token/token.controller.ts b/src/apis/token/token.controller.ts new file mode 100644 index 0000000..57dac29 --- /dev/null +++ b/src/apis/token/token.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, SerializeOptions, Param } from '@nestjs/common'; +import { TokenService } from '@/services/token'; +import { ApiResponse, ApiParam } from '@nestjs/swagger'; +import { TokenData } from '@/entities/token'; +import { TokenDataDto } from './token-data.dto'; + +@Controller('token') +export class TokenController { + constructor(private readonly token: TokenService) {} + + @Get('/:tokenAddress') + @ApiResponse({ status: 200, type: TokenDataDto }) + @SerializeOptions({ type: TokenDataDto }) + @ApiParam({ + name: 'tokenAddress', + required: true, + description: 'Token Address', + example: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + }) + getTokenData( + @Param('tokenAddress') tokenAddress: string, + ): Promise { + return this.token.getTokenData(tokenAddress); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index e3e5be7..73329aa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@/config'; import { HealthApi } from './apis/health'; +import { TokenApi } from './apis/token'; @Module({ - imports: [ConfigModule, HealthApi], + imports: [ConfigModule, HealthApi, TokenApi], }) export class AppModule {} diff --git a/src/config/static-config.ts b/src/config/static-config.ts index 5e1e46d..089a17f 100644 --- a/src/config/static-config.ts +++ b/src/config/static-config.ts @@ -1,7 +1,17 @@ import * as dotenv from 'dotenv'; dotenv.config(); +const missEnvError = (envName) => + new Error(`Required ENV not found: ${envName}`); + +const multicallAddress = process.env.MULTICALL_ADDRESS; +if (!multicallAddress) throw missEnvError('CONTRACT_ADDRESS'); + +const rpcUrl = process.env.RPC_URL; +if (!multicallAddress) throw missEnvError('RPC_URL'); + const port = process.env.PORT ? parseInt(process.env.PORT) : 3000; + const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL.toLowerCase() : 'info'; @@ -14,4 +24,6 @@ const logLevel = process.env.LOG_LEVEL export const staticConfig = { port, logLevel, + multicallAddress, + rpcUrl, }; diff --git a/src/entities/token/index.ts b/src/entities/token/index.ts new file mode 100644 index 0000000..cd38f62 --- /dev/null +++ b/src/entities/token/index.ts @@ -0,0 +1 @@ +export * from './token-data'; diff --git a/src/entities/token/token-data.ts b/src/entities/token/token-data.ts new file mode 100644 index 0000000..e844966 --- /dev/null +++ b/src/entities/token/token-data.ts @@ -0,0 +1,7 @@ +export interface TokenData { + tokenAddress: string; + symbol: string; + decimals: string; + totalSupplyWei: string; + totalSupplyTokens: string; +} diff --git a/src/services/token/index.ts b/src/services/token/index.ts new file mode 100644 index 0000000..f6d0f63 --- /dev/null +++ b/src/services/token/index.ts @@ -0,0 +1,2 @@ +export * from './token.module'; +export * from './token.service'; diff --git a/src/services/token/token.module.ts b/src/services/token/token.module.ts new file mode 100644 index 0000000..d69fa97 --- /dev/null +++ b/src/services/token/token.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TokenService } from './token.service'; + +@Module({ + providers: [TokenService], + exports: [TokenService], +}) +export class TokenModule {} diff --git a/src/services/token/token.service.ts b/src/services/token/token.service.ts new file mode 100644 index 0000000..d4b23ce --- /dev/null +++ b/src/services/token/token.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TokenData } from '@/entities/token'; +import { ethers } from 'ethers'; +import BigNumber from 'bignumber.js'; +import { erc20Abi, multicallAbi } from '@/abis'; + +@Injectable() +export class TokenService { + private readonly contractInterface: ethers.Interface = new ethers.Interface( + erc20Abi, + ); + private readonly logger = new Logger(TokenService.name); + + constructor(private readonly config: ConfigService) {} + + private unpackMulticall( + returnData: ethers.BytesLike[], + ): Record { + return { + symbol: this.contractInterface + .decodeFunctionResult('symbol', returnData[0])[0] + .toString(), + decimals: this.contractInterface + .decodeFunctionResult('decimals', returnData[1])[0] + .toString(), + totalSupplyWei: this.contractInterface + .decodeFunctionResult('totalSupply', returnData[2])[0] + .toString(), + }; + } + + private weiToToken(wei: string, decimals: string): string { + const weiBigN: BigNumber = new BigNumber(wei); + const decimalsNum: number = Number(decimals); + return weiBigN + .dividedBy(new BigNumber(10).pow(decimalsNum)) + .toFixed(decimalsNum); + } + + private async fetchTokenData(tokenAddress: string) { + try { + const multicall = new ethers.Contract( + this.config.getOrThrow('multicallAddress'), + multicallAbi, + new ethers.JsonRpcProvider(this.config.getOrThrow('rpcUrl')), + ); + + const calls = [ + { + target: tokenAddress, + callData: this.contractInterface.encodeFunctionData('symbol'), + }, + { + target: tokenAddress, + callData: this.contractInterface.encodeFunctionData('decimals'), + }, + { + target: tokenAddress, + callData: this.contractInterface.encodeFunctionData('totalSupply'), + }, + ]; + + const [, returnData] = await multicall.aggregate(calls); + + const decodedData = this.unpackMulticall(returnData); + + return { + tokenAddress: tokenAddress, + symbol: decodedData.symbol, + decimals: decodedData.decimals, + totalSupplyWei: decodedData.totalSupplyWei, + totalSupplyTokens: this.weiToToken( + decodedData.totalSupplyWei, + decodedData.decimals, + ), + }; + } catch (error) { + const message = `Error fetching token data: ${error.message}`; + this.logger.error(message); + throw new Error(message); + } + } + + public getTokenData(tokenAddress: string): Promise { + if (!tokenAddress) { + throw new Error(`Missing token address`); + } + return this.fetchTokenData(tokenAddress); + } +} diff --git a/tsconfig.json b/tsconfig.json index 5173054..cbf8bfc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, + "resolveJsonModule": true, + "esModuleInterop": true, "paths": { "@/*": ["./src/*"], "@tests/*": ["./test/*"]