diff --git a/docker-compose.yml b/docker-compose.yml index dca0bbd..827c36f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - ./:/app - /app/node_modules restart: 'unless-stopped' - entrypoint: [ "/app/wait-for.sh", "postgres:5432", "--", "sh", "-c", "node_modules/.bin/sequelize-cli db:migrate --env=test --config=/etc/secrets/db-config.json --migrations-path=src/migrations && npm run start:dev"] + entrypoint: [ "/app/wait-for.sh", "postgres:5432", "--", "sh", "-c", "node_modules/.bin/sequelize-cli db:migrate --env=production --config=/etc/secrets/db-config.json --migrations-path=src/migrations && npm run start:dev"] networks: - eth-network depends_on: diff --git a/package-lock.json b/package-lock.json index 1774b31..65fa4bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,10 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ethers": "^6.11.1", "express": "^4.18.3", "joi": "^17.12.2", + "luxon": "^3.4.4", "passport-jwt": "^4.0.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", @@ -63,6 +65,11 @@ "node": ">=0.10.0" } }, + "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==" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2187,6 +2194,28 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "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==", + "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", @@ -3044,6 +3073,11 @@ "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==" + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -4833,6 +4867,43 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.11.1.tgz", + "integrity": "sha512-mxTAE6wqJQAbp5QAe/+o+rXOID7Nw91OZXvgpjDa1r4fAbq2Nu314oEZSbjoRLacuCzs7kUC3clEvkCQowffGg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "18.15.13", + "aes-js": "4.0.0-beta.5", + "tslib": "2.4.0", + "ws": "8.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "18.15.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", + "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==" + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -7077,6 +7148,14 @@ "es5-ext": "~0.10.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", @@ -10153,6 +10232,26 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "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 713b9e9..3914694 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,10 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ethers": "^6.11.1", "express": "^4.18.3", "joi": "^17.12.2", + "luxon": "^3.4.4", "passport-jwt": "^4.0.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", diff --git a/secrets_example/config/database.json.example b/secrets_example/config/database.json.example new file mode 100644 index 0000000..de4c2b9 --- /dev/null +++ b/secrets_example/config/database.json.example @@ -0,0 +1,19 @@ +{ + "development": { + "username": "ashok", + "password": "secret", + "database": "eth", + "host": "localhost", + "dialect": "postgres", + "port": 5432 + }, + "production": { + "username": "ashok", + "password": "secret", + "database": "eth", + "host": "postgres", + "dialect": "postgres", + "port": 5432 + } + +} diff --git a/secrets_example/database.env.example b/secrets_example/database.env.example new file mode 100644 index 0000000..ad39b73 --- /dev/null +++ b/secrets_example/database.env.example @@ -0,0 +1,3 @@ +POSTGRES_DB=eth +POSTGRES_USER=ashok +POSTGRES_PASSWORD=secret \ No newline at end of file diff --git a/secrets_example/env.example b/secrets_example/env.example new file mode 100644 index 0000000..2c44ab3 --- /dev/null +++ b/secrets_example/env.example @@ -0,0 +1,29 @@ + +NODE_ENV=production #DO NOT CHANGE + +PORT=4000 +VERSION=v1 +ORIGIN=* + +DB_USER=ashok +DB_PASSWORD=secret +DB_DATABASE=eth +DB_PORT=5432 + +DB_HOST=postgres #DO NOT CHANGE + +JWT_SECRET=7LdPnw30KauR9jg63TJpw2BXixqrgo1j +JWT_EXPIRY=1d + +LOCAL_NODE_PROVIDER_URL=http://127.0.0.1:8545 + +INFURA_API_KEY= +INFURA_PROJECT_ID= +INFURA_PROJECT_SECRET= + +NETWORK_NAME=sepolia +CHAIN_ID=11155111 + + +AUCTION_CONTRACT_ADDRESS=0x23436F18efEEcf9AB7210626940963F3d2549053 #Auction contract deployed on Sepolia testnet +# Contract url => https://sepolia.etherscan.io/address/0x23436F18efEEcf9AB7210626940963F3d2549053 \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index ac2c765..f4a0aa2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,7 +5,8 @@ import * as Joi from 'joi'; import { ConfigModule } from '@nestjs/config'; import { DatabaseModule } from './database/database.module'; import { UserModule } from './user/user.module'; -import { AuthModule } from 'auth/auth.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AuctionModule } from './auction/auction.module'; @Module({ imports: [ @@ -13,15 +14,22 @@ import { AuthModule } from 'auth/auth.module'; validationSchema: Joi.object({ NODE_ENV: Joi.string() .required() - .valid('development', 'production', 'staging', 'provision') + .valid('development', 'production', 'test') .default('development'), - PORT: Joi.number().required().default(3000), - VERSION: Joi.string().required().default(''), + PORT: Joi.number().required().default(4000), + INFURA_PROJECT_ID: Joi.string().required().default(''), + INFURA_PROJECT_SECRET: Joi.string().required().default(''), + NETWORK_NAME: Joi.string().required().default('sepolia'), + CHAIN_ID: Joi.string().required().default(11155111), + AUCTION_CONTRACT_ADDRESS: Joi.string() + .required() + .default('0x23436F18efEEcf9AB7210626940963F3d2549053'), }), }), DatabaseModule, AuthModule, UserModule, + AuctionModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auction/auction.controller.ts b/src/auction/auction.controller.ts new file mode 100644 index 0000000..4b0767d --- /dev/null +++ b/src/auction/auction.controller.ts @@ -0,0 +1,114 @@ +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; + +import { AuctionService } from './auction.service'; +import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; +import { + AuctionBalanceDto, + AuctionBeneficiary, + AuctionEndTimeDto, + AuctionHighestBid, + AuctionHighestBidder, + AuctionStatsDto, + AuctionStatusDto, + BidDto, +} from './dto/auction-response-dto'; +import { CurrentUser } from 'src/common/decorator/current-user.decorator'; +import { User } from 'src/user/models/user.model'; +import { PaginationDto } from 'src/common/dto/pagination.dto'; + +@ApiTags('Auction') +@Controller('auction') +export class AuctionController { + constructor(private readonly auctionService: AuctionService) {} + + @Get('me') + @UseGuards(JwtAuthGuard) + async getMe() { + return { + id: 1, + name: 'ss', + email: 'asdsad', + }; + } + + @Get('beneficiary') + async auction(): Promise { + return this.auctionService.getBeneficiary(); + } + + @Get('interface') + async getAuctionInterface() { + return this.auctionService.getAuctionInterface(); + } + + @Get('highest-bid') + async getHighestBid(): Promise { + return this.auctionService.getHighestBid(); + } + + @Get('highest-bidder') + async getHighestBidder(): Promise { + return this.auctionService.getHighestBidder(); + } + + @Get('end-time') + async getAuctionEndTime(): Promise { + return this.auctionService.getAuctionEndTime(); + } + + @Get('status') + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'To know the status of the auction', + type: AuctionStatusDto, + }) + async auctionStatus(): Promise { + return this.auctionService.auctionStatus(); + } + + @Get('balance') + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'To know the available balance of the contract', + type: AuctionBalanceDto, + }) + async auctionBalance(): Promise { + return this.auctionService.auctionBalance(); + } + + @Get('stats') + @ApiOkResponse({ + description: 'To know the stats of the auction', + type: AuctionStatsDto, + }) + async auctionStats(): Promise { + return this.auctionService.auctionStats(); + } + + @Get('history') + @UseGuards(JwtAuthGuard) + async auctionHistory(@Query() paginationDto: PaginationDto) { + return this.auctionService.auctionHistory(paginationDto); + } + + @Post('bid') + @UseGuards(JwtAuthGuard) + async bid(@CurrentUser() user: User, @Body() bidDto: BidDto) { + return this.auctionService.bid(user, bidDto); + } + + @Get('balance') + async getBalance() { + return this.auctionService.getBalance(); + } + + @Get('my-balance') + @UseGuards(JwtAuthGuard) + async getMyBalance( + @CurrentUser() user: User, + @Query('address') address: string, + ) { + return this.auctionService.getMyBalance(user, address); + } +} diff --git a/src/auction/auction.module.ts b/src/auction/auction.module.ts new file mode 100644 index 0000000..5f6dd88 --- /dev/null +++ b/src/auction/auction.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { AuctionController } from './auction.controller'; +import { AuctionService } from './auction.service'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { User } from 'src/user/models/user.model'; +import { Bid } from './models/bid.model'; + +@Module({ + imports: [SequelizeModule.forFeature([User, Bid]), ConfigModule], + controllers: [AuctionController], + providers: [AuctionService], + exports: [AuctionService], +}) +export class AuctionModule {} diff --git a/src/auction/auction.service.ts b/src/auction/auction.service.ts new file mode 100644 index 0000000..1c920d9 --- /dev/null +++ b/src/auction/auction.service.ts @@ -0,0 +1,272 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; +import { DateTime } from 'luxon'; +import { + AuctionBalanceDto, + AuctionBeneficiary, + AuctionEndTimeDto, + AuctionHighestBid, + AuctionHighestBidder, + AuctionStatsDto, + AuctionStatusDto, + BidDto, + BidResponseDto, +} from './dto/auction-response-dto'; + +import * as c from './contract.json'; +import { AuctionStatus } from './enum/auction-status.enum'; +import { ImErrorCodes, toErrorMessage } from 'src/common/error'; +import { log } from 'console'; +import { User } from 'src/user/models/user.model'; +import { InjectModel } from '@nestjs/sequelize'; +import { Bid } from './models/bid.model'; +import { PaginationDto } from 'src/common/dto/pagination.dto'; +import { ENVIRONMENT } from 'src/common/constants'; + +@Injectable() +export class AuctionService { + provider; + contract; + + constructor( + @InjectModel(User) private readonly userModel: typeof User, + @InjectModel(Bid) private readonly bidModel: typeof Bid, + ) { + if (process.env.NODE_ENV === ENVIRONMENT.DEV) { + this.provider = new ethers.JsonRpcProvider( + process.env.LOCAL_NODE_PROVIDER_URL, + ); + } else { + this.provider = new ethers.InfuraProvider( + { + name: process.env.NETWORK_NAME, + chainId: parseInt(process.env.CHAIN_ID, 10), + }, + process.env.INFURA_PROJECT_ID, + process.env.INFURA_PROJECT_SECRET, + ); + } + + this.contract = new ethers.Contract( + process.env.AUCTION_CONTRACT_ADDRESS, + c.abi, + this.provider, + ); + + try { + this.contract.on( + 'HighestBidIncreased', + (bidder: string, bidAmount: string) => { + this.makeBidEntry(bidder, bidAmount); + log('new bid ', bidder, ethers.formatEther(bidAmount)); + }, + ); + } catch (e) { + log( + 'Setting up listener failed, There might not be event with the specified name', + e, + ); + } + } + + private async makeBidEntry(bidder: string, bidAmount: string): Promise { + log('incoming ==> ', bidder, bidAmount); + try { + await this.bidModel.create({ + contract_address: process.env.AUCTION_CONTRACT_ADDRESS, + address: bidder, + bidAmount: bidAmount, + }); + } catch (e) { + console.log('Storing bid entry failed', e); + } + } + + public async getBeneficiary(): Promise { + const beneficiary = await this.contract.beneficiary(); + + return { + beneficiary, + }; + } + + public async getHighestBid(): Promise { + const highestBid = await this.contract.highestBid(); + + return { + highestBid: ethers.formatUnits(highestBid), + }; + } + + public async getHighestBidder(): Promise { + const highestBidder = await this.contract.highestBidder(); + + return { + highestBidder, + }; + } + + public async getAuctionInterface() { + return this.contract.interface; + } + + public async getAuctionEndTime(): Promise { + const auctionEndTime = await this.contract.auctionEndTime(); + + return { + auctionEndTime: ethers.getNumber(auctionEndTime), + }; + } + + public async auctionStats(): Promise { + const balance = await this.provider.getBalance( + process.env.AUCTION_CONTRACT_ADDRESS, + ); + + const bidCount = await this.bidModel.count(); + + return { + totalBids: bidCount?.toString(), + totalEthVolume: ethers.formatEther(balance), + }; + } + + public async auctionBalance(): Promise { + return this.provider.getBalance(process.env.AUCTION_CONTRACT_ADDRESS); + } + + public async auctionStatus(): Promise { + const [auctionEndTime, highestBidder, highestBid, beneficiary] = + await Promise.all([ + this.contract.auctionEndTime(), + this.contract.highestBidder(), + this.contract.highestBid(), + this.contract.beneficiary(), + ]); + + return { + contractAddress: process.env.AUCTION_CONTRACT_ADDRESS, + auctionEndTime: DateTime.fromSeconds( + ethers.getNumber(auctionEndTime), + ).toLocaleString(DateTime.DATETIME_MED), + highestBidder: + highestBidder === ethers.ZeroAddress ? null : highestBidder, + highestBid: ethers.formatUnits(highestBid), + beneficiary, + status: + Date.now() > auctionEndTime + ? AuctionStatus.ACTIVE + : AuctionStatus.ENDED, + }; + } + + public async auctionHistory( + paginationSetting: PaginationDto, + ): Promise<{ history: Bid[] }> { + const { limit, offset } = paginationSetting; + + const history = await this.bidModel.findAll({ + limit, + offset: offset || 0, + order: [['createdAt', 'desc']], + }); + + return { + history, + }; + } + + public async bid(user: User, bidInfo: BidDto): Promise { + console.log('user: User, bidInfo: BidDto ==> ', user, bidInfo); + + return new BidResponseDto(); + // const userWallet = await this.userModel.findOne({ + // where: { + // id: user?.id, + // }, + // attributes: ['privateKey'], + // }); + // if (!userWallet.privateKey) { + // throw new InternalServerErrorException( + // toErrorMessage( + // ImErrorCodes.INTERNAL_SERVER_ERROR, + // 'No private key provided to sign the transaction. Please update using PATCH /user/me', + // ), + // ); + // } + // const walletPrivateKey = userWallet?.privateKey; + // const wallet = new ethers.Wallet(walletPrivateKey); + // const signer = wallet.connect(this.provider); + // const contract = new ethers.Contract( + // process.env.AUCTION_CONTRACT_ADDRESS, + // c.abi, + // signer, + // ); + // const highestBid = await this.contract.highestBid(); + // if (bidInfo.amount <= highestBid) { + // throw new BadRequestException( + // toErrorMessage( + // ImErrorCodes.BAD_REQUEST, + // 'Bid amount is less than the current highest bid, Provide a higher value', + // ), + // ); + // } + // try { + // const transactionInfo = await contract.bid.send({ + // value: bidInfo.amount, + // }); + // return { status: 'Bid Placed', transactionInfo }; + // } catch (e) { + // throw new InternalServerErrorException( + // toErrorMessage(ImErrorCodes.BAD_REQUEST, 'Error while placing bid'), + // ); + // } + } + + async getBalance() { + return this.provider.getBalance(process.env.AUCTION_CONTRACT_ADDRESS); + } + + async getMyBalance( + user: User, + address: string, + ): Promise<{ balance: string }> { + if (address != '') { + const userWallet = await this.userModel.findOne({ + where: { + id: user?.id, + }, + attributes: ['address'], + }); + + if (!userWallet.address) { + throw new BadRequestException( + toErrorMessage( + ImErrorCodes.BAD_REQUEST, + 'Cannot find wallet address, Please add it', + ), + ); + } + address = userWallet.address; + } + + if (!address) { + throw new BadRequestException( + toErrorMessage(ImErrorCodes.BAD_REQUEST, 'No Wallet address provided'), + ); + } + + const isValidAddress = ethers.isAddress(address); + if (!isValidAddress) { + throw new BadRequestException( + toErrorMessage(ImErrorCodes.BAD_REQUEST, 'Invalid address provided'), + ); + } + + const balance = await this.provider.getBalance(address); + + return { + balance: ethers.formatEther(balance), + }; + } +} diff --git a/src/auction/contract.json b/src/auction/contract.json new file mode 100644 index 0000000..662236f --- /dev/null +++ b/src/auction/contract.json @@ -0,0 +1,137 @@ +{ + "abi":[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "_biddingTime", + "type": "uint256" + }, + { + "internalType": "address payable", + "name": "_beneficiary", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "winner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "AuctionEnded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "bidder", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "HighestBidIncreased", + "type": "event" + }, + { + "inputs": [], + "name": "auctionEnd", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "auctionEndTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "beneficiary", + "outputs": [ + { + "internalType": "address payable", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "bid", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "highestBid", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "highestBidder", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/auction/dto/auction-response-dto.ts b/src/auction/dto/auction-response-dto.ts new file mode 100644 index 0000000..6302889 --- /dev/null +++ b/src/auction/dto/auction-response-dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AuctionStatus } from '../enum/auction-status.enum'; +import { IsNotEmpty, IsNumber } from 'class-validator'; + +class AuctionStatusDto { + @ApiProperty() + contractAddress: string; + + @ApiProperty() + beneficiary: string; + + @ApiProperty() + highestBid: string; + + @ApiProperty() + highestBidder: string; + + @ApiProperty() + auctionEndTime: string; + + @ApiProperty() + status: AuctionStatus; +} + +class AuctionStatsDto { + @ApiProperty() + totalBids: string; + + @ApiProperty() + totalEthVolume: string; +} + +class AuctionBalanceDto { + @ApiProperty() + availableBalance: string; +} + +class AuctionEndTimeDto { + @ApiProperty() + auctionEndTime: number; +} + +class AuctionHighestBidder { + @ApiProperty() + highestBidder: string; +} + +class AuctionHighestBid { + @ApiProperty() + highestBid: string; +} + +class AuctionBeneficiary { + @ApiProperty() + beneficiary: string; +} + +class BidDto { + @ApiProperty() + @IsNotEmpty() + @IsNumber() + amount: number; + + @ApiProperty() + @IsNotEmpty() + wallet: string; +} + +class BidResponseDto { + @ApiProperty() + status: string; + + @ApiProperty() + @IsNotEmpty() + transactionInfo: any; +} + +export { + AuctionStatsDto, + AuctionStatusDto, + AuctionEndTimeDto, + AuctionHighestBidder, + AuctionHighestBid, + AuctionBeneficiary, + BidDto, + BidResponseDto, + AuctionBalanceDto, +}; diff --git a/src/auction/dto/create-auction.dto.ts b/src/auction/dto/create-auction.dto.ts new file mode 100644 index 0000000..7b6b906 --- /dev/null +++ b/src/auction/dto/create-auction.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; + +class CreateAuctionDto { + @ApiProperty() + name: string; + + @IsEmail() + @ApiProperty() + email: string; +} + +export { CreateAuctionDto }; diff --git a/src/auction/enum/auction-status.enum.ts b/src/auction/enum/auction-status.enum.ts new file mode 100644 index 0000000..a0223e3 --- /dev/null +++ b/src/auction/enum/auction-status.enum.ts @@ -0,0 +1,6 @@ +enum AuctionStatus { + ACTIVE = 'ACTIVE', + ENDED = 'ENDED', +} + +export { AuctionStatus }; diff --git a/src/auction/models/bid.model.ts b/src/auction/models/bid.model.ts new file mode 100644 index 0000000..7a214f2 --- /dev/null +++ b/src/auction/models/bid.model.ts @@ -0,0 +1,22 @@ +import { Column, DataType, Table } from 'sequelize-typescript'; +import { BaseModel } from 'src/database/base.model'; + +@Table({ + tableName: 'bids', + underscored: true, + paranoid: true, + timestamps: true, +}) +export class Bid extends BaseModel { + @Column({ type: DataType.STRING, allowNull: false }) + contract_address: string; + + @Column({ type: DataType.INTEGER, allowNull: true }) + user_id: number; + + @Column({ type: DataType.STRING, allowNull: false }) + address: string; + + @Column({ type: DataType.STRING, allowNull: false }) + bidAmount: string; +} diff --git a/auth/auth.controller.ts b/src/auth/auth.controller.ts similarity index 50% rename from auth/auth.controller.ts rename to src/auth/auth.controller.ts index 4927f42..f4a122c 100644 --- a/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, + Get, InternalServerErrorException, Post, + Query, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { UserService } from 'src/user/user.service'; import { AuthService } from './auth.service'; import { @@ -17,10 +18,7 @@ import { @ApiTags('auth') @Controller(['auth']) export class AuthController { - constructor( - private readonly authService: AuthService, - private userService: UserService, - ) {} + constructor(private readonly authService: AuthService) {} @Post('login') async login( @@ -43,4 +41,35 @@ export class AuthController { throw new InternalServerErrorException(err); } } + + @Get('wallet-login-nonce') + async walletLoginNonce(): Promise<{ nonce: string }> { + try { + return this.authService.walletLoginNonce(); + } catch (err) { + throw new InternalServerErrorException(err); + } + } + + @Get('get-nonce') + async getNonce( + @Query('address') address: string, + ): Promise<{ tempToken: string; message: string }> { + try { + return this.authService.getNonce(address); + } catch (err) { + throw new InternalServerErrorException(err); + } + } + + @Get('verify') + async verifyLogin( + @Query('address') address: string, + ): Promise<{ tempToken: string; message: string }> { + try { + return this.authService.getNonce(address); + } catch (err) { + throw new InternalServerErrorException(err); + } + } } diff --git a/auth/auth.module.ts b/src/auth/auth.module.ts similarity index 100% rename from auth/auth.module.ts rename to src/auth/auth.module.ts diff --git a/auth/auth.service.ts b/src/auth/auth.service.ts similarity index 54% rename from auth/auth.service.ts rename to src/auth/auth.service.ts index dee2d00..2933de2 100644 --- a/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,4 @@ -import { - ConflictException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { User } from 'src/user/models/user.model'; import { UserService } from 'src/user/user.service'; @@ -14,8 +10,9 @@ import { VerifyLoginCredsResponseDto, } from './dto/verify-login-creds-dtos'; -import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; import { ImErrorCodes, toErrorMessage } from 'src/common/error'; +import { ethers } from 'ethers'; @Injectable() export class AuthService { @@ -26,7 +23,7 @@ export class AuthService { async generateJwtToken(user: User): Promise { const payload: JWTPayload = { - sub: user.id, + sub: user.address, }; return this.jwtService.sign(payload); @@ -37,19 +34,16 @@ export class AuthService { } async register(registerDto: RegisterDto) { - let userExists = await this.userService.findOneByEmail(registerDto.email); - - if (userExists) { - throw new ConflictException( - toErrorMessage(ImErrorCodes.CONFLICT, 'Email already registered'), - ); - } + let userExists = await this.userService.findUserByAddress( + registerDto.address, + ); const userInfo = await this.userService.createMinimalUser( userExists, registerDto, ); userExists = userInfo?.user; + const authToken = await this.generateJwtToken(userExists); // removing userId from response @@ -58,32 +52,46 @@ export class AuthService { return new VerifyLoginCredsResponseDto(userExists).setAuthToken(authToken); } - async login(loginDto: LoginDto) { - const userExists = await this.userService.findOneByEmail(loginDto.email); - - if (!userExists) { - throw new UnauthorizedException( - toErrorMessage(ImErrorCodes.UNAUTHORIZED, 'User not found'), - ); - } + async walletLoginNonce() { + return { + nonce: crypto.randomBytes(32).toString('hex'), + }; + } - const isValidLogin = await bcrypt.compare( - loginDto.password, - userExists.password, + async getNonce( + address: string, + ): Promise<{ tempToken: string; message: string }> { + const nonce = crypto.randomBytes(64).toString('hex'); + const tempToken = this.jwtService.sign( + { nonce, address }, + { expiresIn: '60s' }, ); - if (isValidLogin) { - const authToken = await this.generateJwtToken(userExists); + const message = this.getSignMessage(address, nonce); - delete userExists.id; + return { + tempToken, + message, + }; + } - return new VerifyLoginCredsResponseDto(userExists).setAuthToken( - authToken, - ); - } else { + private getSignMessage(address: string, nonce: string) { + return `Please sign this message for address ${address}:\n\n${nonce}`; + } + + async login(loginDto: LoginDto) { + const { signedMessage, message, address } = loginDto; + + const incomingAddress = ethers.verifyMessage(message, signedMessage); + + if (incomingAddress !== address) { throw new UnauthorizedException( - toErrorMessage(ImErrorCodes.UNAUTHORIZED, 'Failed to login'), + toErrorMessage(ImErrorCodes.UNAUTHORIZED, 'Failed to login via wallet'), ); } + + return this.register({ + address, + }); } } diff --git a/auth/dto/jwt.payload.ts b/src/auth/dto/jwt.payload.ts similarity index 54% rename from auth/dto/jwt.payload.ts rename to src/auth/dto/jwt.payload.ts index 0813c6f..00ec081 100644 --- a/auth/dto/jwt.payload.ts +++ b/src/auth/dto/jwt.payload.ts @@ -1,7 +1,7 @@ export class JWTPayload { - constructor(sub: number) { + constructor(sub: string) { this.sub = sub; } - sub: number; + sub: string; } diff --git a/auth/dto/verify-login-creds-dtos.ts b/src/auth/dto/verify-login-creds-dtos.ts similarity index 76% rename from auth/dto/verify-login-creds-dtos.ts rename to src/auth/dto/verify-login-creds-dtos.ts index b03d489..7b8233a 100644 --- a/auth/dto/verify-login-creds-dtos.ts +++ b/src/auth/dto/verify-login-creds-dtos.ts @@ -2,29 +2,36 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsEmail } from 'class-validator'; import { User } from 'src/user/models/user.model'; - class LoginDto { @ApiProperty() @IsNotEmpty() - email: string; + signedMessage: string; + + @ApiProperty() + @IsNotEmpty() + message: string; @ApiProperty() @IsNotEmpty() - password: string; + address: string; } class RegisterDto { @ApiProperty() @IsEmail() - email: string; + email?: string; + + @ApiProperty() + @IsNotEmpty() + name?: string; @ApiProperty() @IsNotEmpty() - name: string; + password?: string; @ApiProperty() @IsNotEmpty() - password: string; + address: string; } class VerifyLoginCredsResponseDto { @@ -38,7 +45,7 @@ class VerifyLoginCredsResponseDto { isSignUp: boolean; constructor(user: User) { - this.user = user.toJSON(); + this.user = user; } setAuthToken(authToken: string): VerifyLoginCredsResponseDto { diff --git a/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts similarity index 93% rename from auth/strategy/jwt.strategy.ts rename to src/auth/strategy/jwt.strategy.ts index 5f2914f..5e68fcc 100644 --- a/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -21,6 +21,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: JWTPayload): Promise { - return this.userService.getById(payload.sub); + return this.userService.getByAddress(payload.sub); } } diff --git a/src/common/constants.ts b/src/common/constants.ts index c3d2505..83f30b7 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,5 @@ const ENVIRONMENT = { - DEV: 'dev', + DEV: 'development', TEST: 'test', PROD: 'production', }; diff --git a/src/common/decorator/current-user.decorator.ts b/src/common/decorator/current-user.decorator.ts index 6f16009..add937d 100644 --- a/src/common/decorator/current-user.decorator.ts +++ b/src/common/decorator/current-user.decorator.ts @@ -1,7 +1,9 @@ -import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); +export const CurrentUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); - return request.user; -}); + return request.user; + }, +); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..810fbd0 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, Min } from 'class-validator'; + +class PaginationDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + limit: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + offset: number; +} + +export { PaginationDto }; diff --git a/src/common/dto/status.dto.ts b/src/common/dto/status.dto.ts index 146f658..2d1e953 100644 --- a/src/common/dto/status.dto.ts +++ b/src/common/dto/status.dto.ts @@ -1,14 +1,14 @@ enum Status { - SUCCESS = "SUCCESS", - FAILURE = "FAILURE", + SUCCESS = 'SUCCESS', + FAILURE = 'FAILURE', } class ResponseDto { status: Status; - message?: string = ""; + message?: string = ''; - error?: string = ""; + error?: string = ''; setStatus(status: Status): ResponseDto { this.status = status; diff --git a/src/main.ts b/src/main.ts index 598d51c..0893987 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,17 @@ function initializeSwagger(app: NestExpressApplication): void { customCss: cssTheme, }); } -// } + +function enableCors( + app: NestExpressApplication, + config: ConfigService, +): void { + app.enableCors({ + origin: config.get('ORIGIN'), + // methods: "GET,PUT,PATCH,POST", + credentials: config.get('CREDENTIALS'), + }); +} async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -32,6 +42,7 @@ async function bootstrap() { const config = app.get(ConfigService); initializeSwagger(app); + enableCors(app, config); app.use(express.json({ limit: '10mb' })); diff --git a/src/migrations/20240321060844-adding-address-column-to-users.js b/src/migrations/20240321060844-adding-address-column-to-users.js new file mode 100644 index 0000000..b686160 --- /dev/null +++ b/src/migrations/20240321060844-adding-address-column-to-users.js @@ -0,0 +1,31 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('users', 'address', { + type: Sequelize.DataTypes.STRING, + }); + await queryInterface.sequelize.query( + 'ALTER TABLE users ALTER COLUMN name DROP NOT NULL;', + ); + await queryInterface.sequelize.query( + 'ALTER TABLE users ALTER COLUMN email DROP NOT NULL;', + ); + await queryInterface.sequelize.query( + 'ALTER TABLE users DROP COLUMN password;', + ); + await queryInterface.sequelize.query( + 'ALTER TABLE users ADD CONSTRAINT constraint_name UNIQUE (address);', + ); + }, + down: async (queryInterface) => { + await queryInterface.removeColumn('users', 'address'); + await queryInterface.sequelize.query( + 'ALTER TABLE users ALTER COLUMN name SET NOT NULL;', + ); + await queryInterface.sequelize.query( + 'ALTER TABLE users ALTER COLUMN email SET NOT NULL;', + ); + await queryInterface.addColumn('users', 'password', { + type: Sequelize.DataTypes.STRING, + }); + }, +}; diff --git a/src/migrations/20240321070107-adding-bids-table.js b/src/migrations/20240321070107-adding-bids-table.js new file mode 100644 index 0000000..077d096 --- /dev/null +++ b/src/migrations/20240321070107-adding-bids-table.js @@ -0,0 +1,41 @@ +module.exports = { + async up(queryInterface, DataTypes) { + await queryInterface.createTable('bids', { + id: { + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER, + }, + contract_address: { + type: DataTypes.STRING, + allowNull: false, + }, + user_id: { + type: DataTypes.INTEGER, + }, + address: { + type: DataTypes.STRING, + allowNull: false, + }, + bid_amount: { + type: DataTypes.STRING, + allowNull: false, + }, + created_at: { + allowNull: false, + type: DataTypes.DATE, + }, + updated_at: { + allowNull: false, + type: DataTypes.DATE, + }, + deleted_at: { + type: DataTypes.DATE, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('bids'); + }, +}; diff --git a/src/user/dto/user-profile.dto.ts b/src/user/dto/user-profile.dto.ts index e323f53..ac280f1 100644 --- a/src/user/dto/user-profile.dto.ts +++ b/src/user/dto/user-profile.dto.ts @@ -13,6 +13,20 @@ class UserProfileDto { email: string; } +class UpdateUserDto { + @ApiProperty() + name: string; + + @ApiProperty() + email: string; + + @ApiProperty() + address: string; + + @ApiProperty() + privateKey: string; +} + const toUserProfileDto = (user: User): UserProfileDto => { return { id: user.id, @@ -21,4 +35,4 @@ const toUserProfileDto = (user: User): UserProfileDto => { }; }; -export { UserProfileDto, toUserProfileDto }; +export { UserProfileDto, toUserProfileDto, UpdateUserDto }; diff --git a/src/user/models/user.model.ts b/src/user/models/user.model.ts index d64115b..e1ca8b6 100644 --- a/src/user/models/user.model.ts +++ b/src/user/models/user.model.ts @@ -9,6 +9,6 @@ export class User extends BaseModel { @Column({ type: DataType.STRING, allowNull: true }) email: string; - @Column({ type: DataType.STRING, allowNull: true }) - password: string; + @Column({ type: DataType.STRING, allowNull: false, unique: true }) + address: string; } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index ff2d605..0906cc9 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,9 +1,11 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { UserProfileDto } from './dto/user-profile.dto'; +import { UpdateUserDto, UserProfileDto } from './dto/user-profile.dto'; import { UserService } from './user.service'; import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; +import { CurrentUser } from 'src/common/decorator/current-user.decorator'; +import { User } from './models/user.model'; @ApiTags('User') @Controller('user') @@ -25,4 +27,14 @@ export class UserController { email: 'asdsad', }; } + + @Patch('me') + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ type: UserProfileDto }) + async updateUser( + @CurrentUser() user: User, + @Body() updateUserDto: UpdateUserDto, + ) { + return this.userService.updateUser(user, updateUserDto); + } } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index c4d9e59..69b3a2d 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -7,26 +7,20 @@ import { InjectModel } from '@nestjs/sequelize'; import { User } from './models/user.model'; import { ImErrorCodes, toErrorMessage } from 'src/common/error'; -import { RegisterDto } from 'auth/dto/verify-login-creds-dtos'; -import * as bcrypt from 'bcrypt'; +import { RegisterDto } from 'src/auth/dto/verify-login-creds-dtos'; +import { UpdateUserDto } from './dto/user-profile.dto'; @Injectable() export class UserService { - constructor(@InjectModel(User) private readonly UserModel: typeof User) {} + constructor(@InjectModel(User) private readonly userModel: typeof User) {} public async createMinimalUser( userExists: User, registerDto: RegisterDto, ): Promise<{ user: User }> { - if (userExists) { - // userExists = updateUser[1][0]; - } else { - const hashedPassword = await bcrypt.hash(registerDto.password, 10); - - userExists = await this.UserModel.create({ - name: registerDto.name, - email: registerDto.email, - password: hashedPassword, + if (!userExists) { + userExists = await this.userModel.create({ + address: registerDto.address, }); await userExists.save(); @@ -35,12 +29,33 @@ export class UserService { return { user: userExists }; } + public async getByAddress(address: string): Promise { + return this.userModel.findOne({ where: { address } }); + } + public async getById(id: number): Promise { - return this.UserModel.findOne({ where: { id } }); + return this.userModel.findOne({ where: { id } }); } public async findOneByEmail(email: string): Promise { - return this.UserModel.findOne({ where: { email } }); + return this.userModel.findOne({ where: { email } }); + } + + public async updateUser( + user: User, + updateUserDto: UpdateUserDto, + ): Promise { + const { name, email, address, privateKey } = updateUserDto; + + return this.userModel.update( + { + ...(name && { name }), + ...(email && { email }), + ...(address && { address }), + ...(privateKey && { privateKey }), //TODO: Need to encrypt and store, CONFIDENTIAL + }, + { where: { id: user.id } }, + )[1]; } public async findUserById(userId: number): Promise { @@ -48,7 +63,7 @@ export class UserService { throw new BadRequestException( toErrorMessage(ImErrorCodes.BAD_REQUEST, ' No userID provided'), ); - const findUser: User = await this.UserModel.findOne({ + const findUser: User = await this.userModel.findOne({ where: { id: userId, }, @@ -61,13 +76,27 @@ export class UserService { return findUser; } + public async findUserByAddress(address: string): Promise { + if (!address) + throw new BadRequestException( + toErrorMessage(ImErrorCodes.BAD_REQUEST, ' No address provided'), + ); + const findUser: User = await this.userModel.findOne({ + where: { + address, + }, + }); + + return findUser; + } + public async deleteUser(userId: number): Promise { // if (isEmpty(userId)) throw new HttpException(400, "You're not userId"); - const findUser: User = await this.UserModel.findByPk(userId); + const findUser: User = await this.userModel.findByPk(userId); // if (!findUser) throw new HttpException(409, "You're not user"); - await this.UserModel.destroy({ where: { id: userId } }); + await this.userModel.destroy({ where: { id: userId } }); return findUser; } diff --git a/tsconfig.json b/tsconfig.json index 95f5641..e43d996 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "resolveJsonModule": true, } }