├── backend
├── src
│ ├── config
│ │ ├── index.ts
│ │ ├── environments
│ │ │ ├── index.ts
│ │ │ ├── .env.example
│ │ │ └── env.interface.ts
│ │ └── swagger.config.ts
│ ├── modules
│ │ ├── coins
│ │ │ ├── index.ts
│ │ │ └── chainlink
│ │ │ │ ├── contract
│ │ │ │ ├── index.ts
│ │ │ │ ├── chainlink-token.address.ts
│ │ │ │ └── chainlink-token.abi.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── dto
│ │ │ │ ├── index.ts
│ │ │ │ ├── get-balance-param.dto.ts
│ │ │ │ ├── get-balance-response.dto.ts
│ │ │ │ ├── get-price-response.dto..ts
│ │ │ │ ├── get-total-supply-response.dto.ts
│ │ │ │ └── get-details-response.dto.ts
│ │ │ │ ├── chainlink.service.ts
│ │ │ │ ├── chainlink.module.ts
│ │ │ │ ├── chainlink.service.spec.ts
│ │ │ │ ├── chainlink.controller.ts
│ │ │ │ └── chainlink.controller.spec.ts
│ │ └── ethers
│ │ │ ├── contract
│ │ │ ├── index.ts
│ │ │ └── chainlink-price-feed.abi.ts
│ │ │ ├── index.ts
│ │ │ ├── ethers.config.interface.ts
│ │ │ ├── ethers.module.ts
│ │ │ └── ethers.service.ts
│ ├── common
│ │ ├── middleware
│ │ │ ├── index.ts
│ │ │ └── logger.middleware.ts
│ │ └── exception-filters
│ │ │ ├── index.ts
│ │ │ ├── http-exception.utils.ts
│ │ │ └── http-exception.filter.ts
│ ├── app.service.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ └── main.ts
├── .prettierrc
├── tsconfig.build.json
├── nest-cli.json
├── vercel.json
├── tsconfig.json
├── .eslintrc.js
├── .gitignore
├── LICENCE
├── README.md
└── package.json
├── frontend
├── src
│ ├── config
│ │ ├── index.ts
│ │ └── Web3Provider.tsx
│ ├── vite-env.d.ts
│ ├── components
│ │ ├── ui
│ │ │ ├── index.ts
│ │ │ ├── layouts
│ │ │ │ ├── index.ts
│ │ │ │ └── AppLayout.tsx
│ │ │ ├── buttons
│ │ │ │ ├── index.ts
│ │ │ │ ├── ConnectWalletButton.tsx
│ │ │ │ └── Button.tsx
│ │ │ └── Modal.tsx
│ │ ├── guards
│ │ │ ├── index.ts
│ │ │ └── PrivateRoutesGuard.tsx
│ │ ├── index.ts
│ │ ├── Header.tsx
│ │ └── Footer.tsx
│ ├── pages
│ │ ├── Dashboard
│ │ │ ├── hooks
│ │ │ │ ├── index.ts
│ │ │ │ └── useFetchTokenData.tsx
│ │ │ ├── components
│ │ │ │ ├── index.ts
│ │ │ │ ├── TokenCard.tsx
│ │ │ │ └── TokenInfoModal.tsx
│ │ │ ├── types.ts
│ │ │ └── index.tsx
│ │ ├── index.ts
│ │ ├── Home.tsx
│ │ └── NotFound.tsx
│ ├── utils
│ │ ├── routes.ts
│ │ └── index.ts
│ ├── index.css
│ ├── main.tsx
│ └── App.tsx
├── vercel.json
├── postcss.config.js
├── .env.example
├── tailwind.config.js
├── vite.config.ts
├── tsconfig.node.json
├── index.html
├── .eslintrc.cjs
├── .gitignore
├── public
│ └── ethereum-logo.svg
├── tsconfig.json
├── LICENCE
├── README.md
└── package.json
└── README.md
/backend/src/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './swagger.config';
2 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/index.ts:
--------------------------------------------------------------------------------
1 | export * from './chainlink';
2 |
--------------------------------------------------------------------------------
/frontend/src/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Web3Provider';
2 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/backend/src/common/middleware/index.ts:
--------------------------------------------------------------------------------
1 | export * from './logger.middleware';
2 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Modal } from './Modal'
2 |
--------------------------------------------------------------------------------
/backend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/backend/src/common/exception-filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './http-exception.filter';
2 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/contract/index.ts:
--------------------------------------------------------------------------------
1 | export * from './chainlink-price-feed.abi';
2 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/layouts/index.ts:
--------------------------------------------------------------------------------
1 | export { default as AppLayout } from './AppLayout';
2 |
--------------------------------------------------------------------------------
/frontend/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
3 | }
4 |
--------------------------------------------------------------------------------
/backend/src/config/environments/index.ts:
--------------------------------------------------------------------------------
1 | export { default as EnvironmentVariables } from './env.interface';
2 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ethers.module';
2 | export * from './ethers.service';
3 |
--------------------------------------------------------------------------------
/frontend/src/components/guards/index.ts:
--------------------------------------------------------------------------------
1 | export { default as PrivateRoutesGuard } from './PrivateRoutesGuard';
2 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useFetchTokenData } from './useFetchTokenData'
2 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Footer } from './Footer'
2 | export { default as Header } from './Header'
3 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/contract/index.ts:
--------------------------------------------------------------------------------
1 | export * from './chainlink-token.abi';
2 | export * from './chainlink-token.address';
3 |
--------------------------------------------------------------------------------
/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | # API
2 | VITE_API_URL=
3 |
4 | # Ethereum
5 | VITE_RPC_PROVIDER_URL=
6 |
7 | # WalletConnect
8 | VITE_WALLETCONNECT_PROJECT_ID=
9 |
--------------------------------------------------------------------------------
/backend/src/config/environments/.env.example:
--------------------------------------------------------------------------------
1 | # App Config
2 | APP=ERC20WalletWatcher-API
3 | PORT=8000
4 | VERSION=1.0
5 |
6 | # Ethereum
7 | RPC_PROVIDER_URL=
8 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as TokenCard } from './TokenCard'
2 | export { default as TokenInfoModal } from './TokenInfoModal'
3 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/index.ts:
--------------------------------------------------------------------------------
1 | export * from './chainlink.controller';
2 | export * from './chainlink.module';
3 | export * from './chainlink.service';
4 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/buttons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Button } from './Button';
2 | export { default as ConnectWalletButton } from './ConnectWalletButton';
3 |
--------------------------------------------------------------------------------
/frontend/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Dashboard } from './Dashboard'
2 | export { default as Home } from './Home'
3 | export { default as NotFound } from './NotFound'
4 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/ethers.config.interface.ts:
--------------------------------------------------------------------------------
1 | export interface EthersModuleOptions {
2 | tokenAddress: string;
3 | tokenAbi: any[];
4 | priceFeedAddress: string;
5 | }
6 |
--------------------------------------------------------------------------------
/backend/src/config/environments/env.interface.ts:
--------------------------------------------------------------------------------
1 | export default interface EnvironmentVariables {
2 | APP: string;
3 | PORT: number;
4 | VERSION: string;
5 | RPC_PROVIDER_URL: string;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/utils/routes.ts:
--------------------------------------------------------------------------------
1 | export const PublicRoutes = {
2 | HOME: '/',
3 | NOT_FOUND: '*'
4 | } as const
5 |
6 | export const PrivateRoutes = {
7 | DASHBOARD: '/dashboard'
8 | } as const
9 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/types.ts:
--------------------------------------------------------------------------------
1 | export type CoinsApiUrlSegment = 'chainlink'
2 |
3 | export enum CoinsApiResource {
4 | TotalSupply = 'total-supply',
5 | Balance = 'balance',
6 | Details = 'details'
7 | }
8 |
--------------------------------------------------------------------------------
/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {}
6 | },
7 | plugins: []
8 | }
9 |
--------------------------------------------------------------------------------
/backend/src/app.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 |
3 | @Injectable()
4 | export class AppService {
5 | getHealthCheck() {
6 | return { statusCode: 200, message: 'server is alive' };
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | html {
7 | font-family: 'Roboto', system-ui, sans-serif;
8 | background-color: #05040c;
9 | }
10 | }
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/contract/chainlink-token.address.ts:
--------------------------------------------------------------------------------
1 | export const chainlinkTokenAddress =
2 | '0x779877a7b0d9e8603169ddbd7836e478b4624789';
3 |
4 | export const chainlinkUsdPriceFeedAddress =
5 | '0xc59E3633BAAC79493d908e63626716e204A45EdF';
6 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-balance-param.dto';
2 | export * from './get-balance-response.dto';
3 | export * from './get-details-response.dto';
4 | export * from './get-price-response.dto.';
5 | export * from './get-total-supply-response.dto';
6 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | '@': '/src'
10 | }
11 | }
12 | })
13 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/backend/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "builds": [
4 | {
5 | "src": "dist/main.js",
6 | "use": "@vercel/node"
7 | }
8 | ],
9 | "routes": [
10 | {
11 | "src": "/(.*)",
12 | "dest": "dist/main.js",
13 | "methods": ["GET", "POST", "PUT", "DELETE"]
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/get-balance-param.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString, Matches } from 'class-validator';
2 |
3 | export class GetBalanceParamDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | @Matches(/^0x[a-fA-F0-9]{40}$/, {
7 | message: 'address must be a valid Ethereum wallet address',
8 | })
9 | address: string;
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/layouts/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Footer, Header } from '@/components'
2 |
3 | type Props = {
4 | children: React.ReactNode
5 | }
6 |
7 | export default function AppLayout({ children }: Props) {
8 | return (
9 | <>
10 |
11 | {children}
12 |
13 | >
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export const formatAddress = (address: string | undefined): string => {
2 | if (address && address.length > 9) {
3 | return `${address.substring(0, 5)}....${address.substring(address.length - 4)}`
4 | }
5 | return address ?? ''
6 | }
7 |
8 | export const formatCaseString = (str: string): string => {
9 | return str.replace(/-/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2')
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/components/guards/PrivateRoutesGuard.tsx:
--------------------------------------------------------------------------------
1 | import { PublicRoutes } from '@/utils/routes'
2 | import { Navigate, Outlet } from 'react-router-dom'
3 | import { useAccount } from 'wagmi'
4 |
5 | export default function PrivateRoutesGuard() {
6 | const { address } = useAccount()
7 |
8 | if (!address) {
9 | return
10 | }
11 |
12 | return
13 | }
14 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/get-balance-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class GetBalanceResponseDto {
4 | @ApiProperty({
5 | description: 'LINK balance of the wallet',
6 | example: 10,
7 | })
8 | balance: number;
9 |
10 | @ApiProperty({
11 | description: 'Equivalent balance in USD',
12 | example: 1000,
13 | })
14 | balanceInUSD: number;
15 | }
16 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/get-price-response.dto..ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class GetPriceResponseDto {
4 | @ApiProperty({
5 | description: 'Current token price, subject to market changes',
6 | example: 10,
7 | })
8 | price: number;
9 |
10 | @ApiProperty({
11 | description: 'Currency code for the price',
12 | example: 'USD',
13 | })
14 | unit: string;
15 | }
16 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/get-total-supply-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class GetTotalSupplyResponseDto {
4 | @ApiProperty({
5 | description: 'Total LINK supply',
6 | example: 10,
7 | })
8 | totalSupply: number;
9 |
10 | @ApiProperty({
11 | description: 'Total LINK supply in USD',
12 | example: 1000,
13 | })
14 | totalSupplyInUSD: number;
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Erc20 Wallet Watcher
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { BrowserRouter } from 'react-router-dom'
4 | import App from './App.tsx'
5 | import { Web3Provider } from './config'
6 | import './index.css'
7 |
8 | ReactDOM.createRoot(document.getElementById('root')!).render(
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Yarn
27 | .yarn/*
28 | !.yarn/cache
29 | !.yarn/patches
30 | !.yarn/plugins
31 | !.yarn/releases
32 | !.yarn/sdks
33 | !.yarn/versions
34 |
35 | # env
36 | .env
37 |
--------------------------------------------------------------------------------
/frontend/public/ethereum-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/common/middleware/logger.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
2 | import { Request, Response, NextFunction } from 'express';
3 |
4 | @Injectable()
5 | export class LoggerMiddleware implements NestMiddleware {
6 | logger: Logger;
7 | constructor() {
8 | this.logger = new Logger('LoggerMiddleware');
9 | }
10 | use(req: Request, res: Response, next: NextFunction) {
11 | this.logger.log(
12 | `[Request] url: ${req.baseUrl + req.url}, method: ${req.method}, body: ${req.body}`,
13 | );
14 | next();
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/backend/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
3 | import { AppService } from './app.service';
4 |
5 | @ApiTags('App')
6 | @Controller()
7 | export class AppController {
8 | constructor(private readonly appService: AppService) {}
9 |
10 | @Get('health-check')
11 | @ApiOperation({
12 | description: 'API call to check if the server is alive.',
13 | })
14 | @ApiOkResponse({
15 | description: 'The server is alive',
16 | })
17 | getHealthCheck() {
18 | return this.appService.getHealthCheck();
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "ES2021",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { ConnectWalletButton } from './ui/buttons'
2 |
3 | export default function Header() {
4 | return (
5 |
6 |
7 |
8 |

9 |
ERC20 Wallet Watcher
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/chainlink.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { EthersService } from 'src/modules/ethers';
3 |
4 | @Injectable()
5 | export class ChainlinkService {
6 | constructor(private ethersService: EthersService) {}
7 |
8 | getBalance(address: string) {
9 | return this.ethersService.getTokenBalance(address);
10 | }
11 |
12 | getTotalSupply() {
13 | return this.ethersService.getTokenTotalSupply();
14 | }
15 |
16 | getUsdPrice() {
17 | return this.ethersService.getTokenUsdPrice();
18 | }
19 |
20 | getDetails() {
21 | return this.ethersService.getTokenDetails();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/dto/get-details-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class GetDetailsResponseDto {
4 | @ApiProperty({
5 | description: 'Name of the token',
6 | example: 'Chainlink Token',
7 | })
8 | name: string;
9 |
10 | @ApiProperty({
11 | description: "Token's symbol",
12 | example: 'LINK',
13 | })
14 | symbol: string;
15 |
16 | @ApiProperty({
17 | description: 'Number of decimal places the token uses',
18 | example: 18,
19 | })
20 | decimals: number;
21 |
22 | @ApiProperty({
23 | description: "Token's total supply",
24 | example: 1000,
25 | })
26 | totalSupply: number;
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/buttons/ConnectWalletButton.tsx:
--------------------------------------------------------------------------------
1 | import { formatAddress } from '@/utils'
2 | import { ConnectKitButton } from 'connectkit'
3 | import Button from './Button'
4 |
5 | type Props = {
6 | className?: string
7 | children?: React.ReactNode
8 | }
9 |
10 | export default function ConnectWalletButton({ className, children }: Props) {
11 | return (
12 |
13 | {({ isConnected, show, address }) => {
14 | return (
15 |
18 | )
19 | }}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes } from 'react-router-dom'
2 | import { PrivateRoutesGuard } from './components/guards'
3 | import { AppLayout } from './components/ui/layouts'
4 | import { Dashboard, Home, NotFound } from './pages'
5 | import { PrivateRoutes, PublicRoutes } from './utils/routes'
6 |
7 | export default function App() {
8 | return (
9 |
10 |
11 | } />
12 | } />
13 | }>
14 | } />
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/chainlink.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { EthersModule } from 'src/modules/ethers';
4 |
5 | import { ChainlinkController } from './chainlink.controller';
6 | import { ChainlinkService } from './chainlink.service';
7 | import {
8 | chainlinkTokenABI,
9 | chainlinkTokenAddress,
10 | chainlinkUsdPriceFeedAddress,
11 | } from './contract';
12 |
13 | @Module({
14 | imports: [
15 | EthersModule.forRootAsync({
16 | tokenAddress: chainlinkTokenAddress,
17 | tokenAbi: chainlinkTokenABI,
18 | priceFeedAddress: chainlinkUsdPriceFeedAddress,
19 | }),
20 | ],
21 | controllers: [ChainlinkController],
22 | providers: [ChainlinkService],
23 | })
24 | export class ChainlinkModule {}
25 |
--------------------------------------------------------------------------------
/backend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js'],
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | '@typescript-eslint/explicit-module-boundary-types': 'off',
23 | '@typescript-eslint/no-explicit-any': 'off',
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | },
12 |
13 | /* Bundler mode */
14 | "moduleResolution": "bundler",
15 | "allowImportingTsExtensions": true,
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "noEmit": true,
19 | "jsx": "react-jsx",
20 |
21 | /* Linting */
22 | "strict": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noFallthroughCasesInSwitch": true
26 | },
27 | "include": ["src"],
28 | "references": [{ "path": "./tsconfig.node.json" }]
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { ConnectWalletButton } from '@/components/ui/buttons'
2 | import { PrivateRoutes } from '@/utils/routes'
3 | import { Navigate } from 'react-router-dom'
4 | import { useAccount } from 'wagmi'
5 |
6 | export default function Home() {
7 | const { address } = useAccount()
8 |
9 | if (address) {
10 | return
11 | }
12 |
13 | return (
14 |
15 | Empower Your Crypto Experience
16 | Effortlessly Connect, Analyze, and Optimize Your Digital Assets.
17 |
18 | Connect Your Wallet & Start Now!
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/backend/src/common/exception-filters/http-exception.utils.ts:
--------------------------------------------------------------------------------
1 | import type { HttpException } from '@nestjs/common';
2 |
3 | export const getExceptionErrorMessage = (
4 | exception: HttpException | any,
5 | ): string => {
6 | if (exception.response && typeof exception.response === 'object') {
7 | if (Array.isArray(exception.response.message)) {
8 | return exception.response.message.join(', ');
9 | }
10 | return exception.response.message || 'Internal server error';
11 | }
12 | return exception.message || 'Internal server error';
13 | };
14 |
15 | export const getExceptionError = (exception: HttpException | any): string => {
16 | if (exception.response && typeof exception.response === 'object') {
17 | return exception.response.error || 'Internal server error';
18 | }
19 | return exception.message || 'Internal server error';
20 | };
21 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/chainlink.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ChainlinkService } from './chainlink.service';
3 | import { EthersService } from 'src/modules/ethers';
4 |
5 | describe('ChainlinkService', () => {
6 | let service: ChainlinkService;
7 |
8 | beforeEach(async () => {
9 | const module: TestingModule = await Test.createTestingModule({
10 | providers: [
11 | ChainlinkService,
12 | {
13 | provide: EthersService,
14 | useValue: {
15 | getTokenBalance: jest.fn(),
16 | getTokenTotalSupply: jest.fn(),
17 | getTokenUsdPrice: jest.fn(),
18 | getTokenDetails: jest.fn(),
19 | },
20 | },
21 | ],
22 | }).compile();
23 |
24 | service = module.get(ChainlinkService);
25 | });
26 |
27 | it('should be defined', () => {
28 | expect(service).toBeDefined();
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/frontend/src/pages/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { PublicRoutes } from '@/utils/routes'
2 | import { Link } from 'react-router-dom'
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
404
8 |
Page not found
9 |
Sorry, we couldn’t find the page you’re looking for.
10 |
11 |
15 | Go back home
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 | /build
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | pnpm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 |
15 | # OS
16 | .DS_Store
17 |
18 | # Tests
19 | /coverage
20 | /.nyc_output
21 |
22 | # IDEs and editors
23 | /.idea
24 | .project
25 | .classpath
26 | .c9/
27 | *.launch
28 | .settings/
29 | *.sublime-workspace
30 |
31 | # IDE - VSCode
32 | .vscode/*
33 | !.vscode/settings.json
34 | !.vscode/tasks.json
35 | !.vscode/launch.json
36 | !.vscode/extensions.json
37 |
38 | # dotenv environment variable files
39 | src/config/environments/.env
40 |
41 |
42 | # temp directory
43 | .temp
44 | .tmp
45 |
46 | # Runtime data
47 | pids
48 | *.pid
49 | *.seed
50 | *.pid.lock
51 |
52 | # Diagnostic reports (https://nodejs.org/api/report.html)
53 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
54 |
55 | # Yarn
56 | .yarn/*
57 | !.yarn/cache
58 | !.yarn/patches
59 | !.yarn/plugins
60 | !.yarn/releases
61 | !.yarn/sdks
62 | !.yarn/versions
63 | .vercel
64 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/buttons/Button.tsx:
--------------------------------------------------------------------------------
1 | import { twMerge } from 'tailwind-merge'
2 |
3 | interface Props extends React.ButtonHTMLAttributes {
4 | className?: string
5 | children?: React.ReactNode
6 | color?: 'blue-gradient' | 'indigo'
7 | size?: 'small' | 'medium' | 'large'
8 | }
9 |
10 | const buttonColors = {
11 | 'blue-gradient': 'bg-gradient-to-br from-purple-600 to-blue-500 hover:bg-gradient-to-bl',
12 | indigo: 'bg-indigo-600 hover:bg-indigo-700'
13 | }
14 |
15 | const buttonSizes = {
16 | small: 'px-2 py-1 text-xs',
17 | medium: 'px-3 py-2.5 text-sm',
18 | large: 'px-4 py-3 text-base'
19 | }
20 |
21 | export default function Button({ className, children, color = 'blue-gradient', size = 'medium', ...rest }: Props) {
22 | const buttonClassNames = twMerge(
23 | 'text-white font-medium rounded-full text-center',
24 | buttonColors[color],
25 | buttonSizes[size],
26 | className
27 | )
28 | return (
29 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { RouterModule } from '@nestjs/core';
4 |
5 | import { LoggerMiddleware } from './common/middleware';
6 | import { ChainlinkModule } from './modules/coins';
7 |
8 | import { AppController } from './app.controller';
9 | import { AppService } from './app.service';
10 |
11 | @Module({
12 | imports: [
13 | ConfigModule.forRoot({
14 | envFilePath: `${process.cwd()}/src/config/environments/.env`,
15 | isGlobal: true,
16 | }),
17 | ChainlinkModule,
18 | RouterModule.register([
19 | {
20 | path: 'coins',
21 | children: [
22 | {
23 | path: 'chainlink',
24 | module: ChainlinkModule,
25 | },
26 | ],
27 | },
28 | ]),
29 | ],
30 | controllers: [AppController],
31 | providers: [AppService],
32 | })
33 | export class AppModule implements NestModule {
34 | configure(consumer: MiddlewareConsumer) {
35 | consumer.apply(LoggerMiddleware).forRoutes('');
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/backend/src/config/swagger.config.ts:
--------------------------------------------------------------------------------
1 | import { ConfigService } from '@nestjs/config';
2 | import type { NestExpressApplication } from '@nestjs/platform-express';
3 | import {
4 | DocumentBuilder,
5 | SwaggerModule,
6 | type SwaggerCustomOptions,
7 | } from '@nestjs/swagger';
8 |
9 | import type { EnvironmentVariables } from './environments';
10 |
11 | export function setupSwagger(
12 | app: NestExpressApplication,
13 | configService: ConfigService,
14 | ) {
15 | const APP = configService.get('APP', { infer: true });
16 | const VERSION = configService.get('VERSION', { infer: true });
17 |
18 | const config = new DocumentBuilder()
19 | .setTitle(APP)
20 | .setDescription(`${APP} - API Documentation`)
21 | .setVersion(VERSION)
22 | .build();
23 |
24 | const document = SwaggerModule.createDocument(app, config);
25 |
26 | const customOptions: SwaggerCustomOptions = {
27 | swaggerOptions: {
28 | filter: true,
29 | persistAuthorization: true,
30 | tagsSorter: 'alpha',
31 | },
32 | };
33 |
34 | SwaggerModule.setup('api-docs', app, document, customOptions);
35 | }
36 |
--------------------------------------------------------------------------------
/backend/LICENCE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Alex Muñoz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/frontend/LICENCE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Alex Muñoz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ERC20 Wallet Watcher
2 |
3 | This project consists of a backend service built with NestJS to interact with Ethereum smart contracts and a React frontend that provides a user interface for interacting with ERC20 tokens.
4 |
5 | ## Folder Structure
6 |
7 | - `/backend`: Contains all the server-side code including API endpoints, service logic, and configuration for interacting with Ethereum.
8 | - `/frontend`: Contains all the client-side code including the React application setup, components, UI, and configuration for web3 interactions.
9 |
10 | ## Getting Started
11 |
12 | To get the project up and running on your local machine, follow these steps:
13 |
14 | 1. Clone the repository:
15 |
16 | ```bash
17 | git clone https://github.com/alexmf91/erc20-wallet-watcher.git
18 | ```
19 |
20 | 2. Navigate into the project directory:
21 |
22 | ```bash
23 | cd erc20-wallet-watcher
24 | ```
25 |
26 | 3. Follow the README instructions in both `/backend` and `/frontend` directories to set up each part of the project.
27 |
28 | ## License
29 |
30 | This project is licensed under the MIT License - see the `LICENSE` file for details.
31 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Frontend - ERC20 Wallet Watcher
2 |
3 | This is the React-based frontend for the ERC20 Wallet Watcher. It provides a user interface for interacting with ERC20 tokens through the backend service.
4 |
5 | ## Installation
6 |
7 | 1. Ensure you have Node.js and Yarn installed.
8 | 2. Install dependencies:
9 |
10 | ```bash
11 | yarn install
12 | ```
13 |
14 | 3. Copy `.env.example` to `.env` and fill in your environment variables
15 |
16 | 4. Start the development server:
17 |
18 | ```bash
19 | yarn dev
20 | ```
21 |
22 | ## Structure
23 |
24 | - `src/`: Source code including components, pages, and hooks.
25 | - `src/components/`: Reusable components.
26 | - `src/pages/`: Page components for routing.
27 | - `src/utils/`: Utility functions.
28 |
29 | ## Styling
30 |
31 | This project uses TailwindCSS for styling.
32 |
33 | ## Blockchain Interaction
34 |
35 | This project integrates with Ethereum blockchain using wagmi, viem, and ConnectKit to provide a seamless experience in interacting with ERC20 tokens.
36 |
37 | ## Live Version
38 |
39 | Experience the live version of the app at [ERC20 Wallet Watcher](https://erc20-wallet-watcher.vercel.app).
40 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@tanstack/react-query": "^5.28.8",
14 | "connectkit": "^1.7.2",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "react-router-dom": "^6.22.3",
18 | "tailwind-merge": "^2.2.2",
19 | "viem": "2.x",
20 | "wagmi": "^2.5.12"
21 | },
22 | "devDependencies": {
23 | "@types/react": "^18.2.66",
24 | "@types/react-dom": "^18.2.22",
25 | "@typescript-eslint/eslint-plugin": "^7.2.0",
26 | "@typescript-eslint/parser": "^7.2.0",
27 | "@vitejs/plugin-react": "^4.2.1",
28 | "autoprefixer": "^10.4.19",
29 | "eslint": "^8.57.0",
30 | "eslint-plugin-react-hooks": "^4.6.0",
31 | "eslint-plugin-react-refresh": "^0.4.6",
32 | "postcss": "^8.4.38",
33 | "tailwindcss": "^3.4.1",
34 | "typescript": "^5.2.2",
35 | "vite": "^5.2.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/config/Web3Provider.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2 | import { ConnectKitProvider, getDefaultConfig } from 'connectkit'
3 | import { WagmiProvider, createConfig, http } from 'wagmi'
4 | import { sepolia } from 'wagmi/chains'
5 |
6 | const config = createConfig(
7 | getDefaultConfig({
8 | walletConnectProjectId: import.meta.env.VITE_WALLETCONNECT_PROJECT_ID,
9 | chains: [sepolia],
10 | transports: {
11 | [sepolia.id]: http(import.meta.env.VITE_RPC_PROVIDER_URL)
12 | },
13 | appName: 'Erc20 Wallet Watcher',
14 | appDescription: 'Track your ERC20 tokens with ease.',
15 | appUrl: 'https://erc20-wallet-watcher.vercel.app',
16 | appIcon: 'https://erc20-wallet-watcher.vercel.app/ethereum-logo.svg'
17 | })
18 | )
19 |
20 | const queryClient = new QueryClient()
21 |
22 | export const Web3Provider = ({ children }: { children: React.ReactNode }) => {
23 | return (
24 |
25 |
26 | {children}
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Backend - ERC20 Wallet Watcher API
2 |
3 | This is the api service for the ERC20 Wallet Watcher, built with NestJS. It provides API endpoints for interacting with Ethereum smart contracts.
4 |
5 | ## Installation
6 |
7 | 1. Ensure you have Node.js and Yarn installed.
8 | 2. Install dependencies:
9 |
10 | ```bash
11 | yarn install
12 | ```
13 |
14 | 3. Copy `.env.example` to `.env` and fill in your environment variables
15 |
16 | 4. Start the development server:
17 |
18 | ```bash
19 | yarn start:dev
20 | ```
21 |
22 | ## Structure
23 |
24 | - `src/`: Source code including controllers, services, and modules.
25 | - `src/config/`: Configuration files, including environment and swagger configuration.
26 | - `src/modules/`: Modularized code for different domains, e.g., `coins` and `ethers`.
27 |
28 | ## API Documentation
29 |
30 | Swagger API documentation is available at `/api-docs` when the server is running.
31 |
32 | ## Testing
33 |
34 | Run tests using:
35 |
36 | ```bash
37 | yarn test
38 | ```
39 |
40 | ## Online API Documentation
41 |
42 | For a live version of the API documentation, visit [ERC20 Wallet Watcher API Docs](https://erc20-wallet-watcher-xqion.ondigitalocean.app/api-docs).
43 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/hooks/useFetchTokenData.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react'
2 | import { CoinsApiResource, type CoinsApiUrlSegment } from '../types'
3 |
4 | const API_URL = import.meta.env.VITE_API_URL
5 |
6 | export default function useFetchTokenData() {
7 | const [data, setData] = useState<{ [key: string]: string | number } | undefined>()
8 | const [isLoading, setIsLoading] = useState(false)
9 | const [error, setError] = useState()
10 |
11 | const fetchTokenData = useCallback(
12 | async (path: CoinsApiUrlSegment, resource: CoinsApiResource, address?: string): Promise => {
13 | setIsLoading(true)
14 | try {
15 | const response = await fetch(`${API_URL}/coins/${path}/${resource}/${address ?? ''}`)
16 |
17 | if (!response.ok) {
18 | throw new Error(`Something went wrong while fetching ${resource} data. Please try again later.`)
19 | }
20 |
21 | const jsonData = await response.json()
22 | setData(jsonData)
23 | } catch (error) {
24 | setError(error as Error)
25 | } finally {
26 | setIsLoading(false)
27 | }
28 | },
29 | []
30 | )
31 |
32 | return { data, isLoading, error, fetchTokenData }
33 | }
34 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/ethers.module.ts:
--------------------------------------------------------------------------------
1 | import { DynamicModule, Global, Module } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 |
4 | import type { EnvironmentVariables } from 'src/config/environments';
5 | import { EthersModuleOptions } from './ethers.config.interface';
6 | import { EthersService } from './ethers.service';
7 |
8 | @Global()
9 | @Module({})
10 | export class EthersModule {
11 | static forRootAsync(options: EthersModuleOptions): DynamicModule {
12 | return {
13 | module: EthersModule,
14 | providers: [
15 | {
16 | provide: 'ETHERS_SERVICE_OPTIONS',
17 | useValue: options,
18 | },
19 | {
20 | provide: EthersService,
21 | useFactory: (
22 | configService: ConfigService,
23 | ethersOptions: EthersModuleOptions,
24 | ) => {
25 | const rpcProviderUrl = configService.get('RPC_PROVIDER_URL', {
26 | infer: true,
27 | });
28 | return new EthersService(
29 | rpcProviderUrl,
30 | ethersOptions.tokenAddress,
31 | ethersOptions.tokenAbi,
32 | ethersOptions.priceFeedAddress,
33 | );
34 | },
35 | inject: [ConfigService, 'ETHERS_SERVICE_OPTIONS'],
36 | },
37 | ],
38 | exports: [EthersService],
39 | };
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { Logger, ValidationPipe } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { HttpAdapterHost, NestFactory } from '@nestjs/core';
4 | import type { NestExpressApplication } from '@nestjs/platform-express';
5 |
6 | import { AppModule } from './app.module';
7 | import { HttpExceptionFilter } from './common/exception-filters';
8 | import { setupSwagger } from './config';
9 | import type { EnvironmentVariables } from './config/environments';
10 |
11 | async function bootstrap() {
12 | try {
13 | const app = await NestFactory.create(AppModule);
14 | const configService =
15 | app.get>(ConfigService);
16 |
17 | const PORT = configService.get('PORT', { infer: true }) || 3000;
18 | const VERSION = configService.get('VERSION', { infer: true });
19 |
20 | const { httpAdapter } = app.get(HttpAdapterHost);
21 |
22 | app.setGlobalPrefix(`api/v${VERSION}`);
23 | app.enableCors();
24 | app.useGlobalPipes(new ValidationPipe({ transform: true }));
25 | app.useGlobalFilters(new HttpExceptionFilter(httpAdapter));
26 |
27 | setupSwagger(app, configService);
28 |
29 | await app.listen(PORT);
30 | const url = await app.getUrl();
31 |
32 | Logger.verbose(`Application is running on: ${url} ✔️`);
33 | Logger.verbose(`Api documentation run on: ${url}/api ✔️`);
34 | } catch (error) {
35 | Logger.error(`❌❌❌ ${error.message} ❌❌❌`);
36 | }
37 | }
38 |
39 | bootstrap();
40 |
--------------------------------------------------------------------------------
/backend/src/common/exception-filters/http-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArgumentsHost,
3 | Catch,
4 | ExceptionFilter,
5 | HttpException,
6 | HttpStatus,
7 | Logger,
8 | } from '@nestjs/common';
9 | import { AbstractHttpAdapter } from '@nestjs/core';
10 |
11 | import {
12 | getExceptionError,
13 | getExceptionErrorMessage,
14 | } from './http-exception.utils';
15 |
16 | @Catch()
17 | export class HttpExceptionFilter implements ExceptionFilter {
18 | private readonly logger: Logger;
19 |
20 | constructor(private readonly httpAdapterHost: AbstractHttpAdapter) {
21 | this.logger = new Logger(HttpExceptionFilter.name);
22 | }
23 |
24 | catch(exception: HttpException | any, host: ArgumentsHost): void {
25 | const httpAdapter = this.httpAdapterHost;
26 |
27 | const ctx = host.switchToHttp();
28 |
29 | const httpStatus =
30 | exception instanceof HttpException
31 | ? exception.getStatus()
32 | : HttpStatus.INTERNAL_SERVER_ERROR;
33 |
34 | const errorMessage = getExceptionErrorMessage(exception);
35 |
36 | const responseBody = {
37 | path: httpAdapter.getRequestUrl(ctx.getRequest()),
38 | statusCode: httpStatus,
39 | error: getExceptionError(exception),
40 | message: errorMessage,
41 | timestamp: new Date().toISOString(),
42 | };
43 |
44 | this.logger.error(
45 | `HTTP Status: ${httpStatus}, Error Message: ${errorMessage}`,
46 | exception.stack,
47 | );
48 |
49 | httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/components/TokenCard.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/buttons'
2 | import { CoinsApiResource, type CoinsApiUrlSegment } from '../types'
3 |
4 | type Props = {
5 | token: { name: string; symbol: string; path: CoinsApiUrlSegment }
6 | address: string
7 | onFetchData: (path: CoinsApiUrlSegment, resource: CoinsApiResource, address?: string) => void
8 | }
9 |
10 | export default function TokenCard({ token, onFetchData, address }: Props) {
11 | return (
12 |
13 |
14 | {token.name} ({token.symbol})
15 |
16 |
17 |
24 |
31 |
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/components/TokenInfoModal.tsx:
--------------------------------------------------------------------------------
1 | import { Modal } from '@/components/ui'
2 | import { formatCaseString } from '@/utils'
3 |
4 | type Props = {
5 | isOpen: boolean
6 | onClose: () => void
7 | title: string
8 | data?: { [key: string]: string | number }
9 | isLoading: boolean
10 | error?: Error | null
11 | }
12 |
13 | const TokenInfoSkeleton = () => (
14 |
15 | {Array.from({ length: 3 }).map(() => (
16 |
17 | ))}
18 |
19 | )
20 |
21 | export default function TokenInfoModal({ isOpen, onClose, title, data, isLoading, error }: Props) {
22 | return (
23 |
24 |
25 | {title}
26 |
27 |
28 |
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/ethers.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import {
3 | ethers,
4 | formatEther,
5 | formatUnits,
6 | type Contract,
7 | type JsonRpcProvider,
8 | } from 'ethers';
9 |
10 | import { chainlinkPriceFeedAbi } from './contract';
11 |
12 | @Injectable()
13 | export class EthersService {
14 | private provider: JsonRpcProvider;
15 | private contract: Contract;
16 | private priceFeedContract: Contract;
17 |
18 | constructor(
19 | rpcProviderUrl: string,
20 | tokenAddress: string,
21 | tokenAbi: any[],
22 | priceFeedAddress: string,
23 | ) {
24 | this.provider = new ethers.JsonRpcProvider(rpcProviderUrl);
25 | this.contract = new ethers.Contract(tokenAddress, tokenAbi, this.provider);
26 | this.priceFeedContract = new ethers.Contract(
27 | priceFeedAddress,
28 | chainlinkPriceFeedAbi,
29 | this.provider,
30 | );
31 | }
32 |
33 | async getTokenBalance(accountAddress: string): Promise {
34 | const balance = await this.contract.balanceOf(accountAddress);
35 | return +formatEther(balance);
36 | }
37 |
38 | async getTokenTotalSupply(): Promise {
39 | const totalSupply = await this.contract.totalSupply();
40 | return +formatEther(totalSupply);
41 | }
42 |
43 | async getTokenUsdPrice(): Promise<{ price: number; unit: string }> {
44 | const decimals = await this.priceFeedContract.decimals();
45 | const [, price] = await this.priceFeedContract.latestRoundData();
46 |
47 | return { price: parseFloat(formatUnits(price, decimals)), unit: 'USD' };
48 | }
49 |
50 | async getTokenDetails(): Promise<{
51 | name: string;
52 | symbol: string;
53 | decimals: number;
54 | totalSupply: number;
55 | }> {
56 | const [name, symbol, decimals, totalSupply] = await Promise.all([
57 | this.contract.name(),
58 | this.contract.symbol(),
59 | this.contract.decimals().then((decimalsBigInt) => Number(decimalsBigInt)),
60 | this.getTokenTotalSupply(),
61 | ]);
62 |
63 | return { name, symbol, decimals, totalSupply };
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { SVGProps } from 'react'
2 | import { JSX } from 'react/jsx-runtime'
3 |
4 | const navigation = [
5 | {
6 | name: 'GitHub',
7 | href: 'https://github.com/alexmf91/erc20-wallet-watcher',
8 | icon: (props: JSX.IntrinsicAttributes & SVGProps) => (
9 |
16 | )
17 | }
18 | ]
19 |
20 | export default function Footer() {
21 | return (
22 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import { formatCaseString } from '@/utils'
2 | import { useState } from 'react'
3 | import { useAccount } from 'wagmi'
4 | import { TokenCard, TokenInfoModal } from './components'
5 | import { useFetchTokenData } from './hooks'
6 | import { CoinsApiResource, type CoinsApiUrlSegment } from './types'
7 |
8 | const AVAILABLE_TOKENS = [{ name: 'Chainlink Token', symbol: 'LINK', path: 'chainlink' }] as const
9 |
10 | export default function Dashboard() {
11 | const { data, isLoading, error, fetchTokenData } = useFetchTokenData()
12 | const [title, setTitle] = useState('')
13 | const [showModal, setShowModal] = useState(false)
14 |
15 | const { address } = useAccount()
16 |
17 | if (!address) {
18 | return (
19 |
20 | Please connect your wallet to view token data.
21 |
22 | )
23 | }
24 |
25 | const handleFetchData = async (path: CoinsApiUrlSegment, resource: CoinsApiResource, address?: string) => {
26 | setTitle(`Token ${formatCaseString(resource)}`)
27 | setShowModal(true)
28 | await fetchTokenData(path, resource, address)
29 | }
30 |
31 | return (
32 | <>
33 |
34 | Tokens
35 |
36 | {AVAILABLE_TOKENS.map((token) => (
37 |
38 | ))}
39 |
40 |
41 |
42 | More tokens
43 |
coming soon!
44 |
45 |
46 |
47 | setShowModal(false)}
50 | title={title}
51 | data={data}
52 | isLoading={isLoading}
53 | error={error}
54 | />
55 | >
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "erc20-wallet-watcher-api",
3 | "version": "0.0.1",
4 | "private": true,
5 | "author": {
6 | "name": "Alex Muñoz",
7 | "url": "https://github.com/alexmf91"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/alexmf91/erc20-wallet-watcher.git"
12 | },
13 | "license": "MIT",
14 | "scripts": {
15 | "build": "nest build",
16 | "format": "prettier --write \"src/**/*.ts\"",
17 | "start": "nest start",
18 | "start:dev": "nest start --watch",
19 | "start:debug": "nest start --debug --watch",
20 | "start:prod": "node dist/main",
21 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
22 | "test": "jest",
23 | "test:watch": "jest --watch",
24 | "test:cov": "jest --coverage"
25 | },
26 | "dependencies": {
27 | "@nestjs/common": "^10.0.0",
28 | "@nestjs/config": "^3.2.0",
29 | "@nestjs/core": "^10.0.0",
30 | "@nestjs/platform-express": "^10.0.0",
31 | "@nestjs/swagger": "^7.3.0",
32 | "class-transformer": "^0.5.1",
33 | "class-validator": "^0.14.1",
34 | "ethers": "^6.11.1",
35 | "reflect-metadata": "^0.2.0",
36 | "rxjs": "^7.8.1"
37 | },
38 | "devDependencies": {
39 | "@nestjs/cli": "^10.0.0",
40 | "@nestjs/schematics": "^10.0.0",
41 | "@nestjs/testing": "^10.0.0",
42 | "@types/express": "^4.17.17",
43 | "@types/jest": "^29.5.2",
44 | "@types/node": "^20.3.1",
45 | "@types/supertest": "^6.0.0",
46 | "@typescript-eslint/eslint-plugin": "^6.0.0",
47 | "@typescript-eslint/parser": "^6.0.0",
48 | "eslint": "^8.42.0",
49 | "eslint-config-prettier": "^9.0.0",
50 | "eslint-plugin-prettier": "^5.0.0",
51 | "jest": "^29.5.0",
52 | "prettier": "^3.0.0",
53 | "source-map-support": "^0.5.21",
54 | "supertest": "^6.3.3",
55 | "ts-jest": "^29.1.0",
56 | "ts-loader": "^9.4.3",
57 | "ts-node": "^10.9.1",
58 | "tsconfig-paths": "^4.2.0",
59 | "typescript": "5.3.3"
60 | },
61 | "jest": {
62 | "moduleFileExtensions": [
63 | "js",
64 | "json",
65 | "ts"
66 | ],
67 | "rootDir": "src",
68 | "moduleNameMapper": {
69 | "src/(.*)$": "/$1"
70 | },
71 | "testRegex": ".*\\.spec\\.ts$",
72 | "transform": {
73 | "^.+\\.(t|j)s$": "ts-jest"
74 | },
75 | "collectCoverageFrom": [
76 | "**/*.(t|j)s"
77 | ],
78 | "coverageDirectory": "../coverage",
79 | "testEnvironment": "node"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { twMerge } from 'tailwind-merge'
4 |
5 | function ModalWrapper({ children }: { children: React.ReactNode }) {
6 | useEffect(() => {
7 | const [body] = document.getElementsByTagName('body')
8 | body?.classList.add('overflow-hidden')
9 | return () => {
10 | body?.classList.remove('overflow-hidden')
11 | }
12 | }, [])
13 |
14 | return (
15 | <>
16 |
17 |
27 | >
28 | )
29 | }
30 |
31 | type ModalProps = {
32 | isOpen: boolean
33 | children: React.ReactNode
34 | }
35 |
36 | function Modal({ isOpen, children }: ModalProps) {
37 | if (!isOpen) return null
38 |
39 | return ReactDOM.createPortal({children}, document.getElementById('root') as HTMLElement)
40 | }
41 |
42 | type SectionProps = {
43 | className?: string
44 | children?: React.ReactNode
45 | }
46 |
47 | Modal.Header = function ({ onClose, className, children }: SectionProps & { onClose?: () => void }) {
48 | return (
49 |
50 | {children}
51 | {onClose && (
52 |
63 | )}
64 |
65 | )
66 | }
67 |
68 | Modal.Body = function ({ className, children }: SectionProps) {
69 | return (
70 |
71 | {children}
72 |
73 | )
74 | }
75 |
76 | Modal.Footer = function ({ className, children }: SectionProps) {
77 | return {children}
78 | }
79 |
80 | export default Modal
81 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/chainlink.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Param } from '@nestjs/common';
2 | import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
3 | import { ChainlinkService } from './chainlink.service';
4 | import {
5 | GetBalanceParamDto,
6 | GetBalanceResponseDto,
7 | GetDetailsResponseDto,
8 | GetPriceResponseDto,
9 | GetTotalSupplyResponseDto,
10 | } from './dto';
11 |
12 | @ApiTags('Coins')
13 | @Controller()
14 | export class ChainlinkController {
15 | constructor(private readonly chainlinkService: ChainlinkService) {}
16 |
17 | @Get('balance/:address')
18 | @ApiOperation({
19 | summary:
20 | 'Retrieves the LINK balance for a specified wallet address in both the native token and its equivalent in USD',
21 | })
22 | @ApiResponse({
23 | status: 200,
24 | description: 'Balance fetched successfully',
25 | type: GetBalanceResponseDto,
26 | })
27 | @ApiParam({
28 | name: 'address',
29 | required: true,
30 | description: 'Wallet address to query the LINK balance for',
31 | type: 'string',
32 | example: '0x5a821936C1a5606d9Bd870507B52B69964f7318b',
33 | })
34 | async getBalance(@Param() params: GetBalanceParamDto) {
35 | const [balance, priceDetails] = await Promise.all([
36 | this.chainlinkService.getBalance(params.address),
37 | this.chainlinkService.getUsdPrice(),
38 | ]);
39 | const balanceInUSD = +(balance * priceDetails.price).toFixed(2);
40 |
41 | return { balance, balanceInUSD };
42 | }
43 |
44 | @Get('total-supply')
45 | @ApiOperation({
46 | summary:
47 | 'Fetches the total supply of LINK tokens in both native units and USD equivalent',
48 | })
49 | @ApiResponse({
50 | status: 200,
51 | description: 'Total supply fetched successfully',
52 | type: GetTotalSupplyResponseDto,
53 | })
54 | async getTotalSupply() {
55 | const [totalSupply, priceDetails] = await Promise.all([
56 | this.chainlinkService.getTotalSupply(),
57 | this.chainlinkService.getUsdPrice(),
58 | ]);
59 |
60 | const totalSupplyInUSD = +(totalSupply * priceDetails.price).toFixed(2);
61 |
62 | return { totalSupply, totalSupplyInUSD };
63 | }
64 |
65 | @Get('price')
66 | @ApiOperation({
67 | summary: 'Retrieves the current price of the LINK token in USD',
68 | })
69 | @ApiResponse({
70 | status: 200,
71 | description: 'Price fetched successfully',
72 | type: GetPriceResponseDto,
73 | })
74 | async getUsdPrice() {
75 | return await this.chainlinkService.getUsdPrice();
76 | }
77 |
78 | @Get('details')
79 | @ApiOperation({
80 | summary:
81 | 'Provides detailed information about the LINK token, including name, symbol, decimals, and total supply',
82 | })
83 | @ApiResponse({
84 | status: 200,
85 | description: 'Details fetched successfully',
86 | type: GetDetailsResponseDto,
87 | })
88 | async getDetails() {
89 | return await this.chainlinkService.getDetails();
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/chainlink.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ChainlinkController } from './chainlink.controller';
3 | import { ChainlinkService } from './chainlink.service';
4 | import { GetBalanceParamDto } from './dto';
5 | import { EthersService } from 'src/modules/ethers';
6 |
7 | describe('ChainlinkController', () => {
8 | let controller: ChainlinkController;
9 | let service: ChainlinkService;
10 |
11 | beforeEach(async () => {
12 | const module: TestingModule = await Test.createTestingModule({
13 | controllers: [ChainlinkController],
14 | providers: [
15 | ChainlinkService,
16 | {
17 | provide: EthersService,
18 | useValue: {
19 | getTokenBalance: jest.fn(),
20 | getTokenTotalSupply: jest.fn(),
21 | getTokenUsdPrice: jest.fn(),
22 | getTokenDetails: jest.fn(),
23 | },
24 | },
25 | ],
26 | }).compile();
27 |
28 | controller = module.get(ChainlinkController);
29 | service = module.get(ChainlinkService);
30 | });
31 |
32 | it('should be defined', () => {
33 | expect(controller).toBeDefined();
34 | });
35 |
36 | describe('getBalance', () => {
37 | it('should return balance and balanceInUSD', async () => {
38 | const address = '0xTEST';
39 | const balance = 100;
40 | const priceDetails = { price: 2, unit: 'USD' };
41 | const expected = { balance, balanceInUSD: 200 };
42 |
43 | jest.spyOn(service, 'getBalance').mockResolvedValue(balance);
44 | jest.spyOn(service, 'getUsdPrice').mockResolvedValue(priceDetails);
45 |
46 | expect(
47 | await controller.getBalance({ address } as GetBalanceParamDto),
48 | ).toEqual(expected);
49 | expect(service.getBalance).toHaveBeenCalledWith(address);
50 | expect(service.getUsdPrice).toHaveBeenCalled();
51 | });
52 | });
53 |
54 | describe('getTotalSupply', () => {
55 | it('should return total supply and totalSupplyInUSD', async () => {
56 | const totalSupply = 1000;
57 | const priceDetails = { price: 2, unit: 'USD' };
58 | const expected = { totalSupply, totalSupplyInUSD: 2000 };
59 |
60 | jest.spyOn(service, 'getTotalSupply').mockResolvedValue(totalSupply);
61 | jest.spyOn(service, 'getUsdPrice').mockResolvedValue(priceDetails);
62 |
63 | expect(await controller.getTotalSupply()).toEqual(expected);
64 | expect(service.getTotalSupply).toHaveBeenCalled();
65 | expect(service.getUsdPrice).toHaveBeenCalled();
66 | });
67 | });
68 |
69 | describe('getUsdPrice', () => {
70 | it('should return the current price of the LINK token in USD', async () => {
71 | const priceDetails = { price: 3, unit: 'USD' };
72 |
73 | jest.spyOn(service, 'getUsdPrice').mockResolvedValue(priceDetails);
74 |
75 | expect(await controller.getUsdPrice()).toEqual(priceDetails);
76 | expect(service.getUsdPrice).toHaveBeenCalled();
77 | });
78 | });
79 |
80 | describe('getDetails', () => {
81 | it('should return LINK token details', async () => {
82 | const details = {
83 | name: 'ChainLink',
84 | symbol: 'LINK',
85 | decimals: 18,
86 | totalSupply: 1000,
87 | };
88 |
89 | jest.spyOn(service, 'getDetails').mockResolvedValue(details);
90 |
91 | expect(await controller.getDetails()).toEqual(details);
92 | expect(service.getDetails).toHaveBeenCalled();
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/backend/src/modules/coins/chainlink/contract/chainlink-token.abi.ts:
--------------------------------------------------------------------------------
1 | export const chainlinkTokenABI = [
2 | { inputs: [], stateMutability: 'nonpayable', type: 'constructor' },
3 | {
4 | anonymous: false,
5 | inputs: [
6 | {
7 | indexed: true,
8 | internalType: 'address',
9 | name: 'owner',
10 | type: 'address',
11 | },
12 | {
13 | indexed: true,
14 | internalType: 'address',
15 | name: 'spender',
16 | type: 'address',
17 | },
18 | {
19 | indexed: false,
20 | internalType: 'uint256',
21 | name: 'value',
22 | type: 'uint256',
23 | },
24 | ],
25 | name: 'Approval',
26 | type: 'event',
27 | },
28 | {
29 | anonymous: false,
30 | inputs: [
31 | { indexed: true, internalType: 'address', name: 'from', type: 'address' },
32 | { indexed: true, internalType: 'address', name: 'to', type: 'address' },
33 | {
34 | indexed: false,
35 | internalType: 'uint256',
36 | name: 'value',
37 | type: 'uint256',
38 | },
39 | { indexed: false, internalType: 'bytes', name: 'data', type: 'bytes' },
40 | ],
41 | name: 'Transfer',
42 | type: 'event',
43 | },
44 | {
45 | anonymous: false,
46 | inputs: [
47 | { indexed: true, internalType: 'address', name: 'from', type: 'address' },
48 | { indexed: true, internalType: 'address', name: 'to', type: 'address' },
49 | {
50 | indexed: false,
51 | internalType: 'uint256',
52 | name: 'value',
53 | type: 'uint256',
54 | },
55 | ],
56 | name: 'Transfer',
57 | type: 'event',
58 | },
59 | {
60 | inputs: [
61 | { internalType: 'address', name: 'owner', type: 'address' },
62 | { internalType: 'address', name: 'spender', type: 'address' },
63 | ],
64 | name: 'allowance',
65 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
66 | stateMutability: 'view',
67 | type: 'function',
68 | },
69 | {
70 | inputs: [
71 | { internalType: 'address', name: 'spender', type: 'address' },
72 | { internalType: 'uint256', name: 'amount', type: 'uint256' },
73 | ],
74 | name: 'approve',
75 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
76 | stateMutability: 'nonpayable',
77 | type: 'function',
78 | },
79 | {
80 | inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
81 | name: 'balanceOf',
82 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
83 | stateMutability: 'view',
84 | type: 'function',
85 | },
86 | {
87 | inputs: [],
88 | name: 'decimals',
89 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }],
90 | stateMutability: 'view',
91 | type: 'function',
92 | },
93 | {
94 | inputs: [
95 | { internalType: 'address', name: 'spender', type: 'address' },
96 | { internalType: 'uint256', name: 'subtractedValue', type: 'uint256' },
97 | ],
98 | name: 'decreaseAllowance',
99 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
100 | stateMutability: 'nonpayable',
101 | type: 'function',
102 | },
103 | {
104 | inputs: [
105 | { internalType: 'address', name: 'spender', type: 'address' },
106 | { internalType: 'uint256', name: 'subtractedValue', type: 'uint256' },
107 | ],
108 | name: 'decreaseApproval',
109 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
110 | stateMutability: 'nonpayable',
111 | type: 'function',
112 | },
113 | {
114 | inputs: [
115 | { internalType: 'address', name: 'spender', type: 'address' },
116 | { internalType: 'uint256', name: 'addedValue', type: 'uint256' },
117 | ],
118 | name: 'increaseAllowance',
119 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
120 | stateMutability: 'nonpayable',
121 | type: 'function',
122 | },
123 | {
124 | inputs: [
125 | { internalType: 'address', name: 'spender', type: 'address' },
126 | { internalType: 'uint256', name: 'addedValue', type: 'uint256' },
127 | ],
128 | name: 'increaseApproval',
129 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
130 | stateMutability: 'nonpayable',
131 | type: 'function',
132 | },
133 | {
134 | inputs: [],
135 | name: 'name',
136 | outputs: [{ internalType: 'string', name: '', type: 'string' }],
137 | stateMutability: 'view',
138 | type: 'function',
139 | },
140 | {
141 | inputs: [],
142 | name: 'symbol',
143 | outputs: [{ internalType: 'string', name: '', type: 'string' }],
144 | stateMutability: 'view',
145 | type: 'function',
146 | },
147 | {
148 | inputs: [],
149 | name: 'totalSupply',
150 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
151 | stateMutability: 'view',
152 | type: 'function',
153 | },
154 | {
155 | inputs: [
156 | { internalType: 'address', name: 'recipient', type: 'address' },
157 | { internalType: 'uint256', name: 'amount', type: 'uint256' },
158 | ],
159 | name: 'transfer',
160 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
161 | stateMutability: 'nonpayable',
162 | type: 'function',
163 | },
164 | {
165 | inputs: [
166 | { internalType: 'address', name: 'to', type: 'address' },
167 | { internalType: 'uint256', name: 'value', type: 'uint256' },
168 | { internalType: 'bytes', name: 'data', type: 'bytes' },
169 | ],
170 | name: 'transferAndCall',
171 | outputs: [{ internalType: 'bool', name: 'success', type: 'bool' }],
172 | stateMutability: 'nonpayable',
173 | type: 'function',
174 | },
175 | {
176 | inputs: [
177 | { internalType: 'address', name: 'sender', type: 'address' },
178 | { internalType: 'address', name: 'recipient', type: 'address' },
179 | { internalType: 'uint256', name: 'amount', type: 'uint256' },
180 | ],
181 | name: 'transferFrom',
182 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
183 | stateMutability: 'nonpayable',
184 | type: 'function',
185 | },
186 | {
187 | inputs: [],
188 | name: 'typeAndVersion',
189 | outputs: [{ internalType: 'string', name: '', type: 'string' }],
190 | stateMutability: 'pure',
191 | type: 'function',
192 | },
193 | ];
194 |
--------------------------------------------------------------------------------
/backend/src/modules/ethers/contract/chainlink-price-feed.abi.ts:
--------------------------------------------------------------------------------
1 | export const chainlinkPriceFeedAbi = [
2 | {
3 | inputs: [
4 | { internalType: 'address', name: '_aggregator', type: 'address' },
5 | { internalType: 'address', name: '_accessController', type: 'address' },
6 | ],
7 | stateMutability: 'nonpayable',
8 | type: 'constructor',
9 | },
10 | {
11 | anonymous: false,
12 | inputs: [
13 | {
14 | indexed: true,
15 | internalType: 'int256',
16 | name: 'current',
17 | type: 'int256',
18 | },
19 | {
20 | indexed: true,
21 | internalType: 'uint256',
22 | name: 'roundId',
23 | type: 'uint256',
24 | },
25 | {
26 | indexed: false,
27 | internalType: 'uint256',
28 | name: 'updatedAt',
29 | type: 'uint256',
30 | },
31 | ],
32 | name: 'AnswerUpdated',
33 | type: 'event',
34 | },
35 | {
36 | anonymous: false,
37 | inputs: [
38 | {
39 | indexed: true,
40 | internalType: 'uint256',
41 | name: 'roundId',
42 | type: 'uint256',
43 | },
44 | {
45 | indexed: true,
46 | internalType: 'address',
47 | name: 'startedBy',
48 | type: 'address',
49 | },
50 | {
51 | indexed: false,
52 | internalType: 'uint256',
53 | name: 'startedAt',
54 | type: 'uint256',
55 | },
56 | ],
57 | name: 'NewRound',
58 | type: 'event',
59 | },
60 | {
61 | anonymous: false,
62 | inputs: [
63 | { indexed: true, internalType: 'address', name: 'from', type: 'address' },
64 | { indexed: true, internalType: 'address', name: 'to', type: 'address' },
65 | ],
66 | name: 'OwnershipTransferRequested',
67 | type: 'event',
68 | },
69 | {
70 | anonymous: false,
71 | inputs: [
72 | { indexed: true, internalType: 'address', name: 'from', type: 'address' },
73 | { indexed: true, internalType: 'address', name: 'to', type: 'address' },
74 | ],
75 | name: 'OwnershipTransferred',
76 | type: 'event',
77 | },
78 | {
79 | inputs: [],
80 | name: 'acceptOwnership',
81 | outputs: [],
82 | stateMutability: 'nonpayable',
83 | type: 'function',
84 | },
85 | {
86 | inputs: [],
87 | name: 'accessController',
88 | outputs: [
89 | {
90 | internalType: 'contract AccessControllerInterface',
91 | name: '',
92 | type: 'address',
93 | },
94 | ],
95 | stateMutability: 'view',
96 | type: 'function',
97 | },
98 | {
99 | inputs: [],
100 | name: 'aggregator',
101 | outputs: [{ internalType: 'address', name: '', type: 'address' }],
102 | stateMutability: 'view',
103 | type: 'function',
104 | },
105 | {
106 | inputs: [{ internalType: 'address', name: '_aggregator', type: 'address' }],
107 | name: 'confirmAggregator',
108 | outputs: [],
109 | stateMutability: 'nonpayable',
110 | type: 'function',
111 | },
112 | {
113 | inputs: [],
114 | name: 'decimals',
115 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }],
116 | stateMutability: 'view',
117 | type: 'function',
118 | },
119 | {
120 | inputs: [],
121 | name: 'description',
122 | outputs: [{ internalType: 'string', name: '', type: 'string' }],
123 | stateMutability: 'view',
124 | type: 'function',
125 | },
126 | {
127 | inputs: [{ internalType: 'uint256', name: '_roundId', type: 'uint256' }],
128 | name: 'getAnswer',
129 | outputs: [{ internalType: 'int256', name: '', type: 'int256' }],
130 | stateMutability: 'view',
131 | type: 'function',
132 | },
133 | {
134 | inputs: [{ internalType: 'uint80', name: '_roundId', type: 'uint80' }],
135 | name: 'getRoundData',
136 | outputs: [
137 | { internalType: 'uint80', name: 'roundId', type: 'uint80' },
138 | { internalType: 'int256', name: 'answer', type: 'int256' },
139 | { internalType: 'uint256', name: 'startedAt', type: 'uint256' },
140 | { internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
141 | { internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
142 | ],
143 | stateMutability: 'view',
144 | type: 'function',
145 | },
146 | {
147 | inputs: [{ internalType: 'uint256', name: '_roundId', type: 'uint256' }],
148 | name: 'getTimestamp',
149 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
150 | stateMutability: 'view',
151 | type: 'function',
152 | },
153 | {
154 | inputs: [],
155 | name: 'latestAnswer',
156 | outputs: [{ internalType: 'int256', name: '', type: 'int256' }],
157 | stateMutability: 'view',
158 | type: 'function',
159 | },
160 | {
161 | inputs: [],
162 | name: 'latestRound',
163 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
164 | stateMutability: 'view',
165 | type: 'function',
166 | },
167 | {
168 | inputs: [],
169 | name: 'latestRoundData',
170 | outputs: [
171 | { internalType: 'uint80', name: 'roundId', type: 'uint80' },
172 | { internalType: 'int256', name: 'answer', type: 'int256' },
173 | { internalType: 'uint256', name: 'startedAt', type: 'uint256' },
174 | { internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
175 | { internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
176 | ],
177 | stateMutability: 'view',
178 | type: 'function',
179 | },
180 | {
181 | inputs: [],
182 | name: 'latestTimestamp',
183 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
184 | stateMutability: 'view',
185 | type: 'function',
186 | },
187 | {
188 | inputs: [],
189 | name: 'owner',
190 | outputs: [{ internalType: 'address payable', name: '', type: 'address' }],
191 | stateMutability: 'view',
192 | type: 'function',
193 | },
194 | {
195 | inputs: [{ internalType: 'uint16', name: '', type: 'uint16' }],
196 | name: 'phaseAggregators',
197 | outputs: [
198 | {
199 | internalType: 'contract AggregatorV2V3Interface',
200 | name: '',
201 | type: 'address',
202 | },
203 | ],
204 | stateMutability: 'view',
205 | type: 'function',
206 | },
207 | {
208 | inputs: [],
209 | name: 'phaseId',
210 | outputs: [{ internalType: 'uint16', name: '', type: 'uint16' }],
211 | stateMutability: 'view',
212 | type: 'function',
213 | },
214 | {
215 | inputs: [{ internalType: 'address', name: '_aggregator', type: 'address' }],
216 | name: 'proposeAggregator',
217 | outputs: [],
218 | stateMutability: 'nonpayable',
219 | type: 'function',
220 | },
221 | {
222 | inputs: [],
223 | name: 'proposedAggregator',
224 | outputs: [
225 | {
226 | internalType: 'contract AggregatorV2V3Interface',
227 | name: '',
228 | type: 'address',
229 | },
230 | ],
231 | stateMutability: 'view',
232 | type: 'function',
233 | },
234 | {
235 | inputs: [{ internalType: 'uint80', name: '_roundId', type: 'uint80' }],
236 | name: 'proposedGetRoundData',
237 | outputs: [
238 | { internalType: 'uint80', name: 'roundId', type: 'uint80' },
239 | { internalType: 'int256', name: 'answer', type: 'int256' },
240 | { internalType: 'uint256', name: 'startedAt', type: 'uint256' },
241 | { internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
242 | { internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
243 | ],
244 | stateMutability: 'view',
245 | type: 'function',
246 | },
247 | {
248 | inputs: [],
249 | name: 'proposedLatestRoundData',
250 | outputs: [
251 | { internalType: 'uint80', name: 'roundId', type: 'uint80' },
252 | { internalType: 'int256', name: 'answer', type: 'int256' },
253 | { internalType: 'uint256', name: 'startedAt', type: 'uint256' },
254 | { internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
255 | { internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
256 | ],
257 | stateMutability: 'view',
258 | type: 'function',
259 | },
260 | {
261 | inputs: [
262 | { internalType: 'address', name: '_accessController', type: 'address' },
263 | ],
264 | name: 'setController',
265 | outputs: [],
266 | stateMutability: 'nonpayable',
267 | type: 'function',
268 | },
269 | {
270 | inputs: [{ internalType: 'address', name: '_to', type: 'address' }],
271 | name: 'transferOwnership',
272 | outputs: [],
273 | stateMutability: 'nonpayable',
274 | type: 'function',
275 | },
276 | {
277 | inputs: [],
278 | name: 'version',
279 | outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
280 | stateMutability: 'view',
281 | type: 'function',
282 | },
283 | ];
284 |
--------------------------------------------------------------------------------